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,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"printWidth": 120,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"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",
"editor.formatOnSave": true,
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"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:
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.
+5 -13
View File
@@ -1,5 +1,5 @@
<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>
<p align="center">
The Open Source Learning Management System for Companies
@@ -12,7 +12,7 @@
## ✨ 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.
@@ -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:
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.
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)
## 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
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
- `classroomio-com`: [http://localhost:5174](http://localhost:5174)
- `website`: [http://localhost:5174](http://localhost:5174)
- `api`: [http://localhost:3002](http://localhost:3002)
- `dashboard`: [http://localhost:5173](http://localhost:5173)
- `docs`: [http://localhost:3000](http://localhost:3000)
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`
- **dashboard**: `pnpm dev --filter=@cio/dashboard`
- **docs**: `pnpm dev --filter=@cio/docs`
+2
View File
@@ -1,3 +1,5 @@
DATABASE_URL=
## Supabase - DB
PUBLIC_SUPABASE_ANON_KEY=
PUBLIC_SUPABASE_URL=
-4
View File
@@ -40,7 +40,6 @@
"@hono/standard-validator": "^0.1.5",
"@hono/zod-validator": "^0.7.2",
"@scalar/hono-api-reference": "^0.9.18",
"@sentry/node": "^7.110.1",
"@sentry/profiling-node": "^7.110.1",
"@supabase/supabase-js": "^2.32.0",
"axios": "^1.4.0",
@@ -72,8 +71,5 @@
"typescript": "^5.5.2",
"vitest": "^1.2.2",
"wrangler": "4.21.x"
},
"_moduleAliases": {
"$src": "src"
}
}
+3 -3
View File
@@ -2,11 +2,11 @@ import 'dotenv/config';
import { Hono } from 'hono';
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 { mailRouter } from '$src/routes/mail';
import { mailRouter } from '@api/routes/mail';
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';
// 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';
+3 -3
View File
@@ -1,8 +1,8 @@
import 'dotenv/config';
import { app } from '$src/app';
import { configureOpenAPI } from '$src/utils/openapi';
import { env } from '$src/config/env';
import { app } from '@api/app';
import { configureOpenAPI } from '@api/utils/openapi';
import { env } from '@api/config/env';
import { serve } from '@hono/node-server';
import { showRoutes } from 'hono/dev';
+1 -1
View File
@@ -1,6 +1,6 @@
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) => {
try {
+2 -2
View File
@@ -3,10 +3,10 @@ import {
ERROR_MESSAGES,
HTTP_STATUS,
RATE_LIMIT_HEADERS
} from '$src/constants/rate-limiter';
} from '@api/constants/rate-limiter';
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';
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 { generateCertificate } from '$src/utils/certificate';
import { generateCoursePdf } from '$src/utils/course';
import { katexRouter } from '$src/routes/course/katex';
import { lessonRouter } from '$src/routes/course/lesson';
import { presignRouter } from '$src/routes/course/presign';
import { cloneRouter } from '$src/routes/course/clone';
import { TCourseClone,TCourseDownloadContent, TCertificateDownload, ZCertificateDownload, ZCourseDownloadContent } from '@api/types/course';
import { generateCertificate } from '@api/utils/certificate';
import { generateCoursePdf } from '@api/utils/course';
import { katexRouter } from '@api/routes/course/katex';
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';
export const courseRouter = new Hono()
+2 -2
View File
@@ -1,6 +1,6 @@
import { Hono } from 'hono';
import { TLessonDownloadContent, ZLessonDownloadContent } from '$src/types/course/lesson';
import { generateLessonPdf } from '$src/utils/lesson';
import { TLessonDownloadContent, ZLessonDownloadContent } from '@api/types/course/lesson';
import { generateLessonPdf } from '@api/utils/lesson';
import { zValidator } from '@hono/zod-validator';
export const lessonRouter = new Hono().post(
+7 -7
View File
@@ -3,17 +3,17 @@ import {
ZCourseDocumentPresignUrlUpload,
ZCourseDownloadPresignedUrl,
ZCoursePresignUrlUpload
} from '$src/types/course';
} from '@api/types/course';
import { describeRoute, validator } from 'hono-openapi';
import { BUCKET_NAME } from '$src/constants/upload';
import { CLOUDFLARE } from '$src/constants';
import type { GetSignedUrlParameters } from '$src/utils/s3';
import { BUCKET_NAME } from '@api/constants/upload';
import { CLOUDFLARE } from '@api/constants';
import type { GetSignedUrlParameters } from '@api/utils/s3';
import { Hono } from 'hono';
import { authMiddleware } from '$src/middlewares/auth';
import { generateFileKey } from '$src/utils/upload';
import { authMiddleware } from '@api/middlewares/auth';
import { generateFileKey } from '@api/utils/upload';
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
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 { ZSendEmailValidation } from '$src/types/mail';
import { env } from '$src/config/env';
import { ZSendEmailValidation } from '@api/types/mail';
import { env } from '@api/config/env';
import { zValidator } from '@hono/zod-validator';
export const mailRouter = new Hono().post(
+4 -4
View File
@@ -1,10 +1,10 @@
import { extractNameAndEmail, withEmailTemplate } from '$src/utils/mail';
import { nodemailerTransporter, zohoClient } from '$src/utils/email';
import { extractNameAndEmail, withEmailTemplate } from '@api/utils/mail';
import { nodemailerTransporter, zohoClient } from '@api/utils/email';
import { Hono } from 'hono';
import type { TSendEmailValidation } from '$src/types/mail';
import type { TSendEmailValidation } from '@api/types/mail';
import type { Transporter } from 'nodemailer';
import { env } from '$src/config/env';
import { env } from '@api/config/env';
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';
+2 -2
View File
@@ -1,5 +1,5 @@
import type { TCertificateDownload } from '$src/types/course/lesson';
import { getCloudflarePdfBuffer } from '$src/utils/cloudflare';
import type { TCertificateDownload } from '@api/types/course/lesson';
import { getCloudflarePdfBuffer } from '@api/utils/cloudflare';
export interface CertificateData {
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) => {
console.log('Generating PDF with Cloudflare API...');
+2 -2
View File
@@ -1,5 +1,5 @@
import type { TCourseDownloadContent } from '$src/types/course';
import { getCloudflarePdfBuffer } from '$src/utils/cloudflare';
import type { TCourseDownloadContent } from '@api/types/course';
import { getCloudflarePdfBuffer } from '@api/utils/cloudflare';
import { marked } from 'marked';
function getHtmlTemplate(body: string): string {
+1 -1
View File
@@ -1,6 +1,6 @@
import { SendMailClient } from 'zeptomail';
import type { Transporter } from 'nodemailer';
import { env } from '$src/config/env';
import { env } from '@api/config/env';
import nodemailer from 'nodemailer';
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';
function getHtmlTemplate(body: string): string {
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from 'hono';
import { Scalar } from '@scalar/hono-api-reference';
import { env } from '$src/config/env';
import { env } from '@api/config/env';
export function configureOpenAPI(app: Hono) {
if (env.OPENAPI_URL) {
+1 -1
View File
@@ -4,7 +4,7 @@
*/
import type { Context } from 'hono';
import { supabase } from '$src/utils/supabase';
import { supabase } from '@api/utils/supabase';
/**
* Extract user ID from JWT token
+1 -1
View File
@@ -3,7 +3,7 @@ import {
DEFAULT_WINDOW_MS,
ERROR_MESSAGES,
RATE_LIMIT_KEY_PREFIX
} from '$src/constants/rate-limiter';
} from '@api/constants/rate-limiter';
import { Redis } from 'ioredis';
import { redis } from './redis';
+1 -1
View File
@@ -1,4 +1,4 @@
import { Redis } from 'ioredis';
import { env } from '$src/config/env';
import { env } from '@api/config/env';
export const redis = new Redis(env.REDIS_URL);
+1 -1
View File
@@ -8,7 +8,7 @@ import {
S3Client
} from '@aws-sdk/client-s3';
import { CLOUDFLARE } from '$src/constants';
import { CLOUDFLARE } from '@api/constants';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export type GetSignedUrlParameters = Parameters<typeof getSignedUrl>;
+1 -1
View File
@@ -1,6 +1,6 @@
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;
+1 -2
View File
@@ -9,8 +9,7 @@
"moduleResolution": "Bundler",
"outDir": "./dist",
"paths": {
"$src": ["./src"],
"$src/*": ["./src/*"]
"@api/*": ["src/*"]
},
"resolveJsonModule": false,
"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",
"preview": "vite preview",
"lint": "eslint ./src",
"lint:fix": "eslint ./src --fix",
"clean": "rm -rf node_modules",
"format": "prettier . --write .",
"start": "node build",
@@ -21,11 +22,12 @@
"@carbon/charts-svelte": "^1.13.6",
"@cio/api": "workspace:*",
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
"@lucide/svelte": "^0.545.0",
"@polar-sh/sveltekit": "^0.2.1",
"@supabase/supabase-js": "^2.31.0",
"@sveltejs/adapter-auto": "^2.1.0",
"@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/adapter-vercel": "^3.1.0",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/adapter-vercel": "^4.0.0",
"@sveltekit-i18n/base": "^1.3.7",
"@sveltekit-i18n/parser-icu": "^1.0.8",
"@tailwindcss/forms": "^0.5.4",
@@ -55,7 +57,7 @@
"papaparse": "^5.4.1",
"pluralize": "^8.0.0",
"posthog-js": "^1.180.1",
"shared": "workspace:*",
"@cio/utils": "workspace:*",
"sirv": "^2.0.3",
"stripe": "^14.22.0",
"svelte-awesome-color-picker": "^3.1.4",
@@ -74,37 +76,45 @@
"@babel/preset-env": "^7.22.9",
"@babel/preset-typescript": "^7.23.3",
"@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",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.5",
"@types/jest": "^29.5.10",
"@types/lodash": "^4.14.196",
"@types/node": "^20",
"all-object-keys": "^2.2.0",
"autoprefixer": "^10.4.14",
"babel-jest": "^29.7.0",
"carbon-components-svelte": "^0.79.0",
"carbon-icons-svelte": "^12.1.0",
"diff": "^5.2.0",
"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",
"jest": "^29.7.0",
"postcss": "^8.4.24",
"postcss-load-config": "^4.0.1",
"qrcode": "^1.5.3",
"sass": "^1.64.2",
"svelte": "^4.1.2",
"svelte": "^5.0.0",
"svelte-dnd-action": "^0.9.24",
"svelte-jester": "^3.0.0",
"svelte-loading-spinners": "^0.3.4",
"svelte-preprocess": "^5.0.4",
"svelte-preprocess": "^6.0.0",
"tailwindcss": "^3.3.2",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"tsconfig": "workspace:*",
"@cio/tsconfig": "workspace:*",
"tslib": "^2.6.1",
"typescript": "^5.1.6",
"vite": "^4.4.8"
"typescript": "^5.5.0",
"typescript-eslint": "^8.44.1",
"vite": "^5.4.4"
},
"type": "module",
"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
if (!missingKeysWithDetails) {
console.log(
`No missing translations for ${toLanguage.toUpperCase()}. Stopping translation process.`
);
console.log(`No missing translations for ${toLanguage.toUpperCase()}. Stopping translation process.`);
return;
}
+5 -4
View File
@@ -1,6 +1,5 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
@@ -17,8 +16,11 @@
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
<link rel="stylesheet" href="https://assets.cdn.clsrio.com/carbon.css" />
<script defer src="https://umami.hz.oncws.com/script.js"
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"></script>
<script
defer
src="https://umami.hz.oncws.com/script.js"
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"
></script>
<script src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
@@ -28,5 +30,4 @@
<body data-sveltekit-preload-data="hover" class="">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+10 -4
View File
@@ -115,22 +115,28 @@ code {
white-space: pre-wrap;
}
[aria-current] :global(svg.carbon-icon),
/* [aria-current] :global(svg.carbon-icon),
.active :global(svg.carbon-icon) {
fill: var(--main-primary-color);
}
} */
.prose a {
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;
}
:global(svg.carbon-icon.error) {
fill: red;
}
} */
.org-root {
height: calc(100vh - 48px);
+2 -3
View File
@@ -3,9 +3,8 @@
<img src="/logo-512.png" class="logo" alt="ClassroomIO - Logo" />
<h2>Something unexpected occured.</h2>
<p>
Don't worry, your learning is safe. It isn't your fault, it is ours. We have gotten the error
notification and will push a fix ASAP. In the meantime, take a short break and come back a bit
later.
Don't worry, your learning is safe. It isn't your fault, it is ours. We have gotten the error notification and
will push a fix ASAP. In the meantime, take a short break and come back a bit later.
</p>
<a href="mailto:[email protected]">Contact Us</a>
</div>
+2 -5
View File
@@ -38,12 +38,9 @@ export const handle: Handle = async ({ event, resolve }) => {
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Unauthenticated user') {
return new Response(
JSON.stringify({ code: 'unauthenticated', message: 'Unauthenticated user' }),
{
return new Response(JSON.stringify({ code: 'unauthenticated', message: 'Unauthenticated user' }), {
status: 401
}
);
});
}
}
}
@@ -1,56 +1,76 @@
<script lang="ts">
import { useCompletion } from 'ai/svelte';
import { Popover } from 'carbon-components-svelte';
import type { PopoverProps } from 'carbon-components-svelte/types/Popover/Popover.svelte';
import MagicWandFilled from 'carbon-icons-svelte/lib/MagicWandFilled.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import { Loading } from 'carbon-components-svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import { t } from '$lib/utils/functions/translations';
// import { preventDefault } from '$lib/utils/functions/svelte';
// import { writable } from 'svelte/store';
// // import { useCompletion } from 'ai/svelte';
// import { Popover } from 'carbon-components-svelte';
// import type { PopoverProps } from 'carbon-components-svelte/types/Popover/Popover.svelte';
// import WandSparklesIcon from '@lucide/svelte/icons/wand-sparkles';
// import { IconButton } from '$lib/components/IconButton';
// import { Loading } from 'carbon-components-svelte';
// import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
// import { t } from '$lib/utils/functions/translations';
export let className = '';
export let handleInsert = (v: string) => {};
export let defaultPrompt = '';
export let alignPopover: PopoverProps['align'] = 'left';
export let isHTML = false;
// interface Props {
// className?: string;
// handleInsert?: any;
// defaultPrompt?: string;
// alignPopover?: PopoverProps['align'];
// isHTML?: boolean;
// }
let openPopover = false;
let plainText = '';
let textRef: HTMLParagraphElement;
let prompt: string = defaultPrompt;
// let {
// className = '',
// handleInsert = (_v: string) => {},
// 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({
api: '/api/completion/customprompt'
});
// let isSubmitted = $state(false);
function restart() {
isSubmitted = false;
plainText = '';
}
// let isLoading = writable(false);
// // const { input, handleSubmit, completion, isLoading } = useCompletion({
// // api: '/api/completion/customprompt'
// // });
function onCompletion(completion: string) {
plainText = completion;
if (textRef) {
textRef.scrollTop = textRef.scrollHeight;
}
}
// function restart() {
// isSubmitted = false;
// plainText = '';
// }
function callAI(prompt: string, rephrase: boolean) {
isSubmitted = true;
// function onCompletion(completion: string) {
// 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(() => {
handleSubmit({ preventDefault: () => {} });
}, 500);
}
// // $input = rephrase ? `${$t('ai.can_you')}: ${plainText}` : prompt;
$: onCompletion($completion);
$: prompt = defaultPrompt;
// // setTimeout(() => {
// // handleSubmit({ preventDefault: () => {} });
// // }, 500);
// // }
// $effect(() => {
// onCompletion($completion);
// });
// $effect(() => {
// prompt = defaultPrompt;
// });
</script>
<div></div>
<!--
<div class="{className} relative z-[100]">
<IconButton
contained={true}
@@ -58,31 +78,27 @@
size="large"
onClick={() => (openPopover = !openPopover)}
>
<MagicWandFilled size={16} class="carbon-icon" />
<MagicWandFilled />
<Popover caret align={alignPopover} bind:open={openPopover}>
<button
class="p-2 w-[300px] h-[220px] text-start"
type="button"
on:click={(e) => e.stopPropagation()}
>
<div class="h-[220px] w-[300px] p-2 text-start">
{#if isSubmitted}
<div class="h-[200px]">
<div
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}
<HtmlRender content={plainText} />
<HtmlRender>{plainText}</HtmlRender>
{:else}
<p class="font-normal">
{plainText}
</p>
{/if}
</div>
<div class="flex gap-5 ml-2">
<div class="ml-2 flex gap-5">
<button
class="text-xs px-4 py-2 border-[1px] cursor-pointer rounded-md font-medium"
on:click={() => {
class="cursor-pointer rounded-md border-[1px] px-4 py-2 text-xs font-medium"
onclick={() => {
handleInsert(plainText);
openPopover = false;
}}
@@ -96,9 +112,9 @@
{/if}
</button>
<button
class="text-xs px-4 py-2 border-[1px] cursor-pointer rounded-md font-medium {$isLoading &&
'opacity-25 cursor-not-allowed'}"
on:click|preventDefault={() => callAI(prompt, true)}
class="cursor-pointer rounded-md border-[1px] px-4 py-2 text-xs font-medium {$isLoading &&
'cursor-not-allowed opacity-25'}"
onclick={preventDefault(() => callAI(prompt, true))}
type="button"
disabled={$isLoading}
>
@@ -106,8 +122,8 @@
</button>
{#if !$isLoading}
<button
class="text-xs px-4 py-2 border-[1px] cursor-pointer rounded-md font-medium"
on:click|preventDefault={restart}
class="cursor-pointer rounded-md border-[1px] px-4 py-2 text-xs font-medium"
onclick={preventDefault(restart)}
type="button"
>
{$t('ai.reset')}
@@ -117,22 +133,22 @@
</div>
{:else}
<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
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')}
/>
></textarea>
<button
class="flex text-xs px-4 py-2 m-0 border rounded-md font-medium"
on:click|preventDefault={() => callAI(prompt, false)}
class="m-0 flex rounded-md border px-4 py-2 text-xs font-medium"
onclick={preventDefault(() => callAI(prompt, false))}
type="button"
>
{$t('ai.text')}
</button>
</div>
{/if}
</button>
</div>
</Popover>
</IconButton>
</div>
</div> -->
@@ -7,18 +7,22 @@
hidePercentage?: boolean;
}
export let activity: Activity;
interface Props {
activity: Activity;
}
let { activity }: Props = $props();
</script>
<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">
<svelte:component this={activity.icon} size={24} />
<activity.icon />
</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}
</p>
<p class="text-center text-2xl font-bold md:text-left">
@@ -27,7 +31,7 @@
%
{/if}
</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}
</p>
</div>
@@ -3,28 +3,32 @@
import { calDateDiff } from '$lib/utils/functions/date';
import { t } from '$lib/utils/functions/translations';
import type { UserAnalytics } from '$lib/utils/types/analytics';
import Alarm from 'carbon-icons-svelte/lib/Alarm.svelte';
import Email from 'carbon-icons-svelte/lib/Email.svelte';
import AlarmClockIcon from '@lucide/svelte/icons/alarm-clock';
import MailIcon from '@lucide/svelte/icons/mail';
export let user: UserAnalytics['user'];
interface Props {
user: UserAnalytics['user'];
}
let { user }: Props = $props();
</script>
<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">
<Avatar src={user.avatarUrl} name={user.fullName} width="w-16" height="h-16" />
<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}
</p>
<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">
<Email />
<MailIcon size={16} />
{user.email}
</p>
<p class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-300">
<Alarm />
<AlarmClockIcon size={16} />
{$t('analytics.last_seen')}:
<span class="italic">
{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">
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 { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -8,15 +10,31 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import GoogleIconColored from '../Icons/GoogleIconColored.svelte';
export let supabase: SupabaseClient;
export let handleSubmit = () => {};
export let isLogin = true;
export let showOnlyContent = false;
export let isLoading = false;
export let showLogo = false;
export let formRef;
export let hideGoogleAuth = false;
export let redirectPathname = '';
interface Props {
supabase: SupabaseClient;
handleSubmit?: any;
isLogin?: boolean;
showOnlyContent?: boolean;
isLoading?: boolean;
showLogo?: boolean;
formRef: any;
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() {
if (isLoading) {
@@ -70,12 +88,8 @@
</a>
</div>
{/if}
<form
bind:this={formRef}
on:submit|preventDefault={handleSubmit}
class="flex w-10/12 flex-col items-center"
>
<slot />
<form bind:this={formRef} onsubmit={preventDefault(handleSubmit)} class="flex w-10/12 flex-col items-center">
{@render children?.()}
</form>
{#if !showOnlyContent && !hideGoogleAuth}
<div class="mb-3 w-10/12">
@@ -98,14 +112,10 @@
<div class="border-grey w-full border-t p-6 text-center">
{#if isLogin}
{$t('login.not_registered_yet')}
<a class="text-primary-700 hover:underline" href="/signup{$page.url.search}"
>{$t('login.signup')}</a
>
<a class="text-primary-700 hover:underline" href="/signup{page.url.search}">{$t('login.signup')}</a>
{:else}
{$t('login.already_have_account')}
<a class="text-primary-700 hover:underline" href="/login{$page.url.search}"
>{$t('login.login')}</a
>
<a class="text-primary-700 hover:underline" href="/login{page.url.search}">{$t('login.login')}</a>
{/if}
</div>
{/if}
@@ -1,10 +1,21 @@
<script>
export let name = '';
export let src = '';
export let className = '';
export let shape = 'rounded-full';
export let width = 'w-10';
export let height = 'h-10';
<script lang="ts">
interface Props {
name?: string;
src?: string;
className?: string;
shape?: string;
width?: string;
height?: string;
}
let {
name = '',
src = '',
className = '',
shape = 'rounded-full',
width = 'w-10',
height = 'h-10'
}: Props = $props();
</script>
<img alt={name} {src} class="{className} {width} {height} {shape}" />
@@ -1,13 +1,15 @@
<script lang="ts">
export let disableCenteredContent = false;
export let className = '';
interface Props {
disableCenteredContent?: boolean;
className?: string;
children?: import('svelte').Snippet;
}
let { disableCenteredContent = false, className = '', children }: Props = $props();
</script>
<div
class="root {!className.includes('opacity') && 'opacity-100'} {!disableCenteredContent &&
'center'} {className}"
>
<slot />
<div class="root {!className.includes('opacity') && 'opacity-100'} {!disableCenteredContent && 'center'} {className}">
{@render children?.()}
</div>
<style>
@@ -1,9 +1,14 @@
<script>
export let className = '';
<script lang="ts">
interface Props {
className?: string;
children?: import('svelte').Snippet;
}
let { className = '', children }: Props = $props();
</script>
<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>
@@ -1,18 +1,23 @@
<script>
import CloseIcon from 'carbon-icons-svelte/lib/Close.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import IconButton, { type Props as IconButtonProps } from '$lib/components/IconButton/index.svelte';
export let onClick = () => {};
export let contained = false;
export let color = 'text-primary-700 bg-primary-200 dark:bg-neutral-500';
export let size = 'small';
interface Props {
onClick?: IconButtonProps['onClick'];
contained?: boolean;
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>
<IconButton {onClick} {color} {contained} {size}>
{#if size === 'large'}
<CloseIcon size={24} class="carbon-icon dark:text-black" title="Delete" />
{:else}
<CloseIcon size={20} class="carbon-icon dark:text-black" title="Delete" />
{/if}
<XIcon size={size === 'large' ? 24 : 16} class="carbon-icon dark:text-black" />
</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>
import Close from 'carbon-icons-svelte/lib/Close.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { IconButton } from '$lib/components/IconButton';
export let onClick = () => {};
let { onClick = () => {} } = $props();
</script>
<IconButton {onClick}>
<Close size={24} class="carbon-icon dark:text-white" title="Close" />
<XIcon size={16} class="carbon-icon dark:text-white" />
</IconButton>
@@ -1,10 +1,10 @@
<script>
import SendFilled from 'carbon-icons-svelte/lib/SendFilled.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
<script lang="ts">
import SendHorizontalIcon from '@lucide/svelte/icons/send-horizontal';
import { IconButton } from '$lib/components/IconButton';
export let onClick;
let { onClick } = $props();
</script>
<IconButton {onClick}>
<SendFilled size={24} class="carbon-icon dark:text-white" />
<SendHorizontalIcon size={16} class="filled" />
</IconButton>
@@ -7,10 +7,14 @@
import { t } from '$lib/utils/functions/translations';
import { user } from '$lib/utils/store/user';
export let className = '';
export let isLMS = false;
interface Props {
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>
<a {href} target="_blank" class="{className} ml-2 hover:no-underline">
@@ -1,9 +1,11 @@
<script>
export let name = '';
<script lang="ts">
interface Props {
name?: string;
}
let { name = '' }: Props = $props();
</script>
<p
class="w-fit text-xs border font-semibold px-1.5 rounded-md text-center text-blue-700 border-blue-700"
>
<p class="w-fit rounded-md border border-blue-700 px-1.5 text-center text-xs font-semibold text-blue-700">
{name}
</p>
@@ -1,14 +1,16 @@
<script lang="ts">
export let className = '';
export let value: string | number = '';
export let size = '';
export let shape = 'rounded-md';
interface Props {
className?: string;
value?: string | number;
size?: string;
shape?: string;
}
let { className = '', value = '', size = '', shape = 'rounded-md' }: Props = $props();
</script>
<p
class="{className} {size === 'sm' ? 'py-1 px-1' : 'py-2 px-2'} org-avatar {className.includes(
'bg-'
)
class="{className} {size === 'sm' ? 'px-1 py-1' : 'px-2 py-2'} org-avatar {className.includes('bg-')
? ''
: 'bg-gray-200 dark:text-black'} {shape} text-center"
>
@@ -1,6 +1,10 @@
<script>
export let value = 0;
export let className = '';
<script lang="ts">
interface Props {
value?: number;
className?: string;
}
let { value = 0, className = '' }: Props = $props();
</script>
<p class={className ? className : 'bg-set mt-2'}>{value}</p>
@@ -1,7 +1,11 @@
<script>
export let code = '';
<script lang="ts">
interface Props {
code?: string;
}
let { code = '' }: Props = $props();
</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}
</div>
@@ -1,11 +1,9 @@
<script>
<script lang="ts">
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>
<p
class="org-avatar text-xs bg-primary-200 text-primary-700 font-medium py-1 px-2 rounded-md text-center"
>
<p class="org-avatar bg-primary-200 text-primary-700 rounded-md px-2 py-1 text-center text-xs font-medium">
{label}
</p>
@@ -3,7 +3,7 @@
import confetti from 'canvas-confetti';
import { confettiStore } from './store';
let myCanvas;
let myCanvas = $state();
let myConfetti;
function showConfetti(show) {
@@ -29,13 +29,13 @@
myConfetti(
Object.assign({}, defaults, {
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(
Object.assign({}, defaults, {
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);
@@ -44,14 +44,16 @@
onMount(() => {
myConfetti = confetti.create(myCanvas, {
resize: true,
useWorker: true,
useWorker: true
});
});
$: showConfetti($confettiStore.show);
$effect(() => {
showConfetti($confettiStore.show);
});
</script>
<canvas bind:this={myCanvas} />
<canvas bind:this={myCanvas}></canvas>
<style>
canvas {
@@ -1,27 +1,31 @@
<script lang="ts">
export let title: string;
export let value: string | number;
export let subtitle: string = '';
export let icon: any;
export let iconColor: string = 'blue';
export let className: string = '';
interface Props {
title: string;
value: string | number;
subtitle?: string;
icon: any;
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',
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',
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',
yellow: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400'
};
});
const SvelteComponent = $derived(icon);
</script>
<div
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800 {className}"
>
<div 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 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>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
@@ -5,12 +5,17 @@
import { ScaleTypes } from '@carbon/charts-svelte';
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
$: progressChartData = courseAnalytics?.students
let progressChartData = $derived(
courseAnalytics?.students
? [
{
group: '80%+ Progress',
@@ -18,19 +23,20 @@
},
{
group: '60-79% Progress',
value: courseAnalytics.students.filter(
(s) => s.progressPercentage >= 60 && s.progressPercentage < 80
).length
value: courseAnalytics.students.filter((s) => s.progressPercentage >= 60 && s.progressPercentage < 80)
.length
},
{
group: 'Below 60% Progress',
value: courseAnalytics.students.filter((s) => s.progressPercentage < 60).length
}
]
: [];
: []
);
// Transform data for grade distribution chart
$: gradeChartData = courseAnalytics?.students
let gradeChartData = $derived(
courseAnalytics?.students
? [
{
group: '90%+ Grade',
@@ -38,20 +44,19 @@
},
{
group: '80-89% Grade',
value: courseAnalytics.students.filter((s) => s.averageGrade >= 80 && s.averageGrade < 90)
.length
value: courseAnalytics.students.filter((s) => s.averageGrade >= 80 && s.averageGrade < 90).length
},
{
group: '70-79% Grade',
value: courseAnalytics.students.filter((s) => s.averageGrade >= 70 && s.averageGrade < 80)
.length
value: courseAnalytics.students.filter((s) => s.averageGrade >= 70 && s.averageGrade < 80).length
},
{
group: 'Below 70% Grade',
value: courseAnalytics.students.filter((s) => s.averageGrade < 70).length
}
]
: [];
: []
);
onMount(async () => {
if (browser) {
@@ -61,7 +66,7 @@
});
// Chart options for progress distribution
$: progressChartOptions = {
let progressChartOptions = $derived({
axes: {
left: {
mapsTo: 'value',
@@ -84,10 +89,10 @@
'Below 60% Progress': '#ef4444' // red
}
}
};
});
// Chart options for grade distribution
$: gradeChartOptions = {
let gradeChartOptions = $derived({
axes: {
left: {
mapsTo: 'value',
@@ -111,24 +116,16 @@
'Below 70% Grade': '#ef4444' // red
}
}
};
});
</script>
{#if browser && BarChartSimple}
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
<!-- Progress Distribution Chart -->
<div
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>
<div 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>
{#if courseAnalytics?.students && courseAnalytics.students.length > 0}
<svelte:component
this={BarChartSimple}
data={progressChartData}
options={progressChartOptions}
/>
<BarChartSimple data={progressChartData} options={progressChartOptions} />
{:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
No progress data available
@@ -137,12 +134,10 @@
</div>
<!-- Grade Distribution Chart -->
<div
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
>
<div 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>
{#if courseAnalytics?.students && courseAnalytics.students.length > 0}
<svelte:component this={BarChartSimple} data={gradeChartData} options={gradeChartOptions} />
<BarChartSimple data={gradeChartData} options={gradeChartOptions} />
{:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
No grade data available
@@ -153,19 +148,13 @@
{:else}
<!-- Loading state -->
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div
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>
<div 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>
<div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">Loading chart...</div>
</div>
</div>
<div
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
>
<div 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>
<div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">Loading chart...</div>
@@ -8,9 +8,7 @@
<div class="mb-12">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{#each Array(4) as _}
<div
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
>
<div 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">
<SkeletonPlaceholder style="width: 48px; height: 48px;" />
<div class="flex-1">
@@ -26,9 +24,7 @@
<!-- Charts Section Skeleton -->
<div class="mb-12">
<div
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
>
<div 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;" />
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SkeletonPlaceholder style="width: 100%; height: 300px;" />
@@ -38,9 +34,7 @@
</div>
<!-- Students Table Skeleton -->
<div
class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800"
>
<div 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">
<SkeletonPlaceholder style="width: 200px; height: 24px;" />
</div>
@@ -1,17 +1,26 @@
<script lang="ts">
import { ChartBar } from 'carbon-icons-svelte';
import ChartBarIcon from '@lucide/svelte/icons/chart-bar';
export let title: string = 'No data available';
export let description: string = 'There is no data to display at this time.';
export let icon: any = ChartBar;
export let className: string = '';
interface Props {
title?: string;
description?: 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>
<div class="flex flex-col items-center justify-center px-4 py-12 {className}">
<div
class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800"
>
<svelte:component this={icon} size="24" class="text-gray-400 dark:text-gray-500" />
<div 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" />
</div>
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">
{title}
@@ -3,27 +3,32 @@
import { Tag, Pagination } from 'carbon-components-svelte';
import type { StudentOverview } from '$lib/utils/types/analytics';
import EmptyState from './EmptyState.svelte';
import { User } from 'carbon-icons-svelte';
import UserIcon from '@lucide/svelte/icons/user';
import { course } from '../../store';
import { goto } from '$app/navigation';
import { t } from '$lib/utils/functions/translations';
export let students: StudentOverview[] = [];
interface Props {
students?: StudentOverview[];
}
let { students = [] }: Props = $props();
// Pagination state
let currentPage = 1;
let currentPage = $state(1);
const pageSize = 15;
// Computed values
$: totalPages = Math.ceil(students.length / pageSize);
$: startIndex = (currentPage - 1) * pageSize;
$: endIndex = startIndex + pageSize;
$: paginatedStudents = students.slice(startIndex, endIndex);
$: startItem = startIndex + 1;
$: endItem = Math.min(endIndex, students.length);
let totalPages = $derived(Math.ceil(students.length / pageSize));
let startIndex = $derived((currentPage - 1) * pageSize);
let endIndex = $derived(startIndex + pageSize);
let paginatedStudents = $derived(students.slice(startIndex, endIndex));
let startItem = $derived(startIndex + 1);
let endItem = $derived(Math.min(endIndex, students.length));
const GotoFullProfile = (student: StudentOverview) => {
if (!student) return;
goto(`/courses/${$course.id}/people/${student.id}?back=/courses/${$course.id}/analytics`);
};
@@ -32,16 +37,18 @@
}
// Reset to first page when students array changes
$: if (students.length > 0 && currentPage > totalPages) {
$effect(() => {
if (students.length > 0 && currentPage > totalPages) {
currentPage = 1;
}
});
</script>
{#if students.length === 0}
<EmptyState
title={$t('analytics.no_students_found')}
description={$t('analytics.no_students_description')}
icon={User}
icon={UserIcon}
/>
{:else}
<!-- 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"
>
<div class="flex items-center gap-3">
<Avatar
src={student.profile.avatar_url}
name={student.profile.fullname}
width="w-8"
height="h-8"
/>
<Avatar src={student.profile.avatar_url} name={student.profile.fullname} width="w-8" height="h-8" />
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-gray-900 dark:text-white">
{student.profile.fullname}
@@ -115,10 +117,7 @@
</span>
<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-blue-500"
style="width: {student.progressPercentage}%"
></div>
<div class="h-2 rounded-full bg-blue-500" style="width: {student.progressPercentage}%"></div>
</div>
</div>
</div>
@@ -130,11 +129,7 @@
</td>
<td class="min-w-[120px] whitespace-nowrap px-4 py-3">
<Tag
type={student.averageGrade >= 80
? 'green'
: student.averageGrade >= 60
? 'blue'
: 'red'}
type={student.averageGrade >= 80 ? 'green' : student.averageGrade >= 60 ? 'blue' : 'red'}
size="sm"
>
{student.averageGrade}%
@@ -148,7 +143,7 @@
<td class="min-w-[120px] whitespace-nowrap px-4 py-3">
<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"
on:click={() => GotoFullProfile(student)}
onclick={() => GotoFullProfile(student)}
>
{$t('analytics.view_details')}
</button>
@@ -162,9 +157,7 @@
<!-- Pagination Controls -->
{#if students.length > pageSize}
<div
class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3 dark:border-neutral-600"
>
<div 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">
{$t('analytics.showing_students', {
start: startItem,
@@ -172,12 +165,7 @@
total: students.length
})}
</div>
<Pagination
page={currentPage}
totalItems={students.length}
{pageSize}
on:change={handlePageChange}
/>
<Pagination page={currentPage} totalItems={students.length} {pageSize} on:change={handlePageChange} />
</div>
{/if}
{/if}
@@ -1,5 +1,9 @@
<script lang="ts">
export let className: string = '';
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
@@ -1,5 +1,9 @@
<script lang="ts">
export let className: string = '';
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
@@ -1,5 +1,9 @@
<script lang="ts">
export let className: string = '';
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
@@ -1,5 +1,9 @@
<script lang="ts">
export let className: string = '';
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
@@ -10,7 +14,5 @@
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<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"
/>
<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" />
</svg>
@@ -1,6 +1,6 @@
<script>
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 { updateCourse } from '$lib/utils/services/courses';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -30,11 +30,14 @@
'blueBadgePattern'
];
let isSaving = false;
let errors = {
let isSaving = $state(false);
let errors = $state({
description: ''
};
let helperText = '';
});
const helperText = $derived(
`${$course.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
);
const saveCertificate = async () => {
isSaving = true;
@@ -47,8 +50,7 @@
});
if (result && Object.keys(result).length > 0) {
errors.description =
$t(result.description) || $t('course.navItem.certificates.description_error');
errors.description = $t(result.description) || $t('course.navItem.certificates.description_error');
throw new Error(errors.description);
}
@@ -70,15 +72,11 @@
isSaving = false;
}
};
$: helperText = `${$course.description?.length || 0}/200 ${$t(
'course.navItem.certificates.characters'
)}`;
</script>
<svelte:head>
<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
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
rel="stylesheet"
@@ -96,20 +94,12 @@
<p class="my-4 text-xs font-normal dark:text-gray-100">
{$t('course.navItem.certificates.theme')}
</p>
<RadioButtonGroup
bind:selected={$course.certificate_theme}
class="mb-10"
disabled={$isFreePlan}
>
<RadioButtonGroup bind:selected={$course.certificate_theme} class="mb-10" disabled={$isFreePlan}>
<div class="flex flex-wrap justify-between gap-y-5">
{#each themes as theme}
<div class="mr-3 flex">
<RadioButton value={theme} />
<img
src={`/images/certificate_theme_${theme}.png`}
alt="themes"
class="h-[82px] w-[110px]"
/>
<img src={`/images/certificate_theme_${theme}.png`} alt="themes" class="h-[82px] w-[110px]" />
</div>
{/each}
</div>
@@ -159,9 +149,7 @@
</Toggle>
</div>
</section>
<section
class="flex w-full items-center justify-center rounded-md bg-gray-100 lg:w-3/5 dark:bg-neutral-800"
>
<section 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">
{#if $course.certificate_theme === 'professional'}
<Professional studentName={studentNamePlaceholder} />
@@ -188,7 +176,7 @@
isDisabled={$isFreePlan}
>
{#if $isFreePlan}
<FlashFilled size={16} class="text-blue-700" />
<ZapIcon size={16} class="filled" />
{/if}
{$t('course.navItem.certificates.save')}
</PrimaryButton>
@@ -1,4 +1,6 @@
<script>
import { preventDefault } from '$lib/utils/functions/svelte';
import { Toggle } from 'carbon-components-svelte';
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -33,16 +35,13 @@
<span slot="labelA" style="color: #161616">Automatic</span>
<span slot="labelB" style="color: green">Automatic</span>
</Toggle>
<p class="text-sm font-medium my-4">
If you set this as automatic, certificates will be issued after the
learner completes the course.
<p class="my-4 text-sm font-medium">
If you set this as automatic, certificates will be issued after the learner completes the course.
</p>
</div>
<p class="text-xs font-normal text-gray-500 my-4">
or send a personalised/ custom certificate below:
</p>
<form on:submit|preventDefault={issueCertificate}>
<div class="flex flex-col md:flex-row gap-2 w-full">
<p class="my-4 text-xs font-normal text-gray-500">or send a personalised/ custom certificate below:</p>
<form onsubmit={preventDefault(issueCertificate)}>
<div class="flex w-full flex-col gap-2 md:flex-row">
<TextField
label="Email address of the student"
className="w-full my-4"
@@ -65,18 +64,13 @@
label="Add a personalized message"
labelClassName="text-xs font-normal"
bind:value={$issueCertificateModal.message}
rows="2"
maxRows="3"
rows={2}
placeholder="your message here"
className="mb-4"
/>
<div class="mt-5 flex w-full items-end justify-end">
<PrimaryButton
className="px-6 py-3 rounded-md text-sm font-medium"
label="Issue certificate"
type="submit"
/>
<PrimaryButton className="px-6 py-3 rounded-md text-sm font-medium" label="Issue certificate" type="submit" />
</div>
</form>
</main>
@@ -11,15 +11,13 @@
Pagination
} from 'carbon-components-svelte';
let searchValue = '';
let checked = false;
let searchValue = $state('');
let checked = $state(false);
</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)}
<div
class="box flex flex-col rounded border border-gray-200 dark:border-neutral-600 justify-center px-2 mb-5"
>
<div class="box mb-5 flex flex-col justify-center rounded border border-gray-200 px-2 dark:border-neutral-600">
<p class="text-xs font-normal">{data.title}</p>
<bold class="text-2xl font-medium">{data.value}</bold>
</div>
@@ -28,12 +26,8 @@
<section>
<div class="flex flex-row items-center justify-between">
<p class="text-base font-semibold w-full">Certificate issued</p>
<Search
placeholder="search students"
bind:value={searchValue}
searchClass="border-b border-transparent"
/>
<p class="w-full text-base font-semibold">Certificate issued</p>
<Search placeholder="search students" bind:value={searchValue} searchClass="border-b border-transparent" />
</div>
</section>
@@ -42,16 +36,14 @@
<StructuredListHead class="bg-primary-50 py-0">
<StructuredListRow head class="py-0">
<StructuredListCell head class="py-0">
<div
class="flex-3 flex flex-row items-center justify-start w-full text-primary-600 text-sm font-medium"
>
<div class="flex-3 text-primary-600 flex w-full flex-row items-center justify-start text-sm font-medium">
<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>
</StructuredListCell>
<StructuredListCell head class="py-0">
<div class="flex-2 items-center w-full text-primary-900 text-sm font-medium">
<p class="align-middle text-sm font-medium pt-1">Date issued</p>
<div class="flex-2 text-primary-900 w-full items-center text-sm font-medium">
<p class="pt-1 align-middle text-sm font-medium">Date issued</p>
<p class="opacity-0">personalized</p>
</div>
</StructuredListCell>
@@ -61,19 +53,17 @@
{#each students as item (item.id)}
<StructuredListRow label for="row-{item}">
<StructuredListCell>
<div
class="flex-3 flex flex-row items-center justify-start w-full text-sm font-medium pt-4"
>
<div class="flex-3 flex w-full flex-row items-center justify-start pt-4 text-sm font-medium">
<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>
</StructuredListCell>
<StructuredListCell>
<div class="flex-2 flex flex-row items-center gap-10 w-full">
<p class="text-sm font-normal align-middle">{item.issued}</p>
<div class="flex-2 flex w-full flex-row items-center gap-10">
<p class="align-middle text-sm font-normal">{item.issued}</p>
{#if item.peronalized == true}
<span class="text-center bg-primary-100 px-3 py-2 rounded-lg">
<p class="opacity-100 text-xs text-primary-600">personalized</p>
<span class="bg-primary-100 rounded-lg px-3 py-2 text-center">
<p class="text-primary-600 text-xs opacity-100">personalized</p>
</span>
{:else}
<p class="opacity-0">personalized</p>
@@ -4,12 +4,12 @@
</script>
<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]" />
<p class="text-xl font-normal text-center">
<p class="text-center text-xl font-normal">
{$t('course.navItem.certificates.locked_certificate')}
</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.contact_instructor')}
</p>
@@ -1,8 +1,7 @@
<script lang="ts">
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 { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
@@ -15,8 +14,8 @@
import { snackbar } from '$lib/components/Snackbar/store';
import { classroomio } from '$lib/utils/services/api';
let isLoading = false;
let isCourseComplete = false;
let isLoading = $state(false);
let isCourseComplete = $state(false);
let progress: ProfileCourseProgress | undefined;
const downLoadCertificate = async () => {
@@ -73,12 +72,16 @@
hasUserCompletedCourse();
});
$: title = isCourseComplete
let title = $derived(
isCourseComplete
? 'course.navItem.certificates.unlocked_certificate'
: 'course.navItem.certificates.complete_to_download_title';
$: subtitle = isCourseComplete
: 'course.navItem.certificates.complete_to_download_title'
);
let subtitle = $derived(
isCourseComplete
? 'course.navItem.certificates.unlocked_certificate_subtitle'
: 'course.navItem.certificates.complete_to_download_subtitle';
: 'course.navItem.certificates.complete_to_download_subtitle'
);
</script>
<Box>
@@ -94,10 +97,10 @@
className="flex items-center gap-2"
onClick={downLoadCertificate}
variant={VARIANTS.CONTAINED_DARK}
isDisabled={!env.PUBLIC_SERVER_URL || !isCourseComplete}
isDisabled={!isCourseComplete}
{isLoading}
>
<Download size={16} />
<DownloadIcon size={16} />
{$t('course.navItem.certificates.download_certificate')}
</PrimaryButton>
</div>
@@ -1,60 +1,56 @@
<script>
<script lang="ts">
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
export let studentName = '';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="w-full relative dark:text-black">
<img
src="/images/blue-badge-pattern-background.svg"
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="relative w-full dark:text-black">
<img src="/images/blue-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-[17rem]">
<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" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="font-bold mt-16">
<h2 class="text-[7px] m-0">This certificate is awarded to</h2>
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#2D499F]">{studentName}</h1>
<div class="mt-16 font-bold">
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
</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>
<h2 class="text-lg m-0">
<h2 class="m-0 text-lg">
{$course.title}
</h2>
</div>
<div class="flex justify-between items-center my-8 text-center">
<div class="my-8 flex items-center justify-between text-center">
<div>
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p>
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black text-[7px] font-bold">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<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>
<footer class="text-[8px] font-bold w-[90%] desc">
<footer class="desc w-[90%] text-[8px] font-bold">
{$course.description}
</footer>
</div>
<div class="overflow-hidden flex justify-center items-center">
<img
src="/images/blue-badge-pattern-badge.svg"
alt="A badge"
class="max-w-none w-[8rem] md:w-[10rem]"
/>
<div class="flex items-center justify-center overflow-hidden">
<img src="/images/blue-badge-pattern-badge.svg" alt="A badge" class="w-[8rem] max-w-none md:w-[10rem]" />
</div>
</div>
</div>
@@ -1,45 +1,49 @@
<script>
<script lang="ts">
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
export let studentName = '';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="p-3 w-full dark:text-black bg-[#2D499F]">
<div class="w-full bg-white pb-5 flex">
<div class="w-full bg-[#2D499F] p-3 dark:text-black">
<div class="flex w-full bg-white pb-5">
<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" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="font-bold mt-7">
<h2 class="text-[7px] m-0">This certificate is awarded to</h2>
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#2D499F]">{studentName}</h1>
<div class="mt-7 font-bold">
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
</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>
<h2 class="text-lg m-0">
<h2 class="m-0 text-lg">
{$course.title}
</h2>
</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="">
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p>
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black text-[7px] font-bold">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<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>
<footer class="text-[8px] font-bold w-[90%] desc">
<footer class="desc w-[90%] text-[8px] font-bold">
{$course.description}
</footer>
</div>
@@ -1,36 +1,34 @@
<script>
<script lang="ts">
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
export let studentName = '';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const borderwidth = 'border-4';
const bordercolor = 'border-pink-700';
const logo = '/logo-512.png';
</script>
<div
class="certificate-bg w-full flex flex-col items-center justify-center {borderwidth} {bordercolor} py-3"
>
<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"
/>
<div class="certificate-bg flex w-full 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" />
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
</div>
<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
</p>
<div class="border-b border-gray-500 mb-4">
<p class="text-xs text-black font-medium">This is to certify that</p>
<div class="mb-4 border-b border-gray-500">
<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>
</div>
<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>
<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}
</p>
</div>
@@ -1,44 +1,38 @@
<script>
<script lang="ts">
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
export let studentName = '';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const borderwidth = 'border-l-8';
const bordercolor = 'border-primary-800';
const logo = '/logo-512.png';
</script>
<div
class="certificate-bg w-full flex items-center justify-center border border-dashed border-primary-400"
>
<div class="certificate-bg border-primary-400 flex w-full items-center justify-center border border-dashed">
<div class="{borderwidth} {bordercolor} w-full">
<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
</p>
<div class="border-b border-gray-500 mb-4">
<p class="text-xs text-black font-medium">This is to certify that</p>
<div class="mb-4 border-b border-gray-500">
<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>
</div>
<div class="mb-2">
<p class="text-xs text-black font-medium">
has successfully completed introductory courses in
</p>
<p class="text-xs font-medium text-black">has successfully completed introductory courses in</p>
<div>
<p
class="clamp bg-transparent text-base font-semibold text-primary-800 text-center uppercase my-2"
>
<p class="clamp text-primary-800 my-2 bg-transparent text-center text-base font-semibold uppercase">
{$course.title}
</p>
</div>
</div>
<p class="desc mb-4 text-justify dark:text-black">{$course.description}</p>
<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"
/>
<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" />
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
</div>
</div>
@@ -1,60 +1,56 @@
<script>
<script lang="ts">
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
export let studentName = '';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="w-full relative dark:text-black">
<img
src="/images/purple-badge-pattern-background.svg"
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="relative w-full dark:text-black">
<img src="/images/purple-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-[17rem]">
<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" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="font-bold mt-16">
<h2 class="text-[7px] m-0">This certificate is awarded to</h2>
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#914397]">{studentName}</h1>
<div class="mt-16 font-bold">
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#914397]">{studentName}</h1>
</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>
<h2 class="text-lg m-0">
<h2 class="m-0 text-lg">
{$course.title}
</h2>
</div>
<div class="flex justify-between items-center my-8 text-center">
<div class="my-8 flex items-center justify-between text-center">
<div>
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p>
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black text-[7px] font-bold">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<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>
<footer class="text-[8px] font-bold w-[90%] desc">
<footer class="desc w-[90%] text-[8px] font-bold">
{$course.description}
</footer>
</div>
<div class="overflow-hidden flex justify-center items-center">
<img
src="/images/purple-badge-pattern-badge.svg"
alt="A badge"
class="max-w-none w-[8rem] md:w-[10rem]"
/>
<div class="flex items-center justify-center overflow-hidden">
<img src="/images/purple-badge-pattern-badge.svg" alt="A badge" class="w-[8rem] max-w-none md:w-[10rem]" />
</div>
</div>
</div>
@@ -1,45 +1,49 @@
<script>
<script lang="ts">
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
export let studentName = '';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="p-3 w-full dark:text-black bg-[#95449A]">
<div class="w-full bg-white pb-5 flex">
<div class="w-full bg-[#95449A] p-3 dark:text-black">
<div class="flex w-full bg-white pb-5">
<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" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="font-bold mt-7">
<h2 class="text-[7px] m-0">This certificate is awarded to</h2>
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#95449A]">{studentName}</h1>
<div class="mt-7 font-bold">
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#95449A]">{studentName}</h1>
</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>
<h2 class="text-lg m-0">
<h2 class="m-0 text-lg">
{$course.title}
</h2>
</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="">
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p>
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black text-[7px] font-bold">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<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>
<footer class="text-[8px] font-bold w-[90%] desc">
<footer class="desc w-[90%] text-[8px] font-bold">
{$course.description}
</footer>
</div>
@@ -7,9 +7,13 @@
import { course } from '../../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 () => {
isActivating = true;
@@ -37,12 +41,12 @@
maxWidth="max-w-xl"
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">
<h3 class="text-center text-2xl">
{$t('course.navItem.lessons.section_prompt.title')}
</h3>
<p class="text-center max-w-md">
<p class="max-w-md text-center">
{$t('course.navItem.lessons.section_prompt.description')}
</p>
</div>
@@ -4,8 +4,12 @@
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { t } from '$lib/utils/functions/translations';
export let openDeleteModal = false;
export let deleteLesson = () => {};
interface Props {
openDeleteModal?: boolean;
deleteLesson?: any;
}
let { openDeleteModal = $bindable(false), deleteLesson = () => {} }: Props = $props();
async function handleDelete() {
deleteLesson();
@@ -21,7 +25,7 @@
modalHeading={$t('delete_modal.label')}
>
<div>
<h1 class="dark:text-white text-lg">
<h1 class="text-lg dark:text-white">
{$t('delete_modal.content')}?
</h1>
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { t } from '$lib/utils/functions/translations';
@@ -6,8 +6,7 @@
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { deleteConfirmation } from '../store/exercise';
export let onDelete = () => {};
export let onCancel = () => {};
let { onDelete = () => {}, onCancel = () => {} } = $props();
</script>
<Modal
@@ -1,4 +1,6 @@
<script>
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import {
questionnaire,
deleteConfirmation,
@@ -12,17 +14,16 @@
} from '../store/exercise';
import { Select, SelectItem } from 'carbon-components-svelte';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import AddFilledIcon from 'carbon-icons-svelte/lib/AddFilled.svelte';
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
import TrashIcon from '@lucide/svelte/icons/trash';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import Checkbox from '$lib/components/Form/Checkbox.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 PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
@@ -30,21 +31,23 @@
import DeleteConfirmationModal from './DeleteConfirmation.svelte';
import OrderModal from './OrderModal.svelte';
import { QUESTION_TYPE, QUESTION_TYPES } from '$lib/components/Question/constants';
import { filterOutDeleted } from './functions';
import { deleteExercise } from '$lib/utils/services/courses';
import { lesson } from '../store/lessons';
import { t } from '$lib/utils/functions/translations';
const initialQuestionsLength = $questionnaire.questions.length;
export let shouldDeleteExercise = false;
export let exerciseId;
export let goBack = () => {};
interface Props {
shouldDeleteExercise?: boolean;
exerciseId: any;
goBack?: any;
}
let errors = {};
let questionIdToDelete = null;
let questions = [];
let isDeleting = false;
let { shouldDeleteExercise = $bindable(false), exerciseId, goBack = () => {} }: Props = $props();
let questionIdToDelete = $state(null);
let isDeleting = $state(false);
let errors = $derived($questionnaireValidation);
function shouldScrollToLast(questionId, questions) {
const [lastQuestion] = questions.slice(-1);
@@ -70,25 +73,21 @@
}
async function handleDelete() {
console.log('delete');
isDeleting = true;
await deleteExercise(questions, exerciseId);
const qToDelete = $questionnaire.questions.map((q) => ({ id: `${q.id}` }));
await deleteExercise(qToDelete, exerciseId);
lesson.update((_lesson) => ({
..._lesson,
exercises: _lesson.exercises.filter((exercise) => exercise.id !== exerciseId)
}));
goBack();
}
$: errors = $questionnaireValidation;
$: questions = filterOutDeleted($questionnaire.questions);
</script>
<DeleteConfirmationModal
onCancel={() => (questionIdToDelete = null)}
onDelete={onFinalDeleteClicked}
/>
<DeleteConfirmationModal onCancel={() => (questionIdToDelete = null)} onDelete={onFinalDeleteClicked} />
<OrderModal />
@@ -98,8 +97,8 @@
width="w-2/4"
modalHeading={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}
>
<form on:submit|preventDefault>
<h1 class="dark:text-white text-xl">
<form onsubmit={preventDefault()}>
<h1 class="text-xl dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
</h1>
@@ -124,18 +123,34 @@
</form>
</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}
<div class="w-full flex justify-center mb-4">
<ErrorMessage
message={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.error')}
/>
<div class="mb-4 flex w-full justify-center">
<ErrorMessage message={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.error')} />
</div>
{/if}
<div class="questions pt-6 px-6">
{#each questions as question (question.id)}
<!-- {#key question.id} -->
<div class="questions px-6 pt-6">
{#each $questionnaire.questions as question, index}
{#if !question.deleted_at}
<QuestionContainer
key={question.deleted_at}
onClose={onInitDeleteClicked(question.id)}
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
bind:points={question.points}
@@ -144,7 +159,7 @@
question.is_dirty = true;
}}
>
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<div class="mr-5 w-3/5">
<TextField
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.question')}
@@ -159,10 +174,13 @@
<Select
size="xl"
class="w-[50px]"
bind:selected={question.question_type.id}
selected={question?.question_type?.id}
on:change={(e) => {
const id = parseInt(e.target?.value);
question.question_type = QUESTION_TYPES.find((q) => q.id === id);
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;
}}
>
@@ -173,80 +191,42 @@
</div>
{#if typeof question.code === 'string'}
<div class="flex justify-between items-center my-3 w-3/5">
<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)}>
<TrashCanIcon size={24} class="carbon-icon dark:text-white" />
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
<div class="flex flex-col mt-2">
<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}
{#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>
{@render optionActions(question, option)}
</RadioItem>
{/each}
{/if}
{#if QUESTION_TYPE.CHECKBOX === question.question_type.id}
{#each filterOutDeleted(question.options) as option}
{:else if QUESTION_TYPE.CHECKBOX === question.question_type.id}
<Checkbox
isEditable={true}
name={question || 'checkbox-name'}
name={question?.name || '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>
{@render optionActions(question, option)}
</Checkbox>
{/each}
{/if}
{/if}
{/each}
{#if QUESTION_TYPE.TEXTAREA === question.question_type.id}
<TextArea bind:value={question.value} disabled={true} />
@@ -258,23 +238,22 @@
</div>
{#if QUESTION_TYPE.TEXTAREA !== question.question_type.id}
<div class="flex items-center mt-3">
<div class="mt-3 flex items-center">
<PrimaryButton
disablePadding={true}
className="p-2"
variant={VARIANTS.OUTLINED}
onClick={handleAddOption(question.id)}
>
<AddFilledIcon size={24} class="carbon-icon dark:text-white" />
<p class="dark:text-white ml-2">
<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>
<!-- {/key} -->
{/if}
{/each}
</div>
</div>

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