feat: complete rewrite of course (#622)

* theming improvements

* border style improvements

* refactor: migrate org routes to server-side data fetching

Move client-side API calls to server-side load functions for better
performance, security, and SEO. Implement permission-based course filtering

* refactor: clean up course and community components

This commit removes unused components and files related to courses and community features, streamlining the codebase. It also updates various routes to enhance performance and maintainability. Additionally, it introduces new types and constants for better type safety and organization.

* refactor: fix video formatting on website and other styles

* refactor: update course and dashboard components for improved styling and functionality

* more icon improvements

* refactor: streamline course and dashboard components

This commit updates various course and dashboard components for improved styling and functionality. It includes the removal of unused files, updates to imports and exports, and enhancements to type safety. Additionally, it introduces new routes and optimizes existing ones for better performance and maintainability.

* refactor: enhance lesson completion logic

* chore: delete all emails on dashboard and move other api routes to backend.

move all emails to the backend and

* feat: Refactor API and database queries for courses, quizzes, and lessons, migrating Supabase functions and schema management.

* get app loading

* fix ui and build error of cio/api

* refactor: move direct database queries to query layer

- Create query functions: getCourseWithOrgData, getOrganizationByCourseId, updateCourseVersion, updateLessonsSectionId, getProfileByGroupMemberId, getQuestionTypesByIds
- Refactor services to use query functions instead of direct db queries
- Add transaction support to updateCourseVersion and updateLessonsSectionId
- Fix getProfileByGroupMemberId to return profile directly (not nested)
- Fix updateLessonsSectionId to use .returning() instead of rowCount

This aligns services with architecture pattern where services orchestrate queries rather than executing them directly.

* exercise saving improvements

* fix and optimise exercise creation

* refactor: update dialog components for improved styling and functionality

- Adjusted dialog content width and maximum constraints for better responsiveness.
- Changed heading font size for consistency in the new exercise modal.
- Replaced CloseIcon with CrossIcon for a more modern appearance in dialog close button.

* refactor: enhance course and exercise management components

- Introduced new components for exercise creation, editing, and viewing, improving user experience.
- Refactored existing lesson and exercise components for better organization and maintainability.
- Updated routing for exercises and lessons to streamline navigation within the course structure.
- Added new utility functions and constants to support content management across the application.

* feat: implement course content management API and UI components

- Added new API routes for updating and deleting course content, enhancing content management capabilities.
- Introduced new Svelte components for displaying and managing course content, including content lists and action handlers.
- Updated existing course routing to integrate new content management features.
- Refactored content-related utility functions for improved organization and maintainability.

* refactor: remove Supabase references and implement course section management

- Removed Supabase-related environment variables and Dockerfile arguments to streamline configuration.
- Introduced a new section management API with routes for creating, updating, deleting, and reordering course sections.
- Updated course services to handle section operations, ensuring consistency in course content management.
- Refactored existing lesson section logic to align with the new course section structure, enhancing maintainability.

* chore: update .prettierignore to exclude additional file types

- Added exclusions for SQL and environment files to the Prettier ignore list.
- Included Dockerfile and .prettierignore itself to prevent formatting issues.

* feat: enhance course content management with translation support and UI improvements

- Added translation keys for user-facing text in course-related components, ensuring all UI copy is sourced from translation files.
- Updated lesson and exercise creation components to utilize translation keys for success and error messages, improving localization.
- Refactored course content management components to streamline the creation and editing process, enhancing user experience.
- Introduced new constants for content options and stepper states to standardize component behavior and improve maintainability.
- Improved styling and functionality of course content modals and steppers for better user interaction.

* feat: enhance course content management and UI components

- Updated .prettierignore to exclude storybook-static directory.
- Improved exercise creation logic by refining option handling and adding UUID support for option values.
- Enhanced navigation component with debugging output for custom links.
- Introduced a new method in CourseApi to update content items locally, improving state management during navigation.
- Added caching for exercise templates by tag in ExerciseTemplateApi to optimize performance.
- Refactored lesson and exercise components for better organization and maintainability, including updates to modal styling and functionality.
- Implemented local patching for lesson and exercise list items to ensure UI consistency after updates.

* student + teacher invite (not fully tested)

* refactor: streamline course team member middleware

- Removed unnecessary console logging and timing for checking user permissions.
- Simplified the logic for determining if a user is a course team member or organization admin.

* reorder course sidebar to move content to its own page.

* feat: implement unified course enrollment route and API

- Introduced a new enrollment route `/course/:slug/enroll` to replace the previous invite system.
- Unified enrollment logic for free and paid courses, allowing direct enrollment for free courses and invite token validation for paid courses.
- Updated course landing page to redirect to the new enrollment route for free courses.
- Removed obsolete invite routes and related logic, streamlining the enrollment process.
- Enhanced API with a new `POST /course/:courseId/enroll` endpoint for handling enrollments.
- Added rate limiting for enrollment requests to prevent abuse.
- Updated UI components to reflect changes in enrollment flow and messaging.

* chore: update docker stack, docs, and auth token automation

* chore: make prettier check ignore unknown file types

* fix: avoid using authClient on server file and keep authClient client focused

* chore: always get the server on demand depending on the environment

* chore: update dashboard configuration and Docker setup for environment variables

- Modified the Svelte configuration to directly use the PUBLIC_SERVER_URL environment variable.
- Enhanced the Docker Compose file to include health check formatting and added environment variable arguments for the dashboard build.
- Updated the Dockerfile to echo environment variable values during the build process, ensuring clarity on the configuration being used.

* chore: enhance Docker setup and documentation for production deployment

- Added a new production Docker Compose file and example environment file for published images.
- Updated README and usage documentation to include instructions for deploying with production images.
- Modified Svelte configuration to standardize the use of the posthog domain.
- Removed unnecessary console logs from the API client and layout server files for cleaner output.
- Improved overall clarity in Docker documentation regarding self-hosting and image publishing.

* chore(db): log function name in query catch blocks (console.error 'funcName error:', error)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: multiple refresh issue

* fix: server key not passed to api

* fix: clarify usage of PRIVATE_SERVER_KEY and AUTH_BEARER_TOKEN in documentation

- Updated documentation to specify that PRIVATE_SERVER_KEY is used for server-to-server API key authentication and is validated by API key middleware.
- Clarified that AUTH_BEARER_TOKEN is a separate token not utilized by apiKeyMiddleware.
- Adjusted examples in Docker setup and self-hosting documentation to reflect these changes.

* fix migration script

* fix: improve language handling and add database reset script

- Updated language extraction to handle potential undefined values, defaulting to 'en' if necessary.
- Added a new script for resetting the database, including functionality to truncate public tables and run setup with optional seeding.

* feat: add promote ungrouped section functionality and enhance document handling in dashboard

- Implemented a new API endpoint to promote synthetic "Ungrouped" content into a real persisted section, allowing for better organization of course materials.
- Updated the dashboard to include a new component for displaying content count badges for lessons and exercises.
- Enhanced document management features, including a new document card component with dropdown actions for viewing, downloading, and removing documents.
- Improved file upload handling with a dedicated file drop zone and error handling for file type and size restrictions.
- Refactored existing components for better readability and maintainability, including updates to lesson and document lists.

* chore: use root env file for docker compose

* docs: document docker no-build startup flow

* chore: add no-build option to docker full stack script

* fix(email): handle SMTP TLS mode by port

* feat(auth): add configurable cookie domain env and docs

* apps/dashboard/src/lib/utils/services/auth/session.ts

* fix(email): make sender configurable via SMTP_SENDER

* chore(i18n): regenerate locale translations and support language targeting

* chore(config): switch api to PUBLIC_SERVER_URL

* fix(i18n): persist selected locale across navigation

* feat(assets): implement organization asset management routes and services

- Added new routes for managing organization assets, including listing, creating, updating, deleting, and exporting assets.
- Introduced asset-related services for handling asset operations such as creation, retrieval, and deletion.
- Integrated YouTube metadata resolution and asset attachment/detachment functionalities.
- Updated organization routes to include the new asset management routes.
- Enhanced error handling for asset-related operations.

* fix(document-list): update conditional rendering for edit mode in document list

* debug self hosted layout setup

* refactor(sidebar): simplify lms sidebar footer and improve navigation item rendering

* feat: tags

* chore: tags roadmap + translations

* fix(api): add authMiddleware to exercise, invites, and content routers

- exerciseRouter: apply authMiddleware before courseMemberMiddleware on all 10 routes
- invitesRouter: apply authMiddleware before courseTeamMemberMiddleware on all 4 routes
- contentRouter: add authMiddleware to PUT route (DELETE already had it)

Both courseMemberMiddleware and courseTeamMemberMiddleware document requiring
authMiddleware to be applied first. This aligns with lessonRouter pattern and
ensures explicit middleware contract regardless of global session middleware.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(course): use courseTeamMemberMiddleware for PUT, DELETE, and analytics routes

These routes require admin/tutor role per JSDoc but were using
courseMemberMiddleware which allows any course member including students.
Switch to courseTeamMemberMiddleware to restrict access to ADMIN/TUTOR
group members and org admins.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* chore: unify pricing component between dashboard and website (#630)

* feat: modularize pricing card

* feat: initia implementation of pricing togge

* fix: minor amendment

* refactor: update pricing components for improved layout and styling

- Adjusted spacing in the pricing card layout for better visual consistency.
- Modified button styles and sizes for enhanced usability.
- Cleaned up the pricing toggle component by removing unnecessary console logs and restructuring the button rendering logic.

* add handle click

* chore(css): update Tailwind CSS output with new utility classes and theme variables

* fix(api): enable orgMemberMiddleware for stats route

This change ensures that the stats route is protected by orgMemberMiddleware, aligning access control with other routes. Additionally, removed unnecessary console log from the layout server file to clean up the code.

---------

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

* pass query parameter to signup from pricing page

* ui updates to pricing cards

* commit all PRDs

* add open api spec to api prd

* make blue default theme

* more prds

* sync option id sequence on question update

* docs: more classroomio features

* chore: more ui improvements

* docs: new component docs

* chore: CSP Configuration (#638)

* feat(validation): add GitHub repository name validation

- Add ZRepositoryName schema for validating GitHub repo names
- Add ZRepositoryOwner for owner/username validation
- Add ZRepositoryIdentifier for owner/repo format
- Follow GitHub rules: A-Za-z0-9_.-, 1-100 chars, reserved names
- Add translation keys for validation messages

Fixes #637

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* Revert "feat(validation): add GitHub repository name validation"

This reverts commit 3436536bb17a0f05b3a9fe8dd7682cde64dd1dce.

* feat(dashboard): make CSP external domains configurable via env

Move hardcoded external domains from svelte.config.js to .env configuration
for on-premise deployments with strict security policies, data residency
constraints, or CISO-driven allowlists.

- ALLOWED_EXTERNAL_DOMAINS: single list for all CSP directives
- CSP_SCRIPT_SRC_DOMAINS, CSP_STYLE_SRC_DOMAINS, CSP_CONNECT_SRC_DOMAINS,
  CSP_FRAME_SRC_DOMAINS, CSP_FONT_SRC_DOMAINS: per-directive override
- When unset, defaults retain current SaaS behavior (backward compatible)

Fixes #637

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): self-hosted CSP uses only env vars, no defaults

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): add all classroomio.com domains to CSP defaults

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* docs: add CSP ALLOWED_EXTERNAL_DOMAINS to self-hosting guides

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(docker): pass CSP domain env vars as build args to dashboard

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat: add SSO and token authentication routes and services

- Introduced new routes for SSO and token authentication under the organization namespace.
- Implemented SSO connection management including creation, update, activation, and deletion.
- Added token authentication configuration with support for secret rotation and status retrieval.
- Enhanced error handling for SSO and token authentication processes.
- Updated existing routes and services to integrate new functionalities.
- Improved gradebook service to fetch student marks and exercises.
- Refactored API structure for better organization and maintainability.

* update translations

* fix: add PUBLIC_SERVER_URL to .env.example to prevent localhost fallback in production (#641)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* feat: new exercise question types

* MinIO S3 storage backend (#639)

* feat(storage): add MinIO/S3-compatible object storage backend

- Add OBJECT_STORAGE_* env vars for generic S3-compatible backends (MinIO, AWS S3)
- Create storage config module with Cloudflare R2 fallback for backward compatibility
- Refactor s3.ts to use configurable client and bucket names from config
- Update media.ts to use provider-neutral public URL (OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL or CLOUDFLARE_IMAGE_BUCKET_DOMAIN)
- Add MinIO service and init to docker-compose (profile: minio) with videos/documents/media buckets
- Add localhost:9000 to dashboard CSP for local MinIO presigned URL uploads
- Update self-hosting and Cloudflare docs with MinIO option
- Update .env examples and docker USAGE.md

Fixes #635

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* docs: add MINIO_ROOT_USER and MINIO_ROOT_PASSWORD to self-hosting guides

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(media): refactor media management and integrate MinIO support

- Updated media API to use a new `mediaApi` module, replacing `mediaManagerApi` references across components.
- Added MinIO configuration options to the Docker setup, allowing for optional object storage exclusion.
- Enhanced environment variable management for MinIO in the `.env.example` file.
- Updated Content Security Policy (CSP) to allow local MinIO access in the dashboard.
- Introduced new media components for asset management, including asset cards and usage dialogs.

This refactor improves the modularity of the media handling system and provides better support for S3-compatible storage solutions.

* feat(dashboard): enhance logging and refactor auth session handling

- Added console logging for event and environment variables in hooks.server.ts to aid debugging.
- Refactored auth session handling to use authServerClient instead of authClient in session.ts for improved consistency.
- Updated Dockerfile to ensure the dashboard build output is present before starting the application.
- Expanded self-hosting documentation to include troubleshooting steps for disk space issues in PostgreSQL and Docker.

These changes improve the observability of the application and streamline the authentication process.

* refactor(dashboard): remove debug logging and enhance Postgres healthcheck

- Removed console logging for event and environment variables in hooks.server.ts to clean up the code.
- Updated Postgres healthcheck in docker-compose.yaml to require a successful `SELECT 1` query, ensuring dependent services only start once the database is ready.
- Expanded self-hosting documentation to include troubleshooting steps for database recovery issues.

These changes improve code clarity and reliability in service startup sequences.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat: add Plyr media player icons and PDF.js integration

- Introduced new Plyr SVG icons for media player controls in both course and dashboard applications.
- Updated Plyr player configuration to use local icon assets.
- Integrated PDF.js for document viewing, replacing external CDN links with local paths for improved performance and reliability.
- Enhanced PDF viewer modal z-index for better visibility.

This update improves the media player experience and ensures consistent access to necessary resources.

* feat: enhance account and organization services with type definitions and promise return types

- Updated `getAccountData` to return a structured result type including profile and organizations.
- Modified `getOrganizationsWithFilters` to specify a promise return type for better type safety.
- Added new type imports for organization-related data structures to improve clarity and maintainability.

These changes improve type safety and enhance the overall structure of the account and organization services.

* feat: implement Redis caching for presigned URLs and refactor API interactions

- Added Redis caching for presigned download URLs in the API to reduce redundant S3 calls, improving performance.
- Introduced a new `PresignApi` class in the frontend to centralize presigned URL requests, enhancing code maintainability.
- Updated various components to utilize the new `presignApi` methods for document and video upload/download operations.
- Enhanced error handling for Redis operations to ensure graceful degradation in case of cache failures.

These changes optimize the presigned URL generation process and streamline API interactions across the application.

* fix: add better-auth dependency to dashboard (#645)

* chore: unify exercise answer data format

* feat: make selfhosting run under one org for simplicity (#647)

* feat: implement self-hosting PRD + license key system

Self-hosting single-org:
- Add PUBLIC_IS_SELFHOSTED to API env, block org creation when org exists
- Auto-add non-members as students in GET /account bootstrap
- getOrgSiteInfo: root domain returns single org for self-hosted
- App init: route to /lms when self-hosted + no orgs
- Hide org switcher and Add Org modal when self-hosted

Invite-only signup:
- Add settings.signup.inviteOnly via ZUpdateOrganization
- Allow public signups toggle in org auth settings
- Signup page: hide form when invite-only and no invite context
- Landing page nav: respect inviteOnly for signup link

First signup auto-verify:
- Self-hosted first signup (no orgs) auto-verifies email in createProfileHook

License key system:
- LICENSE_KEY, LICENSE_VERIFICATION_URL for external verification API
- Feature gates: SSO, token-auth, no-tracking (PostHog/Senja)
- requireLicense middleware for API routes
- Dashboard: license store, gate PostHog/Senja/SSO/token-auth UI
- docs/LICENSE_API_CONTRACT.md for external API spec

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat: get rid of carbon completely

* chore: license verification happens only for selfhosted instance

* fix: in self hosted instance, orgSite is the default

* docs: how to write math in cio editor

* Update packages/db/src/queries/organization/organization.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update apps/api/src/services/license.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: translations, cursor pr reviews, simplify license fetching and check

* refactor: update analytics setup for cloud and self-hosted deployments; enhance settings merge logic

* refactor: update license fetching URL for development environment; optimize organization query to prevent plan row loss

* more selfhosting improvements

* feat: enhance signup guard for self-hosted instances by requiring organization context when no org ID is provided

* pr reviews

* fix: correct query parameter handling in course navigation

* pr reviews

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
Co-authored-by: Abdulmuizz Hamzat <103611443+buildwithhamzat@users.noreply.github.com>
Co-authored-by: Adeyemi Adetunji <tvnji01@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-03-09 11:18:22 +01:00
committed by GitHub
co-authored by Rotimi Best greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Cursor Agent Abdulmuizz Hamzat Adeyemi Adetunji
parent a42565ad82
commit 1e9a82e912
1196 changed files with 118809 additions and 74705 deletions
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Component } from 'svelte';
import { cn } from '@cio/ui/tools';
interface Activity {
icon: Component;
@@ -7,17 +8,22 @@
percentage: number | string;
description: string;
hidePercentage?: boolean;
className?: string;
}
interface Props {
activity: Activity;
className?: string;
}
let { activity }: Props = $props();
let { activity, className = '' }: Props = $props();
</script>
<div
class="flex flex-col items-center justify-center gap-5 rounded-xl border p-3 md:flex-row md:p-5 dark:border-neutral-600"
class={cn(
'flex w-full flex-col items-center justify-evenly gap-2 rounded-xl border p-3 md:flex-row md:p-5',
className
)}
>
<div class="bg-primary-200 w-fit rounded-full p-4 text-black">
<activity.icon />
@@ -33,8 +39,10 @@
%
{/if}
</p>
<p class="text-center text-xs text-gray-500 md:text-left md:text-sm dark:text-gray-300">
{activity.description}
</p>
{#if activity.description}
<p class="text-center text-xs text-gray-500 md:text-left md:text-sm dark:text-gray-300">
{activity.description}
</p>
{/if}
</div>
</div>
@@ -13,7 +13,7 @@
let { user }: Props = $props();
</script>
<div class="rounded-md border p-5 dark:border-neutral-600">
<div class="rounded-md border p-5">
<div class="flex w-full flex-col items-center justify-start gap-4 text-start md:flex-row">
<Avatar.Root class="h-16 w-16">
<Avatar.Image
@@ -64,7 +64,7 @@
}
</script>
<div class="flex min-h-screen w-full items-center justify-center p-4">
<div class="auth-ui-background flex min-h-screen w-full items-center justify-center p-4">
<Card.Root class="ui:w-full max-w-[400px]">
{#if !showOnlyContent || showLogo}
<Card.Header class="ui:flex ui:flex-col ui:items-center ui:gap-4">
@@ -77,7 +77,7 @@
</Avatar.Root>
<a href="/">
<Card.Title class="ui:text-2xl ui:font-bold">
<Card.Title class="ui:text-2xl font-normal!">
{isLogin ? $t('login.welcome') : $t('login.create_account')}
</Card.Title>
</a>
@@ -122,3 +122,13 @@
{/if}
</Card.Root>
</div>
<style>
/* .auth-ui-background {
background: url(https://assets.cdn.clsrio.com/www/futuristic-classroom.jpg) no-repeat center center fixed;
-webkit-background-size: cover;
-moz-background-size: cover;
-o-background-size: cover;
background-size: cover;
} */
</style>
@@ -1,7 +1,7 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { Spinner } from '@cio/ui/base/spinner';
import { IconButton } from '@cio/ui/custom/icon-button';
import { CloseIcon } from '@cio/ui/custom/moving-icons';
interface Props {
onClick?: (value: string) => void;
@@ -18,8 +18,8 @@
<IconButton {tooltip} onclick={handleClick} disabled={isClosing}>
{#if isClosing}
<Spinner class="text-blue-700! size-5" />
<Spinner class="size-5 text-blue-700!" />
{:else}
<XIcon size={16} class="dark:text-black" />
<CloseIcon size={16} class="[&_svg]:dark:text-white" />
{/if}
</IconButton>
@@ -1,19 +0,0 @@
export function observeIntersection(node, callback, options = {}) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
callback(true);
} else {
callback(false);
}
});
}, options);
observer.observe(node);
return {
destroy() {
observer.unobserve(node);
}
};
}
@@ -4,7 +4,7 @@
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import type { Course } from '$lib/utils/types';
import type { Course } from '$features/course/utils/types';
import { NAV_ITEM_KEY } from '../../constants';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
@@ -3,7 +3,7 @@
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import type { Course } from '$lib/utils/types';
import type { Course } from '$features/course/utils/types';
import { NAV_ITEM_KEY } from '../../constants';
import { t } from '$lib/utils/functions/translations';
@@ -1,13 +1,23 @@
<script lang="ts">
import type { Component } from 'svelte';
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-right';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import { currentOrgDomain } from '$lib/utils/store/org';
import {
HeaderIcon,
GoalIcon,
LessonIcon,
ExerciseIcon,
CertificateIcon,
MoneyIcon,
ReviewIcon,
PersonIcon,
HoverableItem,
PreviewIcon
} from '@cio/ui/custom/moving-icons';
import { IconButton } from '@cio/ui/custom/icon-button';
import { CloseButton } from '$features/ui';
@@ -20,30 +30,32 @@
import CertificateForm from './certificate-form.svelte';
import InstructorForm from './instructor-form.svelte';
import { Button } from '@cio/ui/base/button';
import { updateCourse } from '$lib/utils/services/courses';
import { courseApi } from '$features/course/api';
import generateSlug from '$lib/utils/functions/generateSlug';
import * as Sidebar from '@cio/ui/base/sidebar';
import { useSidebar } from '@cio/ui/base/sidebar';
import { isMobile } from '$lib/utils/store/useMobile';
// import CustomPromptBtn from '$lib/components/AI/AIButton/CustomPromptBtn.svelte';
import type { Course } from '$lib/utils/types';
import type { Course } from '$features/course/utils/types';
import { t } from '$lib/utils/functions/translations';
interface Props {
course: Course;
courseId: string;
syncCourseStore: (course: Course) => void;
onClose?: () => void;
}
let { course = $bindable(), courseId, syncCourseStore }: Props = $props();
let { course = $bindable(), courseId, syncCourseStore, onClose }: Props = $props();
let borderBottomGrey = 'border-r-0 border-b border-l-0 border-gray-300';
const sidebar = useSidebar();
let loading = $state(false);
let show = $state(false);
interface Section {
key: number;
path: string;
title: string;
icon: Component;
enableAIWriter?: boolean;
initPrompt?: string;
}
@@ -52,12 +64,14 @@
{
key: 1,
path: '',
title: $t('course.navItem.landing_page.editor.title.header')
title: $t('course.navItem.landing_page.editor.title.header'),
icon: HeaderIcon
},
{
key: 2,
path: 'metadata.requirements',
title: $t('course.navItem.landing_page.editor.title.requirement'),
icon: ExerciseIcon,
enableAIWriter: true,
initPrompt: $t('course.navItem.landing_page.editor.title.requirement')
},
@@ -65,6 +79,7 @@
key: 3,
path: 'metadata.description',
title: $t('course.navItem.landing_page.editor.title.description'),
icon: LessonIcon,
enableAIWriter: true,
initPrompt: 'Please write a detailed course description for this course:'
},
@@ -72,35 +87,41 @@
key: 4,
path: 'metadata.goals',
title: $t('course.navItem.landing_page.editor.title.goals'),
icon: GoalIcon,
enableAIWriter: true,
initPrompt: 'What should a student expect to learn from this course:'
},
{
key: 5,
path: '',
title: $t('course.navItem.landing_page.editor.title.certificate')
title: $t('course.navItem.landing_page.editor.title.certificate'),
icon: CertificateIcon
},
{
key: 6,
path: '',
title: $t('course.navItem.landing_page.editor.title.reviews')
title: $t('course.navItem.landing_page.editor.title.reviews'),
icon: ReviewIcon
},
{
key: 7,
path: '',
title: $t('course.navItem.landing_page.editor.title.instructor')
title: $t('course.navItem.landing_page.editor.title.instructor'),
icon: PersonIcon
},
{
key: 8,
path: '',
title: $t('course.navItem.landing_page.editor.title.pricing')
title: $t('course.navItem.landing_page.editor.title.pricing'),
icon: MoneyIcon
}
];
let selectedSection: Section | null = $state(null);
function handleClose() {
if (!selectedSection) {
goto(`/courses/${courseId}`);
onClose?.();
return;
}
selectedSection = null;
@@ -129,15 +150,14 @@
course.slug = course.slug || generateSlug(course.title);
console.log('course', course);
await updateCourse(courseId, undefined, {
await courseApi.update(courseId, {
...course,
id: courseId,
attendance: undefined,
group: undefined,
lessons: undefined,
lesson_section: undefined,
polls: undefined,
slug: course.slug
type: course.type!,
slug: course.slug!,
isPublished: course.isPublished ?? undefined,
overview: course.overview ?? undefined,
isCertificateDownloadable: course.isCertificateDownloadable ?? undefined,
certificateTheme: course.certificateTheme ?? undefined
});
loading = false;
@@ -160,118 +180,116 @@
}
</script>
<aside
class={`${
show ? 'fixed z-50 -translate-x-full md:absolute' : 'fixed z-50 translate-x-0 md:relative'
}left-0 z-50 h-full w-[90vw] min-w-[300px] max-w-[350px] border border-b-0 border-l-0 border-r border-t-0 bg-gray-100 transition dark:bg-neutral-800`}
<Sidebar.Header
class="flex flex-row! items-center {sidebar.open ? 'justify-between' : 'justify-center'} border-b px-2 py-2"
>
<div class="toggler absolute rounded-full shadow-lg">
<IconButton
onclick={() => (show = !show)}
tooltip={$isMobile ? undefined : 'Toggle editor'}
tooltipSide={$isMobile ? undefined : 'right'}
>
{#if show}
<ChevronRightIcon size={16} />
{:else}
<ChevronLeftIcon size={16} />
{/if}
</IconButton>
</div>
<div class="flex h-full flex-col">
{#if !selectedSection}
<div class="flex w-full items-center justify-between px-2">
<CloseButton onClick={handleClose} />
<div class="flex items-center">
<Button type="button" class="mr-1" variant="outline" onclick={handleSave} {loading}>
{$t('course.navItem.landing_page.editor.save')}
</Button>
<IconButton onclick={handlePreview} disabled={loading || !course.slug}>
<ArrowUpRightIcon size={16} />
</IconButton>
</div>
{#if !selectedSection}
{#if sidebar.open}
<CloseButton onClick={handleClose} />
<div class="flex items-center gap-1" data-open={sidebar.open} data-mobile={sidebar.isMobile}>
<Button type="button" variant="outline" onclick={handleSave} {loading}>
{$t('course.navItem.landing_page.editor.save')}
</Button>
<HoverableItem>
{#snippet children(isHovered)}
<IconButton onclick={handlePreview} disabled={loading || !course.slug}>
<PreviewIcon {isHovered} size={16} />
</IconButton>
{/snippet}
</HoverableItem>
</div>
<div class="mb-2 flex w-full items-center justify-between px-2">
<h3 class="dark:text-white">{$t('course.navItem.landing_page.editor.page_builder')}</h3>
</div>
{#each sections as section, index}
<button
class="flex w-full items-center justify-between border border-l-0 px-2 py-3 {index + 1 < sections.length &&
'border-b-0'} border-gray-300"
onclick={handleSectionSelect(section.key)}
>
<p class="mr-2 dark:text-white">
{section.title}
{$t('course.navItem.landing_page.editor.section')}
</p>
<ChevronRightIcon size={16} />
</button>
{/each}
{:else}
<!-- Title -->
<div class="flex items-center {borderBottomGrey} w-full">
<IconButton onclick={handleClose}>
<ArrowLeftIcon size={16} />
</IconButton>
<div class=" flex items-center">
<h3 class="dark:text-white">
{selectedSection.title}
</h3>
<!-- {#if selectedSection.enableAIWriter}
<CustomPromptBtn
className="w-fit ml-2"
alignPopover="bottom-left"
defaultPrompt={`${selectedSection.initPrompt} ${course.title}. Please format in html`}
isHTML={true}
handleInsert={(v) => {
if (!selectedSection) return;
const _course = cloneDeep(course);
set(_course, selectedSection.path, v);
course = _course;
}}
/>
{/if} -->
</div>
</div>
<div class="title-content overflow-y-auto p-2">
{#if selectedSection.key === 1}
<HeaderForm bind:course />
{:else if selectedSection.key === 2}
<RequirementForm bind:course {setter} />
{:else if selectedSection.key === 3}
<DescriptionForm bind:course {setter} />
{:else if selectedSection.key === 4}
<GoalsForm bind:course {setter} />
{:else if selectedSection.key === 5}
<CertificateForm bind:course {setter} />
{:else if selectedSection.key === 6}
<ReviewsForm bind:course {setter} />
{:else if selectedSection.key === 7}
<InstructorForm bind:course {setter} />
{:else if selectedSection.key === 8}
<PricingForm bind:course {setter} />
{/if}
</div>
<CloseButton onClick={handleClose} />
{/if}
</div>
</aside>
{:else if sidebar.open || !sidebar.isMobile}
<div class="flex items-center gap-2">
<IconButton onclick={handleClose}>
<ArrowLeftIcon size={16} />
</IconButton>
<h3 class="text-sm font-semibold">
{selectedSection.title}
</h3>
</div>
{:else}
<IconButton onclick={handleClose} tooltip={selectedSection.title}>
<ArrowLeftIcon size={16} />
</IconButton>
{/if}
</Sidebar.Header>
<style>
.title-content {
height: 90%;
}
.toggler {
right: -15px;
z-index: 10;
border: 1px solid var(--border-color);
top: 50px;
height: fit-content;
background: var(--border-color);
}
@media screen and (max-width: 767px) {
.toggler {
right: -25px;
}
}
</style>
<Sidebar.Content class="flex-1 overflow-y-auto">
{#if !selectedSection}
{#if sidebar.open}
<Sidebar.Group>
<Sidebar.GroupLabel class="px-2">
{$t('course.navItem.landing_page.editor.page_builder')}
</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each sections as section}
{@const SectionIcon = section.icon}
<HoverableItem>
{#snippet children(isHovered)}
<Sidebar.MenuItem>
<Sidebar.MenuButton
onclick={handleSectionSelect(section.key)}
tooltipContent={section.title + ' ' + $t('course.navItem.landing_page.editor.section')}
>
<SectionIcon {isHovered} size={16} />
<span>{section.title} {$t('course.navItem.landing_page.editor.section')}</span>
<ChevronRightIcon size={16} />
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/snippet}
</HoverableItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
{:else}
<Sidebar.Menu>
{#each sections as section}
{@const SectionIcon = section.icon}
<HoverableItem>
{#snippet children(isHovered)}
<Sidebar.MenuItem>
<Sidebar.MenuButton
onclick={handleSectionSelect(section.key)}
tooltipContent={section.title + ' ' + $t('course.navItem.landing_page.editor.section')}
>
<SectionIcon {isHovered} size={16} />
<span>{section.title} {$t('course.navItem.landing_page.editor.section')}</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/snippet}
</HoverableItem>
{/each}
</Sidebar.Menu>
{/if}
{:else if sidebar.open || !sidebar.isMobile}
<div class="p-4">
{#if selectedSection.key === 1}
<HeaderForm bind:course />
{:else if selectedSection.key === 2}
<RequirementForm bind:course {setter} />
{:else if selectedSection.key === 3}
<DescriptionForm bind:course {setter} />
{:else if selectedSection.key === 4}
<GoalsForm bind:course {setter} />
{:else if selectedSection.key === 5}
<CertificateForm bind:course {setter} />
{:else if selectedSection.key === 6}
<ReviewsForm bind:course {setter} />
{:else if selectedSection.key === 7}
<InstructorForm bind:course {setter} />
{:else if selectedSection.key === 8}
<PricingForm bind:course {setter} />
{/if}
</div>
{:else}
<div class="flex items-center justify-center p-4">
<p class="text-muted-foreground text-sm">Expand sidebar to edit</p>
</div>
{/if}
</Sidebar.Content>
@@ -3,7 +3,7 @@
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import type { Course } from '$lib/utils/types';
import type { Course } from '$features/course/utils/types';
import { NAV_ITEM_KEY } from '../../constants';
import { t } from '$lib/utils/functions/translations';
@@ -4,7 +4,7 @@
import { InputField } from '@cio/ui/custom/input-field';
import UploadImage from '$features/ui/upload-image.svelte';
import { uploadImage } from '$lib/utils/services/upload';
import type { Course } from '$lib/utils/types';
import type { Course } from '$features/course/utils/types';
import { t } from '$lib/utils/functions/translations';
interface Props {
@@ -4,7 +4,7 @@
import { Switch } from '@cio/ui/base/switch';
import { Label } from '@cio/ui/base/label';
import type { Course } from '$lib/utils/types';
import type { Course } from '$features/course/utils/types';
import { t } from '$lib/utils/functions/translations';
import { isCourseFree } from '$lib/utils/functions/course';
@@ -3,7 +3,7 @@
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import type { Course } from '$lib/utils/types';
import type { Course } from '$features/course/utils/types';
import { NAV_ITEM_KEY } from '../../constants';
import { t } from '$lib/utils/functions/translations';
@@ -9,7 +9,7 @@
import { processErrors } from '$lib/utils/functions/validator';
import { shortenName } from '$lib/utils/functions/string';
import { t } from '$lib/utils/functions/translations';
import type { Course } from '$lib/utils/types';
import type { Course } from '$features/course/utils/types';
interface Props {
course: Course;
@@ -113,7 +113,7 @@
{/if}
<!-- the body -->
{#if review.id === reviewToExpand}
<ReviewFormEditor bind:reviews bind:review={reviews[index]} {errors} courseId={course.id} {onExpand} />
<ReviewFormEditor bind:reviews bind:review={reviews[index]} {errors} {onExpand} />
{/if}
</div>
{/each}
@@ -9,6 +9,6 @@
let { id, children }: Props = $props();
</script>
<section {id} transition:fade={{ delay: 250, duration: 300 }} class="border-b border-gray-300 pb-10 pt-12">
<section {id} transition:fade={{ delay: 250, duration: 300 }} class="border-b border-gray-300 pt-12 pb-10">
{@render children()}
</section>
@@ -1,8 +1,7 @@
<script lang="ts">
import { currentOrg } from '$lib/utils/store/org';
import { preventDefault } from '$lib/utils/functions/svelte';
import { coursePaymentValidation } from '$lib/utils/functions/validator';
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
import { courseApi } from '$features/course/api/course.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { InputField } from '@cio/ui/custom/input-field';
@@ -11,11 +10,10 @@
interface Props {
open?: boolean;
paymentLink?: string;
teacherEmail?: string;
courseName?: string;
courseId?: string;
}
let { open = $bindable(false), paymentLink = '', teacherEmail = '', courseName = '' }: Props = $props();
let { open = $bindable(false), paymentLink = '', courseId = '' }: Props = $props();
let fields = $state({
fullname: '',
@@ -34,7 +32,7 @@
let step = $state(STEPS.STEP_1);
function onSubmit() {
async function onSubmit() {
errors = coursePaymentValidation(fields);
console.log('coursePayment errors', errors);
@@ -42,31 +40,17 @@
return;
}
if (paymentLink) {
if (paymentLink && courseId) {
step = STEPS.STEP_2;
// Send email to first added teacher of student wanting to join
triggerSendEmail(NOTIFICATION_NAME.SEND_TEACHER_STUDENT_BUY_REQUEST, {
to: teacherEmail,
courseName,
studentEmail: fields.email,
studentFullname: fields.fullname
});
// Send payment request emails via backend
await courseApi.createPaymentRequest(courseId, fields.email, fields.fullname);
} else {
open = false;
}
}
function onClickPaymentLink() {
// Send email to student to send proof of payment to teacher
triggerSendEmail(NOTIFICATION_NAME.STUDENT_PROOVE_COURSE_PAYMENT, {
to: fields.email,
courseName,
teacherEmail,
studentFullname: fields.fullname,
orgName: $currentOrg.name
});
fields.fullname = '';
fields.email = '';
open = false;
@@ -2,14 +2,13 @@
import get from 'lodash/get';
import { Button } from '@cio/ui/base/button';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { isCourseFree, getStudentInviteLink, calcCourseDiscount } from '$lib/utils/functions/course';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { isCourseFree, calcCourseDiscount } from '$lib/utils/functions/course';
import { currentOrg } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import HTMLRender from '$features/ui/html-render.svelte';
import PaymentModal from './payment-modal.svelte';
import type { Course } from '$lib/utils/types';
import { ROLE } from '@cio/utils/constants';
import type { Course } from '$features/course/utils/types';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { t } from '$lib/utils/functions/translations';
@@ -34,12 +33,12 @@
const discount = $derived(get(courseData, 'metadata.discount', 0));
const calculatedCost = $derived(
calcCourseDiscount(discount, courseData.cost || 0, !!courseData.metadata.showDiscount)
calcCourseDiscount(discount, courseData.cost || 0, !!courseData.metadata?.showDiscount)
);
const isFree = $derived(isCourseFree(calculatedCost));
function handleJoinCourse() {
if (editMode) return;
async function handleJoinCourse() {
if (editMode || !$currentOrg.siteName) return;
capturePosthogEvent('join_course', {
course_id: courseData.id,
@@ -49,9 +48,7 @@
});
if (isFree) {
const link = getStudentInviteLink(courseData, $currentOrg.siteName, $currentOrgDomain);
goto(link);
goto(`/course/${courseData.slug}/enroll`);
} else {
openModal = true;
}
@@ -64,12 +61,6 @@
formatter = getCurrencyFormatter(currency);
}
function getTeacherEmail(group: Course['group']) {
const firstTutor = group?.members?.find((m) => m.role_id === ROLE.TUTOR);
return firstTutor?.profile?.email || '';
}
$effect(() => {
setFormatter(courseData.currency);
});
@@ -82,8 +73,7 @@
<PaymentModal
bind:open={openModal}
paymentLink={get(courseData, 'metadata.paymentLink', '')}
courseName={courseData.title}
teacherEmail={getTeacherEmail(courseData.group)}
courseId={courseData.id}
/>
<!-- Pricing Details -->
@@ -94,7 +84,7 @@
<aside
class="price-container sticky lg:hidden {editMode
? 'lg:bottom-2'
: 'lg:top-10'} m-h-fit z-0 bg-gray-50 lg:rounded-lg lg:shadow-2xl dark:bg-neutral-800 {className}"
: 'lg:top-10'} m-h-fit z-0 bg-gray-50 dark:bg-neutral-800 {className}"
>
<div class="flex items-center justify-center gap-3 px-3 py-3">
<!-- Pricing -->
@@ -125,7 +115,7 @@
<!-- Call To Action Buttons -->
<div class="flex h-full w-full flex-col items-center">
<Button onclick={handleJoinCourse} disabled={!courseData.metadata.allowNewStudent} class="w-full">
<Button onclick={handleJoinCourse} disabled={!courseData.metadata?.allowNewStudent} class="w-full">
{isFree
? $t('course.navItem.landing_page.pricing_section.enroll')
: $t('course.navItem.landing_page.pricing_section.buy')}
@@ -136,9 +126,9 @@
</div>
{:else}
<aside
class="price-container lg:sticky {editMode
class="price-container border border-gray-200 lg:sticky {editMode
? 'lg:top-0'
: 'lg:top-10'} m-h-fit lg:rounded-lg lg:shadow-2xl dark:bg-neutral-800 {className}"
: 'lg:top-10'} m-h-fit dark:bg-neutral-800 {className}"
>
<div class="p-2 lg:p-10">
<!-- Pricing -->
@@ -169,7 +159,7 @@
<!-- Call To Action Buttons -->
<div class="flex w-full flex-col items-center">
<Button class="mb-3 w-full" onclick={handleJoinCourse} disabled={!courseData.metadata.allowNewStudent}>
<Button class="mb-3 w-full" onclick={handleJoinCourse} disabled={!courseData.metadata?.allowNewStudent}>
{isFree
? $t('course.navItem.landing_page.pricing_section.enroll')
: $t('course.navItem.landing_page.pricing_section.buy')}
@@ -184,8 +174,8 @@
<!-- Gift Container -->
{#if courseData?.metadata?.reward?.show}
<div class="flex flex-col items-center border-b border-t border-gray-300 p-10">
<HtmlRender>{@html sanitizeHtml(get(courseData, 'metadata.reward.description', ''))}</HtmlRender>
<div class="flex flex-col items-center border-t border-b border-gray-300 p-10">
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.reward.description', '') as string)}</HTMLRender>
</div>
{/if}
</aside>
@@ -258,8 +248,4 @@
margin-left: 1rem;
list-style-type: disc;
}
:global(.plyr) {
width: 100% !important;
}
</style>
@@ -5,13 +5,13 @@
import pluralize from 'pluralize';
import { t } from '$lib/utils/functions/translations';
import type { Lesson } from '$lib/utils/types';
import type { LandingPageLesson } from '../utils';
interface Props {
title: string;
lessonCount: number;
exerciseCount: number;
lessons: Lesson[];
lessons: LandingPageLesson[];
}
let { title, lessonCount, exerciseCount, lessons }: Props = $props();
@@ -22,7 +22,7 @@
<div class="my-3 w-full rounded-md">
<!-- lesson header -->
<button
class="flex w-full items-center justify-between bg-[#F7F7F7] py-3 pl-3 pr-4 text-sm font-medium dark:bg-neutral-700"
class="flex w-full items-center justify-between bg-[#F7F7F7] py-3 pr-4 pl-3 text-sm font-medium dark:bg-neutral-700"
onclick={() => (expand = !expand)}
>
<span class="flex items-center gap-2">
@@ -7,28 +7,32 @@
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import PlayIcon from '@lucide/svelte/icons/play';
import { getLectureNo } from '$lib/components/Course/function';
import { getLectureNo } from '$features/course/utils/functions';
import { currentOrg } from '$lib/utils/store/org';
import { globalStore } from '$lib/utils/store/app';
import Navigation from '$lib/components/Navigation/index.svelte';
import { NAV_ITEM_KEY, NAV_ITEMS } from './constants';
import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date';
import { handleOpenWidget, reviewsModalStore } from './store';
import { COURSE_VERSION, type Course } from '$lib/utils/types';
import { course, sortLesson } from '$lib/components/Course/store';
import { getEmbedId } from '$lib/utils/functions/formatYoutubeVideo';
import { getExerciseCount, getLessonSections, getTotalLessons, filterNavItems } from './utils';
import type { Course, CourseMetadata } from '$features/course/utils/types';
import { courseApi } from '$features/course/api';
import { MediaPlayer, isYoutubeUrl } from '@cio/ui/custom/media-player';
import { filterNavItems, getCourseLessons, getCourseSections, getTotalLessons } from './utils';
import { Button } from '@cio/ui/base/button';
import { Chip } from '@cio/ui/custom/chip';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import * as Dialog from '@cio/ui/base/dialog';
import { Image, PoweredBy, UploadWidget } from '$features/ui';
import Image from '$features/ui/image.svelte';
import PoweredBy from '$features/ui/upgrade-powered-by.svelte';
import UploadWidget from '$features/ui/upload-widget/upload-widget.svelte';
import HTMLRender from '$features/ui/html-render.svelte';
import * as Avatar from '@cio/ui/base/avatar';
import PricingSection from './components/pricing-section.svelte';
import NavSection from './components/nav-section.svelte';
import { shortenName } from '$lib/utils/functions/string';
import SectionsDisplay from './components/sections-display.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import { observeIntersection } from './components/intersection-observer';
interface Props {
@@ -41,10 +45,7 @@
const reviews = $derived(get(courseData, 'metadata.reviews') || []);
const video = $derived(get(courseData, 'metadata.videoUrl'));
const lessons = $derived.by(() => {
const _lessons = get(courseData, 'lessons', []);
return sortLesson([..._lessons]);
});
const lessons = $derived(getCourseLessons(courseData));
const totalRatings = $derived(reviews?.reduce((acc = 0, review) => acc + (review?.rating || 0), 0));
const averageRating = $derived(totalRatings / reviews?.length);
const expandDescription = $derived(Array(reviews?.length ?? 0).fill(false));
@@ -52,38 +53,28 @@
const allowNewStudent = $derived(get(courseData, 'metadata.allowNewStudent'));
const bannerImage = $derived(get(courseData, 'logo'));
const instructor = $derived(get(courseData, 'metadata.instructor') || {});
const certificate: Course['metadata']['certificate'] = $derived(
const certificate = $derived(
get(courseData, 'metadata.certificate', {
templateUrl: '/images/certificate-template.svg'
})
}) as CourseMetadata['certificate']
);
const navItems = $derived(filterNavItems(courseData, reviews));
const navItemKeys = $derived(navItems.map((item) => item.key));
let player: any = $state();
let startCoursePayment = $state(false);
let isVisible = $state(false);
let observer: { destroy: () => void };
let activeNav = $state(NAV_ITEMS[0].key);
const lessonSections = getLessonSections(courseData);
const totalLessons = getTotalLessons(lessonSections);
const courseSections = $derived(getCourseSections(courseData));
const totalLessons = $derived(getTotalLessons(courseSections));
function locationHashChanged() {
activeNav = window.location.hash;
}
function initPlyr(_player: any, _video: string | undefined) {
if (!player) return;
// @ts-ignore
const plyr = new Plyr('#player', {});
// @ts-ignore
window.player = plyr;
}
function toggleDescription(id: number) {
expandDescription[id] = !expandDescription[id];
}
@@ -100,16 +91,22 @@
onDestroy(() => {
observer?.destroy();
});
$effect(() => {
initPlyr(player, video);
});
</script>
{#if !editMode}
<PoweredBy />
{/if}
{#if !editMode}
<Navigation
logo={$currentOrg.avatarUrl || '/logo-192.png'}
orgName={$currentOrg.name}
disableSignup={$currentOrg.disableSignup || ($currentOrg.settings?.signup?.inviteOnly ?? false)}
isOrgSite={$globalStore.isOrgSite}
customLinks={$currentOrg.landingpage?.customLinks}
/>
{/if}
<div class="flex w-full flex-col items-center bg-white dark:bg-black">
<!-- Header Section -->
<header id="header" class="banner p- flex w-full items-center justify-center">
@@ -137,24 +134,25 @@
{$t('course.navItem.landing_page.start_course')}
</Button>
{#if $handleOpenWidget.open}
<UploadWidget bind:imageURL={$course.logo} />
<UploadWidget imageURL={courseApi.course?.logo} onchange={(newLogo) => (courseApi.course!.logo = newLogo)} />
{/if}
</div>
<!-- Banner Image getEmbedId(videoUrl) -->
<!-- Banner Image -->
{#if video}
<div class="banner-image flex w-full md:w-2/3">
<div bind:this={player} id="player" data-plyr-provider="youtube" data-plyr-embed-id={getEmbedId(video)}></div>
</div>
<!-- <div class="container">
<div
bind:this={player}
id="player"
class="banner-image w-2/3 h-96 relative"
data-plyr-provider="youtube"
data-plyr-embed-id="bTqVqk7FSmY"
<MediaPlayer
source={{
type: isYoutubeUrl(video) ? 'youtube' : 'generic',
url: video
}}
options={{
width: '100%',
height: '400'
}}
class="w-full"
/>
</div> -->
</div>
{:else}
<div class="banner-image relative overflow-hidden rounded-md md:w-2/3">
<img
@@ -212,12 +210,12 @@
{#if navItemKeys.includes(NAV_ITEM_KEY.REQUIREMENT)}
<NavSection id="requirement">
<h3 class="mb-3 mt-0 text-2xl">
<h3 class="mt-0 mb-3 text-2xl">
{$t('course.navItem.landing_page.requirement')}
</h3>
<ul class="list font-light">
<HtmlRender>{@html sanitizeHtml(get(courseData, 'metadata.requirements', ''))}</HtmlRender>
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.requirements', ''))}</HTMLRender>
</ul>
</NavSection>
{/if}
@@ -225,22 +223,22 @@
<!-- Sections - Course Description -->
{#if navItemKeys.includes(NAV_ITEM_KEY.DESCRIPTION)}
<NavSection id="description">
<h3 class="mb-3 mt-0 text-2xl">
<h3 class="mt-0 mb-3 text-2xl">
{$t('course.navItem.landing_page.description')}
</h3>
<HtmlRender className="dark:text-white text-sm font-light">
<HTMLRender className="dark:text-white text-sm font-light">
{@html sanitizeHtml(get(courseData, 'metadata.description', ''))}
</HtmlRender>
</HTMLRender>
</NavSection>
{/if}
<!-- Sections - Goal -->
{#if navItemKeys.includes(NAV_ITEM_KEY.GOALS)}
<NavSection id="goals">
<h3 class="mb-3 mt-0 text-2xl">{$t('course.navItem.landing_page.learn')}</h3>
<h3 class="mt-0 mb-3 text-2xl">{$t('course.navItem.landing_page.learn')}</h3>
<ul class="list font-light">
<HtmlRender>{@html sanitizeHtml(get(courseData, 'metadata.goals', ''))}</HtmlRender>
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.goals', ''))}</HTMLRender>
</ul>
</NavSection>
{/if}
@@ -262,10 +260,10 @@
{/if}
<!-- Sections - Lessons -->
{#if courseData.version === COURSE_VERSION.V1}
{#if !courseData.metadata?.isContentGroupingEnabled}
<NavSection id="lessons">
<div class="mb-3 flex w-full items-center justify-between">
<h3 class="mb-3 mt-0 text-2xl">
<h3 class="mt-0 mb-3 text-2xl">
{$t('course.navItem.landing_page.content')}
</h3>
<p class="text-sm font-light dark:text-white">
@@ -284,23 +282,23 @@
{/each}
</div>
</NavSection>
{:else if courseData.version === COURSE_VERSION.V2}
{:else if courseData.metadata?.isContentGroupingEnabled}
<NavSection id="lessons">
<!-- header -->
<div class="flex items-center justify-between">
<h1>{$t('course.navItem.landing_page.course_content')}</h1>
<span class="text-xs font-normal">
{pluralize($t('course.navItem.landing_page.modules'), lessonSections.length, true)},
{pluralize($t('course.navItem.landing_page.modules'), courseSections.length, true)},
{pluralize($t('course.navItem.landing_page.lessons'), totalLessons, true)}
</span>
</div>
{#each lessonSections as section (section.id)}
{#each courseSections as section (section.id)}
<SectionsDisplay
exerciseCount={getExerciseCount(section.lessons)}
exerciseCount={section.exerciseCount}
lessonCount={section.lessons?.length}
lessons={section.lessons}
title={section.title}
title={section.title!}
/>
{/each}
</NavSection>
@@ -309,7 +307,7 @@
<!-- Sections - Reviews -->
{#if navItemKeys.includes(NAV_ITEM_KEY.REVIEWS)}
<NavSection id="reviews">
<h2 class="my-16 mb-6 ml-0 mr-0 font-semibold">
<h2 class="my-16 mr-0 mb-6 ml-0 font-semibold">
{$t('course.navItem.landing_page.reviews')}
</h2>
<div class="flex flex-wrap">
@@ -334,7 +332,7 @@
<!-- ratings -->
<div class="flex flex-row items-center">
{#if review.rating}
<img src="/images/rating-{review.rating}.svg" class="mr-4 mt-1 w-24" alt="" />
<img src="/images/rating-{review.rating}.svg" class="mt-1 mr-4 w-24" alt="" />
{/if}
</div>
<div class="read-more-content mb-2" style="max-height: {expandDescription[id] ? 'none' : '50px'}">
@@ -427,7 +425,7 @@
<!-- Sections - Instructor -->
<NavSection id="instructor">
<h3 class="mb-3 mt-0 text-2xl">
<h3 class="mt-0 mb-3 text-2xl">
{$t('course.navItem.landing_page.instructor')}
</h3>
<div class="mb-4 flex items-center">
@@ -468,7 +466,8 @@
</div>
</div>
<style lang="scss">
<!-- TODO: CONVERT TO TAILWIND -->
<style>
.banner {
background-color: #040f2d;
min-height: 472px;
@@ -482,59 +481,20 @@
max-width: 559px;
}
.backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
:global(.certificate-img) {
width: unset !important;
}
.active {
position: relative;
display: inline-block;
}
.active::after {
position: absolute;
content: '';
width: 100%;
height: 3px;
background-color: var(--main-primary-color);
display: block;
bottom: -14px;
left: 0px;
}
.price-container {
width: 405px;
min-width: 330px;
height: fit-content;
position: sticky;
top: 0;
}
.course-content {
max-width: 608px;
min-width: 60%;
}
nav {
overflow: auto;
margin: 0;
overflow-y: hidden;
width: 100%;
}
:global(.list ul li) {
margin-left: 1rem;
list-style-type: disc;
}
:global(.plyr) {
width: 100% !important;
}
.read-more-content {
max-height: 100px; /* Adjust this value to set the maximum height for the truncated content */
overflow: hidden;
@@ -542,16 +502,6 @@
text-overflow: ellipsis;
}
.read-more-button {
cursor: pointer;
color: blue;
text-decoration: underline;
}
.lesson-section:not(:last-child) {
border-bottom: 1px solid #f7f7f7;
}
@media screen and (max-width: 1023px) {
.course-content {
min-width: 80%;
@@ -1,25 +1,86 @@
import type { Course, Review } from '$lib/utils/types';
import type { Course, CourseContentItem } from '$features/course/utils/types';
import { NAV_ITEMS, NAV_ITEM_KEY } from './constants';
import { ContentType } from '@cio/utils/constants/content';
import type { Review } from '$features/course/utils/types';
import get from 'lodash/get';
export function getExerciseCount(lessons) {
return lessons.reduce((total, lesson) => total + (lesson.totalExercises?.[0]?.count || 0), 0);
export type LandingPageLesson = {
id: string;
title: string | null;
order?: number | null;
createdAt?: string | null;
};
export type LandingPageSection = {
id: string;
title: string | null;
lessons: LandingPageLesson[];
exerciseCount: number;
};
function sortLessons(lessons: LandingPageLesson[]): LandingPageLesson[] {
return [...lessons]
.sort((a, b) => new Date(a.createdAt || '').getTime() - new Date(b.createdAt || '').getTime())
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
export function getLessonSections(data) {
const sections =
data?.lesson_section?.map((section) => {
function getLessonsFromItems(items: CourseContentItem[]): LandingPageLesson[] {
const lessonItems = items.filter((item) => item.type === ContentType.Lesson);
const lessons = lessonItems.map((lesson) => ({
id: lesson.id,
title: lesson.title,
order: lesson.order ?? null,
createdAt: lesson.createdAt ?? null
}));
return lessons;
}
function getExerciseCountFromItems(items: CourseContentItem[]): number {
return items.filter((item) => item.type === ContentType.Exercise).length;
}
export function getCourseLessons(course: Course): LandingPageLesson[] {
if (!course?.content) return [];
if (course.content.grouped) {
const groupedLessons = course.content.sections.flatMap((section) => getLessonsFromItems(section.items));
return sortLessons(groupedLessons);
}
if (!course.content.items.length) return [];
return getLessonsFromItems(course.content.items);
}
export function getCourseSections(course: Course): LandingPageSection[] {
if (!course?.content) return [];
if (course.content.grouped) {
return course.content.sections.map((section) => {
const lessons = getLessonsFromItems(section.items);
return {
...section,
lessons: data.lessons.filter((lesson) => lesson.section_id === section.id)
id: section.id,
title: section.title ?? course.title ?? 'Lessons',
lessons,
exerciseCount: getExerciseCountFromItems(section.items)
};
}) || [];
});
}
return sections || [];
if (!course.content.items.length) return [];
return [
{
id: 'ungrouped',
title: course.title ?? 'Lessons',
lessons: getLessonsFromItems(course.content.items),
exerciseCount: getExerciseCountFromItems(course.content.items)
}
];
}
export function getTotalLessons(sections) {
export function getTotalLessons(sections: LandingPageSection[]) {
return sections.reduce((total, section) => {
return total + section.lessons.length;
}, 0);
@@ -0,0 +1,11 @@
<script lang="ts">
let { children, className = '', id = '', disableMaxWidth = false } = $props();
</script>
<article
style:max-width={disableMaxWidth ? 'unset' : undefined}
{id}
class="preview prose sm:prose-sm {className} dark:text-white"
>
{@render children()}
</article>
@@ -2,7 +2,7 @@
// import CircleCheckIcon from '@lucide/svelte/icons/circle-check';
interface Props {
filled?: Boolean;
filled?: boolean;
size?: number;
}
let { filled, size = 16 }: Props = $props();
@@ -1,185 +0,0 @@
<svg width="312" height="312" viewBox="0 0 312 312" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M155.722 138.894C157.24 138.894 158.471 140.124 158.471 141.642C158.471 143.16 157.24 144.391 155.722 144.391C154.204 144.391 152.973 143.16 152.973 141.642C152.973 140.124 154.204 138.894 155.722 138.894ZM163.129 138.894C164.646 138.894 165.877 140.124 165.877 141.642C165.877 143.16 164.646 144.391 163.129 144.391C161.61 144.391 160.38 143.16 160.38 141.642C160.38 140.124 161.61 138.894 163.129 138.894ZM170.382 138.894C171.9 138.894 173.131 140.124 173.131 141.642C173.131 143.16 171.9 144.391 170.382 144.391C168.865 144.391 167.634 143.16 167.634 141.642C167.634 140.124 168.865 138.894 170.382 138.894ZM182.428 135.959C185.622 135.959 188.211 138.549 188.211 141.744C188.211 144.938 185.622 147.528 182.428 147.528C179.233 147.528 176.644 144.938 176.644 141.744C176.644 138.549 179.233 135.959 182.428 135.959ZM78.6013 128.764V130.094C79.0637 130.264 79.5304 130.435 80.0009 130.605V128.764H84.5824V132.217C85.0458 132.375 85.5121 132.533 85.9823 132.691V128.764H90.5638V134.186C91.0272 134.333 91.4941 134.479 91.9637 134.624V128.764H96.5452V136.006C97.0089 136.141 97.4755 136.277 97.9448 136.411V128.764H102.526V137.683C102.991 137.808 103.457 137.931 103.926 138.055V128.764H108.508V139.222C108.972 139.336 109.439 139.45 109.908 139.561V128.764H114.489V140.625C114.954 140.728 115.42 140.831 115.889 140.934V128.764H120.47V141.895C120.935 141.988 121.401 142.082 121.87 142.174V128.764H126.451V143.036C126.917 143.119 127.384 143.203 127.852 143.285V128.764H132.433V144.048C132.898 144.122 133.365 144.196 133.832 144.267V128.764H138.414V144.936C155.445 147.282 174.378 148.38 194.918 147.326V118.124H69.082V126.377C70.9835 127.164 72.9778 127.961 75.0641 128.764H78.6013Z"
fill="#D3D3D3"
/>
<path
d="M138.414 144.931V154.72H133.833V144.264C133.364 144.192 132.898 144.119 132.433 144.045V154.72H127.852V143.281C127.383 143.198 126.917 143.116 126.452 143.032V154.72H121.87V142.17C121.402 142.078 120.936 141.984 120.47 141.891V154.72H115.889V140.93C115.42 140.828 114.954 140.724 114.489 140.621V154.72H109.908V139.558C109.439 139.446 108.972 139.332 108.508 139.217V154.72H103.926V138.052C103.457 137.928 102.991 137.804 102.526 137.68V154.72H97.9447V136.407C97.4755 136.273 97.0089 136.137 96.5452 136.002V154.72H91.9637V134.621C91.4942 134.475 91.0271 134.329 90.5637 134.182V154.72H85.9822V132.687C85.5121 132.53 85.0457 132.372 84.5823 132.213V154.72H80.0009V130.601C79.5304 130.431 79.0637 130.261 78.6013 130.09V154.72H74.0199V128.76H75.0641C72.9778 127.958 70.9834 127.159 69.082 126.373V165.359H194.918V147.321C174.378 148.375 155.444 147.279 138.414 144.931Z"
fill="#BABABA"
/>
<path
d="M74.0195 128.763V154.725H78.601V130.093C77.3945 129.65 76.2151 129.205 75.0638 128.763H74.0195Z"
fill="#919191"
/>
<path d="M75.0645 128.761C76.2158 129.204 77.3952 129.648 78.6017 130.092V128.761H75.0645Z" fill="#AAAAAA" />
<path d="M80.002 128.766V130.609C81.4896 131.146 83.0167 131.684 84.5833 132.22V128.766H80.002Z" fill="#AAAAAA" />
<path d="M80.002 154.725H84.5833V132.217C83.0167 131.682 81.4896 131.144 80.002 130.606V154.725Z" fill="#919191" />
<path d="M85.9824 154.725H90.5638V134.186C89.0026 133.692 87.4752 133.193 85.9824 132.692V154.725Z" fill="#919191" />
<path d="M85.9824 128.761V132.689C87.4753 133.19 89.0026 133.688 90.5638 134.184V128.761H85.9824Z" fill="#AAAAAA" />
<path d="M91.9629 128.763V134.625C93.4596 135.09 94.9873 135.55 96.5443 136.006V128.763H91.9629Z" fill="#AAAAAA" />
<path d="M91.9629 154.725H96.5443V136.006C94.9873 135.551 93.4597 135.09 91.9629 134.625V154.725Z" fill="#919191" />
<path d="M97.9453 128.768V136.416C99.4459 136.846 100.973 137.271 102.527 137.688V128.768H97.9453Z" fill="#AAAAAA" />
<path d="M97.9453 154.725H102.527V137.684C100.973 137.265 99.4459 136.842 97.9453 136.411V154.725Z" fill="#919191" />
<path d="M103.928 154.725H108.509V139.222C106.958 138.84 105.431 138.451 103.928 138.056V154.725Z" fill="#919191" />
<path d="M103.926 128.765V138.057C105.429 138.452 106.956 138.841 108.507 139.223V128.765H103.926Z" fill="#AAAAAA" />
<path d="M109.908 154.725H114.49V140.625C112.941 140.278 111.414 139.925 109.908 139.562V154.725Z" fill="#919191" />
<path d="M109.908 128.754V139.552C111.413 139.915 112.941 140.269 114.49 140.615V128.754H109.908Z" fill="#AAAAAA" />
<path d="M115.889 154.725H120.47V141.895C118.923 141.584 117.396 141.263 115.889 140.934V154.725Z" fill="#919191" />
<path d="M115.889 128.763V140.933C117.396 141.262 118.923 141.583 120.47 141.896V128.763H115.889Z" fill="#AAAAAA" />
<path d="M121.869 154.73H126.451V143.042C124.905 142.763 123.378 142.476 121.869 142.18V154.73Z" fill="#919191" />
<path d="M121.871 128.764V142.175C123.38 142.471 124.907 142.759 126.453 143.037V128.764H121.871Z" fill="#AAAAAA" />
<path d="M127.852 154.725H132.433V144.048C130.888 143.803 129.362 143.549 127.852 143.284V154.725Z" fill="#919191" />
<path d="M127.852 128.76V143.281C129.362 143.545 130.889 143.8 132.432 144.045V128.76H127.852Z" fill="#AAAAAA" />
<path d="M133.832 128.763V144.268C135.343 144.5 136.871 144.722 138.414 144.935V128.763H133.832Z" fill="#AAAAAA" />
<path d="M133.832 154.725H138.414V144.936C136.872 144.723 135.344 144.5 133.832 144.268V154.725Z" fill="#919191" />
<path
d="M155.721 144.395C157.239 144.395 158.47 143.163 158.47 141.646C158.47 140.127 157.239 138.897 155.721 138.897C154.203 138.897 152.973 140.127 152.973 141.646C152.973 143.163 154.203 144.395 155.721 144.395Z"
fill="#494949"
/>
<path
d="M163.128 144.395C164.647 144.395 165.877 143.163 165.877 141.646C165.877 140.127 164.647 138.897 163.128 138.897C161.61 138.897 160.379 140.127 160.379 141.646C160.379 143.163 161.61 144.395 163.128 144.395Z"
fill="#494949"
/>
<path
d="M170.384 144.395C171.901 144.395 173.133 143.163 173.133 141.646C173.133 140.127 171.901 138.897 170.384 138.897C168.865 138.897 167.635 140.127 167.635 141.646C167.635 143.163 168.865 144.395 170.384 144.395Z"
fill="#494949"
/>
<path
d="M182.429 147.528C185.623 147.528 188.213 144.939 188.213 141.745C188.213 138.55 185.623 135.96 182.429 135.96C179.234 135.96 176.645 138.55 176.645 141.745C176.645 144.939 179.234 147.528 182.429 147.528Z"
fill="#5A231C"
/>
<path d="M189.568 171.967H74.4316V165.361H189.568V171.967Z" fill="#595959" />
<path d="M189.568 168.02H74.4316V165.365H189.568V168.02Z" fill="#2B2B2B" />
<path
d="M155.721 192.737C157.24 192.737 158.47 193.968 158.47 195.486C158.47 197.004 157.24 198.235 155.721 198.235C154.203 198.235 152.973 197.004 152.973 195.486C152.973 193.968 154.203 192.737 155.721 192.737ZM163.128 192.737C164.647 192.737 165.877 193.968 165.877 195.486C165.877 197.004 164.647 198.235 163.128 198.235C161.61 198.235 160.38 197.004 160.38 195.486C160.38 193.968 161.61 192.737 163.128 192.737ZM170.382 192.737C171.901 192.737 173.131 193.968 173.131 195.486C173.131 197.004 171.901 198.235 170.382 198.235C168.864 198.235 167.633 197.004 167.633 195.486C167.633 193.968 168.864 192.737 170.382 192.737ZM182.427 189.803C185.621 189.803 188.212 192.394 188.212 195.588C188.212 198.782 185.621 201.372 182.427 201.372C179.233 201.372 176.644 198.782 176.644 195.588C176.644 192.394 179.233 189.803 182.427 189.803ZM78.6013 182.607V183.939C79.0637 184.108 79.5304 184.279 80.0009 184.45V182.607H84.5823V186.061C85.0457 186.219 85.5121 186.377 85.9822 186.536V182.607H90.5637V188.03C91.0271 188.176 91.4942 188.323 91.9637 188.468V182.607H96.5452V189.85C97.0089 189.986 97.4755 190.12 97.9447 190.255V182.607H102.526V191.527C102.991 191.652 103.457 191.775 103.926 191.899V182.607H108.508V193.066C108.972 193.18 109.439 193.293 109.908 193.406V182.607H114.489V194.469C114.954 194.573 115.42 194.675 115.889 194.778V182.607H120.47V195.739C120.936 195.833 121.402 195.926 121.87 196.018V182.607H126.452V196.88C126.917 196.964 127.383 197.046 127.852 197.128V182.607H132.433V197.893C132.898 197.967 133.364 198.039 133.833 198.111V182.607H138.414V198.779C155.444 201.127 174.378 202.223 194.918 201.17V171.968H69.082V180.221C70.9834 181.008 72.9778 181.805 75.0641 182.607H78.6013Z"
fill="#D3D3D3"
/>
<path
d="M138.416 198.775V208.564H133.835V198.107C133.366 198.035 132.9 197.962 132.435 197.888V208.564H127.854V197.124C127.385 197.042 126.919 196.959 126.454 196.875V208.564H121.872V196.013C121.404 195.921 120.938 195.828 120.472 195.734V208.564H115.891V194.773C115.422 194.671 114.956 194.568 114.491 194.464V208.564H109.91V193.402C109.441 193.289 108.974 193.175 108.51 193.061V208.564H103.928V191.894C103.459 191.771 102.993 191.647 102.528 191.522V208.564H97.9467V190.251C97.4775 190.115 97.0109 189.981 96.5471 189.846V208.564H91.9657V188.463C91.4961 188.319 91.0291 188.171 90.5656 188.025V208.564H85.9842V186.531C85.5141 186.372 85.0477 186.215 84.5843 186.057V208.564H80.0028V184.445C79.5323 184.274 79.0656 184.104 78.6032 183.934V208.564H74.0218V182.602H75.0661C72.9798 181.801 70.9854 181.003 69.084 180.217V219.203H194.92V201.165C174.38 202.219 155.446 201.122 138.416 198.775Z"
fill="#BABABA"
/>
<path
d="M74.0215 182.607V208.568H78.6029V183.938C77.3965 183.493 76.2171 183.05 75.0658 182.607H74.0215Z"
fill="#919191"
/>
<path d="M75.0645 182.611C76.2158 183.054 77.3952 183.498 78.6016 183.942V182.611H75.0645Z" fill="#AAAAAA" />
<path d="M80.002 182.609V184.452C81.4897 184.99 83.0168 185.528 84.5833 186.063V182.609H80.002Z" fill="#AAAAAA" />
<path d="M80.002 208.565H84.5835V186.058C83.0167 185.522 81.4897 184.984 80.002 184.446V208.565Z" fill="#919191" />
<path d="M85.9824 208.568H90.5637V188.029C89.0025 187.534 87.4752 187.036 85.9824 186.535V208.568Z" fill="#919191" />
<path d="M85.9824 182.612V186.541C87.4753 187.041 89.0022 187.54 90.564 188.034V182.612H85.9824Z" fill="#AAAAAA" />
<path d="M91.9629 182.607V188.468C93.4589 188.934 94.9866 189.395 96.5437 189.85V182.607H91.9629Z" fill="#AAAAAA" />
<path d="M91.9629 208.565H96.5446V189.846C94.9871 189.391 93.4599 188.93 91.9629 188.465V208.565Z" fill="#919191" />
<path d="M97.9453 182.61V190.258C99.4461 190.689 100.973 191.113 102.527 191.53V182.61H97.9453Z" fill="#AAAAAA" />
<path d="M97.9453 208.565H102.527V191.524C100.973 191.106 99.4459 190.682 97.9453 190.252V208.565Z" fill="#919191" />
<path d="M103.926 208.565H108.507V193.062C106.956 192.681 105.429 192.292 103.926 191.896V208.565Z" fill="#919191" />
<path d="M103.926 182.606V191.898C105.429 192.293 106.957 192.683 108.507 193.064V182.606H103.926Z" fill="#AAAAAA" />
<path d="M109.908 208.565H114.489V194.465C112.941 194.12 111.413 193.765 109.908 193.403V208.565Z" fill="#919191" />
<path d="M109.908 182.604V193.402C111.413 193.764 112.941 194.119 114.49 194.465V182.604H109.908Z" fill="#AAAAAA" />
<path d="M115.889 208.565H120.47V195.736C118.923 195.425 117.396 195.103 115.889 194.775V208.565Z" fill="#919191" />
<path d="M115.889 182.601V194.771C117.397 195.101 118.923 195.422 120.47 195.733V182.601H115.889Z" fill="#AAAAAA" />
<path d="M121.871 208.565H126.452V196.877C124.907 196.599 123.38 196.311 121.871 196.015V208.565Z" fill="#919191" />
<path d="M121.871 182.608V196.02C123.38 196.316 124.907 196.603 126.453 196.881V182.608H121.871Z" fill="#AAAAAA" />
<path d="M127.852 208.568H132.433V197.892C130.889 197.648 129.362 197.392 127.852 197.128V208.568Z" fill="#919191" />
<path d="M127.852 182.606V197.127C129.362 197.39 130.889 197.646 132.433 197.891V182.606H127.852Z" fill="#AAAAAA" />
<path d="M133.832 182.613V198.117C135.344 198.349 136.871 198.572 138.414 198.784V182.613H133.832Z" fill="#AAAAAA" />
<path d="M133.832 208.565H138.414V198.776C136.871 198.564 135.344 198.34 133.832 198.109V208.565Z" fill="#919191" />
<path
d="M155.722 198.23C157.24 198.23 158.47 197 158.47 195.482C158.47 193.963 157.24 192.733 155.722 192.733C154.203 192.733 152.973 193.963 152.973 195.482C152.973 197 154.203 198.23 155.722 198.23Z"
fill="#494949"
/>
<path
d="M163.13 198.23C164.648 198.23 165.879 196.999 165.879 195.482C165.879 193.963 164.648 192.733 163.13 192.733C161.612 192.733 160.381 193.963 160.381 195.482C160.381 196.999 161.612 198.23 163.13 198.23Z"
fill="#494949"
/>
<path
d="M170.383 198.23C171.902 198.23 173.132 197 173.132 195.481C173.132 193.964 171.902 192.733 170.383 192.733C168.865 192.733 167.635 193.964 167.635 195.481C167.635 197 168.865 198.23 170.383 198.23Z"
fill="#494949"
/>
<path
d="M182.429 201.372C185.623 201.372 188.213 198.783 188.213 195.588C188.213 192.394 185.623 189.804 182.429 189.804C179.234 189.804 176.645 192.394 176.645 195.588C176.645 198.783 179.234 201.372 182.429 201.372Z"
fill="#5A231C"
/>
<path d="M189.568 225.811H74.4316V219.205H189.568V225.811Z" fill="#595959" />
<path d="M189.568 221.863H74.4316V219.209H189.568V221.863Z" fill="#2B2B2B" />
<path
d="M155.721 246.581C157.24 246.581 158.47 247.812 158.47 249.33C158.47 250.848 157.24 252.079 155.721 252.079C154.203 252.079 152.973 250.848 152.973 249.33C152.973 247.812 154.203 246.581 155.721 246.581ZM163.128 246.581C164.647 246.581 165.877 247.812 165.877 249.33C165.877 250.848 164.647 252.079 163.128 252.079C161.61 252.079 160.38 250.848 160.38 249.33C160.38 247.812 161.61 246.581 163.128 246.581ZM170.382 246.581C171.901 246.581 173.131 247.812 173.131 249.33C173.131 250.848 171.901 252.079 170.382 252.079C168.864 252.079 167.633 250.848 167.633 249.33C167.633 247.812 168.864 246.581 170.382 246.581ZM182.427 243.648C185.621 243.648 188.212 246.237 188.212 249.432C188.212 252.626 185.621 255.216 182.427 255.216C179.233 255.216 176.644 252.626 176.644 249.432C176.644 246.237 179.233 243.648 182.427 243.648ZM78.6013 236.451V237.782C79.0637 237.952 79.5304 238.123 80.0009 238.293V236.451H84.5823V239.905C85.0457 240.063 85.5121 240.221 85.9822 240.379V236.451H90.5637V241.873C91.0271 242.02 91.4942 242.166 91.9637 242.312V236.451H96.5452V243.693C97.0089 243.829 97.4755 243.964 97.9447 244.099V236.451H102.526V245.371C102.991 245.496 103.457 245.62 103.926 245.743V236.451H108.508V246.909C108.972 247.023 109.439 247.137 109.908 247.25V236.451H114.489V248.312C114.954 248.416 115.42 248.519 115.889 248.622V236.451H120.47V249.583C120.936 249.677 121.402 249.77 121.87 249.862V236.451H126.452V250.724C126.917 250.807 127.383 250.89 127.852 250.972V236.451H132.433V251.736C132.898 251.81 133.364 251.883 133.833 251.955V236.451H138.414V252.623C155.444 254.97 174.378 256.067 194.918 255.013V225.812H69.082V234.065C70.9834 234.851 72.9778 235.649 75.0641 236.451H78.6013Z"
fill="#D3D3D3"
/>
<path
d="M138.414 252.618V262.408H133.833V251.951C133.364 251.879 132.898 251.806 132.433 251.732V262.408H127.852V250.968C127.383 250.886 126.917 250.803 126.452 250.719V262.408H121.87V249.857C121.402 249.765 120.936 249.672 120.47 249.578V262.408H115.889V248.617C115.42 248.515 114.954 248.412 114.489 248.308V262.408H109.908V247.245C109.439 247.132 108.972 247.019 108.508 246.905V262.408H103.926V245.739C103.457 245.615 102.991 245.491 102.526 245.366V262.408H97.9447V244.094C97.4755 243.96 97.0089 243.824 96.5452 243.689V262.408H91.9637V242.308C91.4942 242.162 91.0271 242.016 90.5637 241.869V262.408H85.9822V240.375C85.5121 240.217 85.0457 240.059 84.5823 239.9V262.408H80.0009V238.288C79.5304 238.118 79.0637 237.948 78.6013 237.777V262.408H74.0199V236.447H75.0641C72.9778 235.644 70.9834 234.847 69.082 234.06V273.047H194.918V255.009C174.378 256.063 155.444 254.966 138.414 252.618Z"
fill="#BABABA"
/>
<path
d="M74.0195 236.451V262.412H78.601V237.781C77.3947 237.338 76.2152 236.893 75.064 236.451H74.0195Z"
fill="#919191"
/>
<path d="M75.0645 236.461C76.2156 236.904 77.3951 237.348 78.6015 237.792V236.461H75.0645Z" fill="#AAAAAA" />
<path d="M80 236.452V238.294C81.488 238.832 83.015 239.37 84.5816 239.905V236.452H80Z" fill="#AAAAAA" />
<path d="M80 262.412H84.5816V239.904C83.015 239.369 81.488 238.831 80 238.293V262.412Z" fill="#919191" />
<path d="M85.9824 262.412H90.5637V241.873C89.0026 241.379 87.4753 240.88 85.9824 240.379V262.412Z" fill="#919191" />
<path d="M85.9824 236.456V240.384C87.4753 240.885 89.0026 241.384 90.5637 241.878V236.456H85.9824Z" fill="#AAAAAA" />
<path d="M91.9629 236.451V242.312C93.4596 242.777 94.9873 243.238 96.5443 243.693V236.451H91.9629Z" fill="#AAAAAA" />
<path d="M91.9629 262.412H96.5443V243.693C94.9873 243.238 93.4596 242.777 91.9629 242.312V262.412Z" fill="#919191" />
<path d="M97.9453 236.455V244.102C99.4455 244.532 100.973 244.956 102.527 245.374V236.455H97.9453Z" fill="#AAAAAA" />
<path d="M97.9453 262.412H102.527V245.371C100.973 244.953 99.4455 244.529 97.9453 244.098V262.412Z" fill="#919191" />
<path d="M103.928 262.412H108.509V246.909C106.958 246.528 105.431 246.139 103.928 245.743V262.412Z" fill="#919191" />
<path d="M103.928 236.45V245.742C105.431 246.138 106.958 246.527 108.509 246.908V236.45H103.928Z" fill="#AAAAAA" />
<path d="M109.908 262.412H114.49V248.312C112.94 247.966 111.413 247.612 109.908 247.249V262.412Z" fill="#919191" />
<path d="M109.908 236.449V247.247C111.413 247.609 112.94 247.963 114.49 248.31V236.449H109.908Z" fill="#AAAAAA" />
<path d="M115.889 262.412H120.47V249.583C118.923 249.271 117.396 248.95 115.889 248.621V262.412Z" fill="#919191" />
<path d="M115.889 236.454V248.625C117.396 248.953 118.923 249.274 120.47 249.586V236.454H115.889Z" fill="#AAAAAA" />
<path d="M121.871 262.412H126.452V250.723C124.907 250.445 123.38 250.158 121.871 249.861V262.412Z" fill="#919191" />
<path d="M121.871 236.452V249.863C123.38 250.159 124.907 250.446 126.452 250.725V236.452H121.871Z" fill="#AAAAAA" />
<path d="M127.852 262.412H132.433V251.736C130.889 251.491 129.362 251.236 127.852 250.972V262.412Z" fill="#919191" />
<path d="M127.852 236.447V250.968C129.362 251.232 130.889 251.487 132.433 251.732V236.447H127.852Z" fill="#AAAAAA" />
<path d="M133.834 236.443V251.948C135.346 252.18 136.873 252.403 138.416 252.615V236.443H133.834Z" fill="#AAAAAA" />
<path d="M133.832 262.412H138.414V252.623C136.871 252.41 135.344 252.188 133.832 251.955V262.412Z" fill="#919191" />
<path
d="M155.722 252.074C157.24 252.074 158.47 250.844 158.47 249.325C158.47 247.807 157.24 246.576 155.722 246.576C154.203 246.576 152.973 247.807 152.973 249.325C152.973 250.844 154.203 252.074 155.722 252.074Z"
fill="#494949"
/>
<path
d="M163.13 252.074C164.648 252.074 165.879 250.844 165.879 249.325C165.879 247.807 164.648 246.576 163.13 246.576C161.612 246.576 160.381 247.807 160.381 249.325C160.381 250.844 161.612 252.074 163.13 252.074Z"
fill="#494949"
/>
<path
d="M170.383 252.074C171.902 252.074 173.132 250.844 173.132 249.325C173.132 247.807 171.902 246.576 170.383 246.576C168.865 246.576 167.635 247.807 167.635 249.325C167.635 250.844 168.865 252.074 170.383 252.074Z"
fill="#494949"
/>
<path
d="M182.429 255.222C185.623 255.222 188.213 252.632 188.213 249.438C188.213 246.243 185.623 243.654 182.429 243.654C179.234 243.654 176.645 246.243 176.645 249.438C176.645 252.632 179.234 255.222 182.429 255.222Z"
fill="#5A231C"
/>
<path d="M189.568 279.663H74.4316V273.058H189.568V279.663Z" fill="#595959" />
<path d="M189.568 275.707H74.4316V273.052H189.568V275.707Z" fill="#2B2B2B" />
<path
d="M202.508 19.04C180.343 18.0043 162.736 37.2854 162.736 55.7051C160.664 79.1135 183.658 112.881 206.031 93.2011C208.516 89.9557 212.784 82.0148 214.11 77.0431C215.197 72.9634 221.335 65.4895 225.562 60.4589C227.165 58.5526 228.397 56.3297 228.937 53.8987C234.929 26.8994 214.15 19.3707 202.508 19.04Z"
fill="#5C6065"
/>
<path
d="M185.938 29.3584H195.502C195.871 29.3584 196.056 29.8047 195.795 30.0657L186.645 39.216C186.384 39.477 186.569 39.9232 186.938 39.9232H196.502"
stroke="#FBFEFC"
stroke-width="2.48585"
stroke-linecap="round"
/>
<path
d="M202.717 46.1367H212.281C212.651 46.1367 212.835 46.583 212.574 46.844L203.424 55.9943C203.163 56.2553 203.348 56.7016 203.717 56.7016H213.282"
stroke="#FBFEFC"
stroke-width="2.48585"
stroke-linecap="round"
/>
<path
d="M184.486 68.9229H196.864C197.342 68.9229 197.581 69.5004 197.243 69.8381L185.402 81.6797C185.064 82.0175 185.303 82.595 185.781 82.595H198.158"
stroke="#FBFEFC"
stroke-width="3.21698"
stroke-linecap="round"
/>
<path
d="M167.293 90.5064C168.832 93.7017 170.805 97.4846 168.95 98.3783C167.095 99.2719 161.582 97.0161 160.043 93.8208C158.504 90.6255 159.866 85.4339 161.722 84.5402C163.577 83.6466 165.754 87.3111 167.293 90.5064Z"
fill="#5C6065"
/>
<path
d="M156.205 106.632C154.75 106.487 152.987 106.448 153.192 104.399C153.397 102.349 155.609 98.5627 157.064 98.7083C158.519 98.8538 159.849 102.485 159.644 104.534C159.439 106.583 157.66 106.778 156.205 106.632Z"
fill="#5C6065"
/>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

@@ -2,7 +2,6 @@ export { default as ArrowTopRight } from './arrow-top-right.svelte';
export { default as CircleCheckIcon } from './circle-check-icon.svelte';
export { default as CoursesEmptyIcon } from './courses-empty-icon.svelte';
export { default as EmailSentIcon } from './email-sent-icon.svelte';
export { default as ErrorIcon } from './error-icon.svelte';
export { default as ErrorIconDarkMode } from './error-icon-dark-mode.svelte';
export { default as GoogleIconColored } from './google-icon-colored.svelte';
export { default as UserProfileIcon } from './user-profile-icon.svelte';
@@ -9,8 +9,11 @@ export { default as ComingSoon } from './coming-soon.svelte';
export { default as Confetti } from './confetti/confetti.svelte';
export { default as DeleteModal } from './delete-modal.svelte';
export { default as HeroProfileCard } from './analytics/hero-profile-card.svelte';
export { default as HTMLRender } from './html-render.svelte';
export { default as Image } from './image.svelte';
export { MediaPlayer } from '@cio/ui/custom/media-player';
export { default as LoadingPage } from './analytics/loading-page.svelte';
export * from './question';
export { default as UploadWidget } from './upload-widget/upload-widget.svelte';
export { default as PageLoadProgress } from './page-load-progress.svelte';
export { default as PoweredBy } from './upgrade-powered-by.svelte';
@@ -0,0 +1,3 @@
export { default as MediaPlayer } from './media-player.svelte';
export type { VideoSource, VideoType, MediaPlayerOptions } from './types';
export { formatYoutubeEmbedUrl, getYoutubeVideoId, isYoutubeUrl } from './utils';
@@ -0,0 +1,28 @@
<script lang="ts">
import type { VideoSource, MediaPlayerOptions } from './types';
import PlyrPlayer from './players/plyr-player.svelte';
import MusePlayer from './players/muse-player.svelte';
interface Props {
source: VideoSource;
options?: MediaPlayerOptions;
class?: string;
}
let { source, options = {}, class: className = '' }: Props = $props();
// Muse.ai requires iframe, everything else uses Plyr
const isMuse = $derived.by(() => {
return source.type === 'muse' && source.metadata?.svid;
});
const poster = $derived(source.type === 'upload' ? source.metadata?.thumbnailUrl : undefined);
</script>
<div class="mb-5 {className}">
{#if isMuse}
<MusePlayer svid={source.metadata?.svid} {options} />
{:else}
<PlyrPlayer src={source.url} {poster} {options} />
{/if}
</div>
@@ -0,0 +1,13 @@
export const PLYR_DEFAULT_CONTROLS: string[] = [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'captions',
'settings',
'pip',
'airplay',
'fullscreen'
];
@@ -0,0 +1,28 @@
<script lang="ts">
import type { MediaPlayerOptions } from '../types';
interface Props {
svid?: string;
options?: MediaPlayerOptions;
}
let { svid }: Props = $props();
const embedUrl = $derived(
svid
? `https://muse.ai/embed/${svid}?logo=https://app.classroomio.com/logo-512.png&subtitles=auto&cover_play_position=center`
: ''
);
</script>
{#if embedUrl}
<div style="position:relative;padding-bottom:51.416579%">
<iframe
src={embedUrl}
style="width:100%;height:100%;position:absolute;left:0;top:0"
frameborder="0"
allowfullscreen
title="Muse AI Video Embed"
></iframe>
</div>
{/if}
@@ -0,0 +1,113 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import 'plyr/dist/plyr.css';
import type { MediaPlayerOptions } from '../types';
import { isYoutubeUrl, getYoutubeVideoId } from '../utils';
import { PLYR_DEFAULT_CONTROLS } from './constants';
import type Plyr from 'plyr';
interface Props {
src: string;
poster?: string;
options?: MediaPlayerOptions;
}
let { src, poster, options = {} }: Props = $props();
let videoElement: HTMLVideoElement | null = null;
let containerElement: HTMLDivElement | null = null;
let playerInstance: Plyr | null = null;
const playsinline = $derived(options.playsinline !== false);
const maxHeight = $derived(options.maxHeight || '400px');
const showControls = $derived(options.controls !== false);
const autoplay = $derived(options.autoplay === true);
const isYouTube = $derived(isYoutubeUrl(src));
const youtubeVideoId = $derived(isYouTube ? getYoutubeVideoId(src) : null);
onMount(async () => {
// Dynamically import Plyr only on the client side to avoid SSR issues
const PlyrModule = await import('plyr');
const Plyr = PlyrModule.default;
// Plyr expects controls as an array, not a boolean
const plyrControls: string[] = showControls ? PLYR_DEFAULT_CONTROLS : [];
if (isYouTube && youtubeVideoId && containerElement) {
// For YouTube, use Plyr's YouTube embed support
playerInstance = new Plyr(containerElement, {
controls: plyrControls,
autoplay: autoplay,
ratio: '16:9',
iconUrl: '/plyr.svg',
iconPrefix: 'plyr'
});
} else if (videoElement) {
// For regular HTML5 video
// playsinline is set as an HTML attribute on the video element, not a Plyr option
playerInstance = new Plyr(videoElement, {
controls: plyrControls,
autoplay: autoplay,
ratio: '16:9',
iconUrl: '/plyr.svg',
iconPrefix: 'plyr'
});
}
});
onDestroy(() => {
if (playerInstance) {
try {
// Check if player is an embed (YouTube/Vimeo) and handle accordingly
if (playerInstance.isEmbed) {
// For embed players, try to stop first if possible
try {
if (playerInstance.playing) {
playerInstance.pause();
}
} catch (e) {
// Ignore errors when pausing embed players
}
}
// Destroy the player instance
playerInstance.destroy();
} catch (error) {
// Silently handle errors during destruction
// YouTube player might not be ready when component unmounts
} finally {
playerInstance = null;
}
}
});
</script>
<div class="relative" style="max-height: {maxHeight}; min-height: {options.minHeight}; height: {options.height};">
{#if isYouTube && youtubeVideoId}
<!-- YouTube embed using Plyr's progressive enhancement -->
<div
bind:this={containerElement}
class="plyr__video-embed"
data-plyr-provider="youtube"
data-plyr-embed-id={youtubeVideoId}
></div>
{:else}
<!-- HTML5 video -->
<!-- svelte-ignore a11y_media_has_caption -->
<!-- crossorigin omitted so cross-origin poster (e.g. R2 r2.dev) displays without CORS; r2.dev may not send CORS headers even when bucket CORS is set -->
<video bind:this={videoElement} {src} {poster} {playsinline} class="plyr-player"></video>
{/if}
<!-- ClassroomIO Logo Overlay -->
<div
class="plyr-logo-overlay absolute top-2 right-2 z-10 opacity-90 transition-opacity duration-200 ease-in-out hover:opacity-100"
>
<img src="/logo-192.png" alt="ClassroomIO" class="h-7 w-7 rounded-sm bg-white/90 p-0.5 shadow-sm" />
</div>
</div>
<style>
/* Hide logo when controls are hidden (if needed) */
:global(.plyr--hide-controls) ~ .plyr-logo-overlay {
opacity: 0.7;
}
</style>
@@ -0,0 +1,21 @@
export type VideoType = 'youtube' | 'generic' | 'upload' | 'muse';
export interface VideoSource {
type: VideoType;
url: string;
metadata?: {
svid?: string; // muse.ai video ID
thumbnailUrl?: string; // upload thumbnail / poster
};
}
export interface MediaPlayerOptions {
autoplay?: boolean;
controls?: boolean;
maxHeight?: number | string;
minHeight?: number | string;
width?: number | string;
height?: number | string;
class?: string;
playsinline?: boolean;
}
@@ -0,0 +1,65 @@
/**
* Formats a YouTube URL into an embed URL
* Supports multiple YouTube URL formats:
* - https://www.youtube.com/embed/qajK1J1neAM
* - https://youtu.be/qajK1J1neAM
* - https://www.youtube.com/watch?v=qajK1J1neAM
*/
export function formatYoutubeEmbedUrl(url: string): string {
const prefix = 'https://www.youtube.com/embed';
// Already an embed URL
if (url.includes('embed')) {
return url;
}
// Short youtu.be format
if (url.includes('.be/')) {
const splittedUrlWithBe = url.split('.be/');
return `${prefix}/${splittedUrlWithBe[1]}`;
}
// Standard watch format
const splitedUrl = url.split('watch');
if (splitedUrl.length !== 2) {
return url; // Return original if can't parse
}
const query = new URLSearchParams(splitedUrl[1]);
const videoId = query.get('v');
return videoId ? `${prefix}/${videoId}` : url;
}
/**
* Extracts the YouTube video ID from various YouTube URL formats
*/
export function getYoutubeVideoId(url: string): string | null {
// https://www.youtube.com/embed/qajK1J1neAM
if (url.includes('embed')) {
return url.split('embed/')[1]?.split('?')[0] || null;
}
// https://youtu.be/qajK1J1neAM
if (url.includes('.be/')) {
const splittedUrlWithBe = url.split('.be/');
return splittedUrlWithBe[1]?.split('?')[0] || null;
}
// https://www.youtube.com/watch?v=qajK1J1neAM
const splitedUrl = url.split('watch');
if (splitedUrl.length !== 2) {
return null;
}
const query = new URLSearchParams(splitedUrl[1]);
return query.get('v');
}
/**
* Checks if a URL is a YouTube URL
*/
export function isYoutubeUrl(url: string): boolean {
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)/;
return youtubeRegex.test(url);
}
@@ -19,6 +19,7 @@
page.url.search,
navItems,
$currentOrgPath,
$t,
page.data as { breadcrumb?: string } | undefined
)
);
@@ -22,7 +22,7 @@
</script>
<header
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border ui:bg-background bg-background sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear"
class="border-border ui:bg-background sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8"
>
<div class="flex w-full items-center gap-2 px-4">
<Sidebar.Trigger />
@@ -49,7 +49,7 @@
{#each setupList as item}
<div class="flex items-center gap-2 text-sm">
{#if item.is_completed}
<BadgeCheckIcon class="custom text-green-600! size-4 shrink-0 opacity-50" />
<BadgeCheckIcon class="custom size-4 shrink-0 text-green-600! opacity-50" />
<span class="text-muted-foreground line-through opacity-50">{$t(item.title)}</span>
{:else}
<CircleIcon class="size-4 shrink-0 text-gray-400" />
@@ -4,8 +4,6 @@
*/
import type { BreadcrumbConfig } from './breadcrumb-core';
import type { NavItem as LmsNavItem } from './lms-navigation';
import type { NavItem } from './org-navigation';
/**
* Configuration for Org breadcrumbs
@@ -14,6 +14,7 @@ export function generateBreadcrumbs(
searchParams: string,
navItems: NavItem[],
currentOrgPath: string,
t: (key: string) => string,
pageData?: { breadcrumb?: string } // Optional page data for dynamic titles
): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = [];
@@ -57,7 +58,7 @@ export function generateBreadcrumbs(
const href = matchedNavItem.useHashUrl ? `${currentOrgPath}${matchedNavItem.path}` : matchedNavItem.url;
breadcrumbs.push({
label: matchedNavItem.title,
label: t(matchedNavItem.title),
href: href
});
}
@@ -67,7 +68,7 @@ export function generateBreadcrumbs(
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, undefined, true));
if (subItem) {
breadcrumbs.push({
label: subItem.title,
label: t(subItem.title),
href: subItem.url
});
return breadcrumbs;
@@ -83,7 +84,7 @@ export function generateBreadcrumbs(
const nestedRoute = matchedNavItem.nestedRoutes.find((route) => route.path === secondSegment);
if (nestedRoute) {
breadcrumbs.push({
label: nestedRoute.titleKey, // Could be translated if needed
label: t(nestedRoute.titleKey), // Could be translated if needed
href: `${currentOrgPath}${matchedNavItem.path}/${nestedRoute.path}`
});
return breadcrumbs;
@@ -96,7 +97,7 @@ export function generateBreadcrumbs(
const isStaticRoute = matchedNavItem.nestedRoutes?.some((route) => route.path === secondSegment);
if (!isStaticRoute && pageData?.breadcrumb) {
breadcrumbs.push({
label: pageData.breadcrumb,
label: t(pageData.breadcrumb),
href: `${currentOrgPath}${matchedNavItem.path}/${secondSegment}`
});
}
@@ -13,6 +13,7 @@ export function generateLmsBreadcrumbs(
pathname: string,
searchParams: string,
navItems: NavItem[],
t: (key: string) => string,
pageData?: { breadcrumb?: string } // Optional page data for dynamic titles
): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = [];
@@ -56,7 +57,7 @@ export function generateLmsBreadcrumbs(
const href = matchedNavItem.useHashUrl ? `/lms${matchedNavItem.path}` : matchedNavItem.url;
breadcrumbs.push({
label: matchedNavItem.title,
label: t(matchedNavItem.title),
href: href
});
}
@@ -66,7 +67,7 @@ export function generateLmsBreadcrumbs(
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, undefined, true));
if (subItem) {
breadcrumbs.push({
label: subItem.title,
label: t(subItem.title),
href: subItem.url
});
return breadcrumbs;
@@ -82,7 +83,7 @@ export function generateLmsBreadcrumbs(
const nestedRoute = matchedNavItem.nestedRoutes.find((route) => route.path === secondSegment);
if (nestedRoute) {
breadcrumbs.push({
label: nestedRoute.titleKey, // Could be translated if needed
label: t(nestedRoute.titleKey), // Could be translated if needed
href: `/lms${matchedNavItem.path}/${nestedRoute.path}`
});
return breadcrumbs;
@@ -21,6 +21,7 @@
page.url.pathname,
page.url.search,
navItems,
$t,
breadcrumbTitle ? { breadcrumb: breadcrumbTitle } : undefined
)
);
@@ -17,7 +17,7 @@
</script>
<header
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border ui:bg-background bg-background sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear"
class="border-border ui:bg-background bg-background sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8"
>
<div class="flex w-full items-center gap-2 px-4">
<Sidebar.Trigger />
@@ -1,11 +1,14 @@
import {
CommunityIcon,
CourseIcon,
ExerciseIcon,
ExploreIcon,
HomeIcon,
SettingsIcon
} from '@cio/ui/custom/moving-icons';
import type { AccountOrg } from '$features/app/types';
import CogIcon from '@lucide/svelte/icons/cog';
import type { Component } from 'svelte';
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import ListTodoIcon from '@lucide/svelte/icons/list-todo';
import MapPlusIcon from '@lucide/svelte/icons/map-plus';
import UsersIcon from '@lucide/svelte/icons/users';
import { isActive } from '$lib/utils/functions/app';
export interface NavItem {
@@ -44,26 +47,26 @@ const baseNavConfig: NavItemConfig[] = [
{
titleKey: 'lms_navigation.home',
path: '',
icon: LayoutDashboardIcon,
icon: HomeIcon,
matchPattern: '^/lms/?$'
},
{
titleKey: 'lms_navigation.my_learning',
path: '/mylearning',
icon: LibraryBigIcon,
icon: CourseIcon,
matchPattern: '^/lms/mylearning(/.*)?$'
},
{
titleKey: 'lms_navigation.exercise',
path: '/exercises',
icon: ListTodoIcon,
icon: ExerciseIcon,
matchPattern: '^/lms/exercises(/.*)?$',
show: (currentOrg) => currentOrg?.customization?.dashboard?.exercise === true
},
{
titleKey: 'lms_navigation.community',
path: '/community',
icon: UsersIcon,
icon: CommunityIcon,
matchPattern: '^/lms/community(/.*)?$',
show: (currentOrg) => currentOrg?.customization?.dashboard?.community === true,
supportsDynamicSegment: true, // Supports /community/[slug]
@@ -77,13 +80,13 @@ const baseNavConfig: NavItemConfig[] = [
{
titleKey: 'lms_navigation.explore',
path: '/explore',
icon: MapPlusIcon,
icon: ExploreIcon,
matchPattern: '^/lms/explore(/.*)?$'
},
{
titleKey: 'lms_navigation.settings',
path: '/settings',
icon: CogIcon,
icon: SettingsIcon,
useHashUrl: true,
matchPattern: '^/lms/settings(/.*)?$',
items: [
@@ -1,11 +1,16 @@
import {
AttachmentIcon,
CommunityIcon,
CourseIcon,
DashboardIcon,
PeopleIcon,
SettingsIcon,
SetupIcon
} from '@cio/ui/custom/moving-icons';
import type { AccountOrg } from '$features/app/types';
import CogIcon from '@lucide/svelte/icons/cog';
import type { Component } from 'svelte';
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import UsersIcon from '@lucide/svelte/icons/users';
import TagIcon from '@lucide/svelte/icons/tag';
import { isActive } from '$lib/utils/functions/app';
export interface NavItem {
@@ -16,6 +21,7 @@ export interface NavItem {
isActive?: boolean;
isExpanded?: boolean;
items?: NavItem[]; // for nested items like settings
isPaid?: boolean; // Show upgrade indicator for free plan users
// Metadata for breadcrumb generation
useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings)
nestedRoutes?: NestedRouteConfig[]; // Static nested routes (like community/ask, settings/customize-lms)
@@ -32,6 +38,7 @@ export interface NavItemConfig {
nestedRoutes?: NestedRouteConfig[]; // Static nested routes
supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug])
matchPattern?: string | ((orgSlug: string) => string); // Regex pattern for route matching
isPaid?: boolean; // Show upgrade indicator for free plan users
}
export interface NestedRouteConfig {
@@ -44,19 +51,26 @@ const baseNavConfig: NavItemConfig[] = [
{
titleKey: 'org_navigation.dashboard',
path: '',
icon: LayoutDashboardIcon,
icon: DashboardIcon,
matchPattern: '^/org/[^/]+/?$' // Exact match only
},
{
titleKey: 'org_navigation.courses',
path: '/courses',
icon: LibraryBigIcon,
icon: CourseIcon,
matchPattern: '^/org/[^/]+/courses(/.*)?$' // Matches nested routes
},
{
titleKey: 'org_navigation.tags',
path: '/tags',
icon: TagIcon,
requiresAdmin: true,
matchPattern: '^/org/[^/]+/tags(/.*)?$'
},
{
titleKey: 'org_navigation.community',
path: '/community',
icon: MessageSquareMoreIcon,
icon: CommunityIcon,
supportsDynamicSegment: true, // Supports /community/[slug]
matchPattern: '^/org/[^/]+/community(/.*)?$', // Matches nested routes
nestedRoutes: [
@@ -69,56 +83,76 @@ const baseNavConfig: NavItemConfig[] = [
{
titleKey: 'org_navigation.audience',
path: '/audience',
icon: UsersIcon,
icon: PeopleIcon,
matchPattern: '^/org/[^/]+/audience(/.*)?$' // Matches nested routes
},
{
titleKey: 'org_navigation.media',
path: '/media',
icon: AttachmentIcon,
matchPattern: '^/org/[^/]+/media(/.*)?$'
},
{
titleKey: 'org_navigation.setup',
path: '/setup',
icon: Settings2Icon,
icon: SetupIcon,
requiresAdmin: true,
matchPattern: '^/org/[^/]+/setup(/.*)?$' // Matches nested routes
},
{
titleKey: 'org_navigation.settings',
path: '/settings',
icon: CogIcon,
icon: SettingsIcon,
useHashUrl: true, // Use '#' for collapsible parent
matchPattern: '^/org/[^/]+/settings(/.*)?$', // Matches nested routes
items: [
{
titleKey: 'Profile',
titleKey: 'settings.tabs.profile_tab',
path: '/settings'
},
{
titleKey: 'Organization',
titleKey: 'settings.tabs.organization_tab',
path: '/settings/org'
},
{
titleKey: 'Landing Page',
titleKey: 'settings.tabs.landing_page_tab',
path: '/settings/landingpage'
},
{
titleKey: 'Billing',
titleKey: 'settings.tabs.billing_tab',
path: '/settings/billing'
},
{
titleKey: 'settings.tabs.auth_tab',
matchPattern: '^/org/[^/]+/settings/auth(/.*)?$',
path: '/settings/auth',
isPaid: true
}
// {
// titleKey: 'Integrations',
// path: '/settings/integrations'
// }
],
nestedRoutes: [
{
path: 'customize-lms',
titleKey: 'Customize LMS'
titleKey: 'settings.tabs.customize_lms_tab'
},
{
path: 'domains',
titleKey: 'Domains'
titleKey: 'settings.tabs.domains_tab'
},
{
path: 'teams',
titleKey: 'Teams'
titleKey: 'settings.tabs.teams_tab'
},
{
path: 'auth',
titleKey: 'settings.tabs.auth_tab'
},
{
path: 'auth/sso',
titleKey: 'settings.tabs.sso_tab'
},
{
path: 'auth/token-auth',
titleKey: 'settings.tabs.token_auth_tab'
}
]
}
@@ -147,7 +181,7 @@ export function getOrgNavigationItems(
// Extract match pattern (handle function case)
const matchPattern =
typeof config.matchPattern === 'function' ? config.matchPattern(currentOrg.siteName) : config.matchPattern;
typeof config.matchPattern === 'function' ? config.matchPattern(currentOrg.siteName!) : config.matchPattern;
const item: NavItem = {
title: t(config.titleKey),
@@ -158,16 +192,27 @@ export function getOrgNavigationItems(
isExpanded: config.items ? isActive(pagePathname, fullPath, matchPattern) : undefined,
useHashUrl: config.useHashUrl,
nestedRoutes: config.nestedRoutes,
supportsDynamicSegment: config.supportsDynamicSegment
supportsDynamicSegment: config.supportsDynamicSegment,
isPaid: config.isPaid
};
// Handle nested items (like settings sub-items)
if (config.items) {
item.items = config.items.map((subConfig) => ({
title: subConfig.titleKey,
url: `${currentOrgPath}${subConfig.path}`,
path: subConfig.path
}));
item.items = config.items.map((subConfig) => {
const matchPattern =
typeof subConfig.matchPattern === 'function'
? subConfig.matchPattern(currentOrg.siteName!)
: subConfig.matchPattern;
const url = `${currentOrgPath}${subConfig.path}`;
return {
title: t(subConfig.titleKey),
isActive: isActive(pagePathname, url, matchPattern, true),
url: url,
path: subConfig.path,
isPaid: subConfig.isPaid
};
});
}
items.push(item);
@@ -0,0 +1,60 @@
import { QUESTION_TYPE_KEY, QUESTION_TYPE_REGISTRY, type QuestionTypeKey } from '@cio/question-types';
/** Question types that require a paid plan. On free plan, these show a premium icon and open upgrade modal on click. */
export const PREMIUM_QUESTION_TYPE_KEYS = new Set<QuestionTypeKey>([
QUESTION_TYPE_KEY.FILE_UPLOAD,
QUESTION_TYPE_KEY.ORDERING,
QUESTION_TYPE_KEY.LINK
]);
const QUESTION_TYPE_LABEL_KEYS: Partial<Record<QuestionTypeKey, string>> = {
[QUESTION_TYPE_KEY.RADIO]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.single',
[QUESTION_TYPE_KEY.CHECKBOX]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.multiple',
[QUESTION_TYPE_KEY.TEXTAREA]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.paragraph',
[QUESTION_TYPE_KEY.TRUE_FALSE]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.true_false',
[QUESTION_TYPE_KEY.SHORT_ANSWER]:
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.short_answer',
[QUESTION_TYPE_KEY.NUMERIC]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.numeric',
[QUESTION_TYPE_KEY.FILL_BLANK]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.fill_blank',
[QUESTION_TYPE_KEY.FILE_UPLOAD]:
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.file_upload',
[QUESTION_TYPE_KEY.MATCHING]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.matching',
[QUESTION_TYPE_KEY.ORDERING]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.ordering',
[QUESTION_TYPE_KEY.HOTSPOT]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.hotspot',
[QUESTION_TYPE_KEY.LINK]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.link'
};
// Temp hidden: MATCHING, HOTSPOT
const HIDDEN_QUESTION_TYPE_KEYS = new Set<QuestionTypeKey>([QUESTION_TYPE_KEY.MATCHING, QUESTION_TYPE_KEY.HOTSPOT]);
export const QUESTION_TYPES = QUESTION_TYPE_REGISTRY.filter((qt) => !HIDDEN_QUESTION_TYPE_KEYS.has(qt.key)).map(
(questionType) => ({
id: questionType.id,
key: questionType.key,
label: questionType.label,
labelKey: QUESTION_TYPE_LABEL_KEYS[questionType.key]
})
);
export const DEFAULT_QUESTION_TYPE = QUESTION_TYPES.find(
(questionType) => questionType.key === QUESTION_TYPE_KEY.RADIO
) ??
QUESTION_TYPES[0] ?? {
id: 1,
key: QUESTION_TYPE_KEY.RADIO,
label: 'Single answer',
labelKey: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.single'
};
export const QUESTION_TEMPLATE = {
id: 1,
title: '',
type: DEFAULT_QUESTION_TYPE.id,
answers: [],
options: [
{
id: 1,
value: null
}
]
};
@@ -0,0 +1,35 @@
<script lang="ts">
import { InputField } from '@cio/ui/custom/input-field';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
interface Props {
gradeMax?: number;
disableGrading?: boolean;
grade?: number | undefined;
}
let { gradeMax = 0, disableGrading = false, grade = $bindable(0) }: Props = $props();
$effect(() => {
if (grade && grade > gradeMax) {
snackbar.error('grade cant be more than max value');
grade = gradeMax;
}
});
</script>
<div class="flex items-center">
<InputField
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
bind:value={grade}
max={gradeMax}
type="number"
inputClassName="!w-16"
isDisabled={disableGrading}
/>
<p class="ml-2 flex items-center text-base dark:text-white">
<span class="mr-1">/</span> <span>{gradeMax}</span>
</p>
</div>
@@ -0,0 +1,4 @@
export { default as Grade } from './grade.svelte';
export { default as QuestionTitle } from './question-title.svelte';
export { default as ReasonBox } from './reason-box.svelte';
export * from './constants';
@@ -0,0 +1,13 @@
<script lang="ts">
interface Props {
index: number | string;
title: string;
}
let { index, title }: Props = $props();
</script>
<span class="flex gap-2">
<p class="text-md">{index}</p>
<p class="text-md">{title}</p>
</span>
@@ -0,0 +1,50 @@
<script lang="ts">
import { Skeleton } from '@cio/ui/base/skeleton';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
interface Props {
reason?: string;
isLoading?: boolean;
rejectGrade?: any;
acceptGrade?: any;
}
let { reason = '', isLoading = false, rejectGrade = () => {}, acceptGrade = () => {} }: Props = $props();
</script>
<div class="rounded-md border">
{#if isLoading}
<div class="flex w-full px-2 py-4">
<div class="flex w-full flex-row items-start space-x-4">
<img src="/ai.svg" alt="alt" class="animate-pulse" />
<div class="w-full">
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" />
</div>
</div>
<div class="flex space-x-2">
<Skeleton class="h-4 w-20" />
</div>
</div>
{:else}
<div class="flex items-start justify-between px-2 py-4">
<div class="flex items-center space-x-4">
<img src="/ai.svg" alt="alt" />
<p class="text-sm font-normal">
{reason}
</p>
</div>
<div class="flex space-x-2">
<Button onclick={acceptGrade}>
{$t('course.navItem.submissions.grading_modal.accept_grade')}
</Button>
<Button variant="destructive" onclick={rejectGrade}>
{$t('course.navItem.submissions.grading_modal.reject_grade')}
</Button>
</div>
</div>
{/if}
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import { profile } from '$lib/utils/store/user';
import { group } from '$lib/components/Course/store';
import { courseApi } from '$features/course/api';
import { isOrgAdmin } from '$lib/utils/store/org';
interface Props {
@@ -13,9 +13,9 @@
let { allowedRoles = [], onDenied = () => {}, onlyStudent = false, children }: Props = $props();
const userRole: number = $derived.by(() => {
const user = $group.people.find((person) => person.profile_id === $profile.id);
return user ? user.role_id : 0;
const userRole: number | null = $derived.by(() => {
const user = courseApi.group.people.find((person) => person.profileId === $profile.id);
return user ? Number(user.roleId) : null;
});
const show = $derived.by(() => {
@@ -26,12 +26,13 @@
}
});
function isAllowed(userRole) {
function isAllowed(userRole: number | null) {
if (userRole === null) return false;
return allowedRoles.includes(userRole);
}
$effect(() => {
if (!$isOrgAdmin && $group.people.length && !isAllowed(userRole)) {
if (!$isOrgAdmin && userRole !== null && !isAllowed(userRole)) {
onDenied();
}
});
@@ -2,10 +2,37 @@
import * as Kbd from '@cio/ui/base/kbd';
import * as InputGroup from '@cio/ui/base/input-group';
import SearchIcon from '@lucide/svelte/icons/search';
import type { ComponentProps } from 'svelte';
import { t } from '$lib/utils/functions/translations';
interface Props {
placeholder?: string;
value?: string;
class?: string;
onFocus?: (event: FocusEvent) => void;
onBlur?: (event: FocusEvent) => void;
onKeydown?: (event: KeyboardEvent) => void;
}
let {
placeholder = '',
value = $bindable(''),
class: className = '',
onFocus,
onBlur,
onKeydown,
...restProps
}: Props & Omit<ComponentProps<typeof InputGroup.Root>, 'class' | 'children'> = $props();
</script>
<InputGroup.Root class="hidden! md:flex! max-w-56">
<InputGroup.Input placeholder="Search..." />
<InputGroup.Root class={`hidden! max-w-56 md:flex! ${className}`} {...restProps}>
<InputGroup.Input
placeholder={placeholder || $t('app.search.placeholder')}
bind:value
on:focus={onFocus}
on:blur={onBlur}
on:keydown={onKeydown}
/>
<InputGroup.Addon>
<SearchIcon />
</InputGroup.Addon>
@@ -1,16 +1,27 @@
<script lang="ts">
import { embedSenjaWidget } from '@cio/utils/senja';
import { onMount } from 'svelte';
import { licenseApi } from '$features/license/api/license.svelte';
interface Props {
id?: string;
id: string;
}
let { id = '' }: Props = $props();
onMount(() => {
let isInitialized = $state(false);
const noTracking = $derived(licenseApi.hasAccess('no-tracking'));
$effect(() => {
const shouldEmbed = !noTracking && !isInitialized;
if (!shouldEmbed) {
return;
}
isInitialized = true;
embedSenjaWidget(id);
});
</script>
<div class="senja-embed" data-id={id} data-lazyload="false" data-spinner="false"></div>
{#if !noTracking}
<div class="senja-embed" data-id={id} data-lazyload="false" data-spinner="false"></div>
{/if}
@@ -30,7 +30,7 @@
<Avatar.Image src={$profile.avatarUrl} alt={$profile.fullname} />
<Avatar.Fallback class="rounded-lg">{$profile.fullname.slice(0, 2)}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm font-normal leading-tight">
<div class="grid flex-1 text-left text-sm leading-tight font-normal">
<span class="truncate">{$profile.fullname}</span>
<span class="truncate text-xs">{$profile.email}</span>
</div>
@@ -23,11 +23,9 @@
<NavMain />
</Sidebar.Content>
{#if $profile.id}
<Sidebar.Footer class="gap-4!">
<SidebarFooterMenu />
</Sidebar.Footer>
{/if}
<Sidebar.Footer class="gap-4!">
<SidebarFooterMenu />
</Sidebar.Footer>
<Sidebar.Rail />
</Sidebar.Root>
@@ -7,6 +7,7 @@
import { page } from '$app/state';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { getLmsNavigationItems } from '$features/ui/navigation/lms-navigation';
import { HoverableItem } from '@cio/ui/custom/moving-icons';
const items = $derived(getLmsNavigationItems($currentOrg, $t, page.url.pathname));
@@ -14,34 +15,43 @@
</script>
<Sidebar.Group>
<Sidebar.GroupLabel>LMS Navigation</Sidebar.GroupLabel>
<Sidebar.GroupLabel>{$t('org_navigation.platform')}</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each items as item (item.path)}
{#if item.items}
<!-- Collapsible Settings Menu -->
<Collapsible.Root open={item.isActive || item.isExpanded} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title} isActive={item.isActive}>
{#snippet child({ props })}
<a href={item.url} {...props}>
{#if item.icon}
<item.icon class="custom" />
{/if}
<span>{item.title}</span>
<ChevronRightIcon
class="custom ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</a>
{/snippet}
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
{#each items as item (item.title)}
<Collapsible.Root open={item.isActive || item.isExpanded} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title} isActive={item.isActive}>
{#snippet child({ props })}
<HoverableItem>
{#snippet children(isHovered)}
<a href={item.url} {...props}>
{#if item.icon}
{@const Icon = item.icon}
<Icon {isHovered} size={16} class="custom" />
<span>{item.title}</span>
{:else}
<span>{item.title}</span>
{/if}
{#if item.items}
<ChevronRightIcon
class="custom ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
{/if}
</a>
{/snippet}
</HoverableItem>
{/snippet}
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
{#if item.items}
<Collapsible.Content>
<Sidebar.MenuSub>
{#each item.items ?? [] as subItem (subItem.path)}
{#each item.items ?? [] as subItem (subItem.title)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton isActive={isActive(pathWithQuery, subItem.url, undefined, true)}>
{#snippet child({ props })}
@@ -54,24 +64,10 @@
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{:else}
<!-- Regular Menu Item -->
<Sidebar.MenuItem>
<Sidebar.MenuButton tooltipContent={item.title} isActive={item.isActive}>
{#snippet child({ props })}
<a href={item.url} {...props}>
{#if item.icon}
<item.icon class="custom" />
{/if}
<span>{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
{/if}
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
@@ -21,7 +21,7 @@
{...props}
>
<Avatar.Root class="ui:flex ui:size-6 ui:items-center ui:justify-center">
<Avatar.Image src={'/logo-192.png'} alt="ClassroomIO logo" />
<Avatar.Image src="/logo-192.png" alt="ClassroomIO logo" />
</Avatar.Root>
<span class="truncate font-normal">ClassroomIO</span>
@@ -1,16 +1,17 @@
<script lang="ts">
import { currentOrgPath, currentOrg, isOrgAdmin } from '$lib/utils/store/org';
import { currentOrgPath, currentOrg, isOrgAdmin, isFreePlan } from '$lib/utils/store/org';
import * as Collapsible from '@cio/ui/base/collapsible';
import * as Sidebar from '@cio/ui/base/sidebar';
import { t } from '$lib/utils/functions/translations';
import { isActive } from '$lib/utils/functions/app';
import { page } from '$app/state';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import RocketIcon from '@lucide/svelte/icons/rocket';
import { getOrgNavigationItems } from '$features/ui/navigation/org-navigation';
import { HoverableItem } from '@cio/ui/custom/moving-icons';
const items = $derived(getOrgNavigationItems($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname));
const pathWithQuery = $derived(page.url.pathname + page.url.search);
const items = $derived(
getOrgNavigationItems($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname + page.url.search)
);
</script>
<Sidebar.Group>
@@ -24,17 +25,25 @@
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title} isActive={item.isActive}>
{#snippet child({ props })}
<a href={item.url} {...props}>
{#if item.icon}
<item.icon class="custom" />
{/if}
<span>{item.title}</span>
{#if item.items}
<ChevronRightIcon
class="custom ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
{/if}
</a>
<HoverableItem>
{#snippet children(isHovered)}
<a href={item.url} {...props}>
{#if item.icon}
{@const Icon = item.icon}
<Icon {isHovered} size={16} class="custom" />
<span>{item.title}</span>
{:else}
<span>{item.title}</span>
{/if}
{#if item.items}
<ChevronRightIcon
class="custom ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
{/if}
</a>
{/snippet}
</HoverableItem>
{/snippet}
</Sidebar.MenuButton>
{/snippet}
@@ -44,10 +53,13 @@
<Sidebar.MenuSub>
{#each item.items ?? [] as subItem (subItem.title)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton isActive={isActive(pathWithQuery, subItem.url, undefined, true)}>
<Sidebar.MenuSubButton isActive={subItem.isActive}>
{#snippet child({ props })}
<a href={subItem.url} {...props}>
<span>{subItem.title}</span>
{#if subItem.isPaid && $isFreePlan}
<RocketIcon class="text-primary size-4" />
{/if}
</a>
{/snippet}
</Sidebar.MenuSubButton>
@@ -18,7 +18,7 @@
{#snippet child({ props })}
<a href={orgPath} {...props}>
{#if $currentOrg.name}
<Avatar.Root class="rounded-md! size-6! flex items-center justify-center">
<Avatar.Root class="flex size-6! items-center justify-center rounded-md!">
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
<Avatar.Fallback class="rounded-md! text-xs">{shortenName($currentOrg.name)}</Avatar.Fallback>
</Avatar.Root>
@@ -15,8 +15,10 @@
import { currentOrg, currentOrgPath, orgs } from '$lib/utils/store/org';
import { ComingSoon } from '$features/ui';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
const sidebar = useSidebar();
const isSelfHosted = PUBLIC_IS_SELFHOSTED === 'true';
interface Props {
variant?: 'sidebar' | 'breadcrumb';
@@ -35,14 +37,48 @@
}
</script>
{#if variant === 'breadcrumb'}
{#if isSelfHosted}
<!-- Self-hosted: show org name only, no switching -->
{#if variant === 'breadcrumb'}
<Breadcrumb.Link href={$currentOrgPath} class="flex items-center gap-2">
{#if $currentOrg.name}
<Avatar.Root class="flex size-6! items-center justify-center rounded-md!">
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
<Avatar.Fallback class="rounded-md! text-xs">{shortenName($currentOrg.name)}</Avatar.Fallback>
</Avatar.Root>
<span class="hidden truncate text-sm font-medium md:block">{$currentOrg.name}</span>
{:else}
<Skeleton class="h-4 w-24" />
{/if}
</Breadcrumb.Link>
{:else}
<Sidebar.Menu>
<Sidebar.MenuItem>
<div class="flex items-center gap-3 px-2 py-1.5">
{#if $currentOrg.name}
<Avatar.Root class="ui:flex ui:size-8 ui:items-center ui:justify-center ui:rounded-lg">
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
<Avatar.Fallback class="rounded-lg">{shortenName($currentOrg.name)}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-normal">{$currentOrg.name}</span>
<span class="truncate text-xs">{$currentOrg.plans?.[0]?.planName || 'Free'}</span>
</div>
{:else}
<Skeleton class="h-full w-full" />
{/if}
</div>
</Sidebar.MenuItem>
</Sidebar.Menu>
{/if}
{:else if variant === 'breadcrumb'}
<!-- Breadcrumb context version -->
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Breadcrumb.Link {...props} href="##" class="flex items-center gap-2">
{#if $currentOrg.name}
<Avatar.Root class="rounded-md! size-6! flex items-center justify-center">
<Avatar.Root class="flex size-6! items-center justify-center rounded-md!">
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
<Avatar.Fallback class="rounded-md! text-xs">{shortenName($currentOrg.name)}</Avatar.Fallback>
</Avatar.Root>
@@ -11,7 +11,7 @@
{#if $isFreePlan}
<div class="animate-icon">
{#if sidebar.open || !sidebar.isMobile}
{#if sidebar.open && !sidebar.isMobile}
<div
class="animate-gradient-border mx-2 flex flex-col items-center justify-center gap-4 rounded-md px-2 py-6 text-center transition-all ease-in-out"
>
@@ -27,7 +27,7 @@
}
let {
content = $bindable(''),
content = '',
showToolBar = true,
editable = true,
enablePersistence = false,
@@ -43,7 +43,7 @@
</script>
<Editor
bind:content
{content}
{showToolBar}
{editable}
{enablePersistence}
@@ -1,9 +1,9 @@
<script lang="ts">
import { openUpgradeModal } from '$lib/utils/functions/org';
import ZapIcon from '@lucide/svelte/icons/zap';
import { isFreePlan } from '$lib/utils/store/org';
import * as Item from '@cio/ui/base/item';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { PremiumIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
interface Props {
className?: string;
@@ -11,31 +11,35 @@
children?: import('svelte').Snippet;
}
let { className = '', onClick = () => {}, children }: Props = $props();
let { className = '', onClick = () => {}, ...restProps }: Props = $props();
</script>
{#if $isFreePlan}
<Item.Root variant="outline" size="sm" class="border-blue-700! py-2! h-fit w-full {className}">
{#snippet child({ props })}
<button
{...props}
onclick={() => {
onClick();
openUpgradeModal();
}}
>
<Item.Media variant="icon">
<ZapIcon class="custom size-5 text-blue-700" />
</Item.Media>
<Item.Content>
<Item.Title class="text-blue-700!">
{@render children?.()}
</Item.Title>
</Item.Content>
<Item.Actions>
<ChevronRightIcon class="text-blue-700! size-4" />
</Item.Actions>
</button>
<HoverableItem>
{#snippet children(isHovered)}
<Item.Root variant="outline" size="sm" class="h-fit w-full border-blue-700! py-2! {className}">
{#snippet child({ props })}
<button
{...props}
onclick={() => {
onClick();
openUpgradeModal();
}}
>
<Item.Media variant="icon">
<PremiumIcon {isHovered} size={20} class="text-blue-700 dark:text-white" />
</Item.Media>
<Item.Content>
<Item.Title class="text-blue-700! dark:text-white!">
{@render restProps.children?.()}
</Item.Title>
</Item.Content>
<Item.Actions>
<ChevronRightIcon class="size-4 text-blue-700! dark:text-white!" />
</Item.Actions>
</button>
{/snippet}
</Item.Root>
{/snippet}
</Item.Root>
</HoverableItem>
{/if}
@@ -3,26 +3,25 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Progress } from '@cio/ui/base/progress';
import CheckIcon from '@lucide/svelte/icons/check';
import RocketIcon from '@lucide/svelte/icons/rocket';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { Badge } from '@cio/ui/base/badge';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import { PricingCard } from '@cio/ui/custom/pricing-card';
import { PricingToggle } from '@cio/ui/custom/pricing-toggle';
import { PLANS } from '@cio/utils/plans';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { toggleConfetti } from './confetti/store';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import Confetti from './confetti/confetti.svelte';
const planNames = Object.keys(PLANS);
let isLoadingPlan: string | null = $state(null);
let upgraded = $state(false);
let isYearlyPlan = $state(true);
let isYearlyPlan = $state(false);
const query = $derived(new URLSearchParams(page.url.search));
let open = $state(false);
@@ -81,8 +80,7 @@
}
function onClose() {
const path = upgraded ? $currentOrgPath : page.url.pathname;
goto(path);
goto(page.url.pathname);
}
function handleOpenChange(newOpen: boolean) {
@@ -139,80 +137,28 @@
</div>
{:else}
<div class="flex h-full flex-col items-center justify-center">
<div class="white 0 relative mb-6 flex items-center rounded-full border p-1">
<Button
variant={isYearlyPlan ? 'ghost' : 'secondary'}
size="sm"
class="rounded-full! transition-all duration-500 ease-in-out "
onclick={() => (isYearlyPlan = false)}
>
{$t('pricing.modal.monthly')}
</Button>
<Button
variant={isYearlyPlan ? 'secondary' : 'ghost'}
size="sm"
class="rounded-full! transition-all duration-500 ease-in-out"
onclick={() => (isYearlyPlan = true)}
>
{$t('pricing.modal.annually')}
</Button>
<Badge variant="default" class="rotate-5 absolute -top-3 right-[-20%] shadow-2xl">
<SparklesIcon class="text-amber-500! size-2" />
{$t('pricing.modal.save')}
</Badge>
<div class="ui:mb-6">
<PricingToggle
bind:isYearly={isYearlyPlan}
monthlyLabel={$t('pricing.modal.monthly')}
yearlyLabel={$t('pricing.modal.annually')}
saveLabel={$t('pricing.modal.save')}
/>
</div>
<div class="grid w-full grid-cols-1 gap-6 overflow-y-auto p-2 md:grid-cols-3 md:overflow-y-visible">
<div class="mt-6 grid w-full grid-cols-1 gap-6 overflow-y-auto p-2 md:grid-cols-3 md:overflow-y-visible">
{#each planNames as planName}
{@const plan = PLANS[planName]}
{@const isPopular = planName === 'EARLY_ADOPTER'}
<div
class="relative flex max-w-sm flex-col rounded-lg p-6 {isPopular ? 'animate-gradient-border' : 'border '}"
>
{#if isPopular}
<Badge
variant="default"
class="bg-linear-to-r absolute -top-3 left-1/2 -translate-x-1/2 from-pink-500 to-orange-500"
>
Popular
</Badge>
{/if}
<div class="mb-4">
<h3 class="mb-2 text-lg font-semibold">
{plan?.NAME}
</h3>
<div class="mb-1 flex items-baseline gap-1">
<span class="text-xl">
{plan?.PRICE?.CURRENCY}
{isYearlyPlan ? plan?.PRICE?.YEARLY : plan?.PRICE?.MONTHLY}
</span>
</div>
<p class="text-sm text-gray-500">Per Organization</p>
</div>
<Button
variant={isPopular ? 'default' : 'outline'}
class="mb-6 w-full"
disabled={plan?.CTA?.IS_DISABLED || isLoadingPlan === planName}
loading={isLoadingPlan === planName}
onclick={() => {
if (isLoadingPlan === planName) return;
handleClick(plan, planName);
}}
>
{plan?.CTA?.DASHBOARD_LABEL}
</Button>
<ul class="space-y-3">
{#each plan?.FEATURES as feature}
<li class="flex items-start gap-2">
<CheckIcon class="mt-0.5 size-4 shrink-0" />
<span class="text-sm leading-relaxed">
{feature}
</span>
</li>
{/each}
</ul>
</div>
<PricingCard
{plan}
{planName}
{isPopular}
{isYearlyPlan}
{isLoadingPlan}
{handleClick}
popularLabel={$t('pricing.modal.popular')}
perOrgLabel={$t('pricing.modal.per_org')}
/>
{/each}
</div>
</div>
@@ -17,11 +17,11 @@
<a
href={`https://classroomio.com?utm_source=${$currentOrg.siteName}.classroomio.com`}
target="_blank"
class={cn('group fixed bottom-14 right-9 z-50 hover:no-underline', className)}
class={cn('group fixed right-9 bottom-14 z-50 hover:no-underline', className)}
>
<span
class={cn(
'relative flex items-center gap-1 overflow-hidden rounded-md border border-gray-100 bg-white text-sm font-medium text-black shadow-sm transition duration-500 group-hover:border-blue-600 group-hover:bg-blue-600 group-hover:text-white dark:text-black',
'relative flex items-center gap-1 overflow-hidden rounded-md border border-gray-100 bg-white text-sm font-medium text-black shadow-sm transition duration-500 group-hover:border-blue-600 group-hover:bg-blue-600 group-hover:text-white dark:border-neutral-700 dark:bg-transparent dark:text-white',
showOnlyLogo ? 'border-none p-0' : 'border px-2 py-1'
)}
@@ -94,10 +94,7 @@
disabled={isDisabled || isUploading}
>
<ImageCropper.UploadTrigger aria-disabled={isDisabled || isUploading}>
<div
class="avatar-container {widthHeight ||
'h-[128px] w-[128px]'} pointer border-2 border-gray-200 dark:border-neutral-600 {shape}"
>
<div class="avatar-container {widthHeight || 'h-[128px] w-[128px]'} pointer border-2 border-gray-200 {shape}">
<ImageCropper.Preview>
{#snippet child({ src: imageSrc })}
<div class="group relative h-full w-full">
@@ -10,6 +10,9 @@
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
import type { FileRejectedReason } from '@cio/ui/custom/file-drop-zone';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
interface Props {
@@ -38,9 +41,8 @@
};
alt_description: string;
}[] = $state([]);
let fileInput: HTMLInputElement | undefined = $state();
let label = $state($t('snackbar.landing_page_settings.error.label'));
const MAX_IMAGE_SIZE = 500 * FileDropZone.KILOBYTE;
async function handleImageClick(img: string) {
$handleOpenWidget.open = false;
@@ -49,22 +51,18 @@
imageURL = img;
}
const onFileSelected = () => {
const file = fileInput?.files?.[0];
const sizeInkb = file?.size! / 1024;
if (sizeInkb > 500) {
async function handleFilesUpload(files: File[]) {
const file = files[0];
if (file) await handleUploadImage(file);
}
function handleFileRejected({ reason }: { reason: FileRejectedReason }) {
if (reason === 'Maximum file size exceeded') {
snackbar.error('snackbar.landing_page_settings.error.file_size');
} else {
snackbar.error('snackbar.landing_page_settings.error.file_size');
label = $t('snackbar.landing_page_settings.error.try_again');
return;
}
if (file) {
let reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
handleUploadImage(file);
};
}
};
}
const handleUploadImage = async (image: File) => {
isUploading = true;
@@ -81,10 +79,6 @@
$handleOpenWidget.open = false;
};
function handleUpload() {
fileInput?.click();
}
async function handleSubmit() {
try {
unsplashImages = await queryUnsplash(searchQuery || 'rocks');
@@ -117,40 +111,30 @@
{/each}
</UnderlineTabs.List>
<UnderlineTabs.Content value={tabs[1].value}>
<!-- Your Upload content here -->
<div class="w-full">
<input
type="file"
style="display: none;"
bind:this={fileInput}
onchange={onFileSelected}
disabled={isUploading}
/>
<Button onclick={handleUpload} loading={isUploading}>
{label}
</Button>
<p class="my-2 text-center text-sm text-gray-500">
{$t('course.navItem.landing_page.upload_widget.width')}
</p>
<p class="text-center text-sm text-gray-500">
{$t('course.navItem.landing_page.upload_widget.size')}
</p>
<div class="w-full {isUploading ? 'ui:opacity-50 ui:pointer-events-none' : ''}">
<FileDropZone.Root
accept={FileDropZone.ACCEPT_IMAGE}
maxFiles={1}
fileCount={0}
maxFileSize={MAX_IMAGE_SIZE}
onUpload={handleFilesUpload}
onFileRejected={handleFileRejected}
>
<FileDropZone.Trigger
label={$t('course.navItem.landing_page.upload_widget.drag_drop')}
formatMaxSize={(_size) => $t('course.navItem.landing_page.upload_widget.size')}
/>
</FileDropZone.Root>
</div>
</UnderlineTabs.Content>
<UnderlineTabs.Content value={tabs[0].value}>
<!-- Your Images content here -->
<div class="h-full overflow-y-scroll">
<form onsubmit={preventDefault(handleSubmit)} class="mt-1 flex gap-2 pb-3">
<input
type="text"
bind:value={searchQuery}
name=""
id=""
class="ml-2 w-[85%] rounded-lg dark:text-black"
/>
<button type="submit" class="rounded-lg border border-gray-500 bg-white px-3 py-1 text-black"
>{$t('course.navItem.landing_page.upload_widget.submit')}</button
>
<InputField className="ml-2 w-[85%]" bind:value={searchQuery} />
<Button type="submit" variant="outline">
{$t('course.navItem.landing_page.upload_widget.submit')}
</Button>
</form>
{#if unsplashImages && unsplashImages.length > 0}
<div class="hide-scrollbar flex max-h-[300px] flex-row flex-wrap items-center gap-2 px-[10px]">
@@ -1,21 +1,22 @@
import { getAccessToken } from '$lib/utils/functions/supabase';
import { classroomio } from '$lib/utils/services/api';
export async function queryUnsplash(searchQuery: string) {
try {
const response = await fetch('/api/unsplash', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: await getAccessToken()
},
body: JSON.stringify({ searchQuery })
const response = await classroomio.unsplash.$post({
json: { searchQuery }
});
if (!response.ok) {
throw new Error(await response.text());
}
const result = await response.json();
return result.photos;
if (result.success && result.photos) {
return result.photos;
}
const error = 'error' in result ? result.error : 'Failed to fetch photos from Unsplash';
throw new Error(error);
} catch (error) {
console.error('Error sending fetch request', error);
throw error;
}
}