feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
GetOrgCoursesRequest,
|
||||
GetOrgCoursesRequestQuery,
|
||||
GetRecommendedCoursesRequest,
|
||||
GetUserEnrolledCoursesRequest,
|
||||
OrgCourses,
|
||||
OrgCoursesPagination,
|
||||
OrgCoursesQuery,
|
||||
RecommendedCourses,
|
||||
UserEnrolledCourses
|
||||
} from '$features/course/types';
|
||||
@@ -15,8 +18,15 @@ import { SvelteSet } from 'svelte/reactivity';
|
||||
*/
|
||||
export class CoursesApi extends BaseApiWithErrors {
|
||||
orgCourses = $state<OrgCourses>([]);
|
||||
orgCoursesPagination = $state<OrgCoursesPagination | null>(null);
|
||||
enrolledCourses = $state<UserEnrolledCourses>([]);
|
||||
recommendedCourses = $state<RecommendedCourses>([]);
|
||||
private activeOrgCoursesRequestController: AbortController | null = null;
|
||||
|
||||
cancelOrgCoursesRequest() {
|
||||
this.activeOrgCoursesRequestController?.abort();
|
||||
this.activeOrgCoursesRequestController = null;
|
||||
}
|
||||
|
||||
/** Keeps list UIs in sync after a course is deleted (server data is updated separately on navigation). */
|
||||
removeCourseFromLists(courseId: string) {
|
||||
@@ -30,26 +40,79 @@ export class CoursesApi extends BaseApiWithErrors {
|
||||
* Org ID is automatically added from currentOrg store
|
||||
*/
|
||||
async getOrgCourses(tagSlugs: string[] = []) {
|
||||
const normalizedTagSlugs = Array.from(new SvelteSet(tagSlugs.map((tag) => tag.trim()).filter(Boolean)));
|
||||
const allCourses: OrgCourses = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
let lastResponse: Awaited<ReturnType<CoursesApi['getOrgCoursesPage']>> | undefined;
|
||||
|
||||
return this.execute<GetOrgCoursesRequest>({
|
||||
while (page <= totalPages) {
|
||||
const response = await this.getOrgCoursesPage({ page, limit: 100 }, tagSlugs);
|
||||
if (!response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
allCourses.push(...(response.data ?? []));
|
||||
totalPages = response.pagination?.totalPages ?? 1;
|
||||
lastResponse = response;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
this.orgCourses = allCourses;
|
||||
this.orgCoursesPagination = lastResponse?.pagination ?? null;
|
||||
|
||||
return lastResponse;
|
||||
}
|
||||
|
||||
async getOrgCoursesPage(
|
||||
query: Partial<OrgCoursesQuery> = {},
|
||||
tagSlugs: string[] = [],
|
||||
options: { abortPrevious?: boolean; signal?: AbortSignal } = {}
|
||||
) {
|
||||
const normalizedTagSlugs = Array.from(new SvelteSet(tagSlugs.map((tag) => tag.trim()).filter(Boolean)));
|
||||
const requestQuery: GetOrgCoursesRequestQuery = {
|
||||
page: String(query.page ?? 1),
|
||||
limit: String(query.limit ?? 20),
|
||||
search: query.search,
|
||||
tags: normalizedTagSlugs.length > 0 ? normalizedTagSlugs.join(',') : undefined
|
||||
};
|
||||
|
||||
let requestSignal = options.signal;
|
||||
let requestController: AbortController | null = null;
|
||||
|
||||
if (options.abortPrevious) {
|
||||
this.cancelOrgCoursesRequest();
|
||||
requestController = new AbortController();
|
||||
this.activeOrgCoursesRequestController = requestController;
|
||||
requestSignal = requestController.signal;
|
||||
}
|
||||
|
||||
const response = await this.execute<GetOrgCoursesRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.courses.$get(
|
||||
normalizedTagSlugs.length > 0
|
||||
? {
|
||||
query: {
|
||||
tags: normalizedTagSlugs.join(',')
|
||||
}
|
||||
}
|
||||
: { query: {} }
|
||||
{
|
||||
query: requestQuery
|
||||
},
|
||||
{
|
||||
init: {
|
||||
signal: requestSignal
|
||||
}
|
||||
}
|
||||
),
|
||||
logContext: 'fetching org courses',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.orgCourses = response.data;
|
||||
}
|
||||
|
||||
this.orgCoursesPagination = response.pagination;
|
||||
}
|
||||
});
|
||||
|
||||
if (requestController && this.activeOrgCoursesRequestController === requestController) {
|
||||
this.activeOrgCoursesRequestController = null;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -180,21 +180,20 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Post updated successfully');
|
||||
// Update local state, preserving author fields from existing feed
|
||||
const index = this.feeds.findIndex((f) => f.id === feedId);
|
||||
if (index !== -1) {
|
||||
const existingFeed = this.feeds[index];
|
||||
// Merge response data with existing feed to preserve author fields
|
||||
this.feeds[index] = {
|
||||
...existingFeed,
|
||||
this.feeds = this.feeds.map((feed) => {
|
||||
if (feed.id !== feedId) {
|
||||
return feed;
|
||||
}
|
||||
|
||||
return {
|
||||
...feed,
|
||||
...response.data,
|
||||
// Preserve author fields from existing feed
|
||||
authorProfileId: existingFeed.authorProfileId,
|
||||
authorFullname: existingFeed.authorFullname,
|
||||
authorUsername: existingFeed.authorUsername,
|
||||
authorAvatarUrl: existingFeed.authorAvatarUrl
|
||||
authorProfileId: feed.authorProfileId,
|
||||
authorFullname: feed.authorFullname,
|
||||
authorUsername: feed.authorUsername,
|
||||
authorAvatarUrl: feed.authorAvatarUrl
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
@@ -232,21 +231,20 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
logContext: 'updating newsfeed reaction',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
// Update local state, preserving author fields from existing feed
|
||||
const index = this.feeds.findIndex((f) => f.id === feedId);
|
||||
if (index !== -1) {
|
||||
const existingFeed = this.feeds[index];
|
||||
// Merge response data with existing feed to preserve author fields
|
||||
this.feeds[index] = {
|
||||
...existingFeed,
|
||||
this.feeds = this.feeds.map((feed) => {
|
||||
if (feed.id !== feedId) {
|
||||
return feed;
|
||||
}
|
||||
|
||||
return {
|
||||
...feed,
|
||||
...response.data,
|
||||
// Preserve author fields from existing feed
|
||||
authorProfileId: existingFeed.authorProfileId,
|
||||
authorFullname: existingFeed.authorFullname,
|
||||
authorUsername: existingFeed.authorUsername,
|
||||
authorAvatarUrl: existingFeed.authorAvatarUrl
|
||||
authorProfileId: feed.authorProfileId,
|
||||
authorFullname: feed.authorFullname,
|
||||
authorUsername: feed.authorUsername,
|
||||
authorAvatarUrl: feed.authorAvatarUrl
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { CourseCard, DEFAULT_COURSE_BANNER_IMAGE } from '@cio/ui';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
|
||||
import pluralize from 'pluralize';
|
||||
@@ -30,9 +28,11 @@
|
||||
isOnLandingPage?: boolean;
|
||||
isLMS?: boolean;
|
||||
isExplore?: boolean;
|
||||
href?: string;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let { course, isOnLandingPage, isLMS, isExplore }: Props = $props();
|
||||
let { course, isOnLandingPage, isLMS, isExplore, href, actions }: Props = $props();
|
||||
|
||||
let {
|
||||
bannerImage,
|
||||
@@ -42,6 +42,7 @@
|
||||
description = '',
|
||||
isPublished = false,
|
||||
totalLessons = 0,
|
||||
totalExercises = 0,
|
||||
totalStudents = 0,
|
||||
currency = 'USD',
|
||||
progressRate = 45,
|
||||
@@ -54,7 +55,7 @@
|
||||
} = $derived({
|
||||
id: course.id,
|
||||
slug: course.slug,
|
||||
bannerImage: course.logo || '/images/classroomio-course-img-template.jpg',
|
||||
bannerImage: course.logo || DEFAULT_COURSE_BANNER_IMAGE,
|
||||
title: course.title,
|
||||
type: course.type,
|
||||
description: course.description,
|
||||
@@ -66,6 +67,10 @@
|
||||
},
|
||||
currency: course.currency,
|
||||
totalLessons: course.lessonCount,
|
||||
totalExercises: (() => {
|
||||
const c = course as { exerciseCount?: number };
|
||||
return typeof c.exerciseCount === 'number' ? c.exerciseCount : 0;
|
||||
})(),
|
||||
progressRate: (() => {
|
||||
const c = course as { exerciseCount?: number; exercisesCompleted?: number };
|
||||
if (typeof c.exerciseCount === 'number' && typeof c.exercisesCompleted === 'number') {
|
||||
@@ -122,91 +127,104 @@
|
||||
let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount));
|
||||
|
||||
let courseUrl = $derived(
|
||||
isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`
|
||||
href ?? (isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`)
|
||||
);
|
||||
|
||||
const typeBadge = $derived(
|
||||
type && COURSE_TAG[type]
|
||||
? {
|
||||
label: COURSE_TAG[type].label,
|
||||
icon: COURSE_TAG[type].icon,
|
||||
iconClass: COURSE_TAG[type].iconStyle
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
<Item.Root variant="outline" class="group relative max-w-[320px] p-3!">
|
||||
{#snippet child({ props })}
|
||||
<a href={resolve(courseUrl, {})} {...props}>
|
||||
{#if !isLMS && !isOnLandingPage}
|
||||
<CardDropdown {id} {title} {description} />
|
||||
{/if}
|
||||
<CourseCard href={resolve(courseUrl, {})} {title} {description} {typeBadge} class="group relative">
|
||||
{#snippet media()}
|
||||
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
|
||||
{/snippet}
|
||||
|
||||
<Item.Header>
|
||||
<Item.Media variant="image" class="relative h-50! w-full!">
|
||||
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
|
||||
{#snippet overlay()}
|
||||
{#if actions}
|
||||
{@render actions()}
|
||||
{:else if !isLMS && !isOnLandingPage}
|
||||
<CardDropdown {id} {title} {description} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if type}
|
||||
{@const tag = COURSE_TAG[type]}
|
||||
<Badge class="absolute bottom-2 left-2 z-10 flex items-center rounded-md! capitalize" variant="secondary">
|
||||
<tag.icon class={tag.iconStyle} />
|
||||
{tag.label}
|
||||
</Badge>
|
||||
{/if}
|
||||
</Item.Media>
|
||||
</Item.Header>
|
||||
{#snippet tags()}
|
||||
{#if !isLMS}
|
||||
<CourseTagsOverflow tags={courseTags} variant="card" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-2 min-h-14 text-base!">
|
||||
{title}
|
||||
</Item.Title>
|
||||
|
||||
<Item.Description class="min-h-[63px]">{description}</Item.Description>
|
||||
{#if !isLMS}
|
||||
<CourseTagsOverflow tags={courseTags} variant="card" />
|
||||
{/if}
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
<div class="flex justify-between {isLMS && 'items-center'} w-full">
|
||||
<div class="w-[50%]">
|
||||
<p class="text-xs {!isLMS && 'pl-2'} dark:text-white">
|
||||
{#snippet footer()}
|
||||
<div class="flex justify-between {isLMS && 'items-center'} w-full">
|
||||
<div class="w-[60%]">
|
||||
{#if isLMS || isOnLandingPage}
|
||||
<p class="text-xs {!isLMS && 'pl-2'} flex gap-1 dark:text-white">
|
||||
<span>
|
||||
{totalLessons}
|
||||
{$t('courses.course_card.lessons_number')}
|
||||
</p>
|
||||
<div class="py-2 text-xs">
|
||||
{#if isOnLandingPage}
|
||||
<span class="px-2">
|
||||
{#if !cost}
|
||||
{$t('course.navItem.landing_page.pricing_section.free')}
|
||||
{:else if pricingData.showDiscount}
|
||||
{formatter.format(cost)}
|
||||
<span class="line-through">
|
||||
{formatter?.format(pricingData?.cost ?? 0)}
|
||||
</span>
|
||||
{:else}
|
||||
{formatter.format(cost)}
|
||||
{/if}
|
||||
</span>
|
||||
&
|
||||
<span>
|
||||
{pluralize($t('courses.course_card.exercise'), totalExercises, true)}
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
<div class="py-2 text-xs">
|
||||
{#if isOnLandingPage}
|
||||
<span class="px-2">
|
||||
{#if !cost}
|
||||
{$t('course.navItem.landing_page.pricing_section.free')}
|
||||
{:else if pricingData.showDiscount}
|
||||
{formatter.format(cost)}
|
||||
<span class="line-through">
|
||||
{formatter?.format(pricingData?.cost ?? 0)}
|
||||
</span>
|
||||
{:else if isLMS}
|
||||
{#if !isExplore}
|
||||
<div class="flex w-3/4 items-center gap-2">
|
||||
<Progress value={progressRate} />
|
||||
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<CoursePublishBadge {isPublished} />
|
||||
{formatter.format(cost)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLMS}
|
||||
<Button variant="outline">
|
||||
{isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')}
|
||||
|
||||
<ArrowRightIcon class="custom" />
|
||||
</Button>
|
||||
{:else if !isOnLandingPage}
|
||||
<div class="flex flex-col justify-between">
|
||||
<p class="pl-2 text-xs dark:text-white">
|
||||
{pluralize($t('courses.course_card.students'), totalStudents, true)}
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
{:else if isLMS}
|
||||
{#if !isExplore}
|
||||
<div class="flex w-3/4 items-center gap-2">
|
||||
<Progress value={progressRate} />
|
||||
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<CoursePublishBadge {isPublished} />
|
||||
{/if}
|
||||
</div>
|
||||
</Item.Content>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if isLMS}
|
||||
<Button variant="outline">
|
||||
{isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')}
|
||||
|
||||
<ArrowRightIcon class="custom" />
|
||||
</Button>
|
||||
{:else if !isOnLandingPage}
|
||||
<div class="flex flex-col justify-end gap-1 text-right">
|
||||
<p class="pl-2 text-xs whitespace-nowrap dark:text-white">
|
||||
<span>
|
||||
{totalLessons}
|
||||
{$t('courses.course_card.lessons_number')}
|
||||
</span>
|
||||
&
|
||||
<span>
|
||||
{pluralize($t('courses.course_card.exercise'), totalExercises, true)}
|
||||
</span>
|
||||
</p>
|
||||
<p class="pl-2 text-xs dark:text-white">
|
||||
{pluralize($t('courses.course_card.students'), totalStudents, true)}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Item.Root>
|
||||
</CourseCard>
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
COURSE_SORT_OPTIONS,
|
||||
CourseSortOrder,
|
||||
DEFAULT_COURSE_SORT,
|
||||
DEFAULT_SORT_ORDER,
|
||||
type CourseSortBy
|
||||
type CourseSortBy,
|
||||
type CourseSortOrder
|
||||
} from '../utils/constants';
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import * as ToggleGroup from '@cio/ui/base/toggle-group';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import FilterIcon from '@lucide/svelte/icons/filter';
|
||||
import { SortPopover } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface FilterTag {
|
||||
@@ -54,7 +49,9 @@
|
||||
onClearFilters = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
const translatedSortOptions = $derived(
|
||||
sortOptions.map((option) => ({ value: option.value, label: $t(option.label) }))
|
||||
);
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0
|
||||
@@ -65,117 +62,58 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open={isOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<div class="relative">
|
||||
<IconButton {...props} aria-label={$t('courses.tag_filters.filter')} tooltip={$t('courses.tag_filters.filter')}>
|
||||
{#if isFiltering}
|
||||
<Spinner class="size-4" />
|
||||
{:else}
|
||||
<FilterIcon size={16} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
{#if hasActiveFilters}
|
||||
<span
|
||||
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content align="end" class="w-[360px] p-3">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-semibold">{$t('courses.tag_filters.popover_title')}</p>
|
||||
<Button variant="link" class="h-auto p-0" onclick={onClearFilters} disabled={!hasActiveFilters}>
|
||||
{$t('courses.tag_filters.clear_all')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('courses.tag_filters.sort')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each sortOptions as option (option.value)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={sortKey === option.value ? 'secondary' : 'outline'}
|
||||
onclick={() => (sortKey = option.value)}
|
||||
>
|
||||
{$t(option.label)}
|
||||
</Button>
|
||||
<SortPopover
|
||||
sortOptions={translatedSortOptions}
|
||||
bind:sortKey
|
||||
bind:selectedOrder
|
||||
defaultSortKey={DEFAULT_COURSE_SORT}
|
||||
defaultSortOrder={DEFAULT_SORT_ORDER}
|
||||
{isFiltering}
|
||||
{hasActiveFilters}
|
||||
{onClearFilters}
|
||||
>
|
||||
{#snippet additionalContent()}
|
||||
<div class="space-y-3">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
||||
{#if tagGroups.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
|
||||
{:else}
|
||||
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
|
||||
{#each tagGroups as group (group.id)}
|
||||
{#if group.tags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{group.name}</p>
|
||||
{#if group.description}
|
||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<label
|
||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isTagSelected(tag.slug)}
|
||||
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm">{tag.name}</span>
|
||||
</div>
|
||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('courses.tag_filters.order')}
|
||||
</p>
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
value={selectedOrder}
|
||||
onValueChange={(value) => {
|
||||
if (value === CourseSortOrder.Asc || value === CourseSortOrder.Desc) {
|
||||
selectedOrder = value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroup.Item value={CourseSortOrder.Asc}>{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value={CourseSortOrder.Desc}>{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
||||
{#if tagGroups.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
|
||||
{:else}
|
||||
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
|
||||
{#each tagGroups as group (group.id)}
|
||||
{#if group.tags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{group.name}</p>
|
||||
{#if group.description}
|
||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<label
|
||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isTagSelected(tag.slug)}
|
||||
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm">{tag.name}</span>
|
||||
</div>
|
||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{/snippet}
|
||||
</SortPopover>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<script lang="ts">
|
||||
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { Image } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
import { buildCoursePlaceholderAvatarUrl } from '$features/course/utils/course-list-row-utils';
|
||||
import CourseContentIcon from './course-content-icon.svelte';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
logo?: string | null;
|
||||
type?: string | null;
|
||||
description?: string;
|
||||
isPublished?: boolean;
|
||||
lessonCount?: number;
|
||||
exerciseCount?: number;
|
||||
totalStudents?: number;
|
||||
updatedAt?: string | null;
|
||||
tags?: Tag[];
|
||||
isExplore?: boolean;
|
||||
isLMS?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
slug = '',
|
||||
title,
|
||||
logo = null,
|
||||
type,
|
||||
description = '',
|
||||
isPublished = false,
|
||||
lessonCount = 0,
|
||||
exerciseCount = 0,
|
||||
totalStudents = 0,
|
||||
updatedAt,
|
||||
tags = [],
|
||||
isExplore = false,
|
||||
isLMS = false
|
||||
}: Props = $props();
|
||||
|
||||
const titleInitial = $derived(title.trim().charAt(0).toUpperCase() || 'C');
|
||||
|
||||
const bannerImage = $derived(logo?.trim() ? logo : '/images/classroomio-course-img-template.jpg');
|
||||
|
||||
const gridTemplateColumns = $derived(
|
||||
isLMS
|
||||
? '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem'
|
||||
: '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem 2.5rem'
|
||||
);
|
||||
|
||||
const studentPlaceholderAvatarUrls = $derived.by(() => [
|
||||
buildCoursePlaceholderAvatarUrl(`${id}:student:0`),
|
||||
buildCoursePlaceholderAvatarUrl(`${id}:student:1`)
|
||||
]);
|
||||
|
||||
const emptyStateAvatarUrl = $derived(buildCoursePlaceholderAvatarUrl(`${id}:student:empty`));
|
||||
|
||||
const typeLabel = $derived(type ? $t(`course.navItem.settings.${type.toLowerCase()}`) : null);
|
||||
|
||||
const updatedLabel = $derived.by(() => {
|
||||
if (!updatedAt) return null;
|
||||
const d = new Date(updatedAt);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return `Updated ${d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
|
||||
});
|
||||
|
||||
function handleRowClick() {
|
||||
if (isExplore) {
|
||||
goto(resolve(`/course/${slug}`, {}));
|
||||
return;
|
||||
}
|
||||
if (isLMS) {
|
||||
goto(resolve(`/courses/${id}/lessons?next=true`, {}));
|
||||
return;
|
||||
}
|
||||
goto(resolve(`/courses/${id}`, {}));
|
||||
}
|
||||
|
||||
function handleOpen(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
goto(resolve(`/courses/${id}`, {}));
|
||||
}
|
||||
|
||||
function handleClone(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
$copyCourseModal.open = true;
|
||||
$copyCourseModal.id = id;
|
||||
$copyCourseModal.title = title;
|
||||
$copyCourseModal.description = description;
|
||||
}
|
||||
|
||||
function handleDelete(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
$deleteCourseModal.open = true;
|
||||
$deleteCourseModal.id = id;
|
||||
$deleteCourseModal.title = title;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ResourceListRow.Root
|
||||
variant="default"
|
||||
size="sm"
|
||||
align="start"
|
||||
class="cursor-pointer py-4!"
|
||||
onclick={handleRowClick}
|
||||
role="row"
|
||||
>
|
||||
<div class="grid w-full items-start gap-3" style="grid-template-columns: {gridTemplateColumns}">
|
||||
<!-- Banner -->
|
||||
<div
|
||||
class="ui:border-border bg-muted relative size-28 shrink-0 overflow-hidden rounded-md border"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Image src={bannerImage} alt="" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
|
||||
<!-- Title / type / updated -->
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<p class="line-clamp-2 text-base">{title}</p>
|
||||
{#if typeLabel}
|
||||
<p class="ui:text-muted-foreground mt-0.5 text-sm">{typeLabel}</p>
|
||||
{/if}
|
||||
{#if updatedLabel}
|
||||
<p class="ui:text-muted-foreground mt-0.5 text-xs">{updatedLabel}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Published badge -->
|
||||
<div>
|
||||
{#if isPublished}
|
||||
<Badge variant="success" class="whitespace-nowrap">
|
||||
{$t('courses.course_card.published')}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="secondary" class="whitespace-nowrap">
|
||||
{$t('courses.course_card.unpublished')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-1">
|
||||
{#if tags.length === 0}
|
||||
<span class="ui:text-muted-foreground text-xs">—</span>
|
||||
{:else}
|
||||
{#each tags as tag (tag.id)}
|
||||
<Badge variant="outline" class="max-w-[140px] truncate">
|
||||
<span
|
||||
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Lessons / exercises -->
|
||||
<div class="flex flex-col gap-1 tabular-nums">
|
||||
<p class="flex items-center gap-1.5 text-sm" aria-label="{lessonCount} lessons">
|
||||
<CourseContentIcon type={ContentType.Lesson} size={14} />
|
||||
<span class="font-medium">{lessonCount}</span>
|
||||
</p>
|
||||
<p class="flex items-center gap-1.5 text-sm" aria-label="{exerciseCount} exercises">
|
||||
<CourseContentIcon type={ContentType.Exercise} size={14} />
|
||||
<span class="font-medium">{exerciseCount}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Students -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#if totalStudents > 0}
|
||||
{#if totalStudents < 3}
|
||||
<div class="flex items-center gap-1">
|
||||
<Avatar.Root class="ui:border-background size-6 border-2">
|
||||
<Avatar.Image src={studentPlaceholderAvatarUrls[0]} alt="" loading="lazy" decoding="async" />
|
||||
<Avatar.Fallback aria-hidden="true">
|
||||
{titleInitial}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="text-xs tabular-nums">{totalStudents}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex -space-x-2">
|
||||
{#each studentPlaceholderAvatarUrls as avatarUrl, slotIndex (slotIndex)}
|
||||
<Avatar.Root class="ui:border-background size-6 border-2">
|
||||
<Avatar.Image src={avatarUrl} alt="" loading="lazy" decoding="async" />
|
||||
<Avatar.Fallback aria-hidden="true">
|
||||
{titleInitial}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{/each}
|
||||
<Avatar.Root class="ui:border-background size-6 border-2">
|
||||
<Avatar.Fallback class="text-[10px]">+{totalStudents - 2}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<Avatar.Root class="size-6">
|
||||
<Avatar.Image src={emptyStateAvatarUrl} alt="" loading="lazy" decoding="async" />
|
||||
<Avatar.Fallback aria-hidden="true">
|
||||
{titleInitial}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="text-xs">0</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
{#if !isLMS}
|
||||
<div class="flex justify-end">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
aria-label="Course actions"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVerticalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={handleOpen}>
|
||||
{$t('courses.course_card.context_menu.open')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleClone}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item variant="destructive" onclick={handleDelete}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ResourceListRow.Root>
|
||||
@@ -15,7 +15,7 @@
|
||||
const flipDurationMs = 300;
|
||||
|
||||
interface Question {
|
||||
id: number;
|
||||
id: number | string;
|
||||
name: string;
|
||||
type: number;
|
||||
}
|
||||
@@ -36,23 +36,36 @@
|
||||
items = e.detail.items;
|
||||
}
|
||||
|
||||
function handleDndFinalize(e) {
|
||||
items = e.detail.items;
|
||||
function applyItemOrder(nextItems: Array<Question>) {
|
||||
questionnaire.update((currentQuestionnaire) => {
|
||||
const nextOrderById = new Map(nextItems.map((item, index) => [String(item.id), index + 1]));
|
||||
const nextQuestions = currentQuestionnaire.questions.map((question) => {
|
||||
const nextOrder = nextOrderById.get(String(question.id));
|
||||
if (nextOrder == null) {
|
||||
return question;
|
||||
}
|
||||
|
||||
return {
|
||||
...question,
|
||||
isDirty: true,
|
||||
order: nextOrder
|
||||
};
|
||||
});
|
||||
const activeQuestions = nextQuestions
|
||||
.filter((question) => !question.deletedAt)
|
||||
.sort((leftQuestion, rightQuestion) => (leftQuestion.order ?? 0) - (rightQuestion.order ?? 0));
|
||||
const deletedQuestions = nextQuestions.filter((question) => question.deletedAt);
|
||||
|
||||
return {
|
||||
...currentQuestionnaire,
|
||||
questions: [...activeQuestions, ...deletedQuestions]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
items.forEach((item, index) => {
|
||||
const order = index + 1;
|
||||
$questionnaire.questions.some((q) => {
|
||||
if (q.id === item.id) {
|
||||
q.isDirty = true;
|
||||
q.order = order;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
$questionnaire.questions = $questionnaire.questions.sort((a, b) => a.order - b.order);
|
||||
$questionnaireOrder.open = false;
|
||||
function handleDndFinalize(e) {
|
||||
items = e.detail.items;
|
||||
applyItemOrder(e.detail.items);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -63,7 +76,9 @@
|
||||
<Dialog.Root
|
||||
bind:open={$questionnaireOrder.open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) handleClose();
|
||||
if (!isOpen) {
|
||||
applyItemOrder(items);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-96">
|
||||
@@ -81,7 +96,7 @@
|
||||
}}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="w-full"
|
||||
class="max-h-[300px] w-full"
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<div
|
||||
|
||||
@@ -176,6 +176,29 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'numeric.review.out_of_range': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.out_of_range'
|
||||
),
|
||||
'star.edit.correct_value_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.correct_value_label'
|
||||
),
|
||||
'star.edit.correct_value_info': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.correct_value_info'
|
||||
),
|
||||
'star.edit.max_stars_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.max_stars_label'
|
||||
),
|
||||
'star.edit.max_stars_info': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.max_stars_info'
|
||||
),
|
||||
'star.edit.correct_value_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.correct_value_placeholder'
|
||||
),
|
||||
'star.edit.max_stars_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.max_stars_placeholder'
|
||||
),
|
||||
'star.take.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.star.take.helper'),
|
||||
'star.take.star_sr': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.star.take.star_sr'),
|
||||
'star.preview.correct_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.preview.correct_label'
|
||||
),
|
||||
'fill_blank.edit.accepted_answers_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.edit.accepted_answers_placeholder'
|
||||
),
|
||||
@@ -188,6 +211,54 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'fill_blank.review.your_answer_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.review.your_answer_label'
|
||||
),
|
||||
'word_bank.edit.template_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.template_label'
|
||||
),
|
||||
'word_bank.edit.template_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.template_placeholder'
|
||||
),
|
||||
'word_bank.edit.template_helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.template_helper'
|
||||
),
|
||||
'word_bank.edit.no_blanks_warning': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.no_blanks_warning'
|
||||
),
|
||||
'word_bank.edit.correct_answers_heading': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.correct_answers_heading'
|
||||
),
|
||||
'word_bank.edit.blank_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.blank_label'
|
||||
),
|
||||
'word_bank.edit.correct_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.correct_placeholder'
|
||||
),
|
||||
'word_bank.edit.distractors_heading': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.distractors_heading'
|
||||
),
|
||||
'word_bank.edit.distractor_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.distractor_placeholder'
|
||||
),
|
||||
'word_bank.edit.add_distractor': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.add_distractor'
|
||||
),
|
||||
'word_bank.take.helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.take.helper'
|
||||
),
|
||||
'word_bank.take.bank_heading': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.take.bank_heading'
|
||||
),
|
||||
'word_bank.take.clear_blank_sr': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.take.clear_blank_sr'
|
||||
),
|
||||
'word_bank.preview.filled_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.preview.filled_label'
|
||||
),
|
||||
'word_bank.review.your_answer_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.review.your_answer_label'
|
||||
),
|
||||
'word_bank.review.correct_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.review.correct_label'
|
||||
),
|
||||
'file_upload.edit.accepted_types_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder'
|
||||
),
|
||||
|
||||
@@ -235,6 +235,7 @@
|
||||
case 'RADIO':
|
||||
case 'TRUE_FALSE':
|
||||
case 'NUMERIC':
|
||||
case 'STAR':
|
||||
return true;
|
||||
case 'CHECKBOX':
|
||||
return answerValue.optionIds.length > 0;
|
||||
@@ -244,6 +245,10 @@
|
||||
return answerValue.text.trim().length > 0;
|
||||
case 'FILL_BLANK':
|
||||
return answerValue.values.length > 0;
|
||||
case 'WORD_BANK': {
|
||||
const filled = answerValue.filledBlanks ?? [];
|
||||
return filled.length > 0 && filled.every((v) => String(v).trim().length > 0);
|
||||
}
|
||||
case 'FILE_UPLOAD':
|
||||
return !!answerValue.fileKey;
|
||||
case 'MATCHING':
|
||||
@@ -470,9 +475,12 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished}
|
||||
<div class="mb-6">
|
||||
<Progress value={$questionnaireMetaData.progressValue} />
|
||||
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished && $questionnaireMetaData.currentQuestionIndex > 0}
|
||||
<div class="mb-6 flex min-w-0 items-center gap-3">
|
||||
<span class="ui:text-muted-foreground shrink-0 text-sm tabular-nums">
|
||||
{$questionnaireMetaData.currentQuestionIndex}/{$questionnaire.questions.length}
|
||||
</span>
|
||||
<Progress class="min-w-0 flex-1" value={$questionnaireMetaData.progressValue} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -605,7 +613,9 @@
|
||||
{/if}
|
||||
|
||||
<Preview
|
||||
questions={$questionnaire.questions.sort((a, b) => a.order - b.order)}
|
||||
questions={[...$questionnaire.questions].sort(
|
||||
(leftQuestion, rightQuestion) => leftQuestion.order - rightQuestion.order
|
||||
)}
|
||||
questionnaireMetaData={$questionnaireMetaData}
|
||||
grades={$questionnaireMetaData.grades}
|
||||
disableGrading={true}
|
||||
|
||||
@@ -7,6 +7,7 @@ export { default as CourseFilterPopover } from './course-filter-popover.svelte';
|
||||
export { default as CourseTagPicker } from './course-tag-picker.svelte';
|
||||
export { default as CreateCourseButton } from './create-course-button.svelte';
|
||||
export { default as CourseList } from './list.svelte';
|
||||
export { default as CourseListRow } from './course-list-row.svelte';
|
||||
export { default as CourseTagsOverflow } from './course-tags-overflow.svelte';
|
||||
export { default as CourseListLoader } from './list-loader.svelte';
|
||||
export { default as NewCourseModal } from './new-course-modal.svelte';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import UploadVideo from './upload-video.svelte';
|
||||
import EmbedLink from './embed-link.svelte';
|
||||
import LibraryVideo from './library-video.svelte';
|
||||
import GoogleDriveVideo from './google-drive-video.svelte';
|
||||
import * as CONSTANTS from './constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
@@ -57,6 +58,9 @@
|
||||
<UnderlineTabs.Content value="4" class="mt-3">
|
||||
<LibraryVideo {lessonId} />
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value="5" class="mt-3">
|
||||
<GoogleDriveVideo {lessonId} />
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
|
||||
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
|
||||
import LibraryIcon from '@lucide/svelte/icons/library';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import YoutubeIcon from '@lucide/svelte/icons/youtube';
|
||||
@@ -23,5 +24,10 @@ export const videoTabs = [
|
||||
value: 4,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.library',
|
||||
icon: LibraryIcon
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.google_drive',
|
||||
icon: HardDriveIcon
|
||||
}
|
||||
];
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Lesson, LessonVideoType } from '$features/course/utils/types';
|
||||
import type { TAssetProvider } from '@cio/utils/validation/assets';
|
||||
import { lessonApi } from '$features/course/api';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { copyToClipboard, removeVideo } from '$lib/utils/functions/formatYoutubeVideo';
|
||||
import {
|
||||
googleDrivePreviewUrl,
|
||||
googleDriveThumbnailUrl,
|
||||
pickGoogleDriveVideo
|
||||
} from '$lib/utils/functions/google-drive-picker';
|
||||
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
interface Props {
|
||||
lessonId?: string;
|
||||
}
|
||||
|
||||
let { lessonId = '' }: Props = $props();
|
||||
|
||||
let isOpeningPicker = $state(false);
|
||||
let errorMessage = $state('');
|
||||
|
||||
const clientId = $derived((env.PUBLIC_GOOGLE_PICKER_CLIENT_ID ?? '').trim());
|
||||
const apiKey = $derived((env.PUBLIC_GOOGLE_PICKER_API_KEY ?? '').trim());
|
||||
const isConfigured = $derived(Boolean(clientId && apiKey));
|
||||
|
||||
function isGoogleDriveLessonVideo(video: NonNullable<Lesson['videos']>[number]): boolean {
|
||||
return (video as { type: string }).type === 'google_drive';
|
||||
}
|
||||
|
||||
async function openPicker() {
|
||||
errorMessage = '';
|
||||
|
||||
if (!isConfigured) {
|
||||
errorMessage = $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_not_configured');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lessonApi.lesson) return;
|
||||
|
||||
isOpeningPicker = true;
|
||||
|
||||
try {
|
||||
const picked = await pickGoogleDriveVideo({ clientId, apiKey });
|
||||
|
||||
if (!picked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewUrl = googleDrivePreviewUrl(picked.fileId);
|
||||
const thumbnailUrl = googleDriveThumbnailUrl(picked.fileId);
|
||||
const createdAt = new Date().toISOString();
|
||||
const videoMetadata = {
|
||||
createdAt,
|
||||
title: picked.name,
|
||||
googleDriveFileId: picked.fileId,
|
||||
mimeType: picked.mimeType,
|
||||
thumbnailUrl
|
||||
};
|
||||
|
||||
const existingCount = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0;
|
||||
|
||||
const asset = await mediaApi.createAsset({
|
||||
kind: 'video',
|
||||
provider: 'google_drive' as TAssetProvider,
|
||||
storageProvider: 'external',
|
||||
storageKey: picked.fileId,
|
||||
sourceUrl: previewUrl,
|
||||
isExternal: true,
|
||||
title: picked.name,
|
||||
thumbnailUrl,
|
||||
metadata: videoMetadata
|
||||
});
|
||||
|
||||
if (asset && lessonId) {
|
||||
await mediaApi.attachAsset(asset.id, {
|
||||
targetType: 'lesson',
|
||||
targetId: lessonId,
|
||||
slotType: 'lesson_video',
|
||||
position: existingCount
|
||||
});
|
||||
}
|
||||
|
||||
const newVideo = {
|
||||
type: 'google_drive' as LessonVideoType,
|
||||
link: previewUrl,
|
||||
assetId: asset?.id,
|
||||
fileName: picked.name,
|
||||
metadata: videoMetadata
|
||||
};
|
||||
|
||||
lessonApi.updateLessonState('videos', [newVideo as unknown as NonNullable<Lesson['videos']>[number]], {
|
||||
append: true
|
||||
});
|
||||
lessonApi.isDirty = true;
|
||||
} catch (error) {
|
||||
console.error('Google Drive picker error:', error);
|
||||
errorMessage = $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_error');
|
||||
} finally {
|
||||
isOpeningPicker = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_hint')}
|
||||
</p>
|
||||
|
||||
<Button type="button" onclick={openPicker} disabled={isOpeningPicker} class="w-fit">
|
||||
{isOpeningPicker
|
||||
? $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_opening')
|
||||
: $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_choose')}
|
||||
</Button>
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="ui:text-destructive text-sm">{errorMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="mt-4 pl-2 text-sm">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}:
|
||||
<strong>
|
||||
{lessonApi.lesson?.videos?.filter(isGoogleDriveLessonVideo).length || 0}
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<div class="">
|
||||
{#each lessonApi.lesson?.videos || [] as video, index (video.assetId ?? `${video.type}-${index}-${video.link}`)}
|
||||
{#if isGoogleDriveLessonVideo(video)}
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge class="max-w-md truncate" variant="secondary">
|
||||
{video.fileName || video.link}
|
||||
</Badge>
|
||||
<IconButton onclick={() => copyToClipboard(video.link)}>
|
||||
<CopyIcon size={16} />
|
||||
</IconButton>
|
||||
<IconButton onclick={() => removeVideo(index)}>
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -9,6 +9,8 @@ type VideoMetadata = {
|
||||
duration?: number;
|
||||
createdAt?: string;
|
||||
thumbnailUrl?: string;
|
||||
googleDriveFileId?: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -23,6 +25,13 @@ export function getVideoThumbnailUrl(video: LessonVideo): string | null {
|
||||
const meta = video.metadata as VideoMetadata | undefined;
|
||||
if (meta?.thumbnailUrl && typeof meta.thumbnailUrl === 'string') return meta.thumbnailUrl;
|
||||
}
|
||||
if (video.type === 'google_drive') {
|
||||
const meta = video.metadata as VideoMetadata | undefined;
|
||||
if (meta?.thumbnailUrl && typeof meta.thumbnailUrl === 'string') return meta.thumbnailUrl;
|
||||
if (meta?.googleDriveFileId && typeof meta.googleDriveFileId === 'string') {
|
||||
return `https://drive.google.com/thumbnail?id=${meta.googleDriveFileId}&sz=w400`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -67,6 +76,7 @@ export function getVideoTitle(video: LessonVideo, index: number): string {
|
||||
|
||||
if (video.type === 'youtube') return 'YouTube video';
|
||||
if (video.type === 'generic') return 'Embedded video';
|
||||
if (video.type === 'google_drive') return 'Google Drive video';
|
||||
|
||||
return `Video ${index + 1}`;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Image } from '$features/ui';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
|
||||
import VideoCardDropdown from './video-card-dropdown.svelte';
|
||||
import {
|
||||
getVideoThumbnailUrl,
|
||||
@@ -67,15 +67,19 @@
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-2 min-h-[44px] text-base!" {title}>
|
||||
{#if isYoutubeWithLink}
|
||||
<a
|
||||
href={video.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:text-foreground hover:ui:underline focus:ui:underline focus:ui:outline-none inline-flex items-center gap-1.5"
|
||||
>
|
||||
<span class="line-clamp-2">{title}</span>
|
||||
<ExternalLinkIcon class="ui:text-muted-foreground size-3.5 shrink-0" aria-hidden="true" />
|
||||
</a>
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<a
|
||||
href={video.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:text-foreground hover:ui:underline focus:ui:underline focus:ui:outline-none inline-flex items-center gap-1.5"
|
||||
>
|
||||
<span class="line-clamp-2">{title}</span>
|
||||
<ExternalLinkIcon {isHovered} size={14} class="ui:text-muted-foreground shrink-0" ariaHidden={true} />
|
||||
</a>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{:else}
|
||||
{title}
|
||||
{/if}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
totalStudents?: number;
|
||||
isExplore?: boolean;
|
||||
isLMS?: boolean;
|
||||
href?: string;
|
||||
showActions?: boolean;
|
||||
tags?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -40,10 +42,17 @@
|
||||
totalStudents = 0,
|
||||
isExplore = false,
|
||||
isLMS = false,
|
||||
href,
|
||||
showActions = true,
|
||||
tags = []
|
||||
}: Props = $props();
|
||||
|
||||
function handleRowClick() {
|
||||
if (href) {
|
||||
goto(resolve(href, {}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExplore) {
|
||||
goto(resolve(`/course/${slug}`, {}));
|
||||
return;
|
||||
@@ -104,7 +113,7 @@
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !isLMS}
|
||||
{#if !isLMS && showActions}
|
||||
<Table.Cell class="text-center">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
|
||||
import type { Feed } from '$features/course/utils/types';
|
||||
import { newsfeedApi } from '$features/course/api';
|
||||
import { getNewsfeedReactionCounts, getSelectedNewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
|
||||
|
||||
import DeleteConfirmation from './delete-confirmation.svelte';
|
||||
import type { NewsfeedCommentsByFeedId } from '$features/course/api';
|
||||
import Header from './header.svelte';
|
||||
import Reactions from './reactions.svelte';
|
||||
import { NewsfeedReactions } from '@cio/ui/custom/newsfeed-reactions';
|
||||
import Comments from './comments.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -64,6 +65,9 @@
|
||||
deleteFeed(feed.id);
|
||||
};
|
||||
|
||||
const reactionCounts = $derived(getNewsfeedReactionCounts(feed.reaction));
|
||||
const selectedReactionType = $derived(getSelectedNewsfeedReactionType(feed.reaction, author.id));
|
||||
|
||||
onMount(() => {
|
||||
if (isActive) {
|
||||
const el = document.getElementById(feed.id);
|
||||
@@ -84,7 +88,14 @@
|
||||
>
|
||||
<Header {feed} {onPin} onEdit={openEditFeed} onRequestDelete={() => (isDeleteFeedModal = true)} />
|
||||
|
||||
<Reactions {feed} authorId={author.id} {isReacting} onReact={handleReact} />
|
||||
<div class="px-3 pt-1 pb-2">
|
||||
<NewsfeedReactions
|
||||
{reactionCounts}
|
||||
{selectedReactionType}
|
||||
disabled={isReacting || !author.id}
|
||||
onReactionToggle={handleReact}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Comments
|
||||
{courseId}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
@@ -24,11 +26,17 @@
|
||||
<div class="mb-2 flex justify-between">
|
||||
<span class="flex items-center gap-3">
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={feed.authorAvatarUrl || ''}
|
||||
alt={$t('course.navItem.news_feed.user_avatar_alt')}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
<Avatar.Root class="h-full w-full">
|
||||
<Avatar.Image
|
||||
src={feed.authorAvatarUrl ?? ''}
|
||||
alt={$t('course.navItem.news_feed.user_avatar_alt')}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<Avatar.Fallback aria-hidden="true">
|
||||
<UserIcon class="text-muted-foreground h-5 w-5" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</div>
|
||||
<span>
|
||||
<p class="text-base font-semibold capitalize">{feed.authorFullname || ''}</p>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import type { Feed } from '$features/course/utils/types';
|
||||
|
||||
interface Props {
|
||||
feed: Feed;
|
||||
authorId: string;
|
||||
isReacting?: boolean;
|
||||
onReact: (reactionType: string) => void;
|
||||
}
|
||||
|
||||
let { feed, authorId, isReacting = false, onReact }: Props = $props();
|
||||
|
||||
const reactions: Record<string, string> = {
|
||||
smile: '😀',
|
||||
thumbsup: '👍',
|
||||
thumbsdown: '👎',
|
||||
clap: '👏'
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Reactions -->
|
||||
<div class="flex items-center gap-4 px-3">
|
||||
<div class="flex items-center gap-1">
|
||||
{#each Object.keys(feed.reaction || {}) as reactionType}
|
||||
{#if reactions[reactionType]}
|
||||
{@const isAuthorReacted = (feed.reaction?.[reactionType] || []).includes(authorId)}
|
||||
<Button
|
||||
variant={isAuthorReacted ? 'light-default' : 'ghost'}
|
||||
size="icon-sm"
|
||||
class="size-unset! px-5! py-0!"
|
||||
disabled={isReacting}
|
||||
onclick={() => onReact(reactionType)}
|
||||
>
|
||||
{reactions[reactionType]}
|
||||
{#if feed.reaction?.[reactionType]?.length >= 1}
|
||||
<span class="text-xs">{feed.reaction?.[reactionType]?.length}</span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,12 +13,10 @@
|
||||
import { peopleApi } from '$features/course/api';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import type { OrgStudent, Tutor } from './types';
|
||||
|
||||
import TutorSelectSection from './tutor-select-section.svelte';
|
||||
import ExistingStudentsSection from './existing-students-section.svelte';
|
||||
import BulkEmailSection from './bulk-email-section.svelte';
|
||||
import type { OrgStudent, Tutor } from '$features/people/utils/types';
|
||||
import { TutorSelectSection, ExistingStudentsSection, BulkEmailSection } from '$features/people/components';
|
||||
|
||||
let tutors = $state<Tutor[]>([]);
|
||||
let selectedIds = $state<string[]>([]);
|
||||
@@ -87,6 +85,56 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStudentSearch(value: string) {
|
||||
const orgId = $currentOrg.id;
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingStudents = true;
|
||||
|
||||
try {
|
||||
await orgApi.getOrgAudience(
|
||||
orgId,
|
||||
{
|
||||
...DEFAULT_ORG_AUDIENCE_QUERY,
|
||||
search: value.trim() || undefined
|
||||
},
|
||||
{ abortPrevious: true }
|
||||
);
|
||||
} finally {
|
||||
isLoadingStudents = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function assignExistingStudents(profileIds: string[], sendEmail: boolean) {
|
||||
const result = await orgApi.assignAudienceToCourses({
|
||||
profileIds,
|
||||
courseIds: [courseId],
|
||||
sendEmail
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
await courseApi.refreshCourse(courseId, $profile.id);
|
||||
}
|
||||
|
||||
async function inviteNewStudents(recipientCsv: string, sendEmail: boolean) {
|
||||
const response = await orgApi.importAudienceMembers({
|
||||
recipientCsv,
|
||||
courseIds: [courseId],
|
||||
sendEmail
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
await courseApi.refreshCourse(courseId, $profile.id);
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!selectedTutors.length) {
|
||||
goto(resolve(page.url.pathname, {}));
|
||||
@@ -144,7 +192,12 @@
|
||||
|
||||
<UnderlineTabs.Content value="tutors">
|
||||
<div class="mt-6 space-y-6">
|
||||
<TutorSelectSection {tutors} bind:selectedIds isLoading={orgApi.isLoading} />
|
||||
<TutorSelectSection
|
||||
{tutors}
|
||||
bind:selectedIds
|
||||
isLoading={orgApi.isLoading}
|
||||
helperHref={`/org/${$currentOrg.siteName}/settings/teams`}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex flex-row-reverse items-center gap-2">
|
||||
<Button variant="secondary" type="button" onclick={onSubmit} loading={peopleApi.isLoading}>
|
||||
@@ -156,8 +209,13 @@
|
||||
|
||||
<UnderlineTabs.Content value="students">
|
||||
<div class="mt-6 space-y-6">
|
||||
<ExistingStudentsSection {courseId} students={availableStudents} isLoading={isLoadingStudents} />
|
||||
<BulkEmailSection {courseId} />
|
||||
<ExistingStudentsSection
|
||||
students={availableStudents}
|
||||
isLoading={isLoadingStudents}
|
||||
onSearchValueChange={handleStudentSearch}
|
||||
onAssign={assignExistingStudents}
|
||||
/>
|
||||
<BulkEmailSection onInvite={inviteNewStudents} />
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
</section>
|
||||
|
||||
<section class="my-2 space-y-4">
|
||||
<div class="rounded-md border">
|
||||
<div class="overflow-x-auto rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { SortSelect } from '$features/ui';
|
||||
import GridIcon from '@lucide/svelte/icons/grid-2x2';
|
||||
import ListIcon from '@lucide/svelte/icons/list';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
@@ -12,8 +12,8 @@
|
||||
import {
|
||||
CourseCardList,
|
||||
CourseCardLoader,
|
||||
CourseListRow,
|
||||
CreateCourseButton,
|
||||
CourseList,
|
||||
CopyCourseModal,
|
||||
NewCourseModal
|
||||
} from '$features/course/components';
|
||||
@@ -60,8 +60,6 @@
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedLabel = $derived(filterOptions.find((opt) => opt.value === sortKey)?.label || filterOptions[0].label);
|
||||
|
||||
const setViewPreference = (preference: 'grid' | 'list') => {
|
||||
$courseMetaDeta.view = preference;
|
||||
if (browser) {
|
||||
@@ -97,27 +95,20 @@
|
||||
<Page.BodyHeader align="right" class="p-0!">
|
||||
<Search placeholder={$t('courses.search_placeholder')} bind:value={searchValue} />
|
||||
{#if showSortSelect}
|
||||
<Select.Root type="single" bind:value={sortKey}>
|
||||
<Select.Trigger class="min-w-[150px]">
|
||||
<p>{sortKey ? selectedLabel : filterOptions[0].label}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each filterOptions as option (option.label)}
|
||||
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<SortSelect options={filterOptions} bind:value={sortKey} />
|
||||
{/if}
|
||||
{@render filterControls?.()}
|
||||
|
||||
{#if $courseMetaDeta.view === 'list'}
|
||||
<IconButton onclick={() => setViewPreference('grid')}>
|
||||
<GridIcon size={16} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton onclick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{#if !isLMS}
|
||||
{#if $courseMetaDeta.view === 'list'}
|
||||
<IconButton onclick={() => setViewPreference('grid')}>
|
||||
<GridIcon size={16} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton onclick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
{/if}
|
||||
</Page.BodyHeader>
|
||||
|
||||
@@ -134,48 +125,33 @@
|
||||
<CreateCourseButton isResponsive />
|
||||
{/if}
|
||||
</Empty>
|
||||
{:else if $courseMetaDeta.view === 'list'}
|
||||
<div class="w-full overflow-hidden rounded-md border">
|
||||
<Table.Root class="w-full table-fixed">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[20%]">{$t('courses.course_card.list_view.title')}</Table.Head>
|
||||
<Table.Head class="w-[20%]">{$t('courses.course_card.list_view.description')}</Table.Head>
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.type')}</Table.Head>
|
||||
<Table.Head class="w-3/12 min-w-0">{$t('courses.course_card.list_view.tags')}</Table.Head>
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.lessons')}</Table.Head>
|
||||
{#if !isLMS}
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
|
||||
<Table.Head class="w-[6%]"></Table.Head>
|
||||
{/if}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each courses as courseData (courseData.id)}
|
||||
<CourseList
|
||||
id={courseData.id}
|
||||
slug={courseData.slug ?? ''}
|
||||
title={courseData.title}
|
||||
type={$t(`course.navItem.settings.${courseData.type?.toLowerCase()}`)}
|
||||
description={courseData.description}
|
||||
isPublished={courseData.isPublished ?? false}
|
||||
totalLessons={courseData.lessonCount}
|
||||
totalStudents={'totalStudents' in courseData ? courseData.totalStudents : 0}
|
||||
tags={('tags' in courseData && Array.isArray(courseData.tags) ? courseData.tags : []) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
}>}
|
||||
{isExplore}
|
||||
{isLMS}
|
||||
/>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{:else}
|
||||
{:else if isLMS || $courseMetaDeta.view === 'grid'}
|
||||
<CourseCardList {courses} {isExplore} {isLMS} />
|
||||
{:else}
|
||||
<ResourceListRow.Group>
|
||||
{#each courses as courseData (courseData.id)}
|
||||
<CourseListRow
|
||||
id={courseData.id}
|
||||
slug={courseData.slug ?? ''}
|
||||
title={courseData.title}
|
||||
logo={courseData.logo ?? null}
|
||||
type={courseData.type}
|
||||
description={courseData.description}
|
||||
isPublished={courseData.isPublished ?? false}
|
||||
lessonCount={courseData.lessonCount}
|
||||
exerciseCount={'exerciseCount' in courseData ? (courseData.exerciseCount ?? 0) : 0}
|
||||
totalStudents={'totalStudents' in courseData ? courseData.totalStudents : 0}
|
||||
updatedAt={'updatedAt' in courseData ? (courseData.updatedAt ?? null) : null}
|
||||
tags={('tags' in courseData && Array.isArray(courseData.tags) ? courseData.tags : []) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
}>}
|
||||
{isExplore}
|
||||
{isLMS}
|
||||
/>
|
||||
{/each}
|
||||
</ResourceListRow.Group>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -249,7 +249,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Page.Header isSticky={true} class="z-250! min-h-[36px]">
|
||||
<Page.Header isSticky={true} class="z-50! min-h-[36px]">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title class="flex flex-wrap items-center gap-2">
|
||||
<span>{$questionnaire.title || 'Exercise'}</span>
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import Empty from '@cio/ui/custom/empty/empty.svelte';
|
||||
import { BookIcon } from '@lucide/svelte';
|
||||
import type { TNewsfeedReactionUpdate } from '@cio/utils/validation/newsfeed';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { buildNextNewsfeedReaction } from '@cio/ui/custom/newsfeed-reactions';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
@@ -48,27 +49,12 @@
|
||||
};
|
||||
|
||||
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
|
||||
if (isReactingByFeedId[feedId]) return;
|
||||
if (!authorId || isReactingByFeedId[feedId]) return;
|
||||
|
||||
const reactedFeed = newsfeedApi.feeds.find((feed) => feed.id === feedId);
|
||||
if (!reactedFeed) return;
|
||||
|
||||
const reaction = reactedFeed.reaction;
|
||||
let reactedAuthorIds: string[] = reaction?.[reactionType] || [];
|
||||
|
||||
if (reactedAuthorIds.includes(authorId)) {
|
||||
reactedAuthorIds = reactedAuthorIds.filter((reactionAuthorId) => reactionAuthorId !== authorId);
|
||||
} else {
|
||||
reactedAuthorIds = [...reactedAuthorIds, authorId];
|
||||
}
|
||||
|
||||
const updatedReaction: TNewsfeedReactionUpdate['reaction'] = {
|
||||
clap: reaction?.clap || [],
|
||||
smile: reaction?.smile || [],
|
||||
thumbsup: reaction?.thumbsup || [],
|
||||
thumbsdown: reaction?.thumbsdown || [],
|
||||
[reactionType]: reactedAuthorIds
|
||||
};
|
||||
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction, reactionType, authorId);
|
||||
|
||||
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };
|
||||
|
||||
@@ -99,11 +85,11 @@
|
||||
return snackbar.error('snackbar.course.error.toggle_error');
|
||||
}
|
||||
|
||||
snackbar.success(
|
||||
`${
|
||||
newIsPinned ? 'snackbar.course.success.pinned' : 'snackbar.course.success.unpinned'
|
||||
} snackbar.course.success.successfully`
|
||||
);
|
||||
const pinStatusMessage = newIsPinned
|
||||
? t.get('snackbar.course.success.pinned')
|
||||
: t.get('snackbar.course.success.unpinned');
|
||||
|
||||
snackbar.success(`${pinStatusMessage} ${t.get('snackbar.course.success.successfully')}`);
|
||||
};
|
||||
|
||||
const deleteFeed = async (id: string) => {
|
||||
@@ -119,10 +105,10 @@
|
||||
});
|
||||
|
||||
function getPageRoles(org: AccountOrg) {
|
||||
const roles: number[] = [1, 2];
|
||||
const roles: number[] = [ROLE.ADMIN, ROLE.TUTOR];
|
||||
|
||||
if (org.customization.course.newsfeed) {
|
||||
roles.push(3);
|
||||
roles.push(ROLE.STUDENT);
|
||||
}
|
||||
|
||||
return roles;
|
||||
@@ -143,7 +129,7 @@
|
||||
goto(`/courses/${courseId}/lessons?next=true`);
|
||||
}}
|
||||
>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<RoleBasedSecurity allowedRoles={getPageRoles($currentOrg)}>
|
||||
<NewFeedModal {courseId} bind:edit bind:editFeed />
|
||||
</RoleBasedSecurity>
|
||||
{#if isListing}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
// Question type inferred from API Exercise response
|
||||
import type { Course as CourseDetails, Exercise } from './utils/types';
|
||||
import type { InferResponseType } from '@cio/api/rpc-types';
|
||||
import type { InferRequestType, InferResponseType } from '@cio/api/rpc-types';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
// Org Courses types
|
||||
export type GetOrgCoursesRequest = typeof classroomio.organization.courses.$get;
|
||||
export type OrgCoursesResponse = InferResponseType<GetOrgCoursesRequest> | null;
|
||||
export type OrgCoursesSuccess = Extract<InferResponseType<GetOrgCoursesRequest>, { success: true }>;
|
||||
export type GetOrgCoursesRequestQuery = NonNullable<InferRequestType<GetOrgCoursesRequest>['query']>;
|
||||
export type OrgCourses = OrgCoursesSuccess['data'];
|
||||
export type OrgCoursesPagination = OrgCoursesSuccess['pagination'];
|
||||
export type OrgCoursesQuery = {
|
||||
page: number;
|
||||
limit: number;
|
||||
search?: string;
|
||||
tags?: string;
|
||||
};
|
||||
|
||||
// User Enrolled Courses types
|
||||
export type GetUserEnrolledCoursesRequest = typeof classroomio.organization.courses.enrolled.$get;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** FNV-1a–style hash for stable, client-only placeholder picks (e.g. icon per course). */
|
||||
export function hashStringToUInt32(value: string): number {
|
||||
let hash = 2166136261;
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function buildCoursePlaceholderAvatarUrl(seed: string): string {
|
||||
const stableSeed = hashStringToUInt32(seed).toString(36);
|
||||
|
||||
return `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(stableSeed)}`;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { writable } from 'svelte/store';
|
||||
export const courseMetaDeta = writable<{
|
||||
view: 'grid' | 'list';
|
||||
}>({
|
||||
view: 'grid'
|
||||
view: 'list'
|
||||
});
|
||||
|
||||
export const createCourseModal = writable({
|
||||
|
||||
Reference in New Issue
Block a user