feat: redo settings and all org pages. (#601)

* feat: new components, deploy storybook

* refactor: simplify component field definitions and enhance button loading state

- Removed unnecessary type imports and simplified the FIELDS array definitions across multiple components in Storybook.
- Updated button component to use a loading state instead of isLoading, improving clarity and consistency in props.
- Adjusted button stories to reflect the new loading prop and enhanced the button's loading visual feedback.
- Minor CSS adjustments for better layout and styling consistency.

* chore: add field, card, password components

* feat: improve page component & update AuthUI path

* chore: update bits-ui to version 2.14.4 and all org related UI's

- Updated bits-ui dependency in pnpm-lock.yaml and package.json to version 2.14.4.
- Refactored imports for RoleBasedSecurity and UpgradeBanner components to use the new ui feature directory.
- Removed unused Course components and related files to clean up the codebase.
- Enhanced various components to improve consistency and maintainability.

* chore: move all setting buttons to the top and update auth ui styles

* chore: remove svelte-loading-spinners and replace with @cio/ui/base/spinner

- Removed all instances of svelte-loading-spinners from the project.
- Updated components to use the new Spinner from @cio/ui/base/spinner.
- Cleaned up pnpm-lock.yaml and package.json to reflect the removal of svelte-loading-spinners.
- Added new CSS animations for rocket launch effects in app.css.
- Enhanced email change flow with updated verification messages in translations.

* chore: remove supabase file upload from dashboard and move to r2

* chore: update organization API and UI components

- Added new API methods for organization plan operations and team management.
- Introduced middleware for API key authentication and organization admin checks.
- Refactored organization-related components to utilize the new API structure.
- Updated various UI components to improve consistency and maintainability.
- Enhanced error handling and validation for organization-related actions.
- Removed deprecated services and streamlined the codebase.
This commit is contained in:
Rotimi Best
2025-12-02 09:54:38 +01:00
committed by GitHub
parent 531516a8f7
commit c7854efae3
350 changed files with 13928 additions and 3895 deletions
@@ -35,7 +35,7 @@
<Sidebar.Inset>
<AppHeader />
<div class="container mx-auto flex max-w-6xl flex-1 flex-col gap-4 p-4 pt-0">
<div class="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-4 overflow-y-auto px-4">
{#if data.orgName === '*'}
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<Skeleton class="aspect-video rounded-xl" />
+127 -152
View File
@@ -4,14 +4,10 @@
import { Skeleton } from '@cio/ui/base/skeleton';
import UserIcon from '@lucide/svelte/icons/user';
import UsersIcon from '@lucide/svelte/icons/users';
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import DollarSignIcon from '@lucide/svelte/icons/dollar-sign';
import { profile } from '$lib/utils/store/user';
import PlusIcon from '@lucide/svelte/icons/plus';
import { isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { isMobile } from '$lib/utils/store/useMobile';
import { calDateDiff } from '$lib/utils/functions/date';
import { getGreeting } from '$lib/utils/functions/date';
import { snackbar } from '$lib/components/Snackbar/store';
@@ -19,22 +15,19 @@
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import type { OrganisationAnalytics } from '$lib/utils/types/analytics';
import { CreateCourseButton } from '$lib/features/course/components';
import Avatar from '$lib/components/Avatar/index.svelte';
import { ActivityCard } from '$lib/components/Analytics';
import Progress from '$lib/components/Progress/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Grid } from '$lib/components/Org/Settings/Layout';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { Button } from '@cio/ui/base/button';
import { WelcomeModal } from '$lib/features/onboarding/components';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
import * as Page from '@cio/ui/base/page';
import { Empty } from '@cio/ui/custom/empty';
let dashAnalytics: OrganisationAnalytics | undefined = $state();
let hasFetched = $state(false);
function createCourse() {
goto(`${$currentOrgPath}/courses?create=true`);
}
async function fetchDashAnalytics(orgId: string) {
if (!orgId || hasFetched) return;
@@ -98,153 +91,135 @@
<WelcomeModal />
<div class="w-full">
<div class="mb-5 flex items-center justify-between">
<h1 class="m-0 text-xl font-medium dark:text-white">
{$t(getGreeting())}
{$profile.fullname}!
</h1>
<div class="flex items-center">
<PrimaryButton
variant={VARIANTS.OUTLINED}
onClick={createCourse}
isDisabled={!$isOrgAdmin}
className="min-h-[36px]"
>
{#if $isMobile}
<PlusIcon size={16} />
{:else}
{$t('dashboard.create_course')}
{/if}
</PrimaryButton>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t(getGreeting())}
{$profile.fullname}!
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<CreateCourseButton variant="outline" isResponsive />
<VisitOrgSiteButton />
</div>
</div>
<div class="mb-10 flex flex-wrap items-start">
<Grid class="px-0">
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each cards as card}
{#if !dashAnalytics}
<Skeleton style="width: 100%; min-width: 300px; height: 10rem;" class="rounded-md" />
{:else}
<ActivityCard activity={card} />
{/if}
{/each}
</div>
</Grid>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white">
<h3 class="mt-0 text-2xl">
{$t('dashboard.top_courses')}
</h3>
<div class="h-full space-y-6">
{#if !dashAnalytics}
{#each Array(5) as _}
<Skeleton class="h-10 w-full rounded-md" />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="mb-10 flex flex-wrap items-start">
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each cards as card}
{#if !dashAnalytics}
<Skeleton style="width: 100%; height: 10rem;" class="rounded-md" />
{:else}
<ActivityCard activity={card} />
{/if}
{/each}
{:else}
{#each dashAnalytics.topCourses as course}
<div class="flex items-center gap-2">
<div class="w-4/6 space-y-1">
<a href={`/courses/${course.id}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none">
{course.title}
</p>
</a>
<p class="text-sm">
{course.enrollments}
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
</p>
</div>
<div class="ml-auto w-2/6">
<Progress value={course.completion} />
<div class="text-sm font-medium">
{course.completion}%
{$t('dashboard.completion')}
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div
class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white"
>
<h3 class="mt-0 text-lg">
{$t('dashboard.top_courses')}
</h3>
<div class="h-full space-y-6">
{#if !dashAnalytics}
{#each Array(5) as _}
<Skeleton class="h-10 w-full rounded-md" />
{/each}
{:else}
{#each dashAnalytics.topCourses as course}
<div class="flex items-center gap-2">
<div class="w-4/6 space-y-1">
<a href={`/courses/${course.id}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none">
{course.title}
</p>
</a>
<p class="text-sm">
{course.enrollments}
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
</p>
</div>
<div class="ml-auto w-2/6">
<Progress value={course.completion} />
<div class="text-sm font-medium">
{course.completion}%
{$t('dashboard.completion')}
</div>
</div>
</div>
</div>
</div>
{:else}
<div class="flex flex-col h-full items-center justify-center p-3">
<div class="bg-primary-200 w-fit rounded-full p-4 text-black">
<LibraryBigIcon size={30} />
</div>
<div class="my-4 text-center">
<p class=" text-xl font-semibold">
{$t('dashboard.create_first_course')}
</p>
<p class="text-sm text-gray-500 dark:text-gray-300">
{$t('dashboard.create_first_course_description')}
</p>
</div>
<PrimaryButton variant={VARIANTS.OUTLINED} onClick={createCourse} label={$t('dashboard.create_course')} />
</div>
{/each}
{/if}
</div>
</div>
{:else}
<Empty
title={$t('dashboard.create_first_course')}
description={$t('dashboard.create_first_course_description')}
icon={BookIcon}
class="h-full"
>
<CreateCourseButton variant="outline" />
</Empty>
{/each}
{/if}
</div>
</div>
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white">
<h3 class="mt-0 text-2xl">
{$t('dashboard.recent_enrollments')}
</h3>
<div
class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white"
>
<h3 class="mt-0 text-lg">
{$t('dashboard.recent_enrollments')}
</h3>
<div class="h-full space-y-6">
{#if !dashAnalytics}
{#each Array(5) as _}
<Skeleton class="h-10 w-full rounded-md" />
{/each}
{:else}
{#each dashAnalytics.enrollments as enrollment}
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Avatar src={enrollment.avatarUrl} name={enrollment.name} width="w-6" height="h-6" />
<div class="h-full space-y-6">
{#if !dashAnalytics}
{#each Array(5) as _}
<Skeleton class="h-10 w-full rounded-md" />
{/each}
{:else}
{#each dashAnalytics.enrollments as enrollment}
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Avatar src={enrollment.avatarUrl} name={enrollment.name} width="w-6" height="h-6" />
<div class="min-h-[45px] space-y-1">
<p class="text-sm font-medium capitalize leading-none">{enrollment.name}</p>
<p class="text-muted-foreground text-sm">
<span class="italic">
{calDateDiff(enrollment.date)}
</span>
</p>
<div class="min-h-[45px] space-y-1">
<p class="text-sm font-medium capitalize leading-none">{enrollment.name}</p>
<p class="text-muted-foreground text-sm">
<span class="italic">
{calDateDiff(enrollment.date)}
</span>
</p>
</div>
</div>
<div class="w-2/4">
<a href={`/courses/${enrollment.courseId}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none">
{enrollment.course}
</p>
</a>
</div>
</div>
</div>
<div class="w-2/4">
<a href={`/courses/${enrollment.courseId}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none">
{enrollment.course}
</p>
</a>
</div>
</div>
{:else}
<div class="flex flex-col h-full items-center justify-center p-3">
<div class="bg-primary-200 w-fit rounded-full p-4 text-black">
<UserIcon size={16} />
</div>
<div class="my-4 text-center">
<p class=" text-xl font-semibold">
{$t('dashboard.publish_first_course')}
</p>
<p class="text-sm text-gray-500 dark:text-gray-300">
{$t('dashboard.publish_first_course_description')}
</p>
</div>
<PrimaryButton
variant={VARIANTS.OUTLINED}
onClick={() => goto(`${$currentOrgPath}/courses`)}
label={$t('dashboard.publish_course')}
/>
</div>
{/each}
{/if}
{:else}
<Empty
title={$t('dashboard.publish_first_course')}
description={$t('dashboard.publish_first_course_description')}
icon={UserIcon}
class="h-full"
>
<Button variant="outline" onclick={() => goto(`${$currentOrgPath}/courses`)}>
{$t('dashboard.publish_course')}
</Button>
</Empty>
{/each}
{/if}
</div>
</div>
</div>
</div>
</div>
</div>
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,10 +1,11 @@
<script>
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Audience from '$lib/components/Org/Audience/index.svelte';
import { AudiencePage } from '$lib/features/audience/pages';
import { t } from '$lib/utils/functions/translations';
import { orgAudience, currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org';
import { currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org';
import { PLAN } from '@cio/utils/plans';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import * as Page from '@cio/ui/base/page';
import { orgApi } from '$lib/features/org/api/org.svelte';
let isLoading = $state(false);
@@ -19,23 +20,25 @@
<title>Audience - ClassroomIO</title>
</svelte:head>
<section class="mx-auto w-full max-w-6xl">
<div class="px-5 py-10">
<div class="mb-10 flex items-center justify-between">
<div class="flex items-center">
<h1 class="m-0 text-2xl md:text-3xl dark:text-white">{$t('audience.title')}</h1>
<Page.Root class="mx-auto w-full max-w-6xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t('audience.title')}
{#if $currentOrgPlan?.planName !== PLAN.ENTERPRISE}
<span class="ml-2">
({$orgAudience.length} / {$currentOrgMaxAudience})
<span class="ml-2 text-sm">
({orgApi.audience.length} / {$currentOrgMaxAudience})
</span>
{/if}
</div>
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<PrimaryButton label={$t('audience.export')} onClick={exportAudience} isDisabled={isLoading} {isLoading} />
</div>
{#if $orgAudience.length >= $currentOrgMaxAudience}
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
{/if}
<Audience />
</div>
</section>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<AudiencePage />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,208 +1,18 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { Badge } from '@cio/ui/base/badge';
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
import Progress from '$lib/components/Progress/index.svelte';
import { Grid } from '$lib/components/Org/Settings/Layout';
import { ActivityCard, HeroProfileCard, LoadingPage } from '$lib/components/Analytics';
import { t } from '$lib/utils/functions/translations';
import { currentOrgPath } from '$lib/utils/store/org';
import { snackbar } from '$lib/components/Snackbar/store';
import { getAccessToken } from '$lib/utils/functions/supabase';
import type { UserAnalytics } from '$lib/utils/types/analytics';
import { UserAnalyticsPage } from '$lib/features/audience/pages';
import * as Page from '@cio/ui/base/page';
let { data } = $props();
let userAnalytics: UserAnalytics | undefined = $state();
let courseFilter = $state('all');
function toggleCourseFilter(filter: 'completed' | 'incomplete') {
if (courseFilter === filter) {
courseFilter = 'all';
} else {
courseFilter = filter;
}
}
async function fetchUserAnalytics() {
const accessToken = await getAccessToken();
const response = await fetch('/api/analytics/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify(data)
});
if (!response.ok) {
console.error(response);
snackbar.error('Failed to fetch analytics data');
}
userAnalytics = (await response.json()) as UserAnalytics;
}
onMount(() => {
fetchUserAnalytics();
});
let userMetrics = $derived([
{
icon: BookOpenIcon,
title: $t('analytics.enrolled_courses'),
description: $t('analytics.enrolled_courses_description'),
percentage: userAnalytics?.courses?.length || 0,
hidePercentage: true
},
{
icon: ChartLineIcon,
title: $t('analytics.overall_course_progress'),
description: $t('analytics.overall_course_progress_description'),
percentage: userAnalytics?.overallCourseProgress || 0
},
{
icon: UnfoldVerticalIcon,
title: $t('analytics.total_average_grade'),
description: $t('analytics.total_average_grade_description'),
percentage: userAnalytics?.overallAverageGrade || 0
}
]);
let completedCourses = $derived(
userAnalytics?.courses?.filter((course) => course.lessons_count === course.lessons_completed)?.length
);
let incompleteCourses = $derived(
userAnalytics?.courses?.filter((course) => course.lessons_count !== course.lessons_completed)?.length
);
let filteredCourses = $derived(
userAnalytics?.courses?.filter((course) => {
if (courseFilter === 'all') {
return true;
}
return (course.lessons_count === course.lessons_completed) === (courseFilter === 'completed');
})
);
</script>
{#if userAnalytics}
<section class="w-full md:mx-auto md:max-w-5xl">
<div class="p-5">
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/audience`}>
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
</div>
<svelte:head>
<title>User Analytics - ClassroomIO</title>
</svelte:head>
<div class="px-5 py-1">
<HeroProfileCard user={userAnalytics.user} />
<Grid 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}
<ActivityCard {activity} />
{/each}
</div>
</Grid>
<div class="mt-5 rounded-md border p-3 md:p-5 dark:border-neutral-600">
<h3 class="text-2xl">
{$t('analytics.courses')}
</h3>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
{$t('analytics.progress')}
</p>
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
{$t('analytics.progress_description', {
value: completedCourses,
total: userAnalytics.courses.length
})}
</p>
</div>
<Progress value={userAnalytics.overallCourseProgress} />
<div class="flex items-center justify-between">
<Badge
type={courseFilter === 'incomplete' ? 'secondary' : 'outline'}
class="text-yellow-700 dark:text-yellow-500"
onclick={() => toggleCourseFilter('incomplete')}
>
{incompleteCourses}
{$t('analytics.incomplete')}
</Badge>
<Badge
type={courseFilter === 'completed' ? 'secondary' : 'outline'}
class="text-green-700 dark:text-green-500"
onclick={() => toggleCourseFilter('completed')}
>
{completedCourses}
{$t('analytics.complete')}
</Badge>
</div>
</div>
{#each filteredCourses as course, index}
{#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}
</p>
</div>
</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>
</div>
{/key}
{/each}
</div>
</div>
</section>
{:else}
<LoadingPage />
{/if}
<Page.Root class="w-full md:max-w-5xl lg:mx-auto">
<Page.Body>
{#snippet child()}
<UserAnalyticsPage {data} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,32 +1,26 @@
<script>
import { goto } from '$app/navigation';
import Community from '$lib/components/Org/Community/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { currentOrgPath } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
import PlusIcon from '@lucide/svelte/icons/plus';
import { CommunityPage } from '$lib/features/community/pages';
import { AskCommunityButton } from '$lib/features/community/components';
import { t } from '$lib/utils/functions/translations';
function askCommunity() {
goto(`${$currentOrgPath}/community/ask`);
}
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Community - ClassroomIO</title>
</svelte:head>
<section class="w-full">
<div class="mb-10 flex w-full items-center justify-between">
<h1 class="text-2xl md:text-3xl dark:text-white">{$t('community.title')}</h1>
{#if $isMobile}
<PrimaryButton onClick={askCommunity}>
<PlusIcon size={16} />
</PrimaryButton>
{:else}
<PrimaryButton label={$t('community.ask_button')} variant={VARIANTS.CONTAINED_DARK} onClick={askCommunity} />
{/if}
</div>
<Community />
</section>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('community.title')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<AskCommunityButton />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CommunityPage />
{/snippet}
</Page.Body>
</Page.Root>
@@ -8,7 +8,7 @@
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/components/Courses/store';
import { courses } from '$lib/features/course/utils/store';
import { calDateDiff } from '$lib/utils/functions/date';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
@@ -25,8 +25,9 @@
import TextField from '$lib/components/Form/TextField.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import DeleteModal from '$lib/components/Org/Community/DeleteModal.svelte';
import { CommunityDeleteModal } from '$lib/features/community/components';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import * as Page from '@cio/ui/base/page';
interface Comment {
id: string;
@@ -377,18 +378,7 @@
<title>{question?.title || $t('community.ask.question')}</title>
</svelte:head>
<DeleteModal
bind:open={deleteQuestion.shouldDelete}
isDeleting={deleteQuestion.isDeleting}
onCancel={() => {
deleteQuestion.shouldDelete = false;
deleteQuestion.questionId = '';
}}
onDelete={() => handleDelete(true)}
isQuestion={true}
/>
<DeleteModal
<CommunityDeleteModal
bind:open={deleteComment.shouldDelete}
isDeleting={deleteComment.isDeleting}
onCancel={() => {
@@ -397,44 +387,50 @@
}}
onDelete={() => handleDelete(false)}
/>
<section class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
{#if !question}
<div class="mb-3 px-5 py-10">
<Skeleton class="h-4 w-[25%]" />
<Skeleton class="mb-2 h-4 w-full" />
<Skeleton class="h-80 w-full" />
</div>
{:else}
{#if !question}
<div class="mb-3 px-5 py-10">
<Skeleton class="h-4 w-[25%]" />
<Skeleton class="mb-2 h-4 w-full" />
<Skeleton class="h-80 w-full" />
</div>
{:else}
<Page.Root class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
<div class="px-5 py-10">
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/community`}>
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
<div class="my-5 flex items-center justify-between">
</div>
<Page.Header>
<Page.HeaderContent>
{#if isEditMode}
<TextField bind:value={editContent.title} className="w-full mr-2" errorMessage={errors.title} />
<Select.Root type="single" bind:value={editContent.courseId}>
<Select.Trigger class="h-full w-[25%]">
<p>
{editContent.courseId
? fetchedCourses.find((course) => course.id === editContent.courseId)?.title
: $t('community.ask.select_course')}
</p>
</Select.Trigger>
<Select.Content>
{#each fetchedCourses as course}
<Select.Item value={course.id || ''}>{course.title}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<div class="flex w-full items-center gap-2">
<TextField bind:value={editContent.title} className="flex-1" errorMessage={errors.title} />
<Select.Root type="single" bind:value={editContent.courseId}>
<Select.Trigger class="h-full w-[25%]">
<p>
{editContent.courseId
? fetchedCourses.find((course) => course.id === editContent.courseId)?.title
: $t('community.ask.select_course')}
</p>
</Select.Trigger>
<Select.Content>
{#each fetchedCourses as course}
<Select.Item value={course.id || ''}>{course.title}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{:else}
<div class="flex items-center">
<div class="flex items-center gap-2">
<Vote value={question.votes} upVote={() => upvoteQuestion('question')} disabled={voted.question} />
<h2 class="text-3xl">{question.title}</h2>
<Page.Title>{question.title}</Page.Title>
</div>
{/if}
{#if question.author.id === $profile.id}
</Page.HeaderContent>
{#if question.author.id === $profile.id}
<Page.Action>
<PrimaryButton
label={isEditMode ? $t('community.ask.save') : $t('community.ask.edit')}
variant={VARIANTS.OUTLINED}
@@ -450,101 +446,105 @@
disablePadding={true}
/>
{/if}
{/if}
</div>
<div class="border-gray my-1 rounded-lg border px-1">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black no-underline hover:underline">
<Avatar src={question.author.avatar} name={question.author.name} width="w-7" height="h-7" />
<p class="ml-2 text-sm dark:text-white">{question.author.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{question.createdAt}
</p>
</div>
{#if question.author.id === $profile.id || $isOrgAdmin}
<IconButton
value="delete-question"
onClick={() => {
if (!question) return;
</Page.Action>
{/if}
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="border-gray my-1 rounded-lg border px-1">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black no-underline hover:underline">
<Avatar src={question.author.avatar} name={question.author.name} width="w-7" height="h-7" />
<p class="ml-2 text-sm dark:text-white">{question.author.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{question.createdAt}
</p>
</div>
{#if question.author.id === $profile.id || $isOrgAdmin}
<IconButton
value="delete-question"
onClick={() => {
if (!question) return;
deleteQuestion.shouldDelete = true;
deleteQuestion.questionId = question.id;
}}
>
<TrashIcon size={16} />
</IconButton>
deleteQuestion.shouldDelete = true;
deleteQuestion.questionId = question.id;
}}
>
<TrashIcon size={16} />
</IconButton>
{/if}
</header>
{#if isEditMode && editorInstance}
<div class="my-2">
<TextEditor
placeholder="Give an answer"
content={editContent.body}
onChange={(content) => (editContent.body = content)}
/>
</div>
{:else}
<section class="prose prose-sm sm:prose p-2">
{@html question.body}
</section>
{/if}
</header>
{#if isEditMode && editorInstance}
<div class="my-2">
<TextEditor
placeholder="Give an answer"
content={editContent.body}
onChange={(content) => (editContent.body = content)}
</div>
<div class="my-8">
{pluralize($t('community.answers'), question.totalComments, true)}
</div>
{#each question.comments as comment}
<div class="my-5 flex items-start px-1">
<Vote
value={comment.votes}
upVote={() => upvoteQuestion('comment', comment.id)}
disabled={voted.comment[comment.id]}
/>
<div class="border-gray w-full rounded-lg border">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black">
<Avatar src={comment.avatar} name={comment.name} width="w-7" height="h-7" />
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{comment.createdAt}
</p>
</div>
{#if isValidAnswer}
<CircleCheckIcon size={16} />
{/if}
{#if comment.authorId === $profile.id || $isOrgAdmin}
<IconButton
value="delete-comment"
onClick={() => {
deleteComment.shouldDelete = true;
deleteComment.commentId = comment.id;
}}
>
<TrashIcon size={16} />
</IconButton>
{/if}
</header>
<article class="prose prose-sm sm:prose p-2">
{@html comment.comment}
</article>
</div>
</div>
{:else}
<section class="prose prose-sm p-2 sm:prose">
{@html question.body}
</section>
{/if}
</div>
{/each}
<div class="my-8">
{pluralize($t('community.answers'), question.totalComments, true)}
</div>
<hr />
{#each question.comments as comment}
<div class="my-5 flex items-start px-1">
<Vote
value={comment.votes}
upVote={() => upvoteQuestion('comment', comment.id)}
disabled={voted.comment[comment.id]}
/>
<div class="border-gray w-full rounded-lg border">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black">
<Avatar src={comment.avatar} name={comment.name} width="w-7" height="h-7" />
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{comment.createdAt}
</p>
</div>
<div class="mt-4">
{#if !editorInstance}
<TextEditor placeholder="Give an answer" content={comment} onChange={(content) => (comment = content)} />
{/if}
{#if isValidAnswer}
<CircleCheckIcon size={16} />
{/if}
{#if comment.authorId === $profile.id || $isOrgAdmin}
<IconButton
value="delete-comment"
onClick={() => {
deleteComment.shouldDelete = true;
deleteComment.commentId = comment.id;
}}
>
<TrashIcon size={16} />
</IconButton>
{/if}
</header>
<article class="prose prose-sm p-2 sm:prose">
{@html comment.comment}
</article>
<div class="mt-2 flex justify-end">
<PrimaryButton label={$t('community.ask.comment')} onClick={submitComment} />
</div>
</div>
{/each}
<hr />
<div class="mt-4">
{#if !editorInstance}
<TextEditor placeholder="Give an answer" content={comment} onChange={(content) => (comment = content)} />
{/if}
<div class="mt-2 flex justify-end">
<PrimaryButton label={$t('community.ask.comment')} onClick={submitComment} />
</div>
</div>
</div>
{/if}
</section>
{/snippet}
</Page.Body>
</Page.Root>
{/if}
@@ -1,12 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as Select from '@cio/ui/base/select';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import type { Course } from '$lib/utils/types';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/components/Courses/store';
import { courses } from '$lib/features/course/utils/store';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import { fetchCourses } from '$lib/utils/services/courses';
@@ -18,6 +17,7 @@
import TextField from '$lib/components/Form/TextField.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import * as Page from '@cio/ui/base/page';
let errors: {
title?: string;
@@ -93,52 +93,59 @@
<title>Ask the Community - ClassroomIO</title>
</svelte:head>
<section class="w-full md:mx-auto md:max-w-3xl">
<div class="p-5">
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/community`}>
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
<div class="flex items-center justify-between gap-12">
<h1 class="text-2xl md:text-3xl dark:text-white">{$t('community.ask.ask_the')}</h1>
<Page.Root>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<PrimaryButton label={$t('community.ask.publish')} variant={VARIANTS.CONTAINED_DARK} onClick={handleSave} />
</div>
</div>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="mb-3 flex justify-between gap-x-5">
<TextField
bind:value={fields.title}
placeholder={$t('community.ask.title')}
errorMessage={errors.title}
className="w-[75%]"
/>
<div class="w-[25%]">
<Select.Root type="single" bind:value={fields.courseId}>
<Select.Trigger class="h-full w-full">
<p>
{fields.courseId
? fetchedCourses.find((course) => course.id === fields.courseId)?.title
: $t('community.ask.select_course')}
</p>
</Select.Trigger>
<Select.Content>
{#each fetchedCourses as course}
{#if course.id}
<Select.Item value={course.id}>{course.title}</Select.Item>
{/if}
{/each}
</Select.Content>
</Select.Root>
{#if errors.courseId}
<p class="mt-1 text-sm text-red-500">
{errors.courseId}
</p>
{/if}
</div>
</div>
<div class="px-2">
<TextEditor
placeholder="Give an answer"
content={fields.body}
onChange={(content) => (fields.body = content)}
/>
<div class="mb-3 flex justify-between gap-x-5 p-2">
<TextField
bind:value={fields.title}
placeholder={$t('community.ask.title')}
errorMessage={errors.title}
className="w-[75%]"
/>
<div class="w-[25%]">
<Select.Root type="single" bind:value={fields.courseId}>
<Select.Trigger class="h-full w-full">
<p>
{fields.courseId
? fetchedCourses.find((course) => course.id === fields.courseId)?.title
: $t('community.ask.select_course')}
</p>
</Select.Trigger>
<Select.Content>
{#each fetchedCourses as course}
<Select.Item value={course.id}>{course.title}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if errors.courseId}
<p class="mt-1 text-sm text-red-500">
{errors.courseId}
</p>
{/if}
</div>
</div>
<div class="px-2">
<TextEditor placeholder="Give an answer" content={fields.body} onChange={(content) => (fields.body = content)} />
{#if errors.body}
<p class="mt-2 text-sm text-red-500">{errors.body}</p>
{/if}
</div>
</section>
{#if errors.body}
<p class="mt-2 text-sm text-red-500">{errors.body}</p>
{/if}
</div>
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,27 +1,15 @@
<script lang="ts">
import { Input } from '@cio/ui/base/input';
import * as Select from '@cio/ui/base/select';
import SearchIcon from '@lucide/svelte/icons/search';
import { profile } from '$lib/utils/store/user';
import { fetchCourses } from '$lib/utils/services/courses';
import Courses from '$lib/components/Courses/index.svelte';
import NewCourseModal from '$lib/components/Courses/components/NewCourseModal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { courses, courseMetaDeta } from '$lib/components/Courses/store';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import PlusIcon from '@lucide/svelte/icons/plus';
import { isMobile } from '$lib/utils/store/useMobile';
import { isOrgAdmin } from '$lib/utils/store/org';
import { CoursesPage } from '$lib/features/course/pages';
import { CreateCourseButton } from '$lib/features/course/components';
import { courses, courseMetaDeta } from '$lib/features/course/utils/store';
import { currentOrg } from '$lib/utils/store/org';
import type { Course } from '$lib/utils/types';
import { browser } from '$app/environment';
import { t } from '$lib/utils/functions/translations';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { IconButton } from '$lib/components/IconButton';
import GridIcon from '@lucide/svelte/icons/grid-2x2';
import ListIcon from '@lucide/svelte/icons/list';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import * as Page from '@cio/ui/base/page';
let { data } = $props();
@@ -30,16 +18,6 @@
let selectedId: string = $state('0');
let hasFetched = false;
const filterOptions = $derived([
{ value: '0', label: $t('courses.course_filter.date_created') },
{ value: '1', label: $t('courses.course_filter.published') },
{ value: '2', label: $t('courses.course_filter.lessons') }
]);
const selectedLabel = $derived(
filterOptions.find((opt) => opt.value === selectedId)?.label || filterOptions[0].label
);
const filteredCourses: Course[] = $derived(filterCourses(searchValue, selectedId, $courses));
async function getCourses(userId: string | undefined, orgId: string) {
@@ -89,15 +67,6 @@
return filteredCourses;
}
const setViewPreference = (preference: 'grid' | 'list') => {
$courseMetaDeta.view = preference;
localStorage.setItem('courseView', preference);
};
const openNewCourseModal = () => {
goto($currentOrgPath + '/courses?create=true');
};
onMount(() => {
const courseView = localStorage.getItem('courseView') as 'grid' | 'list' | null;
@@ -115,51 +84,18 @@
<title>Courses - ClassroomIO</title>
</svelte:head>
<section class="w-full">
<div class="mb-5 flex items-center justify-between">
<h1 class="text-2xl md:text-3xl dark:text-white">{$t('courses.heading')}</h1>
{#if $isMobile}
<PrimaryButton isDisabled={!$isOrgAdmin} onClick={openNewCourseModal}>
<PlusIcon size={16} />
</PrimaryButton>
{:else}
<PrimaryButton
label={$t('courses.heading_button')}
variant={VARIANTS.CONTAINED_DARK}
isDisabled={!$isOrgAdmin}
onClick={openNewCourseModal}
/>
{/if}
</div>
<div class="mb-5 flex flex-row-reverse">
<div class="filter-containter flex items-end justify-start gap-2">
<div class="relative">
<Input type="text" placeholder={$t('courses.search_placeholder')} bind:value={searchValue} class="pl-9" />
<SearchIcon class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2" />
</div>
<Select.Root type="single" bind:value={selectedId}>
<Select.Trigger class="min-w-[150px]">
<p>{selectedId ? selectedLabel : filterOptions[0].label}</p>
</Select.Trigger>
<Select.Content>
{#each filterOptions as option}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if $courseMetaDeta.view === 'list'}
<IconButton onClick={() => setViewPreference('grid')}>
<GridIcon size={16} />
</IconButton>
{:else}
<IconButton onClick={() => setViewPreference('list')}>
<ListIcon size={16} />
</IconButton>
{/if}
</div>
</div>
<NewCourseModal />
<Courses courses={filteredCourses} />
</section>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('courses.heading')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<CreateCourseButton isResponsive />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CoursesPage courses={filteredCourses} bind:searchValue bind:selectedId />
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,12 @@
<script lang="ts">
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
import * as Page from '@cio/ui/base/page';
</script>
<Page.Root class="w-full pb-10 md:max-w-3xl lg:mx-auto">
{@render children?.()}
</Page.Root>
@@ -1,21 +1,40 @@
<script>
import OrgSettings from '$lib/components/Org/Settings/index.svelte';
<script lang="ts">
import { ProfilePage } from '$lib/features/settings/pages';
import ViewSiteBtn from '$lib/components/Buttons/VisitOrgSite.svelte';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let profileComponent: ProfilePage | null = $state(null);
let isLoading = $state(false);
async function handleUpdate() {
isLoading = true;
try {
await profileComponent?.handleUpdate();
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Settings - ClassroomIO</title>
</svelte:head>
<section class="w-full md:max-w-6xl lg:mx-auto">
<div class="px-5 md:py-10">
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl dark:text-white">{$t('settings.heading')}</h1>
<ViewSiteBtn />
</div>
<OrgSettings />
</div>
</section>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.heading')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" loading={isLoading} onclick={handleUpdate}>
{$t('settings.profile.update_profile')}
</Button>
<ViewSiteBtn />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<ProfilePage bind:this={profileComponent} />
{/snippet}
</Page.Body>
@@ -0,0 +1,20 @@
<script>
import { BillingPage } from '$lib/features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Billing - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.billing.sub_title')}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<BillingPage />
{/snippet}
</Page.Body>
@@ -1,24 +1,40 @@
<script>
import CustomizeLMS from '$lib/components/Org/Settings/CustomizeLms.svelte';
<script lang="ts">
import { CustomizeLmsPage } from '$lib/features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import { currentOrgPath } from '$lib/utils/store/org';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let customizeLmsComponent: CustomizeLmsPage | null = $state(null);
let isSaving = $state(false);
async function handleSave() {
isSaving = true;
try {
await customizeLmsComponent?.handleSave();
} finally {
isSaving = false;
}
}
</script>
<svelte:head>
<title>Customize LMS - ClassroomIO</title>
</svelte:head>
<section class="mx-auto w-full md:max-w-4xl">
<div class="px-3 py-10 md:px-5">
<a class="text-md text-gray-500 dark:text-white" href={`${$currentOrgPath}/settings?tab=org`}
>{$t('upgrade.back')}</a
>
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl dark:text-white">
{$t('components.settings.customize_lms.title')}
</h1>
</div>
<CustomizeLMS />
</div>
</section>
<Page.Root class="w-full md:max-w-4xl lg:mx-auto">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('components.settings.customize_lms.title')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" loading={isSaving} disabled={isSaving} onclick={handleSave}>
{$t('components.settings.customize_lms.save')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CustomizeLmsPage bind:this={customizeLmsComponent} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,22 +1,20 @@
<script>
import Domains from '$lib/components/Org/Settings/Domains.svelte';
import { DomainsPage } from '$lib/features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import { currentOrgPath } from '$lib/utils/store/org';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Custom domain - ClassroomIO</title>
</svelte:head>
<section class="mx-auto w-full md:max-w-4xl">
<div class="px-3 py-10 md:px-5">
<a class="text-md text-gray-500 dark:text-white" href={`${$currentOrgPath}/settings?tab=org`}
>{$t('upgrade.back')}</a
>
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl dark:text-white">{$t('components.settings.domains.title')}</h1>
</div>
<Domains />
</div>
</section>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('components.settings.domains.title')}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<DomainsPage />
{/snippet}
</Page.Body>
@@ -0,0 +1,20 @@
<script>
import { IntegrationsPage } from '$lib/features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Integrations - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.integrations.heading')}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<IntegrationsPage />
{/snippet}
</Page.Body>
@@ -0,0 +1,38 @@
<script lang="ts">
import { LandingpagePage } from '$lib/features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let landingPageComponent: LandingpagePage | null = $state(null);
let isSaving = $state(false);
async function handleSave() {
isSaving = true;
try {
await landingPageComponent?.handleSave();
} finally {
isSaving = false;
}
}
</script>
<svelte:head>
<title>Landing Page Settings - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.landing_page.heading')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" loading={isSaving} disabled={isSaving} onclick={handleSave}>
{$t('settings.landing_page.save_changes')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<LandingpagePage bind:this={landingPageComponent} />
{/snippet}
</Page.Body>
@@ -0,0 +1,38 @@
<script lang="ts">
import { OrgPage } from '$lib/features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let orgComponent: OrgPage | null = $state(null);
let loading = $state(false);
async function handleUpdate() {
loading = true;
try {
await orgComponent?.handleUpdate();
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Organization Settings - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.organization.organization_profile.heading')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" {loading} disabled={loading} onclick={handleUpdate}>
{$t('settings.organization.organization_profile.update_organization')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<OrgPage bind:this={orgComponent} />
{/snippet}
</Page.Body>
@@ -1,24 +1,20 @@
<script>
import TeamMembers from '$lib/components/Org/Settings/TeamMembers.svelte';
import { currentOrgPath } from '$lib/utils/store/org';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { TeamsPage } from '$lib/features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Teams - ClassroomIO</title>
</svelte:head>
<section class="mx-auto w-full md:max-w-4xl">
<div class="px-3 py-10 md:px-5">
<a class="text-md text-gray-500 dark:text-white" href={`${$currentOrgPath}/settings?tab=org`}
>{$t('upgrade.back')}</a
>
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl dark:text-white">{$t('upgrade.team_members')}</h1>
</div>
<UpgradeBanner>{$t('upgrade.team')}</UpgradeBanner>
<TeamMembers />
</div>
</section>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('upgrade.team_members')}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<TeamsPage />
{/snippet}
</Page.Body>
@@ -1,26 +1,46 @@
<script lang="ts">
import Chip from '$lib/components/Chip/Text.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import { Button } from '@cio/ui/base/button';
import * as Item from '@cio/ui/base/item';
import * as Page from '@cio/ui/base/page';
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import UserPlusIcon from '@lucide/svelte/icons/user-plus';
import UsersIcon from '@lucide/svelte/icons/users';
import GlobeIcon from '@lucide/svelte/icons/globe';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import SetupProgress from '$lib/features/setup/components/setup-progress.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
import { snackbar } from '$lib/components/Snackbar/store.js';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { setupProgressApi } from '$lib/features/setup/api/setup-progress.svelte';
import { onMount } from 'svelte';
let { data } = $props();
const setupList = $derived(
(data.setup || []).map((item) => {
setupProgressApi.setupList.map((item) => {
if (item.id === 'profile') {
item.is_completed = !$profile.avatarUrl?.includes('avatars/avatar.png');
return {
...item,
is_completed: !$profile.avatarUrl?.includes('avatars/avatar.png')
};
}
return item;
})
);
const completed = $derived(setupList.filter((list) => list.is_completed).length);
const total = $derived(setupList.length);
// Ensure setup progress is fetched when page loads
onMount(() => {
if ($currentOrg.siteName && setupProgressApi.progress.setup.length === 0) {
// The app-header should have already fetched it, but ensure it's loaded
setupProgressApi.fetchSetupProgress($currentOrg.siteName);
}
});
const StepsEnum = {
UPDATE_PROFILE: 'profile',
@@ -43,8 +63,11 @@
case StepsEnum.CREATE_LESSON:
if (isCompleted('course')) {
const courseId = data?.courses[0].id;
goto(`/courses/${courseId}/lessons`);
const courses = setupProgressApi.progress.courses || [];
const courseId = courses[0]?.id;
if (courseId) {
goto(`/courses/${courseId}/lessons`);
}
} else {
snackbar.info('setup.info_course');
}
@@ -52,9 +75,13 @@
case StepsEnum.CREATE_EXERCISE:
if (isCompleted('lesson')) {
const courseId = data?.courses[0].id;
const lessonId = data?.lessons[0].id;
goto(`/courses/${courseId}/lessons/${lessonId}`);
const courses = setupProgressApi.progress.courses || [];
const lessons = setupProgressApi.progress.lessons || [];
const courseId = courses[0]?.id;
const lessonId = lessons[0]?.id;
if (courseId && lessonId) {
goto(`/courses/${courseId}/lessons/${lessonId}`);
}
} else {
snackbar.info('setup.info_lesson');
}
@@ -62,8 +89,11 @@
case StepsEnum.PUBLISH_COURSE:
if (isCompleted('course')) {
const courseId = data?.courses[0].id;
goto(`/courses/${courseId}/settings`);
const courses = setupProgressApi.progress.courses || [];
const courseId = courses[0]?.id;
if (courseId) {
goto(`/courses/${courseId}/settings`);
}
} else {
snackbar.info('setup.info_course');
}
@@ -74,52 +104,98 @@
break;
case StepsEnum.UPDATE_ORG_PROFILE:
goto(`/org/${$currentOrg.siteName}/settings?tab=org`);
goto(`/org/${$currentOrg.siteName}/settings/org`);
break;
}
};
</script>
<section class="mx-auto w-full md:max-w-3xl">
<div class="px-2 py-2 md:px-5 md:py-10">
<div class="flex items-center gap-2">
<h1 class="text-2xl md:text-3xl dark:text-white">{$t('setup.get_started')}</h1>
<Chip
value={`${completed}/${setupList.length}`}
className="text-[10px] font-semibold px-3 py-1!"
shape="rounded-full"
/>
</div>
<svelte:head>
<title>Setup - ClassroomIO</title>
</svelte:head>
<section class="px-4">
{#each setupList as list, i}
<div
class="flex w-full flex-col justify-between gap-2 border-b border-gray-300 py-8 lg:flex-row lg:items-center"
>
<div class={`flex-1 space-y-1 ${list.is_completed ? 'opacity-50' : ''} lg:max-w-[50%]`}>
<div class="flex items-center gap-3">
<Chip value={i + 1} className="text-[10px] font-semibold py-1" shape="rounded-full" />
<p class="text-lg font-medium">{$t(list.title)}</p>
<Page.Root class="w-full md:max-w-4xl lg:mx-auto">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('setup.get_started')}</Page.Title>
<Page.Subtitle>{$t('setup.description')}</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<SetupProgress setupItems={setupList} />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<section class="space-y-6 px-4">
<Item.Root variant="outline" class="ui:cursor-default">
<Item.Content>
<Item.Description class="mb-4">
{$t('setup.description')}
</Item.Description>
<div class="flex items-center gap-2">
<Item.Description class="text-sm">
{completed} of {total}
{$t('setup.completed')}
</Item.Description>
<div class="flex gap-1">
{#each Array(total) as _, i}
<div
class="size-2 rounded-full {i < completed ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'}"
></div>
{/each}
</div>
</div>
<p class="text-sm">
{$t(list.desc)}
</p>
</div>
<div class="w-full lg:w-[30%]">
<PrimaryButton
variant={list.is_completed ? VARIANTS.CONTAINED_DARK : VARIANTS.OUTLINED}
className="w-full font-normal text-sm flex items-center gap-2"
onClick={() => handleClick(list.id)}
isDisabled={list.is_completed}
>
{#if list.is_completed}
<CircleCheckIcon size={16} />
{/if}
{$t(list.button_label)}
</PrimaryButton>
</div>
</div>
{/each}
</section>
</div>
</section>
</Item.Content>
</Item.Root>
<!-- Setup Items -->
<Item.Group class="space-y-2">
{#each setupList as list}
<Item.Root variant="muted" class={list.is_completed ? 'opacity-60' : ''}>
<Item.Media variant="icon">
{#if list.is_completed}
<div class="flex size-5 items-center justify-center rounded">
<BadgeCheckIcon class="size-5 text-white" />
</div>
{:else if list.id === StepsEnum.CREATE_COURSE}
<BookOpenIcon class="size-5 text-gray-600" />
{:else if list.id === StepsEnum.CREATE_LESSON || list.id === StepsEnum.CREATE_EXERCISE}
<FileTextIcon class="size-5 text-gray-600" />
{:else if list.id === StepsEnum.PUBLISH_COURSE}
<GlobeIcon class="size-5 text-gray-600" />
{:else if list.id === StepsEnum.UPDATE_PROFILE}
<UserPlusIcon class="size-5 text-gray-600" />
{:else if list.id === StepsEnum.UPDATE_ORG_PROFILE}
<UsersIcon class="size-5 text-gray-600" />
{:else}
<BookOpenIcon class="size-5 text-gray-600" />
{/if}
</Item.Media>
<Item.Content>
<Item.Title class={list.is_completed ? 'text-gray-500 line-through' : 'font-semibold'}>
{$t(list.title)}
</Item.Title>
<Item.Description class={list.is_completed ? 'text-gray-500 line-through' : ''}>
{$t(list.desc)}
</Item.Description>
</Item.Content>
<Item.Actions>
{#if list.is_completed}
<Button variant="secondary" size="sm" disabled class="bg-gray-400 text-white">
{$t('setup.done')}
</Button>
{:else}
<Button variant="outline" size="sm" onclick={() => handleClick(list.id)} class="w-full sm:w-auto">
{$t('setup.todo')}
<ChevronRightIcon class="ml-2 size-4" />
</Button>
{/if}
</Item.Actions>
</Item.Root>
{/each}
</Item.Group>
</section>
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,160 +1,5 @@
import get from 'lodash/get';
import { getSupabase } from '$lib/utils/functions/supabase';
const supabase = getSupabase();
async function getGenericData(orgSlug: string): Promise<{
isCoursePublished: boolean;
isCourseCreated: boolean;
orgHasAvatarUrl: boolean;
courseData: any;
}> {
const { data: publishedCourse } = await supabase
.from('course')
.select(
`
is_published,
group:group_id!inner(
organization!inner(
siteName
)
)
`
)
.eq('is_published', true)
.eq('group.organization.siteName', orgSlug)
.limit(1)
.returns<{ is_published: boolean }>();
const { data } = await supabase
.from('course')
.select(
`
id,
is_published,
group:group_id!inner(
organization!inner(
avatar_url,
siteName
)
)
`
)
.eq('group.organization.siteName', orgSlug)
.limit(1);
const result = data || [];
return {
isCoursePublished: publishedCourse?.is_published === true,
isCourseCreated: result.length > 0,
orgHasAvatarUrl: !!get(result[0], 'group.organization.avatar_url', ''),
courseData: result
};
}
async function getIsLessonCreated(orgSlug: string): Promise<{ isLessonCreated: boolean; lessonData: any }> {
const { data } = await supabase
.from('lesson')
.select(
`
id,
course:course_id!inner(
group:group_id!inner(
organization!inner(
siteName
)
)
)
`
)
.eq('course.group.organization.siteName', orgSlug)
.limit(1);
console.log('lessonsData', data);
return {
isLessonCreated: Array.isArray(data) ? data.length > 0 : false,
lessonData: data
};
}
async function getIsExerciseCreated(orgSlug: string): Promise<boolean> {
const { data } = await supabase
.from('exercise')
.select(
`
id,
lesson:lesson_id!inner(
course:course_id!inner(
group:group_id!inner(
organization!inner(
siteName
)
)
)
)
`
)
.eq('lesson.course.group.organization.siteName', orgSlug)
.limit(1);
return Array.isArray(data) ? data?.length > 0 : false;
}
export const load = async ({ params = { slug: '' } }) => {
const { isCourseCreated, isCoursePublished, orgHasAvatarUrl, courseData } = await getGenericData(params.slug);
const { isLessonCreated, lessonData } = await getIsLessonCreated(params.slug);
const isExerciseCreated = await getIsExerciseCreated(params.slug);
const data = [
{
id: 'profile',
title: 'setup.personal_profile.title',
desc: 'setup.personal_profile.desc',
is_completed: false,
button_label: 'setup.personal_profile.button_label'
},
{
id: 'organization',
title: 'setup.organization_profile.title',
desc: 'setup.organization_profile.desc',
is_completed: orgHasAvatarUrl,
button_label: 'setup.organization_profile.button_label'
},
{
id: 'course',
title: 'setup.course.title',
desc: 'setup.course.desc',
is_completed: isCourseCreated,
button_label: 'setup.course.button_label'
},
{
id: 'lesson',
title: 'setup.lesson.title',
desc: 'setup.lesson.desc',
is_completed: isLessonCreated,
button_label: 'setup.lesson.button_label'
},
{
id: 'exercise',
title: 'setup.exercise.title',
desc: 'setup.exercise.desc',
is_completed: isExerciseCreated,
button_label: 'setup.exercise.button_label'
},
{
id: 'publish',
title: 'setup.publish_course.title',
desc: 'setup.publish_course.desc',
is_completed: isCoursePublished,
button_label: 'setup.publish_course.button_label'
}
];
return {
orgSiteName: params.slug,
setup: data,
courses: courseData,
lessons: lessonData
orgSiteName: params.slug
};
};