feat: setup drizzle (#594)

* chore: migration to SvelteKit 2

* stage 1: migrate to svelte 5 and fix rerendering issues

* stage 2: fix more $: reactivity across dashboard

* fix merge conflicts

* chore: migrate code in main to svelte 5

* chore: update preventDefault

* chore: simplify tsconfig and remove unused mobileMenuRef in MobileMenu.svelte

* chore: enforce .ts across dashboard

* rename from classroomio-com to website

* stage-3: Replace carbon icon svelte with lucide icon

* update lock file

* chore: finish icon replacement

* fix: settings and setup page multiple rerender

* stage 3: More icon and rerendering fixes

* feat: setup drizzle and better auth

* fix: onboarding validation error showing translation key #586 (#593)

* fix: onboarding validation error showing translation key #586

* fix: remove debug log for profile update errors

* chore: add a clean command

* fix: remove message from email verification response
This commit is contained in:
Rotimi Best
2025-11-01 15:49:29 +01:00
committed by GitHub
parent 97fb446df5
commit b96d91cd7c
801 changed files with 22279 additions and 10834 deletions
-15
View File
@@ -1,15 +0,0 @@
root = true
[*]
indent_style = tab
tab_width = 2
end_of_line = lf
charset = utf-8
insert_final_newline = false
trim_trailing_whitespace = true
[{.*rc,*.yml,*.md,*.json,*.svg}]
indent_style = space
[*.md]
trim_trailing_whitespace = false
-46
View File
@@ -1,46 +0,0 @@
image: gitpod/workspace-full
tasks:
- name: Setup all projects
init: |
pnpm i &&
cd apps/classroomio-com &&
cp .env.example .env &&
cd ../dashboard &&
cp .env.example .env &&
cd ../.. &&
pnpx supabase start
command: |
SUPABASE_ANON_KEY=$(pnpx supabase status | grep "anon key" | awk '{print $3}' | tr -d '\\n') &&
cd apps/dashboard &&
sed -i "s/PUBLIC_SUPABASE_ANON_KEY=.*/PUBLIC_SUPABASE_ANON_KEY=$SUPABASE_ANON_KEY/g" .env &&
sed -i "s/PRIVATE_SUPABASE_SERVICE_ROLE=.*/PRIVATE_SUPABASE_SERVICE_ROLE=$SUPABASE_SERVICE_ROLE/g" .env &&
cd ../classroomio-com &&
cd ../.. &&
pnpm dev
ports:
- port: 3000
visibility: public
onOpen: open-browser
- port: 5173
visibility: public
onOpen: open-browser
- port: 5174
visibility: public
onOpen: open-browser
- port: 54321
visibility: public
onOpen: ignore
- port: 54323
visibility: public
onOpen: ignore
- port: 54324
visibility: public
onOpen: ignore
github:
prebuilds:
master: true
pullRequests: true
addComment: true
-2
View File
@@ -1,2 +0,0 @@
auto-install-peers = true
package-manager-strict=false
+5 -2
View File
@@ -2,13 +2,16 @@
"useTabs": false, "useTabs": false,
"singleQuote": true, "singleQuote": true,
"trailingComma": "none", "trailingComma": "none",
"printWidth": 100, "printWidth": 120,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [ "overrides": [
{ {
"files": "*.svelte", "files": "*.svelte",
"options": { "options": {
"parser": "svelte" "parser": "svelte",
"bracketSameLine": false,
"svelteAllowShorthand": true,
"svelteIndentScriptAndStyle": true
} }
} }
] ]
+1 -1
View File
@@ -19,7 +19,7 @@
"deno.importMap": "./supabase/functions/import_map.json", "deno.importMap": "./supabase/functions/import_map.json",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"[svelte]": { "[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"vitest.disableWorkspaceWarning": true "vitest.disableWorkspaceWarning": true
} }
+1 -1
View File
@@ -14,7 +14,7 @@ We invite innovative minds to contribute to the evolution of ClassroomIO. If you
Ready to dive into the code and make a real impact? Here's your path: Ready to dive into the code and make a real impact? Here's your path:
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://classroomio.com/docs/contributor-guides/gitpod) 1. **Fork the Repository:** Fork our repository
2. **Implementation:** Code it out, test it and apply your changes. 2. **Implementation:** Code it out, test it and apply your changes.
+5 -13
View File
@@ -1,5 +1,5 @@
<a href="https://classroomio.com/"> <a href="https://classroomio.com/">
<img alt="ClassroomIO is a no-code tool that allows you build and scale your online bootcamp with ease." src="https://raw.githubusercontent.com/classroomio/classroomio/main/apps/classroomio-com/static/classroomio-opengraph-image.png" /> <img alt="ClassroomIO is a no-code tool that allows you build and scale your online bootcamp with ease." src="https://raw.githubusercontent.com/classroomio/classroomio/main/apps/website/static/classroomio-opengraph-image.png" />
<h1 align="center">ClassroomIO.com</h1> <h1 align="center">ClassroomIO.com</h1>
<p align="center"> <p align="center">
The Open Source Learning Management System for Companies The Open Source Learning Management System for Companies
@@ -12,7 +12,7 @@
## ✨ About ClassroomIO.com ## ✨ About ClassroomIO.com
<img alt="ClassroomIO Courses page" src="https://raw.githubusercontent.com/classroomio/classroomio/main/apps/classroomio-com/static/classroomio-courses.png" /> <img alt="ClassroomIO Courses page" src="https://raw.githubusercontent.com/classroomio/classroomio/main/apps/website/static/classroomio-courses.png" />
Streamline training for everyone with ClassroomIO.com. Our all-in-one platform empowers bootcamps, educators, and businesses to manage training programs easily. With our platform, anyone can run multiple classes and cohorts all from one UI. The application is mobile-first, which means that students can access your lesson materials from any device. Streamline training for everyone with ClassroomIO.com. Our all-in-one platform empowers bootcamps, educators, and businesses to manage training programs easily. With our platform, anyone can run multiple classes and cohorts all from one UI. The application is mobile-first, which means that students can access your lesson materials from any device.
@@ -71,21 +71,13 @@ Here is what you need to be able to run ClassroomIO.com
This repo is a mono repo that consists of 3 projects: This repo is a mono repo that consists of 3 projects:
1. `classroomio-com`: The landing page of ClassroomIO hosted [here](https://classroomio.com) 1. `website`: The landing page of ClassroomIO hosted [here](https://classroomio.com)
2. `api`: The api service that handles PDF, video processing, Emailing and Notifications. 2. `api`: The api service that handles PDF, video processing, Emailing and Notifications.
3. `dashboard`: The web application that runs the learning management system hosted [here](https://app.classroomio.com). 3. `dashboard`: The web application that runs the learning management system hosted [here](https://app.classroomio.com).
4. `docs`: Official documentation of ClassroomIO hosted [here](https://classroomio.com/docs) 4. `docs`: Official documentation of ClassroomIO hosted [here](https://classroomio.com/docs)
## Development ## Development
### Gitpod Setup
1. Click the button below to open this project in Gitpod.
2. This will open a fully configured workspace in your browser with all the necessary dependencies already installed.
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/classroomio/classroomio)
### Local Setup ### Local Setup
1. Fork the repo, then clone it using the following command (remember to replace the url with the url from your forked repo) 1. Fork the repo, then clone it using the following command (remember to replace the url with the url from your forked repo)
@@ -173,14 +165,14 @@ To get the environmental variables for supabase continue to step(5)
4. All projects should start running 4. All projects should start running
- `classroomio-com`: [http://localhost:5174](http://localhost:5174) - `website`: [http://localhost:5174](http://localhost:5174)
- `api`: [http://localhost:3002](http://localhost:3002) - `api`: [http://localhost:3002](http://localhost:3002)
- `dashboard`: [http://localhost:5173](http://localhost:5173) - `dashboard`: [http://localhost:5173](http://localhost:5173)
- `docs`: [http://localhost:3000](http://localhost:3000) - `docs`: [http://localhost:3000](http://localhost:3000)
5. Running a specific project 5. Running a specific project
- **classroomio-com**: `pnpm dev --filter=@cio/classroomio-com` - **website**: `pnpm dev --filter=@cio/website`
- **api**: `pnpm dev --filter=@cio/api` - **api**: `pnpm dev --filter=@cio/api`
- **dashboard**: `pnpm dev --filter=@cio/dashboard` - **dashboard**: `pnpm dev --filter=@cio/dashboard`
- **docs**: `pnpm dev --filter=@cio/docs` - **docs**: `pnpm dev --filter=@cio/docs`
+2
View File
@@ -1,3 +1,5 @@
DATABASE_URL=
## Supabase - DB ## Supabase - DB
PUBLIC_SUPABASE_ANON_KEY= PUBLIC_SUPABASE_ANON_KEY=
PUBLIC_SUPABASE_URL= PUBLIC_SUPABASE_URL=
-4
View File
@@ -40,7 +40,6 @@
"@hono/standard-validator": "^0.1.5", "@hono/standard-validator": "^0.1.5",
"@hono/zod-validator": "^0.7.2", "@hono/zod-validator": "^0.7.2",
"@scalar/hono-api-reference": "^0.9.18", "@scalar/hono-api-reference": "^0.9.18",
"@sentry/node": "^7.110.1",
"@sentry/profiling-node": "^7.110.1", "@sentry/profiling-node": "^7.110.1",
"@supabase/supabase-js": "^2.32.0", "@supabase/supabase-js": "^2.32.0",
"axios": "^1.4.0", "axios": "^1.4.0",
@@ -72,8 +71,5 @@
"typescript": "^5.5.2", "typescript": "^5.5.2",
"vitest": "^1.2.2", "vitest": "^1.2.2",
"wrangler": "4.21.x" "wrangler": "4.21.x"
},
"_moduleAliases": {
"$src": "src"
} }
} }
+3 -3
View File
@@ -2,11 +2,11 @@ import 'dotenv/config';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { cors } from 'hono/cors'; import { cors } from 'hono/cors';
import { courseRouter } from '$src/routes/course/course'; import { courseRouter } from '@api/routes/course/course';
import { logger } from 'hono/logger'; import { logger } from 'hono/logger';
import { mailRouter } from '$src/routes/mail'; import { mailRouter } from '@api/routes/mail';
import { prettyJSON } from 'hono/pretty-json'; import { prettyJSON } from 'hono/pretty-json';
import { rateLimiterMiddleware } from '$src/middlewares/rate-limiter'; import { rateLimiterMiddleware } from '@api/middlewares/rate-limiter';
import { secureHeaders } from 'hono/secure-headers'; import { secureHeaders } from 'hono/secure-headers';
// Create Hono app with chaining for RPC support // Create Hono app with chaining for RPC support
+1 -1
View File
@@ -1,4 +1,4 @@
import { env } from '$src/config/env'; import { env } from '@api/config/env';
export * from './rate-limiter'; export * from './rate-limiter';
+3 -3
View File
@@ -1,8 +1,8 @@
import 'dotenv/config'; import 'dotenv/config';
import { app } from '$src/app'; import { app } from '@api/app';
import { configureOpenAPI } from '$src/utils/openapi'; import { configureOpenAPI } from '@api/utils/openapi';
import { env } from '$src/config/env'; import { env } from '@api/config/env';
import { serve } from '@hono/node-server'; import { serve } from '@hono/node-server';
import { showRoutes } from 'hono/dev'; import { showRoutes } from 'hono/dev';
+1 -1
View File
@@ -1,6 +1,6 @@
import { Context, Next } from 'hono'; import { Context, Next } from 'hono';
import { validateUser } from '$src/utils/auth/validate-user'; import { validateUser } from '@api/utils/auth/validate-user';
export const authMiddleware = async (c: Context, next: Next) => { export const authMiddleware = async (c: Context, next: Next) => {
try { try {
+2 -2
View File
@@ -3,10 +3,10 @@ import {
ERROR_MESSAGES, ERROR_MESSAGES,
HTTP_STATUS, HTTP_STATUS,
RATE_LIMIT_HEADERS RATE_LIMIT_HEADERS
} from '$src/constants/rate-limiter'; } from '@api/constants/rate-limiter';
import type { MiddlewareHandler } from 'hono'; import type { MiddlewareHandler } from 'hono';
import { rateLimiter } from '$src/utils/redis/limiter'; import { rateLimiter } from '@api/utils/redis/limiter';
import { userKeyGenerator } from '../utils/redis/key-generators'; import { userKeyGenerator } from '../utils/redis/key-generators';
export interface RateLimiterOptions { export interface RateLimiterOptions {
+8 -8
View File
@@ -1,12 +1,12 @@
import { TCourseClone,TCourseDownloadContent, TCertificateDownload, ZCertificateDownload, ZCourseDownloadContent } from '$src/types/course';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { generateCertificate } from '$src/utils/certificate'; import { TCourseClone,TCourseDownloadContent, TCertificateDownload, ZCertificateDownload, ZCourseDownloadContent } from '@api/types/course';
import { generateCoursePdf } from '$src/utils/course';
import { katexRouter } from '$src/routes/course/katex'; import { generateCertificate } from '@api/utils/certificate';
import { lessonRouter } from '$src/routes/course/lesson'; import { generateCoursePdf } from '@api/utils/course';
import { presignRouter } from '$src/routes/course/presign'; import { katexRouter } from '@api/routes/course/katex';
import { cloneRouter } from '$src/routes/course/clone'; import { lessonRouter } from '@api/routes/course/lesson';
import { presignRouter } from '@api/routes/course/presign';
import { cloneRouter } from '@api/routes/course/clone';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
export const courseRouter = new Hono() export const courseRouter = new Hono()
+2 -2
View File
@@ -1,6 +1,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { TLessonDownloadContent, ZLessonDownloadContent } from '$src/types/course/lesson'; import { TLessonDownloadContent, ZLessonDownloadContent } from '@api/types/course/lesson';
import { generateLessonPdf } from '$src/utils/lesson'; import { generateLessonPdf } from '@api/utils/lesson';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
export const lessonRouter = new Hono().post( export const lessonRouter = new Hono().post(
+7 -7
View File
@@ -3,17 +3,17 @@ import {
ZCourseDocumentPresignUrlUpload, ZCourseDocumentPresignUrlUpload,
ZCourseDownloadPresignedUrl, ZCourseDownloadPresignedUrl,
ZCoursePresignUrlUpload ZCoursePresignUrlUpload
} from '$src/types/course'; } from '@api/types/course';
import { describeRoute, validator } from 'hono-openapi'; import { describeRoute, validator } from 'hono-openapi';
import { BUCKET_NAME } from '$src/constants/upload'; import { BUCKET_NAME } from '@api/constants/upload';
import { CLOUDFLARE } from '$src/constants'; import { CLOUDFLARE } from '@api/constants';
import type { GetSignedUrlParameters } from '$src/utils/s3'; import type { GetSignedUrlParameters } from '@api/utils/s3';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { authMiddleware } from '$src/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
import { generateFileKey } from '$src/utils/upload'; import { generateFileKey } from '@api/utils/upload';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { s3Client } from '$src/utils/s3'; import { s3Client } from '@api/utils/s3';
// Response schemas for OpenAPI documentation // Response schemas for OpenAPI documentation
const PresignUploadResponse = { const PresignUploadResponse = {
+3 -3
View File
@@ -1,8 +1,8 @@
import { sendWithNodemailer, sendWithZoho } from '$src/services/mail'; import { sendWithNodemailer, sendWithZoho } from '@api/services/mail';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { ZSendEmailValidation } from '$src/types/mail'; import { ZSendEmailValidation } from '@api/types/mail';
import { env } from '$src/config/env'; import { env } from '@api/config/env';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
export const mailRouter = new Hono().post( export const mailRouter = new Hono().post(
+4 -4
View File
@@ -1,10 +1,10 @@
import { extractNameAndEmail, withEmailTemplate } from '$src/utils/mail'; import { extractNameAndEmail, withEmailTemplate } from '@api/utils/mail';
import { nodemailerTransporter, zohoClient } from '$src/utils/email'; import { nodemailerTransporter, zohoClient } from '@api/utils/email';
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { TSendEmailValidation } from '$src/types/mail'; import type { TSendEmailValidation } from '@api/types/mail';
import type { Transporter } from 'nodemailer'; import type { Transporter } from 'nodemailer';
import { env } from '$src/config/env'; import { env } from '@api/config/env';
export const mailRouter = new Hono(); export const mailRouter = new Hono();
+1 -1
View File
@@ -1,4 +1,4 @@
import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '$src/constants/upload'; import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '@api/constants/upload';
import { z } from 'zod'; import { z } from 'zod';
+2 -2
View File
@@ -1,5 +1,5 @@
import type { TCertificateDownload } from '$src/types/course/lesson'; import type { TCertificateDownload } from '@api/types/course/lesson';
import { getCloudflarePdfBuffer } from '$src/utils/cloudflare'; import { getCloudflarePdfBuffer } from '@api/utils/cloudflare';
export interface CertificateData { export interface CertificateData {
theme: string; theme: string;
+1 -1
View File
@@ -1,4 +1,4 @@
import { CLOUDFLARE } from '$src/constants'; import { CLOUDFLARE } from '@api/constants';
export const getCloudflarePdfBuffer = async (html: string, styles?: string) => { export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
console.log('Generating PDF with Cloudflare API...'); console.log('Generating PDF with Cloudflare API...');
+2 -2
View File
@@ -1,5 +1,5 @@
import type { TCourseDownloadContent } from '$src/types/course'; import type { TCourseDownloadContent } from '@api/types/course';
import { getCloudflarePdfBuffer } from '$src/utils/cloudflare'; import { getCloudflarePdfBuffer } from '@api/utils/cloudflare';
import { marked } from 'marked'; import { marked } from 'marked';
function getHtmlTemplate(body: string): string { function getHtmlTemplate(body: string): string {
+1 -1
View File
@@ -1,6 +1,6 @@
import { SendMailClient } from 'zeptomail'; import { SendMailClient } from 'zeptomail';
import type { Transporter } from 'nodemailer'; import type { Transporter } from 'nodemailer';
import { env } from '$src/config/env'; import { env } from '@api/config/env';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
export const zohoClient = new SendMailClient({ export const zohoClient = new SendMailClient({
+1 -1
View File
@@ -1,4 +1,4 @@
import type { TLessonDownloadContent } from '$src/types/course/lesson'; import type { TLessonDownloadContent } from '@api/types/course/lesson';
import { marked } from 'marked'; import { marked } from 'marked';
function getHtmlTemplate(body: string): string { function getHtmlTemplate(body: string): string {
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { Scalar } from '@scalar/hono-api-reference'; import { Scalar } from '@scalar/hono-api-reference';
import { env } from '$src/config/env'; import { env } from '@api/config/env';
export function configureOpenAPI(app: Hono) { export function configureOpenAPI(app: Hono) {
if (env.OPENAPI_URL) { if (env.OPENAPI_URL) {
+1 -1
View File
@@ -4,7 +4,7 @@
*/ */
import type { Context } from 'hono'; import type { Context } from 'hono';
import { supabase } from '$src/utils/supabase'; import { supabase } from '@api/utils/supabase';
/** /**
* Extract user ID from JWT token * Extract user ID from JWT token
+1 -1
View File
@@ -3,7 +3,7 @@ import {
DEFAULT_WINDOW_MS, DEFAULT_WINDOW_MS,
ERROR_MESSAGES, ERROR_MESSAGES,
RATE_LIMIT_KEY_PREFIX RATE_LIMIT_KEY_PREFIX
} from '$src/constants/rate-limiter'; } from '@api/constants/rate-limiter';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import { redis } from './redis'; import { redis } from './redis';
+1 -1
View File
@@ -1,4 +1,4 @@
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import { env } from '$src/config/env'; import { env } from '@api/config/env';
export const redis = new Redis(env.REDIS_URL); export const redis = new Redis(env.REDIS_URL);
+1 -1
View File
@@ -8,7 +8,7 @@ import {
S3Client S3Client
} from '@aws-sdk/client-s3'; } from '@aws-sdk/client-s3';
import { CLOUDFLARE } from '$src/constants'; import { CLOUDFLARE } from '@api/constants';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export type GetSignedUrlParameters = Parameters<typeof getSignedUrl>; export type GetSignedUrlParameters = Parameters<typeof getSignedUrl>;
+1 -1
View File
@@ -1,6 +1,6 @@
import { SupabaseClient, createClient } from '@supabase/supabase-js'; import { SupabaseClient, createClient } from '@supabase/supabase-js';
import { env } from '$src/config/env'; import { env } from '@api/config/env';
const { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL, PRIVATE_SUPABASE_SERVICE_ROLE } = env; const { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL, PRIVATE_SUPABASE_SERVICE_ROLE } = env;
+1 -2
View File
@@ -9,8 +9,7 @@
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"outDir": "./dist", "outDir": "./dist",
"paths": { "paths": {
"$src": ["./src"], "@api/*": ["src/*"]
"$src/*": ["./src/*"]
}, },
"resolveJsonModule": false, "resolveJsonModule": false,
"skipLibCheck": true, "skipLibCheck": true,
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
extends: ['custom/svelte'],
rules: {
'@typescript-eslint/ban-ts-comment': 'off'
}
};
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
build
.vercel
.turbo
.svelte-kit
static
+53
View File
@@ -0,0 +1,53 @@
import { defineConfig } from 'eslint/config';
import { fileURLToPath } from 'node:url';
import globals from 'globals';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import svelteConfig from './svelte.config.js';
import ts from 'typescript-eslint';
import unusedImports from 'eslint-plugin-unused-imports';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
{
plugins: {
'unused-imports': unusedImports
},
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off',
'no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_'
}
]
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);
+21 -11
View File
@@ -8,6 +8,7 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint ./src", "lint": "eslint ./src",
"lint:fix": "eslint ./src --fix",
"clean": "rm -rf node_modules", "clean": "rm -rf node_modules",
"format": "prettier . --write .", "format": "prettier . --write .",
"start": "node build", "start": "node build",
@@ -21,11 +22,12 @@
"@carbon/charts-svelte": "^1.13.6", "@carbon/charts-svelte": "^1.13.6",
"@cio/api": "workspace:*", "@cio/api": "workspace:*",
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0", "@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
"@lucide/svelte": "^0.545.0",
"@polar-sh/sveltekit": "^0.2.1", "@polar-sh/sveltekit": "^0.2.1",
"@supabase/supabase-js": "^2.31.0", "@supabase/supabase-js": "^2.31.0",
"@sveltejs/adapter-auto": "^2.1.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^1.3.1", "@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/adapter-vercel": "^3.1.0", "@sveltejs/adapter-vercel": "^4.0.0",
"@sveltekit-i18n/base": "^1.3.7", "@sveltekit-i18n/base": "^1.3.7",
"@sveltekit-i18n/parser-icu": "^1.0.8", "@sveltekit-i18n/parser-icu": "^1.0.8",
"@tailwindcss/forms": "^0.5.4", "@tailwindcss/forms": "^0.5.4",
@@ -55,7 +57,7 @@
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
"posthog-js": "^1.180.1", "posthog-js": "^1.180.1",
"shared": "workspace:*", "@cio/utils": "workspace:*",
"sirv": "^2.0.3", "sirv": "^2.0.3",
"stripe": "^14.22.0", "stripe": "^14.22.0",
"svelte-awesome-color-picker": "^3.1.4", "svelte-awesome-color-picker": "^3.1.4",
@@ -74,37 +76,45 @@
"@babel/preset-env": "^7.22.9", "@babel/preset-env": "^7.22.9",
"@babel/preset-typescript": "^7.23.3", "@babel/preset-typescript": "^7.23.3",
"@babel/runtime": "^7.22.6", "@babel/runtime": "^7.22.6",
"@sveltejs/kit": "^1.29.0", "@eslint/compat": "^1.4.0",
"@eslint/js": "^9.36.0",
"@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.5", "@testing-library/svelte": "^4.0.5",
"@types/jest": "^29.5.10", "@types/jest": "^29.5.10",
"@types/lodash": "^4.14.196", "@types/lodash": "^4.14.196",
"@types/node": "^20",
"all-object-keys": "^2.2.0", "all-object-keys": "^2.2.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"carbon-components-svelte": "^0.79.0", "carbon-components-svelte": "^0.79.0",
"carbon-icons-svelte": "^12.1.0",
"diff": "^5.2.0", "diff": "^5.2.0",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"eslint": "^9.36.0",
"eslint-plugin-svelte": "^3.12.4",
"eslint-plugin-unused-imports": "^4.2.0",
"globals": "^16.4.0",
"jessy": "^3.1.1", "jessy": "^3.1.1",
"jest": "^29.7.0", "jest": "^29.7.0",
"postcss": "^8.4.24", "postcss": "^8.4.24",
"postcss-load-config": "^4.0.1", "postcss-load-config": "^4.0.1",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"sass": "^1.64.2", "sass": "^1.64.2",
"svelte": "^4.1.2", "svelte": "^5.0.0",
"svelte-dnd-action": "^0.9.24", "svelte-dnd-action": "^0.9.24",
"svelte-jester": "^3.0.0", "svelte-jester": "^3.0.0",
"svelte-loading-spinners": "^0.3.4", "svelte-loading-spinners": "^0.3.4",
"svelte-preprocess": "^5.0.4", "svelte-preprocess": "^6.0.0",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"ts-jest": "^29.1.1", "ts-jest": "^29.1.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig": "workspace:*", "@cio/tsconfig": "workspace:*",
"tslib": "^2.6.1", "tslib": "^2.6.1",
"typescript": "^5.1.6", "typescript": "^5.5.0",
"vite": "^4.4.8" "typescript-eslint": "^8.44.1",
"vite": "^5.4.4"
}, },
"type": "module", "type": "module",
"engines": { "engines": {
+1 -3
View File
@@ -50,9 +50,7 @@ const getMissingTranslations = async (toLanguage, outputFilePath) => {
// Check if there are missing translations, if not, stop the process // Check if there are missing translations, if not, stop the process
if (!missingKeysWithDetails) { if (!missingKeysWithDetails) {
console.log( console.log(`No missing translations for ${toLanguage.toUpperCase()}. Stopping translation process.`);
`No missing translations for ${toLanguage.toUpperCase()}. Stopping translation process.`
);
return; return;
} }
+25 -24
View File
@@ -1,32 +1,33 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="theme-color" content="#333333" />
<head> <link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<meta charset="utf-8" /> <link rel="icon" type="image/png" href="/favicon.ico" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" /> <link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
<meta name="theme-color" content="#333333" /> <link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" /> <link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link rel="icon" type="image/png" href="/favicon.ico" /> <link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" /> <link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
<link rel="stylesheet" href="https://assets.cdn.clsrio.com/carbon.css" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" /> <script
<link rel="icon" type="image/png" href="/favicon.ico" /> defer
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" /> src="https://umami.hz.oncws.com/script.js"
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" /> data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"
<link rel="stylesheet" href="https://assets.cdn.clsrio.com/carbon.css" /> ></script>
<script defer src="https://umami.hz.oncws.com/script.js" <script src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"></script>
<script src="https://cdn.plyr.io/3.7.3/plyr.js"></script> %sveltekit.head%
</head>
%sveltekit.head% <body data-sveltekit-preload-data="hover" class="">
</head> <div style="display: contents">%sveltekit.body%</div>
</body>
<body data-sveltekit-preload-data="hover" class=""> </html>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+10 -4
View File
@@ -115,22 +115,28 @@ code {
white-space: pre-wrap; white-space: pre-wrap;
} }
[aria-current] :global(svg.carbon-icon), /* [aria-current] :global(svg.carbon-icon),
.active :global(svg.carbon-icon) { .active :global(svg.carbon-icon) {
fill: var(--main-primary-color); fill: var(--main-primary-color);
} } */
.prose a { .prose a {
color: var(--main-primary-color); color: var(--main-primary-color);
} }
.success :global(svg.carbon-icon) { svg.lucide-icon:not(.custom) {
color: #666666;
}
.dark svg.lucide-icon:not(.custom) {
color: #fff;
}
/* .success :global(svg.carbon-icon) {
fill: green; fill: green;
} }
:global(svg.carbon-icon.error) { :global(svg.carbon-icon.error) {
fill: red; fill: red;
} } */
.org-root { .org-root {
height: calc(100vh - 48px); height: calc(100vh - 48px);
+2 -3
View File
@@ -3,9 +3,8 @@
<img src="/logo-512.png" class="logo" alt="ClassroomIO - Logo" /> <img src="/logo-512.png" class="logo" alt="ClassroomIO - Logo" />
<h2>Something unexpected occured.</h2> <h2>Something unexpected occured.</h2>
<p> <p>
Don't worry, your learning is safe. It isn't your fault, it is ours. We have gotten the error Don't worry, your learning is safe. It isn't your fault, it is ours. We have gotten the error notification and
notification and will push a fix ASAP. In the meantime, take a short break and come back a bit will push a fix ASAP. In the meantime, take a short break and come back a bit later.
later.
</p> </p>
<a href="mailto:[email protected]">Contact Us</a> <a href="mailto:[email protected]">Contact Us</a>
</div> </div>
+3 -6
View File
@@ -38,12 +38,9 @@ export const handle: Handle = async ({ event, resolve }) => {
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
if (error.message === 'Unauthenticated user') { if (error.message === 'Unauthenticated user') {
return new Response( return new Response(JSON.stringify({ code: 'unauthenticated', message: 'Unauthenticated user' }), {
JSON.stringify({ code: 'unauthenticated', message: 'Unauthenticated user' }), status: 401
{ });
status: 401
}
);
} }
} }
} }
@@ -1,56 +1,76 @@
<script lang="ts"> <script lang="ts">
import { useCompletion } from 'ai/svelte'; // import { preventDefault } from '$lib/utils/functions/svelte';
import { Popover } from 'carbon-components-svelte'; // import { writable } from 'svelte/store';
import type { PopoverProps } from 'carbon-components-svelte/types/Popover/Popover.svelte'; // // import { useCompletion } from 'ai/svelte';
import MagicWandFilled from 'carbon-icons-svelte/lib/MagicWandFilled.svelte'; // import { Popover } from 'carbon-components-svelte';
import IconButton from '$lib/components/IconButton/index.svelte'; // import type { PopoverProps } from 'carbon-components-svelte/types/Popover/Popover.svelte';
import { Loading } from 'carbon-components-svelte'; // import WandSparklesIcon from '@lucide/svelte/icons/wand-sparkles';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte'; // import { IconButton } from '$lib/components/IconButton';
import { t } from '$lib/utils/functions/translations'; // import { Loading } from 'carbon-components-svelte';
// import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
// import { t } from '$lib/utils/functions/translations';
export let className = ''; // interface Props {
export let handleInsert = (v: string) => {}; // className?: string;
export let defaultPrompt = ''; // handleInsert?: any;
export let alignPopover: PopoverProps['align'] = 'left'; // defaultPrompt?: string;
export let isHTML = false; // alignPopover?: PopoverProps['align'];
// isHTML?: boolean;
// }
let openPopover = false; // let {
let plainText = ''; // className = '',
let textRef: HTMLParagraphElement; // handleInsert = (_v: string) => {},
let prompt: string = defaultPrompt; // defaultPrompt = '',
// alignPopover = 'left',
// isHTML = false
// }: Props = $props();
let isSubmitted = false; // let openPopover = $state(false);
// let plainText = $state('');
// let textRef: HTMLParagraphElement | undefined = $state();
// let prompt: string = $state(defaultPrompt);
const { input, handleSubmit, completion, isLoading } = useCompletion({ // let isSubmitted = $state(false);
api: '/api/completion/customprompt'
});
function restart() { // let isLoading = writable(false);
isSubmitted = false; // // const { input, handleSubmit, completion, isLoading } = useCompletion({
plainText = ''; // // api: '/api/completion/customprompt'
} // // });
function onCompletion(completion: string) { // function restart() {
plainText = completion; // isSubmitted = false;
if (textRef) { // plainText = '';
textRef.scrollTop = textRef.scrollHeight; // }
}
}
function callAI(prompt: string, rephrase: boolean) { // function onCompletion(completion: string) {
isSubmitted = true; // plainText = completion;
// if (textRef) {
// textRef.scrollTop = textRef.scrollHeight;
// }
// }
$input = rephrase ? `${$t('ai.can_you')}: ${plainText}` : prompt; // // function callAI(prompt: string, rephrase: boolean) {
// // isSubmitted = true;
setTimeout(() => { // // $input = rephrase ? `${$t('ai.can_you')}: ${plainText}` : prompt;
handleSubmit({ preventDefault: () => {} });
}, 500);
}
$: onCompletion($completion); // // setTimeout(() => {
$: prompt = defaultPrompt; // // handleSubmit({ preventDefault: () => {} });
// // }, 500);
// // }
// $effect(() => {
// onCompletion($completion);
// });
// $effect(() => {
// prompt = defaultPrompt;
// });
</script> </script>
<div></div>
<!--
<div class="{className} relative z-[100]"> <div class="{className} relative z-[100]">
<IconButton <IconButton
contained={true} contained={true}
@@ -58,31 +78,27 @@
size="large" size="large"
onClick={() => (openPopover = !openPopover)} onClick={() => (openPopover = !openPopover)}
> >
<MagicWandFilled size={16} class="carbon-icon" /> <MagicWandFilled />
<Popover caret align={alignPopover} bind:open={openPopover}> <Popover caret align={alignPopover} bind:open={openPopover}>
<button <div class="h-[220px] w-[300px] p-2 text-start">
class="p-2 w-[300px] h-[220px] text-start"
type="button"
on:click={(e) => e.stopPropagation()}
>
{#if isSubmitted} {#if isSubmitted}
<div class="h-[200px]"> <div class="h-[200px]">
<div <div
bind:this={textRef} bind:this={textRef}
class="h-[82%] bg-white dark:bg-neutral-700 p-2 overflow-y-auto text-sm text-start w-full my-1" class="my-1 h-[82%] w-full overflow-y-auto bg-white p-2 text-start text-sm dark:bg-neutral-700"
> >
{#if isHTML} {#if isHTML}
<HtmlRender content={plainText} /> <HtmlRender>{plainText}</HtmlRender>
{:else} {:else}
<p class="font-normal"> <p class="font-normal">
{plainText} {plainText}
</p> </p>
{/if} {/if}
</div> </div>
<div class="flex gap-5 ml-2"> <div class="ml-2 flex gap-5">
<button <button
class="text-xs px-4 py-2 border-[1px] cursor-pointer rounded-md font-medium" class="cursor-pointer rounded-md border-[1px] px-4 py-2 text-xs font-medium"
on:click={() => { onclick={() => {
handleInsert(plainText); handleInsert(plainText);
openPopover = false; openPopover = false;
}} }}
@@ -96,9 +112,9 @@
{/if} {/if}
</button> </button>
<button <button
class="text-xs px-4 py-2 border-[1px] cursor-pointer rounded-md font-medium {$isLoading && class="cursor-pointer rounded-md border-[1px] px-4 py-2 text-xs font-medium {$isLoading &&
'opacity-25 cursor-not-allowed'}" 'cursor-not-allowed opacity-25'}"
on:click|preventDefault={() => callAI(prompt, true)} onclick={preventDefault(() => callAI(prompt, true))}
type="button" type="button"
disabled={$isLoading} disabled={$isLoading}
> >
@@ -106,8 +122,8 @@
</button> </button>
{#if !$isLoading} {#if !$isLoading}
<button <button
class="text-xs px-4 py-2 border-[1px] cursor-pointer rounded-md font-medium" class="cursor-pointer rounded-md border-[1px] px-4 py-2 text-xs font-medium"
on:click|preventDefault={restart} onclick={preventDefault(restart)}
type="button" type="button"
> >
{$t('ai.reset')} {$t('ai.reset')}
@@ -117,22 +133,22 @@
</div> </div>
{:else} {:else}
<div class="h-full"> <div class="h-full">
<p class="text-sm font-medium mb-2 ml-1">{$t('ai.help_me')}</p> <p class="mb-2 ml-1 text-sm font-medium">{$t('ai.help_me')}</p>
<textarea <textarea
bind:value={prompt} bind:value={prompt}
class="w-full border-0 rounded-lg h-[65%] text-sm m-0 dark:text-black" class="m-0 h-[65%] w-full rounded-lg border-0 text-sm dark:text-black"
placeholder={$t('ai.placeholder')} placeholder={$t('ai.placeholder')}
/> ></textarea>
<button <button
class="flex text-xs px-4 py-2 m-0 border rounded-md font-medium" class="m-0 flex rounded-md border px-4 py-2 text-xs font-medium"
on:click|preventDefault={() => callAI(prompt, false)} onclick={preventDefault(() => callAI(prompt, false))}
type="button" type="button"
> >
{$t('ai.text')} {$t('ai.text')}
</button> </button>
</div> </div>
{/if} {/if}
</button> </div>
</Popover> </Popover>
</IconButton> </IconButton>
</div> </div> -->
@@ -7,18 +7,22 @@
hidePercentage?: boolean; hidePercentage?: boolean;
} }
export let activity: Activity; interface Props {
activity: Activity;
}
let { activity }: Props = $props();
</script> </script>
<div <div
class="flex flex-col items-center justify-center gap-5 rounded-xl border p-3 dark:border-neutral-600 md:flex-row md:p-5" 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"
> >
<div class="bg-primary-200 w-fit rounded-full p-4 text-black"> <div class="bg-primary-200 w-fit rounded-full p-4 text-black">
<svelte:component this={activity.icon} size={24} /> <activity.icon />
</div> </div>
<div> <div>
<p class="text-center text-sm font-medium text-gray-600 dark:text-gray-200 md:text-left"> <p class="text-center text-sm font-medium text-gray-600 md:text-left dark:text-gray-200">
{activity.title} {activity.title}
</p> </p>
<p class="text-center text-2xl font-bold md:text-left"> <p class="text-center text-2xl font-bold md:text-left">
@@ -27,7 +31,7 @@
% %
{/if} {/if}
</p> </p>
<p class="text-center text-xs text-gray-500 dark:text-gray-300 md:text-left md:text-sm"> <p class="text-center text-xs text-gray-500 md:text-left md:text-sm dark:text-gray-300">
{activity.description} {activity.description}
</p> </p>
</div> </div>
@@ -3,28 +3,32 @@
import { calDateDiff } from '$lib/utils/functions/date'; import { calDateDiff } from '$lib/utils/functions/date';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import type { UserAnalytics } from '$lib/utils/types/analytics'; import type { UserAnalytics } from '$lib/utils/types/analytics';
import Alarm from 'carbon-icons-svelte/lib/Alarm.svelte'; import AlarmClockIcon from '@lucide/svelte/icons/alarm-clock';
import Email from 'carbon-icons-svelte/lib/Email.svelte'; import MailIcon from '@lucide/svelte/icons/mail';
export let user: UserAnalytics['user']; interface Props {
user: UserAnalytics['user'];
}
let { user }: Props = $props();
</script> </script>
<div class="rounded-md border p-5 dark:border-neutral-600"> <div class="rounded-md border p-5 dark:border-neutral-600">
<div class="flex w-full flex-col items-center justify-start gap-4 text-start md:flex-row"> <div class="flex w-full flex-col items-center justify-start gap-4 text-start md:flex-row">
<Avatar src={user.avatarUrl} name={user.fullName} width="w-16" height="h-16" /> <Avatar src={user.avatarUrl} name={user.fullName} width="w-16" height="h-16" />
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<p class="text-center text-2xl font-bold dark:text-white md:text-left"> <p class="text-center text-2xl font-bold md:text-left dark:text-white">
{user.fullName} {user.fullName}
</p> </p>
<div class="flex flex-col items-center gap-1 md:flex-row md:gap-4"> <div class="flex flex-col items-center gap-1 md:flex-row md:gap-4">
<p class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-300"> <p class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-300">
<Email /> <MailIcon size={16} />
{user.email} {user.email}
</p> </p>
<p class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-300"> <p class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-300">
<Alarm /> <AlarmClockIcon size={16} />
{$t('analytics.last_seen')}: {$t('analytics.last_seen')}:
<span class="italic"> <span class="italic">
{user.lastSeen ? calDateDiff(user.lastSeen) : $t('analytics.a_while_ago')} {user.lastSeen ? calDateDiff(user.lastSeen) : $t('analytics.a_while_ago')}
@@ -1,12 +0,0 @@
<script>
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
import { PageNav } from '$lib/components/Page';
export let handleClose;
</script>
<PageNav title="Note" overidableStyle="padding: 0 10px">
<div slot="widget">
<CloseButton onClick={handleClose} />
</div>
</PageNav>
@@ -1,7 +0,0 @@
<script>
export let name;
export let src;
export let className;
</script>
<img alt={name} {src} class="w-10 max-h-10 rounded-full bg-primary-100 {className}" />
@@ -1,126 +0,0 @@
<script lang="ts">
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import DateTime from '$lib/components/Form/DateTime.svelte';
import type { PollType } from '../types';
import { course } from '$lib/components/Course/store';
import { profile } from '$lib/utils/store/user';
import TextField from '$lib/components/Form/TextField.svelte';
import { Add, TrashCan } from 'carbon-icons-svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import { t } from '$lib/utils/functions/translations';
export let title = 'Poll';
export let isSaving = false;
export let onSubmit = (p: PollType) => {};
export let onCancel = () => {};
let poll: PollType = {
id: new Date().getTime().toString(),
courseId: $course.id || '',
question: '',
expiration: new Date().toDateString(),
isPublic: false,
status: $t('course.navItem.lessons.poll.draft'),
author: {
id: $profile.id || '',
username: $profile.username || '',
fullname: $profile.fullname || '',
avatarUrl: $profile.avatar_url || ''
},
options: [
{
id: '',
label: '',
selectedBy: []
}
]
};
let isCreateDisabled = false;
function handleAddOptions() {
poll = {
...poll,
options: [
...poll.options,
{
id: '',
label: '',
selectedBy: []
}
]
};
}
function handleRemoveOptions(index: string | number) {
return () => {
poll.options = poll.options.filter((_, i) => i !== index);
};
}
function finishPoll() {
onSubmit(poll);
}
$: isCreateDisabled = poll.question === '' || poll.options.length < 2;
</script>
<div class="border rounded-md">
<div class="border-b-2 p-4 text-center">
{title}
</div>
<div class="p-3">
<TextField
label={$t('course.navItem.lessons.poll.question')}
className="w-full mb-3"
bind:value={poll.question}
placeholder={$t('course.navItem.lessons.poll.poll_question')}
isRequired={true}
/>
<DateTime
label={$t('course.navItem.lessons.poll.expiration')}
className="w-full mb-3"
bind:value={poll.expiration}
isRequired={true}
/>
<div>
<p class="dark:text-white p-0 m-0 mb-1 text-md flex items-center gap-2">
{$t('course.navItem.lessons.poll.options')}
<IconButton onClick={handleAddOptions} contained={true} size="small">
<Add size={16} />
</IconButton>
</p>
</div>
{#each poll.options as option, index}
<div class="flex items-center gap-2 mb-3">
<TextField
label=""
className="w-full"
bind:value={option.label}
placeholder={$t('course.navItem.lessons.poll.option_label')}
isRequired={true}
/>
<IconButton onClick={handleRemoveOptions(index)} contained={true} size="small">
<TrashCan size={16} />
</IconButton>
</div>
{/each}
</div>
<div class="w-full flex justify-center gap-2 mb-3">
<PrimaryButton
label={$t('course.navItem.lessons.poll.create_poll')}
onClick={finishPoll}
isLoading={isSaving}
isDisabled={isCreateDisabled}
/>
<PrimaryButton
label={$t('course.navItem.lessons.poll.cancel')}
variant={VARIANTS.OUTLINED}
onClick={onCancel}
/>
</div>
</div>
@@ -1,5 +0,0 @@
<p
class="dark:text-white p-0 m-0 mb-1 uppercase text-sm font-bold text-gray-600"
>
<slot />
</p>
@@ -1,215 +0,0 @@
<script script lang="ts">
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import Label from './Label.svelte';
import Avatar from './Avatar.svelte';
import type { PollType } from '../types';
import { updatePollStatus } from '../service';
import { t } from '$lib/utils/functions/translations';
import { Dashboard } from 'carbon-icons-svelte';
export let poll: PollType;
export let onSelect: (a: string) => void;
export let handlePollDelete = () => {};
export let currentUserId: number | string;
let viewResult = false;
let selectedOptionToView: { selectedBy: PollType['options'][0]['selectedBy'] } | null;
let isAuthor = false;
let totalVotes = 0;
let isThereAnyVote = false;
function getTotalVotes(options: PollType['options']) {
return options.reduce((acc, cur) => acc + cur.selectedBy.length, 0);
}
function calculatePercent(
options: PollType['options'],
currentOption: { selectedBy: string | PollType['options'][0]['selectedBy'] }
) {
const totalVoteOfAllOptions = getTotalVotes(options);
const totalVotesOfCurrentOption = currentOption.selectedBy.length;
return Math.round((totalVotesOfCurrentOption / totalVoteOfAllOptions) * 100) || 0;
}
function didUserSelect(
option: { selectedBy: PollType['options'][0]['selectedBy'] },
userId: number | string
) {
return option.selectedBy.find((user) => user.id === userId);
}
let prevStatus = poll.status;
function onStatusChange(status: string) {
if (status === prevStatus || !status) return;
updatePollStatus(poll.id, status);
prevStatus = status;
}
$: onStatusChange(poll.status);
$: isAuthor = currentUserId === poll.author.id;
$: totalVotes = getTotalVotes(poll.options);
$: {
isThereAnyVote = poll?.options?.some((option) => option.selectedBy.length) || false;
}
</script>
<div class="p-4 flex-wrap bg-white dark:bg-black border-2 rounded-md mb-3 w-full">
{#if selectedOptionToView}
<div>
<div class="flex items-center mb-5">
<button class="text-xl w-10 px-3" on:click={() => (selectedOptionToView = null)}>
{`<`}
</button>
<div class="flex-grow text-center">
<h3 class="dark:text-white font-bold">{$t('course.navItem.lessons.poll.voted')}</h3>
</div>
</div>
{#each selectedOptionToView.selectedBy as user}
<div class="flex items-center mb-2">
<Avatar src={user.avatarUrl} name={user.username} className="mr-2" />
<p class="dark:text-white">
{user.fullname}
</p>
</div>
{/each}
</div>
{:else}
<div class="flex flex-wrap gap-2 mb-3 justify-between">
<div>
<h3 class="dark:text-white font-bold">
{poll.question}
</h3>
<p class="dark:text-white text-sm italic">
{$t('course.navItem.lessons.poll.created_by')}
<span class="text-primary-500">@{poll.author.username}</span>
</p>
</div>
{#if isAuthor}
<div class="flex mb-3 justify-end items-center">
<label class="mr-2 font-bold" for="user-select"
>{$t('course.navItem.lessons.poll.status')}:</label
>
<select
name="users"
id="user-select"
class="dark:text-white dark:bg-black"
bind:value={poll.status}
>
<option value="draft">{$t('course.navItem.lessons.poll.draft')}</option>
<option value="published">{$t('course.navItem.lessons.poll.publish')}</option>
</select>
</div>
{/if}
</div>
{#if viewResult}
<div class="flex items-center justify-between">
<div class="mr-5">
<Label>{$t('course.navItem.lessons.poll.expiration')}</Label>
<p class="dark:text-white text-sm text-gray-600 mb-4">
{poll.expiration || $t('dashboard.today')}
</p>
</div>
<div class="flex flex-col items-center">
<Label>{$t('course.navItem.lessons.poll.total_voted')}</Label>
<p class="dark:text-white text-sm text-gray-600 mb-4">
{totalVotes}
</p>
</div>
</div>
<Label>{$t('course.navItem.lessons.poll.responses')}</Label>
{#each poll.options as option}
<div class="border-b p-5">
<p class="dark:text-white">
{option.label}
</p>
<div class="flex items-center">
<div class="w-4/5 h-2 bg-gray-200 relative">
<span
class="absolute top-0 left-0 bg-primary-700 h-full"
style="width: {calculatePercent(poll.options, option)}%;"
/>
</div>
{#if option.selectedBy.length}
<button class="text-xl px-3 ml-5" on:click={() => (selectedOptionToView = option)}>
>
</button>
{/if}
</div>
<div class="flex items-center">
{#each option.selectedBy.slice(0, 3) as user}
<Avatar src={user.avatarUrl} name={user.username} className="mr-2" />
{/each}
</div>
</div>
{/each}
{:else}
{#each poll.options as option, index}
<button
class="bg-white dark:bg-black rounded-md border-2 border-gray-100 dark:border-neutral-600 h-[50px] {didUserSelect(
option,
currentUserId
) &&
'focus:border-primary-700 border-primary-700'} text-black dark:text-white p-2 w-full mb-3 text-left relative"
on:click={() => onSelect(option.id)}
>
{#if isThereAnyVote}
<span
class="progress absolute top-0 text-black left-0 {didUserSelect(option, currentUserId)
? 'bg-primary-700'
: 'bg-gray-300'} h-full bg-opacity-25"
style="width: {calculatePercent(poll.options, option)}%;"
/>
{/if}
<p
class="absolute top-3 {didUserSelect(option, currentUserId)
? 'text-white'
: 'dark:text-white text-black'} left-2"
>
{option.label}
({calculatePercent(poll.options, option)}%)
</p>
</button>
{/each}
<div class="flex items-center">
<p class="dark:text-white text-sm text-gray-600">
{$t('course.navItem.lessons.poll.expires')}: {poll.expiration || $t('dashboard.today')}
</p>
<span class="mx-2">|</span>
<p class="dark:text-white text-sm text-gray-600">
{$t('course.navItem.lessons.poll.voted')}: {totalVotes}
</p>
</div>
{/if}
{#if isAuthor}
<div class="w-full flex items-center mt-3">
<PrimaryButton
label={viewResult
? $t('course.navItem.lessons.poll.back')
: $t('course.navItem.lessons.poll.result')}
onClick={() => (viewResult = !viewResult)}
/>
<PrimaryButton
label={$t('course.navItem.lessons.poll.delete')}
variant={VARIANTS.TEXT_DANGER}
onClick={handlePollDelete}
/>
</div>
{/if}
{/if}
</div>
<style>
.progress {
transition: width 0.5s linear;
}
</style>
@@ -1,34 +0,0 @@
<script lang="ts">
import Chip from '$lib/components/Chip/index.svelte';
import RoleBaseSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { Add } from 'carbon-icons-svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import type { TabsType } from '../types';
export let onCreate: () => void;
export let tabs: TabsType = [];
export let selectedTab: number;
</script>
<div class="mb-3 flex items-center justify-between">
<div>
{#each tabs as tab}
<button
class="mr-3 pb-2 border-t-0 border-l-0 border-r-0 {selectedTab === tab.value
? 'border-b-4 border-primary-600 focus:border-primary-600'
: 'border-b-0'}"
on:click={() => (selectedTab = tab.value)}
>
{tab.label}
<Chip bind:value={tab.number} />
</button>
{/each}
</div>
<RoleBaseSecurity allowedRoles={[1, 2]}>
<IconButton onClick={onCreate} contained={true} size="large">
<Add size={16} />
</IconButton>
</RoleBaseSecurity>
</div>
@@ -1,290 +0,0 @@
<script lang="ts">
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
import { course, group } from '$lib/components/Course/store';
import { PageNav } from '$lib/components/Page';
import { snackbar } from '$lib/components/Snackbar/store';
import { getSupabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import { globalStore } from '$lib/utils/store/app';
import { profile } from '$lib/utils/store/user';
import type { Groupmember } from '$lib/utils/types';
import type {
PostgrestSingleResponse,
RealtimeChannel,
RealtimePostgresChangesPayload
} from '@supabase/supabase-js';
import { onDestroy, onMount } from 'svelte';
import CreatePollForm from './components/CreatePollForm.svelte';
import Poll from './components/Poll.svelte';
import Tabs from './components/Tabs.svelte';
import { fetchPolls, handleVote } from './service';
import { polls } from './store';
import type { PollOptionsSubmissionType, PollType, TabsType } from './types';
import { getPollsData } from './utils';
export let handleClose = () => {};
const supabase = getSupabase();
let selectedTab = 0;
let currentGroupMember: Groupmember | undefined;
let openCreatePollForm = false;
let isCreating = false;
let isLoading = false;
let author: PollType['author'] = {
id: '',
username: '',
fullname: '',
avatarUrl: ''
};
let pollSubmissionsChannel: RealtimeChannel;
let tabs: TabsType = [];
let activePolls: PollType[] = [];
let expiredPolls: PollType[] = [];
function setCoursePolls() {
$course.polls = $polls.map((p) => ({ status: p.status }));
}
async function createPoll(poll: PollType) {
if (!currentGroupMember || !$course.id) return;
const { data, error } = await supabase
.from('apps_poll')
.insert({
question: poll.question,
authorId: currentGroupMember.id,
status: poll.status,
expiration: poll.expiration,
courseId: $course.id
})
.select('id');
if (error || !data) {
console.log(error);
snackbar.error('snackbar.poll.error.creating_poll');
return;
}
const pollId = data[0].id;
const appsPollOptions = poll.options.map((option) => ({
poll_id: pollId,
label: option.label
}));
const { data: appsPollOptionsData, error: appsPollOptionsError } = await supabase
.from('apps_poll_option')
.insert(appsPollOptions)
.select();
if (appsPollOptionsError || !appsPollOptionsData) {
console.log({ appsPollOptionsError });
snackbar.error('snackbar.poll.error.creating_poll');
return;
}
$polls = [
{
...poll,
id: pollId,
courseId: $course.id,
author,
options: appsPollOptionsData.map((option) => ({
...option,
id: option.id,
selectedBy: []
}))
},
...$polls
];
setCoursePolls();
}
async function handleInsert(payload: RealtimePostgresChangesPayload<PollOptionsSubmissionType>) {
const newVote = payload.new as PollOptionsSubmissionType;
const {
data,
error
}: PostgrestSingleResponse<{
profile: {
username: string;
fullname: string;
avatar_url: string;
};
}> = await supabase
.from('groupmember')
.select('profile:profile_id(username, fullname, avatar_url)')
.eq('id', newVote.selected_by_id)
.single();
console.log('newVote => data', data);
console.log('newVote => error', error);
if (error || !data) {
return;
}
$polls = $polls.map((poll) => {
if (poll.id === newVote.poll_id) {
return {
...poll,
options: poll.options.map((option) => {
if (option.id === newVote.poll_option_id) {
if (option.selectedBy.some((s) => s.id === newVote.selected_by_id)) {
return option;
}
return {
...option,
selectedBy: [
...option.selectedBy,
{
id: newVote.selected_by_id,
username: data.profile.username,
fullname: data.profile.fullname,
avatarUrl: data.profile.avatar_url
}
]
};
}
return option;
})
};
}
return poll;
});
}
async function handlePollCreate(poll: PollType) {
if (!currentGroupMember) return;
isCreating = true;
await createPoll(poll);
isCreating = false;
openCreatePollForm = false;
}
function handlePollDelete(pollId: string) {
return async () => {
await supabase.from('apps_poll_submission').delete().match({ poll_id: pollId });
await supabase.from('apps_poll_option').delete().match({ poll_id: pollId });
await supabase.from('apps_poll').delete().match({ id: pollId });
$polls = [...$polls.filter((p, i) => p.id !== pollId)];
setCoursePolls();
};
}
onMount(async () => {
if (!$course.id) return;
isLoading = true;
const { data, error } = await fetchPolls($course.id);
if (!data || error) {
console.log(error);
isLoading = false;
return;
}
polls.set(getPollsData(data, $globalStore.isStudent));
setCoursePolls();
isLoading = false;
pollSubmissionsChannel = supabase
.channel('any')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'apps_poll_submission' },
handleInsert
)
.subscribe();
});
onDestroy(() => {
supabase.removeChannel(pollSubmissionsChannel);
});
$: currentGroupMember = $group.people.find(
(person) => person.profile_id === $profile.id
) as Groupmember;
$: author = {
id: currentGroupMember?.id || '',
username: $profile.username || '',
fullname: $profile.fullname || '',
avatarUrl: $profile.avatar_url || ''
};
$: {
activePolls = $polls.filter(
(poll) => new Date(poll.expiration).getTime() >= new Date().getTime()
);
expiredPolls = $polls.filter(
(poll) => new Date(poll.expiration).getTime() <= new Date().getTime()
);
tabs = [
{
label: $t('course.navItem.lessons.polls.active_polls'),
value: 0,
number: activePolls.length
},
{
label: $t('course.navItem.lessons.polls.expired_polls'),
value: 1,
number: expiredPolls.length
}
];
}
</script>
<PageNav
title={$t('course.navItem.lessons.polls.title')}
overidableStyle="padding: 0 10px"
paddingClass="w-full"
>
<div slot="widget">
<CloseButton onClick={handleClose} />
</div>
</PageNav>
<div class="overlow-y-auto w-full p-2 md:min-w-[340px] md:max-w-[350px]">
{#if openCreatePollForm}
<CreatePollForm
onSubmit={handlePollCreate}
onCancel={() => (openCreatePollForm = !openCreatePollForm)}
bind:isSaving={isCreating}
/>
{:else if currentGroupMember}
<div>
<Tabs {tabs} bind:selectedTab onCreate={() => (openCreatePollForm = !openCreatePollForm)} />
{#if isLoading}
{$t('course.navItem.lessons.polls.loading')}...
{:else}
{#each selectedTab === tabs[0].value ? activePolls : expiredPolls as poll}
<Poll
bind:poll
onSelect={handleVote(poll.id, currentGroupMember?.id || '', author)}
handlePollDelete={handlePollDelete(poll.id)}
bind:currentUserId={currentGroupMember.id}
/>
{:else}
<div
class="bg-gray-100 dark:bg-neutral-800 border rounded-md h-60 flex items-center justify-center"
>
<h2 class="text-xl font-bold">{$t('course.navItem.lessons.polls.no_polls')}</h2>
</div>
{/each}
{/if}
</div>
{/if}
</div>
@@ -1,141 +0,0 @@
import { snackbar } from '$lib/components/Snackbar/store';
import type { PollType, PollOptionType, FetchPollsResponse } from './types';
import { getSupabase } from '$lib/utils/functions/supabase';
import { polls } from './store';
const supabase = getSupabase();
export async function fetchPolls(courseId: string) {
return await supabase
.from('apps_poll')
.select(
`
*,
created_at,
author:groupmember(
profile(
username,
fullname,
avatar_url
)
),
options: apps_poll_option (
id,
label,
submissions:apps_poll_submission(
selectedBy:groupmember(
id,
profile(
username,
fullname,
avatar_url
)
)
)
)
`
)
.match({ courseId })
.order('created_at', { ascending: false })
.returns<FetchPollsResponse>();
}
export const updatePollStatus = async (pollId: string, status: PollType['status']) => {
const { error } = await supabase.from('apps_poll').update({ status }).match({ id: pollId });
if (error) {
console.log(error);
snackbar.error('snackbar.poll.error.updating_poll');
return;
}
};
export const togglePollSubmission = async (
pollId: PollType['id'],
pollOptionId: PollOptionType['id'],
groupmemberId: string,
add: boolean
) => {
if (add) {
const { error } = await supabase.from('apps_poll_submission').insert({
poll_id: pollId,
poll_option_id: pollOptionId,
selected_by_id: groupmemberId
});
if (error) {
console.log(error);
snackbar.error('snackbar.poll.error.submitting_poll');
return;
}
} else {
const { error } = await supabase.from('apps_poll_submission').delete().match({
poll_id: pollId,
poll_option_id: pollOptionId,
selected_by_id: groupmemberId
});
if (error) {
console.log(error);
snackbar.error('snackbar.poll.error.submitting_poll');
return;
}
}
};
function isOptionSelectedByCurrentUser(option: PollOptionType, groupmemberId: string) {
return option.selectedBy.some((gmember) => gmember.id === groupmemberId);
}
export function handleVote(pollId: string, groupmemberId: string, author: PollType['author']) {
return (optionId: string) => {
// we have the pollId and the optionId
polls.update((_polls) => {
return [
..._polls.map((poll) => {
// Prevent user from voting on their own poll
// if (poll.author.id === currentGroupMember?.id) return poll;
// Prevent user from voting on a poll that has expired
if (poll.expiration && new Date(poll.expiration) < new Date()) return poll;
if (poll.id === pollId) {
// Prevent user from voting on a poll that the status is not published
if (poll.status !== 'published') {
snackbar.info('snackbar.poll.info.not_published');
return poll;
}
// Prevent user from voting twice
if (
poll.options.some((option) => isOptionSelectedByCurrentUser(option, groupmemberId))
) {
snackbar.info('snackbar.poll.info.vote_once');
return poll;
}
poll.options = poll.options.map((option) => {
const isSelected = isOptionSelectedByCurrentUser(option, groupmemberId);
// If user has not voted on this option, add their vote
if (option.id === optionId && !isSelected) {
option.selectedBy = [...option.selectedBy, author];
togglePollSubmission(pollId, optionId, groupmemberId, true);
}
// else if (isSelected) {
// // If user has voted on this option, remove their vote
// option.selectedBy = option.selectedBy.filter(
// (gmember) => gmember.id !== groupmemberId
// );
// }
return option;
});
}
return poll;
})
];
});
};
}
@@ -1,4 +0,0 @@
import { writable, type Writable } from 'svelte/store';
import type { PollType } from './types';
export const polls: Writable<PollType[]> = writable([]);
@@ -1,69 +0,0 @@
export type PollOptionType = {
id: string;
label: string;
selectedBy: {
id: string;
username: string;
fullname: string;
avatarUrl: string;
}[];
};
export type PollType = {
courseId: string;
id: string;
question: string;
author: {
id: string;
username: string;
fullname: string;
avatarUrl: string;
};
status: string;
isPublic: boolean;
expiration: string | Date;
options: PollOptionType[];
};
export type TabsType = {
label: string;
value: number;
number: number;
}[];
export type PollOptionsSubmissionType = {
poll_id: string;
poll_option_id: string;
selected_by_id: string;
};
export type FetchPollsResponse = {
id: string;
courseId: string;
expiration: string;
authorId: string;
status: string;
question: string;
created_at: string;
author: {
profile: {
username: string;
fullname: string;
avatar_url: string;
};
};
options: {
id: string;
label: string;
submissions: {
selectedBy: {
id: string;
profile: {
username: string;
fullname: string;
avatar_url: string;
};
};
}[];
}[];
}[];
@@ -1,31 +0,0 @@
import type { PollType, FetchPollsResponse } from './types';
export const getPollsData = (data: FetchPollsResponse, isStudent?: boolean) => {
const pollsData: PollType[] = data.map((appsPollItem) => ({
id: appsPollItem.id,
courseId: appsPollItem.courseId,
expiration: appsPollItem.expiration,
status: appsPollItem.status,
question: appsPollItem.question,
author: {
id: appsPollItem.authorId,
username: appsPollItem.author.profile.username,
fullname: appsPollItem.author.profile.fullname,
avatarUrl: appsPollItem.author.profile.avatar_url
},
options: appsPollItem.options.map((option) => ({
id: option.id,
label: option.label,
selectedBy: option.submissions.map((submission) => {
return {
id: submission.selectedBy.id,
username: submission.selectedBy.profile.username,
fullname: submission.selectedBy.profile.fullname,
avatarUrl: submission.selectedBy.profile.avatar_url
};
})
}))
}));
return pollsData.filter((p) => (isStudent ? p.status === 'published' : true));
};
@@ -1,135 +0,0 @@
<script>
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { PageNav } from '$lib/components/Page';
import Vote from '$lib/components/Vote/index.svelte';
import CheckmarkOutline from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
export let handleClose;
let bodyRef;
let question;
let questions = [
{
id: 1,
name: 'Evgeniy',
question: "I'am looking at this and it looks great",
vote: 1,
answered: false
},
{
id: 2,
name: 'Sergey Semko',
question:
'Thanks for the feedback, what do you think about my website. https://rotimibest.com',
vote: 4,
answered: false
},
{
id: 3,
name: 'Vitalii Marushko',
question: 'Всем привет',
vote: 20,
answered: false
},
{
id: 4,
name: 'Natasha',
question: 'Привет Виталий',
vote: 0,
answered: false
}
];
function handleSend() {
if (!question) {
return;
}
questions = [
...questions,
{
id: questions.length,
name: 'You',
question,
vote: 0,
answered: false
}
];
question = null;
setTimeout(() => {
bodyRef.scrollTo({
top: bodyRef.scrollHeight
});
}, 100);
}
function handleMarkAnswered(id) {
return () => {
questions = questions
.map((question) => {
if (question.id === id) {
question.answered = !question.answered;
}
return question;
})
.sort((a, b) => Number(a.answered) - Number(b.answered));
};
}
function handleKeyDown(e) {
if (e.key === 'Enter') {
handleSend();
}
}
</script>
<PageNav title="Q/A" overidableStyle="padding: 0 10px">
<div slot="widget">
<CloseButton onClick={handleClose} />
</div>
</PageNav>
<div bind:this={bodyRef} class="body">
{#each questions as question}
<div class="mb-2 ml-2 mt-2 flex items-start">
<Vote bind:value={question.vote} />
<div class="ml-2 flex-grow">
<p class="text-sm font-bold dark:text-white">{question.name}</p>
<article class="prose prose-sm sm:prose">
{question.question}
</article>
</div>
<button class={`${question.answered && 'active'}`} on:click={handleMarkAnswered(question.id)}>
<CheckmarkOutline size={20} class="carbon-icon dark:text-white" />
</button>
</div>
{/each}
</div>
<div class="footer">
<TextField placeholder="Say something" bind:value={question} onKeyDown={handleKeyDown} />
</div>
<style lang="scss">
.body {
overflow: auto;
height: 80%;
& button {
display: none;
}
& div:hover button,
& button.active {
display: block;
}
}
.footer {
position: absolute;
bottom: 0px;
width: 100%;
}
</style>
@@ -1,8 +0,0 @@
export default {
APPS: {
QANDA: 'QANDA',
NOTES: 'NOTES',
COMMENTS: 'COMMENTS',
POLL: 'POLL'
}
};
@@ -1,237 +0,0 @@
<script lang="ts">
import IconButton from '$lib/components/IconButton/index.svelte';
import { globalStore } from '$lib/utils/store/app';
import ChartPie from 'carbon-icons-svelte/lib/ChartPie.svelte';
import Settings from 'carbon-icons-svelte/lib/Settings.svelte';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { apps } from './store';
import { browser } from '$app/environment';
import Chip from '$lib/components/Chip/index.svelte';
import { course } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import Notes from './components/Notes/index.svelte';
import Poll from './components/Poll/index.svelte';
import QandA from './components/QandA/index.svelte';
import APPS_CONSTANTS from './constants';
let appBarRef: HTMLDivElement;
let appContentRef: HTMLDivElement;
let topPadding = $globalStore.isStudent ? '48px' : '109px';
const getResizableSidebar = () => {
if (!browser) return;
return window.innerWidth >= 1024;
};
function handleClose() {
$apps.selectedApp = undefined;
if ($globalStore.isStudent) {
$apps.open = false;
}
}
function handleAppClick(appName?: string) {
if (appName === $apps.selectedApp) {
handleClose();
} else {
$apps.selectedApp = appName;
}
$apps.dropdown = !$apps.dropdown;
}
function updateTopPadding(isStudent?: boolean) {
topPadding = isStudent ? '48px' : '109px';
}
function getAppClass(appName: string, selected: string | undefined) {
return `transition duration-500 delay-150 ease-in-out border-t-0 border-b-0 border-r-0 ${
appName === selected ? 'border-l-4 border-primary-600 focus:border-primary-600' : 'border-l-0'
}`;
}
onMount(() => {
updateTopPadding($globalStore.isStudent);
});
$: updateTopPadding($globalStore.isStudent);
$: getResizableSidebar();
</script>
<div
style={`--top-padding:${topPadding}`}
class={`${$apps.open ? 'open ' : 'close'} root bg-white dark:bg-black`}
bind:this={appBarRef}
>
<div class="apps">
<div class="mb-2 lg:hidden">
<IconButton
buttonClassName="lg:hidden"
toolTipProps={{ title: 'Settings', hotkeys: [] }}
onClick={handleAppClick}
>
<Settings size={24} class="carbon-icon dark:text-white lg:hidden" />
</IconButton>
</div>
<div class="mb-2 {getAppClass(APPS_CONSTANTS.APPS.POLL, $apps.selectedApp)}">
<IconButton
toolTipProps={{
title: `${
$globalStore.isOrgSite && !$currentOrg.customization.apps.poll
? $t('course.navItem.lessons.disabled')
: $t('course.navItem.lessons.polls.title')
} `,
hotkeys: ['Ctrl/Command', '2']
}}
value={APPS_CONSTANTS.APPS.POLL}
onClick={handleAppClick}
disabled={$globalStore.isOrgSite && !$currentOrg.customization.apps.poll}
>
<Chip
value={$course.polls.length}
className="absolute -top-1 right-0 bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-white"
/>
<ChartPie size={24} class="carbon-icon dark:text-white" />
</IconButton>
</div>
<!-- <div class="mb-2">
<IconButton
toolTipProps={{ title: 'QandA', hotkeys: ['A', '3'] }}
value={APPS_CONSTANTS.APPS.QANDA}
onClick={handleAppClick}
contained={APPS_CONSTANTS.APPS.QANDA === $apps.selectedApp}
>
<Forum size={24} class="carbon-icon dark:text-white" />
</IconButton>
</div>
<div class="mb-2">
<IconButton
toolTipProps={{ title: 'Notes', hotkeys: ['A', '3'] }}
value={APPS_CONSTANTS.APPS.NOTES}
onClick={handleAppClick}
contained={APPS_CONSTANTS.APPS.NOTES === $apps.selectedApp}
>
<AlignBoxTopLeft size={24} class="carbon-icon dark:text-white" />
</IconButton>
</div> -->
</div>
{#if !!$apps.selectedApp}
<div
class="app max-w-[350px] transition delay-150 duration-100 ease-in-out"
bind:this={appContentRef}
>
{#key $apps.selectedApp}
<div class="h-full w-full" transition:fade={{ duration: 200 }}>
{#if $apps.selectedApp === APPS_CONSTANTS.APPS.QANDA}
<QandA {handleClose} />
{:else if $apps.selectedApp === APPS_CONSTANTS.APPS.POLL}
<Poll {handleClose} />
{:else if $apps.selectedApp === APPS_CONSTANTS.APPS.NOTES}
<Notes {handleClose} />
{/if}
</div>
{/key}
</div>
{/if}
</div>
<style lang="scss">
.root {
height: calc(100vh - 48px);
position: sticky;
top: 0;
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: row;
z-index: 1;
& .apps {
padding-top: 20px;
display: flex;
flex-direction: column;
align-items: center;
width: 60px;
border-right: 1px solid var(--border-color);
height: 100%;
}
& .app {
width: 100%;
position: relative;
overflow: auto;
}
}
@media screen and (min-width: 768px) and (max-width: 1023px) {
.app {
width: 50vw;
position: relative;
overflow: auto;
}
}
@media screen and (max-width: 767px) {
.app {
width: 100vw;
min-width: calc(100vw - 60px);
position: relative;
overflow: auto;
}
}
@media screen and (max-width: 1023px) {
.root {
position: absolute;
height: calc(100vh - var(--top-padding));
top: var(--top-padding);
right: 0;
z-index: 999;
}
.open {
position: absolute;
transform: translateX(0);
transition: all 0.2s ease-in-out;
}
.close {
position: absolute;
transform: translateX(100%);
display: none;
transition: all 0.2s ease-in-out;
}
}
@media screen and (min-width: 1024px) {
.root {
height: calc(100vh - 48px);
position: sticky;
top: 0;
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: row;
z-index: 1;
& .apps {
padding-top: 20px;
display: flex;
flex-direction: column;
align-items: center;
width: 60px;
border-right: 1px solid var(--border-color);
height: 100%;
}
& .app {
width: 100%;
position: relative;
overflow: auto;
}
}
}
</style>
@@ -1,11 +0,0 @@
import { writable } from 'svelte/store';
export const apps = writable<{
open: boolean;
dropdown: boolean;
selectedApp: string | undefined;
}>({
open: false,
dropdown: false,
selectedApp: undefined
});
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { preventDefault } from '$lib/utils/functions/svelte';
import { page } from '$app/state';
import Avatar from '$lib/components/Avatar/index.svelte'; import Avatar from '$lib/components/Avatar/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants'; import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -8,15 +10,31 @@
import type { SupabaseClient } from '@supabase/supabase-js'; import type { SupabaseClient } from '@supabase/supabase-js';
import GoogleIconColored from '../Icons/GoogleIconColored.svelte'; import GoogleIconColored from '../Icons/GoogleIconColored.svelte';
export let supabase: SupabaseClient; interface Props {
export let handleSubmit = () => {}; supabase: SupabaseClient;
export let isLogin = true; handleSubmit?: any;
export let showOnlyContent = false; isLogin?: boolean;
export let isLoading = false; showOnlyContent?: boolean;
export let showLogo = false; isLoading?: boolean;
export let formRef; showLogo?: boolean;
export let hideGoogleAuth = false; formRef: any;
export let redirectPathname = ''; hideGoogleAuth?: boolean;
redirectPathname?: string;
children?: import('svelte').Snippet;
}
let {
supabase,
handleSubmit = () => {},
isLogin = true,
showOnlyContent = false,
isLoading = false,
showLogo = false,
formRef = $bindable(),
hideGoogleAuth = false,
redirectPathname = '',
children
}: Props = $props();
async function signInWithGoogle() { async function signInWithGoogle() {
if (isLoading) { if (isLoading) {
@@ -70,12 +88,8 @@
</a> </a>
</div> </div>
{/if} {/if}
<form <form bind:this={formRef} onsubmit={preventDefault(handleSubmit)} class="flex w-10/12 flex-col items-center">
bind:this={formRef} {@render children?.()}
on:submit|preventDefault={handleSubmit}
class="flex w-10/12 flex-col items-center"
>
<slot />
</form> </form>
{#if !showOnlyContent && !hideGoogleAuth} {#if !showOnlyContent && !hideGoogleAuth}
<div class="mb-3 w-10/12"> <div class="mb-3 w-10/12">
@@ -98,14 +112,10 @@
<div class="border-grey w-full border-t p-6 text-center"> <div class="border-grey w-full border-t p-6 text-center">
{#if isLogin} {#if isLogin}
{$t('login.not_registered_yet')} {$t('login.not_registered_yet')}
<a class="text-primary-700 hover:underline" href="/signup{$page.url.search}" <a class="text-primary-700 hover:underline" href="/signup{page.url.search}">{$t('login.signup')}</a>
>{$t('login.signup')}</a
>
{:else} {:else}
{$t('login.already_have_account')} {$t('login.already_have_account')}
<a class="text-primary-700 hover:underline" href="/login{$page.url.search}" <a class="text-primary-700 hover:underline" href="/login{page.url.search}">{$t('login.login')}</a>
>{$t('login.login')}</a
>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -1,10 +1,21 @@
<script> <script lang="ts">
export let name = ''; interface Props {
export let src = ''; name?: string;
export let className = ''; src?: string;
export let shape = 'rounded-full'; className?: string;
export let width = 'w-10'; shape?: string;
export let height = 'h-10'; width?: string;
height?: string;
}
let {
name = '',
src = '',
className = '',
shape = 'rounded-full',
width = 'w-10',
height = 'h-10'
}: Props = $props();
</script> </script>
<img alt={name} {src} class="{className} {width} {height} {shape}" /> <img alt={name} {src} class="{className} {width} {height} {shape}" />
@@ -1,13 +1,15 @@
<script lang="ts"> <script lang="ts">
export let disableCenteredContent = false; interface Props {
export let className = ''; disableCenteredContent?: boolean;
className?: string;
children?: import('svelte').Snippet;
}
let { disableCenteredContent = false, className = '', children }: Props = $props();
</script> </script>
<div <div class="root {!className.includes('opacity') && 'opacity-100'} {!disableCenteredContent && 'center'} {className}">
class="root {!className.includes('opacity') && 'opacity-100'} {!disableCenteredContent && {@render children?.()}
'center'} {className}"
>
<slot />
</div> </div>
<style> <style>
@@ -1,9 +1,14 @@
<script> <script lang="ts">
export let className = ''; interface Props {
className?: string;
children?: import('svelte').Snippet;
}
let { className = '', children }: Props = $props();
</script> </script>
<div <div
class="{className} w-full h-[517px] flex items-center justify-center flex-col border rounded border-gray-200 dark:border-neutral-600" class="{className} flex h-[517px] w-full flex-col items-center justify-center rounded border border-gray-200 dark:border-neutral-600"
> >
<slot /> {@render children?.()}
</div> </div>
@@ -1,18 +1,23 @@
<script> <script lang="ts">
import CloseIcon from 'carbon-icons-svelte/lib/Close.svelte'; import XIcon from '@lucide/svelte/icons/x';
import IconButton from '$lib/components/IconButton/index.svelte'; import IconButton, { type Props as IconButtonProps } from '$lib/components/IconButton/index.svelte';
export let onClick = () => {}; interface Props {
export let contained = false; onClick?: IconButtonProps['onClick'];
export let color = 'text-primary-700 bg-primary-200 dark:bg-neutral-500'; contained?: boolean;
export let size = 'small'; color?: string;
size?: IconButtonProps['size'];
toolTipProps?: IconButtonProps['toolTipProps'];
}
let {
onClick = () => {},
contained = false,
color = 'text-primary-700 bg-primary-200 dark:bg-neutral-500',
size = 'small'
}: Props = $props();
</script> </script>
<IconButton {onClick} {color} {contained} {size}> <IconButton {onClick} {color} {contained} {size}>
{#if size === 'large'} <XIcon size={size === 'large' ? 24 : 16} class="carbon-icon dark:text-black" />
<CloseIcon size={24} class="carbon-icon dark:text-black" title="Delete" />
{:else}
<CloseIcon size={20} class="carbon-icon dark:text-black" title="Delete" />
{/if}
</IconButton> </IconButton>
<!-- color="text-primary-700 bg-primary-200 dark:bg-gray-200 dark:text-black" -->
@@ -0,0 +1 @@
export { default as CloseButton } from './index.svelte';
@@ -1,10 +1,10 @@
<script> <script lang="ts">
import Close from 'carbon-icons-svelte/lib/Close.svelte'; import XIcon from '@lucide/svelte/icons/x';
import IconButton from '$lib/components/IconButton/index.svelte'; import { IconButton } from '$lib/components/IconButton';
export let onClick = () => {}; let { onClick = () => {} } = $props();
</script> </script>
<IconButton {onClick}> <IconButton {onClick}>
<Close size={24} class="carbon-icon dark:text-white" title="Close" /> <XIcon size={16} class="carbon-icon dark:text-white" />
</IconButton> </IconButton>
@@ -1,10 +1,10 @@
<script> <script lang="ts">
import SendFilled from 'carbon-icons-svelte/lib/SendFilled.svelte'; import SendHorizontalIcon from '@lucide/svelte/icons/send-horizontal';
import IconButton from '$lib/components/IconButton/index.svelte'; import { IconButton } from '$lib/components/IconButton';
export let onClick; let { onClick } = $props();
</script> </script>
<IconButton {onClick}> <IconButton {onClick}>
<SendFilled size={24} class="carbon-icon dark:text-white" /> <SendHorizontalIcon size={16} class="filled" />
</IconButton> </IconButton>
@@ -7,10 +7,14 @@
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { user } from '$lib/utils/store/user'; import { user } from '$lib/utils/store/user';
export let className = ''; interface Props {
export let isLMS = false; className?: string;
isLMS?: boolean;
}
$: href = isLMS && $user.isLoggedIn ? `${$currentOrgDomain}/home` : $currentOrgDomain; let { className = '', isLMS = false }: Props = $props();
let href = $derived(isLMS && $user.isLoggedIn ? `${$currentOrgDomain}/home` : $currentOrgDomain);
</script> </script>
<a {href} target="_blank" class="{className} ml-2 hover:no-underline"> <a {href} target="_blank" class="{className} ml-2 hover:no-underline">
@@ -1,9 +1,11 @@
<script> <script lang="ts">
export let name = ''; interface Props {
name?: string;
}
let { name = '' }: Props = $props();
</script> </script>
<p <p class="w-fit rounded-md border border-blue-700 px-1.5 text-center text-xs font-semibold text-blue-700">
class="w-fit text-xs border font-semibold px-1.5 rounded-md text-center text-blue-700 border-blue-700"
>
{name} {name}
</p> </p>
@@ -1,14 +1,16 @@
<script lang="ts"> <script lang="ts">
export let className = ''; interface Props {
export let value: string | number = ''; className?: string;
export let size = ''; value?: string | number;
export let shape = 'rounded-md'; size?: string;
shape?: string;
}
let { className = '', value = '', size = '', shape = 'rounded-md' }: Props = $props();
</script> </script>
<p <p
class="{className} {size === 'sm' ? 'py-1 px-1' : 'py-2 px-2'} org-avatar {className.includes( class="{className} {size === 'sm' ? 'px-1 py-1' : 'px-2 py-2'} org-avatar {className.includes('bg-')
'bg-'
)
? '' ? ''
: 'bg-gray-200 dark:text-black'} {shape} text-center" : 'bg-gray-200 dark:text-black'} {shape} text-center"
> >
@@ -1,6 +1,10 @@
<script> <script lang="ts">
export let value = 0; interface Props {
export let className = ''; value?: number;
className?: string;
}
let { value = 0, className = '' }: Props = $props();
</script> </script>
<p class={className ? className : 'bg-set mt-2'}>{value}</p> <p class={className ? className : 'bg-set mt-2'}>{value}</p>
@@ -1,7 +1,11 @@
<script> <script lang="ts">
export let code = ''; interface Props {
code?: string;
}
let { code = '' }: Props = $props();
</script> </script>
<div class="bg-gray-200 dark:bg-gray-500 p-5 rounded-md mb-3"> <div class="mb-3 rounded-md bg-gray-200 p-5 dark:bg-gray-500">
{@html code} {@html code}
</div> </div>
@@ -1,11 +1,9 @@
<script> <script lang="ts">
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
export let label = $t('course.navItem.lessons.exercises.new_exercise_modal.coming_soon'); let { label = $t('course.navItem.lessons.exercises.new_exercise_modal.coming_soon') } = $props();
</script> </script>
<p <p class="org-avatar bg-primary-200 text-primary-700 rounded-md px-2 py-1 text-center text-xs font-medium">
class="org-avatar text-xs bg-primary-200 text-primary-700 font-medium py-1 px-2 rounded-md text-center"
>
{label} {label}
</p> </p>
@@ -3,7 +3,7 @@
import confetti from 'canvas-confetti'; import confetti from 'canvas-confetti';
import { confettiStore } from './store'; import { confettiStore } from './store';
let myCanvas; let myCanvas = $state();
let myConfetti; let myConfetti;
function showConfetti(show) { function showConfetti(show) {
@@ -29,13 +29,13 @@
myConfetti( myConfetti(
Object.assign({}, defaults, { Object.assign({}, defaults, {
particleCount, particleCount,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }
}) })
); );
myConfetti( myConfetti(
Object.assign({}, defaults, { Object.assign({}, defaults, {
particleCount, particleCount,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }
}) })
); );
}, 250); }, 250);
@@ -44,14 +44,16 @@
onMount(() => { onMount(() => {
myConfetti = confetti.create(myCanvas, { myConfetti = confetti.create(myCanvas, {
resize: true, resize: true,
useWorker: true, useWorker: true
}); });
}); });
$: showConfetti($confettiStore.show); $effect(() => {
showConfetti($confettiStore.show);
});
</script> </script>
<canvas bind:this={myCanvas} /> <canvas bind:this={myCanvas}></canvas>
<style> <style>
canvas { canvas {
@@ -1,27 +1,31 @@
<script lang="ts"> <script lang="ts">
export let title: string; interface Props {
export let value: string | number; title: string;
export let subtitle: string = ''; value: string | number;
export let icon: any; subtitle?: string;
export let iconColor: string = 'blue'; icon: any;
export let className: string = ''; iconColor?: string;
className?: string;
}
$: colorClasses = { let { title, value, subtitle = '', icon, iconColor = 'blue', className = '' }: Props = $props();
let colorClasses = $derived({
blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400', blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
green: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400', green: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
purple: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', purple: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
orange: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400', orange: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400',
red: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400', red: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
yellow: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400' yellow: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400'
}; });
const SvelteComponent = $derived(icon);
</script> </script>
<div <div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800 {className}">
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800 {className}"
>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg {colorClasses[iconColor]}"> <div class="flex h-12 w-12 items-center justify-center rounded-lg {colorClasses[iconColor]}">
<svelte:component this={icon} className={colorClasses[iconColor]} /> <SvelteComponent className={colorClasses[iconColor]} />
</div> </div>
<div> <div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p> <p class="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
@@ -5,53 +5,58 @@
import { ScaleTypes } from '@carbon/charts-svelte'; import { ScaleTypes } from '@carbon/charts-svelte';
import '@carbon/charts-svelte/styles.css'; import '@carbon/charts-svelte/styles.css';
export let courseAnalytics: CourseAnalytics | null = null; interface Props {
courseAnalytics?: CourseAnalytics | null;
}
let BarChartSimple: any; let { courseAnalytics = null }: Props = $props();
let BarChartSimple: any = $state();
// Transform data for progress distribution chart // Transform data for progress distribution chart
$: progressChartData = courseAnalytics?.students let progressChartData = $derived(
? [ courseAnalytics?.students
{ ? [
group: '80%+ Progress', {
value: courseAnalytics.students.filter((s) => s.progressPercentage >= 80).length group: '80%+ Progress',
}, value: courseAnalytics.students.filter((s) => s.progressPercentage >= 80).length
{ },
group: '60-79% Progress', {
value: courseAnalytics.students.filter( group: '60-79% Progress',
(s) => s.progressPercentage >= 60 && s.progressPercentage < 80 value: courseAnalytics.students.filter((s) => s.progressPercentage >= 60 && s.progressPercentage < 80)
).length .length
}, },
{ {
group: 'Below 60% Progress', group: 'Below 60% Progress',
value: courseAnalytics.students.filter((s) => s.progressPercentage < 60).length value: courseAnalytics.students.filter((s) => s.progressPercentage < 60).length
} }
] ]
: []; : []
);
// Transform data for grade distribution chart // Transform data for grade distribution chart
$: gradeChartData = courseAnalytics?.students let gradeChartData = $derived(
? [ courseAnalytics?.students
{ ? [
group: '90%+ Grade', {
value: courseAnalytics.students.filter((s) => s.averageGrade >= 90).length group: '90%+ Grade',
}, value: courseAnalytics.students.filter((s) => s.averageGrade >= 90).length
{ },
group: '80-89% Grade', {
value: courseAnalytics.students.filter((s) => s.averageGrade >= 80 && s.averageGrade < 90) group: '80-89% Grade',
.length value: courseAnalytics.students.filter((s) => s.averageGrade >= 80 && s.averageGrade < 90).length
}, },
{ {
group: '70-79% Grade', group: '70-79% Grade',
value: courseAnalytics.students.filter((s) => s.averageGrade >= 70 && s.averageGrade < 80) value: courseAnalytics.students.filter((s) => s.averageGrade >= 70 && s.averageGrade < 80).length
.length },
}, {
{ group: 'Below 70% Grade',
group: 'Below 70% Grade', value: courseAnalytics.students.filter((s) => s.averageGrade < 70).length
value: courseAnalytics.students.filter((s) => s.averageGrade < 70).length }
} ]
] : []
: []; );
onMount(async () => { onMount(async () => {
if (browser) { if (browser) {
@@ -61,7 +66,7 @@
}); });
// Chart options for progress distribution // Chart options for progress distribution
$: progressChartOptions = { let progressChartOptions = $derived({
axes: { axes: {
left: { left: {
mapsTo: 'value', mapsTo: 'value',
@@ -84,10 +89,10 @@
'Below 60% Progress': '#ef4444' // red 'Below 60% Progress': '#ef4444' // red
} }
} }
}; });
// Chart options for grade distribution // Chart options for grade distribution
$: gradeChartOptions = { let gradeChartOptions = $derived({
axes: { axes: {
left: { left: {
mapsTo: 'value', mapsTo: 'value',
@@ -111,24 +116,16 @@
'Below 70% Grade': '#ef4444' // red 'Below 70% Grade': '#ef4444' // red
} }
} }
}; });
</script> </script>
{#if browser && BarChartSimple} {#if browser && BarChartSimple}
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
<!-- Progress Distribution Chart --> <!-- Progress Distribution Chart -->
<div <div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800" <h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">Student Progress Distribution</h3>
>
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">
Student Progress Distribution
</h3>
{#if courseAnalytics?.students && courseAnalytics.students.length > 0} {#if courseAnalytics?.students && courseAnalytics.students.length > 0}
<svelte:component <BarChartSimple data={progressChartData} options={progressChartOptions} />
this={BarChartSimple}
data={progressChartData}
options={progressChartOptions}
/>
{:else} {:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400"> <div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
No progress data available No progress data available
@@ -137,12 +134,10 @@
</div> </div>
<!-- Grade Distribution Chart --> <!-- Grade Distribution Chart -->
<div <div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
>
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">Grade Distribution</h3> <h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">Grade Distribution</h3>
{#if courseAnalytics?.students && courseAnalytics.students.length > 0} {#if courseAnalytics?.students && courseAnalytics.students.length > 0}
<svelte:component this={BarChartSimple} data={gradeChartData} options={gradeChartOptions} /> <BarChartSimple data={gradeChartData} options={gradeChartOptions} />
{:else} {:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400"> <div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
No grade data available No grade data available
@@ -153,19 +148,13 @@
{:else} {:else}
<!-- Loading state --> <!-- Loading state -->
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div <div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800" <h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">Student Progress Distribution</h3>
>
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">
Student Progress Distribution
</h3>
<div class="flex h-[300px] items-center justify-center"> <div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">Loading chart...</div> <div class="animate-pulse text-gray-500 dark:text-gray-400">Loading chart...</div>
</div> </div>
</div> </div>
<div <div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
>
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">Grade Distribution</h3> <h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">Grade Distribution</h3>
<div class="flex h-[300px] items-center justify-center"> <div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">Loading chart...</div> <div class="animate-pulse text-gray-500 dark:text-gray-400">Loading chart...</div>
@@ -8,9 +8,7 @@
<div class="mb-12"> <div class="mb-12">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{#each Array(4) as _} {#each Array(4) as _}
<div <div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<SkeletonPlaceholder style="width: 48px; height: 48px;" /> <SkeletonPlaceholder style="width: 48px; height: 48px;" />
<div class="flex-1"> <div class="flex-1">
@@ -26,9 +24,7 @@
<!-- Charts Section Skeleton --> <!-- Charts Section Skeleton -->
<div class="mb-12"> <div class="mb-12">
<div <div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
>
<SkeletonPlaceholder style="width: 200px; height: 24px; margin-bottom: 24px;" /> <SkeletonPlaceholder style="width: 200px; height: 24px; margin-bottom: 24px;" />
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SkeletonPlaceholder style="width: 100%; height: 300px;" /> <SkeletonPlaceholder style="width: 100%; height: 300px;" />
@@ -38,9 +34,7 @@
</div> </div>
<!-- Students Table Skeleton --> <!-- Students Table Skeleton -->
<div <div class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800">
class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800"
>
<div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700"> <div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700">
<SkeletonPlaceholder style="width: 200px; height: 24px;" /> <SkeletonPlaceholder style="width: 200px; height: 24px;" />
</div> </div>
@@ -1,17 +1,26 @@
<script lang="ts"> <script lang="ts">
import { ChartBar } from 'carbon-icons-svelte'; import ChartBarIcon from '@lucide/svelte/icons/chart-bar';
export let title: string = 'No data available'; interface Props {
export let description: string = 'There is no data to display at this time.'; title?: string;
export let icon: any = ChartBar; description?: string;
export let className: string = ''; icon?: any;
className?: string;
}
let {
title = 'No data available',
description = 'There is no data to display at this time.',
icon = ChartBarIcon,
className = ''
}: Props = $props();
const SvelteComponent = $derived(icon);
</script> </script>
<div class="flex flex-col items-center justify-center px-4 py-12 {className}"> <div class="flex flex-col items-center justify-center px-4 py-12 {className}">
<div <div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800">
class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800" <SvelteComponent size="24" class="text-gray-400 dark:text-gray-500" />
>
<svelte:component this={icon} size="24" class="text-gray-400 dark:text-gray-500" />
</div> </div>
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white"> <h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">
{title} {title}
@@ -3,27 +3,32 @@
import { Tag, Pagination } from 'carbon-components-svelte'; import { Tag, Pagination } from 'carbon-components-svelte';
import type { StudentOverview } from '$lib/utils/types/analytics'; import type { StudentOverview } from '$lib/utils/types/analytics';
import EmptyState from './EmptyState.svelte'; import EmptyState from './EmptyState.svelte';
import { User } from 'carbon-icons-svelte'; import UserIcon from '@lucide/svelte/icons/user';
import { course } from '../../store'; import { course } from '../../store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
export let students: StudentOverview[] = []; interface Props {
students?: StudentOverview[];
}
let { students = [] }: Props = $props();
// Pagination state // Pagination state
let currentPage = 1; let currentPage = $state(1);
const pageSize = 15; const pageSize = 15;
// Computed values // Computed values
$: totalPages = Math.ceil(students.length / pageSize); let totalPages = $derived(Math.ceil(students.length / pageSize));
$: startIndex = (currentPage - 1) * pageSize; let startIndex = $derived((currentPage - 1) * pageSize);
$: endIndex = startIndex + pageSize; let endIndex = $derived(startIndex + pageSize);
$: paginatedStudents = students.slice(startIndex, endIndex); let paginatedStudents = $derived(students.slice(startIndex, endIndex));
$: startItem = startIndex + 1; let startItem = $derived(startIndex + 1);
$: endItem = Math.min(endIndex, students.length); let endItem = $derived(Math.min(endIndex, students.length));
const GotoFullProfile = (student: StudentOverview) => { const GotoFullProfile = (student: StudentOverview) => {
if (!student) return; if (!student) return;
goto(`/courses/${$course.id}/people/${student.id}?back=/courses/${$course.id}/analytics`); goto(`/courses/${$course.id}/people/${student.id}?back=/courses/${$course.id}/analytics`);
}; };
@@ -32,16 +37,18 @@
} }
// Reset to first page when students array changes // Reset to first page when students array changes
$: if (students.length > 0 && currentPage > totalPages) { $effect(() => {
currentPage = 1; if (students.length > 0 && currentPage > totalPages) {
} currentPage = 1;
}
});
</script> </script>
{#if students.length === 0} {#if students.length === 0}
<EmptyState <EmptyState
title={$t('analytics.no_students_found')} title={$t('analytics.no_students_found')}
description={$t('analytics.no_students_description')} description={$t('analytics.no_students_description')}
icon={User} icon={UserIcon}
/> />
{:else} {:else}
<!-- Responsive Table --> <!-- Responsive Table -->
@@ -91,12 +98,7 @@
class="sticky left-0 z-10 min-w-[200px] whitespace-nowrap bg-white px-4 py-3 transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700" class="sticky left-0 z-10 min-w-[200px] whitespace-nowrap bg-white px-4 py-3 transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Avatar <Avatar src={student.profile.avatar_url} name={student.profile.fullname} width="w-8" height="h-8" />
src={student.profile.avatar_url}
name={student.profile.fullname}
width="w-8"
height="h-8"
/>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate font-medium text-gray-900 dark:text-white"> <p class="truncate font-medium text-gray-900 dark:text-white">
{student.profile.fullname} {student.profile.fullname}
@@ -115,10 +117,7 @@
</span> </span>
<div class="w-20 flex-shrink-0"> <div class="w-20 flex-shrink-0">
<div class="h-2 rounded-full bg-gray-200 dark:bg-neutral-700"> <div class="h-2 rounded-full bg-gray-200 dark:bg-neutral-700">
<div <div class="h-2 rounded-full bg-blue-500" style="width: {student.progressPercentage}%"></div>
class="h-2 rounded-full bg-blue-500"
style="width: {student.progressPercentage}%"
></div>
</div> </div>
</div> </div>
</div> </div>
@@ -130,11 +129,7 @@
</td> </td>
<td class="min-w-[120px] whitespace-nowrap px-4 py-3"> <td class="min-w-[120px] whitespace-nowrap px-4 py-3">
<Tag <Tag
type={student.averageGrade >= 80 type={student.averageGrade >= 80 ? 'green' : student.averageGrade >= 60 ? 'blue' : 'red'}
? 'green'
: student.averageGrade >= 60
? 'blue'
: 'red'}
size="sm" size="sm"
> >
{student.averageGrade}% {student.averageGrade}%
@@ -148,7 +143,7 @@
<td class="min-w-[120px] whitespace-nowrap px-4 py-3"> <td class="min-w-[120px] whitespace-nowrap px-4 py-3">
<button <button
class="whitespace-nowrap rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700" class="whitespace-nowrap rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
on:click={() => GotoFullProfile(student)} onclick={() => GotoFullProfile(student)}
> >
{$t('analytics.view_details')} {$t('analytics.view_details')}
</button> </button>
@@ -162,9 +157,7 @@
<!-- Pagination Controls --> <!-- Pagination Controls -->
{#if students.length > pageSize} {#if students.length > pageSize}
<div <div class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3 dark:border-neutral-600">
class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3 dark:border-neutral-600"
>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm text-gray-700 dark:text-gray-300">
{$t('analytics.showing_students', { {$t('analytics.showing_students', {
start: startItem, start: startItem,
@@ -172,12 +165,7 @@
total: students.length total: students.length
})} })}
</div> </div>
<Pagination <Pagination page={currentPage} totalItems={students.length} {pageSize} on:change={handlePageChange} />
page={currentPage}
totalItems={students.length}
{pageSize}
on:change={handlePageChange}
/>
</div> </div>
{/if} {/if}
{/if} {/if}
@@ -1,5 +1,9 @@
<script lang="ts"> <script lang="ts">
export let className: string = ''; interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script> </script>
<svg <svg
@@ -1,5 +1,9 @@
<script lang="ts"> <script lang="ts">
export let className: string = ''; interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script> </script>
<svg <svg
@@ -1,5 +1,9 @@
<script lang="ts"> <script lang="ts">
export let className: string = ''; interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script> </script>
<svg <svg
@@ -1,5 +1,9 @@
<script lang="ts"> <script lang="ts">
export let className: string = ''; interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script> </script>
<svg <svg
@@ -10,7 +14,5 @@
viewBox="0 0 20 20" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z" />
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z"
/>
</svg> </svg>
@@ -1,6 +1,6 @@
<script> <script>
import { RadioButtonGroup, RadioButton, Toggle } from 'carbon-components-svelte'; import { RadioButtonGroup, RadioButton, Toggle } from 'carbon-components-svelte';
import FlashFilled from 'carbon-icons-svelte/lib/FlashFilled.svelte'; import ZapIcon from '@lucide/svelte/icons/zap';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { updateCourse } from '$lib/utils/services/courses'; import { updateCourse } from '$lib/utils/services/courses';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -30,11 +30,14 @@
'blueBadgePattern' 'blueBadgePattern'
]; ];
let isSaving = false; let isSaving = $state(false);
let errors = { let errors = $state({
description: '' description: ''
}; });
let helperText = '';
const helperText = $derived(
`${$course.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
);
const saveCertificate = async () => { const saveCertificate = async () => {
isSaving = true; isSaving = true;
@@ -47,8 +50,7 @@
}); });
if (result && Object.keys(result).length > 0) { if (result && Object.keys(result).length > 0) {
errors.description = errors.description = $t(result.description) || $t('course.navItem.certificates.description_error');
$t(result.description) || $t('course.navItem.certificates.description_error');
throw new Error(errors.description); throw new Error(errors.description);
} }
@@ -70,15 +72,11 @@
isSaving = false; isSaving = false;
} }
}; };
$: helperText = `${$course.description?.length || 0}/200 ${$t(
'course.navItem.certificates.characters'
)}`;
</script> </script>
<svelte:head> <svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="true" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link <link
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap" href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
rel="stylesheet" rel="stylesheet"
@@ -96,20 +94,12 @@
<p class="my-4 text-xs font-normal dark:text-gray-100"> <p class="my-4 text-xs font-normal dark:text-gray-100">
{$t('course.navItem.certificates.theme')} {$t('course.navItem.certificates.theme')}
</p> </p>
<RadioButtonGroup <RadioButtonGroup bind:selected={$course.certificate_theme} class="mb-10" disabled={$isFreePlan}>
bind:selected={$course.certificate_theme}
class="mb-10"
disabled={$isFreePlan}
>
<div class="flex flex-wrap justify-between gap-y-5"> <div class="flex flex-wrap justify-between gap-y-5">
{#each themes as theme} {#each themes as theme}
<div class="mr-3 flex"> <div class="mr-3 flex">
<RadioButton value={theme} /> <RadioButton value={theme} />
<img <img src={`/images/certificate_theme_${theme}.png`} alt="themes" class="h-[82px] w-[110px]" />
src={`/images/certificate_theme_${theme}.png`}
alt="themes"
class="h-[82px] w-[110px]"
/>
</div> </div>
{/each} {/each}
</div> </div>
@@ -159,9 +149,7 @@
</Toggle> </Toggle>
</div> </div>
</section> </section>
<section <section class="flex w-full items-center justify-center rounded-md bg-gray-100 lg:w-3/5 dark:bg-neutral-800">
class="flex w-full items-center justify-center rounded-md bg-gray-100 lg:w-3/5 dark:bg-neutral-800"
>
<div class="certificate-container flex items-center justify-center"> <div class="certificate-container flex items-center justify-center">
{#if $course.certificate_theme === 'professional'} {#if $course.certificate_theme === 'professional'}
<Professional studentName={studentNamePlaceholder} /> <Professional studentName={studentNamePlaceholder} />
@@ -188,7 +176,7 @@
isDisabled={$isFreePlan} isDisabled={$isFreePlan}
> >
{#if $isFreePlan} {#if $isFreePlan}
<FlashFilled size={16} class="text-blue-700" /> <ZapIcon size={16} class="filled" />
{/if} {/if}
{$t('course.navItem.certificates.save')} {$t('course.navItem.certificates.save')}
</PrimaryButton> </PrimaryButton>
@@ -1,4 +1,6 @@
<script> <script>
import { preventDefault } from '$lib/utils/functions/svelte';
import { Toggle } from 'carbon-components-svelte'; import { Toggle } from 'carbon-components-svelte';
import Modal from '$lib/components/Modal/index.svelte'; import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -33,16 +35,13 @@
<span slot="labelA" style="color: #161616">Automatic</span> <span slot="labelA" style="color: #161616">Automatic</span>
<span slot="labelB" style="color: green">Automatic</span> <span slot="labelB" style="color: green">Automatic</span>
</Toggle> </Toggle>
<p class="text-sm font-medium my-4"> <p class="my-4 text-sm font-medium">
If you set this as automatic, certificates will be issued after the If you set this as automatic, certificates will be issued after the learner completes the course.
learner completes the course.
</p> </p>
</div> </div>
<p class="text-xs font-normal text-gray-500 my-4"> <p class="my-4 text-xs font-normal text-gray-500">or send a personalised/ custom certificate below:</p>
or send a personalised/ custom certificate below: <form onsubmit={preventDefault(issueCertificate)}>
</p> <div class="flex w-full flex-col gap-2 md:flex-row">
<form on:submit|preventDefault={issueCertificate}>
<div class="flex flex-col md:flex-row gap-2 w-full">
<TextField <TextField
label="Email address of the student" label="Email address of the student"
className="w-full my-4" className="w-full my-4"
@@ -65,18 +64,13 @@
label="Add a personalized message" label="Add a personalized message"
labelClassName="text-xs font-normal" labelClassName="text-xs font-normal"
bind:value={$issueCertificateModal.message} bind:value={$issueCertificateModal.message}
rows="2" rows={2}
maxRows="3"
placeholder="your message here" placeholder="your message here"
className="mb-4" className="mb-4"
/> />
<div class="mt-5 flex w-full items-end justify-end"> <div class="mt-5 flex w-full items-end justify-end">
<PrimaryButton <PrimaryButton className="px-6 py-3 rounded-md text-sm font-medium" label="Issue certificate" type="submit" />
className="px-6 py-3 rounded-md text-sm font-medium"
label="Issue certificate"
type="submit"
/>
</div> </div>
</form> </form>
</main> </main>
@@ -11,15 +11,13 @@
Pagination Pagination
} from 'carbon-components-svelte'; } from 'carbon-components-svelte';
let searchValue = ''; let searchValue = $state('');
let checked = false; let checked = $state(false);
</script> </script>
<section class="flex flex-wrap items-start gap-2 mb-10 -ml-4 -mr-4"> <section class="-ml-4 -mr-4 mb-10 flex flex-wrap items-start gap-2">
{#each CertificateReportData as data (data.id)} {#each CertificateReportData as data (data.id)}
<div <div class="box mb-5 flex flex-col justify-center rounded border border-gray-200 px-2 dark:border-neutral-600">
class="box flex flex-col rounded border border-gray-200 dark:border-neutral-600 justify-center px-2 mb-5"
>
<p class="text-xs font-normal">{data.title}</p> <p class="text-xs font-normal">{data.title}</p>
<bold class="text-2xl font-medium">{data.value}</bold> <bold class="text-2xl font-medium">{data.value}</bold>
</div> </div>
@@ -28,12 +26,8 @@
<section> <section>
<div class="flex flex-row items-center justify-between"> <div class="flex flex-row items-center justify-between">
<p class="text-base font-semibold w-full">Certificate issued</p> <p class="w-full text-base font-semibold">Certificate issued</p>
<Search <Search placeholder="search students" bind:value={searchValue} searchClass="border-b border-transparent" />
placeholder="search students"
bind:value={searchValue}
searchClass="border-b border-transparent"
/>
</div> </div>
</section> </section>
@@ -42,16 +36,14 @@
<StructuredListHead class="bg-primary-50 py-0"> <StructuredListHead class="bg-primary-50 py-0">
<StructuredListRow head class="py-0"> <StructuredListRow head class="py-0">
<StructuredListCell head class="py-0"> <StructuredListCell head class="py-0">
<div <div class="flex-3 text-primary-600 flex w-full flex-row items-center justify-start text-sm font-medium">
class="flex-3 flex flex-row items-center justify-start w-full text-primary-600 text-sm font-medium"
>
<Checkbox bind:checked /> <Checkbox bind:checked />
<p class="text-primary-900 text-start w-full text-sm font-medium">Name of student</p> <p class="text-primary-900 w-full text-start text-sm font-medium">Name of student</p>
</div> </div>
</StructuredListCell> </StructuredListCell>
<StructuredListCell head class="py-0"> <StructuredListCell head class="py-0">
<div class="flex-2 items-center w-full text-primary-900 text-sm font-medium"> <div class="flex-2 text-primary-900 w-full items-center text-sm font-medium">
<p class="align-middle text-sm font-medium pt-1">Date issued</p> <p class="pt-1 align-middle text-sm font-medium">Date issued</p>
<p class="opacity-0">personalized</p> <p class="opacity-0">personalized</p>
</div> </div>
</StructuredListCell> </StructuredListCell>
@@ -61,19 +53,17 @@
{#each students as item (item.id)} {#each students as item (item.id)}
<StructuredListRow label for="row-{item}"> <StructuredListRow label for="row-{item}">
<StructuredListCell> <StructuredListCell>
<div <div class="flex-3 flex w-full flex-row items-center justify-start pt-4 text-sm font-medium">
class="flex-3 flex flex-row items-center justify-start w-full text-sm font-medium pt-4"
>
<Checkbox /> <Checkbox />
<p class="text-start w-full text-sm font-normal">{item.name}</p> <p class="w-full text-start text-sm font-normal">{item.name}</p>
</div> </div>
</StructuredListCell> </StructuredListCell>
<StructuredListCell> <StructuredListCell>
<div class="flex-2 flex flex-row items-center gap-10 w-full"> <div class="flex-2 flex w-full flex-row items-center gap-10">
<p class="text-sm font-normal align-middle">{item.issued}</p> <p class="align-middle text-sm font-normal">{item.issued}</p>
{#if item.peronalized == true} {#if item.peronalized == true}
<span class="text-center bg-primary-100 px-3 py-2 rounded-lg"> <span class="bg-primary-100 rounded-lg px-3 py-2 text-center">
<p class="opacity-100 text-xs text-primary-600">personalized</p> <p class="text-primary-600 text-xs opacity-100">personalized</p>
</span> </span>
{:else} {:else}
<p class="opacity-0">personalized</p> <p class="opacity-0">personalized</p>
@@ -4,12 +4,12 @@
</script> </script>
<Box> <Box>
<div class="flex flex-col items-center justify-center w-max h-full gap-2"> <div class="flex h-full w-max flex-col items-center justify-center gap-2">
<img src="/images/student-certificate-preview.png" alt="Certificate" class="max-w-[218px]" /> <img src="/images/student-certificate-preview.png" alt="Certificate" class="max-w-[218px]" />
<p class="text-xl font-normal text-center"> <p class="text-center text-xl font-normal">
{$t('course.navItem.certificates.locked_certificate')} {$t('course.navItem.certificates.locked_certificate')}
</p> </p>
<p class="text-sm font-normal text-center"> <p class="text-center text-sm font-normal">
{$t('course.navItem.certificates.instructor_unlock')} <br /> {$t('course.navItem.certificates.instructor_unlock')} <br />
{$t('course.navItem.certificates.contact_instructor')} {$t('course.navItem.certificates.contact_instructor')}
</p> </p>
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Download from 'carbon-icons-svelte/lib/Download.svelte'; import DownloadIcon from '@lucide/svelte/icons/download';
import { env } from '$env/dynamic/public';
import { course } from '$lib/components/Course/store'; import { course } from '$lib/components/Course/store';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org'; import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
@@ -15,8 +14,8 @@
import { snackbar } from '$lib/components/Snackbar/store'; import { snackbar } from '$lib/components/Snackbar/store';
import { classroomio } from '$lib/utils/services/api'; import { classroomio } from '$lib/utils/services/api';
let isLoading = false; let isLoading = $state(false);
let isCourseComplete = false; let isCourseComplete = $state(false);
let progress: ProfileCourseProgress | undefined; let progress: ProfileCourseProgress | undefined;
const downLoadCertificate = async () => { const downLoadCertificate = async () => {
@@ -73,12 +72,16 @@
hasUserCompletedCourse(); hasUserCompletedCourse();
}); });
$: title = isCourseComplete let title = $derived(
? 'course.navItem.certificates.unlocked_certificate' isCourseComplete
: 'course.navItem.certificates.complete_to_download_title'; ? 'course.navItem.certificates.unlocked_certificate'
$: subtitle = isCourseComplete : 'course.navItem.certificates.complete_to_download_title'
? 'course.navItem.certificates.unlocked_certificate_subtitle' );
: 'course.navItem.certificates.complete_to_download_subtitle'; let subtitle = $derived(
isCourseComplete
? 'course.navItem.certificates.unlocked_certificate_subtitle'
: 'course.navItem.certificates.complete_to_download_subtitle'
);
</script> </script>
<Box> <Box>
@@ -94,10 +97,10 @@
className="flex items-center gap-2" className="flex items-center gap-2"
onClick={downLoadCertificate} onClick={downLoadCertificate}
variant={VARIANTS.CONTAINED_DARK} variant={VARIANTS.CONTAINED_DARK}
isDisabled={!env.PUBLIC_SERVER_URL || !isCourseComplete} isDisabled={!isCourseComplete}
{isLoading} {isLoading}
> >
<Download size={16} /> <DownloadIcon size={16} />
{$t('course.navItem.certificates.download_certificate')} {$t('course.navItem.certificates.download_certificate')}
</PrimaryButton> </PrimaryButton>
</div> </div>
@@ -1,60 +1,56 @@
<script> <script lang="ts">
import { course } from '$lib/components/Course/store'; import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
export let studentName = ''; interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png'; const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString(); const dateIssued = new Date().toLocaleDateString();
</script> </script>
<div class="w-full relative dark:text-black"> <div class="relative w-full dark:text-black">
<img <img src="/images/blue-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
src="/images/blue-badge-pattern-background.svg" <div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-[17rem]">
alt=""
class="w-full h-full absolute z-0 object-cover"
/>
<div class="relative md:h-[17rem] h-full bg-white flex justify-between z-20 m-2">
<div class="w-[70%] pl-3"> <div class="w-[70%] pl-3">
<header class="absolute w-full flex justify-center items-center gap-1 text-sm font-bold"> <header class="absolute flex w-full items-center justify-center gap-1 text-sm font-bold">
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" /> <img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1> <h1 class="text-sm">{$currentOrg.name}</h1>
</header> </header>
<div class="font-bold mt-16"> <div class="mt-16 font-bold">
<h2 class="text-[7px] m-0">This certificate is awarded to</h2> <h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#2D499F]">{studentName}</h1> <h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
</div> </div>
<div class="font-bold mt-3 w-[90%]"> <div class="mt-3 w-[90%] font-bold">
<h1 class="text-[7px]">has successfully completed training on</h1> <h1 class="text-[7px]">has successfully completed training on</h1>
<h2 class="text-lg m-0"> <h2 class="m-0 text-lg">
{$course.title} {$course.title}
</h2> </h2>
</div> </div>
<div class="flex justify-between items-center my-8 text-center"> <div class="my-8 flex items-center justify-between text-center">
<div> <div>
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1> <h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p> <p class="border-b border-black text-[7px] font-bold">{dateIssued}</p>
</div> </div>
<div class="w-[20%] text-[7px]"> <div class="w-[20%] text-[7px]">
<div></div> <div></div>
<p class="border-t border-black font-bold text-[7px]">Signature</p> <p class="border-t border-black text-[7px] font-bold">Signature</p>
</div> </div>
</div> </div>
<footer class="text-[8px] font-bold w-[90%] desc"> <footer class="desc w-[90%] text-[8px] font-bold">
{$course.description} {$course.description}
</footer> </footer>
</div> </div>
<div class="overflow-hidden flex justify-center items-center"> <div class="flex items-center justify-center overflow-hidden">
<img <img src="/images/blue-badge-pattern-badge.svg" alt="A badge" class="w-[8rem] max-w-none md:w-[10rem]" />
src="/images/blue-badge-pattern-badge.svg"
alt="A badge"
class="max-w-none w-[8rem] md:w-[10rem]"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -1,45 +1,49 @@
<script> <script lang="ts">
import { course } from '$lib/components/Course/store'; import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
export let studentName = ''; interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png'; const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString(); const dateIssued = new Date().toLocaleDateString();
</script> </script>
<div class="p-3 w-full dark:text-black bg-[#2D499F]"> <div class="w-full bg-[#2D499F] p-3 dark:text-black">
<div class="w-full bg-white pb-5 flex"> <div class="flex w-full bg-white pb-5">
<div class="w-[80%] pl-3"> <div class="w-[80%] pl-3">
<header class="flex justify-start items-center gap-1 text-sm font-bold"> <header class="flex items-center justify-start gap-1 text-sm font-bold">
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" /> <img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1> <h1 class="text-sm">{$currentOrg.name}</h1>
</header> </header>
<div class="font-bold mt-7"> <div class="mt-7 font-bold">
<h2 class="text-[7px] m-0">This certificate is awarded to</h2> <h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#2D499F]">{studentName}</h1> <h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
</div> </div>
<div class="font-bold mt-3 w-[90%]"> <div class="mt-3 w-[90%] font-bold">
<h1 class="text-[7px]">has succesfully completed training on</h1> <h1 class="text-[7px]">has succesfully completed training on</h1>
<h2 class="text-lg m-0"> <h2 class="m-0 text-lg">
{$course.title} {$course.title}
</h2> </h2>
</div> </div>
<div class="flex justify-between items-center mt-8 mb-5 text-center"> <div class="mb-5 mt-8 flex items-center justify-between text-center">
<div class=""> <div class="">
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1> <h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p> <p class="border-b border-black text-[7px] font-bold">{dateIssued}</p>
</div> </div>
<div class="w-[20%] text-[7px]"> <div class="w-[20%] text-[7px]">
<div class=""></div> <div class=""></div>
<p class="border-t border-black font-bold text-[7px]">Signature</p> <p class="border-t border-black text-[7px] font-bold">Signature</p>
</div> </div>
</div> </div>
<footer class="text-[8px] font-bold w-[90%] desc"> <footer class="desc w-[90%] text-[8px] font-bold">
{$course.description} {$course.description}
</footer> </footer>
</div> </div>
@@ -1,36 +1,34 @@
<script> <script lang="ts">
import { course } from '$lib/components/Course/store'; import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
export let studentName = ''; interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const borderwidth = 'border-4'; const borderwidth = 'border-4';
const bordercolor = 'border-pink-700'; const bordercolor = 'border-pink-700';
const logo = '/logo-512.png'; const logo = '/logo-512.png';
</script> </script>
<div <div class="certificate-bg flex w-full flex-col items-center justify-center {borderwidth} {bordercolor} py-3">
class="certificate-bg w-full flex flex-col items-center justify-center {borderwidth} {bordercolor} py-3" <div class="my-2 flex items-center gap-1">
> <img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class=" h-10 w-10 rounded-md" />
<div class="flex items-center gap-1 my-2">
<img
src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo}
alt="logo"
class=" w-10 h-10 rounded-md"
/>
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p> <p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
</div> </div>
<div class="w-full px-5 py-3"> <div class="w-full px-5 py-3">
<p class="text-sm text-center font-normal my-2 uppercase tracking-widest text-gray-500 w-full"> <p class="my-2 w-full text-center text-sm font-normal uppercase tracking-widest text-gray-500">
Certificate of completion Certificate of completion
</p> </p>
<div class="border-b border-gray-500 mb-4"> <div class="mb-4 border-b border-gray-500">
<p class="text-xs text-black font-medium">This is to certify that</p> <p class="text-xs font-medium text-black">This is to certify that</p>
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p> <p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<p class="text-xs text-black font-medium">has successfully completed the course</p> <p class="text-xs font-medium text-black">has successfully completed the course</p>
<div> <div>
<p class="clamp bg-transparent text-base font-semibold text-pink-700 text-center uppercase"> <p class="clamp bg-transparent text-center text-base font-semibold uppercase text-pink-700">
{$course.title} {$course.title}
</p> </p>
</div> </div>
@@ -1,44 +1,38 @@
<script> <script lang="ts">
import { course } from '$lib/components/Course/store'; import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
export let studentName = ''; interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const borderwidth = 'border-l-8'; const borderwidth = 'border-l-8';
const bordercolor = 'border-primary-800'; const bordercolor = 'border-primary-800';
const logo = '/logo-512.png'; const logo = '/logo-512.png';
</script> </script>
<div <div class="certificate-bg border-primary-400 flex w-full items-center justify-center border border-dashed">
class="certificate-bg w-full flex items-center justify-center border border-dashed border-primary-400"
>
<div class="{borderwidth} {bordercolor} w-full"> <div class="{borderwidth} {bordercolor} w-full">
<div class="w-full px-5 py-3"> <div class="w-full px-5 py-3">
<p class="text-sm font-normal my-2 uppercase text-gray-500"> <p class="my-2 text-sm font-normal uppercase text-gray-500">
Certificate<br /> of completion Certificate<br /> of completion
</p> </p>
<div class="border-b border-gray-500 mb-4"> <div class="mb-4 border-b border-gray-500">
<p class="text-xs text-black font-medium">This is to certify that</p> <p class="text-xs font-medium text-black">This is to certify that</p>
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p> <p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<p class="text-xs text-black font-medium"> <p class="text-xs font-medium text-black">has successfully completed introductory courses in</p>
has successfully completed introductory courses in
</p>
<div> <div>
<p <p class="clamp text-primary-800 my-2 bg-transparent text-center text-base font-semibold uppercase">
class="clamp bg-transparent text-base font-semibold text-primary-800 text-center uppercase my-2"
>
{$course.title} {$course.title}
</p> </p>
</div> </div>
</div> </div>
<p class="desc mb-4 text-justify dark:text-black">{$course.description}</p> <p class="desc mb-4 text-justify dark:text-black">{$course.description}</p>
<div class="flex items-center gap-1 my-2"> <div class="my-2 flex items-center gap-1">
<img <img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="h-10 w-10 rounded-md" />
src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo}
alt="logo"
class="w-10 h-10 rounded-md"
/>
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p> <p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
</div> </div>
</div> </div>
@@ -1,60 +1,56 @@
<script> <script lang="ts">
import { course } from '$lib/components/Course/store'; import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
export let studentName = ''; interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png'; const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString(); const dateIssued = new Date().toLocaleDateString();
</script> </script>
<div class="w-full relative dark:text-black"> <div class="relative w-full dark:text-black">
<img <img src="/images/purple-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
src="/images/purple-badge-pattern-background.svg" <div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-[17rem]">
alt=""
class="w-full h-full absolute z-0 object-cover"
/>
<div class="relative md:h-[17rem] h-full bg-white flex justify-between z-20 m-2">
<div class="w-[70%] pl-3"> <div class="w-[70%] pl-3">
<header class="absolute w-full flex justify-center items-center gap-1 text-sm font-bold"> <header class="absolute flex w-full items-center justify-center gap-1 text-sm font-bold">
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" /> <img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1> <h1 class="text-sm">{$currentOrg.name}</h1>
</header> </header>
<div class="font-bold mt-16"> <div class="mt-16 font-bold">
<h2 class="text-[7px] m-0">This certificate is awarded to</h2> <h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#914397]">{studentName}</h1> <h1 class="m-0 mt-0.5 text-lg leading-3 text-[#914397]">{studentName}</h1>
</div> </div>
<div class="font-bold mt-3 w-[90%]"> <div class="mt-3 w-[90%] font-bold">
<h1 class="text-[7px]">has successfully completed training on</h1> <h1 class="text-[7px]">has successfully completed training on</h1>
<h2 class="text-lg m-0"> <h2 class="m-0 text-lg">
{$course.title} {$course.title}
</h2> </h2>
</div> </div>
<div class="flex justify-between items-center my-8 text-center"> <div class="my-8 flex items-center justify-between text-center">
<div> <div>
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1> <h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p> <p class="border-b border-black text-[7px] font-bold">{dateIssued}</p>
</div> </div>
<div class="w-[20%] text-[7px]"> <div class="w-[20%] text-[7px]">
<div></div> <div></div>
<p class="border-t border-black font-bold text-[7px]">Signature</p> <p class="border-t border-black text-[7px] font-bold">Signature</p>
</div> </div>
</div> </div>
<footer class="text-[8px] font-bold w-[90%] desc"> <footer class="desc w-[90%] text-[8px] font-bold">
{$course.description} {$course.description}
</footer> </footer>
</div> </div>
<div class="overflow-hidden flex justify-center items-center"> <div class="flex items-center justify-center overflow-hidden">
<img <img src="/images/purple-badge-pattern-badge.svg" alt="A badge" class="w-[8rem] max-w-none md:w-[10rem]" />
src="/images/purple-badge-pattern-badge.svg"
alt="A badge"
class="max-w-none w-[8rem] md:w-[10rem]"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -1,45 +1,49 @@
<script> <script lang="ts">
import { course } from '$lib/components/Course/store'; import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
export let studentName = ''; interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png'; const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString(); const dateIssued = new Date().toLocaleDateString();
</script> </script>
<div class="p-3 w-full dark:text-black bg-[#95449A]"> <div class="w-full bg-[#95449A] p-3 dark:text-black">
<div class="w-full bg-white pb-5 flex"> <div class="flex w-full bg-white pb-5">
<div class="w-[80%] pl-3"> <div class="w-[80%] pl-3">
<header class="flex justify-start items-center gap-1 text-sm font-bold"> <header class="flex items-center justify-start gap-1 text-sm font-bold">
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" /> <img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1> <h1 class="text-sm">{$currentOrg.name}</h1>
</header> </header>
<div class="font-bold mt-7"> <div class="mt-7 font-bold">
<h2 class="text-[7px] m-0">This certificate is awarded to</h2> <h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#95449A]">{studentName}</h1> <h1 class="m-0 mt-0.5 text-lg leading-3 text-[#95449A]">{studentName}</h1>
</div> </div>
<div class="font-bold mt-3 w-[90%]"> <div class="mt-3 w-[90%] font-bold">
<h1 class="text-[7px]">has succesfully completed training on</h1> <h1 class="text-[7px]">has succesfully completed training on</h1>
<h2 class="text-lg m-0"> <h2 class="m-0 text-lg">
{$course.title} {$course.title}
</h2> </h2>
</div> </div>
<div class="flex justify-between items-center mt-8 mb-5 text-center"> <div class="mb-5 mt-8 flex items-center justify-between text-center">
<div class=""> <div class="">
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1> <h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p> <p class="border-b border-black text-[7px] font-bold">{dateIssued}</p>
</div> </div>
<div class="w-[20%] text-[7px]"> <div class="w-[20%] text-[7px]">
<div class=""></div> <div class=""></div>
<p class="border-t border-black font-bold text-[7px]">Signature</p> <p class="border-t border-black text-[7px] font-bold">Signature</p>
</div> </div>
</div> </div>
<footer class="text-[8px] font-bold w-[90%] desc"> <footer class="desc w-[90%] text-[8px] font-bold">
{$course.description} {$course.description}
</footer> </footer>
</div> </div>
@@ -7,9 +7,13 @@
import { course } from '../../store'; import { course } from '../../store';
import { snackbar } from '$lib/components/Snackbar/store'; import { snackbar } from '$lib/components/Snackbar/store';
export let open = false; interface Props {
open?: boolean;
}
let isActivating = false; let { open = $bindable(false) }: Props = $props();
let isActivating = $state(false);
const activate = async () => { const activate = async () => {
isActivating = true; isActivating = true;
@@ -37,12 +41,12 @@
maxWidth="max-w-xl" maxWidth="max-w-xl"
modalHeading={$t(`course.navItem.lessons.section_prompt.header`)} modalHeading={$t(`course.navItem.lessons.section_prompt.header`)}
> >
<div class="flex flex-col w-full items-center"> <div class="flex w-full flex-col items-center">
<div class="mb-8"> <div class="mb-8">
<h3 class="text-center text-2xl"> <h3 class="text-center text-2xl">
{$t('course.navItem.lessons.section_prompt.title')} {$t('course.navItem.lessons.section_prompt.title')}
</h3> </h3>
<p class="text-center max-w-md"> <p class="max-w-md text-center">
{$t('course.navItem.lessons.section_prompt.description')} {$t('course.navItem.lessons.section_prompt.description')}
</p> </p>
</div> </div>
@@ -4,8 +4,12 @@
import { VARIANTS } from '$lib/components/PrimaryButton/constants'; import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
export let openDeleteModal = false; interface Props {
export let deleteLesson = () => {}; openDeleteModal?: boolean;
deleteLesson?: any;
}
let { openDeleteModal = $bindable(false), deleteLesson = () => {} }: Props = $props();
async function handleDelete() { async function handleDelete() {
deleteLesson(); deleteLesson();
@@ -21,7 +25,7 @@
modalHeading={$t('delete_modal.label')} modalHeading={$t('delete_modal.label')}
> >
<div> <div>
<h1 class="dark:text-white text-lg"> <h1 class="text-lg dark:text-white">
{$t('delete_modal.content')}? {$t('delete_modal.content')}?
</h1> </h1>
@@ -1,4 +1,4 @@
<script> <script lang="ts">
import Modal from '$lib/components/Modal/index.svelte'; import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
@@ -6,8 +6,7 @@
import { VARIANTS } from '$lib/components/PrimaryButton/constants'; import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { deleteConfirmation } from '../store/exercise'; import { deleteConfirmation } from '../store/exercise';
export let onDelete = () => {}; let { onDelete = () => {}, onCancel = () => {} } = $props();
export let onCancel = () => {};
</script> </script>
<Modal <Modal
@@ -1,4 +1,6 @@
<script> <script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import { import {
questionnaire, questionnaire,
deleteConfirmation, deleteConfirmation,
@@ -12,17 +14,16 @@
} from '../store/exercise'; } from '../store/exercise';
import { Select, SelectItem } from 'carbon-components-svelte'; import { Select, SelectItem } from 'carbon-components-svelte';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte'; import TrashIcon from '@lucide/svelte/icons/trash';
import AddFilledIcon from 'carbon-icons-svelte/lib/AddFilled.svelte'; import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte'; import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
import TextField from '$lib/components/Form/TextField.svelte'; import TextField from '$lib/components/Form/TextField.svelte';
import Modal from '$lib/components/Modal/index.svelte'; import Modal from '$lib/components/Modal/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte'; import TextArea from '$lib/components/Form/TextArea.svelte';
import Checkbox from '$lib/components/Form/Checkbox.svelte'; import Checkbox from '$lib/components/Form/Checkbox.svelte';
import RadioItem from '$lib/components/Form/RadioItem.svelte'; import RadioItem from '$lib/components/Form/RadioItem.svelte';
import IconButton from '$lib/components/IconButton/index.svelte'; import { IconButton } from '$lib/components/IconButton';
import ErrorMessage from '$lib/components/ErrorMessage/index.svelte'; import ErrorMessage from '$lib/components/ErrorMessage/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants'; import { VARIANTS } from '$lib/components/PrimaryButton/constants';
@@ -30,21 +31,23 @@
import DeleteConfirmationModal from './DeleteConfirmation.svelte'; import DeleteConfirmationModal from './DeleteConfirmation.svelte';
import OrderModal from './OrderModal.svelte'; import OrderModal from './OrderModal.svelte';
import { QUESTION_TYPE, QUESTION_TYPES } from '$lib/components/Question/constants'; import { QUESTION_TYPE, QUESTION_TYPES } from '$lib/components/Question/constants';
import { filterOutDeleted } from './functions';
import { deleteExercise } from '$lib/utils/services/courses'; import { deleteExercise } from '$lib/utils/services/courses';
import { lesson } from '../store/lessons'; import { lesson } from '../store/lessons';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
const initialQuestionsLength = $questionnaire.questions.length; const initialQuestionsLength = $questionnaire.questions.length;
export let shouldDeleteExercise = false; interface Props {
export let exerciseId; shouldDeleteExercise?: boolean;
export let goBack = () => {}; exerciseId: any;
goBack?: any;
}
let errors = {}; let { shouldDeleteExercise = $bindable(false), exerciseId, goBack = () => {} }: Props = $props();
let questionIdToDelete = null;
let questions = []; let questionIdToDelete = $state(null);
let isDeleting = false; let isDeleting = $state(false);
let errors = $derived($questionnaireValidation);
function shouldScrollToLast(questionId, questions) { function shouldScrollToLast(questionId, questions) {
const [lastQuestion] = questions.slice(-1); const [lastQuestion] = questions.slice(-1);
@@ -70,25 +73,21 @@
} }
async function handleDelete() { async function handleDelete() {
console.log('delete');
isDeleting = true; isDeleting = true;
await deleteExercise(questions, exerciseId);
const qToDelete = $questionnaire.questions.map((q) => ({ id: `${q.id}` }));
await deleteExercise(qToDelete, exerciseId);
lesson.update((_lesson) => ({ lesson.update((_lesson) => ({
..._lesson, ..._lesson,
exercises: _lesson.exercises.filter((exercise) => exercise.id !== exerciseId) exercises: _lesson.exercises.filter((exercise) => exercise.id !== exerciseId)
})); }));
goBack(); goBack();
} }
$: errors = $questionnaireValidation;
$: questions = filterOutDeleted($questionnaire.questions);
</script> </script>
<DeleteConfirmationModal <DeleteConfirmationModal onCancel={() => (questionIdToDelete = null)} onDelete={onFinalDeleteClicked} />
onCancel={() => (questionIdToDelete = null)}
onDelete={onFinalDeleteClicked}
/>
<OrderModal /> <OrderModal />
@@ -98,8 +97,8 @@
width="w-2/4" width="w-2/4"
modalHeading={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')} modalHeading={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}
> >
<form on:submit|preventDefault> <form onsubmit={preventDefault()}>
<h1 class="dark:text-white text-xl"> <h1 class="text-xl dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')} {$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
</h1> </h1>
@@ -124,157 +123,137 @@
</form> </form>
</Modal> </Modal>
<div class="w-full mb-20"> {#snippet optionActions(question, option)}
<div data-name="option-action" class="ml-2 flex items-center gap-2">
<IconButton value={`${option.id}`} onClick={handleRemoveOption(question.id, option.id)} size="small" bordered>
<TrashIcon size={18} />
</IconButton>
<IconButton
value={`${option.id}`}
onClick={handleAnswerSelect(question.id, option.id)}
buttonClassName={option.is_correct ? 'success' : ''}
size="small"
bordered
>
<CircleCheckIcon size={18} filled={option.is_correct} />
</IconButton>
</div>
{/snippet}
<div class="mb-20 w-full">
{#if Object.values(errors).length} {#if Object.values(errors).length}
<div class="w-full flex justify-center mb-4"> <div class="mb-4 flex w-full justify-center">
<ErrorMessage <ErrorMessage message={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.error')} />
message={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.error')}
/>
</div> </div>
{/if} {/if}
<div class="questions pt-6 px-6"> <div class="questions px-6 pt-6">
{#each questions as question (question.id)} {#each $questionnaire.questions as question, index}
<!-- {#key question.id} --> {#if !question.deleted_at}
<QuestionContainer <QuestionContainer
onClose={onInitDeleteClicked(question.id)} key={question.deleted_at}
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)} onClose={onInitDeleteClicked(question.id)}
bind:points={question.points} scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
hasError={!!errors[question.id]} bind:points={question.points}
onPointsChange={() => { hasError={!!errors[question.id]}
question.is_dirty = true; onPointsChange={() => {
}} question.is_dirty = true;
> }}
<div class="flex justify-between items-center"> >
<div class="mr-5 w-3/5"> <div class="flex items-center justify-between">
<TextField <div class="mr-5 w-3/5">
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.question')} <TextField
bind:value={question.title} placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.question')}
isRequired={true} bind:value={question.title}
onChange={() => { isRequired={true}
onChange={() => {
question.is_dirty = true;
}}
/>
</div>
<Select
size="xl"
class="w-[50px]"
selected={question?.question_type?.id}
on:change={(e) => {
const id = (e.target as HTMLSelectElement)?.value;
if (!id) return;
question.question_type = QUESTION_TYPES.find((q) => q.id === parseInt(id)) ?? QUESTION_TYPES[0];
question.question_type_id = question.question_type.id;
question.is_dirty = true; question.is_dirty = true;
}} }}
/>
</div>
<Select
size="xl"
class="w-[50px]"
bind:selected={question.question_type.id}
on:change={(e) => {
const id = parseInt(e.target?.value);
question.question_type = QUESTION_TYPES.find((q) => q.id === id);
question.is_dirty = true;
}}
>
{#each QUESTION_TYPES as type}
<SelectItem value={type.id} text={$t(type.label)} />
{/each}
</Select>
</div>
{#if typeof question.code === 'string'}
<div class="flex justify-between items-center my-3 w-3/5">
<TextArea
bind:value={question.code}
rows={2}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.write')}
/>
<IconButton value="write-code" onClick={() => handleCode(question.id, false)}>
<TrashCanIcon size={24} class="carbon-icon dark:text-white" />
</IconButton>
</div>
{/if}
<div class="flex flex-col mt-2">
{#if QUESTION_TYPE.RADIO === question.question_type.id}
{#each filterOutDeleted(question.options) as option}
<RadioItem
isEditable={true}
name={question.title || 'radio-name'}
bind:label={option.label}
onChange={addDynamicValue(question.id, option.id)}
>
<div slot="iconbutton" class="flex items-center">
<IconButton
value={option.id}
onClick={handleRemoveOption(question.id, option.id)}
>
<TrashCanIcon size={24} class="carbon-icon dark:text-white" />
</IconButton>
<IconButton
value={option.id}
onClick={handleAnswerSelect(question.id, option.id)}
buttonClassName={option.is_correct && 'success'}
>
{#if option.is_correct}
<CheckmarkFilledIcon size={24} class="carbon-icon dark:text-white" />
{:else}
<CheckmarkOutlineIcon size={24} class="carbon-icon dark:text-white" />
{/if}
</IconButton>
</div>
</RadioItem>
{/each}
{/if}
{#if QUESTION_TYPE.CHECKBOX === question.question_type.id}
{#each filterOutDeleted(question.options) as option}
<Checkbox
isEditable={true}
name={question || 'checkbox-name'}
bind:label={option.label}
onChange={addDynamicValue(question.id, option.id)}
>
<div slot="iconbutton" class="flex items-center">
<IconButton
value={option.id}
onClick={handleRemoveOption(question.id, option.id)}
>
<TrashCanIcon size={24} class="carbon-icon dark:text-white" />
</IconButton>
<IconButton
value={option.id}
onClick={handleAnswerSelect(question.id, option.id)}
buttonClassName={option.is_correct && 'success'}
>
{#if option.is_correct}
<CheckmarkFilledIcon size={24} class="carbon-icon dark:text-white" />
{:else}
<CheckmarkOutlineIcon size={24} class="carbon-icon dark:text-white" />
{/if}
</IconButton>
</div>
</Checkbox>
{/each}
{/if}
{#if QUESTION_TYPE.TEXTAREA === question.question_type.id}
<TextArea bind:value={question.value} disabled={true} />
{/if}
{#if getQuestionErrorMsg(errors, question, 'option')}
<ErrorMessage message={getQuestionErrorMsg(errors, question, 'option')} />
{/if}
</div>
{#if QUESTION_TYPE.TEXTAREA !== question.question_type.id}
<div class="flex items-center mt-3">
<PrimaryButton
disablePadding={true}
className="p-2"
variant={VARIANTS.OUTLINED}
onClick={handleAddOption(question.id)}
> >
<AddFilledIcon size={24} class="carbon-icon dark:text-white" /> {#each QUESTION_TYPES as type}
<p class="dark:text-white ml-2"> <SelectItem value={type.id} text={$t(type.label)} />
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.option')} {/each}
</p> </Select>
</PrimaryButton>
</div> </div>
{/if}
</QuestionContainer>
<!-- {/key} --> {#if typeof question.code === 'string'}
<div class="my-3 flex w-3/5 items-center justify-between">
<TextArea
bind:value={question.code}
rows={2}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.write')}
/>
<IconButton value="write-code" onClick={() => handleCode(question.id, false)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
<div class="mt-2 flex flex-col">
{#each $questionnaire.questions[index]?.options || [] as option}
{#if !option.deleted_at}
{#if QUESTION_TYPE.RADIO === question.question_type.id}
<RadioItem
isEditable={true}
name={question.title || 'radio-name'}
bind:label={option.label}
onChange={addDynamicValue(question.id, option.id)}
>
{@render optionActions(question, option)}
</RadioItem>
{:else if QUESTION_TYPE.CHECKBOX === question.question_type.id}
<Checkbox
isEditable={true}
name={question?.name || 'checkbox-name'}
bind:label={option.label}
onChange={addDynamicValue(question.id, option.id)}
>
{@render optionActions(question, option)}
</Checkbox>
{/if}
{/if}
{/each}
{#if QUESTION_TYPE.TEXTAREA === question.question_type.id}
<TextArea bind:value={question.value} disabled={true} />
{/if}
{#if getQuestionErrorMsg(errors, question, 'option')}
<ErrorMessage message={getQuestionErrorMsg(errors, question, 'option')} />
{/if}
</div>
{#if QUESTION_TYPE.TEXTAREA !== question.question_type.id}
<div class="mt-3 flex items-center">
<PrimaryButton
disablePadding={true}
className="p-2"
variant={VARIANTS.OUTLINED}
onClick={handleAddOption(question.id)}
>
<CirclePlusIcon size={16} />
<p class="ml-2 dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.option')}
</p>
</PrimaryButton>
</div>
{/if}
</QuestionContainer>
{/if}
{/each} {/each}
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More