feat: lint codebase and run precommit hook
@@ -6,3 +6,11 @@ _template
|
||||
supabase
|
||||
static
|
||||
dist
|
||||
docker
|
||||
|
||||
**/course-app
|
||||
apps/docs
|
||||
apps/api/static
|
||||
|
||||
pnpm-lock.yaml
|
||||
*.md
|
||||
@@ -0,0 +1 @@
|
||||
static
|
||||
@@ -56,9 +56,9 @@ GET /lessons (which course?)
|
||||
|
||||
```typescript
|
||||
// ✅ OK - Top-level resources
|
||||
GET /account
|
||||
PUT /account/user
|
||||
GET /account/subscription
|
||||
GET / account;
|
||||
PUT / account / user;
|
||||
GET / account / subscription;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -68,7 +68,7 @@ GET /account/subscription
|
||||
Use HTTP methods correctly for their intended purpose:
|
||||
|
||||
| Method | Purpose | Idempotent | Response Body |
|
||||
|--------|---------|------------|---------------|
|
||||
| -------- | ----------------------- | ---------- | ------------------- |
|
||||
| `GET` | Retrieve resource(s) | ✅ Yes | Always |
|
||||
| `POST` | Create new resource | ❌ No | Created resource |
|
||||
| `PUT` | Replace entire resource | ✅ Yes | Updated resource |
|
||||
@@ -112,11 +112,13 @@ POST /lessons?courseId=123 // Relationship not obvious
|
||||
```
|
||||
|
||||
**When to nest:**
|
||||
|
||||
- ✅ Child resource only exists within parent context
|
||||
- ✅ Child resource ID is unique within parent (e.g., `lessonId` within `courseId`)
|
||||
- ✅ Operations typically scoped to parent
|
||||
|
||||
**When NOT to nest:**
|
||||
|
||||
- ❌ Resource can exist independently
|
||||
- ❌ Resource ID is globally unique
|
||||
- ❌ Operations span multiple parents
|
||||
@@ -154,6 +156,7 @@ GET /get-courses // Redundant verb
|
||||
**Pattern:** `POST /resource/:id/action`
|
||||
|
||||
**When to use:**
|
||||
|
||||
- ✅ State transitions (publish, unpublish, activate, cancel)
|
||||
- ✅ Side effects (clone, duplicate, export)
|
||||
- ✅ Complex operations that don't fit CRUD
|
||||
@@ -182,7 +185,7 @@ GET /courses/search/javascript
|
||||
**Common query parameter patterns:**
|
||||
|
||||
| Purpose | Parameter | Example |
|
||||
|---------|-----------|---------|
|
||||
| ------------- | ------------------------- | ---------------------------------- |
|
||||
| Filtering | `status`, `type`, `role` | `?status=active&type=course` |
|
||||
| Search | `q`, `search` | `?q=javascript` |
|
||||
| Pagination | `page`, `limit`, `offset` | `?page=1&limit=20` |
|
||||
@@ -457,16 +460,16 @@ DELETE /courses/:id
|
||||
|
||||
```typescript
|
||||
// ❌ BAD
|
||||
GET /course
|
||||
POST /courses
|
||||
GET /organization
|
||||
POST /organizations
|
||||
GET / course;
|
||||
POST / courses;
|
||||
GET / organization;
|
||||
POST / organizations;
|
||||
|
||||
// ✅ GOOD
|
||||
GET /courses
|
||||
POST /courses
|
||||
GET /organizations
|
||||
POST /organizations
|
||||
GET / courses;
|
||||
POST / courses;
|
||||
GET / organizations;
|
||||
POST / organizations;
|
||||
```
|
||||
|
||||
### ❌ Don't: Use query parameters for required identifiers
|
||||
@@ -540,4 +543,3 @@ When creating a new route, ensure:
|
||||
8. **Organized by domain** in code structure
|
||||
|
||||
Following these conventions ensures your API is **predictable**, **maintainable**, and **developer-friendly**.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"test": "vitest NODE_ENV=test",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"format": "prettier --write .",
|
||||
"upload:openapi": "tsx scripts/upload-openapi-spec.ts"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -11,6 +11,7 @@ pnpm run upload:openapi
|
||||
```
|
||||
|
||||
### Direct script usage
|
||||
|
||||
```bash
|
||||
tsx scripts/upload-openapi-spec.ts
|
||||
```
|
||||
@@ -49,6 +50,7 @@ This script is integrated with a GitHub Action that automatically generates and
|
||||
### Automatic Workflow
|
||||
|
||||
The GitHub Action runs on:
|
||||
|
||||
- **Push to main** - Automatically uploads to R2
|
||||
- **Pull Requests** - Generates spec for review (no upload)
|
||||
- **Manual dispatch** - Allows manual triggering with custom parameters
|
||||
|
||||
@@ -24,11 +24,7 @@ class OpenAPISpecGenerator {
|
||||
}
|
||||
|
||||
private initializeS3Client() {
|
||||
if (
|
||||
env.CLOUDFLARE_ACCESS_KEY &&
|
||||
env.CLOUDFLARE_SECRET_ACCESS_KEY &&
|
||||
env.CLOUDFLARE_ACCOUNT_ID
|
||||
) {
|
||||
if (env.CLOUDFLARE_ACCESS_KEY && env.CLOUDFLARE_SECRET_ACCESS_KEY && env.CLOUDFLARE_ACCOUNT_ID) {
|
||||
this.s3Client = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
|
||||
@@ -36,7 +36,6 @@ export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareH
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// Generate rate limit key
|
||||
const key = opts.keyGenerator(c);
|
||||
|
||||
|
||||
@@ -13,19 +13,13 @@ export class RedisRateLimiter {
|
||||
private windowMs: number;
|
||||
private maxRequests: number;
|
||||
|
||||
constructor(
|
||||
redis: Redis,
|
||||
windowMs: number = DEFAULT_WINDOW_MS,
|
||||
maxRequests: number = DEFAULT_MAX_REQUESTS
|
||||
) {
|
||||
constructor(redis: Redis, windowMs: number = DEFAULT_WINDOW_MS, maxRequests: number = DEFAULT_MAX_REQUESTS) {
|
||||
this.redis = redis;
|
||||
this.windowMs = windowMs;
|
||||
this.maxRequests = maxRequests;
|
||||
}
|
||||
|
||||
async isAllowed(
|
||||
key: string
|
||||
): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
|
||||
async isAllowed(key: string): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
|
||||
const now = Date.now();
|
||||
const windowStart = now - this.windowMs;
|
||||
const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`;
|
||||
|
||||
@@ -154,20 +154,23 @@
|
||||
<section>
|
||||
<h1 class="text-xl font-bold my-1">Extra Resources</h1>
|
||||
<div class="flex mt-2 mb-1">
|
||||
<p class="m-0 mr-2">Slide Link:</p> <a href=https://youtu.be/dyLaKhePzj8 style="color: blue"
|
||||
<p class="m-0 mr-2">Slide Link:</p> <a href="https://youtu.be/dyLaKhePzj8" style="color: blue"
|
||||
class="underline">Open Here</a>
|
||||
</div>
|
||||
|
||||
<p class="m-0">Video Link:</p>
|
||||
<div class="m-0">
|
||||
<ol class="m-0">
|
||||
|
||||
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
|
||||
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
|
||||
|
||||
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
|
||||
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
|
||||
|
||||
<li>
|
||||
<a href="https://youtu.be/0fYi8SGA20k" style="color: blue" class="underline">
|
||||
https://youtu.be/0fYi8SGA20k
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://youtu.be/0fYi8SGA20k" style="color: blue" class="underline">
|
||||
https://youtu.be/0fYi8SGA20k
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -158,20 +158,25 @@
|
||||
<section>
|
||||
<h1 class="text-xl font-bold my-1">Extra Resources</h1>
|
||||
<div class="flex mt-2 mb-1">
|
||||
<p class="m-0 mr-2">Slide Link:</p> <a href=https://youtu.be/dyLaKhePzj8 style="color: blue"
|
||||
class="underline">Open Here</a>
|
||||
<p class="m-0 mr-2">Slide Link:</p>
|
||||
<a href="https://youtu.be/dyLaKhePzj8" style="color: blue" class="underline">
|
||||
Open Here
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="m-0">Video Link:</p>
|
||||
<div class="m-0">
|
||||
<ol class="m-0">
|
||||
|
||||
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
|
||||
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
|
||||
|
||||
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
|
||||
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
|
||||
|
||||
<li>
|
||||
<a href="https://youtu.be/0fYi8SGA20k" style="color: blue" class="underline">
|
||||
https://youtu.be/0fYi8SGA20k
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://youtu.be/0fYi8SGA20k" style="color: blue" class="underline">
|
||||
https://youtu.be/0fYi8SGA20k
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"format": "echo 'disabled'",
|
||||
"format:run": "prettier --write .",
|
||||
"lint": "prettier --check .",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@@ -17,8 +16,10 @@
|
||||
<meta property="og:url" content="https://www.courseapp.oncws.com/" />
|
||||
<meta property="og:image" itemprop="image" content="https://cdn.courseapp.oncws.com/courseapp-og.png" />
|
||||
<meta property="og:title" content="Courseapp by ClassroomIO – Create your SAAS Academy in Minutes" />
|
||||
<meta property="og:description"
|
||||
content="Series of templates available for you to clone, customise and launch your SAAS Academy. Scale your customer education effort today." />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Series of templates available for you to clone, customise and launch your SAAS Academy. Scale your customer education effort today."
|
||||
/>
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
@@ -30,18 +31,28 @@
|
||||
<meta property="twitter:domain" content="courseapp.oncws.com" />
|
||||
<meta property="twitter:url" content="https://www.courseapp.oncws.com/" />
|
||||
<meta name="twitter:title" content="Courseapp by ClassroomIO – Create your SAAS Academy in Minutes" />
|
||||
<meta name="twitter:description"
|
||||
content="Series of templates available for you to clone, customise and launch your SAAS Academy. Scale your customer education effort today." />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Series of templates available for you to clone, customise and launch your SAAS Academy. Scale your customer education effort today."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://cdn.courseapp.oncws.com/courseapp-og.png" />
|
||||
|
||||
%sveltekit.head%
|
||||
|
||||
<script>(function (w, r) { w._rwq = r; w[r] = w[r] || function () { (w[r].q = w[r].q || []).push(arguments) } })(window, 'rewardful');</script>
|
||||
<script async src='https://r.wdfl.co/rw.js' data-rewardful='d216d5'></script>
|
||||
</head>
|
||||
<script>
|
||||
(function (w, r) {
|
||||
w._rwq = r;
|
||||
w[r] =
|
||||
w[r] ||
|
||||
function () {
|
||||
(w[r].q = w[r].q || []).push(arguments);
|
||||
};
|
||||
})(window, 'rewardful');
|
||||
</script>
|
||||
<script async src="https://r.wdfl.co/rw.js" data-rewardful="d216d5"></script>
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,35 +1,33 @@
|
||||
import type { Button as ButtonPrimitive } from "bits-ui";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
import Root from "./button.svelte";
|
||||
import type { Button as ButtonPrimitive } from 'bits-ui';
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
import Root from './button.svelte';
|
||||
|
||||
const buttonVariants = tv({
|
||||
base: "focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50",
|
||||
base: 'focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50',
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
|
||||
outline:
|
||||
"border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
|
||||
outline: 'border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
});
|
||||
|
||||
type Variant = VariantProps<typeof buttonVariants>["variant"];
|
||||
type Size = VariantProps<typeof buttonVariants>["size"];
|
||||
type Variant = VariantProps<typeof buttonVariants>['variant'];
|
||||
type Size = VariantProps<typeof buttonVariants>['size'];
|
||||
|
||||
type Props = ButtonPrimitive.Props & {
|
||||
variant?: Variant;
|
||||
@@ -46,5 +44,5 @@ export {
|
||||
Root as Button,
|
||||
type Props as ButtonProps,
|
||||
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 Portal from "./dialog-portal.svelte";
|
||||
import Footer from "./dialog-footer.svelte";
|
||||
import Header from "./dialog-header.svelte";
|
||||
import Overlay from "./dialog-overlay.svelte";
|
||||
import Content from "./dialog-content.svelte";
|
||||
import Description from "./dialog-description.svelte";
|
||||
import Title from './dialog-title.svelte';
|
||||
import Portal from './dialog-portal.svelte';
|
||||
import Footer from './dialog-footer.svelte';
|
||||
import Header from './dialog-header.svelte';
|
||||
import Overlay from './dialog-overlay.svelte';
|
||||
import Content from './dialog-content.svelte';
|
||||
import Description from './dialog-description.svelte';
|
||||
|
||||
const Root = DialogPrimitive.Root;
|
||||
const Trigger = DialogPrimitive.Trigger;
|
||||
@@ -33,5 +33,5 @@ export {
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
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 & {
|
||||
currentTarget: EventTarget & HTMLInputElement;
|
||||
@@ -25,5 +25,5 @@ export type InputEvents = {
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
Root as Input
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
import Root from './label.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
Root as Label
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
import Root from './separator.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
Root as Separator
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default as Toaster } from "./sonner.svelte";
|
||||
export { default as Toaster } from './sonner.svelte';
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -100,11 +100,9 @@ Here is what you need to be able to run Cal.com.
|
||||
You can install nvm from [here](https://github.com/nvm-sh/nvm).
|
||||
|
||||
4. Set up your `.env` file
|
||||
|
||||
- Duplicate `.env.example` to `.env`
|
||||
|
||||
5. Setup Supabase.
|
||||
|
||||
- Make sure you've downloaded the [supabase cli](https://github.com/supabase/cli)
|
||||
- Install and Start [docker](https://docs.docker.com/engine/install/)
|
||||
- Go to the project directory in your terminal and start supabase
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface NestedRouteConfig {
|
||||
### Step 2: Update Breadcrumb Generation Calls
|
||||
|
||||
#### Before (Org):
|
||||
|
||||
```typescript
|
||||
import { generateBreadcrumbs } from './breadcrumb';
|
||||
|
||||
@@ -53,6 +54,7 @@ const breadcrumbs = generateBreadcrumbs(
|
||||
```
|
||||
|
||||
#### After (Org - Store-Based Reactive):
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
@@ -61,24 +63,20 @@ const breadcrumbs = generateBreadcrumbs(
|
||||
import { courseStore } from '$features/course/utils/store';
|
||||
|
||||
// Reactive config that reads from stores
|
||||
const config = $derived(createOrgBreadcrumbConfigWithStore(
|
||||
const config = $derived(
|
||||
createOrgBreadcrumbConfigWithStore(
|
||||
$currentOrgPath,
|
||||
() => $courseStore.title // Reads reactively - updates automatically!
|
||||
));
|
||||
|
||||
// Breadcrumbs update automatically when store changes!
|
||||
const breadcrumbs = $derived(
|
||||
generateBreadcrumbsSync(
|
||||
page.url.pathname,
|
||||
page.url.search,
|
||||
navItems,
|
||||
config
|
||||
)
|
||||
);
|
||||
|
||||
// Breadcrumbs update automatically when store changes!
|
||||
const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
|
||||
</script>
|
||||
```
|
||||
|
||||
#### Before (LMS):
|
||||
|
||||
```typescript
|
||||
import { generateLmsBreadcrumbs } from './lms-breadcrumb';
|
||||
import { currentCommunityQuestion } from '$features/community/utils/store';
|
||||
@@ -93,6 +91,7 @@ const breadcrumbs = generateLmsBreadcrumbs(
|
||||
```
|
||||
|
||||
#### After (LMS - Reactive Store-Based):
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
@@ -101,19 +100,14 @@ const breadcrumbs = generateLmsBreadcrumbs(
|
||||
import { currentCommunityQuestion } from '$features/community/utils/store';
|
||||
|
||||
// Config is reactive - updates when store changes
|
||||
const config = $derived(createLmsBreadcrumbConfigWithStore(
|
||||
const config = $derived(
|
||||
createLmsBreadcrumbConfigWithStore(
|
||||
() => $currentCommunityQuestion.title // Reads reactively from store
|
||||
));
|
||||
|
||||
// Breadcrumbs update automatically when store changes!
|
||||
const breadcrumbs = $derived(
|
||||
generateBreadcrumbsSync(
|
||||
page.url.pathname,
|
||||
page.url.search,
|
||||
navItems,
|
||||
config
|
||||
)
|
||||
);
|
||||
|
||||
// Breadcrumbs update automatically when store changes!
|
||||
const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -131,6 +125,7 @@ The refactored system supports up to 5 layers of nesting through recursive trave
|
||||
#### Example: Courses → Course → Lesson → Assignment → Submission (5 Layers)
|
||||
|
||||
**Navigation Config** (in `org-navigation.ts`):
|
||||
|
||||
```typescript
|
||||
{
|
||||
titleKey: 'org_navigation.courses',
|
||||
@@ -186,6 +181,7 @@ The refactored system supports up to 5 layers of nesting through recursive trave
|
||||
```
|
||||
|
||||
**Breadcrumb Component** (in `app-breadcrumbs.svelte`):
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
@@ -194,9 +190,8 @@ The refactored system supports up to 5 layers of nesting through recursive trave
|
||||
import { assignmentStore, submissionStore, courseStore, lessonStore } from '$features/course/utils/store';
|
||||
|
||||
// Create reactive config that reads from stores
|
||||
const config = $derived(createOrgBreadcrumbConfigWithStore(
|
||||
$currentOrgPath,
|
||||
() => {
|
||||
const config = $derived(
|
||||
createOrgBreadcrumbConfigWithStore($currentOrgPath, () => {
|
||||
// This function is called reactively - reads from stores
|
||||
const pathSegments = page.url.pathname.split('/').filter(Boolean);
|
||||
|
||||
@@ -214,21 +209,15 @@ The refactored system supports up to 5 layers of nesting through recursive trave
|
||||
return $courseStore.title || 'Course';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
));
|
||||
|
||||
const breadcrumbs = $derived(
|
||||
generateBreadcrumbsSync(
|
||||
page.url.pathname,
|
||||
page.url.search,
|
||||
navItems,
|
||||
config
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
|
||||
</script>
|
||||
```
|
||||
|
||||
**Alternative: Per-Route Store Resolvers** (More Granular):
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
@@ -237,9 +226,8 @@ The refactored system supports up to 5 layers of nesting through recursive trave
|
||||
import { assignmentStore, submissionStore, courseStore, lessonStore } from '$features/course/utils/store';
|
||||
|
||||
// Create config with store-based resolver
|
||||
const config = $derived(createOrgBreadcrumbConfigWithStore(
|
||||
$currentOrgPath,
|
||||
() => {
|
||||
const config = $derived(
|
||||
createOrgBreadcrumbConfigWithStore($currentOrgPath, () => {
|
||||
// Read from stores reactively based on path structure
|
||||
const pathSegments = page.url.pathname.split('/').filter(Boolean);
|
||||
|
||||
@@ -257,17 +245,10 @@ The refactored system supports up to 5 layers of nesting through recursive trave
|
||||
return $courseStore.title || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
));
|
||||
|
||||
const breadcrumbs = $derived(
|
||||
generateBreadcrumbsSync(
|
||||
page.url.pathname,
|
||||
page.url.search,
|
||||
navItems,
|
||||
config
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -288,6 +269,7 @@ The refactored system supports up to 5 layers of nesting through recursive trave
|
||||
#### Store-Based vs Async Resolvers
|
||||
|
||||
**✅ Recommended: Store-Based (Reactive)**
|
||||
|
||||
```typescript
|
||||
// In breadcrumb component
|
||||
const config = $derived(createOrgBreadcrumbConfigWithStore(
|
||||
@@ -300,15 +282,17 @@ const breadcrumbs = $derived(generateBreadcrumbsSync(...));
|
||||
```
|
||||
|
||||
**⚠️ Alternative: Async Resolvers (API Calls)**
|
||||
|
||||
```typescript
|
||||
// In navigation config
|
||||
titleResolver: async (params) => {
|
||||
const assignment = await fetchAssignment(params.param0);
|
||||
return assignment.title;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Why Use Stores?**
|
||||
|
||||
- ✅ **No API calls** - data already loaded in component
|
||||
- ✅ **Automatic updates** - breadcrumb updates when store changes
|
||||
- ✅ **Better performance** - no loading states or delays
|
||||
@@ -350,41 +334,43 @@ For routes that need async data fetching:
|
||||
```typescript
|
||||
import { generateBreadcrumbs } from './breadcrumb-core'; // Note: async version
|
||||
|
||||
const breadcrumbs = await generateBreadcrumbs(
|
||||
page.url.pathname,
|
||||
page.url.search,
|
||||
navItems,
|
||||
config
|
||||
);
|
||||
const breadcrumbs = await generateBreadcrumbs(page.url.pathname, page.url.search, navItems, config);
|
||||
```
|
||||
|
||||
## Benefits of the Refactored System
|
||||
|
||||
### ✅ 1. No Code Duplication
|
||||
|
||||
- Single unified generator for all contexts
|
||||
- Context-specific configs handle differences
|
||||
|
||||
### ✅ 2. Multi-Level Nesting Support
|
||||
|
||||
- Recursive generation handles any depth
|
||||
- Configurable max depth prevents infinite loops
|
||||
|
||||
### ✅ 3. Flexible Path Prefixes
|
||||
|
||||
- Function-based prefix extraction
|
||||
- Supports dynamic prefixes like `/org/[slug]`
|
||||
|
||||
### ✅ 4. Consistent Dynamic Title Resolution
|
||||
|
||||
- Unified interface for all resolvers
|
||||
- Supports both sync and async resolution
|
||||
|
||||
### ✅ 5. Route-Specific Customization
|
||||
|
||||
- Override labels per route
|
||||
- Custom resolvers for complex cases
|
||||
|
||||
### ✅ 6. Optional Caching
|
||||
|
||||
- Performance optimization
|
||||
- Configurable cache TTL
|
||||
|
||||
### ✅ 7. Type Safety
|
||||
|
||||
- Full TypeScript support
|
||||
- Compile-time error checking
|
||||
|
||||
@@ -393,6 +379,7 @@ const breadcrumbs = await generateBreadcrumbs(
|
||||
**URL**: `/org/myorg/courses/123/lessons/456/assignments/789/submissions/101`
|
||||
|
||||
**Config**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: '/courses',
|
||||
@@ -492,6 +479,7 @@ The recursive function processes **each path segment** separately, incrementing
|
||||
**Breadcrumb Result**: `Courses / React Fundamentals / Lessons / Components / Assignments / Build Todo App / Submissions / Submission #101`
|
||||
|
||||
**Depth Breakdown**:
|
||||
|
||||
- `depth=0`: Top-level nav item (`/courses`) → "Courses"
|
||||
- `depth=1`: First dynamic segment (`123`) → "React Fundamentals"
|
||||
- `depth=2`: Static nested route (`lessons`) → "Lessons"
|
||||
@@ -560,12 +548,7 @@ beforeEach(() => {
|
||||
|
||||
// Test breadcrumb generation
|
||||
test('generates breadcrumbs for nested route', () => {
|
||||
const breadcrumbs = generateBreadcrumbsSync(
|
||||
'/org/test/courses/123/lessons/456',
|
||||
'',
|
||||
navItems,
|
||||
config
|
||||
);
|
||||
const breadcrumbs = generateBreadcrumbsSync('/org/test/courses/123/lessons/456', '', navItems, config);
|
||||
|
||||
expect(breadcrumbs).toHaveLength(4);
|
||||
expect(breadcrumbs[0].label).toBe('Courses');
|
||||
@@ -604,6 +587,7 @@ This allows gradual migration without breaking existing code.
|
||||
### Why Use Stores Instead of Async Requests?
|
||||
|
||||
**✅ Benefits:**
|
||||
|
||||
- **No API calls** - Data is already loaded in your component
|
||||
- **Automatic updates** - Breadcrumbs update reactively when stores change
|
||||
- **Better performance** - No loading states, delays, or network requests
|
||||
@@ -619,6 +603,7 @@ This allows gradual migration without breaking existing code.
|
||||
### Example: Community Post Title
|
||||
|
||||
**Store** (`$features/community/utils/store.ts`):
|
||||
|
||||
```typescript
|
||||
export const currentCommunityQuestion = writable<{ title: string | null }>({
|
||||
title: null
|
||||
@@ -626,6 +611,7 @@ export const currentCommunityQuestion = writable<{ title: string | null }>({
|
||||
```
|
||||
|
||||
**Page Component** (`+page.svelte`):
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { currentCommunityQuestion } from '$features/community/utils/store';
|
||||
@@ -646,20 +632,21 @@ export const currentCommunityQuestion = writable<{ title: string | null }>({
|
||||
```
|
||||
|
||||
**Breadcrumb Component** (`lms-breadcrumbs.svelte`):
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { createLmsBreadcrumbConfigWithStore } from './breadcrumb-configs';
|
||||
import { currentCommunityQuestion } from '$features/community/utils/store';
|
||||
|
||||
// Reactive config - updates when store changes
|
||||
const config = $derived(createLmsBreadcrumbConfigWithStore(
|
||||
const config = $derived(
|
||||
createLmsBreadcrumbConfigWithStore(
|
||||
() => $currentCommunityQuestion.title // Reads reactively!
|
||||
));
|
||||
)
|
||||
);
|
||||
|
||||
// Breadcrumbs automatically update when store changes!
|
||||
const breadcrumbs = $derived(
|
||||
generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config)
|
||||
);
|
||||
const breadcrumbs = $derived(generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config));
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -669,9 +656,8 @@ For routes with multiple dynamic segments, use path-based logic:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
const config = $derived(createOrgBreadcrumbConfigWithStore(
|
||||
$currentOrgPath,
|
||||
() => {
|
||||
const config = $derived(
|
||||
createOrgBreadcrumbConfigWithStore($currentOrgPath, () => {
|
||||
const pathSegments = page.url.pathname.split('/').filter(Boolean);
|
||||
|
||||
// Determine which store to read based on route
|
||||
@@ -688,8 +674,8 @@ For routes with multiple dynamic segments, use path-based logic:
|
||||
return $courseStore.title;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
));
|
||||
})
|
||||
);
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -699,4 +685,3 @@ For routes with multiple dynamic segments, use path-based logic:
|
||||
2. **Use `$derived`**: Always wrap config and breadcrumbs in `$derived` for reactivity
|
||||
3. **Store Updates**: When you update a store, breadcrumbs automatically update - no manual refresh needed!
|
||||
4. **No Async Needed**: Store-based resolvers are synchronous - just return the store value
|
||||
|
||||
|
||||
@@ -20,11 +20,13 @@ This guide explains how to create pages and routes in the dashboard application,
|
||||
The dashboard has two main route contexts:
|
||||
|
||||
### 1. Organization Routes (`/org/[slug]/...`)
|
||||
|
||||
- Uses `OrgSidebar` and `AppHeader`
|
||||
- Path pattern: `/org/[slug]/[feature]`
|
||||
- Example: `/org/acme/courses`, `/org/acme/settings`
|
||||
|
||||
### 2. LMS Routes (`/lms/...`)
|
||||
|
||||
- Uses `LMSSidebar` and `LmsHeader`
|
||||
- Path pattern: `/lms/[feature]`
|
||||
- Example: `/lms/community`, `/lms/settings`
|
||||
@@ -38,6 +40,7 @@ The dashboard has two main route contexts:
|
||||
**Location**: `apps/dashboard/src/routes/[context]/[feature]/+page.svelte`
|
||||
|
||||
**Pattern**:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { FeaturePage } from '$features/[feature]/pages';
|
||||
@@ -72,6 +75,7 @@ The dashboard has two main route contexts:
|
||||
**Location**: `apps/dashboard/src/lib/features/[feature]/pages/[feature].svelte`
|
||||
|
||||
**Pattern**:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
@@ -230,6 +234,7 @@ Settings pages follow a specific pattern with layouts and subroutes.
|
||||
Feature pages contain all business logic and should be placed in `lib/features/[feature]/pages/`.
|
||||
|
||||
**Key Principles**:
|
||||
|
||||
- ✅ All business logic goes in the feature page
|
||||
- ✅ Use `Page.BodyHeader` for filters, search, and view toggles
|
||||
- ✅ Use `Empty` component for empty states
|
||||
@@ -363,6 +368,7 @@ Feature pages contain all business logic and should be placed in `lib/features/[
|
||||
```
|
||||
|
||||
**Key Differences**:
|
||||
|
||||
- Org routes use `AppHeader` (includes AppSetup)
|
||||
- LMS routes use `LmsHeader` (no AppSetup)
|
||||
- Org routes use `OrgSidebar`
|
||||
@@ -426,11 +432,7 @@ Feature pages contain all business logic and should be placed in `lib/features/[
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('form.another_section')}</Field.Legend>
|
||||
<Field.Field>
|
||||
<UploadImage
|
||||
bind:avatar
|
||||
src={avatarUrl}
|
||||
change={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
<UploadImage bind:avatar src={avatarUrl} change={() => (hasUnsavedChanges = true)} />
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
@@ -568,6 +570,7 @@ export async function load({ params }) {
|
||||
### Example 1: Simple Feature Page
|
||||
|
||||
**Route**: `routes/org/[slug]/courses/+page.svelte`
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { CoursesPage } from '$features/courses/pages';
|
||||
@@ -596,6 +599,7 @@ export async function load({ params }) {
|
||||
### Example 2: Settings Page with Save Action
|
||||
|
||||
**Route**: `routes/org/[slug]/settings/customize-lms/+page.svelte`
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { CustomizeLmsPage } from '$features/settings/pages';
|
||||
@@ -642,6 +646,7 @@ export async function load({ params }) {
|
||||
### Example 3: Form Page Component
|
||||
|
||||
**Feature Page**: `lib/features/settings/pages/profile.svelte`
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
@@ -655,11 +660,14 @@ export async function load({ params }) {
|
||||
let hasUnsavedChanges = $state(false);
|
||||
|
||||
export async function handleUpdate() {
|
||||
await profileApi.submit({
|
||||
await profileApi.submit(
|
||||
{
|
||||
fullname: $profile.fullname,
|
||||
username: $profile.username,
|
||||
avatar
|
||||
}, $profile);
|
||||
},
|
||||
$profile
|
||||
);
|
||||
|
||||
if (profileApi.success) {
|
||||
hasUnsavedChanges = false;
|
||||
@@ -708,12 +716,14 @@ export async function load({ params }) {
|
||||
When creating a new page, follow this checklist:
|
||||
|
||||
### Route Setup
|
||||
|
||||
- [ ] Create route file at `routes/[context]/[feature]/+page.svelte`
|
||||
- [ ] Add `Page.Root`, `Page.Header`, `Page.Body` structure
|
||||
- [ ] Add `<svelte:head>` with page title
|
||||
- [ ] Import and render feature page component
|
||||
|
||||
### Feature Page
|
||||
|
||||
- [ ] Create feature page at `lib/features/[feature]/pages/[feature].svelte`
|
||||
- [ ] Export from `lib/features/[feature]/pages/index.ts`
|
||||
- [ ] Use `Page.BodyHeader` for filters/search
|
||||
@@ -721,6 +731,7 @@ When creating a new page, follow this checklist:
|
||||
- [ ] Handle loading states
|
||||
|
||||
### Forms (if applicable)
|
||||
|
||||
- [ ] Use `Field.Group` pattern (NOT Row/Grid/Column)
|
||||
- [ ] Use `Field.Set` for sections
|
||||
- [ ] Use `Field.Separator` between sections
|
||||
@@ -729,6 +740,7 @@ When creating a new page, follow this checklist:
|
||||
- [ ] Add `UnsavedChanges` component if needed
|
||||
|
||||
### Navigation (if needed)
|
||||
|
||||
- [ ] Add item to `org-navigation.ts` or `lms-navigation.ts`
|
||||
- [ ] Set appropriate `matchPattern` for active state
|
||||
- [ ] Add icon component
|
||||
@@ -736,6 +748,7 @@ When creating a new page, follow this checklist:
|
||||
- [ ] Configure dynamic segments if needed
|
||||
|
||||
### Settings Pages (if applicable)
|
||||
|
||||
- [ ] Create `+layout.svelte` with `Page.Root` wrapper
|
||||
- [ ] Create default `+page.svelte` for main settings
|
||||
- [ ] Create subroute `+page.svelte` files for each subroute
|
||||
@@ -755,6 +768,7 @@ When creating a new page, follow this checklist:
|
||||
### Page Actions
|
||||
|
||||
Actions in `Page.Action` typically include:
|
||||
|
||||
- Save/Update buttons
|
||||
- Create/Add buttons
|
||||
- Export buttons
|
||||
@@ -763,6 +777,7 @@ Actions in `Page.Action` typically include:
|
||||
### Loading States
|
||||
|
||||
Use skeleton loaders or spinner components:
|
||||
|
||||
```svelte
|
||||
{#if isLoading}
|
||||
<FeatureLoader />
|
||||
@@ -776,13 +791,9 @@ Use skeleton loaders or spinner components:
|
||||
### Empty States
|
||||
|
||||
Always use the `Empty` component:
|
||||
|
||||
```svelte
|
||||
<Empty
|
||||
title={$t('feature.empty_title')}
|
||||
description={$t('feature.empty_description')}
|
||||
icon={SomeIcon}
|
||||
variant="page"
|
||||
>
|
||||
<Empty title={$t('feature.empty_title')} description={$t('feature.empty_description')} icon={SomeIcon} variant="page">
|
||||
<ActionButton />
|
||||
</Empty>
|
||||
```
|
||||
@@ -807,6 +818,7 @@ Always use the `Empty` component:
|
||||
If you encounter old patterns, migrate them:
|
||||
|
||||
### Old: Row/Grid/Column Pattern
|
||||
|
||||
```svelte
|
||||
<!-- OLD - DON'T USE -->
|
||||
<Grid>
|
||||
@@ -822,6 +834,7 @@ If you encounter old patterns, migrate them:
|
||||
```
|
||||
|
||||
### New: Field.Group Pattern
|
||||
|
||||
```svelte
|
||||
<!-- NEW - USE THIS -->
|
||||
<Field.Group class="max-w-md! w-full px-2">
|
||||
@@ -843,4 +856,3 @@ If you encounter old patterns, migrate them:
|
||||
- See existing pages in `routes/org/[slug]/` and `routes/lms/` for examples
|
||||
- See `lib/features/settings/pages/` for form examples
|
||||
- See `lib/features/community/pages/` for list page examples
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#333333" />
|
||||
@@ -16,16 +15,19 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
|
||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
|
||||
|
||||
<script nonce="%sveltekit.nonce%" defer src="https://umami.hz.oncws.com/script.js"
|
||||
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"></script>
|
||||
<script
|
||||
nonce="%sveltekit.nonce%"
|
||||
defer
|
||||
src="https://umami.hz.oncws.com/script.js"
|
||||
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"
|
||||
></script>
|
||||
|
||||
<script nonce="%sveltekit.nonce%" src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover" class="">
|
||||
<body data-sveltekit-preload-data="hover" class="">
|
||||
<div style="display: contents" class="theme-container">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,8 +13,8 @@
|
||||
<Skeleton class="h-[48px] w-[48px]" />
|
||||
<div class="flex-1">
|
||||
<Skeleton class="h-[48px] w-[48px]" />
|
||||
<Skeleton class="h-[16px] w-[80px] mb-2" />
|
||||
<Skeleton class="h-[24px] w-[40px] mb-1" />
|
||||
<Skeleton class="mb-2 h-[16px] w-[80px]" />
|
||||
<Skeleton class="mb-1 h-[24px] w-[40px]" />
|
||||
<Skeleton class="h-[12px] w-[60px]" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,10 +26,10 @@
|
||||
<!-- Charts Section Skeleton -->
|
||||
<div class="mb-12">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<Skeleton class="h-[24px] w-[200px] mb-6" />
|
||||
<Skeleton class="mb-6 h-[24px] w-[200px]" />
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Skeleton class="w-full h-[300px]" />
|
||||
<Skeleton class="w-full h-[300px]" />
|
||||
<Skeleton class="h-[300px] w-full" />
|
||||
<Skeleton class="h-[300px] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@
|
||||
<!-- Students Table Skeleton -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700">
|
||||
<Skeleton class="h-[24px] w-[200px] mb-6" />
|
||||
<Skeleton class="mb-6 h-[24px] w-[200px]" />
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
@@ -45,7 +45,7 @@
|
||||
<div class="flex items-center space-x-4">
|
||||
<Skeleton class="h-[32px] w-[32px]" />
|
||||
<div class="flex-1">
|
||||
<Skeleton class="h-[16px] w-[150px] mb-1" />
|
||||
<Skeleton class="mb-1 h-[16px] w-[150px]" />
|
||||
<Skeleton class="h-[12px] w-[200px]" />
|
||||
</div>
|
||||
<Skeleton class="h-[16px] w-[80px]" />
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
if (!isOpen) handleClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-[80%] md:w-[65%] max-w-xl">
|
||||
<Dialog.Content class="w-[80%] max-w-xl md:w-[65%]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t(`course.navItem.lessons.section_prompt.header`)}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
@@ -54,16 +54,10 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={handleClose}
|
||||
>
|
||||
<Button variant="outline" onclick={handleClose}>
|
||||
{$t('course.navItem.lessons.section_prompt.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={activate}
|
||||
loading={isActivating}
|
||||
>
|
||||
<Button onclick={activate} loading={isActivating}>
|
||||
{$t('course.navItem.lessons.section_prompt.activate')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
if (!isOpen) openDeleteModal = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-96 px-6 pt-2 pb-6">
|
||||
<Dialog.Content class="w-96 px-6 pb-6 pt-2">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('delete_modal.label')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
@@ -32,16 +32,10 @@
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (openDeleteModal = false)}
|
||||
>
|
||||
<Button variant="outline" onclick={() => (openDeleteModal = false)}>
|
||||
{$t('delete_modal.no')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={handleDelete}
|
||||
>
|
||||
<Button variant="outline" onclick={handleDelete}>
|
||||
{$t('delete_modal.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
let { onDelete = () => {}, onCancel = () => {} } = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$deleteConfirmation.open}
|
||||
>
|
||||
<Dialog.Root bind:open={$deleteConfirmation.open}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}</Dialog.Title>
|
||||
|
||||
@@ -236,7 +236,7 @@
|
||||
if (!isOpen) onClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-4/5 h-[90%] flex items-start !max-h-full h-[85%] py-0 px-4">
|
||||
<Dialog.Content class="flex h-[85%] h-[90%] !max-h-full w-4/5 items-start px-4 py-0">
|
||||
<Dialog.Header class="py-2">
|
||||
<Dialog.Title class="text-base font-semibold">{data.title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
if (!isOpen) handleCancelAddExercise();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-w-2xl w-4/5">
|
||||
<Dialog.Content class="w-4/5 max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.new_exercise_modal.heading')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={openDeleteModal}
|
||||
>
|
||||
<Dialog.Root bind:open={openDeleteModal}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<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>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (openDeleteModal = false)}
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>
|
||||
Yes
|
||||
</Button>
|
||||
<Button variant="outline" onclick={() => (openDeleteModal = false)}>No</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>Yes</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -140,10 +140,7 @@
|
||||
{/if}
|
||||
<div class="flex items-center justify-end py-2">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={resetEditor}
|
||||
>
|
||||
<Button variant="outline" onclick={resetEditor}>
|
||||
{$t('course.navItem.news_feed.heading_button.cancel')}
|
||||
</Button>
|
||||
<Button loading={isLoading} onclick={onPost}>
|
||||
|
||||
@@ -12,11 +12,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$deleteMemberModal.open}
|
||||
>
|
||||
<Dialog.Root bind:open={$deleteMemberModal.open}>
|
||||
<Dialog.Content class="w-96 pt-3">
|
||||
<Dialog.Header class="py-2 px-5">
|
||||
<Dialog.Header class="px-5 py-2">
|
||||
<Dialog.Title>{$t('course.navItem.people.delete_confirmation.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
@@ -25,16 +23,10 @@
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => ($deleteMemberModal.open = false)}
|
||||
>
|
||||
<Button variant="outline" onclick={() => ($deleteMemberModal.open = false)}>
|
||||
{$t('course.navItem.people.delete_confirmation.no')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={handleDelete}
|
||||
>
|
||||
<Button variant="outline" onclick={handleDelete}>
|
||||
{$t('course.navItem.people.delete_confirmation.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -20,15 +20,12 @@
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
>
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{$t('community.delete.title')} {isQuestion
|
||||
? $t('community.delete.question')
|
||||
: $t('community.delete.comment')}
|
||||
{$t('community.delete.title')}
|
||||
{isQuestion ? $t('community.delete.question') : $t('community.delete.comment')}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
@@ -38,16 +35,10 @@
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={onCancel}
|
||||
>
|
||||
<Button variant="outline" onclick={onCancel}>
|
||||
{$t('community.delete.no')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={onDelete}
|
||||
loading={isDeleting}
|
||||
>
|
||||
<Button onclick={onDelete} loading={isDeleting}>
|
||||
{$t('community.delete.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -7,4 +7,3 @@ export interface CurrentCommunityQuestion {
|
||||
export const currentCommunityQuestion = writable<CurrentCommunityQuestion>({
|
||||
title: null
|
||||
});
|
||||
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$copyCourseModal.open}
|
||||
>
|
||||
<Dialog.Root bind:open={$copyCourseModal.open}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('courses.copy_course.title')}</Dialog.Title>
|
||||
|
||||
@@ -233,4 +233,3 @@
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -109,4 +109,3 @@
|
||||
variant="page"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -55,4 +55,3 @@
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ export interface DOMProtectionCleanup {
|
||||
* @param isEmailVerified - A readable store or getter function that returns whether email is verified
|
||||
* @returns Cleanup object with intervals, observers, and a cleanup function
|
||||
*/
|
||||
export function setupDOMProtection(
|
||||
isEmailVerified: Readable<boolean> | (() => boolean | null)
|
||||
): DOMProtectionCleanup {
|
||||
export function setupDOMProtection(isEmailVerified: Readable<boolean> | (() => boolean | null)): DOMProtectionCleanup {
|
||||
if (!browser) {
|
||||
return {
|
||||
cleanup: () => {}
|
||||
@@ -251,4 +249,3 @@ export function updateBodyClass(isEmailVerified: boolean | null) {
|
||||
document.body.classList.remove('email-verified');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,4 +67,3 @@
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
|
||||
@@ -27,4 +27,3 @@
|
||||
<p class="text-sm text-red-600">{$t('courses.course_card.error_message')}</p>
|
||||
</div>
|
||||
{/await}
|
||||
|
||||
|
||||
@@ -25,4 +25,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -24,4 +24,3 @@
|
||||
<CourseCardLoader />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -3,4 +3,3 @@ import { writable } from 'svelte/store';
|
||||
export const newOrgModal = writable({
|
||||
open: false
|
||||
});
|
||||
|
||||
|
||||
@@ -114,9 +114,8 @@
|
||||
<Dialog.Content class="w-4/5 md:w-2/5">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{$createQuizModal.openEdit ? $t('components.quiz.update') : $t('components.quiz.create')} {$t(
|
||||
'components.quiz.a_quiz'
|
||||
)}
|
||||
{$createQuizModal.openEdit ? $t('components.quiz.update') : $t('components.quiz.create')}
|
||||
{$t('components.quiz.a_quiz')}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={preventDefault(createQuiz)}>
|
||||
|
||||
@@ -53,10 +53,7 @@
|
||||
errorMessage={optionHasError(option.id, currentError.options) && $t('components.quiz.label')}
|
||||
/>
|
||||
<div class="absolute right-2 top-2 flex justify-end">
|
||||
<IconButton
|
||||
onclick={() => (option.isCorrect = !option.isCorrect)}
|
||||
class={option.isCorrect ? 'success' : ''}
|
||||
>
|
||||
<IconButton onclick={() => (option.isCorrect = !option.isCorrect)} class={option.isCorrect ? 'success' : ''}>
|
||||
<CircleCheckIcon size={16} filled={option.isCorrect} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
@@ -114,4 +114,3 @@ export const landingPageSettings: Writable<OrgLandingPageJson> = writable({
|
||||
show: true
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -38,4 +38,3 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,4 +44,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,4 +10,3 @@
|
||||
<Skeleton class="h-40 w-[270px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,22 +28,13 @@
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (open = false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Button variant="outline" onclick={() => (open = false)} disabled={isLoading}>
|
||||
{$t('delete_modal.no')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onclick={onDelete}
|
||||
loading={isLoading}
|
||||
>
|
||||
<Button variant="destructive" onclick={onDelete} loading={isLoading}>
|
||||
{$t('delete_modal.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
|
||||
@@ -15,4 +15,3 @@
|
||||
<line x1="17" y1="7" x2="7" y2="17" /><polyline points="8 7 17 7 17 16" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -51,4 +51,3 @@
|
||||
fill: none; /* Ensure the checkmark path itself is not filled */
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -41,4 +41,3 @@
|
||||
stroke-linejoin="bevel"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
@@ -15,4 +15,3 @@
|
||||
stroke-width="5"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -183,4 +183,3 @@
|
||||
fill="#E4E4E4"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -183,4 +183,3 @@
|
||||
fill="#5C6065"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -24,4 +24,3 @@
|
||||
fill="#EB4132"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -15,4 +15,3 @@
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -122,10 +122,7 @@ function getCachedOrGenerate(
|
||||
/**
|
||||
* Extract path prefix from pathname
|
||||
*/
|
||||
function extractPathPrefix(
|
||||
pathname: string,
|
||||
prefixConfig: string | ((pathname: string) => string)
|
||||
): string {
|
||||
function extractPathPrefix(pathname: string, prefixConfig: string | ((pathname: string) => string)): string {
|
||||
if (typeof prefixConfig === 'function') {
|
||||
return prefixConfig(pathname);
|
||||
}
|
||||
@@ -143,10 +140,7 @@ function extractPathPrefix(
|
||||
/**
|
||||
* Get base path for href generation
|
||||
*/
|
||||
function getBasePath(
|
||||
pathname: string,
|
||||
basePathConfig: string | ((pathname: string) => string)
|
||||
): string {
|
||||
function getBasePath(pathname: string, basePathConfig: string | ((pathname: string) => string)): string {
|
||||
if (typeof basePathConfig === 'function') {
|
||||
return basePathConfig(pathname);
|
||||
}
|
||||
@@ -228,10 +222,7 @@ async function resolveDynamicTitle(
|
||||
/**
|
||||
* Extract parameters from pathname (e.g., { slug: 'my-post' })
|
||||
*/
|
||||
function extractParamsFromPath(
|
||||
pathname: string,
|
||||
parentPath: string
|
||||
): Record<string, string> {
|
||||
function extractParamsFromPath(pathname: string, parentPath: string): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
const relativePath = pathname.replace(parentPath, '').replace(/^\//, '');
|
||||
const segments = relativePath.split('/').filter(Boolean);
|
||||
@@ -300,16 +291,11 @@ async function generateNestedBreadcrumbs(
|
||||
}
|
||||
|
||||
// Handle dynamic segment
|
||||
if (navItem.supportsDynamicSegment ||
|
||||
navItem.nestedRoutes?.some((route) => route.supportsDynamicSegment && route.path !== currentSegment)) {
|
||||
const dynamicTitle = await resolveDynamicTitle(
|
||||
currentSegment,
|
||||
currentPath,
|
||||
fullPathname,
|
||||
navItem,
|
||||
config,
|
||||
context
|
||||
);
|
||||
if (
|
||||
navItem.supportsDynamicSegment ||
|
||||
navItem.nestedRoutes?.some((route) => route.supportsDynamicSegment && route.path !== currentSegment)
|
||||
) {
|
||||
const dynamicTitle = await resolveDynamicTitle(currentSegment, currentPath, fullPathname, navItem, config, context);
|
||||
|
||||
if (dynamicTitle) {
|
||||
breadcrumbs.push({
|
||||
@@ -400,9 +386,7 @@ export function generateBreadcrumbsSync(
|
||||
}
|
||||
|
||||
// Add parent breadcrumb
|
||||
const parentHref = matchedNavItem.useHashUrl
|
||||
? `${basePath}${matchedNavItem.path}`
|
||||
: matchedNavItem.url;
|
||||
const parentHref = matchedNavItem.useHashUrl ? `${basePath}${matchedNavItem.path}` : matchedNavItem.url;
|
||||
|
||||
breadcrumbs.push({
|
||||
label: matchedNavItem.breadcrumbLabel || matchedNavItem.title,
|
||||
@@ -411,9 +395,7 @@ export function generateBreadcrumbsSync(
|
||||
|
||||
// Handle query params (settings tabs, etc.)
|
||||
if (matchedNavItem.items && searchParams) {
|
||||
const subItem = matchedNavItem.items.find((sub) =>
|
||||
isActive(pathWithQuery, sub.url, undefined, true)
|
||||
);
|
||||
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, undefined, true));
|
||||
|
||||
if (subItem) {
|
||||
breadcrumbs.push({
|
||||
@@ -508,12 +490,7 @@ function generateNestedBreadcrumbsSync(
|
||||
let dynamicTitle: string | null = null;
|
||||
|
||||
if (config.dynamicTitleResolver) {
|
||||
const result = config.dynamicTitleResolver(
|
||||
currentSegment,
|
||||
currentPath,
|
||||
fullPathname,
|
||||
context
|
||||
);
|
||||
const result = config.dynamicTitleResolver(currentSegment, currentPath, fullPathname, context);
|
||||
// Only use if it's not a promise
|
||||
if (typeof result === 'string') {
|
||||
dynamicTitle = result;
|
||||
@@ -560,4 +537,3 @@ export function clearBreadcrumbCache() {
|
||||
breadcrumbCache.clear();
|
||||
cacheTimestamps.clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -105,4 +105,3 @@ export function generateLmsBreadcrumbs(
|
||||
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,4 +98,3 @@
|
||||
{/if}
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb.Root>
|
||||
|
||||
|
||||
@@ -60,4 +60,3 @@
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -14,4 +14,3 @@
|
||||
</script>
|
||||
|
||||
<div class="senja-embed" data-id={id} data-lazyload="false" data-spinner="false"></div>
|
||||
|
||||
|
||||
@@ -75,4 +75,3 @@
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
|
||||
|
||||
@@ -141,7 +141,13 @@
|
||||
<!-- Your Images content here -->
|
||||
<div class="h-full overflow-y-scroll">
|
||||
<form onsubmit={preventDefault(handleSubmit)} class="mt-1 flex gap-2 pb-3">
|
||||
<input type="text" bind:value={searchQuery} name="" id="" class="ml-2 w-[85%] rounded-lg dark:text-black" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
name=""
|
||||
id=""
|
||||
class="ml-2 w-[85%] rounded-lg dark:text-black"
|
||||
/>
|
||||
<button type="submit" class="rounded-lg border border-gray-500 bg-white px-3 py-1 text-black"
|
||||
>{$t('course.navItem.landing_page.upload_widget.submit')}</button
|
||||
>
|
||||
|
||||
@@ -16,4 +16,3 @@
|
||||
<ArrowUpIcon size={16} />
|
||||
<Chip {value} />
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
* Server-side file type validation utilities for preventing malicious uploads
|
||||
* Specifically designed to prevent SVG XSS attacks and other security issues
|
||||
*/
|
||||
export const ALLOWED_IMAGE_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp'
|
||||
] as const;
|
||||
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'] as const;
|
||||
|
||||
export const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] as const;
|
||||
|
||||
|
||||
@@ -24,11 +24,7 @@ export async function checkUserCoursePermissions(
|
||||
.single();
|
||||
|
||||
// Check if user is org admin
|
||||
const { data: orgData } = await supabase
|
||||
.from('group')
|
||||
.select('organization_id')
|
||||
.eq('id', courseGroupId)
|
||||
.single();
|
||||
const { data: orgData } = await supabase.from('group').select('organization_id').eq('id', courseGroupId).single();
|
||||
|
||||
let isOrgAdmin = false;
|
||||
if (orgData?.organization_id) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
## Overview
|
||||
|
||||
We use **convention-based translation keys** for Zod validation errors. This approach:
|
||||
|
||||
- ✅ No metadata needed - just follow the naming pattern
|
||||
- ✅ Clean shared schemas without hardcoded messages
|
||||
- ✅ Automatic fallback to generic translations
|
||||
@@ -19,7 +20,7 @@ validations.{fieldName}.{errorCode}
|
||||
### Common Error Codes
|
||||
|
||||
| Error Code | When It Occurs | Example Field |
|
||||
|------------|----------------|---------------|
|
||||
| -------------------- | ------------------------------------------------------- | ----------------------- |
|
||||
| `too_small` | String too short, number too small, array too few items | username.too_small |
|
||||
| `too_big` | String too long, number too large, array too many items | bio.too_big |
|
||||
| `invalid_email` | Invalid email format | email.invalid_email |
|
||||
@@ -194,7 +195,7 @@ Zod validates in order and returns the **first error** it encounters:
|
||||
|
||||
```typescript
|
||||
// Schema
|
||||
z.string().min(3).max(20)
|
||||
z.string().min(3).max(20);
|
||||
|
||||
// Input: ""
|
||||
// Error: too_small (stops at first rule)
|
||||
@@ -240,6 +241,7 @@ function handleSubmit() {
|
||||
## Adding New Fields
|
||||
|
||||
1. **Add to schema** (in packages/utils):
|
||||
|
||||
```typescript
|
||||
export const ZUserProfile = z.object({
|
||||
// ... existing fields
|
||||
@@ -248,6 +250,7 @@ export const ZUserProfile = z.object({
|
||||
```
|
||||
|
||||
2. **Add translations**:
|
||||
|
||||
```json
|
||||
{
|
||||
"validations": {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
let { message, title } = $derived.by(() => {
|
||||
const fields = {
|
||||
title: '',
|
||||
message: '',
|
||||
message: ''
|
||||
};
|
||||
if (!type) return fields;
|
||||
|
||||
@@ -42,10 +42,7 @@
|
||||
});
|
||||
|
||||
function handleSupport() {
|
||||
window.open(
|
||||
'mailto:help@classroomio.com?subject=Email Verification Issue&body=Error Type: ' + type,
|
||||
'_blank'
|
||||
);
|
||||
window.open('mailto:help@classroomio.com?subject=Email Verification Issue&body=Error Type: ' + type, '_blank');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -55,7 +52,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<Empty
|
||||
title={title}
|
||||
{title}
|
||||
description={message}
|
||||
icon={OctagonAlertIcon}
|
||||
variant="page"
|
||||
@@ -63,10 +60,8 @@
|
||||
showLogo={true}
|
||||
class="h-fit! flex-none! max-h-none!"
|
||||
>
|
||||
{#if type === 'legacy_method_blocked'}
|
||||
<div
|
||||
class="rounded-md border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20"
|
||||
>
|
||||
{#if type === 'legacy_method_blocked'}
|
||||
<div class="rounded-md border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20">
|
||||
<div class="flex items-center">
|
||||
<TriangleAlertIcon class="h-5 w-5 flex-shrink-0 text-yellow-400" />
|
||||
<div class="ml-3">
|
||||
@@ -78,19 +73,14 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="lex w-full flex-col justify-center gap-4 md:flex-row">
|
||||
<Button
|
||||
onclick={handleSupport}
|
||||
variant="ghost"
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="lex w-full flex-col justify-center gap-4 md:flex-row">
|
||||
<Button onclick={handleSupport} variant="ghost">
|
||||
{$t('email_verification.actions.contact_support')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => goto('/')}
|
||||
>
|
||||
<Button onclick={() => goto('/')}>
|
||||
{$t('email_verification.actions.back_to_dashboard')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Empty>
|
||||
|
||||
@@ -77,8 +77,7 @@
|
||||
}
|
||||
console.log('path', path);
|
||||
console.log('isPublicRoute', isPublicRoute(path));
|
||||
console.log('data', $session.data)
|
||||
|
||||
console.log('data', $session.data);
|
||||
|
||||
// No need to require login for public routes
|
||||
if (isPublicRoute(path) && path !== '/') return;
|
||||
|
||||
@@ -19,9 +19,7 @@ export async function POST({ request }) {
|
||||
|
||||
try {
|
||||
// Clean up expired tokens
|
||||
const { data: cleanupResult, error } = await supabase.rpc(
|
||||
'cleanup_expired_verification_tokens'
|
||||
);
|
||||
const { data: cleanupResult, error } = await supabase.rpc('cleanup_expired_verification_tokens');
|
||||
|
||||
if (error) {
|
||||
console.error('Token cleanup failed:', error);
|
||||
|
||||
@@ -43,14 +43,11 @@ export async function GET({ url, request }) {
|
||||
|
||||
const recent = tokenStats.filter((token) => new Date(token.created_at) > last24Hours);
|
||||
const used = tokenStats.filter((token) => token.used_at);
|
||||
const expired = tokenStats.filter(
|
||||
(token) => new Date(token.expires_at) < now && !token.used_at
|
||||
);
|
||||
const expired = tokenStats.filter((token) => new Date(token.expires_at) < now && !token.used_at);
|
||||
|
||||
// Count unique IPs for anomaly detection
|
||||
const uniqueCreationIPs = new Set(tokenStats.map((t) => t.created_by_ip)).size;
|
||||
const uniqueUsageIPs = new Set(tokenStats.filter((t) => t.used_by_ip).map((t) => t.used_by_ip))
|
||||
.size;
|
||||
const uniqueUsageIPs = new Set(tokenStats.filter((t) => t.used_by_ip).map((t) => t.used_by_ip)).size;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
@@ -61,8 +58,7 @@ export async function GET({ url, request }) {
|
||||
expired_unused: expired.length,
|
||||
unique_creation_ips: uniqueCreationIPs,
|
||||
unique_usage_ips: uniqueUsageIPs,
|
||||
success_rate:
|
||||
tokenStats.length > 0 ? ((used.length / tokenStats.length) * 100).toFixed(1) : 0
|
||||
success_rate: tokenStats.length > 0 ? ((used.length / tokenStats.length) * 100).toFixed(1) : 0
|
||||
},
|
||||
recent_activity: recent.slice(0, 10).map((token) => ({
|
||||
created_at: token.created_at,
|
||||
|
||||
@@ -108,9 +108,7 @@ async function getCourseAnalytics(supabase: any, courseId: string): Promise<Cour
|
||||
|
||||
// Get student analytics
|
||||
const studentAnalytics = await Promise.all(
|
||||
members
|
||||
.filter((member) => member.role_id === 3)
|
||||
.map((member) => getStudentOverview(supabase, courseId, member))
|
||||
members.filter((member) => member.role_id === 3).map((member) => getStudentOverview(supabase, courseId, member))
|
||||
);
|
||||
|
||||
analytics.students = studentAnalytics.filter((student): student is StudentOverview => student !== null);
|
||||
@@ -155,11 +153,7 @@ async function getLastLogin(supabase: any, userId: string): Promise<string | und
|
||||
}
|
||||
}
|
||||
|
||||
async function getStudentOverview(
|
||||
supabase: any,
|
||||
courseId: string,
|
||||
member: any
|
||||
): Promise<StudentOverview | null> {
|
||||
async function getStudentOverview(supabase: any, courseId: string, member: any): Promise<StudentOverview | null> {
|
||||
try {
|
||||
// Use the same service function that the working analytics/user API uses
|
||||
const { data: courseProgressData, error: progressError } = await fetchProfileCourseProgress(
|
||||
|
||||
@@ -40,12 +40,7 @@ async function getCourse(supabase: any, courseId: string) {
|
||||
return course;
|
||||
}
|
||||
|
||||
async function getGroupMembers(
|
||||
supabase: any,
|
||||
groupId: string,
|
||||
isStudent: boolean,
|
||||
userMembership: any
|
||||
) {
|
||||
async function getGroupMembers(supabase: any, groupId: string, isStudent: boolean, userMembership: any) {
|
||||
if (isStudent) {
|
||||
return {
|
||||
id: groupId,
|
||||
@@ -53,11 +48,7 @@ async function getGroupMembers(
|
||||
};
|
||||
}
|
||||
|
||||
const { data: group, error } = await supabase
|
||||
.from('group')
|
||||
.select(GROUP_MEMBERS_SELECT)
|
||||
.eq('id', groupId)
|
||||
.single();
|
||||
const { data: group, error } = await supabase.from('group').select(GROUP_MEMBERS_SELECT).eq('id', groupId).single();
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching group members');
|
||||
|
||||
@@ -19,11 +19,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
// Get course group_id for permission check
|
||||
const { data: course } = await supabase
|
||||
.from('course')
|
||||
.select('group_id')
|
||||
.eq('id', courseId)
|
||||
.single();
|
||||
const { data: course } = await supabase.from('course').select('group_id').eq('id', courseId).single();
|
||||
|
||||
if (!course) {
|
||||
return json({ success: false, message: 'Course not found' }, { status: 404 });
|
||||
@@ -41,9 +37,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const { data: exercises, error } = await supabase
|
||||
.rpc('get_exercises')
|
||||
.eq('course_id', courseId);
|
||||
const { data: exercises, error } = await supabase.rpc('get_exercises').eq('course_id', courseId);
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching exercises');
|
||||
|
||||
@@ -19,11 +19,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
// Get course group_id for permission check
|
||||
const { data: course } = await supabase
|
||||
.from('course')
|
||||
.select('group_id')
|
||||
.eq('id', courseId)
|
||||
.single();
|
||||
const { data: course } = await supabase.from('course').select('group_id').eq('id', courseId).single();
|
||||
|
||||
if (!course) {
|
||||
return json({ success: false, message: 'Course not found' }, { status: 404 });
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
<Sidebar.Provider>
|
||||
<CourseSidebar {path} />
|
||||
|
||||
<Sidebar.Inset class="w-[calc(100vw-var(--sidebar-width))] overflow-x-hidden group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]">
|
||||
<Sidebar.Inset
|
||||
class="w-[calc(100vw-var(--sidebar-width))] overflow-x-hidden group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
|
||||
>
|
||||
<CourseHeader />
|
||||
|
||||
<Page.Root
|
||||
class="mx-auto flex px-4 lg:max-w-6xl"
|
||||
>
|
||||
<Page.Root class="mx-auto flex px-4 lg:max-w-6xl">
|
||||
{@render children?.()}
|
||||
</Page.Root>
|
||||
</Sidebar.Inset>
|
||||
|
||||
@@ -130,12 +130,6 @@
|
||||
</div>
|
||||
|
||||
<div class="my-4 flex w-full items-center justify-center">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disableSubmit || loading}
|
||||
loading={loading || !$profile.id}
|
||||
>
|
||||
Join Course
|
||||
</Button>
|
||||
<Button type="submit" disabled={disableSubmit || loading} loading={loading || !$profile.id}>Join Course</Button>
|
||||
</div>
|
||||
</AuthUI>
|
||||
|
||||
@@ -10,4 +10,3 @@
|
||||
<Page.Root class="w-full pb-10 md:max-w-3xl lg:mx-auto">
|
||||
{@render children?.()}
|
||||
</Page.Root>
|
||||
|
||||
|
||||
@@ -18,4 +18,3 @@
|
||||
<IntegrationsPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.svelte"],
|
||||
"options": {
|
||||
"svelteBracketNewLine": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -121,11 +121,8 @@ src/lib/
|
||||
**Import Pattern**:
|
||||
|
||||
```svelte
|
||||
// ✅ CORRECT - Use named imports from the index
|
||||
import { Footer, Navigation, Hero } from '$lib/components';
|
||||
|
||||
// ❌ INCORRECT - Direct file imports are not allowed
|
||||
// import Footer from '$lib/components/footer.svelte';
|
||||
// ✅ CORRECT - Use named imports from the index import {(Footer, Navigation, Hero)} from '$lib/components'; // ❌ INCORRECT
|
||||
- Direct file imports are not allowed // import Footer from '$lib/components/footer.svelte';
|
||||
```
|
||||
|
||||
**Note**: All components must be imported using named imports from `$lib/components`. Direct file imports are not allowed.
|
||||
@@ -150,8 +147,7 @@ import { Footer, Navigation, Hero } from '$lib/components';
|
||||
**Import Pattern**:
|
||||
|
||||
```svelte
|
||||
import FeedbackForm from '$lib/features/contact/feedback-form.svelte';
|
||||
import { htmlBody, openModal } from '$lib/features/tools/progress/store';
|
||||
import FeedbackForm from '$lib/features/contact/feedback-form.svelte'; import {(htmlBody, openModal)} from '$lib/features/tools/progress/store';
|
||||
```
|
||||
|
||||
### `/utils` - Utilities
|
||||
@@ -338,7 +334,7 @@ export { default as Navigation } from './navigation.svelte';
|
||||
This allows for clean, consistent imports:
|
||||
|
||||
```svelte
|
||||
import { Footer, Navigation, Hero } from '$lib/components';
|
||||
import {(Footer, Navigation, Hero)} from '$lib/components';
|
||||
```
|
||||
|
||||
**Rule**: Direct file imports (e.g., `import Footer from '$lib/components/footer.svelte'`) are **not allowed**. Always use named imports from `$lib/components`.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"prepare": "svelte-kit sync"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</script>
|
||||
|
||||
{#if !isRecommended}
|
||||
<div class="mb-5 flex md:flex-row flex-col md:items-center gap-2">
|
||||
<div class="mb-5 flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<!-- Date -->
|
||||
<p class="text-sm text-slate-500">{formatDate(post.date)}</p>
|
||||
|
||||
@@ -29,24 +29,16 @@
|
||||
{/if}
|
||||
|
||||
<a href="/blog/{post.slug}" class="group">
|
||||
<img
|
||||
loading="lazy"
|
||||
src={post.imageUrl}
|
||||
alt={post.title}
|
||||
class="w-70 h-48 object-cover rounded-md"
|
||||
/>
|
||||
<p
|
||||
class="font-bold text-lg py-2 {isRecommended &&
|
||||
'h-[60px]'} line-clamp-2 group-hover:text-slate-500"
|
||||
>
|
||||
<img loading="lazy" src={post.imageUrl} alt={post.title} class="w-70 h-48 rounded-md object-cover" />
|
||||
<p class="py-2 text-lg font-bold {isRecommended && 'h-[60px]'} line-clamp-2 group-hover:text-slate-500">
|
||||
{@html post.title}
|
||||
</p>
|
||||
|
||||
<p class="text-slate-500 pt-2 mb-4 line-clamp-3">{post.description}</p>
|
||||
<p class="mb-4 line-clamp-3 pt-2 text-slate-500">{post.description}</p>
|
||||
</a>
|
||||
|
||||
<div class="flex items-center justify-start gap-4 my-2">
|
||||
<img loading="lazy" src={post.avatar} alt="avatar" class="w-10 h-10 rounded-full" />
|
||||
<div class="my-2 flex items-center justify-start gap-4">
|
||||
<img loading="lazy" src={post.avatar} alt="avatar" class="h-10 w-10 rounded-full" />
|
||||
<span>
|
||||
<p class="font-semibold">{post.author}</p>
|
||||
<p class="text-slate-500">{post.role}</p>
|
||||
|
||||
@@ -26,14 +26,14 @@
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section {id} class="w-full py-32 border-b border-x-0 border-t-0 border-gray-200">
|
||||
<section {id} class="w-full border-x-0 border-b border-t-0 border-gray-200 py-32">
|
||||
<div
|
||||
class="w-4/5 mx-auto flex flex-col {rightToLeft
|
||||
class="mx-auto flex w-4/5 flex-col {rightToLeft
|
||||
? 'lg:flex-row-reverse'
|
||||
: 'lg:flex-row'} items-center justify-between gap-8 lg:gap-28"
|
||||
>
|
||||
<div class="w-[80vw] flex flex-col">
|
||||
<div class="my-2 lg:my-4 ml-1 flex gap-2 items-center">
|
||||
<div class="flex w-[80vw] flex-col">
|
||||
<div class="my-2 ml-1 flex items-center gap-2 lg:my-4">
|
||||
{#if taglineIcon === 'simplified'}
|
||||
<img width="27" height="27" loading="lazy" src={thumbsUp} alt="" class="w-7" />
|
||||
{:else if taglineIcon === 'flexible'}
|
||||
@@ -46,16 +46,16 @@
|
||||
{:else if taglineIcon === 'productivity'}
|
||||
<img width="27" height="27" loading="lazy" src={robotArm} alt="" class="w-7" />
|
||||
{/if}
|
||||
<p class="font-medium text-base">{tagline}</p>
|
||||
<p class="text-base font-medium">{tagline}</p>
|
||||
</div>
|
||||
<h2 class="text-3xl mb-3 lg:mb-6">{title}</h2>
|
||||
<p class="leading-8 text-lg text-gray-500">
|
||||
<h2 class="mb-3 text-3xl lg:mb-6">{title}</h2>
|
||||
<p class="text-lg leading-8 text-gray-500">
|
||||
{description}
|
||||
</p>
|
||||
{#if more}
|
||||
<br />
|
||||
|
||||
<p class="leading-8 text-lg text-gray-500">
|
||||
<p class="text-lg leading-8 text-gray-500">
|
||||
{@render more?.()}
|
||||
</p>
|
||||
{/if}
|
||||
@@ -64,7 +64,7 @@
|
||||
<video
|
||||
width="100%"
|
||||
height="100%"
|
||||
class="w-full h-auto lg:max-h-[80%] rounded-md shadow-xl"
|
||||
class="h-auto w-full rounded-md shadow-xl lg:max-h-[80%]"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { PUBLIC_ENABLE_STATS } from '$env/static/public';
|
||||
</script>
|
||||
|
||||
<section class="w-full py-32 border-b border-x-0 border-t-0 border-gray-200">
|
||||
<div class="justify-center flex text-center flex-wrap gap-x-12 gap-y-16">
|
||||
<section class="w-full border-x-0 border-b border-t-0 border-gray-200 py-32">
|
||||
<div class="flex flex-wrap justify-center gap-x-12 gap-y-16 text-center">
|
||||
<div class="w-full min-w-0" style="max-width: 1000px; flex: 1 1 550px;">
|
||||
<div class="mx-auto flex max-w-lg flex-col items-center gap-2">
|
||||
<div class="flex-none">
|
||||
@@ -19,19 +19,16 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-base leading-snug text-center">
|
||||
<span class="font-bold opacity-50 text-4xl">“</span>
|
||||
<div class="text-center text-base leading-snug">
|
||||
<span class="text-4xl font-bold opacity-50">“</span>
|
||||
<span class="text-gray-800" style="font-size: 17px;"
|
||||
>ClassroomIO really helped me bootstrap my program. The dashboard is extremely
|
||||
intuitive. I could navigate most of it without any help / support. Coming from framer, I
|
||||
can honestly say that I am extremely greatful that I found ClassroomIO.</span
|
||||
> <span class="font-bold opacity-50 inline-block text-4xl">”</span>
|
||||
>ClassroomIO really helped me bootstrap my program. The dashboard is extremely intuitive. I could navigate
|
||||
most of it without any help / support. Coming from framer, I can honestly say that I am extremely greatful
|
||||
that I found ClassroomIO.</span
|
||||
> <span class="inline-block text-4xl font-bold opacity-50">”</span>
|
||||
</div>
|
||||
<div class="mx-auto flex items-center gap-2 justify-center">
|
||||
<div
|
||||
class="flex max-w-[200px] flex-none flex-col overflow-hidden text-left"
|
||||
style="color: var(--clr-text);"
|
||||
>
|
||||
<div class="mx-auto flex items-center justify-center gap-2">
|
||||
<div class="flex max-w-[200px] flex-none flex-col overflow-hidden text-left" style="color: var(--clr-text);">
|
||||
<div class="text-center text-sm font-medium opacity-90">
|
||||
Pranav Singhal <div class="block text-xs font-medium opacity-70 hover:opacity-50">
|
||||
Founder at <a href="https://www.dblocked.io/" target="_blank">DBlockEd</a>
|
||||
@@ -80,26 +77,18 @@
|
||||
<!-- body section 5 -->
|
||||
{#if PUBLIC_ENABLE_STATS}
|
||||
<div
|
||||
class="flex justify-between flex-row flex-wrap lg:flex-nowrap items-center text-center px-[10%] py-[7%] border-b-[1px] gap-y-20 gap-x-20 mx-0 lg:mx-[12%]"
|
||||
class="mx-0 flex flex-row flex-wrap items-center justify-between gap-x-20 gap-y-20 border-b-[1px] px-[10%] py-[7%] text-center lg:mx-[12%] lg:flex-nowrap"
|
||||
>
|
||||
<div class="w-full">
|
||||
<h2
|
||||
class="w-full order-first text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl"
|
||||
>
|
||||
1 thousand
|
||||
</h2>
|
||||
<h2 class="order-first w-full text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl">1 thousand</h2>
|
||||
<p class="text-base leading-7 text-gray-600">Feedback collected</p>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<h2 class="order-first text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl">
|
||||
5 thousand
|
||||
</h2>
|
||||
<h2 class="order-first text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl">5 thousand</h2>
|
||||
<p class="text-base leading-7 text-gray-600">Documents processed</p>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<h2 class="order-first text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl">
|
||||
1 million
|
||||
</h2>
|
||||
<h2 class="order-first text-3xl font-semibold tracking-tight text-gray-900 sm:text-5xl">1 million</h2>
|
||||
<p class="text-base leading-7 text-gray-600">Page views recorded</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,5 @@
|
||||
</script>
|
||||
|
||||
<figure>
|
||||
<img
|
||||
{src}
|
||||
{alt}
|
||||
loading="lazy"
|
||||
class="max-w-full w-full h-full md:min-h-[400px] object-cover my-2 rounded-lg"
|
||||
/>
|
||||
<img {src} {alt} loading="lazy" class="my-2 h-full w-full max-w-full rounded-lg object-cover md:min-h-[400px]" />
|
||||
</figure>
|
||||
|
||||
@@ -6,19 +6,17 @@
|
||||
|
||||
<div class="mb-20 mt-10 flex flex-col items-center justify-center">
|
||||
<header class="text-center">
|
||||
<h1 class="mt-2 text-xl md:text-4xl">
|
||||
Questions & Answers
|
||||
</h1>
|
||||
<h1 class="mt-2 text-xl md:text-4xl">Questions & Answers</h1>
|
||||
<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?
|
||||
<a class="font-semibold text-blue-700" href="mailto:help@classroomio.com" target="_blank"
|
||||
>Shoot us an email</a> and we'll get back to you ASAP.
|
||||
<a class="font-semibold text-blue-700" href="mailto:help@classroomio.com" target="_blank">Shoot us an email</a> and
|
||||
we'll get back to you ASAP.
|
||||
</p>
|
||||
</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">
|
||||
{#each faqs as faq, index}
|
||||
<Accordion.Item value="faq-{index}">
|
||||
|
||||
@@ -26,17 +26,16 @@
|
||||
/></a
|
||||
> -->
|
||||
<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 />
|
||||
</Button>
|
||||
<Button href="https://twitter.com/classroomio" target="_blank" variant="ghost" aria-label="Twitter Link">
|
||||
<img
|
||||
width="20"
|
||||
height="20"
|
||||
loading="lazy"
|
||||
src="/twitter.svg"
|
||||
alt=""
|
||||
/>
|
||||
<img width="20" height="20" loading="lazy" src="/twitter.svg" alt="" />
|
||||
</Button>
|
||||
<Button href="https://www.youtube.com/@ClassroomIO" target="_blank" variant="ghost" aria-label="Youtube Link">
|
||||
<Youtube />
|
||||
@@ -130,9 +129,7 @@
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<footer
|
||||
class="border-t py-3 text-center text-xs font-light leading-5 text-gray-500 lg:text-left lg:text-base"
|
||||
>
|
||||
<footer 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 class="flex items-center justify-center">
|
||||
ClassroomIO © {new Date().getFullYear()} by
|
||||
|
||||
@@ -114,9 +114,9 @@
|
||||
<span>from one Platform</span>
|
||||
</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">
|
||||
ClassroomIO is an open source learning management system for companies. We give you a suite
|
||||
of tools to help you achieve your training, onboarding and certification goals.
|
||||
<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 of tools to help you
|
||||
achieve your training, onboarding and certification goals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -169,12 +169,8 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Book a demo
|
||||
</Button>
|
||||
<Button href="/signup" target="_blank" rel="noopener noreferrer nofollow">
|
||||
Sign Up for Free
|
||||
</Button>
|
||||
<Button variant="outline" href="/demo" target="_blank" rel="noopener noreferrer nofollow">Book a demo</Button>
|
||||
<Button href="/signup" target="_blank" rel="noopener noreferrer nofollow">Sign Up for Free</Button>
|
||||
<!-- <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"
|
||||
href="/github"
|
||||
@@ -195,10 +191,7 @@
|
||||
/>
|
||||
|
||||
</div> -->
|
||||
<div
|
||||
style="position: relative; padding-bottom: calc(50% + 42px);"
|
||||
class="mx-auto w-[85%] lg:w-[70%]"
|
||||
>
|
||||
<div style="position: relative; padding-bottom: calc(50% + 42px);" class="mx-auto w-[85%] lg:w-[70%]">
|
||||
<iframe
|
||||
title="ClassroomIO Live Demo"
|
||||
src="https://app.supademo.com/embed/cluw8chyy06nhq2s9m697a680"
|
||||
|
||||
@@ -13,10 +13,8 @@
|
||||
{#if open}
|
||||
<div class="relative flex items-center justify-center">
|
||||
<!-- black background -->
|
||||
<div class="fixed z-[3000] top-0 left-0 w-full h-full bg-black opacity-[0.7]"></div>
|
||||
<div
|
||||
class="fixed z-[3001] {className} w-full md:w-[30%] md:min-w-[500px] bg-white rounded-md mx-auto py-6 px-7"
|
||||
>
|
||||
<div class="fixed left-0 top-0 z-[3000] h-full w-full bg-black opacity-[0.7]"></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]">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,15 +11,13 @@
|
||||
{
|
||||
icon: globe,
|
||||
title: 'Customizable Landing Page',
|
||||
description:
|
||||
'A minimalistic landing page template out of the box to share your courses with the world',
|
||||
description: 'A minimalistic landing page template out of the box to share your courses with the world',
|
||||
image: '/landing-page-builder.webp'
|
||||
},
|
||||
{
|
||||
icon: team,
|
||||
title: 'Team management',
|
||||
description:
|
||||
'Extend invitations and manage your teaching institution seamlessly through ClassroomIO.',
|
||||
description: 'Extend invitations and manage your teaching institution seamlessly through ClassroomIO.',
|
||||
image: '/team-management.png'
|
||||
},
|
||||
// {
|
||||
@@ -49,39 +47,28 @@
|
||||
</script>
|
||||
|
||||
<!-- 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="w-full lg:w-4/5 mx-4 lg:mx-0 mb-14">
|
||||
<h2 class="mt-2 text-3xl tracking-tight">
|
||||
There is More...
|
||||
</h2>
|
||||
<p class="mt-4 text-lg text-gray-500 w-[94%] lg:w-4/5">
|
||||
<div class="mx-4 mb-14 w-full lg:mx-0 lg:w-4/5">
|
||||
<h2 class="mt-2 text-3xl tracking-tight">There is More...</h2>
|
||||
<p class="mt-4 w-[94%] text-lg text-gray-500 lg:w-4/5">
|
||||
ClassroomIO is packed with useful features while we try to make it easy to use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse lg:flex-row gap-3 md:gap-10" id="image-container">
|
||||
<div
|
||||
class="w-[95%] p-3 mt-5 overflow-auto lg:overflow-hidden lg:mt-0 lg:w-[45%] flex lg:block"
|
||||
>
|
||||
<div class="flex flex-col-reverse gap-3 md:gap-10 lg:flex-row" id="image-container">
|
||||
<div class="mt-5 flex w-[95%] overflow-auto p-3 lg:mt-0 lg:block lg:w-[45%] lg:overflow-hidden">
|
||||
{#each moreFeatures as moreFeature, i}
|
||||
<button
|
||||
class="p-4 rounded-md {i === selected &&
|
||||
'selected'} mb-3 text-start min-w-[75vw] lg:min-w-[unset] cursor-pointer"
|
||||
class="rounded-md p-4 {i === selected &&
|
||||
'selected'} mb-3 min-w-[75vw] cursor-pointer text-start lg:min-w-[unset]"
|
||||
onclick={() => {
|
||||
selected = i;
|
||||
clearInterval(interval);
|
||||
}}
|
||||
>
|
||||
<div class="flex gap-1 items-center">
|
||||
<img
|
||||
loading="lazy"
|
||||
width="32"
|
||||
height="32"
|
||||
src={moreFeature.icon}
|
||||
alt=""
|
||||
class="w-8"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<img loading="lazy" width="32" height="32" src={moreFeature.icon} alt="" class="w-8" />
|
||||
<p class="text-lg">
|
||||
{moreFeature.title}
|
||||
</p>
|
||||
@@ -100,7 +87,7 @@
|
||||
loading="lazy"
|
||||
id="image"
|
||||
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}
|
||||
alt={moreFeatures[selected].title}
|
||||
/>
|
||||
@@ -112,11 +99,6 @@
|
||||
|
||||
<style>
|
||||
#morefeatures .selected {
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
rgb(253, 239, 132),
|
||||
rgb(247, 198, 169),
|
||||
rgb(21, 186, 196)
|
||||
);
|
||||
background-image: linear-gradient(45deg, rgb(253, 239, 132), rgb(247, 198, 169), rgb(21, 186, 196));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
|
||||
import { cn } from '@cio/ui/tools';
|
||||
|
||||
|
||||
let { stars }: { stars: number } = $props();
|
||||
|
||||
type ListItemProps = HTMLAttributes<HTMLAnchorElement> & {
|
||||
@@ -126,7 +125,7 @@
|
||||
<p class="text-sm">
|
||||
{title}
|
||||
</p>
|
||||
<p class="mt-1 text-xs ui:text-muted-foreground">
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
@@ -136,8 +135,8 @@
|
||||
</li>
|
||||
{/snippet}
|
||||
|
||||
<section class="z-50 fixed top-0 w-full bg-white border-b">
|
||||
<div class="mx-auto flex w-full items-center justify-between gap-20 py-1 lg:w-[90%] lg:px-0 px-4">
|
||||
<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 px-4 py-1 lg:w-[90%] lg:px-0">
|
||||
<a href="/" class="w-[10%]">
|
||||
<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" />
|
||||
@@ -151,7 +150,7 @@
|
||||
<NavigationMenu.Item>
|
||||
<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",
|
||||
'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!'
|
||||
)}
|
||||
>
|
||||
@@ -173,7 +172,10 @@
|
||||
|
||||
<NavigationMenu.Item>
|
||||
<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>
|
||||
</NavigationMenu.Trigger>
|
||||
@@ -278,7 +280,7 @@
|
||||
<li class="w-full cursor-pointer text-sm font-semibold text-gray-800 md:text-lg">
|
||||
<button
|
||||
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'
|
||||
)}
|
||||
onclick={handleShowDrawer}
|
||||
@@ -313,7 +315,7 @@
|
||||
</a>
|
||||
<a
|
||||
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'
|
||||
)}
|
||||
onclick={() => {
|
||||
@@ -325,7 +327,7 @@
|
||||
</a>
|
||||
<a
|
||||
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'
|
||||
)}
|
||||
onclick={() => {
|
||||
@@ -337,7 +339,7 @@
|
||||
</a>
|
||||
<a
|
||||
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'
|
||||
)}
|
||||
onclick={() => {
|
||||
|
||||
@@ -22,18 +22,13 @@
|
||||
}
|
||||
</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
|
||||
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."
|
||||
icon={SearchXIcon}
|
||||
variant="page"
|
||||
>
|
||||
<Button
|
||||
onclick={handleClick}
|
||||
>
|
||||
Go Home
|
||||
</Button>
|
||||
<Button onclick={handleClick}>Go Home</Button>
|
||||
</Empty>
|
||||
</div>
|
||||
|
||||
@@ -13,26 +13,19 @@
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<h3
|
||||
class="font-display mt-3 text-xl tracking-tight text-white lg:mt-2 lg:text-4xl max-w-2xl"
|
||||
>
|
||||
<h3 class="font-display mt-3 max-w-2xl text-xl tracking-tight text-white lg:mt-2 lg:text-4xl">
|
||||
{header}
|
||||
</h3>
|
||||
<p class="mt-6 text-md text-gray-300">
|
||||
<p class="text-md mt-6 text-gray-300">
|
||||
{subText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if demo}
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-cal-config="'layout':'month_view'"
|
||||
data-cal-link="classroomio/demo"
|
||||
class="bg-white!"
|
||||
>
|
||||
<Button variant="ghost" data-cal-config="'layout':'month_view'" data-cal-link="classroomio/demo" class="bg-white!">
|
||||
{btnLabel}
|
||||
</Button>
|
||||
{:else}
|
||||
|
||||
@@ -2,24 +2,20 @@
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
</script>
|
||||
|
||||
<section id="student-dashboard" class="text-center text-white py-[10%] px-3 lg:px-0 bg-black">
|
||||
<Badge class="py-2! px-4! underline text-sm!">yourorg.classroomio.com</Badge>
|
||||
<h1
|
||||
class="mx-auto text-3xl md:text-4xl lg:text-6xl font-normal leading-[1.5] flex flex-col items-center gap-4"
|
||||
>
|
||||
<section id="student-dashboard" class="bg-black px-3 py-[10%] text-center text-white lg:px-0">
|
||||
<Badge class="py-2! px-4! text-sm! underline">yourorg.classroomio.com</Badge>
|
||||
<h1 class="mx-auto flex flex-col items-center gap-4 text-3xl font-normal leading-[1.5] md:text-4xl lg:text-6xl">
|
||||
Your Student Dashboard
|
||||
</h1>
|
||||
|
||||
<p
|
||||
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.
|
||||
<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">
|
||||
Once you create an account, you get a dedicated dashboard where your students can access all their courses,
|
||||
assignments, and more.
|
||||
</p>
|
||||
|
||||
<div class="relative my-5 md:my-10">
|
||||
<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
|
||||
loop
|
||||
muted
|
||||
|
||||