Enhance audience management features with new components and API integrations
- Added `AssignCoursesModal`, `AudienceMemberRow`, `AudienceTable`, and related components for improved audience management UI. - Implemented audience invite actions including resend and revoke functionality. - Updated audience API to support importing members and assigning courses. - Refactored audience data handling to include status and invite management. - Improved overall user experience with pagination and selection features in the audience table.
This commit is contained in:
@@ -119,7 +119,8 @@ class AppInitApi extends BaseApi {
|
||||
}
|
||||
|
||||
// This allows you to be on the landing page of an organization site and not be redirected
|
||||
if (isOrgSite && isPublicRoute(window.location.pathname)) {
|
||||
const path = window.location.pathname;
|
||||
if (isPublicRoute(path) && (path !== '/' || isOrgSite)) {
|
||||
console.log('no redirect is needed');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { CheckboxField, MultiSelectList } from '@cio/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
selectedProfileIds: string[];
|
||||
courses: Course[];
|
||||
onclose: () => void;
|
||||
onassigned: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), selectedProfileIds, courses, onclose, onassigned }: Props = $props();
|
||||
|
||||
let fields = $state({
|
||||
selectedCourseIds: new SvelteSet<string>(),
|
||||
sendEmail: true
|
||||
});
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
function toggleCourse(courseId: string) {
|
||||
if (fields.selectedCourseIds.has(courseId)) {
|
||||
fields.selectedCourseIds.delete(courseId);
|
||||
} else {
|
||||
fields.selectedCourseIds.add(courseId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
fields.selectedCourseIds.clear();
|
||||
fields.sendEmail = true;
|
||||
onclose();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (fields.selectedCourseIds.size === 0) {
|
||||
snackbar.error('audience.assign.snackbar_no_courses');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
await orgApi.assignAudienceToCourses({
|
||||
profileIds: selectedProfileIds,
|
||||
courseIds: [...fields.selectedCourseIds],
|
||||
sendEmail: fields.sendEmail
|
||||
});
|
||||
|
||||
fields.selectedCourseIds.clear();
|
||||
fields.sendEmail = true;
|
||||
onassigned();
|
||||
} catch {
|
||||
snackbar.error('audience.assign.snackbar_failed');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(v) => !v && handleClose()}>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('audience.assign.title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('audience.assign.description')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<MultiSelectList
|
||||
heading={$t('audience.assign.select_courses')}
|
||||
emptyMessage={$t('audience.assign.select_courses_placeholder')}
|
||||
items={courses.map((c) => ({ id: c.id, label: c.title || c.id }))}
|
||||
isSelected={(id) => fields.selectedCourseIds.has(id)}
|
||||
onToggle={toggleCourse}
|
||||
namePrefix="assign-course"
|
||||
/>
|
||||
|
||||
<CheckboxField name="assign-send-email" label={$t('audience.assign.send_email')} bind:checked={fields.sendEmail} />
|
||||
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button variant="outline" onclick={handleClose}>
|
||||
{$t('audience.assign.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || fields.selectedCourseIds.size === 0}
|
||||
>
|
||||
{$t('audience.assign.submit')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import type { OrganizationAudienceMember } from '$features/org/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import {
|
||||
canResendAudienceInvite,
|
||||
canRevokeAudienceInvite,
|
||||
statusBadgeVariant,
|
||||
statusLabelKey
|
||||
} from '$features/audience/utils/audience-utils';
|
||||
|
||||
interface Props {
|
||||
row: OrganizationAudienceMember;
|
||||
memberDetailHref: string | null;
|
||||
selected: boolean;
|
||||
onToggleSelect: () => void;
|
||||
inviteActionEmail: string | null;
|
||||
onResendInvite: (email: string) => void;
|
||||
onRevokeInvite: (email: string) => void;
|
||||
}
|
||||
|
||||
let { row, memberDetailHref, selected, onToggleSelect, inviteActionEmail, onResendInvite, onRevokeInvite }: Props =
|
||||
$props();
|
||||
|
||||
const showInviteActions = $derived(
|
||||
!row.profileId && (canResendAudienceInvite(row.status) || canRevokeAudienceInvite(row.status))
|
||||
);
|
||||
</script>
|
||||
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-10">
|
||||
<Checkbox disabled={!row.profileId} checked={selected} onCheckedChange={onToggleSelect} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if memberDetailHref}
|
||||
<a href={memberDetailHref} class="ui:text-primary flex items-center gap-2 hover:underline">
|
||||
<Avatar.Root class="h-5 w-5">
|
||||
{#if row.avatarUrl}
|
||||
<Avatar.Image src={row.avatarUrl} alt={row.name ? row.name : 'User'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{row.name}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="flex items-center gap-2">
|
||||
<Avatar.Root class="h-5 w-5">
|
||||
{#if row.avatarUrl}
|
||||
<Avatar.Image src={row.avatarUrl} alt={row.name ? row.name : 'User'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{row.name}
|
||||
</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{row.email}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={statusBadgeVariant(row.status)}>{$t(statusLabelKey(row.status))}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{row.createdAt}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
{#if showInviteActions}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="hover:ui:bg-muted inline-flex items-center justify-center rounded-md p-1.5"
|
||||
aria-label={$t('audience.invite.row_actions_aria')}
|
||||
disabled={inviteActionEmail !== null}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVerticalIcon class="ui:size-4 ui:text-muted-foreground" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if canResendAudienceInvite(row.status)}
|
||||
<DropdownMenu.Item disabled={inviteActionEmail === row.email} onclick={() => onResendInvite(row.email)}>
|
||||
{$t('audience.invite.resend')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if canRevokeAudienceInvite(row.status)}
|
||||
<DropdownMenu.Item
|
||||
class="ui:text-destructive focus:ui:text-destructive"
|
||||
disabled={inviteActionEmail === row.email}
|
||||
onclick={() => onRevokeInvite(row.email)}
|
||||
>
|
||||
{$t('audience.invite.revoke')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import * as Pagination from '@cio/ui/base/pagination';
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
perPage: number;
|
||||
page: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
let { count, perPage, page, onPageChange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Pagination.Root {count} {perPage} {page} {onPageChange}>
|
||||
{#snippet children({ pages, currentPage: activePage })}
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as pageItem (pageItem.key)}
|
||||
{#if pageItem.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link page={pageItem} isActive={activePage === pageItem.value}>
|
||||
{pageItem.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
|
||||
interface Props {
|
||||
rowCount?: number;
|
||||
}
|
||||
|
||||
let { rowCount = 20 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each Array(rowCount) as _, i (i)}
|
||||
<Skeleton class="h-12 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
hasSelection: boolean;
|
||||
selectedCount: number;
|
||||
searchValue?: string;
|
||||
onOpenAssign: () => void;
|
||||
}
|
||||
|
||||
let { hasSelection, selectedCount, searchValue = $bindable(''), onOpenAssign }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Page.BodyHeader>
|
||||
{#if hasSelection}
|
||||
<div class="flex items-center gap-3 rounded-md border px-4 py-2">
|
||||
<span class="ui:text-muted-foreground text-sm">
|
||||
{$t('audience.selected_count', { count: selectedCount })}
|
||||
</span>
|
||||
<Button variant="secondary" size="sm" onclick={onOpenAssign}>
|
||||
{$t('audience.assign_courses')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="" />
|
||||
{/if}
|
||||
</Page.BodyHeader>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import type { OrganizationAudienceMember } from '$features/org/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import AudienceMemberRow from './audience-member-row.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
interface Header {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
headers: Header[];
|
||||
rows: OrganizationAudienceMember[];
|
||||
allPageSelected: boolean;
|
||||
somePageSelected: boolean;
|
||||
onToggleSelectAll: () => void;
|
||||
isRowSelected: (rowId: string) => boolean;
|
||||
onToggleRow: (rowId: string) => void;
|
||||
inviteActionEmail: string | null;
|
||||
onResendInvite: (email: string) => void;
|
||||
onRevokeInvite: (email: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
headers,
|
||||
rows,
|
||||
allPageSelected,
|
||||
somePageSelected,
|
||||
onToggleSelectAll,
|
||||
isRowSelected,
|
||||
onToggleRow,
|
||||
inviteActionEmail,
|
||||
onResendInvite,
|
||||
onRevokeInvite
|
||||
}: Props = $props();
|
||||
|
||||
function memberHref(row: OrganizationAudienceMember): string | null {
|
||||
if (!row.profileId) return null;
|
||||
return resolve(`${window.location.pathname}/${row.profileId}`, {});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-10">
|
||||
<Checkbox
|
||||
checked={allPageSelected}
|
||||
indeterminate={somePageSelected}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
aria-label={$t('audience.select_all')}
|
||||
/>
|
||||
</Table.Head>
|
||||
{#each headers as header (header)}
|
||||
<Table.Head>{header.value}</Table.Head>
|
||||
{/each}
|
||||
<Table.Head class="w-12 text-right">
|
||||
<span class="sr-only">{$t('audience.invite.row_actions_aria')}</span>
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each rows as row (String(row.id))}
|
||||
<AudienceMemberRow
|
||||
{row}
|
||||
memberDetailHref={memberHref(row)}
|
||||
selected={isRowSelected(String(row.id))}
|
||||
onToggleSelect={() => onToggleRow(String(row.id))}
|
||||
{inviteActionEmail}
|
||||
{onResendInvite}
|
||||
{onRevokeInvite}
|
||||
/>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
@@ -1 +1,6 @@
|
||||
// Components exports will be added here when needed
|
||||
export { default as AssignCoursesModal } from './assign-courses-modal.svelte';
|
||||
export { default as AudienceMemberRow } from './audience-member-row.svelte';
|
||||
export { default as AudiencePagination } from './audience-pagination.svelte';
|
||||
export { default as AudienceTable } from './audience-table.svelte';
|
||||
export { default as AudienceTableSkeleton } from './audience-table-skeleton.svelte';
|
||||
export { default as AudienceTableToolbar } from './audience-table-toolbar.svelte';
|
||||
|
||||
@@ -1,41 +1,68 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { page } from '$app/state';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import * as Pagination from '@cio/ui/base/pagination';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { UpgradeBanner } from '$features/ui';
|
||||
import { currentOrgMaxAudience } from '$lib/utils/store/org';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import type { OrganizationAudience } from '$features/org/utils/types';
|
||||
import AssignCoursesModal from '$features/audience/components/assign-courses-modal.svelte';
|
||||
import AudienceTableToolbar from '$features/audience/components/audience-table-toolbar.svelte';
|
||||
import AudienceTableSkeleton from '$features/audience/components/audience-table-skeleton.svelte';
|
||||
import AudienceTable from '$features/audience/components/audience-table.svelte';
|
||||
import AudiencePagination from '$features/audience/components/audience-pagination.svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
audience?: OrganizationAudience;
|
||||
courses?: Course[];
|
||||
}
|
||||
|
||||
let { audience }: Props = $props();
|
||||
let { audience, courses = [] }: Props = $props();
|
||||
|
||||
// Initialize audience from prop if provided
|
||||
$effect(() => {
|
||||
if (audience) {
|
||||
orgApi.audience = audience;
|
||||
}
|
||||
});
|
||||
|
||||
const headers = [
|
||||
{ key: 'name', value: $t('audience.name') },
|
||||
{ key: 'email', value: $t('audience.email') },
|
||||
{ key: 'date_joined', value: $t('audience.date_joined') }
|
||||
];
|
||||
let pageSize = $state(5);
|
||||
const headers = $derived([
|
||||
{ key: 'name', value: t.get('audience.name') },
|
||||
{ key: 'email', value: t.get('audience.email') },
|
||||
{ key: 'status', value: t.get('audience.status') },
|
||||
{ key: 'date_joined', value: t.get('audience.date_joined') }
|
||||
]);
|
||||
|
||||
let inviteActionEmail = $state<string | null>(null);
|
||||
|
||||
async function handleResendInvite(email: string) {
|
||||
inviteActionEmail = email;
|
||||
try {
|
||||
await orgApi.resendAudienceInvite({ email });
|
||||
} finally {
|
||||
inviteActionEmail = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevokeInvite(email: string) {
|
||||
inviteActionEmail = email;
|
||||
try {
|
||||
await orgApi.revokeAudienceInvite({ email });
|
||||
} finally {
|
||||
inviteActionEmail = null;
|
||||
}
|
||||
}
|
||||
|
||||
let pageSize = $state(20);
|
||||
let currentPage = $state(1);
|
||||
let searchValue = $state('');
|
||||
let selectedIds = new SvelteSet<string>();
|
||||
let assignModalOpen = $state(false);
|
||||
|
||||
const filteredRows = $derived(
|
||||
searchValue
|
||||
@@ -50,6 +77,45 @@
|
||||
const endIndex = $derived(startIndex + pageSize);
|
||||
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
|
||||
|
||||
const selectablePageRows = $derived(paginatedRows.filter((row) => row.profileId));
|
||||
|
||||
const allPageSelected = $derived(
|
||||
selectablePageRows.length > 0 && selectablePageRows.every((row) => selectedIds.has(String(row.id)))
|
||||
);
|
||||
const somePageSelected = $derived(
|
||||
selectablePageRows.some((row) => selectedIds.has(String(row.id))) && !allPageSelected
|
||||
);
|
||||
const hasSelection = $derived(selectedIds.size > 0);
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allPageSelected) {
|
||||
for (const row of selectablePageRows) {
|
||||
selectedIds.delete(String(row.id));
|
||||
}
|
||||
} else {
|
||||
for (const row of selectablePageRows) {
|
||||
selectedIds.add(String(row.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRow(id: string) {
|
||||
if (selectedIds.has(id)) {
|
||||
selectedIds.delete(id);
|
||||
} else {
|
||||
selectedIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProfileIds = $derived(
|
||||
orgApi.audience.filter((r) => selectedIds.has(String(r.id)) && r.profileId).map((r) => r.profileId!)
|
||||
);
|
||||
|
||||
function handleAssigned() {
|
||||
assignModalOpen = false;
|
||||
selectedIds.clear();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (filteredRows.length > 0 && currentPage > totalPages) {
|
||||
currentPage = 1;
|
||||
@@ -61,90 +127,47 @@
|
||||
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
|
||||
{/if}
|
||||
|
||||
<Page.BodyHeader>
|
||||
<Search placeholder="Search..." bind:value={searchValue} class="" />
|
||||
</Page.BodyHeader>
|
||||
<AudienceTableToolbar
|
||||
{hasSelection}
|
||||
selectedCount={selectedIds.size}
|
||||
bind:searchValue
|
||||
onOpenAssign={() => (assignModalOpen = true)}
|
||||
/>
|
||||
|
||||
{#if orgApi.audience.length || orgApi.isLoading}
|
||||
<div class="w-full space-y-4">
|
||||
<!-- Table -->
|
||||
{#if orgApi.isLoading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(pageSize) as _, i (i)}
|
||||
<Skeleton class="h-12 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
<AudienceTableSkeleton rowCount={pageSize} />
|
||||
{:else}
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#each headers as header (header)}
|
||||
<Table.Head>{header.value}</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each paginatedRows as row (row.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<a
|
||||
href={`${page.url.href}/${row.id}`}
|
||||
class="ui:text-primary flex items-center gap-2 hover:underline"
|
||||
>
|
||||
<Avatar.Root class="h-5 w-5">
|
||||
<Avatar.Image
|
||||
src={row.avatarUrl ? row.avatarUrl : '/logo-192.png'}
|
||||
alt={row.name ? row.name : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(row.name) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{row.name}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{row.email}</Table.Cell>
|
||||
<Table.Cell>{row.createdAt}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
<AudienceTable
|
||||
{headers}
|
||||
rows={paginatedRows}
|
||||
{allPageSelected}
|
||||
{somePageSelected}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
isRowSelected={(id) => selectedIds.has(id)}
|
||||
onToggleRow={toggleRow}
|
||||
{inviteActionEmail}
|
||||
onResendInvite={handleResendInvite}
|
||||
onRevokeInvite={handleRevokeInvite}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Pagination.Root
|
||||
count={filteredRows.length}
|
||||
perPage={pageSize}
|
||||
page={currentPage}
|
||||
onPageChange={(page) => (currentPage = page)}
|
||||
>
|
||||
{#snippet children({ pages, currentPage: activePage })}
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={activePage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
<AudiencePagination
|
||||
count={filteredRows.length}
|
||||
perPage={pageSize}
|
||||
page={currentPage}
|
||||
onPageChange={(p) => (currentPage = p)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
|
||||
{/if}
|
||||
|
||||
<AssignCoursesModal
|
||||
bind:open={assignModalOpen}
|
||||
{selectedProfileIds}
|
||||
{courses}
|
||||
onclose={() => (assignModalOpen = false)}
|
||||
onassigned={handleAssigned}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { MultiSelectList } from '@cio/ui';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
courses: Course[];
|
||||
}
|
||||
|
||||
let { courses }: Props = $props();
|
||||
|
||||
let recipientCsv = $state('');
|
||||
let courseAccessMode = $state('none');
|
||||
let selectedCourseIds = new SvelteSet<string>();
|
||||
let sendEmail = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
function toggleCourse(courseId: string) {
|
||||
if (selectedCourseIds.has(courseId)) {
|
||||
selectedCourseIds.delete(courseId);
|
||||
} else {
|
||||
selectedCourseIds.add(courseId);
|
||||
}
|
||||
}
|
||||
|
||||
const audiencePath = $derived(page.url.pathname.replace(/\/import$/, ''));
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!recipientCsv.trim()) {
|
||||
snackbar.error('audience.import.snackbar_no_emails');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const result = await orgApi.importAudienceMembers({
|
||||
recipientCsv,
|
||||
allCourses: courseAccessMode === 'all',
|
||||
courseIds: courseAccessMode === 'select' ? [...selectedCourseIds] : undefined,
|
||||
sendEmail
|
||||
});
|
||||
|
||||
if (result) {
|
||||
await goto(resolve(audiencePath, {}));
|
||||
}
|
||||
} catch {
|
||||
snackbar.error('audience.import.snackbar_failed');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('audience.import.description')}</p>
|
||||
|
||||
<TextareaField
|
||||
label={$t('audience.import.emails_label')}
|
||||
bind:value={recipientCsv}
|
||||
rows={6}
|
||||
className="w-full"
|
||||
placeholder={$t('audience.import.emails_placeholder')}
|
||||
/>
|
||||
|
||||
<div class="space-y-3">
|
||||
<Label class="text-sm font-medium">{$t('audience.import.course_access')}</Label>
|
||||
<RadioGroup.Root bind:value={courseAccessMode} class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="none" id="course-none" />
|
||||
<Label for="course-none" class="font-normal">{$t('audience.import.no_courses')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="all" id="course-all" />
|
||||
<Label for="course-all" class="font-normal">{$t('audience.import.all_courses')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="select" id="course-select" />
|
||||
<Label for="course-select" class="font-normal">{$t('audience.import.select_courses')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
{#if courseAccessMode === 'select'}
|
||||
<MultiSelectList
|
||||
class="ml-6"
|
||||
listClass="max-h-40"
|
||||
heading={$t('audience.assign.select_courses')}
|
||||
emptyMessage={$t('audience.import.select_courses_placeholder')}
|
||||
items={courses.map((c) => ({ id: c.id, label: c.title || c.id }))}
|
||||
isSelected={(id) => selectedCourseIds.has(id)}
|
||||
onToggle={toggleCourse}
|
||||
namePrefix="import-course"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> -->
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button onclick={handleSubmit} loading={isSubmitting} disabled={isSubmitting}>
|
||||
{$t('audience.import.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as AudiencePage } from './audience.svelte';
|
||||
export { default as ImportAudiencePage } from './import.svelte';
|
||||
export { default as UserAnalyticsPage } from './user-analytics.svelte';
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
|
||||
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
|
||||
|
||||
import { BackButton } from '@cio/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
||||
@@ -61,10 +61,7 @@
|
||||
|
||||
{#if userAnalytics}
|
||||
<div class="p-5">
|
||||
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/audience`}>
|
||||
<ArrowLeftIcon size={16} />
|
||||
{$t('community.ask.go_back')}
|
||||
</a>
|
||||
<BackButton href={`${$currentOrgPath}/audience`} label={$t('community.ask.go_back')} />
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-1">
|
||||
@@ -116,7 +113,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each filteredCourses as course, index}
|
||||
{#each filteredCourses as course (course.id)}
|
||||
{#key index}
|
||||
<div
|
||||
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { BadgeVariant } from '@cio/ui/base/badge';
|
||||
|
||||
export function statusBadgeVariant(status: string): BadgeVariant {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'secondary';
|
||||
case 'pending':
|
||||
return 'outline';
|
||||
case 'expired':
|
||||
case 'revoked':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
export function statusLabelKey(status: string): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'audience.status_active';
|
||||
case 'pending':
|
||||
return 'audience.status_pending';
|
||||
case 'expired':
|
||||
return 'audience.status_expired';
|
||||
case 'revoked':
|
||||
return 'audience.status_revoked';
|
||||
default:
|
||||
return 'audience.status_pending';
|
||||
}
|
||||
}
|
||||
|
||||
export function canResendAudienceInvite(status: string): boolean {
|
||||
return status === 'pending' || status === 'expired' || status === 'revoked';
|
||||
}
|
||||
|
||||
export function canRevokeAudienceInvite(status: string): boolean {
|
||||
return status === 'pending';
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { Vote } from '$features/ui';
|
||||
import { CircleCheckIcon } from '$features/ui/icons';
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
@@ -40,11 +40,15 @@
|
||||
<header class="flex items-center justify-between p-2 leading-none">
|
||||
<div class="flex items-center text-black">
|
||||
<Avatar.Root class="size-7">
|
||||
<Avatar.Image
|
||||
src={comment.author?.avatarUrl ? comment.author.avatarUrl : '/logo-192.png'}
|
||||
alt={comment.author?.fullname ? comment.author.fullname : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(comment.author?.fullname) || 'U'}</Avatar.Fallback>
|
||||
{#if comment.author?.avatarUrl}
|
||||
<Avatar.Image
|
||||
src={comment.author.avatarUrl}
|
||||
alt={comment.author?.fullname ? comment.author.fullname : 'User'}
|
||||
/>
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3.5 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<p class="ml-2 text-sm dark:text-white">{comment.author?.fullname}</p>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
@@ -17,19 +18,20 @@
|
||||
import { Vote } from '$features/ui';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { TextEditor } from '$features/ui';
|
||||
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
|
||||
import { BackButton } from '@cio/ui';
|
||||
import type { CommunityQuestionSuccess } from '../utils/types';
|
||||
import { currentCommunityQuestion } from '../utils/store';
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
backHref?: string;
|
||||
question?: CommunityQuestionSuccess['data'] | null;
|
||||
}
|
||||
|
||||
let { question }: Props = $props();
|
||||
let { question, backHref }: Props = $props();
|
||||
|
||||
let commentEditor: TiptapEditor | undefined = $state();
|
||||
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
|
||||
@@ -100,6 +102,9 @@
|
||||
<Page.Root class="mx-auto w-[90%] md:mx-10 md:max-w-3xl lg:mb-20">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
{#if backHref}
|
||||
<BackButton href={backHref} label={$t('community.ask.go_back')} />
|
||||
{/if}
|
||||
{#if communityApi.isEditMode}
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<InputField
|
||||
@@ -175,13 +180,15 @@
|
||||
<div class="flex items-center justify-between p-2 leading-none">
|
||||
<div class="flex items-center text-black no-underline hover:underline">
|
||||
<Avatar.Root class="h-7 w-7">
|
||||
<Avatar.Image
|
||||
src={communityApi.question?.authorAvatarUrl
|
||||
? communityApi.question?.authorAvatarUrl
|
||||
: '/logo-192.png'}
|
||||
alt={communityApi.question?.authorFullname ? communityApi.question?.authorFullname : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(communityApi.question?.authorFullname) || 'U'}</Avatar.Fallback>
|
||||
{#if communityApi.question?.authorAvatarUrl}
|
||||
<Avatar.Image
|
||||
src={communityApi.question.authorAvatarUrl}
|
||||
alt={communityApi.question?.authorFullname ? communityApi.question.authorFullname : 'User'}
|
||||
/>
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3.5 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="ml-2 text-sm dark:text-white">{communityApi.question?.authorFullname}</p>
|
||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { resolve } from '$app/paths';
|
||||
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
||||
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
|
||||
|
||||
@@ -62,10 +64,8 @@
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="">{$t('community.all')}</Select.Item>
|
||||
{#each communityApi.courses as course}
|
||||
{#if course.id}
|
||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
||||
{/if}
|
||||
{#each communityApi.courses as course (course.id)}
|
||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -77,10 +77,10 @@
|
||||
<CommunityListLoader />
|
||||
<CommunityListLoader />
|
||||
{:else}
|
||||
{#each filteredQuestions as question}
|
||||
{#each filteredQuestions as question (question.slug)}
|
||||
<Item.Root variant="outline">
|
||||
{#snippet child({ props })}
|
||||
<a href="{$basePath}/community/{question.slug}" {...props}>
|
||||
<a href={resolve(`${$basePath}/community/[slug]`, { slug: question.slug })} {...props}>
|
||||
<Vote value={question.votes} />
|
||||
<Item.Content class="gap-y-0.5">
|
||||
<Item.Title class="mt-0">
|
||||
@@ -89,11 +89,15 @@
|
||||
<Item.Description>
|
||||
{question?.authorFullname} asked {calDateDiff(question.createdAt || new Date())}
|
||||
</Item.Description>
|
||||
<a class="m-0" href="/courses/{question.courseId}" onclick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
class="m-0! justify-start! px-0! py-0! underline"
|
||||
href={resolve(`/courses/[id]`, { id: question.courseId })}
|
||||
variant="ghost"
|
||||
>
|
||||
<span class="text-muted-foreground p-0 text-xs">
|
||||
#{question?.courseTitle}
|
||||
</span>
|
||||
</a>
|
||||
</Button>
|
||||
</Item.Content>
|
||||
<Item.Actions>
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -24,6 +24,7 @@ import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { resolve } from '$app/paths';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { coursesApi } from './courses.svelte';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import type { CourseMembers } from '../utils/types';
|
||||
@@ -499,6 +500,7 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
}),
|
||||
logContext: 'deleting course',
|
||||
onSuccess: (response) => {
|
||||
coursesApi.removeCourseFromLists(courseId);
|
||||
if (response.data) {
|
||||
snackbar.success('Course deleted successfully');
|
||||
this.success = true;
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
UserEnrolledCourses
|
||||
} from '$features/course/types';
|
||||
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
/**
|
||||
* API class for course operations
|
||||
*/
|
||||
@@ -16,12 +18,19 @@ export class CoursesApi extends BaseApiWithErrors {
|
||||
enrolledCourses = $state<UserEnrolledCourses>([]);
|
||||
recommendedCourses = $state<RecommendedCourses>([]);
|
||||
|
||||
/** Keeps list UIs in sync after a course is deleted (server data is updated separately on navigation). */
|
||||
removeCourseFromLists(courseId: string) {
|
||||
this.orgCourses = this.orgCourses.filter((c) => c.id !== courseId);
|
||||
this.enrolledCourses = this.enrolledCourses.filter((c) => c.id !== courseId);
|
||||
this.recommendedCourses = this.recommendedCourses.filter((c) => c.id !== courseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches org courses for the current organization
|
||||
* Org ID is automatically added from currentOrg store
|
||||
*/
|
||||
async getOrgCourses(tagSlugs: string[] = []) {
|
||||
const normalizedTagSlugs = Array.from(new Set(tagSlugs.map((tag) => tag.trim()).filter(Boolean)));
|
||||
const normalizedTagSlugs = Array.from(new SvelteSet(tagSlugs.map((tag) => tag.trim()).filter(Boolean)));
|
||||
|
||||
return this.execute<GetOrgCoursesRequest>({
|
||||
requestFn: () =>
|
||||
@@ -32,7 +41,7 @@ export class CoursesApi extends BaseApiWithErrors {
|
||||
tags: normalizedTagSlugs.join(',')
|
||||
}
|
||||
}
|
||||
: {}
|
||||
: { query: {} }
|
||||
),
|
||||
logContext: 'fetching org courses',
|
||||
onSuccess: (response) => {
|
||||
|
||||
@@ -77,7 +77,6 @@ export class LessonApi extends BaseApiWithErrors {
|
||||
|
||||
translations = $state<Record<string, Record<TLocale, string>>>({});
|
||||
note = $state('');
|
||||
private autoSaveTimeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Gets a lesson by ID
|
||||
@@ -841,23 +840,6 @@ export class LessonApi extends BaseApiWithErrors {
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts autosave with debouncing
|
||||
* @param courseId Course ID
|
||||
* @param lessonId Lesson ID
|
||||
*/
|
||||
startAutoSave(courseId: string, lessonId: string) {
|
||||
if (!this.isDirty || this.isSaving) return;
|
||||
if (this.autoSaveTimeoutId) clearTimeout(this.autoSaveTimeoutId);
|
||||
|
||||
this.isSaving = true;
|
||||
this.autoSaveTimeoutId = setTimeout(async () => {
|
||||
await this.save(courseId, lessonId);
|
||||
this.isSaving = false;
|
||||
this.autoSaveTimeoutId = null;
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
export const lessonApi = new LessonApi();
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import EmptyState from './empty-state.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import type { CourseAnalytics } from '$features/course/utils/types';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
|
||||
interface Props {
|
||||
students?: CourseAnalytics['students'];
|
||||
@@ -37,7 +36,10 @@
|
||||
if (!student) return;
|
||||
|
||||
goto(
|
||||
resolve(`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`)
|
||||
resolve(
|
||||
`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`,
|
||||
{}
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -79,11 +81,15 @@
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar.Root class="size-8">
|
||||
<Avatar.Image
|
||||
src={student.profile.avatar_url ? student.profile.avatar_url : '/logo-192.png'}
|
||||
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(student.profile.fullname) || 'S'}</Avatar.Fallback>
|
||||
{#if student.profile.avatar_url}
|
||||
<Avatar.Image
|
||||
src={student.profile.avatar_url}
|
||||
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
|
||||
/>
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-4 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-gray-900 dark:text-white">
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
|
||||
import CardDropdown from './card-dropdown.svelte';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
import CourseTagsOverflow from './course-tags-overflow.svelte';
|
||||
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
|
||||
import type { OrgPublicCourses } from '$features/org/utils/types';
|
||||
|
||||
@@ -57,7 +58,7 @@
|
||||
title: course.title,
|
||||
type: course.type,
|
||||
description: course.description,
|
||||
isPublished: course.isPublished,
|
||||
isPublished: !!course.isPublished,
|
||||
pricingData: {
|
||||
cost: course.cost,
|
||||
discount: course.metadata?.discount || 0,
|
||||
@@ -147,25 +148,14 @@
|
||||
</Item.Header>
|
||||
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-1 min-h-[44px] text-base!">
|
||||
<Item.Title class="line-clamp-2 min-h-14 text-base!">
|
||||
{title}
|
||||
</Item.Title>
|
||||
|
||||
<Item.Description class="min-h-[63px]">{description}</Item.Description>
|
||||
<div class="ui:text-muted-foreground flex min-h-[12px] flex-wrap items-center gap-1">
|
||||
{#if !isLMS && courseTags.length > 0}
|
||||
{#each courseTags as tag (tag.id)}
|
||||
<Item.SubDescription class="border-border inline-flex items-center gap-1.5 rounded-full border px-2">
|
||||
<span
|
||||
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 rounded-full"
|
||||
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span>{tag.name}</span>
|
||||
</Item.SubDescription>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#if !isLMS}
|
||||
<CourseTagsOverflow tags={courseTags} variant="card" />
|
||||
{/if}
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { COURSE_SORT_OPTIONS } from '../utils/constants';
|
||||
import {
|
||||
COURSE_SORT_OPTIONS,
|
||||
CourseSortOrder,
|
||||
DEFAULT_COURSE_SORT,
|
||||
DEFAULT_SORT_ORDER,
|
||||
type CourseSortBy
|
||||
} 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';
|
||||
@@ -27,8 +33,8 @@
|
||||
type SortOption = (typeof COURSE_SORT_OPTIONS)[number];
|
||||
|
||||
interface Props {
|
||||
selectedId?: string;
|
||||
selectedOrder?: 'asc' | 'desc';
|
||||
sortKey?: CourseSortBy;
|
||||
selectedOrder?: CourseSortOrder;
|
||||
selectedTags?: string[];
|
||||
tagGroups?: FilterTagGroup[];
|
||||
sortOptions?: readonly SortOption[];
|
||||
@@ -38,8 +44,8 @@
|
||||
}
|
||||
|
||||
let {
|
||||
selectedId = $bindable('0'),
|
||||
selectedOrder = $bindable('desc' as 'asc' | 'desc'),
|
||||
sortKey = $bindable(DEFAULT_COURSE_SORT),
|
||||
selectedOrder = $bindable(DEFAULT_SORT_ORDER),
|
||||
selectedTags = [],
|
||||
tagGroups = [],
|
||||
sortOptions = COURSE_SORT_OPTIONS,
|
||||
@@ -50,7 +56,9 @@
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
||||
const hasActiveFilters = $derived(selectedId !== '0' || selectedOrder !== 'desc' || selectedTags.length > 0);
|
||||
const hasActiveFilters = $derived(
|
||||
sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0
|
||||
);
|
||||
|
||||
function isTagSelected(tagSlug: string) {
|
||||
return selectedTags.includes(tagSlug);
|
||||
@@ -96,8 +104,8 @@
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selectedId === option.value ? 'secondary' : 'outline'}
|
||||
onclick={() => (selectedId = option.value)}
|
||||
variant={sortKey === option.value ? 'secondary' : 'outline'}
|
||||
onclick={() => (sortKey = option.value)}
|
||||
>
|
||||
{$t(option.label)}
|
||||
</Button>
|
||||
@@ -115,13 +123,13 @@
|
||||
size="sm"
|
||||
value={selectedOrder}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'asc' || value === 'desc') {
|
||||
if (value === CourseSortOrder.Asc || value === CourseSortOrder.Desc) {
|
||||
selectedOrder = value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroup.Item value="asc">{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="desc">{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import * as Tooltip from '@cio/ui/base/tooltip';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tags: Tag[];
|
||||
variant?: 'card' | 'table';
|
||||
}
|
||||
|
||||
let { tags, variant = 'card' }: Props = $props();
|
||||
|
||||
let tagsRowEl: HTMLDivElement | undefined = $state();
|
||||
let tagsMeasureEl: HTMLDivElement | undefined = $state();
|
||||
let overflowBadgeMeasureEl: HTMLSpanElement | undefined = $state();
|
||||
/** -1 = not measured yet (show all tags until layout runs) */
|
||||
let visibleTagCount = $state(-1);
|
||||
|
||||
const tagGapPx = $derived(variant === 'table' ? 8 : 4);
|
||||
const gapClass = $derived(variant === 'table' ? 'gap-2' : 'gap-1');
|
||||
|
||||
function computeVisibleTagCount() {
|
||||
if (tags.length === 0) {
|
||||
visibleTagCount = 0;
|
||||
return;
|
||||
}
|
||||
if (!tagsRowEl || !tagsMeasureEl) {
|
||||
visibleTagCount = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
const widths = Array.from(tagsMeasureEl.children).map((el) => (el as HTMLElement).offsetWidth);
|
||||
const containerWidth = tagsRowEl.clientWidth;
|
||||
const badgeMaxWidth = overflowBadgeMeasureEl?.offsetWidth ?? 40;
|
||||
const n = widths.length;
|
||||
const gap = tagGapPx;
|
||||
|
||||
if (containerWidth <= 0) {
|
||||
visibleTagCount = n;
|
||||
return;
|
||||
}
|
||||
|
||||
let best = 0;
|
||||
for (let k = 0; k <= n; k++) {
|
||||
const rest = n - k;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < k; i++) {
|
||||
sum += widths[i] ?? 0;
|
||||
if (i > 0) sum += gap;
|
||||
}
|
||||
const needBadge = rest > 0;
|
||||
const total = sum + (needBadge ? gap + badgeMaxWidth : 0);
|
||||
if (total <= containerWidth) {
|
||||
best = k;
|
||||
}
|
||||
}
|
||||
visibleTagCount = best;
|
||||
}
|
||||
|
||||
const displayTagCount = $derived(visibleTagCount < 0 ? tags.length : visibleTagCount);
|
||||
|
||||
$effect(() => {
|
||||
void tags;
|
||||
void tagsRowEl;
|
||||
void tagsMeasureEl;
|
||||
void overflowBadgeMeasureEl;
|
||||
void variant;
|
||||
|
||||
const schedule = () => {
|
||||
requestAnimationFrame(() => {
|
||||
computeVisibleTagCount();
|
||||
});
|
||||
};
|
||||
|
||||
tick().then(schedule);
|
||||
|
||||
if (!tagsRowEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(schedule);
|
||||
ro.observe(tagsRowEl);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
|
||||
const hiddenTags = $derived(tags.slice(displayTagCount));
|
||||
const hiddenTagsLabel = $derived(hiddenTags.map((t) => t.name).join(', '));
|
||||
</script>
|
||||
|
||||
{#if tags.length > 0}
|
||||
<div class="relative min-h-[12px] w-full min-w-0">
|
||||
<div
|
||||
class="pointer-events-none invisible absolute top-0 left-0 -z-10 flex {gapClass}"
|
||||
bind:this={tagsMeasureEl}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#each tags as tag (tag.id)}
|
||||
{#if variant === 'card'}
|
||||
<Item.SubDescription class="border-border inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2">
|
||||
<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>
|
||||
<span>{tag.name}</span>
|
||||
</Item.SubDescription>
|
||||
{:else}
|
||||
<span class="ui:text-muted-foreground text-2xs inline-flex shrink-0 items-center gap-1.5">
|
||||
<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>
|
||||
<span>{tag.name}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<span
|
||||
bind:this={overflowBadgeMeasureEl}
|
||||
class="border-border ui:text-muted-foreground pointer-events-none invisible absolute top-0 left-0 -z-10 inline-flex shrink-0 items-center rounded-full border {variant ===
|
||||
'table'
|
||||
? 'text-2xs px-1.5 py-0.5'
|
||||
: 'px-2 text-xs'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
+{tags.length}
|
||||
</span>
|
||||
<div
|
||||
bind:this={tagsRowEl}
|
||||
class="ui:text-muted-foreground flex min-w-0 flex-nowrap items-center overflow-hidden {gapClass} {variant ===
|
||||
'table'
|
||||
? 'text-2xs'
|
||||
: ''}"
|
||||
>
|
||||
{#each tags.slice(0, displayTagCount) as tag (tag.id)}
|
||||
{#if variant === 'card'}
|
||||
<Item.SubDescription class="border-border inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2">
|
||||
<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>
|
||||
<span>{tag.name}</span>
|
||||
</Item.SubDescription>
|
||||
{:else}
|
||||
<span class="inline-flex shrink-0 items-center gap-1.5">
|
||||
<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>
|
||||
<span>{tag.name}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if hiddenTags.length > 0}
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
{#if variant === 'card'}
|
||||
<Item.SubDescription
|
||||
{...props}
|
||||
class="border-border inline-flex shrink-0 cursor-default items-center gap-1.5 rounded-full border px-2"
|
||||
aria-label={$t('courses.course_card.tags_overflow_aria', {
|
||||
count: hiddenTags.length
|
||||
})}
|
||||
>
|
||||
+{hiddenTags.length}
|
||||
</Item.SubDescription>
|
||||
{:else}
|
||||
<span
|
||||
{...props}
|
||||
class="border-border ui:text-muted-foreground text-2xs inline-flex shrink-0 cursor-default items-center rounded-full border px-1.5 py-0.5"
|
||||
aria-label={$t('courses.course_card.tags_overflow_aria', {
|
||||
count: hiddenTags.length
|
||||
})}
|
||||
>
|
||||
+{hiddenTags.length}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" sideOffset={4} class="max-w-xs">
|
||||
{hiddenTagsLabel}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionContractKey } from '@cio/question-types';
|
||||
import type { AnswerData } from '@cio/question-types';
|
||||
import type { AnswerData, ExerciseQuestionModel } from '@cio/question-types';
|
||||
import type { Question } from '$features/course/types';
|
||||
import { Grade, ReasonBox } from '$features/ui/question';
|
||||
import { toExerciseQuestionModel } from './question-type-utils';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
@@ -8,15 +9,15 @@
|
||||
import type { QuestionnaireMetaData } from './store';
|
||||
|
||||
interface Props {
|
||||
questions?: any;
|
||||
questions?: Question[];
|
||||
questionnaireMetaData?: Partial<QuestionnaireMetaData>;
|
||||
/** Per-question scores (e.g. from `questionnaireMetaData.grades` or grading modal `questionAnswerByPoint`). */
|
||||
grades?: Record<string, number>;
|
||||
onSubmit?: any;
|
||||
onPrevious?: any;
|
||||
disableGrading?: boolean;
|
||||
isGradeWithAI?: boolean;
|
||||
isLoading?: boolean;
|
||||
reasons?: any;
|
||||
/** Optional AI grading copy keyed by question id. */
|
||||
reasons?: Record<string, string>;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -33,12 +34,12 @@
|
||||
isGradeWithAI = false;
|
||||
}
|
||||
|
||||
function rejectGradeSuggestion(questionId) {
|
||||
function rejectGradeSuggestion(questionId: Question['id']) {
|
||||
isGradeWithAI = false;
|
||||
grades[questionId] = 0;
|
||||
grades[String(questionId)] = 0;
|
||||
}
|
||||
|
||||
function getAnswerForQuestion(questionModel, fallbackIndex = 0) {
|
||||
function getAnswerForQuestion(questionModel: ExerciseQuestionModel, fallbackIndex = 0): AnswerData | undefined {
|
||||
const questionKey = getExerciseQuestionContractKey(questionModel, fallbackIndex);
|
||||
return _questionnaireMetaData?.answers?.[questionKey];
|
||||
}
|
||||
@@ -59,11 +60,11 @@
|
||||
|
||||
{#if disableGrading && !Object.keys(grades || {}).length}
|
||||
<ExerciseQuestion.QuestionList
|
||||
contract={{ mode: 'view', questions: questionModels, answersByKey, labels: questionLabels, disabled: true }}
|
||||
contract={{ mode: 'review', questions: questionModels, answersByKey, labels: questionLabels, disabled: true }}
|
||||
itemClass="mb-4"
|
||||
/>
|
||||
{:else if disableGrading && Object.keys(grades || {}).length}
|
||||
{#each questions as currentQuestion, index}
|
||||
{#each questions as currentQuestion, index (currentQuestion.id)}
|
||||
{@const questionModel = questionModels[index]}
|
||||
<div class="mb-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -73,7 +74,7 @@
|
||||
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'view',
|
||||
mode: 'review',
|
||||
question: questionModel,
|
||||
answer: getAnswerForQuestion(questionModel, index),
|
||||
labels: questionLabels,
|
||||
@@ -83,7 +84,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each questions as currentQuestion, index}
|
||||
{#each questions as currentQuestion, index (currentQuestion.id)}
|
||||
{@const questionModel = questionModels[index]}
|
||||
<div class="mb-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -93,7 +94,7 @@
|
||||
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'view',
|
||||
mode: 'review',
|
||||
question: questionModel,
|
||||
answer: getAnswerForQuestion(questionModel, index),
|
||||
labels: questionLabels,
|
||||
|
||||
@@ -18,6 +18,9 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'common.correct_badge': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.common.correct_badge'
|
||||
),
|
||||
'common.incorrect_badge': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.common.incorrect_badge'
|
||||
),
|
||||
'common.not_set': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.common.not_set'),
|
||||
'navigation.previous': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.previous'),
|
||||
'navigation.next': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.next'),
|
||||
@@ -131,6 +134,12 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'short_answer.preview.helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.preview.helper'
|
||||
),
|
||||
'short_answer.review.your_answer_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.review.your_answer_label'
|
||||
),
|
||||
'short_answer.review.accepted_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.review.accepted_label'
|
||||
),
|
||||
'numeric.edit.correct_value_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_label'
|
||||
),
|
||||
@@ -158,6 +167,15 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'numeric.preview.tolerance_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.preview.tolerance_label'
|
||||
),
|
||||
'numeric.review.your_answer_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.your_answer_label'
|
||||
),
|
||||
'numeric.review.in_range': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.in_range'
|
||||
),
|
||||
'numeric.review.out_of_range': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.out_of_range'
|
||||
),
|
||||
'fill_blank.edit.accepted_answers_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.edit.accepted_answers_placeholder'
|
||||
),
|
||||
@@ -167,6 +185,9 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'fill_blank.preview.accepted_answers_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.preview.accepted_answers_label'
|
||||
),
|
||||
'fill_blank.review.your_answer_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.review.your_answer_label'
|
||||
),
|
||||
'file_upload.edit.accepted_types_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder'
|
||||
),
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@
|
||||
<div class="pb-4">
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'view',
|
||||
mode: 'review',
|
||||
question: { ...toExerciseQuestionModel(q), title: `${i + 1}. ${q.title}` },
|
||||
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
|
||||
labels: questionLabels,
|
||||
|
||||
@@ -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 CourseTagsOverflow } from './course-tags-overflow.svelte';
|
||||
export { default as CourseListLoader } from './list-loader.svelte';
|
||||
export { default as NewCourseModal } from './new-course-modal.svelte';
|
||||
export { default as QuestionContainer } from './question-container.svelte';
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { untrack } from 'svelte';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { DeleteModal } from '$features/ui';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
@@ -99,11 +99,12 @@
|
||||
<div>
|
||||
<div class="flex h-full items-start gap-3">
|
||||
<Avatar.Root class="mt-2 size-6!">
|
||||
<Avatar.Image
|
||||
src={$profile.avatarUrl ? $profile.avatarUrl : '/logo-192.png'}
|
||||
alt={$profile.fullname ? $profile.fullname : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName($profile.fullname) || 'U'}</Avatar.Fallback>
|
||||
{#if $profile.avatarUrl}
|
||||
<Avatar.Image src={$profile.avatarUrl} alt={$profile.fullname ? $profile.fullname : 'User'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="h-full w-full">
|
||||
<TextareaField
|
||||
@@ -125,11 +126,12 @@
|
||||
{#each comments as commentItem (commentItem.id)}
|
||||
<div class="mt-2 flex items-start gap-3 pb-2">
|
||||
<Avatar.Root class="mt-2 size-6!">
|
||||
<Avatar.Image
|
||||
src={commentItem.avatar ? commentItem.avatar : '/logo-192.png'}
|
||||
alt={commentItem.name ? commentItem.name : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(commentItem.name) || 'U'}</Avatar.Fallback>
|
||||
{#if commentItem.avatar}
|
||||
<Avatar.Image src={commentItem.avatar} alt={commentItem.name ? commentItem.name : 'User'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<div class="w-full rounded-md border px-3 py-2 dark:border-neutral-700">
|
||||
|
||||
+54
-22
@@ -5,6 +5,7 @@
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { CircleCheckIcon } from '$features/ui/icons';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Tooltip from '@cio/ui/base/tooltip';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi, lessonApi } from '$features/course/api';
|
||||
@@ -115,8 +116,25 @@
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (INTERACTIVE_TAGS.has(target.tagName) || target.isContentEditable) return;
|
||||
|
||||
if (event.key === 'ArrowLeft' && !isPrevDisabled) {
|
||||
event.preventDefault();
|
||||
goToContent(prevNextContent.prev);
|
||||
} else if (event.key === 'ArrowRight' && !isNextDisabled) {
|
||||
event.preventDefault();
|
||||
goToContent(prevNextContent.next);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if showMarkComplete && lessonId}
|
||||
<Button
|
||||
@@ -131,27 +149,41 @@
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
onclick={() => goToContent(prevNextContent.prev)}
|
||||
disabled={isPrevDisabled}
|
||||
aria-label={$t('course.navItem.lessons.prev')}
|
||||
title={$t('course.navItem.lessons.prev')}
|
||||
>
|
||||
<ChevronLeftIcon size={14} />
|
||||
</Button>
|
||||
<Tooltip.Provider>
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
onclick={() => goToContent(prevNextContent.prev)}
|
||||
disabled={isPrevDisabled}
|
||||
aria-label={$t('course.navItem.lessons.prev')}
|
||||
>
|
||||
<ChevronLeftIcon size={14} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom" sideOffset={4}>
|
||||
{$t('course.navItem.lessons.prev_shortcut')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
onclick={() => goToContent(prevNextContent.next)}
|
||||
disabled={isNextDisabled}
|
||||
aria-label={$t('course.navItem.lessons.next')}
|
||||
title={$t('course.navItem.lessons.next')}
|
||||
>
|
||||
<ChevronRightIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
onclick={() => goToContent(prevNextContent.next)}
|
||||
disabled={isNextDisabled}
|
||||
aria-label={$t('course.navItem.lessons.next')}
|
||||
>
|
||||
<ChevronRightIcon size={14} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom" sideOffset={4}>
|
||||
{$t('course.navItem.lessons.next_shortcut')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import AIButton from '$features/course/components/lesson/ai-button.svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { saveDraft } from '$features/course/utils/lesson-draft';
|
||||
|
||||
interface Props {
|
||||
mode?: (typeof MODES)[keyof typeof MODES];
|
||||
@@ -33,12 +34,7 @@
|
||||
}
|
||||
lessonApi.translations[lessonId][lessonApi.currentLocale] = `${content}`;
|
||||
|
||||
try {
|
||||
localStorage.setItem(`lesson-${lessonId}-${lessonApi.currentLocale}`, `${content}`);
|
||||
} catch (error) {
|
||||
console.error('Error saving lesson note to localStorage', error);
|
||||
}
|
||||
|
||||
saveDraft(lessonId, lessonApi.currentLocale, `${content}`);
|
||||
lessonApi.isDirty = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
@@ -7,6 +8,7 @@
|
||||
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
import CourseTagsOverflow from './course-tags-overflow.svelte';
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
@@ -60,29 +62,18 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Table.Row class="cursor-pointer" onclick={() => goto(`/courses/${id}`)}>
|
||||
<Table.Row class="cursor-pointer" onclick={() => goto(resolve(`/courses/[id]`, { id }))}>
|
||||
<Table.Cell class="truncate"><p class="font-semibold">{title}</p></Table.Cell>
|
||||
<Table.Cell class="truncate">
|
||||
<p>{description}</p>
|
||||
</Table.Cell>
|
||||
{#if !isMobileStore.current}
|
||||
<Table.Cell class="truncate">{type}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Table.Cell class="w-3/12 min-w-0">
|
||||
{#if tags.length === 0}
|
||||
<span class="ui:text-muted-foreground text-2xs">-</span>
|
||||
{:else}
|
||||
<div class="ui:text-muted-foreground text-2xs flex max-w-[220px] flex-wrap items-center gap-2">
|
||||
{#each tags as tag (tag.id)}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 rounded-full"
|
||||
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span>{tag.name}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<CourseTagsOverflow {tags} variant="table" />
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{totalLessons}</Table.Cell>
|
||||
|
||||
+24
-38
@@ -3,10 +3,10 @@
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { inviteSettingsStore, studentInviteLinkStore } from './store';
|
||||
import { getInvitePayload } from './invite-utils';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { peopleApi } from '$features/course/api';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
const INVITE_MODAL = 'course.navItem.people.invite_modal';
|
||||
|
||||
@@ -19,72 +19,58 @@
|
||||
|
||||
let recipientCsv = $state('');
|
||||
let sendInviteEmails = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
async function createEmailInvites() {
|
||||
async function inviteNewStudents() {
|
||||
if (!courseId) return;
|
||||
if (!recipientCsv.trim()) {
|
||||
snackbar.error(`${INVITE_MODAL}.snackbar_add_one_recipient`);
|
||||
snackbar.error('audience.import.snackbar_no_emails');
|
||||
return;
|
||||
}
|
||||
|
||||
studentInviteLinkStore.update((s) => ({ ...s, isCreating: true }));
|
||||
isSubmitting = true;
|
||||
try {
|
||||
const settings = $inviteSettingsStore;
|
||||
const payload = getInvitePayload(settings);
|
||||
const response = await peopleApi.createStudentInvite(courseId, {
|
||||
...payload,
|
||||
const response = await orgApi.importAudienceMembers({
|
||||
recipientCsv,
|
||||
courseIds: [courseId],
|
||||
sendEmail: sendInviteEmails
|
||||
});
|
||||
|
||||
const createdCount = response?.invites?.length || 0;
|
||||
const sent = response?.delivery?.sent || 0;
|
||||
const failed = response?.delivery?.failed || 0;
|
||||
const duplicates = response?.duplicatesSkipped?.length || 0;
|
||||
|
||||
snackbar.success(
|
||||
$t(`${INVITE_MODAL}.snackbar_email_invites_success`, {
|
||||
count: createdCount,
|
||||
sent,
|
||||
failed,
|
||||
duplicates
|
||||
})
|
||||
);
|
||||
|
||||
recipientCsv = '';
|
||||
onInviteCreated?.();
|
||||
if (response) {
|
||||
recipientCsv = '';
|
||||
await courseApi.refreshCourse(courseId, $profile.id);
|
||||
await onInviteCreated?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create email invites', error);
|
||||
snackbar.error(`${INVITE_MODAL}.snackbar_failed_create_email_invites`);
|
||||
console.error('Failed to invite new students into organization', error);
|
||||
snackbar.error('audience.import.snackbar_failed');
|
||||
} finally {
|
||||
studentInviteLinkStore.update((s) => ({ ...s, isCreating: false }));
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
const isCreating = $derived($studentInviteLinkStore.isCreating);
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border p-4">
|
||||
<p class="mb-2 text-base font-semibold">{$t(`${INVITE_MODAL}.direct_email_bulk`)}</p>
|
||||
<p class="mb-2 text-base font-semibold">{$t(`${INVITE_MODAL}.invite_new_students_title`)}</p>
|
||||
<p class="ui:text-muted-foreground mb-2 text-sm">
|
||||
{$t(`${INVITE_MODAL}.direct_email_description`)}
|
||||
{$t(`${INVITE_MODAL}.invite_new_students_description`)}
|
||||
</p>
|
||||
<TextareaField
|
||||
label={$t(`${INVITE_MODAL}.recipient_emails`)}
|
||||
label={$t('audience.import.emails_label')}
|
||||
bind:value={recipientCsv}
|
||||
rows={4}
|
||||
className="w-full"
|
||||
placeholder={$t(`${INVITE_MODAL}.recipient_emails_placeholder`)}
|
||||
placeholder={$t('audience.import.emails_placeholder')}
|
||||
/>
|
||||
<CheckboxField
|
||||
className="mt-2"
|
||||
name="send-invite-emails"
|
||||
label={$t(`${INVITE_MODAL}.send_invite_emails`)}
|
||||
label={$t('audience.import.send_email')}
|
||||
bind:checked={sendInviteEmails}
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<Button variant="secondary" onclick={createEmailInvites} loading={isCreating}>
|
||||
{$t(`${INVITE_MODAL}.create_email_invites`)}
|
||||
<Button variant="secondary" onclick={inviteNewStudents} loading={isSubmitting}>
|
||||
{$t(`${INVITE_MODAL}.invite_new_students`)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { OrgStudent } from './types';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
students: OrgStudent[];
|
||||
isLoading?: boolean;
|
||||
onAssigned?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
let { courseId, students, isLoading = false, onAssigned }: Props = $props();
|
||||
|
||||
let searchValue = $state('');
|
||||
const selectedProfileIds = new SvelteSet<string>();
|
||||
let sendEmail = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
const filteredStudents = $derived.by(() => {
|
||||
const query = searchValue.trim().toLowerCase();
|
||||
const sortedStudents = [...students].sort((a, b) =>
|
||||
(a.name || a.email || '').localeCompare(b.name || b.email || '', undefined, { sensitivity: 'base' })
|
||||
);
|
||||
|
||||
if (!query) {
|
||||
return sortedStudents;
|
||||
}
|
||||
|
||||
return sortedStudents.filter((student) => {
|
||||
const name = student.name?.toLowerCase() || '';
|
||||
const email = student.email?.toLowerCase() || '';
|
||||
return name.includes(query) || email.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
const selectedCount = $derived(selectedProfileIds.size);
|
||||
|
||||
function toggleStudent(profileId: string) {
|
||||
if (selectedProfileIds.has(profileId)) {
|
||||
selectedProfileIds.delete(profileId);
|
||||
} else {
|
||||
selectedProfileIds.add(profileId);
|
||||
}
|
||||
}
|
||||
|
||||
async function assignStudents() {
|
||||
if (!courseId) return;
|
||||
if (selectedProfileIds.size === 0) {
|
||||
snackbar.error('course.navItem.people.invite_modal.select_at_least_one_student');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const result = await orgApi.assignAudienceToCourses({
|
||||
profileIds: [...selectedProfileIds],
|
||||
courseIds: [courseId],
|
||||
sendEmail
|
||||
});
|
||||
|
||||
if (result) {
|
||||
await courseApi.refreshCourse(courseId, $profile.id);
|
||||
selectedProfileIds.clear();
|
||||
searchValue = '';
|
||||
await onAssigned?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to assign existing students to course', error);
|
||||
snackbar.error('audience.assign.snackbar_failed');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border p-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-base font-semibold">{$t('course.navItem.people.invite_modal.existing_students_title')}</p>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.people.invite_modal.existing_students_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<Search placeholder={$t('course.navItem.people.invite_modal.existing_students_search')} bind:value={searchValue} />
|
||||
|
||||
{#if selectedCount > 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('audience.selected_count', { count: selectedCount })}</p>
|
||||
{/if}
|
||||
|
||||
<div class="max-h-72 space-y-2 overflow-y-auto rounded-md border p-3">
|
||||
{#if isLoading}
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.people.invite_modal.loading_existing_students')}
|
||||
</p>
|
||||
{:else if students.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('course.navItem.people.invite_modal.no_existing_students')}</p>
|
||||
{:else if filteredStudents.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.people.invite_modal.no_matching_existing_students')}
|
||||
</p>
|
||||
{:else}
|
||||
{#each filteredStudents as student (student.profileId)}
|
||||
<div
|
||||
class="ui:hover:bg-muted/40 rounded-md px-2 py-1"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => student.profileId && toggleStudent(student.profileId)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (student.profileId) toggleStudent(student.profileId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckboxField
|
||||
name={`existing-student-${student.profileId}`}
|
||||
label={student.name || student.email}
|
||||
checked={student.profileId ? selectedProfileIds.has(student.profileId) : false}
|
||||
>
|
||||
<span class="ui:text-muted-foreground text-sm">{student.email}</span>
|
||||
</CheckboxField>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<CheckboxField
|
||||
name="notify-existing-students"
|
||||
label={$t('course.navItem.people.invite_modal.notify_existing_students')}
|
||||
bind:checked={sendEmail}
|
||||
/>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button variant="secondary" onclick={assignStudents} loading={isSubmitting} disabled={isLoading || isSubmitting}>
|
||||
{$t('course.navItem.people.invite_modal.grant_access')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,19 +13,11 @@
|
||||
import { peopleApi } from '$features/course/api';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import {
|
||||
DEFAULT_INVITE_SETTINGS_STATE,
|
||||
DEFAULT_STUDENT_INVITE_LINK_STATE,
|
||||
inviteSettingsStore,
|
||||
studentInviteLinkStore
|
||||
} from './store';
|
||||
import type { Tutor } from './types';
|
||||
import type { OrgStudent, Tutor } from './types';
|
||||
|
||||
import TutorSelectSection from './tutor-select-section.svelte';
|
||||
import InviteSettingsSection from './invite-settings-section.svelte';
|
||||
import ExistingStudentsSection from './existing-students-section.svelte';
|
||||
import BulkEmailSection from './bulk-email-section.svelte';
|
||||
import InviteListSection from './invite-list-section.svelte';
|
||||
import type { InviteListItem } from './types';
|
||||
|
||||
let tutors = $state<Tutor[]>([]);
|
||||
let selectedIds = $state<string[]>([]);
|
||||
@@ -36,21 +28,9 @@
|
||||
const selectedTutors = $derived(tutors.filter((t) => selectedIds.includes(t.id.toString())));
|
||||
const INVITE_MODAL = 'course.navItem.people.invite_modal';
|
||||
|
||||
let existingInvites = $state<InviteListItem[]>([]);
|
||||
let isLoadingInvites = $state(false);
|
||||
let activeTab = $state<'tutors' | 'students'>('tutors');
|
||||
let activeStudentTab = $state<'email' | 'history'>('email');
|
||||
|
||||
async function loadInvites() {
|
||||
if (!courseId) return;
|
||||
isLoadingInvites = true;
|
||||
try {
|
||||
const invites = await peopleApi.listStudentInvites(courseId);
|
||||
existingInvites = (invites || []) as InviteListItem[];
|
||||
} finally {
|
||||
isLoadingInvites = false;
|
||||
}
|
||||
}
|
||||
let isLoadingStudents = $state(false);
|
||||
let activeTab = $state<'tutors' | 'students'>('students');
|
||||
const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, courseApi.group.people));
|
||||
|
||||
function getTutors(team: OrgTeamMember[]) {
|
||||
const existingTutors = courseApi?.group?.tutors || [];
|
||||
@@ -65,6 +45,22 @@
|
||||
}));
|
||||
}
|
||||
|
||||
function getAvailableStudents(students: OrgStudent[], courseMembers: typeof courseApi.group.people) {
|
||||
const enrolledStudentIds = new Set(
|
||||
courseMembers
|
||||
.filter((member) => Number(member.roleId) === ROLE.STUDENT)
|
||||
.map((member) => member.profileId)
|
||||
.filter((profileId): profileId is string => Boolean(profileId))
|
||||
);
|
||||
|
||||
return students.filter((student) => {
|
||||
if (!student.profileId) {
|
||||
return false;
|
||||
}
|
||||
return !enrolledStudentIds.has(student.profileId);
|
||||
});
|
||||
}
|
||||
|
||||
function setTutors(orgId: string | undefined) {
|
||||
if (!orgId) return;
|
||||
untrack(async () => {
|
||||
@@ -77,6 +73,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
function loadStudents(orgId: string | undefined) {
|
||||
if (!orgId) return;
|
||||
untrack(async () => {
|
||||
isLoadingStudents = true;
|
||||
try {
|
||||
await orgApi.getOrgAudience(orgId);
|
||||
} finally {
|
||||
isLoadingStudents = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!selectedTutors.length) {
|
||||
goto(resolve(page.url.pathname, {}));
|
||||
@@ -105,11 +113,8 @@
|
||||
$effect(() => {
|
||||
if (!isOpen || !courseId) return;
|
||||
untrack(() => {
|
||||
activeTab = 'tutors';
|
||||
activeStudentTab = 'email';
|
||||
inviteSettingsStore.set({ ...DEFAULT_INVITE_SETTINGS_STATE });
|
||||
studentInviteLinkStore.set({ ...DEFAULT_STUDENT_INVITE_LINK_STATE });
|
||||
void loadInvites();
|
||||
activeTab = 'students';
|
||||
void loadStudents($currentOrg.id);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -149,35 +154,8 @@
|
||||
|
||||
<UnderlineTabs.Content value="students">
|
||||
<div class="mt-6 space-y-6">
|
||||
<InviteSettingsSection />
|
||||
|
||||
<UnderlineTabs.Root bind:value={activeStudentTab}>
|
||||
<UnderlineTabs.List class="flex flex-wrap">
|
||||
<UnderlineTabs.Trigger value="email">
|
||||
{$t(`${INVITE_MODAL}.direct_email_bulk`)}
|
||||
</UnderlineTabs.Trigger>
|
||||
<UnderlineTabs.Trigger value="history">
|
||||
{$t(`${INVITE_MODAL}.existing_invites`)}
|
||||
</UnderlineTabs.Trigger>
|
||||
</UnderlineTabs.List>
|
||||
|
||||
<UnderlineTabs.Content value="email">
|
||||
<div class="mt-6">
|
||||
<BulkEmailSection {courseId} onInviteCreated={loadInvites} />
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
|
||||
<UnderlineTabs.Content value="history">
|
||||
<div class="mt-6">
|
||||
<InviteListSection
|
||||
{courseId}
|
||||
invites={existingInvites}
|
||||
isLoading={isLoadingInvites}
|
||||
onRefresh={loadInvites}
|
||||
/>
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
<ExistingStudentsSection {courseId} students={availableStudents} isLoading={isLoadingStudents} />
|
||||
<BulkEmailSection {courseId} />
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { StudentInviteListItem, StudentInviteAuditItem } from '../../utils/types';
|
||||
import type { OrganizationTeamMembers } from '$features/org/utils/types';
|
||||
import type { OrganizationAudience } from '$features/org/utils/types';
|
||||
import type { TCourseInvitePreset } from '@cio/utils/validation/course/invite';
|
||||
|
||||
export interface Profile {
|
||||
@@ -30,6 +31,9 @@ export type Tutor = Pick<OrgTeamMemberItem, 'id' | 'profileId' | 'email'> & {
|
||||
text: OrgTeamMemberItem['fullname'];
|
||||
};
|
||||
|
||||
type OrgAudienceMemberItem = OrganizationAudience[number];
|
||||
export type OrgStudent = OrgAudienceMemberItem;
|
||||
|
||||
/** Re-export API-driven invite types from course utils */
|
||||
export type InviteListItem = StudentInviteListItem;
|
||||
export type InviteAuditItem = StudentInviteAuditItem;
|
||||
|
||||
+2
-6
@@ -4,8 +4,7 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import { BackButton } from '@cio/ui';
|
||||
|
||||
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
|
||||
import {
|
||||
@@ -231,10 +230,7 @@
|
||||
</script>
|
||||
|
||||
<Sidebar.Group class="pt-0!">
|
||||
<Button variant="link" class="h-fit! justify-start! px-2! py-2!" href={resolve(coursesListPath, {})}>
|
||||
<ArrowLeftIcon class="custom" />
|
||||
<span class="text-xs">{$t('org_navigation.courses')}</span>
|
||||
</Button>
|
||||
<BackButton href={resolve(coursesListPath, {})} label={$t('org_navigation.courses')} class="px-2! py-2!" />
|
||||
|
||||
<Sidebar.Menu>
|
||||
{#each navItems as item (item.id)}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { DeleteModal } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { COURSE_SORT_OPTIONS, DEFAULT_COURSE_SORT, type CourseSortBy } from '../utils/constants';
|
||||
import { deleteCourseModal, deleteCourseModalInitialState, courseMetaDeta } from '../utils/store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
|
||||
@@ -33,7 +34,7 @@
|
||||
isExplore?: boolean;
|
||||
isLMS?: boolean;
|
||||
searchValue?: string;
|
||||
selectedId?: string;
|
||||
sortKey?: CourseSortBy;
|
||||
isLoading?: boolean;
|
||||
showSortSelect?: boolean;
|
||||
filterControls?: Snippet;
|
||||
@@ -46,22 +47,21 @@
|
||||
isExplore = false,
|
||||
isLMS = false,
|
||||
searchValue = $bindable(''),
|
||||
selectedId = $bindable('0'),
|
||||
sortKey = $bindable(DEFAULT_COURSE_SORT),
|
||||
isLoading = false,
|
||||
showSortSelect = true,
|
||||
filterControls
|
||||
}: Props = $props();
|
||||
|
||||
const filterOptions = $derived([
|
||||
{ value: '0', label: $t('courses.course_filter.date_created') },
|
||||
{ value: '1', label: $t('courses.course_filter.published') },
|
||||
{ value: '2', label: $t('courses.course_filter.lessons') }
|
||||
]);
|
||||
|
||||
const selectedLabel = $derived(
|
||||
filterOptions.find((opt) => opt.value === selectedId)?.label || filterOptions[0].label
|
||||
const filterOptions = $derived(
|
||||
COURSE_SORT_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: $t(option.label)
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedLabel = $derived(filterOptions.find((opt) => opt.value === sortKey)?.label || filterOptions[0].label);
|
||||
|
||||
const setViewPreference = (preference: 'grid' | 'list') => {
|
||||
$courseMetaDeta.view = preference;
|
||||
if (browser) {
|
||||
@@ -94,12 +94,12 @@
|
||||
isLoading={$deleteCourseModal.isDeleting}
|
||||
/>
|
||||
|
||||
<Page.BodyHeader align="right">
|
||||
<Page.BodyHeader align="right" class="p-0!">
|
||||
<Search placeholder={$t('courses.search_placeholder')} bind:value={searchValue} />
|
||||
{#if showSortSelect}
|
||||
<Select.Root type="single" bind:value={selectedId}>
|
||||
<Select.Root type="single" bind:value={sortKey}>
|
||||
<Select.Trigger class="min-w-[150px]">
|
||||
<p>{selectedId ? selectedLabel : filterOptions[0].label}</p>
|
||||
<p>{sortKey ? selectedLabel : filterOptions[0].label}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each filterOptions as option (option.label)}
|
||||
@@ -142,9 +142,9 @@
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[20%]">{$t('courses.course_card.list_view.title')}</Table.Head>
|
||||
<Table.Head class="w-[24%]">{$t('courses.course_card.list_view.description')}</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-[14%]">{$t('courses.course_card.list_view.tags')}</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>
|
||||
<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>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { RefreshPageData } from '$features/ui';
|
||||
import { RefreshPageData, UnsavedChanges } from '$features/ui';
|
||||
import LessonVersionHistory from '$features/course/components/lesson/lesson-version-history.svelte';
|
||||
import { courseApi, lessonApi } from '$features/course/api';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
@@ -50,6 +50,7 @@
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import { orderedTabs, tabs as materialTabs } from '$features/course/components/lesson/constants';
|
||||
import { getViewModeComponents } from '$features/course/components/lesson/utils';
|
||||
import { loadDraft, clearDraft } from '$features/course/utils/lesson-draft';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
@@ -61,9 +62,14 @@
|
||||
const mode = $derived($page.url.searchParams.get('mode') === 'edit' ? MODES.edit : MODES.view);
|
||||
|
||||
let prevModeParam = $state<string | null>(null);
|
||||
let isSaving = $state(false);
|
||||
let isDeletingLesson = $state(false);
|
||||
let isVersionDrawerOpen = $state(false);
|
||||
// eslint-disable-next-line svelte/prefer-writable-derived -- must be writable: cleared before intentional navigations and bound to UnsavedChanges
|
||||
let hasUnsavedChanges = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
hasUnsavedChanges = lessonApi.isDirty && mode === MODES.edit;
|
||||
});
|
||||
|
||||
const lessonTitle = $derived(lessonApi.lesson?.title || 'Lesson');
|
||||
const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false);
|
||||
@@ -80,6 +86,12 @@
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
if (mode === MODES.edit && lessonApi.isDirty) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = undefined;
|
||||
saveLesson();
|
||||
}
|
||||
hasUnsavedChanges = false;
|
||||
setModeQueryParam(mode === MODES.edit ? MODES.view : MODES.edit);
|
||||
}
|
||||
|
||||
@@ -139,7 +151,7 @@
|
||||
|
||||
const isMaterialsEmpty = $derived(tabs.every((tab) => tab.badgeValue === 0));
|
||||
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
let isLoading = writable(false);
|
||||
|
||||
@@ -184,9 +196,6 @@
|
||||
async function saveLesson() {
|
||||
if (!lessonApi.lesson) return false;
|
||||
|
||||
// Prevent autosave loops: we only set this back to `true` when the user edits again.
|
||||
lessonApi.isDirty = false;
|
||||
|
||||
const [isLessonUpdated] = await Promise.all([
|
||||
lessonApi.update(courseApi.course?.id || '', lessonId, {
|
||||
title: lessonApi.lesson.title || undefined,
|
||||
@@ -200,6 +209,8 @@
|
||||
|
||||
if (isLessonUpdated) {
|
||||
patchLessonListItemLocally();
|
||||
clearDraft(lessonId, lessonApi.currentLocale);
|
||||
lessonApi.isDirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +230,7 @@
|
||||
await lessonApi.delete(courseId, lessonId);
|
||||
|
||||
if (lessonApi.success) {
|
||||
hasUnsavedChanges = false;
|
||||
await goto(resolve(`/courses/${courseId}/lessons`, {}));
|
||||
}
|
||||
|
||||
@@ -228,6 +240,8 @@
|
||||
function handleSave(prevMode: string) {
|
||||
untrack(() => {
|
||||
if (prevMode === MODES.edit) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = undefined;
|
||||
saveLesson();
|
||||
}
|
||||
});
|
||||
@@ -240,11 +254,8 @@
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
untrack(() => {
|
||||
isSaving = true;
|
||||
timeoutId = setTimeout(async () => {
|
||||
await saveLesson();
|
||||
|
||||
isSaving = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
@@ -255,13 +266,41 @@
|
||||
autoSave();
|
||||
};
|
||||
|
||||
// Check for an unsaved localStorage draft when the lesson first loads.
|
||||
let draftCheckedForLesson = '';
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
if (!lessonApi.lesson || !lessonId) return;
|
||||
if (draftCheckedForLesson === lessonId) return;
|
||||
draftCheckedForLesson = lessonId;
|
||||
|
||||
const draft = loadDraft(lessonId, lessonApi.currentLocale);
|
||||
if (!draft) return;
|
||||
|
||||
const serverContent = lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || '';
|
||||
if (draft.content === serverContent) {
|
||||
clearDraft(lessonId, lessonApi.currentLocale);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldRestore = window.confirm(t.get('course.navItem.lessons.draft_recovery'));
|
||||
if (shouldRestore) {
|
||||
if (!lessonApi.translations[lessonId]) {
|
||||
lessonApi.translations[lessonId] = {} as Record<TLocale, string>;
|
||||
}
|
||||
lessonApi.translations[lessonId][lessonApi.currentLocale] = draft.content;
|
||||
lessonApi.isDirty = true;
|
||||
} else {
|
||||
clearDraft(lessonId, lessonApi.currentLocale);
|
||||
}
|
||||
});
|
||||
|
||||
// Only autosave after real edits, not on every reactive update.
|
||||
$effect(() => {
|
||||
if (mode !== MODES.edit) return;
|
||||
if (!lessonApi.lesson || !lessonId) return;
|
||||
if (!lessonApi.isDirty) return;
|
||||
if ($isLoading) return;
|
||||
console.log('autoSave');
|
||||
|
||||
autoSave();
|
||||
});
|
||||
@@ -270,7 +309,6 @@
|
||||
let didHandleExitEdit = false;
|
||||
$effect(() => {
|
||||
if (!lessonId) return;
|
||||
console.log('didHandleExitEdit');
|
||||
|
||||
const currentParam = $page.url.searchParams.get('mode');
|
||||
const prev = prevModeParam;
|
||||
@@ -338,8 +376,17 @@
|
||||
</IconButton>
|
||||
{/if}
|
||||
|
||||
<div class="flex-row items-center lg:flex">
|
||||
<IconButton onclick={toggleMode} disabled={isSaving}>
|
||||
<div class="flex flex-row items-center gap-2 lg:flex">
|
||||
{#if mode === MODES.edit}
|
||||
<span class="ui:text-muted-foreground text-sm" aria-live="polite">
|
||||
{#if lessonApi.isSaving}
|
||||
{$t('course.navItem.lessons.saving')}
|
||||
{:else if !lessonApi.isDirty}
|
||||
{$t('course.navItem.lessons.saved')}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<IconButton onclick={toggleMode} disabled={lessonApi.isSaving}>
|
||||
{#if mode === MODES.edit}
|
||||
<Save size={20} />
|
||||
{:else}
|
||||
@@ -452,6 +499,8 @@
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
|
||||
<UnsavedChanges bind:hasUnsavedChanges />
|
||||
|
||||
{#if isVersionDrawerOpen && window.innerWidth >= 1024}
|
||||
<LessonVersionHistory
|
||||
open={isVersionDrawerOpen}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import type { CourseMembers, CourseMember } from '$features/course/utils/types';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
@@ -138,11 +139,15 @@
|
||||
{#if person.profile}
|
||||
<div class="flex items-start lg:items-center">
|
||||
<Avatar.Root class="mr-3">
|
||||
<Avatar.Image
|
||||
src={person.profile.avatar_url ? person.profile.avatar_url : '/logo-192.png'}
|
||||
alt={person.profile.fullname ? person.profile.fullname : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(person.profile.fullname) || 'U'}</Avatar.Fallback>
|
||||
{#if person.profile.avatar_url}
|
||||
<Avatar.Image
|
||||
src={person.profile.avatar_url}
|
||||
alt={person.profile.fullname ? person.profile.fullname : 'User'}
|
||||
/>
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-4 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="flex flex-col items-start lg:flex-row lg:items-center">
|
||||
<div class="mr-2">
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
await submissionApi.updateGrades(courseId, subId, {
|
||||
answers,
|
||||
total,
|
||||
feedback,
|
||||
feedback: feedback || undefined,
|
||||
statusId: STATUS.GRADED
|
||||
});
|
||||
|
||||
@@ -195,6 +195,8 @@
|
||||
}
|
||||
};
|
||||
|
||||
handleModalClose();
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,8 +16,51 @@ export const EXERCISE_TEMPLATE_TAGS = {
|
||||
GIT: 'GIT'
|
||||
};
|
||||
|
||||
/** Named sort keys for course lists (URL `sort`, localStorage, filter UI). */
|
||||
export const CourseSortBy = {
|
||||
DateCreated: 'date_created',
|
||||
Published: 'published',
|
||||
Lessons: 'lessons'
|
||||
} as const;
|
||||
|
||||
export type CourseSortBy = (typeof CourseSortBy)[keyof typeof CourseSortBy];
|
||||
|
||||
export const DEFAULT_COURSE_SORT: CourseSortBy = CourseSortBy.DateCreated;
|
||||
|
||||
/** URL `order` param and filter toggle (ascending vs descending). */
|
||||
export const CourseSortOrder = {
|
||||
Asc: 'asc',
|
||||
Desc: 'desc'
|
||||
} as const;
|
||||
|
||||
export type CourseSortOrder = (typeof CourseSortOrder)[keyof typeof CourseSortOrder];
|
||||
|
||||
export const DEFAULT_SORT_ORDER: CourseSortOrder = CourseSortOrder.Desc;
|
||||
|
||||
const COURSE_SORT_ORDER_VALUES = new Set<string>(Object.values(CourseSortOrder));
|
||||
|
||||
export function parseCourseSortOrder(value: string | null | undefined): CourseSortOrder {
|
||||
if (value != null && COURSE_SORT_ORDER_VALUES.has(value)) {
|
||||
return value as CourseSortOrder;
|
||||
}
|
||||
return DEFAULT_SORT_ORDER;
|
||||
}
|
||||
|
||||
export const COURSE_SORT_OPTIONS = [
|
||||
{ value: '0', label: 'courses.course_filter.date_created' },
|
||||
{ value: '1', label: 'courses.course_filter.published' },
|
||||
{ value: '2', label: 'courses.course_filter.lessons' }
|
||||
{ value: CourseSortBy.DateCreated, label: 'courses.course_filter.date_created' },
|
||||
{ value: CourseSortBy.Published, label: 'courses.course_filter.published' },
|
||||
{ value: CourseSortBy.Lessons, label: 'courses.course_filter.lessons' }
|
||||
] as const;
|
||||
|
||||
const COURSE_SORT_VALUES = new Set<string>(Object.values(CourseSortBy));
|
||||
|
||||
/** Parses URL `sort` / stored sort strings into {@link CourseSortBy}; unknown values fall back to default. */
|
||||
export function parseCourseSortValue(value: string | null | undefined): CourseSortBy {
|
||||
if (value == null || value === '') {
|
||||
return DEFAULT_COURSE_SORT;
|
||||
}
|
||||
if (COURSE_SORT_VALUES.has(value)) {
|
||||
return value as CourseSortBy;
|
||||
}
|
||||
return DEFAULT_COURSE_SORT;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
|
||||
interface LessonDraft {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
function draftKey(lessonId: string, locale: TLocale): string {
|
||||
return `lesson-draft-${lessonId}-${locale}`;
|
||||
}
|
||||
|
||||
export function saveDraft(lessonId: string, locale: TLocale, content: string) {
|
||||
try {
|
||||
const draft: LessonDraft = { content, timestamp: Date.now() };
|
||||
localStorage.setItem(draftKey(lessonId, locale), JSON.stringify(draft));
|
||||
} catch {
|
||||
// localStorage may be full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDraft(lessonId: string, locale: TLocale): LessonDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(draftKey(lessonId, locale));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as LessonDraft;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDraft(lessonId: string, locale: TLocale) {
|
||||
try {
|
||||
localStorage.removeItem(draftKey(lessonId, locale));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -7,15 +8,18 @@
|
||||
import { courseMetaDeta } from '$features/course/utils/store';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
import type { RecommendedCourses } from '$features/course/types';
|
||||
import { CourseSortBy, DEFAULT_COURSE_SORT, parseCourseSortValue } from '$features/course/utils/constants';
|
||||
|
||||
let searchValue = $state('');
|
||||
let selectedId: string | undefined = $state('');
|
||||
let sortKey = $state<CourseSortBy>(DEFAULT_COURSE_SORT);
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
void sortKey;
|
||||
localStorage.setItem('classroomio_filter_course_key', sortKey);
|
||||
});
|
||||
|
||||
const filteredExploreCourses: RecommendedCourses = $derived.by(() => {
|
||||
if (selectedId) {
|
||||
localStorage.setItem('classroomio_filter_course_key', selectedId);
|
||||
}
|
||||
|
||||
const coursesFiltered = coursesApi.recommendedCourses.filter((course) => {
|
||||
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
|
||||
return true;
|
||||
@@ -24,11 +28,13 @@
|
||||
return false;
|
||||
});
|
||||
|
||||
if (selectedId === '0') {
|
||||
if (sortKey === CourseSortBy.DateCreated) {
|
||||
return coursesFiltered.sort(
|
||||
(a, b) => new Date(a.createdAt ?? '').getTime() - new Date(b.createdAt ?? '').getTime()
|
||||
);
|
||||
} else if (selectedId === '1') {
|
||||
} else if (sortKey === CourseSortBy.Published) {
|
||||
return coursesFiltered.sort((a, b) => Number(b.isPublished) - Number(a.isPublished));
|
||||
} else if (sortKey === CourseSortBy.Lessons) {
|
||||
return coursesFiltered.sort((a, b) => (b.lessonCount ?? 0) - (a.lessonCount ?? 0));
|
||||
}
|
||||
|
||||
@@ -42,7 +48,7 @@
|
||||
$courseMetaDeta.view = courseView;
|
||||
}
|
||||
|
||||
selectedId = localStorage.getItem('classroomio_filter_course_key') || '0';
|
||||
sortKey = parseCourseSortValue(localStorage.getItem('classroomio_filter_course_key'));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -60,5 +66,5 @@
|
||||
isLMS={true}
|
||||
isLoading={coursesApi.isLoading}
|
||||
bind:searchValue
|
||||
bind:selectedId
|
||||
bind:sortKey
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
AssignAudienceCoursesRequest,
|
||||
DeleteTeamRequest,
|
||||
DomainRequestRequest,
|
||||
GetAudienceRequest,
|
||||
GetOrgPublicCoursesRequest,
|
||||
ImportAudienceRequest,
|
||||
InviteTeamRequest,
|
||||
OrgPublicCourses,
|
||||
OrganizationAudience,
|
||||
OrganizationTeamMembers
|
||||
OrganizationTeamMembers,
|
||||
ResendAudienceInviteRequest,
|
||||
RevokeAudienceInviteRequest
|
||||
} from '../utils/types';
|
||||
import type { TCreateOrganization, TGetOrganizations, TUpdateOrganization } from '@cio/utils/validation/organization';
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
TAssignAudienceCourses,
|
||||
TAudienceInviteByEmail,
|
||||
TCreateOrganization,
|
||||
TGetOrganizations,
|
||||
TImportAudienceMembers,
|
||||
TUpdateOrganization
|
||||
} from '@cio/utils/validation/organization';
|
||||
import { ZCreateOrganization, ZUpdateOrganization } from '@cio/utils/validation/organization';
|
||||
import { currentOrg, orgs } from '$lib/utils/store/org';
|
||||
|
||||
@@ -370,6 +381,117 @@ class OrgApi extends BaseApiWithErrors {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports users into the organization as students
|
||||
*/
|
||||
async importAudienceMembers(data: TImportAudienceMembers) {
|
||||
console.log('importAudienceMembers', data);
|
||||
return this.execute<ImportAudienceRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.audience.import.$post({
|
||||
json: data
|
||||
}),
|
||||
logContext: 'importing audience members',
|
||||
onSuccess: (response) => {
|
||||
const d = response.data;
|
||||
snackbar.success(
|
||||
t.get('audience.import.snackbar_success', {
|
||||
imported: d.imported,
|
||||
skipped: d.skipped,
|
||||
emailsSent: d.emailsSent
|
||||
})
|
||||
);
|
||||
this.success = true;
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result) {
|
||||
this.errors[result.field as string] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns existing audience members to courses
|
||||
*/
|
||||
async assignAudienceToCourses(data: TAssignAudienceCourses) {
|
||||
return this.execute<AssignAudienceCoursesRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.audience['assign-courses'].$post({
|
||||
json: data
|
||||
}),
|
||||
logContext: 'assigning audience to courses',
|
||||
onSuccess: (response) => {
|
||||
const d = response.data;
|
||||
snackbar.success(
|
||||
t.get('audience.assign.snackbar_success', {
|
||||
assigned: d.assigned,
|
||||
alreadyEnrolled: d.alreadyEnrolled,
|
||||
emailsSent: d.emailsSent
|
||||
})
|
||||
);
|
||||
this.success = true;
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result) {
|
||||
this.errors[result.field as string] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async resendAudienceInvite(data: TAudienceInviteByEmail) {
|
||||
return this.execute<ResendAudienceInviteRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.audience['resend-invite'].$post({
|
||||
json: data
|
||||
}),
|
||||
logContext: 'resending audience invite',
|
||||
onSuccess: (response) => {
|
||||
if (response.data.emailSent) {
|
||||
snackbar.success('audience.invite.resend_snackbar_success');
|
||||
} else {
|
||||
snackbar.error('audience.invite.resend_snackbar_email_failed');
|
||||
}
|
||||
const orgId = get(currentOrg).id;
|
||||
if (orgId) this.getOrgAudience(orgId);
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async revokeAudienceInvite(data: TAudienceInviteByEmail) {
|
||||
return this.execute<RevokeAudienceInviteRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.audience['revoke-invite'].$post({
|
||||
json: data
|
||||
}),
|
||||
logContext: 'revoking audience invite',
|
||||
onSuccess: () => {
|
||||
snackbar.success('audience.invite.revoke_snackbar_success');
|
||||
const orgId = get(currentOrg).id;
|
||||
if (orgId) this.getOrgAudience(orgId);
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const orgApi = new OrgApi();
|
||||
|
||||
@@ -23,6 +23,8 @@ export type OrganizationAudienceSuccess = Extract<InferResponseType<GetAudienceR
|
||||
|
||||
export type OrganizationAudience = OrganizationAudienceSuccess['data'];
|
||||
|
||||
export type OrganizationAudienceMember = OrganizationAudience[number];
|
||||
|
||||
// Org Public Courses types
|
||||
export type GetOrgPublicCoursesRequest = typeof classroomio.organization.courses.public.$get;
|
||||
export type OrgPublicCoursesResponse = InferResponseType<GetOrgPublicCoursesRequest> | null;
|
||||
@@ -83,6 +85,20 @@ export type SsoDiscoveryResult = GetSsoDiscoverySuccess['data'];
|
||||
export type GetOrgSsoInfoSuccess = Extract<InferResponseType<GetOrgSsoInfoRequest>, { success: true }>;
|
||||
export type OrgSsoInfo = GetOrgSsoInfoSuccess['data'];
|
||||
|
||||
// Audience import types
|
||||
export type ImportAudienceRequest = (typeof classroomio.organization)['audience']['import']['$post'];
|
||||
export type ImportAudienceSuccess = Extract<InferResponseType<ImportAudienceRequest>, { success: true }>;
|
||||
export type ImportAudienceData = ImportAudienceSuccess['data'];
|
||||
|
||||
// Audience assign courses types
|
||||
export type AssignAudienceCoursesRequest = (typeof classroomio.organization)['audience']['assign-courses']['$post'];
|
||||
export type AssignAudienceCoursesSuccess = Extract<InferResponseType<AssignAudienceCoursesRequest>, { success: true }>;
|
||||
export type AssignAudienceCoursesData = AssignAudienceCoursesSuccess['data'];
|
||||
|
||||
// Audience invite actions
|
||||
export type ResendAudienceInviteRequest = (typeof classroomio.organization)['audience']['resend-invite']['$post'];
|
||||
export type RevokeAudienceInviteRequest = (typeof classroomio.organization)['audience']['revoke-invite']['$post'];
|
||||
|
||||
// Token auth types
|
||||
export type CreateTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$post'];
|
||||
export type GetTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$get'];
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
||||
import AlarmClockIcon from '@lucide/svelte/icons/alarm-clock';
|
||||
import MailIcon from '@lucide/svelte/icons/mail';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
interface Props {
|
||||
user: UserAnalytics['user'];
|
||||
@@ -16,11 +17,12 @@
|
||||
<div class="rounded-md border p-5">
|
||||
<div class="flex w-full flex-col items-center justify-start gap-4 text-start md:flex-row">
|
||||
<Avatar.Root class="h-16 w-16">
|
||||
<Avatar.Image
|
||||
src={user.avatarUrl ? user.avatarUrl : '/logo-192.png'}
|
||||
alt={user.fullName ? user.fullName : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{user.fullName ? user.fullName : 'User'}</Avatar.Fallback>
|
||||
{#if user.avatarUrl}
|
||||
<Avatar.Image src={user.avatarUrl} alt={user.fullName ? user.fullName : 'User'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-8 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<p class="text-center text-2xl md:text-left dark:text-white">
|
||||
|
||||
+7
-6
@@ -2,12 +2,12 @@
|
||||
import get from 'lodash/get';
|
||||
import z from 'zod';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import ReviewFormEditor from './review-form-editor.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { processErrors } from '$lib/utils/functions/validator';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
|
||||
@@ -98,11 +98,12 @@
|
||||
<!-- the headers -->
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<Avatar.Root class="mt-1 h-10 w-10">
|
||||
<Avatar.Image
|
||||
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
|
||||
alt={review.name ? review.name : 'Reviewer'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(review.name) || 'R'}</Avatar.Fallback>
|
||||
{#if review.avatar_url}
|
||||
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Reviewer'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="text-sm">{review.name}</p>
|
||||
|
||||
|
||||
+9
-1
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { cn } from '@cio/ui/tools';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -7,8 +9,14 @@
|
||||
}
|
||||
|
||||
let { id, children }: Props = $props();
|
||||
|
||||
let includesHash = $derived(page.url.hash?.includes(id) ?? false);
|
||||
</script>
|
||||
|
||||
<section {id} transition:fade={{ delay: 250, duration: 300 }} class="border-b border-gray-300 pt-12 pb-10">
|
||||
<section
|
||||
{id}
|
||||
transition:fade={{ delay: 250, duration: 300 }}
|
||||
class={cn('border-b border-gray-300 pt-5 pb-5', includesHash && 'pt-22')}
|
||||
>
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import PlayIcon from '@lucide/svelte/icons/play';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
import { getLectureNo } from '$features/course/utils/functions';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
@@ -31,7 +32,6 @@
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import PricingSection from './components/pricing-section.svelte';
|
||||
import NavSection from './components/nav-section.svelte';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import SectionsDisplay from './components/sections-display.svelte';
|
||||
import { observeIntersection } from './components/intersection-observer';
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
<!-- Course Details -->
|
||||
<div class="course-content w-full p-3 lg:mr-10 lg:w-10/12">
|
||||
<!-- Navigation -->
|
||||
<UnderlineTabs.Root bind:value={activeNav} class="ui:bg-background sticky top-0 py-2">
|
||||
<UnderlineTabs.Root bind:value={activeNav} class="ui:bg-background sticky top-10 py-2">
|
||||
<UnderlineTabs.List>
|
||||
{#each navItems as navItem (navItem.key)}
|
||||
<UnderlineTabs.Trigger value={navItem.key}>
|
||||
@@ -316,15 +316,14 @@
|
||||
<!-- review -->
|
||||
<div class="item-start my-2 flex w-2/4 flex-row">
|
||||
<!-- image container -->
|
||||
{#if review.avatar_url}
|
||||
<Avatar.Root class="mt-1 size-10">
|
||||
<Avatar.Image
|
||||
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
|
||||
alt={review.name ? review.name : 'Avatar'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(review.name) || 'AV'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{/if}
|
||||
<Avatar.Root class="mt-1 size-10">
|
||||
{#if review.avatar_url}
|
||||
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Avatar'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<!-- profile content -->
|
||||
<div class="w-11/12 pl-2.5">
|
||||
@@ -385,15 +384,14 @@
|
||||
<!-- review -->
|
||||
<div class="item-start my-2 flex w-full flex-row">
|
||||
<!-- image container -->
|
||||
{#if review.avatar_url}
|
||||
<Avatar.Root class="mt-1 h-10 w-10">
|
||||
<Avatar.Image
|
||||
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
|
||||
alt={review.name ? review.name : 'Avatar'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(review.name) || 'AV'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{/if}
|
||||
<Avatar.Root class="mt-1 h-10 w-10">
|
||||
{#if review.avatar_url}
|
||||
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Avatar'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<!-- profile content -->
|
||||
<div class="w-11/12 pl-2.5">
|
||||
|
||||
@@ -48,7 +48,7 @@ export const landingPageStore = writable({
|
||||
id: 1,
|
||||
show: true,
|
||||
name: 'Adeyemi Adetunji',
|
||||
avatar_url: '/logo-192.png',
|
||||
avatar_url: '',
|
||||
rating: 1,
|
||||
created_at: new Date(),
|
||||
description: 'rice ventore officiis maxime laudantium recusandae sit dolor.'
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { basePath, globalStore } from '$lib/utils/store/app';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
||||
@@ -12,7 +14,6 @@
|
||||
import ThemeToggle from './theme-toggle.svelte';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { logout } from '$lib/utils/functions/logout';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
@@ -36,7 +37,7 @@
|
||||
|
||||
{#snippet usertrigger()}
|
||||
<DropdownMenu.Label class="p-0">
|
||||
<a href={`${$basePath}/settings`} class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<a href={resolve(`${$basePath}/settings`, {})} class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
{@render avatarblock()}
|
||||
</a>
|
||||
</DropdownMenu.Label>
|
||||
@@ -122,7 +123,7 @@
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item onclick={() => logout()}>
|
||||
<DropdownMenu.Item onclick={() => goto(resolve(`/logout`, {}))}>
|
||||
<span class="flex items-center gap-2">
|
||||
<LogOutIcon />
|
||||
<p class="text-sm">{$t('settings.profile.logout')}</p>
|
||||
|
||||
Reference in New Issue
Block a user