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