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 supabase
static static
dist dist
docker
**/course-app
apps/docs
apps/api/static
pnpm-lock.yaml
*.md
+1
View File
@@ -0,0 +1 @@
static
+16 -14
View File
@@ -56,9 +56,9 @@ GET /lessons (which course?)
```typescript ```typescript
// ✅ OK - Top-level resources // ✅ OK - Top-level resources
GET /account GET / account;
PUT /account/user PUT / account / user;
GET /account/subscription GET / account / subscription;
``` ```
--- ---
@@ -68,7 +68,7 @@ GET /account/subscription
Use HTTP methods correctly for their intended purpose: Use HTTP methods correctly for their intended purpose:
| Method | Purpose | Idempotent | Response Body | | Method | Purpose | Idempotent | Response Body |
|--------|---------|------------|---------------| | -------- | ----------------------- | ---------- | ------------------- |
| `GET` | Retrieve resource(s) | ✅ Yes | Always | | `GET` | Retrieve resource(s) | ✅ Yes | Always |
| `POST` | Create new resource | ❌ No | Created resource | | `POST` | Create new resource | ❌ No | Created resource |
| `PUT` | Replace entire resource | ✅ Yes | Updated resource | | `PUT` | Replace entire resource | ✅ Yes | Updated resource |
@@ -112,11 +112,13 @@ POST /lessons?courseId=123 // Relationship not obvious
``` ```
**When to nest:** **When to nest:**
- ✅ Child resource only exists within parent context - ✅ Child resource only exists within parent context
- ✅ Child resource ID is unique within parent (e.g., `lessonId` within `courseId`) - ✅ Child resource ID is unique within parent (e.g., `lessonId` within `courseId`)
- ✅ Operations typically scoped to parent - ✅ Operations typically scoped to parent
**When NOT to nest:** **When NOT to nest:**
- ❌ Resource can exist independently - ❌ Resource can exist independently
- ❌ Resource ID is globally unique - ❌ Resource ID is globally unique
- ❌ Operations span multiple parents - ❌ Operations span multiple parents
@@ -154,6 +156,7 @@ GET /get-courses // Redundant verb
**Pattern:** `POST /resource/:id/action` **Pattern:** `POST /resource/:id/action`
**When to use:** **When to use:**
- ✅ State transitions (publish, unpublish, activate, cancel) - ✅ State transitions (publish, unpublish, activate, cancel)
- ✅ Side effects (clone, duplicate, export) - ✅ Side effects (clone, duplicate, export)
- ✅ Complex operations that don't fit CRUD - ✅ Complex operations that don't fit CRUD
@@ -182,7 +185,7 @@ GET /courses/search/javascript
**Common query parameter patterns:** **Common query parameter patterns:**
| Purpose | Parameter | Example | | Purpose | Parameter | Example |
|---------|-----------|---------| | ------------- | ------------------------- | ---------------------------------- |
| Filtering | `status`, `type`, `role` | `?status=active&type=course` | | Filtering | `status`, `type`, `role` | `?status=active&type=course` |
| Search | `q`, `search` | `?q=javascript` | | Search | `q`, `search` | `?q=javascript` |
| Pagination | `page`, `limit`, `offset` | `?page=1&limit=20` | | Pagination | `page`, `limit`, `offset` | `?page=1&limit=20` |
@@ -457,16 +460,16 @@ DELETE /courses/:id
```typescript ```typescript
// ❌ BAD // ❌ BAD
GET /course GET / course;
POST /courses POST / courses;
GET /organization GET / organization;
POST /organizations POST / organizations;
// ✅ GOOD // ✅ GOOD
GET /courses GET / courses;
POST /courses POST / courses;
GET /organizations GET / organizations;
POST /organizations POST / organizations;
``` ```
### ❌ Don't: Use query parameters for required identifiers ### ❌ Don't: Use query parameters for required identifiers
@@ -540,4 +543,3 @@ When creating a new route, ensure:
8. **Organized by domain** in code structure 8. **Organized by domain** in code structure
Following these conventions ensures your API is **predictable**, **maintainable**, and **developer-friendly**. Following these conventions ensures your API is **predictable**, **maintainable**, and **developer-friendly**.
+1 -1
View File
@@ -17,7 +17,7 @@
"test": "vitest NODE_ENV=test", "test": "vitest NODE_ENV=test",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"lint": "eslint . --ext .ts", "lint": "eslint . --ext .ts",
"format": "prettier --write \"src/**/*.ts\"", "format": "prettier --write .",
"upload:openapi": "tsx scripts/upload-openapi-spec.ts" "upload:openapi": "tsx scripts/upload-openapi-spec.ts"
}, },
"repository": { "repository": {
+2
View File
@@ -11,6 +11,7 @@ pnpm run upload:openapi
``` ```
### Direct script usage ### Direct script usage
```bash ```bash
tsx scripts/upload-openapi-spec.ts tsx scripts/upload-openapi-spec.ts
``` ```
@@ -49,6 +50,7 @@ This script is integrated with a GitHub Action that automatically generates and
### Automatic Workflow ### Automatic Workflow
The GitHub Action runs on: The GitHub Action runs on:
- **Push to main** - Automatically uploads to R2 - **Push to main** - Automatically uploads to R2
- **Pull Requests** - Generates spec for review (no upload) - **Pull Requests** - Generates spec for review (no upload)
- **Manual dispatch** - Allows manual triggering with custom parameters - **Manual dispatch** - Allows manual triggering with custom parameters
+1 -5
View File
@@ -24,11 +24,7 @@ class OpenAPISpecGenerator {
} }
private initializeS3Client() { private initializeS3Client() {
if ( if (env.CLOUDFLARE_ACCESS_KEY && env.CLOUDFLARE_SECRET_ACCESS_KEY && env.CLOUDFLARE_ACCOUNT_ID) {
env.CLOUDFLARE_ACCESS_KEY &&
env.CLOUDFLARE_SECRET_ACCESS_KEY &&
env.CLOUDFLARE_ACCOUNT_ID
) {
this.s3Client = new S3Client({ this.s3Client = new S3Client({
region: 'auto', region: 'auto',
endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`, endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
-1
View File
@@ -36,7 +36,6 @@ export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareH
} }
try { try {
// Generate rate limit key // Generate rate limit key
const key = opts.keyGenerator(c); const key = opts.keyGenerator(c);
+2 -8
View File
@@ -13,19 +13,13 @@ export class RedisRateLimiter {
private windowMs: number; private windowMs: number;
private maxRequests: number; private maxRequests: number;
constructor( constructor(redis: Redis, windowMs: number = DEFAULT_WINDOW_MS, maxRequests: number = DEFAULT_MAX_REQUESTS) {
redis: Redis,
windowMs: number = DEFAULT_WINDOW_MS,
maxRequests: number = DEFAULT_MAX_REQUESTS
) {
this.redis = redis; this.redis = redis;
this.windowMs = windowMs; this.windowMs = windowMs;
this.maxRequests = maxRequests; this.maxRequests = maxRequests;
} }
async isAllowed( async isAllowed(key: string): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
key: string
): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
const now = Date.now(); const now = Date.now();
const windowStart = now - this.windowMs; const windowStart = now - this.windowMs;
const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`; const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`;
+11 -8
View File
@@ -154,20 +154,23 @@
<section> <section>
<h1 class="text-xl font-bold my-1">Extra Resources</h1> <h1 class="text-xl font-bold my-1">Extra Resources</h1>
<div class="flex mt-2 mb-1"> <div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=https://youtu.be/dyLaKhePzj8 style="color: blue" <p class="m-0 mr-2">Slide Link:</p> <a href="https://youtu.be/dyLaKhePzj8" style="color: blue"
class="underline">Open Here</a> class="underline">Open Here</a>
</div> </div>
<p class="m-0">Video Link:</p> <p class="m-0">Video Link:</p>
<div class="m-0"> <div class="m-0">
<ol class="m-0"> <ol class="m-0">
<li>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue" <a href="https://youtu.be/0fYi8SGA20k" style="color: blue" class="underline">
class="underline">https://youtu.be/0fYi8SGA20k</a></li> https://youtu.be/0fYi8SGA20k
</a>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue" </li>
class="underline">https://youtu.be/0fYi8SGA20k</a></li> <li>
<a href="https://youtu.be/0fYi8SGA20k" style="color: blue" class="underline">
https://youtu.be/0fYi8SGA20k
</a>
</li>
</ol> </ol>
</div> </div>
+14 -9
View File
@@ -158,20 +158,25 @@
<section> <section>
<h1 class="text-xl font-bold my-1">Extra Resources</h1> <h1 class="text-xl font-bold my-1">Extra Resources</h1>
<div class="flex mt-2 mb-1"> <div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=https://youtu.be/dyLaKhePzj8 style="color: blue" <p class="m-0 mr-2">Slide Link:</p>
class="underline">Open Here</a> <a href="https://youtu.be/dyLaKhePzj8" style="color: blue" class="underline">
Open Here
</a>
</div> </div>
<p class="m-0">Video Link:</p> <p class="m-0">Video Link:</p>
<div class="m-0"> <div class="m-0">
<ol class="m-0"> <ol class="m-0">
<li>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue" <a href="https://youtu.be/0fYi8SGA20k" style="color: blue" class="underline">
class="underline">https://youtu.be/0fYi8SGA20k</a></li> https://youtu.be/0fYi8SGA20k
</a>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue" </li>
class="underline">https://youtu.be/0fYi8SGA20k</a></li> <li>
<a href="https://youtu.be/0fYi8SGA20k" style="color: blue" class="underline">
https://youtu.be/0fYi8SGA20k
</a>
</li>
</ol> </ol>
</div> </div>
-4
View File
@@ -1,4 +0,0 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
-15
View File
@@ -1,15 +0,0 @@
{
"useTabs": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+2 -1
View File
@@ -9,7 +9,8 @@
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .", "format": "echo 'disabled'",
"format:run": "prettier --write .",
"lint": "prettier --check .", "lint": "prettier --check .",
"test:unit": "vitest", "test:unit": "vitest",
"test": "npm run test:unit -- --run" "test": "npm run test:unit -- --run"
+19 -8
View File
@@ -1,6 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" /> <link rel="icon" href="%sveltekit.assets%/favicon.ico" />
@@ -17,8 +16,10 @@
<meta property="og:url" content="https://www.courseapp.oncws.com/" /> <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: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:title" content="Courseapp by ClassroomIO –&nbsp;Create your SAAS Academy in Minutes" />
<meta property="og:description" <meta
content="Series of templates available for you to clone, customise and launch your SAAS Academy. Scale your customer education effort today." /> 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:type" content="website" />
<meta property="og:image:type" content="image/png" /> <meta property="og:image:type" content="image/png" />
@@ -30,18 +31,28 @@
<meta property="twitter:domain" content="courseapp.oncws.com" /> <meta property="twitter:domain" content="courseapp.oncws.com" />
<meta property="twitter:url" content="https://www.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:title" content="Courseapp by ClassroomIO –&nbsp;Create your SAAS Academy in Minutes" />
<meta name="twitter:description" <meta
content="Series of templates available for you to clone, customise and launch your SAAS Academy. Scale your customer education effort today." /> 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: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>
<script async src='https://r.wdfl.co/rw.js' data-rewardful='d216d5'></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> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>
@@ -1,35 +1,33 @@
import type { Button as ButtonPrimitive } from "bits-ui"; import type { Button as ButtonPrimitive } from 'bits-ui';
import { type VariantProps, tv } from "tailwind-variants"; import { type VariantProps, tv } from 'tailwind-variants';
import Root from "./button.svelte"; import Root from './button.svelte';
const buttonVariants = tv({ const buttonVariants = tv({
base: "focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50", base: 'focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50',
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow", default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow',
destructive: destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
"bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm", outline: 'border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm',
outline: secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm',
"border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm", ghost: 'hover:bg-accent hover:text-accent-foreground',
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm", link: 'text-primary underline-offset-4 hover:underline'
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 px-4 py-2", default: 'h-9 px-4 py-2',
sm: "h-8 rounded-md px-3 text-xs", sm: 'h-8 rounded-md px-3 text-xs',
lg: "h-10 rounded-md px-8", lg: 'h-10 rounded-md px-8',
icon: "h-9 w-9", icon: 'h-9 w-9'
}, }
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default'
}, }
}); });
type Variant = VariantProps<typeof buttonVariants>["variant"]; type Variant = VariantProps<typeof buttonVariants>['variant'];
type Size = VariantProps<typeof buttonVariants>["size"]; type Size = VariantProps<typeof buttonVariants>['size'];
type Props = ButtonPrimitive.Props & { type Props = ButtonPrimitive.Props & {
variant?: Variant; variant?: Variant;
@@ -46,5 +44,5 @@ export {
Root as Button, Root as Button,
type Props as ButtonProps, type Props as ButtonProps,
type Events as ButtonEvents, type Events as ButtonEvents,
buttonVariants, buttonVariants
}; };
@@ -1,12 +1,12 @@
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from 'bits-ui';
import Title from "./dialog-title.svelte"; import Title from './dialog-title.svelte';
import Portal from "./dialog-portal.svelte"; import Portal from './dialog-portal.svelte';
import Footer from "./dialog-footer.svelte"; import Footer from './dialog-footer.svelte';
import Header from "./dialog-header.svelte"; import Header from './dialog-header.svelte';
import Overlay from "./dialog-overlay.svelte"; import Overlay from './dialog-overlay.svelte';
import Content from "./dialog-content.svelte"; import Content from './dialog-content.svelte';
import Description from "./dialog-description.svelte"; import Description from './dialog-description.svelte';
const Root = DialogPrimitive.Root; const Root = DialogPrimitive.Root;
const Trigger = DialogPrimitive.Trigger; const Trigger = DialogPrimitive.Trigger;
@@ -33,5 +33,5 @@ export {
Overlay as DialogOverlay, Overlay as DialogOverlay,
Content as DialogContent, Content as DialogContent,
Description as DialogDescription, Description as DialogDescription,
Close as DialogClose, Close as DialogClose
}; };
@@ -1,4 +1,4 @@
import Root from "./input.svelte"; import Root from './input.svelte';
export type FormInputEvent<T extends Event = Event> = T & { export type FormInputEvent<T extends Event = Event> = T & {
currentTarget: EventTarget & HTMLInputElement; currentTarget: EventTarget & HTMLInputElement;
@@ -25,5 +25,5 @@ export type InputEvents = {
export { export {
Root, Root,
// //
Root as Input, Root as Input
}; };
@@ -1,7 +1,7 @@
import Root from "./label.svelte"; import Root from './label.svelte';
export { export {
Root, Root,
// //
Root as Label, Root as Label
}; };
@@ -1,7 +1,7 @@
import Root from "./separator.svelte"; import Root from './separator.svelte';
export { export {
Root, Root,
// //
Root as Separator, Root as Separator
}; };
@@ -1 +1 @@
export { default as Toaster } from "./sonner.svelte"; export { default as Toaster } from './sonner.svelte';
+11 -1
View File
@@ -1 +1,11 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} {
"name": "",
"short_name": "",
"icons": [
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
-2
View File
@@ -100,11 +100,9 @@ Here is what you need to be able to run Cal.com.
You can install nvm from [here](https://github.com/nvm-sh/nvm). You can install nvm from [here](https://github.com/nvm-sh/nvm).
4. Set up your `.env` file 4. Set up your `.env` file
- Duplicate `.env.example` to `.env` - Duplicate `.env.example` to `.env`
5. Setup Supabase. 5. Setup Supabase.
- Make sure you've downloaded the [supabase cli](https://github.com/supabase/cli) - Make sure you've downloaded the [supabase cli](https://github.com/supabase/cli)
- Install and Start [docker](https://docs.docker.com/engine/install/) - Install and Start [docker](https://docs.docker.com/engine/install/)
- Go to the project directory in your terminal and start supabase - Go to the project directory in your terminal and start supabase
+55 -70
View File
@@ -40,6 +40,7 @@ export interface NestedRouteConfig {
### Step 2: Update Breadcrumb Generation Calls ### Step 2: Update Breadcrumb Generation Calls
#### Before (Org): #### Before (Org):
```typescript ```typescript
import { generateBreadcrumbs } from './breadcrumb'; import { generateBreadcrumbs } from './breadcrumb';
@@ -53,6 +54,7 @@ const breadcrumbs = generateBreadcrumbs(
``` ```
#### After (Org - Store-Based Reactive): #### After (Org - Store-Based Reactive):
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
@@ -61,24 +63,20 @@ const breadcrumbs = generateBreadcrumbs(
import { courseStore } from '$features/course/utils/store'; import { courseStore } from '$features/course/utils/store';
// Reactive config that reads from stores // Reactive config that reads from stores
const config = $derived(createOrgBreadcrumbConfigWithStore( const config = $derived(
createOrgBreadcrumbConfigWithStore(
$currentOrgPath, $currentOrgPath,
() => $courseStore.title // Reads reactively - updates automatically! () => $courseStore.title // Reads reactively - updates automatically!
));
// Breadcrumbs update automatically when store changes!
const breadcrumbs = $derived(
generateBreadcrumbsSync(
page.url.pathname,
page.url.search,
navItems,
config
) )
); );
// Breadcrumbs update automatically when store changes!
const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
</script> </script>
``` ```
#### Before (LMS): #### Before (LMS):
```typescript ```typescript
import { generateLmsBreadcrumbs } from './lms-breadcrumb'; import { generateLmsBreadcrumbs } from './lms-breadcrumb';
import { currentCommunityQuestion } from '$features/community/utils/store'; import { currentCommunityQuestion } from '$features/community/utils/store';
@@ -93,6 +91,7 @@ const breadcrumbs = generateLmsBreadcrumbs(
``` ```
#### After (LMS - Reactive Store-Based): #### After (LMS - Reactive Store-Based):
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
@@ -101,19 +100,14 @@ const breadcrumbs = generateLmsBreadcrumbs(
import { currentCommunityQuestion } from '$features/community/utils/store'; import { currentCommunityQuestion } from '$features/community/utils/store';
// Config is reactive - updates when store changes // Config is reactive - updates when store changes
const config = $derived(createLmsBreadcrumbConfigWithStore( const config = $derived(
createLmsBreadcrumbConfigWithStore(
() => $currentCommunityQuestion.title // Reads reactively from store () => $currentCommunityQuestion.title // Reads reactively from store
));
// Breadcrumbs update automatically when store changes!
const breadcrumbs = $derived(
generateBreadcrumbsSync(
page.url.pathname,
page.url.search,
navItems,
config
) )
); );
// Breadcrumbs update automatically when store changes!
const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
</script> </script>
``` ```
@@ -131,6 +125,7 @@ The refactored system supports up to 5 layers of nesting through recursive trave
#### Example: Courses → Course → Lesson → Assignment → Submission (5 Layers) #### Example: Courses → Course → Lesson → Assignment → Submission (5 Layers)
**Navigation Config** (in `org-navigation.ts`): **Navigation Config** (in `org-navigation.ts`):
```typescript ```typescript
{ {
titleKey: 'org_navigation.courses', titleKey: 'org_navigation.courses',
@@ -186,6 +181,7 @@ The refactored system supports up to 5 layers of nesting through recursive trave
``` ```
**Breadcrumb Component** (in `app-breadcrumbs.svelte`): **Breadcrumb Component** (in `app-breadcrumbs.svelte`):
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
@@ -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'; import { assignmentStore, submissionStore, courseStore, lessonStore } from '$features/course/utils/store';
// Create reactive config that reads from stores // Create reactive config that reads from stores
const config = $derived(createOrgBreadcrumbConfigWithStore( const config = $derived(
$currentOrgPath, createOrgBreadcrumbConfigWithStore($currentOrgPath, () => {
() => {
// This function is called reactively - reads from stores // This function is called reactively - reads from stores
const pathSegments = page.url.pathname.split('/').filter(Boolean); const pathSegments = page.url.pathname.split('/').filter(Boolean);
@@ -214,21 +209,15 @@ The refactored system supports up to 5 layers of nesting through recursive trave
return $courseStore.title || 'Course'; return $courseStore.title || 'Course';
} }
return null; return null;
} })
));
const breadcrumbs = $derived(
generateBreadcrumbsSync(
page.url.pathname,
page.url.search,
navItems,
config
)
); );
const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
</script> </script>
``` ```
**Alternative: Per-Route Store Resolvers** (More Granular): **Alternative: Per-Route Store Resolvers** (More Granular):
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
@@ -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'; import { assignmentStore, submissionStore, courseStore, lessonStore } from '$features/course/utils/store';
// Create config with store-based resolver // Create config with store-based resolver
const config = $derived(createOrgBreadcrumbConfigWithStore( const config = $derived(
$currentOrgPath, createOrgBreadcrumbConfigWithStore($currentOrgPath, () => {
() => {
// Read from stores reactively based on path structure // Read from stores reactively based on path structure
const pathSegments = page.url.pathname.split('/').filter(Boolean); const pathSegments = page.url.pathname.split('/').filter(Boolean);
@@ -257,17 +245,10 @@ The refactored system supports up to 5 layers of nesting through recursive trave
return $courseStore.title || null; return $courseStore.title || null;
} }
return null; return null;
} })
));
const breadcrumbs = $derived(
generateBreadcrumbsSync(
page.url.pathname,
page.url.search,
navItems,
config
)
); );
const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
</script> </script>
``` ```
@@ -288,6 +269,7 @@ The refactored system supports up to 5 layers of nesting through recursive trave
#### Store-Based vs Async Resolvers #### Store-Based vs Async Resolvers
**✅ Recommended: Store-Based (Reactive)** **✅ Recommended: Store-Based (Reactive)**
```typescript ```typescript
// In breadcrumb component // In breadcrumb component
const config = $derived(createOrgBreadcrumbConfigWithStore( const config = $derived(createOrgBreadcrumbConfigWithStore(
@@ -300,15 +282,17 @@ const breadcrumbs = $derived(generateBreadcrumbsSync(...));
``` ```
**⚠️ Alternative: Async Resolvers (API Calls)** **⚠️ Alternative: Async Resolvers (API Calls)**
```typescript ```typescript
// In navigation config // In navigation config
titleResolver: async (params) => { titleResolver: async (params) => {
const assignment = await fetchAssignment(params.param0); const assignment = await fetchAssignment(params.param0);
return assignment.title; return assignment.title;
} };
``` ```
**Why Use Stores?** **Why Use Stores?**
- ✅ **No API calls** - data already loaded in component - ✅ **No API calls** - data already loaded in component
- ✅ **Automatic updates** - breadcrumb updates when store changes - ✅ **Automatic updates** - breadcrumb updates when store changes
- ✅ **Better performance** - no loading states or delays - ✅ **Better performance** - no loading states or delays
@@ -350,41 +334,43 @@ For routes that need async data fetching:
```typescript ```typescript
import { generateBreadcrumbs } from './breadcrumb-core'; // Note: async version import { generateBreadcrumbs } from './breadcrumb-core'; // Note: async version
const breadcrumbs = await generateBreadcrumbs( const breadcrumbs = await generateBreadcrumbs(page.url.pathname, page.url.search, navItems, config);
page.url.pathname,
page.url.search,
navItems,
config
);
``` ```
## Benefits of the Refactored System ## Benefits of the Refactored System
### ✅ 1. No Code Duplication ### ✅ 1. No Code Duplication
- Single unified generator for all contexts - Single unified generator for all contexts
- Context-specific configs handle differences - Context-specific configs handle differences
### ✅ 2. Multi-Level Nesting Support ### ✅ 2. Multi-Level Nesting Support
- Recursive generation handles any depth - Recursive generation handles any depth
- Configurable max depth prevents infinite loops - Configurable max depth prevents infinite loops
### ✅ 3. Flexible Path Prefixes ### ✅ 3. Flexible Path Prefixes
- Function-based prefix extraction - Function-based prefix extraction
- Supports dynamic prefixes like `/org/[slug]` - Supports dynamic prefixes like `/org/[slug]`
### ✅ 4. Consistent Dynamic Title Resolution ### ✅ 4. Consistent Dynamic Title Resolution
- Unified interface for all resolvers - Unified interface for all resolvers
- Supports both sync and async resolution - Supports both sync and async resolution
### ✅ 5. Route-Specific Customization ### ✅ 5. Route-Specific Customization
- Override labels per route - Override labels per route
- Custom resolvers for complex cases - Custom resolvers for complex cases
### ✅ 6. Optional Caching ### ✅ 6. Optional Caching
- Performance optimization - Performance optimization
- Configurable cache TTL - Configurable cache TTL
### ✅ 7. Type Safety ### ✅ 7. Type Safety
- Full TypeScript support - Full TypeScript support
- Compile-time error checking - Compile-time error checking
@@ -393,6 +379,7 @@ const breadcrumbs = await generateBreadcrumbs(
**URL**: `/org/myorg/courses/123/lessons/456/assignments/789/submissions/101` **URL**: `/org/myorg/courses/123/lessons/456/assignments/789/submissions/101`
**Config**: **Config**:
```typescript ```typescript
{ {
path: '/courses', path: '/courses',
@@ -492,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` **Breadcrumb Result**: `Courses / React Fundamentals / Lessons / Components / Assignments / Build Todo App / Submissions / Submission #101`
**Depth Breakdown**: **Depth Breakdown**:
- `depth=0`: Top-level nav item (`/courses`) → "Courses" - `depth=0`: Top-level nav item (`/courses`) → "Courses"
- `depth=1`: First dynamic segment (`123`) → "React Fundamentals" - `depth=1`: First dynamic segment (`123`) → "React Fundamentals"
- `depth=2`: Static nested route (`lessons`) → "Lessons" - `depth=2`: Static nested route (`lessons`) → "Lessons"
@@ -560,12 +548,7 @@ beforeEach(() => {
// Test breadcrumb generation // Test breadcrumb generation
test('generates breadcrumbs for nested route', () => { test('generates breadcrumbs for nested route', () => {
const breadcrumbs = generateBreadcrumbsSync( const breadcrumbs = generateBreadcrumbsSync('/org/test/courses/123/lessons/456', '', navItems, config);
'/org/test/courses/123/lessons/456',
'',
navItems,
config
);
expect(breadcrumbs).toHaveLength(4); expect(breadcrumbs).toHaveLength(4);
expect(breadcrumbs[0].label).toBe('Courses'); expect(breadcrumbs[0].label).toBe('Courses');
@@ -604,6 +587,7 @@ This allows gradual migration without breaking existing code.
### Why Use Stores Instead of Async Requests? ### Why Use Stores Instead of Async Requests?
**✅ Benefits:** **✅ Benefits:**
- **No API calls** - Data is already loaded in your component - **No API calls** - Data is already loaded in your component
- **Automatic updates** - Breadcrumbs update reactively when stores change - **Automatic updates** - Breadcrumbs update reactively when stores change
- **Better performance** - No loading states, delays, or network requests - **Better performance** - No loading states, delays, or network requests
@@ -619,6 +603,7 @@ This allows gradual migration without breaking existing code.
### Example: Community Post Title ### Example: Community Post Title
**Store** (`$features/community/utils/store.ts`): **Store** (`$features/community/utils/store.ts`):
```typescript ```typescript
export const currentCommunityQuestion = writable<{ title: string | null }>({ export const currentCommunityQuestion = writable<{ title: string | null }>({
title: null title: null
@@ -626,6 +611,7 @@ export const currentCommunityQuestion = writable<{ title: string | null }>({
``` ```
**Page Component** (`+page.svelte`): **Page Component** (`+page.svelte`):
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { currentCommunityQuestion } from '$features/community/utils/store'; import { currentCommunityQuestion } from '$features/community/utils/store';
@@ -646,20 +632,21 @@ export const currentCommunityQuestion = writable<{ title: string | null }>({
``` ```
**Breadcrumb Component** (`lms-breadcrumbs.svelte`): **Breadcrumb Component** (`lms-breadcrumbs.svelte`):
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { createLmsBreadcrumbConfigWithStore } from './breadcrumb-configs'; import { createLmsBreadcrumbConfigWithStore } from './breadcrumb-configs';
import { currentCommunityQuestion } from '$features/community/utils/store'; import { currentCommunityQuestion } from '$features/community/utils/store';
// Reactive config - updates when store changes // Reactive config - updates when store changes
const config = $derived(createLmsBreadcrumbConfigWithStore( const config = $derived(
createLmsBreadcrumbConfigWithStore(
() => $currentCommunityQuestion.title // Reads reactively! () => $currentCommunityQuestion.title // Reads reactively!
)); )
);
// Breadcrumbs automatically update when store changes! // Breadcrumbs automatically update when store changes!
const breadcrumbs = $derived( const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config)
);
</script> </script>
``` ```
@@ -669,9 +656,8 @@ For routes with multiple dynamic segments, use path-based logic:
```svelte ```svelte
<script lang="ts"> <script lang="ts">
const config = $derived(createOrgBreadcrumbConfigWithStore( const config = $derived(
$currentOrgPath, createOrgBreadcrumbConfigWithStore($currentOrgPath, () => {
() => {
const pathSegments = page.url.pathname.split('/').filter(Boolean); const pathSegments = page.url.pathname.split('/').filter(Boolean);
// Determine which store to read based on route // Determine which store to read based on route
@@ -688,8 +674,8 @@ For routes with multiple dynamic segments, use path-based logic:
return $courseStore.title; return $courseStore.title;
} }
return null; return null;
} })
)); );
</script> </script>
``` ```
@@ -699,4 +685,3 @@ For routes with multiple dynamic segments, use path-based logic:
2. **Use `$derived`**: Always wrap config and breadcrumbs in `$derived` for reactivity 2. **Use `$derived`**: Always wrap config and breadcrumbs in `$derived` for reactivity
3. **Store Updates**: When you update a store, breadcrumbs automatically update - no manual refresh needed! 3. **Store Updates**: When you update a store, breadcrumbs automatically update - no manual refresh needed!
4. **No Async Needed**: Store-based resolvers are synchronous - just return the store value 4. **No Async Needed**: Store-based resolvers are synchronous - just return the store value
+26 -14
View File
@@ -20,11 +20,13 @@ This guide explains how to create pages and routes in the dashboard application,
The dashboard has two main route contexts: The dashboard has two main route contexts:
### 1. Organization Routes (`/org/[slug]/...`) ### 1. Organization Routes (`/org/[slug]/...`)
- Uses `OrgSidebar` and `AppHeader` - Uses `OrgSidebar` and `AppHeader`
- Path pattern: `/org/[slug]/[feature]` - Path pattern: `/org/[slug]/[feature]`
- Example: `/org/acme/courses`, `/org/acme/settings` - Example: `/org/acme/courses`, `/org/acme/settings`
### 2. LMS Routes (`/lms/...`) ### 2. LMS Routes (`/lms/...`)
- Uses `LMSSidebar` and `LmsHeader` - Uses `LMSSidebar` and `LmsHeader`
- Path pattern: `/lms/[feature]` - Path pattern: `/lms/[feature]`
- Example: `/lms/community`, `/lms/settings` - Example: `/lms/community`, `/lms/settings`
@@ -38,6 +40,7 @@ The dashboard has two main route contexts:
**Location**: `apps/dashboard/src/routes/[context]/[feature]/+page.svelte` **Location**: `apps/dashboard/src/routes/[context]/[feature]/+page.svelte`
**Pattern**: **Pattern**:
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { FeaturePage } from '$features/[feature]/pages'; import { FeaturePage } from '$features/[feature]/pages';
@@ -72,6 +75,7 @@ The dashboard has two main route contexts:
**Location**: `apps/dashboard/src/lib/features/[feature]/pages/[feature].svelte` **Location**: `apps/dashboard/src/lib/features/[feature]/pages/[feature].svelte`
**Pattern**: **Pattern**:
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { Search } from '@cio/ui/custom/search'; import { Search } from '@cio/ui/custom/search';
@@ -230,6 +234,7 @@ Settings pages follow a specific pattern with layouts and subroutes.
Feature pages contain all business logic and should be placed in `lib/features/[feature]/pages/`. Feature pages contain all business logic and should be placed in `lib/features/[feature]/pages/`.
**Key Principles**: **Key Principles**:
- ✅ All business logic goes in the feature page - ✅ All business logic goes in the feature page
- ✅ Use `Page.BodyHeader` for filters, search, and view toggles - ✅ Use `Page.BodyHeader` for filters, search, and view toggles
- ✅ Use `Empty` component for empty states - ✅ Use `Empty` component for empty states
@@ -363,6 +368,7 @@ Feature pages contain all business logic and should be placed in `lib/features/[
``` ```
**Key Differences**: **Key Differences**:
- Org routes use `AppHeader` (includes AppSetup) - Org routes use `AppHeader` (includes AppSetup)
- LMS routes use `LmsHeader` (no AppSetup) - LMS routes use `LmsHeader` (no AppSetup)
- Org routes use `OrgSidebar` - Org routes use `OrgSidebar`
@@ -426,11 +432,7 @@ Feature pages contain all business logic and should be placed in `lib/features/[
<Field.Set> <Field.Set>
<Field.Legend>{$t('form.another_section')}</Field.Legend> <Field.Legend>{$t('form.another_section')}</Field.Legend>
<Field.Field> <Field.Field>
<UploadImage <UploadImage bind:avatar src={avatarUrl} change={() => (hasUnsavedChanges = true)} />
bind:avatar
src={avatarUrl}
change={() => (hasUnsavedChanges = true)}
/>
</Field.Field> </Field.Field>
</Field.Set> </Field.Set>
</Field.Group> </Field.Group>
@@ -568,6 +570,7 @@ export async function load({ params }) {
### Example 1: Simple Feature Page ### Example 1: Simple Feature Page
**Route**: `routes/org/[slug]/courses/+page.svelte` **Route**: `routes/org/[slug]/courses/+page.svelte`
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { CoursesPage } from '$features/courses/pages'; import { CoursesPage } from '$features/courses/pages';
@@ -596,6 +599,7 @@ export async function load({ params }) {
### Example 2: Settings Page with Save Action ### Example 2: Settings Page with Save Action
**Route**: `routes/org/[slug]/settings/customize-lms/+page.svelte` **Route**: `routes/org/[slug]/settings/customize-lms/+page.svelte`
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { CustomizeLmsPage } from '$features/settings/pages'; import { CustomizeLmsPage } from '$features/settings/pages';
@@ -642,6 +646,7 @@ export async function load({ params }) {
### Example 3: Form Page Component ### Example 3: Form Page Component
**Feature Page**: `lib/features/settings/pages/profile.svelte` **Feature Page**: `lib/features/settings/pages/profile.svelte`
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
@@ -655,11 +660,14 @@ export async function load({ params }) {
let hasUnsavedChanges = $state(false); let hasUnsavedChanges = $state(false);
export async function handleUpdate() { export async function handleUpdate() {
await profileApi.submit({ await profileApi.submit(
{
fullname: $profile.fullname, fullname: $profile.fullname,
username: $profile.username, username: $profile.username,
avatar avatar
}, $profile); },
$profile
);
if (profileApi.success) { if (profileApi.success) {
hasUnsavedChanges = false; hasUnsavedChanges = false;
@@ -708,12 +716,14 @@ export async function load({ params }) {
When creating a new page, follow this checklist: When creating a new page, follow this checklist:
### Route Setup ### Route Setup
- [ ] Create route file at `routes/[context]/[feature]/+page.svelte` - [ ] Create route file at `routes/[context]/[feature]/+page.svelte`
- [ ] Add `Page.Root`, `Page.Header`, `Page.Body` structure - [ ] Add `Page.Root`, `Page.Header`, `Page.Body` structure
- [ ] Add `<svelte:head>` with page title - [ ] Add `<svelte:head>` with page title
- [ ] Import and render feature page component - [ ] Import and render feature page component
### Feature Page ### Feature Page
- [ ] Create feature page at `lib/features/[feature]/pages/[feature].svelte` - [ ] Create feature page at `lib/features/[feature]/pages/[feature].svelte`
- [ ] Export from `lib/features/[feature]/pages/index.ts` - [ ] Export from `lib/features/[feature]/pages/index.ts`
- [ ] Use `Page.BodyHeader` for filters/search - [ ] Use `Page.BodyHeader` for filters/search
@@ -721,6 +731,7 @@ When creating a new page, follow this checklist:
- [ ] Handle loading states - [ ] Handle loading states
### Forms (if applicable) ### Forms (if applicable)
- [ ] Use `Field.Group` pattern (NOT Row/Grid/Column) - [ ] Use `Field.Group` pattern (NOT Row/Grid/Column)
- [ ] Use `Field.Set` for sections - [ ] Use `Field.Set` for sections
- [ ] Use `Field.Separator` between sections - [ ] Use `Field.Separator` between sections
@@ -729,6 +740,7 @@ When creating a new page, follow this checklist:
- [ ] Add `UnsavedChanges` component if needed - [ ] Add `UnsavedChanges` component if needed
### Navigation (if needed) ### Navigation (if needed)
- [ ] Add item to `org-navigation.ts` or `lms-navigation.ts` - [ ] Add item to `org-navigation.ts` or `lms-navigation.ts`
- [ ] Set appropriate `matchPattern` for active state - [ ] Set appropriate `matchPattern` for active state
- [ ] Add icon component - [ ] Add icon component
@@ -736,6 +748,7 @@ When creating a new page, follow this checklist:
- [ ] Configure dynamic segments if needed - [ ] Configure dynamic segments if needed
### Settings Pages (if applicable) ### Settings Pages (if applicable)
- [ ] Create `+layout.svelte` with `Page.Root` wrapper - [ ] Create `+layout.svelte` with `Page.Root` wrapper
- [ ] Create default `+page.svelte` for main settings - [ ] Create default `+page.svelte` for main settings
- [ ] Create subroute `+page.svelte` files for each subroute - [ ] Create subroute `+page.svelte` files for each subroute
@@ -755,6 +768,7 @@ When creating a new page, follow this checklist:
### Page Actions ### Page Actions
Actions in `Page.Action` typically include: Actions in `Page.Action` typically include:
- Save/Update buttons - Save/Update buttons
- Create/Add buttons - Create/Add buttons
- Export buttons - Export buttons
@@ -763,6 +777,7 @@ Actions in `Page.Action` typically include:
### Loading States ### Loading States
Use skeleton loaders or spinner components: Use skeleton loaders or spinner components:
```svelte ```svelte
{#if isLoading} {#if isLoading}
<FeatureLoader /> <FeatureLoader />
@@ -776,13 +791,9 @@ Use skeleton loaders or spinner components:
### Empty States ### Empty States
Always use the `Empty` component: Always use the `Empty` component:
```svelte ```svelte
<Empty <Empty title={$t('feature.empty_title')} description={$t('feature.empty_description')} icon={SomeIcon} variant="page">
title={$t('feature.empty_title')}
description={$t('feature.empty_description')}
icon={SomeIcon}
variant="page"
>
<ActionButton /> <ActionButton />
</Empty> </Empty>
``` ```
@@ -807,6 +818,7 @@ Always use the `Empty` component:
If you encounter old patterns, migrate them: If you encounter old patterns, migrate them:
### Old: Row/Grid/Column Pattern ### Old: Row/Grid/Column Pattern
```svelte ```svelte
<!-- OLD - DON'T USE --> <!-- OLD - DON'T USE -->
<Grid> <Grid>
@@ -822,6 +834,7 @@ If you encounter old patterns, migrate them:
``` ```
### New: Field.Group Pattern ### New: Field.Group Pattern
```svelte ```svelte
<!-- NEW - USE THIS --> <!-- NEW - USE THIS -->
<Field.Group class="max-w-md! w-full px-2"> <Field.Group class="max-w-md! w-full px-2">
@@ -843,4 +856,3 @@ If you encounter old patterns, migrate them:
- See existing pages in `routes/org/[slug]/` and `routes/lms/` for examples - See existing pages in `routes/org/[slug]/` and `routes/lms/` for examples
- See `lib/features/settings/pages/` for form examples - See `lib/features/settings/pages/` for form examples
- See `lib/features/community/pages/` for list page examples - See `lib/features/community/pages/` for list page examples
+6 -4
View File
@@ -1,6 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" /> <meta name="viewport" content="width=device-width,initial-scale=1.0" />
@@ -16,8 +15,12 @@
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" /> <link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
<script nonce="%sveltekit.nonce%" defer src="https://umami.hz.oncws.com/script.js" <script
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"></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>
@@ -27,5 +30,4 @@
<body data-sveltekit-preload-data="hover" class=""> <body data-sveltekit-preload-data="hover" class="">
<div style="display: contents" class="theme-container">%sveltekit.body%</div> <div style="display: contents" class="theme-container">%sveltekit.body%</div>
</body> </body>
</html> </html>
@@ -13,8 +13,8 @@
<Skeleton class="h-[48px] w-[48px]" /> <Skeleton class="h-[48px] w-[48px]" />
<div class="flex-1"> <div class="flex-1">
<Skeleton class="h-[48px] w-[48px]" /> <Skeleton class="h-[48px] w-[48px]" />
<Skeleton class="h-[16px] w-[80px] mb-2" /> <Skeleton class="mb-2 h-[16px] w-[80px]" />
<Skeleton class="h-[24px] w-[40px] mb-1" /> <Skeleton class="mb-1 h-[24px] w-[40px]" />
<Skeleton class="h-[12px] w-[60px]" /> <Skeleton class="h-[12px] w-[60px]" />
</div> </div>
</div> </div>
@@ -26,10 +26,10 @@
<!-- Charts Section Skeleton --> <!-- Charts Section Skeleton -->
<div class="mb-12"> <div class="mb-12">
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"> <div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
<Skeleton class="h-[24px] w-[200px] mb-6" /> <Skeleton class="mb-6 h-[24px] w-[200px]" />
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Skeleton class="w-full h-[300px]" /> <Skeleton class="h-[300px] w-full" />
<Skeleton class="w-full h-[300px]" /> <Skeleton class="h-[300px] w-full" />
</div> </div>
</div> </div>
</div> </div>
@@ -37,7 +37,7 @@
<!-- Students Table Skeleton --> <!-- Students Table Skeleton -->
<div class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800"> <div class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800">
<div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700"> <div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700">
<Skeleton class="h-[24px] w-[200px] mb-6" /> <Skeleton class="mb-6 h-[24px] w-[200px]" />
</div> </div>
<div class="p-6"> <div class="p-6">
<div class="space-y-4"> <div class="space-y-4">
@@ -45,7 +45,7 @@
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<Skeleton class="h-[32px] w-[32px]" /> <Skeleton class="h-[32px] w-[32px]" />
<div class="flex-1"> <div class="flex-1">
<Skeleton class="h-[16px] w-[150px] mb-1" /> <Skeleton class="mb-1 h-[16px] w-[150px]" />
<Skeleton class="h-[12px] w-[200px]" /> <Skeleton class="h-[12px] w-[200px]" />
</div> </div>
<Skeleton class="h-[16px] w-[80px]" /> <Skeleton class="h-[16px] w-[80px]" />
@@ -39,7 +39,7 @@
if (!isOpen) handleClose(); if (!isOpen) handleClose();
}} }}
> >
<Dialog.Content class="w-[80%] md:w-[65%] max-w-xl"> <Dialog.Content class="w-[80%] max-w-xl md:w-[65%]">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{$t(`course.navItem.lessons.section_prompt.header`)}</Dialog.Title> <Dialog.Title>{$t(`course.navItem.lessons.section_prompt.header`)}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
@@ -54,16 +54,10 @@
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button variant="outline" onclick={handleClose}>
variant="outline"
onclick={handleClose}
>
{$t('course.navItem.lessons.section_prompt.cancel')} {$t('course.navItem.lessons.section_prompt.cancel')}
</Button> </Button>
<Button <Button onclick={activate} loading={isActivating}>
onclick={activate}
loading={isActivating}
>
{$t('course.navItem.lessons.section_prompt.activate')} {$t('course.navItem.lessons.section_prompt.activate')}
</Button> </Button>
</div> </div>
@@ -22,7 +22,7 @@
if (!isOpen) openDeleteModal = false; if (!isOpen) openDeleteModal = false;
}} }}
> >
<Dialog.Content class="w-96 px-6 pt-2 pb-6"> <Dialog.Content class="w-96 px-6 pb-6 pt-2">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{$t('delete_modal.label')}</Dialog.Title> <Dialog.Title>{$t('delete_modal.label')}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
@@ -32,16 +32,10 @@
</h1> </h1>
<div class="mt-5 flex items-center justify-between"> <div class="mt-5 flex items-center justify-between">
<Button <Button variant="outline" onclick={() => (openDeleteModal = false)}>
variant="outline"
onclick={() => (openDeleteModal = false)}
>
{$t('delete_modal.no')} {$t('delete_modal.no')}
</Button> </Button>
<Button <Button variant="outline" onclick={handleDelete}>
variant="outline"
onclick={handleDelete}
>
{$t('delete_modal.yes')} {$t('delete_modal.yes')}
</Button> </Button>
</div> </div>
@@ -7,9 +7,7 @@
let { onDelete = () => {}, onCancel = () => {} } = $props(); let { onDelete = () => {}, onCancel = () => {} } = $props();
</script> </script>
<Dialog.Root <Dialog.Root bind:open={$deleteConfirmation.open}>
bind:open={$deleteConfirmation.open}
>
<Dialog.Content class="w-96"> <Dialog.Content class="w-96">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}</Dialog.Title> <Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}</Dialog.Title>
@@ -236,7 +236,7 @@
if (!isOpen) onClose(); if (!isOpen) onClose();
}} }}
> >
<Dialog.Content class="w-4/5 h-[90%] flex items-start !max-h-full h-[85%] py-0 px-4"> <Dialog.Content class="flex h-[85%] h-[90%] !max-h-full w-4/5 items-start px-4 py-0">
<Dialog.Header class="py-2"> <Dialog.Header class="py-2">
<Dialog.Title class="text-base font-semibold">{data.title}</Dialog.Title> <Dialog.Title class="text-base font-semibold">{data.title}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
@@ -153,7 +153,7 @@
if (!isOpen) handleCancelAddExercise(); if (!isOpen) handleCancelAddExercise();
}} }}
> >
<Dialog.Content class="max-w-2xl w-4/5"> <Dialog.Content class="w-4/5 max-w-2xl">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.new_exercise_modal.heading')}</Dialog.Title> <Dialog.Title>{$t('course.navItem.lessons.exercises.new_exercise_modal.heading')}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
@@ -15,9 +15,7 @@
} }
</script> </script>
<Dialog.Root <Dialog.Root bind:open={openDeleteModal}>
bind:open={openDeleteModal}
>
<Dialog.Content class="w-96"> <Dialog.Content class="w-96">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Delete Feed</Dialog.Title> <Dialog.Title>Delete Feed</Dialog.Title>
@@ -26,15 +24,8 @@
<h1 class="text-lg dark:text-white">Are you sure you want to delete this feed?</h1> <h1 class="text-lg dark:text-white">Are you sure you want to delete this feed?</h1>
<div class="mt-5 flex items-center justify-between"> <div class="mt-5 flex items-center justify-between">
<Button <Button variant="outline" onclick={() => (openDeleteModal = false)}>No</Button>
variant="outline" <Button variant="outline" onclick={handleDelete}>Yes</Button>
onclick={() => (openDeleteModal = false)}
>
No
</Button>
<Button variant="outline" onclick={handleDelete}>
Yes
</Button>
</div> </div>
</div> </div>
</Dialog.Content> </Dialog.Content>
@@ -140,10 +140,7 @@
{/if} {/if}
<div class="flex items-center justify-end py-2"> <div class="flex items-center justify-end py-2">
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button variant="outline" onclick={resetEditor}>
variant="outline"
onclick={resetEditor}
>
{$t('course.navItem.news_feed.heading_button.cancel')} {$t('course.navItem.news_feed.heading_button.cancel')}
</Button> </Button>
<Button loading={isLoading} onclick={onPost}> <Button loading={isLoading} onclick={onPost}>
@@ -12,11 +12,9 @@
} }
</script> </script>
<Dialog.Root <Dialog.Root bind:open={$deleteMemberModal.open}>
bind:open={$deleteMemberModal.open}
>
<Dialog.Content class="w-96 pt-3"> <Dialog.Content class="w-96 pt-3">
<Dialog.Header class="py-2 px-5"> <Dialog.Header class="px-5 py-2">
<Dialog.Title>{$t('course.navItem.people.delete_confirmation.title')}</Dialog.Title> <Dialog.Title>{$t('course.navItem.people.delete_confirmation.title')}</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<div> <div>
@@ -25,16 +23,10 @@
</p> </p>
<div class="mt-5 flex items-center justify-between"> <div class="mt-5 flex items-center justify-between">
<Button <Button variant="outline" onclick={() => ($deleteMemberModal.open = false)}>
variant="outline"
onclick={() => ($deleteMemberModal.open = false)}
>
{$t('course.navItem.people.delete_confirmation.no')} {$t('course.navItem.people.delete_confirmation.no')}
</Button> </Button>
<Button <Button variant="outline" onclick={handleDelete}>
variant="outline"
onclick={handleDelete}
>
{$t('course.navItem.people.delete_confirmation.yes')} {$t('course.navItem.people.delete_confirmation.yes')}
</Button> </Button>
</div> </div>
@@ -20,15 +20,12 @@
}: Props = $props(); }: Props = $props();
</script> </script>
<Dialog.Root <Dialog.Root bind:open>
bind:open
>
<Dialog.Content class="w-96"> <Dialog.Content class="w-96">
<Dialog.Header> <Dialog.Header>
<Dialog.Title> <Dialog.Title>
{$t('community.delete.title')} {isQuestion {$t('community.delete.title')}
? $t('community.delete.question') {isQuestion ? $t('community.delete.question') : $t('community.delete.comment')}
: $t('community.delete.comment')}
</Dialog.Title> </Dialog.Title>
</Dialog.Header> </Dialog.Header>
<div> <div>
@@ -38,16 +35,10 @@
</p> </p>
<div class="mt-5 flex items-center justify-between"> <div class="mt-5 flex items-center justify-between">
<Button <Button variant="outline" onclick={onCancel}>
variant="outline"
onclick={onCancel}
>
{$t('community.delete.no')} {$t('community.delete.no')}
</Button> </Button>
<Button <Button onclick={onDelete} loading={isDeleting}>
onclick={onDelete}
loading={isDeleting}
>
{$t('community.delete.yes')} {$t('community.delete.yes')}
</Button> </Button>
</div> </div>
@@ -7,4 +7,3 @@ export interface CurrentCommunityQuestion {
export const currentCommunityQuestion = writable<CurrentCommunityQuestion>({ export const currentCommunityQuestion = writable<CurrentCommunityQuestion>({
title: null title: null
}); });
@@ -20,9 +20,7 @@
}); });
</script> </script>
<Dialog.Root <Dialog.Root bind:open={$copyCourseModal.open}>
bind:open={$copyCourseModal.open}
>
<Dialog.Content class="w-96"> <Dialog.Content class="w-96">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{$t('courses.copy_course.title')}</Dialog.Title> <Dialog.Title>{$t('courses.copy_course.title')}</Dialog.Title>
@@ -233,4 +233,3 @@
</div> </div>
{/if} {/if}
</section> </section>
@@ -109,4 +109,3 @@
variant="page" variant="page"
/> />
{/if} {/if}
@@ -55,4 +55,3 @@
{/if} {/if}
</div> </div>
</section> </section>
@@ -14,9 +14,7 @@ export interface DOMProtectionCleanup {
* @param isEmailVerified - A readable store or getter function that returns whether email is verified * @param isEmailVerified - A readable store or getter function that returns whether email is verified
* @returns Cleanup object with intervals, observers, and a cleanup function * @returns Cleanup object with intervals, observers, and a cleanup function
*/ */
export function setupDOMProtection( export function setupDOMProtection(isEmailVerified: Readable<boolean> | (() => boolean | null)): DOMProtectionCleanup {
isEmailVerified: Readable<boolean> | (() => boolean | null)
): DOMProtectionCleanup {
if (!browser) { if (!browser) {
return { return {
cleanup: () => {} cleanup: () => {}
@@ -251,4 +249,3 @@ export function updateBodyClass(isEmailVerified: boolean | null) {
document.body.classList.remove('email-verified'); document.body.classList.remove('email-verified');
} }
} }
@@ -67,4 +67,3 @@
</form> </form>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -27,4 +27,3 @@
<p class="text-sm text-red-600">{$t('courses.course_card.error_message')}</p> <p class="text-sm text-red-600">{$t('courses.course_card.error_message')}</p>
</div> </div>
{/await} {/await}
@@ -25,4 +25,3 @@
</div> </div>
</div> </div>
</section> </section>
@@ -24,4 +24,3 @@
<CourseCardLoader /> <CourseCardLoader />
</div> </div>
</section> </section>
@@ -3,4 +3,3 @@ import { writable } from 'svelte/store';
export const newOrgModal = writable({ export const newOrgModal = writable({
open: false open: false
}); });
@@ -114,9 +114,8 @@
<Dialog.Content class="w-4/5 md:w-2/5"> <Dialog.Content class="w-4/5 md:w-2/5">
<Dialog.Header> <Dialog.Header>
<Dialog.Title> <Dialog.Title>
{$createQuizModal.openEdit ? $t('components.quiz.update') : $t('components.quiz.create')} {$t( {$createQuizModal.openEdit ? $t('components.quiz.update') : $t('components.quiz.create')}
'components.quiz.a_quiz' {$t('components.quiz.a_quiz')}
)}
</Dialog.Title> </Dialog.Title>
</Dialog.Header> </Dialog.Header>
<form onsubmit={preventDefault(createQuiz)}> <form onsubmit={preventDefault(createQuiz)}>
@@ -53,10 +53,7 @@
errorMessage={optionHasError(option.id, currentError.options) && $t('components.quiz.label')} errorMessage={optionHasError(option.id, currentError.options) && $t('components.quiz.label')}
/> />
<div class="absolute right-2 top-2 flex justify-end"> <div class="absolute right-2 top-2 flex justify-end">
<IconButton <IconButton onclick={() => (option.isCorrect = !option.isCorrect)} class={option.isCorrect ? 'success' : ''}>
onclick={() => (option.isCorrect = !option.isCorrect)}
class={option.isCorrect ? 'success' : ''}
>
<CircleCheckIcon size={16} filled={option.isCorrect} /> <CircleCheckIcon size={16} filled={option.isCorrect} />
</IconButton> </IconButton>
</div> </div>
@@ -114,4 +114,3 @@ export const landingPageSettings: Writable<OrgLandingPageJson> = writable({
show: true show: true
} }
}); });
@@ -38,4 +38,3 @@
</p> </p>
</div> </div>
</div> </div>
@@ -44,4 +44,3 @@
</div> </div>
</div> </div>
</div> </div>
@@ -10,4 +10,3 @@
<Skeleton class="h-40 w-[270px]" /> <Skeleton class="h-40 w-[270px]" />
</div> </div>
</div> </div>
@@ -28,22 +28,13 @@
</p> </p>
<div class="mt-8 flex items-center justify-between"> <div class="mt-8 flex items-center justify-between">
<Button <Button variant="outline" onclick={() => (open = false)} disabled={isLoading}>
variant="outline"
onclick={() => (open = false)}
disabled={isLoading}
>
{$t('delete_modal.no')} {$t('delete_modal.no')}
</Button> </Button>
<Button <Button variant="destructive" onclick={onDelete} loading={isLoading}>
variant="destructive"
onclick={onDelete}
loading={isLoading}
>
{$t('delete_modal.yes')} {$t('delete_modal.yes')}
</Button> </Button>
</div> </div>
</div> </div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -15,4 +15,3 @@
<line x1="17" y1="7" x2="7" y2="17" /><polyline points="8 7 17 7 17 16" /> <line x1="17" y1="7" x2="7" y2="17" /><polyline points="8 7 17 7 17 16" />
</svg> </svg>
</span> </span>
@@ -51,4 +51,3 @@
fill: none; /* Ensure the checkmark path itself is not filled */ fill: none; /* Ensure the checkmark path itself is not filled */
} }
</style> </style>
@@ -41,4 +41,3 @@
stroke-linejoin="bevel" stroke-linejoin="bevel"
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

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

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

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

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

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

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

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

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -122,10 +122,7 @@ function getCachedOrGenerate(
/** /**
* Extract path prefix from pathname * Extract path prefix from pathname
*/ */
function extractPathPrefix( function extractPathPrefix(pathname: string, prefixConfig: string | ((pathname: string) => string)): string {
pathname: string,
prefixConfig: string | ((pathname: string) => string)
): string {
if (typeof prefixConfig === 'function') { if (typeof prefixConfig === 'function') {
return prefixConfig(pathname); return prefixConfig(pathname);
} }
@@ -143,10 +140,7 @@ function extractPathPrefix(
/** /**
* Get base path for href generation * Get base path for href generation
*/ */
function getBasePath( function getBasePath(pathname: string, basePathConfig: string | ((pathname: string) => string)): string {
pathname: string,
basePathConfig: string | ((pathname: string) => string)
): string {
if (typeof basePathConfig === 'function') { if (typeof basePathConfig === 'function') {
return basePathConfig(pathname); return basePathConfig(pathname);
} }
@@ -228,10 +222,7 @@ async function resolveDynamicTitle(
/** /**
* Extract parameters from pathname (e.g., { slug: 'my-post' }) * Extract parameters from pathname (e.g., { slug: 'my-post' })
*/ */
function extractParamsFromPath( function extractParamsFromPath(pathname: string, parentPath: string): Record<string, string> {
pathname: string,
parentPath: string
): Record<string, string> {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
const relativePath = pathname.replace(parentPath, '').replace(/^\//, ''); const relativePath = pathname.replace(parentPath, '').replace(/^\//, '');
const segments = relativePath.split('/').filter(Boolean); const segments = relativePath.split('/').filter(Boolean);
@@ -300,16 +291,11 @@ async function generateNestedBreadcrumbs(
} }
// Handle dynamic segment // Handle dynamic segment
if (navItem.supportsDynamicSegment || if (
navItem.nestedRoutes?.some((route) => route.supportsDynamicSegment && route.path !== currentSegment)) { navItem.supportsDynamicSegment ||
const dynamicTitle = await resolveDynamicTitle( navItem.nestedRoutes?.some((route) => route.supportsDynamicSegment && route.path !== currentSegment)
currentSegment, ) {
currentPath, const dynamicTitle = await resolveDynamicTitle(currentSegment, currentPath, fullPathname, navItem, config, context);
fullPathname,
navItem,
config,
context
);
if (dynamicTitle) { if (dynamicTitle) {
breadcrumbs.push({ breadcrumbs.push({
@@ -400,9 +386,7 @@ export function generateBreadcrumbsSync(
} }
// Add parent breadcrumb // Add parent breadcrumb
const parentHref = matchedNavItem.useHashUrl const parentHref = matchedNavItem.useHashUrl ? `${basePath}${matchedNavItem.path}` : matchedNavItem.url;
? `${basePath}${matchedNavItem.path}`
: matchedNavItem.url;
breadcrumbs.push({ breadcrumbs.push({
label: matchedNavItem.breadcrumbLabel || matchedNavItem.title, label: matchedNavItem.breadcrumbLabel || matchedNavItem.title,
@@ -411,9 +395,7 @@ export function generateBreadcrumbsSync(
// Handle query params (settings tabs, etc.) // Handle query params (settings tabs, etc.)
if (matchedNavItem.items && searchParams) { if (matchedNavItem.items && searchParams) {
const subItem = matchedNavItem.items.find((sub) => const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, undefined, true));
isActive(pathWithQuery, sub.url, undefined, true)
);
if (subItem) { if (subItem) {
breadcrumbs.push({ breadcrumbs.push({
@@ -508,12 +490,7 @@ function generateNestedBreadcrumbsSync(
let dynamicTitle: string | null = null; let dynamicTitle: string | null = null;
if (config.dynamicTitleResolver) { if (config.dynamicTitleResolver) {
const result = config.dynamicTitleResolver( const result = config.dynamicTitleResolver(currentSegment, currentPath, fullPathname, context);
currentSegment,
currentPath,
fullPathname,
context
);
// Only use if it's not a promise // Only use if it's not a promise
if (typeof result === 'string') { if (typeof result === 'string') {
dynamicTitle = result; dynamicTitle = result;
@@ -560,4 +537,3 @@ export function clearBreadcrumbCache() {
breadcrumbCache.clear(); breadcrumbCache.clear();
cacheTimestamps.clear(); cacheTimestamps.clear();
} }
@@ -105,4 +105,3 @@ export function generateLmsBreadcrumbs(
return breadcrumbs; return breadcrumbs;
} }
@@ -98,4 +98,3 @@
{/if} {/if}
</Breadcrumb.List> </Breadcrumb.List>
</Breadcrumb.Root> </Breadcrumb.Root>
@@ -60,4 +60,3 @@
{/if} {/if}
</div> </div>
</header> </header>
@@ -14,4 +14,3 @@
</script> </script>
<div class="senja-embed" data-id={id} data-lazyload="false" data-spinner="false"></div> <div class="senja-embed" data-id={id} data-lazyload="false" data-spinner="false"></div>
@@ -75,4 +75,3 @@
{/each} {/each}
</Sidebar.Menu> </Sidebar.Menu>
</Sidebar.Group> </Sidebar.Group>
@@ -141,7 +141,13 @@
<!-- Your Images content here --> <!-- Your Images content here -->
<div class="h-full overflow-y-scroll"> <div class="h-full overflow-y-scroll">
<form onsubmit={preventDefault(handleSubmit)} class="mt-1 flex gap-2 pb-3"> <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" /> <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" <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 >{$t('course.navItem.landing_page.upload_widget.submit')}</button
> >
@@ -16,4 +16,3 @@
<ArrowUpIcon size={16} /> <ArrowUpIcon size={16} />
<Chip {value} /> <Chip {value} />
</Button> </Button>
@@ -2,13 +2,7 @@
* Server-side file type validation utilities for preventing malicious uploads * Server-side file type validation utilities for preventing malicious uploads
* Specifically designed to prevent SVG XSS attacks and other security issues * Specifically designed to prevent SVG XSS attacks and other security issues
*/ */
export const ALLOWED_IMAGE_TYPES = [ export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'] as const;
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp'
] as const;
export const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] as const; export const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] as const;
@@ -24,11 +24,7 @@ export async function checkUserCoursePermissions(
.single(); .single();
// Check if user is org admin // Check if user is org admin
const { data: orgData } = await supabase const { data: orgData } = await supabase.from('group').select('organization_id').eq('id', courseGroupId).single();
.from('group')
.select('organization_id')
.eq('id', courseGroupId)
.single();
let isOrgAdmin = false; let isOrgAdmin = false;
if (orgData?.organization_id) { if (orgData?.organization_id) {
@@ -3,6 +3,7 @@
## Overview ## Overview
We use **convention-based translation keys** for Zod validation errors. This approach: We use **convention-based translation keys** for Zod validation errors. This approach:
- ✅ No metadata needed - just follow the naming pattern - ✅ No metadata needed - just follow the naming pattern
- ✅ Clean shared schemas without hardcoded messages - ✅ Clean shared schemas without hardcoded messages
- ✅ Automatic fallback to generic translations - ✅ Automatic fallback to generic translations
@@ -19,7 +20,7 @@ validations.{fieldName}.{errorCode}
### Common Error Codes ### Common Error Codes
| Error Code | When It Occurs | Example Field | | Error Code | When It Occurs | Example Field |
|------------|----------------|---------------| | -------------------- | ------------------------------------------------------- | ----------------------- |
| `too_small` | String too short, number too small, array too few items | username.too_small | | `too_small` | String too short, number too small, array too few items | username.too_small |
| `too_big` | String too long, number too large, array too many items | bio.too_big | | `too_big` | String too long, number too large, array too many items | bio.too_big |
| `invalid_email` | Invalid email format | email.invalid_email | | `invalid_email` | Invalid email format | email.invalid_email |
@@ -194,7 +195,7 @@ Zod validates in order and returns the **first error** it encounters:
```typescript ```typescript
// Schema // Schema
z.string().min(3).max(20) z.string().min(3).max(20);
// Input: "" // Input: ""
// Error: too_small (stops at first rule) // Error: too_small (stops at first rule)
@@ -240,6 +241,7 @@ function handleSubmit() {
## Adding New Fields ## Adding New Fields
1. **Add to schema** (in packages/utils): 1. **Add to schema** (in packages/utils):
```typescript ```typescript
export const ZUserProfile = z.object({ export const ZUserProfile = z.object({
// ... existing fields // ... existing fields
@@ -248,6 +250,7 @@ export const ZUserProfile = z.object({
``` ```
2. **Add translations**: 2. **Add translations**:
```json ```json
{ {
"validations": { "validations": {
@@ -12,7 +12,7 @@
let { message, title } = $derived.by(() => { let { message, title } = $derived.by(() => {
const fields = { const fields = {
title: '', title: '',
message: '', message: ''
}; };
if (!type) return fields; if (!type) return fields;
@@ -42,10 +42,7 @@
}); });
function handleSupport() { function handleSupport() {
window.open( window.open('mailto:help@classroomio.com?subject=Email Verification Issue&body=Error Type: ' + type, '_blank');
'mailto:help@classroomio.com?subject=Email Verification Issue&body=Error Type: ' + type,
'_blank'
);
} }
</script> </script>
@@ -55,7 +52,7 @@
</svelte:head> </svelte:head>
<Empty <Empty
title={title} {title}
description={message} description={message}
icon={OctagonAlertIcon} icon={OctagonAlertIcon}
variant="page" variant="page"
@@ -64,9 +61,7 @@
class="h-fit! flex-none! max-h-none!" class="h-fit! flex-none! max-h-none!"
> >
{#if type === 'legacy_method_blocked'} {#if type === 'legacy_method_blocked'}
<div <div class="rounded-md border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20">
class="rounded-md border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20"
>
<div class="flex items-center"> <div class="flex items-center">
<TriangleAlertIcon class="h-5 w-5 flex-shrink-0 text-yellow-400" /> <TriangleAlertIcon class="h-5 w-5 flex-shrink-0 text-yellow-400" />
<div class="ml-3"> <div class="ml-3">
@@ -81,15 +76,10 @@
</div> </div>
{/if} {/if}
<div class="lex w-full flex-col justify-center gap-4 md:flex-row"> <div class="lex w-full flex-col justify-center gap-4 md:flex-row">
<Button <Button onclick={handleSupport} variant="ghost">
onclick={handleSupport}
variant="ghost"
>
{$t('email_verification.actions.contact_support')} {$t('email_verification.actions.contact_support')}
</Button> </Button>
<Button <Button onclick={() => goto('/')}>
onclick={() => goto('/')}
>
{$t('email_verification.actions.back_to_dashboard')} {$t('email_verification.actions.back_to_dashboard')}
</Button> </Button>
</div> </div>
+1 -2
View File
@@ -77,8 +77,7 @@
} }
console.log('path', path); console.log('path', path);
console.log('isPublicRoute', isPublicRoute(path)); console.log('isPublicRoute', isPublicRoute(path));
console.log('data', $session.data) console.log('data', $session.data);
// No need to require login for public routes // No need to require login for public routes
if (isPublicRoute(path) && path !== '/') return; if (isPublicRoute(path) && path !== '/') return;
@@ -19,9 +19,7 @@ export async function POST({ request }) {
try { try {
// Clean up expired tokens // Clean up expired tokens
const { data: cleanupResult, error } = await supabase.rpc( const { data: cleanupResult, error } = await supabase.rpc('cleanup_expired_verification_tokens');
'cleanup_expired_verification_tokens'
);
if (error) { if (error) {
console.error('Token cleanup failed:', error); console.error('Token cleanup failed:', error);
@@ -43,14 +43,11 @@ export async function GET({ url, request }) {
const recent = tokenStats.filter((token) => new Date(token.created_at) > last24Hours); const recent = tokenStats.filter((token) => new Date(token.created_at) > last24Hours);
const used = tokenStats.filter((token) => token.used_at); const used = tokenStats.filter((token) => token.used_at);
const expired = tokenStats.filter( const expired = tokenStats.filter((token) => new Date(token.expires_at) < now && !token.used_at);
(token) => new Date(token.expires_at) < now && !token.used_at
);
// Count unique IPs for anomaly detection // Count unique IPs for anomaly detection
const uniqueCreationIPs = new Set(tokenStats.map((t) => t.created_by_ip)).size; const uniqueCreationIPs = new Set(tokenStats.map((t) => t.created_by_ip)).size;
const uniqueUsageIPs = new Set(tokenStats.filter((t) => t.used_by_ip).map((t) => t.used_by_ip)) const uniqueUsageIPs = new Set(tokenStats.filter((t) => t.used_by_ip).map((t) => t.used_by_ip)).size;
.size;
return json({ return json({
success: true, success: true,
@@ -61,8 +58,7 @@ export async function GET({ url, request }) {
expired_unused: expired.length, expired_unused: expired.length,
unique_creation_ips: uniqueCreationIPs, unique_creation_ips: uniqueCreationIPs,
unique_usage_ips: uniqueUsageIPs, unique_usage_ips: uniqueUsageIPs,
success_rate: success_rate: tokenStats.length > 0 ? ((used.length / tokenStats.length) * 100).toFixed(1) : 0
tokenStats.length > 0 ? ((used.length / tokenStats.length) * 100).toFixed(1) : 0
}, },
recent_activity: recent.slice(0, 10).map((token) => ({ recent_activity: recent.slice(0, 10).map((token) => ({
created_at: token.created_at, created_at: token.created_at,
@@ -108,9 +108,7 @@ async function getCourseAnalytics(supabase: any, courseId: string): Promise<Cour
// Get student analytics // Get student analytics
const studentAnalytics = await Promise.all( const studentAnalytics = await Promise.all(
members members.filter((member) => member.role_id === 3).map((member) => getStudentOverview(supabase, courseId, member))
.filter((member) => member.role_id === 3)
.map((member) => getStudentOverview(supabase, courseId, member))
); );
analytics.students = studentAnalytics.filter((student): student is StudentOverview => student !== null); analytics.students = studentAnalytics.filter((student): student is StudentOverview => student !== null);
@@ -155,11 +153,7 @@ async function getLastLogin(supabase: any, userId: string): Promise<string | und
} }
} }
async function getStudentOverview( async function getStudentOverview(supabase: any, courseId: string, member: any): Promise<StudentOverview | null> {
supabase: any,
courseId: string,
member: any
): Promise<StudentOverview | null> {
try { try {
// Use the same service function that the working analytics/user API uses // Use the same service function that the working analytics/user API uses
const { data: courseProgressData, error: progressError } = await fetchProfileCourseProgress( const { data: courseProgressData, error: progressError } = await fetchProfileCourseProgress(
@@ -40,12 +40,7 @@ async function getCourse(supabase: any, courseId: string) {
return course; return course;
} }
async function getGroupMembers( async function getGroupMembers(supabase: any, groupId: string, isStudent: boolean, userMembership: any) {
supabase: any,
groupId: string,
isStudent: boolean,
userMembership: any
) {
if (isStudent) { if (isStudent) {
return { return {
id: groupId, id: groupId,
@@ -53,11 +48,7 @@ async function getGroupMembers(
}; };
} }
const { data: group, error } = await supabase const { data: group, error } = await supabase.from('group').select(GROUP_MEMBERS_SELECT).eq('id', groupId).single();
.from('group')
.select(GROUP_MEMBERS_SELECT)
.eq('id', groupId)
.single();
if (error) { if (error) {
throw new Error('Error fetching group members'); throw new Error('Error fetching group members');
@@ -19,11 +19,7 @@ export const POST: RequestHandler = async ({ request }) => {
const supabase = getServerSupabase(); const supabase = getServerSupabase();
// Get course group_id for permission check // Get course group_id for permission check
const { data: course } = await supabase const { data: course } = await supabase.from('course').select('group_id').eq('id', courseId).single();
.from('course')
.select('group_id')
.eq('id', courseId)
.single();
if (!course) { if (!course) {
return json({ success: false, message: 'Course not found' }, { status: 404 }); return json({ success: false, message: 'Course not found' }, { status: 404 });
@@ -41,9 +37,7 @@ export const POST: RequestHandler = async ({ request }) => {
); );
} }
const { data: exercises, error } = await supabase const { data: exercises, error } = await supabase.rpc('get_exercises').eq('course_id', courseId);
.rpc('get_exercises')
.eq('course_id', courseId);
if (error) { if (error) {
throw new Error('Error fetching exercises'); throw new Error('Error fetching exercises');
@@ -19,11 +19,7 @@ export const POST: RequestHandler = async ({ request }) => {
const supabase = getServerSupabase(); const supabase = getServerSupabase();
// Get course group_id for permission check // Get course group_id for permission check
const { data: course } = await supabase const { data: course } = await supabase.from('course').select('group_id').eq('id', courseId).single();
.from('course')
.select('group_id')
.eq('id', courseId)
.single();
if (!course) { if (!course) {
return json({ success: false, message: 'Course not found' }, { status: 404 }); return json({ success: false, message: 'Course not found' }, { status: 404 });
@@ -15,12 +15,12 @@
<Sidebar.Provider> <Sidebar.Provider>
<CourseSidebar {path} /> <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 /> <CourseHeader />
<Page.Root <Page.Root class="mx-auto flex px-4 lg:max-w-6xl">
class="mx-auto flex px-4 lg:max-w-6xl"
>
{@render children?.()} {@render children?.()}
</Page.Root> </Page.Root>
</Sidebar.Inset> </Sidebar.Inset>
@@ -130,12 +130,6 @@
</div> </div>
<div class="my-4 flex w-full items-center justify-center"> <div class="my-4 flex w-full items-center justify-center">
<Button <Button type="submit" disabled={disableSubmit || loading} loading={loading || !$profile.id}>Join Course</Button>
type="submit"
disabled={disableSubmit || loading}
loading={loading || !$profile.id}
>
Join Course
</Button>
</div> </div>
</AuthUI> </AuthUI>
@@ -10,4 +10,3 @@
<Page.Root class="w-full pb-10 md:max-w-3xl lg:mx-auto"> <Page.Root class="w-full pb-10 md:max-w-3xl lg:mx-auto">
{@render children?.()} {@render children?.()}
</Page.Root> </Page.Root>
@@ -18,4 +18,3 @@
<IntegrationsPage /> <IntegrationsPage />
{/snippet} {/snippet}
</Page.Body> </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**: **Import Pattern**:
```svelte ```svelte
// ✅ CORRECT - Use named imports from the index // ✅ CORRECT - Use named imports from the index import {(Footer, Navigation, Hero)} from '$lib/components'; // ❌ INCORRECT
import { Footer, Navigation, Hero } from '$lib/components'; - Direct file imports are not allowed // import Footer from '$lib/components/footer.svelte';
// ❌ 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. **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**: **Import Pattern**:
```svelte ```svelte
import FeedbackForm from '$lib/features/contact/feedback-form.svelte'; import FeedbackForm from '$lib/features/contact/feedback-form.svelte'; import {(htmlBody, openModal)} from '$lib/features/tools/progress/store';
import { htmlBody, openModal } from '$lib/features/tools/progress/store';
``` ```
### `/utils` - Utilities ### `/utils` - Utilities
@@ -338,7 +334,7 @@ export { default as Navigation } from './navigation.svelte';
This allows for clean, consistent imports: This allows for clean, consistent imports:
```svelte ```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`. **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", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"format": "prettier --write .",
"prepare": "svelte-kit sync" "prepare": "svelte-kit sync"
}, },
"devDependencies": { "devDependencies": {
@@ -13,7 +13,7 @@
</script> </script>
{#if !isRecommended} {#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 --> <!-- Date -->
<p class="text-sm text-slate-500">{formatDate(post.date)}</p> <p class="text-sm text-slate-500">{formatDate(post.date)}</p>
@@ -29,24 +29,16 @@
{/if} {/if}
<a href="/blog/{post.slug}" class="group"> <a href="/blog/{post.slug}" class="group">
<img <img loading="lazy" src={post.imageUrl} alt={post.title} class="w-70 h-48 rounded-md object-cover" />
loading="lazy" <p class="py-2 text-lg font-bold {isRecommended && 'h-[60px]'} line-clamp-2 group-hover:text-slate-500">
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"
>
{@html post.title} {@html post.title}
</p> </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> </a>
<div class="flex items-center justify-start gap-4 my-2"> <div class="my-2 flex items-center justify-start gap-4">
<img loading="lazy" src={post.avatar} alt="avatar" class="w-10 h-10 rounded-full" /> <img loading="lazy" src={post.avatar} alt="avatar" class="h-10 w-10 rounded-full" />
<span> <span>
<p class="font-semibold">{post.author}</p> <p class="font-semibold">{post.author}</p>
<p class="text-slate-500">{post.role}</p> <p class="text-slate-500">{post.role}</p>
@@ -26,14 +26,14 @@
} = $props(); } = $props();
</script> </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 <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-reverse'
: 'lg:flex-row'} items-center justify-between gap-8 lg:gap-28" : 'lg:flex-row'} items-center justify-between gap-8 lg:gap-28"
> >
<div class="w-[80vw] flex flex-col"> <div class="flex w-[80vw] flex-col">
<div class="my-2 lg:my-4 ml-1 flex gap-2 items-center"> <div class="my-2 ml-1 flex items-center gap-2 lg:my-4">
{#if taglineIcon === 'simplified'} {#if taglineIcon === 'simplified'}
<img width="27" height="27" loading="lazy" src={thumbsUp} alt="" class="w-7" /> <img width="27" height="27" loading="lazy" src={thumbsUp} alt="" class="w-7" />
{:else if taglineIcon === 'flexible'} {:else if taglineIcon === 'flexible'}
@@ -46,16 +46,16 @@
{:else if taglineIcon === 'productivity'} {:else if taglineIcon === 'productivity'}
<img width="27" height="27" loading="lazy" src={robotArm} alt="" class="w-7" /> <img width="27" height="27" loading="lazy" src={robotArm} alt="" class="w-7" />
{/if} {/if}
<p class="font-medium text-base">{tagline}</p> <p class="text-base font-medium">{tagline}</p>
</div> </div>
<h2 class="text-3xl mb-3 lg:mb-6">{title}</h2> <h2 class="mb-3 text-3xl lg:mb-6">{title}</h2>
<p class="leading-8 text-lg text-gray-500"> <p class="text-lg leading-8 text-gray-500">
{description} {description}
</p> </p>
{#if more} {#if more}
<br /> <br />
<p class="leading-8 text-lg text-gray-500"> <p class="text-lg leading-8 text-gray-500">
{@render more?.()} {@render more?.()}
</p> </p>
{/if} {/if}
@@ -64,7 +64,7 @@
<video <video
width="100%" width="100%"
height="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 autoplay
loop loop
muted muted
@@ -3,8 +3,8 @@
import { PUBLIC_ENABLE_STATS } from '$env/static/public'; import { PUBLIC_ENABLE_STATS } from '$env/static/public';
</script> </script>
<section class="w-full py-32 border-b border-x-0 border-t-0 border-gray-200"> <section class="w-full border-x-0 border-b border-t-0 border-gray-200 py-32">
<div class="justify-center flex text-center flex-wrap gap-x-12 gap-y-16"> <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="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="mx-auto flex max-w-lg flex-col items-center gap-2">
<div class="flex-none"> <div class="flex-none">
@@ -19,19 +19,16 @@
/> />
</div> </div>
</div> </div>
<div class="text-base leading-snug text-center"> <div class="text-center text-base leading-snug">
<span class="font-bold opacity-50 text-4xl">“</span> <span class="text-4xl font-bold opacity-50">“</span>
<span class="text-gray-800" style="font-size: 17px;" <span class="text-gray-800" style="font-size: 17px;"
>ClassroomIO really helped me bootstrap my program. The dashboard is extremely >ClassroomIO really helped me bootstrap my program. The dashboard is extremely intuitive. I could navigate
intuitive. I could navigate most of it without any help / support. Coming from framer, I most of it without any help / support. Coming from framer, I can honestly say that I am extremely greatful
can honestly say that I am extremely greatful that I found ClassroomIO.</span that I found ClassroomIO.</span
> <span class="font-bold opacity-50 inline-block text-4xl">”</span> > <span class="inline-block text-4xl font-bold opacity-50">”</span>
</div> </div>
<div class="mx-auto flex items-center gap-2 justify-center"> <div class="mx-auto flex items-center justify-center gap-2">
<div <div class="flex max-w-[200px] flex-none flex-col overflow-hidden text-left" style="color: var(--clr-text);">
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"> <div class="text-center text-sm font-medium opacity-90">
Pranav Singhal <div class="block text-xs font-medium opacity-70 hover:opacity-50"> 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> Founder at <a href="https://www.dblocked.io/" target="_blank">DBlockEd</a>
@@ -80,26 +77,18 @@
<!-- body section 5 --> <!-- body section 5 -->
{#if PUBLIC_ENABLE_STATS} {#if PUBLIC_ENABLE_STATS}
<div <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"> <div class="w-full">
<h2 <h2 class="order-first w-full text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl">1 thousand</h2>
class="w-full order-first 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> <p class="text-base leading-7 text-gray-600">Feedback collected</p>
</div> </div>
<div class="w-full"> <div class="w-full">
<h2 class="order-first text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl"> <h2 class="order-first text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl">5 thousand</h2>
5 thousand
</h2>
<p class="text-base leading-7 text-gray-600">Documents processed</p> <p class="text-base leading-7 text-gray-600">Documents processed</p>
</div> </div>
<div class="w-full"> <div class="w-full">
<h2 class="order-first text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl"> <h2 class="order-first text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl">1 million</h2>
1 million
</h2>
<p class="text-base leading-7 text-gray-600">Page views recorded</p> <p class="text-base leading-7 text-gray-600">Page views recorded</p>
</div> </div>
</div> </div>
@@ -8,10 +8,5 @@
</script> </script>
<figure> <figure>
<img <img {src} {alt} loading="lazy" class="my-2 h-full w-full max-w-full rounded-lg object-cover md:min-h-[400px]" />
{src}
{alt}
loading="lazy"
class="max-w-full w-full h-full md:min-h-[400px] object-cover my-2 rounded-lg"
/>
</figure> </figure>
+5 -7
View File
@@ -6,19 +6,17 @@
<div class="mb-20 mt-10 flex flex-col items-center justify-center"> <div class="mb-20 mt-10 flex flex-col items-center justify-center">
<header class="text-center"> <header class="text-center">
<h1 class="mt-2 text-xl md:text-4xl"> <h1 class="mt-2 text-xl md:text-4xl">Questions & Answers</h1>
Questions & Answers
</h1>
<p <p
class="mt-4 text-sm md:text-lg font-normal tracking-normal ui:text-muted-foreground lg:px-[20%] lg:text-xl lg:tracking-wide" class="ui:text-muted-foreground mt-4 text-sm font-normal tracking-normal md:text-lg lg:px-[20%] lg:text-xl lg:tracking-wide"
> >
Can't find the answer you're looking for? Can't find the answer you're looking for?
<a class="font-semibold text-blue-700" href="mailto:help@classroomio.com" target="_blank" <a class="font-semibold text-blue-700" href="mailto:help@classroomio.com" target="_blank">Shoot us an email</a> and
>Shoot us an email</a> and we'll get back to you ASAP. we'll get back to you ASAP.
</p> </p>
</header> </header>
<div class="mt-[20%] lg:m-0 max-w-3xl w-full mx-auto px-2"> <div class="mx-auto mt-[20%] w-full max-w-3xl px-2 lg:m-0">
<Accordion.Root type="single" class="w-full"> <Accordion.Root type="single" class="w-full">
{#each faqs as faq, index} {#each faqs as faq, index}
<Accordion.Item value="faq-{index}"> <Accordion.Item value="faq-{index}">
+8 -11
View File
@@ -26,17 +26,16 @@
/></a /></a
> --> > -->
<div class="mt-5 flex items-center gap-3 lg:mt-10 lg:gap-7"> <div class="mt-5 flex items-center gap-3 lg:mt-10 lg:gap-7">
<Button href="https://www.facebook.com/classroomiohq" target="_blank" variant="ghost" aria-label="Facebook Link"> <Button
href="https://www.facebook.com/classroomiohq"
target="_blank"
variant="ghost"
aria-label="Facebook Link"
>
<Facebook /> <Facebook />
</Button> </Button>
<Button href="https://twitter.com/classroomio" target="_blank" variant="ghost" aria-label="Twitter Link"> <Button href="https://twitter.com/classroomio" target="_blank" variant="ghost" aria-label="Twitter Link">
<img <img width="20" height="20" loading="lazy" src="/twitter.svg" alt="" />
width="20"
height="20"
loading="lazy"
src="/twitter.svg"
alt=""
/>
</Button> </Button>
<Button href="https://www.youtube.com/@ClassroomIO" target="_blank" variant="ghost" aria-label="Youtube Link"> <Button href="https://www.youtube.com/@ClassroomIO" target="_blank" variant="ghost" aria-label="Youtube Link">
<Youtube /> <Youtube />
@@ -130,9 +129,7 @@
</div> </div>
</nav> </nav>
</div> </div>
<footer <footer class="border-t py-3 text-center text-xs font-light leading-5 text-gray-500 lg:text-left lg:text-base">
class="border-t py-3 text-center text-xs font-light leading-5 text-gray-500 lg:text-left lg:text-base"
>
<p>All rights reserved.</p> <p>All rights reserved.</p>
<p class="flex items-center justify-center"> <p class="flex items-center justify-center">
ClassroomIO © {new Date().getFullYear()} by ClassroomIO © {new Date().getFullYear()} by
+6 -13
View File
@@ -114,9 +114,9 @@
<span>from one Platform</span> <span>from one Platform</span>
</div> </div>
<p class="mx-auto mt-10 px-4 ui:text-muted-foreground md:text-lg lg:mt-6 lg:w-[42%] lg:p-0"> <p class="ui:text-muted-foreground mx-auto mt-10 px-4 md:text-lg lg:mt-6 lg:w-[42%] lg:p-0">
ClassroomIO is an open source learning management system for companies. We give you a suite ClassroomIO is an open source learning management system for companies. We give you a suite of tools to help you
of tools to help you achieve your training, onboarding and certification goals. achieve your training, onboarding and certification goals.
</p> </p>
</div> </div>
@@ -169,12 +169,8 @@
</div> </div>
<div class="my-10 flex flex-col items-center justify-center gap-4 md:flex-row"> <div class="my-10 flex flex-col items-center justify-center gap-4 md:flex-row">
<Button variant="outline" href="/demo" target="_blank" rel="noopener noreferrer nofollow"> <Button variant="outline" href="/demo" target="_blank" rel="noopener noreferrer nofollow">Book a demo</Button>
Book a demo <Button href="/signup" target="_blank" rel="noopener noreferrer nofollow">Sign Up for Free</Button>
</Button>
<Button href="/signup" target="_blank" rel="noopener noreferrer nofollow">
Sign Up for Free
</Button>
<!-- <a <!-- <a
class="font-medium lg:font-semibold text-sm md:text-base w-11/12 md:w-fit border px-6 py-3 lg:px-10 lg:py-5 rounded-md" class="font-medium lg:font-semibold text-sm md:text-base w-11/12 md:w-fit border px-6 py-3 lg:px-10 lg:py-5 rounded-md"
href="/github" href="/github"
@@ -195,10 +191,7 @@
/> />
</div> --> </div> -->
<div <div style="position: relative; padding-bottom: calc(50% + 42px);" class="mx-auto w-[85%] lg:w-[70%]">
style="position: relative; padding-bottom: calc(50% + 42px);"
class="mx-auto w-[85%] lg:w-[70%]"
>
<iframe <iframe
title="ClassroomIO Live Demo" title="ClassroomIO Live Demo"
src="https://app.supademo.com/embed/cluw8chyy06nhq2s9m697a680" src="https://app.supademo.com/embed/cluw8chyy06nhq2s9m697a680"
+2 -4
View File
@@ -13,10 +13,8 @@
{#if open} {#if open}
<div class="relative flex items-center justify-center"> <div class="relative flex items-center justify-center">
<!-- black background --> <!-- black background -->
<div class="fixed z-[3000] top-0 left-0 w-full h-full bg-black opacity-[0.7]"></div> <div class="fixed left-0 top-0 z-[3000] h-full w-full bg-black opacity-[0.7]"></div>
<div <div class="fixed z-[3001] {className} mx-auto w-full rounded-md bg-white px-7 py-6 md:w-[30%] md:min-w-[500px]">
class="fixed z-[3001] {className} w-full md:w-[30%] md:min-w-[500px] bg-white rounded-md mx-auto py-6 px-7"
>
{@render children?.()} {@render children?.()}
</div> </div>
</div> </div>
@@ -11,15 +11,13 @@
{ {
icon: globe, icon: globe,
title: 'Customizable Landing Page', title: 'Customizable Landing Page',
description: description: 'A minimalistic landing page template out of the box to share your courses with the world',
'A minimalistic landing page template out of the box to share your courses with the world',
image: '/landing-page-builder.webp' image: '/landing-page-builder.webp'
}, },
{ {
icon: team, icon: team,
title: 'Team management', title: 'Team management',
description: description: 'Extend invitations and manage your teaching institution seamlessly through ClassroomIO.',
'Extend invitations and manage your teaching institution seamlessly through ClassroomIO.',
image: '/team-management.png' image: '/team-management.png'
}, },
// { // {
@@ -49,39 +47,28 @@
</script> </script>
<!-- More Features --> <!-- More Features -->
<section id="morefeatures" class="py-[10%] px-3 lg:px-0 bg-gray-50"> <section id="morefeatures" class="bg-gray-50 px-3 py-[10%] lg:px-0">
<div class="mx-0 lg:mx-[12%]"> <div class="mx-0 lg:mx-[12%]">
<div class="w-full lg:w-4/5 mx-4 lg:mx-0 mb-14"> <div class="mx-4 mb-14 w-full lg:mx-0 lg:w-4/5">
<h2 class="mt-2 text-3xl tracking-tight"> <h2 class="mt-2 text-3xl tracking-tight">There is More...</h2>
There is More... <p class="mt-4 w-[94%] text-lg text-gray-500 lg:w-4/5">
</h2>
<p class="mt-4 text-lg text-gray-500 w-[94%] lg:w-4/5">
ClassroomIO is packed with useful features while we try to make it easy to use. ClassroomIO is packed with useful features while we try to make it easy to use.
</p> </p>
</div> </div>
<div class="flex flex-col-reverse lg:flex-row gap-3 md:gap-10" id="image-container"> <div class="flex flex-col-reverse gap-3 md:gap-10 lg:flex-row" id="image-container">
<div <div class="mt-5 flex w-[95%] overflow-auto p-3 lg:mt-0 lg:block lg:w-[45%] lg:overflow-hidden">
class="w-[95%] p-3 mt-5 overflow-auto lg:overflow-hidden lg:mt-0 lg:w-[45%] flex lg:block"
>
{#each moreFeatures as moreFeature, i} {#each moreFeatures as moreFeature, i}
<button <button
class="p-4 rounded-md {i === selected && class="rounded-md p-4 {i === selected &&
'selected'} mb-3 text-start min-w-[75vw] lg:min-w-[unset] cursor-pointer" 'selected'} mb-3 min-w-[75vw] cursor-pointer text-start lg:min-w-[unset]"
onclick={() => { onclick={() => {
selected = i; selected = i;
clearInterval(interval); clearInterval(interval);
}} }}
> >
<div class="flex gap-1 items-center"> <div class="flex items-center gap-1">
<img <img loading="lazy" width="32" height="32" src={moreFeature.icon} alt="" class="w-8" />
loading="lazy"
width="32"
height="32"
src={moreFeature.icon}
alt=""
class="w-8"
/>
<p class="text-lg"> <p class="text-lg">
{moreFeature.title} {moreFeature.title}
</p> </p>
@@ -100,7 +87,7 @@
loading="lazy" loading="lazy"
id="image" id="image"
in:fly={{ x: 100, duration: 800 }} in:fly={{ x: 100, duration: 800 }}
class="w-full flex flex-col items-center" class="flex w-full flex-col items-center"
src={moreFeatures[selected].image} src={moreFeatures[selected].image}
alt={moreFeatures[selected].title} alt={moreFeatures[selected].title}
/> />
@@ -112,11 +99,6 @@
<style> <style>
#morefeatures .selected { #morefeatures .selected {
background-image: linear-gradient( background-image: linear-gradient(45deg, rgb(253, 239, 132), rgb(247, 198, 169), rgb(21, 186, 196));
45deg,
rgb(253, 239, 132),
rgb(247, 198, 169),
rgb(21, 186, 196)
);
} }
</style> </style>
@@ -16,7 +16,6 @@
import LibraryBigIcon from '@lucide/svelte/icons/library-big'; import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import { cn } from '@cio/ui/tools'; import { cn } from '@cio/ui/tools';
let { stars }: { stars: number } = $props(); let { stars }: { stars: number } = $props();
type ListItemProps = HTMLAttributes<HTMLAnchorElement> & { type ListItemProps = HTMLAttributes<HTMLAnchorElement> & {
@@ -126,7 +125,7 @@
<p class="text-sm"> <p class="text-sm">
{title} {title}
</p> </p>
<p class="mt-1 text-xs ui:text-muted-foreground"> <p class="ui:text-muted-foreground mt-1 text-xs">
{subtitle} {subtitle}
</p> </p>
</div> </div>
@@ -136,8 +135,8 @@
</li> </li>
{/snippet} {/snippet}
<section class="z-50 fixed top-0 w-full bg-white border-b"> <section class="fixed top-0 z-50 w-full border-b bg-white">
<div class="mx-auto flex w-full items-center justify-between gap-20 py-1 lg:w-[90%] lg:px-0 px-4"> <div class="mx-auto flex w-full items-center justify-between gap-20 px-4 py-1 lg:w-[90%] lg:px-0">
<a href="/" class="w-[10%]"> <a href="/" class="w-[10%]">
<div class="flex w-full items-center"> <div class="flex w-full items-center">
<img loading="lazy" width="28" height="28" src="/logo-512.png" alt="classroomio logo" class="w-6 md:w-7" /> <img loading="lazy" width="28" height="28" src="/logo-512.png" alt="classroomio logo" class="w-6 md:w-7" />
@@ -151,7 +150,7 @@
<NavigationMenu.Item> <NavigationMenu.Item>
<NavigationMenu.Trigger <NavigationMenu.Trigger
class={cn( class={cn(
"flex cursor-pointer items-center rounded-md px-4 py-2 text-sm font-medium text-gray-800 transition-all duration-200 hover:bg-gray-100 data-[state=open]:bg-gray-100", 'flex cursor-pointer items-center rounded-md px-4 py-2 text-sm font-medium text-gray-800 transition-all duration-200 hover:bg-gray-100 data-[state=open]:bg-gray-100',
isSolutionsActive && 'bg-gray-100!' isSolutionsActive && 'bg-gray-100!'
)} )}
> >
@@ -173,7 +172,10 @@
<NavigationMenu.Item> <NavigationMenu.Item>
<NavigationMenu.Trigger <NavigationMenu.Trigger
class={cn("flex cursor-pointer items-center rounded-md px-4 py-2 text-sm font-medium text-gray-800 transition-all duration-200 hover:bg-gray-100 data-[state=open]:bg-gray-100", activeLink.startsWith('/tools') && 'bg-gray-100!')} class={cn(
'flex cursor-pointer items-center rounded-md px-4 py-2 text-sm font-medium text-gray-800 transition-all duration-200 hover:bg-gray-100 data-[state=open]:bg-gray-100',
activeLink.startsWith('/tools') && 'bg-gray-100!'
)}
> >
<a href="/tools" class="no-underline"> Free Tools </a> <a href="/tools" class="no-underline"> Free Tools </a>
</NavigationMenu.Trigger> </NavigationMenu.Trigger>
@@ -278,7 +280,7 @@
<li class="w-full cursor-pointer text-sm font-semibold text-gray-800 md:text-lg"> <li class="w-full cursor-pointer text-sm font-semibold text-gray-800 md:text-lg">
<button <button
class={cn( class={cn(
"flex w-full items-center justify-between rounded-lg px-4 py-3 transition-all duration-200 hover:bg-gray-100", 'flex w-full items-center justify-between rounded-lg px-4 py-3 transition-all duration-200 hover:bg-gray-100',
isSolutionsActive && 'bg-gray-100' isSolutionsActive && 'bg-gray-100'
)} )}
onclick={handleShowDrawer} onclick={handleShowDrawer}
@@ -313,7 +315,7 @@
</a> </a>
<a <a
class={cn( class={cn(
"w-full cursor-pointer rounded-md px-4 py-3 text-sm font-semibold text-gray-800 transition-all duration-200 hover:bg-gray-100 md:text-lg", 'w-full cursor-pointer rounded-md px-4 py-3 text-sm font-semibold text-gray-800 transition-all duration-200 hover:bg-gray-100 md:text-lg',
activeLink.startsWith('/morefeatures') && 'bg-gray-100' activeLink.startsWith('/morefeatures') && 'bg-gray-100'
)} )}
onclick={() => { onclick={() => {
@@ -325,7 +327,7 @@
</a> </a>
<a <a
class={cn( class={cn(
"w-full cursor-pointer rounded-xl px-4 py-3 text-sm font-semibold text-gray-800 transition-all duration-200 hover:bg-gray-100 md:text-lg", 'w-full cursor-pointer rounded-xl px-4 py-3 text-sm font-semibold text-gray-800 transition-all duration-200 hover:bg-gray-100 md:text-lg',
activeLink.startsWith('/pricing') && 'bg-gray-100' activeLink.startsWith('/pricing') && 'bg-gray-100'
)} )}
onclick={() => { onclick={() => {
@@ -337,7 +339,7 @@
</a> </a>
<a <a
class={cn( class={cn(
"w-full cursor-pointer rounded-md px-4 py-3 text-sm font-semibold text-gray-800 transition-all duration-200 hover:bg-gray-100 md:text-lg", 'w-full cursor-pointer rounded-md px-4 py-3 text-sm font-semibold text-gray-800 transition-all duration-200 hover:bg-gray-100 md:text-lg',
activeHash.includes('morefeatures') && 'bg-gray-100' activeHash.includes('morefeatures') && 'bg-gray-100'
)} )}
onclick={() => { onclick={() => {
@@ -22,18 +22,13 @@
} }
</script> </script>
<div class="{className} min-h-[80vh] w-screen flex items-center justify-center"> <div class="{className} flex min-h-[80vh] w-screen items-center justify-center">
<Empty <Empty
title={isOrg ? 'Organization doesn\'t exist!' : 'Page not found'} title={isOrg ? "Organization doesn't exist!" : 'Page not found'}
description="The page you are looking for doesn't exist or has been moved. Please go back to the homepage." description="The page you are looking for doesn't exist or has been moved. Please go back to the homepage."
icon={SearchXIcon} icon={SearchXIcon}
variant="page" variant="page"
> >
<Button <Button onclick={handleClick}>Go Home</Button>
onclick={handleClick}
>
Go Home
</Button>
</Empty> </Empty>
</div> </div>
@@ -13,26 +13,19 @@
</script> </script>
<div <div
class="floating grad relative mx-auto my-5 flex h-[61vh] w-full flex-col items-center justify-center px-6 py-12 text-center shadow-2xl lg:h-[50vh] lg:w-[70%] lg:rounded-3xl max-w-4xl space-y-8" class="floating grad relative mx-auto my-5 flex h-[61vh] w-full max-w-4xl flex-col items-center justify-center space-y-8 px-6 py-12 text-center shadow-2xl lg:h-[50vh] lg:w-[70%] lg:rounded-3xl"
> >
<div> <div>
<h3 <h3 class="font-display mt-3 max-w-2xl text-xl tracking-tight text-white lg:mt-2 lg:text-4xl">
class="font-display mt-3 text-xl tracking-tight text-white lg:mt-2 lg:text-4xl max-w-2xl"
>
{header} {header}
</h3> </h3>
<p class="mt-6 text-md text-gray-300"> <p class="text-md mt-6 text-gray-300">
{subText} {subText}
</p> </p>
</div> </div>
{#if demo} {#if demo}
<Button <Button variant="ghost" data-cal-config="'layout':'month_view'" data-cal-link="classroomio/demo" class="bg-white!">
variant="ghost"
data-cal-config="'layout':'month_view'"
data-cal-link="classroomio/demo"
class="bg-white!"
>
{btnLabel} {btnLabel}
</Button> </Button>
{:else} {:else}
@@ -2,24 +2,20 @@
import { Badge } from '@cio/ui/base/badge'; import { Badge } from '@cio/ui/base/badge';
</script> </script>
<section id="student-dashboard" class="text-center text-white py-[10%] px-3 lg:px-0 bg-black"> <section id="student-dashboard" class="bg-black px-3 py-[10%] text-center text-white lg:px-0">
<Badge class="py-2! px-4! underline text-sm!">yourorg.classroomio.com</Badge> <Badge class="py-2! px-4! text-sm! underline">yourorg.classroomio.com</Badge>
<h1 <h1 class="mx-auto flex flex-col items-center gap-4 text-3xl font-normal leading-[1.5] md:text-4xl lg:text-6xl">
class="mx-auto text-3xl md:text-4xl lg:text-6xl font-normal leading-[1.5] flex flex-col items-center gap-4"
>
Your Student Dashboard Your Student Dashboard
</h1> </h1>
<p <p class="mx-auto mt-10 px-4 text-base leading-6 tracking-wide text-gray-400 lg:mt-6 lg:w-2/4 lg:p-0">
class="mx-auto mt-10 lg:mt-6 px-4 lg:p-0 text-base tracking-wide leading-6 text-gray-400 lg:w-2/4" Once you create an account, you get a dedicated dashboard where your students can access all their courses,
> assignments, and more.
Once you create an account, you get a dedicated dashboard where your students can access all
their courses, assignments, and more.
</p> </p>
<div class="relative my-5 md:my-10"> <div class="relative my-5 md:my-10">
<video <video
class="floating w-[85%] lg:w-[70%] mx-auto rounded-lg shadow-lg" class="floating mx-auto w-[85%] rounded-lg shadow-lg lg:w-[70%]"
autoplay autoplay
loop loop
muted muted

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