feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
|
||||
@import '@cio/ui/output.css';
|
||||
|
||||
@source '../../packages/ui/src/custom/animation/**/*.svelte';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Geist variable font — self-hosted */
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { SortPopover } from '$features/ui';
|
||||
import type { OrganizationAudienceSortBy, OrganizationAudienceSortOrder } from '$features/org/utils/types';
|
||||
|
||||
interface Props {
|
||||
hasSelection: boolean;
|
||||
selectedCount: number;
|
||||
searchValue?: string;
|
||||
sortValue: string;
|
||||
onSortChange: (value: string) => void;
|
||||
sortBy: OrganizationAudienceSortBy;
|
||||
sortOrder: OrganizationAudienceSortOrder;
|
||||
onSortChange: (sortBy: OrganizationAudienceSortBy, sortOrder: OrganizationAudienceSortOrder) => void;
|
||||
onOpenAssign: () => void;
|
||||
}
|
||||
|
||||
@@ -18,19 +20,37 @@
|
||||
hasSelection,
|
||||
selectedCount,
|
||||
searchValue = $bindable(''),
|
||||
sortValue,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
onOpenAssign
|
||||
}: Props = $props();
|
||||
|
||||
const sortOptions = [
|
||||
{ label: t.get('audience.sort.newest'), value: 'createdAt:desc' },
|
||||
{ label: t.get('audience.sort.oldest'), value: 'createdAt:asc' },
|
||||
{ label: t.get('audience.sort.name_asc'), value: 'name:asc' },
|
||||
{ label: t.get('audience.sort.name_desc'), value: 'name:desc' },
|
||||
{ label: t.get('audience.sort.email_asc'), value: 'email:asc' },
|
||||
{ label: t.get('audience.sort.email_desc'), value: 'email:desc' }
|
||||
{ label: t.get('audience.date_joined'), value: 'createdAt' },
|
||||
{ label: t.get('audience.name'), value: 'name' },
|
||||
{ label: t.get('audience.email'), value: 'email' }
|
||||
];
|
||||
|
||||
let localSortKey = $state(sortBy);
|
||||
let localSortOrder = $state(sortOrder);
|
||||
|
||||
$effect(() => {
|
||||
localSortKey = sortBy;
|
||||
localSortOrder = sortOrder;
|
||||
});
|
||||
|
||||
function handleSortKeyChange(key: string) {
|
||||
onSortChange(key as OrganizationAudienceSortBy, localSortOrder);
|
||||
}
|
||||
|
||||
function handleOrderChange(order: 'asc' | 'desc') {
|
||||
onSortChange(localSortKey, order);
|
||||
}
|
||||
|
||||
function handleClearSort() {
|
||||
onSortChange('createdAt', 'desc');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.BodyHeader>
|
||||
@@ -46,20 +66,16 @@
|
||||
{:else}
|
||||
<div class="flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="w-full md:max-w-sm" />
|
||||
<Select.Root type="single" value={sortValue} onValueChange={onSortChange} name="audience-sort">
|
||||
<Select.Trigger class="w-full md:w-[220px]">
|
||||
{sortOptions.find((option) => option.value === sortValue)?.label ?? $t('audience.sort.label')}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
{#each sortOptions as option (option.value)}
|
||||
<Select.Item value={option.value} label={option.label} disabled={option.value === sortValue}>
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<SortPopover
|
||||
{sortOptions}
|
||||
bind:sortKey={localSortKey}
|
||||
bind:selectedOrder={localSortOrder}
|
||||
defaultSortKey="createdAt"
|
||||
defaultSortOrder="desc"
|
||||
onSortKeyChange={handleSortKeyChange}
|
||||
onOrderChange={handleOrderChange}
|
||||
onClearFilters={handleClearSort}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Page.BodyHeader>
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
let deleteCandidate = $state<OrganizationAudienceMember | null>(null);
|
||||
let deleteDialogOpen = $state(false);
|
||||
let searchValue = $state(query.search ?? '');
|
||||
let sortValue = $state(`${query.sortBy}:${query.sortOrder}`);
|
||||
|
||||
async function handleResendInvite(email: string) {
|
||||
inviteActionEmail = email;
|
||||
@@ -126,7 +125,6 @@
|
||||
|
||||
$effect(() => {
|
||||
searchValue = query.search ?? '';
|
||||
sortValue = `${query.sortBy}:${query.sortOrder}`;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -179,23 +177,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
function handleSortChange(nextSortValue: string) {
|
||||
if (nextSortValue === sortValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
sortValue = nextSortValue;
|
||||
const [sortBy, sortOrder] = nextSortValue.split(':') as [
|
||||
OrganizationAudienceQuery['sortBy'],
|
||||
OrganizationAudienceQuery['sortOrder']
|
||||
];
|
||||
|
||||
void navigateAudience({
|
||||
...query,
|
||||
page: 1,
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
function handleSortChange(
|
||||
sortBy: OrganizationAudienceQuery['sortBy'],
|
||||
sortOrder: OrganizationAudienceQuery['sortOrder']
|
||||
) {
|
||||
void navigateAudience({ ...query, page: 1, sortBy, sortOrder });
|
||||
}
|
||||
|
||||
function openDeleteConfirmation(member: OrganizationAudienceMember) {
|
||||
@@ -236,7 +222,8 @@
|
||||
{hasSelection}
|
||||
selectedCount={selectedIds.size}
|
||||
bind:searchValue
|
||||
{sortValue}
|
||||
sortBy={query.sortBy}
|
||||
sortOrder={query.sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
onOpenAssign={() => (assignModalOpen = true)}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<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';
|
||||
@@ -17,15 +16,23 @@
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
courses: Course[];
|
||||
interface Program {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
let { courses }: Props = $props();
|
||||
interface Props {
|
||||
courses: Course[];
|
||||
programs: Program[];
|
||||
}
|
||||
|
||||
let { courses, programs }: Props = $props();
|
||||
|
||||
let recipientCsv = $state('');
|
||||
let courseAccessMode = $state('none');
|
||||
let programAccessMode = $state('none');
|
||||
let selectedCourseIds = new SvelteSet<string>();
|
||||
let selectedProgramIds = new SvelteSet<string>();
|
||||
let sendEmail = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
@@ -37,9 +44,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleProgram(programId: string) {
|
||||
if (selectedProgramIds.has(programId)) {
|
||||
selectedProgramIds.delete(programId);
|
||||
} else {
|
||||
selectedProgramIds.add(programId);
|
||||
}
|
||||
}
|
||||
|
||||
const audiencePath = $derived(page.url.pathname.replace(/\/import$/, ''));
|
||||
|
||||
async function handleSubmit() {
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!recipientCsv.trim()) {
|
||||
snackbar.error('audience.import.snackbar_no_emails');
|
||||
return;
|
||||
@@ -51,7 +68,9 @@
|
||||
const result = await orgApi.importAudienceMembers({
|
||||
recipientCsv,
|
||||
allCourses: courseAccessMode === 'all',
|
||||
allPrograms: programAccessMode === 'all',
|
||||
courseIds: courseAccessMode === 'select' ? [...selectedCourseIds] : undefined,
|
||||
programIds: programAccessMode === 'select' ? [...selectedProgramIds] : undefined,
|
||||
sendEmail
|
||||
});
|
||||
|
||||
@@ -66,7 +85,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<form id="import-audience-form" class="space-y-6" onsubmit={handleSubmit}>
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('audience.import.description')}</p>
|
||||
|
||||
<TextareaField
|
||||
@@ -108,11 +127,38 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> -->
|
||||
{#if programs.length > 0}
|
||||
<div class="space-y-3">
|
||||
<Label class="text-sm font-medium">{$t('audience.import.program_access')}</Label>
|
||||
<RadioGroup.Root bind:value={programAccessMode} class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="none" id="program-none" />
|
||||
<Label for="program-none" class="font-normal">{$t('audience.import.no_programs')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="all" id="program-all" />
|
||||
<Label for="program-all" class="font-normal">{$t('audience.import.all_programs')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="select" id="program-select" />
|
||||
<Label for="program-select" class="font-normal">{$t('audience.import.select_programs')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button onclick={handleSubmit} loading={isSubmitting} disabled={isSubmitting}>
|
||||
{$t('audience.import.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if programAccessMode === 'select'}
|
||||
<MultiSelectList
|
||||
class="ml-6"
|
||||
listClass="max-h-40"
|
||||
heading={$t('audience.import.select_programs')}
|
||||
emptyMessage={$t('audience.import.select_programs_placeholder')}
|
||||
items={programs.map((p) => ({ id: p.id, label: p.name || p.id }))}
|
||||
isSelected={(id) => selectedProgramIds.has(id)}
|
||||
onToggle={toggleProgram}
|
||||
namePrefix="import-program"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> -->
|
||||
</form>
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
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';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -60,16 +58,12 @@
|
||||
</script>
|
||||
|
||||
{#if userAnalytics}
|
||||
<div class="p-5">
|
||||
<BackButton href={`${$currentOrgPath}/audience`} label={$t('community.ask.go_back')} />
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-1">
|
||||
<HeroProfileCard user={userAnalytics!.user} />
|
||||
|
||||
<div class="mt-5 px-0">
|
||||
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each userMetrics as activity}
|
||||
{#each userMetrics as activity (activity.title)}
|
||||
<ActivityCard {activity} />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -114,54 +108,52 @@
|
||||
</div>
|
||||
|
||||
{#each filteredCourses as course (course.id)}
|
||||
{#key index}
|
||||
<div
|
||||
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
|
||||
course.lessons_count === course.lessons_completed
|
||||
? 'border-green-200 bg-green-50 dark:bg-green-100'
|
||||
: 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100'
|
||||
}`}
|
||||
transition:fade={{ duration: 300 }}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex w-4/5 items-center gap-4">
|
||||
<img
|
||||
src={course.logo || '/images/classroomio-course-img-template.jpg'}
|
||||
alt={course.title}
|
||||
class="h-20 w-24 rounded-md"
|
||||
/>
|
||||
<div class="mb-4 gap-4">
|
||||
<a href={`/courses/${course.id}`}>
|
||||
<p class="text-lg font-semibold text-gray-600">
|
||||
{course.title}
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<p class="line-clamp-2 text-sm text-gray-600">
|
||||
{course.description}
|
||||
<div
|
||||
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
|
||||
course.lessons_count === course.lessons_completed
|
||||
? 'border-green-200 bg-green-50 dark:bg-green-100'
|
||||
: 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100'
|
||||
}`}
|
||||
transition:fade={{ duration: 300 }}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex w-4/5 items-center gap-4">
|
||||
<img
|
||||
src={course.logo || '/images/classroomio-course-img-template.jpg'}
|
||||
alt={course.title}
|
||||
class="h-20 w-24 rounded-md"
|
||||
/>
|
||||
<div class="mb-4 gap-4">
|
||||
<a href={`/courses/${course.id}`}>
|
||||
<p class="text-lg font-semibold text-gray-600">
|
||||
{course.title}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<p class="line-clamp-2 text-sm text-gray-600">
|
||||
{course.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
class={`${
|
||||
course.lessons_count === course.lessons_completed
|
||||
? 'bg-green-200 text-green-700'
|
||||
: 'bg-yellow-200 text-yellow-700'
|
||||
}`}
|
||||
>
|
||||
{course.lessons_count === course.lessons_completed
|
||||
? $t('analytics.completed')
|
||||
: $t('analytics.incomplete')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center gap-1">
|
||||
<Progress value={course.progress_percentage} />
|
||||
<p>{course.progress_percentage}%</p>
|
||||
</div>
|
||||
<Badge
|
||||
class={`${
|
||||
course.lessons_count === course.lessons_completed
|
||||
? 'bg-green-200 text-green-700'
|
||||
: 'bg-yellow-200 text-yellow-700'
|
||||
}`}
|
||||
>
|
||||
{course.lessons_count === course.lessons_completed
|
||||
? $t('analytics.completed')
|
||||
: $t('analytics.incomplete')}
|
||||
</Badge>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
<div class="flex w-full items-center gap-1">
|
||||
<Progress value={course.progress_percentage} />
|
||||
<p>{course.progress_percentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,8 @@ class AutomationApi extends BaseApiWithErrors {
|
||||
onSuccess: (response) => {
|
||||
this.keys = this.keys.map((key) => (key.id === response.data.id ? response.data : key));
|
||||
if (this.usage) {
|
||||
const activeKeys = this.keys.filter((key) => key.type === 'mcp' && !key.revokedAt).length;
|
||||
const revokedKeyType = response.data.type;
|
||||
const activeKeys = this.keys.filter((key) => key.type === revokedKeyType && !key.revokedAt).length;
|
||||
this.usage = { ...this.usage, activeKeys };
|
||||
}
|
||||
snackbar.success('snackbar.automation.revoked');
|
||||
|
||||
@@ -1,14 +1,221 @@
|
||||
<script lang="ts">
|
||||
import { automationApi } from '$features/automation/api/automation.svelte';
|
||||
import { getMaskedAutomationSecret } from '$features/automation/utils/automation-utils';
|
||||
import { isOrgAdmin, isEnterprisePlan } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import * as Alert from '@cio/ui/base/alert';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Code from '@cio/ui/custom/code';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { UpgradeBanner } from '$features/ui';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import KeyIcon from '@lucide/svelte/icons/key';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
|
||||
let generatedSecret = $state<string | null>(null);
|
||||
let isCreateKeyModalOpen = $state(false);
|
||||
let keyLabel = $state('');
|
||||
|
||||
const canCreateKey = $derived(keyLabel.trim().length > 0 && !automationApi.isLoading);
|
||||
|
||||
function resetCreateKeyModal() {
|
||||
keyLabel = '';
|
||||
automationApi.resetErrors();
|
||||
}
|
||||
|
||||
function openCreateKeyModal() {
|
||||
if (!$isEnterprisePlan) {
|
||||
snackbar.error('upgrade.required');
|
||||
return;
|
||||
}
|
||||
resetCreateKeyModal();
|
||||
isCreateKeyModalOpen = true;
|
||||
}
|
||||
|
||||
async function onGenerateKey() {
|
||||
if (!$isEnterprisePlan) return;
|
||||
const result = await automationApi.createKey('api', keyLabel);
|
||||
if (!result) return;
|
||||
|
||||
generatedSecret = automationApi.generatedSecret;
|
||||
automationApi.clearGeneratedSecret();
|
||||
isCreateKeyModalOpen = false;
|
||||
resetCreateKeyModal();
|
||||
}
|
||||
|
||||
async function onRevokeKey(keyId: string) {
|
||||
if (!$isEnterprisePlan) return;
|
||||
if (!confirm(t.get('automation.keys.revoke_confirm'))) return;
|
||||
await automationApi.revokeKey(keyId);
|
||||
}
|
||||
|
||||
async function onRotateKey(keyId: string) {
|
||||
if (!$isEnterprisePlan) return;
|
||||
if (!confirm(t.get('automation.keys.rotate_confirm'))) return;
|
||||
await automationApi.rotateKey(keyId);
|
||||
generatedSecret = automationApi.generatedSecret;
|
||||
automationApi.clearGeneratedSecret();
|
||||
}
|
||||
|
||||
const apiKeys = $derived(automationApi.keys.filter((key) => key.type === 'api'));
|
||||
</script>
|
||||
|
||||
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
title={$t('automation.tabs.api')}
|
||||
description={$t('automation.coming_soon')}
|
||||
class="w-full"
|
||||
/>
|
||||
<Field.Group class="mx-auto w-full space-y-2">
|
||||
<UpgradeBanner>{$t('upgrade.enterprise_required')}</UpgradeBanner>
|
||||
|
||||
{#if !$isOrgAdmin}
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
title={$t('automation.permissions.disabled_title')}
|
||||
description={$t('automation.permissions.disabled_description')}
|
||||
class="w-full"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<KeyIcon class="size-5" />
|
||||
{$t('automation.api.keys.title')}
|
||||
</div>
|
||||
{#if $isOrgAdmin}
|
||||
<IconButton
|
||||
onclick={openCreateKeyModal}
|
||||
disabled={automationApi.isLoading || !$isEnterprisePlan}
|
||||
tooltip={$t('automation.api.keys.generate')}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('automation.api.keys.description')}</p>
|
||||
|
||||
{#if generatedSecret}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<p class="ui:text-amber-800 text-sm font-medium">{$t('automation.api.keys.secret_once')}</p>
|
||||
<div class="mt-3">
|
||||
<Code.Root code={generatedSecret} lang="bash" hideLines={true} class="ui:break-all ui:text-xs">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $isOrgAdmin}
|
||||
{#if apiKeys.length > 0}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>{$t('automation.keys.table_key')}</Table.Head>
|
||||
<Table.Head>{$t('automation.keys.table_status')}</Table.Head>
|
||||
<Table.Head>{$t('automation.keys.table_created')}</Table.Head>
|
||||
<Table.Head>{$t('automation.keys.table_last_used')}</Table.Head>
|
||||
<Table.Head class="w-10"></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each apiKeys as key (key.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<div>
|
||||
<p class="font-medium">{key.label}</p>
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{getMaskedAutomationSecret(key.secretPrefix)}
|
||||
</p>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={key.revokedAt ? 'secondary' : 'default'}>
|
||||
{key.revokedAt ? $t('automation.keys.revoked') : $t('automation.keys.active')}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="ui:text-muted-foreground text-sm">
|
||||
{new Date(key.createdAt).toLocaleDateString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="ui:text-muted-foreground text-sm">
|
||||
{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleDateString() : $t('automation.keys.never_used')}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if !key.revokedAt}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class="inline-flex items-center justify-center">
|
||||
<IconButton aria-label="actions">
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => onRotateKey(key.id)}>
|
||||
{$t('automation.keys.rotate')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => onRevokeKey(key.id)} class="ui:text-destructive">
|
||||
{$t('automation.keys.revoke')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('automation.api.keys.empty_title')}
|
||||
description={$t('automation.api.keys.empty_description')}
|
||||
icon={KeyIcon}
|
||||
variant="page"
|
||||
>
|
||||
{#if $isOrgAdmin}
|
||||
<Button onclick={openCreateKeyModal} disabled={automationApi.isLoading || !$isEnterprisePlan}>
|
||||
<PlusIcon size={16} />
|
||||
{$t('automation.api.keys.generate')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Empty>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Field.Group>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={isCreateKeyModalOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetCreateKeyModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('automation.api.keys.modal.title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('automation.api.keys.modal.description')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="py-2">
|
||||
<InputField
|
||||
label={$t('automation.api.keys.modal.name_label')}
|
||||
placeholder={$t('automation.api.keys.modal.name_placeholder')}
|
||||
bind:value={keyLabel}
|
||||
errorMessage={automationApi.errors.label}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (isCreateKeyModalOpen = false)}>
|
||||
{$t('automation.api.keys.modal.cancel')}
|
||||
</Button>
|
||||
<Button onclick={onGenerateKey} loading={automationApi.isLoading} disabled={!canCreateKey}>
|
||||
{$t('automation.api.keys.modal.save')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Code from '@cio/ui/custom/code';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
@@ -97,6 +98,7 @@
|
||||
});
|
||||
|
||||
const isLastUsageCard = (index: number) => index === usageCards.length - 1;
|
||||
const hasActiveMcpKey = $derived(mcpKeys.some((key) => !key.revokedAt));
|
||||
</script>
|
||||
|
||||
<Field.Group class="mx-auto w-full space-y-2">
|
||||
@@ -209,64 +211,73 @@
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{:else}
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
<Empty
|
||||
title={$t('automation.keys.empty_title')}
|
||||
description={$t('automation.keys.empty_description')}
|
||||
class="w-full"
|
||||
/>
|
||||
icon={KeyIcon}
|
||||
variant="page"
|
||||
>
|
||||
{#if $isOrgAdmin}
|
||||
<Button onclick={openCreateKeyModal} disabled={automationApi.isLoading}>
|
||||
<PlusIcon size={16} />
|
||||
{$t('automation.mcp.keys.generate')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Empty>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Field.Set class="gap-3!">
|
||||
<Field.Legend class="flex items-center gap-2">
|
||||
<SparklesIcon class="size-5" />
|
||||
{$t('automation.mcp.setup.title')}
|
||||
</Field.Legend>
|
||||
<Field.Description>{$t('automation.mcp.setup.description')}</Field.Description>
|
||||
{#if hasActiveMcpKey}
|
||||
<Field.Set class="gap-3!">
|
||||
<Field.Legend class="flex items-center gap-2">
|
||||
<SparklesIcon class="size-5" />
|
||||
{$t('automation.mcp.setup.title')}
|
||||
</Field.Legend>
|
||||
<Field.Description>{$t('automation.mcp.setup.description')}</Field.Description>
|
||||
|
||||
<Tabs.Root bind:value={activeSetupTab} class="w-full">
|
||||
<Tabs.List class="inline-flex w-auto">
|
||||
<Tabs.Trigger value="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="opencode">{$t('automation.clients.opencode')}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Root bind:value={activeSetupTab} class="w-full">
|
||||
<Tabs.List class="inline-flex w-auto">
|
||||
<Tabs.Trigger value="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="opencode">{$t('automation.clients.opencode')}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="claude-code" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getClaudeCodeSnippet(generatedSecret)} lang="bash">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="claude-code" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getClaudeCodeSnippet(generatedSecret)} lang="bash">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="codex" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getCodexSnippet(generatedSecret)} lang="bash">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="codex" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getCodexSnippet(generatedSecret)} lang="bash">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="cursor" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getCursorSnippet(generatedSecret)} lang="json">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="cursor" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getCursorSnippet(generatedSecret)} lang="json">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="opencode" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getOpenCodeSnippet(generatedSecret)} lang="json">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Field.Set>
|
||||
<Tabs.Content value="opencode" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getOpenCodeSnippet(generatedSecret)} lang="json">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Field.Set>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
|
||||
<Dialog.Root
|
||||
|
||||
@@ -9,14 +9,14 @@ import type {
|
||||
UpvotePostRequest
|
||||
} from '../utils/types';
|
||||
import { ZCommunityComment, ZCommunityQuestionUpdate, ZCreateCommunityQuestion } from '@cio/utils/validation/community';
|
||||
import { currentOrg as currentOrgStore } from '$lib/utils/store/org';
|
||||
import { basePath, isStudentExperience } from '$lib/utils/store/app';
|
||||
|
||||
import type { TCreateCommunityQuestion } from '@cio/utils/validation/community';
|
||||
import { coursesApi } from '$features/course/api/courses.svelte';
|
||||
import { currentCommunityQuestion } from '../utils/store';
|
||||
import { currentOrg as currentOrgStore } from '$lib/utils/store/org';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { get } from 'svelte/store';
|
||||
import { basePath, isStudentExperience } from '$lib/utils/store/app';
|
||||
import { goto } from '$app/navigation';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile as profileStore } from '$lib/utils/store/user';
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
/>
|
||||
<Page.Title>{communityApi.question.title}</Page.Title>
|
||||
</div>
|
||||
<Page.Subtitle>{$t('community.question.page_subtitle')}</Page.Subtitle>
|
||||
{/if}
|
||||
</Page.HeaderContent>
|
||||
{#if communityApi.question.authorId === $profile.id}
|
||||
|
||||
@@ -36,10 +36,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
let isCoursesFetched = $state(false);
|
||||
$effect(() => {
|
||||
if (!$profile.id || !$currentOrg.id) return;
|
||||
if (!$profile.id || !$currentOrg.id || isCoursesFetched) return;
|
||||
|
||||
communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id);
|
||||
isCoursesFetched = true;
|
||||
});
|
||||
|
||||
let filteredQuestions = $derived(
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
GetOrgCoursesRequest,
|
||||
GetOrgCoursesRequestQuery,
|
||||
GetRecommendedCoursesRequest,
|
||||
GetUserEnrolledCoursesRequest,
|
||||
OrgCourses,
|
||||
OrgCoursesPagination,
|
||||
OrgCoursesQuery,
|
||||
RecommendedCourses,
|
||||
UserEnrolledCourses
|
||||
} from '$features/course/types';
|
||||
@@ -15,8 +18,15 @@ import { SvelteSet } from 'svelte/reactivity';
|
||||
*/
|
||||
export class CoursesApi extends BaseApiWithErrors {
|
||||
orgCourses = $state<OrgCourses>([]);
|
||||
orgCoursesPagination = $state<OrgCoursesPagination | null>(null);
|
||||
enrolledCourses = $state<UserEnrolledCourses>([]);
|
||||
recommendedCourses = $state<RecommendedCourses>([]);
|
||||
private activeOrgCoursesRequestController: AbortController | null = null;
|
||||
|
||||
cancelOrgCoursesRequest() {
|
||||
this.activeOrgCoursesRequestController?.abort();
|
||||
this.activeOrgCoursesRequestController = null;
|
||||
}
|
||||
|
||||
/** Keeps list UIs in sync after a course is deleted (server data is updated separately on navigation). */
|
||||
removeCourseFromLists(courseId: string) {
|
||||
@@ -30,26 +40,79 @@ export class CoursesApi extends BaseApiWithErrors {
|
||||
* Org ID is automatically added from currentOrg store
|
||||
*/
|
||||
async getOrgCourses(tagSlugs: string[] = []) {
|
||||
const normalizedTagSlugs = Array.from(new SvelteSet(tagSlugs.map((tag) => tag.trim()).filter(Boolean)));
|
||||
const allCourses: OrgCourses = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
let lastResponse: Awaited<ReturnType<CoursesApi['getOrgCoursesPage']>> | undefined;
|
||||
|
||||
return this.execute<GetOrgCoursesRequest>({
|
||||
while (page <= totalPages) {
|
||||
const response = await this.getOrgCoursesPage({ page, limit: 100 }, tagSlugs);
|
||||
if (!response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
allCourses.push(...(response.data ?? []));
|
||||
totalPages = response.pagination?.totalPages ?? 1;
|
||||
lastResponse = response;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
this.orgCourses = allCourses;
|
||||
this.orgCoursesPagination = lastResponse?.pagination ?? null;
|
||||
|
||||
return lastResponse;
|
||||
}
|
||||
|
||||
async getOrgCoursesPage(
|
||||
query: Partial<OrgCoursesQuery> = {},
|
||||
tagSlugs: string[] = [],
|
||||
options: { abortPrevious?: boolean; signal?: AbortSignal } = {}
|
||||
) {
|
||||
const normalizedTagSlugs = Array.from(new SvelteSet(tagSlugs.map((tag) => tag.trim()).filter(Boolean)));
|
||||
const requestQuery: GetOrgCoursesRequestQuery = {
|
||||
page: String(query.page ?? 1),
|
||||
limit: String(query.limit ?? 20),
|
||||
search: query.search,
|
||||
tags: normalizedTagSlugs.length > 0 ? normalizedTagSlugs.join(',') : undefined
|
||||
};
|
||||
|
||||
let requestSignal = options.signal;
|
||||
let requestController: AbortController | null = null;
|
||||
|
||||
if (options.abortPrevious) {
|
||||
this.cancelOrgCoursesRequest();
|
||||
requestController = new AbortController();
|
||||
this.activeOrgCoursesRequestController = requestController;
|
||||
requestSignal = requestController.signal;
|
||||
}
|
||||
|
||||
const response = await this.execute<GetOrgCoursesRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.courses.$get(
|
||||
normalizedTagSlugs.length > 0
|
||||
? {
|
||||
query: {
|
||||
tags: normalizedTagSlugs.join(',')
|
||||
}
|
||||
}
|
||||
: { query: {} }
|
||||
{
|
||||
query: requestQuery
|
||||
},
|
||||
{
|
||||
init: {
|
||||
signal: requestSignal
|
||||
}
|
||||
}
|
||||
),
|
||||
logContext: 'fetching org courses',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.orgCourses = response.data;
|
||||
}
|
||||
|
||||
this.orgCoursesPagination = response.pagination;
|
||||
}
|
||||
});
|
||||
|
||||
if (requestController && this.activeOrgCoursesRequestController === requestController) {
|
||||
this.activeOrgCoursesRequestController = null;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -180,21 +180,20 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Post updated successfully');
|
||||
// Update local state, preserving author fields from existing feed
|
||||
const index = this.feeds.findIndex((f) => f.id === feedId);
|
||||
if (index !== -1) {
|
||||
const existingFeed = this.feeds[index];
|
||||
// Merge response data with existing feed to preserve author fields
|
||||
this.feeds[index] = {
|
||||
...existingFeed,
|
||||
this.feeds = this.feeds.map((feed) => {
|
||||
if (feed.id !== feedId) {
|
||||
return feed;
|
||||
}
|
||||
|
||||
return {
|
||||
...feed,
|
||||
...response.data,
|
||||
// Preserve author fields from existing feed
|
||||
authorProfileId: existingFeed.authorProfileId,
|
||||
authorFullname: existingFeed.authorFullname,
|
||||
authorUsername: existingFeed.authorUsername,
|
||||
authorAvatarUrl: existingFeed.authorAvatarUrl
|
||||
authorProfileId: feed.authorProfileId,
|
||||
authorFullname: feed.authorFullname,
|
||||
authorUsername: feed.authorUsername,
|
||||
authorAvatarUrl: feed.authorAvatarUrl
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
@@ -232,21 +231,20 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
logContext: 'updating newsfeed reaction',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
// Update local state, preserving author fields from existing feed
|
||||
const index = this.feeds.findIndex((f) => f.id === feedId);
|
||||
if (index !== -1) {
|
||||
const existingFeed = this.feeds[index];
|
||||
// Merge response data with existing feed to preserve author fields
|
||||
this.feeds[index] = {
|
||||
...existingFeed,
|
||||
this.feeds = this.feeds.map((feed) => {
|
||||
if (feed.id !== feedId) {
|
||||
return feed;
|
||||
}
|
||||
|
||||
return {
|
||||
...feed,
|
||||
...response.data,
|
||||
// Preserve author fields from existing feed
|
||||
authorProfileId: existingFeed.authorProfileId,
|
||||
authorFullname: existingFeed.authorFullname,
|
||||
authorUsername: existingFeed.authorUsername,
|
||||
authorAvatarUrl: existingFeed.authorAvatarUrl
|
||||
authorProfileId: feed.authorProfileId,
|
||||
authorFullname: feed.authorFullname,
|
||||
authorUsername: feed.authorUsername,
|
||||
authorAvatarUrl: feed.authorAvatarUrl
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { CourseCard, DEFAULT_COURSE_BANNER_IMAGE } from '@cio/ui';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
|
||||
import pluralize from 'pluralize';
|
||||
@@ -30,9 +28,11 @@
|
||||
isOnLandingPage?: boolean;
|
||||
isLMS?: boolean;
|
||||
isExplore?: boolean;
|
||||
href?: string;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let { course, isOnLandingPage, isLMS, isExplore }: Props = $props();
|
||||
let { course, isOnLandingPage, isLMS, isExplore, href, actions }: Props = $props();
|
||||
|
||||
let {
|
||||
bannerImage,
|
||||
@@ -42,6 +42,7 @@
|
||||
description = '',
|
||||
isPublished = false,
|
||||
totalLessons = 0,
|
||||
totalExercises = 0,
|
||||
totalStudents = 0,
|
||||
currency = 'USD',
|
||||
progressRate = 45,
|
||||
@@ -54,7 +55,7 @@
|
||||
} = $derived({
|
||||
id: course.id,
|
||||
slug: course.slug,
|
||||
bannerImage: course.logo || '/images/classroomio-course-img-template.jpg',
|
||||
bannerImage: course.logo || DEFAULT_COURSE_BANNER_IMAGE,
|
||||
title: course.title,
|
||||
type: course.type,
|
||||
description: course.description,
|
||||
@@ -66,6 +67,10 @@
|
||||
},
|
||||
currency: course.currency,
|
||||
totalLessons: course.lessonCount,
|
||||
totalExercises: (() => {
|
||||
const c = course as { exerciseCount?: number };
|
||||
return typeof c.exerciseCount === 'number' ? c.exerciseCount : 0;
|
||||
})(),
|
||||
progressRate: (() => {
|
||||
const c = course as { exerciseCount?: number; exercisesCompleted?: number };
|
||||
if (typeof c.exerciseCount === 'number' && typeof c.exercisesCompleted === 'number') {
|
||||
@@ -122,91 +127,104 @@
|
||||
let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount));
|
||||
|
||||
let courseUrl = $derived(
|
||||
isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`
|
||||
href ?? (isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`)
|
||||
);
|
||||
|
||||
const typeBadge = $derived(
|
||||
type && COURSE_TAG[type]
|
||||
? {
|
||||
label: COURSE_TAG[type].label,
|
||||
icon: COURSE_TAG[type].icon,
|
||||
iconClass: COURSE_TAG[type].iconStyle
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
<Item.Root variant="outline" class="group relative max-w-[320px] p-3!">
|
||||
{#snippet child({ props })}
|
||||
<a href={resolve(courseUrl, {})} {...props}>
|
||||
{#if !isLMS && !isOnLandingPage}
|
||||
<CardDropdown {id} {title} {description} />
|
||||
{/if}
|
||||
<CourseCard href={resolve(courseUrl, {})} {title} {description} {typeBadge} class="group relative">
|
||||
{#snippet media()}
|
||||
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
|
||||
{/snippet}
|
||||
|
||||
<Item.Header>
|
||||
<Item.Media variant="image" class="relative h-50! w-full!">
|
||||
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
|
||||
{#snippet overlay()}
|
||||
{#if actions}
|
||||
{@render actions()}
|
||||
{:else if !isLMS && !isOnLandingPage}
|
||||
<CardDropdown {id} {title} {description} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if type}
|
||||
{@const tag = COURSE_TAG[type]}
|
||||
<Badge class="absolute bottom-2 left-2 z-10 flex items-center rounded-md! capitalize" variant="secondary">
|
||||
<tag.icon class={tag.iconStyle} />
|
||||
{tag.label}
|
||||
</Badge>
|
||||
{/if}
|
||||
</Item.Media>
|
||||
</Item.Header>
|
||||
{#snippet tags()}
|
||||
{#if !isLMS}
|
||||
<CourseTagsOverflow tags={courseTags} variant="card" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-2 min-h-14 text-base!">
|
||||
{title}
|
||||
</Item.Title>
|
||||
|
||||
<Item.Description class="min-h-[63px]">{description}</Item.Description>
|
||||
{#if !isLMS}
|
||||
<CourseTagsOverflow tags={courseTags} variant="card" />
|
||||
{/if}
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
<div class="flex justify-between {isLMS && 'items-center'} w-full">
|
||||
<div class="w-[50%]">
|
||||
<p class="text-xs {!isLMS && 'pl-2'} dark:text-white">
|
||||
{#snippet footer()}
|
||||
<div class="flex justify-between {isLMS && 'items-center'} w-full">
|
||||
<div class="w-[60%]">
|
||||
{#if isLMS || isOnLandingPage}
|
||||
<p class="text-xs {!isLMS && 'pl-2'} flex gap-1 dark:text-white">
|
||||
<span>
|
||||
{totalLessons}
|
||||
{$t('courses.course_card.lessons_number')}
|
||||
</p>
|
||||
<div class="py-2 text-xs">
|
||||
{#if isOnLandingPage}
|
||||
<span class="px-2">
|
||||
{#if !cost}
|
||||
{$t('course.navItem.landing_page.pricing_section.free')}
|
||||
{:else if pricingData.showDiscount}
|
||||
{formatter.format(cost)}
|
||||
<span class="line-through">
|
||||
{formatter?.format(pricingData?.cost ?? 0)}
|
||||
</span>
|
||||
{:else}
|
||||
{formatter.format(cost)}
|
||||
{/if}
|
||||
</span>
|
||||
&
|
||||
<span>
|
||||
{pluralize($t('courses.course_card.exercise'), totalExercises, true)}
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
<div class="py-2 text-xs">
|
||||
{#if isOnLandingPage}
|
||||
<span class="px-2">
|
||||
{#if !cost}
|
||||
{$t('course.navItem.landing_page.pricing_section.free')}
|
||||
{:else if pricingData.showDiscount}
|
||||
{formatter.format(cost)}
|
||||
<span class="line-through">
|
||||
{formatter?.format(pricingData?.cost ?? 0)}
|
||||
</span>
|
||||
{:else if isLMS}
|
||||
{#if !isExplore}
|
||||
<div class="flex w-3/4 items-center gap-2">
|
||||
<Progress value={progressRate} />
|
||||
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<CoursePublishBadge {isPublished} />
|
||||
{formatter.format(cost)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLMS}
|
||||
<Button variant="outline">
|
||||
{isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')}
|
||||
|
||||
<ArrowRightIcon class="custom" />
|
||||
</Button>
|
||||
{:else if !isOnLandingPage}
|
||||
<div class="flex flex-col justify-between">
|
||||
<p class="pl-2 text-xs dark:text-white">
|
||||
{pluralize($t('courses.course_card.students'), totalStudents, true)}
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
{:else if isLMS}
|
||||
{#if !isExplore}
|
||||
<div class="flex w-3/4 items-center gap-2">
|
||||
<Progress value={progressRate} />
|
||||
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<CoursePublishBadge {isPublished} />
|
||||
{/if}
|
||||
</div>
|
||||
</Item.Content>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if isLMS}
|
||||
<Button variant="outline">
|
||||
{isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')}
|
||||
|
||||
<ArrowRightIcon class="custom" />
|
||||
</Button>
|
||||
{:else if !isOnLandingPage}
|
||||
<div class="flex flex-col justify-end gap-1 text-right">
|
||||
<p class="pl-2 text-xs whitespace-nowrap dark:text-white">
|
||||
<span>
|
||||
{totalLessons}
|
||||
{$t('courses.course_card.lessons_number')}
|
||||
</span>
|
||||
&
|
||||
<span>
|
||||
{pluralize($t('courses.course_card.exercise'), totalExercises, true)}
|
||||
</span>
|
||||
</p>
|
||||
<p class="pl-2 text-xs dark:text-white">
|
||||
{pluralize($t('courses.course_card.students'), totalStudents, true)}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Item.Root>
|
||||
</CourseCard>
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
COURSE_SORT_OPTIONS,
|
||||
CourseSortOrder,
|
||||
DEFAULT_COURSE_SORT,
|
||||
DEFAULT_SORT_ORDER,
|
||||
type CourseSortBy
|
||||
type CourseSortBy,
|
||||
type CourseSortOrder
|
||||
} from '../utils/constants';
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import * as ToggleGroup from '@cio/ui/base/toggle-group';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import FilterIcon from '@lucide/svelte/icons/filter';
|
||||
import { SortPopover } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface FilterTag {
|
||||
@@ -54,7 +49,9 @@
|
||||
onClearFilters = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
const translatedSortOptions = $derived(
|
||||
sortOptions.map((option) => ({ value: option.value, label: $t(option.label) }))
|
||||
);
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0
|
||||
@@ -65,117 +62,58 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open={isOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<div class="relative">
|
||||
<IconButton {...props} aria-label={$t('courses.tag_filters.filter')} tooltip={$t('courses.tag_filters.filter')}>
|
||||
{#if isFiltering}
|
||||
<Spinner class="size-4" />
|
||||
{:else}
|
||||
<FilterIcon size={16} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
{#if hasActiveFilters}
|
||||
<span
|
||||
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content align="end" class="w-[360px] p-3">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-semibold">{$t('courses.tag_filters.popover_title')}</p>
|
||||
<Button variant="link" class="h-auto p-0" onclick={onClearFilters} disabled={!hasActiveFilters}>
|
||||
{$t('courses.tag_filters.clear_all')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('courses.tag_filters.sort')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each sortOptions as option (option.value)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={sortKey === option.value ? 'secondary' : 'outline'}
|
||||
onclick={() => (sortKey = option.value)}
|
||||
>
|
||||
{$t(option.label)}
|
||||
</Button>
|
||||
<SortPopover
|
||||
sortOptions={translatedSortOptions}
|
||||
bind:sortKey
|
||||
bind:selectedOrder
|
||||
defaultSortKey={DEFAULT_COURSE_SORT}
|
||||
defaultSortOrder={DEFAULT_SORT_ORDER}
|
||||
{isFiltering}
|
||||
{hasActiveFilters}
|
||||
{onClearFilters}
|
||||
>
|
||||
{#snippet additionalContent()}
|
||||
<div class="space-y-3">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
||||
{#if tagGroups.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
|
||||
{:else}
|
||||
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
|
||||
{#each tagGroups as group (group.id)}
|
||||
{#if group.tags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{group.name}</p>
|
||||
{#if group.description}
|
||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<label
|
||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isTagSelected(tag.slug)}
|
||||
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm">{tag.name}</span>
|
||||
</div>
|
||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('courses.tag_filters.order')}
|
||||
</p>
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
value={selectedOrder}
|
||||
onValueChange={(value) => {
|
||||
if (value === CourseSortOrder.Asc || value === CourseSortOrder.Desc) {
|
||||
selectedOrder = value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroup.Item value={CourseSortOrder.Asc}>{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value={CourseSortOrder.Desc}>{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
||||
{#if tagGroups.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
|
||||
{:else}
|
||||
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
|
||||
{#each tagGroups as group (group.id)}
|
||||
{#if group.tags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{group.name}</p>
|
||||
{#if group.description}
|
||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<label
|
||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isTagSelected(tag.slug)}
|
||||
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm">{tag.name}</span>
|
||||
</div>
|
||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{/snippet}
|
||||
</SortPopover>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<script lang="ts">
|
||||
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { Image } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
import { buildCoursePlaceholderAvatarUrl } from '$features/course/utils/course-list-row-utils';
|
||||
import CourseContentIcon from './course-content-icon.svelte';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
logo?: string | null;
|
||||
type?: string | null;
|
||||
description?: string;
|
||||
isPublished?: boolean;
|
||||
lessonCount?: number;
|
||||
exerciseCount?: number;
|
||||
totalStudents?: number;
|
||||
updatedAt?: string | null;
|
||||
tags?: Tag[];
|
||||
isExplore?: boolean;
|
||||
isLMS?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
slug = '',
|
||||
title,
|
||||
logo = null,
|
||||
type,
|
||||
description = '',
|
||||
isPublished = false,
|
||||
lessonCount = 0,
|
||||
exerciseCount = 0,
|
||||
totalStudents = 0,
|
||||
updatedAt,
|
||||
tags = [],
|
||||
isExplore = false,
|
||||
isLMS = false
|
||||
}: Props = $props();
|
||||
|
||||
const titleInitial = $derived(title.trim().charAt(0).toUpperCase() || 'C');
|
||||
|
||||
const bannerImage = $derived(logo?.trim() ? logo : '/images/classroomio-course-img-template.jpg');
|
||||
|
||||
const gridTemplateColumns = $derived(
|
||||
isLMS
|
||||
? '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem'
|
||||
: '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem 2.5rem'
|
||||
);
|
||||
|
||||
const studentPlaceholderAvatarUrls = $derived.by(() => [
|
||||
buildCoursePlaceholderAvatarUrl(`${id}:student:0`),
|
||||
buildCoursePlaceholderAvatarUrl(`${id}:student:1`)
|
||||
]);
|
||||
|
||||
const emptyStateAvatarUrl = $derived(buildCoursePlaceholderAvatarUrl(`${id}:student:empty`));
|
||||
|
||||
const typeLabel = $derived(type ? $t(`course.navItem.settings.${type.toLowerCase()}`) : null);
|
||||
|
||||
const updatedLabel = $derived.by(() => {
|
||||
if (!updatedAt) return null;
|
||||
const d = new Date(updatedAt);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return `Updated ${d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
|
||||
});
|
||||
|
||||
function handleRowClick() {
|
||||
if (isExplore) {
|
||||
goto(resolve(`/course/${slug}`, {}));
|
||||
return;
|
||||
}
|
||||
if (isLMS) {
|
||||
goto(resolve(`/courses/${id}/lessons?next=true`, {}));
|
||||
return;
|
||||
}
|
||||
goto(resolve(`/courses/${id}`, {}));
|
||||
}
|
||||
|
||||
function handleOpen(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
goto(resolve(`/courses/${id}`, {}));
|
||||
}
|
||||
|
||||
function handleClone(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
$copyCourseModal.open = true;
|
||||
$copyCourseModal.id = id;
|
||||
$copyCourseModal.title = title;
|
||||
$copyCourseModal.description = description;
|
||||
}
|
||||
|
||||
function handleDelete(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
$deleteCourseModal.open = true;
|
||||
$deleteCourseModal.id = id;
|
||||
$deleteCourseModal.title = title;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ResourceListRow.Root
|
||||
variant="default"
|
||||
size="sm"
|
||||
align="start"
|
||||
class="cursor-pointer py-4!"
|
||||
onclick={handleRowClick}
|
||||
role="row"
|
||||
>
|
||||
<div class="grid w-full items-start gap-3" style="grid-template-columns: {gridTemplateColumns}">
|
||||
<!-- Banner -->
|
||||
<div
|
||||
class="ui:border-border bg-muted relative size-28 shrink-0 overflow-hidden rounded-md border"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Image src={bannerImage} alt="" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
|
||||
<!-- Title / type / updated -->
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<p class="line-clamp-2 text-base">{title}</p>
|
||||
{#if typeLabel}
|
||||
<p class="ui:text-muted-foreground mt-0.5 text-sm">{typeLabel}</p>
|
||||
{/if}
|
||||
{#if updatedLabel}
|
||||
<p class="ui:text-muted-foreground mt-0.5 text-xs">{updatedLabel}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Published badge -->
|
||||
<div>
|
||||
{#if isPublished}
|
||||
<Badge variant="success" class="whitespace-nowrap">
|
||||
{$t('courses.course_card.published')}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="secondary" class="whitespace-nowrap">
|
||||
{$t('courses.course_card.unpublished')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-1">
|
||||
{#if tags.length === 0}
|
||||
<span class="ui:text-muted-foreground text-xs">—</span>
|
||||
{:else}
|
||||
{#each tags as tag (tag.id)}
|
||||
<Badge variant="outline" class="max-w-[140px] truncate">
|
||||
<span
|
||||
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Lessons / exercises -->
|
||||
<div class="flex flex-col gap-1 tabular-nums">
|
||||
<p class="flex items-center gap-1.5 text-sm" aria-label="{lessonCount} lessons">
|
||||
<CourseContentIcon type={ContentType.Lesson} size={14} />
|
||||
<span class="font-medium">{lessonCount}</span>
|
||||
</p>
|
||||
<p class="flex items-center gap-1.5 text-sm" aria-label="{exerciseCount} exercises">
|
||||
<CourseContentIcon type={ContentType.Exercise} size={14} />
|
||||
<span class="font-medium">{exerciseCount}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Students -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#if totalStudents > 0}
|
||||
{#if totalStudents < 3}
|
||||
<div class="flex items-center gap-1">
|
||||
<Avatar.Root class="ui:border-background size-6 border-2">
|
||||
<Avatar.Image src={studentPlaceholderAvatarUrls[0]} alt="" loading="lazy" decoding="async" />
|
||||
<Avatar.Fallback aria-hidden="true">
|
||||
{titleInitial}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="text-xs tabular-nums">{totalStudents}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex -space-x-2">
|
||||
{#each studentPlaceholderAvatarUrls as avatarUrl, slotIndex (slotIndex)}
|
||||
<Avatar.Root class="ui:border-background size-6 border-2">
|
||||
<Avatar.Image src={avatarUrl} alt="" loading="lazy" decoding="async" />
|
||||
<Avatar.Fallback aria-hidden="true">
|
||||
{titleInitial}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{/each}
|
||||
<Avatar.Root class="ui:border-background size-6 border-2">
|
||||
<Avatar.Fallback class="text-[10px]">+{totalStudents - 2}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<Avatar.Root class="size-6">
|
||||
<Avatar.Image src={emptyStateAvatarUrl} alt="" loading="lazy" decoding="async" />
|
||||
<Avatar.Fallback aria-hidden="true">
|
||||
{titleInitial}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="text-xs">0</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
{#if !isLMS}
|
||||
<div class="flex justify-end">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
aria-label="Course actions"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVerticalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={handleOpen}>
|
||||
{$t('courses.course_card.context_menu.open')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleClone}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item variant="destructive" onclick={handleDelete}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ResourceListRow.Root>
|
||||
@@ -15,7 +15,7 @@
|
||||
const flipDurationMs = 300;
|
||||
|
||||
interface Question {
|
||||
id: number;
|
||||
id: number | string;
|
||||
name: string;
|
||||
type: number;
|
||||
}
|
||||
@@ -36,23 +36,36 @@
|
||||
items = e.detail.items;
|
||||
}
|
||||
|
||||
function handleDndFinalize(e) {
|
||||
items = e.detail.items;
|
||||
function applyItemOrder(nextItems: Array<Question>) {
|
||||
questionnaire.update((currentQuestionnaire) => {
|
||||
const nextOrderById = new Map(nextItems.map((item, index) => [String(item.id), index + 1]));
|
||||
const nextQuestions = currentQuestionnaire.questions.map((question) => {
|
||||
const nextOrder = nextOrderById.get(String(question.id));
|
||||
if (nextOrder == null) {
|
||||
return question;
|
||||
}
|
||||
|
||||
return {
|
||||
...question,
|
||||
isDirty: true,
|
||||
order: nextOrder
|
||||
};
|
||||
});
|
||||
const activeQuestions = nextQuestions
|
||||
.filter((question) => !question.deletedAt)
|
||||
.sort((leftQuestion, rightQuestion) => (leftQuestion.order ?? 0) - (rightQuestion.order ?? 0));
|
||||
const deletedQuestions = nextQuestions.filter((question) => question.deletedAt);
|
||||
|
||||
return {
|
||||
...currentQuestionnaire,
|
||||
questions: [...activeQuestions, ...deletedQuestions]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
items.forEach((item, index) => {
|
||||
const order = index + 1;
|
||||
$questionnaire.questions.some((q) => {
|
||||
if (q.id === item.id) {
|
||||
q.isDirty = true;
|
||||
q.order = order;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
$questionnaire.questions = $questionnaire.questions.sort((a, b) => a.order - b.order);
|
||||
$questionnaireOrder.open = false;
|
||||
function handleDndFinalize(e) {
|
||||
items = e.detail.items;
|
||||
applyItemOrder(e.detail.items);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -63,7 +76,9 @@
|
||||
<Dialog.Root
|
||||
bind:open={$questionnaireOrder.open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) handleClose();
|
||||
if (!isOpen) {
|
||||
applyItemOrder(items);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-96">
|
||||
@@ -81,7 +96,7 @@
|
||||
}}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="w-full"
|
||||
class="max-h-[300px] w-full"
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<div
|
||||
|
||||
@@ -176,6 +176,29 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'numeric.review.out_of_range': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.out_of_range'
|
||||
),
|
||||
'star.edit.correct_value_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.correct_value_label'
|
||||
),
|
||||
'star.edit.correct_value_info': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.correct_value_info'
|
||||
),
|
||||
'star.edit.max_stars_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.max_stars_label'
|
||||
),
|
||||
'star.edit.max_stars_info': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.max_stars_info'
|
||||
),
|
||||
'star.edit.correct_value_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.correct_value_placeholder'
|
||||
),
|
||||
'star.edit.max_stars_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.max_stars_placeholder'
|
||||
),
|
||||
'star.take.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.star.take.helper'),
|
||||
'star.take.star_sr': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.star.take.star_sr'),
|
||||
'star.preview.correct_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.preview.correct_label'
|
||||
),
|
||||
'fill_blank.edit.accepted_answers_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.edit.accepted_answers_placeholder'
|
||||
),
|
||||
@@ -188,6 +211,54 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'fill_blank.review.your_answer_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.review.your_answer_label'
|
||||
),
|
||||
'word_bank.edit.template_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.template_label'
|
||||
),
|
||||
'word_bank.edit.template_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.template_placeholder'
|
||||
),
|
||||
'word_bank.edit.template_helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.template_helper'
|
||||
),
|
||||
'word_bank.edit.no_blanks_warning': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.no_blanks_warning'
|
||||
),
|
||||
'word_bank.edit.correct_answers_heading': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.correct_answers_heading'
|
||||
),
|
||||
'word_bank.edit.blank_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.blank_label'
|
||||
),
|
||||
'word_bank.edit.correct_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.correct_placeholder'
|
||||
),
|
||||
'word_bank.edit.distractors_heading': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.distractors_heading'
|
||||
),
|
||||
'word_bank.edit.distractor_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.distractor_placeholder'
|
||||
),
|
||||
'word_bank.edit.add_distractor': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.add_distractor'
|
||||
),
|
||||
'word_bank.take.helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.take.helper'
|
||||
),
|
||||
'word_bank.take.bank_heading': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.take.bank_heading'
|
||||
),
|
||||
'word_bank.take.clear_blank_sr': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.take.clear_blank_sr'
|
||||
),
|
||||
'word_bank.preview.filled_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.preview.filled_label'
|
||||
),
|
||||
'word_bank.review.your_answer_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.review.your_answer_label'
|
||||
),
|
||||
'word_bank.review.correct_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.review.correct_label'
|
||||
),
|
||||
'file_upload.edit.accepted_types_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder'
|
||||
),
|
||||
|
||||
@@ -235,6 +235,7 @@
|
||||
case 'RADIO':
|
||||
case 'TRUE_FALSE':
|
||||
case 'NUMERIC':
|
||||
case 'STAR':
|
||||
return true;
|
||||
case 'CHECKBOX':
|
||||
return answerValue.optionIds.length > 0;
|
||||
@@ -244,6 +245,10 @@
|
||||
return answerValue.text.trim().length > 0;
|
||||
case 'FILL_BLANK':
|
||||
return answerValue.values.length > 0;
|
||||
case 'WORD_BANK': {
|
||||
const filled = answerValue.filledBlanks ?? [];
|
||||
return filled.length > 0 && filled.every((v) => String(v).trim().length > 0);
|
||||
}
|
||||
case 'FILE_UPLOAD':
|
||||
return !!answerValue.fileKey;
|
||||
case 'MATCHING':
|
||||
@@ -470,9 +475,12 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished}
|
||||
<div class="mb-6">
|
||||
<Progress value={$questionnaireMetaData.progressValue} />
|
||||
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished && $questionnaireMetaData.currentQuestionIndex > 0}
|
||||
<div class="mb-6 flex min-w-0 items-center gap-3">
|
||||
<span class="ui:text-muted-foreground shrink-0 text-sm tabular-nums">
|
||||
{$questionnaireMetaData.currentQuestionIndex}/{$questionnaire.questions.length}
|
||||
</span>
|
||||
<Progress class="min-w-0 flex-1" value={$questionnaireMetaData.progressValue} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -605,7 +613,9 @@
|
||||
{/if}
|
||||
|
||||
<Preview
|
||||
questions={$questionnaire.questions.sort((a, b) => a.order - b.order)}
|
||||
questions={[...$questionnaire.questions].sort(
|
||||
(leftQuestion, rightQuestion) => leftQuestion.order - rightQuestion.order
|
||||
)}
|
||||
questionnaireMetaData={$questionnaireMetaData}
|
||||
grades={$questionnaireMetaData.grades}
|
||||
disableGrading={true}
|
||||
|
||||
@@ -7,6 +7,7 @@ export { default as CourseFilterPopover } from './course-filter-popover.svelte';
|
||||
export { default as CourseTagPicker } from './course-tag-picker.svelte';
|
||||
export { default as CreateCourseButton } from './create-course-button.svelte';
|
||||
export { default as CourseList } from './list.svelte';
|
||||
export { default as CourseListRow } from './course-list-row.svelte';
|
||||
export { default as CourseTagsOverflow } from './course-tags-overflow.svelte';
|
||||
export { default as CourseListLoader } from './list-loader.svelte';
|
||||
export { default as NewCourseModal } from './new-course-modal.svelte';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import UploadVideo from './upload-video.svelte';
|
||||
import EmbedLink from './embed-link.svelte';
|
||||
import LibraryVideo from './library-video.svelte';
|
||||
import GoogleDriveVideo from './google-drive-video.svelte';
|
||||
import * as CONSTANTS from './constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
@@ -57,6 +58,9 @@
|
||||
<UnderlineTabs.Content value="4" class="mt-3">
|
||||
<LibraryVideo {lessonId} />
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value="5" class="mt-3">
|
||||
<GoogleDriveVideo {lessonId} />
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
|
||||
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
|
||||
import LibraryIcon from '@lucide/svelte/icons/library';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import YoutubeIcon from '@lucide/svelte/icons/youtube';
|
||||
@@ -23,5 +24,10 @@ export const videoTabs = [
|
||||
value: 4,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.library',
|
||||
icon: LibraryIcon
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.google_drive',
|
||||
icon: HardDriveIcon
|
||||
}
|
||||
];
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Lesson, LessonVideoType } from '$features/course/utils/types';
|
||||
import type { TAssetProvider } from '@cio/utils/validation/assets';
|
||||
import { lessonApi } from '$features/course/api';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { copyToClipboard, removeVideo } from '$lib/utils/functions/formatYoutubeVideo';
|
||||
import {
|
||||
googleDrivePreviewUrl,
|
||||
googleDriveThumbnailUrl,
|
||||
pickGoogleDriveVideo
|
||||
} from '$lib/utils/functions/google-drive-picker';
|
||||
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
interface Props {
|
||||
lessonId?: string;
|
||||
}
|
||||
|
||||
let { lessonId = '' }: Props = $props();
|
||||
|
||||
let isOpeningPicker = $state(false);
|
||||
let errorMessage = $state('');
|
||||
|
||||
const clientId = $derived((env.PUBLIC_GOOGLE_PICKER_CLIENT_ID ?? '').trim());
|
||||
const apiKey = $derived((env.PUBLIC_GOOGLE_PICKER_API_KEY ?? '').trim());
|
||||
const isConfigured = $derived(Boolean(clientId && apiKey));
|
||||
|
||||
function isGoogleDriveLessonVideo(video: NonNullable<Lesson['videos']>[number]): boolean {
|
||||
return (video as { type: string }).type === 'google_drive';
|
||||
}
|
||||
|
||||
async function openPicker() {
|
||||
errorMessage = '';
|
||||
|
||||
if (!isConfigured) {
|
||||
errorMessage = $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_not_configured');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lessonApi.lesson) return;
|
||||
|
||||
isOpeningPicker = true;
|
||||
|
||||
try {
|
||||
const picked = await pickGoogleDriveVideo({ clientId, apiKey });
|
||||
|
||||
if (!picked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewUrl = googleDrivePreviewUrl(picked.fileId);
|
||||
const thumbnailUrl = googleDriveThumbnailUrl(picked.fileId);
|
||||
const createdAt = new Date().toISOString();
|
||||
const videoMetadata = {
|
||||
createdAt,
|
||||
title: picked.name,
|
||||
googleDriveFileId: picked.fileId,
|
||||
mimeType: picked.mimeType,
|
||||
thumbnailUrl
|
||||
};
|
||||
|
||||
const existingCount = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0;
|
||||
|
||||
const asset = await mediaApi.createAsset({
|
||||
kind: 'video',
|
||||
provider: 'google_drive' as TAssetProvider,
|
||||
storageProvider: 'external',
|
||||
storageKey: picked.fileId,
|
||||
sourceUrl: previewUrl,
|
||||
isExternal: true,
|
||||
title: picked.name,
|
||||
thumbnailUrl,
|
||||
metadata: videoMetadata
|
||||
});
|
||||
|
||||
if (asset && lessonId) {
|
||||
await mediaApi.attachAsset(asset.id, {
|
||||
targetType: 'lesson',
|
||||
targetId: lessonId,
|
||||
slotType: 'lesson_video',
|
||||
position: existingCount
|
||||
});
|
||||
}
|
||||
|
||||
const newVideo = {
|
||||
type: 'google_drive' as LessonVideoType,
|
||||
link: previewUrl,
|
||||
assetId: asset?.id,
|
||||
fileName: picked.name,
|
||||
metadata: videoMetadata
|
||||
};
|
||||
|
||||
lessonApi.updateLessonState('videos', [newVideo as unknown as NonNullable<Lesson['videos']>[number]], {
|
||||
append: true
|
||||
});
|
||||
lessonApi.isDirty = true;
|
||||
} catch (error) {
|
||||
console.error('Google Drive picker error:', error);
|
||||
errorMessage = $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_error');
|
||||
} finally {
|
||||
isOpeningPicker = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_hint')}
|
||||
</p>
|
||||
|
||||
<Button type="button" onclick={openPicker} disabled={isOpeningPicker} class="w-fit">
|
||||
{isOpeningPicker
|
||||
? $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_opening')
|
||||
: $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_choose')}
|
||||
</Button>
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="ui:text-destructive text-sm">{errorMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="mt-4 pl-2 text-sm">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}:
|
||||
<strong>
|
||||
{lessonApi.lesson?.videos?.filter(isGoogleDriveLessonVideo).length || 0}
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<div class="">
|
||||
{#each lessonApi.lesson?.videos || [] as video, index (video.assetId ?? `${video.type}-${index}-${video.link}`)}
|
||||
{#if isGoogleDriveLessonVideo(video)}
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge class="max-w-md truncate" variant="secondary">
|
||||
{video.fileName || video.link}
|
||||
</Badge>
|
||||
<IconButton onclick={() => copyToClipboard(video.link)}>
|
||||
<CopyIcon size={16} />
|
||||
</IconButton>
|
||||
<IconButton onclick={() => removeVideo(index)}>
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -9,6 +9,8 @@ type VideoMetadata = {
|
||||
duration?: number;
|
||||
createdAt?: string;
|
||||
thumbnailUrl?: string;
|
||||
googleDriveFileId?: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -23,6 +25,13 @@ export function getVideoThumbnailUrl(video: LessonVideo): string | null {
|
||||
const meta = video.metadata as VideoMetadata | undefined;
|
||||
if (meta?.thumbnailUrl && typeof meta.thumbnailUrl === 'string') return meta.thumbnailUrl;
|
||||
}
|
||||
if (video.type === 'google_drive') {
|
||||
const meta = video.metadata as VideoMetadata | undefined;
|
||||
if (meta?.thumbnailUrl && typeof meta.thumbnailUrl === 'string') return meta.thumbnailUrl;
|
||||
if (meta?.googleDriveFileId && typeof meta.googleDriveFileId === 'string') {
|
||||
return `https://drive.google.com/thumbnail?id=${meta.googleDriveFileId}&sz=w400`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -67,6 +76,7 @@ export function getVideoTitle(video: LessonVideo, index: number): string {
|
||||
|
||||
if (video.type === 'youtube') return 'YouTube video';
|
||||
if (video.type === 'generic') return 'Embedded video';
|
||||
if (video.type === 'google_drive') return 'Google Drive video';
|
||||
|
||||
return `Video ${index + 1}`;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Image } from '$features/ui';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
|
||||
import VideoCardDropdown from './video-card-dropdown.svelte';
|
||||
import {
|
||||
getVideoThumbnailUrl,
|
||||
@@ -67,15 +67,19 @@
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-2 min-h-[44px] text-base!" {title}>
|
||||
{#if isYoutubeWithLink}
|
||||
<a
|
||||
href={video.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:text-foreground hover:ui:underline focus:ui:underline focus:ui:outline-none inline-flex items-center gap-1.5"
|
||||
>
|
||||
<span class="line-clamp-2">{title}</span>
|
||||
<ExternalLinkIcon class="ui:text-muted-foreground size-3.5 shrink-0" aria-hidden="true" />
|
||||
</a>
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<a
|
||||
href={video.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:text-foreground hover:ui:underline focus:ui:underline focus:ui:outline-none inline-flex items-center gap-1.5"
|
||||
>
|
||||
<span class="line-clamp-2">{title}</span>
|
||||
<ExternalLinkIcon {isHovered} size={14} class="ui:text-muted-foreground shrink-0" ariaHidden={true} />
|
||||
</a>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{:else}
|
||||
{title}
|
||||
{/if}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
totalStudents?: number;
|
||||
isExplore?: boolean;
|
||||
isLMS?: boolean;
|
||||
href?: string;
|
||||
showActions?: boolean;
|
||||
tags?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -40,10 +42,17 @@
|
||||
totalStudents = 0,
|
||||
isExplore = false,
|
||||
isLMS = false,
|
||||
href,
|
||||
showActions = true,
|
||||
tags = []
|
||||
}: Props = $props();
|
||||
|
||||
function handleRowClick() {
|
||||
if (href) {
|
||||
goto(resolve(href, {}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExplore) {
|
||||
goto(resolve(`/course/${slug}`, {}));
|
||||
return;
|
||||
@@ -104,7 +113,7 @@
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !isLMS}
|
||||
{#if !isLMS && showActions}
|
||||
<Table.Cell class="text-center">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
|
||||
import type { Feed } from '$features/course/utils/types';
|
||||
import { newsfeedApi } from '$features/course/api';
|
||||
import { getNewsfeedReactionCounts, getSelectedNewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
|
||||
|
||||
import DeleteConfirmation from './delete-confirmation.svelte';
|
||||
import type { NewsfeedCommentsByFeedId } from '$features/course/api';
|
||||
import Header from './header.svelte';
|
||||
import Reactions from './reactions.svelte';
|
||||
import { NewsfeedReactions } from '@cio/ui/custom/newsfeed-reactions';
|
||||
import Comments from './comments.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -64,6 +65,9 @@
|
||||
deleteFeed(feed.id);
|
||||
};
|
||||
|
||||
const reactionCounts = $derived(getNewsfeedReactionCounts(feed.reaction));
|
||||
const selectedReactionType = $derived(getSelectedNewsfeedReactionType(feed.reaction, author.id));
|
||||
|
||||
onMount(() => {
|
||||
if (isActive) {
|
||||
const el = document.getElementById(feed.id);
|
||||
@@ -84,7 +88,14 @@
|
||||
>
|
||||
<Header {feed} {onPin} onEdit={openEditFeed} onRequestDelete={() => (isDeleteFeedModal = true)} />
|
||||
|
||||
<Reactions {feed} authorId={author.id} {isReacting} onReact={handleReact} />
|
||||
<div class="px-3 pt-1 pb-2">
|
||||
<NewsfeedReactions
|
||||
{reactionCounts}
|
||||
{selectedReactionType}
|
||||
disabled={isReacting || !author.id}
|
||||
onReactionToggle={handleReact}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Comments
|
||||
{courseId}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
@@ -24,11 +26,17 @@
|
||||
<div class="mb-2 flex justify-between">
|
||||
<span class="flex items-center gap-3">
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={feed.authorAvatarUrl || ''}
|
||||
alt={$t('course.navItem.news_feed.user_avatar_alt')}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
<Avatar.Root class="h-full w-full">
|
||||
<Avatar.Image
|
||||
src={feed.authorAvatarUrl ?? ''}
|
||||
alt={$t('course.navItem.news_feed.user_avatar_alt')}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<Avatar.Fallback aria-hidden="true">
|
||||
<UserIcon class="text-muted-foreground h-5 w-5" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</div>
|
||||
<span>
|
||||
<p class="text-base font-semibold capitalize">{feed.authorFullname || ''}</p>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import type { Feed } from '$features/course/utils/types';
|
||||
|
||||
interface Props {
|
||||
feed: Feed;
|
||||
authorId: string;
|
||||
isReacting?: boolean;
|
||||
onReact: (reactionType: string) => void;
|
||||
}
|
||||
|
||||
let { feed, authorId, isReacting = false, onReact }: Props = $props();
|
||||
|
||||
const reactions: Record<string, string> = {
|
||||
smile: '😀',
|
||||
thumbsup: '👍',
|
||||
thumbsdown: '👎',
|
||||
clap: '👏'
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Reactions -->
|
||||
<div class="flex items-center gap-4 px-3">
|
||||
<div class="flex items-center gap-1">
|
||||
{#each Object.keys(feed.reaction || {}) as reactionType}
|
||||
{#if reactions[reactionType]}
|
||||
{@const isAuthorReacted = (feed.reaction?.[reactionType] || []).includes(authorId)}
|
||||
<Button
|
||||
variant={isAuthorReacted ? 'light-default' : 'ghost'}
|
||||
size="icon-sm"
|
||||
class="size-unset! px-5! py-0!"
|
||||
disabled={isReacting}
|
||||
onclick={() => onReact(reactionType)}
|
||||
>
|
||||
{reactions[reactionType]}
|
||||
{#if feed.reaction?.[reactionType]?.length >= 1}
|
||||
<span class="text-xs">{feed.reaction?.[reactionType]?.length}</span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,12 +13,10 @@
|
||||
import { peopleApi } from '$features/course/api';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import type { OrgStudent, Tutor } from './types';
|
||||
|
||||
import TutorSelectSection from './tutor-select-section.svelte';
|
||||
import ExistingStudentsSection from './existing-students-section.svelte';
|
||||
import BulkEmailSection from './bulk-email-section.svelte';
|
||||
import type { OrgStudent, Tutor } from '$features/people/utils/types';
|
||||
import { TutorSelectSection, ExistingStudentsSection, BulkEmailSection } from '$features/people/components';
|
||||
|
||||
let tutors = $state<Tutor[]>([]);
|
||||
let selectedIds = $state<string[]>([]);
|
||||
@@ -87,6 +85,56 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStudentSearch(value: string) {
|
||||
const orgId = $currentOrg.id;
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingStudents = true;
|
||||
|
||||
try {
|
||||
await orgApi.getOrgAudience(
|
||||
orgId,
|
||||
{
|
||||
...DEFAULT_ORG_AUDIENCE_QUERY,
|
||||
search: value.trim() || undefined
|
||||
},
|
||||
{ abortPrevious: true }
|
||||
);
|
||||
} finally {
|
||||
isLoadingStudents = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function assignExistingStudents(profileIds: string[], sendEmail: boolean) {
|
||||
const result = await orgApi.assignAudienceToCourses({
|
||||
profileIds,
|
||||
courseIds: [courseId],
|
||||
sendEmail
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
await courseApi.refreshCourse(courseId, $profile.id);
|
||||
}
|
||||
|
||||
async function inviteNewStudents(recipientCsv: string, sendEmail: boolean) {
|
||||
const response = await orgApi.importAudienceMembers({
|
||||
recipientCsv,
|
||||
courseIds: [courseId],
|
||||
sendEmail
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
await courseApi.refreshCourse(courseId, $profile.id);
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!selectedTutors.length) {
|
||||
goto(resolve(page.url.pathname, {}));
|
||||
@@ -144,7 +192,12 @@
|
||||
|
||||
<UnderlineTabs.Content value="tutors">
|
||||
<div class="mt-6 space-y-6">
|
||||
<TutorSelectSection {tutors} bind:selectedIds isLoading={orgApi.isLoading} />
|
||||
<TutorSelectSection
|
||||
{tutors}
|
||||
bind:selectedIds
|
||||
isLoading={orgApi.isLoading}
|
||||
helperHref={`/org/${$currentOrg.siteName}/settings/teams`}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex flex-row-reverse items-center gap-2">
|
||||
<Button variant="secondary" type="button" onclick={onSubmit} loading={peopleApi.isLoading}>
|
||||
@@ -156,8 +209,13 @@
|
||||
|
||||
<UnderlineTabs.Content value="students">
|
||||
<div class="mt-6 space-y-6">
|
||||
<ExistingStudentsSection {courseId} students={availableStudents} isLoading={isLoadingStudents} />
|
||||
<BulkEmailSection {courseId} />
|
||||
<ExistingStudentsSection
|
||||
students={availableStudents}
|
||||
isLoading={isLoadingStudents}
|
||||
onSearchValueChange={handleStudentSearch}
|
||||
onAssign={assignExistingStudents}
|
||||
/>
|
||||
<BulkEmailSection onInvite={inviteNewStudents} />
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
</section>
|
||||
|
||||
<section class="my-2 space-y-4">
|
||||
<div class="rounded-md border">
|
||||
<div class="overflow-x-auto rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { SortSelect } from '$features/ui';
|
||||
import GridIcon from '@lucide/svelte/icons/grid-2x2';
|
||||
import ListIcon from '@lucide/svelte/icons/list';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
@@ -12,8 +12,8 @@
|
||||
import {
|
||||
CourseCardList,
|
||||
CourseCardLoader,
|
||||
CourseListRow,
|
||||
CreateCourseButton,
|
||||
CourseList,
|
||||
CopyCourseModal,
|
||||
NewCourseModal
|
||||
} from '$features/course/components';
|
||||
@@ -60,8 +60,6 @@
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedLabel = $derived(filterOptions.find((opt) => opt.value === sortKey)?.label || filterOptions[0].label);
|
||||
|
||||
const setViewPreference = (preference: 'grid' | 'list') => {
|
||||
$courseMetaDeta.view = preference;
|
||||
if (browser) {
|
||||
@@ -97,27 +95,20 @@
|
||||
<Page.BodyHeader align="right" class="p-0!">
|
||||
<Search placeholder={$t('courses.search_placeholder')} bind:value={searchValue} />
|
||||
{#if showSortSelect}
|
||||
<Select.Root type="single" bind:value={sortKey}>
|
||||
<Select.Trigger class="min-w-[150px]">
|
||||
<p>{sortKey ? selectedLabel : filterOptions[0].label}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each filterOptions as option (option.label)}
|
||||
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<SortSelect options={filterOptions} bind:value={sortKey} />
|
||||
{/if}
|
||||
{@render filterControls?.()}
|
||||
|
||||
{#if $courseMetaDeta.view === 'list'}
|
||||
<IconButton onclick={() => setViewPreference('grid')}>
|
||||
<GridIcon size={16} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton onclick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{#if !isLMS}
|
||||
{#if $courseMetaDeta.view === 'list'}
|
||||
<IconButton onclick={() => setViewPreference('grid')}>
|
||||
<GridIcon size={16} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton onclick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
{/if}
|
||||
</Page.BodyHeader>
|
||||
|
||||
@@ -134,48 +125,33 @@
|
||||
<CreateCourseButton isResponsive />
|
||||
{/if}
|
||||
</Empty>
|
||||
{:else if $courseMetaDeta.view === 'list'}
|
||||
<div class="w-full overflow-hidden rounded-md border">
|
||||
<Table.Root class="w-full table-fixed">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[20%]">{$t('courses.course_card.list_view.title')}</Table.Head>
|
||||
<Table.Head class="w-[20%]">{$t('courses.course_card.list_view.description')}</Table.Head>
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.type')}</Table.Head>
|
||||
<Table.Head class="w-3/12 min-w-0">{$t('courses.course_card.list_view.tags')}</Table.Head>
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.lessons')}</Table.Head>
|
||||
{#if !isLMS}
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
|
||||
<Table.Head class="w-[6%]"></Table.Head>
|
||||
{/if}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each courses as courseData (courseData.id)}
|
||||
<CourseList
|
||||
id={courseData.id}
|
||||
slug={courseData.slug ?? ''}
|
||||
title={courseData.title}
|
||||
type={$t(`course.navItem.settings.${courseData.type?.toLowerCase()}`)}
|
||||
description={courseData.description}
|
||||
isPublished={courseData.isPublished ?? false}
|
||||
totalLessons={courseData.lessonCount}
|
||||
totalStudents={'totalStudents' in courseData ? courseData.totalStudents : 0}
|
||||
tags={('tags' in courseData && Array.isArray(courseData.tags) ? courseData.tags : []) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
}>}
|
||||
{isExplore}
|
||||
{isLMS}
|
||||
/>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{:else}
|
||||
{:else if isLMS || $courseMetaDeta.view === 'grid'}
|
||||
<CourseCardList {courses} {isExplore} {isLMS} />
|
||||
{:else}
|
||||
<ResourceListRow.Group>
|
||||
{#each courses as courseData (courseData.id)}
|
||||
<CourseListRow
|
||||
id={courseData.id}
|
||||
slug={courseData.slug ?? ''}
|
||||
title={courseData.title}
|
||||
logo={courseData.logo ?? null}
|
||||
type={courseData.type}
|
||||
description={courseData.description}
|
||||
isPublished={courseData.isPublished ?? false}
|
||||
lessonCount={courseData.lessonCount}
|
||||
exerciseCount={'exerciseCount' in courseData ? (courseData.exerciseCount ?? 0) : 0}
|
||||
totalStudents={'totalStudents' in courseData ? courseData.totalStudents : 0}
|
||||
updatedAt={'updatedAt' in courseData ? (courseData.updatedAt ?? null) : null}
|
||||
tags={('tags' in courseData && Array.isArray(courseData.tags) ? courseData.tags : []) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
}>}
|
||||
{isExplore}
|
||||
{isLMS}
|
||||
/>
|
||||
{/each}
|
||||
</ResourceListRow.Group>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -249,7 +249,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Page.Header isSticky={true} class="z-250! min-h-[36px]">
|
||||
<Page.Header isSticky={true} class="z-50! min-h-[36px]">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title class="flex flex-wrap items-center gap-2">
|
||||
<span>{$questionnaire.title || 'Exercise'}</span>
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import Empty from '@cio/ui/custom/empty/empty.svelte';
|
||||
import { BookIcon } from '@lucide/svelte';
|
||||
import type { TNewsfeedReactionUpdate } from '@cio/utils/validation/newsfeed';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { buildNextNewsfeedReaction } from '@cio/ui/custom/newsfeed-reactions';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
@@ -48,27 +49,12 @@
|
||||
};
|
||||
|
||||
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
|
||||
if (isReactingByFeedId[feedId]) return;
|
||||
if (!authorId || isReactingByFeedId[feedId]) return;
|
||||
|
||||
const reactedFeed = newsfeedApi.feeds.find((feed) => feed.id === feedId);
|
||||
if (!reactedFeed) return;
|
||||
|
||||
const reaction = reactedFeed.reaction;
|
||||
let reactedAuthorIds: string[] = reaction?.[reactionType] || [];
|
||||
|
||||
if (reactedAuthorIds.includes(authorId)) {
|
||||
reactedAuthorIds = reactedAuthorIds.filter((reactionAuthorId) => reactionAuthorId !== authorId);
|
||||
} else {
|
||||
reactedAuthorIds = [...reactedAuthorIds, authorId];
|
||||
}
|
||||
|
||||
const updatedReaction: TNewsfeedReactionUpdate['reaction'] = {
|
||||
clap: reaction?.clap || [],
|
||||
smile: reaction?.smile || [],
|
||||
thumbsup: reaction?.thumbsup || [],
|
||||
thumbsdown: reaction?.thumbsdown || [],
|
||||
[reactionType]: reactedAuthorIds
|
||||
};
|
||||
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction, reactionType, authorId);
|
||||
|
||||
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };
|
||||
|
||||
@@ -99,11 +85,11 @@
|
||||
return snackbar.error('snackbar.course.error.toggle_error');
|
||||
}
|
||||
|
||||
snackbar.success(
|
||||
`${
|
||||
newIsPinned ? 'snackbar.course.success.pinned' : 'snackbar.course.success.unpinned'
|
||||
} snackbar.course.success.successfully`
|
||||
);
|
||||
const pinStatusMessage = newIsPinned
|
||||
? t.get('snackbar.course.success.pinned')
|
||||
: t.get('snackbar.course.success.unpinned');
|
||||
|
||||
snackbar.success(`${pinStatusMessage} ${t.get('snackbar.course.success.successfully')}`);
|
||||
};
|
||||
|
||||
const deleteFeed = async (id: string) => {
|
||||
@@ -119,10 +105,10 @@
|
||||
});
|
||||
|
||||
function getPageRoles(org: AccountOrg) {
|
||||
const roles: number[] = [1, 2];
|
||||
const roles: number[] = [ROLE.ADMIN, ROLE.TUTOR];
|
||||
|
||||
if (org.customization.course.newsfeed) {
|
||||
roles.push(3);
|
||||
roles.push(ROLE.STUDENT);
|
||||
}
|
||||
|
||||
return roles;
|
||||
@@ -143,7 +129,7 @@
|
||||
goto(`/courses/${courseId}/lessons?next=true`);
|
||||
}}
|
||||
>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<RoleBasedSecurity allowedRoles={getPageRoles($currentOrg)}>
|
||||
<NewFeedModal {courseId} bind:edit bind:editFeed />
|
||||
</RoleBasedSecurity>
|
||||
{#if isListing}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
// Question type inferred from API Exercise response
|
||||
import type { Course as CourseDetails, Exercise } from './utils/types';
|
||||
import type { InferResponseType } from '@cio/api/rpc-types';
|
||||
import type { InferRequestType, InferResponseType } from '@cio/api/rpc-types';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
// Org Courses types
|
||||
export type GetOrgCoursesRequest = typeof classroomio.organization.courses.$get;
|
||||
export type OrgCoursesResponse = InferResponseType<GetOrgCoursesRequest> | null;
|
||||
export type OrgCoursesSuccess = Extract<InferResponseType<GetOrgCoursesRequest>, { success: true }>;
|
||||
export type GetOrgCoursesRequestQuery = NonNullable<InferRequestType<GetOrgCoursesRequest>['query']>;
|
||||
export type OrgCourses = OrgCoursesSuccess['data'];
|
||||
export type OrgCoursesPagination = OrgCoursesSuccess['pagination'];
|
||||
export type OrgCoursesQuery = {
|
||||
page: number;
|
||||
limit: number;
|
||||
search?: string;
|
||||
tags?: string;
|
||||
};
|
||||
|
||||
// User Enrolled Courses types
|
||||
export type GetUserEnrolledCoursesRequest = typeof classroomio.organization.courses.enrolled.$get;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** FNV-1a–style hash for stable, client-only placeholder picks (e.g. icon per course). */
|
||||
export function hashStringToUInt32(value: string): number {
|
||||
let hash = 2166136261;
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function buildCoursePlaceholderAvatarUrl(seed: string): string {
|
||||
const stableSeed = hashStringToUInt32(seed).toString(36);
|
||||
|
||||
return `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(stableSeed)}`;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { writable } from 'svelte/store';
|
||||
export const courseMetaDeta = writable<{
|
||||
view: 'grid' | 'list';
|
||||
}>({
|
||||
view: 'grid'
|
||||
view: 'list'
|
||||
});
|
||||
|
||||
export const createCourseModal = writable({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LMSExercise } from './exercises.svelte';
|
||||
import type { GetLMSExercisesSuccess, LMSExercise } from './exercises.svelte';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
|
||||
import { getApiKeyHeaders, safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
/**
|
||||
* Server-side API methods for LMS exercises
|
||||
@@ -15,15 +15,15 @@ export class LMSExercisesApiServer {
|
||||
*/
|
||||
static async fetchLMSExercises(orgId: string): Promise<LMSExercise[] | null> {
|
||||
try {
|
||||
const response = await classroomio.organization[':orgId'].exercises.lms.$get(
|
||||
{
|
||||
param: { orgId }
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
const result = await safeServerApi<GetLMSExercisesSuccess>(() =>
|
||||
classroomio.organization[':orgId'].exercises.lms.$get(
|
||||
{
|
||||
param: { orgId }
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
)
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success && data.data ? data.data : null;
|
||||
return result.ok && result.body.data ? result.body.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching LMS exercises (server):', error);
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type { PendingOrgInvite } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
invite: PendingOrgInvite;
|
||||
onAccepted?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), invite, onAccepted }: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
const orgName = $derived(invite.organization.name);
|
||||
|
||||
function getInvitationMessage(): string {
|
||||
if (invite.roleId === ROLE.STUDENT) {
|
||||
return t.get('invite.organization.invitation_student', { orgName });
|
||||
}
|
||||
|
||||
if (invite.roleId === ROLE.TUTOR) {
|
||||
return t.get('invite.organization.invitation_teacher', { orgName });
|
||||
}
|
||||
|
||||
if (invite.roleId === ROLE.ADMIN) {
|
||||
return t.get('invite.organization.invitation_admin', { orgName });
|
||||
}
|
||||
|
||||
return t.get('invite.organization.invitation_fallback', { orgName, role: invite.roleLabel });
|
||||
}
|
||||
|
||||
async function handleAccept() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const response = await classroomio.invite.organization[':inviteId']['accept-by-id'].$post({
|
||||
param: { inviteId: invite.id }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
const failed = result as { error?: string; message?: string };
|
||||
snackbar.error(failed.error ?? failed.message ?? t.get('invite.organization.messages.join_failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
snackbar.success('invite.organization.messages.joined');
|
||||
open = false;
|
||||
onAccepted?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to accept organization invite by id', error);
|
||||
snackbar.error('invite.organization.messages.join_failed');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen && !isLoading) open = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-96 p-6">
|
||||
<Dialog.Header class="p-2">
|
||||
<Dialog.Title>{$t('invite.organization.page_title', { orgName })}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="mt-2 space-y-3 px-2 text-center">
|
||||
<p class="text-sm font-light dark:text-white">{getInvitationMessage()}</p>
|
||||
<p class="text-sm font-light dark:text-white">{$t('invite.organization.email_line', { email: invite.email })}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (open = false)} disabled={isLoading}>
|
||||
{$t('delete_modal.no')}
|
||||
</Button>
|
||||
<Button onclick={handleAccept} loading={isLoading} disabled={isLoading}>
|
||||
{$t('invite.organization.accept_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -7,6 +7,7 @@
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
import { resolve } from '$app/paths';
|
||||
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
|
||||
|
||||
let totalCompleted = $derived(
|
||||
coursesApi.enrolledCourses.reduce((acc, cur) => {
|
||||
@@ -32,30 +33,34 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-fit w-full flex-col-reverse justify-between rounded-md border px-4 py-2 md:flex-row md:items-center">
|
||||
<div class="w-full md:w-[75%] lg:w-[80%]">
|
||||
<p class="mb-5 w-4/6 font-normal lg:text-xl">
|
||||
{$currentOrg.customization.dashboard.bannerText
|
||||
? $currentOrg.customization.dashboard.bannerText
|
||||
: $t('dashboard.lms_dashboard_hero')}
|
||||
</p>
|
||||
<Button onclick={() => goto(resolve('/lms/mylearning', {}))}>
|
||||
{$t('dashboard.continue_learning')}
|
||||
</Button>
|
||||
<BlurFade delay={0} once>
|
||||
<div
|
||||
class="flex h-fit w-full flex-col-reverse justify-between rounded-md border px-4 py-2 md:flex-row md:items-center"
|
||||
>
|
||||
<div class="w-full md:w-[75%] lg:w-[80%]">
|
||||
<p class="mb-5 w-4/6 font-normal lg:text-xl">
|
||||
{$currentOrg.customization.dashboard.bannerText
|
||||
? $currentOrg.customization.dashboard.bannerText
|
||||
: $t('dashboard.lms_dashboard_hero')}
|
||||
</p>
|
||||
<Button onclick={() => goto(resolve('/lms/mylearning', {}))}>
|
||||
{$t('dashboard.continue_learning')}
|
||||
</Button>
|
||||
</div>
|
||||
<img
|
||||
src={$currentOrg.customization.dashboard.bannerImage
|
||||
? $currentOrg.customization.dashboard.bannerImage
|
||||
: '/images/student-learning.svg'}
|
||||
alt="student Learning Pictogram"
|
||||
class="mb-3 aspect-square size-48 md:mb-0 md:block"
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
src={$currentOrg.customization.dashboard.bannerImage
|
||||
? $currentOrg.customization.dashboard.bannerImage
|
||||
: '/images/student-learning.svg'}
|
||||
alt="student Learning Pictogram"
|
||||
class="mb-3 aspect-square size-48 md:mb-0 md:block"
|
||||
/>
|
||||
</div>
|
||||
</BlurFade>
|
||||
<section class="flex w-full flex-col md:gap-4 lg:flex-row">
|
||||
<div class="mt-10 w-full lg:w-[50%] xl:mt-2">
|
||||
<BlurFade delay={0.1} once class="mt-10 w-full lg:w-[50%] xl:mt-2">
|
||||
<Learning />
|
||||
</div>
|
||||
<div class="mt-10 w-full lg:w-[50%] xl:mt-2">
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.2} once class="mt-10 w-full lg:w-[50%] xl:mt-2">
|
||||
<p class="pb-3 text-base font-semibold text-[#040F2D] dark:text-white">
|
||||
{$t('dashboard.your_progress')}
|
||||
</p>
|
||||
@@ -88,5 +93,5 @@
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BlurFade>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
export type GetPendingOrgInviteRequest = (typeof classroomio.invite.organization)['pending']['$get'];
|
||||
type GetPendingOrgInviteResponse = Extract<InferResponseType<GetPendingOrgInviteRequest>, { success: true }>;
|
||||
export type PendingOrgInvite = NonNullable<GetPendingOrgInviteResponse['data']>;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { OrganizationAsset } from './types';
|
||||
|
||||
type LessonVideoType = 'upload' | 'youtube' | 'generic';
|
||||
type LessonVideoType = 'upload' | 'youtube' | 'generic' | 'google_drive';
|
||||
|
||||
export function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes == null || Number.isNaN(bytes) || bytes < 0) {
|
||||
@@ -21,6 +21,7 @@ export function formatBytes(bytes: number | null | undefined): string {
|
||||
|
||||
export function mapAssetProviderToLessonVideoType(provider: string): LessonVideoType {
|
||||
if (provider === 'youtube') return 'youtube';
|
||||
if (provider === 'google_drive') return 'google_drive';
|
||||
if (provider === 'generic' || provider === 'external_url') return 'generic';
|
||||
return 'upload';
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import type { DashStatsSuccess } from '$features/org/utils/types';
|
||||
*/
|
||||
class DashStatApi extends BaseApi {
|
||||
stats = $state<DashStatsSuccess['data']>({
|
||||
enrollments: [],
|
||||
recentCertifications: [],
|
||||
numberOfCourses: 0,
|
||||
revenue: 0,
|
||||
totalCertificates: 0,
|
||||
totalStudents: 0,
|
||||
topCourses: []
|
||||
});
|
||||
@@ -18,7 +18,7 @@ class DashStatApi extends BaseApi {
|
||||
* Fetches dashboard statistics for an organization
|
||||
* @param orgId Optional organization ID
|
||||
* @param siteName Optional site name
|
||||
* @returns Organization analytics data (enrollments, courses, revenue, students, top courses)
|
||||
* @returns Organization analytics data (certifications, courses, students, top courses)
|
||||
*/
|
||||
async fetchOrgStats({ orgId, siteName }: { orgId?: string; siteName?: string }) {
|
||||
await this.execute<typeof classroomio.dash.stats.$get>({
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { TCancelOrgPlan, TCreateOrgPlan, TUpdateOrgPlan } from '@cio/utils/validation/organization';
|
||||
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { getApiKeyHeaders, safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
type CreateOrgPlanRequest = typeof classroomio.organization.plan.$post;
|
||||
type CreateOrgPlanSuccess = Extract<InferResponseType<CreateOrgPlanRequest>, { success: true }>;
|
||||
type UpdateOrgPlanRequest = typeof classroomio.organization.plan.$put;
|
||||
type UpdateOrgPlanSuccess = Extract<InferResponseType<UpdateOrgPlanRequest>, { success: true }>;
|
||||
type CancelOrgPlanRequest = typeof classroomio.organization.plan.cancel.$post;
|
||||
type CancelOrgPlanSuccess = Extract<InferResponseType<CancelOrgPlanRequest>, { success: true }>;
|
||||
|
||||
/**
|
||||
* Server-side API methods for organization plan operations
|
||||
@@ -15,20 +22,21 @@ export class OrgPlanApiServer {
|
||||
* @returns Response data or null on error
|
||||
*/
|
||||
static async createOrgPlan(params: TCreateOrgPlan) {
|
||||
try {
|
||||
const response = await classroomio.organization.plan.$post(
|
||||
const result = await safeServerApi<CreateOrgPlanSuccess>(() =>
|
||||
classroomio.organization.plan.$post(
|
||||
{
|
||||
json: params
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success ? data.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error creating org plan (server):', error);
|
||||
if (!result.ok) {
|
||||
console.error('Error creating org plan (server):', result);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.body.data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,20 +45,21 @@ export class OrgPlanApiServer {
|
||||
* @returns Response data or null on error
|
||||
*/
|
||||
static async updateOrgPlan(params: TUpdateOrgPlan) {
|
||||
try {
|
||||
const response = await classroomio.organization.plan.$put(
|
||||
const result = await safeServerApi<UpdateOrgPlanSuccess>(() =>
|
||||
classroomio.organization.plan.$put(
|
||||
{
|
||||
json: params
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success ? data.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error updating org plan (server):', error);
|
||||
if (!result.ok) {
|
||||
console.error('Error updating org plan (server):', result);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.body.data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,19 +68,20 @@ export class OrgPlanApiServer {
|
||||
* @returns Response data or null on error
|
||||
*/
|
||||
static async cancelOrgPlan(params: TCancelOrgPlan) {
|
||||
try {
|
||||
const response = await classroomio.organization.plan.cancel.$post(
|
||||
const result = await safeServerApi<CancelOrgPlanSuccess>(() =>
|
||||
classroomio.organization.plan.cancel.$post(
|
||||
{
|
||||
json: params
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success ? data.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error canceling org plan (server):', error);
|
||||
if (!result.ok) {
|
||||
console.error('Error canceling org plan (server):', result);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.body.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
import type { FirstOrganizationRecord, OrganizationRecord } from '../utils/types';
|
||||
|
||||
/**
|
||||
* Server-side organization API functions.
|
||||
@@ -15,14 +17,16 @@ import { classroomio } from '$lib/utils/services/api';
|
||||
* @returns First organization or null
|
||||
*/
|
||||
export async function getFirstOrg(apiKeyHeaders: { headers: Record<string, string> }) {
|
||||
try {
|
||||
const response = await classroomio.organization.first.$get(undefined, apiKeyHeaders);
|
||||
const data = await response.json();
|
||||
return data.success && data.data && data.data.length > 0 ? data.data[0] : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching first organization (server):', error);
|
||||
const result = await safeServerApi<{ success: true; data: FirstOrganizationRecord[] }>(() =>
|
||||
classroomio.organization.first.$get(undefined, apiKeyHeaders)
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
console.error('Error fetching first organization (server):', result);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.body.data.length > 0 ? result.body.data[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,20 +36,21 @@ export async function getFirstOrg(apiKeyHeaders: { headers: Record<string, strin
|
||||
* @returns Organization or null
|
||||
*/
|
||||
export async function getOrgBySiteName(siteName: string, apiKeyHeaders: { headers: Record<string, string> }) {
|
||||
try {
|
||||
const response = await classroomio.organization.$get(
|
||||
const result = await safeServerApi<{ success: true; data: OrganizationRecord[] }>(() =>
|
||||
classroomio.organization.$get(
|
||||
{
|
||||
query: { siteName }
|
||||
},
|
||||
apiKeyHeaders
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success && data.data && data.data.length > 0 ? data.data[0] : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching organization by siteName (server):', error);
|
||||
if (!result.ok) {
|
||||
console.error('Error fetching organization by siteName (server):', result);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.body.data.length > 0 ? result.body.data[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,8 +65,8 @@ export async function getOrgsByCustomDomain(
|
||||
isCustomDomainVerified: boolean,
|
||||
apiKeyHeaders: { headers: Record<string, string> }
|
||||
) {
|
||||
try {
|
||||
const response = await classroomio.organization.$get(
|
||||
const result = await safeServerApi<{ success: true; data: OrganizationRecord[] }>(() =>
|
||||
classroomio.organization.$get(
|
||||
{
|
||||
query: {
|
||||
customDomain,
|
||||
@@ -69,12 +74,13 @@ export async function getOrgsByCustomDomain(
|
||||
}
|
||||
},
|
||||
apiKeyHeaders
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success && data.data ? data.data : [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching organizations by custom domain (server):', error);
|
||||
if (!result.ok) {
|
||||
console.error('Error fetching organizations by custom domain (server):', result);
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.body.data;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ class OrgApi extends BaseApiWithErrors {
|
||||
audience = $state<OrganizationAudience>([]);
|
||||
audiencePagination = $state<OrganizationAudiencePagination | null>(null);
|
||||
publicCourses: OrgPublicCourses = $state([]);
|
||||
hasMorePublicCourses = $state(false);
|
||||
|
||||
isFetchingOrgPublicCourses = $state(false);
|
||||
private activeAudienceRequestController: AbortController | null = null;
|
||||
@@ -165,7 +166,8 @@ class OrgApi extends BaseApiWithErrors {
|
||||
}),
|
||||
logContext: 'fetching public courses',
|
||||
onSuccess: (response) => {
|
||||
this.publicCourses = response.data;
|
||||
this.publicCourses = response.data.courses;
|
||||
this.hasMorePublicCourses = response.data.hasMoreCourses;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,36 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { fade } from 'svelte/transition';
|
||||
import MailIcon from '@lucide/svelte/icons/mail';
|
||||
import PhoneIcon from '@lucide/svelte/icons/phone';
|
||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
||||
import MapPinIcon from '@lucide/svelte/icons/map-pin';
|
||||
import TwitterIcon from '@lucide/svelte/icons/twitter';
|
||||
import FacebookIcon from '@lucide/svelte/icons/facebook';
|
||||
import LinkedinIcon from '@lucide/svelte/icons/linkedin';
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
import Navigation from '$lib/components/Navigation/index.svelte';
|
||||
import { PoweredBy } from '$features/ui';
|
||||
import { CourseCardList, CourseCardLoader } from '$features/course/components';
|
||||
import { landingPageSettings } from '$features/org/components/settings/landing-page-store';
|
||||
import { CoursesEmptyIcon } from '$features/ui/icons';
|
||||
|
||||
import PageLoader from './page-loader.svelte';
|
||||
import * as Accordion from '@cio/ui/base/accordion';
|
||||
import * as Button from '@cio/ui/base/button';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { validateEmail } from '$lib/utils/functions/validateEmail';
|
||||
import { orgLandingpageValidation } from '$lib/utils/functions/validator';
|
||||
import { MediaPlayer, isYoutubeUrl } from '@cio/ui/custom/media-player';
|
||||
import PageLoader from './page-loader.svelte';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import {
|
||||
buildOrgLandingPageProps,
|
||||
importThemeComponent,
|
||||
normalizeLandingPageSettings
|
||||
} from '$features/org/utils/landing-page';
|
||||
import { basePath } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
|
||||
interface Props {
|
||||
orgSiteName?: string;
|
||||
@@ -39,519 +22,54 @@
|
||||
|
||||
let { orgSiteName = '', org }: Props = $props();
|
||||
|
||||
let email: string | undefined = $state('');
|
||||
let isAdding = $state(false);
|
||||
let success = $state(false);
|
||||
let successContactSaved = $state(false);
|
||||
let isContactSubmiting = $state(false);
|
||||
let contact = $state({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
message: ''
|
||||
});
|
||||
let contactError: Record<string, string> = $state({});
|
||||
let hasLoadedCourses = $state(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let ThemeComponent = $state<Component<any> | null>(null);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email || !validateEmail(email) || !org) return;
|
||||
isAdding = true;
|
||||
const authAction = $derived(
|
||||
$user.isLoggedIn
|
||||
? {
|
||||
label: t.get($basePath === '/lms' || $basePath === '#' ? 'navigation.goto_lms' : 'navigation.goto_dashboard'),
|
||||
href: resolve($basePath !== '#' ? $basePath : '/lms', {})
|
||||
}
|
||||
: {
|
||||
label: t.get('navigation.login'),
|
||||
href: '/login'
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: Implement email list submission
|
||||
const landingPageProps = $derived(
|
||||
buildOrgLandingPageProps(
|
||||
org,
|
||||
normalizeLandingPageSettings(org.landingpage),
|
||||
orgApi.publicCourses,
|
||||
orgApi.hasMorePublicCourses,
|
||||
authAction
|
||||
)
|
||||
);
|
||||
|
||||
success = true;
|
||||
setTimeout(() => {
|
||||
isAdding = false;
|
||||
success = false;
|
||||
email = undefined;
|
||||
}, 5000);
|
||||
}
|
||||
onMount(async () => {
|
||||
const settings = normalizeLandingPageSettings(org.landingpage);
|
||||
const mod = await importThemeComponent(settings.theme);
|
||||
ThemeComponent = mod.default;
|
||||
|
||||
async function handleContactSubmit() {
|
||||
isContactSubmiting = true;
|
||||
contactError = orgLandingpageValidation(contact);
|
||||
if (Object.keys(contactError).length) {
|
||||
isContactSubmiting = false;
|
||||
const siteName = orgSiteName || org.siteName;
|
||||
if (!siteName) {
|
||||
hasLoadedCourses = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to db
|
||||
// TODO: Implement contact submission
|
||||
|
||||
isContactSubmiting = false;
|
||||
successContactSaved = true;
|
||||
contact = {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
message: ''
|
||||
};
|
||||
}
|
||||
$effect(() => {
|
||||
if (!orgSiteName) return;
|
||||
|
||||
orgApi.getPublicCoursesBySiteName(orgSiteName);
|
||||
});
|
||||
|
||||
// Use default data in store if user hasn't added their own content to landing page
|
||||
function setDefault(landingpage) {
|
||||
if (landingpage && Object.keys(landingpage).length) {
|
||||
if (!landingpage?.header?.banner) {
|
||||
landingpage.header.banner = $landingPageSettings.header.banner;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
$landingPageSettings = {
|
||||
...landingpage
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getBgImage(settings) {
|
||||
const defaultImg = '/images/org-landingpage-banner.jpeg';
|
||||
|
||||
const { show, image } = settings.header.background || {
|
||||
show: true,
|
||||
image: defaultImg
|
||||
};
|
||||
|
||||
if (!show) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `url('${image || defaultImg}')`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
setDefault(org?.landingpage);
|
||||
orgApi.publicCourses = [];
|
||||
orgApi.hasMorePublicCourses = false;
|
||||
hasLoadedCourses = false;
|
||||
void orgApi.getPublicCoursesBySiteName(siteName).finally(() => {
|
||||
hasLoadedCourses = true;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{!org?.name ? '' : `${org.name}'s `}{$t('course.navItem.landing_page.landing_page')}
|
||||
</title>
|
||||
{#if $landingPageSettings.header.background?.show !== false}
|
||||
<link
|
||||
rel="preload"
|
||||
as="image"
|
||||
href={$landingPageSettings.header.background?.image || '/images/org-landingpage-banner.jpeg'}
|
||||
/>
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<PoweredBy />
|
||||
|
||||
{#if !org?.landingpage}
|
||||
{#if !ThemeComponent || !hasLoadedCourses || orgApi.isFetchingOrgPublicCourses}
|
||||
<PageLoader />
|
||||
{:else}
|
||||
<main>
|
||||
<Navigation
|
||||
logo={org.avatarUrl || '/logo-192.png'}
|
||||
orgName={org.name}
|
||||
disableSignup={org.disableSignup || (org.settings?.signup?.inviteOnly ?? false)}
|
||||
isOrgSite={true}
|
||||
customLinks={$landingPageSettings.customLinks}
|
||||
/>
|
||||
<!-- Header Section -->
|
||||
{#if $landingPageSettings.header.show}
|
||||
<header
|
||||
id="header"
|
||||
class={`relative mb-10 h-screen w-full md:h-[90vh] ${
|
||||
$landingPageSettings.header.background?.show ? 'bg-cover bg-center' : 'border-b border-gray-300'
|
||||
}`}
|
||||
style="background-image: {getBgImage($landingPageSettings)}"
|
||||
>
|
||||
<div class="absolute top-0 z-10 h-screen w-full bg-white opacity-80 md:h-[90vh]"></div>
|
||||
{#if $landingPageSettings.header.banner.show}
|
||||
<div class="flex items-center justify-center py-2 md:h-full">
|
||||
<div
|
||||
class="relative z-20 flex w-full flex-col-reverse items-center justify-center md:w-11/12 md:flex-row md:justify-between lg:w-5/6"
|
||||
>
|
||||
<!-- Org Description -->
|
||||
<div class="mr-3 flex w-11/12 flex-col items-center py-10 md:w-2/5 md:items-start">
|
||||
<p class="ui:text-primary text-2xl font-semibold capitalize">
|
||||
{org.name}
|
||||
</p>
|
||||
<h1 class="my-4 text-center text-4xl md:text-start md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.header.title} <br /><span class="ui:text-primary"
|
||||
>{$landingPageSettings.header.titleHighlight}</span
|
||||
>
|
||||
</h1>
|
||||
<p class="text-md mb-3 text-center text-xl md:mb-5 md:text-start">
|
||||
{$landingPageSettings.header.subtitle}
|
||||
</p>
|
||||
|
||||
<Button.Root
|
||||
class="mt-2 w-fit px-10 md:mt-5"
|
||||
onclick={() => {
|
||||
$landingPageSettings.header.action.redirect && goto($landingPageSettings.header.action.link);
|
||||
}}
|
||||
>
|
||||
{$landingPageSettings.header.action.label}
|
||||
</Button.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex w-5/6 md:w-1/2 md:max-w-[650px]">
|
||||
{#if $landingPageSettings.header?.banner?.video && $landingPageSettings.header.banner.type === 'video'}
|
||||
<div style="width:100%; border-radius:12px">
|
||||
<MediaPlayer
|
||||
source={{
|
||||
type: isYoutubeUrl($landingPageSettings.header.banner.video) ? 'youtube' : 'generic',
|
||||
url: $landingPageSettings.header.banner.video
|
||||
}}
|
||||
options={{
|
||||
width: '100%',
|
||||
height: '400'
|
||||
}}
|
||||
class="rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<img
|
||||
class="rounded-md"
|
||||
src={$landingPageSettings.header?.banner?.image}
|
||||
alt="landing page banner"
|
||||
width="640"
|
||||
height="427"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="relative z-20 flex h-full w-full items-center justify-center md:flex-row">
|
||||
<div class="mx-auto flex w-11/12 max-w-[600px] flex-col items-center py-10">
|
||||
<p class=" ui:text-primary text-2xl font-semibold capitalize">{org.name}</p>
|
||||
<h1 class="my-4 text-center text-4xl md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.header.title} <br /><span class="ui:text-primary"
|
||||
>{$landingPageSettings.header.titleHighlight}</span
|
||||
>
|
||||
</h1>
|
||||
<p class="text-md mb-6 text-center text-xl">
|
||||
{$landingPageSettings.header.subtitle}
|
||||
</p>
|
||||
|
||||
<Button.Root
|
||||
class="mt-5 w-fit px-10"
|
||||
onclick={() => {
|
||||
$landingPageSettings.header.action.redirect && goto($landingPageSettings.header.action.link);
|
||||
}}
|
||||
>
|
||||
{$landingPageSettings.header.action.label}
|
||||
</Button.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<!-- Our Story section -->
|
||||
{#if $landingPageSettings.aboutUs.show}
|
||||
<section id="aboutus" class="m-h-[400px] mx-auto my-10 w-full max-w-6xl">
|
||||
<div class="mx-4 flex flex-col items-center justify-evenly lg:flex-row">
|
||||
<div class="mr-5 mb-5 max-w-[600px] lg:mb-0 lg:w-2/5">
|
||||
<h2 class="text-4xl md:text-5xl lg:text-6xl">{$landingPageSettings.aboutUs.title}</h2>
|
||||
<p class="mb-2">
|
||||
{$landingPageSettings.aboutUs.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="image">
|
||||
<img
|
||||
src={$landingPageSettings.aboutUs.imageUrl}
|
||||
alt="Our Story"
|
||||
class="max-h-[450px] rounded-2xl"
|
||||
width="900"
|
||||
height="562"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Courses Section -->
|
||||
{#if $landingPageSettings.courses.show}
|
||||
<section id="courses" transition:fade class="mx-auto my-10 w-full max-w-6xl">
|
||||
<div class="w-full">
|
||||
<div class="mx-auto w-11/12 max-w-[500px] py-10">
|
||||
<h1 class="my-4 text-center text-4xl md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.courses.title}
|
||||
<span class="text-primary">{$landingPageSettings.courses.titleHighlight}</span>
|
||||
</h1>
|
||||
<p class="text-md text-center">
|
||||
{$landingPageSettings.courses.subtitle}
|
||||
</p>
|
||||
<div class="mt-5 flex justify-center">
|
||||
<Button.Root variant="link" class="w-fit px-8 py-4" onclick={() => goto(resolve('/courses', {}))}>
|
||||
{$t('course.navItem.landing_page.view_all')}
|
||||
</Button.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if orgApi.isFetchingOrgPublicCourses}
|
||||
<div class="cards-container mx-2 my-4">
|
||||
<CourseCardLoader />
|
||||
<CourseCardLoader />
|
||||
<CourseCardLoader />
|
||||
</div>
|
||||
{:else if orgApi.publicCourses.length > 0}
|
||||
<CourseCardList courses={orgApi.publicCourses.slice(0, 3)} isOnLandingPage={true} />
|
||||
{:else}
|
||||
<Empty
|
||||
icon={CoursesEmptyIcon}
|
||||
title={$t('course.navItem.landing_page.no_course_published')}
|
||||
description={$t('course.navItem.landing_page.coming_your_way')}
|
||||
variant="page"
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- FAQ Section -->
|
||||
{#if $landingPageSettings.faq.show}
|
||||
<section id="faq" transition:fade class="mx-auto my-10 w-full max-w-[700px]">
|
||||
<div class="py-10">
|
||||
<h1 class="my-4 text-center text-4xl md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.faq.title}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="mx-2">
|
||||
<Accordion.Root type="single" class="w-full">
|
||||
{#each $landingPageSettings.faq.questions as faq, index}
|
||||
<Accordion.Item value="item-{index}">
|
||||
<Accordion.Trigger>
|
||||
{faq.title}
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
{faq.content}
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
{/each}
|
||||
</Accordion.Root>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Contact Section-->
|
||||
{#if $landingPageSettings.contact.show}
|
||||
<section id="contact" transition:fade class="ui:bg-primary-foreground my-10 w-full">
|
||||
<div class="mx-auto w-full max-w-6xl">
|
||||
<div class="mx-auto w-11/12 max-w-[500px] py-10">
|
||||
<h1 class="my-4 text-center text-4xl md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.contact.title}
|
||||
<span class="ui:text-primary">{$landingPageSettings.contact.titleHighlight}</span>
|
||||
</h1>
|
||||
<p class="text-md text-center">{$landingPageSettings.contact.subtitle}</p>
|
||||
</div>
|
||||
<div class="mx-auto max-w-[700px] pb-10">
|
||||
<!-- Contact Details -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div
|
||||
class="mx-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border py-2 text-center break-all transition-all duration-500"
|
||||
>
|
||||
<MapPinIcon size={16} class="filled" />
|
||||
<p class="mt-3 max-w-[200px] text-xs md:text-sm">
|
||||
{$landingPageSettings.contact.address}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="mx-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border py-2 text-center break-all transition-all duration-500"
|
||||
>
|
||||
<PhoneIcon size={16} />
|
||||
<p class="mt-3 text-xs md:text-sm">{$landingPageSettings.contact.phone}</p>
|
||||
</div>
|
||||
<div
|
||||
class="mx-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border py-2 text-center break-all transition-all duration-500"
|
||||
>
|
||||
<MailIcon size={16} />
|
||||
<p class="mt-3 text-xs md:text-sm">{$landingPageSettings.contact.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Form -->
|
||||
<div class="mt-8 rounded-lg bg-white p-7">
|
||||
{#if successContactSaved}
|
||||
<div class="flex w-full items-center justify-center">
|
||||
{$t('course.navItem.landing_page.thank_you')}
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={preventDefault(handleContactSubmit)}>
|
||||
<div class="flex w-full flex-col justify-between md:flex-row">
|
||||
<div class="mr-5 w-full md:w-2/4">
|
||||
<Field.Field class="mb-5">
|
||||
<Field.Label class="font-bold">
|
||||
{$t('course.navItem.landing_page.name')}
|
||||
</Field.Label>
|
||||
<Input bind:value={contact.name} placeholder="Elon Musk" aria-invalid={!!contactError.name} />
|
||||
{#if contactError.name}
|
||||
<Field.Error>{contactError.name}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field class="mb-5">
|
||||
<Field.Label class="text-xs font-normal">
|
||||
{$t('course.navItem.landing_page.email')}
|
||||
</Field.Label>
|
||||
<Input
|
||||
bind:value={contact.email}
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
aria-invalid={!!contactError.email}
|
||||
/>
|
||||
{#if contactError.email}
|
||||
<Field.Error>{contactError.email}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field class="mb-5">
|
||||
<Field.Label class="text-xs font-normal">
|
||||
{$t('course.navItem.landing_page.phone')}
|
||||
</Field.Label>
|
||||
<Input
|
||||
bind:value={contact.phone}
|
||||
placeholder="+1194802480"
|
||||
aria-invalid={!!contactError.phone}
|
||||
/>
|
||||
{#if contactError.phone}
|
||||
<Field.Error>{contactError.phone}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</div>
|
||||
<div class="w-full md:w-2/4">
|
||||
<Field.Field>
|
||||
<Field.Label>
|
||||
{$t('course.navItem.landing_page.message')}
|
||||
</Field.Label>
|
||||
<Textarea
|
||||
bind:value={contact.message}
|
||||
rows={9}
|
||||
placeholder={$t('course.navItem.landing_page.your_message')}
|
||||
aria-invalid={!!contactError.message}
|
||||
/>
|
||||
{#if contactError.message}
|
||||
<Field.Error>{contactError.message}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button.Root class="mx-auto mt-5 w-full md:mt-0" type="submit" loading={isContactSubmiting}>
|
||||
<span class="text-md mr-2">{$t('course.navItem.landing_page.submit')}</span>
|
||||
<RocketIcon size={16} class="custom" />
|
||||
</Button.Root>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Waitlist Section -->
|
||||
{#if $landingPageSettings.mailinglist.show}
|
||||
<section id="waitlist" transition:fade class="mx-auto my-10 w-[95%] max-w-6xl">
|
||||
<div class="ui:bg-primary gap-4 rounded-lg px-4 py-14 md:px-10 lg:flex-row lg:items-center">
|
||||
<div class="mx-auto flex max-w-[700px] flex-col lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="w-3/5">
|
||||
<h1 class="mt-0 mb-5 text-4xl text-white">
|
||||
{$landingPageSettings.mailinglist.title}
|
||||
</h1>
|
||||
<p class="text-md text-white">
|
||||
{$landingPageSettings.mailinglist.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<form onsubmit={preventDefault(handleSubmit)}>
|
||||
<div class="flex max-w-60 flex-col items-center">
|
||||
{#if success}
|
||||
<p class="text-white">{$t('course.navItem.landing_page.successful_sub')}</p>
|
||||
{:else}
|
||||
<Input
|
||||
bind:value={email}
|
||||
type="email"
|
||||
placeholder={$t('course.navItem.landing_page.enter')}
|
||||
required={true}
|
||||
disabled={isAdding}
|
||||
/>
|
||||
<Button.Root class="my-1 mt-2 w-full" variant="outline" type="submit" loading={isAdding}>
|
||||
{$landingPageSettings.mailinglist.buttonLabel}
|
||||
</Button.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Footer Section -->
|
||||
|
||||
{#if $landingPageSettings.footer.show}
|
||||
<footer
|
||||
id="footer"
|
||||
class="my-10 flex w-full flex-col items-center justify-center border-t border-r-0 border-b-0 border-l-0 border-gray-300 px-5 py-10 md:py-3"
|
||||
>
|
||||
<ul class="flex w-11/12 flex-col items-center sm:flex-row">
|
||||
<div class="logo">
|
||||
<a href="/" title={`Go to ${org.name} Home`} id="logo" data-hveid="8" class="flex items-center">
|
||||
<img
|
||||
src={org.avatarUrl || '/logo-192.png'}
|
||||
alt={`${org.name} logo`}
|
||||
class="mx-auto inline-block max-h-10 w-10 rounded"
|
||||
width="40"
|
||||
height="40"
|
||||
loading="lazy"
|
||||
/>
|
||||
<h3 class="ml-3 text-xl text-black">{org.name}</h3>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<span class="flex-grow"></span>
|
||||
|
||||
<div class="mt-5 flex gap-2 sm:mt-0">
|
||||
{#if $landingPageSettings.footer.facebook}
|
||||
<li>
|
||||
<a
|
||||
href={$landingPageSettings.footer.facebook}
|
||||
target="_blank"
|
||||
title={$t('settings.landing_page.footer.facebook')}
|
||||
id="logo-fb"
|
||||
data-hveid="8"
|
||||
>
|
||||
<FacebookIcon size={16} />
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
{#if $landingPageSettings.footer.twitter}
|
||||
<li>
|
||||
<a
|
||||
href={$landingPageSettings.footer.twitter}
|
||||
target="_blank"
|
||||
title={$t('settings.landing_page.footer.twitter')}
|
||||
id="logo-tw"
|
||||
data-hveid="8"
|
||||
>
|
||||
<TwitterIcon size={16} />
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
{#if $landingPageSettings.footer.linkedin}
|
||||
<li>
|
||||
<a
|
||||
href={$landingPageSettings.footer.linkedin}
|
||||
target="_blank"
|
||||
title={$t('settings.landing_page.footer.linkedin')}
|
||||
id="logo-ln"
|
||||
data-hveid="8"
|
||||
>
|
||||
<LinkedinIcon size={16} />
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
</div>
|
||||
</ul>
|
||||
</footer>
|
||||
{/if}
|
||||
</main>
|
||||
<ThemeComponent {...landingPageProps} />
|
||||
{/if}
|
||||
|
||||
@@ -1,116 +1,7 @@
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const landingPageSettings: Writable<OrgLandingPageJson> = writable({
|
||||
header: {
|
||||
title: 'Web Development Courses for',
|
||||
titleHighlight: 'Everyone',
|
||||
subtitle: 'Practical project-based courses that are easy to understand and straight to the point',
|
||||
action: {
|
||||
label: 'Start learning Today',
|
||||
link: '#contact',
|
||||
redirect: false
|
||||
},
|
||||
banner: {
|
||||
video: '',
|
||||
image: '/images/learn-on-cio.jpg',
|
||||
type: 'image',
|
||||
show: true
|
||||
},
|
||||
background: {
|
||||
image: '',
|
||||
show: true
|
||||
},
|
||||
show: true
|
||||
},
|
||||
aboutUs: {
|
||||
title: 'Our Story',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.',
|
||||
imageUrl: '/images/riverside.jpeg',
|
||||
show: true
|
||||
},
|
||||
courses: {
|
||||
title: 'Explore our',
|
||||
titleHighlight: 'Courses',
|
||||
subtitle: 'Find courses you will love from best teachers all over the world🌎.',
|
||||
show: true
|
||||
},
|
||||
faq: {
|
||||
title: 'Frequently Asked Questions.',
|
||||
questions: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
}
|
||||
],
|
||||
show: true
|
||||
},
|
||||
contact: {
|
||||
title: 'Have you got any',
|
||||
titleHighlight: 'Question?',
|
||||
subtitle: 'Reach out to us with any question you might have.',
|
||||
address: 'Ashwoodfield House Farm, Kidderminster Rd, Stourbridge, Kingswinford DY6 0AA',
|
||||
phone: '+4407339904995',
|
||||
email: '[email protected]',
|
||||
show: true
|
||||
},
|
||||
mailinglist: {
|
||||
title: 'Join our mailing list',
|
||||
subtitle:
|
||||
'We are constantly releasing new courses and sharing them with our email list. Subscribe to get notified once we release a new course',
|
||||
buttonLabel: 'Subscribe',
|
||||
show: true
|
||||
},
|
||||
customLinks: {
|
||||
show: true,
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
label: 'About Us',
|
||||
url: '#aboutus',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: 'Courses',
|
||||
url: '/courses',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: 'FAQ',
|
||||
url: '#faq',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: 'Contact Us',
|
||||
url: '#contact',
|
||||
openInNewTab: false
|
||||
}
|
||||
]
|
||||
},
|
||||
footer: {
|
||||
facebook: '#',
|
||||
instagram: '#',
|
||||
twitter: '#',
|
||||
linkedin: '#',
|
||||
show: true
|
||||
}
|
||||
});
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import { createDefaultLandingPageSettings } from '$features/org/utils/landing-page';
|
||||
|
||||
export const landingPageSettings: Writable<OrgLandingPageJson> = writable(createDefaultLandingPageSettings());
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
BoldLandingHero,
|
||||
BoldLandingNav,
|
||||
BoldLandingPage,
|
||||
ClassicLandingHero,
|
||||
ClassicLandingNav,
|
||||
ClassicLandingPage,
|
||||
MinimalLandingHero,
|
||||
MinimalLandingNav,
|
||||
MinimalLandingPage
|
||||
} from '@cio/ui/custom/org-landing-page';
|
||||
|
||||
export const landingPageThemeComponents = {
|
||||
minimal: MinimalLandingPage,
|
||||
bold: BoldLandingPage,
|
||||
classic: ClassicLandingPage
|
||||
} as const;
|
||||
|
||||
export const landingPageNavComponents = {
|
||||
minimal: MinimalLandingNav,
|
||||
bold: BoldLandingNav,
|
||||
classic: ClassicLandingNav
|
||||
} as const;
|
||||
|
||||
export const landingPageHeroComponents = {
|
||||
minimal: MinimalLandingHero,
|
||||
bold: BoldLandingHero,
|
||||
classic: ClassicLandingHero
|
||||
} as const;
|
||||
@@ -0,0 +1,397 @@
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import type {
|
||||
OrgLandingPageCallout,
|
||||
OrgLandingPageFooterLink,
|
||||
OrgLandingPageHero,
|
||||
OrgLandingPageJson,
|
||||
OrgLandingPageNavItem,
|
||||
OrgLandingPageTheme
|
||||
} from '$lib/utils/types/org';
|
||||
import type { OrgPublicCourses } from './types';
|
||||
import type { OrgLandingPageProps } from '@cio/ui/custom/org-landing-page/types';
|
||||
|
||||
export const landingPageThemes = ['minimal', 'bold', 'classic'] as const satisfies OrgLandingPageTheme[];
|
||||
|
||||
export const defaultLandingPageHero: OrgLandingPageHero = {
|
||||
heading: 'Become a Certified AI Engineer',
|
||||
subheading: 'Master the skills, earn your certification, and prove your expertise with hands-on training programs.',
|
||||
primaryAction: {
|
||||
label: 'Get Started',
|
||||
href: '#courses'
|
||||
},
|
||||
secondaryAction: {
|
||||
label: 'View Programs',
|
||||
href: '/courses'
|
||||
},
|
||||
image: '/images/learn-on-cio.jpg'
|
||||
};
|
||||
|
||||
export const defaultLandingPageSettings: OrgLandingPageJson = {
|
||||
theme: 'minimal',
|
||||
hero: defaultLandingPageHero,
|
||||
navItems: [
|
||||
{ label: 'Programs', href: '#courses' },
|
||||
{ label: 'Contact', href: '#contact' }
|
||||
],
|
||||
footerLinks: [
|
||||
{ label: 'Terms', href: '/terms' },
|
||||
{ label: 'Privacy', href: '/privacy' },
|
||||
{ label: 'Contact', href: '#contact' }
|
||||
],
|
||||
footerText: ''
|
||||
};
|
||||
|
||||
export function createDefaultLandingPageSettings(): OrgLandingPageJson {
|
||||
return structuredClone(defaultLandingPageSettings);
|
||||
}
|
||||
|
||||
type LegacyLandingPageJson = {
|
||||
header?: {
|
||||
title?: string;
|
||||
titleHighlight?: string;
|
||||
subtitle?: string;
|
||||
action?: {
|
||||
label?: string;
|
||||
link?: string;
|
||||
};
|
||||
};
|
||||
customLinks?: {
|
||||
links?: Array<{
|
||||
label?: string;
|
||||
url?: string;
|
||||
openInNewTab?: boolean;
|
||||
}>;
|
||||
};
|
||||
footer?: {
|
||||
facebook?: string;
|
||||
instagram?: string;
|
||||
twitter?: string;
|
||||
linkedin?: string;
|
||||
};
|
||||
hero?: OrgLandingPageHero;
|
||||
navItems?: OrgLandingPageNavItem[];
|
||||
footerLinks?: OrgLandingPageFooterLink[];
|
||||
footerText?: string;
|
||||
theme?: OrgLandingPageTheme;
|
||||
embed?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
code?: string;
|
||||
secondaryAction?: {
|
||||
label?: string;
|
||||
href?: string;
|
||||
};
|
||||
src?: string;
|
||||
height?: number;
|
||||
allowFullscreen?: boolean;
|
||||
sandbox?: string;
|
||||
};
|
||||
callout?: {
|
||||
heading?: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label?: string;
|
||||
href?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function normalizeHref(value: unknown, fallback = '#') {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function normalizeEmbedCode(embed: {
|
||||
title?: unknown;
|
||||
description?: unknown;
|
||||
code?: unknown;
|
||||
src?: unknown;
|
||||
height?: unknown;
|
||||
allowFullscreen?: unknown;
|
||||
sandbox?: unknown;
|
||||
}) {
|
||||
if (typeof embed.code === 'string' && embed.code.trim().length > 0) {
|
||||
return embed.code.trim();
|
||||
}
|
||||
|
||||
const src = normalizeHref(embed.src, '');
|
||||
if (!src) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const height = typeof embed.height === 'number' && Number.isFinite(embed.height) ? embed.height : 560;
|
||||
const allowFullscreen = embed.allowFullscreen !== false;
|
||||
const title = normalizeText(embed.title, 'Section title');
|
||||
const sandbox =
|
||||
typeof embed.sandbox === 'string' && embed.sandbox.trim().length > 0 ? ` sandbox="${embed.sandbox.trim()}"` : '';
|
||||
|
||||
return `<iframe title="${title}" src="${src}" width="100%" height="${height}" loading="lazy" referrerpolicy="no-referrer"${sandbox}${
|
||||
allowFullscreen ? ' allowfullscreen' : ''
|
||||
}></iframe>`;
|
||||
}
|
||||
|
||||
function normalizeCallout(callout: NonNullable<LegacyLandingPageJson['callout']>): OrgLandingPageCallout | undefined {
|
||||
const heading = normalizeText(callout.heading, '');
|
||||
if (!heading) return undefined;
|
||||
|
||||
return {
|
||||
heading,
|
||||
description: normalizeText(callout.description, ''),
|
||||
action: {
|
||||
label: normalizeText(callout.action?.label, 'Get Started'),
|
||||
href: normalizeHref(callout.action?.href, '#')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLandingPageSettings(value: unknown): OrgLandingPageJson {
|
||||
const defaultSettings = createDefaultLandingPageSettings();
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
const landingPage = value as LegacyLandingPageJson;
|
||||
if (landingPage.hero && landingPage.navItems && landingPage.footerLinks && landingPage.theme) {
|
||||
return {
|
||||
theme: landingPage.theme,
|
||||
hero: {
|
||||
heading: normalizeText(landingPage.hero.heading, defaultLandingPageHero.heading),
|
||||
subheading: normalizeText(landingPage.hero.subheading, defaultLandingPageHero.subheading),
|
||||
primaryAction: {
|
||||
label: normalizeText(landingPage.hero.primaryAction?.label, defaultLandingPageHero.primaryAction.label),
|
||||
href: normalizeHref(landingPage.hero.primaryAction?.href, defaultLandingPageHero.primaryAction.href)
|
||||
},
|
||||
secondaryAction: landingPage.hero.secondaryAction
|
||||
? {
|
||||
label: normalizeText(
|
||||
landingPage.hero.secondaryAction.label,
|
||||
defaultLandingPageHero.secondaryAction?.label ?? ''
|
||||
),
|
||||
href: normalizeHref(
|
||||
landingPage.hero.secondaryAction.href,
|
||||
defaultLandingPageHero.secondaryAction?.href ?? '#'
|
||||
)
|
||||
}
|
||||
: undefined,
|
||||
image: normalizeText(landingPage.hero.image, '')
|
||||
},
|
||||
navItems: landingPage.navItems
|
||||
.map((navItem) => ({
|
||||
label: normalizeText(navItem.label, ''),
|
||||
href: normalizeHref(navItem.href, '#')
|
||||
}))
|
||||
.filter((navItem) => navItem.label.length > 0),
|
||||
footerLinks: landingPage.footerLinks
|
||||
.map((footerLink) => ({
|
||||
label: normalizeText(footerLink.label, ''),
|
||||
href: normalizeHref(footerLink.href, '#')
|
||||
}))
|
||||
.filter((footerLink) => footerLink.label.length > 0),
|
||||
footerText: normalizeText(landingPage.footerText, defaultSettings.footerText),
|
||||
embed: landingPage.embed
|
||||
? {
|
||||
title: normalizeText(landingPage.embed.title, 'Section title'),
|
||||
description: normalizeText(landingPage.embed.description, ''),
|
||||
code: normalizeEmbedCode(landingPage.embed),
|
||||
secondaryAction: landingPage.embed.secondaryAction
|
||||
? {
|
||||
label: normalizeText(landingPage.embed.secondaryAction.label, ''),
|
||||
href: normalizeHref(landingPage.embed.secondaryAction.href, '#')
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
: undefined,
|
||||
callout: landingPage.callout ? normalizeCallout(landingPage.callout) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
if (landingPage.hero) {
|
||||
return {
|
||||
theme: landingPage.theme ?? 'minimal',
|
||||
hero: {
|
||||
heading: normalizeText(landingPage.hero.heading, defaultLandingPageHero.heading),
|
||||
subheading: normalizeText(landingPage.hero.subheading, defaultLandingPageHero.subheading),
|
||||
primaryAction: {
|
||||
label: normalizeText(landingPage.hero.primaryAction?.label, defaultLandingPageHero.primaryAction.label),
|
||||
href: normalizeHref(landingPage.hero.primaryAction?.href, defaultLandingPageHero.primaryAction.href)
|
||||
},
|
||||
secondaryAction: landingPage.hero.secondaryAction
|
||||
? {
|
||||
label: normalizeText(
|
||||
landingPage.hero.secondaryAction.label,
|
||||
defaultLandingPageHero.secondaryAction?.label ?? ''
|
||||
),
|
||||
href: normalizeHref(
|
||||
landingPage.hero.secondaryAction.href,
|
||||
defaultLandingPageHero.secondaryAction?.href ?? '#'
|
||||
)
|
||||
}
|
||||
: defaultLandingPageHero.secondaryAction,
|
||||
image: normalizeText(landingPage.hero.image, defaultLandingPageHero.image ?? '')
|
||||
},
|
||||
navItems: landingPage.navItems?.length
|
||||
? landingPage.navItems
|
||||
.map((navItem) => ({
|
||||
label: normalizeText(navItem.label, ''),
|
||||
href: normalizeHref(navItem.href, '#')
|
||||
}))
|
||||
.filter((navItem) => navItem.label.length > 0)
|
||||
: [...defaultSettings.navItems],
|
||||
footerLinks: landingPage.footerLinks?.length
|
||||
? landingPage.footerLinks
|
||||
.map((footerLink) => ({
|
||||
label: normalizeText(footerLink.label, ''),
|
||||
href: normalizeHref(footerLink.href, '#')
|
||||
}))
|
||||
.filter((footerLink) => footerLink.label.length > 0)
|
||||
: [...defaultSettings.footerLinks],
|
||||
footerText: normalizeText(landingPage.footerText, defaultSettings.footerText),
|
||||
embed: landingPage.embed
|
||||
? {
|
||||
title: normalizeText(landingPage.embed.title, 'Section title'),
|
||||
description: normalizeText(landingPage.embed.description, ''),
|
||||
code: normalizeEmbedCode(landingPage.embed)
|
||||
}
|
||||
: undefined,
|
||||
callout: landingPage.callout ? normalizeCallout(landingPage.callout) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackHeading = normalizeText(landingPage.header?.title, defaultLandingPageHero.heading);
|
||||
const fallbackHighlight = normalizeText(landingPage.header?.titleHighlight, '');
|
||||
const fallbackSubheading = normalizeText(landingPage.header?.subtitle, defaultLandingPageHero.subheading);
|
||||
|
||||
return {
|
||||
...defaultSettings,
|
||||
hero: {
|
||||
heading: fallbackHighlight ? `${fallbackHeading} ${fallbackHighlight}` : fallbackHeading,
|
||||
subheading: fallbackSubheading,
|
||||
primaryAction: {
|
||||
label: normalizeText(landingPage.header?.action?.label, defaultLandingPageHero.primaryAction.label),
|
||||
href: normalizeHref(landingPage.header?.action?.link, defaultLandingPageHero.primaryAction.href)
|
||||
},
|
||||
secondaryAction: defaultLandingPageHero.secondaryAction,
|
||||
image: defaultLandingPageHero.image
|
||||
},
|
||||
navItems: landingPage.customLinks?.links?.length
|
||||
? landingPage.customLinks.links
|
||||
.map((link) => ({
|
||||
label: normalizeText(link.label, ''),
|
||||
href: normalizeHref(link.url, '#')
|
||||
}))
|
||||
.filter((navItem) => navItem.label.length > 0)
|
||||
: [...defaultSettings.navItems],
|
||||
footerLinks: [
|
||||
landingPage.footer?.facebook ? { label: 'Facebook', href: landingPage.footer.facebook } : null,
|
||||
landingPage.footer?.instagram ? { label: 'Instagram', href: landingPage.footer.instagram } : null,
|
||||
landingPage.footer?.twitter ? { label: 'Twitter', href: landingPage.footer.twitter } : null,
|
||||
landingPage.footer?.linkedin ? { label: 'LinkedIn', href: landingPage.footer.linkedin } : null
|
||||
].filter(Boolean) as OrgLandingPageFooterLink[],
|
||||
footerText: defaultSettings.footerText,
|
||||
embed: landingPage.embed
|
||||
? {
|
||||
title: normalizeText(landingPage.embed.title, 'Section title'),
|
||||
description: normalizeText(landingPage.embed.description, ''),
|
||||
code: normalizeEmbedCode(landingPage.embed),
|
||||
secondaryAction: landingPage.embed.secondaryAction
|
||||
? {
|
||||
label: normalizeText(landingPage.embed.secondaryAction.label, ''),
|
||||
href: normalizeHref(landingPage.embed.secondaryAction.href, '#')
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function mapPublicCoursesToLandingPageCourses(courses: OrgPublicCourses): OrgLandingPageProps['courses'] {
|
||||
return courses.map((course) => {
|
||||
const courseRecord = course as Record<string, unknown>;
|
||||
const metadataRecord = isRecord(courseRecord.metadata) ? courseRecord.metadata : undefined;
|
||||
const tags = Array.isArray(courseRecord.tags)
|
||||
? (courseRecord.tags as OrgLandingPageProps['courses'][number]['tags'])
|
||||
: undefined;
|
||||
const courseSlug =
|
||||
typeof courseRecord.slug === 'string' && courseRecord.slug.length > 0 ? courseRecord.slug : course.id;
|
||||
const courseCost = typeof courseRecord.cost === 'number' ? courseRecord.cost : undefined;
|
||||
const courseCurrency = typeof courseRecord.currency === 'string' ? courseRecord.currency : undefined;
|
||||
const lessonCount = typeof courseRecord.lessonCount === 'number' ? courseRecord.lessonCount : undefined;
|
||||
const exerciseCount = typeof courseRecord.exerciseCount === 'number' ? courseRecord.exerciseCount : undefined;
|
||||
const totalStudents = typeof courseRecord.totalStudents === 'number' ? courseRecord.totalStudents : undefined;
|
||||
const image = typeof courseRecord.image === 'string' ? courseRecord.image : undefined;
|
||||
const logo = typeof courseRecord.logo === 'string' ? courseRecord.logo : undefined;
|
||||
const price = typeof courseRecord.price === 'string' ? courseRecord.price : undefined;
|
||||
const duration = typeof courseRecord.duration === 'string' ? courseRecord.duration : undefined;
|
||||
const level = typeof courseRecord.level === 'string' ? courseRecord.level : undefined;
|
||||
const description = typeof courseRecord.description === 'string' ? courseRecord.description : '';
|
||||
const type = typeof courseRecord.type === 'string' ? courseRecord.type : undefined;
|
||||
const isPublished = typeof courseRecord.isPublished === 'boolean' ? courseRecord.isPublished : true;
|
||||
|
||||
return {
|
||||
id: course.id,
|
||||
slug: typeof courseRecord.slug === 'string' ? courseRecord.slug : undefined,
|
||||
logo: logo ?? null,
|
||||
title: typeof courseRecord.title === 'string' ? courseRecord.title : '',
|
||||
description,
|
||||
type,
|
||||
isPublished,
|
||||
cost: courseCost,
|
||||
currency: courseCurrency,
|
||||
lessonCount,
|
||||
exerciseCount,
|
||||
totalStudents,
|
||||
metadata: metadataRecord
|
||||
? {
|
||||
discount: typeof metadataRecord.discount === 'number' ? metadataRecord.discount : undefined,
|
||||
showDiscount: typeof metadataRecord.showDiscount === 'boolean' ? metadataRecord.showDiscount : undefined
|
||||
}
|
||||
: undefined,
|
||||
tags,
|
||||
image,
|
||||
link: `/course/${courseSlug}`,
|
||||
price,
|
||||
duration,
|
||||
level
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildOrgLandingPageProps(
|
||||
org: AccountOrg,
|
||||
landingpage: unknown,
|
||||
courses: OrgPublicCourses,
|
||||
hasMoreCourses = false,
|
||||
authAction?: OrgLandingPageProps['authAction']
|
||||
): OrgLandingPageProps {
|
||||
const normalizedLandingPage = normalizeLandingPageSettings(landingpage);
|
||||
|
||||
return {
|
||||
orgName: org.name,
|
||||
logoUrl: org.avatarUrl || undefined,
|
||||
authAction,
|
||||
...normalizedLandingPage,
|
||||
courses: mapPublicCoursesToLandingPageCourses(courses),
|
||||
hasMoreCourses
|
||||
};
|
||||
}
|
||||
|
||||
export type LandingPageThemeKey = (typeof landingPageThemes)[number];
|
||||
|
||||
export function importThemeComponent(theme: LandingPageThemeKey) {
|
||||
switch (theme) {
|
||||
case 'bold':
|
||||
return import('@cio/ui/custom/org-landing-page/bold.svelte');
|
||||
case 'classic':
|
||||
return import('@cio/ui/custom/org-landing-page/classic.svelte');
|
||||
default:
|
||||
return import('@cio/ui/custom/org-landing-page/minimal.svelte');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
import { classroomio, type InferRequestType, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
export type GetOrganizationRequest = typeof classroomio.organization.$get;
|
||||
export type GetOrganizationResponse = InferResponseType<GetOrganizationRequest> | null;
|
||||
export type GetOrganizationSuccess = Extract<InferResponseType<GetOrganizationRequest>, { success: true }>;
|
||||
export type OrganizationRecord = GetOrganizationSuccess['data'][number];
|
||||
|
||||
export type GetFirstOrganizationRequest = typeof classroomio.organization.first.$get;
|
||||
export type GetFirstOrganizationResponse = InferResponseType<GetFirstOrganizationRequest> | null;
|
||||
export type GetFirstOrganizationSuccess = Extract<InferResponseType<GetFirstOrganizationRequest>, { success: true }>;
|
||||
export type FirstOrganizationRecord = GetFirstOrganizationSuccess['data'][number];
|
||||
|
||||
// Team member types
|
||||
export type GetTeamRequest = (typeof classroomio.organization)['team']['$get'];
|
||||
export type OrganizationTeamResponse = InferResponseType<GetTeamRequest> | null;
|
||||
@@ -39,7 +49,8 @@ export type OrganizationAudienceMember = OrganizationAudience[number];
|
||||
export type GetOrgPublicCoursesRequest = typeof classroomio.organization.courses.public.$get;
|
||||
export type OrgPublicCoursesResponse = InferResponseType<GetOrgPublicCoursesRequest> | null;
|
||||
export type OrgPublicCoursesSuccess = Extract<InferResponseType<GetOrgPublicCoursesRequest>, { success: true }>;
|
||||
export type OrgPublicCourses = OrgPublicCoursesSuccess['data'];
|
||||
export type OrgPublicCoursesData = OrgPublicCoursesSuccess['data'];
|
||||
export type OrgPublicCourses = OrgPublicCoursesData['courses'];
|
||||
|
||||
// dashboard analytics types
|
||||
export type GetDashStatsRequest = typeof classroomio.dash.stats.$get;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
interface Props {
|
||||
onInvite: (recipientCsv: string, sendEmail: boolean) => void | Promise<void>;
|
||||
titleKey?: string;
|
||||
descriptionKey?: string;
|
||||
submitKey?: string;
|
||||
noEmailsKey?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onInvite,
|
||||
titleKey = 'course.navItem.people.invite_modal.invite_new_students_title',
|
||||
descriptionKey = 'course.navItem.people.invite_modal.invite_new_students_description',
|
||||
submitKey = 'course.navItem.people.invite_modal.invite_new_students',
|
||||
noEmailsKey = 'audience.import.snackbar_no_emails'
|
||||
}: Props = $props();
|
||||
|
||||
let recipientCsv = $state('');
|
||||
let sendInviteEmails = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
async function inviteStudents() {
|
||||
if (!recipientCsv.trim()) {
|
||||
snackbar.error(noEmailsKey);
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
await onInvite(recipientCsv, sendInviteEmails);
|
||||
recipientCsv = '';
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border p-4">
|
||||
<p class="mb-2 text-base font-semibold">{$t(titleKey)}</p>
|
||||
<p class="ui:text-muted-foreground mb-2 text-sm">
|
||||
{$t(descriptionKey)}
|
||||
</p>
|
||||
<TextareaField
|
||||
label={$t('audience.import.emails_label')}
|
||||
bind:value={recipientCsv}
|
||||
rows={4}
|
||||
className="w-full"
|
||||
placeholder={$t('audience.import.emails_placeholder')}
|
||||
/>
|
||||
<CheckboxField
|
||||
className="mt-2"
|
||||
name="send-invite-emails"
|
||||
label={$t('audience.import.send_email')}
|
||||
bind:checked={sendInviteEmails}
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<Button variant="secondary" onclick={inviteStudents} loading={isSubmitting}>
|
||||
{$t(submitKey)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
|
||||
import { MultiSelectList } from '@cio/ui/custom/multi-select-list';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import type { OrgStudent } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
students: OrgStudent[];
|
||||
isLoading?: boolean;
|
||||
onSearchValueChange: (value: string) => void | Promise<void>;
|
||||
onAssign: (profileIds: string[], sendEmail: boolean) => void | Promise<void>;
|
||||
headingKey?: string;
|
||||
emptyMatchingKey?: string;
|
||||
emptyDefaultKey?: string;
|
||||
searchPlaceholderKey?: string;
|
||||
notifyLabelKey?: string;
|
||||
submitKey?: string;
|
||||
selectAtLeastOneKey?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
students,
|
||||
isLoading = false,
|
||||
onSearchValueChange,
|
||||
onAssign,
|
||||
headingKey = 'course.navItem.people.invite_modal.existing_students_title',
|
||||
emptyMatchingKey = 'course.navItem.people.invite_modal.no_matching_existing_students',
|
||||
emptyDefaultKey = 'course.navItem.people.invite_modal.no_existing_students',
|
||||
searchPlaceholderKey = 'course.navItem.people.invite_modal.existing_students_search',
|
||||
notifyLabelKey = 'course.navItem.people.invite_modal.notify_existing_students',
|
||||
submitKey = 'course.navItem.people.invite_modal.grant_access',
|
||||
selectAtLeastOneKey = 'course.navItem.people.invite_modal.select_at_least_one_student'
|
||||
}: Props = $props();
|
||||
|
||||
let searchValue = $state('');
|
||||
let sendEmail = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 350;
|
||||
|
||||
const selectedProfileIds = new SvelteSet<string>();
|
||||
const sortedStudents = $derived.by(() =>
|
||||
[...students].sort((a, b) =>
|
||||
(a.name || a.email || '').localeCompare(b.name || b.email || '', undefined, { sensitivity: 'base' })
|
||||
)
|
||||
);
|
||||
const selectedCount = $derived(selectedProfileIds.size);
|
||||
const emptyMessage = $derived(searchValue.trim() ? $t(emptyMatchingKey) : $t(emptyDefaultKey));
|
||||
const multiSelectItems = $derived(
|
||||
sortedStudents
|
||||
.filter((student): student is OrgStudent & { profileId: string } => Boolean(student.profileId))
|
||||
.map((student) => ({
|
||||
id: student.profileId,
|
||||
label: student.name || student.email,
|
||||
description: student.email
|
||||
}))
|
||||
);
|
||||
|
||||
function toggleStudent(profileId: string) {
|
||||
if (selectedProfileIds.has(profileId)) {
|
||||
selectedProfileIds.delete(profileId);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedProfileIds.add(profileId);
|
||||
}
|
||||
|
||||
function handleSearch(value: string) {
|
||||
searchValue = value;
|
||||
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
if (!value.trim()) {
|
||||
void onSearchValueChange(value);
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
void onSearchValueChange(value);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function assignStudents() {
|
||||
if (selectedProfileIds.size === 0) {
|
||||
snackbar.error(selectAtLeastOneKey);
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
await onAssign([...selectedProfileIds], sendEmail);
|
||||
selectedProfileIds.clear();
|
||||
searchValue = '';
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (!searchTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="max-h-72 overflow-y-auto">
|
||||
<MultiSelectList
|
||||
class="border-0"
|
||||
{emptyMessage}
|
||||
items={multiSelectItems}
|
||||
{isLoading}
|
||||
isSelected={(id) => selectedProfileIds.has(id)}
|
||||
onSearchValueChange={handleSearch}
|
||||
onToggle={(id) => toggleStudent(id)}
|
||||
namePrefix="existing-student"
|
||||
listClass="max-h-60"
|
||||
searchPlaceholder={$t(searchPlaceholderKey)}
|
||||
bind:searchValue
|
||||
>
|
||||
{#snippet headingSnippet()}
|
||||
<p class="ui:text-sm ui:font-medium">
|
||||
{$t(headingKey)} ({multiSelectItems.length})
|
||||
{#if selectedCount > 0}
|
||||
<span class="ui:text-muted-foreground text-xs"
|
||||
>{$t('audience.selected_count', { count: selectedCount })}</span
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{/snippet}
|
||||
</MultiSelectList>
|
||||
</div>
|
||||
|
||||
<CheckboxField name="notify-existing-students" label={$t(notifyLabelKey)} bind:checked={sendEmail} />
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button variant="secondary" onclick={assignStudents} loading={isSubmitting} disabled={isLoading || isSubmitting}>
|
||||
{$t(submitKey)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as TutorSelectSection } from './tutor-select-section.svelte';
|
||||
export { default as ExistingStudentsSection } from './existing-students-section.svelte';
|
||||
export { default as BulkEmailSection } from './bulk-email-section.svelte';
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Tutor } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
tutors: Tutor[];
|
||||
selectedIds: string[];
|
||||
isLoading: boolean;
|
||||
titleKey?: string;
|
||||
emptySelectionKey?: string;
|
||||
loadingKey?: string;
|
||||
helperTextKey?: string;
|
||||
helperLinkKey?: string;
|
||||
helperHref?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
tutors,
|
||||
selectedIds = $bindable(),
|
||||
isLoading,
|
||||
titleKey = 'course.navItem.people.invite_modal.invite',
|
||||
emptySelectionKey = 'course.navItem.people.invite_modal.select',
|
||||
loadingKey = 'course.navItem.people.invite_modal.loading',
|
||||
helperTextKey = 'course.navItem.people.invite_modal.to_add',
|
||||
helperLinkKey = 'course.navItem.people.invite_modal.go_to',
|
||||
helperHref = '#'
|
||||
}: Props = $props();
|
||||
|
||||
const selectedTutors = $derived(tutors.filter((tutor) => selectedIds.includes(tutor.id.toString())));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border p-4">
|
||||
<p class="mb-2 text-base font-semibold">{$t(titleKey)}</p>
|
||||
|
||||
<Select.Root type="multiple" bind:value={selectedIds} disabled={isLoading}>
|
||||
<Select.Trigger class="w-full">
|
||||
<div>
|
||||
{#if selectedTutors.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each selectedTutors as tutor}
|
||||
<Badge variant="secondary" class="flex items-center gap-1">
|
||||
{tutor.text}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
selectedIds = selectedIds.filter((id) => id !== tutor.id.toString());
|
||||
}}
|
||||
class="h-5 w-5 rounded-sm hover:bg-gray-200"
|
||||
aria-label={`Remove ${tutor.text}`}
|
||||
>
|
||||
<XIcon size={14} />
|
||||
</Button>
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{$t(emptySelectionKey)}
|
||||
{/if}
|
||||
</div>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each tutors as tutor}
|
||||
<Select.Item value={tutor.id.toString()}>
|
||||
{tutor.text}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<Spinner class="h-4 w-4" />
|
||||
<span class="text-sm">{$t(loadingKey)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="mt-2 block text-sm">
|
||||
{$t(helperTextKey)}
|
||||
<a href={helperHref} class="ui:text-primary underline">
|
||||
{$t(helperLinkKey)}
|
||||
</a>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { OrganizationAudience, OrganizationTeamMembers } from '$features/org/utils/types';
|
||||
|
||||
type OrgTeamMemberItem = OrganizationTeamMembers[number];
|
||||
export type Tutor = Pick<OrgTeamMemberItem, 'id' | 'profileId' | 'email'> & {
|
||||
text: OrgTeamMemberItem['fullname'];
|
||||
};
|
||||
|
||||
type OrgAudienceMemberItem = OrganizationAudience[number];
|
||||
export type OrgStudent = OrgAudienceMemberItem;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { programApi } from './program.svelte';
|
||||
export { programNewsfeedApi, type ProgramNewsfeedCommentsByFeedId } from './newsfeed.svelte';
|
||||
@@ -0,0 +1,344 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
CreateProgramNewsfeedCommentRequest,
|
||||
CreateProgramNewsfeedRequest,
|
||||
DeleteProgramNewsfeedCommentRequest,
|
||||
DeleteProgramNewsfeedRequest,
|
||||
GetProgramNewsfeedCommentsRequest,
|
||||
ListProgramNewsfeed,
|
||||
ListProgramNewsfeedRequest,
|
||||
ProgramNewsfeedComment,
|
||||
ReactToProgramNewsfeedRequest,
|
||||
UpdateProgramNewsfeedRequest
|
||||
} from '../utils/types';
|
||||
import type {
|
||||
TCreateProgramNewsfeed,
|
||||
TCreateProgramNewsfeedComment,
|
||||
TUpdateProgramNewsfeed,
|
||||
TUpdateProgramReaction
|
||||
} from '@cio/utils/validation/program';
|
||||
import {
|
||||
ZCreateProgramNewsfeed,
|
||||
ZCreateProgramNewsfeedComment,
|
||||
ZUpdateProgramNewsfeed,
|
||||
ZUpdateProgramReaction
|
||||
} from '@cio/utils/validation/program';
|
||||
import { get } from 'svelte/store';
|
||||
import { getTextFromHTML } from '$lib/utils/functions/toHtml';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
export type ProgramNewsfeedCommentsByFeedId = {
|
||||
items: ProgramNewsfeedComment[];
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
feeds = $state<ListProgramNewsfeed>([]);
|
||||
|
||||
commentsByFeedId = $state<Record<string, ProgramNewsfeedCommentsByFeedId>>({});
|
||||
|
||||
isNewFeedModalOpen = $state(false);
|
||||
|
||||
openNewFeedModal() {
|
||||
this.isNewFeedModalOpen = true;
|
||||
}
|
||||
|
||||
closeNewFeedModal() {
|
||||
this.isNewFeedModalOpen = false;
|
||||
}
|
||||
|
||||
async list(programId: string) {
|
||||
await this.execute<ListProgramNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed.$get({
|
||||
param: { programId },
|
||||
query: {}
|
||||
}),
|
||||
logContext: 'listing program newsfeed',
|
||||
onSuccess: (response) => {
|
||||
this.feeds = response.data.items || [];
|
||||
this.commentsByFeedId = {};
|
||||
|
||||
for (const feed of this.feeds) {
|
||||
this.initComments(feed.id, feed.commentCount);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
snackbar.error('snackbar.program.error.news_feed_fail');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async create(programId: string, fields: TCreateProgramNewsfeed) {
|
||||
this.success = false;
|
||||
|
||||
const validationResult = ZCreateProgramNewsfeed.safeParse({
|
||||
...fields,
|
||||
content: getTextFromHTML(fields.content)
|
||||
});
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<CreateProgramNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed.$post({
|
||||
param: { programId },
|
||||
json: fields
|
||||
}),
|
||||
logContext: 'creating program newsfeed',
|
||||
onSuccess: (response) => {
|
||||
const author = get(profile);
|
||||
const newFeed = {
|
||||
...response.data,
|
||||
authorProfileId: author.id,
|
||||
authorFullname: author.fullname,
|
||||
authorUsername: author.username,
|
||||
authorAvatarUrl: author.avatarUrl,
|
||||
commentCount: 0
|
||||
};
|
||||
|
||||
this.feeds = [newFeed, ...this.feeds];
|
||||
this.initComments(newFeed.id, 0);
|
||||
snackbar.success('snackbar.program.success.post_created');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async update(programId: string, feedId: string, fields: TUpdateProgramNewsfeed) {
|
||||
this.success = false;
|
||||
|
||||
const validationResult = ZUpdateProgramNewsfeed.safeParse({
|
||||
...fields,
|
||||
...(fields.content ? { content: getTextFromHTML(fields.content) } : {})
|
||||
});
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<UpdateProgramNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].$put({
|
||||
param: { programId, feedId },
|
||||
json: fields
|
||||
}),
|
||||
logContext: 'updating program newsfeed',
|
||||
onSuccess: (response) => {
|
||||
const index = this.feeds.findIndex((feed) => feed.id === feedId);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingFeed = this.feeds[index];
|
||||
this.feeds[index] = {
|
||||
...existingFeed,
|
||||
...response.data,
|
||||
authorProfileId: existingFeed.authorProfileId,
|
||||
authorFullname: existingFeed.authorFullname,
|
||||
authorUsername: existingFeed.authorUsername,
|
||||
authorAvatarUrl: existingFeed.authorAvatarUrl,
|
||||
commentCount: existingFeed.commentCount
|
||||
};
|
||||
|
||||
snackbar.success('snackbar.program.success.post_updated');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async react(programId: string, feedId: string, reaction: TUpdateProgramReaction['reaction']) {
|
||||
const validationResult = ZUpdateProgramReaction.safeParse({ reaction });
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<ReactToProgramNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].react.$put({
|
||||
param: { programId, feedId },
|
||||
json: validationResult.data
|
||||
}),
|
||||
logContext: 'reacting to program newsfeed',
|
||||
onSuccess: (response) => {
|
||||
const index = this.feeds.findIndex((feed) => feed.id === feedId);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingFeed = this.feeds[index];
|
||||
this.feeds[index] = {
|
||||
...existingFeed,
|
||||
...response.data,
|
||||
authorProfileId: existingFeed.authorProfileId,
|
||||
authorFullname: existingFeed.authorFullname,
|
||||
authorUsername: existingFeed.authorUsername,
|
||||
authorAvatarUrl: existingFeed.authorAvatarUrl,
|
||||
commentCount: existingFeed.commentCount
|
||||
};
|
||||
},
|
||||
onError: () => {
|
||||
snackbar.error('snackbar.program.error.reaction_error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async delete(programId: string, feedId: string) {
|
||||
await this.execute<DeleteProgramNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].$delete({
|
||||
param: { programId, feedId }
|
||||
}),
|
||||
logContext: 'deleting program newsfeed',
|
||||
onSuccess: () => {
|
||||
this.feeds = this.feeds.filter((feed) => feed.id !== feedId);
|
||||
|
||||
const { [feedId]: _removedComments, ...remainingComments } = this.commentsByFeedId;
|
||||
this.commentsByFeedId = remainingComments;
|
||||
|
||||
snackbar.success('snackbar.program.success.feed_delete_success');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initComments(feedId: string, totalCount: number) {
|
||||
if (!this.commentsByFeedId[feedId]) {
|
||||
this.commentsByFeedId[feedId] = {
|
||||
items: [],
|
||||
totalCount,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
cursor: null
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
this.commentsByFeedId[feedId].totalCount = totalCount;
|
||||
}
|
||||
|
||||
async getComments(programId: string, feedId: string) {
|
||||
if (!this.commentsByFeedId[feedId]) {
|
||||
this.initComments(feedId, 0);
|
||||
}
|
||||
|
||||
this.commentsByFeedId[feedId].isLoading = true;
|
||||
|
||||
await this.execute<GetProgramNewsfeedCommentsRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].comments.$get({
|
||||
param: { programId, feedId }
|
||||
}),
|
||||
logContext: 'fetching program newsfeed comments',
|
||||
onSuccess: (response) => {
|
||||
this.commentsByFeedId[feedId] = {
|
||||
items: response.data,
|
||||
totalCount: response.data.length,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
cursor: null
|
||||
};
|
||||
},
|
||||
onError: () => {
|
||||
this.commentsByFeedId[feedId].isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createComment(
|
||||
programId: string,
|
||||
feedId: string,
|
||||
content: string,
|
||||
author: { id: string; username: string; fullname: string; avatarUrl: string }
|
||||
) {
|
||||
const validationResult = ZCreateProgramNewsfeedComment.safeParse({
|
||||
content: getTextFromHTML(content)
|
||||
} satisfies TCreateProgramNewsfeedComment);
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<CreateProgramNewsfeedCommentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].comment.$post({
|
||||
param: { programId, feedId },
|
||||
json: { content }
|
||||
}),
|
||||
logContext: 'creating program newsfeed comment',
|
||||
onSuccess: (response) => {
|
||||
const existingCommentState = this.commentsByFeedId[feedId];
|
||||
if (!existingCommentState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newComment = {
|
||||
...response.data,
|
||||
authorProfileId: author.id,
|
||||
authorFullname: author.fullname,
|
||||
authorUsername: author.username,
|
||||
authorAvatarUrl: author.avatarUrl
|
||||
};
|
||||
|
||||
this.commentsByFeedId[feedId] = {
|
||||
...existingCommentState,
|
||||
items: [newComment, ...existingCommentState.items],
|
||||
totalCount: existingCommentState.totalCount + 1
|
||||
};
|
||||
|
||||
const feedIndex = this.feeds.findIndex((feed) => feed.id === feedId);
|
||||
if (feedIndex !== -1) {
|
||||
this.feeds[feedIndex] = {
|
||||
...this.feeds[feedIndex],
|
||||
commentCount: this.feeds[feedIndex].commentCount + 1
|
||||
};
|
||||
}
|
||||
|
||||
snackbar.success('snackbar.program.success.comment_added');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteComment(programId: string, feedId: string, commentId: string) {
|
||||
await this.execute<DeleteProgramNewsfeedCommentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].comment[':commentId'].$delete({
|
||||
param: { programId, feedId, commentId }
|
||||
}),
|
||||
logContext: 'deleting program newsfeed comment',
|
||||
onSuccess: () => {
|
||||
const existingCommentState = this.commentsByFeedId[feedId];
|
||||
if (!existingCommentState) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.commentsByFeedId[feedId] = {
|
||||
...existingCommentState,
|
||||
items: existingCommentState.items.filter((item) => String(item.id) !== commentId),
|
||||
totalCount: Math.max(0, existingCommentState.totalCount - 1)
|
||||
};
|
||||
|
||||
const feedIndex = this.feeds.findIndex((feed) => feed.id === feedId);
|
||||
if (feedIndex !== -1) {
|
||||
this.feeds[feedIndex] = {
|
||||
...this.feeds[feedIndex],
|
||||
commentCount: Math.max(0, this.feeds[feedIndex].commentCount - 1)
|
||||
};
|
||||
}
|
||||
|
||||
snackbar.success('snackbar.program.success.comment_deleted');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const programNewsfeedApi = new ProgramNewsfeedApi();
|
||||
@@ -0,0 +1,330 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type { Program, ProgramDetail, ProgramMember, ProgramCourse, ProgramNewsfeed } from '../utils/types';
|
||||
import type {
|
||||
TCreateProgram,
|
||||
TUpdateProgram,
|
||||
TAddProgramMembers,
|
||||
TUpdateProgramMember,
|
||||
TAddCourseToProgram
|
||||
} from '@cio/utils/validation/program';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { get } from 'svelte/store';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
class ProgramApi extends BaseApiWithErrors {
|
||||
programs = $state<Program[]>([]);
|
||||
program = $state<ProgramDetail | null>(null);
|
||||
members = $state<ProgramMember[]>([]);
|
||||
courses = $state<ProgramCourse[]>([]);
|
||||
newsfeed = $state<ProgramNewsfeed | null>(null);
|
||||
isProgramShellLoading = $state(false);
|
||||
currentProgramId = $state<string | null>(null);
|
||||
|
||||
loadedProgramId = $state<string | null>(null);
|
||||
loadedMembersProgramId = $state<string | null>(null);
|
||||
loadedCoursesProgramId = $state<string | null>(null);
|
||||
loadedCoursesStudentExperience = $state<boolean | null>(null);
|
||||
loadedNewsfeedProgramId = $state<string | null>(null);
|
||||
|
||||
syncProgramCourseCount(programId: string, courseCount: number) {
|
||||
this.programs = this.programs.map((program) => (program.id === programId ? { ...program, courseCount } : program));
|
||||
}
|
||||
|
||||
async listPrograms() {
|
||||
const org = get(currentOrg);
|
||||
if (!org.id) return;
|
||||
|
||||
await this.execute<typeof classroomio.program.$get>({
|
||||
requestFn: () => classroomio.program.$get({ query: { organizationId: org.id } }),
|
||||
onSuccess: (data) => {
|
||||
this.programs = data.data;
|
||||
},
|
||||
logContext: 'listPrograms'
|
||||
});
|
||||
}
|
||||
|
||||
async getProgram(programId: string, force = false) {
|
||||
if (!force && this.loadedProgramId === programId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['$get']>({
|
||||
requestFn: () => classroomio.program[':programId'].$get({ param: { programId } }),
|
||||
onSuccess: (data) => {
|
||||
this.program = data.data;
|
||||
this.loadedProgramId = programId;
|
||||
},
|
||||
logContext: 'getProgram'
|
||||
});
|
||||
}
|
||||
|
||||
resetProgramShell(programId?: string) {
|
||||
this.currentProgramId = programId ?? null;
|
||||
this.program = null;
|
||||
this.members = [];
|
||||
this.courses = [];
|
||||
this.newsfeed = null;
|
||||
this.loadedProgramId = null;
|
||||
this.loadedMembersProgramId = null;
|
||||
this.loadedCoursesProgramId = null;
|
||||
this.loadedCoursesStudentExperience = null;
|
||||
this.loadedNewsfeedProgramId = null;
|
||||
}
|
||||
|
||||
async ensureProgramShell(programId: string, force = false) {
|
||||
const studentExperience = get(isStudentExperience);
|
||||
const isShellReady =
|
||||
this.loadedProgramId === programId &&
|
||||
this.loadedMembersProgramId === programId &&
|
||||
this.loadedCoursesProgramId === programId &&
|
||||
this.loadedCoursesStudentExperience === studentExperience;
|
||||
|
||||
if (!force && isShellReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isProgramChanged = this.currentProgramId !== programId;
|
||||
if (force || isProgramChanged) {
|
||||
this.resetProgramShell(programId);
|
||||
}
|
||||
|
||||
this.currentProgramId = programId;
|
||||
this.isProgramShellLoading = true;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
this.getProgram(programId, force),
|
||||
this.listMembers(programId, force),
|
||||
this.listCourses(programId, force)
|
||||
]);
|
||||
} finally {
|
||||
this.isProgramShellLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async createProgram(data: TCreateProgram) {
|
||||
const org = get(currentOrg);
|
||||
if (!org.id) return;
|
||||
|
||||
await this.execute<typeof classroomio.program.$post>({
|
||||
requestFn: () =>
|
||||
classroomio.program.$post({
|
||||
json: { ...data, organizationId: org.id }
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
this.programs = [res.data, ...this.programs];
|
||||
snackbar.success(t.get('programs.create_success') || 'Program created');
|
||||
},
|
||||
logContext: 'createProgram'
|
||||
});
|
||||
}
|
||||
|
||||
async updateProgram(programId: string, data: TUpdateProgram) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['$put']>({
|
||||
requestFn: () => classroomio.program[':programId'].$put({ param: { programId }, json: data }),
|
||||
onSuccess: (res) => {
|
||||
this.program = res.data;
|
||||
this.programs = this.programs.map((p) => (p.id === programId ? { ...p, ...res.data } : p));
|
||||
snackbar.success(t.get('programs.update_success') || 'Program updated');
|
||||
},
|
||||
onError: (result) => {
|
||||
const message = typeof result === 'string' ? result : result.error;
|
||||
snackbar.error(message || 'Failed to update program');
|
||||
},
|
||||
logContext: 'updateProgram'
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProgram(programId: string) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['$delete']>({
|
||||
requestFn: () => classroomio.program[':programId'].$delete({ param: { programId } }),
|
||||
onSuccess: () => {
|
||||
this.programs = this.programs.filter((p) => p.id !== programId);
|
||||
snackbar.success(t.get('programs.delete_success') || 'Program deleted');
|
||||
},
|
||||
logContext: 'deleteProgram'
|
||||
});
|
||||
}
|
||||
|
||||
// Members
|
||||
|
||||
async listMembers(programId: string, force = false) {
|
||||
if (!force && this.loadedMembersProgramId === programId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['members']['$get']>({
|
||||
requestFn: () => classroomio.program[':programId'].members.$get({ param: { programId } }),
|
||||
onSuccess: (data) => {
|
||||
this.members = data.data;
|
||||
this.loadedMembersProgramId = programId;
|
||||
},
|
||||
logContext: 'listProgramMembers'
|
||||
});
|
||||
}
|
||||
|
||||
async addMembers(programId: string, data: TAddProgramMembers) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['members']['$post']>({
|
||||
requestFn: () => classroomio.program[':programId'].members.$post({ param: { programId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listMembers(programId, true);
|
||||
snackbar.success(t.get('programs.members_added') || 'Members added');
|
||||
},
|
||||
logContext: 'addProgramMembers'
|
||||
});
|
||||
}
|
||||
|
||||
async updateMember(programId: string, memberId: string, data: TUpdateProgramMember) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['members'][':memberId']['$put']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].members[':memberId'].$put({
|
||||
param: { programId, memberId },
|
||||
json: data
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
this.members = this.members.map((m) => (m.id === memberId ? { ...m, ...res.data } : m));
|
||||
},
|
||||
logContext: 'updateProgramMember'
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(programId: string, memberId: string) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['members'][':memberId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].members[':memberId'].$delete({
|
||||
param: { programId, memberId }
|
||||
}),
|
||||
onSuccess: () => {
|
||||
this.members = this.members.filter((m) => m.id !== memberId);
|
||||
snackbar.success(t.get('programs.member_removed') || 'Member removed');
|
||||
},
|
||||
logContext: 'removeProgramMember'
|
||||
});
|
||||
}
|
||||
|
||||
// Courses
|
||||
|
||||
async listCourses(programId: string, force = false) {
|
||||
const studentExperience = get(isStudentExperience);
|
||||
|
||||
if (
|
||||
!force &&
|
||||
this.loadedCoursesProgramId === programId &&
|
||||
this.loadedCoursesStudentExperience === studentExperience
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['courses']['$get']>({
|
||||
requestFn: () => classroomio.program[':programId'].courses.$get({ param: { programId } }),
|
||||
onSuccess: (data) => {
|
||||
this.courses = data.data;
|
||||
this.syncProgramCourseCount(programId, data.data.length);
|
||||
this.loadedCoursesProgramId = programId;
|
||||
this.loadedCoursesStudentExperience = studentExperience;
|
||||
},
|
||||
logContext: 'listProgramCourses'
|
||||
});
|
||||
}
|
||||
|
||||
async addCourse(programId: string, data: TAddCourseToProgram) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['courses']['$post']>({
|
||||
requestFn: () => classroomio.program[':programId'].courses.$post({ param: { programId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listCourses(programId, true);
|
||||
this.syncProgramCourseCount(programId, this.courses.length);
|
||||
snackbar.success(t.get('programs.course_added') || 'Course added');
|
||||
},
|
||||
logContext: 'addCourseToProgram'
|
||||
});
|
||||
}
|
||||
|
||||
async addCourses(programId: string, courseIds: string[]) {
|
||||
if (courseIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.success = false;
|
||||
|
||||
try {
|
||||
for (const courseId of courseIds) {
|
||||
const response = await classroomio.program[':programId'].courses.$post({
|
||||
param: { programId },
|
||||
json: { courseId }
|
||||
});
|
||||
const result = (await response.json()) as { success?: boolean; error?: string };
|
||||
|
||||
if (!result.success) {
|
||||
this.error = result.error ?? 'Failed to add course to program';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await this.listCourses(programId, true);
|
||||
this.syncProgramCourseCount(programId, this.courses.length);
|
||||
|
||||
const successMessage =
|
||||
courseIds.length === 1
|
||||
? t.get('programs.course_added') || 'Course added'
|
||||
: t.get('programs.multiple_added') || 'Courses added';
|
||||
|
||||
snackbar.success(successMessage);
|
||||
this.success = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error in addCoursesToProgram:', error);
|
||||
this.error = error instanceof Error ? error.message : 'Failed to add courses to program';
|
||||
return false;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async removeCourse(programId: string, courseId: string) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['courses'][':courseId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].courses[':courseId'].$delete({
|
||||
param: { programId, courseId }
|
||||
}),
|
||||
onSuccess: () => {
|
||||
this.courses = this.courses.filter((c) => c.courseId !== courseId);
|
||||
this.syncProgramCourseCount(programId, this.courses.length);
|
||||
snackbar.success(t.get('programs.course_removed') || 'Course removed');
|
||||
},
|
||||
logContext: 'removeCourseFromProgram'
|
||||
});
|
||||
}
|
||||
|
||||
// Newsfeed
|
||||
|
||||
async listNewsfeed(programId: string, force = false, cursor?: string) {
|
||||
if (!force && this.loadedNewsfeedProgramId === programId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['newsfeed']['$get']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed.$get({
|
||||
param: { programId },
|
||||
query: { limit: '10', ...(cursor ? { cursor } : {}) }
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
this.newsfeed = data.data;
|
||||
this.loadedNewsfeedProgramId = programId;
|
||||
},
|
||||
logContext: 'listProgramNewsfeed'
|
||||
});
|
||||
}
|
||||
|
||||
// LMS — enrolled programs
|
||||
|
||||
async listEnrolledPrograms() {
|
||||
await this.execute<typeof classroomio.program.enrolled.$get>({
|
||||
requestFn: () => classroomio.program.enrolled.$get({}),
|
||||
onSuccess: (data) => {
|
||||
this.programs = data.data;
|
||||
},
|
||||
logContext: 'listEnrolledPrograms'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const programApi = new ProgramApi();
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { MultiSelectList } from '@cio/ui/custom/multi-select-list';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '../api';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
programId: string;
|
||||
}
|
||||
|
||||
const COURSE_PAGE_SIZE = 20;
|
||||
|
||||
let { open = $bindable(false), programId }: Props = $props();
|
||||
|
||||
let searchValue = $state('');
|
||||
let currentPage = $state(1);
|
||||
const selectedCourseIds = new SvelteSet<string>();
|
||||
|
||||
const availableCourses = $derived.by(() => {
|
||||
const existingCourseIds = new Set(programApi.courses.map((course) => course.courseId));
|
||||
|
||||
return coursesApi.orgCourses.filter((course) => !existingCourseIds.has(course.id));
|
||||
});
|
||||
const filteredCourses = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return availableCourses;
|
||||
}
|
||||
|
||||
return availableCourses.filter((course) =>
|
||||
[course.title, course.description].some((value) => value?.toLowerCase().includes(normalizedSearch))
|
||||
);
|
||||
});
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(filteredCourses.length / COURSE_PAGE_SIZE)));
|
||||
const paginatedCourses = $derived(
|
||||
filteredCourses.slice((currentPage - 1) * COURSE_PAGE_SIZE, currentPage * COURSE_PAGE_SIZE)
|
||||
);
|
||||
const selectedCount = $derived(selectedCourseIds.size);
|
||||
const showLoadingState = $derived(coursesApi.isLoading);
|
||||
const canGoToPreviousPage = $derived(currentPage > 1);
|
||||
const canGoToNextPage = $derived(currentPage < totalPages);
|
||||
const emptyMessage = $derived(
|
||||
searchValue.trim()
|
||||
? $t('programs.courses.no_matching_courses') || 'No courses match your search.'
|
||||
: $t('programs.courses.no_available_courses') || 'No available courses to add.'
|
||||
);
|
||||
const multiSelectItems = $derived(
|
||||
paginatedCourses.map((course) => ({
|
||||
id: course.id,
|
||||
label: course.title || course.id,
|
||||
description: course.description || undefined
|
||||
}))
|
||||
);
|
||||
|
||||
async function ensureCoursesLoaded() {
|
||||
if (coursesApi.orgCourses.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await coursesApi.getOrgCourses();
|
||||
}
|
||||
|
||||
function toggleCourse(courseId: string) {
|
||||
if (selectedCourseIds.has(courseId)) {
|
||||
selectedCourseIds.delete(courseId);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedCourseIds.add(courseId);
|
||||
}
|
||||
|
||||
function handleSearchValueChange(value: string) {
|
||||
searchValue = value;
|
||||
currentPage = 1;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
selectedCourseIds.clear();
|
||||
searchValue = '';
|
||||
currentPage = 1;
|
||||
open = false;
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
currentPage = page;
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
const courseIds = [...selectedCourseIds];
|
||||
if (courseIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const didAddCourses = await programApi.addCourses(programId, courseIds);
|
||||
if (!didAddCourses) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
selectedCourseIds.clear();
|
||||
searchValue = '';
|
||||
currentPage = 1;
|
||||
|
||||
void ensureCoursesLoaded();
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
coursesApi.cancelOrgCoursesRequest();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('programs.courses.add_modal_title') || 'Add Course to Program'}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<MultiSelectList
|
||||
class="border-0"
|
||||
{emptyMessage}
|
||||
items={multiSelectItems}
|
||||
isLoading={showLoadingState}
|
||||
isSelected={(id) => selectedCourseIds.has(id)}
|
||||
onSearchValueChange={handleSearchValueChange}
|
||||
onToggle={(id) => toggleCourse(id)}
|
||||
namePrefix="program-course"
|
||||
listClass="max-h-60"
|
||||
searchPlaceholder={$t('programs.courses.search_placeholder') || 'Search courses'}
|
||||
bind:searchValue
|
||||
>
|
||||
{#snippet headingSnippet()}
|
||||
<p class="ui:text-sm ui:font-medium">
|
||||
{$t('programs.courses.select_courses') || $t('programs.courses.select_course') || 'Select course(s)'}
|
||||
{#if selectedCount > 0}
|
||||
<span class="ui:text-muted-foreground text-xs">
|
||||
{$t('audience.selected_count', { count: selectedCount })}
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/snippet}
|
||||
</MultiSelectList>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between gap-2 text-sm">
|
||||
<span class="ui:text-muted-foreground">
|
||||
{$t('programs.courses.pagination_status', {
|
||||
page: currentPage,
|
||||
totalPages
|
||||
}) || `Page ${currentPage} of ${totalPages}`}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
disabled={!canGoToPreviousPage || showLoadingState}
|
||||
>
|
||||
{$t('app.previous') || 'Previous'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
disabled={!canGoToNextPage || showLoadingState}
|
||||
>
|
||||
{$t('app.next') || 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={closeModal} disabled={programApi.isLoading}>
|
||||
{$t('app.cancel') || 'Cancel'}
|
||||
</Button>
|
||||
<Button onclick={handleAdd} loading={programApi.isLoading} disabled={selectedCount === 0 || programApi.isLoading}>
|
||||
{$t('programs.courses.add') || 'Add Course'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '../api';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let { open = $bindable(false) }: Props = $props();
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) return;
|
||||
await programApi.createProgram({ name: name.trim(), description: description.trim() || undefined });
|
||||
name = '';
|
||||
description = '';
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
name = '';
|
||||
description = '';
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('programs.create_modal.title') || 'Create Program'}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{$t('programs.create_modal.description') || 'Add a new certification or compliance program.'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="program-name">{$t('programs.create_modal.name_label') || 'Name'} *</Label>
|
||||
<Input
|
||||
id="program-name"
|
||||
bind:value={name}
|
||||
placeholder={$t('programs.create_modal.name_placeholder') || 'e.g. OSHA Safety Certification'}
|
||||
maxlength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="program-description">{$t('programs.create_modal.description_label') || 'Description'}</Label>
|
||||
<Textarea
|
||||
id="program-description"
|
||||
bind:value={description}
|
||||
placeholder={$t('programs.create_modal.description_placeholder') || 'What is this program about?'}
|
||||
rows={3}
|
||||
maxlength={2000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={handleCancel}>{$t('app.cancel') || 'Cancel'}</Button>
|
||||
<Button onclick={handleCreate} disabled={!name.trim() || programApi.isLoading}>
|
||||
{$t('programs.create_modal.submit') || 'Create'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,248 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
||||
import { BulkEmailSection, ExistingStudentsSection, TutorSelectSection } from '$features/people/components';
|
||||
import type { OrgStudent, Tutor } from '$features/people/utils/types';
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import { programApi } from '../api';
|
||||
|
||||
interface Props {
|
||||
programId: string;
|
||||
}
|
||||
|
||||
let { programId }: Props = $props();
|
||||
|
||||
let tutors = $state<Tutor[]>([]);
|
||||
let selectedIds = $state<string[]>([]);
|
||||
let isLoadingStudents = $state(false);
|
||||
let activeTab = $state<'tutors' | 'students'>('students');
|
||||
|
||||
const addPeopleParam = $derived(new URLSearchParams(page.url.search).get('add'));
|
||||
const isOpen = $derived(addPeopleParam === 'true');
|
||||
const selectedTutors = $derived(tutors.filter((tutor) => selectedIds.includes(tutor.id.toString())));
|
||||
const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, programApi.members));
|
||||
const INVITE_MODAL = 'course.navItem.people.invite_modal';
|
||||
|
||||
function getTutors(team: OrgTeamMember[]) {
|
||||
const existingTutorIds = new Set(
|
||||
programApi.members
|
||||
.filter((member) => Number(member.roleId) === ROLE.TUTOR || Number(member.roleId) === ROLE.ADMIN)
|
||||
.map((member) => member.profileId)
|
||||
.filter((profileId): profileId is string => Boolean(profileId))
|
||||
);
|
||||
|
||||
return team
|
||||
.filter((teamMember) => teamMember.verified)
|
||||
.filter((teamMember) => !existingTutorIds.has(teamMember.profileId))
|
||||
.map((teamMember) => ({
|
||||
id: teamMember.id,
|
||||
text: teamMember.fullname,
|
||||
profileId: teamMember.profileId,
|
||||
email: teamMember.email
|
||||
}));
|
||||
}
|
||||
|
||||
function getAvailableStudents(students: OrgStudent[], programMembers: typeof programApi.members) {
|
||||
const existingStudentIds = new Set(
|
||||
programMembers
|
||||
.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 !existingStudentIds.has(student.profileId);
|
||||
});
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
goto(resolve(page.url.pathname, {}));
|
||||
}
|
||||
|
||||
function setTutors(orgId: string | undefined) {
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(async () => {
|
||||
await orgApi.getOrgTeam();
|
||||
if (orgApi.error) {
|
||||
console.error('Error fetching teams', orgApi.error);
|
||||
return;
|
||||
}
|
||||
|
||||
tutors = getTutors(orgApi.teamMembers);
|
||||
});
|
||||
}
|
||||
|
||||
function loadStudents(orgId: string | undefined) {
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(async () => {
|
||||
isLoadingStudents = true;
|
||||
|
||||
try {
|
||||
await orgApi.getOrgAudience(orgId, DEFAULT_ORG_AUDIENCE_QUERY, { abortPrevious: true });
|
||||
} finally {
|
||||
isLoadingStudents = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStudentSearch(value: string) {
|
||||
const orgId = $currentOrg.id;
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingStudents = true;
|
||||
|
||||
try {
|
||||
await orgApi.getOrgAudience(
|
||||
orgId,
|
||||
{
|
||||
...DEFAULT_ORG_AUDIENCE_QUERY,
|
||||
search: value.trim() || undefined
|
||||
},
|
||||
{ abortPrevious: true }
|
||||
);
|
||||
} finally {
|
||||
isLoadingStudents = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addTutors() {
|
||||
if (!selectedTutors.length) {
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
programApi.success = false;
|
||||
|
||||
await programApi.addMembers(programId, {
|
||||
members: selectedTutors.map((tutor) => ({
|
||||
profileId: tutor.profileId,
|
||||
roleId: ROLE.TUTOR,
|
||||
email: tutor.email,
|
||||
name: tutor.text
|
||||
}))
|
||||
});
|
||||
|
||||
if (programApi.success) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
async function assignExistingStudents(profileIds: string[], sendEmail: boolean) {
|
||||
const result = await orgApi.assignAudienceToCourses({
|
||||
profileIds,
|
||||
programIds: [programId],
|
||||
sendEmail
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
await programApi.listMembers(programId, true);
|
||||
}
|
||||
|
||||
async function inviteNewStudents(recipientCsv: string, sendEmail: boolean) {
|
||||
const result = await orgApi.importAudienceMembers({
|
||||
recipientCsv,
|
||||
programIds: [programId],
|
||||
sendEmail
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
await programApi.listMembers(programId, true);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
setTutors($currentOrg.id);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen || !programId) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
activeTab = 'students';
|
||||
selectedIds = [];
|
||||
void loadStudents($currentOrg.id);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeModal();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-3xl! overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('programs.people.invite_modal_title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<UnderlineTabs.Root bind:value={activeTab}>
|
||||
<UnderlineTabs.List class="flex flex-wrap">
|
||||
<UnderlineTabs.Trigger value="tutors">
|
||||
{$t(`${INVITE_MODAL}.invite`)}
|
||||
</UnderlineTabs.Trigger>
|
||||
<UnderlineTabs.Trigger value="students">
|
||||
{$t(`${INVITE_MODAL}.invite_students`)}
|
||||
</UnderlineTabs.Trigger>
|
||||
</UnderlineTabs.List>
|
||||
|
||||
<UnderlineTabs.Content value="tutors">
|
||||
<div class="mt-6 space-y-6">
|
||||
<TutorSelectSection
|
||||
{tutors}
|
||||
bind:selectedIds
|
||||
isLoading={orgApi.isLoading}
|
||||
helperHref={`/org/${$currentOrg.siteName}/settings/teams`}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex flex-row-reverse items-center gap-2">
|
||||
<Button variant="secondary" type="button" onclick={addTutors} loading={programApi.isLoading}>
|
||||
{$t(`${INVITE_MODAL}.finish`)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
|
||||
<UnderlineTabs.Content value="students">
|
||||
<div class="mt-6 space-y-6">
|
||||
<ExistingStudentsSection
|
||||
students={availableStudents}
|
||||
isLoading={isLoadingStudents}
|
||||
onSearchValueChange={handleStudentSearch}
|
||||
onAssign={assignExistingStudents}
|
||||
/>
|
||||
<BulkEmailSection onInvite={inviteNewStudents} />
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import pluralize from 'pluralize';
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Program } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
program: Program;
|
||||
isLMS?: boolean;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
let { program, isLMS = false, href }: Props = $props();
|
||||
|
||||
const courseCount = $derived(program.courseCount ?? 0);
|
||||
const studentCount = $derived(program.studentCount ?? 0);
|
||||
|
||||
function handleRowClick() {
|
||||
if (href) {
|
||||
goto(resolve(href, {}));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRoute = isLMS ? `/programs/${program.id}/newsfeed?source=lms` : `/programs/${program.id}/newsfeed`;
|
||||
|
||||
goto(resolve(nextRoute, {}));
|
||||
}
|
||||
|
||||
function getStatusVariant(status: Program['status']) {
|
||||
if (status === 'ACTIVE') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'INACTIVE') {
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function getStatusLabelKey(status: Program['status']) {
|
||||
if (status === 'ACTIVE') {
|
||||
return 'programs.status.active';
|
||||
}
|
||||
|
||||
if (status === 'INACTIVE') {
|
||||
return 'programs.status.inactive';
|
||||
}
|
||||
|
||||
return 'programs.status.archived';
|
||||
}
|
||||
</script>
|
||||
|
||||
<ResourceListRow.Root
|
||||
variant="default"
|
||||
align="start"
|
||||
class="ui:cursor-pointer ui:py-3"
|
||||
onclick={handleRowClick}
|
||||
role="row"
|
||||
>
|
||||
<div class="ui:flex ui:w-full ui:items-start ui:gap-6">
|
||||
<div class="ui:min-w-0 ui:flex-1">
|
||||
<p class="ui:w-full ui:truncate ui:text-base ui:font-semibold">{program.name}</p>
|
||||
|
||||
{#if program.description}
|
||||
<p class="ui:mt-1 ui:text-sm ui:text-muted-foreground">{program.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ui:flex ui:shrink-0 ui:items-start">
|
||||
<Badge variant={getStatusVariant(program.status)}>
|
||||
{$t(getStatusLabelKey(program.status))}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="ui:flex ui:shrink-0 ui:flex-col ui:items-start ui:gap-1">
|
||||
<p class="ui:text-sm ui:text-muted-foreground">
|
||||
{pluralize(t.get('programs.card.courses'), courseCount, true)}
|
||||
</p>
|
||||
|
||||
{#if !isLMS}
|
||||
<p class="ui:text-sm ui:text-muted-foreground">
|
||||
{pluralize(t.get('programs.card.students'), studentCount, true)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</ResourceListRow.Root>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import { programNewsfeedApi, type ProgramNewsfeedCommentsByFeedId } from '$features/program/api';
|
||||
import {
|
||||
getNewsfeedReactionCounts,
|
||||
getSelectedNewsfeedReactionType,
|
||||
NewsfeedReactions
|
||||
} from '@cio/ui/custom/newsfeed-reactions';
|
||||
import DeleteConfirmation from './delete-confirmation.svelte';
|
||||
import Header from './header.svelte';
|
||||
import Comments from './comments.svelte';
|
||||
|
||||
interface Props {
|
||||
feed: ProgramNewsfeedItem;
|
||||
comments?: ProgramNewsfeedCommentsByFeedId;
|
||||
programId?: string;
|
||||
editFeed: ProgramNewsfeedItem | null;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
fullname: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
reactionAuthorId: string;
|
||||
canComment?: boolean;
|
||||
canManageFeed?: boolean;
|
||||
edit?: boolean;
|
||||
deleteFeed?: (feedId: string) => void;
|
||||
deleteComment?: (feedId: string, commentId: string) => void;
|
||||
addNewComment?: (content: string, feedId: string) => Promise<void> | void;
|
||||
addNewReaction?: (reactionType: string, feedId: string, authorId: string) => Promise<void> | void;
|
||||
onPin?: (feedId: ProgramNewsfeedItem['id'], isPinned: ProgramNewsfeedItem['isPinned']) => void;
|
||||
isActive?: boolean;
|
||||
isReacting?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
feed,
|
||||
comments,
|
||||
programId,
|
||||
editFeed = $bindable(),
|
||||
author,
|
||||
reactionAuthorId,
|
||||
canComment = true,
|
||||
canManageFeed = false,
|
||||
edit = $bindable(false),
|
||||
deleteFeed = (_feedId: string) => {},
|
||||
deleteComment = (_feedId: string, _commentId: string) => {},
|
||||
addNewComment = (_content: string, _feedId: string) => {},
|
||||
addNewReaction = (_reactionType: string, _feedId: string, _authorId: string) => {},
|
||||
onPin = (_feedId: string, _isPinned: boolean | null) => {},
|
||||
isActive = false,
|
||||
isReacting = false
|
||||
}: Props = $props();
|
||||
|
||||
let isDeleteFeedModal = $state(false);
|
||||
|
||||
const reactionCounts = $derived(getNewsfeedReactionCounts(feed.reaction));
|
||||
const selectedReactionType = $derived(getSelectedNewsfeedReactionType(feed.reaction, reactionAuthorId));
|
||||
|
||||
const openEditFeed = () => {
|
||||
programNewsfeedApi.openNewFeedModal();
|
||||
edit = true;
|
||||
editFeed = feed;
|
||||
};
|
||||
|
||||
const handleReact = (reactionType: string) => addNewReaction(reactionType, feed.id, reactionAuthorId);
|
||||
|
||||
onMount(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.getElementById(feed.id);
|
||||
element?.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'smooth',
|
||||
inline: 'nearest'
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
id={feed.id}
|
||||
class="mb-7 flex w-full max-w-3xl flex-col justify-between gap-2 rounded-md {isActive
|
||||
? 'border-primary-700 border-2'
|
||||
: 'border border-gray-200'}"
|
||||
>
|
||||
<Header
|
||||
{feed}
|
||||
{canManageFeed}
|
||||
{onPin}
|
||||
onEdit={openEditFeed}
|
||||
onRequestDelete={() => {
|
||||
isDeleteFeedModal = true;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="px-3 pt-1 pb-2">
|
||||
<NewsfeedReactions
|
||||
{reactionCounts}
|
||||
{selectedReactionType}
|
||||
disabled={isReacting || !reactionAuthorId}
|
||||
onReactionToggle={handleReact}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Comments
|
||||
{programId}
|
||||
{feed}
|
||||
{author}
|
||||
{comments}
|
||||
{canComment}
|
||||
onAddComment={async (content) => {
|
||||
await addNewComment(content, feed.id);
|
||||
}}
|
||||
onDeleteComment={(commentId) => {
|
||||
deleteComment(feed.id, String(commentId));
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteConfirmation
|
||||
bind:openDeleteModal={isDeleteFeedModal}
|
||||
deleteFeed={() => {
|
||||
deleteFeed(feed.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import pluralize from 'pluralize';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import SendHorizontalIcon from '@lucide/svelte/icons/send-horizontal';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programNewsfeedApi } from '$features/program/api';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import type { ProgramNewsfeedCommentsByFeedId } from '$features/program/api';
|
||||
|
||||
interface Props {
|
||||
programId?: string;
|
||||
feed: ProgramNewsfeedItem;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
fullname: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
canComment?: boolean;
|
||||
comments?: ProgramNewsfeedCommentsByFeedId;
|
||||
onAddComment: (content: string) => Promise<void> | void;
|
||||
onDeleteComment: (commentId: string | number) => void;
|
||||
}
|
||||
|
||||
let { programId, feed, author, canComment = true, comments, onAddComment, onDeleteComment }: Props = $props();
|
||||
|
||||
let comment = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
let isBootstrapping = $state(false);
|
||||
let didBootstrap = $state(false);
|
||||
|
||||
const pageSize = 5;
|
||||
let visibleCount = $state(1);
|
||||
|
||||
const loadedCount = $derived((comments?.items?.length ?? 0) as number);
|
||||
const totalCount = $derived(comments?.totalCount || feed.commentCount);
|
||||
const isLoading = $derived((comments?.isLoading ?? false) as boolean);
|
||||
const shownCount = $derived(Math.min(visibleCount, loadedCount));
|
||||
|
||||
const bootstrap = async () => {
|
||||
if (!programId || !comments) return;
|
||||
if (didBootstrap) return;
|
||||
|
||||
didBootstrap = true;
|
||||
isBootstrapping = true;
|
||||
|
||||
try {
|
||||
await programNewsfeedApi.getComments(programId, feed.id);
|
||||
} finally {
|
||||
isBootstrapping = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitComment = async () => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
await onAddComment(comment);
|
||||
comment = '';
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewComment = async (event: KeyboardEvent | MouseEvent) => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
const maybeKey = event as KeyboardEvent & { key?: string };
|
||||
if (maybeKey.key !== 'Enter' && event.type !== 'click') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
await submitComment();
|
||||
};
|
||||
|
||||
const showMore = async () => {
|
||||
if (!comments) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!didBootstrap) {
|
||||
await bootstrap();
|
||||
}
|
||||
|
||||
visibleCount = visibleCount <= 1 ? pageSize : visibleCount + pageSize;
|
||||
};
|
||||
|
||||
const minimize = () => {
|
||||
visibleCount = visibleCount > 1 ? 1 : shownCount;
|
||||
};
|
||||
|
||||
const toggleComments = async () => {
|
||||
if (totalCount === shownCount) {
|
||||
minimize();
|
||||
return;
|
||||
}
|
||||
|
||||
await showMore();
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="border-t border-gray-200 p-3">
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={toggleComments}
|
||||
loading={isBootstrapping || isLoading}
|
||||
class="justify-start"
|
||||
>
|
||||
<UsersIcon size={16} />
|
||||
<p class="py-2 text-sm">{pluralize($t('programs.newsfeed.comments.comment'), totalCount, true)}</p>
|
||||
|
||||
{#if totalCount > shownCount}
|
||||
<span class="ml-auto text-xs text-gray-600">
|
||||
{$t('programs.newsfeed.comments.showing_of', { shown: shownCount, total: totalCount })}
|
||||
</span>
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
{#if shownCount > 1}
|
||||
<Button variant="ghost" size="sm" onclick={minimize}>
|
||||
<span class="ml-auto text-xs text-gray-600">{$t('programs.newsfeed.comments.hide')}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if comments}
|
||||
{#each comments.items.slice(0, shownCount) as commentItem (commentItem.id)}
|
||||
{#if commentItem.content}
|
||||
<div class="group flex items-center justify-between py-2">
|
||||
<span class="flex items-center gap-3">
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={commentItem.authorAvatarUrl || ''}
|
||||
alt={$t('programs.newsfeed.user_avatar_alt')}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium capitalize">{commentItem.authorFullname || ''}</p>
|
||||
<p class="text-xs font-medium text-gray-600">{calDateDiff(commentItem.createdAt)}</p>
|
||||
</div>
|
||||
<p>{commentItem.content}</p>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{#if commentItem.authorProfileId === $profile.id || $isOrgAdmin}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="hidden h-8 w-8 items-center justify-center rounded-md group-hover:flex hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item class="text-red-600" onclick={() => onDeleteComment(commentItem.id)}>
|
||||
{$t('programs.newsfeed.comments.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if canComment}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<UserAvatar src={author.avatarUrl} alt={$t('programs.newsfeed.user_avatar_alt')} class="size-7" />
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={comment}
|
||||
onkeydown={handleAddNewComment}
|
||||
placeholder={$t('programs.newsfeed.comments.placeholder')}
|
||||
class="w-full rounded-3xl border border-gray-200 bg-transparent p-1 pl-2 text-sm disabled:opacity-50"
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="rounded-full"
|
||||
loading={isSubmitting}
|
||||
onclick={handleAddNewComment}
|
||||
aria-label={$t('programs.newsfeed.comments.submit_aria')}
|
||||
>
|
||||
<SendHorizontalIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
openDeleteModal?: boolean;
|
||||
deleteFeed?: () => void;
|
||||
}
|
||||
|
||||
let { openDeleteModal = $bindable(false), deleteFeed = () => {} }: Props = $props();
|
||||
|
||||
const handleDelete = async () => {
|
||||
deleteFeed();
|
||||
openDeleteModal = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={openDeleteModal}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('programs.newsfeed.delete_feed_title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<p class="text-lg">{$t('programs.newsfeed.delete_feed_description')}</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (openDeleteModal = false)}>
|
||||
{$t('programs.newsfeed.cancel')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>
|
||||
{$t('programs.newsfeed.confirm_delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import { HTMLRender } from '$features/ui';
|
||||
|
||||
interface Props {
|
||||
feed: ProgramNewsfeedItem;
|
||||
canManageFeed?: boolean;
|
||||
onPin: (feedId: ProgramNewsfeedItem['id'], isPinned: ProgramNewsfeedItem['isPinned']) => void;
|
||||
onEdit: () => void;
|
||||
onRequestDelete: () => void;
|
||||
}
|
||||
|
||||
let { feed, canManageFeed = false, onPin, onEdit, onRequestDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="px-3 pt-3 pb-0">
|
||||
<div class="mb-2 flex justify-between">
|
||||
<span class="flex items-center gap-3">
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={feed.authorAvatarUrl || ''}
|
||||
alt={$t('programs.newsfeed.user_avatar_alt')}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
<p class="text-base font-semibold capitalize">{feed.authorFullname || ''}</p>
|
||||
<p class="text-sm font-medium text-gray-600">{calDateDiff(feed.createdAt)}</p>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{#if canManageFeed}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => onPin(feed.id, feed.isPinned)}>
|
||||
{feed.isPinned ? $t('programs.newsfeed.card.unpin') : $t('programs.newsfeed.card.pin')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={onEdit}>{$t('programs.newsfeed.card.edit')}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="text-red-600" onclick={onRequestDelete}>
|
||||
{$t('programs.newsfeed.card.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isHtmlValueEmpty(feed.content || '')}
|
||||
<HTMLRender className="w-[80%]">
|
||||
<div>
|
||||
<SafeHtmlContent content={feed.content || ''} />
|
||||
</div>
|
||||
</HTMLRender>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as NewFeedModal } from './new-feed-modal.svelte';
|
||||
export { default as NewsFeedCard } from './card/card.svelte';
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import { getErrorKey } from '$features/program/utils/error';
|
||||
import { programNewsfeedApi } from '$features/program/api';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { TextEditor } from '$features/ui';
|
||||
|
||||
interface Props {
|
||||
programId?: string;
|
||||
edit?: boolean;
|
||||
editFeed: ProgramNewsfeedItem | null;
|
||||
}
|
||||
|
||||
let { programId = '', edit = $bindable(false), editFeed = $bindable() }: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let newPost = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (edit && editFeed) {
|
||||
newPost = editFeed.content || '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!programNewsfeedApi.isNewFeedModalOpen) {
|
||||
newPost = '';
|
||||
}
|
||||
});
|
||||
|
||||
const resetEditor = () => {
|
||||
newPost = '';
|
||||
edit = false;
|
||||
editFeed = null;
|
||||
programNewsfeedApi.closeNewFeedModal();
|
||||
};
|
||||
|
||||
const onPost = async () => {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
if (edit && editFeed) {
|
||||
await programNewsfeedApi.update(programId, editFeed.id, { content: newPost ?? '' });
|
||||
|
||||
if (programNewsfeedApi.success) {
|
||||
resetEditor();
|
||||
} else {
|
||||
snackbar.error(getErrorKey(programNewsfeedApi.error, 'update'));
|
||||
}
|
||||
} else {
|
||||
await programNewsfeedApi.create(programId, {
|
||||
content: newPost ?? '',
|
||||
isPinned: false
|
||||
});
|
||||
|
||||
if (programNewsfeedApi.success) {
|
||||
resetEditor();
|
||||
} else {
|
||||
snackbar.error(getErrorKey(programNewsfeedApi.error, 'create'));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
snackbar.error(getErrorKey(programNewsfeedApi.error, edit ? 'update' : 'create'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={programNewsfeedApi.isNewFeedModalOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetEditor();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="h-fit! w-fit! max-w-none!">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{edit ? $t('programs.newsfeed.edit_post') : $t('programs.newsfeed.make_a_post')}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<section class="max-w-lg">
|
||||
<TextEditor
|
||||
content={newPost || editFeed?.content || ''}
|
||||
onChange={(text) => {
|
||||
newPost = text;
|
||||
}}
|
||||
editorClass="max-h-[400px]"
|
||||
placeholder={$t('programs.newsfeed.editor_placeholder')}
|
||||
/>
|
||||
</section>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={resetEditor}>
|
||||
{$t('programs.newsfeed.cancel')}
|
||||
</Button>
|
||||
<Button loading={isLoading} onclick={onPost}>
|
||||
{$t('programs.newsfeed.post_submit')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import pluralize from 'pluralize';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Program } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
program: Program;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { program, onclick }: Props = $props();
|
||||
|
||||
const courseCount = $derived(program.courseCount ?? 0);
|
||||
const studentCount = $derived(program.studentCount ?? 0);
|
||||
</script>
|
||||
|
||||
<Item.Root variant="outline" class="group relative max-w-[320px] cursor-pointer p-4!" {onclick}>
|
||||
{#snippet child({ props })}
|
||||
<div class="flex h-full flex-col justify-between gap-5" {...props}>
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-2 min-h-14 text-base!">
|
||||
{program.name}
|
||||
</Item.Title>
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
<div class="flex w-full justify-between">
|
||||
<div class="w-[60%]">
|
||||
<p class="pl-2 text-xs dark:text-white">
|
||||
{pluralize($t('programs.card.courses'), courseCount, true)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-between">
|
||||
<p class="pl-2 text-xs dark:text-white">
|
||||
{pluralize($t('programs.card.students'), studentCount, true)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Item.Content>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Item.Root>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import * as ToggleGroup from '@cio/ui/base/toggle-group';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import FilterIcon from '@lucide/svelte/icons/filter';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import {
|
||||
DEFAULT_PROGRAM_SORT,
|
||||
DEFAULT_PROGRAM_SORT_ORDER,
|
||||
type ProgramSortBy,
|
||||
type ProgramSortOrder
|
||||
} from '../utils/constants';
|
||||
|
||||
interface StatusOption {
|
||||
value: 'ACTIVE' | 'INACTIVE' | 'ARCHIVED';
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SortOption {
|
||||
value: ProgramSortBy;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sortKey?: ProgramSortBy;
|
||||
selectedOrder?: ProgramSortOrder;
|
||||
selectedStatuses?: string[];
|
||||
statusOptions?: StatusOption[];
|
||||
sortOptions?: SortOption[];
|
||||
onToggleStatus?: (status: string, checked: boolean) => void;
|
||||
onClearFilters?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
sortKey = $bindable(DEFAULT_PROGRAM_SORT),
|
||||
selectedOrder = $bindable(DEFAULT_PROGRAM_SORT_ORDER),
|
||||
selectedStatuses = [],
|
||||
statusOptions = [],
|
||||
sortOptions = [],
|
||||
onToggleStatus = () => {},
|
||||
onClearFilters = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
sortKey !== DEFAULT_PROGRAM_SORT || selectedOrder !== DEFAULT_PROGRAM_SORT_ORDER || selectedStatuses.length > 0
|
||||
);
|
||||
|
||||
function isStatusSelected(status: string) {
|
||||
return selectedStatuses.includes(status);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open={isOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<div class="relative">
|
||||
<IconButton {...props} aria-label={$t('programs.filters.filter')} tooltip={$t('programs.filters.filter')}>
|
||||
<FilterIcon size={16} />
|
||||
</IconButton>
|
||||
{#if hasActiveFilters}
|
||||
<span
|
||||
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content align="end" class="w-[360px] p-3">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-semibold">{$t('programs.filters.popover_title')}</p>
|
||||
<Button variant="link" class="h-auto p-0" onclick={onClearFilters} disabled={!hasActiveFilters}>
|
||||
{$t('programs.filters.clear_all')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('programs.filters.sort')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each sortOptions as option (option.value)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={sortKey === option.value ? 'secondary' : 'outline'}
|
||||
onclick={() => (sortKey = option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('programs.filters.order')}
|
||||
</p>
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
value={selectedOrder}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'asc' || value === 'desc') {
|
||||
selectedOrder = value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroup.Item value="asc">{$t('programs.filters.ascending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="desc">{$t('programs.filters.descending')}</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('programs.filters.status')}
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
{#each statusOptions as option (option.value)}
|
||||
<label class="hover:ui:bg-muted/30 flex cursor-pointer items-center rounded-md border px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isStatusSelected(option.value)}
|
||||
onCheckedChange={(checked) => onToggleStatus(option.value, Boolean(checked))}
|
||||
/>
|
||||
<span class="text-sm">{option.label}</span>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { programApi } from '../api';
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="border-border ui:bg-background sticky top-0 z-100 flex h-12 w-full shrink-0 items-center gap-2 border-b backdrop-blur transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8"
|
||||
>
|
||||
<div class="flex w-full items-center gap-2 px-4">
|
||||
<Sidebar.Trigger />
|
||||
|
||||
<div class="h-4 w-2">
|
||||
<Separator orientation="vertical" />
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<p class="max-w-2xs truncate text-sm font-medium">
|
||||
{programApi.program?.name || ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span class="grow"></span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ProgramSidebar } from './program-sidebar.svelte';
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { BackButton } from '@cio/ui';
|
||||
import { HoverableItem, NewsFeedIcon, CourseIcon, PeopleIcon, SettingsIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
let { path, id }: Props = $props();
|
||||
|
||||
const currentPath = $derived(path || page.url.pathname);
|
||||
const isLmsProgramContext = $derived(page.url.searchParams.get('source') === 'lms' || $isStudentExperience);
|
||||
const programsListPath = $derived(isLmsProgramContext ? '/lms/programs' : `${$currentOrgPath}/programs`);
|
||||
|
||||
const currentMemberRole = $derived(programApi.members.find((m) => m.profileId === $profile.id)?.roleId ?? null);
|
||||
|
||||
// Admin + Tutor can manage members; org admins always can
|
||||
const canManageMembers = $derived(
|
||||
$isOrgAdmin === true || (currentMemberRole !== null && currentMemberRole <= ROLE.TUTOR)
|
||||
);
|
||||
|
||||
// Only Admins and org admins can access settings
|
||||
const canManageSettings = $derived($isOrgAdmin === true || currentMemberRole === ROLE.ADMIN);
|
||||
|
||||
const navItems = $derived(
|
||||
[
|
||||
{
|
||||
id: 'newsfeed',
|
||||
title: $t('programs.sidebar.newsfeed') || 'Newsfeed',
|
||||
url: `/programs/${id}/newsfeed`,
|
||||
isActive: currentPath.endsWith('/newsfeed'),
|
||||
icon: NewsFeedIcon,
|
||||
show: () => true
|
||||
},
|
||||
{
|
||||
id: 'courses',
|
||||
title: $t('programs.sidebar.courses') || 'Courses',
|
||||
url: `/programs/${id}/courses`,
|
||||
isActive: currentPath.endsWith('/courses'),
|
||||
icon: CourseIcon,
|
||||
show: () => true
|
||||
},
|
||||
{
|
||||
id: 'people',
|
||||
title: $t('programs.sidebar.people') || 'People',
|
||||
url: `/programs/${id}/people`,
|
||||
isActive: currentPath.endsWith('/people'),
|
||||
icon: PeopleIcon,
|
||||
show: () => canManageMembers
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: $t('programs.sidebar.settings') || 'Settings',
|
||||
url: `/programs/${id}/settings`,
|
||||
isActive: currentPath.endsWith('/settings'),
|
||||
icon: SettingsIcon,
|
||||
show: () => canManageSettings
|
||||
}
|
||||
].filter((item) => item.show())
|
||||
);
|
||||
</script>
|
||||
|
||||
<Sidebar.Group class="pt-0!">
|
||||
<BackButton
|
||||
href={resolve(programsListPath, {})}
|
||||
label={$t(isLmsProgramContext ? 'lms_navigation.programs' : 'org_navigation.programs') || 'Programs'}
|
||||
class="px-2! py-2!"
|
||||
/>
|
||||
|
||||
<Sidebar.Menu>
|
||||
{#each navItems as item (item.id)}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton tooltipContent={item.title} isActive={item.isActive}>
|
||||
{#snippet child({ props })}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
{@const Icon = item.icon}
|
||||
<a href={resolve(item.url, {})} {...props}>
|
||||
<Icon {isHovered} size={16} />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
|
||||
<style>
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { orgs, isFreePlan } from '$lib/utils/store/org';
|
||||
|
||||
import OrgLogo from '$features/ui/sidebar/org-sidebar/org-logo.svelte';
|
||||
import Navigation from './program-sidebar-navigation.svelte';
|
||||
import SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
|
||||
import UpgradePoweredBy from '$features/ui/upgrade-powered-by.svelte';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
|
||||
const SIDEBAR_ITEM_SKELETON_COUNT = 4;
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
id: string;
|
||||
isProgramReady?: boolean;
|
||||
}
|
||||
|
||||
let { path, id, isProgramReady = true }: Props = $props();
|
||||
|
||||
const isOrgLoaded = $derived($orgs.length > 0 && $profile.id);
|
||||
const sidebar = useSidebar();
|
||||
const currentPath = $derived(path || page.url.pathname);
|
||||
</script>
|
||||
|
||||
{#if !isOrgLoaded}
|
||||
<SidebarSkeleton />
|
||||
{:else}
|
||||
<Sidebar.Root collapsible="icon">
|
||||
<Sidebar.Header>
|
||||
<OrgLogo />
|
||||
</Sidebar.Header>
|
||||
|
||||
<Sidebar.Content>
|
||||
{#if isProgramReady}
|
||||
<Navigation path={currentPath} {id} />
|
||||
{:else}
|
||||
<Sidebar.Group class="pt-0!">
|
||||
<div class="flex h-8 items-center gap-2 rounded-md px-2">
|
||||
<Skeleton class="ui:size-4 ui:rounded-md" />
|
||||
<Skeleton class="ui:h-4 ui:w-24" />
|
||||
</div>
|
||||
<Sidebar.Menu>
|
||||
{#each Array(SIDEBAR_ITEM_SKELETON_COUNT) as _, i (i)}
|
||||
<Sidebar.MenuItem>
|
||||
<div class="ui:flex ui:h-8 ui:items-center ui:gap-2 ui:rounded-md ui:px-2" data-sidebar="menu-skeleton">
|
||||
<Skeleton class="ui:size-4 ui:rounded-md" />
|
||||
<Skeleton class="ui:h-4 ui:max-w-32 ui:flex-1" />
|
||||
</div>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
{/if}
|
||||
</Sidebar.Content>
|
||||
|
||||
<Sidebar.Rail />
|
||||
|
||||
{#if $isFreePlan}
|
||||
<Sidebar.Footer>
|
||||
<UpgradePoweredBy
|
||||
class="static [&_span]:font-normal [&_span]:shadow-none"
|
||||
showOnlyLogo={!sidebar.open || sidebar.isMobile}
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
{/if}
|
||||
</Sidebar.Root>
|
||||
{/if}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ProgramsPage } from './programs.svelte';
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { GoalIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import ProgramList from '../components/list.svelte';
|
||||
import type { Program } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
programs?: Program[];
|
||||
isLMS?: boolean;
|
||||
isLoading?: boolean;
|
||||
searchValue?: string;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
searchPlaceholder?: string;
|
||||
filterControls?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
programs = [],
|
||||
isLMS = false,
|
||||
isLoading = false,
|
||||
searchValue = $bindable(''),
|
||||
emptyTitle = $t('programs.empty_title'),
|
||||
emptyDescription = $t('programs.empty_description'),
|
||||
searchPlaceholder = $t('programs.search_placeholder'),
|
||||
filterControls
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Page.BodyHeader align="right" class="p-0!">
|
||||
<Search placeholder={searchPlaceholder} bind:value={searchValue} />
|
||||
{@render filterControls?.()}
|
||||
</Page.BodyHeader>
|
||||
|
||||
<div class="mx-auto mt-4 w-full flex-1">
|
||||
{#if isLoading}
|
||||
<div class="flex h-64 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
{:else if !programs.length}
|
||||
<Empty title={emptyTitle} description={emptyDescription} icon={GoalIcon} variant="page" />
|
||||
{:else}
|
||||
<ResourceListRow.Group class="w-full">
|
||||
{#each programs as program (program.id)}
|
||||
<ProgramList {program} {isLMS} />
|
||||
{/each}
|
||||
</ResourceListRow.Group>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
export type ProgramSortBy = 'name' | 'courses' | 'students' | 'status';
|
||||
export type ProgramSortOrder = 'asc' | 'desc';
|
||||
export const DEFAULT_PROGRAM_SORT: ProgramSortBy = 'name';
|
||||
export const DEFAULT_PROGRAM_SORT_ORDER: ProgramSortOrder = 'asc';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ErrorCodes } from '@cio/utils/constants';
|
||||
import { tryParseApiErrorBody } from '$lib/utils/services/api/parse-api-error-body';
|
||||
|
||||
const PROGRAM_ERROR_CODE_SNACKBAR_KEYS: Record<string, string> = {
|
||||
[ErrorCodes.PROGRAM_FORBIDDEN]: 'snackbar.program.error.code.program_forbidden',
|
||||
[ErrorCodes.PROGRAM_MEMBER_CHECK_FAILED]: 'snackbar.program.error.code.program_member_check_failed',
|
||||
[ErrorCodes.PROGRAM_NOT_FOUND]: 'snackbar.program.error.code.program_not_found',
|
||||
[ErrorCodes.PROGRAM_CREATE_FAILED]: 'snackbar.program.error.code.program_create_failed',
|
||||
[ErrorCodes.PROGRAM_UPDATE_FAILED]: 'snackbar.program.error.code.program_update_failed',
|
||||
[ErrorCodes.PROGRAM_DELETE_FAILED]: 'snackbar.program.error.code.program_delete_failed',
|
||||
[ErrorCodes.PROGRAM_MEMBER_NOT_FOUND]: 'snackbar.program.error.code.program_member_not_found',
|
||||
[ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND]: 'snackbar.program.error.code.program_newsfeed_not_found',
|
||||
[ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND]: 'snackbar.program.error.code.program_newsfeed_comment_not_found'
|
||||
};
|
||||
|
||||
export function getErrorKey(rawError: string | null, kind: 'create' | 'update'): string {
|
||||
const parsed = tryParseApiErrorBody(rawError);
|
||||
const mapped = parsed?.code ? PROGRAM_ERROR_CODE_SNACKBAR_KEYS[parsed.code] : undefined;
|
||||
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
return kind === 'update' ? 'snackbar.program.error.post_update_failed' : 'snackbar.program.error.post_create_failed';
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
type ProgramListResponse = InferResponseType<typeof classroomio.program.$get>;
|
||||
type ProgramListSuccess = Extract<ProgramListResponse, { success: true }>;
|
||||
export type Program = ProgramListSuccess['data'][number];
|
||||
|
||||
type ProgramResponse = InferResponseType<(typeof classroomio.program)[':programId']['$get']>;
|
||||
type ProgramSuccess = Extract<ProgramResponse, { success: true }>;
|
||||
export type ProgramDetail = ProgramSuccess['data'];
|
||||
|
||||
type ProgramMembersResponse = InferResponseType<(typeof classroomio.program)[':programId']['members']['$get']>;
|
||||
type ProgramMembersSuccess = Extract<ProgramMembersResponse, { success: true }>;
|
||||
export type ProgramMember = ProgramMembersSuccess['data'][number];
|
||||
|
||||
type ProgramCoursesResponse = InferResponseType<(typeof classroomio.program)[':programId']['courses']['$get']>;
|
||||
type ProgramCoursesSuccess = Extract<ProgramCoursesResponse, { success: true }>;
|
||||
export type ProgramCourse = ProgramCoursesSuccess['data'][number];
|
||||
|
||||
export type ListProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed']['$get'];
|
||||
type ListProgramNewsfeedResponse = InferResponseType<ListProgramNewsfeedRequest>;
|
||||
type ListProgramNewsfeedSuccess = Extract<ListProgramNewsfeedResponse, { success: true }>;
|
||||
export type ProgramNewsfeed = ListProgramNewsfeedSuccess['data'];
|
||||
export type ListProgramNewsfeed = ProgramNewsfeed['items'];
|
||||
export type ProgramNewsfeedItem = ListProgramNewsfeed[number];
|
||||
|
||||
export type CreateProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed']['$post'];
|
||||
export type UpdateProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed'][':feedId']['$put'];
|
||||
export type ReactToProgramNewsfeedRequest =
|
||||
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['react']['$put'];
|
||||
export type DeleteProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed'][':feedId']['$delete'];
|
||||
|
||||
export type GetProgramNewsfeedCommentsRequest =
|
||||
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['comments']['$get'];
|
||||
type GetProgramNewsfeedCommentsResponse = InferResponseType<GetProgramNewsfeedCommentsRequest>;
|
||||
type GetProgramNewsfeedCommentsSuccess = Extract<GetProgramNewsfeedCommentsResponse, { success: true }>;
|
||||
export type ProgramNewsfeedCommentsResponse = GetProgramNewsfeedCommentsSuccess['data'];
|
||||
export type ProgramNewsfeedComment = ProgramNewsfeedCommentsResponse[number];
|
||||
|
||||
export type CreateProgramNewsfeedCommentRequest =
|
||||
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['comment']['$post'];
|
||||
export type DeleteProgramNewsfeedCommentRequest =
|
||||
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['comment'][':commentId']['$delete'];
|
||||
@@ -5,10 +5,9 @@
|
||||
import HourglassIcon from '@lucide/svelte/icons/hourglass';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import ReceiptIcon from '@lucide/svelte/icons/receipt';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
|
||||
|
||||
let isRedirecting = $state(false);
|
||||
|
||||
@@ -28,7 +27,7 @@
|
||||
if ($currentOrgPlan?.provider === 'lmz') {
|
||||
isRedirecting = true;
|
||||
const url = new URL('/api/lmz/subscription', window.location.origin);
|
||||
url.searchParams.set('subscriptionId', $currentOrgPlan?.subscriptionId);
|
||||
url.searchParams.set('subscriptionId', $currentOrgPlan?.subscriptionId || '');
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
const result = await response.json();
|
||||
@@ -70,10 +69,14 @@
|
||||
<Item.Description>{$t('settings.billing.provider')}</Item.Description>
|
||||
</Item.Content>
|
||||
<Item.Actions>
|
||||
<Button size="sm" variant="outline" onclick={onOpenBilling} loading={isRedirecting}>
|
||||
<ExternalLinkIcon class="size-4" />
|
||||
{$t('settings.billing.open_billing')}
|
||||
</Button>
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<Button size="sm" variant="outline" onclick={onOpenBilling} loading={isRedirecting}>
|
||||
<ExternalLinkIcon {isHovered} size={16} ariaHidden={true} />
|
||||
{$t('settings.billing.open_billing')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
</Item.Actions>
|
||||
</Item.Root>
|
||||
{/if}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import isValidDomain from 'is-valid-domain';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { parse } from 'tldts';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
@@ -157,6 +156,7 @@
|
||||
}
|
||||
|
||||
const sanitizedDomain = sanitizeDomain(customDomain);
|
||||
const { parse } = await import('tldts/dist/es6/index.js');
|
||||
const details = parse(sanitizedDomain);
|
||||
|
||||
if (!details.subdomain) {
|
||||
@@ -338,7 +338,7 @@
|
||||
{#if domainSetup?.dnsRecords?.length}
|
||||
<Field.Field>
|
||||
<div class="space-y-3 rounded-md border px-4 py-3">
|
||||
{#each domainSetup.dnsRecords as record}
|
||||
{#each domainSetup.dnsRecords as record (record.name)}
|
||||
<div class="grid gap-3 sm:grid-cols-[80px,1fr,1fr] sm:items-center">
|
||||
<div>
|
||||
<Field.Label class="font-light">{$t('components.settings.domains.dns_type')}</Field.Label>
|
||||
|
||||
@@ -4,6 +4,7 @@ export { default as TeamsPage } from './teams.svelte';
|
||||
export { default as CustomizeLmsPage } from './customize-lms.svelte';
|
||||
export { default as OrgPage } from './org.svelte';
|
||||
export { default as LandingpagePage } from './landingpage.svelte';
|
||||
export { default as LandingpageEditor } from './landingpage-editor.svelte';
|
||||
export { default as BillingPage } from './billing.svelte';
|
||||
export { default as IntegrationsPage } from './integrations.svelte';
|
||||
export { default as AuthPage } from './auth.svelte';
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { CloseButton, UnsavedChanges } from '$features/ui';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import { ContentIcon, ExternalLinkIcon, GoalIcon, HeaderIcon, SettingsIcon } from '@cio/ui/custom/moving-icons';
|
||||
|
||||
import HeroSection from './landingpage-editor/hero-section.svelte';
|
||||
import NavigationSection from './landingpage-editor/navigation-section.svelte';
|
||||
import CalloutSection from './landingpage-editor/callout-section.svelte';
|
||||
import EmbedSection from './landingpage-editor/embed-section.svelte';
|
||||
import FooterSection from './landingpage-editor/footer-section.svelte';
|
||||
|
||||
interface Props {
|
||||
settings: OrgLandingPageJson;
|
||||
onSave: () => Promise<void> | void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type SectionKey = 'hero' | 'navigation' | 'callout' | 'embed' | 'footer';
|
||||
|
||||
interface SectionDefinition {
|
||||
key: SectionKey;
|
||||
title: string;
|
||||
icon: typeof HeaderIcon;
|
||||
component: Component;
|
||||
}
|
||||
|
||||
let { settings = $bindable(), onSave, onClose }: Props = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
let selectedSection = $state<SectionKey | null>(null);
|
||||
let isSaving = $state(false);
|
||||
let hasUnsavedChanges = $state(false);
|
||||
|
||||
const sections: SectionDefinition[] = [
|
||||
{
|
||||
key: 'navigation',
|
||||
title: t.get('settings.landing_page.editor.sections.navigation'),
|
||||
icon: ContentIcon,
|
||||
component: NavigationSection
|
||||
},
|
||||
{
|
||||
key: 'hero',
|
||||
title: t.get('settings.landing_page.editor.sections.hero'),
|
||||
icon: HeaderIcon,
|
||||
component: HeroSection
|
||||
},
|
||||
{
|
||||
key: 'embed',
|
||||
title: t.get('settings.landing_page.editor.sections.embed'),
|
||||
icon: ExternalLinkIcon,
|
||||
component: EmbedSection
|
||||
},
|
||||
{
|
||||
key: 'callout',
|
||||
title: t.get('settings.landing_page.editor.sections.callout'),
|
||||
icon: GoalIcon,
|
||||
component: CalloutSection
|
||||
},
|
||||
{
|
||||
key: 'footer',
|
||||
title: t.get('settings.landing_page.editor.sections.footer'),
|
||||
icon: SettingsIcon,
|
||||
component: FooterSection
|
||||
}
|
||||
];
|
||||
|
||||
const selectedSectionDefinition = $derived(sections.find((section) => section.key === selectedSection) ?? null);
|
||||
|
||||
function markDirty() {
|
||||
hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (selectedSection) {
|
||||
selectedSection = null;
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleSaveClick() {
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
await onSave();
|
||||
hasUnsavedChanges = false;
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<UnsavedChanges bind:hasUnsavedChanges />
|
||||
|
||||
<Sidebar.Header
|
||||
class="flex flex-row! items-center {sidebar.open ? 'justify-between' : 'justify-center'} border-b px-2 py-2"
|
||||
>
|
||||
{#if !selectedSection}
|
||||
{#if sidebar.open}
|
||||
<CloseButton onClick={handleClose} />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" onclick={handleSaveClick} loading={isSaving} disabled={isSaving}>
|
||||
{$t('settings.landing_page.editor.save')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<CloseButton onClick={handleClose} />
|
||||
{/if}
|
||||
{:else if sidebar.open || !sidebar.isMobile}
|
||||
<div class="flex items-center gap-2">
|
||||
<IconButton onclick={() => (selectedSection = null)}>
|
||||
<ArrowLeftIcon size={16} />
|
||||
</IconButton>
|
||||
<h3 class="text-sm font-semibold">
|
||||
{selectedSectionDefinition?.title}
|
||||
</h3>
|
||||
</div>
|
||||
{:else}
|
||||
<IconButton onclick={() => (selectedSection = null)} tooltip={selectedSectionDefinition?.title ?? ''}>
|
||||
<ArrowLeftIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</Sidebar.Header>
|
||||
|
||||
<Sidebar.Content class="flex-1 overflow-y-auto">
|
||||
{#if !selectedSection}
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel class="px-2">
|
||||
{$t('settings.landing_page.editor.page_builder')}
|
||||
</Sidebar.GroupLabel>
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu>
|
||||
{#each sections as section (section.key)}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton onclick={() => (selectedSection = section.key)} tooltipContent={section.title}>
|
||||
{@const SectionIcon = section.icon}
|
||||
<SectionIcon size={16} />
|
||||
<span>{section.title}</span>
|
||||
<ChevronRightIcon size={16} />
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
{:else if selectedSectionDefinition}
|
||||
{@const SectionComponent = selectedSectionDefinition.component}
|
||||
<div class="p-4">
|
||||
<SectionComponent bind:settings {markDirty} />
|
||||
</div>
|
||||
{/if}
|
||||
</Sidebar.Content>
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
href: string;
|
||||
labelFieldLabel: string;
|
||||
hrefFieldLabel: string;
|
||||
labelPlaceholder?: string;
|
||||
hrefPlaceholder?: string;
|
||||
className?: string;
|
||||
removeAriaLabel?: string;
|
||||
onLabelInput: (value: string) => void;
|
||||
onHrefInput: (value: string) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
href,
|
||||
labelFieldLabel,
|
||||
hrefFieldLabel,
|
||||
labelPlaceholder,
|
||||
hrefPlaceholder,
|
||||
className = 'space-y-4',
|
||||
removeAriaLabel,
|
||||
onLabelInput,
|
||||
onHrefInput,
|
||||
onRemove
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
{#if onRemove}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="absolute! -top-3! right-2!"
|
||||
aria-label={removeAriaLabel}
|
||||
onclick={() => onRemove()}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{labelFieldLabel}</Field.Label>
|
||||
<Input value={label} placeholder={labelPlaceholder} oninput={(event) => onLabelInput(event.currentTarget.value)} />
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{hrefFieldLabel}</Field.Label>
|
||||
<Input value={href} placeholder={hrefPlaceholder} oninput={(event) => onHrefInput(event.currentTarget.value)} />
|
||||
</Field.Field>
|
||||
</div>
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import set from 'lodash/set';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import type { OrgLandingPageCallout, OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
|
||||
interface Props {
|
||||
settings: OrgLandingPageJson;
|
||||
markDirty: () => void;
|
||||
}
|
||||
|
||||
let { settings = $bindable(), markDirty }: Props = $props();
|
||||
|
||||
function setter(value: unknown, setterKey: string) {
|
||||
if (typeof value === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSettings = cloneDeep(settings);
|
||||
set(nextSettings, setterKey, value);
|
||||
settings = nextSettings;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function ensureCallout(): OrgLandingPageCallout {
|
||||
if (settings.callout) {
|
||||
return settings.callout;
|
||||
}
|
||||
|
||||
const callout: OrgLandingPageCallout = {
|
||||
heading: t.get('settings.landing_page.editor.callout.heading_default'),
|
||||
description: '',
|
||||
action: {
|
||||
label: t.get('settings.landing_page.editor.callout.action_label_default'),
|
||||
href: '#'
|
||||
}
|
||||
};
|
||||
|
||||
settings = {
|
||||
...settings,
|
||||
callout
|
||||
};
|
||||
markDirty();
|
||||
|
||||
return callout;
|
||||
}
|
||||
|
||||
function setCalloutEnabled(enabled: boolean) {
|
||||
if (!enabled) {
|
||||
settings = {
|
||||
...settings,
|
||||
callout: undefined
|
||||
};
|
||||
markDirty();
|
||||
return;
|
||||
}
|
||||
|
||||
setter(ensureCallout(), 'callout');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field.Group class="space-y-6">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.editor.sections.callout')}</Field.Legend>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch
|
||||
checked={Boolean(settings.callout)}
|
||||
onCheckedChange={(checked) => setCalloutEnabled(checked === true)}
|
||||
/>
|
||||
<Field.Label>{$t('settings.landing_page.editor.callout.toggle')}</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
{#if settings.callout}
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.editor.callout.heading')}</Field.Label>
|
||||
<Input
|
||||
value={settings.callout.heading}
|
||||
placeholder={$t('settings.landing_page.editor.callout.heading_placeholder')}
|
||||
oninput={(event) => setter(event.currentTarget.value, 'callout.heading')}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.editor.callout.description')}</Field.Label>
|
||||
<Textarea
|
||||
class="ui:[field-sizing:fixed] ui:min-w-0 ui:w-full ui:max-w-full ui:box-border ui:resize-y"
|
||||
value={settings.callout.description}
|
||||
placeholder={$t('settings.landing_page.editor.callout.description_placeholder')}
|
||||
oninput={(event) => setter(event.currentTarget.value, 'callout.description')}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.editor.callout.action_label')}</Field.Label>
|
||||
<Input
|
||||
value={settings.callout.action.label}
|
||||
placeholder={$t('settings.landing_page.editor.callout.action_label_placeholder')}
|
||||
oninput={(event) => setter(event.currentTarget.value, 'callout.action.label')}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.editor.callout.action_href')}</Field.Label>
|
||||
<Input
|
||||
value={settings.callout.action.href}
|
||||
placeholder={$t('settings.landing_page.editor.callout.action_href_placeholder')}
|
||||
oninput={(event) => setter(event.currentTarget.value, 'callout.action.href')}
|
||||
/>
|
||||
</Field.Field>
|
||||
{/if}
|
||||
</div>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import set from 'lodash/set';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import type { OrgLandingPageEmbed, OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import ButtonLinkFields from './button-link-fields.svelte';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
|
||||
interface Props {
|
||||
settings: OrgLandingPageJson;
|
||||
markDirty: () => void;
|
||||
}
|
||||
|
||||
let { settings = $bindable(), markDirty }: Props = $props();
|
||||
|
||||
function setter(value: unknown, setterKey: string) {
|
||||
if (typeof value === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSettings = cloneDeep(settings);
|
||||
set(nextSettings, setterKey, value);
|
||||
settings = nextSettings;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function ensureEmbed(): OrgLandingPageEmbed {
|
||||
if (settings.embed) {
|
||||
return settings.embed;
|
||||
}
|
||||
|
||||
const embed = {
|
||||
title: t.get('settings.landing_page.editor.embed.title'),
|
||||
description: '',
|
||||
code: '',
|
||||
secondaryAction: undefined
|
||||
};
|
||||
|
||||
settings = {
|
||||
...settings,
|
||||
embed
|
||||
};
|
||||
markDirty();
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
function setEmbedEnabled(enabled: boolean) {
|
||||
if (!enabled) {
|
||||
settings = {
|
||||
...settings,
|
||||
embed: undefined
|
||||
};
|
||||
markDirty();
|
||||
return;
|
||||
}
|
||||
|
||||
setter(ensureEmbed(), 'embed');
|
||||
}
|
||||
|
||||
function updateSecondaryAction(label: string, href: string) {
|
||||
if (!settings.embed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setter(
|
||||
{
|
||||
label,
|
||||
href
|
||||
},
|
||||
'embed.secondaryAction'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field.Group class="space-y-6">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.editor.sections.embed')}</Field.Legend>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch checked={Boolean(settings.embed)} onCheckedChange={(checked) => setEmbedEnabled(checked === true)} />
|
||||
<Field.Label>{$t('settings.landing_page.editor.embed.toggle')}</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
{#if settings.embed}
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.editor.embed.title')}</Field.Label>
|
||||
<Input
|
||||
value={settings.embed.title}
|
||||
placeholder={$t('settings.landing_page.editor.embed.title_placeholder')}
|
||||
oninput={(event) => setter(event.currentTarget.value, 'embed.title')}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field class="min-w-0">
|
||||
<Field.Label>{$t('settings.landing_page.editor.embed.description')}</Field.Label>
|
||||
<Textarea
|
||||
class="ui:[field-sizing:fixed] ui:min-w-0 ui:w-full ui:max-w-full ui:box-border ui:resize-y"
|
||||
value={settings.embed.description ?? ''}
|
||||
placeholder={$t('settings.landing_page.editor.embed.description_placeholder')}
|
||||
oninput={(event) => setter(event.currentTarget.value, 'embed.description')}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field class="min-w-0">
|
||||
<Field.Label>{$t('settings.landing_page.editor.embed.code')}</Field.Label>
|
||||
<Field.Description>{$t('settings.landing_page.editor.embed.code_description')}</Field.Description>
|
||||
<Textarea
|
||||
class="ui:[field-sizing:fixed] ui:min-w-0 ui:w-full ui:max-w-full ui:box-border ui:resize-y"
|
||||
value={settings.embed.code}
|
||||
placeholder={$t('settings.landing_page.editor.embed.code_placeholder')}
|
||||
oninput={(event) => setter(event.currentTarget.value, 'embed.code')}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<ButtonLinkFields
|
||||
label={settings.embed.secondaryAction?.label ?? ''}
|
||||
href={settings.embed.secondaryAction?.href ?? ''}
|
||||
labelFieldLabel={$t('settings.landing_page.editor.embed.secondary_label')}
|
||||
hrefFieldLabel={$t('settings.landing_page.editor.embed.secondary_href')}
|
||||
labelPlaceholder={$t('settings.landing_page.editor.embed.secondary_label')}
|
||||
hrefPlaceholder={$t('settings.landing_page.editor.embed.secondary_href')}
|
||||
onLabelInput={(value) => updateSecondaryAction(value, settings.embed.secondaryAction?.href ?? '')}
|
||||
onHrefInput={(value) => updateSecondaryAction(settings.embed.secondaryAction?.label ?? '', value)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import set from 'lodash/set';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import ButtonLinkFields from './button-link-fields.svelte';
|
||||
|
||||
interface Props {
|
||||
settings: OrgLandingPageJson;
|
||||
markDirty: () => void;
|
||||
}
|
||||
|
||||
let { settings = $bindable(), markDirty }: Props = $props();
|
||||
|
||||
function setter(value: unknown, setterKey: string) {
|
||||
if (typeof value === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSettings = cloneDeep(settings);
|
||||
set(nextSettings, setterKey, value);
|
||||
settings = nextSettings;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function addFooterLink() {
|
||||
settings = {
|
||||
...settings,
|
||||
footerLinks: [
|
||||
...settings.footerLinks,
|
||||
{
|
||||
label: t.get('settings.landing_page.editor.footer.default_label'),
|
||||
href: '#'
|
||||
}
|
||||
]
|
||||
};
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function removeFooterLink(index: number) {
|
||||
settings = {
|
||||
...settings,
|
||||
footerLinks: settings.footerLinks.filter((_, linkIndex) => linkIndex !== index)
|
||||
};
|
||||
markDirty();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field.Group class="space-y-6">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.editor.sections.footer')}</Field.Legend>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Field.Field class="mb-6">
|
||||
<Field.Label>{$t('settings.landing_page.editor.footer.text')}</Field.Label>
|
||||
<Textarea
|
||||
value={settings.footerText}
|
||||
oninput={(event) => {
|
||||
settings = {
|
||||
...settings,
|
||||
footerText: event.currentTarget.value
|
||||
};
|
||||
markDirty();
|
||||
}}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each settings.footerLinks as footerLink, index (index)}
|
||||
<ButtonLinkFields
|
||||
label={footerLink.label}
|
||||
href={footerLink.href}
|
||||
labelFieldLabel={$t('settings.landing_page.editor.footer.link_label')}
|
||||
hrefFieldLabel={$t('settings.landing_page.editor.footer.link_href')}
|
||||
className="group ui:border-border relative space-y-3 border-b pb-5"
|
||||
removeAriaLabel={$t('settings.landing_page.editor.footer.remove')}
|
||||
onLabelInput={(value) => setter(value, `footerLinks.${index}.label`)}
|
||||
onHrefInput={(value) => setter(value, `footerLinks.${index}.href`)}
|
||||
onRemove={() => removeFooterLink(index)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<Button type="button" variant="outline" onclick={addFooterLink}>
|
||||
{$t('settings.landing_page.editor.footer.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import set from 'lodash/set';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { UploadWidget } from '$features/ui';
|
||||
import { handleOpenWidget } from '$features/ui/course-landing-page/store';
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import ButtonLinkFields from './button-link-fields.svelte';
|
||||
|
||||
interface Props {
|
||||
settings: OrgLandingPageJson;
|
||||
markDirty: () => void;
|
||||
}
|
||||
|
||||
let { settings = $bindable(), markDirty }: Props = $props();
|
||||
|
||||
let activeWidget = $state<'hero-image' | ''>('');
|
||||
|
||||
function setter(value: unknown, setterKey: string) {
|
||||
if (typeof value === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSettings = cloneDeep(settings);
|
||||
set(nextSettings, setterKey, value);
|
||||
settings = nextSettings;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function toggleSecondaryAction(enabled: boolean) {
|
||||
if (!enabled) {
|
||||
settings = {
|
||||
...settings,
|
||||
hero: {
|
||||
...settings.hero,
|
||||
secondaryAction: undefined
|
||||
}
|
||||
};
|
||||
markDirty();
|
||||
return;
|
||||
}
|
||||
|
||||
setter(
|
||||
settings.hero.secondaryAction ?? {
|
||||
label: t.get('settings.landing_page.editor.hero.secondary_label'),
|
||||
href: '/courses'
|
||||
},
|
||||
'hero.secondaryAction'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field.Group class="space-y-6">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.editor.sections.hero')}</Field.Legend>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.editor.hero.heading')}</Field.Label>
|
||||
<Input value={settings.hero.heading} oninput={(event) => setter(event.currentTarget.value, 'hero.heading')} />
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.editor.hero.subheading')}</Field.Label>
|
||||
<Textarea
|
||||
value={settings.hero.subheading}
|
||||
oninput={(event) => setter(event.currentTarget.value, 'hero.subheading')}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<ButtonLinkFields
|
||||
label={settings.hero.primaryAction.label}
|
||||
href={settings.hero.primaryAction.href}
|
||||
labelFieldLabel={$t('settings.landing_page.editor.hero.primary_label')}
|
||||
hrefFieldLabel={$t('settings.landing_page.editor.hero.primary_href')}
|
||||
labelPlaceholder={$t('settings.landing_page.editor.hero.primary_label')}
|
||||
hrefPlaceholder={$t('settings.landing_page.editor.hero.primary_href')}
|
||||
onLabelInput={(value) => setter(value, 'hero.primaryAction.label')}
|
||||
onHrefInput={(value) => setter(value, 'hero.primaryAction.href')}
|
||||
/>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch
|
||||
checked={Boolean(settings.hero.secondaryAction)}
|
||||
onCheckedChange={(checked) => toggleSecondaryAction(checked === true)}
|
||||
/>
|
||||
<Field.Label>{$t('settings.landing_page.editor.hero.secondary_toggle')}</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
{#if settings.hero.secondaryAction}
|
||||
<ButtonLinkFields
|
||||
label={settings.hero.secondaryAction.label}
|
||||
href={settings.hero.secondaryAction.href}
|
||||
labelFieldLabel={$t('settings.landing_page.editor.hero.secondary_label')}
|
||||
hrefFieldLabel={$t('settings.landing_page.editor.hero.secondary_href')}
|
||||
labelPlaceholder={$t('settings.landing_page.editor.hero.secondary_label')}
|
||||
hrefPlaceholder={$t('settings.landing_page.editor.hero.secondary_href')}
|
||||
onLabelInput={(value) => setter(value, 'hero.secondaryAction.label')}
|
||||
onHrefInput={(value) => setter(value, 'hero.secondaryAction.href')}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.editor.hero.image')}</Field.Label>
|
||||
{#if settings.hero.image}
|
||||
<img src={settings.hero.image} alt="" class="border-border/60 mb-3 w-full rounded-lg border object-cover" />
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
activeWidget = 'hero-image';
|
||||
$handleOpenWidget.open = true;
|
||||
}}
|
||||
>
|
||||
{$t('settings.landing_page.editor.hero.upload')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onclick={() => {
|
||||
setter('', 'hero.image');
|
||||
}}
|
||||
>
|
||||
{$t('settings.landing_page.editor.hero.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if $handleOpenWidget.open && activeWidget === 'hero-image'}
|
||||
<UploadWidget
|
||||
imageURL={settings.hero.image}
|
||||
onchange={(imageUrl) => {
|
||||
setter(imageUrl, 'hero.image');
|
||||
$handleOpenWidget.open = false;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</div>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import set from 'lodash/set';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import ButtonLinkFields from './button-link-fields.svelte';
|
||||
|
||||
interface Props {
|
||||
settings: OrgLandingPageJson;
|
||||
markDirty: () => void;
|
||||
}
|
||||
|
||||
let { settings = $bindable(), markDirty }: Props = $props();
|
||||
|
||||
function setter(value: unknown, setterKey: string) {
|
||||
if (typeof value === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSettings = cloneDeep(settings);
|
||||
set(nextSettings, setterKey, value);
|
||||
settings = nextSettings;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function addNavigationItem() {
|
||||
settings = {
|
||||
...settings,
|
||||
navItems: [
|
||||
...settings.navItems,
|
||||
{
|
||||
label: t.get('settings.landing_page.editor.navigation.default_label'),
|
||||
href: '#'
|
||||
}
|
||||
]
|
||||
};
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function removeNavigationItem(index: number) {
|
||||
settings = {
|
||||
...settings,
|
||||
navItems: settings.navItems.filter((_, itemIndex) => itemIndex !== index)
|
||||
};
|
||||
markDirty();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-7">
|
||||
<Field.Legend>{$t('settings.landing_page.editor.sections.navigation')}</Field.Legend>
|
||||
|
||||
{#each settings.navItems as navItem, index (index)}
|
||||
<ButtonLinkFields
|
||||
label={navItem.label}
|
||||
href={navItem.href}
|
||||
labelFieldLabel={$t('settings.landing_page.editor.navigation.link_label')}
|
||||
hrefFieldLabel={$t('settings.landing_page.editor.navigation.link_href')}
|
||||
className="group ui:border-border relative space-y-3 border-b pb-5"
|
||||
removeAriaLabel={$t('settings.landing_page.editor.navigation.remove')}
|
||||
onLabelInput={(value) => setter(value, `navItems.${index}.label`)}
|
||||
onHrefInput={(value) => setter(value, `navItems.${index}.href`)}
|
||||
onRemove={() => removeNavigationItem(index)}
|
||||
/>
|
||||
{/each}
|
||||
<Button type="button" variant="outline" onclick={addNavigationItem}>
|
||||
{$t('settings.landing_page.editor.navigation.add')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1,761 +1,177 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
|
||||
import { landingPageSettings } from '../utils/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { handleOpenWidget } from '$features/ui/course-landing-page/store';
|
||||
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { UploadWidget, UnsavedChanges } from '$features/ui';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
import { UnsavedChanges } from '$features/ui';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import {
|
||||
createDefaultLandingPageSettings,
|
||||
landingPageThemes,
|
||||
normalizeLandingPageSettings
|
||||
} from '$features/org/utils/landing-page';
|
||||
import { landingPageSettings } from '../utils/store';
|
||||
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
|
||||
let creatingNewQuestion = $state(false);
|
||||
let creatingNewCustomLink = $state(false);
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let widgetKey = $state('');
|
||||
const banner = [
|
||||
{ value: 'video', label: `${$t('settings.landing_page.actions.banner_type.video')}` },
|
||||
{ value: 'image', label: `${$t('settings.landing_page.actions.banner_type.image')}` }
|
||||
];
|
||||
let hasInitialized = $state(false);
|
||||
let skipUnsavedPrompt = $state(false);
|
||||
let { hasUnsavedChanges = $bindable(false) }: { hasUnsavedChanges?: boolean } = $props();
|
||||
|
||||
let newQuestion = $state({
|
||||
title: '',
|
||||
content: ''
|
||||
});
|
||||
const themeCards = [
|
||||
{
|
||||
value: 'minimal',
|
||||
preview: 'https://assets.cdn.clsrio.com/www/templates/minimal.png',
|
||||
titleKey: 'settings.landing_page.theme.cards.minimal.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.minimal.description'
|
||||
},
|
||||
{
|
||||
value: 'bold',
|
||||
preview: 'https://assets.cdn.clsrio.com/www/templates/bold.png',
|
||||
titleKey: 'settings.landing_page.theme.cards.bold.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.bold.description'
|
||||
},
|
||||
{
|
||||
value: 'classic',
|
||||
preview: 'https://assets.cdn.clsrio.com/www/templates/classic.png',
|
||||
titleKey: 'settings.landing_page.theme.cards.classic.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.classic.description'
|
||||
}
|
||||
] as const;
|
||||
|
||||
let newCustomLink = $state({
|
||||
label: '',
|
||||
url: '',
|
||||
openInNewTab: false
|
||||
});
|
||||
|
||||
function widgetControl(key: string) {
|
||||
widgetKey = key;
|
||||
$handleOpenWidget.open = true;
|
||||
function isPaidTheme(theme: string): boolean {
|
||||
return theme !== 'minimal';
|
||||
}
|
||||
|
||||
function createNewFaq() {
|
||||
newQuestion = {
|
||||
title: '',
|
||||
content: ''
|
||||
function selectTheme(theme: (typeof landingPageThemes)[number]) {
|
||||
if ($isFreePlan && isPaidTheme(theme)) {
|
||||
openUpgradeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
$landingPageSettings.theme = theme;
|
||||
hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
function isLandingPageTheme(value: string | null): value is (typeof landingPageThemes)[number] {
|
||||
return value === 'minimal' || value === 'bold' || value === 'classic';
|
||||
}
|
||||
|
||||
function getEditContentUrl() {
|
||||
const query = new SvelteURLSearchParams(page.url.searchParams);
|
||||
query.set('theme', $landingPageSettings.theme);
|
||||
return `${$currentOrgPath}/settings/landingpage/edit?${query.toString()}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (hasInitialized || !$currentOrg.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedLandingPage = normalizeLandingPageSettings($currentOrg.landingpage);
|
||||
const orgDefaultLandingPageSettings = createDefaultLandingPageSettings();
|
||||
const queryTheme = page.url.searchParams.get('theme');
|
||||
$landingPageSettings = {
|
||||
...orgDefaultLandingPageSettings,
|
||||
...normalizedLandingPage
|
||||
};
|
||||
creatingNewQuestion = true;
|
||||
}
|
||||
|
||||
function saveNewFAQ() {
|
||||
if (newQuestion.title !== '' && newQuestion.content !== '') {
|
||||
$landingPageSettings.faq.questions = [
|
||||
...$landingPageSettings.faq.questions,
|
||||
{
|
||||
id: new Date().getTime(),
|
||||
title: newQuestion.title,
|
||||
content: newQuestion.content
|
||||
}
|
||||
];
|
||||
creatingNewQuestion = false;
|
||||
if (isLandingPageTheme(queryTheme)) {
|
||||
$landingPageSettings.theme = queryTheme;
|
||||
hasUnsavedChanges = queryTheme !== normalizedLandingPage.theme;
|
||||
} else {
|
||||
hasUnsavedChanges = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelNewFAQ() {
|
||||
creatingNewQuestion = false;
|
||||
}
|
||||
|
||||
function deleteFaq(id: number) {
|
||||
let faqs = $landingPageSettings.faq.questions;
|
||||
const filteredFaq = faqs.filter((faq) => faq.id !== id);
|
||||
$landingPageSettings.faq.questions = filteredFaq;
|
||||
}
|
||||
|
||||
function createNewCustomLink() {
|
||||
newCustomLink = {
|
||||
label: '',
|
||||
url: '',
|
||||
openInNewTab: false
|
||||
};
|
||||
creatingNewCustomLink = true;
|
||||
}
|
||||
|
||||
function saveNewCustomLink() {
|
||||
if (newCustomLink.label !== '' && newCustomLink.url !== '') {
|
||||
$landingPageSettings.customLinks.links = [
|
||||
...$landingPageSettings.customLinks.links,
|
||||
{
|
||||
id: new Date().getTime(),
|
||||
label: newCustomLink.label,
|
||||
url: newCustomLink.url,
|
||||
openInNewTab: newCustomLink.openInNewTab
|
||||
}
|
||||
];
|
||||
creatingNewCustomLink = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelNewCustomLink() {
|
||||
creatingNewCustomLink = false;
|
||||
}
|
||||
|
||||
function deleteCustomLink(id: number) {
|
||||
let customLinks = $landingPageSettings.customLinks.links;
|
||||
const filteredCustomLinks = customLinks.filter((link) => link.id !== id);
|
||||
$landingPageSettings.customLinks.links = filteredCustomLinks;
|
||||
}
|
||||
|
||||
const checkPrefix = (inputValue: string) => {
|
||||
if (!inputValue) return;
|
||||
|
||||
if (inputValue.trim() !== '') {
|
||||
if (!inputValue.startsWith('https://')) {
|
||||
inputValue = 'https://' + inputValue;
|
||||
}
|
||||
}
|
||||
return inputValue;
|
||||
};
|
||||
hasInitialized = true;
|
||||
});
|
||||
|
||||
export async function handleSave() {
|
||||
$landingPageSettings.footer.twitter = checkPrefix($landingPageSettings.footer.twitter) || '';
|
||||
$landingPageSettings.footer.linkedin = checkPrefix($landingPageSettings.footer.linkedin) || '';
|
||||
$landingPageSettings.footer.facebook = checkPrefix($landingPageSettings.footer.facebook) || '';
|
||||
const updatedLandingPage = normalizeLandingPageSettings($landingPageSettings as AccountOrg['landingpage']);
|
||||
|
||||
await orgApi.update($currentOrg.id, {
|
||||
landingpage: $landingPageSettings as AccountOrg['landingpage']
|
||||
landingpage: updatedLandingPage as AccountOrg['landingpage']
|
||||
});
|
||||
|
||||
if (orgApi.success) {
|
||||
$currentOrg.landingpage = $landingPageSettings as AccountOrg['landingpage'];
|
||||
$landingPageSettings = updatedLandingPage;
|
||||
$currentOrg.landingpage = updatedLandingPage as AccountOrg['landingpage'];
|
||||
hasUnsavedChanges = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Set default from store
|
||||
currentOrg.subscribe((cOrg) => {
|
||||
if (cOrg.landingpage && Object.keys(cOrg.landingpage).length) {
|
||||
const landingpage = { ...(cOrg.landingpage as unknown as OrgLandingPageJson) };
|
||||
|
||||
// Fallbacks for new keys we added into JSON, in case a user already saved the old JSON
|
||||
if (!landingpage?.header?.banner) {
|
||||
landingpage.header.banner = $landingPageSettings.header.banner;
|
||||
}
|
||||
|
||||
if (!landingpage?.header?.background) {
|
||||
landingpage.header.background = $landingPageSettings.header.background;
|
||||
}
|
||||
|
||||
if (!landingpage.customLinks) {
|
||||
landingpage.customLinks = $landingPageSettings.customLinks;
|
||||
}
|
||||
$landingPageSettings = {
|
||||
...landingpage
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<UnsavedChanges bind:hasUnsavedChanges />
|
||||
<UnsavedChanges bind:hasUnsavedChanges skipPrompt={skipUnsavedPrompt} />
|
||||
|
||||
<Field.Group class="w-full max-w-md! px-2">
|
||||
<Field.Group class="w-full px-2">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.header.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.header.show
|
||||
? $t('settings.landing_page.show_section')
|
||||
: $t('settings.landing_page.hide_section')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<Field.Legend>{$t('settings.landing_page.theme.heading')}</Field.Legend>
|
||||
<Field.Description>{$t('settings.landing_page.theme.description')}</Field.Description>
|
||||
</div>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.title')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
|
||||
bind:value={$landingPageSettings.header.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
class="w-full"
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.title_highlight')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
|
||||
class="w-full"
|
||||
bind:value={$landingPageSettings.header.titleHighlight}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.subtitle')}</Field.Label>
|
||||
<Textarea
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
|
||||
class="w-full"
|
||||
bind:value={$landingPageSettings.header.subtitle}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={async () => {
|
||||
skipUnsavedPrompt = true;
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.actions.heading')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.label')}
|
||||
class="w-full"
|
||||
bind:value={$landingPageSettings.header.action.label}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.actions.link')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.link')}
|
||||
bind:value={$landingPageSettings.header.action.link}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch
|
||||
bind:checked={$landingPageSettings.header.action.redirect}
|
||||
onCheckedChange={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.header.action.redirect
|
||||
? $t('settings.landing_page.actions.redirect')
|
||||
: $t('settings.landing_page.actions.no_redirect')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
try {
|
||||
await goto(resolve(getEditContentUrl(), {}));
|
||||
} finally {
|
||||
skipUnsavedPrompt = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$t('settings.landing_page.content.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.actions.banner_type.heading')}</Field.Label>
|
||||
<RadioGroup.Root
|
||||
bind:value={$landingPageSettings.header.banner.type}
|
||||
onValueChange={() => {
|
||||
if (hasUnsavedChanges) return;
|
||||
hasUnsavedChanges = true;
|
||||
}}
|
||||
>
|
||||
{#each banner as item}
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value={item.value} id={item.value} />
|
||||
<Label for={item.value} class="cursor-pointer">{item.label}</Label>
|
||||
<Field.Field>
|
||||
<RadioGroup.Root
|
||||
bind:value={$landingPageSettings.theme}
|
||||
class="grid gap-4 md:grid-cols-3"
|
||||
onValueChange={() => {
|
||||
hasUnsavedChanges = true;
|
||||
}}
|
||||
>
|
||||
{#each themeCards as themeCard (themeCard.value)}
|
||||
{@const isLocked = $isFreePlan && isPaidTheme(themeCard.value)}
|
||||
<RadioGroup.Item value={themeCard.value} id={themeCard.value} class="sr-only" />
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectTheme(themeCard.value)}
|
||||
aria-pressed={$landingPageSettings.theme === themeCard.value}
|
||||
class={`group relative cursor-pointer rounded-xl border transition ${
|
||||
isLocked
|
||||
? 'border-border opacity-75 hover:border-blue-500/60'
|
||||
: $landingPageSettings.theme === themeCard.value
|
||||
? 'border-primary ring-primary/20 ring-2'
|
||||
: 'border-border hover:border-primary/60'
|
||||
}`}
|
||||
>
|
||||
{#if isLocked}
|
||||
<div
|
||||
class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded-full bg-blue-600 px-2 py-0.5 text-xs font-medium text-white shadow-sm"
|
||||
>
|
||||
<PremiumIcon size={14} color="white" />
|
||||
Upgrade
|
||||
</div>
|
||||
{/if}
|
||||
<div class="bg-background mb-4 overflow-hidden rounded-xl">
|
||||
<img src={themeCard.preview} alt={$t(themeCard.titleKey)} class="h-50 w-full object-cover object-top" />
|
||||
</div>
|
||||
{/each}
|
||||
</RadioGroup.Root>
|
||||
</Field.Field>
|
||||
|
||||
{#if $landingPageSettings.header.banner.type === 'video'}
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.actions.link')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.video')}
|
||||
bind:value={$landingPageSettings.header.banner.video}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
{:else}
|
||||
<Field.Field>
|
||||
<Button variant="outline" onclick={() => widgetControl('header.banner.image')}>
|
||||
{$t('settings.landing_page.about.select_image')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{/if}
|
||||
{#if $landingPageSettings.header.banner.image && $landingPageSettings.header.banner.type === 'image'}
|
||||
<img alt="bannerImage" src={$landingPageSettings.header.banner.image} class="mt-2 w-full rounded-md" />
|
||||
{/if}
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch
|
||||
bind:checked={$landingPageSettings.header.banner.show}
|
||||
onCheckedChange={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.header.banner.show
|
||||
? $t('settings.landing_page.actions.show_banner')
|
||||
: $t('settings.landing_page.actions.hide_banner')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
{#if $handleOpenWidget.open && widgetKey === 'banner'}
|
||||
<UploadWidget
|
||||
bind:imageURL={$landingPageSettings.header.banner.image}
|
||||
onchange={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.background.title')}</Field.Label>
|
||||
<Button variant="outline" onclick={() => widgetControl('background')}>
|
||||
{$t('settings.landing_page.about.select_image')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{#if $landingPageSettings.header.background?.image}
|
||||
<img alt="backgroundImage" src={$landingPageSettings.header.background.image} class="mt-2 w-full rounded-md" />
|
||||
{/if}
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch
|
||||
checked={$landingPageSettings.header.background?.show}
|
||||
onCheckedChange={() => {
|
||||
hasUnsavedChanges = true;
|
||||
if (!$landingPageSettings.header.background) {
|
||||
$landingPageSettings.header.background = {
|
||||
image: '',
|
||||
show: true
|
||||
};
|
||||
} else {
|
||||
$landingPageSettings.header.background.show = !$landingPageSettings.header.background.show;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.header.background?.show
|
||||
? $t('settings.landing_page.background.show_background')
|
||||
: $t('settings.landing_page.background.hide_background')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
{#if $handleOpenWidget.open && widgetKey === 'background'}
|
||||
<UploadWidget
|
||||
imageURL={$landingPageSettings.header.background?.image}
|
||||
onchange={(imgUrl) => {
|
||||
hasUnsavedChanges = true;
|
||||
|
||||
if (!$landingPageSettings.header.background) {
|
||||
$landingPageSettings.header.background = {
|
||||
image: imgUrl,
|
||||
show: true
|
||||
};
|
||||
} else {
|
||||
$landingPageSettings.header.background.image = imgUrl;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.about.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.aboutUs.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.aboutUs.show
|
||||
? $t('settings.landing_page.show_section')
|
||||
: $t('settings.landing_page.hide_section')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.about.title')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
|
||||
bind:value={$landingPageSettings.aboutUs.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.about.content')}</Field.Label>
|
||||
<Textarea
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.content')}
|
||||
bind:value={$landingPageSettings.aboutUs.content}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label class="font-bold">{$t('settings.landing_page.about.upload_an_image')}</Field.Label>
|
||||
<Button variant="outline" onclick={() => widgetControl('about-us')}>
|
||||
{$t('settings.landing_page.about.select_image')}
|
||||
</Button>
|
||||
{#if $landingPageSettings.aboutUs.imageUrl}
|
||||
<img alt="About us" src={$landingPageSettings.aboutUs.imageUrl} class="mt-2 w-full rounded-md" />
|
||||
{/if}
|
||||
{#if $handleOpenWidget.open && widgetKey === 'about-us'}
|
||||
<UploadWidget bind:imageURL={$landingPageSettings.aboutUs.imageUrl} />
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.courses.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.courses.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.courses.show
|
||||
? $t('settings.landing_page.courses.show_section')
|
||||
: $t('settings.landing_page.courses.hide_section')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.courses.title')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
|
||||
bind:value={$landingPageSettings.courses.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.courses.title_highlight')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
|
||||
bind:value={$landingPageSettings.courses.titleHighlight}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.courses.subtitle')}</Field.Label>
|
||||
<Textarea
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
|
||||
bind:value={$landingPageSettings.courses.subtitle}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.faq.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.faq.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.faq.show
|
||||
? $t('settings.landing_page.faq.show_section')
|
||||
: $t('settings.landing_page.faq.hide_section')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.faq.title')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
|
||||
bind:value={$landingPageSettings.faq.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
{#each $landingPageSettings.faq.questions as item (item.id)}
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.faq.question')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_question_here')}
|
||||
bind:value={item.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.faq.answer')}</Field.Label>
|
||||
<Textarea
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_answer_here')}
|
||||
bind:value={item.content}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Button variant="ghost" size="icon" onclick={() => deleteFaq(item.id)}>
|
||||
<TrashIcon size={16} />
|
||||
</Button>
|
||||
{/each}
|
||||
|
||||
{#if creatingNewQuestion}
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.faq.question')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_question_here')}
|
||||
bind:value={newQuestion.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.faq.answer')}</Field.Label>
|
||||
<Textarea
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_answer_here')}
|
||||
bind:value={newQuestion.content}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button variant="outline" onclick={saveNewFAQ}>
|
||||
{$t('settings.landing_page.faq.save')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={cancelNewFAQ}>
|
||||
{$t('settings.landing_page.faq.cancel')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{:else}
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button variant="outline" onclick={createNewFaq}>
|
||||
{$t('settings.landing_page.faq.button')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.contact_us.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.contact.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.contact.show
|
||||
? $t('settings.landing_page.contact_us.show_section')
|
||||
: $t('settings.landing_page.contact_us.hide_section')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.contact_us.title')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
|
||||
bind:value={$landingPageSettings.contact.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.contact_us.title_highlight')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
|
||||
bind:value={$landingPageSettings.contact.titleHighlight}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.contact_us.subtitle')}</Field.Label>
|
||||
<Textarea
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
|
||||
bind:value={$landingPageSettings.contact.subtitle}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.contact_us.address')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.address')}
|
||||
bind:value={$landingPageSettings.contact.address}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.contact_us.phone_number')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.number')}
|
||||
bind:value={$landingPageSettings.contact.phone}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.contact_us.email')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.email')}
|
||||
bind:value={$landingPageSettings.contact.email}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.mailing_list.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.mailinglist.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.mailinglist.show
|
||||
? $t('settings.landing_page.mailing_list.show_section')
|
||||
: $t('settings.landing_page.mailing_list.hide_section')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.mailing_list.title')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
|
||||
bind:value={$landingPageSettings.mailinglist.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.mailing_list.subtitle')}</Field.Label>
|
||||
<Textarea
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
|
||||
bind:value={$landingPageSettings.mailinglist.subtitle}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.mailing_list.button_label')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.label')}
|
||||
bind:value={$landingPageSettings.mailinglist.buttonLabel}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.custom_links.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.customLinks.show} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.customLinks.show
|
||||
? $t('settings.landing_page.custom_links.show_links')
|
||||
: $t('settings.landing_page.custom_links.hide_links')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Description>{$t('settings.landing_page.custom_links.description')}</Field.Description>
|
||||
|
||||
<Field.Group>
|
||||
{#each $landingPageSettings.customLinks.links as link (link.id)}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.custom_links.label')}</Field.Label>
|
||||
<Input placeholder={$t('settings.landing_page.custom_links.label_placeholder')} bind:value={link.label} />
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.custom_links.url')}</Field.Label>
|
||||
<Input placeholder={$t('settings.landing_page.custom_links.url_placeholder')} bind:value={link.url} />
|
||||
</Field.Field>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Field.Field orientation="horizontal">
|
||||
<Checkbox id="new-tab-{link.id}" bind:checked={link.openInNewTab} />
|
||||
<Field.Label class="text-sm">{$t('settings.landing_page.custom_links.new_tab')}</Field.Label>
|
||||
</Field.Field>
|
||||
<Button variant="ghost" size="icon" onclick={() => deleteCustomLink(link.id)}>
|
||||
<TrashIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Field.Separator />
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if creatingNewCustomLink}
|
||||
<div class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div class="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.custom_links.label')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('settings.landing_page.custom_links.label_placeholder')}
|
||||
bind:value={newCustomLink.label}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.custom_links.url')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('settings.landing_page.custom_links.url_placeholder')}
|
||||
bind:value={newCustomLink.url}
|
||||
/>
|
||||
</Field.Field>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Field.Field orientation="horizontal">
|
||||
<input type="checkbox" bind:checked={newCustomLink.openInNewTab} class="mr-2" />
|
||||
<Field.Label class="text-sm">{$t('settings.landing_page.custom_links.new_tab')}</Field.Label>
|
||||
</Field.Field>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={saveNewCustomLink} class="px-3 py-1 text-sm">
|
||||
{$t('settings.landing_page.custom_links.save')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={cancelNewCustomLink} class="px-3 py-1 text-sm">
|
||||
{$t('settings.landing_page.custom_links.cancel')}
|
||||
</Button>
|
||||
<div class="space-y-1 px-2 pb-2">
|
||||
<p class="text-base font-semibold">{$t(themeCard.titleKey)}</p>
|
||||
<p class="ui:text-muted-foreground h-[40px] text-sm">{$t(themeCard.descriptionKey)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button variant="outline" onclick={createNewCustomLink}>
|
||||
{$t('settings.landing_page.custom_links.add')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.footer.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.footer.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.footer.show
|
||||
? $t('settings.landing_page.footer.show_section')
|
||||
: $t('settings.landing_page.footer.hide_section')}
|
||||
</Field.Label>
|
||||
</button>
|
||||
{/each}
|
||||
</RadioGroup.Root>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.footer.facebook')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.facebook')}
|
||||
bind:value={$landingPageSettings.footer.facebook}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<!-- <InputField
|
||||
label="Instagram"
|
||||
placeholder="Write your Instagram link here"
|
||||
className="mb-5"
|
||||
bind:value={$landingPageSettings.footer.instagram}
|
||||
/> -->
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.footer.twitter')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.twitter')}
|
||||
bind:value={$landingPageSettings.footer.twitter}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.footer.linkedin')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.linkedin')}
|
||||
bind:value={$landingPageSettings.footer.linkedin}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
|
||||
<style>
|
||||
:global(.dark) {
|
||||
--cp-text-color: #fff;
|
||||
--cp-border-color: white;
|
||||
--cp-text-color: white;
|
||||
--cp-input-color: #555;
|
||||
--cp-button-hover-color: #777;
|
||||
}
|
||||
|
||||
:global(.dark .alpha) {
|
||||
background: #333 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,116 +1,7 @@
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const landingPageSettings: Writable<OrgLandingPageJson> = writable({
|
||||
header: {
|
||||
title: 'Web Development Courses for',
|
||||
titleHighlight: 'Everyone',
|
||||
subtitle: 'Practical project-based courses that are easy to understand and straight to the point',
|
||||
action: {
|
||||
label: 'Start learning Today',
|
||||
link: '#contact',
|
||||
redirect: false
|
||||
},
|
||||
banner: {
|
||||
video: '',
|
||||
image: '/images/learn-on-cio.jpg',
|
||||
type: 'image',
|
||||
show: true
|
||||
},
|
||||
background: {
|
||||
image: '',
|
||||
show: true
|
||||
},
|
||||
show: true
|
||||
},
|
||||
aboutUs: {
|
||||
title: 'Our Story',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.',
|
||||
imageUrl: '/images/riverside.jpeg',
|
||||
show: true
|
||||
},
|
||||
courses: {
|
||||
title: 'Explore our',
|
||||
titleHighlight: 'Courses',
|
||||
subtitle: 'Find courses you will love from best teachers all over the world🌎.',
|
||||
show: true
|
||||
},
|
||||
faq: {
|
||||
title: 'Frequently Asked Questions.',
|
||||
questions: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
}
|
||||
],
|
||||
show: true
|
||||
},
|
||||
contact: {
|
||||
title: 'Have you got any',
|
||||
titleHighlight: 'Question?',
|
||||
subtitle: 'Reach out to us with any question you might have.',
|
||||
address: 'Ashwoodfield House Farm, Kidderminster Rd, Stourbridge, Kingswinford DY6 0AA',
|
||||
phone: '+4407339904995',
|
||||
email: '[email protected]',
|
||||
show: true
|
||||
},
|
||||
mailinglist: {
|
||||
title: 'Join our mailing list',
|
||||
subtitle:
|
||||
'We are constantly releasing new courses and sharing them with our email list. Subscribe to get notified once we release a new course',
|
||||
buttonLabel: 'Subscribe',
|
||||
show: true
|
||||
},
|
||||
customLinks: {
|
||||
show: true,
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
label: 'About Us',
|
||||
url: '#aboutus',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: 'Courses',
|
||||
url: '/courses',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: 'FAQ',
|
||||
url: '#faq',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: 'Contact Us',
|
||||
url: '#contact',
|
||||
openInNewTab: false
|
||||
}
|
||||
]
|
||||
},
|
||||
footer: {
|
||||
facebook: '#',
|
||||
instagram: '#',
|
||||
twitter: '#',
|
||||
linkedin: '#',
|
||||
show: true
|
||||
}
|
||||
});
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import { createDefaultLandingPageSettings } from '$features/org/utils/landing-page';
|
||||
|
||||
export const landingPageSettings: Writable<OrgLandingPageJson> = writable(createDefaultLandingPageSettings());
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('tags_admin.heading')}</Page.Title>
|
||||
<Page.Subtitle>{$t('tags_admin.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<ButtonGroup.Root>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
import { ROUTE } from '$lib/utils/constants/routes';
|
||||
import { DotPattern } from '@cio/ui/custom/animation/dot-pattern';
|
||||
|
||||
interface Props {
|
||||
isLogin?: boolean;
|
||||
@@ -67,8 +68,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="auth-ui-background flex min-h-screen w-full items-center justify-center p-4">
|
||||
<Card.Root class="ui:w-full max-w-[400px]">
|
||||
<div class="auth-ui-background relative flex min-h-screen w-full items-center justify-center overflow-hidden p-4">
|
||||
<DotPattern fillColor="rgb(2 51 189 / 0.25)" class="absolute inset-0 h-full w-full" />
|
||||
<Card.Root class="ui:w-full relative z-10 max-w-[400px]">
|
||||
{#if !showOnlyContent || showLogo}
|
||||
<Card.Header class="ui:flex ui:flex-col ui:items-center ui:gap-4">
|
||||
<Avatar.Root>
|
||||
@@ -132,13 +134,3 @@
|
||||
{/if}
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* .auth-ui-background {
|
||||
background: url(https://assets.cdn.clsrio.com/www/futuristic-classroom.jpg) no-repeat center center fixed;
|
||||
-webkit-background-size: cover;
|
||||
-moz-background-size: cover;
|
||||
-o-background-size: cover;
|
||||
background-size: cover;
|
||||
} */
|
||||
</style>
|
||||
|
||||
+20
-86
@@ -11,6 +11,7 @@
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { BorderBeam } from '@cio/ui/custom/animation/border-beam';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -81,14 +82,10 @@
|
||||
<div
|
||||
class="sticky bottom-0 flex h-fit w-full items-center justify-center bg-gray-50 transition duration-300 lg:hidden dark:bg-neutral-800"
|
||||
>
|
||||
<aside
|
||||
class="price-container sticky lg:hidden {editMode
|
||||
? 'lg:bottom-2'
|
||||
: 'lg:top-10'} m-h-fit z-0 bg-gray-50 dark:bg-neutral-800 {className}"
|
||||
>
|
||||
<aside class="sticky z-0 w-[90%] max-w-[405px] bg-gray-50 lg:hidden dark:bg-neutral-800 {className}">
|
||||
<div class="flex items-center justify-center gap-3 px-3 py-3">
|
||||
<!-- Pricing -->
|
||||
<div class=" text-center">
|
||||
<div class="text-center">
|
||||
{#if courseData?.metadata?.allowNewStudent}
|
||||
<p class="flex items-center gap-1 text-sm font-medium dark:text-white">
|
||||
{formatter?.format(calculatedCost) || calculatedCost}
|
||||
@@ -126,24 +123,26 @@
|
||||
</div>
|
||||
{:else}
|
||||
<aside
|
||||
class="price-container border border-gray-200 lg:sticky {editMode
|
||||
class="relative w-[90%] max-w-[405px] overflow-hidden rounded-xl border border-gray-200 shadow-lg lg:sticky lg:w-[350px] lg:min-w-[250px] xl:w-[405px] xl:min-w-[330px] {editMode
|
||||
? 'lg:top-0'
|
||||
: 'lg:top-10'} m-h-fit dark:bg-neutral-800 {className}"
|
||||
: 'lg:top-10'} h-fit dark:border-neutral-700 dark:bg-neutral-800 {className}"
|
||||
>
|
||||
<div class="p-2 lg:p-10">
|
||||
<BorderBeam size={150} duration={12} colorFrom="#3b82f6" colorTo="#8b5cf6" borderWidth={1.5} />
|
||||
|
||||
<div class="p-4 lg:p-10">
|
||||
<!-- Pricing -->
|
||||
<div class="mb-6">
|
||||
{#if courseData?.metadata?.allowNewStudent}
|
||||
<p class="text-lg font-medium dark:text-white">
|
||||
<p class="text-2xl font-bold dark:text-white">
|
||||
{formatter?.format(calculatedCost) || calculatedCost}
|
||||
{#if isFree}
|
||||
<span class="text-sm">
|
||||
<span class="text-base font-normal">
|
||||
({$t('course.navItem.landing_page.pricing_section.free')})
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
{#if courseData?.metadata?.showDiscount}
|
||||
<p class="text-sm font-light text-gray-500 dark:text-white">
|
||||
<p class="mt-1 text-sm font-light text-gray-500 dark:text-white">
|
||||
{discount}% {$t('course.navItem.landing_page.pricing_section.discount')}.
|
||||
<span class="line-through">
|
||||
{formatter?.format(courseData?.cost || 0) || courseData.cost}
|
||||
@@ -159,7 +158,11 @@
|
||||
|
||||
<!-- Call To Action Buttons -->
|
||||
<div class="flex w-full flex-col items-center">
|
||||
<Button class="mb-3 w-full" onclick={handleJoinCourse} disabled={!courseData.metadata?.allowNewStudent}>
|
||||
<Button
|
||||
class="mb-3 h-12 w-full text-base font-semibold"
|
||||
onclick={handleJoinCourse}
|
||||
disabled={!courseData.metadata?.allowNewStudent}
|
||||
>
|
||||
{isFree
|
||||
? $t('course.navItem.landing_page.pricing_section.enroll')
|
||||
: $t('course.navItem.landing_page.pricing_section.buy')}
|
||||
@@ -174,80 +177,11 @@
|
||||
|
||||
<!-- Gift Container -->
|
||||
{#if courseData?.metadata?.reward?.show}
|
||||
<div class="flex flex-col items-center border-t border-b border-gray-300 p-10">
|
||||
<HTMLRender
|
||||
><SafeHtmlContent content={get(courseData, 'metadata.reward.description', '') as string} /></HTMLRender
|
||||
>
|
||||
<div class="flex flex-col items-center border-t border-gray-200 p-10 dark:border-neutral-700">
|
||||
<HTMLRender>
|
||||
<SafeHtmlContent content={get(courseData, 'metadata.reward.description', '') as string} />
|
||||
</HTMLRender>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.banner {
|
||||
background-color: #040f2d;
|
||||
min-height: 472px;
|
||||
}
|
||||
|
||||
.author {
|
||||
color: #7888b7;
|
||||
}
|
||||
|
||||
.banner-image {
|
||||
max-width: 559px;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.active {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.active::after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background-color: var(--main-primary-color);
|
||||
display: block;
|
||||
bottom: -14px;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
.price-container {
|
||||
width: 90%;
|
||||
max-width: 405px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.price-container {
|
||||
width: 50%;
|
||||
height: fit-content;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 1024px) {
|
||||
.price-container {
|
||||
width: 350px;
|
||||
min-width: 250px;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 1280px) {
|
||||
.price-container {
|
||||
width: 405px;
|
||||
min-width: 330px;
|
||||
height: fit-content;
|
||||
}
|
||||
}
|
||||
.course-content {
|
||||
max-width: 608px;
|
||||
}
|
||||
|
||||
:global(.list ul li) {
|
||||
margin-left: 1rem;
|
||||
list-style-type: disc;
|
||||
}
|
||||
</style>
|
||||
|
||||
+317
-252
@@ -7,6 +7,17 @@
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import PlayIcon from '@lucide/svelte/icons/play';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import StarIcon from '@lucide/svelte/icons/star';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
|
||||
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
|
||||
import { BlurIn } from '@cio/ui/custom/animation/blurin';
|
||||
import { DotPattern } from '@cio/ui/custom/animation/dot-pattern';
|
||||
import { HeroVideoDialog } from '@cio/ui/custom/animation/hero-video-dialog';
|
||||
import { Sparkle } from '@cio/ui/custom/animation/sparkle';
|
||||
import { MagicCard } from '@cio/ui/custom/animation/magic-card';
|
||||
|
||||
import { getLectureNo } from '$features/course/utils/functions';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
@@ -18,7 +29,7 @@
|
||||
import { handleOpenWidget, reviewsModalStore } from './store';
|
||||
import type { Course, CourseMetadata } from '$features/course/utils/types';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { MediaPlayer, isYoutubeUrl } from '@cio/ui/custom/media-player';
|
||||
import { formatYoutubeEmbedUrl, isYoutubeUrl } from '@cio/ui/custom/media-player';
|
||||
import { filterNavItems, getCourseLessons, getCourseSections, getTotalLessons } from './utils';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
@@ -38,16 +49,17 @@
|
||||
interface Props {
|
||||
editMode?: boolean;
|
||||
courseData: Course;
|
||||
showStandaloneShell?: boolean;
|
||||
}
|
||||
|
||||
let { editMode = false, courseData = $bindable() }: Props = $props();
|
||||
let { editMode = false, courseData = $bindable(), showStandaloneShell = true }: Props = $props();
|
||||
|
||||
const reviews = $derived(get(courseData, 'metadata.reviews') || []);
|
||||
const video = $derived(get(courseData, 'metadata.videoUrl'));
|
||||
|
||||
const lessons = $derived(getCourseLessons(courseData));
|
||||
const totalRatings = $derived(reviews?.reduce((acc = 0, review) => acc + (review?.rating || 0), 0));
|
||||
const averageRating = $derived(totalRatings / reviews?.length);
|
||||
const averageRating = $derived(reviews?.length ? (totalRatings / reviews.length).toFixed(1) : null);
|
||||
const expandDescription = $derived(Array(reviews?.length ?? 0).fill(false));
|
||||
|
||||
const allowNewStudent = $derived(get(courseData, 'metadata.allowNewStudent'));
|
||||
@@ -70,6 +82,9 @@
|
||||
|
||||
const courseSections = $derived(getCourseSections(courseData));
|
||||
const totalLessons = $derived(getTotalLessons(courseSections));
|
||||
const studentCount = $derived(courseData.group?.members?.length ?? 0);
|
||||
|
||||
const videoEmbedUrl = $derived(video ? (isYoutubeUrl(video) ? formatYoutubeEmbedUrl(video) : video) : '');
|
||||
|
||||
function locationHashChanged() {
|
||||
activeNav = window.location.hash;
|
||||
@@ -93,11 +108,11 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !editMode}
|
||||
{#if !editMode && showStandaloneShell}
|
||||
<PoweredBy />
|
||||
{/if}
|
||||
|
||||
{#if !editMode}
|
||||
{#if !editMode && showStandaloneShell}
|
||||
<Navigation
|
||||
logo={$currentOrg.avatarUrl || '/logo-192.png'}
|
||||
orgName={$currentOrg.name}
|
||||
@@ -108,93 +123,127 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex w-full flex-col items-center bg-white dark:bg-black">
|
||||
<!-- Header Section -->
|
||||
<header id="header" class="banner p- flex w-full items-center justify-center">
|
||||
<div class="flex w-full flex-col-reverse items-center justify-between md:w-5/6 md:flex-row">
|
||||
<!-- Course Description -->
|
||||
<div class="w-11/12 py-10 md:w-2/5">
|
||||
<h1 class="my-4 text-5xl text-white dark:text-white">
|
||||
{get(courseData, 'title', '')}
|
||||
</h1>
|
||||
<p class="text-md mb-6 text-white dark:text-white">
|
||||
{get(courseData, 'description', '')}
|
||||
</p>
|
||||
{#if showStandaloneShell}
|
||||
<!-- Hero Section -->
|
||||
<header class="relative w-full overflow-hidden bg-[#040f2d] py-12 md:py-20">
|
||||
<DotPattern
|
||||
class="[mask-image:radial-gradient(ellipse_at_center,white,transparent_80%)] opacity-10"
|
||||
fillColor="rgb(255 255 255 / 0.5)"
|
||||
/>
|
||||
|
||||
<p class="author my-3 text-sm dark:text-white">
|
||||
{get(courseData, 'metadata.instructor.name', '')}
|
||||
</p>
|
||||
<Button
|
||||
class="mt-6 hidden sm:w-fit md:block"
|
||||
onclick={() => {
|
||||
if (editMode) return;
|
||||
startCoursePayment = true;
|
||||
}}
|
||||
disabled={!allowNewStudent}
|
||||
>
|
||||
{$t('course.navItem.landing_page.start_course')}
|
||||
</Button>
|
||||
{#if $handleOpenWidget.open}
|
||||
<UploadWidget imageURL={courseApi.course?.logo} onchange={(newLogo) => (courseApi.course!.logo = newLogo)} />
|
||||
{/if}
|
||||
<div
|
||||
class="relative z-10 mx-auto flex w-full max-w-6xl flex-col-reverse items-center gap-8 px-6 md:flex-row md:gap-12"
|
||||
>
|
||||
<!-- Course Description -->
|
||||
<div class="w-full py-4 md:w-1/2">
|
||||
<BlurIn class="my-4 text-4xl font-bold tracking-tight text-white md:text-5xl lg:text-6xl">
|
||||
{get(courseData, 'title', '')}
|
||||
</BlurIn>
|
||||
|
||||
<BlurFade delay={0.2}>
|
||||
<p class="mb-6 text-base leading-relaxed text-gray-300 md:text-lg">
|
||||
{get(courseData, 'description', '')}
|
||||
</p>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.3}>
|
||||
<p class="mb-6 text-sm text-blue-300">
|
||||
{get(courseData, 'metadata.instructor.name', '')}
|
||||
</p>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.4}>
|
||||
<Button
|
||||
class="hidden h-12 px-8 text-base font-semibold sm:w-fit md:block"
|
||||
onclick={() => {
|
||||
if (editMode) return;
|
||||
startCoursePayment = true;
|
||||
}}
|
||||
disabled={!allowNewStudent}
|
||||
>
|
||||
{$t('course.navItem.landing_page.start_course')}
|
||||
</Button>
|
||||
</BlurFade>
|
||||
|
||||
{#if $handleOpenWidget.open}
|
||||
<UploadWidget
|
||||
imageURL={courseApi.course?.logo}
|
||||
onchange={(newLogo) => (courseApi.course!.logo = newLogo)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Banner Image / Video -->
|
||||
<div class="w-full md:w-1/2">
|
||||
<BlurFade delay={0.3} yOffset={20}>
|
||||
{#if video}
|
||||
<HeroVideoDialog
|
||||
videoSrc={videoEmbedUrl}
|
||||
thumbnailSrc={bannerImage || '/images/classroomio-course-img-template.jpg'}
|
||||
thumbnailAlt={get(courseData, 'title', 'Course video')}
|
||||
animationStyle="from-center"
|
||||
/>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-xl shadow-2xl">
|
||||
<img
|
||||
alt="Course banner"
|
||||
src={bannerImage ? bannerImage : '/images/classroomio-course-img-template.jpg'}
|
||||
class="h-auto w-full rounded-xl object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</BlurFade>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Banner Image -->
|
||||
{#if video}
|
||||
<div class="banner-image flex w-full md:w-2/3">
|
||||
<MediaPlayer
|
||||
source={{
|
||||
type: isYoutubeUrl(video) ? 'youtube' : 'generic',
|
||||
url: video
|
||||
}}
|
||||
options={{
|
||||
width: '100%',
|
||||
height: '400'
|
||||
}}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="banner-image relative overflow-hidden rounded-md md:w-2/3">
|
||||
<img
|
||||
style="min-width:280px; min-height:200px"
|
||||
alt="About us"
|
||||
src={bannerImage ? bannerImage : '/images/classroomio-course-img-template.jpg'}
|
||||
class="relative mt-2 h-full max-h-[400px] w-full max-w-[500px] rounded-md md:mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- <div
|
||||
class="banner-image w-2/3 h-96 relative cursor-pointer"
|
||||
on:click={playVideo}
|
||||
<!-- Social Proof Strip -->
|
||||
{#if reviews.length > 0 || studentCount > 0 || lessons.length > 0}
|
||||
<div class="w-full border-b border-gray-200 bg-gray-50 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div
|
||||
class="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-center gap-6 px-6 py-4 text-sm md:justify-start"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute top-0 left-0 backdrop w-full h-full"
|
||||
/>
|
||||
<img
|
||||
src="/images/course-banner-image.png"
|
||||
alt="banner"
|
||||
class="w-full h-full"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-full z-50 flex items-center justify-center"
|
||||
>
|
||||
<img src="/images/play_video.png" alt="play video" />
|
||||
</div>
|
||||
</div> -->
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
{#if averageRating}
|
||||
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
||||
<StarIcon size={16} class="fill-yellow-400 text-yellow-400" />
|
||||
<span class="font-semibold">{averageRating}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400"
|
||||
>({reviews.length} {reviews.length === 1 ? 'review' : 'reviews'})</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if studentCount > 0}
|
||||
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
||||
<UsersIcon size={16} />
|
||||
<span>{studentCount} {studentCount === 1 ? 'student' : 'students'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if lessons.length > 0}
|
||||
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
||||
<BookOpenIcon size={16} />
|
||||
<span>{pluralize('lesson', lessons.length, true)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if courseData.type}
|
||||
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
||||
<ClockIcon size={16} />
|
||||
<span>{courseData.type === 'LIVE_CLASS' ? 'Live Class' : 'Self-Paced'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Body -->
|
||||
<div class="w-full bg-white dark:bg-black">
|
||||
<div
|
||||
class="mx-0 my-2 flex w-full max-w-[1200px] flex-col-reverse items-center justify-between lg:m-auto lg:w-11/12 lg:flex-row lg:items-start lg:py-8"
|
||||
class="mx-auto my-2 flex w-full max-w-6xl flex-col-reverse items-center justify-between px-4 lg:flex-row lg:items-start lg:py-8"
|
||||
>
|
||||
<!-- Course Details -->
|
||||
<div class="course-content w-full p-3 lg:mr-10 lg:w-10/12">
|
||||
<div class="w-full max-w-[680px] min-w-[60%] p-3 lg:mr-10">
|
||||
<!-- Navigation -->
|
||||
<UnderlineTabs.Root bind:value={activeNav} class="ui:bg-background sticky top-10 py-2">
|
||||
<UnderlineTabs.Root bind:value={activeNav} class="ui:bg-background sticky top-0 z-20 py-2">
|
||||
<UnderlineTabs.List>
|
||||
{#each navItems as navItem (navItem.key)}
|
||||
<UnderlineTabs.Trigger value={navItem.key}>
|
||||
@@ -207,155 +256,194 @@
|
||||
</UnderlineTabs.Root>
|
||||
|
||||
<!-- Sections - Requirement -->
|
||||
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.REQUIREMENT)}
|
||||
<NavSection id="requirement">
|
||||
<h3 class="mt-0 mb-3 text-2xl">
|
||||
{$t('course.navItem.landing_page.requirement')}
|
||||
</h3>
|
||||
|
||||
<ul class="list font-light">
|
||||
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.requirements', '')} /></HTMLRender>
|
||||
</ul>
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 mb-3 text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.requirement')}
|
||||
</h3>
|
||||
<ul class="list font-light">
|
||||
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.requirements', '')} /></HTMLRender>
|
||||
</ul>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Course Description -->
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.DESCRIPTION)}
|
||||
<NavSection id="description">
|
||||
<h3 class="mt-0 mb-3 text-2xl">
|
||||
{$t('course.navItem.landing_page.description')}
|
||||
</h3>
|
||||
|
||||
<HTMLRender className="dark:text-white text-sm font-light">
|
||||
<SafeHtmlContent content={get(courseData, 'metadata.description', '')} />
|
||||
</HTMLRender>
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 mb-3 text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.description')}
|
||||
</h3>
|
||||
<HTMLRender className="dark:text-white text-sm font-light leading-relaxed">
|
||||
<SafeHtmlContent content={get(courseData, 'metadata.description', '')} />
|
||||
</HTMLRender>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Goal -->
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.GOALS)}
|
||||
<NavSection id="goals">
|
||||
<h3 class="mt-0 mb-3 text-2xl">{$t('course.navItem.landing_page.learn')}</h3>
|
||||
<ul class="list font-light">
|
||||
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.goals', '')} /></HTMLRender>
|
||||
</ul>
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 mb-3 text-2xl font-semibold">{$t('course.navItem.landing_page.learn')}</h3>
|
||||
<ul class="list font-light">
|
||||
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.goals', '')} /></HTMLRender>
|
||||
</ul>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Certificate -->
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.CERTIFICATE)}
|
||||
<NavSection id="certificate">
|
||||
<h3 class="mt-0 text-2xl">{$t('course.navItem.landing_page.certificate')}</h3>
|
||||
<p class="mb-3 text-sm font-light dark:text-white">
|
||||
{$t('course.navItem.landing_page.certificate_text')}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src={certificate?.templateUrl}
|
||||
alt="certificate template"
|
||||
className="certificate-img max-h-[215px]"
|
||||
/>
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 text-2xl font-semibold">{$t('course.navItem.landing_page.certificate')}</h3>
|
||||
<p class="mb-4 text-sm font-light dark:text-white">
|
||||
{$t('course.navItem.landing_page.certificate_text')}
|
||||
</p>
|
||||
<MagicCard
|
||||
gradientColor="#3b82f6"
|
||||
gradientOpacity={0.15}
|
||||
class="inline-block w-auto border-0 bg-transparent py-0 dark:bg-transparent"
|
||||
>
|
||||
<Image src={certificate?.templateUrl} alt="certificate template" className="max-h-[215px] w-auto" />
|
||||
</MagicCard>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Lessons -->
|
||||
{#if !courseData.metadata?.isContentGroupingEnabled}
|
||||
<NavSection id="lessons">
|
||||
<div class="mb-3 flex w-full items-center justify-between">
|
||||
<h3 class="mt-0 mb-3 text-2xl">
|
||||
{$t('course.navItem.landing_page.content')}
|
||||
</h3>
|
||||
<p class="text-sm font-light dark:text-white">
|
||||
{pluralize('lesson', lessons.length, true)}
|
||||
</p>
|
||||
</div>
|
||||
<BlurFade delay={0.1}>
|
||||
<div class="mb-3 flex w-full items-center justify-between">
|
||||
<h3 class="mt-0 mb-3 text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.content')}
|
||||
</h3>
|
||||
<p class="text-sm font-light dark:text-white">
|
||||
{pluralize('lesson', lessons.length, true)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
{#each lessons as lesson, index (lesson.id)}
|
||||
<div class="m-2 rounded border px-2 py-1">
|
||||
<Chip value={getLectureNo(index + 1, '0')} />
|
||||
<p class="ml-2 inline text-xs font-light dark:text-white">
|
||||
{lesson.title}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each lessons as lesson, index (lesson.id)}
|
||||
<BlurFade delay={0.05 * Math.min(index, 10)}>
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 px-3 py-1.5 transition-colors hover:border-gray-400 dark:border-neutral-700 dark:hover:border-neutral-500"
|
||||
>
|
||||
<Chip value={getLectureNo(index + 1, '0')} />
|
||||
<p class="ml-2 inline text-xs font-light dark:text-white">
|
||||
{lesson.title}
|
||||
</p>
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{:else if courseData.metadata?.isContentGroupingEnabled}
|
||||
<NavSection id="lessons">
|
||||
<!-- header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1>{$t('course.navItem.landing_page.course_content')}</h1>
|
||||
<span class="text-xs font-normal">
|
||||
{pluralize($t('course.navItem.landing_page.modules'), courseSections.length, true)},
|
||||
{pluralize($t('course.navItem.landing_page.lessons'), totalLessons, true)}
|
||||
</span>
|
||||
</div>
|
||||
<BlurFade delay={0.1}>
|
||||
<!-- header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">{$t('course.navItem.landing_page.course_content')}</h1>
|
||||
<span class="text-xs font-normal">
|
||||
{pluralize($t('course.navItem.landing_page.modules'), courseSections.length, true)},
|
||||
{pluralize($t('course.navItem.landing_page.lessons'), totalLessons, true)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#each courseSections as section (section.id)}
|
||||
<SectionsDisplay
|
||||
exerciseCount={section.exerciseCount}
|
||||
lessonCount={section.lessons?.length}
|
||||
lessons={section.lessons}
|
||||
title={section.title!}
|
||||
/>
|
||||
{/each}
|
||||
{#each courseSections as section, index (section.id)}
|
||||
<BlurFade delay={0.05 * Math.min(index, 8)}>
|
||||
<SectionsDisplay
|
||||
exerciseCount={section.exerciseCount}
|
||||
lessonCount={section.lessons?.length}
|
||||
lessons={section.lessons}
|
||||
title={section.title!}
|
||||
/>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Reviews -->
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.REVIEWS)}
|
||||
<NavSection id="reviews">
|
||||
<h2 class="my-16 mr-0 mb-6 ml-0 font-semibold">
|
||||
{$t('course.navItem.landing_page.reviews')}
|
||||
</h2>
|
||||
<div class="flex flex-wrap">
|
||||
<BlurFade delay={0.1}>
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.reviews')}
|
||||
</h2>
|
||||
{#if averageRating}
|
||||
<Sparkle
|
||||
text={averageRating}
|
||||
colors={{ first: '#facc15', second: '#f97316' }}
|
||||
sparklesCount={3}
|
||||
class="text-xl font-bold"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</BlurFade>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each reviews.slice(0, 4) as review, id (review.id)}
|
||||
{#if !review.hide}
|
||||
<!-- review -->
|
||||
<div class="item-start my-2 flex w-2/4 flex-row">
|
||||
<!-- image container -->
|
||||
<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>
|
||||
<BlurFade delay={0.1 + id * 0.08}>
|
||||
<div
|
||||
class="flex flex-row items-start gap-3 rounded-lg border border-gray-100 p-4 dark:border-neutral-800"
|
||||
>
|
||||
<!-- image container -->
|
||||
<Avatar.Root class="mt-1 size-10 shrink-0">
|
||||
{#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">
|
||||
<p class="mb-0.5 font-medium">{review.name}</p>
|
||||
<!-- ratings -->
|
||||
<div class="flex flex-row items-center">
|
||||
{#if review.rating}
|
||||
<img src="/images/rating-{review.rating}.svg" class="mt-1 mr-4 w-24" alt="" />
|
||||
<!-- profile content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="mb-0.5 font-medium">{review.name}</p>
|
||||
<!-- ratings -->
|
||||
<div class="mb-2 flex items-center gap-0.5">
|
||||
{#each Array(5) as _, i}
|
||||
<StarIcon
|
||||
size={14}
|
||||
class={i < (review.rating || 0)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300 dark:text-neutral-600'}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mb-2 overflow-hidden" style:max-height={expandDescription[id] ? 'none' : '60px'}>
|
||||
<p class="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
|
||||
{review.description}
|
||||
</p>
|
||||
</div>
|
||||
{#if !expandDescription[id] && review.description.split(' ').length > 12}
|
||||
<button
|
||||
class="ui:text-primary text-sm font-normal underline"
|
||||
onclick={() => toggleDescription(id)}>See More</button
|
||||
>
|
||||
{/if}
|
||||
{#if expandDescription[id]}
|
||||
<button
|
||||
class="ui:text-primary text-sm font-normal underline"
|
||||
onclick={() => toggleDescription(id)}>See Less</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="read-more-content mb-2" style="max-height: {expandDescription[id] ? 'none' : '50px'}">
|
||||
<p class="my-2 text-sm leading-5 text-gray-600">
|
||||
{review.description}
|
||||
</p>
|
||||
</div>
|
||||
{#if !expandDescription[id] && review.description.split(' ').length > 9}
|
||||
<button class="ui:text-primary mt-2 font-normal underline" onclick={() => toggleDescription(id)}
|
||||
>See More</button
|
||||
>
|
||||
{/if}
|
||||
{#if expandDescription[id]}
|
||||
<button class="ui:text-primary mt-2 font-normal underline" onclick={() => toggleDescription(id)}
|
||||
>See Less</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if reviews.length > 4}
|
||||
<Button class="mt-2" variant="outline" onclick={() => ($reviewsModalStore.open = true)}>
|
||||
<Button class="mt-4" variant="outline" onclick={() => ($reviewsModalStore.open = true)}>
|
||||
{$t('course.navItem.landing_page.see_all')}
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -382,7 +470,7 @@
|
||||
<div class="flex w-4/6 flex-wrap">
|
||||
{#each reviews as review, id (review.id)}
|
||||
<!-- review -->
|
||||
<div class="item-start my-2 flex w-full flex-row">
|
||||
<div class="my-2 flex w-full flex-row items-start">
|
||||
<!-- image container -->
|
||||
<Avatar.Root class="mt-1 h-10 w-10">
|
||||
{#if review.avatar_url}
|
||||
@@ -397,17 +485,26 @@
|
||||
<div class="w-11/12 pl-2.5">
|
||||
<p class="mb-0.5 font-medium">{review.name}</p>
|
||||
<!-- ratings -->
|
||||
<div class="flex flex-row">
|
||||
<img src="/images/rating-full.svg" alt="" class="mr-2" />
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each Array(5) as _, i}
|
||||
<StarIcon
|
||||
size={14}
|
||||
class={i < (review.rating || 0)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300 dark:text-neutral-600'}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-600">
|
||||
{calDateDiff(review.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="read-more-content mb-2"
|
||||
style="max-height: {expandDescription[id] ? 'none' : '50px'}"
|
||||
class="mb-2 overflow-hidden transition-all"
|
||||
style:max-height={expandDescription[id] ? 'none' : '60px'}
|
||||
>
|
||||
<p class="my-2 text-sm leading-5 text-gray-600">
|
||||
<p class="my-2 text-sm leading-relaxed text-gray-600">
|
||||
{review.description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -423,35 +520,39 @@
|
||||
|
||||
<!-- Sections - Instructor -->
|
||||
<NavSection id="instructor">
|
||||
<h3 class="mt-0 mb-3 text-2xl">
|
||||
{$t('course.navItem.landing_page.instructor')}
|
||||
</h3>
|
||||
<div class="mb-4 flex items-center">
|
||||
<img
|
||||
alt="Author Avatar"
|
||||
class="mr-3 block h-20 w-20 rounded-full"
|
||||
src={get(instructor, 'imgUrl', $currentOrg.avatarUrl || '/logo-512.png')}
|
||||
/>
|
||||
<div>
|
||||
<p class="text-md font-light dark:text-white">
|
||||
{get(instructor, 'name', $currentOrg.name)}
|
||||
</p>
|
||||
<p class="text-xs font-light dark:text-white">
|
||||
{get(instructor, 'role', '')}
|
||||
</p>
|
||||
<p class="text-md flex items-center font-light dark:text-white">
|
||||
<PlayIcon size={16} class="filled" />
|
||||
<span class="ml-1"
|
||||
>{get(instructor, 'courseNo', '')}
|
||||
{$t('course.navItem.landing_page.courses')}</span
|
||||
>
|
||||
</p>
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 mb-4 text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.instructor')}
|
||||
</h3>
|
||||
<div class="mb-4 flex items-center gap-4">
|
||||
<img
|
||||
alt="Author Avatar"
|
||||
class="block h-20 w-20 rounded-full object-cover ring-2 ring-gray-200 dark:ring-neutral-700"
|
||||
src={get(instructor, 'imgUrl', $currentOrg.avatarUrl || '/logo-512.png')}
|
||||
/>
|
||||
<div>
|
||||
<p class="text-base font-medium dark:text-white">
|
||||
{get(instructor, 'name', $currentOrg.name)}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{get(instructor, 'role', '')}
|
||||
</p>
|
||||
{#if get(instructor, 'courseNo', '')}
|
||||
<p class="mt-1 flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-300">
|
||||
<PlayIcon size={14} class="filled" />
|
||||
<span>
|
||||
{get(instructor, 'courseNo', '')}
|
||||
{$t('course.navItem.landing_page.courses')}
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm font-light dark:text-white">
|
||||
{get(instructor, 'description', '')}
|
||||
</p>
|
||||
<p class="text-sm leading-relaxed font-light dark:text-white">
|
||||
{get(instructor, 'description', '')}
|
||||
</p>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
</div>
|
||||
|
||||
@@ -464,45 +565,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TODO: CONVERT TO TAILWIND -->
|
||||
<style>
|
||||
.banner {
|
||||
background-color: #040f2d;
|
||||
min-height: 472px;
|
||||
}
|
||||
|
||||
.author {
|
||||
color: #7888b7;
|
||||
}
|
||||
|
||||
.banner-image {
|
||||
max-width: 559px;
|
||||
}
|
||||
|
||||
:global(.certificate-img) {
|
||||
width: unset !important;
|
||||
}
|
||||
|
||||
.course-content {
|
||||
max-width: 608px;
|
||||
min-width: 60%;
|
||||
}
|
||||
|
||||
:global(.list ul li) {
|
||||
margin-left: 1rem;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.read-more-content {
|
||||
max-height: 100px; /* Adjust this value to set the maximum height for the truncated content */
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1023px) {
|
||||
.course-content {
|
||||
min-width: 80%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,3 +30,6 @@ export { default as UpgradeModal } from './upgrade-modal.svelte';
|
||||
export { default as VisitOrgSiteButton } from './visit-org-site-btn.svelte';
|
||||
export { default as Vote } from './vote.svelte';
|
||||
export { default as PageRestricted } from './page-restricted.svelte';
|
||||
export { default as SortSelect } from './sort-select.svelte';
|
||||
export type { SortOption } from './sort-select.svelte';
|
||||
export { default as SortPopover } from './sort-popover.svelte';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type VideoType = 'youtube' | 'generic' | 'upload' | 'muse';
|
||||
export type VideoType = 'youtube' | 'generic' | 'upload' | 'muse' | 'google_drive';
|
||||
|
||||
export interface VideoSource {
|
||||
type: VideoType;
|
||||
@@ -6,6 +6,7 @@ export interface VideoSource {
|
||||
metadata?: {
|
||||
svid?: string; // muse.ai video ID
|
||||
thumbnailUrl?: string; // upload thumbnail / poster
|
||||
title?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CourseIcon,
|
||||
ExerciseIcon,
|
||||
ExploreIcon,
|
||||
GoalIcon,
|
||||
HomeIcon,
|
||||
SettingsIcon
|
||||
} from '@cio/ui/custom/moving-icons';
|
||||
@@ -56,6 +57,12 @@ const baseNavConfig: NavItemConfig[] = [
|
||||
icon: CourseIcon,
|
||||
matchPattern: '^/lms/mylearning(/.*)?$'
|
||||
},
|
||||
{
|
||||
titleKey: 'lms_navigation.programs',
|
||||
path: '/programs',
|
||||
icon: GoalIcon,
|
||||
matchPattern: '^/lms/programs(/.*)?$'
|
||||
},
|
||||
{
|
||||
titleKey: 'lms_navigation.exercise',
|
||||
path: '/exercises',
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import {
|
||||
ApiIcon,
|
||||
AttachmentIcon,
|
||||
CommunityIcon,
|
||||
CourseIcon,
|
||||
DashboardIcon,
|
||||
GoalIcon,
|
||||
PeopleIcon,
|
||||
SettingsIcon,
|
||||
SetupIcon
|
||||
SetupIcon,
|
||||
TagIcon,
|
||||
ZapIcon
|
||||
} from '@cio/ui/custom/moving-icons';
|
||||
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import type { Component } from 'svelte';
|
||||
import BracesIcon from '@lucide/svelte/icons/braces';
|
||||
import TagIcon from '@lucide/svelte/icons/tag';
|
||||
import BotIcon from '@lucide/svelte/icons/bot';
|
||||
import { isActive } from '$lib/utils/functions/app';
|
||||
|
||||
export interface NavItem {
|
||||
@@ -42,6 +45,12 @@ export interface NavItemConfig {
|
||||
supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug])
|
||||
matchPattern?: string | ((orgSlug: string) => string); // Regex pattern for route matching
|
||||
isPaid?: boolean; // Show upgrade indicator for free plan users
|
||||
group?: string | null; // Group label key for sidebar grouping
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
labelKey: string | null;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export interface NestedRouteConfig {
|
||||
@@ -52,18 +61,43 @@ export interface NestedRouteConfig {
|
||||
// Base navigation configuration structure
|
||||
const baseNavConfig: NavItemConfig[] = [
|
||||
{
|
||||
group: 'home',
|
||||
titleKey: 'org_navigation.dashboard',
|
||||
path: '',
|
||||
icon: DashboardIcon,
|
||||
matchPattern: '^/org/[^/]+/?$' // Exact match only
|
||||
},
|
||||
{
|
||||
group: 'home',
|
||||
titleKey: 'org_navigation.setup',
|
||||
path: '/setup',
|
||||
icon: SetupIcon,
|
||||
requiresAdmin: true,
|
||||
matchPattern: '^/org/[^/]+/setup(/.*)?$' // Matches nested routes
|
||||
},
|
||||
{
|
||||
group: 'content',
|
||||
titleKey: 'org_navigation.courses',
|
||||
path: '/courses',
|
||||
icon: CourseIcon,
|
||||
matchPattern: '^/org/[^/]+/courses(/.*)?$' // Matches nested routes
|
||||
},
|
||||
{
|
||||
group: 'content',
|
||||
titleKey: 'org_navigation.programs',
|
||||
path: '/programs',
|
||||
icon: GoalIcon,
|
||||
matchPattern: '^/org/[^/]+/programs(/.*)?$'
|
||||
},
|
||||
{
|
||||
group: 'content',
|
||||
titleKey: 'org_navigation.media',
|
||||
path: '/media',
|
||||
icon: AttachmentIcon,
|
||||
matchPattern: '^/org/[^/]+/media(/.*)?$'
|
||||
},
|
||||
{
|
||||
group: 'content',
|
||||
titleKey: 'org_navigation.tags',
|
||||
path: '/tags',
|
||||
icon: TagIcon,
|
||||
@@ -71,6 +105,7 @@ const baseNavConfig: NavItemConfig[] = [
|
||||
matchPattern: '^/org/[^/]+/tags(/.*)?$'
|
||||
},
|
||||
{
|
||||
group: 'people',
|
||||
titleKey: 'org_navigation.community',
|
||||
path: '/community',
|
||||
icon: CommunityIcon,
|
||||
@@ -84,60 +119,38 @@ const baseNavConfig: NavItemConfig[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'people',
|
||||
titleKey: 'org_navigation.audience',
|
||||
path: '/audience',
|
||||
icon: PeopleIcon,
|
||||
matchPattern: '^/org/[^/]+/audience(/.*)?$' // Matches nested routes
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.media',
|
||||
path: '/media',
|
||||
icon: AttachmentIcon,
|
||||
matchPattern: '^/org/[^/]+/media(/.*)?$'
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.setup',
|
||||
path: '/setup',
|
||||
icon: SetupIcon,
|
||||
requiresAdmin: true,
|
||||
matchPattern: '^/org/[^/]+/setup(/.*)?$' // Matches nested routes
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.automation',
|
||||
path: '/automation',
|
||||
icon: BracesIcon,
|
||||
group: 'automation',
|
||||
titleKey: 'automation.tabs.mcp',
|
||||
path: '/mcp',
|
||||
icon: BotIcon,
|
||||
requiresAdmin: true,
|
||||
disableWhenNotAdmin: true,
|
||||
useHashUrl: true,
|
||||
matchPattern: '^/org/[^/]+/automation(/.*)?$',
|
||||
items: [
|
||||
{
|
||||
titleKey: 'automation.tabs.mcp',
|
||||
path: '/automation/mcp'
|
||||
},
|
||||
{
|
||||
titleKey: 'automation.tabs.zapier',
|
||||
path: '/automation/zapier'
|
||||
},
|
||||
{
|
||||
titleKey: 'automation.tabs.api',
|
||||
path: '/automation/api'
|
||||
}
|
||||
],
|
||||
nestedRoutes: [
|
||||
{
|
||||
path: 'mcp',
|
||||
titleKey: 'automation.tabs.mcp'
|
||||
},
|
||||
{
|
||||
path: 'zapier',
|
||||
titleKey: 'automation.tabs.zapier'
|
||||
},
|
||||
{
|
||||
path: 'api',
|
||||
titleKey: 'automation.tabs.api'
|
||||
}
|
||||
]
|
||||
matchPattern: '^/org/[^/]+/mcp(/.*)?$'
|
||||
},
|
||||
{
|
||||
group: 'automation',
|
||||
titleKey: 'automation.tabs.api',
|
||||
path: '/api',
|
||||
icon: ApiIcon,
|
||||
requiresAdmin: true,
|
||||
disableWhenNotAdmin: true,
|
||||
matchPattern: '^/org/[^/]+/api(/.*)?$'
|
||||
},
|
||||
{
|
||||
group: 'automation',
|
||||
titleKey: 'automation.tabs.zapier',
|
||||
path: '/zapier',
|
||||
icon: ZapIcon,
|
||||
requiresAdmin: true,
|
||||
disableWhenNotAdmin: true,
|
||||
matchPattern: '^/org/[^/]+/zapier(/.*)?$'
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.settings',
|
||||
@@ -261,3 +274,79 @@ export function getOrgNavigationItems(
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
const GROUP_ORDER: Array<{ key: string | null; labelKey: string | null }> = [
|
||||
{ key: 'home', labelKey: 'org_navigation.home' },
|
||||
{ key: 'content', labelKey: 'org_navigation.content' },
|
||||
{ key: 'people', labelKey: 'org_navigation.people' },
|
||||
{ key: 'automation', labelKey: 'org_navigation.automation' },
|
||||
{ key: null, labelKey: null }
|
||||
];
|
||||
|
||||
/**
|
||||
* Get navigation items grouped for the sidebar
|
||||
*/
|
||||
export function getOrgNavigationGroups(
|
||||
currentOrgPath: string,
|
||||
currentOrg: AccountOrg,
|
||||
isOrgAdmin: boolean | null,
|
||||
t: (key: string) => string,
|
||||
pagePathname: string
|
||||
): NavGroup[] {
|
||||
const groupedMap = new Map<string | null, NavItem[]>();
|
||||
|
||||
for (const groupDef of GROUP_ORDER) {
|
||||
groupedMap.set(groupDef.key, []);
|
||||
}
|
||||
|
||||
for (const config of baseNavConfig) {
|
||||
if (config.requiresAdmin && !isOrgAdmin && !config.disableWhenNotAdmin) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = config.path === '' ? currentOrgPath : `${currentOrgPath}${config.path}`;
|
||||
const fullPath = config.path === '' ? `/org/${currentOrg.siteName}` : `/org/${currentOrg.siteName}${config.path}`;
|
||||
const matchPattern =
|
||||
typeof config.matchPattern === 'function' ? config.matchPattern(currentOrg.siteName!) : config.matchPattern;
|
||||
|
||||
const item: NavItem = {
|
||||
title: t(config.titleKey),
|
||||
url: config.useHashUrl ? '#' : url,
|
||||
path: config.path,
|
||||
icon: config.icon,
|
||||
isActive: isActive(pagePathname, fullPath, matchPattern),
|
||||
isExpanded: config.items ? isActive(pagePathname, fullPath, matchPattern) : undefined,
|
||||
disabled: Boolean(config.disableWhenNotAdmin && !isOrgAdmin),
|
||||
useHashUrl: config.useHashUrl,
|
||||
nestedRoutes: config.nestedRoutes,
|
||||
supportsDynamicSegment: config.supportsDynamicSegment,
|
||||
isPaid: config.isPaid
|
||||
};
|
||||
|
||||
if (config.items) {
|
||||
item.items = config.items.map((subConfig) => {
|
||||
const subMatchPattern =
|
||||
typeof subConfig.matchPattern === 'function'
|
||||
? subConfig.matchPattern(currentOrg.siteName!)
|
||||
: subConfig.matchPattern;
|
||||
const subUrl = `${currentOrgPath}${subConfig.path}`;
|
||||
return {
|
||||
title: t(subConfig.titleKey),
|
||||
isActive: isActive(pagePathname, subUrl, subMatchPattern, true),
|
||||
url: subUrl,
|
||||
path: subConfig.path,
|
||||
isPaid: subConfig.isPaid
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const groupKey = config.group !== undefined ? config.group : null;
|
||||
const bucket = groupedMap.get(groupKey) ?? groupedMap.get(null)!;
|
||||
bucket.push(item);
|
||||
}
|
||||
|
||||
return GROUP_ORDER.filter(({ key }) => (groupedMap.get(key) ?? []).length > 0).map(({ key, labelKey }) => ({
|
||||
labelKey,
|
||||
items: groupedMap.get(key) ?? []
|
||||
}));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user