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