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