feat: lint codebase and run precommit hook

This commit is contained in:
Rotimi Best
2025-12-25 09:01:51 +00:00
parent 334c67f22a
commit b31ccf3037
214 changed files with 6191 additions and 2880 deletions
+9 -1
View File
@@ -5,4 +5,12 @@ build
_template _template
supabase supabase
static static
dist dist
docker
**/course-app
apps/docs
apps/api/static
pnpm-lock.yaml
*.md
+1
View File
@@ -0,0 +1 @@
static
+49 -47
View File
@@ -56,9 +56,9 @@ GET /lessons (which course?)
```typescript ```typescript
// ✅ OK - Top-level resources // ✅ OK - Top-level resources
GET /account GET / account;
PUT /account/user PUT / account / user;
GET /account/subscription GET / account / subscription;
``` ```
--- ---
@@ -67,13 +67,13 @@ GET /account/subscription
Use HTTP methods correctly for their intended purpose: Use HTTP methods correctly for their intended purpose:
| Method | Purpose | Idempotent | Response Body | | Method | Purpose | Idempotent | Response Body |
|--------|---------|------------|---------------| | -------- | ----------------------- | ---------- | ------------------- |
| `GET` | Retrieve resource(s) | ✅ Yes | Always | | `GET` | Retrieve resource(s) | ✅ Yes | Always |
| `POST` | Create new resource | ❌ No | Created resource | | `POST` | Create new resource | ❌ No | Created resource |
| `PUT` | Replace entire resource | ✅ Yes | Updated resource | | `PUT` | Replace entire resource | ✅ Yes | Updated resource |
| `PATCH` | Partial update | ✅ Yes | Updated resource | | `PATCH` | Partial update | ✅ Yes | Updated resource |
| `DELETE` | Remove resource | ✅ Yes | Usually empty (204) | | `DELETE` | Remove resource | ✅ Yes | Usually empty (204) |
```typescript ```typescript
// ✅ GOOD - Correct method usage // ✅ GOOD - Correct method usage
@@ -112,11 +112,13 @@ POST /lessons?courseId=123 // Relationship not obvious
``` ```
**When to nest:** **When to nest:**
- ✅ Child resource only exists within parent context - ✅ Child resource only exists within parent context
- ✅ Child resource ID is unique within parent (e.g., `lessonId` within `courseId`) - ✅ Child resource ID is unique within parent (e.g., `lessonId` within `courseId`)
- ✅ Operations typically scoped to parent - ✅ Operations typically scoped to parent
**When NOT to nest:** **When NOT to nest:**
- ❌ Resource can exist independently - ❌ Resource can exist independently
- ❌ Resource ID is globally unique - ❌ Resource ID is globally unique
- ❌ Operations span multiple parents - ❌ Operations span multiple parents
@@ -154,6 +156,7 @@ GET /get-courses // Redundant verb
**Pattern:** `POST /resource/:id/action` **Pattern:** `POST /resource/:id/action`
**When to use:** **When to use:**
- ✅ State transitions (publish, unpublish, activate, cancel) - ✅ State transitions (publish, unpublish, activate, cancel)
- ✅ Side effects (clone, duplicate, export) - ✅ Side effects (clone, duplicate, export)
- ✅ Complex operations that don't fit CRUD - ✅ Complex operations that don't fit CRUD
@@ -181,14 +184,14 @@ GET /courses/search/javascript
**Common query parameter patterns:** **Common query parameter patterns:**
| Purpose | Parameter | Example | | Purpose | Parameter | Example |
|---------|-----------|---------| | ------------- | ------------------------- | ---------------------------------- |
| Filtering | `status`, `type`, `role` | `?status=active&type=course` | | Filtering | `status`, `type`, `role` | `?status=active&type=course` |
| Search | `q`, `search` | `?q=javascript` | | Search | `q`, `search` | `?q=javascript` |
| Pagination | `page`, `limit`, `offset` | `?page=1&limit=20` | | Pagination | `page`, `limit`, `offset` | `?page=1&limit=20` |
| Sorting | `sort`, `order` | `?sort=created_at&order=desc` | | Sorting | `sort`, `order` | `?sort=created_at&order=desc` |
| Date range | `startDate`, `endDate` | `?startDate=2024-01-01` | | Date range | `startDate`, `endDate` | `?startDate=2024-01-01` |
| Boolean flags | `include`, `expand` | `?include=lessons&expand=metadata` | | Boolean flags | `include`, `expand` | `?include=lessons&expand=metadata` |
--- ---
@@ -303,9 +306,9 @@ import { organizationRouter } from '@api/routes/organization';
import { accountRouter } from '@api/routes/account'; import { accountRouter } from '@api/routes/account';
export const app = new Hono() export const app = new Hono()
.route('/course', courseRouter) // /course/* .route('/course', courseRouter) // /course/*
.route('/organization', organizationRouter) // /organization/* .route('/organization', organizationRouter) // /organization/*
.route('/account', accountRouter); // /account/* .route('/account', accountRouter); // /account/*
``` ```
--- ---
@@ -321,37 +324,37 @@ export const courseRouter = new Hono()
.get('/', authMiddleware, zValidator('query', ZListCourses), async (c) => { .get('/', authMiddleware, zValidator('query', ZListCourses), async (c) => {
// GET /course?orgId=123&status=published&page=1&limit=20 // GET /course?orgId=123&status=published&page=1&limit=20
}) })
// Get single course // Get single course
.get('/:courseId', authMiddleware, zValidator('param', ZGetCourse), async (c) => { .get('/:courseId', authMiddleware, zValidator('param', ZGetCourse), async (c) => {
// GET /course/:courseId // GET /course/:courseId
}) })
// Create course // Create course
.post('/', authMiddleware, zValidator('json', ZCreateCourse), async (c) => { .post('/', authMiddleware, zValidator('json', ZCreateCourse), async (c) => {
// POST /course // POST /course
}) })
// Update course (full) // Update course (full)
.put('/:courseId', authMiddleware, zValidator('json', ZUpdateCourse), async (c) => { .put('/:courseId', authMiddleware, zValidator('json', ZUpdateCourse), async (c) => {
// PUT /course/:courseId // PUT /course/:courseId
}) })
// Update course (partial) // Update course (partial)
.patch('/:courseId', authMiddleware, zValidator('json', ZPatchCourse), async (c) => { .patch('/:courseId', authMiddleware, zValidator('json', ZPatchCourse), async (c) => {
// PATCH /course/:courseId // PATCH /course/:courseId
}) })
// Delete course // Delete course
.delete('/:courseId', authMiddleware, zValidator('param', ZDeleteCourse), async (c) => { .delete('/:courseId', authMiddleware, zValidator('param', ZDeleteCourse), async (c) => {
// DELETE /course/:courseId // DELETE /course/:courseId
}) })
// Actions // Actions
.post('/:courseId/clone', authMiddleware, zValidator('json', ZCloneCourse), async (c) => { .post('/:courseId/clone', authMiddleware, zValidator('json', ZCloneCourse), async (c) => {
// POST /course/:courseId/clone // POST /course/:courseId/clone
}) })
.post('/:courseId/publish', authMiddleware, zValidator('param', ZPublishCourse), async (c) => { .post('/:courseId/publish', authMiddleware, zValidator('param', ZPublishCourse), async (c) => {
// POST /course/:courseId/publish // POST /course/:courseId/publish
}); });
@@ -366,22 +369,22 @@ export const lessonRouter = new Hono()
.get('/', authMiddleware, zValidator('query', ZListLessons), async (c) => { .get('/', authMiddleware, zValidator('query', ZListLessons), async (c) => {
// GET /course/:courseId/lesson?page=1&limit=20 // GET /course/:courseId/lesson?page=1&limit=20
}) })
// Get single lesson // Get single lesson
.get('/:lessonId', authMiddleware, zValidator('param', ZGetLesson), async (c) => { .get('/:lessonId', authMiddleware, zValidator('param', ZGetLesson), async (c) => {
// GET /course/:courseId/lesson/:lessonId // GET /course/:courseId/lesson/:lessonId
}) })
// Create lesson // Create lesson
.post('/', authMiddleware, zValidator('json', ZCreateLesson), async (c) => { .post('/', authMiddleware, zValidator('json', ZCreateLesson), async (c) => {
// POST /course/:courseId/lesson // POST /course/:courseId/lesson
}) })
// Update lesson // Update lesson
.put('/:lessonId', authMiddleware, zValidator('json', ZUpdateLesson), async (c) => { .put('/:lessonId', authMiddleware, zValidator('json', ZUpdateLesson), async (c) => {
// PUT /course/:courseId/lesson/:lessonId // PUT /course/:courseId/lesson/:lessonId
}) })
// Delete lesson // Delete lesson
.delete('/:lessonId', authMiddleware, zValidator('param', ZDeleteLesson), async (c) => { .delete('/:lessonId', authMiddleware, zValidator('param', ZDeleteLesson), async (c) => {
// DELETE /course/:courseId/lesson/:lessonId // DELETE /course/:courseId/lesson/:lessonId
@@ -391,7 +394,7 @@ export const lessonRouter = new Hono()
// apps/api/src/routes/course/course.ts // apps/api/src/routes/course/course.ts
export const courseRouter = new Hono() export const courseRouter = new Hono()
// ... course routes ... // ... course routes ...
.route('/:courseId/lesson', lessonRouter); // Nested under course .route('/:courseId/lesson', lessonRouter); // Nested under course
``` ```
### Example 3: Organization with Team ### Example 3: Organization with Team
@@ -403,31 +406,31 @@ export const organizationRouter = new Hono()
.get('/', authMiddleware, zValidator('query', ZListOrgs), async (c) => { .get('/', authMiddleware, zValidator('query', ZListOrgs), async (c) => {
// GET /organization?siteName=acme // GET /organization?siteName=acme
}) })
// Get organization // Get organization
.get('/:orgId', authMiddleware, zValidator('param', ZGetOrg), async (c) => { .get('/:orgId', authMiddleware, zValidator('param', ZGetOrg), async (c) => {
// GET /organization/:orgId // GET /organization/:orgId
}) })
// Create organization // Create organization
.post('/', authMiddleware, zValidator('json', ZCreateOrg), async (c) => { .post('/', authMiddleware, zValidator('json', ZCreateOrg), async (c) => {
// POST /organization // POST /organization
}) })
// Update organization // Update organization
.put('/:orgId', authMiddleware, zValidator('json', ZUpdateOrg), async (c) => { .put('/:orgId', authMiddleware, zValidator('json', ZUpdateOrg), async (c) => {
// PUT /organization/:orgId // PUT /organization/:orgId
}) })
// Team management (nested) // Team management (nested)
.get('/:orgId/team', authMiddleware, zValidator('param', ZGetTeam), async (c) => { .get('/:orgId/team', authMiddleware, zValidator('param', ZGetTeam), async (c) => {
// GET /organization/:orgId/team // GET /organization/:orgId/team
}) })
.post('/:orgId/team/invite', authMiddleware, zValidator('json', ZInviteTeam), async (c) => { .post('/:orgId/team/invite', authMiddleware, zValidator('json', ZInviteTeam), async (c) => {
// POST /organization/:orgId/team/invite // POST /organization/:orgId/team/invite
}) })
.delete('/:orgId/team/:memberId', authMiddleware, zValidator('param', ZRemoveMember), async (c) => { .delete('/:orgId/team/:memberId', authMiddleware, zValidator('param', ZRemoveMember), async (c) => {
// DELETE /organization/:orgId/team/:memberId // DELETE /organization/:orgId/team/:memberId
}); });
@@ -457,16 +460,16 @@ DELETE /courses/:id
```typescript ```typescript
// ❌ BAD // ❌ BAD
GET /course GET / course;
POST /courses POST / courses;
GET /organization GET / organization;
POST /organizations POST / organizations;
// ✅ GOOD // ✅ GOOD
GET /courses GET / courses;
POST /courses POST / courses;
GET /organizations GET / organizations;
POST /organizations POST / organizations;
``` ```
### ❌ Don't: Use query parameters for required identifiers ### ❌ Don't: Use query parameters for required identifiers
@@ -540,4 +543,3 @@ When creating a new route, ensure:
8. **Organized by domain** in code structure 8. **Organized by domain** in code structure
Following these conventions ensures your API is **predictable**, **maintainable**, and **developer-friendly**. Following these conventions ensures your API is **predictable**, **maintainable**, and **developer-friendly**.
+1 -1
View File
@@ -17,7 +17,7 @@
"test": "vitest NODE_ENV=test", "test": "vitest NODE_ENV=test",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"lint": "eslint . --ext .ts", "lint": "eslint . --ext .ts",
"format": "prettier --write \"src/**/*.ts\"", "format": "prettier --write .",
"upload:openapi": "tsx scripts/upload-openapi-spec.ts" "upload:openapi": "tsx scripts/upload-openapi-spec.ts"
}, },
"repository": { "repository": {
+3 -1
View File
@@ -11,6 +11,7 @@ pnpm run upload:openapi
``` ```
### Direct script usage ### Direct script usage
```bash ```bash
tsx scripts/upload-openapi-spec.ts tsx scripts/upload-openapi-spec.ts
``` ```
@@ -49,6 +50,7 @@ This script is integrated with a GitHub Action that automatically generates and
### Automatic Workflow ### Automatic Workflow
The GitHub Action runs on: The GitHub Action runs on:
- **Push to main** - Automatically uploads to R2 - **Push to main** - Automatically uploads to R2
- **Pull Requests** - Generates spec for review (no upload) - **Pull Requests** - Generates spec for review (no upload)
- **Manual dispatch** - Allows manual triggering with custom parameters - **Manual dispatch** - Allows manual triggering with custom parameters
@@ -57,7 +59,7 @@ The GitHub Action runs on:
1. **Required secrets:** 1. **Required secrets:**
- `CLOUDFLARE_ACCESS_KEY` - `CLOUDFLARE_ACCESS_KEY`
- `CLOUDFLARE_SECRET_ACCESS_KEY` - `CLOUDFLARE_SECRET_ACCESS_KEY`
- `CLOUDFLARE_ACCOUNT_ID` - `CLOUDFLARE_ACCOUNT_ID`
- `PUBLIC_SUPABASE_URL` - `PUBLIC_SUPABASE_URL`
- `PUBLIC_SUPABASE_ANON_KEY` - `PUBLIC_SUPABASE_ANON_KEY`
+1 -5
View File
@@ -24,11 +24,7 @@ class OpenAPISpecGenerator {
} }
private initializeS3Client() { private initializeS3Client() {
if ( if (env.CLOUDFLARE_ACCESS_KEY && env.CLOUDFLARE_SECRET_ACCESS_KEY && env.CLOUDFLARE_ACCOUNT_ID) {
env.CLOUDFLARE_ACCESS_KEY &&
env.CLOUDFLARE_SECRET_ACCESS_KEY &&
env.CLOUDFLARE_ACCOUNT_ID
) {
this.s3Client = new S3Client({ this.s3Client = new S3Client({
region: 'auto', region: 'auto',
endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`, endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
-1
View File
@@ -36,7 +36,6 @@ export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareH
} }
try { try {
// Generate rate limit key // Generate rate limit key
const key = opts.keyGenerator(c); const key = opts.keyGenerator(c);
+2 -8
View File
@@ -13,19 +13,13 @@ export class RedisRateLimiter {
private windowMs: number; private windowMs: number;
private maxRequests: number; private maxRequests: number;
constructor( constructor(redis: Redis, windowMs: number = DEFAULT_WINDOW_MS, maxRequests: number = DEFAULT_MAX_REQUESTS) {
redis: Redis,
windowMs: number = DEFAULT_WINDOW_MS,
maxRequests: number = DEFAULT_MAX_REQUESTS
) {
this.redis = redis; this.redis = redis;
this.windowMs = windowMs; this.windowMs = windowMs;
this.maxRequests = maxRequests; this.maxRequests = maxRequests;
} }
async isAllowed( async isAllowed(key: string): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
key: string
): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
const now = Date.now(); const now = Date.now();
const windowStart = now - this.windowMs; const windowStart = now - this.windowMs;
const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`; const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`;
+11 -8
View File
@@ -154,20 +154,23 @@
<section> <section>
<h1 class="text-xl font-bold my-1">Extra Resources</h1> <h1 class="text-xl font-bold my-1">Extra Resources</h1>
<div class="flex mt-2 mb-1"> <div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=https://youtu.be/dyLaKhePzj8 style="color: blue" <p class="m-0 mr-2">Slide Link:</p> <a href="https://youtu.be/dyLaKhePzj8" style="color: blue"
class="underline">Open Here</a> class="underline">Open Here</a>
</div> </div>
<p class="m-0">Video Link:</p> <p class="m-0">Video Link:</p>
<div class="m-0"> <div class="m-0">
<ol class="m-0"> <ol class="m-0">
<li>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue" <a href="https://youtu.be/0fYi8SGA20k" style="color: blue" class="underline">
class="underline">https://youtu.be/0fYi8SGA20k</a></li> https://youtu.be/0fYi8SGA20k
</a>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue" </li>
class="underline">https://youtu.be/0fYi8SGA20k</a></li> <li>
<a href="https://youtu.be/0fYi8SGA20k" style="color: blue" class="underline">
https://youtu.be/0fYi8SGA20k
</a>
</li>
</ol> </ol>
</div> </div>
+14 -9
View File
@@ -158,20 +158,25 @@
<section> <section>
<h1 class="text-xl font-bold my-1">Extra Resources</h1> <h1 class="text-xl font-bold my-1">Extra Resources</h1>
<div class="flex mt-2 mb-1"> <div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=https://youtu.be/dyLaKhePzj8 style="color: blue" <p class="m-0 mr-2">Slide Link:</p>
class="underline">Open Here</a> <a href="https://youtu.be/dyLaKhePzj8" style="color: blue" class="underline">
Open Here
</a>
</div> </div>
<p class="m-0">Video Link:</p> <p class="m-0">Video Link:</p>
<div class="m-0"> <div class="m-0">
<ol class="m-0"> <ol class="m-0">
<li>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue" <a href="https://youtu.be/0fYi8SGA20k" style="color: blue" class="underline">
class="underline">https://youtu.be/0fYi8SGA20k</a></li> https://youtu.be/0fYi8SGA20k
</a>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue" </li>
class="underline">https://youtu.be/0fYi8SGA20k</a></li> <li>
<a href="https://youtu.be/0fYi8SGA20k" style="color: blue" class="underline">
https://youtu.be/0fYi8SGA20k
</a>
</li>
</ol> </ol>
</div> </div>
-4
View File
@@ -1,4 +0,0 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
-15
View File
@@ -1,15 +0,0 @@
{
"useTabs": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+13 -13
View File
@@ -1,14 +1,14 @@
{ {
"$schema": "https://shadcn-svelte.com/schema.json", "$schema": "https://shadcn-svelte.com/schema.json",
"style": "new-york", "style": "new-york",
"tailwind": { "tailwind": {
"config": "tailwind.config.ts", "config": "tailwind.config.ts",
"css": "src/app.css", "css": "src/app.css",
"baseColor": "zinc" "baseColor": "zinc"
}, },
"aliases": { "aliases": {
"components": "$lib/components", "components": "$lib/components",
"utils": "$lib/utils" "utils": "$lib/utils"
}, },
"typescript": true "typescript": true
} }
+2 -1
View File
@@ -9,7 +9,8 @@
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .", "format": "echo 'disabled'",
"format:run": "prettier --write .",
"lint": "prettier --check .", "lint": "prettier --check .",
"test:unit": "vitest", "test:unit": "vitest",
"test": "npm run test:unit -- --run" "test": "npm run test:unit -- --run"
+4 -4
View File
@@ -1,6 +1,6 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {} autoprefixer: {}
} }
}; };
+7 -7
View File
@@ -1,13 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}
} }
} }
export {}; export {};
+49 -38
View File
@@ -1,47 +1,58 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<head> <title>Courseapp by ClassroomIO –&nbsp;Create your SAAS Academy in Minutes</title>
<meta charset="utf-8" /> <meta name="description" content="Courseapp" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" /> <meta name="application-name" content="Courseapp" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="apple-mobile-web-app-title" content="Courseapp" />
<meta name="theme-color" content="#ffffff" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Courseapp by ClassroomIO –&nbsp;Create your SAAS Academy in Minutes</title> <!-- Facebook Meta Tags -->
<meta name="description" content="Courseapp" /> <meta property="og:url" content="https://www.courseapp.oncws.com/" />
<meta name="application-name" content="Courseapp" /> <meta property="og:image" itemprop="image" content="https://cdn.courseapp.oncws.com/courseapp-og.png" />
<meta name="apple-mobile-web-app-title" content="Courseapp" /> <meta property="og:title" content="Courseapp by ClassroomIO –&nbsp;Create your SAAS Academy in Minutes" />
<meta name="theme-color" content="#ffffff" /> <meta
<meta name="viewport" content="width=device-width, initial-scale=1" /> property="og:description"
content="Series of templates available for you to clone, customise and launch your SAAS Academy. Scale your customer education effort today."
/>
<!-- Facebook Meta Tags --> <meta property="og:type" content="website" />
<meta property="og:url" content="https://www.courseapp.oncws.com/" /> <meta property="og:image:type" content="image/png" />
<meta property="og:image" itemprop="image" content="https://cdn.courseapp.oncws.com/courseapp-og.png" /> <meta property="og:image:width" content="1920" />
<meta property="og:title" content="Courseapp by ClassroomIO –&nbsp;Create your SAAS Academy in Minutes" /> <meta property="og:image:height" content="1080" />
<meta property="og:description" <meta property="og:image:secure_url" itemprop="image" content="https://cdn.courseapp.oncws.com/courseapp-og.png" />
content="Series of templates available for you to clone, customise and launch your SAAS Academy. Scale your customer education effort today." />
<meta property="og:type" content="website" /> <meta name="twitter:card" content="summary_large_image" />
<meta property="og:image:type" content="image/png" /> <meta property="twitter:domain" content="courseapp.oncws.com" />
<meta property="og:image:width" content="1920" /> <meta property="twitter:url" content="https://www.courseapp.oncws.com/" />
<meta property="og:image:height" content="1080" /> <meta name="twitter:title" content="Courseapp by ClassroomIO –&nbsp;Create your SAAS Academy in Minutes" />
<meta property="og:image:secure_url" itemprop="image" content="https://cdn.courseapp.oncws.com/courseapp-og.png" /> <meta
name="twitter:description"
content="Series of templates available for you to clone, customise and launch your SAAS Academy. Scale your customer education effort today."
/>
<meta name="twitter:image" content="https://cdn.courseapp.oncws.com/courseapp-og.png" />
<meta name="twitter:card" content="summary_large_image" /> %sveltekit.head%
<meta property="twitter:domain" content="courseapp.oncws.com" />
<meta property="twitter:url" content="https://www.courseapp.oncws.com/" />
<meta name="twitter:title" content="Courseapp by ClassroomIO –&nbsp;Create your SAAS Academy in Minutes" />
<meta name="twitter:description"
content="Series of templates available for you to clone, customise and launch your SAAS Academy. Scale your customer education effort today." />
<meta name="twitter:image" content="https://cdn.courseapp.oncws.com/courseapp-og.png" />
%sveltekit.head% <script>
(function (w, r) {
w._rwq = r;
w[r] =
w[r] ||
function () {
(w[r].q = w[r].q || []).push(arguments);
};
})(window, 'rewardful');
</script>
<script async src="https://r.wdfl.co/rw.js" data-rewardful="d216d5"></script>
</head>
<script>(function (w, r) { w._rwq = r; w[r] = w[r] || function () { (w[r].q = w[r].q || []).push(arguments) } })(window, 'rewardful');</script> <body data-sveltekit-preload-data="hover">
<script async src='https://r.wdfl.co/rw.js' data-rewardful='d216d5'></script> <div style="display: contents">%sveltekit.body%</div>
</head> </body>
</html>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+3 -3
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
describe('sum test', () => { describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => { it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3); expect(1 + 2).toBe(3);
}); });
}); });
@@ -1,50 +1,48 @@
import type { Button as ButtonPrimitive } from "bits-ui"; import type { Button as ButtonPrimitive } from 'bits-ui';
import { type VariantProps, tv } from "tailwind-variants"; import { type VariantProps, tv } from 'tailwind-variants';
import Root from "./button.svelte"; import Root from './button.svelte';
const buttonVariants = tv({ const buttonVariants = tv({
base: "focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50", base: 'focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50',
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow", default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow',
destructive: destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
"bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm", outline: 'border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm',
outline: secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm',
"border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm", ghost: 'hover:bg-accent hover:text-accent-foreground',
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm", link: 'text-primary underline-offset-4 hover:underline'
ghost: "hover:bg-accent hover:text-accent-foreground", },
link: "text-primary underline-offset-4 hover:underline", size: {
}, default: 'h-9 px-4 py-2',
size: { sm: 'h-8 rounded-md px-3 text-xs',
default: "h-9 px-4 py-2", lg: 'h-10 rounded-md px-8',
sm: "h-8 rounded-md px-3 text-xs", icon: 'h-9 w-9'
lg: "h-10 rounded-md px-8", }
icon: "h-9 w-9", },
}, defaultVariants: {
}, variant: 'default',
defaultVariants: { size: 'default'
variant: "default", }
size: "default",
},
}); });
type Variant = VariantProps<typeof buttonVariants>["variant"]; type Variant = VariantProps<typeof buttonVariants>['variant'];
type Size = VariantProps<typeof buttonVariants>["size"]; type Size = VariantProps<typeof buttonVariants>['size'];
type Props = ButtonPrimitive.Props & { type Props = ButtonPrimitive.Props & {
variant?: Variant; variant?: Variant;
size?: Size; size?: Size;
}; };
type Events = ButtonPrimitive.Events; type Events = ButtonPrimitive.Events;
export { export {
Root, Root,
type Props, type Props,
type Events, type Events,
// //
Root as Button, Root as Button,
type Props as ButtonProps, type Props as ButtonProps,
type Events as ButtonEvents, type Events as ButtonEvents,
buttonVariants, buttonVariants
}; };
@@ -1,37 +1,37 @@
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from 'bits-ui';
import Title from "./dialog-title.svelte"; import Title from './dialog-title.svelte';
import Portal from "./dialog-portal.svelte"; import Portal from './dialog-portal.svelte';
import Footer from "./dialog-footer.svelte"; import Footer from './dialog-footer.svelte';
import Header from "./dialog-header.svelte"; import Header from './dialog-header.svelte';
import Overlay from "./dialog-overlay.svelte"; import Overlay from './dialog-overlay.svelte';
import Content from "./dialog-content.svelte"; import Content from './dialog-content.svelte';
import Description from "./dialog-description.svelte"; import Description from './dialog-description.svelte';
const Root = DialogPrimitive.Root; const Root = DialogPrimitive.Root;
const Trigger = DialogPrimitive.Trigger; const Trigger = DialogPrimitive.Trigger;
const Close = DialogPrimitive.Close; const Close = DialogPrimitive.Close;
export { export {
Root, Root,
Title, Title,
Portal, Portal,
Footer, Footer,
Header, Header,
Trigger, Trigger,
Overlay, Overlay,
Content, Content,
Description, Description,
Close, Close,
// //
Root as Dialog, Root as Dialog,
Title as DialogTitle, Title as DialogTitle,
Portal as DialogPortal, Portal as DialogPortal,
Footer as DialogFooter, Footer as DialogFooter,
Header as DialogHeader, Header as DialogHeader,
Trigger as DialogTrigger, Trigger as DialogTrigger,
Overlay as DialogOverlay, Overlay as DialogOverlay,
Content as DialogContent, Content as DialogContent,
Description as DialogDescription, Description as DialogDescription,
Close as DialogClose, Close as DialogClose
}; };
@@ -1,29 +1,29 @@
import Root from "./input.svelte"; import Root from './input.svelte';
export type FormInputEvent<T extends Event = Event> = T & { export type FormInputEvent<T extends Event = Event> = T & {
currentTarget: EventTarget & HTMLInputElement; currentTarget: EventTarget & HTMLInputElement;
}; };
export type InputEvents = { export type InputEvents = {
blur: FormInputEvent<FocusEvent>; blur: FormInputEvent<FocusEvent>;
change: FormInputEvent<Event>; change: FormInputEvent<Event>;
click: FormInputEvent<MouseEvent>; click: FormInputEvent<MouseEvent>;
focus: FormInputEvent<FocusEvent>; focus: FormInputEvent<FocusEvent>;
focusin: FormInputEvent<FocusEvent>; focusin: FormInputEvent<FocusEvent>;
focusout: FormInputEvent<FocusEvent>; focusout: FormInputEvent<FocusEvent>;
keydown: FormInputEvent<KeyboardEvent>; keydown: FormInputEvent<KeyboardEvent>;
keypress: FormInputEvent<KeyboardEvent>; keypress: FormInputEvent<KeyboardEvent>;
keyup: FormInputEvent<KeyboardEvent>; keyup: FormInputEvent<KeyboardEvent>;
mouseover: FormInputEvent<MouseEvent>; mouseover: FormInputEvent<MouseEvent>;
mouseenter: FormInputEvent<MouseEvent>; mouseenter: FormInputEvent<MouseEvent>;
mouseleave: FormInputEvent<MouseEvent>; mouseleave: FormInputEvent<MouseEvent>;
mousemove: FormInputEvent<MouseEvent>; mousemove: FormInputEvent<MouseEvent>;
paste: FormInputEvent<ClipboardEvent>; paste: FormInputEvent<ClipboardEvent>;
input: FormInputEvent<InputEvent>; input: FormInputEvent<InputEvent>;
wheel: FormInputEvent<WheelEvent>; wheel: FormInputEvent<WheelEvent>;
}; };
export { export {
Root, Root,
// //
Root as Input, Root as Input
}; };
@@ -1,7 +1,7 @@
import Root from "./label.svelte"; import Root from './label.svelte';
export { export {
Root, Root,
// //
Root as Label, Root as Label
}; };
@@ -1,7 +1,7 @@
import Root from "./separator.svelte"; import Root from './separator.svelte';
export { export {
Root, Root,
// //
Root as Separator, Root as Separator
}; };
@@ -1 +1 @@
export { default as Toaster } from "./sonner.svelte"; export { default as Toaster } from './sonner.svelte';
+11 -1
View File
@@ -1 +1,11 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} {
"name": "",
"short_name": "",
"icons": [
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
+9 -9
View File
@@ -3,15 +3,15 @@ import { sveltekit } from '@sveltejs/kit/vite';
import path from 'path'; import path from 'path';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
test: { test: {
include: ['src/**/*.{test,spec}.{js,ts}'] include: ['src/**/*.{test,spec}.{js,ts}']
}, },
resolve: { resolve: {
alias: { alias: {
$lib: path.resolve('./src/lib') $lib: path.resolve('./src/lib')
} }
} }
}); });
-2
View File
@@ -100,11 +100,9 @@ Here is what you need to be able to run Cal.com.
You can install nvm from [here](https://github.com/nvm-sh/nvm). You can install nvm from [here](https://github.com/nvm-sh/nvm).
4. Set up your `.env` file 4. Set up your `.env` file
- Duplicate `.env.example` to `.env` - Duplicate `.env.example` to `.env`
5. Setup Supabase. 5. Setup Supabase.
- Make sure you've downloaded the [supabase cli](https://github.com/supabase/cli) - Make sure you've downloaded the [supabase cli](https://github.com/supabase/cli)
- Install and Start [docker](https://docs.docker.com/engine/install/) - Install and Start [docker](https://docs.docker.com/engine/install/)
- Go to the project directory in your terminal and start supabase - Go to the project directory in your terminal and start supabase
+78 -93
View File
@@ -11,13 +11,13 @@ The navigation config types now support additional properties:
```typescript ```typescript
export interface NavItemConfig { export interface NavItemConfig {
// ... existing properties ... // ... existing properties ...
// NEW: Route-specific breadcrumb label override // NEW: Route-specific breadcrumb label override
breadcrumbLabel?: string; breadcrumbLabel?: string;
// NEW: Custom resolver for dynamic breadcrumb titles // NEW: Custom resolver for dynamic breadcrumb titles
breadcrumbResolver?: (params: Record<string, string>) => string | Promise<string>; breadcrumbResolver?: (params: Record<string, string>) => string | Promise<string>;
// ENHANCED: Nested routes can now have nested routes (multi-level) // ENHANCED: Nested routes can now have nested routes (multi-level)
nestedRoutes?: NestedRouteConfig[]; nestedRoutes?: NestedRouteConfig[];
} }
@@ -25,13 +25,13 @@ export interface NavItemConfig {
export interface NestedRouteConfig { export interface NestedRouteConfig {
path: string; path: string;
titleKey: string; titleKey: string;
// NEW: Custom resolver for this nested route // NEW: Custom resolver for this nested route
titleResolver?: (params: Record<string, string>) => string | Promise<string>; titleResolver?: (params: Record<string, string>) => string | Promise<string>;
// NEW: Nested routes can have nested routes // NEW: Nested routes can have nested routes
nestedRoutes?: NestedRouteConfig[]; nestedRoutes?: NestedRouteConfig[];
// NEW: Support dynamic segments within nested routes // NEW: Support dynamic segments within nested routes
supportsDynamicSegment?: boolean; supportsDynamicSegment?: boolean;
} }
@@ -40,6 +40,7 @@ export interface NestedRouteConfig {
### Step 2: Update Breadcrumb Generation Calls ### Step 2: Update Breadcrumb Generation Calls
#### Before (Org): #### Before (Org):
```typescript ```typescript
import { generateBreadcrumbs } from './breadcrumb'; import { generateBreadcrumbs } from './breadcrumb';
@@ -53,6 +54,7 @@ const breadcrumbs = generateBreadcrumbs(
``` ```
#### After (Org - Store-Based Reactive): #### After (Org - Store-Based Reactive):
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
@@ -61,24 +63,20 @@ const breadcrumbs = generateBreadcrumbs(
import { courseStore } from '$features/course/utils/store'; import { courseStore } from '$features/course/utils/store';
// Reactive config that reads from stores // Reactive config that reads from stores
const config = $derived(createOrgBreadcrumbConfigWithStore( const config = $derived(
$currentOrgPath, createOrgBreadcrumbConfigWithStore(
() => $courseStore.title // Reads reactively - updates automatically! $currentOrgPath,
)); () => $courseStore.title // Reads reactively - updates automatically!
// Breadcrumbs update automatically when store changes!
const breadcrumbs = $derived(
generateBreadcrumbsSync(
page.url.pathname,
page.url.search,
navItems,
config
) )
); );
// Breadcrumbs update automatically when store changes!
const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
</script> </script>
``` ```
#### Before (LMS): #### Before (LMS):
```typescript ```typescript
import { generateLmsBreadcrumbs } from './lms-breadcrumb'; import { generateLmsBreadcrumbs } from './lms-breadcrumb';
import { currentCommunityQuestion } from '$features/community/utils/store'; import { currentCommunityQuestion } from '$features/community/utils/store';
@@ -93,6 +91,7 @@ const breadcrumbs = generateLmsBreadcrumbs(
``` ```
#### After (LMS - Reactive Store-Based): #### After (LMS - Reactive Store-Based):
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
@@ -101,19 +100,14 @@ const breadcrumbs = generateLmsBreadcrumbs(
import { currentCommunityQuestion } from '$features/community/utils/store'; import { currentCommunityQuestion } from '$features/community/utils/store';
// Config is reactive - updates when store changes // Config is reactive - updates when store changes
const config = $derived(createLmsBreadcrumbConfigWithStore( const config = $derived(
() => $currentCommunityQuestion.title // Reads reactively from store createLmsBreadcrumbConfigWithStore(
)); () => $currentCommunityQuestion.title // Reads reactively from store
// Breadcrumbs update automatically when store changes!
const breadcrumbs = $derived(
generateBreadcrumbsSync(
page.url.pathname,
page.url.search,
navItems,
config
) )
); );
// Breadcrumbs update automatically when store changes!
const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
</script> </script>
``` ```
@@ -131,6 +125,7 @@ The refactored system supports up to 5 layers of nesting through recursive trave
#### Example: Courses → Course → Lesson → Assignment → Submission (5 Layers) #### Example: Courses → Course → Lesson → Assignment → Submission (5 Layers)
**Navigation Config** (in `org-navigation.ts`): **Navigation Config** (in `org-navigation.ts`):
```typescript ```typescript
{ {
titleKey: 'org_navigation.courses', titleKey: 'org_navigation.courses',
@@ -186,20 +181,20 @@ The refactored system supports up to 5 layers of nesting through recursive trave
``` ```
**Breadcrumb Component** (in `app-breadcrumbs.svelte`): **Breadcrumb Component** (in `app-breadcrumbs.svelte`):
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { generateBreadcrumbsSync } from './breadcrumb-core'; import { generateBreadcrumbsSync } from './breadcrumb-core';
import { createOrgBreadcrumbConfigWithStore } from './breadcrumb-configs'; import { createOrgBreadcrumbConfigWithStore } from './breadcrumb-configs';
import { assignmentStore, submissionStore, courseStore, lessonStore } from '$features/course/utils/store'; import { assignmentStore, submissionStore, courseStore, lessonStore } from '$features/course/utils/store';
// Create reactive config that reads from stores // Create reactive config that reads from stores
const config = $derived(createOrgBreadcrumbConfigWithStore( const config = $derived(
$currentOrgPath, createOrgBreadcrumbConfigWithStore($currentOrgPath, () => {
() => {
// This function is called reactively - reads from stores // This function is called reactively - reads from stores
const pathSegments = page.url.pathname.split('/').filter(Boolean); const pathSegments = page.url.pathname.split('/').filter(Boolean);
// Determine which store to read based on current route // Determine which store to read based on current route
if (pathSegments.includes('submissions') && pathSegments.length > 5) { if (pathSegments.includes('submissions') && pathSegments.length > 5) {
return $submissionStore.title || 'Submission'; return $submissionStore.title || 'Submission';
@@ -214,35 +209,28 @@ The refactored system supports up to 5 layers of nesting through recursive trave
return $courseStore.title || 'Course'; return $courseStore.title || 'Course';
} }
return null; return null;
} })
));
const breadcrumbs = $derived(
generateBreadcrumbsSync(
page.url.pathname,
page.url.search,
navItems,
config
)
); );
const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
</script> </script>
``` ```
**Alternative: Per-Route Store Resolvers** (More Granular): **Alternative: Per-Route Store Resolvers** (More Granular):
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { generateBreadcrumbsSync } from './breadcrumb-core'; import { generateBreadcrumbsSync } from './breadcrumb-core';
import { createOrgBreadcrumbConfigWithStore } from './breadcrumb-configs'; import { createOrgBreadcrumbConfigWithStore } from './breadcrumb-configs';
import { assignmentStore, submissionStore, courseStore, lessonStore } from '$features/course/utils/store'; import { assignmentStore, submissionStore, courseStore, lessonStore } from '$features/course/utils/store';
// Create config with store-based resolver // Create config with store-based resolver
const config = $derived(createOrgBreadcrumbConfigWithStore( const config = $derived(
$currentOrgPath, createOrgBreadcrumbConfigWithStore($currentOrgPath, () => {
() => {
// Read from stores reactively based on path structure // Read from stores reactively based on path structure
const pathSegments = page.url.pathname.split('/').filter(Boolean); const pathSegments = page.url.pathname.split('/').filter(Boolean);
// Match store to segment position // Match store to segment position
if (pathSegments.includes('submissions')) { if (pathSegments.includes('submissions')) {
return $submissionStore.title || null; return $submissionStore.title || null;
@@ -257,17 +245,10 @@ The refactored system supports up to 5 layers of nesting through recursive trave
return $courseStore.title || null; return $courseStore.title || null;
} }
return null; return null;
} })
));
const breadcrumbs = $derived(
generateBreadcrumbsSync(
page.url.pathname,
page.url.search,
navItems,
config
)
); );
const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
</script> </script>
``` ```
@@ -288,6 +269,7 @@ The refactored system supports up to 5 layers of nesting through recursive trave
#### Store-Based vs Async Resolvers #### Store-Based vs Async Resolvers
**✅ Recommended: Store-Based (Reactive)** **✅ Recommended: Store-Based (Reactive)**
```typescript ```typescript
// In breadcrumb component // In breadcrumb component
const config = $derived(createOrgBreadcrumbConfigWithStore( const config = $derived(createOrgBreadcrumbConfigWithStore(
@@ -300,15 +282,17 @@ const breadcrumbs = $derived(generateBreadcrumbsSync(...));
``` ```
**⚠️ Alternative: Async Resolvers (API Calls)** **⚠️ Alternative: Async Resolvers (API Calls)**
```typescript ```typescript
// In navigation config // In navigation config
titleResolver: async (params) => { titleResolver: async (params) => {
const assignment = await fetchAssignment(params.param0); const assignment = await fetchAssignment(params.param0);
return assignment.title; return assignment.title;
} };
``` ```
**Why Use Stores?** **Why Use Stores?**
- ✅ **No API calls** - data already loaded in component - ✅ **No API calls** - data already loaded in component
- ✅ **Automatic updates** - breadcrumb updates when store changes - ✅ **Automatic updates** - breadcrumb updates when store changes
- ✅ **Better performance** - no loading states or delays - ✅ **Better performance** - no loading states or delays
@@ -322,7 +306,7 @@ The `extractParamsFromPath` function extracts all path segments as `param0`, `pa
// For URL: /courses/123/lessons/456/assignments/789 // For URL: /courses/123/lessons/456/assignments/789
// params = { // params = {
// param0: '123', // courseId // param0: '123', // courseId
// param1: '456', // lessonId // param1: '456', // lessonId
// param2: '789' // assignmentId // param2: '789' // assignmentId
// } // }
``` ```
@@ -350,41 +334,43 @@ For routes that need async data fetching:
```typescript ```typescript
import { generateBreadcrumbs } from './breadcrumb-core'; // Note: async version import { generateBreadcrumbs } from './breadcrumb-core'; // Note: async version
const breadcrumbs = await generateBreadcrumbs( const breadcrumbs = await generateBreadcrumbs(page.url.pathname, page.url.search, navItems, config);
page.url.pathname,
page.url.search,
navItems,
config
);
``` ```
## Benefits of the Refactored System ## Benefits of the Refactored System
### ✅ 1. No Code Duplication ### ✅ 1. No Code Duplication
- Single unified generator for all contexts - Single unified generator for all contexts
- Context-specific configs handle differences - Context-specific configs handle differences
### ✅ 2. Multi-Level Nesting Support ### ✅ 2. Multi-Level Nesting Support
- Recursive generation handles any depth - Recursive generation handles any depth
- Configurable max depth prevents infinite loops - Configurable max depth prevents infinite loops
### ✅ 3. Flexible Path Prefixes ### ✅ 3. Flexible Path Prefixes
- Function-based prefix extraction - Function-based prefix extraction
- Supports dynamic prefixes like `/org/[slug]` - Supports dynamic prefixes like `/org/[slug]`
### ✅ 4. Consistent Dynamic Title Resolution ### ✅ 4. Consistent Dynamic Title Resolution
- Unified interface for all resolvers - Unified interface for all resolvers
- Supports both sync and async resolution - Supports both sync and async resolution
### ✅ 5. Route-Specific Customization ### ✅ 5. Route-Specific Customization
- Override labels per route - Override labels per route
- Custom resolvers for complex cases - Custom resolvers for complex cases
### ✅ 6. Optional Caching ### ✅ 6. Optional Caching
- Performance optimization - Performance optimization
- Configurable cache TTL - Configurable cache TTL
### ✅ 7. Type Safety ### ✅ 7. Type Safety
- Full TypeScript support - Full TypeScript support
- Compile-time error checking - Compile-time error checking
@@ -393,6 +379,7 @@ const breadcrumbs = await generateBreadcrumbs(
**URL**: `/org/myorg/courses/123/lessons/456/assignments/789/submissions/101` **URL**: `/org/myorg/courses/123/lessons/456/assignments/789/submissions/101`
**Config**: **Config**:
```typescript ```typescript
{ {
path: '/courses', path: '/courses',
@@ -492,8 +479,9 @@ The recursive function processes **each path segment** separately, incrementing
**Breadcrumb Result**: `Courses / React Fundamentals / Lessons / Components / Assignments / Build Todo App / Submissions / Submission #101` **Breadcrumb Result**: `Courses / React Fundamentals / Lessons / Components / Assignments / Build Todo App / Submissions / Submission #101`
**Depth Breakdown**: **Depth Breakdown**:
- `depth=0`: Top-level nav item (`/courses`) → "Courses" - `depth=0`: Top-level nav item (`/courses`) → "Courses"
- `depth=1`: First dynamic segment (`123`) → "React Fundamentals" - `depth=1`: First dynamic segment (`123`) → "React Fundamentals"
- `depth=2`: Static nested route (`lessons`) → "Lessons" - `depth=2`: Static nested route (`lessons`) → "Lessons"
- `depth=3`: Second dynamic segment (`456`) → "Components" - `depth=3`: Second dynamic segment (`456`) → "Components"
- `depth=4`: Static nested route (`assignments`) → "Assignments" - `depth=4`: Static nested route (`assignments`) → "Assignments"
@@ -560,13 +548,8 @@ beforeEach(() => {
// Test breadcrumb generation // Test breadcrumb generation
test('generates breadcrumbs for nested route', () => { test('generates breadcrumbs for nested route', () => {
const breadcrumbs = generateBreadcrumbsSync( const breadcrumbs = generateBreadcrumbsSync('/org/test/courses/123/lessons/456', '', navItems, config);
'/org/test/courses/123/lessons/456',
'',
navItems,
config
);
expect(breadcrumbs).toHaveLength(4); expect(breadcrumbs).toHaveLength(4);
expect(breadcrumbs[0].label).toBe('Courses'); expect(breadcrumbs[0].label).toBe('Courses');
}); });
@@ -604,6 +587,7 @@ This allows gradual migration without breaking existing code.
### Why Use Stores Instead of Async Requests? ### Why Use Stores Instead of Async Requests?
**✅ Benefits:** **✅ Benefits:**
- **No API calls** - Data is already loaded in your component - **No API calls** - Data is already loaded in your component
- **Automatic updates** - Breadcrumbs update reactively when stores change - **Automatic updates** - Breadcrumbs update reactively when stores change
- **Better performance** - No loading states, delays, or network requests - **Better performance** - No loading states, delays, or network requests
@@ -619,6 +603,7 @@ This allows gradual migration without breaking existing code.
### Example: Community Post Title ### Example: Community Post Title
**Store** (`$features/community/utils/store.ts`): **Store** (`$features/community/utils/store.ts`):
```typescript ```typescript
export const currentCommunityQuestion = writable<{ title: string | null }>({ export const currentCommunityQuestion = writable<{ title: string | null }>({
title: null title: null
@@ -626,19 +611,20 @@ export const currentCommunityQuestion = writable<{ title: string | null }>({
``` ```
**Page Component** (`+page.svelte`): **Page Component** (`+page.svelte`):
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { currentCommunityQuestion } from '$features/community/utils/store'; import { currentCommunityQuestion } from '$features/community/utils/store';
let question = $state(); let question = $state();
async function fetchQuestion() { async function fetchQuestion() {
const data = await supabase.from('community_question').select('*').single(); const data = await supabase.from('community_question').select('*').single();
question = data; question = data;
// Update store - breadcrumb will reactively update! // Update store - breadcrumb will reactively update!
currentCommunityQuestion.set({ title: data.title }); currentCommunityQuestion.set({ title: data.title });
} }
$effect(() => { $effect(() => {
fetchQuestion(); fetchQuestion();
}); });
@@ -646,20 +632,21 @@ export const currentCommunityQuestion = writable<{ title: string | null }>({
``` ```
**Breadcrumb Component** (`lms-breadcrumbs.svelte`): **Breadcrumb Component** (`lms-breadcrumbs.svelte`):
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { createLmsBreadcrumbConfigWithStore } from './breadcrumb-configs'; import { createLmsBreadcrumbConfigWithStore } from './breadcrumb-configs';
import { currentCommunityQuestion } from '$features/community/utils/store'; import { currentCommunityQuestion } from '$features/community/utils/store';
// Reactive config - updates when store changes // Reactive config - updates when store changes
const config = $derived(createLmsBreadcrumbConfigWithStore( const config = $derived(
() => $currentCommunityQuestion.title // Reads reactively! createLmsBreadcrumbConfigWithStore(
)); () => $currentCommunityQuestion.title // Reads reactively!
)
// Breadcrumbs automatically update when store changes!
const breadcrumbs = $derived(
generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config)
); );
// Breadcrumbs automatically update when store changes!
const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
</script> </script>
``` ```
@@ -669,11 +656,10 @@ For routes with multiple dynamic segments, use path-based logic:
```svelte ```svelte
<script lang="ts"> <script lang="ts">
const config = $derived(createOrgBreadcrumbConfigWithStore( const config = $derived(
$currentOrgPath, createOrgBreadcrumbConfigWithStore($currentOrgPath, () => {
() => {
const pathSegments = page.url.pathname.split('/').filter(Boolean); const pathSegments = page.url.pathname.split('/').filter(Boolean);
// Determine which store to read based on route // Determine which store to read based on route
if (pathSegments.includes('submissions')) { if (pathSegments.includes('submissions')) {
return $submissionStore.title; return $submissionStore.title;
@@ -688,8 +674,8 @@ For routes with multiple dynamic segments, use path-based logic:
return $courseStore.title; return $courseStore.title;
} }
return null; return null;
} })
)); );
</script> </script>
``` ```
@@ -699,4 +685,3 @@ For routes with multiple dynamic segments, use path-based logic:
2. **Use `$derived`**: Always wrap config and breadcrumbs in `$derived` for reactivity 2. **Use `$derived`**: Always wrap config and breadcrumbs in `$derived` for reactivity
3. **Store Updates**: When you update a store, breadcrumbs automatically update - no manual refresh needed! 3. **Store Updates**: When you update a store, breadcrumbs automatically update - no manual refresh needed!
4. **No Async Needed**: Store-based resolvers are synchronous - just return the store value 4. **No Async Needed**: Store-based resolvers are synchronous - just return the store value
+30 -18
View File
@@ -20,11 +20,13 @@ This guide explains how to create pages and routes in the dashboard application,
The dashboard has two main route contexts: The dashboard has two main route contexts:
### 1. Organization Routes (`/org/[slug]/...`) ### 1. Organization Routes (`/org/[slug]/...`)
- Uses `OrgSidebar` and `AppHeader` - Uses `OrgSidebar` and `AppHeader`
- Path pattern: `/org/[slug]/[feature]` - Path pattern: `/org/[slug]/[feature]`
- Example: `/org/acme/courses`, `/org/acme/settings` - Example: `/org/acme/courses`, `/org/acme/settings`
### 2. LMS Routes (`/lms/...`) ### 2. LMS Routes (`/lms/...`)
- Uses `LMSSidebar` and `LmsHeader` - Uses `LMSSidebar` and `LmsHeader`
- Path pattern: `/lms/[feature]` - Path pattern: `/lms/[feature]`
- Example: `/lms/community`, `/lms/settings` - Example: `/lms/community`, `/lms/settings`
@@ -38,6 +40,7 @@ The dashboard has two main route contexts:
**Location**: `apps/dashboard/src/routes/[context]/[feature]/+page.svelte` **Location**: `apps/dashboard/src/routes/[context]/[feature]/+page.svelte`
**Pattern**: **Pattern**:
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { FeaturePage } from '$features/[feature]/pages'; import { FeaturePage } from '$features/[feature]/pages';
@@ -72,6 +75,7 @@ The dashboard has two main route contexts:
**Location**: `apps/dashboard/src/lib/features/[feature]/pages/[feature].svelte` **Location**: `apps/dashboard/src/lib/features/[feature]/pages/[feature].svelte`
**Pattern**: **Pattern**:
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { Search } from '@cio/ui/custom/search'; import { Search } from '@cio/ui/custom/search';
@@ -230,6 +234,7 @@ Settings pages follow a specific pattern with layouts and subroutes.
Feature pages contain all business logic and should be placed in `lib/features/[feature]/pages/`. Feature pages contain all business logic and should be placed in `lib/features/[feature]/pages/`.
**Key Principles**: **Key Principles**:
- ✅ All business logic goes in the feature page - ✅ All business logic goes in the feature page
- ✅ Use `Page.BodyHeader` for filters, search, and view toggles - ✅ Use `Page.BodyHeader` for filters, search, and view toggles
- ✅ Use `Empty` component for empty states - ✅ Use `Empty` component for empty states
@@ -363,6 +368,7 @@ Feature pages contain all business logic and should be placed in `lib/features/[
``` ```
**Key Differences**: **Key Differences**:
- Org routes use `AppHeader` (includes AppSetup) - Org routes use `AppHeader` (includes AppSetup)
- LMS routes use `LmsHeader` (no AppSetup) - LMS routes use `LmsHeader` (no AppSetup)
- Org routes use `OrgSidebar` - Org routes use `OrgSidebar`
@@ -426,11 +432,7 @@ Feature pages contain all business logic and should be placed in `lib/features/[
<Field.Set> <Field.Set>
<Field.Legend>{$t('form.another_section')}</Field.Legend> <Field.Legend>{$t('form.another_section')}</Field.Legend>
<Field.Field> <Field.Field>
<UploadImage <UploadImage bind:avatar src={avatarUrl} change={() => (hasUnsavedChanges = true)} />
bind:avatar
src={avatarUrl}
change={() => (hasUnsavedChanges = true)}
/>
</Field.Field> </Field.Field>
</Field.Set> </Field.Set>
</Field.Group> </Field.Group>
@@ -554,7 +556,7 @@ For dynamic routes (like `community/[slug]`), add breadcrumb data in the route's
export async function load({ params }) { export async function load({ params }) {
// Fetch question data // Fetch question data
const question = await fetchQuestion(params.slug); const question = await fetchQuestion(params.slug);
return { return {
breadcrumb: question.title // This will appear in breadcrumbs breadcrumb: question.title // This will appear in breadcrumbs
}; };
@@ -568,6 +570,7 @@ export async function load({ params }) {
### Example 1: Simple Feature Page ### Example 1: Simple Feature Page
**Route**: `routes/org/[slug]/courses/+page.svelte` **Route**: `routes/org/[slug]/courses/+page.svelte`
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { CoursesPage } from '$features/courses/pages'; import { CoursesPage } from '$features/courses/pages';
@@ -596,6 +599,7 @@ export async function load({ params }) {
### Example 2: Settings Page with Save Action ### Example 2: Settings Page with Save Action
**Route**: `routes/org/[slug]/settings/customize-lms/+page.svelte` **Route**: `routes/org/[slug]/settings/customize-lms/+page.svelte`
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { CustomizeLmsPage } from '$features/settings/pages'; import { CustomizeLmsPage } from '$features/settings/pages';
@@ -642,6 +646,7 @@ export async function load({ params }) {
### Example 3: Form Page Component ### Example 3: Form Page Component
**Feature Page**: `lib/features/settings/pages/profile.svelte` **Feature Page**: `lib/features/settings/pages/profile.svelte`
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
@@ -655,11 +660,14 @@ export async function load({ params }) {
let hasUnsavedChanges = $state(false); let hasUnsavedChanges = $state(false);
export async function handleUpdate() { export async function handleUpdate() {
await profileApi.submit({ await profileApi.submit(
fullname: $profile.fullname, {
username: $profile.username, fullname: $profile.fullname,
avatar username: $profile.username,
}, $profile); avatar
},
$profile
);
if (profileApi.success) { if (profileApi.success) {
hasUnsavedChanges = false; hasUnsavedChanges = false;
@@ -708,12 +716,14 @@ export async function load({ params }) {
When creating a new page, follow this checklist: When creating a new page, follow this checklist:
### Route Setup ### Route Setup
- [ ] Create route file at `routes/[context]/[feature]/+page.svelte` - [ ] Create route file at `routes/[context]/[feature]/+page.svelte`
- [ ] Add `Page.Root`, `Page.Header`, `Page.Body` structure - [ ] Add `Page.Root`, `Page.Header`, `Page.Body` structure
- [ ] Add `<svelte:head>` with page title - [ ] Add `<svelte:head>` with page title
- [ ] Import and render feature page component - [ ] Import and render feature page component
### Feature Page ### Feature Page
- [ ] Create feature page at `lib/features/[feature]/pages/[feature].svelte` - [ ] Create feature page at `lib/features/[feature]/pages/[feature].svelte`
- [ ] Export from `lib/features/[feature]/pages/index.ts` - [ ] Export from `lib/features/[feature]/pages/index.ts`
- [ ] Use `Page.BodyHeader` for filters/search - [ ] Use `Page.BodyHeader` for filters/search
@@ -721,6 +731,7 @@ When creating a new page, follow this checklist:
- [ ] Handle loading states - [ ] Handle loading states
### Forms (if applicable) ### Forms (if applicable)
- [ ] Use `Field.Group` pattern (NOT Row/Grid/Column) - [ ] Use `Field.Group` pattern (NOT Row/Grid/Column)
- [ ] Use `Field.Set` for sections - [ ] Use `Field.Set` for sections
- [ ] Use `Field.Separator` between sections - [ ] Use `Field.Separator` between sections
@@ -729,6 +740,7 @@ When creating a new page, follow this checklist:
- [ ] Add `UnsavedChanges` component if needed - [ ] Add `UnsavedChanges` component if needed
### Navigation (if needed) ### Navigation (if needed)
- [ ] Add item to `org-navigation.ts` or `lms-navigation.ts` - [ ] Add item to `org-navigation.ts` or `lms-navigation.ts`
- [ ] Set appropriate `matchPattern` for active state - [ ] Set appropriate `matchPattern` for active state
- [ ] Add icon component - [ ] Add icon component
@@ -736,6 +748,7 @@ When creating a new page, follow this checklist:
- [ ] Configure dynamic segments if needed - [ ] Configure dynamic segments if needed
### Settings Pages (if applicable) ### Settings Pages (if applicable)
- [ ] Create `+layout.svelte` with `Page.Root` wrapper - [ ] Create `+layout.svelte` with `Page.Root` wrapper
- [ ] Create default `+page.svelte` for main settings - [ ] Create default `+page.svelte` for main settings
- [ ] Create subroute `+page.svelte` files for each subroute - [ ] Create subroute `+page.svelte` files for each subroute
@@ -755,6 +768,7 @@ When creating a new page, follow this checklist:
### Page Actions ### Page Actions
Actions in `Page.Action` typically include: Actions in `Page.Action` typically include:
- Save/Update buttons - Save/Update buttons
- Create/Add buttons - Create/Add buttons
- Export buttons - Export buttons
@@ -763,6 +777,7 @@ Actions in `Page.Action` typically include:
### Loading States ### Loading States
Use skeleton loaders or spinner components: Use skeleton loaders or spinner components:
```svelte ```svelte
{#if isLoading} {#if isLoading}
<FeatureLoader /> <FeatureLoader />
@@ -776,13 +791,9 @@ Use skeleton loaders or spinner components:
### Empty States ### Empty States
Always use the `Empty` component: Always use the `Empty` component:
```svelte ```svelte
<Empty <Empty title={$t('feature.empty_title')} description={$t('feature.empty_description')} icon={SomeIcon} variant="page">
title={$t('feature.empty_title')}
description={$t('feature.empty_description')}
icon={SomeIcon}
variant="page"
>
<ActionButton /> <ActionButton />
</Empty> </Empty>
``` ```
@@ -807,6 +818,7 @@ Always use the `Empty` component:
If you encounter old patterns, migrate them: If you encounter old patterns, migrate them:
### Old: Row/Grid/Column Pattern ### Old: Row/Grid/Column Pattern
```svelte ```svelte
<!-- OLD - DON'T USE --> <!-- OLD - DON'T USE -->
<Grid> <Grid>
@@ -822,6 +834,7 @@ If you encounter old patterns, migrate them:
``` ```
### New: Field.Group Pattern ### New: Field.Group Pattern
```svelte ```svelte
<!-- NEW - USE THIS --> <!-- NEW - USE THIS -->
<Field.Group class="max-w-md! w-full px-2"> <Field.Group class="max-w-md! w-full px-2">
@@ -843,4 +856,3 @@ If you encounter old patterns, migrate them:
- See existing pages in `routes/org/[slug]/` and `routes/lms/` for examples - See existing pages in `routes/org/[slug]/` and `routes/lms/` for examples
- See `lib/features/settings/pages/` for form examples - See `lib/features/settings/pages/` for form examples
- See `lib/features/community/pages/` for list page examples - See `lib/features/community/pages/` for list page examples
+25 -23
View File
@@ -1,31 +1,33 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="theme-color" content="#333333" />
<head> <link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<meta charset="utf-8" /> <link rel="icon" type="image/png" href="/favicon.ico" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" /> <link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
<meta name="theme-color" content="#333333" /> <link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" /> <link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link rel="icon" type="image/png" href="/favicon.ico" /> <link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" /> <link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" /> <script
<link rel="icon" type="image/png" href="/favicon.ico" /> nonce="%sveltekit.nonce%"
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" /> defer
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" /> src="https://umami.hz.oncws.com/script.js"
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"
></script>
<script nonce="%sveltekit.nonce%" defer src="https://umami.hz.oncws.com/script.js" <script nonce="%sveltekit.nonce%" src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"></script>
<script nonce="%sveltekit.nonce%" src="https://cdn.plyr.io/3.7.3/plyr.js"></script> %sveltekit.head%
</head>
%sveltekit.head% <body data-sveltekit-preload-data="hover" class="">
</head> <div style="display: contents" class="theme-container">%sveltekit.body%</div>
</body>
<body data-sveltekit-preload-data="hover" class=""> </html>
<div style="display: contents" class="theme-container">%sveltekit.body%</div>
</body>
</html>
@@ -13,8 +13,8 @@
<Skeleton class="h-[48px] w-[48px]" /> <Skeleton class="h-[48px] w-[48px]" />
<div class="flex-1"> <div class="flex-1">
<Skeleton class="h-[48px] w-[48px]" /> <Skeleton class="h-[48px] w-[48px]" />
<Skeleton class="h-[16px] w-[80px] mb-2" /> <Skeleton class="mb-2 h-[16px] w-[80px]" />
<Skeleton class="h-[24px] w-[40px] mb-1" /> <Skeleton class="mb-1 h-[24px] w-[40px]" />
<Skeleton class="h-[12px] w-[60px]" /> <Skeleton class="h-[12px] w-[60px]" />
</div> </div>
</div> </div>
@@ -26,10 +26,10 @@
<!-- Charts Section Skeleton --> <!-- Charts Section Skeleton -->
<div class="mb-12"> <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">
<Skeleton class="h-[24px] w-[200px] mb-6" /> <Skeleton class="mb-6 h-[24px] w-[200px]" />
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Skeleton class="w-full h-[300px]" /> <Skeleton class="h-[300px] w-full" />
<Skeleton class="w-full h-[300px]" /> <Skeleton class="h-[300px] w-full" />
</div> </div>
</div> </div>
</div> </div>
@@ -37,7 +37,7 @@
<!-- Students Table Skeleton --> <!-- 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"> <div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700">
<Skeleton class="h-[24px] w-[200px] mb-6" /> <Skeleton class="mb-6 h-[24px] w-[200px]" />
</div> </div>
<div class="p-6"> <div class="p-6">
<div class="space-y-4"> <div class="space-y-4">
@@ -45,7 +45,7 @@
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<Skeleton class="h-[32px] w-[32px]" /> <Skeleton class="h-[32px] w-[32px]" />
<div class="flex-1"> <div class="flex-1">
<Skeleton class="h-[16px] w-[150px] mb-1" /> <Skeleton class="mb-1 h-[16px] w-[150px]" />
<Skeleton class="h-[12px] w-[200px]" /> <Skeleton class="h-[12px] w-[200px]" />
</div> </div>
<Skeleton class="h-[16px] w-[80px]" /> <Skeleton class="h-[16px] w-[80px]" />
@@ -45,7 +45,7 @@
isSaving = true; isSaving = true;
try { try {
// Prevent free plan users from bypassing UI restrictions // Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) { if ($isFreePlan) {
errors.description = 'Certificate customization is only available on paid plans'; errors.description = 'Certificate customization is only available on paid plans';
throw new Error(errors.description); throw new Error(errors.description);
@@ -28,47 +28,47 @@
<Dialog.Title>Send Certificate</Dialog.Title> <Dialog.Title>Send Certificate</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<main> <main>
<div> <div>
<div class="mb-4 flex items-center space-x-2"> <div class="mb-4 flex items-center space-x-2">
<Switch id="auto-certificate" bind:checked={isAutomatic} /> <Switch id="auto-certificate" bind:checked={isAutomatic} />
<Label for="auto-certificate" class="text-sm font-medium">Automatic</Label> <Label for="auto-certificate" class="text-sm font-medium">Automatic</Label>
</div> </div>
<p class="my-4 text-sm font-medium"> <p class="my-4 text-sm font-medium">
If you set this as automatic, certificates will be issued after the learner completes the course. If you set this as automatic, certificates will be issued after the learner completes the course.
</p> </p>
</div>
<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">
<InputField
label="Email address of the student"
className="w-full my-4"
labelClassName="text-xs font-normal"
placeholder="email,comma seperated"
bind:value={$issueCertificateModal.email}
/>
<InputField
label="Schedule date"
className="w-full my-4"
labelClassName="text-xs font-normal"
placeholder="12/06/2023"
bind:value={$issueCertificateModal.date}
/>
</div> </div>
<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">
<InputField
label="Email address of the student"
className="w-full my-4"
labelClassName="text-xs font-normal"
placeholder="email,comma seperated"
bind:value={$issueCertificateModal.email}
/>
<InputField
label="Schedule date"
className="w-full my-4"
labelClassName="text-xs font-normal"
placeholder="12/06/2023"
bind:value={$issueCertificateModal.date}
/>
</div>
<TextareaField <TextareaField
label="Add a personalized message" label="Add a personalized message"
labelClassName="text-xs font-normal" labelClassName="text-xs font-normal"
bind:value={$issueCertificateModal.message} bind:value={$issueCertificateModal.message}
rows={2} rows={2}
placeholder="your message here" placeholder="your message here"
className="mb-4" className="mb-4"
/> />
<div class="mt-5 flex w-full items-end justify-end"> <div class="mt-5 flex w-full items-end justify-end">
<Button type="submit">Issue certificate</Button> <Button type="submit">Issue certificate</Button>
</div> </div>
</form> </form>
</main> </main>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -39,34 +39,28 @@
if (!isOpen) handleClose(); if (!isOpen) handleClose();
}} }}
> >
<Dialog.Content class="w-[80%] md:w-[65%] max-w-xl"> <Dialog.Content class="w-[80%] max-w-xl md:w-[65%]">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{$t(`course.navItem.lessons.section_prompt.header`)}</Dialog.Title> <Dialog.Title>{$t(`course.navItem.lessons.section_prompt.header`)}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<div class="flex w-full flex-col items-center"> <div class="flex w-full flex-col items-center">
<div class="mb-8"> <div class="mb-8">
<h3 class="text-center text-2xl"> <h3 class="text-center text-2xl">
{$t('course.navItem.lessons.section_prompt.title')} {$t('course.navItem.lessons.section_prompt.title')}
</h3> </h3>
<p class="max-w-md text-center"> <p class="max-w-md text-center">
{$t('course.navItem.lessons.section_prompt.description')} {$t('course.navItem.lessons.section_prompt.description')}
</p> </p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button variant="outline" onclick={handleClose}>
variant="outline" {$t('course.navItem.lessons.section_prompt.cancel')}
onclick={handleClose} </Button>
> <Button onclick={activate} loading={isActivating}>
{$t('course.navItem.lessons.section_prompt.cancel')} {$t('course.navItem.lessons.section_prompt.activate')}
</Button> </Button>
<Button </div>
onclick={activate}
loading={isActivating}
>
{$t('course.navItem.lessons.section_prompt.activate')}
</Button>
</div> </div>
</div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -22,29 +22,23 @@
if (!isOpen) openDeleteModal = false; if (!isOpen) openDeleteModal = false;
}} }}
> >
<Dialog.Content class="w-96 px-6 pt-2 pb-6"> <Dialog.Content class="w-96 px-6 pb-6 pt-2">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{$t('delete_modal.label')}</Dialog.Title> <Dialog.Title>{$t('delete_modal.label')}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<div> <div>
<h1 class="text-lg dark:text-white"> <h1 class="text-lg dark:text-white">
{$t('delete_modal.content')}? {$t('delete_modal.content')}?
</h1> </h1>
<div class="mt-5 flex items-center justify-between"> <div class="mt-5 flex items-center justify-between">
<Button <Button variant="outline" onclick={() => (openDeleteModal = false)}>
variant="outline" {$t('delete_modal.no')}
onclick={() => (openDeleteModal = false)} </Button>
> <Button variant="outline" onclick={handleDelete}>
{$t('delete_modal.no')} {$t('delete_modal.yes')}
</Button> </Button>
<Button </div>
variant="outline"
onclick={handleDelete}
>
{$t('delete_modal.yes')}
</Button>
</div> </div>
</div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -7,37 +7,35 @@
let { onDelete = () => {}, onCancel = () => {} } = $props(); let { onDelete = () => {}, onCancel = () => {} } = $props();
</script> </script>
<Dialog.Root <Dialog.Root bind:open={$deleteConfirmation.open}>
bind:open={$deleteConfirmation.open}
>
<Dialog.Content class="w-96"> <Dialog.Content class="w-96">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}</Dialog.Title> <Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<div> <div>
<p class="mt-0 text-base dark:text-white"> <p class="mt-0 text-base dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')} {$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
</p> </p>
<div class="mt-5 flex items-center justify-between"> <div class="mt-5 flex items-center justify-between">
<Button <Button
variant="outline" variant="outline"
onclick={() => { onclick={() => {
$deleteConfirmation.open = false; $deleteConfirmation.open = false;
onCancel(); onCancel();
}} }}
> >
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')} {$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
</Button> </Button>
<Button <Button
onclick={() => { onclick={() => {
onDelete(); onDelete();
$deleteConfirmation.open = false; $deleteConfirmation.open = false;
}} }}
> >
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')} {$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
</Button> </Button>
</div>
</div> </div>
</div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -236,108 +236,108 @@
if (!isOpen) onClose(); if (!isOpen) onClose();
}} }}
> >
<Dialog.Content class="w-4/5 h-[90%] flex items-start !max-h-full h-[85%] py-0 px-4"> <Dialog.Content class="flex h-[85%] h-[90%] !max-h-full w-4/5 items-start px-4 py-0">
<Dialog.Header class="py-2"> <Dialog.Header class="py-2">
<Dialog.Title class="text-base font-semibold">{data.title}</Dialog.Title> <Dialog.Title class="text-base font-semibold">{data.title}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<div class="mt-2 h-full w-full"> <div class="mt-2 h-full w-full">
{#if openDeletePrompt} {#if openDeletePrompt}
<div class="mx-auto w-96 rounded-md border border-gray-300 p-3"> <div class="mx-auto w-96 rounded-md border border-gray-300 p-3">
<h1 class="text-lg dark:text-white"> <h1 class="text-lg dark:text-white">
{$t('delete_modal.content')} {$t('delete_modal.content')}
</h1> </h1>
<div class="mt-5 flex items-center justify-between"> <div class="mt-5 flex items-center justify-between">
<Button variant="outline" onclick={() => (openDeletePrompt = false)} disabled={isDeleting}> <Button variant="outline" onclick={() => (openDeletePrompt = false)} disabled={isDeleting}>
{$t('delete_modal.no')} {$t('delete_modal.no')}
</Button> </Button>
<Button variant="outline" onclick={handleDeleteSubmission} loading={isDeleting}> <Button variant="outline" onclick={handleDeleteSubmission} loading={isDeleting}>
{$t('delete_modal.yes')} {$t('delete_modal.yes')}
</Button> </Button>
</div>
</div> </div>
</div> {:else}
{:else} <Preview
<Preview questions={sortedQuestions}
questions={sortedQuestions} questionnaireMetaData={{
questionnaireMetaData={{ answers: data.answers || {},
answers: data.answers || {}, isFinished: true
isFinished: true }}
}} bind:grades={data.questionAnswerByPoint}
bind:grades={data.questionAnswerByPoint} bind:reasons
bind:reasons bind:isGradeWithAI
bind:isGradeWithAI bind:isLoading
bind:isLoading disableGrading={false}
disableGrading={false} />
/> {/if}
{/if} </div>
</div> <div class="sticky top-0 ml-4 mt-2 w-2/5">
<div class="sticky top-0 ml-4 mt-2 w-2/5"> <div class="rounded-md border border-gray-300">
<div class="rounded-md border border-gray-300"> <div
<div class="flex items-center justify-between gap-1 border-b border-l-0 border-r-0 border-t-0 border-gray-300 p-3"
class="flex items-center justify-between gap-1 border-b border-l-0 border-r-0 border-t-0 border-gray-300 p-3" >
> <p class="text-base dark:text-white">
<p class="text-base dark:text-white"> {$t('course.navItem.submissions.grading_modal.details')}
{$t('course.navItem.submissions.grading_modal.details')} {#if data.isEarly}
{#if data.isEarly} <span class="badge ml-2 rounded-sm bg-green-500 px-2 text-sm text-white">
<span class="badge ml-2 rounded-sm bg-green-500 px-2 text-sm text-white"> {$t('course.navItem.submissions.grading_modal.early')}</span
{$t('course.navItem.submissions.grading_modal.early')}</span >
{:else}
<span class="badge ml-2 rounded-sm bg-red-500 px-2 text-sm text-white">
{$t('course.navItem.submissions.grading_modal.late')}
</span>
{/if}
</p>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
> >
{:else} <EllipsisVerticalIcon class="h-5 w-5" />
<span class="badge ml-2 rounded-sm bg-red-500 px-2 text-sm text-white"> </DropdownMenu.Trigger>
{$t('course.navItem.submissions.grading_modal.late')} <DropdownMenu.Content align="end">
</span> <DropdownMenu.Item
{/if} class="text-red-600"
</p> onclick={() => {
openDeletePrompt = true;
}}
>
{$t('delete_modal.label')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
<DropdownMenu.Root> <div class="flex items-center space-x-4 px-3 py-2 text-sm">
<DropdownMenu.Trigger <p class="text-sm font-semibold text-gray-500 dark:text-white">
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700" {$t('course.navItem.submissions.grading_modal.total_grade')}:
> </p>
<EllipsisVerticalIcon class="h-5 w-5" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
class="text-red-600"
onclick={() => {
openDeletePrompt = true;
}}
>
{$t('delete_modal.label')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
<div class="flex items-center space-x-4 px-3 py-2 text-sm"> <Badge class="w-fit rounded-md bg-gray-100 font-semibold text-black dark:bg-neutral-700 dark:text-white">
<p class="text-sm font-semibold text-gray-500 dark:text-white"> {total}/{maxPoints}
{$t('course.navItem.submissions.grading_modal.total_grade')}: </Badge>
</p> </div>
<!-- <div class="flex items-center text-sm p-3">
<Badge class="w-fit rounded-md bg-gray-100 font-semibold text-black dark:bg-neutral-700 dark:text-white">
{total}/{maxPoints}
</Badge>
</div>
<!-- <div class="flex items-center text-sm p-3">
<p class="dark:text-white w-1/2">Status</p> <p class="dark:text-white w-1/2">Status</p>
<div class="flex items-center"> <div class="flex items-center">
<p class="dark:text-white rounded-full w-5 h-5 bg-yellow-300 mr-2" /> <p class="dark:text-white rounded-full w-5 h-5 bg-yellow-300 mr-2" />
<p class="dark:text-white">Grading</p> <p class="dark:text-white">Grading</p>
</div> </div>
</div> --> </div> -->
<div class="flex items-center space-x-4 px-3 py-2 text-sm"> <div class="flex items-center space-x-4 px-3 py-2 text-sm">
<p class="text-sm font-semibold text-gray-500 dark:text-white"> <p class="text-sm font-semibold text-gray-500 dark:text-white">
{$t('course.navItem.submissions.grading_modal.student')}: {$t('course.navItem.submissions.grading_modal.student')}:
</p> </p>
{#if data.student} {#if data.student}
<div class="flex flex-row items-center justify-center rounded-md bg-gray-100 p-[6px] dark:bg-neutral-700"> <div class="flex flex-row items-center justify-center rounded-md bg-gray-100 p-[6px] dark:bg-neutral-700">
<img alt="Student avatar" class="flex h-5 w-5 rounded-full" src={data.student.avatar_url} /> <img alt="Student avatar" class="flex h-5 w-5 rounded-full" src={data.student.avatar_url} />
<p class="ml-2 line-clamp-1 text-sm font-semibold dark:text-white"> <p class="ml-2 line-clamp-1 text-sm font-semibold dark:text-white">
{data.student.fullname} {data.student.fullname}
</p> </p>
</div> </div>
{/if} {/if}
</div> </div>
<!-- <div class="flex items-center space-x-4 text-sm px-3 py-2"> <!-- <div class="flex items-center space-x-4 text-sm px-3 py-2">
<p class="dark:text-white text-sm text-gray-500 font-semibold">Assesment Type:</p> <p class="dark:text-white text-sm text-gray-500 font-semibold">Assesment Type:</p>
<Tag <Tag
class="dark:text-white font-semibold bg-gray-100 dark:bg-neutral-700 rounded-md text-black w-fit" class="dark:text-white font-semibold bg-gray-100 dark:bg-neutral-700 rounded-md text-black w-fit"
@@ -345,68 +345,68 @@
> >
</div> --> </div> -->
<div class="flex flex-col items-start px-3 py-2 text-sm"> <div class="flex flex-col items-start px-3 py-2 text-sm">
<p class="font-semibold text-gray-500 dark:text-white"> <p class="font-semibold text-gray-500 dark:text-white">
{$t('course.navItem.submissions.grading_modal.status')}: {$t('course.navItem.submissions.grading_modal.status')}:
</p>
<Select.Root
type="single"
value={status.id.toString()}
onValueChange={(value) => {
if (value) {
handleStatusChange(value);
}
}}
>
<Select.Trigger class="w-full">
{status.text}
</Select.Trigger>
<Select.Content>
{#each SELECTABLE_STATUS as statusOption}
<Select.Item value={statusOption.id.toString()}>
{statusOption.text}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col items-start px-3 py-2 text-sm">
<p class="font-semibold text-gray-500 dark:text-white">
{$t('course.navItem.submissions.grading_modal.add_comment')}:
</p>
<TextareaField
bgColor="bg-gray-100 dark:bg-neutral-700"
className="font-semibold"
placeholder={$t('course.navItem.submissions.grading_modal.add_comment_placeholder')}
bind:value={data.feedback}
/>
</div>
<div class="flex w-full flex-col space-y-3 px-3 py-2">
<Button variant="outline" onclick={gradeWithAI} class="w-full">
<img src="/ai.svg" alt="ai" />
<p class="text-sm font-semibold">
{$t('course.navItem.submissions.grading_modal.grade_with_ai')}
</p> </p>
</Button> <Select.Root
<Button type="single"
onclick={() => { value={status.id.toString()}
handleSave(data); onValueChange={(value) => {
// onClose(); if (value) {
}} handleStatusChange(value);
loading={isSaving} }
class="w-full" }}
> >
{$t('course.navItem.submissions.grading_modal.submit_grades')} <Select.Trigger class="w-full">
</Button> {status.text}
</div> </Select.Trigger>
<!-- <div class="flex items-center text-sm p-3"> <Select.Content>
{#each SELECTABLE_STATUS as statusOption}
<Select.Item value={statusOption.id.toString()}>
{statusOption.text}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col items-start px-3 py-2 text-sm">
<p class="font-semibold text-gray-500 dark:text-white">
{$t('course.navItem.submissions.grading_modal.add_comment')}:
</p>
<TextareaField
bgColor="bg-gray-100 dark:bg-neutral-700"
className="font-semibold"
placeholder={$t('course.navItem.submissions.grading_modal.add_comment_placeholder')}
bind:value={data.feedback}
/>
</div>
<div class="flex w-full flex-col space-y-3 px-3 py-2">
<Button variant="outline" onclick={gradeWithAI} class="w-full">
<img src="/ai.svg" alt="ai" />
<p class="text-sm font-semibold">
{$t('course.navItem.submissions.grading_modal.grade_with_ai')}
</p>
</Button>
<Button
onclick={() => {
handleSave(data);
// onClose();
}}
loading={isSaving}
class="w-full"
>
{$t('course.navItem.submissions.grading_modal.submit_grades')}
</Button>
</div>
<!-- <div class="flex items-center text-sm p-3">
<p class="dark:text-white w-1/2">Teacher</p> <p class="dark:text-white w-1/2">Teacher</p>
<p class="dark:text-white">rotimi-best</p> <p class="dark:text-white">rotimi-best</p>
</div> --> </div> -->
</div>
</div> </div>
</div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -71,34 +71,34 @@
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.order_questions')}</Dialog.Title> <Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.order_questions')}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<section <section
use:dndzone={{ use:dndzone={{
items, items,
flipDurationMs, flipDurationMs,
dropTargetStyle: { dropTargetStyle: {
border: '2px #1d4ed8 solid', border: '2px #1d4ed8 solid',
'border-style': 'dashed' 'border-style': 'dashed'
} }
}} }}
onconsider={handleDndConsider} onconsider={handleDndConsider}
onfinalize={handleDndFinalize} onfinalize={handleDndFinalize}
class="w-full" class="w-full"
> >
{#each items as item (item.id)} {#each items as item (item.id)}
<div <div
animate:flip={{ duration: flipDurationMs }} animate:flip={{ duration: flipDurationMs }}
class="ui:border-primary flex items-center rounded-md border p-4" class="ui:border-primary flex items-center rounded-md border p-4"
> >
{#if item.type === 1} {#if item.type === 1}
<SquareCheckIcon size={16} /> <SquareCheckIcon size={16} />
{:else if item.type === 2} {:else if item.type === 2}
<CircleDotIcon size={16} /> <CircleDotIcon size={16} />
{:else} {:else}
<TablePropertiesIcon size={16} /> <TablePropertiesIcon size={16} />
{/if} {/if}
{` ${item.name}`} {` ${item.name}`}
</div> </div>
{/each} {/each}
</section> </section>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -153,7 +153,7 @@
if (!isOpen) handleCancelAddExercise(); if (!isOpen) handleCancelAddExercise();
}} }}
> >
<Dialog.Content class="max-w-2xl w-4/5"> <Dialog.Content class="w-4/5 max-w-2xl">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.new_exercise_modal.heading')}</Dialog.Title> <Dialog.Title>{$t('course.navItem.lessons.exercises.new_exercise_modal.heading')}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
@@ -161,147 +161,147 @@
<Confetti /> <Confetti />
{/if} {/if}
{#if step === 0} {#if step === 0}
<div>
<h2 class="my-5 text-2xl font-medium">
{$t('course.navItem.lessons.exercises.new_exercise_modal.how')}?
</h2>
<div class="my-8 flex justify-between gap-2">
{#each options as option}
<button
class="h-60 w-[261px] rounded-md border-2 p-5 dark:bg-neutral-700 {option.type === type
? 'border-primary-400'
: `border-gray-200 dark:border-neutral-600 ${
!option.isDisabled && 'hover:scale-95'
}`} flex flex-col {option.isDisabled && 'cursor-not-allowed opacity-60'} transition-all ease-in-out"
type="button"
onclick={!option.isDisabled ? () => (type = option.type) : undefined}
>
<div class="flex h-[70%] w-full flex-row-reverse">
<CircleCheckIcon size={16} filled={option.type === type} />
</div>
<div>
<p class="flex items-center text-start">
<span class="mr-2 text-sm">{option.title}</span>
{#if option.isDisabled}
<ComingSoon />
{/if}
</p>
<p class="text-start text-xs font-light">{option.subtitle}</p>
</div>
</button>
{/each}
</div>
<div class="mt-8 flex flex-row-reverse items-center">
<Button onclick={handleNext}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.next')}
</Button>
</div>
</div>
{:else if step === 1}
{#if type === Type.SCRATCH}
<div class="m-auto flex min-h-[300px] w-96 items-center justify-center">
<div class="w-full">
<h2 class="my-5 text-2xl font-medium">
{$t('course.navItem.lessons.exercises.new_exercise_modal.title')}
</h2>
<InputField
bind:value={title}
autoFocus={true}
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.title_placeholder')}
className="my-4"
/>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" onclick={handleBack}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
</Button>
<Button onclick={handleAddExercise}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
</Button>
</div>
</div>
</div>
{:else if type === Type.TEMPLATE}
<div> <div>
<h2 class="m-0 mb-2 text-2xl font-medium"> <h2 class="my-5 text-2xl font-medium">
{$t('course.navItem.lessons.exercises.new_exercise_modal.select_template')} {$t('course.navItem.lessons.exercises.new_exercise_modal.how')}?
</h2> </h2>
<div> <div class="my-8 flex justify-between gap-2">
<div class="mb-5 flex items-center gap-2"> {#each options as option}
{#each tags as tag} <button
<Badge class={selectedTag === tag ? 'bg-primary-400' : ''} onclick={() => handleTagSelection(tag)} class="h-60 w-[261px] rounded-md border-2 p-5 dark:bg-neutral-700 {option.type === type
>{tag}</Badge ? 'border-primary-400'
> : `border-gray-200 dark:border-neutral-600 ${
{/each} !option.isDisabled && 'hover:scale-95'
</div> }`} flex flex-col {option.isDisabled && 'cursor-not-allowed opacity-60'} transition-all ease-in-out"
type="button"
{#if exerciseTemplateApi.isLoading} onclick={!option.isDisabled ? () => (type = option.type) : undefined}
<div class="grid grid-cols-2 items-start gap-4 lg:grid-cols-3 xl:grid-cols-4">
{#each Array(16) as _}
<div
class="h-[140px] w-full rounded-md border-2 border-gray-200 p-5 dark:border-neutral-600 dark:bg-neutral-700"
>
<div class="flex h-full flex-col justify-evenly">
<Skeleton class="h-4 w-3/4" />
<div class="flex flex-col items-start justify-between gap-1">
<Skeleton class="h-3 w-20" />
<Skeleton class="h-3 w-16" />
</div>
</div>
</div>
{/each}
</div>
{:else if exerciseTemplateApi.templates.length > 0}
<div class="grid grid-cols-2 items-start gap-4 lg:grid-cols-3 xl:grid-cols-4">
{#each exerciseTemplateApi.templates as template}
<button
class="h-[140px] w-full rounded-md border-2 p-5 hover:scale-95 dark:bg-neutral-700 {template.id ===
selectedTemplateId
? 'border-primary-400'
: `border-gray-200 dark:border-neutral-600 `} flex flex-col transition-all ease-in-out"
type="button"
onclick={() => (selectedTemplateId = template.id)}
>
<div class="flex h-full flex-col justify-evenly">
<p class="flex items-center text-start text-sm">
{template.title}
</p>
<div class="flex flex-col items-start justify-between gap-1">
<p class="text-xs font-normal">
{template.questions}
{$t('course.navItem.lessons.exercises.new_exercise_modal.questions')}
</p>
<p class="text-start text-xs font-normal">
{template.points}
{$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
</p>
</div>
</div>
</button>
{/each}
</div>
{/if}
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" onclick={handleBack}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
</Button>
<Button
disabled={!selectedTemplateId || exerciseTemplateApi.templates.length === 0}
loading={isTemplateFinishedLoading}
onclick={handleTemplateSelection}
> >
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')} <div class="flex h-[70%] w-full flex-row-reverse">
</Button> <CircleCheckIcon size={16} filled={option.type === type} />
</div> </div>
<div>
<p class="flex items-center text-start">
<span class="mr-2 text-sm">{option.title}</span>
{#if option.isDisabled}
<ComingSoon />
{/if}
</p>
<p class="text-start text-xs font-light">{option.subtitle}</p>
</div>
</button>
{/each}
</div>
<div class="mt-8 flex flex-row-reverse items-center">
<Button onclick={handleNext}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.next')}
</Button>
</div> </div>
</div> </div>
<!-- {:else if type === Type.AI} --> {:else if step === 1}
<!-- <div> {#if type === Type.SCRATCH}
<div class="m-auto flex min-h-[300px] w-96 items-center justify-center">
<div class="w-full">
<h2 class="my-5 text-2xl font-medium">
{$t('course.navItem.lessons.exercises.new_exercise_modal.title')}
</h2>
<InputField
bind:value={title}
autoFocus={true}
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.title_placeholder')}
className="my-4"
/>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" onclick={handleBack}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
</Button>
<Button onclick={handleAddExercise}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
</Button>
</div>
</div>
</div>
{:else if type === Type.TEMPLATE}
<div>
<h2 class="m-0 mb-2 text-2xl font-medium">
{$t('course.navItem.lessons.exercises.new_exercise_modal.select_template')}
</h2>
<div>
<div class="mb-5 flex items-center gap-2">
{#each tags as tag}
<Badge class={selectedTag === tag ? 'bg-primary-400' : ''} onclick={() => handleTagSelection(tag)}
>{tag}</Badge
>
{/each}
</div>
{#if exerciseTemplateApi.isLoading}
<div class="grid grid-cols-2 items-start gap-4 lg:grid-cols-3 xl:grid-cols-4">
{#each Array(16) as _}
<div
class="h-[140px] w-full rounded-md border-2 border-gray-200 p-5 dark:border-neutral-600 dark:bg-neutral-700"
>
<div class="flex h-full flex-col justify-evenly">
<Skeleton class="h-4 w-3/4" />
<div class="flex flex-col items-start justify-between gap-1">
<Skeleton class="h-3 w-20" />
<Skeleton class="h-3 w-16" />
</div>
</div>
</div>
{/each}
</div>
{:else if exerciseTemplateApi.templates.length > 0}
<div class="grid grid-cols-2 items-start gap-4 lg:grid-cols-3 xl:grid-cols-4">
{#each exerciseTemplateApi.templates as template}
<button
class="h-[140px] w-full rounded-md border-2 p-5 hover:scale-95 dark:bg-neutral-700 {template.id ===
selectedTemplateId
? 'border-primary-400'
: `border-gray-200 dark:border-neutral-600 `} flex flex-col transition-all ease-in-out"
type="button"
onclick={() => (selectedTemplateId = template.id)}
>
<div class="flex h-full flex-col justify-evenly">
<p class="flex items-center text-start text-sm">
{template.title}
</p>
<div class="flex flex-col items-start justify-between gap-1">
<p class="text-xs font-normal">
{template.questions}
{$t('course.navItem.lessons.exercises.new_exercise_modal.questions')}
</p>
<p class="text-start text-xs font-normal">
{template.points}
{$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
</p>
</div>
</div>
</button>
{/each}
</div>
{/if}
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" onclick={handleBack}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
</Button>
<Button
disabled={!selectedTemplateId || exerciseTemplateApi.templates.length === 0}
loading={isTemplateFinishedLoading}
onclick={handleTemplateSelection}
>
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
</Button>
</div>
</div>
</div>
<!-- {:else if type === Type.AI} -->
<!-- <div>
<div class="flex max-h-[500px] flex-row justify-between"> <div class="flex max-h-[500px] flex-row justify-between">
<div class="mr-1 w-[60%] rounded-md border px-3 py-2"> <div class="mr-1 w-[60%] rounded-md border px-3 py-2">
{#if note.length} {#if note.length}
@@ -369,7 +369,7 @@
</Button> </Button>
</div> </div>
</div> --> </div> -->
{/if} {/if}
{/if} {/if}
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -101,14 +101,14 @@
async function uploadDocument() { async function uploadDocument() {
if (!selectedFile) return; if (!selectedFile) return;
// Prevent free plan users from bypassing UI restrictions // Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) { if ($isFreePlan) {
$lessonDocUpload.error = $t('upgrade.required'); $lessonDocUpload.error = $t('upgrade.required');
snackbar.error($lessonDocUpload.error); snackbar.error($lessonDocUpload.error);
return; return;
} }
$lessonDocUpload.isUploading = true; $lessonDocUpload.isUploading = true;
try { try {
@@ -39,7 +39,7 @@
// Prevent free plan users from bypassing UI restrictions // Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) { if ($isFreePlan) {
formRes = { formRes = {
type: 'AUTHORIZATION_ERROR', type: 'AUTHORIZATION_ERROR',
status: 403, status: 403,
message: $t('upgrade.required') message: $t('upgrade.required')
}; };
@@ -15,27 +15,18 @@
} }
</script> </script>
<Dialog.Root <Dialog.Root bind:open={openDeleteModal}>
bind:open={openDeleteModal}
>
<Dialog.Content class="w-96"> <Dialog.Content class="w-96">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Delete Feed</Dialog.Title> <Dialog.Title>Delete Feed</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<div> <div>
<h1 class="text-lg dark:text-white">Are you sure you want to delete this feed?</h1> <h1 class="text-lg dark:text-white">Are you sure you want to delete this feed?</h1>
<div class="mt-5 flex items-center justify-between"> <div class="mt-5 flex items-center justify-between">
<Button <Button variant="outline" onclick={() => (openDeleteModal = false)}>No</Button>
variant="outline" <Button variant="outline" onclick={handleDelete}>Yes</Button>
onclick={() => (openDeleteModal = false)} </div>
>
No
</Button>
<Button variant="outline" onclick={handleDelete}>
Yes
</Button>
</div> </div>
</div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -127,30 +127,27 @@
</Dialog.Title> </Dialog.Title>
</Dialog.Header> </Dialog.Header>
<section class="w-2/ flex h-full flex-col rounded-xl pb-3"> <section class="w-2/ flex h-full flex-col rounded-xl pb-3">
<TextEditor <TextEditor
content={newPost} content={newPost}
onChange={(text) => { onChange={(text) => {
newPost = text; newPost = text;
}} }}
editorClass="max-h-[400px]" editorClass="max-h-[400px]"
placeholder={$t('course.navItem.news_feed.heading_button.placeholder')} placeholder={$t('course.navItem.news_feed.heading_button.placeholder')}
/> />
{#if errors.newPost} {#if errors.newPost}
<p class="text-sm text-red-500">{errors.newPost}</p> <p class="text-sm text-red-500">{errors.newPost}</p>
{/if} {/if}
<div class="flex items-center justify-end py-2"> <div class="flex items-center justify-end py-2">
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button variant="outline" onclick={resetEditor}>
variant="outline" {$t('course.navItem.news_feed.heading_button.cancel')}
onclick={resetEditor} </Button>
> <Button loading={isLoading} onclick={onPost}>
{$t('course.navItem.news_feed.heading_button.cancel')} {$t('course.navItem.news_feed.heading_button.post')}
</Button> </Button>
<Button loading={isLoading} onclick={onPost}> </div>
{$t('course.navItem.news_feed.heading_button.post')}
</Button>
</div> </div>
</div> </section>
</section>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -12,32 +12,24 @@
} }
</script> </script>
<Dialog.Root <Dialog.Root bind:open={$deleteMemberModal.open}>
bind:open={$deleteMemberModal.open}
>
<Dialog.Content class="w-96 pt-3"> <Dialog.Content class="w-96 pt-3">
<Dialog.Header class="py-2 px-5"> <Dialog.Header class="px-5 py-2">
<Dialog.Title>{$t('course.navItem.people.delete_confirmation.title')}</Dialog.Title> <Dialog.Title>{$t('course.navItem.people.delete_confirmation.title')}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<div> <div>
<p class="mt-0 text-base dark:text-white"> <p class="mt-0 text-base dark:text-white">
{$t('course.navItem.people.delete_confirmation.sure')} <strong>{email}</strong>? {$t('course.navItem.people.delete_confirmation.sure')} <strong>{email}</strong>?
</p> </p>
<div class="mt-5 flex items-center justify-between"> <div class="mt-5 flex items-center justify-between">
<Button <Button variant="outline" onclick={() => ($deleteMemberModal.open = false)}>
variant="outline" {$t('course.navItem.people.delete_confirmation.no')}
onclick={() => ($deleteMemberModal.open = false)} </Button>
> <Button variant="outline" onclick={handleDelete}>
{$t('course.navItem.people.delete_confirmation.no')} {$t('course.navItem.people.delete_confirmation.yes')}
</Button> </Button>
<Button </div>
variant="outline"
onclick={handleDelete}
>
{$t('course.navItem.people.delete_confirmation.yes')}
</Button>
</div> </div>
</div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -20,37 +20,28 @@
}: Props = $props(); }: Props = $props();
</script> </script>
<Dialog.Root <Dialog.Root bind:open>
bind:open
>
<Dialog.Content class="w-96"> <Dialog.Content class="w-96">
<Dialog.Header> <Dialog.Header>
<Dialog.Title> <Dialog.Title>
{$t('community.delete.title')} {isQuestion {$t('community.delete.title')}
? $t('community.delete.question') {isQuestion ? $t('community.delete.question') : $t('community.delete.comment')}
: $t('community.delete.comment')}
</Dialog.Title> </Dialog.Title>
</Dialog.Header> </Dialog.Header>
<div> <div>
<p class="mt-0 text-base dark:text-white"> <p class="mt-0 text-base dark:text-white">
{$t('community.delete.sure')} {$t('community.delete.sure')}
{isQuestion ? $t('community.delete.question') : $t('community.delete.comment')}? {isQuestion ? $t('community.delete.question') : $t('community.delete.comment')}?
</p> </p>
<div class="mt-5 flex items-center justify-between"> <div class="mt-5 flex items-center justify-between">
<Button <Button variant="outline" onclick={onCancel}>
variant="outline" {$t('community.delete.no')}
onclick={onCancel} </Button>
> <Button onclick={onDelete} loading={isDeleting}>
{$t('community.delete.no')} {$t('community.delete.yes')}
</Button> </Button>
<Button </div>
onclick={onDelete}
loading={isDeleting}
>
{$t('community.delete.yes')}
</Button>
</div> </div>
</div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -7,4 +7,3 @@ export interface CurrentCommunityQuestion {
export const currentCommunityQuestion = writable<CurrentCommunityQuestion>({ export const currentCommunityQuestion = writable<CurrentCommunityQuestion>({
title: null title: null
}); });
@@ -20,43 +20,41 @@
}); });
</script> </script>
<Dialog.Root <Dialog.Root bind:open={$copyCourseModal.open}>
bind:open={$copyCourseModal.open}
>
<Dialog.Content class="w-96"> <Dialog.Content class="w-96">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{$t('courses.copy_course.title')}</Dialog.Title> <Dialog.Title>{$t('courses.copy_course.title')}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<form onsubmit={preventDefault(createCourse)}> <form onsubmit={preventDefault(createCourse)}>
<InputField <InputField
label={$t('courses.copy_course.course_name_label')} label={$t('courses.copy_course.course_name_label')}
bind:value={$copyCourseModal.title} bind:value={$copyCourseModal.title}
autoFocus={true} autoFocus={true}
placeholder={$t('courses.copy_course.course_name_placeholder')} placeholder={$t('courses.copy_course.course_name_placeholder')}
className="mb-4" className="mb-4"
isRequired={true} isRequired={true}
autoComplete={false} autoComplete={false}
errorMessage={courseCloneApi.errors.title} errorMessage={courseCloneApi.errors.title}
/> />
<TextareaField <TextareaField
label={$t('courses.copy_course.course_description_label')} label={$t('courses.copy_course.course_description_label')}
bind:value={$copyCourseModal.description} bind:value={$copyCourseModal.description}
placeholder={$t('courses.copy_course.course_description_placeholder')} placeholder={$t('courses.copy_course.course_description_placeholder')}
className="mb-4" className="mb-4"
rows={4} rows={4}
errorMessage={courseCloneApi.errors.description} errorMessage={courseCloneApi.errors.description}
/> />
{#if courseCloneApi.errors.general} {#if courseCloneApi.errors.general}
<div class="mb-4 text-sm text-red-600">{courseCloneApi.errors.general}</div> <div class="mb-4 text-sm text-red-600">{courseCloneApi.errors.general}</div>
{/if} {/if}
<div class="mt-5 flex flex-row-reverse items-center"> <div class="mt-5 flex flex-row-reverse items-center">
<Button type="submit" loading={$copyCourseModal.isSaving}> <Button type="submit" loading={$copyCourseModal.isSaving}>
{$t('courses.copy_course.create_button')} {$t('courses.copy_course.create_button')}
</Button> </Button>
</div> </div>
</form> </form>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -233,4 +233,3 @@
</div> </div>
{/if} {/if}
</section> </section>
@@ -109,4 +109,3 @@
variant="page" variant="page"
/> />
{/if} {/if}
@@ -55,4 +55,3 @@
{/if} {/if}
</div> </div>
</section> </section>
@@ -2,9 +2,9 @@ import type { Readable } from 'svelte/store';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
export interface DOMProtectionCleanup { export interface DOMProtectionCleanup {
protectionInterval?: ReturnType<typeof setInterval>; protectionInterval?: ReturnType<typeof setInterval>;
domObserver?: MutationObserver; domObserver?: MutationObserver;
cleanup: () => void; cleanup: () => void;
} }
/** /**
@@ -14,192 +14,190 @@ export interface DOMProtectionCleanup {
* @param isEmailVerified - A readable store or getter function that returns whether email is verified * @param isEmailVerified - A readable store or getter function that returns whether email is verified
* @returns Cleanup object with intervals, observers, and a cleanup function * @returns Cleanup object with intervals, observers, and a cleanup function
*/ */
export function setupDOMProtection( export function setupDOMProtection(isEmailVerified: Readable<boolean> | (() => boolean | null)): DOMProtectionCleanup {
isEmailVerified: Readable<boolean> | (() => boolean | null) if (!browser) {
): DOMProtectionCleanup { return {
if (!browser) { cleanup: () => {}
return { };
cleanup: () => {} }
};
}
let protectionInterval: ReturnType<typeof setInterval> | undefined; let protectionInterval: ReturnType<typeof setInterval> | undefined;
let domObserver: MutationObserver | undefined; let domObserver: MutationObserver | undefined;
// Helper to get current verification status // Helper to get current verification status
const getIsVerified = () => { const getIsVerified = () => {
if (typeof isEmailVerified === 'function') { if (typeof isEmailVerified === 'function') {
return isEmailVerified() ?? false; return isEmailVerified() ?? false;
} }
// For stores, we need to subscribe temporarily - this is a limitation // For stores, we need to subscribe temporarily - this is a limitation
// In practice, the component should pass a getter function // In practice, the component should pass a getter function
let value = false; let value = false;
const unsubscribe = isEmailVerified.subscribe((v) => { const unsubscribe = isEmailVerified.subscribe((v) => {
value = v ?? false; value = v ?? false;
}); });
unsubscribe(); unsubscribe();
return value; return value;
}; };
// Function to disable all interactive elements // Function to disable all interactive elements
function disableInteractions() { function disableInteractions() {
if (getIsVerified()) return; if (getIsVerified()) return;
// Disable all buttons except verification-related ones // Disable all buttons except verification-related ones
const buttons = document.querySelectorAll('button, [role="button"], a[href], input[type="submit"]'); const buttons = document.querySelectorAll('button, [role="button"], a[href], input[type="submit"]');
buttons.forEach((button) => { buttons.forEach((button) => {
const el = button as HTMLElement; const el = button as HTMLElement;
// Skip if already protected or is verification-related // Skip if already protected or is verification-related
if ( if (
el.dataset.verificationProtected === 'true' || el.dataset.verificationProtected === 'true' ||
el.closest('[data-verification-modal]') || el.closest('[data-verification-modal]') ||
el.textContent?.toLowerCase().includes('verify') || el.textContent?.toLowerCase().includes('verify') ||
el.textContent?.toLowerCase().includes('resend') el.textContent?.toLowerCase().includes('resend')
) { ) {
return; return;
} }
el.dataset.verificationProtected = 'true'; el.dataset.verificationProtected = 'true';
el.dataset.originalPointerEvents = el.style.pointerEvents || 'auto'; el.dataset.originalPointerEvents = el.style.pointerEvents || 'auto';
el.style.pointerEvents = 'none'; el.style.pointerEvents = 'none';
el.style.opacity = '0.5'; el.style.opacity = '0.5';
el.style.cursor = 'not-allowed'; el.style.cursor = 'not-allowed';
el.setAttribute('title', 'Email verification required'); el.setAttribute('title', 'Email verification required');
// Add click prevention // Add click prevention
el.addEventListener('click', preventInteraction, true); el.addEventListener('click', preventInteraction, true);
el.addEventListener('mousedown', preventInteraction, true); el.addEventListener('mousedown', preventInteraction, true);
}); });
// Disable all form inputs // Disable all form inputs
const inputs = document.querySelectorAll('input, textarea, select'); const inputs = document.querySelectorAll('input, textarea, select');
inputs.forEach((input) => { inputs.forEach((input) => {
const el = input as HTMLInputElement; const el = input as HTMLInputElement;
// Skip if in verification modal // Skip if in verification modal
if (el.closest('[data-verification-modal]')) return; if (el.closest('[data-verification-modal]')) return;
if (!el.dataset.verificationProtected) { if (!el.dataset.verificationProtected) {
el.dataset.verificationProtected = 'true'; el.dataset.verificationProtected = 'true';
el.dataset.originalDisabled = el.disabled ? 'true' : 'false'; el.dataset.originalDisabled = el.disabled ? 'true' : 'false';
el.disabled = true; el.disabled = true;
el.style.opacity = '0.5'; el.style.opacity = '0.5';
el.addEventListener('focus', preventInteraction, true); el.addEventListener('focus', preventInteraction, true);
el.addEventListener('input', preventInteraction, true); el.addEventListener('input', preventInteraction, true);
} }
}); });
// Disable contenteditable elements // Disable contenteditable elements
const editables = document.querySelectorAll('[contenteditable="true"]'); const editables = document.querySelectorAll('[contenteditable="true"]');
editables.forEach((el) => { editables.forEach((el) => {
const element = el as HTMLElement; const element = el as HTMLElement;
if (!element.dataset.verificationProtected) { if (!element.dataset.verificationProtected) {
element.dataset.verificationProtected = 'true'; element.dataset.verificationProtected = 'true';
element.contentEditable = 'false'; element.contentEditable = 'false';
element.style.opacity = '0.5'; element.style.opacity = '0.5';
} }
}); });
} }
function preventInteraction(event: Event) { function preventInteraction(event: Event) {
if (!getIsVerified()) { if (!getIsVerified()) {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
// Show warning // Show warning
if (event.target) { if (event.target) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
target.setAttribute('title', 'Please verify your email to use this feature'); target.setAttribute('title', 'Please verify your email to use this feature');
} }
// Optional: Show alert // Optional: Show alert
console.warn('🔒 Email verification required to interact with this element'); console.warn('🔒 Email verification required to interact with this element');
return false; return false;
} }
} }
// Function to restore interactions when verified // Function to restore interactions when verified
function enableInteractions() { function enableInteractions() {
const protectedElements = document.querySelectorAll('[data-verification-protected="true"]'); const protectedElements = document.querySelectorAll('[data-verification-protected="true"]');
protectedElements.forEach((el) => { protectedElements.forEach((el) => {
const element = el as HTMLElement; const element = el as HTMLElement;
// Restore styles // Restore styles
if (element.dataset.originalPointerEvents) { if (element.dataset.originalPointerEvents) {
element.style.pointerEvents = element.dataset.originalPointerEvents; element.style.pointerEvents = element.dataset.originalPointerEvents;
} else { } else {
element.style.pointerEvents = ''; element.style.pointerEvents = '';
} }
element.style.opacity = ''; element.style.opacity = '';
element.style.cursor = ''; element.style.cursor = '';
element.removeAttribute('title'); element.removeAttribute('title');
// Restore functionality for inputs // Restore functionality for inputs
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT') { if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT') {
const input = element as HTMLInputElement; const input = element as HTMLInputElement;
input.disabled = input.dataset.originalDisabled === 'true'; input.disabled = input.dataset.originalDisabled === 'true';
} }
// Restore contenteditable // Restore contenteditable
if (element.hasAttribute('contenteditable')) { if (element.hasAttribute('contenteditable')) {
element.contentEditable = 'true'; element.contentEditable = 'true';
} }
// Remove event listeners // Remove event listeners
element.removeEventListener('click', preventInteraction, true); element.removeEventListener('click', preventInteraction, true);
element.removeEventListener('mousedown', preventInteraction, true); element.removeEventListener('mousedown', preventInteraction, true);
element.removeEventListener('focus', preventInteraction, true); element.removeEventListener('focus', preventInteraction, true);
element.removeEventListener('input', preventInteraction, true); element.removeEventListener('input', preventInteraction, true);
// Clean up data attributes // Clean up data attributes
delete element.dataset.verificationProtected; delete element.dataset.verificationProtected;
delete element.dataset.originalPointerEvents; delete element.dataset.originalPointerEvents;
delete element.dataset.originalDisabled; delete element.dataset.originalDisabled;
}); });
} }
// Set up mutation observer to protect new elements // Set up mutation observer to protect new elements
if (window.MutationObserver) { if (window.MutationObserver) {
domObserver = new MutationObserver((mutations) => { domObserver = new MutationObserver((mutations) => {
let shouldReapply = false; let shouldReapply = false;
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
shouldReapply = true; shouldReapply = true;
} }
}); });
if (shouldReapply && !getIsVerified()) { if (shouldReapply && !getIsVerified()) {
setTimeout(disableInteractions, 100); setTimeout(disableInteractions, 100);
} }
}); });
domObserver.observe(document.body, { domObserver.observe(document.body, {
childList: true, childList: true,
subtree: true subtree: true
}); });
} }
// Run protection every second // Run protection every second
protectionInterval = setInterval(() => { protectionInterval = setInterval(() => {
if (getIsVerified()) { if (getIsVerified()) {
enableInteractions(); enableInteractions();
cleanup(); cleanup();
} else { } else {
disableInteractions(); disableInteractions();
} }
}, 1000); }, 1000);
// Initial protection // Initial protection
if (!getIsVerified()) { if (!getIsVerified()) {
disableInteractions(); disableInteractions();
} }
// Add global CSS to prevent any missed interactions // Add global CSS to prevent any missed interactions
if (document) { if (document) {
const style = document.createElement('style'); const style = document.createElement('style');
style.id = 'email-verification-protection'; style.id = 'email-verification-protection';
style.textContent = ` style.textContent = `
body:not(.email-verified) button:not([data-verification-modal] button), body:not(.email-verified) button:not([data-verification-modal] button),
body:not(.email-verified) a:not([data-verification-modal] a), body:not(.email-verified) a:not([data-verification-modal] a),
body:not(.email-verified) input:not([data-verification-modal] input), body:not(.email-verified) input:not([data-verification-modal] input),
@@ -210,45 +208,44 @@ export function setupDOMProtection(
user-select: none !important; user-select: none !important;
} }
`; `;
document.head.appendChild(style); document.head.appendChild(style);
} }
function cleanup() { function cleanup() {
if (protectionInterval) { if (protectionInterval) {
clearInterval(protectionInterval); clearInterval(protectionInterval);
protectionInterval = undefined; protectionInterval = undefined;
} }
if (domObserver) { if (domObserver) {
domObserver.disconnect(); domObserver.disconnect();
domObserver = undefined; domObserver = undefined;
} }
// Clean up protection styles // Clean up protection styles
if (browser) { if (browser) {
const style = document?.getElementById('email-verification-protection'); const style = document?.getElementById('email-verification-protection');
if (style) style.remove(); if (style) style.remove();
} }
} }
return { return {
protectionInterval, protectionInterval,
domObserver, domObserver,
cleanup cleanup
}; };
} }
/** /**
* Updates the body class based on email verification status * Updates the body class based on email verification status
*/ */
export function updateBodyClass(isEmailVerified: boolean | null) { export function updateBodyClass(isEmailVerified: boolean | null) {
if (!browser) { if (!browser) {
return; return;
} }
if (isEmailVerified) { if (isEmailVerified) {
document.body.classList.add('email-verified'); document.body.classList.add('email-verified');
} else { } else {
document.body.classList.remove('email-verified'); document.body.classList.remove('email-verified');
} }
} }
@@ -41,30 +41,29 @@
<Dialog.Title>{$t('add_org.create_org')}</Dialog.Title> <Dialog.Title>{$t('add_org.create_org')}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<form onsubmit={preventDefault(createNewOrg)} class="px-2"> <form onsubmit={preventDefault(createNewOrg)} class="px-2">
<Field.Group> <Field.Group>
<Field.Field> <Field.Field>
<Field.Label>{$t('add_org.name')}</Field.Label> <Field.Label>{$t('add_org.name')}</Field.Label>
<Input bind:value={orgName} placeholder="e.g Pepsi Co" autofocus /> <Input bind:value={orgName} placeholder="e.g Pepsi Co" autofocus />
{#if orgApi.errors.name || orgApi.errors.orgName} {#if orgApi.errors.name || orgApi.errors.orgName}
<Field.Error>{orgApi.errors.name || orgApi.errors.orgName}</Field.Error> <Field.Error>{orgApi.errors.name || orgApi.errors.orgName}</Field.Error>
{/if} {/if}
</Field.Field> </Field.Field>
<Field.Field> <Field.Field>
<Field.Label>{$t('add_org.org_sitename')}</Field.Label> <Field.Label>{$t('add_org.org_sitename')}</Field.Label>
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".classroomio.com" /> <DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".classroomio.com" />
{#if orgApi.errors.siteName || orgApi.errors.general} {#if orgApi.errors.siteName || orgApi.errors.general}
<Field.Error>{orgApi.errors.siteName || orgApi.errors.general}</Field.Error> <Field.Error>{orgApi.errors.siteName || orgApi.errors.general}</Field.Error>
{/if} {/if}
</Field.Field> </Field.Field>
<Field.Field orientation="horizontal"> <Field.Field orientation="horizontal">
<Button type="submit" loading={orgApi.isLoading} class="ml-auto"> <Button type="submit" loading={orgApi.isLoading} class="ml-auto">
{$t('add_org.create')} {$t('add_org.create')}
</Button> </Button>
</Field.Field> </Field.Field>
</Field.Group> </Field.Group>
</form> </form>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -27,4 +27,3 @@
<p class="text-sm text-red-600">{$t('courses.course_card.error_message')}</p> <p class="text-sm text-red-600">{$t('courses.course_card.error_message')}</p>
</div> </div>
{/await} {/await}
@@ -25,4 +25,3 @@
</div> </div>
</div> </div>
</section> </section>
@@ -24,4 +24,3 @@
<CourseCardLoader /> <CourseCardLoader />
</div> </div>
</section> </section>
@@ -3,4 +3,3 @@ import { writable } from 'svelte/store';
export const newOrgModal = writable({ export const newOrgModal = writable({
open: false open: false
}); });
@@ -43,29 +43,29 @@
</Dialog.Title> </Dialog.Title>
</Dialog.Header> </Dialog.Header>
<div class="my-10 flex w-full flex-col items-center"> <div class="my-10 flex w-full flex-col items-center">
<h1 class="text-center text-lg dark:text-white"> <h1 class="text-center text-lg dark:text-white">
{#if $deleteModal.isQuestion} {#if $deleteModal.isQuestion}
{$t('components.quiz.delete_question')} {$t('components.quiz.delete_question')}
{:else} {:else}
{$t('components.quiz.delete_quiz')} {$t('components.quiz.delete_quiz')}
{/if} {/if}
{$t('components.quiz.lose_content')} {$t('components.quiz.lose_content')}
</h1> </h1>
<div class="mt-5"> <div class="mt-5">
<Button <Button
onclick={() => { onclick={() => {
if ($deleteModal.isQuestion) { if ($deleteModal.isQuestion) {
onDelete(); onDelete();
$deleteModal.open = false; $deleteModal.open = false;
} else { } else {
deleteQuiz(); deleteQuiz();
} }
}} }}
> >
{$t('components.quiz.delete')} {$t('components.quiz.delete')}
</Button> </Button>
</div>
</div> </div>
</div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -114,28 +114,27 @@
<Dialog.Content class="w-4/5 md:w-2/5"> <Dialog.Content class="w-4/5 md:w-2/5">
<Dialog.Header> <Dialog.Header>
<Dialog.Title> <Dialog.Title>
{$createQuizModal.openEdit ? $t('components.quiz.update') : $t('components.quiz.create')} {$t( {$createQuizModal.openEdit ? $t('components.quiz.update') : $t('components.quiz.create')}
'components.quiz.a_quiz' {$t('components.quiz.a_quiz')}
)}
</Dialog.Title> </Dialog.Title>
</Dialog.Header> </Dialog.Header>
<form onsubmit={preventDefault(createQuiz)}> <form onsubmit={preventDefault(createQuiz)}>
<InputField <InputField
label={$t('components.quiz.quiz_title')} label={$t('components.quiz.quiz_title')}
bind:value={$createQuizModal.title} bind:value={$createQuizModal.title}
autofocus={true} autofocus={true}
placeholder={$t('components.quiz.placehoolder')} placeholder={$t('components.quiz.placehoolder')}
className="mb-4" className="mb-4"
isRequired={true} isRequired={true}
errorMessage={errors.title} errorMessage={errors.title}
autoComplete={false} autoComplete={false}
/> />
<div class="mt-5 flex items-center justify-end"> <div class="mt-5 flex items-center justify-end">
<Button type="submit" disabled={isLoading} loading={isLoading}> <Button type="submit" disabled={isLoading} loading={isLoading}>
{$createQuizModal.openEdit ? $t('components.quiz.save') : $t('components.quiz.continue')} {$createQuizModal.openEdit ? $t('components.quiz.save') : $t('components.quiz.continue')}
</Button> </Button>
</div> </div>
</form> </form>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -53,10 +53,7 @@
errorMessage={optionHasError(option.id, currentError.options) && $t('components.quiz.label')} errorMessage={optionHasError(option.id, currentError.options) && $t('components.quiz.label')}
/> />
<div class="absolute right-2 top-2 flex justify-end"> <div class="absolute right-2 top-2 flex justify-end">
<IconButton <IconButton onclick={() => (option.isCorrect = !option.isCorrect)} class={option.isCorrect ? 'success' : ''}>
onclick={() => (option.isCorrect = !option.isCorrect)}
class={option.isCorrect ? 'success' : ''}
>
<CircleCheckIcon size={16} filled={option.isCorrect} /> <CircleCheckIcon size={16} filled={option.isCorrect} />
</IconButton> </IconButton>
</div> </div>
@@ -114,4 +114,3 @@ export const landingPageSettings: Writable<OrgLandingPageJson> = writable({
show: true show: true
} }
}); });
@@ -38,4 +38,3 @@
</p> </p>
</div> </div>
</div> </div>
@@ -44,4 +44,3 @@
</div> </div>
</div> </div>
</div> </div>
@@ -10,4 +10,3 @@
<Skeleton class="h-40 w-[270px]" /> <Skeleton class="h-40 w-[270px]" />
</div> </div>
</div> </div>
@@ -23,27 +23,18 @@
<Dialog.Title>{$t('delete_modal.label')}</Dialog.Title> <Dialog.Title>{$t('delete_modal.label')}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<div> <div>
<p class="text-center text-xl dark:text-white"> <p class="text-center text-xl dark:text-white">
{$t('delete_modal.content')} {$t('delete_modal.content')}
</p> </p>
<div class="mt-8 flex items-center justify-between"> <div class="mt-8 flex items-center justify-between">
<Button <Button variant="outline" onclick={() => (open = false)} disabled={isLoading}>
variant="outline" {$t('delete_modal.no')}
onclick={() => (open = false)} </Button>
disabled={isLoading} <Button variant="destructive" onclick={onDelete} loading={isLoading}>
> {$t('delete_modal.yes')}
{$t('delete_modal.no')} </Button>
</Button> </div>
<Button
variant="destructive"
onclick={onDelete}
loading={isLoading}
>
{$t('delete_modal.yes')}
</Button>
</div> </div>
</div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -15,4 +15,3 @@
<line x1="17" y1="7" x2="7" y2="17" /><polyline points="8 7 17 7 17 16" /> <line x1="17" y1="7" x2="7" y2="17" /><polyline points="8 7 17 7 17 16" />
</svg> </svg>
</span> </span>
@@ -51,4 +51,3 @@
fill: none; /* Ensure the checkmark path itself is not filled */ fill: none; /* Ensure the checkmark path itself is not filled */
} }
</style> </style>
@@ -41,4 +41,3 @@
stroke-linejoin="bevel" stroke-linejoin="bevel"
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -15,4 +15,3 @@
stroke-width="5" stroke-width="5"
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -183,4 +183,3 @@
fill="#E4E4E4" fill="#E4E4E4"
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -183,4 +183,3 @@
fill="#5C6065" fill="#5C6065"
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -24,4 +24,3 @@
fill="#EB4132" fill="#EB4132"
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -15,4 +15,3 @@
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -46,12 +46,12 @@ export interface BreadcrumbConfig {
* Can be a string or a function that extracts the prefix dynamically * Can be a string or a function that extracts the prefix dynamically
*/ */
pathPrefix: string | ((pathname: string) => string); pathPrefix: string | ((pathname: string) => string);
/** /**
* Base path for generating hrefs (e.g., '/lms', '/org/myorg') * Base path for generating hrefs (e.g., '/lms', '/org/myorg')
*/ */
basePath: string | ((pathname: string) => string); basePath: string | ((pathname: string) => string);
/** /**
* Resolver for dynamic segment titles * Resolver for dynamic segment titles
* Can resolve from page.data, stores, or API calls * Can resolve from page.data, stores, or API calls
@@ -62,12 +62,12 @@ export interface BreadcrumbConfig {
fullPathname: string, fullPathname: string,
context?: unknown context?: unknown
) => string | Promise<string> | null | undefined; ) => string | Promise<string> | null | undefined;
/** /**
* Optional: Cache breadcrumbs for better performance * Optional: Cache breadcrumbs for better performance
*/ */
enableCache?: boolean; enableCache?: boolean;
/** /**
* Optional: Maximum breadcrumb depth (prevents infinite recursion) * Optional: Maximum breadcrumb depth (prevents infinite recursion)
*/ */
@@ -107,7 +107,7 @@ function getCachedOrGenerate(
} }
clearExpiredCache(); clearExpiredCache();
const cached = breadcrumbCache.get(cacheKey); const cached = breadcrumbCache.get(cacheKey);
if (cached) { if (cached) {
return cached; return cached;
@@ -122,31 +122,25 @@ function getCachedOrGenerate(
/** /**
* Extract path prefix from pathname * Extract path prefix from pathname
*/ */
function extractPathPrefix( function extractPathPrefix(pathname: string, prefixConfig: string | ((pathname: string) => string)): string {
pathname: string,
prefixConfig: string | ((pathname: string) => string)
): string {
if (typeof prefixConfig === 'function') { if (typeof prefixConfig === 'function') {
return prefixConfig(pathname); return prefixConfig(pathname);
} }
// Handle dynamic prefixes like '/org/[slug]' // Handle dynamic prefixes like '/org/[slug]'
if (prefixConfig.includes('[')) { if (prefixConfig.includes('[')) {
const pattern = prefixConfig.replace(/\[.*?\]/g, '[^/]+'); const pattern = prefixConfig.replace(/\[.*?\]/g, '[^/]+');
const match = pathname.match(new RegExp(`^${pattern}`)); const match = pathname.match(new RegExp(`^${pattern}`));
return match ? match[0] : ''; return match ? match[0] : '';
} }
return pathname.startsWith(prefixConfig) ? prefixConfig : ''; return pathname.startsWith(prefixConfig) ? prefixConfig : '';
} }
/** /**
* Get base path for href generation * Get base path for href generation
*/ */
function getBasePath( function getBasePath(pathname: string, basePathConfig: string | ((pathname: string) => string)): string {
pathname: string,
basePathConfig: string | ((pathname: string) => string)
): string {
if (typeof basePathConfig === 'function') { if (typeof basePathConfig === 'function') {
return basePathConfig(pathname); return basePathConfig(pathname);
} }
@@ -228,19 +222,16 @@ async function resolveDynamicTitle(
/** /**
* Extract parameters from pathname (e.g., { slug: 'my-post' }) * Extract parameters from pathname (e.g., { slug: 'my-post' })
*/ */
function extractParamsFromPath( function extractParamsFromPath(pathname: string, parentPath: string): Record<string, string> {
pathname: string,
parentPath: string
): Record<string, string> {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
const relativePath = pathname.replace(parentPath, '').replace(/^\//, ''); const relativePath = pathname.replace(parentPath, '').replace(/^\//, '');
const segments = relativePath.split('/').filter(Boolean); const segments = relativePath.split('/').filter(Boolean);
// Simple extraction - can be enhanced for more complex patterns // Simple extraction - can be enhanced for more complex patterns
segments.forEach((segment, index) => { segments.forEach((segment, index) => {
params[`param${index}`] = segment; params[`param${index}`] = segment;
}); });
return params; return params;
} }
@@ -272,7 +263,7 @@ async function generateNestedBreadcrumbs(
// Check for static nested route // Check for static nested route
if (navItem.nestedRoutes) { if (navItem.nestedRoutes) {
const nestedRoute = navItem.nestedRoutes.find((route) => route.path === currentSegment); const nestedRoute = navItem.nestedRoutes.find((route) => route.path === currentSegment);
if (nestedRoute) { if (nestedRoute) {
breadcrumbs.push({ breadcrumbs.push({
label: nestedRoute.titleKey, label: nestedRoute.titleKey,
@@ -300,16 +291,11 @@ async function generateNestedBreadcrumbs(
} }
// Handle dynamic segment // Handle dynamic segment
if (navItem.supportsDynamicSegment || if (
navItem.nestedRoutes?.some((route) => route.supportsDynamicSegment && route.path !== currentSegment)) { navItem.supportsDynamicSegment ||
const dynamicTitle = await resolveDynamicTitle( navItem.nestedRoutes?.some((route) => route.supportsDynamicSegment && route.path !== currentSegment)
currentSegment, ) {
currentPath, const dynamicTitle = await resolveDynamicTitle(currentSegment, currentPath, fullPathname, navItem, config, context);
fullPathname,
navItem,
config,
context
);
if (dynamicTitle) { if (dynamicTitle) {
breadcrumbs.push({ breadcrumbs.push({
@@ -323,7 +309,7 @@ async function generateNestedBreadcrumbs(
const supportingNestedRoute = navItem.nestedRoutes?.find( const supportingNestedRoute = navItem.nestedRoutes?.find(
(route) => route.supportsDynamicSegment && route.nestedRoutes (route) => route.supportsDynamicSegment && route.nestedRoutes
); );
if (supportingNestedRoute) { if (supportingNestedRoute) {
const nestedBreadcrumbs = await generateNestedBreadcrumbs( const nestedBreadcrumbs = await generateNestedBreadcrumbs(
remainingSegments, remainingSegments,
@@ -356,7 +342,7 @@ export async function generateBreadcrumbs(
context?: unknown context?: unknown
): Promise<BreadcrumbItem[]> { ): Promise<BreadcrumbItem[]> {
const cacheKey = `${pathname}${searchParams}`; const cacheKey = `${pathname}${searchParams}`;
return getCachedOrGenerate( return getCachedOrGenerate(
cacheKey, cacheKey,
() => generateBreadcrumbsSync(pathname, searchParams, navItems, config, context), () => generateBreadcrumbsSync(pathname, searchParams, navItems, config, context),
@@ -381,7 +367,7 @@ export function generateBreadcrumbsSync(
// Extract path prefix and base path // Extract path prefix and base path
const pathPrefix = extractPathPrefix(pathname, config.pathPrefix); const pathPrefix = extractPathPrefix(pathname, config.pathPrefix);
const basePath = getBasePath(pathname, config.basePath); const basePath = getBasePath(pathname, config.basePath);
// Get relative path after removing prefix // Get relative path after removing prefix
const relativePath = pathname.replace(pathPrefix, '').replace(/^\//, '') || ''; const relativePath = pathname.replace(pathPrefix, '').replace(/^\//, '') || '';
const pathSegments = relativePath.split('/').filter(Boolean); const pathSegments = relativePath.split('/').filter(Boolean);
@@ -400,10 +386,8 @@ export function generateBreadcrumbsSync(
} }
// Add parent breadcrumb // Add parent breadcrumb
const parentHref = matchedNavItem.useHashUrl const parentHref = matchedNavItem.useHashUrl ? `${basePath}${matchedNavItem.path}` : matchedNavItem.url;
? `${basePath}${matchedNavItem.path}`
: matchedNavItem.url;
breadcrumbs.push({ breadcrumbs.push({
label: matchedNavItem.breadcrumbLabel || matchedNavItem.title, label: matchedNavItem.breadcrumbLabel || matchedNavItem.title,
href: parentHref href: parentHref
@@ -411,10 +395,8 @@ export function generateBreadcrumbsSync(
// Handle query params (settings tabs, etc.) // Handle query params (settings tabs, etc.)
if (matchedNavItem.items && searchParams) { if (matchedNavItem.items && searchParams) {
const subItem = matchedNavItem.items.find((sub) => const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, undefined, true));
isActive(pathWithQuery, sub.url, undefined, true)
);
if (subItem) { if (subItem) {
breadcrumbs.push({ breadcrumbs.push({
label: subItem.breadcrumbLabel || subItem.title, label: subItem.breadcrumbLabel || subItem.title,
@@ -428,7 +410,7 @@ export function generateBreadcrumbsSync(
if (pathSegments.length > 1) { if (pathSegments.length > 1) {
const remainingSegments = pathSegments.slice(1); const remainingSegments = pathSegments.slice(1);
const parentPath = `${basePath}${matchedNavItem.path}`; const parentPath = `${basePath}${matchedNavItem.path}`;
// Note: For async resolvers, you'd need to use generateBreadcrumbs instead // Note: For async resolvers, you'd need to use generateBreadcrumbs instead
const nestedBreadcrumbs = generateNestedBreadcrumbsSync( const nestedBreadcrumbs = generateNestedBreadcrumbsSync(
remainingSegments, remainingSegments,
@@ -440,7 +422,7 @@ export function generateBreadcrumbsSync(
config, config,
context context
); );
breadcrumbs.push(...nestedBreadcrumbs); breadcrumbs.push(...nestedBreadcrumbs);
} }
@@ -475,7 +457,7 @@ function generateNestedBreadcrumbsSync(
// Check for static nested route // Check for static nested route
if (navItem.nestedRoutes) { if (navItem.nestedRoutes) {
const nestedRoute = navItem.nestedRoutes.find((route) => route.path === currentSegment); const nestedRoute = navItem.nestedRoutes.find((route) => route.path === currentSegment);
if (nestedRoute) { if (nestedRoute) {
breadcrumbs.push({ breadcrumbs.push({
label: nestedRoute.titleKey, label: nestedRoute.titleKey,
@@ -506,14 +488,9 @@ function generateNestedBreadcrumbsSync(
if (navItem.supportsDynamicSegment) { if (navItem.supportsDynamicSegment) {
// Try to resolve title synchronously (from context if available) // Try to resolve title synchronously (from context if available)
let dynamicTitle: string | null = null; let dynamicTitle: string | null = null;
if (config.dynamicTitleResolver) { if (config.dynamicTitleResolver) {
const result = config.dynamicTitleResolver( const result = config.dynamicTitleResolver(currentSegment, currentPath, fullPathname, context);
currentSegment,
currentPath,
fullPathname,
context
);
// Only use if it's not a promise // Only use if it's not a promise
if (typeof result === 'string') { if (typeof result === 'string') {
dynamicTitle = result; dynamicTitle = result;
@@ -531,7 +508,7 @@ function generateNestedBreadcrumbsSync(
const supportingNestedRoute = navItem.nestedRoutes?.find( const supportingNestedRoute = navItem.nestedRoutes?.find(
(route) => route.supportsDynamicSegment && route.nestedRoutes (route) => route.supportsDynamicSegment && route.nestedRoutes
); );
if (supportingNestedRoute?.nestedRoutes) { if (supportingNestedRoute?.nestedRoutes) {
const nestedBreadcrumbs = generateNestedBreadcrumbsSync( const nestedBreadcrumbs = generateNestedBreadcrumbsSync(
remainingSegments, remainingSegments,
@@ -560,4 +537,3 @@ export function clearBreadcrumbCache() {
breadcrumbCache.clear(); breadcrumbCache.clear();
cacheTimestamps.clear(); cacheTimestamps.clear();
} }
@@ -105,4 +105,3 @@ export function generateLmsBreadcrumbs(
return breadcrumbs; return breadcrumbs;
} }
@@ -15,7 +15,7 @@
// Get breadcrumb title from store for LMS community posts // Get breadcrumb title from store for LMS community posts
const breadcrumbTitle = $derived($currentCommunityQuestion.title); const breadcrumbTitle = $derived($currentCommunityQuestion.title);
const breadcrumbs = $derived( const breadcrumbs = $derived(
generateLmsBreadcrumbs( generateLmsBreadcrumbs(
page.url.pathname, page.url.pathname,
@@ -98,4 +98,3 @@
{/if} {/if}
</Breadcrumb.List> </Breadcrumb.List>
</Breadcrumb.Root> </Breadcrumb.Root>
@@ -60,4 +60,3 @@
{/if} {/if}
</div> </div>
</header> </header>
@@ -14,4 +14,3 @@
</script> </script>
<div class="senja-embed" data-id={id} data-lazyload="false" data-spinner="false"></div> <div class="senja-embed" data-id={id} data-lazyload="false" data-spinner="false"></div>
@@ -75,4 +75,3 @@
{/each} {/each}
</Sidebar.Menu> </Sidebar.Menu>
</Sidebar.Group> </Sidebar.Group>
@@ -108,78 +108,84 @@
<Dialog.Title>{$t('course.navItem.landing_page.upload_widget.title')}</Dialog.Title> <Dialog.Title>{$t('course.navItem.landing_page.upload_widget.title')}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<div class="w-full bg-white p-5 dark:bg-inherit"> <div class="w-full bg-white p-5 dark:bg-inherit">
<UnderlineTabs.Root bind:value={currentTab}> <UnderlineTabs.Root bind:value={currentTab}>
<UnderlineTabs.List> <UnderlineTabs.List>
{#each tabs as tab} {#each tabs as tab}
<UnderlineTabs.Trigger value={tab.value}> <UnderlineTabs.Trigger value={tab.value}>
{tab.label} {tab.label}
</UnderlineTabs.Trigger> </UnderlineTabs.Trigger>
{/each} {/each}
</UnderlineTabs.List> </UnderlineTabs.List>
<UnderlineTabs.Content value={tabs[1].value}> <UnderlineTabs.Content value={tabs[1].value}>
<!-- Your Upload content here --> <!-- Your Upload content here -->
<div class="w-full"> <div class="w-full">
<input <input
type="file" type="file"
style="display: none;" style="display: none;"
bind:this={fileInput} bind:this={fileInput}
onchange={onFileSelected} onchange={onFileSelected}
disabled={isUploading} disabled={isUploading}
/> />
<Button onclick={handleUpload} loading={isUploading}> <Button onclick={handleUpload} loading={isUploading}>
{label} {label}
</Button> </Button>
<p class="my-2 text-center text-sm text-gray-500"> <p class="my-2 text-center text-sm text-gray-500">
{$t('course.navItem.landing_page.upload_widget.width')} {$t('course.navItem.landing_page.upload_widget.width')}
</p>
<p class="text-center text-sm text-gray-500">
{$t('course.navItem.landing_page.upload_widget.size')}
</p>
</div>
</UnderlineTabs.Content>
<UnderlineTabs.Content value={tabs[0].value}>
<!-- Your Images content here -->
<div class="h-full overflow-y-scroll">
<form onsubmit={preventDefault(handleSubmit)} class="mt-1 flex gap-2 pb-3">
<input type="text" bind:value={searchQuery} name="" id="" class="ml-2 w-[85%] rounded-lg dark:text-black" />
<button type="submit" class="rounded-lg border border-gray-500 bg-white px-3 py-1 text-black"
>{$t('course.navItem.landing_page.upload_widget.submit')}</button
>
</form>
{#if unsplashImages && unsplashImages.length > 0}
<div class="hide-scrollbar flex max-h-[300px] flex-row flex-wrap items-center gap-2 px-[10px]">
{#each unsplashImages as unsplashImages (unsplashImages.id)}
<div>
<div class="relative h-[130px] w-[195px] overflow-hidden">
<button onclick={() => handleImageClick(unsplashImages.urls.regular)}>
<img
src={unsplashImages.urls.regular}
alt={unsplashImages.alt_description}
class="h-full w-full cursor-pointer rounded-md object-cover hover:opacity-80"
/>
</button>
</div>
{#if unsplashImages.user.name}
<p class="mt-1 text-center text-xs font-light text-gray-500">
By <a
href={`https://unsplash.com/@${unsplashImages.user.username}`}
target="_blank"
class="hover:text-red-700">{unsplashImages.user.name}</a
>
</p>
{/if}
</div>
{/each}
</div>
{:else}
<p class="pt-7 text-center">
{$t('course.navItem.landing_page.upload_widget.no_images')}
</p> </p>
{/if} <p class="text-center text-sm text-gray-500">
</div> {$t('course.navItem.landing_page.upload_widget.size')}
</UnderlineTabs.Content> </p>
</UnderlineTabs.Root> </div>
</div> </UnderlineTabs.Content>
<UnderlineTabs.Content value={tabs[0].value}>
<!-- Your Images content here -->
<div class="h-full overflow-y-scroll">
<form onsubmit={preventDefault(handleSubmit)} class="mt-1 flex gap-2 pb-3">
<input
type="text"
bind:value={searchQuery}
name=""
id=""
class="ml-2 w-[85%] rounded-lg dark:text-black"
/>
<button type="submit" class="rounded-lg border border-gray-500 bg-white px-3 py-1 text-black"
>{$t('course.navItem.landing_page.upload_widget.submit')}</button
>
</form>
{#if unsplashImages && unsplashImages.length > 0}
<div class="hide-scrollbar flex max-h-[300px] flex-row flex-wrap items-center gap-2 px-[10px]">
{#each unsplashImages as unsplashImages (unsplashImages.id)}
<div>
<div class="relative h-[130px] w-[195px] overflow-hidden">
<button onclick={() => handleImageClick(unsplashImages.urls.regular)}>
<img
src={unsplashImages.urls.regular}
alt={unsplashImages.alt_description}
class="h-full w-full cursor-pointer rounded-md object-cover hover:opacity-80"
/>
</button>
</div>
{#if unsplashImages.user.name}
<p class="mt-1 text-center text-xs font-light text-gray-500">
By <a
href={`https://unsplash.com/@${unsplashImages.user.username}`}
target="_blank"
class="hover:text-red-700">{unsplashImages.user.name}</a
>
</p>
{/if}
</div>
{/each}
</div>
{:else}
<p class="pt-7 text-center">
{$t('course.navItem.landing_page.upload_widget.no_images')}
</p>
{/if}
</div>
</UnderlineTabs.Content>
</UnderlineTabs.Root>
</div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -16,4 +16,3 @@
<ArrowUpIcon size={16} /> <ArrowUpIcon size={16} />
<Chip {value} /> <Chip {value} />
</Button> </Button>
@@ -2,13 +2,7 @@
* Server-side file type validation utilities for preventing malicious uploads * Server-side file type validation utilities for preventing malicious uploads
* Specifically designed to prevent SVG XSS attacks and other security issues * Specifically designed to prevent SVG XSS attacks and other security issues
*/ */
export const ALLOWED_IMAGE_TYPES = [ export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'] as const;
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp'
] as const;
export const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] as const; export const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] as const;
@@ -24,11 +24,7 @@ export async function checkUserCoursePermissions(
.single(); .single();
// Check if user is org admin // Check if user is org admin
const { data: orgData } = await supabase const { data: orgData } = await supabase.from('group').select('organization_id').eq('id', courseGroupId).single();
.from('group')
.select('organization_id')
.eq('id', courseGroupId)
.single();
let isOrgAdmin = false; let isOrgAdmin = false;
if (orgData?.organization_id) { if (orgData?.organization_id) {
@@ -1624,4 +1624,4 @@
} }
} }
} }
} }
@@ -1655,4 +1655,4 @@
} }
} }
} }
} }
@@ -1655,4 +1655,4 @@
} }
} }
} }
} }
@@ -1655,4 +1655,4 @@
} }
} }
} }
} }
@@ -1655,4 +1655,4 @@
} }
} }
} }
} }
@@ -1625,4 +1625,4 @@
} }
} }
} }
} }
@@ -1655,4 +1655,4 @@
} }
} }
} }
} }
@@ -1655,4 +1655,4 @@
} }
} }
} }
} }
@@ -1655,4 +1655,4 @@
} }
} }
} }
} }
@@ -3,6 +3,7 @@
## Overview ## Overview
We use **convention-based translation keys** for Zod validation errors. This approach: We use **convention-based translation keys** for Zod validation errors. This approach:
- ✅ No metadata needed - just follow the naming pattern - ✅ No metadata needed - just follow the naming pattern
- ✅ Clean shared schemas without hardcoded messages - ✅ Clean shared schemas without hardcoded messages
- ✅ Automatic fallback to generic translations - ✅ Automatic fallback to generic translations
@@ -18,14 +19,14 @@ validations.{fieldName}.{errorCode}
### Common Error Codes ### Common Error Codes
| Error Code | When It Occurs | Example Field | | Error Code | When It Occurs | Example Field |
|------------|----------------|---------------| | -------------------- | ------------------------------------------------------- | ----------------------- |
| `too_small` | String too short, number too small, array too few items | username.too_small | | `too_small` | String too short, number too small, array too few items | username.too_small |
| `too_big` | String too long, number too large, array too many items | bio.too_big | | `too_big` | String too long, number too large, array too many items | bio.too_big |
| `invalid_email` | Invalid email format | email.invalid_email | | `invalid_email` | Invalid email format | email.invalid_email |
| `invalid_url` | Invalid URL format | website.invalid_url | | `invalid_url` | Invalid URL format | website.invalid_url |
| `invalid_type` | Wrong type (e.g., undefined when string expected) | username.invalid_type | | `invalid_type` | Wrong type (e.g., undefined when string expected) | username.invalid_type |
| `invalid_enum_value` | Value not in allowed options | role.invalid_enum_value | | `invalid_enum_value` | Value not in allowed options | role.invalid_enum_value |
## Quick Start ## Quick Start
@@ -40,12 +41,12 @@ let errors: Record<string, string> = {};
function handleSubmit() { function handleSubmit() {
const result = ZEmailData.safeParse(formData); const result = ZEmailData.safeParse(formData);
if (!result.success) { if (!result.success) {
errors = mapZodErrorsToTranslations(result.error); errors = mapZodErrorsToTranslations(result.error);
return; return;
} }
// Use validated data // Use validated data
sendEmail(result.data); sendEmail(result.data);
} }
@@ -68,7 +69,7 @@ function handleSubmit() {
"too_small": "Content must be at least 1 character", "too_small": "Content must be at least 1 character",
"too_big": "Content is too long" "too_big": "Content is too long"
}, },
"generic": { "generic": {
"required": "This field is required", "required": "This field is required",
"invalid_email": "Invalid email address", "invalid_email": "Invalid email address",
@@ -101,24 +102,24 @@ export const ZUserProfile = z.object({
<script lang="ts"> <script lang="ts">
import { ZUserProfile } from '@cio/utils/validation/user'; import { ZUserProfile } from '@cio/utils/validation/user';
import { mapZodErrorsToTranslations } from '$lib/utils/validation'; import { mapZodErrorsToTranslations } from '$lib/utils/validation';
let formData = { let formData = {
email: '', email: '',
username: '', username: '',
bio: '', bio: '',
age: 0 age: 0
}; };
let errors: Record<string, string> = {}; let errors: Record<string, string> = {};
function handleSubmit() { function handleSubmit() {
const result = ZUserProfile.safeParse(formData); const result = ZUserProfile.safeParse(formData);
if (!result.success) { if (!result.success) {
errors = mapZodErrorsToTranslations(result.error); errors = mapZodErrorsToTranslations(result.error);
return; return;
} }
// Success! // Success!
saveProfile(result.data); saveProfile(result.data);
} }
@@ -194,7 +195,7 @@ Zod validates in order and returns the **first error** it encounters:
```typescript ```typescript
// Schema // Schema
z.string().min(3).max(20) z.string().min(3).max(20);
// Input: "" // Input: ""
// Error: too_small (stops at first rule) // Error: too_small (stops at first rule)
@@ -226,12 +227,12 @@ import { validateWithTranslation } from '$lib/utils/validation';
function handleSubmit() { function handleSubmit() {
const result = validateWithTranslation(ZUserProfile, formData); const result = validateWithTranslation(ZUserProfile, formData);
if ('errors' in result) { if ('errors' in result) {
errors = result.errors; errors = result.errors;
return; return;
} }
// Success - result.data is typed! // Success - result.data is typed!
saveProfile(result.data); saveProfile(result.data);
} }
@@ -240,6 +241,7 @@ function handleSubmit() {
## Adding New Fields ## Adding New Fields
1. **Add to schema** (in packages/utils): 1. **Add to schema** (in packages/utils):
```typescript ```typescript
export const ZUserProfile = z.object({ export const ZUserProfile = z.object({
// ... existing fields // ... existing fields
@@ -248,6 +250,7 @@ export const ZUserProfile = z.object({
``` ```
2. **Add translations**: 2. **Add translations**:
```json ```json
{ {
"validations": { "validations": {
@@ -12,7 +12,7 @@
let { message, title } = $derived.by(() => { let { message, title } = $derived.by(() => {
const fields = { const fields = {
title: '', title: '',
message: '', message: ''
}; };
if (!type) return fields; if (!type) return fields;
@@ -42,10 +42,7 @@
}); });
function handleSupport() { function handleSupport() {
window.open( window.open('mailto:[email protected]?subject=Email Verification Issue&body=Error Type: ' + type, '_blank');
'mailto:[email protected]?subject=Email Verification Issue&body=Error Type: ' + type,
'_blank'
);
} }
</script> </script>
@@ -55,7 +52,7 @@
</svelte:head> </svelte:head>
<Empty <Empty
title={title} {title}
description={message} description={message}
icon={OctagonAlertIcon} icon={OctagonAlertIcon}
variant="page" variant="page"
@@ -63,34 +60,27 @@
showLogo={true} showLogo={true}
class="h-fit! flex-none! max-h-none!" class="h-fit! flex-none! max-h-none!"
> >
{#if type === 'legacy_method_blocked'} {#if type === 'legacy_method_blocked'}
<div <div class="rounded-md border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20">
class="rounded-md border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20" <div class="flex items-center">
> <TriangleAlertIcon class="h-5 w-5 flex-shrink-0 text-yellow-400" />
<div class="flex items-center"> <div class="ml-3">
<TriangleAlertIcon class="h-5 w-5 flex-shrink-0 text-yellow-400" /> <h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
<div class="ml-3"> {$t('email_verification.security_notice.title')}
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200"> </h3>
{$t('email_verification.security_notice.title')} <p class="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
</h3> {$t('email_verification.security_notice.message')}
<p class="mt-1 text-xs text-yellow-700 dark:text-yellow-300"> </p>
{$t('email_verification.security_notice.message')} </div>
</p> </div>
</div> </div>
{/if}
<div class="lex w-full flex-col justify-center gap-4 md:flex-row">
<Button onclick={handleSupport} variant="ghost">
{$t('email_verification.actions.contact_support')}
</Button>
<Button onclick={() => goto('/')}>
{$t('email_verification.actions.back_to_dashboard')}
</Button>
</div> </div>
</div>
{/if}
<div class="lex w-full flex-col justify-center gap-4 md:flex-row">
<Button
onclick={handleSupport}
variant="ghost"
>
{$t('email_verification.actions.contact_support')}
</Button>
<Button
onclick={() => goto('/')}
>
{$t('email_verification.actions.back_to_dashboard')}
</Button>
</div>
</Empty> </Empty>
+1 -2
View File
@@ -77,8 +77,7 @@
} }
console.log('path', path); console.log('path', path);
console.log('isPublicRoute', isPublicRoute(path)); console.log('isPublicRoute', isPublicRoute(path));
console.log('data', $session.data) console.log('data', $session.data);
// No need to require login for public routes // No need to require login for public routes
if (isPublicRoute(path) && path !== '/') return; if (isPublicRoute(path) && path !== '/') return;
@@ -19,9 +19,7 @@ export async function POST({ request }) {
try { try {
// Clean up expired tokens // Clean up expired tokens
const { data: cleanupResult, error } = await supabase.rpc( const { data: cleanupResult, error } = await supabase.rpc('cleanup_expired_verification_tokens');
'cleanup_expired_verification_tokens'
);
if (error) { if (error) {
console.error('Token cleanup failed:', error); console.error('Token cleanup failed:', error);
@@ -43,14 +43,11 @@ export async function GET({ url, request }) {
const recent = tokenStats.filter((token) => new Date(token.created_at) > last24Hours); const recent = tokenStats.filter((token) => new Date(token.created_at) > last24Hours);
const used = tokenStats.filter((token) => token.used_at); const used = tokenStats.filter((token) => token.used_at);
const expired = tokenStats.filter( const expired = tokenStats.filter((token) => new Date(token.expires_at) < now && !token.used_at);
(token) => new Date(token.expires_at) < now && !token.used_at
);
// Count unique IPs for anomaly detection // Count unique IPs for anomaly detection
const uniqueCreationIPs = new Set(tokenStats.map((t) => t.created_by_ip)).size; const uniqueCreationIPs = new Set(tokenStats.map((t) => t.created_by_ip)).size;
const uniqueUsageIPs = new Set(tokenStats.filter((t) => t.used_by_ip).map((t) => t.used_by_ip)) const uniqueUsageIPs = new Set(tokenStats.filter((t) => t.used_by_ip).map((t) => t.used_by_ip)).size;
.size;
return json({ return json({
success: true, success: true,
@@ -61,8 +58,7 @@ export async function GET({ url, request }) {
expired_unused: expired.length, expired_unused: expired.length,
unique_creation_ips: uniqueCreationIPs, unique_creation_ips: uniqueCreationIPs,
unique_usage_ips: uniqueUsageIPs, unique_usage_ips: uniqueUsageIPs,
success_rate: success_rate: tokenStats.length > 0 ? ((used.length / tokenStats.length) * 100).toFixed(1) : 0
tokenStats.length > 0 ? ((used.length / tokenStats.length) * 100).toFixed(1) : 0
}, },
recent_activity: recent.slice(0, 10).map((token) => ({ recent_activity: recent.slice(0, 10).map((token) => ({
created_at: token.created_at, created_at: token.created_at,
@@ -108,9 +108,7 @@ async function getCourseAnalytics(supabase: any, courseId: string): Promise<Cour
// Get student analytics // Get student analytics
const studentAnalytics = await Promise.all( const studentAnalytics = await Promise.all(
members members.filter((member) => member.role_id === 3).map((member) => getStudentOverview(supabase, courseId, member))
.filter((member) => member.role_id === 3)
.map((member) => getStudentOverview(supabase, courseId, member))
); );
analytics.students = studentAnalytics.filter((student): student is StudentOverview => student !== null); analytics.students = studentAnalytics.filter((student): student is StudentOverview => student !== null);
@@ -155,11 +153,7 @@ async function getLastLogin(supabase: any, userId: string): Promise<string | und
} }
} }
async function getStudentOverview( async function getStudentOverview(supabase: any, courseId: string, member: any): Promise<StudentOverview | null> {
supabase: any,
courseId: string,
member: any
): Promise<StudentOverview | null> {
try { try {
// Use the same service function that the working analytics/user API uses // Use the same service function that the working analytics/user API uses
const { data: courseProgressData, error: progressError } = await fetchProfileCourseProgress( const { data: courseProgressData, error: progressError } = await fetchProfileCourseProgress(
@@ -40,12 +40,7 @@ async function getCourse(supabase: any, courseId: string) {
return course; return course;
} }
async function getGroupMembers( async function getGroupMembers(supabase: any, groupId: string, isStudent: boolean, userMembership: any) {
supabase: any,
groupId: string,
isStudent: boolean,
userMembership: any
) {
if (isStudent) { if (isStudent) {
return { return {
id: groupId, id: groupId,
@@ -53,11 +48,7 @@ async function getGroupMembers(
}; };
} }
const { data: group, error } = await supabase const { data: group, error } = await supabase.from('group').select(GROUP_MEMBERS_SELECT).eq('id', groupId).single();
.from('group')
.select(GROUP_MEMBERS_SELECT)
.eq('id', groupId)
.single();
if (error) { if (error) {
throw new Error('Error fetching group members'); throw new Error('Error fetching group members');
@@ -19,11 +19,7 @@ export const POST: RequestHandler = async ({ request }) => {
const supabase = getServerSupabase(); const supabase = getServerSupabase();
// Get course group_id for permission check // Get course group_id for permission check
const { data: course } = await supabase const { data: course } = await supabase.from('course').select('group_id').eq('id', courseId).single();
.from('course')
.select('group_id')
.eq('id', courseId)
.single();
if (!course) { if (!course) {
return json({ success: false, message: 'Course not found' }, { status: 404 }); return json({ success: false, message: 'Course not found' }, { status: 404 });
@@ -41,9 +37,7 @@ export const POST: RequestHandler = async ({ request }) => {
); );
} }
const { data: exercises, error } = await supabase const { data: exercises, error } = await supabase.rpc('get_exercises').eq('course_id', courseId);
.rpc('get_exercises')
.eq('course_id', courseId);
if (error) { if (error) {
throw new Error('Error fetching exercises'); throw new Error('Error fetching exercises');
@@ -19,11 +19,7 @@ export const POST: RequestHandler = async ({ request }) => {
const supabase = getServerSupabase(); const supabase = getServerSupabase();
// Get course group_id for permission check // Get course group_id for permission check
const { data: course } = await supabase const { data: course } = await supabase.from('course').select('group_id').eq('id', courseId).single();
.from('course')
.select('group_id')
.eq('id', courseId)
.single();
if (!course) { if (!course) {
return json({ success: false, message: 'Course not found' }, { status: 404 }); return json({ success: false, message: 'Course not found' }, { status: 404 });

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