feat: migrate course UI from supabase (#615)

* chore: fix storybook build

* chore: implement theming

* community list not showing properly

* refactor: migrate Chip component to @cio/ui/custom/chip

- Updated imports for the Chip component across multiple files to use the new location in the @cio/ui package.
- Replaced the previous Chip implementation with a new version that utilizes a Badge for better styling and functionality.
- Adjusted related components to ensure consistent usage of the updated Chip component.

* fix: align course sidebar design to match organization design (#611)

* fix: align course sidebar design to match organization design

* feat: add hideSearch prop to AppHeader and update sidebar layout

---------

Co-authored-by: Rotimi Best <best@classroomio.com>

* refactor: replace PrimaryButton with Button component across multiple files

- Updated various components to use the new Button component from @cio/ui/base/button instead of the deprecated PrimaryButton.
- Ensured consistent button usage throughout the application for improved styling and functionality.
- Removed the PrimaryButton component and its associated constants from the codebase.

* refactor: standardize button and input usage across components

- Replaced instances of deprecated components such as TextField, TextArea, and IconButton with their updated counterparts (InputField, TextareaField, and updated IconButton).
- Ensured consistent event handling by changing `onClick` to `onclick` for better alignment with Svelte's event binding conventions.
- Removed unused components and streamlined imports to enhance code maintainability and readability.

* chore: themeing, course card, use $features

* refactor: centralize UI components and update modal implementations

- Refactored various components to centralize UI elements, improving discoverability and maintainability.
- Replaced deprecated modal components with the new Dialog component for consistent modal behavior across the application.
- Removed unused components and streamlined imports to enhance code clarity and reduce complexity.

* feat: add new documentation and refactor org and lms UI components

- Introduced new documentation files for Auth Flow, Breadcrumbs Refactoring, Page and Route Creation, and Route Page Setup to enhance developer onboarding and reference.
- Refactored various UI components, including the Course Sidebar and Lesson Modal, to improve layout and functionality.
- Removed deprecated components and streamlined imports for better maintainability.
- Updated translations across multiple languages to ensure consistency in user experience.

* refactor: update UI components and improve styling consistency

- Refactored various components to enhance styling consistency, including updates to button and text color classes.
- Removed deprecated components and streamlined imports for better maintainability.
- Replaced instances of PageBody with a more flexible Page component structure across multiple routes.
- Updated translations and UI elements to align with the new design system.
- Deleted unused components to reduce code complexity and improve clarity.

* update output.css

---------

Co-authored-by: Abdulmuizz Hamzat <103611443+canhamzacode@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2025-12-22 02:36:48 +01:00
committed by GitHub
co-authored by Abdulmuizz Hamzat
parent e6a3cf9332
commit 9fb6c7cbaa
401 changed files with 10489 additions and 11679 deletions
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { AuthUI } from '$lib/features/ui';
import { AuthUI } from '$features/ui';
import { Button } from '@cio/ui/base/button';
import * as Card from '@cio/ui/base/card';
import { currentOrg } from '$lib/utils/store/org';
@@ -1,10 +1,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ROUTE } from '$lib/utils/constants/routes';
import { AuthUI } from '$lib/features/ui';
import EmailSentIcon from '$lib/components/Icons/EmailSentIcon.svelte';
import { forgotApi } from '$lib/features/auth/api/forgot.svelte';
import type { TForgotPasswordForm } from '$lib/features/auth/utils/types';
import { AuthUI } from '$features/ui';
import { EmailSentIcon } from '$features/ui/icons';
import { forgotApi } from '$features/auth/api/forgot.svelte';
import type { TForgotPasswordForm } from '$features/auth/utils/types';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
@@ -35,9 +35,7 @@
<div class="ui:flex ui:flex-col ui:gap-6">
<div>
<Card.Title class="ui:text-xl">Forgot Password</Card.Title>
<Card.Description class="ui:mt-2">
We will send you a reset link to your email
</Card.Description>
<Card.Description class="ui:mt-2">We will send you a reset link to your email</Card.Description>
</div>
<Field.Field>
<Field.Label for="email">Your email</Field.Label>
@@ -61,9 +59,7 @@
<Button type="submit" disabled={forgotApi.isLoading} loading={forgotApi.isLoading} class="ui:w-full">
Reset Password
</Button>
<Button type="button" variant="ghost" class="ui:w-full" onclick={() => goto(ROUTE.LOGIN)}>
Cancel
</Button>
<Button type="button" variant="ghost" class="ui:w-full" onclick={() => goto(ROUTE.LOGIN)}>Cancel</Button>
</div>
</div>
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import { AuthUI } from '$lib/features/ui';
import { AuthUI } from '$features/ui';
import { LOGIN_FIELDS } from '$lib/utils/constants/authentication';
import { t } from '$lib/utils/functions/translations';
import { authValidation } from '$lib/utils/functions/validator';
@@ -4,13 +4,13 @@
import { DomainInput } from '@cio/ui/custom/domain-input';
import * as Field from '@cio/ui/base/field';
import { Button } from '@cio/ui/base/button';
import UserProfileIcon from '$lib/components/Icons/UserProfileIcon.svelte';
import { UserProfileIcon } from '$features/ui/icons';
import { profile } from '$lib/utils/store/user';
import { onboardingApi } from '$lib/features/onboarding/api/onboarding.svelte';
import { onboardingApi } from '$features/onboarding/api/onboarding.svelte';
import { generateSitename } from '$lib/utils/functions/org';
import { t } from '$lib/utils/functions/translations';
import { GOALS, ONBOARDING_STEPS, SOURCES, DROPDOWN_ITEMS } from '$lib/features/onboarding/utils/constants';
import type { OnboardingField } from '$lib/features/onboarding/utils/types';
import { GOALS, ONBOARDING_STEPS, SOURCES, DROPDOWN_ITEMS } from '$features/onboarding/utils/constants';
import type { OnboardingField } from '$features/onboarding/utils/types';
import { untrack } from 'svelte';
let fields: OnboardingField = $state({
@@ -4,10 +4,10 @@
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { getConfirmPasswordError } from '$lib/utils/functions/validator';
import { AuthUI } from '$lib/features/ui';
import { resetApi } from '$lib/features/auth/api/reset.svelte';
import type { TResetPasswordForm } from '$lib/features/auth/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
import { AuthUI } from '$features/ui';
import { resetApi } from '$features/auth/api/reset.svelte';
import type { TResetPasswordForm } from '$features/auth/utils/types';
import { snackbar } from '$features/ui/snackbar/store';
import * as Field from '@cio/ui/base/field';
import { Password } from '@cio/ui/custom/password';
import { Button } from '@cio/ui/base/button';
@@ -1,7 +1,6 @@
<script lang="ts">
import { untrack } from 'svelte';
import { AuthUI } from '$lib/features/ui';
import SenjaEmbed from '$lib/components/Senja/Embed.svelte';
import { AuthUI, SenjaEmbed } from '$features/ui';
import { SIGNUP_FIELDS } from '$lib/utils/constants/authentication';
import { t } from '$lib/utils/functions/translations';
import { authValidation, getConfirmPasswordError, getDisableSubmit } from '$lib/utils/functions/validator';
+1 -2
View File
@@ -3,8 +3,7 @@
import { browser } from '$app/environment';
import { page } from '$app/state';
import { globalStore } from '$lib/utils/store/app';
import ErrorIcon from '$lib/components/Icons/ErrorIcon.svelte';
import ErrorIconDarkMode from '$lib/components/Icons/ErrorIconDarkMode.svelte';
import { ErrorIcon } from '$features/ui/icons';
if (page.status == 404 && browser) {
goto('/404');
+2 -2
View File
@@ -1,11 +1,11 @@
import { getSupabase, supabase } from '$lib/utils/functions/supabase';
import type { AccountOrg } from '$lib/features/app/types';
import type { AccountOrg } from '$features/app/types';
import type { MetaTagsProps } from 'svelte-meta-tags';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import { getOrgSiteInfo } from '$lib/features/app/layout-setup';
import { getOrgSiteInfo } from '$features/app/layout-setup';
import { redirect } from '@sveltejs/kit';
if (!supabase) {
+27 -9
View File
@@ -1,23 +1,21 @@
<script lang="ts">
import { page } from '$app/state';
import OrgLandingPage from '$lib/components/Org/LandingPage/index.svelte';
import PlayQuiz from '$lib/components/Org/Quiz/Play/index.svelte';
import { PageRestricted } from '$lib/components/Page';
import Snackbar from '$lib/components/Snackbar/index.svelte';
import { UpgradeModal } from '$lib/features/ui';
import { OrgLandingPage, PlayQuiz } from '$features/org';
import { Snackbar } from '$features/ui';
import { UpgradeModal, PageLoadProgress, PageRestricted } from '$features/ui';
import { user } from '$lib/utils/store/user';
import { setupAnalytics } from '$lib/utils/functions/appSetup';
import { setTheme } from '$lib/utils/functions/theme';
import { initOrgAnalytics } from '$lib/utils/services/posthog';
import { globalStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org';
import { appInitApi } from '$lib/features/app/init.svelte';
import { appInitApi } from '$features/app/init.svelte';
import merge from 'lodash/merge';
import { onMount } from 'svelte';
import { MetaTags } from 'svelte-meta-tags';
import { authClient } from '$lib/utils/services/auth/client';
import { PageLoadProgress } from '$lib/features/ui';
import { isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
import { ModeWatcher } from '@cio/ui/base/dark-mode';
@@ -25,7 +23,7 @@
let { data, children } = $props();
let path = $derived(page.url?.pathname?.replace('/', ''));
let path = $derived(page.url.pathname);
function intialAppSetup() {
console.log(
@@ -70,8 +68,28 @@
const session = authClient.useSession();
const isSessionReady = $derived(!$session.isPending && !$session.isRefetching && $session.data);
// The goal of this effect is to make sure that we do a redirect to /login if the session data is expired
// This should be happening on the hooks.server.ts but we've made the home page public so we can show a loading spinner while the app is initializing OR an error message if the backend is down.
$effect(() => {
if ($session.isPending || $session.isRefetching) {
console.log('session is pending or refetching');
return;
}
console.log('path', path);
console.log('isPublicRoute', isPublicRoute(path));
// No need to require login for public routes
if (isPublicRoute(path)) return;
if (!$session.data && path !== '/login') {
console.log('session data is not available, go to login');
window.location.href = '/login';
}
});
$effect(() => {
// this means the session cookie 'classroomio.session_data' expired and we need to trigger a new session
// triggering a new session will update the session data in the cookies so that our hooks.server.ts doesn't always have to hit the DB when checking if user is logged in or not. Without the session cookie, every page navigation or route would always hit the database to check if user is logged in or not.
if (!data.locals.fromSessions && isSessionReady) {
authClient.getSession().then(() => {
console.log('triggered new session');
@@ -105,7 +123,7 @@
<PageRestricted />
{:else if data.skipAuth}
<PlayQuiz />
{:else if data.isOrgSite && data.org && !path}
{:else if data.isOrgSite && data.org && path === '/'}
<OrgLandingPage orgSiteName={data.orgSiteName} org={data.org} />
{:else}
<PageLoadProgress zIndex={10000} />
+33 -8
View File
@@ -1,16 +1,41 @@
<script>
import { appInitApi } from '$lib/features/app/init.svelte';
import { appInitApi } from '$features/app/init.svelte';
import { Spinner } from '@cio/ui/base/spinner';
import { Button } from '@cio/ui/base/button';
import FrownIcon from '@lucide/svelte/icons/frown';
import { Empty } from '@cio/ui/custom/empty';
import { SimpleLogoNav } from '@cio/ui/custom/simple-logo-nav';
import { authClient } from '$lib/utils/services/auth/client';
const session = authClient.useSession();
const isSessionLoading = $derived($session.isPending || $session.isRefetching);
const isLoadingApp = $derived(isSessionLoading || appInitApi.loading);
</script>
<svelte:head>
<title>ClassroomIO - Beautiful and Robust Course Platform</title>
<title>ClassroomIO - The Coure Platform That’s Actually Easy To Use</title>
</svelte:head>
<div class="m-2 flex h-screen w-screen flex-col items-center justify-center font-sans sm:m-0">
{#if appInitApi.loading || appInitApi.data}
{#if !isLoadingApp && appInitApi.error}
<Empty
title="Something Went Wrong"
description="We encountered an unexpected error. Please reload the page or contact us for support."
icon={FrownIcon}
variant="page"
layout="full-page"
showLogo={true}
>
{#if appInitApi.error}
<p class="my-2 text-red-500">{appInitApi.error}</p>
{/if}
<div class="flex gap-2">
<Button variant="secondary" onclick={() => window.location.reload()}>Reload Page</Button>
<Button variant="default" href="https://classroomio.com/contact">Contact Us</Button>
</div>
</Empty>
{:else}
<div class="m-2 flex h-screen w-screen flex-col items-center justify-center font-sans sm:m-0">
<SimpleLogoNav />
<Spinner class="size-14! text-blue-700!" />
{:else if appInitApi.error}
<p class="text-red-500">{appInitApi.error}</p>
{/if}
</div>
</div>
{/if}
+23 -3
View File
@@ -1,10 +1,30 @@
<script>
import { page } from '$app/state';
import { PageNotFound } from '$lib/components/Page';
import { goto } from '$app/navigation';
import { Button } from '@cio/ui/base/button';
import SearchXIcon from '@lucide/svelte/icons/search-x';
import { Empty } from '@cio/ui/custom/empty';
import { resolve } from '$app/paths';
let query = $derived(new URLSearchParams(page.url.search));
let isOrg = $derived(query.get('type') === 'org');
function handleClick() {
if (!isOrg) {
return goto(resolve('/', {}));
}
window.location.href = 'https://classroomio.com';
}
</script>
<PageNotFound {isOrg} />
<Empty
title={isOrg ? "Organization doesn't exist!" : 'Page not found'}
description="The page you are looking for doesn't exist or has been moved. Please go back to the homepage."
icon={SearchXIcon}
variant="page"
layout="full-page"
showLogo={true}
>
<Button onclick={handleClick}>Back to Safety</Button>
</Empty>
@@ -1,4 +1,4 @@
import { OrgPlanApiServer } from '$lib/features/org/api/org-plan.server';
import { OrgPlanApiServer } from '$features/org/api/org-plan.server';
import { PLAN } from '@cio/utils/plans';
import type { PolarWebhookPayload } from '$lib/utils/types/polar';
import { Webhooks } from '@polar-sh/sveltekit';
@@ -1,5 +1,5 @@
<script lang="ts">
import CourseLandingPage from '$lib/components/CourseLandingPage/index.svelte';
import { CourseLandingPage } from '$features/ui';
import type { Course } from '$lib/utils/types';
interface Data {
@@ -1,9 +1,27 @@
<script lang="ts">
import * as Sidebar from '@cio/ui/base/sidebar';
import { CourseSidebar } from '$features/course/components/sidebar';
import { CourseHeader } from '$features/course/components';
import * as Page from '@cio/ui/base/page';
interface Props {
children?: import('svelte').Snippet;
path: string;
}
let { children }: Props = $props();
let { children, path }: Props = $props();
</script>
{@render children?.()}
<Sidebar.Provider>
<CourseSidebar {path} />
<Sidebar.Inset>
<CourseHeader />
<Page.Root
class="mx-auto flex w-[calc(100vw-var(--sidebar-width))] overflow-x-hidden px-4 group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))] lg:max-w-6xl"
>
{@render children?.()}
</Page.Root>
</Sidebar.Inset>
</Sidebar.Provider>
@@ -1,219 +1,19 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import Box from '$lib/components/Box/index.svelte';
import NewFeedModal from '$lib/components/Course/components/NewsFeed/NewFeedModal.svelte';
import NewsFeedCard from '$lib/components/Course/components/NewsFeed/NewsFeedCard.svelte';
import NewsFeedLoader from '$lib/components/Course/components/NewsFeed/NewsFeedLoader.svelte';
import { isNewFeedModal, newsFeed } from '$lib/components/Course/components/NewsFeed/store';
import { group } from '$lib/components/Course/store';
import { NewsFeedPage } from '$features/course/pages';
import { CourseContainer } from '$lib/components/CourseContainer';
import { PageBody, PageNav } from '$lib/components/Page';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { snackbar } from '$lib/components/Snackbar/store';
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import {
createComment,
deleteNewsFeed,
deleteNewsFeedComment,
fetchNewsFeedReaction,
fetchNewsFeeds,
handleEditFeed,
toggleFeedIsPinned
} from '$lib/utils/services/newsfeed';
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
import { RoleBasedSecurity } from '$features/ui';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import type { Feed } from '$lib/utils/types/feed';
import PinIcon from '@lucide/svelte/icons/pin';
import { onMount } from 'svelte';
import type { AccountOrg } from '$lib/features/app/types';
import { t } from '$lib/utils/functions/translations';
import { getGreeting } from '$lib/utils/functions/date.js';
import type { AccountOrg } from '$features/app/types';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
let { data = $bindable() } = $props();
let createdComment;
let edit = $state(false);
let isLoading = $state(false);
let editFeed: Feed | null = $state(null);
let query = new URLSearchParams(page.url.search);
let feedId = query.get('feedId') || '';
const author = $derived.by(() => {
const groupMember = $group.people.find((person) => person.profile_id === $profile.id);
return {
id: groupMember?.id || '',
username: $profile.username || '',
fullname: $profile.fullname || '',
avatar_url: $profile.avatarUrl || ''
};
});
const onEdit = (id: string, content: string) => {
handleEditFeed(id, content);
$newsFeed = $newsFeed.map((feed) => {
if (feed.id === id) {
return { ...feed, content: content };
}
return feed;
});
};
const deleteComment = (id: string) => {
deleteNewsFeedComment(id);
$newsFeed = $newsFeed.flatMap((feed) => ({
...feed,
comment: feed.comment.filter((comment) => comment.id !== id)
}));
return snackbar.success('snackbar.course.success.comment_deleted');
};
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
const { data } = await fetchNewsFeedReaction(feedId);
if (!data) return;
const reactedFeed = data || $newsFeed.find((feed) => feed.id === feedId);
let reactedAuthorIds: string[] = reactedFeed.reaction[reactionType];
if (reactedAuthorIds.includes(authorId)) {
reactedAuthorIds = reactedAuthorIds.filter((reactionAuthorId) => reactionAuthorId !== authorId);
} else {
reactedAuthorIds = [...reactedAuthorIds, authorId];
}
reactedFeed.reaction = {
...reactedFeed.reaction,
[reactionType]: reactedAuthorIds
};
const response = await supabase
.from('course_newsfeed')
.update({
reaction: reactedFeed.reaction
})
.eq('id', feedId);
if (response.error) {
return snackbar.error('snackbar.course.error.reaction_error');
}
$newsFeed = $newsFeed.map((feed) => {
if (feed.id === feedId) {
feed.reaction = reactedFeed.reaction;
}
return feed;
});
};
const addNewComment = async (comment: string, feedId: string, authorId: string) => {
const { response } = await createComment({
content: comment,
author_id: authorId,
course_newsfeed_id: feedId
});
if (response.error) {
return snackbar.error('snackbar.course.error.commenting_error');
}
if (!response.data) return;
createdComment = response?.data[0];
triggerSendEmail(NOTIFICATION_NAME.NEWSFEED, {
authorId: author.id,
feedId: feedId,
comment
});
$newsFeed = $newsFeed.map((feed) => {
if (feed.id === feedId) {
const newComment = {
id: createdComment.id,
author: {
profile: { ...author }
},
created_at: createdComment.created_at,
content: comment
};
snackbar.success('snackbar.course.success.comment_added');
return {
...feed,
comment: [...feed.comment, { ...newComment }]
};
}
return feed;
});
};
const onPin = async (feedId, isPinned) => {
const newIsPinned = !isPinned;
const { response } = await toggleFeedIsPinned(feedId, newIsPinned);
if (response.error) {
return snackbar.error('snackbar.course.error.toggle_error');
}
$newsFeed = $newsFeed.map((feed) => {
if (feed.id === feedId) {
snackbar.success(
`${
newIsPinned ? 'snackbar.course.success.pinned' : 'snackbar.course.success.unpinned'
} snackbar.course.success.successfully`
);
feed.isPinned = newIsPinned;
return feed;
}
return feed;
});
};
const deleteFeed = (id: string) => {
deleteNewsFeed(id);
const deletedFeed = $newsFeed.filter((feed) => feed.id !== id);
snackbar.success('snackbar.course.success.feed_delete_success');
$newsFeed = deletedFeed;
};
const initNewsFeed = async (courseId: string) => {
if (!courseId) return;
try {
isLoading = true;
const { data, error } = await fetchNewsFeeds(courseId);
if (error) {
snackbar.error('snackbar.course.error.news_feed_fail');
}
if (data) {
$newsFeed = data
.map((feedItem) => ({
...feedItem,
isPinned: feedItem.is_pinned
}))
.sort((a, b) => Number(b.isPinned) - Number(a.isPinned));
}
} catch (error) {
snackbar.error('snackbar.course.error.feed_fetch_error');
} finally {
isLoading = false;
}
};
function getPageRoles(org: AccountOrg) {
const roles: number[] = [1, 2];
@@ -223,12 +23,12 @@
return roles;
}
onMount(() => {
initNewsFeed(data.courseId);
});
</script>
<svelte:head>
<title>News Feed - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<RoleBasedSecurity
allowedRoles={getPageRoles($currentOrg)}
@@ -236,70 +36,24 @@
goto(`/courses/${data.courseId}/lessons?next=true`);
}}
>
<PageNav title={$t('course.navItem.news_feed.heading')} disableSticky={true}>
{#snippet widget()}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<PrimaryButton
className="mr-2"
label={$t('course.navItem.news_feed.heading_button.title')}
onClick={() => ($isNewFeedModal.open = true)}
/>
</RoleBasedSecurity>
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t(getGreeting())}
{$profile.fullname}!
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button class="mr-2" onclick={() => ($isNewFeedModal.open = true)}>
{$t('course.navItem.news_feed.heading_button.title')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<NewsFeedPage courseId={data.courseId} />
{/snippet}
</PageNav>
<PageBody width="max-w-4xl px-3">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<NewFeedModal
courseId={data.courseId}
{author}
bind:edit
bind:editFeed
onSave={(newFeed) => {
$newsFeed = [newFeed, ...$newsFeed];
}}
{onEdit}
/>
</RoleBasedSecurity>
{#if isLoading}
<div>
<NewsFeedLoader />
<NewsFeedLoader />
<NewsFeedLoader />
</div>
{:else if !$newsFeed.length}
<Box>
<div class="flex w-[90%] flex-col items-center justify-between md:w-96">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="mx-auto my-2.5" />
<h2 class="my-1.5 text-xl font-normal">{$t('course.navItem.news_feed.body_header')}</h2>
<p class="text-center text-sm text-slate-500">
{$t('course.navItem.news_feed.body_content')}
</p>
</div>
</Box>
{:else}
{#each $newsFeed as feed}
{#if feed.isPinned}
<div class="mb-3 flex items-center gap-2">
<PinIcon size={16} class="filled" />
<p class="text-sm">{$t('course.navItem.news_feed.pinned')}</p>
</div>
{/if}
<NewsFeedCard
{feed}
{deleteFeed}
{addNewComment}
{deleteComment}
{addNewReaction}
{onPin}
{author}
bind:edit
bind:editFeed
isActive={feedId === feed.id}
/>
{/each}
{/if}
</PageBody>
</Page.Body>
</RoleBasedSecurity>
</CourseContainer>
@@ -1,138 +1,27 @@
<script lang="ts">
import { onMount } from 'svelte';
import ChartBarIcon from '@lucide/svelte/icons/chart-bar';
import { CourseContainer } from '$lib/components/CourseContainer';
import { PageBody, PageNav } from '$lib/components/Page';
import { AnalyticsPage } from '$features/course/pages';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { getAccessToken } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import type { CourseAnalytics } from '$lib/utils/types/analytics';
import { fade } from 'svelte/transition';
import StudentTable from '$lib/components/Course/components/Analytics/StudentTable.svelte';
import AnalyticsGraph from '$lib/components/Course/components/Analytics/AnalyticsGraph.svelte';
import AnalyticsSkeleton from '$lib/components/Course/components/Analytics/AnalyticsSkeleton.svelte';
import AnalyticsCard from '$lib/components/Course/components/Analytics/AnalyticsCard.svelte';
import StudentsIcon from '$lib/components/Course/components/Analytics/icons/StudentsIcon.svelte';
import LessonsIcon from '$lib/components/Course/components/Analytics/icons/LessonsIcon.svelte';
import ProgressIcon from '$lib/components/Course/components/Analytics/icons/ProgressIcon.svelte';
import ExercisesIcon from '$lib/components/Course/components/Analytics/icons/ExercisesIcon.svelte';
import EmptyState from '$lib/components/Course/components/Analytics/EmptyState.svelte';
let { data = $bindable() } = $props();
let courseAnalytics: CourseAnalytics | null = $state(null);
let isLoading = $state(true);
async function fetchCourseAnalytics() {
try {
isLoading = true;
const accessToken = await getAccessToken();
const response = await fetch('/api/analytics/course', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify({ courseId: data.courseId })
});
if (response.ok) {
courseAnalytics = (await response.json()) as CourseAnalytics;
} else {
console.error('Failed to fetch course analytics:', response);
snackbar.error('Failed to load analytics data');
courseAnalytics = null;
}
} catch (error) {
console.error('Failed to fetch course analytics:', error);
snackbar.error('Failed to load analytics data');
courseAnalytics = null;
} finally {
isLoading = false;
}
}
onMount(() => {
fetchCourseAnalytics();
});
let { data } = $props();
</script>
<svelte:head>
<title>Analytics - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<PageNav title={$t('course.navItem.analytics.title')} />
<PageBody width="w-full max-w-6xl md:w-11/12">
{#if isLoading}
<AnalyticsSkeleton />
{:else if courseAnalytics}
<div class="min-h-screen" in:fade>
<!-- Main Content Area -->
<div class="px-4 py-8">
<!-- Course Overview Cards -->
<div class="mb-12">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Students Card -->
<AnalyticsCard
title={$t('analytics.students')}
value={courseAnalytics?.totalStudents || 0}
subtitle="{$t('analytics.tutors')}: {courseAnalytics?.totalTutors || 0}"
icon={StudentsIcon}
iconColor="blue"
/>
<!-- Lessons Card -->
<AnalyticsCard
title={$t('analytics.lessons')}
value={courseAnalytics?.totalLessons || 0}
subtitle="{$t('analytics.exercises')}: {courseAnalytics?.totalExercises || 0}"
icon={LessonsIcon}
iconColor="green"
/>
<!-- Progress Card -->
<AnalyticsCard
title={$t('analytics.progress')}
value="{courseAnalytics?.lessonCompletionRate || 0}%"
subtitle="{$t('analytics.avg_grade')}: {courseAnalytics?.averageGrade || 0}%"
icon={ProgressIcon}
iconColor="purple"
/>
<!-- Exercise Completion Card -->
<AnalyticsCard
title={$t('analytics.exercises')}
value="{courseAnalytics?.exerciseCompletionRate || 0}%"
subtitle={$t('analytics.completion_rate')}
icon={ExercisesIcon}
iconColor="orange"
/>
</div>
</div>
<!-- Charts Section -->
<div class="mb-12">
<AnalyticsGraph {courseAnalytics} />
</div>
<!-- Students Overview Table -->
<div class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-neutral-800">
<div class="border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{$t('analytics.student_progress_overview')}
</h3>
</div>
<div class="p-6">
<StudentTable students={courseAnalytics?.students || []} />
</div>
</div>
</div>
</div>
{:else}
<EmptyState
title={$t('analytics.no_analytics_data')}
description={$t('analytics.no_analytics_description')}
icon={ChartBarIcon}
className="h-screen"
/>
{/if}
</PageBody>
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t('analytics.title')}
</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<AnalyticsPage courseId={data.courseId} />
{/snippet}
</Page.Body>
</CourseContainer>
@@ -1,255 +1,27 @@
<script lang="ts">
import { Input } from '@cio/ui/base/input';
import * as Table from '@cio/ui/base/table';
import Search from '@lucide/svelte/icons/search';
import { Checkbox } from '@cio/ui/base/checkbox';
import * as Pagination from '@cio/ui/base/pagination';
import BadgeXIcon from '@lucide/svelte/icons/badge-x';
import { profile } from '$lib/utils/store/user';
import { ROLE } from '@cio/utils/constants';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { attendance } from '$lib/utils/store/attendance';
import { snackbar } from '$lib/components/Snackbar/store';
import { course, group } from '$lib/components/Course/store';
import { takeAttendance } from '$lib/utils/services/attendance';
import { getLectureNo } from '$lib/components/Course/function.js';
import type { GroupPerson, Lesson } from '$lib/utils/types/index';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import Box from '$lib/components/Box/index.svelte';
import { PageBody, PageNav } from '$lib/components/Page';
import { CourseContainer } from '$lib/components/CourseContainer';
import { AttendancePage } from '$features/course/pages';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
let { data = $bindable() } = $props();
interface CourseData {
attendance: {
student_id: string;
lesson_id: string;
is_present: boolean;
id: number;
}[];
}
let hasFetched = $state(false);
const students: GroupPerson[] = $derived(
$globalStore.isStudent
? $group.people.filter((person) => !!person.profile && person.profile.id === $profile.id)
: $group.people.filter((person) => !!person.profile && person.role_id === ROLE.STUDENT)
);
let searchValue = $state('');
let currentPage = $state(1);
let pageSize = $state(10);
const filteredStudents = $derived(searchStudents(searchValue, students));
const totalStudents = $derived(filteredStudents.length);
const paginatedStudents = $derived(filteredStudents.slice((currentPage - 1) * pageSize, currentPage * pageSize));
function setAttendance(courseData: CourseData) {
for (const attendanceItem of courseData.attendance) {
const { student_id, lesson_id, is_present, id } = attendanceItem;
if (!$attendance[student_id]) {
$attendance[student_id] = {
[lesson_id]: {
id,
is_present
}
};
} else {
$attendance[student_id] = {
...$attendance[student_id],
[lesson_id]: {
id,
is_present
}
};
}
}
}
function handleAttendanceChange(e: any, student: GroupPerson, lesson: Lesson) {
if ($globalStore.isStudent) return;
const attendanceItem = $attendance[student.id]
? $attendance[student.id][lesson.id] || { id: undefined }
: { id: undefined };
/*
This validation is useless. We shouldn't assume we already have an attendance item id
because it might be a new attendance.
if (!attendanceItem.id) {
snackbar.error("Something isn't right. Please reload and take attendance again");
console.error(`Attendance Id Missing`, courseId, student.id, lesson.id);
return;
}
*/
const _data = {
id: attendanceItem.id,
student_id: student.id,
is_present: e.target.checked,
lesson_id: lesson.id,
course_id: data.courseId
};
takeAttendance(_data).then((res) => {
if (res.error) {
console.error(`res.error`, res.error);
snackbar.error('snackbar.attendance.error.reload');
} else {
// console.log('res', JSON.stringify(res));
const { id, is_present } = res.data[0];
if ($attendance[student.id]) {
$attendance[student.id][lesson.id] = {
id,
is_present
};
} else {
$attendance[student.id] = {
[lesson.id]: {
id,
is_present
}
};
}
}
});
}
// function for the searchbar
function searchStudents(query: string, _students: GroupPerson[]) {
const lowercaseQuery = query.toLowerCase();
return _students.filter((student) => student.profile?.fullname?.toLowerCase()?.includes(lowercaseQuery));
}
async function firstRender(courseId?: string) {
if (!courseId || hasFetched) return;
hasFetched = true;
if (!Object.keys($attendance).length) {
setAttendance($course);
}
return;
}
$effect(() => {
firstRender($course.id);
});
let { data } = $props();
</script>
<svelte:head>
<title>Attendance - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<PageNav title={$t('course.navItem.attendance.title')} />
<PageBody width="w-full max-w-6xl md:w-11/12">
<section class="mx-2 my-5 flex items-center lg:mx-9">
<div class="flex w-full flex-col items-start justify-between gap-2 lg:flex-row lg:items-center">
<div class="flex gap-5">
<p class="flex items-center gap-2">
<Checkbox disabled />
<span>{$t('course.navItem.attendance.present')}</span>
</p>
<p class="flex items-center gap-2">
<Checkbox disabled />
<span>{$t('course.navItem.attendance.absent')}</span>
</p>
</div>
<div class="relative w-full">
<Input type="text" placeholder={$t('course.navItem.attendance.search_students')} bind:value={searchValue} />
<Search class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2" />
</div>
</div>
</section>
<section class="mx-2 my-5 lg:mx-9">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-1/4">{$t('course.navItem.attendance.student')}</Table.Head>
{#each $lessons as _lesson, index}
<Table.Head class="text-center"
>{$t('course.navItem.attendance.lesson')} 0{getLectureNo(index + 1)}</Table.Head
>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedStudents as student}
<Table.Row>
<Table.Cell class="font-semibold">
{student.profile.fullname}
</Table.Cell>
{#each $lessons as lesson}
<Table.Cell class="text-center">
<div class="flex justify-center">
<Checkbox
disabled={$globalStore.isStudent}
checked={$attendance[student.id]
? $attendance[student.id][lesson.id]
? $attendance[student.id][lesson.id].is_present
: false
: false}
onCheckedChange={(checked) => {
const e = { target: { checked } };
handleAttendanceChange(e, student, lesson);
}}
/>
</div>
</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{#if students.length === 0}
<Box className="h-[300px] w-full">
<BadgeXIcon size={48} />
<h3 class="text-center text-3xl text-gray-500 dark:text-white">
{$t('course.navItem.attendance.no_student')}
</h3>
</Box>
{/if}
{#if totalStudents > 0}
<div class="mt-4">
<Pagination.Root
count={totalStudents}
perPage={pageSize}
page={currentPage}
onPageChange={(page) => (currentPage = page)}
>
{#snippet children({ pages, currentPage: activePage })}
<Pagination.Content>
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={activePage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}
</section>
</PageBody>
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t('course.navItem.attendance.title')}
</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<AttendancePage courseId={data.courseId} />
{/snippet}
</Page.Body>
</CourseContainer>
@@ -1,57 +1,27 @@
<script lang="ts">
import IssueCertificateModal from '$lib/components/Course/components/Ceritficate/IssueCertificateModal.svelte';
import { CertificatesPage } from '$features/course/pages';
import { CourseContainer } from '$lib/components/CourseContainer';
import { PageBody, PageNav } from '$lib/components/Page';
import { RoleBasedSecurity } from '$lib/features/ui';
// import DeleteConfirmation from '$lib/components/Course/components/People/DeleteConfirmation.svelte';
import Design from '$lib/components/Course/components/Ceritficate/Design.svelte';
// import Reports from '$lib/components/Course/components/Ceritficate/Reports.svelte';
// import { issueCertificateModal } from '$lib/components/Course/components/Ceritficate/store';
import StudentCertificate from '$lib/components/Course/components/Ceritficate/StudentCertificate/Index.svelte';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
let { data = $bindable() } = $props();
let { data } = $props();
</script>
<IssueCertificateModal />
<svelte:head>
<title>Certificates - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<PageNav title={$t('course.navItem.certificates.title')} disableSticky={true}>
{#snippet widget()}
<slot:fragment>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<!-- Hide Functionality For V1 -->
<!-- <PrimaryButton
className="mr-2 rounded-md text-sm font-medium"
label="Issue Cert"
onClick={() => ($issueCertificateModal.open = true)}
/> -->
</RoleBasedSecurity>
</slot:fragment>
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t('course.navItem.certificates.title')}
</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<CertificatesPage courseId={data.courseId} />
{/snippet}
</PageNav>
<PageBody className="w-full mx-0" padding="px-3 pb-10">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Design />
</RoleBasedSecurity>
<RoleBasedSecurity onlyStudent allowedRoles={[3]}>
<StudentCertificate />
</RoleBasedSecurity>
<!-- <Tabs autoWidth class="border-b border-gray-200 dark:border-neutral-600">
<Tab label="Design" />
<Tab label="Reports" />
<svelte:fragment slot="content">
<TabContent>
<Design />
</TabContent>
Temp disable Reports tab for V2
<TabContent>
<Reports />
</TabContent>
</svelte:fragment>
</Tabs> -->
</PageBody>
</Page.Body>
</CourseContainer>
@@ -5,8 +5,8 @@
import { lessons, lessonSections } from '$lib/components/Course/components/Lesson/store/lessons';
import { CourseContainer } from '$lib/components/CourseContainer';
import CourseLandingPage from '$lib/components/CourseLandingPage/index.svelte';
import Editor from '$lib/components/CourseLandingPage/components/Editor/index.svelte';
import { CourseLandingPage } from '$features/ui';
import Editor from '$features/ui/course-landing-page/components/editor/editor.svelte';
let { data } = $props();
@@ -1,159 +1,62 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import Box from '$lib/components/Box/index.svelte';
import ActivateSectionsModal from '$lib/components/Course/components/Lesson/ActivateSectionsModal.svelte';
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
import LessonList from '$lib/components/Course/components/Lesson/LessonList.svelte';
import LessonSectionList from '$lib/components/Course/components/Lesson/LessonSectionList.svelte';
import NewLessonModal from '$lib/components/Course/components/Lesson/NewLessonModal.svelte';
import { handleAddLessonWidget } from '$lib/components/Course/components/Lesson/store';
import { handleDelete, lessons, lessonSections } from '$lib/components/Course/components/Lesson/store/lessons';
import { course } from '$lib/components/Course/store';
import { LessonsPage } from '$features/course/pages';
import { CourseContainer } from '$lib/components/CourseContainer';
import { PageBody, PageNav } from '$lib/components/Page';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { Button } from '@cio/ui/base/button';
import { RoleBasedSecurity } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { profile } from '$lib/utils/store/user';
import type { Lesson } from '$lib/utils/types';
import { COURSE_VERSION } from '$lib/utils/types';
import { getGreeting } from '$lib/utils/functions/date.js';
import { course } from '$lib/components/Course/store';
import { handleAddLessonWidget } from '$lib/components/Course/components/Lesson/store';
let { data } = $props();
const query = new URLSearchParams(page.url.search);
const lessonsLength = $derived($course.version === COURSE_VERSION.V1 ? $lessons.length : $lessonSections.length);
let lessonEditing: string | undefined;
let lessonToDelete: Lesson | undefined = $state();
let openDeleteModal: boolean = $state(false);
let isFetching: boolean = $state(false);
let reorder = $state(false);
let activateSections = $state(false);
let lessonEditing: string | undefined;
function addLesson() {
$handleAddLessonWidget.open = true;
$handleAddLessonWidget.isSection = $course.version === COURSE_VERSION.V2;
}
function hasUserCompletedLesson(completion) {
return completion?.find((c) => c.profile_id === $profile.id);
}
const getLessons = () => {
if ($course.version === COURSE_VERSION.V1) {
return $lessons;
} else {
const _lessons: Lesson[] = [];
$lessonSections.forEach((section) => {
_lessons.push(...section.lessons);
});
return _lessons;
}
};
function findFirstIncompleteLesson() {
return getLessons().find(
(lesson) => !hasUserCompletedLesson(lesson.lesson_completion) && lesson.is_unlocked === true
);
}
function onNextQuery(lessons) {
if (!isFetching && lessons.length > 0) {
const incompleteLesson = findFirstIncompleteLesson();
if (incompleteLesson) {
goto(`/courses/${data.courseId}/lessons/${incompleteLesson.id}`);
} else {
goto(`/courses/${data.courseId}/lessons`);
}
}
}
let shouldGoToNextLesson = $derived(query.get('next') === 'true');
$effect(() => {
!isFetching && shouldGoToNextLesson && onNextQuery($lessons);
});
</script>
<NewLessonModal />
<svelte:head>
<title>Lessons - ClassroomIO</title>
</svelte:head>
<ActivateSectionsModal bind:open={activateSections} />
<DeleteLessonConfirmation bind:openDeleteModal deleteLesson={() => handleDelete(lessonToDelete?.id)} />
<CourseContainer
onFetchingChange={(fetching) => {
isFetching = fetching;
}}
courseId={data.courseId}
>
<PageNav title={$t('course.navItem.lessons.heading_v2')}>
{#snippet widget()}
<CourseContainer courseId={data.courseId}>
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t(getGreeting())}
{$profile.fullname}!
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<div class="flex w-full justify-end gap-2">
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if $course.version === COURSE_VERSION.V1}
<PrimaryButton
label={$t(`course.navItem.lessons.section_prompt.cta`)}
variant={VARIANTS.OUTLINED}
onClick={() => (activateSections = !activateSections)}
isDisabled={!!lessonEditing}
/>
<Button variant="outline" onclick={() => (activateSections = !activateSections)} disabled={!!lessonEditing}>
{$t(`course.navItem.lessons.section_prompt.cta`)}
</Button>
{/if}
<PrimaryButton
label={$t(`course.navItem.lessons.add_lesson.${reorder ? 'end_reorder' : 'start_reorder'}`)}
variant={VARIANTS.OUTLINED}
onClick={() => (reorder = !reorder)}
isDisabled={!!lessonEditing}
/>
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.button_title')}
onClick={addLesson}
isDisabled={!!lessonEditing}
/>
<Button variant="outline" onclick={() => (reorder = !reorder)} disabled={!!lessonEditing}>
{$t(`course.navItem.lessons.add_lesson.${reorder ? 'end_reorder' : 'start_reorder'}`)}
</Button>
<Button onclick={addLesson} disabled={!!lessonEditing}>
{$t('course.navItem.lessons.add_lesson.button_title')}
</Button>
</RoleBasedSecurity>
</div>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<LessonsPage courseId={data.courseId} />
{/snippet}
</PageNav>
<PageBody width="max-w-6xl" padding="p-0">
{#if shouldGoToNextLesson}
<Box className="w-full lg:w-11/12 lg:px-4 m-auto">
<div class="flex flex-col items-center justify-between">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="mx-auto my-2.5" />
<h2 class="my-1.5 text-xl font-normal">{$t('course.navItem.lessons.no_lesson')}</h2>
<p class="text-center text-sm text-slate-500">
{$t('course.navItem.lessons.share_your_knowledge')}
</p>
</div>
</Box>
{:else if lessonsLength > 0}
{#if reorder}
<p class="text-center text-xs italic text-gray-400 dark:text-white">
{$t('course.navItem.lessons.drag')}
</p>
{/if}
{#if $course.version === COURSE_VERSION.V1}
<LessonList {reorder} {lessonEditing} bind:lessonToDelete bind:openDeleteModal />
{:else if $course.version === COURSE_VERSION.V2}
<LessonSectionList {reorder} {lessonEditing} />
{/if}
{:else}
<Box className="w-full lg:w-11/12 lg:px-4 m-auto">
<div class="flex flex-col items-center justify-between">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="mx-auto my-2.5" />
<h2 class="my-1.5 text-xl font-normal">{$t('course.navItem.lessons.body_header')}</h2>
<p class="text-center text-sm text-slate-500">
{$t('course.navItem.lessons.body_content')}
</p>
</div>
</Box>
{/if}
</PageBody>
</Page.Body>
</CourseContainer>
@@ -10,13 +10,13 @@
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import { CircleCheckIcon } from '$features/ui/icons';
import MODES from '$lib/utils/constants/mode';
import { profile } from '$lib/utils/store/user';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { course, group } from '$lib/components/Course/store';
import { LANGUAGES } from '$lib/utils/constants/translation';
import { getGroupMemberId } from '$lib/components/Course/function';
@@ -26,10 +26,10 @@
import { checkExercisesComplete, fetchLesson, updateLessonCompletion } from '$lib/utils/services/courses';
import { lesson, setLesson, lessons, lessonSections } from '$lib/components/Course/components/Lesson/store/lessons';
import { IconButton } from '$lib/components/IconButton';
import { PageBody, PageNav } from '$lib/components/Page';
import { IconButton } from '@cio/ui/custom/icon-button';
import * as Page from '@cio/ui/base/page';
import { CourseContainer } from '$lib/components/CourseContainer';
import { RoleBasedSecurity } from '$lib/features/ui';
import { RoleBasedSecurity } from '$features/ui';
import Exercises from '$lib/components/Course/components/Lesson/Exercises/index.svelte';
import Materials from '$lib/components/Course/components/Lesson/Materials/index.svelte';
@@ -207,36 +207,28 @@
courseId={data.courseId}
isExercisePage={!data.isMaterialsTabActive && !!data.exerciseId}
>
<PageNav
hideOnMobile={$globalStore.isStudent}
navItems={[
{
label: $t('course.navItem.lessons.lesson_nav.materials'),
isActive: data.isMaterialsTabActive,
href: path
},
{
label: $t('course.navItem.lessons.lesson_nav.exercises'),
badgeValue: data.isMaterialsTabActive ? $lesson.totalExercises : $lesson.exercises.length,
isActive: !data.isMaterialsTabActive,
href: `${path}/exercises`
}
]}
>
{#snippet widget()}
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{data.isMaterialsTabActive
? $t('course.navItem.lessons.lesson_nav.materials')
: $t('course.navItem.lessons.lesson_nav.exercises')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<div class="flex items-center gap-1">
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if data.isMaterialsTabActive}
<!-- Version control -->
{#if mode === MODES.edit && window.innerWidth >= 1024}
<IconButton onClick={() => (isVersionDrawerOpen = true)}>
<IconButton onclick={() => (isVersionDrawerOpen = true)}>
<HistoryIcon size={20} />
</IconButton>
{/if}
<div class="flex-row items-center lg:flex">
<IconButton
onClick={() => {
onclick={() => {
toggleMode();
}}
disabled={isSaving}
@@ -266,13 +258,13 @@
{/if}
</RoleBasedSecurity>
</div>
{/snippet}
</PageNav>
</Page.Action>
</Page.Header>
{#if !data.isMaterialsTabActive}
<Exercises lessonId={data.lessonId} exerciseId={data.exerciseId} path={`${path}/exercises`} />
{:else if !!data.lessonId}
<PageBody isPageNavHidden={$globalStore.isStudent} width="lg:w-full xl:w-11/12" className="overflow-x-hidden">
<div class="overflow-x-hidden lg:w-full xl:w-11/12">
<!-- Shows Lesson Note / Slides / Video -->
<Materials
lessonId={data.lessonId}
@@ -282,7 +274,7 @@
bind:isSaving
isStudent={$globalStore.isStudent}
/>
</PageBody>
</div>
{/if}
<!-- Bottom Lesson Widget -->
@@ -1,133 +1,65 @@
<script lang="ts">
import jsPDF from 'jspdf';
import Papa from 'papaparse';
import { goto } from '$app/navigation';
import autoTable from 'jspdf-autotable';
import { CourseContainer } from '$lib/components/CourseContainer';
import { MarksPage } from '$features/course/pages';
import * as Page from '@cio/ui/base/page';
import { RoleBasedSecurity } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import type { AccountOrg } from '$features/app/types';
import { currentOrg } from '$lib/utils/store/org';
import { Button } from '@cio/ui/base/button';
import { Progress } from '@cio/ui/base/progress';
import BadgeXIcon from '@lucide/svelte/icons/badge-x';
import DownloadIcon from '@lucide/svelte/icons/download';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { currentOrg } from '$lib/utils/store/org';
import type { GroupPerson } from '$lib/utils/types';
import { t } from '$lib/utils/functions/translations';
import { fetchMarks } from '$lib/utils/services/marks';
import type { AccountOrg } from '$lib/features/app/types';
import { snackbar } from '$lib/components/Snackbar/store';
import { getLectureNo } from '$lib/components/Course/function.js';
import { fetchExercisesByMarks } from '$lib/utils/services/courses';
import Box from '$lib/components/Box/index.svelte';
import jsPDF from 'jspdf';
import Papa from 'papaparse';
import autoTable from 'jspdf-autotable';
import { course } from '$lib/components/Course/store';
import { PageBody, PageNav } from '$lib/components/Page';
import { CourseContainer } from '$lib/components/CourseContainer';
import { RoleBasedSecurity } from '$lib/features/ui';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import type { GroupPerson } from '$lib/utils/types';
import { getLectureNo } from '$lib/components/Course/function.js';
let { data } = $props();
let borderBottomGrey = 'border-r-0 border-t-0 border-b border-l-0 border-gray-300';
let borderleftGrey = 'border-r-0 border-t-0 border-b-0 border-l border-gray-300';
let students: GroupPerson[] = $state([]);
let lessonMapping: Record<string, Record<string, { title: string; points: number }>> = $state({}); // { lessonId: { exerciseId: exerciseTitle, ... }, ... }
let studentMarksByExerciseId: Record<string, Record<string, string>> = $state({}); // { groupMemberId: { exerciseId: `total_gotten/points`, ... }, ... }
let isDownloading = $state(false);
let hasFetched = $state(false);
let students: GroupPerson[] = $state([]);
let lessonMapping: Record<string, Record<string, { title: string; points: number }>> = $state({});
let studentMarksByExerciseId: Record<string, Record<string, string>> = $state({});
function calculateStudentTotal(studentExerciseData) {
if (!studentExerciseData) return 0;
return Object.values(studentExerciseData).reduce((total: number, point) => (total += parseInt(point as string)), 0);
}
async function firstRender(courseId?: string) {
if (!courseId || hasFetched) return;
hasFetched = true;
const { data: marks, error } = await fetchMarks(courseId);
if (error) {
console.error('Error fetching marks', error);
snackbar.error('snackbar.marks.error');
return;
}
if (!marks || !Array.isArray(marks)) return;
marks.forEach((mark) => {
const { groupmember_id, exercise_id, total_points_gotten } = mark;
if (studentMarksByExerciseId[groupmember_id]) {
studentMarksByExerciseId[groupmember_id] = {
...studentMarksByExerciseId[groupmember_id],
[exercise_id]: total_points_gotten
};
} else {
studentMarksByExerciseId[groupmember_id] = {
[exercise_id]: total_points_gotten
};
}
});
const { data: exercises } = await fetchExercisesByMarks(courseId);
// Map exercises to { lessonId: { exerciseId: exerciseTitle, ... }, ... }
exercises.forEach((exercise) => {
const { lesson_id, exercise_id, exercise_title, points } = exercise;
if (lessonMapping[lesson_id]) {
lessonMapping[lesson_id] = {
...lessonMapping[lesson_id],
[exercise_id]: { title: exercise_title, points }
};
} else {
lessonMapping[lesson_id] = {
[exercise_id]: { title: exercise_title, points }
};
}
});
}
// TODO WRITE A REDIRECT FUNCTION FOR THIS THIS PAGE
function getPageRoles(org: AccountOrg) {
const roles = [1, 2];
if (org.customization?.course?.grading) {
roles.push(3);
}
return roles;
}
const downloadCSV = () => {
isDownloading = true;
const exportData = students.map((student) => {
const rowData = {
name: student.profile.fullname,
email: student.profile.email,
Total: 0
};
const totalPoints = calculateStudentTotal(studentMarksByExerciseId[student.id]);
$lessons.forEach((lesson, lessonIndex) => {
const quizzes = lessonMapping[lesson.id] || {};
const quizMark = studentMarksByExerciseId[student.id] || {};
Object.keys(quizzes).forEach((quizId) => {
const quiz = quizzes[quizId];
const key = `L${lessonIndex + 1} - ${quiz.title} (${lesson.title})`;
rowData[key] = quizMark[quizId] || '-';
});
});
rowData['Total'] = totalPoints;
return rowData;
});
const csv = Papa.unparse(exportData);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
@@ -137,20 +69,14 @@
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
isDownloading = false;
};
function downloadPDF() {
isDownloading = true;
try {
const doc = new jsPDF({
orientation: 'landscape'
});
// Construct Headers
const head = [['Student']]; // Starting with 'Student' as the first header
const doc = new jsPDF({ orientation: 'landscape' });
const head = [['Student']];
$lessons.forEach((lesson, lessonIndex) => {
if (lessonMapping[lesson.id]) {
Object.values(lessonMapping[lesson.id]).forEach((exercise) => {
@@ -158,13 +84,10 @@
});
}
});
head[0].push('Total'); // Add 'Total' as the last header
// Construct Rows
head[0].push('Total');
const body = students.map((student) => {
const row: string[] = [];
row.push(student?.profile?.fullname ?? ''); // Student name
row.push(student?.profile?.fullname ?? '');
$lessons.forEach((lesson) => {
if (lessonMapping[lesson.id]) {
Object.keys(lessonMapping[lesson.id]).forEach((exerciseId) => {
@@ -173,15 +96,10 @@
});
}
});
// Calculate total
const total = '' + calculateStudentTotal(studentMarksByExerciseId[student.id]);
row.push(total);
return row;
});
// Generate the table in PDF
autoTable(doc, {
head,
body,
@@ -190,14 +108,11 @@
headStyles: { fillColor: [22, 160, 133] },
footStyles: { fillColor: [22, 160, 133] },
didDrawPage: function (data) {
// Header
doc.setFontSize(14);
doc.setTextColor(40);
doc.text(`${$course?.title} - Marks`, data.settings.margin.left, 10);
}
});
// Save the PDF
doc.save(`${$course?.title}-marks.pdf`);
} catch (error) {
console.error('Error generating PDF:', error);
@@ -205,12 +120,12 @@
isDownloading = false;
}
}
$effect(() => {
firstRender($course.id);
});
</script>
<svelte:head>
<title>Marks - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<RoleBasedSecurity
allowedRoles={getPageRoles($currentOrg)}
@@ -218,132 +133,43 @@
goto(`/courses/${data.courseId}/lessons?next=true`);
}}
>
<PageNav title={$t('course.navItem.marks.title')}>
{#snippet widget()}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon" class="rounded-full">
{#if isDownloading}
<Progress />
{:else}
<DownloadIcon size={16} />
{/if}
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={downloadCSV}>
{$t('course.navItem.marks.export.csv')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={downloadPDF}>
{$t('course.navItem.marks.export.pdf')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</RoleBasedSecurity>
{/snippet}
</PageNav>
<PageBody width="w-full max-w-6xl md:w-11/12">
<div id="tableContainer" class="table w-full rounded-md border border-gray-300">
<div class="flex items-center {borderBottomGrey}">
<div class="box flex items-center p-3">
<p class="w-40 dark:text-white">{$t('course.navItem.marks.student')}</p>
</div>
{#each $lessons as lesson, index}
{#if lessonMapping[lesson.id]}
<div class="box flex flex-col items-center {borderleftGrey}">
<p class="col lesson-number dark:text-white" title={lesson.title}>
{getLectureNo(index + 1)}
</p>
<div class="flex h-full items-center border-b-0 border-l-0 border-r-0 border-t border-gray-300">
{#each Object.keys(lessonMapping[lesson.id]) as exerciseId, index}
<p
class="col text-sm dark:text-white {index && borderleftGrey}"
title={lessonMapping[lesson.id][exerciseId].title}
>
{lessonMapping[lesson.id][exerciseId].title}
<span>({lessonMapping[lesson.id][exerciseId].points})</span>
</p>
{/each}
</div>
</div>
{/if}
{/each}
<div class="box flex items-center {borderleftGrey}">
<p class="w-20 text-center dark:text-white">{$t('course.navItem.marks.total')}</p>
</div>
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t('course.navItem.marks.title')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<div class="flex w-full justify-end gap-2">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon" class="rounded-full">
{#if isDownloading}
<Progress />
{:else}
<DownloadIcon size={16} />
{/if}
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={downloadCSV}>
{$t('course.navItem.marks.export.csv')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={downloadPDF}>
{$t('course.navItem.marks.export.pdf')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</RoleBasedSecurity>
</div>
</Page.Action>
</Page.Header>
{#each students as student}
<div class="relative flex cursor-pointer items-center p-3 {borderBottomGrey}">
<div class="flex w-40 items-center">
<img alt="Student avatar" src={student.profile.avatar_url} class="mr-2 h-8 w-8 rounded-full" />
<div class="text-sm">
<p class="font-semibold dark:text-white">
{student.profile.fullname}
</p>
<p class="dark:text-white">
{`${student.assigned_student_id ? '#' + student.assigned_student_id : '-'}`}
</p>
</div>
</div>
{#each $lessons as lesson}
{#if lessonMapping[lesson.id]}
<div class="flex items-center">
{#each Object.keys(lessonMapping[lesson.id]) as exerciseId}
<p class="col dark:text-white">
{studentMarksByExerciseId[student.id]
? studentMarksByExerciseId[student.id][exerciseId] || '-'
: '-'}
</p>
{/each}
</div>
{/if}
{/each}
<div class="flex w-20 items-center">
<div class="text-sm">
<p class="col dark:text-white">
{calculateStudentTotal(studentMarksByExerciseId[student.id])}
</p>
</div>
</div>
</div>
{:else}
<Box>
<BadgeXIcon size={16} />
<h3 class="text-3xl text-gray-500 dark:text-white">
{$t('course.navItem.marks.no_student')}
</h3>
</Box>
{/each}
</div>
</PageBody>
<Page.Body>
{#snippet child()}
<MarksPage courseId={data.courseId} />
{/snippet}
</Page.Body>
</RoleBasedSecurity>
</CourseContainer>
<style>
.col {
text-align: center;
padding: 5px;
width: 100px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.br {
border-right: 1px solid rgba(209, 213, 219, var(--tw-border-opacity));
}
.box {
height: 95px;
}
.lesson-number {
height: 30px;
}
</style>
@@ -1,12 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { IconButton } from '$lib/components/IconButton';
import { PageBody, PageNav } from '$lib/components/Page';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { IconButton } from '@cio/ui/custom/icon-button';
import { Button } from '@cio/ui/base/button';
import { RoleBasedSecurity } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import * as Page from '@cio/ui/base/page';
import { CourseContainer } from '$lib/components/CourseContainer';
let { data = $bindable(), children } = $props();
@@ -29,25 +29,35 @@
</script>
<CourseContainer courseId={data.courseId}>
<PageNav title={$t('course.navItem.people.title')} disableSticky={true}>
{#snippet image()}
<div class="mx-auto w-full max-w-3xl">
<Page.Header>
{#if data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<IconButton size="large" onClick={handleBackNavigation}>
<IconButton onclick={handleBackNavigation}>
<ArrowLeftIcon size={16} />
</IconButton>
</RoleBasedSecurity>
{/if}
{/snippet}
{#snippet widget()}
{#if !data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<PrimaryButton className="mr-2" label={$t('course.navItem.people.add')} onClick={handleClick} />
</RoleBasedSecurity>
{/if}
{/snippet}
</PageNav>
<PageBody width="w-full max-w-6xl md:w-11/12">
{@render children?.()}
</PageBody>
<Page.HeaderContent>
<Page.Title>
{$t('course.navItem.people.title')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
{#if !data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Button class="mr-2" onclick={handleClick}>
{$t('course.navItem.people.add')}
</Button>
</RoleBasedSecurity>
{/if}
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
{@render children?.()}
{/snippet}
</Page.Body>
</div>
</CourseContainer>
@@ -1,245 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import * as Table from '@cio/ui/base/table';
import CopyIcon from '@lucide/svelte/icons/copy';
import TrashIcon from '@lucide/svelte/icons/trash';
import CheckIcon from '@lucide/svelte/icons/check';
import SearchIcon from '@lucide/svelte/icons/search';
import { PeoplePage } from '$features/course/pages';
import TextChip from '$lib/components/Chip/Text.svelte';
import * as Avatar from '@cio/ui/base/avatar';
import { ComingSoon } from '$lib/features/ui';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import InvitationModal from '$lib/components/Course/components/People/InvitationModal.svelte';
import DeleteConfirmation from '$lib/components/Course/components/People/DeleteConfirmation.svelte';
import { profile } from '$lib/utils/store/user';
import type { GroupPerson } from '$lib/utils/types';
import { group } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import { shortenName } from '$lib/utils/functions/string';
import Select from '$lib/components/Form/Select.svelte';
import { IconButton } from '$lib/components/IconButton';
import { ROLE_LABEL, ROLES } from '$lib/utils/constants/roles';
import { deleteGroupMember } from '$lib/utils/services/courses';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import type { ProfileRole } from '$lib/components/Course/components/People/types';
import { deleteMemberModal } from '$lib/components/Course/components/People/store';
let member: { id?: string; email?: string; profile?: { email: string } } = $state({});
let filterBy: ProfileRole = $state(ROLES[0]);
let searchValue = $state('');
let copiedEmail = $state<string | null>(null);
const people: Array<GroupPerson> = $derived(sortAndFilterPeople($group.people, filterBy));
function filterPeople(_query, people) {
const query = _query.toLowerCase();
return people.filter((person) => {
const { profile, email } = person;
return profile?.fullname?.toLowerCase()?.includes(query) || email?.includes(query);
});
}
async function deletePerson() {
if (!member.id) return;
$group.people = $group.people.filter((person: { id: string }) => person.id !== member.id);
$group.tutors = $group.tutors.filter((person: GroupPerson) => person.memberId !== member.id);
await deleteGroupMember(member.id);
}
function sortAndFilterPeople(_people: Array<GroupPerson>, filterBy: ProfileRole) {
return (_people || [])
.filter((person) => {
if (filterBy.value === 'all') return true;
return person.role_id === filterBy.value;
})
.sort((a: GroupPerson, b: GroupPerson) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.sort((a: GroupPerson, b: GroupPerson) => a.role_id - b.role_id);
}
function getEmail(person) {
const { profile, email } = person;
return profile ? profile.email : email;
}
function obscureEmail(email) {
const [username, domain] = email.split('@');
const obscuredUsername =
username.charAt(0) + '*'.repeat(username.length - 2) + username.charAt(username.length - 1);
return `${obscuredUsername}@${domain}`;
}
function gotoPerson(person) {
goto(`${page.url.href}/${person.profile_id}`);
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
copiedEmail = text;
setTimeout(() => {
copiedEmail = null;
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
let { data } = $props();
</script>
<InvitationModal />
<DeleteConfirmation email={member.email || (member.profile && member.profile.email)} {deletePerson} />
<section class="mx-2 my-5 md:mx-9">
<div class="flex-end mb-7 flex flex-col items-start justify-end gap-2 md:flex-row md:items-center">
<div class="relative max-w-[320px]">
<Input type="text" placeholder={$t('course.navItem.people.search')} bind:value={searchValue} class="w-full" />
<SearchIcon class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2" />
</div>
<div class="mb-3">
<Select
bind:value={filterBy}
options={ROLES.map((role) => ({ label: $t(role.label), value: role.value }))}
className="dark:text-black mt-3 max-w-[80px]"
/>
</div>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<p class="hidden w-20 text-lg lg:block dark:text-white"></p>
</RoleBasedSecurity>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('course.navItem.people.name')}</Table.Head>
<Table.Head>{$t('course.navItem.people.role')}</Table.Head>
<Table.Head>{$t('course.navItem.people.action')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filterPeople(searchValue, people) as person}
<Table.Row>
<!-- first column -->
<Table.Cell class="w-4/6 md:w-3/6">
{#if person.profile}
<div class="flex items-start lg:items-center">
<Avatar.Root class="mr-3">
<Avatar.Image
src={person.profile.avatar_url ? person.profile.avatar_url : '/logo-192.png'}
alt={person.profile.fullname ? person.profile.fullname : 'User'}
/>
<Avatar.Fallback>{shortenName(person.profile.fullname) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col items-start lg:flex-row lg:items-center">
<div class="mr-2">
<p class="text-base font-normal dark:text-white">
{person.profile.fullname}
</p>
<p class="text-primary-600 line-clamp-1 text-xs">
{obscureEmail(getEmail(person))}
</p>
</div>
<div class="flex items-center">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => copyToClipboard(getEmail(person))}
>
{#if copiedEmail === getEmail(person)}
<CheckIcon size={16} class="text-green-600" />
{:else}
<CopyIcon size={16} />
{/if}
</Button>
</RoleBasedSecurity>
{#if person.profile_id == $profile.id}
<ComingSoon label={$t('course.navItem.people.you')} />
{/if}
</div>
</div>
</div>
{:else}
<div class="flex w-2/4 items-start lg:items-center">
<TextChip
value={shortenName(person.email)}
className="bg-primary-200 text-black font-semibold text-xs mr-3"
shape="rounded-full"
/>
<a href="mailto:{person.email}" class="text-md text-primary-600 mr-2 dark:text-white">
{person.email}
</a>
<div class="flex items-center justify-between">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => copyToClipboard(getEmail(person))}
>
{#if copiedEmail === getEmail(person)}
<CheckIcon size={16} class="text-green-600" />
{:else}
<CopyIcon size={16} />
{/if}
</Button>
</RoleBasedSecurity>
<TextChip
value={$t('course.navItem.people.pending')}
className="text-xs bg-yellow-200 text-yellow-700 h-fit"
size="sm"
/>
</div>
</div>
{/if}
</Table.Cell>
<!-- second column -->
<Table.Cell class="w-1/4">
<p class=" w-1/4 text-center text-base font-normal dark:text-white">
{$t(ROLE_LABEL[person.role_id])}
</p>
</Table.Cell>
<!-- third column -->
<Table.Cell class="w-1/4">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="hidden space-x-2 sm:flex sm:items-center">
{#if person.profile_id !== $profile.id}
<IconButton
onClick={() => {
member = person;
$deleteMemberModal.open = true;
}}
>
<TrashIcon size={16} />
</IconButton>
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.people.view')}
onClick={() => gotoPerson(person)}
/>
{/if}
</div>
</RoleBasedSecurity>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
</section>
<PeoplePage courseId={data.courseId} />
@@ -7,13 +7,12 @@
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { getAccessToken } from '$lib/utils/functions/supabase';
import type { UserCourseAnalytics } from '$lib/utils/types/analytics';
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 { Progress } from '@cio/ui/base/progress';
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
let { data } = $props();
@@ -62,7 +61,7 @@
{
description: $t('analytics.overall_course_progress_user_description'),
icon: BookOpenIcon,
percentage: userCourseAnalytics?.progressPercentage,
percentage: userCourseAnalytics?.progressPercentage ?? 0,
title: $t('analytics.overall_course_progress')
},
{
@@ -77,7 +76,7 @@
{
description: $t('analytics.average_grade_description'),
icon: UnfoldVerticalIcon,
percentage: userCourseAnalytics?.averageGrade,
percentage: userCourseAnalytics?.averageGrade ?? 0,
title: $t('analytics.average_grade')
}
]);
@@ -102,13 +101,13 @@
<section class="px-1">
<HeroProfileCard user={userCourseAnalytics.user} />
<Grid class="mt-5 px-0">
<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 learningActivities as activity}
<ActivityCard {activity} />
{/each}
</div>
</Grid>
</div>
<div class="mt-5 rounded-md border p-3 md:p-5 dark:border-neutral-600">
<h3 class="text-2xl">
@@ -1,15 +1,47 @@
<script lang="ts">
import Settings from '$lib/components/Course/components/Settings/index.svelte';
import { PageBody, PageNav } from '$lib/components/Page';
import { CourseSettingsPage } from '$features/course/pages';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { CourseContainer } from '$lib/components/CourseContainer';
import { Button } from '@cio/ui/base/button';
let { data } = $props();
let settingsComponent: CourseSettingsPage | null = $state(null);
let isSaving = $state(false);
async function handleSave() {
isSaving = true;
try {
await settingsComponent?.handleSave();
} finally {
isSaving = false;
}
}
</script>
<svelte:head>
<title>Settings - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<PageNav title={$t('course.navItem.settings.heading')} />
<PageBody>
<Settings />
</PageBody>
<div class="mx-auto w-full max-w-3xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t('course.navItem.settings.heading')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="secondary" loading={isSaving} onclick={handleSave}>
{$t('course.navItem.settings.save')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CourseSettingsPage bind:this={settingsComponent} />
{/snippet}
</Page.Body>
</div>
</CourseContainer>
@@ -1,459 +1,31 @@
<script lang="ts">
import { page } from '$app/state';
import { flip } from 'svelte/animate';
import { goto } from '$app/navigation';
import { dndzone } from 'svelte-dnd-action';
import {
deleteSubmission,
fetchSubmissions,
updateQuestionAnswer,
updateSubmission
} from '$lib/utils/services/submissions';
import { course } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import formatDate from '$lib/utils/functions/formatDate';
import { snackbar } from '$lib/components/Snackbar/store';
import { formatAnswers } from '$lib/components/Course/function.js';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import isSubmissionEarly from '$lib/utils/functions/isSubmissionEarly';
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
import type { SubmissionIdData, SubmissionItem, SubmissionSection } from '$lib/utils/types/submission';
import { Skeleton } from '@cio/ui/base/skeleton';
import Chip from '$lib/components/Chip/index.svelte';
import { PageBody, PageNav } from '$lib/components/Page';
import { SubmissionsPage } from '$features/course/pages';
import { CourseContainer } from '$lib/components/CourseContainer';
import { RoleBasedSecurity } from '$lib/features/ui';
import MarkExerciseModal from '$lib/components/Course/components/Lesson/Exercise/MarkExerciseModal.svelte';
import { RoleBasedSecurity } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
let { data = $bindable() } = $props();
const { courseId } = data;
const flipDurationMs = 300;
let exerciseDetails: { id: string; title: string };
let lessonDetails: { id: string; title: string };
let totalMark = 0;
let maxMark = 0;
let submissionIdData: { [key: number]: SubmissionIdData } = $state({});
let isGradeWithAI = $state(false);
let fetching = $state(false);
let isSaving = $state(false);
let hasFetched = $state(false);
const submissionId = $derived(new URLSearchParams(page.url.search).get('submissionId') ?? '');
let openExercise = $derived.by(() => {
return !!submissionId && !!submissionIdData[submissionId];
});
const submissionStatus: { [key: number]: string } = {
1: $t('course.navItem.submissions.submission_status.submitted'),
2: $t('course.navItem.submissions.submission_status.in_progress'),
3: $t('course.navItem.submissions.submission_status.graded')
};
let sections: SubmissionSection[] = $state([
{
id: 1,
title: $t('course.navItem.submissions.submission_status.submitted'),
value: 0,
items: []
},
{
id: 2,
title: $t('course.navItem.submissions.submission_status.in_progress'),
value: 0,
items: []
},
{
id: 3,
title: $t('course.navItem.submissions.submission_status.graded'),
value: 10,
items: []
}
]);
function getMaxPoints(questions) {
return (questions || []).reduce((acc, question) => acc + question.points, 0);
}
function calculateTotal(grades: string[]): number {
if (!grades) return 0;
return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade), 0);
}
const notifyStudent = (submissionData) => {
maxMark = getMaxPoints(submissionData?.questions);
totalMark = calculateTotal(submissionData?.questionAnswerByPoint);
const { fullname, email } = submissionData?.student;
const { title, statusId }: { title: string; statusId: number } = submissionData;
const exerciseLink = `${$currentOrgDomain}/courses/${courseId}/lessons/${lessonDetails.id}/exercises/${exerciseDetails.id}`;
const content = `
<p>Hello ${fullname},</p>
<p>The status of your submitted exercise on <strong>${title}</strong> has been updated to ${
submissionStatus[statusId]
}</p>
${
statusId == 3
? `<p>Your score was ${totalMark}/${maxMark}</p>
<a class="button" href="${exerciseLink}">View your Result</a>
`
: `<a class="button" href="${exerciseLink}">Open Exercise</a>`
}
<p>This exercise is for <strong>${
lessonDetails.title
}</strong> in a course you are taking titled <strong>${$course.title}</strong></p>
`;
triggerSendEmail(NOTIFICATION_NAME.SUBMISSION_UPDATE, {
to: email,
content,
orgName: $currentOrg?.name
});
};
function handleItemFinalize(
columnIdx: number,
newItems: { map: (arg0: (item: SubmissionItem) => SubmissionItem) => SubmissionItem[] }
) {
let itemToWithNewStatus: SubmissionItem | undefined;
const { id } = sections[columnIdx];
// Set column in the UI
sections[columnIdx].items = newItems.map((item) => {
if (item.statusId !== id) {
itemToWithNewStatus = item;
item.statusId = id;
}
return item;
});
// Update backend
if (itemToWithNewStatus) {
// Update key mapping for each submission also
submissionIdData[itemToWithNewStatus.id] = {
...submissionIdData[itemToWithNewStatus.id],
statusId: itemToWithNewStatus.statusId
};
notifyStudent(submissionIdData[itemToWithNewStatus.id]);
updateSubmission({
id: itemToWithNewStatus.id,
status_id: itemToWithNewStatus.statusId
}).then((res) => console.log('Updated submission', res));
}
}
function handleDndConsiderCards(columnIdx: number) {
return function (e) {
sections[columnIdx].items = e.detail.items;
};
}
function handleDndFinalizeCards(columnIdx: number) {
return (e) => handleItemFinalize(columnIdx, e.detail.items);
}
function handleModalClose() {
isGradeWithAI = false;
goto(page.url.pathname);
}
// Via dialog
function updateStatus({
submissionId,
prevStatusId,
nextStatusId,
total
}: {
submissionId: string;
prevStatusId: number;
nextStatusId: number;
total: number;
}) {
let itemToWithNewStatus: SubmissionItem | undefined;
// Remove from current column
const { items } = sections[prevStatusId - 1];
sections[prevStatusId - 1].items = items?.filter((item) => {
if (item.id === submissionId) {
itemToWithNewStatus = Object.assign(item);
if (itemToWithNewStatus) {
itemToWithNewStatus.statusId = nextStatusId;
}
return false;
}
return true;
});
// Move to right column
if (itemToWithNewStatus) {
sections[nextStatusId - 1].items = [...sections[nextStatusId - 1].items, itemToWithNewStatus];
}
// If something changed
if (itemToWithNewStatus) {
// Update key mapping for each submission also
submissionIdData[itemToWithNewStatus.id] = {
...submissionIdData[itemToWithNewStatus.id],
statusId: itemToWithNewStatus.statusId
};
notifyStudent(submissionIdData[itemToWithNewStatus.id]);
// Update backend
updateSubmission({
id: itemToWithNewStatus.id,
status_id: itemToWithNewStatus.statusId,
total
}).then((res) => console.log('Updated submission', res));
}
}
async function handleDeleteSubmission(id: string, statusId: number) {
const { items } = sections[statusId - 1];
sections[statusId - 1].items = items?.filter((item) => {
return item.id === id ? false : true;
});
submissionIdData[id] = undefined;
const { error } = await deleteSubmission(id);
if (error) {
console.error('Error deleting submission', error);
snackbar.error('course.navItem.submissions.grading_modal.delete_error');
return;
}
snackbar.success('course.navItem.submissions.grading_modal.delete_success');
handleModalClose();
}
async function handleSave(submission: { questionAnswerByPoint: any; questionAnswers: any; feedback: any }) {
isSaving = true;
const { questionAnswerByPoint, questionAnswers, feedback } = submission;
let totalPoints = 0;
for (const questionId in questionAnswerByPoint) {
if (Object.prototype.hasOwnProperty.call(questionAnswerByPoint, questionId)) {
const questionAnswer = questionAnswers.find(
(answer: { question_id: string }) => answer.question_id == questionId
);
const point = questionAnswerByPoint[questionId];
totalPoints += parseInt(point, 10);
const qaResponse = await updateQuestionAnswer({ point }, { id: questionAnswer?.id });
if (qaResponse.error) {
console.error('Error saving', qaResponse.error);
snackbar.error(`snackbar.something`);
return;
}
}
}
await updateSubmission({
id: submissionId,
total: totalPoints,
feedback: feedback
});
snackbar.success('snackbar.submissions.success.grading');
isSaving = false;
}
async function firstRender(courseId?: string) {
if (!courseId || hasFetched) return;
hasFetched = true;
fetching = true;
const { data: submissions } = await fetchSubmissions(courseId);
const sectionById: { [key: number]: SubmissionSection[] } = {};
if (submissions) {
for (const submission of submissions) {
const { id, created_at, answers, groupmember, feedback, exercise } = submission;
const isEarly = isSubmissionEarly(created_at, exercise.due_by);
const submissionItem = {
id,
statusId: submission.status_id,
isEarly,
feedback,
submittedAt: formatDate(created_at),
exercise: {
id: exercise.id,
title: exercise.title
},
answers,
student: groupmember && groupmember.profile ? groupmember.profile : {},
lesson: {
id: exercise.lesson.id,
title: exercise.lesson.title
}
};
exerciseDetails = { id: submissionItem.exercise.id, title: submissionItem.exercise.title };
lessonDetails = {
id: submissionItem.lesson.id,
title: submissionItem.lesson.title
};
submissionIdData[id] = {
id,
statusId: submissionItem.statusId,
feedback,
isEarly,
title: exercise.title,
student: submissionItem.student,
questions: exercise.questions,
answers: formatAnswers({ questions: exercise.questions, answers }),
questionAnswers: answers,
questionAnswerByPoint: answers.reduce((acc, answer) => {
acc[answer.question_id] = answer.point;
return acc;
}, {})
};
if (Array.isArray(sectionById[submission.status_id])) {
sectionById[submission.status_id].push(submissionItem);
} else {
sectionById[submission.status_id] = [submissionItem];
}
}
}
sections = sections.map((section, index) => ({
...section,
value: Array.isArray(sectionById[index + 1]) ? sectionById[index + 1].length : 0,
items: Array.isArray(sectionById[index + 1]) ? sectionById[index + 1] : []
}));
fetching = false;
}
$effect(() => {
firstRender($course.id);
});
let { data } = $props();
</script>
<MarkExerciseModal
bind:open={openExercise}
onClose={handleModalClose}
data={submissionIdData[submissionId] || {}}
{handleSave}
{updateStatus}
deleteSubmission={handleDeleteSubmission}
bind:isGradeWithAI
{isSaving}
/>
<svelte:head>
<title>Submissions - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<PageNav title={$t('course.navItem.submissions.title')} />
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t('course.navItem.submissions.title')}
</Page.Title>
</Page.HeaderContent>
</Page.Header>
<PageBody width="w-full max-w-6xl md:w-11/12 overflow-x-auto">
<div class="flex w-full items-center">
{#each sections as { id, title, items }, idx (id)}
<div
class="section mr-3 h-80 overflow-hidden rounded-md border border-gray-50 bg-gray-100 p-3 dark:border-neutral-600 dark:bg-black"
animate:flip={{ duration: flipDurationMs }}
>
<div class="mb-2 flex items-center">
<Chip value={items.length} className="bg-set dark:bg-neutral-800" />
<p class="ml-2 dark:text-white">{title}</p>
</div>
{#if fetching}
<Skeleton class="my-2 h-[170px] w-full rounded-md" />
<Skeleton class="my-2 h-[170px] w-full rounded-md" />
<Skeleton class="my-2 h-[170px] w-full rounded-md" />
{:else}
<div
class="content mb-3 overflow-y-auto pr-2"
use:dndzone={{
items,
flipDurationMs,
dropTargetStyle: { outline: 'blue' }
}}
onconsider={handleDndConsiderCards(idx)}
onfinalize={handleDndFinalizeCards(idx)}
>
{#each items as item (item.id)}
<div
class="{item.isEarly
? 'border-none'
: 'border border-red-700'} mx-0 my-2 w-full rounded-md bg-white px-3 py-3 dark:bg-neutral-800"
animate:flip={{ duration: flipDurationMs }}
>
<a
class="mb-2 flex w-full cursor-pointer items-center text-black"
href={`${page.url.pathname}?submissionId=${item.id}`}
>
<img alt="Student avatar" class="block h-6 w-6 rounded-full" src={item.student.avatar_url} />
<p class="ml-2 text-sm dark:text-white">
{item.student.username}
</p>
</a>
<a class="text-primary-700 text-md" href="{page.url.pathname}?submissionId={item.id}">
{item.exercise.title}
</a>
<a
class="my-2 flex items-center text-black no-underline hover:underline"
href="{page.url?.pathname?.replace('submissions', 'lessons')}/{item.lesson.id}/exercises/{item
.exercise.id}"
>
<p class="text-grey text-sm dark:text-white">
#{item.lesson.title}
</p>
</a>
<p class="text-xs text-gray-500 dark:text-white">
{item.submittedAt}
</p>
<!-- <div class="badge rounded-md px-2 bg-green-500 text-white">
early
</div>
<div class="badge rounded-md px-2 bg-red-600 text-white">
late
</div> -->
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
</PageBody>
<Page.Body>
{#snippet child()}
<SubmissionsPage courseId={data.courseId} />
{/snippet}
</Page.Body>
</RoleBasedSecurity>
</CourseContainer>
<style>
.section {
max-width: 355px;
min-width: 355px;
height: 75vh;
}
.content {
height: 95%;
}
@media screen and (max-width: 768px) {
.section {
min-width: 250px;
}
}
</style>
+1 -1
View File
@@ -1,5 +1,5 @@
<script>
import OrgLandingPage from '$lib/components/Org/LandingPage/index.svelte';
import { OrgLandingPage } from '$features/org';
import { globalStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org';
</script>
@@ -1,4 +1,4 @@
import { OrgApiServer } from '$lib/features/org/api/org.server';
import { OrgApiServer } from '$features/org/api/org.server';
import { redirect } from '@sveltejs/kit';
export const load = async ({ params = { hash: '' } }) => {
@@ -1,16 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { Button } from '@cio/ui/base/button';
import { getSupabase } from '$lib/utils/functions/supabase';
import { AuthUI } from '$lib/features/ui';
import { AuthUI } from '$features/ui';
import { currentOrg } from '$lib/utils/store/org';
import { setTheme } from '$lib/utils/functions/theme';
import { addGroupMember } from '$lib/utils/services/courses';
import { ROLE } from '@cio/utils/constants';
import { profile } from '$lib/utils/store/user';
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
import { snackbar } from '$lib/components/Snackbar/store.js';
import { snackbar } from '$features/ui/snackbar/store';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { page } from '$app/state';
@@ -130,11 +130,12 @@
</div>
<div class="my-4 flex w-full items-center justify-center">
<PrimaryButton
label="Join Course"
<Button
type="submit"
isDisabled={disableSubmit || loading}
isLoading={loading || !$profile.id}
/>
disabled={disableSubmit || loading}
loading={loading || !$profile.id}
>
Join Course
</Button>
</div>
</AuthUI>
@@ -1,4 +1,4 @@
import { OrgApiServer } from '$lib/features/org/api/org.server';
import { OrgApiServer } from '$features/org/api/org.server';
import { getProfile } from '$lib/utils/functions/user';
import { redirect } from '@sveltejs/kit';
@@ -1,10 +1,11 @@
<script lang="ts">
import { page } from '$app/state';
import { AuthUI } from '$lib/features/ui';
import { AuthUI } from '$features/ui';
import type { Profile } from '$lib/components/Course/components/People/types';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Password } from '@cio/ui/custom/password';
import { SIGNUP_FIELDS } from '$lib/utils/constants/authentication';
import { logout } from '$lib/utils/functions/logout';
import { getSupabase } from '$lib/utils/functions/supabase';
@@ -14,7 +15,7 @@
import { authValidation, getConfirmPasswordError, getDisableSubmit } from '$lib/utils/functions/validator';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { onMount, untrack } from 'svelte';
import { snackbar } from '$lib/components/Snackbar/store.js';
import { snackbar } from '$features/ui/snackbar/store';
let { data } = $props();
@@ -179,54 +180,63 @@
{$t('login.create_to_join')}
{/if}
</p>
<TextField
<InputField
label={$t('login.fields.email')}
value={data.invite.email}
type="email"
placeholder="you@domain.com"
className="mb-6"
inputClassName="w-full"
isDisabled={true}
/>
{#if $profile?.email !== data.invite.email}
{#if !data.invite.profile}
<TextField
<InputField
label={$t('login.fields.full_name')}
bind:value={fields.name}
type="text"
autoFocus={true}
placeholder="e.g Joke Silva"
className="mb-6"
inputClassName="w-full"
isDisabled={isLoading}
errorMessage={errors.name}
isRequired
/>
{/if}
<TextField
label={$t('login.fields.password')}
bind:value={fields.password}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={isLoading}
errorMessage={errors.password}
helperMessage={$t('login.fields.password_helper_message')}
isRequired
/>
<Field.Field class="mb-6">
<Field.Label for="password">{$t('login.fields.password')}</Field.Label>
<Field.Content>
<Password
id="password"
bind:value={fields.password}
placeholder="************"
disabled={isLoading}
aria-invalid={errors.password ? 'true' : undefined}
autocomplete={data.invite.profile ? 'current-password' : 'new-password'}
/>
{#if errors.password}
<Field.Error>{$t(errors.password)}</Field.Error>
{:else}
<Field.Description>{$t('login.fields.password_helper_message')}</Field.Description>
{/if}
</Field.Content>
</Field.Field>
{#if !data.invite.profile}
<TextField
label={$t('login.fields.confirm_password')}
bind:value={fields.confirmPassword}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={isLoading}
errorMessage={confirmPasswordError}
isRequired
/>
<Field.Field class="mb-6">
<Field.Label for="confirm-password">{$t('login.fields.confirm_password')}</Field.Label>
<Field.Content>
<Password
id="confirm-password"
bind:value={fields.confirmPassword}
placeholder="************"
disabled={isLoading}
aria-invalid={confirmPasswordError ? 'true' : undefined}
autocomplete="new-password"
/>
{#if confirmPasswordError}
<Field.Error>{$t(confirmPasswordError)}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
{/if}
{/if}
{#if submitError}
@@ -236,13 +246,11 @@
<div class="my-4 flex w-full items-center justify-end">
{#if shouldLogout}
<PrimaryButton
label="Logout"
<Button
type="button"
className="sm:w-full w-full"
isLoading={isLoggingOut}
variant={VARIANTS.CONTAINED_DANGER}
onClick={async () => {
loading={isLoggingOut}
variant="destructive"
onclick={async () => {
isLoggingOut = true;
await logout(false);
@@ -250,15 +258,11 @@
isLoggingOut = false;
shouldLogout = false;
}}
/>
>
Logout
</Button>
{:else}
<PrimaryButton
label="Accept Invite"
type="submit"
className="sm:w-full w-full"
isDisabled={disableSubmit}
{isLoading}
/>
<Button type="submit" disabled={disableSubmit} loading={isLoading}>Accept Invite</Button>
{/if}
</div>
</AuthUI>
+14 -7
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import LMSSideBar from '$lib/components/LMS/SideBar.svelte';
import * as Sidebar from '@cio/ui/base/sidebar';
import LmsHeader from '$features/ui/navigation/lms-header.svelte';
import { LMSSidebar } from '$features/ui/sidebar/lms-sidebar';
interface Props {
children?: import('svelte').Snippet;
@@ -8,9 +10,14 @@
let { children }: Props = $props();
</script>
<div class="org-root flex w-full items-center justify-between">
<LMSSideBar />
<div class="org-slot w-full bg-white dark:bg-black">
{@render children?.()}
</div>
</div>
<Sidebar.Provider>
<LMSSidebar />
<Sidebar.Inset>
<LmsHeader />
<div class="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-4 px-4">
{@render children?.()}
</div>
</Sidebar.Inset>
</Sidebar.Provider>
+15 -114
View File
@@ -1,126 +1,27 @@
<script lang="ts">
import { goto } from '$app/navigation';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
import { courseMetaDeta, courses } from '$lib/features/course/utils/store';
import Learning from '$lib/components/LMS/components/Learning.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { DashboardPage } from '$features/lms/pages';
import { getGreeting } from '$lib/utils/functions/date';
import { t } from '$lib/utils/functions/translations';
import { fetchCourses } from '$lib/utils/services/courses';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { untrack } from 'svelte';
let hasFetched = false;
let totalCompleted = $derived($courses.reduce((acc, cur) => acc + (cur.progress_rate || 0), 0));
let totalLessons = $derived($courses.reduce((acc, cur) => acc + (cur.total_lessons || 0), 0));
let progressPercentage = $derived(Math.round((totalCompleted / totalLessons) * 100) || 0);
function getCourses(userId?: string, orgId?: string) {
if (hasFetched || !userId || !orgId) {
return;
}
untrack(async () => {
// only show is loading when fetching for the first time
if (!$courses.length) {
$courseMetaDeta.isLoading = true;
}
const coursesResult = await fetchCourses(userId, orgId);
console.log(`coursesResult`, coursesResult);
$courseMetaDeta.isLoading = false;
if (!coursesResult) return;
courses.set(coursesResult.allCourses);
hasFetched = true;
});
}
$effect(() => {
getCourses($profile.id, $currentOrg.id);
});
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Student Dashboard - ClassroomIO</title>
</svelte:head>
<section class="mx-auto max-w-6xl gap-5">
<div class="m-5">
<div class="mb-10 flex items-center justify-between">
<h1 class="text-2xl md:text-3xl dark:text-white">
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t(getGreeting())}
{$profile.fullname}!
</h1>
<VisitOrgSiteButton isLMS={true} />
</div>
<div
class="bg-primary-900 my-2 flex h-fit w-full flex-col-reverse justify-between rounded-md p-5 md:flex-row md:items-center lg:h-[265px] lg:p-10"
>
<div class="w-full md:w-[75%] lg:w-[80%]">
<p class=" mb-5 text-xs font-normal text-white lg:text-xl">
{$currentOrg.customization.dashboard.bannerText
? $currentOrg.customization.dashboard.bannerText
: $t('dashboard.lms_dashboard_hero')}
</p>
<PrimaryButton
label={$t('dashboard.dont')}
variant={VARIANTS.CONTAINED_WHITE}
onClick={() => goto('/lms/mylearning')}
/>
</div>
<img
src={$currentOrg.customization.dashboard.bannerImage
? $currentOrg.customization.dashboard.bannerImage
: '/images/student-learning.svg'}
alt="student Learning Pictogram"
class="mb-3 w-28 md:mb-0 md:block md:w-1/3 lg:h-[205px] lg:w-[275px]"
/>
</div>
<section class="flex w-full flex-col md:gap-5 lg:flex-row">
<div 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">
<p class="pb-3 text-base font-semibold text-[#040F2D] dark:text-white">
{$t('dashboard.your_progress')}
</p>
<div
class="flex h-fit items-center justify-center gap-2 rounded border border-[#EAEAEA] p-3 lg:h-[40vh] lg:overflow-y-auto dark:bg-neutral-800"
>
<div
class="flex h-full w-full flex-col items-center justify-between gap-5 sm:flex-row lg:items-center lg:justify-around xl:flex-col xl:items-start"
>
<div>
<img src="/images/target.svg" alt="student Learning score" />
</div>
<span class="text-center xl:text-start">
<p class="py-2 text-base font-semibold text-[#040F2D] dark:text-white">
{$t('dashboard.your_progress')}
</p>
{#if totalLessons > 0}
<p class="text-xs font-normal text-[#656565] dark:text-white">
{totalCompleted}/{totalLessons}
{$t('dashboard.lessons_completed')}
</p>
{:else}
<p class="text-xs font-normal text-[#656565] dark:text-white">
{$t('dashboard.no_courses_started')}
</p>
{/if}
</span>
<h1 class="my-0 whitespace-nowrap text-5xl text-[#262626] lg:text-6xl dark:text-white">
{progressPercentage} %
</h1>
</div>
</div>
</div>
</section>
</div>
</section>
</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<DashboardPage />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,25 +1,26 @@
<script>
import { goto } from '$app/navigation';
import { CommunityPage } from '$lib/features/community/pages';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
<script lang="ts">
import { CommunityPage } from '$features/community/pages';
import { AskCommunityButton } from '$features/community/components';
import { t } from '$lib/utils/functions/translations';
function askCommunity() {
goto(`/lms/community/ask`);
}
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Community - ClassroomIO</title>
</svelte:head>
<section class="mx-auto w-full max-w-4xl">
<div class="px-5 py-10">
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl dark:text-white">{$t('community.title')}</h1>
<PrimaryButton label={$t('community.ask_button')} onClick={askCommunity} />
</div>
<CommunityPage isLMS={true} />
</div>
</section>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('community.title')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<AskCommunityButton isLMS={true} />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CommunityPage isLMS={true} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -5,29 +5,29 @@
import * as Select from '@cio/ui/base/select';
import { Skeleton } from '@cio/ui/base/skeleton';
import TrashIcon from '@lucide/svelte/icons/trash';
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/features/course/utils/store';
import { courses } from '$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';
import { snackbar } from '$features/ui/snackbar/store';
import { fetchCourses } from '$lib/utils/services/courses';
import { currentOrg, isOrgAdmin } from '$lib/utils/store/org';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Button } from '@cio/ui/base/button';
import { askCommunityValidation, commentInCommunityValidation } from '$lib/utils/functions/validator';
import Vote from '$lib/components/Vote/index.svelte';
import { IconButton } from '$lib/components/IconButton';
import { Vote } from '$features/ui';
import { IconButton } from '@cio/ui/custom/icon-button';
import * as Avatar from '@cio/ui/base/avatar';
import TextField from '$lib/components/Form/TextField.svelte';
import { InputField } from '@cio/ui/custom/input-field';
import { shortenName } from '$lib/utils/functions/string';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import { CommunityDeleteModal } from '$lib/features/community/components';
import { TextEditor } from '$features/ui';
import { CircleCheckIcon } from '$features/ui/icons';
import { CommunityDeleteModal } from '$features/community/components';
import { currentCommunityQuestion } from '$features/community/utils/store';
import * as Page from '@cio/ui/base/page';
interface Comment {
id: string;
@@ -164,6 +164,8 @@
untrack(() => {
question = mapResToQuestion(data);
question.totalComments = question.comments.length;
// Update store for breadcrumb
currentCommunityQuestion.set({ title: question.title });
});
}
@@ -365,6 +367,13 @@
getCourses($profile.id, $currentOrg.id);
}
});
// Clear store when component unmounts
$effect(() => {
return () => {
currentCommunityQuestion.set({ title: null });
};
});
</script>
<svelte:head>
@@ -382,168 +391,163 @@
isQuestion={true}
/>
<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}
<div class="px-5 py-10">
<a class="text-md flex items-center text-gray-500 dark:text-white" href="/lms/community">
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
<div class="my-5 flex items-center justify-between">
{#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}
{@const currentQuestion = question}
<Page.Root class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
<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
: '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>
{:else}
<div class="flex items-center">
<Vote value={question.votes} upVote={() => upvoteQuestion('question')} disabled={voted.question} />
<h2 class="text-3xl">{question.title}</h2>
</div>
{/if}
{#if question.author.id === $profile.id}
<PrimaryButton
label={isEditMode ? 'Save' : 'Edit'}
variant={VARIANTS.OUTLINED}
onClick={handleQuestionEdit}
className="h-fit"
/>
{#if isEditMode}
<PrimaryButton
label="Cancel"
variant={VARIANTS.TEXT}
onClick={() => (isEditMode = !isEditMode)}
className="py-3 px-6 rounded-sm h-fit"
disablePadding={true}
/>
{/if}
{/if}
</div>
<div class="border-1 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.Root class="h-7 w-7">
<Avatar.Image
src={question.author.avatar ? question.author.avatar : '/logo-192.png'}
alt={question.author.name ? question.author.name : 'User'}
/>
<Avatar.Fallback>{shortenName(question.author.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<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>
{/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}
</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-1 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.Root class="h-7 w-7">
<Avatar.Image
src={comment.avatar ? comment.avatar : '/logo-192.png'}
alt={comment.name ? comment.name : 'User'}
/>
<Avatar.Fallback>{shortenName(comment.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<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}
<div class="flex w-full items-center gap-2">
<InputField 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>
</div>
</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>
</div>
{:else}
<div class="flex items-center gap-2">
<Vote value={currentQuestion.votes} upVote={() => upvoteQuestion('question')} disabled={voted.question} />
<Page.Title>{currentQuestion.title}</Page.Title>
</div>
{/if}
</Page.HeaderContent>
{#if currentQuestion.author.id === $profile.id}
<Page.Action>
<Button variant="outline" onclick={handleQuestionEdit}>
{isEditMode ? $t('community.ask.save') : $t('community.ask.edit')}
</Button>
{#if isEditMode}
<Button variant="ghost" onclick={() => (isEditMode = !isEditMode)}>
{$t('community.ask.cancel')}
</Button>
{/if}
</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.Root class="h-7 w-7">
<Avatar.Image
src={currentQuestion.author.avatar ? currentQuestion.author.avatar : '/logo-192.png'}
alt={currentQuestion.author.name ? currentQuestion.author.name : 'User'}
/>
<Avatar.Fallback>{shortenName(currentQuestion.author.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<p class="ml-2 text-sm dark:text-white">{currentQuestion.author.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{currentQuestion.createdAt}
</p>
</div>
{#if currentQuestion.author.id === $profile.id || $isOrgAdmin}
<IconButton
onclick={() => {
deleteQuestion.shouldDelete = true;
deleteQuestion.questionId = currentQuestion.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 currentQuestion.body}
</section>
{/if}
</div>
{#if isValidAnswer}
<CircleCheckIcon size={16} />
{/if}
<div class="my-8">
{pluralize($t('community.answers'), currentQuestion.totalComments, true)}
</div>
{#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>
{#each currentQuestion.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.Root class="h-7 w-7">
<Avatar.Image
src={comment.avatar ? comment.avatar : '/logo-192.png'}
alt={comment.name ? comment.name : 'User'}
/>
<Avatar.Fallback>{shortenName(comment.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<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
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>
{/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">
<Button onclick={submitComment}>
{$t('community.ask.comment')}
</Button>
</div>
</div>
{/each}
<hr />
<div>
{#if !editorInstance}
<TextEditor placeholder="Give an answer" content={comment} onChange={(content) => (comment = content)} />
{/if}
<div class="mr-2 flex justify-end">
<PrimaryButton label="Comment" onClick={submitComment} />
</div>
</div>
</div>
{/if}
</section>
{/snippet}
</Page.Body>
</Page.Root>
{/if}
@@ -1,26 +1,27 @@
<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 { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/features/course/utils/store';
import { courses } from '$features/course/utils/store';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { fetchCourses } from '$lib/utils/services/courses';
import generateSlug from '$lib/utils/functions/generateSlug';
import { currentOrg } from '$lib/utils/store/org';
import { askCommunityValidation } from '$lib/utils/functions/validator';
import { Button } from '@cio/ui/base/button';
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 { InputField } from '@cio/ui/custom/input-field';
import { TextEditor } from '$features/ui';
import * as Page from '@cio/ui/base/page';
let errors: {
title?: string;
courseId?: string;
body?: string;
} = $state({});
let fields = $state({
title: '',
@@ -28,22 +29,23 @@
courseId: ''
});
let fetchedCourses: Course[] = $state([]);
let hasFetched = $state(false);
let fetchedCourses: Course[] = $state([]);
async function getCourses(userId?: string, orgId?: string) {
if (!userId || !orgId || hasFetched) {
return;
}
async function getCourses(userId: string | null, orgId: string) {
if (hasFetched) return;
hasFetched = true;
if ($courses.length) {
fetchedCourses = [...$courses];
hasFetched = true;
return;
}
const coursesResults = await fetchCourses(userId, orgId);
fetchedCourses = coursesResults?.allCourses || [];
if (!coursesResults) return;
fetchedCourses = coursesResults.allCourses;
hasFetched = true;
}
async function handleSave() {
@@ -59,28 +61,30 @@
.insert({
title: fields.title,
body: fields.body,
course_id: fields.courseId,
organization_id: $currentOrg.id,
author_profile_id: $profile.id,
votes: 0,
slug: generateSlug(fields.title)
slug: generateSlug(fields.title),
course_id: fields.courseId
})
.select();
if (error) {
console.error('Error: asking question', error);
snackbar.error();
snackbar.error('snackbar.community.error.try_again');
} else {
const slug = question[0]?.slug;
console.log('Success: asking question in lms', question, slug);
snackbar.success('snackbar.community.success.question_submitted');
console.log('Success: asking question', question, slug);
snackbar.success('snackbar.community.success.question_submitted');
goto(`/lms/community/${slug}`);
}
}
$effect(() => {
getCourses($profile.id, $currentOrg.id);
if ($profile.id && $currentOrg.id) {
getCourses($profile.id, $currentOrg.id);
}
});
</script>
@@ -88,53 +92,61 @@
<title>Ask the Community - ClassroomIO</title>
</svelte:head>
<section class="mx-auto w-full max-w-3xl">
<div class="p-5">
<a class="text-md flex items-center text-gray-500 dark:text-white" href="/lms/community">
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
<div class="flex items-center justify-between">
<h1 class="text-3xl dark:text-white">{$t('community.ask.ask_the')}</h1>
<PrimaryButton label={$t('community.ask.publish')} onClick={handleSave} />
</div>
</div>
<Page.Root>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" onclick={handleSave}>
{$t('community.ask.publish')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="mb-3 flex justify-between gap-x-5">
<InputField
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}
{#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>
<TextEditor
placeholder={$t('community.ask.ask_community')}
content={fields.body}
onChange={(content) => (fields.body = content)}
/>
</section>
{#if errors.body}
<p class="mt-2 text-sm text-red-500">{errors.body}</p>
{/if}
</div>
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,173 +1,22 @@
<script lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import Chip from '$lib/components/Chip/index.svelte';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$lib/components/Snackbar/store';
import { fetchLMSExercises } from '$lib/utils/services/lms/exercises';
import type { LMSExercise } from '$lib/utils/services/lms/exercises';
import { calDateDiff } from '$lib/utils/functions/date';
import { ExercisesPage } from '$features/lms/pages';
import { t } from '$lib/utils/functions/translations';
const defaultSections = [
{
id: 0,
title: $t('exercises.not_submitted'),
items: [],
className: 'text-[#E35353] bg-[#FDDFE4]'
},
{
id: 1,
title: $t('exercises.submitted'),
items: [],
className: 'text-orange-700 bg-orange-200'
},
{
id: 2,
title: $t('exercises.in_progress'),
items: [],
className: 'text-yellow-700 bg-yellow-200'
},
{
id: 3,
title: $t('exercises.graded'),
value: 0,
items: [],
className: 'text-green-700 bg-green-200'
}
];
let sections: Section[] = $state(cloneDeep(defaultSections));
let hasFetched = $state(false);
interface Section {
id: number;
title: string;
className: string;
items: ExerciseItem[];
}
interface ExerciseItem {
courseTitle: string;
courseURL: string;
exerciseId: string;
exerciseTitle: string;
exerciseURL: string;
grade: string;
lessonNo: string;
lessonTitle: string;
lessonURL: string;
submissionStatus: number;
submissionUpdatedAt: string;
}
function generateSections(exercises: LMSExercise[]): Section[] {
const _sections: Section[] = cloneDeep(defaultSections);
for (const exercise of exercises) {
const { id, title, updated_at, submission, lesson, questions } = exercise;
const submissionItem = submission[0] || {
status_id: 0,
updated_at,
total: 0
};
const courseURL = `/courses/${lesson.course.id}`;
const lessonURL = `${courseURL}/lessons/${lesson.id}`;
const exerciseURL = `${lessonURL}/exercises/${id}`;
const grade = `${submissionItem.total}/${questions.reduce((acc, cur) => (acc += cur.points), 0)}`;
const item: ExerciseItem = {
exerciseId: id,
courseTitle: lesson.course.title,
courseURL,
exerciseTitle: title,
exerciseURL,
lessonTitle: lesson.title,
lessonNo: lesson.order < 9 ? '0' + (lesson.order + 1) : `${lesson.order}`,
lessonURL,
submissionStatus: submissionItem.status_id,
submissionUpdatedAt: calDateDiff(submissionItem.updated_at),
grade
};
_sections[submissionItem.status_id].items.push(item);
}
return _sections;
}
async function fetchData(profileId?: string, orgId?: string) {
if (hasFetched || !profileId || !orgId) {
return;
}
hasFetched = true;
// Don't rerun this function if any state is updated in this function.
const { exercises, error } = await fetchLMSExercises(profileId, orgId);
console.log('exercises', exercises);
console.log('error', error);
if (error) {
snackbar.error('snackbar.exercise.error_fetching');
return;
}
if (!exercises) return;
sections = generateSections(exercises);
console.log('sections', sections);
}
$effect(() => {
fetchData($profile.id, $currentOrg.id);
});
import * as Page from '@cio/ui/base/page';
</script>
<section class="mx-auto w-full max-w-6xl">
<div class="p-5">
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl dark:text-white">{$t('exercises.heading')}</h1>
</div>
<svelte:head>
<title>Exercises - ClassroomIO</title>
</svelte:head>
<div>
<div class="flex w-full items-center">
{#each sections as { title, items, className, id }}
<div
class="mr-3 h-[70vh] min-w-[355px] max-w-[355px] overflow-hidden rounded-md border border-gray-50 bg-gray-100 p-3 dark:border-neutral-700 dark:bg-black"
>
<div class="mb-2 flex items-center gap-2">
<p class="ml-2 dark:text-white">{title}</p>
<Chip value={items.length} {className} />
</div>
<div class="h-full overflow-y-auto pb-3 pr-2">
{#each items as item}
<div class=" mx-0 my-2 w-full rounded-md bg-white px-3 py-3 dark:bg-neutral-800">
<a class="text-primary-600 mb-2 flex w-full cursor-pointer items-center" href={item.courseURL}>
<p class="text-xs">{item.courseTitle}</p>
</a>
<a class="text-md text-black dark:text-white" href={item.exerciseURL}>
{#if id === 3}
({item.grade}) -
{/if}
{item.exerciseTitle}
</a>
<a class="my-2 flex w-fit items-center text-black no-underline hover:underline" href={item.lessonURL}>
<p class="text-grey text-sm dark:text-white">
{$t('exercises.lesson')} <span class="italic">{item.lessonTitle}</span>
</p>
</a>
<p class="text-xs text-gray-500 dark:text-white">
{item.submissionUpdatedAt}
</p>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
</section>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('exercises.heading')}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<ExercisesPage />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,135 +1,22 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { Input } from '@cio/ui/base/input';
import * as Select from '@cio/ui/base/select';
import ListIcon from '@lucide/svelte/icons/list';
import GridIcon from '@lucide/svelte/icons/grid-2x2';
import SearchIcon from '@lucide/svelte/icons/search';
import type { Course } from '$lib/utils/types';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { ExplorePage } from '$features/lms/pages';
import { t } from '$lib/utils/functions/translations';
import { CoursesPage } from '$lib/features/course/pages';
import { courseMetaDeta } from '$lib/features/course/utils/store';
import { fetchExploreCourses } from '$lib/utils/services/courses';
import { IconButton } from '$lib/components/IconButton';
let searchValue = $state('');
let selectedId: string | undefined = $state();
let hasFetched = false;
let exploreCourseList: Course[] = $state([]);
const filterOptions = $derived([
{ value: '0', label: $t('courses.course_filter.date_created') },
{ value: '1', label: $t('courses.course_filter.lessons') }
]);
const selectedLabel = $derived(
filterOptions.find((opt) => opt.value === selectedId)?.label || filterOptions[0].label
);
const filteredExploreCourses: Course[] = $derived(filterCourses(searchValue, selectedId || '', exploreCourseList));
function getCourses(userId?: string, orgId?: string) {
if (hasFetched || !userId || !orgId) {
return;
}
// don't rerun this function if any state is updated in this function.
untrack(async () => {
$courseMetaDeta.isLoading = true;
const coursesResult = await fetchExploreCourses(userId, orgId);
$courseMetaDeta.isLoading = false;
if (!coursesResult) return;
exploreCourseList = coursesResult.allCourses;
hasFetched = true;
});
}
function filterCourses(searchValue: string, _selectedId: string | null, courses: Course[]) {
if (_selectedId) {
localStorage.setItem('classroomio_filter_course_key', _selectedId);
}
const coursesFiltered = courses.filter((course) => {
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
return true;
}
return false;
});
if (_selectedId === '0') {
return coursesFiltered.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
} else if (_selectedId === '1') {
return coursesFiltered.sort((a, b) => (b.total_lessons ?? 0) - (a.total_lessons ?? 0));
}
return coursesFiltered;
}
const setViewPreference = (preference: 'grid' | 'list') => {
$courseMetaDeta.view = preference;
localStorage.setItem('courseView', preference);
};
onMount(() => {
const courseView = localStorage.getItem('courseView') as 'grid' | 'list' | null;
if (courseView) {
$courseMetaDeta.view = courseView;
}
selectedId = localStorage.getItem('classroomio_filter_course_key') || '0';
});
$effect(() => {
getCourses($profile.id, $currentOrg.id);
});
import * as Page from '@cio/ui/base/page';
</script>
<section class="w-full md:mx-auto md:max-w-6xl">
<div class="px-2 py-2 md:px-5 md:py-10">
<h1 class="text-3xl dark:text-white">{$t('explore.heading')}</h1>
<div class="flex flex-row-reverse">
<div class="filter-container flex items-end justify-start gap-2">
<div class="relative">
<SearchIcon class="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input type="text" placeholder={$t('courses.search_placeholder')} bind:value={searchValue} class="pl-9" />
</div>
<Select.Root type="single" bind:value={selectedId}>
<Select.Trigger class="min-w-[150px]">
<p>{selectedLabel}</p>
</Select.Trigger>
<Select.Content>
{#each filterOptions as option}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<svelte:head>
<title>Explore - ClassroomIO</title>
</svelte:head>
{#if $courseMetaDeta.view === 'list'}
<IconButton onClick={() => setViewPreference('grid')}>
<GridIcon size={16} />
</IconButton>
{:else}
<IconButton onClick={() => setViewPreference('list')}>
<ListIcon size={16} />
</IconButton>
{/if}
</div>
</div>
<CoursesPage
courses={filteredExploreCourses}
emptyTitle={$t('explore.empty_heading')}
emptyDescription={$t('explore.empty_description')}
isExplore={true}
/>
</div>
</section>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('explore.heading')}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<ExplorePage />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,93 +1,22 @@
<script lang="ts">
import { untrack } from 'svelte';
import { Input } from '@cio/ui/base/input';
import SearchIcon from '@lucide/svelte/icons/search';
import Tabs from '$lib/components/Tabs/index.svelte';
import { CoursesPage } from '$lib/features/course/pages';
import { fetchCourses } from '$lib/utils/services/courses';
import TabContent from '$lib/components/TabContent/index.svelte';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { MyLearningPage } from '$features/lms/pages';
import { t } from '$lib/utils/functions/translations';
import { courses, courseMetaDeta, coursesComplete, coursesInProgress } from '$lib/features/course/utils/store';
let hasFetched = false;
let searchValue = $state('');
function onChange(tab) {
return () => (currentTab = tab);
}
function getCourses(userId?: string, orgId?: string) {
if (hasFetched || !userId || !orgId) {
return;
}
// don't rerun this function if any state is updated in this function.
untrack(async () => {
hasFetched = true;
// only show is loading when fetching for the first time
if (!$courses.length) {
$courseMetaDeta.isLoading = true;
}
const coursesResult = await fetchCourses(userId, orgId);
console.log(`get courses result`, coursesResult);
$courseMetaDeta.isLoading = false;
if (!coursesResult) return;
courses.set(coursesResult.allCourses);
hasFetched = true;
});
}
$effect(() => {
getCourses($profile.id, $currentOrg.id);
});
let tabs = $derived([
{
label: `${$t('my_learning.progress')} (${$coursesInProgress.length})`,
value: '1'
},
{
label: `${$t('my_learning.complete')} (${$coursesComplete.length})`,
value: '2'
}
]);
let currentTab = $derived(tabs[0].value);
import * as Page from '@cio/ui/base/page';
</script>
<section class="mx-auto max-w-6xl">
<div class="m-2 md:m-5">
<div role="searchbox" class="relative w-full md:w-[60%] lg:w-[30%]">
<Input type="text" placeholder={$t('my_learning.search')} bind:value={searchValue} />
<SearchIcon class="text-muted-foreground absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2" />
</div>
<h1 class="my-4 text-3xl font-semibold">{$t('my_learning.heading')}</h1>
<Tabs {tabs} {currentTab} {onChange}>
{#snippet content()}
<slot:fragment>
<TabContent value={tabs[0].value} index={currentTab}>
<CoursesPage
courses={$coursesInProgress}
emptyTitle={$t('my_learning.not_in_progress')}
emptyDescription={$t('my_learning.any_progress')}
/>
</TabContent>
<TabContent value={tabs[1].value} index={currentTab}>
<CoursesPage
courses={$coursesComplete}
emptyTitle={$t('my_learning.not_completed')}
emptyDescription={$t('my_learning.any_course')}
/>
</TabContent>
</slot:fragment>
{/snippet}
</Tabs>
</div>
</section>
<svelte:head>
<title>My Learning - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('my_learning.heading')}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<MyLearningPage />
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,13 @@
<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,49 +1,41 @@
<script>
import { page } from '$app/state';
import * as Tabs from '@cio/ui/base/tabs';
<script lang="ts">
import { ProfilePage } from '$features/settings/pages';
import { VisitOrgSiteButton } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
import Profile from '$lib/components/LMS/components/Profile.svelte';
import Integrations from '$lib/components/Org/Settings/Integrations.svelte';
let profileComponent: ProfilePage | null = $state(null);
let isLoading = $state(false);
let selectedTab = $state('profile');
const tabs = $derived([
{
key: 'profile',
label: $t('settings.tabs.profile_tab'),
href: page.url.pathname
},
{
key: 'integrations',
label: $t('settings.tabs.integrations_tab'),
href: `${page.url.pathname}?tab=integrations`,
disabled: false
async function handleUpdate() {
isLoading = true;
try {
await profileComponent?.handleUpdate();
} finally {
isLoading = false;
}
]);
}
</script>
<section class="mx-auto w-full max-w-6xl">
<div class="px-5 py-10">
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl dark:text-white">{$t('settings.heading')}</h1>
</div>
<svelte:head>
<title>Settings - ClassroomIO</title>
</svelte:head>
<div class="">
<Tabs.Root bind:value={selectedTab}>
<Tabs.List>
{#each tabs as tab}
<Tabs.Trigger value={tab.key}>{tab.label}</Tabs.Trigger>
{/each}
</Tabs.List>
<Tabs.Content value="profile">
<Profile />
</Tabs.Content>
<Tabs.Content value="integrations">
<Integrations />
</Tabs.Content>
</Tabs.Root>
</div>
</div>
</section>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.heading')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
{$t('settings.profile.update_profile')}
</Button>
<VisitOrgSiteButton />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<ProfilePage bind:this={profileComponent} />
{/snippet}
</Page.Body>
@@ -0,0 +1,21 @@
<script lang="ts">
import { IntegrationsPage } from '$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>
@@ -1,5 +1,5 @@
import { env } from '$env/dynamic/private';
import { getSubdomain } from '$lib/features/app/layout-setup';
import { getSubdomain } from '$features/app/layout-setup';
import { redirect } from '@sveltejs/kit';
const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || ['app'];
@@ -4,12 +4,12 @@
import * as Sidebar from '@cio/ui/base/sidebar';
import { Skeleton } from '@cio/ui/base/skeleton';
import { currentOrg } from '$lib/utils/store/org';
import { AppHeader } from '$lib/features/ui';
import { AppHeader } from '$features/ui';
import { VerifyEmailModal } from '$lib/features/onboarding/components';
import { VerifyEmailModal } from '$features/onboarding/components';
import { OrgSidebar } from '$lib/features/ui/sidebar/org-sidebar/index.js';
import AddOrgModal from '$lib/components/Org/AddOrgModal/AddOrgModal.svelte';
import { OrgSidebar } from '$features/ui/sidebar/org-sidebar';
import { AddOrgModal } from '$features/org';
let { data, children } = $props();
@@ -35,7 +35,7 @@
<Sidebar.Inset>
<AppHeader />
<div class="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-4 overflow-y-auto px-4">
<div class="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-4 px-4">
{#if data.orgName === '*'}
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<Skeleton class="aspect-video rounded-xl" />
@@ -11,16 +11,15 @@
import { calDateDiff } from '$lib/utils/functions/date';
import { getGreeting } from '$lib/utils/functions/date';
import { currentOrgPath } from '$lib/utils/store/org';
import { dashStatApi } from '$lib/features/org/api';
import { dashStatApi } from '$features/org/api';
import { CreateCourseButton } from '$lib/features/course/components';
import { CreateCourseButton } from '$features/course/components';
import * as Avatar from '@cio/ui/base/avatar';
import { ActivityCard } from '$lib/components/Analytics';
import { shortenName } from '$lib/utils/functions/string';
import Progress from '$lib/components/Progress/index.svelte';
import { Progress } from '@cio/ui/base/progress';
import { Button } from '@cio/ui/base/button';
import { WelcomeModal } from '$lib/features/onboarding/components';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
import { WelcomeModal } from '$features/onboarding/components';
import { ActivityCard, VisitOrgSiteButton } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { Empty } from '@cio/ui/custom/empty';
@@ -1,11 +1,11 @@
<script>
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { AudiencePage } from '$lib/features/audience/pages';
import { Button } from '@cio/ui/base/button';
import { AudiencePage } from '$features/audience/pages';
import { t } from '$lib/utils/functions/translations';
import { currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org';
import { PLAN } from '@cio/utils/plans';
import * as Page from '@cio/ui/base/page';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { orgApi } from '$features/org/api/org.svelte';
let isLoading = $state(false);
@@ -33,7 +33,9 @@
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<PrimaryButton label={$t('audience.export')} onClick={exportAudience} isDisabled={isLoading} {isLoading} />
<Button onclick={exportAudience} disabled={isLoading} loading={isLoading}>
{$t('audience.export')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
@@ -1,5 +1,5 @@
<script lang="ts">
import { UserAnalyticsPage } from '$lib/features/audience/pages';
import { UserAnalyticsPage } from '$features/audience/pages';
import * as Page from '@cio/ui/base/page';
let { data } = $props();
@@ -1,6 +1,6 @@
<script>
import { CommunityPage } from '$lib/features/community/pages';
import { AskCommunityButton } from '$lib/features/community/components';
import { CommunityPage } from '$features/community/pages';
import { AskCommunityButton } from '$features/community/components';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
@@ -5,29 +5,27 @@
import { Skeleton } from '@cio/ui/base/skeleton';
import * as Select from '@cio/ui/base/select';
import TrashIcon from '@lucide/svelte/icons/trash';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/features/course/utils/store';
import { courses } from '$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';
import { snackbar } from '$features/ui/snackbar/store';
import { fetchCourses } from '$lib/utils/services/courses';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Button } from '@cio/ui/base/button';
import { currentOrg, currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
import { askCommunityValidation, commentInCommunityValidation } from '$lib/utils/functions/validator';
import type { Course } from '$lib/utils/types';
import { profile } from '$lib/utils/store/user';
import Vote from '$lib/components/Vote/index.svelte';
import { IconButton } from '$lib/components/IconButton';
import { Vote } from '$features/ui';
import { IconButton } from '@cio/ui/custom/icon-button';
import * as Avatar from '@cio/ui/base/avatar';
import TextField from '$lib/components/Form/TextField.svelte';
import { InputField } from '@cio/ui/custom/input-field';
import { shortenName } from '$lib/utils/functions/string';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { CommunityDeleteModal } from '$lib/features/community/components';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import { TextEditor } from '$features/ui';
import { CommunityDeleteModal } from '$features/community/components';
import { CircleCheckIcon } from '$features/ui/icons';
import * as Page from '@cio/ui/base/page';
interface Comment {
@@ -64,7 +62,6 @@
title?: string;
} = $state({});
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
let resetInput = 1;
let voted: {
question: boolean;
comment: {
@@ -211,7 +208,7 @@
id: _c.id,
authorId: $profile.id || '',
name: $profile?.fullname || '',
avatar: $profile?.avatar_url || '',
avatar: $profile?.avatarUrl || '',
votes: 0,
comment: _c.body,
createdAt: calDateDiff(_c.created_at)
@@ -220,7 +217,6 @@
// Reset input
comment = '';
resetInput = new Date().getTime();
}
}
@@ -397,17 +393,11 @@
</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>
<Page.Header>
<Page.HeaderContent>
{#if isEditMode}
<div class="flex w-full items-center gap-2">
<TextField bind:value={editContent.title} className="flex-1" errorMessage={errors.title} />
<InputField 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>
@@ -432,20 +422,13 @@
</Page.HeaderContent>
{#if question.author.id === $profile.id}
<Page.Action>
<PrimaryButton
label={isEditMode ? $t('community.ask.save') : $t('community.ask.edit')}
variant={VARIANTS.OUTLINED}
onClick={handleQuestionEdit}
className="h-fit"
/>
<Button variant="outline" onclick={handleQuestionEdit}>
{isEditMode ? $t('community.ask.save') : $t('community.ask.edit')}
</Button>
{#if isEditMode}
<PrimaryButton
label={$t('community.ask.cancel')}
variant={VARIANTS.TEXT}
onClick={() => (isEditMode = !isEditMode)}
className="py-3 px-6 rounded-sm h-fit"
disablePadding={true}
/>
<Button variant="ghost" onclick={() => (isEditMode = !isEditMode)}>
{$t('community.ask.cancel')}
</Button>
{/if}
</Page.Action>
{/if}
@@ -469,8 +452,7 @@
</div>
{#if question.author.id === $profile.id || $isOrgAdmin}
<IconButton
value="delete-question"
onClick={() => {
onclick={() => {
if (!question) return;
deleteQuestion.shouldDelete = true;
@@ -529,8 +511,7 @@
{#if comment.authorId === $profile.id || $isOrgAdmin}
<IconButton
value="delete-comment"
onClick={() => {
onclick={() => {
deleteComment.shouldDelete = true;
deleteComment.commentId = comment.id;
}}
@@ -554,7 +535,9 @@
{/if}
<div class="mt-2 flex justify-end">
<PrimaryButton label={$t('community.ask.comment')} onClick={submitComment} />
<Button onclick={submitComment}>
{$t('community.ask.comment')}
</Button>
</div>
</div>
{/snippet}
@@ -5,18 +5,17 @@
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/features/course/utils/store';
import { courses } from '$features/course/utils/store';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { fetchCourses } from '$lib/utils/services/courses';
import generateSlug from '$lib/utils/functions/generateSlug';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { askCommunityValidation } from '$lib/utils/functions/validator';
import { Button } from '@cio/ui/base/button';
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 { InputField } from '@cio/ui/custom/input-field';
import { TextEditor } from '$features/ui';
import * as Page from '@cio/ui/base/page';
let errors: {
@@ -99,13 +98,15 @@
<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} />
<Button variant="default" onclick={handleSave}>
{$t('community.ask.publish')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="mb-3 flex justify-between gap-x-5">
<TextField
<InputField
bind:value={fields.title}
placeholder={$t('community.ask.title')}
errorMessage={errors.title}
@@ -1,9 +1,9 @@
<script lang="ts">
import { profile } from '$lib/utils/store/user';
import { fetchCourses } from '$lib/utils/services/courses';
import { CoursesPage } from '$lib/features/course/pages';
import { CreateCourseButton } from '$lib/features/course/components';
import { courses, courseMetaDeta } from '$lib/features/course/utils/store';
import { CoursesPage } from '$features/course/pages';
import { CreateCourseButton } from '$features/course/components';
import { courses, courseMetaDeta } from '$features/course/utils/store';
import { currentOrg } from '$lib/utils/store/org';
import type { Course } from '$lib/utils/types';
import { browser } from '$app/environment';
@@ -1,8 +1,6 @@
<script>
import Quizzes from '$lib/components/Org/Quiz/Quizzes.svelte';
import NewQuizModal from '$lib/components/Org/Quiz/NewQuizModal.svelte';
import DeleteModal from '$lib/components/Org/Quiz/DeleteModal.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { Quizzes, NewQuizModal, DeleteModal } from '$features/org';
import { Button } from '@cio/ui/base/button';
import { createQuizModal } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
import PlusIcon from '@lucide/svelte/icons/plus';
@@ -17,11 +15,11 @@
<div class="mb-10 flex items-center justify-between">
<h1 class="text-2xl md:text-3xl dark:text-white">Interactive Quizzes</h1>
{#if $isMobile}
<PrimaryButton onClick={() => ($createQuizModal.open = true)}>
<Button onclick={() => ($createQuizModal.open = true)}>
<PlusIcon size={16} />
</PrimaryButton>
</Button>
{:else}
<PrimaryButton label="Create Quiz" onClick={() => ($createQuizModal.open = true)} />
<Button onclick={() => ($createQuizModal.open = true)}>Create Quiz</Button>
{/if}
</div>
@@ -7,17 +7,14 @@
import * as Select from '@cio/ui/base/select';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import { CircleCheckIcon } from '$features/ui/icons';
import Preview from '$lib/components/Org/Quiz/Play/Preview.svelte';
import DeleteModal from '$lib/components/Org/Quiz/DeleteModal.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import QuizQuestion from '$lib/components/Org/Quiz/QuizQuestion.svelte';
import { Preview, DeleteModal, QuizQuestion } from '$features/org';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { snackbar } from '$features/ui/snackbar/store';
import { currentOrgPath, deleteModal, quizStore, quizesStore } from '$lib/utils/store/org';
import { allOptions, allThemes, booleanOptions, themeImages } from '$lib/utils/constants/quiz';
@@ -274,7 +271,7 @@
</div>
<div class="flex w-full justify-end">
<PrimaryButton label="Add Question" variant={VARIANTS.CONTAINED} onClick={addQuestion} />
<Button onclick={addQuestion}>Add Question</Button>
</div>
</div>
</aside>
@@ -297,21 +294,16 @@
{/if}
{#if currentQuestion.type !== 'boolean'}
<div class="mb-4 flex w-full justify-center">
<div class="mb-4 flex w-full justify-center gap-4">
{#if currentQuestion.options.length < allOptions.length}
<PrimaryButton
label={$t('components.quiz.add_more')}
variant={VARIANTS.CONTAINED_WHITE}
onClick={addOption}
className="mr-5"
/>
<Button onclick={addOption}>
{$t('components.quiz.add_more')}
</Button>
{/if}
{#if currentQuestion.options.length > 0}
<PrimaryButton
label={$t('components.quiz.remove_last')}
variant={VARIANTS.CONTAINED_WHITE}
onClick={deleteOption}
/>
<Button onclick={deleteOption}>
{$t('components.quiz.remove_last')}
</Button>
{/if}
</div>
{/if}
@@ -323,17 +315,19 @@
<aside class="settings h-full w-1/5 bg-gray-100 p-4 dark:bg-neutral-800">
<div class="py-5">
<h5>Quiz settings</h5>
<PrimaryButton label="Save Changes" variant={VARIANTS.CONTAINED} onClick={saveQuiz} className="my-3" />
<PrimaryButton label="Preview Quiz" variant={VARIANTS.OUTLINED} onClick={previewQuiz} className="my-3" />
<PrimaryButton
label="Delete question"
variant={VARIANTS.TEXT}
onClick={() => {
<Button onclick={saveQuiz} class="my-3">Save Changes</Button>
<Button variant="outline" onclick={previewQuiz} class="my-3">Preview Quiz</Button>
<Button
variant="ghost"
onclick={() => {
if ($quizStore.questions.length === 1) return;
$deleteModal.open = true;
$deleteModal.isQuestion = true;
}}
className="my-3"
class="my-3"
>
Delete question
</Button>
/>
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import { ProfilePage } from '$lib/features/settings/pages';
import ViewSiteBtn from '$lib/components/Buttons/VisitOrgSite.svelte';
import { ProfilePage } from '$features/settings/pages';
import { VisitOrgSiteButton } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
@@ -27,10 +27,11 @@
<Page.Title>{$t('settings.heading')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" loading={isLoading} onclick={handleUpdate}>
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
{$t('settings.profile.update_profile')}
</Button>
<ViewSiteBtn />
<VisitOrgSiteButton />
</Page.Action>
</Page.Header>
<Page.Body>
@@ -1,5 +1,5 @@
<script>
import { BillingPage } from '$lib/features/settings/pages';
import { BillingPage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
@@ -1,5 +1,5 @@
<script lang="ts">
import { CustomizeLmsPage } from '$lib/features/settings/pages';
import { CustomizeLmsPage } from '$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';
@@ -1,5 +1,5 @@
<script>
import { DomainsPage } from '$lib/features/settings/pages';
import { DomainsPage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
@@ -1,5 +1,5 @@
<script>
import { IntegrationsPage } from '$lib/features/settings/pages';
import { IntegrationsPage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
@@ -1,5 +1,5 @@
<script lang="ts">
import { LandingpagePage } from '$lib/features/settings/pages';
import { LandingpagePage } from '$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';
@@ -1,5 +1,5 @@
<script lang="ts">
import { OrgPage } from '$lib/features/settings/pages';
import { OrgPage } from '$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';
@@ -1,5 +1,5 @@
<script>
import { TeamsPage } from '$lib/features/settings/pages';
import { TeamsPage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
@@ -9,14 +9,14 @@
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 SetupProgress from '$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 { snackbar } from '$features/ui/snackbar/store';
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 { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
import { onMount } from 'svelte';
const setupList = $derived(