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

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

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

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

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

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

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

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

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

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -122,10 +122,7 @@ function getCachedOrGenerate(
/**
* Extract path prefix from pathname
*/
function extractPathPrefix(
pathname: string,
prefixConfig: string | ((pathname: string) => string)
): string {
function extractPathPrefix(pathname: string, prefixConfig: string | ((pathname: string) => string)): string {
if (typeof prefixConfig === 'function') {
return prefixConfig(pathname);
}
@@ -143,10 +140,7 @@ function extractPathPrefix(
/**
* Get base path for href generation
*/
function getBasePath(
pathname: string,
basePathConfig: string | ((pathname: string) => string)
): string {
function getBasePath(pathname: string, basePathConfig: string | ((pathname: string) => string)): string {
if (typeof basePathConfig === 'function') {
return basePathConfig(pathname);
}
@@ -228,10 +222,7 @@ async function resolveDynamicTitle(
/**
* Extract parameters from pathname (e.g., { slug: 'my-post' })
*/
function extractParamsFromPath(
pathname: string,
parentPath: string
): Record<string, string> {
function extractParamsFromPath(pathname: string, parentPath: string): Record<string, string> {
const params: Record<string, string> = {};
const relativePath = pathname.replace(parentPath, '').replace(/^\//, '');
const segments = relativePath.split('/').filter(Boolean);
@@ -300,16 +291,11 @@ async function generateNestedBreadcrumbs(
}
// Handle dynamic segment
if (navItem.supportsDynamicSegment ||
navItem.nestedRoutes?.some((route) => route.supportsDynamicSegment && route.path !== currentSegment)) {
const dynamicTitle = await resolveDynamicTitle(
currentSegment,
currentPath,
fullPathname,
navItem,
config,
context
);
if (
navItem.supportsDynamicSegment ||
navItem.nestedRoutes?.some((route) => route.supportsDynamicSegment && route.path !== currentSegment)
) {
const dynamicTitle = await resolveDynamicTitle(currentSegment, currentPath, fullPathname, navItem, config, context);
if (dynamicTitle) {
breadcrumbs.push({
@@ -400,9 +386,7 @@ export function generateBreadcrumbsSync(
}
// Add parent breadcrumb
const parentHref = matchedNavItem.useHashUrl
? `${basePath}${matchedNavItem.path}`
: matchedNavItem.url;
const parentHref = matchedNavItem.useHashUrl ? `${basePath}${matchedNavItem.path}` : matchedNavItem.url;
breadcrumbs.push({
label: matchedNavItem.breadcrumbLabel || matchedNavItem.title,
@@ -411,9 +395,7 @@ export function generateBreadcrumbsSync(
// Handle query params (settings tabs, etc.)
if (matchedNavItem.items && searchParams) {
const subItem = matchedNavItem.items.find((sub) =>
isActive(pathWithQuery, sub.url, undefined, true)
);
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, undefined, true));
if (subItem) {
breadcrumbs.push({
@@ -508,12 +490,7 @@ function generateNestedBreadcrumbsSync(
let dynamicTitle: string | null = null;
if (config.dynamicTitleResolver) {
const result = config.dynamicTitleResolver(
currentSegment,
currentPath,
fullPathname,
context
);
const result = config.dynamicTitleResolver(currentSegment, currentPath, fullPathname, context);
// Only use if it's not a promise
if (typeof result === 'string') {
dynamicTitle = result;
@@ -560,4 +537,3 @@ export function clearBreadcrumbCache() {
breadcrumbCache.clear();
cacheTimestamps.clear();
}
@@ -105,4 +105,3 @@ export function generateLmsBreadcrumbs(
return breadcrumbs;
}
@@ -98,4 +98,3 @@
{/if}
</Breadcrumb.List>
</Breadcrumb.Root>
@@ -60,4 +60,3 @@
{/if}
</div>
</header>
@@ -14,4 +14,3 @@
</script>
<div class="senja-embed" data-id={id} data-lazyload="false" data-spinner="false"></div>
@@ -75,4 +75,3 @@
{/each}
</Sidebar.Menu>
</Sidebar.Group>
@@ -108,78 +108,84 @@
<Dialog.Title>{$t('course.navItem.landing_page.upload_widget.title')}</Dialog.Title>
</Dialog.Header>
<div class="w-full bg-white p-5 dark:bg-inherit">
<UnderlineTabs.Root bind:value={currentTab}>
<UnderlineTabs.List>
{#each tabs as tab}
<UnderlineTabs.Trigger value={tab.value}>
{tab.label}
</UnderlineTabs.Trigger>
{/each}
</UnderlineTabs.List>
<UnderlineTabs.Content value={tabs[1].value}>
<!-- Your Upload content here -->
<div class="w-full">
<input
type="file"
style="display: none;"
bind:this={fileInput}
onchange={onFileSelected}
disabled={isUploading}
/>
<Button onclick={handleUpload} loading={isUploading}>
{label}
</Button>
<p class="my-2 text-center text-sm text-gray-500">
{$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')}
<UnderlineTabs.Root bind:value={currentTab}>
<UnderlineTabs.List>
{#each tabs as tab}
<UnderlineTabs.Trigger value={tab.value}>
{tab.label}
</UnderlineTabs.Trigger>
{/each}
</UnderlineTabs.List>
<UnderlineTabs.Content value={tabs[1].value}>
<!-- Your Upload content here -->
<div class="w-full">
<input
type="file"
style="display: none;"
bind:this={fileInput}
onchange={onFileSelected}
disabled={isUploading}
/>
<Button onclick={handleUpload} loading={isUploading}>
{label}
</Button>
<p class="my-2 text-center text-sm text-gray-500">
{$t('course.navItem.landing_page.upload_widget.width')}
</p>
{/if}
</div>
</UnderlineTabs.Content>
</UnderlineTabs.Root>
</div>
<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>
{/if}
</div>
</UnderlineTabs.Content>
</UnderlineTabs.Root>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -16,4 +16,3 @@
<ArrowUpIcon size={16} />
<Chip {value} />
</Button>
@@ -2,13 +2,7 @@
* Server-side file type validation utilities for preventing malicious uploads
* Specifically designed to prevent SVG XSS attacks and other security issues
*/
export const ALLOWED_IMAGE_TYPES = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp'
] as const;
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'] as const;
export const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] as const;
@@ -24,11 +24,7 @@ export async function checkUserCoursePermissions(
.single();
// Check if user is org admin
const { data: orgData } = await supabase
.from('group')
.select('organization_id')
.eq('id', courseGroupId)
.single();
const { data: orgData } = await supabase.from('group').select('organization_id').eq('id', courseGroupId).single();
let isOrgAdmin = false;
if (orgData?.organization_id) {
@@ -3,6 +3,7 @@
## Overview
We use **convention-based translation keys** for Zod validation errors. This approach:
- ✅ No metadata needed - just follow the naming pattern
- ✅ Clean shared schemas without hardcoded messages
- ✅ Automatic fallback to generic translations
@@ -18,14 +19,14 @@ validations.{fieldName}.{errorCode}
### Common Error Codes
| Error Code | When It Occurs | Example Field |
|------------|----------------|---------------|
| `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 |
| `invalid_email` | Invalid email format | email.invalid_email |
| `invalid_url` | Invalid URL format | website.invalid_url |
| `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 |
| Error Code | When It Occurs | Example Field |
| -------------------- | ------------------------------------------------------- | ----------------------- |
| `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 |
| `invalid_email` | Invalid email format | email.invalid_email |
| `invalid_url` | Invalid URL format | website.invalid_url |
| `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 |
## Quick Start
@@ -194,7 +195,7 @@ Zod validates in order and returns the **first error** it encounters:
```typescript
// Schema
z.string().min(3).max(20)
z.string().min(3).max(20);
// Input: ""
// Error: too_small (stops at first rule)
@@ -240,6 +241,7 @@ function handleSubmit() {
## Adding New Fields
1. **Add to schema** (in packages/utils):
```typescript
export const ZUserProfile = z.object({
// ... existing fields
@@ -248,6 +250,7 @@ export const ZUserProfile = z.object({
```
2. **Add translations**:
```json
{
"validations": {
@@ -12,7 +12,7 @@
let { message, title } = $derived.by(() => {
const fields = {
title: '',
message: '',
message: ''
};
if (!type) return fields;
@@ -42,10 +42,7 @@
});
function handleSupport() {
window.open(
'mailto:help@classroomio.com?subject=Email Verification Issue&body=Error Type: ' + type,
'_blank'
);
window.open('mailto:help@classroomio.com?subject=Email Verification Issue&body=Error Type: ' + type, '_blank');
}
</script>
@@ -55,7 +52,7 @@
</svelte:head>
<Empty
title={title}
{title}
description={message}
icon={OctagonAlertIcon}
variant="page"
@@ -63,34 +60,27 @@
showLogo={true}
class="h-fit! flex-none! max-h-none!"
>
{#if type === 'legacy_method_blocked'}
<div
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="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{$t('email_verification.security_notice.title')}
</h3>
<p class="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
{$t('email_verification.security_notice.message')}
</p>
{#if type === 'legacy_method_blocked'}
<div 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="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{$t('email_verification.security_notice.title')}
</h3>
<p class="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
{$t('email_verification.security_notice.message')}
</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>
{/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>
+1 -2
View File
@@ -77,8 +77,7 @@
}
console.log('path', path);
console.log('isPublicRoute', isPublicRoute(path));
console.log('data', $session.data)
console.log('data', $session.data);
// No need to require login for public routes
if (isPublicRoute(path) && path !== '/') return;
@@ -19,9 +19,7 @@ export async function POST({ request }) {
try {
// Clean up expired tokens
const { data: cleanupResult, error } = await supabase.rpc(
'cleanup_expired_verification_tokens'
);
const { data: cleanupResult, error } = await supabase.rpc('cleanup_expired_verification_tokens');
if (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 used = tokenStats.filter((token) => token.used_at);
const expired = tokenStats.filter(
(token) => new Date(token.expires_at) < now && !token.used_at
);
const expired = tokenStats.filter((token) => new Date(token.expires_at) < now && !token.used_at);
// Count unique IPs for anomaly detection
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))
.size;
const uniqueUsageIPs = new Set(tokenStats.filter((t) => t.used_by_ip).map((t) => t.used_by_ip)).size;
return json({
success: true,
@@ -61,8 +58,7 @@ export async function GET({ url, request }) {
expired_unused: expired.length,
unique_creation_ips: uniqueCreationIPs,
unique_usage_ips: uniqueUsageIPs,
success_rate:
tokenStats.length > 0 ? ((used.length / tokenStats.length) * 100).toFixed(1) : 0
success_rate: tokenStats.length > 0 ? ((used.length / tokenStats.length) * 100).toFixed(1) : 0
},
recent_activity: recent.slice(0, 10).map((token) => ({
created_at: token.created_at,
@@ -108,9 +108,7 @@ async function getCourseAnalytics(supabase: any, courseId: string): Promise<Cour
// Get student analytics
const studentAnalytics = await Promise.all(
members
.filter((member) => member.role_id === 3)
.map((member) => getStudentOverview(supabase, courseId, member))
members.filter((member) => member.role_id === 3).map((member) => getStudentOverview(supabase, courseId, member))
);
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(
supabase: any,
courseId: string,
member: any
): Promise<StudentOverview | null> {
async function getStudentOverview(supabase: any, courseId: string, member: any): Promise<StudentOverview | null> {
try {
// Use the same service function that the working analytics/user API uses
const { data: courseProgressData, error: progressError } = await fetchProfileCourseProgress(
@@ -40,12 +40,7 @@ async function getCourse(supabase: any, courseId: string) {
return course;
}
async function getGroupMembers(
supabase: any,
groupId: string,
isStudent: boolean,
userMembership: any
) {
async function getGroupMembers(supabase: any, groupId: string, isStudent: boolean, userMembership: any) {
if (isStudent) {
return {
id: groupId,
@@ -53,11 +48,7 @@ async function getGroupMembers(
};
}
const { data: group, error } = await supabase
.from('group')
.select(GROUP_MEMBERS_SELECT)
.eq('id', groupId)
.single();
const { data: group, error } = await supabase.from('group').select(GROUP_MEMBERS_SELECT).eq('id', groupId).single();
if (error) {
throw new Error('Error fetching group members');
@@ -19,11 +19,7 @@ export const POST: RequestHandler = async ({ request }) => {
const supabase = getServerSupabase();
// Get course group_id for permission check
const { data: course } = await supabase
.from('course')
.select('group_id')
.eq('id', courseId)
.single();
const { data: course } = await supabase.from('course').select('group_id').eq('id', courseId).single();
if (!course) {
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
.rpc('get_exercises')
.eq('course_id', courseId);
const { data: exercises, error } = await supabase.rpc('get_exercises').eq('course_id', courseId);
if (error) {
throw new Error('Error fetching exercises');
@@ -19,11 +19,7 @@ export const POST: RequestHandler = async ({ request }) => {
const supabase = getServerSupabase();
// Get course group_id for permission check
const { data: course } = await supabase
.from('course')
.select('group_id')
.eq('id', courseId)
.single();
const { data: course } = await supabase.from('course').select('group_id').eq('id', courseId).single();
if (!course) {
return json({ success: false, message: 'Course not found' }, { status: 404 });
@@ -37,8 +37,8 @@ async function onPayload(payload: PolarWebhookPayload) {
if (isSubscriptionActive) {
try {
const planData = {
orgId,
triggeredBy: parseInt(triggeredBy),
orgId,
triggeredBy: parseInt(triggeredBy),
planName: PLAN.EARLY_ADOPTER as 'EARLY_ADOPTER' | 'ENTERPRISE' | 'BASIC',
subscriptionId,
payload: payload.data as unknown as Record<string, unknown>
@@ -56,9 +56,9 @@ async function onPayload(payload: PolarWebhookPayload) {
// Handle Cancel Subscription
try {
const result = await OrgPlanApiServer.cancelOrgPlan({
subscriptionId,
subscriptionId,
payload: payload.data as unknown as Record<string, unknown>
});
});
console.log('Subscription canceled', result);
} catch (error) {
console.error('Error canceling org plan', error);
@@ -67,9 +67,9 @@ async function onPayload(payload: PolarWebhookPayload) {
// Handle Update Subscription
try {
const result = await OrgPlanApiServer.updateOrgPlan({
subscriptionId,
subscriptionId,
payload: payload.data as unknown as Record<string, unknown>
});
});
console.log('Subscription updated', result);
} catch (error) {
console.error('Error updating org plan', error);
@@ -15,12 +15,12 @@
<Sidebar.Provider>
<CourseSidebar {path} />
<Sidebar.Inset class="w-[calc(100vw-var(--sidebar-width))] overflow-x-hidden group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]">
<Sidebar.Inset
class="w-[calc(100vw-var(--sidebar-width))] overflow-x-hidden group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
>
<CourseHeader />
<Page.Root
class="mx-auto flex px-4 lg:max-w-6xl"
>
<Page.Root class="mx-auto flex px-4 lg:max-w-6xl">
{@render children?.()}
</Page.Root>
</Sidebar.Inset>
@@ -130,12 +130,6 @@
</div>
<div class="my-4 flex w-full items-center justify-center">
<Button
type="submit"
disabled={disableSubmit || loading}
loading={loading || !$profile.id}
>
Join Course
</Button>
<Button type="submit" disabled={disableSubmit || loading} loading={loading || !$profile.id}>Join Course</Button>
</div>
</AuthUI>
@@ -10,4 +10,3 @@
<Page.Root class="w-full pb-10 md:max-w-3xl lg:mx-auto">
{@render children?.()}
</Page.Root>
@@ -18,4 +18,3 @@
<IntegrationsPage />
{/snippet}
</Page.Body>
-14
View File
@@ -1,14 +0,0 @@
{
"useTabs": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"overrides": [
{
"files": ["*.svelte"],
"options": {
"svelteBracketNewLine": true
}
}
]
}
+4 -8
View File
@@ -121,11 +121,8 @@ src/lib/
**Import Pattern**:
```svelte
// ✅ CORRECT - Use named imports from the index
import { Footer, Navigation, Hero } from '$lib/components';
// ❌ INCORRECT - Direct file imports are not allowed
// import Footer from '$lib/components/footer.svelte';
// ✅ CORRECT - Use named imports from the index import {(Footer, Navigation, Hero)} from '$lib/components'; // ❌ INCORRECT
- Direct file imports are not allowed // import Footer from '$lib/components/footer.svelte';
```
**Note**: All components must be imported using named imports from `$lib/components`. Direct file imports are not allowed.
@@ -150,8 +147,7 @@ import { Footer, Navigation, Hero } from '$lib/components';
**Import Pattern**:
```svelte
import FeedbackForm from '$lib/features/contact/feedback-form.svelte';
import { htmlBody, openModal } from '$lib/features/tools/progress/store';
import FeedbackForm from '$lib/features/contact/feedback-form.svelte'; import {(htmlBody, openModal)} from '$lib/features/tools/progress/store';
```
### `/utils` - Utilities
@@ -338,7 +334,7 @@ export { default as Navigation } from './navigation.svelte';
This allows for clean, consistent imports:
```svelte
import { Footer, Navigation, Hero } from '$lib/components';
import {(Footer, Navigation, Hero)} from '$lib/components';
```
**Rule**: Direct file imports (e.g., `import Footer from '$lib/components/footer.svelte'`) are **not allowed**. Always use named imports from `$lib/components`.
+1
View File
@@ -8,6 +8,7 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"format": "prettier --write .",
"prepare": "svelte-kit sync"
},
"devDependencies": {
@@ -13,7 +13,7 @@
</script>
{#if !isRecommended}
<div class="mb-5 flex md:flex-row flex-col md:items-center gap-2">
<div class="mb-5 flex flex-col gap-2 md:flex-row md:items-center">
<!-- Date -->
<p class="text-sm text-slate-500">{formatDate(post.date)}</p>
@@ -29,24 +29,16 @@
{/if}
<a href="/blog/{post.slug}" class="group">
<img
loading="lazy"
src={post.imageUrl}
alt={post.title}
class="w-70 h-48 object-cover rounded-md"
/>
<p
class="font-bold text-lg py-2 {isRecommended &&
'h-[60px]'} line-clamp-2 group-hover:text-slate-500"
>
<img loading="lazy" src={post.imageUrl} alt={post.title} class="w-70 h-48 rounded-md object-cover" />
<p class="py-2 text-lg font-bold {isRecommended && 'h-[60px]'} line-clamp-2 group-hover:text-slate-500">
{@html post.title}
</p>
<p class="text-slate-500 pt-2 mb-4 line-clamp-3">{post.description}</p>
<p class="mb-4 line-clamp-3 pt-2 text-slate-500">{post.description}</p>
</a>
<div class="flex items-center justify-start gap-4 my-2">
<img loading="lazy" src={post.avatar} alt="avatar" class="w-10 h-10 rounded-full" />
<div class="my-2 flex items-center justify-start gap-4">
<img loading="lazy" src={post.avatar} alt="avatar" class="h-10 w-10 rounded-full" />
<span>
<p class="font-semibold">{post.author}</p>
<p class="text-slate-500">{post.role}</p>
@@ -26,14 +26,14 @@
} = $props();
</script>
<section {id} class="w-full py-32 border-b border-x-0 border-t-0 border-gray-200">
<section {id} class="w-full border-x-0 border-b border-t-0 border-gray-200 py-32">
<div
class="w-4/5 mx-auto flex flex-col {rightToLeft
class="mx-auto flex w-4/5 flex-col {rightToLeft
? 'lg:flex-row-reverse'
: 'lg:flex-row'} items-center justify-between gap-8 lg:gap-28"
>
<div class="w-[80vw] flex flex-col">
<div class="my-2 lg:my-4 ml-1 flex gap-2 items-center">
<div class="flex w-[80vw] flex-col">
<div class="my-2 ml-1 flex items-center gap-2 lg:my-4">
{#if taglineIcon === 'simplified'}
<img width="27" height="27" loading="lazy" src={thumbsUp} alt="" class="w-7" />
{:else if taglineIcon === 'flexible'}
@@ -46,16 +46,16 @@
{:else if taglineIcon === 'productivity'}
<img width="27" height="27" loading="lazy" src={robotArm} alt="" class="w-7" />
{/if}
<p class="font-medium text-base">{tagline}</p>
<p class="text-base font-medium">{tagline}</p>
</div>
<h2 class="text-3xl mb-3 lg:mb-6">{title}</h2>
<p class="leading-8 text-lg text-gray-500">
<h2 class="mb-3 text-3xl lg:mb-6">{title}</h2>
<p class="text-lg leading-8 text-gray-500">
{description}
</p>
{#if more}
<br />
<p class="leading-8 text-lg text-gray-500">
<p class="text-lg leading-8 text-gray-500">
{@render more?.()}
</p>
{/if}
@@ -64,7 +64,7 @@
<video
width="100%"
height="100%"
class="w-full h-auto lg:max-h-[80%] rounded-md shadow-xl"
class="h-auto w-full rounded-md shadow-xl lg:max-h-[80%]"
autoplay
loop
muted
@@ -3,8 +3,8 @@
import { PUBLIC_ENABLE_STATS } from '$env/static/public';
</script>
<section class="w-full py-32 border-b border-x-0 border-t-0 border-gray-200">
<div class="justify-center flex text-center flex-wrap gap-x-12 gap-y-16">
<section class="w-full border-x-0 border-b border-t-0 border-gray-200 py-32">
<div class="flex flex-wrap justify-center gap-x-12 gap-y-16 text-center">
<div class="w-full min-w-0" style="max-width: 1000px; flex: 1 1 550px;">
<div class="mx-auto flex max-w-lg flex-col items-center gap-2">
<div class="flex-none">
@@ -19,19 +19,16 @@
/>
</div>
</div>
<div class="text-base leading-snug text-center">
<span class="font-bold opacity-50 text-4xl">“</span>
<div class="text-center text-base leading-snug">
<span class="text-4xl font-bold opacity-50">“</span>
<span class="text-gray-800" style="font-size: 17px;"
>ClassroomIO really helped me bootstrap my program. The dashboard is extremely
intuitive. I could navigate most of it without any help / support. Coming from framer, I
can honestly say that I am extremely greatful that I found ClassroomIO.</span
> <span class="font-bold opacity-50 inline-block text-4xl">”</span>
>ClassroomIO really helped me bootstrap my program. The dashboard is extremely intuitive. I could navigate
most of it without any help / support. Coming from framer, I can honestly say that I am extremely greatful
that I found ClassroomIO.</span
> <span class="inline-block text-4xl font-bold opacity-50">”</span>
</div>
<div class="mx-auto flex items-center gap-2 justify-center">
<div
class="flex max-w-[200px] flex-none flex-col overflow-hidden text-left"
style="color: var(--clr-text);"
>
<div class="mx-auto flex items-center justify-center gap-2">
<div class="flex max-w-[200px] flex-none flex-col overflow-hidden text-left" style="color: var(--clr-text);">
<div class="text-center text-sm font-medium opacity-90">
Pranav Singhal <div class="block text-xs font-medium opacity-70 hover:opacity-50">
Founder at <a href="https://www.dblocked.io/" target="_blank">DBlockEd</a>
@@ -80,26 +77,18 @@
<!-- body section 5 -->
{#if PUBLIC_ENABLE_STATS}
<div
class="flex justify-between flex-row flex-wrap lg:flex-nowrap items-center text-center px-[10%] py-[7%] border-b-[1px] gap-y-20 gap-x-20 mx-0 lg:mx-[12%]"
class="mx-0 flex flex-row flex-wrap items-center justify-between gap-x-20 gap-y-20 border-b-[1px] px-[10%] py-[7%] text-center lg:mx-[12%] lg:flex-nowrap"
>
<div class="w-full">
<h2
class="w-full order-first text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl"
>
1 thousand
</h2>
<h2 class="order-first w-full text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl">1 thousand</h2>
<p class="text-base leading-7 text-gray-600">Feedback collected</p>
</div>
<div class="w-full">
<h2 class="order-first text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl">
5 thousand
</h2>
<h2 class="order-first text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl">5 thousand</h2>
<p class="text-base leading-7 text-gray-600">Documents processed</p>
</div>
<div class="w-full">
<h2 class="order-first text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl">
1 million
</h2>
<h2 class="order-first text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl">1 million</h2>
<p class="text-base leading-7 text-gray-600">Page views recorded</p>
</div>
</div>
@@ -8,10 +8,5 @@
</script>
<figure>
<img
{src}
{alt}
loading="lazy"
class="max-w-full w-full h-full md:min-h-[400px] object-cover my-2 rounded-lg"
/>
<img {src} {alt} loading="lazy" class="my-2 h-full w-full max-w-full rounded-lg object-cover md:min-h-[400px]" />
</figure>

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