feat: migrate course UI from supabase (#615)

* chore: fix storybook build

* chore: implement theming

* community list not showing properly

* refactor: migrate Chip component to @cio/ui/custom/chip

- Updated imports for the Chip component across multiple files to use the new location in the @cio/ui package.
- Replaced the previous Chip implementation with a new version that utilizes a Badge for better styling and functionality.
- Adjusted related components to ensure consistent usage of the updated Chip component.

* fix: align course sidebar design to match organization design (#611)

* fix: align course sidebar design to match organization design

* feat: add hideSearch prop to AppHeader and update sidebar layout

---------

Co-authored-by: Rotimi Best <best@classroomio.com>

* refactor: replace PrimaryButton with Button component across multiple files

- Updated various components to use the new Button component from @cio/ui/base/button instead of the deprecated PrimaryButton.
- Ensured consistent button usage throughout the application for improved styling and functionality.
- Removed the PrimaryButton component and its associated constants from the codebase.

* refactor: standardize button and input usage across components

- Replaced instances of deprecated components such as TextField, TextArea, and IconButton with their updated counterparts (InputField, TextareaField, and updated IconButton).
- Ensured consistent event handling by changing `onClick` to `onclick` for better alignment with Svelte's event binding conventions.
- Removed unused components and streamlined imports to enhance code maintainability and readability.

* chore: themeing, course card, use $features

* refactor: centralize UI components and update modal implementations

- Refactored various components to centralize UI elements, improving discoverability and maintainability.
- Replaced deprecated modal components with the new Dialog component for consistent modal behavior across the application.
- Removed unused components and streamlined imports to enhance code clarity and reduce complexity.

* feat: add new documentation and refactor org and lms UI components

- Introduced new documentation files for Auth Flow, Breadcrumbs Refactoring, Page and Route Creation, and Route Page Setup to enhance developer onboarding and reference.
- Refactored various UI components, including the Course Sidebar and Lesson Modal, to improve layout and functionality.
- Removed deprecated components and streamlined imports for better maintainability.
- Updated translations across multiple languages to ensure consistency in user experience.

* refactor: update UI components and improve styling consistency

- Refactored various components to enhance styling consistency, including updates to button and text color classes.
- Removed deprecated components and streamlined imports for better maintainability.
- Replaced instances of PageBody with a more flexible Page component structure across multiple routes.
- Updated translations and UI elements to align with the new design system.
- Deleted unused components to reduce code complexity and improve clarity.

* update output.css

---------

Co-authored-by: Abdulmuizz Hamzat <103611443+canhamzacode@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2025-12-22 02:36:48 +01:00
committed by GitHub
co-authored by Abdulmuizz Hamzat
parent e6a3cf9332
commit 9fb6c7cbaa
401 changed files with 10489 additions and 11679 deletions
+106 -3
View File
@@ -1495,7 +1495,7 @@ Replace Supabase calls with API class method:
**After (API Route):**
```typescript
<script lang="ts">
import { orgApi } from '$lib/features/org/api/org.svelte';
import { orgApi } from '$features/org/api/org.svelte';
import { goto } from '$app/navigation';
async function createNewOrg() {
@@ -1941,7 +1941,7 @@ export class OrgApiServer {
```typescript
// apps/dashboard/src/routes/invite/t/[hash]/+layout.server.ts
import { OrgApiServer } from '$lib/features/org/api/org.server';
import { OrgApiServer } from '$features/org/api/org.server';
export const load = async ({ params }) => {
// Use static method from .server.ts class
@@ -1995,7 +1995,7 @@ export class OrgPlanApiServer {
```typescript
// apps/dashboard/src/routes/api/polar/webhook/+server.ts
import { OrgPlanApiServer } from '$lib/features/org/api/org-plan.server';
import { OrgPlanApiServer } from '$features/org/api/org-plan.server';
// Use static method from .server.ts class
const result = await OrgPlanApiServer.createOrgPlan(planData);
@@ -2420,3 +2420,106 @@ Frontend Response (maps errors to translations if validation failed)
```
By following these patterns, our API remains **consistent**, **secure**, **testable**, and **ready for public consumption**.
---
## Refactoring Components to Features/UI
When moving components from `apps/dashboard/src/lib/components/` to `apps/dashboard/src/lib/features/ui/`, follow this workflow:
### Step 1: Determine Component Structure
**Single-file components**: Move directly to `features/ui/` with kebab-case naming:
- `components/CodeSnippet/index.svelte` → `features/ui/code-snippet.svelte`
**Components with related files** (stores, utils, etc.): Create a folder matching the component name:
- `components/Confetti/index.svelte` + `store.ts` → `features/ui/confetti/confetti.svelte` + `confetti-store.ts`
- `components/UploadWidget/index.svelte` + `utils.ts` → `features/ui/upload-widget/upload-widget.svelte` + `utils.ts`
**Naming Convention**:
- ✅ File name matches folder name: `confetti/confetti.svelte`, `upload-widget/upload-widget.svelte`
- ❌ Don't use `index.svelte` as the root file - always name it the same as the folder
### Step 2: Move Files
1. Create the new file(s) in `features/ui/` (or subfolder if needed)
2. Update internal imports (e.g., `./store` → `./confetti-store` within the folder)
3. Copy component code to new location
### Step 3: Update Exports
Add export to `apps/dashboard/src/lib/features/ui/index.ts`:
```typescript
export { default as ComponentName } from './component-name.svelte';
// or for folder-based components:
export { default as ComponentName } from './component-name/component-name.svelte';
```
Keep exports alphabetically sorted.
### Step 4: Update All Imports
Find all usages:
```bash
grep -r "components/OldComponent" apps/dashboard/src
```
Replace imports:
- **From**: `import Component from '$lib/components/OldComponent/index.svelte';`
- **To**: `import { Component } from '$features/ui';`
For store/utils imports:
- **From**: `import { store } from '$lib/components/OldComponent/store';`
- **To**: `import { store } from '$features/ui/component-name/store';`
### Step 5: Merge Duplicate Imports
After updating imports, check for duplicate imports from the same source and merge them:
```typescript
// Before
import { Confetti } from '$features/ui';
import { ComingSoon } from '$features/ui';
// After
import { Confetti, ComingSoon } from '$features/ui';
```
### Step 6: Clean Up
1. Delete old component files
2. Remove empty folders: `rmdir apps/dashboard/src/lib/components/OldComponent`
3. Verify no remaining references: `grep -r "components/OldComponent" apps/dashboard/src`
4. Run linter to check for errors
### Step 7: Verify
- ✅ No linter errors
- ✅ No remaining references to old path
- ✅ All imports use the new path
- ✅ Component exports correctly from `features/ui/index.ts`
- ✅ Related files (stores/utils) are properly organized
### Example: Complete Refactoring
**Before**:
```
apps/dashboard/src/lib/components/
└── Confetti/
├── index.svelte
└── store.ts
```
**After**:
```
apps/dashboard/src/lib/features/ui/
├── confetti/
│ ├── confetti.svelte
│ └── store.ts
└── index.ts (exports Confetti)
```
**Import changes**:
- Component: `import { Confetti } from '$features/ui';`
- Store: `import { toggleConfetti } from '$features/ui/confetti/store';`
This refactoring centralizes UI components in a single location, making them easier to discover, maintain, and reuse across the application.
+702
View File
@@ -0,0 +1,702 @@
# Breadcrumbs Refactoring Guide
This document explains how to migrate from the old breadcrumb system to the new unified system that addresses all scalability limitations.
## Migration Steps
### Step 1: Update Navigation Config Types
The navigation config types now support additional properties:
```typescript
export interface NavItemConfig {
// ... existing properties ...
// NEW: Route-specific breadcrumb label override
breadcrumbLabel?: string;
// NEW: Custom resolver for dynamic breadcrumb titles
breadcrumbResolver?: (params: Record<string, string>) => string | Promise<string>;
// ENHANCED: Nested routes can now have nested routes (multi-level)
nestedRoutes?: NestedRouteConfig[];
}
export interface NestedRouteConfig {
path: string;
titleKey: string;
// NEW: Custom resolver for this nested route
titleResolver?: (params: Record<string, string>) => string | Promise<string>;
// NEW: Nested routes can have nested routes
nestedRoutes?: NestedRouteConfig[];
// NEW: Support dynamic segments within nested routes
supportsDynamicSegment?: boolean;
}
```
### Step 2: Update Breadcrumb Generation Calls
#### Before (Org):
```typescript
import { generateBreadcrumbs } from './breadcrumb';
const breadcrumbs = generateBreadcrumbs(
page.url.pathname,
page.url.search,
navItems,
$currentOrgPath,
page.data as { breadcrumb?: string } | undefined
);
```
#### After (Org - Store-Based Reactive):
```svelte
<script lang="ts">
import { page } from '$app/state';
import { generateBreadcrumbsSync } from './breadcrumb-core';
import { createOrgBreadcrumbConfigWithStore } from './breadcrumb-configs';
import { courseStore } from '$features/course/utils/store';
// Reactive config that reads from stores
const config = $derived(createOrgBreadcrumbConfigWithStore(
$currentOrgPath,
() => $courseStore.title // Reads reactively - updates automatically!
));
// Breadcrumbs update automatically when store changes!
const breadcrumbs = $derived(
generateBreadcrumbsSync(
page.url.pathname,
page.url.search,
navItems,
config
)
);
</script>
```
#### Before (LMS):
```typescript
import { generateLmsBreadcrumbs } from './lms-breadcrumb';
import { currentCommunityQuestion } from '$features/community/utils/store';
const breadcrumbTitle = $derived($currentCommunityQuestion.title);
const breadcrumbs = generateLmsBreadcrumbs(
page.url.pathname,
page.url.search,
navItems,
breadcrumbTitle ? { breadcrumb: breadcrumbTitle } : undefined
);
```
#### After (LMS - Reactive Store-Based):
```svelte
<script lang="ts">
import { page } from '$app/state';
import { generateBreadcrumbsSync } from './breadcrumb-core';
import { createLmsBreadcrumbConfigWithStore } from './breadcrumb-configs';
import { currentCommunityQuestion } from '$features/community/utils/store';
// Config is reactive - updates when store changes
const config = $derived(createLmsBreadcrumbConfigWithStore(
() => $currentCommunityQuestion.title // Reads reactively from store
));
// Breadcrumbs update automatically when store changes!
const breadcrumbs = $derived(
generateBreadcrumbsSync(
page.url.pathname,
page.url.search,
navItems,
config
)
);
</script>
```
### Step 3: Support Multi-Level Nesting (Up to 5 Layers)
The refactored system supports up to 5 layers of nesting through recursive traversal. Here's how it works:
#### How Deep Nesting Works
1. **Recursive Processing**: The `generateNestedBreadcrumbs` function processes path segments recursively, incrementing a `depth` counter
2. **Depth Limit**: Default `maxDepth` is 10, but can be configured (supports up to 5 layers by default)
3. **Segment-by-Segment Matching**: Each segment is matched against nested route definitions
4. **Dynamic Title Resolution**: Dynamic segments resolve titles via `titleResolver` or `dynamicTitleResolver`
#### Example: Courses → Course → Lesson → Assignment → Submission (5 Layers)
**Navigation Config** (in `org-navigation.ts`):
```typescript
{
titleKey: 'org_navigation.courses',
path: '/courses',
nestedRoutes: [
{
path: '[id]',
titleKey: 'Course',
supportsDynamicSegment: true,
// Note: titleResolver can be sync (store-based) or async (API-based)
nestedRoutes: [
{
path: 'lessons',
titleKey: 'Lessons',
nestedRoutes: [
{
path: '[lessonId]',
titleKey: 'Lesson',
supportsDynamicSegment: true,
nestedRoutes: [
{
path: 'assignments',
titleKey: 'Assignments',
nestedRoutes: [
{
path: '[assignmentId]',
titleKey: 'Assignment',
supportsDynamicSegment: true,
nestedRoutes: [
{
path: 'submissions',
titleKey: 'Submissions',
nestedRoutes: [
{
path: '[submissionId]',
titleKey: 'Submission',
supportsDynamicSegment: true
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
```
**Breadcrumb Component** (in `app-breadcrumbs.svelte`):
```svelte
<script lang="ts">
import { page } from '$app/state';
import { generateBreadcrumbsSync } from './breadcrumb-core';
import { createOrgBreadcrumbConfigWithStore } from './breadcrumb-configs';
import { assignmentStore, submissionStore, courseStore, lessonStore } from '$features/course/utils/store';
// Create reactive config that reads from stores
const config = $derived(createOrgBreadcrumbConfigWithStore(
$currentOrgPath,
() => {
// This function is called reactively - reads from stores
const pathSegments = page.url.pathname.split('/').filter(Boolean);
// Determine which store to read based on current route
if (pathSegments.includes('submissions') && pathSegments.length > 5) {
return $submissionStore.title || 'Submission';
}
if (pathSegments.includes('assignments') && pathSegments.length > 4) {
return $assignmentStore.title || 'Assignment';
}
if (pathSegments.includes('lessons') && pathSegments.length > 3) {
return $lessonStore.title || 'Lesson';
}
if (pathSegments.includes('courses') && pathSegments.length > 2) {
return $courseStore.title || 'Course';
}
return null;
}
));
const breadcrumbs = $derived(
generateBreadcrumbsSync(
page.url.pathname,
page.url.search,
navItems,
config
)
);
</script>
```
**Alternative: Per-Route Store Resolvers** (More Granular):
```svelte
<script lang="ts">
import { page } from '$app/state';
import { generateBreadcrumbsSync } from './breadcrumb-core';
import { createOrgBreadcrumbConfigWithStore } from './breadcrumb-configs';
import { assignmentStore, submissionStore, courseStore, lessonStore } from '$features/course/utils/store';
// Create config with store-based resolver
const config = $derived(createOrgBreadcrumbConfigWithStore(
$currentOrgPath,
() => {
// Read from stores reactively based on path structure
const pathSegments = page.url.pathname.split('/').filter(Boolean);
// Match store to segment position
if (pathSegments.includes('submissions')) {
return $submissionStore.title || null;
}
if (pathSegments.includes('assignments')) {
return $assignmentStore.title || null;
}
if (pathSegments.includes('lessons')) {
return $lessonStore.title || null;
}
if (pathSegments.includes('courses')) {
return $courseStore.title || null;
}
return null;
}
));
const breadcrumbs = $derived(
generateBreadcrumbsSync(
page.url.pathname,
page.url.search,
navItems,
config
)
);
</script>
```
**URL**: `/org/myorg/courses/123/lessons/456/assignments/789/submissions/101`
**Result**: `Courses / React Fundamentals / Lessons / Components / Assignments / Build Todo App / Submissions / Submission #101`
**Key Benefit**: When stores update (e.g., `assignmentStore.title` changes), the breadcrumb automatically updates reactively without any API calls!
#### Key Points for 5-Layer Nesting:
1. **Each Level Must Define Nested Routes**: For nesting to work, each level must have `nestedRoutes` defined
2. **Dynamic Segments Need `supportsDynamicSegment: true`**: Mark dynamic segments explicitly
3. **Store-Based Resolvers Are Reactive**: Use stores instead of async requests for automatic updates
4. **Static Routes Are Checked First**: Static nested routes (like `lessons`, `assignments`) are matched before dynamic segments
5. **Recursive Depth Tracking**: The function tracks depth and stops at `maxDepth` (default: 10, supports 5 layers easily)
#### Store-Based vs Async Resolvers
**✅ Recommended: Store-Based (Reactive)**
```typescript
// In breadcrumb component
const config = $derived(createOrgBreadcrumbConfigWithStore(
$currentOrgPath,
() => $assignmentStore.title // Reads reactively from store
));
// Breadcrumbs update automatically when store changes!
const breadcrumbs = $derived(generateBreadcrumbsSync(...));
```
**⚠️ Alternative: Async Resolvers (API Calls)**
```typescript
// In navigation config
titleResolver: async (params) => {
const assignment = await fetchAssignment(params.param0);
return assignment.title;
}
```
**Why Use Stores?**
- ✅ **No API calls** - data already loaded in component
- ✅ **Automatic updates** - breadcrumb updates when store changes
- ✅ **Better performance** - no loading states or delays
- ✅ **Reactive** - works seamlessly with Svelte's reactivity system
#### Parameter Extraction for Deep Nesting
The `extractParamsFromPath` function extracts all path segments as `param0`, `param1`, `param2`, etc.:
```typescript
// For URL: /courses/123/lessons/456/assignments/789
// params = {
// param0: '123', // courseId
// param1: '456', // lessonId
// param2: '789' // assignmentId
// }
```
**Note**: When using stores, you typically don't need these params - just read from the store directly!
### Step 4: Add Route-Specific Overrides
```typescript
{
titleKey: 'org_navigation.courses',
path: '/courses',
breadcrumbLabel: 'My Courses', // Override default title
breadcrumbResolver: (params) => {
// Custom logic for this route
return `Courses (${params.count || ''})`;
}
}
```
### Step 5: Use Async Resolvers When Needed
For routes that need async data fetching:
```typescript
import { generateBreadcrumbs } from './breadcrumb-core'; // Note: async version
const breadcrumbs = await generateBreadcrumbs(
page.url.pathname,
page.url.search,
navItems,
config
);
```
## Benefits of the Refactored System
### ✅ 1. No Code Duplication
- Single unified generator for all contexts
- Context-specific configs handle differences
### ✅ 2. Multi-Level Nesting Support
- Recursive generation handles any depth
- Configurable max depth prevents infinite loops
### ✅ 3. Flexible Path Prefixes
- Function-based prefix extraction
- Supports dynamic prefixes like `/org/[slug]`
### ✅ 4. Consistent Dynamic Title Resolution
- Unified interface for all resolvers
- Supports both sync and async resolution
### ✅ 5. Route-Specific Customization
- Override labels per route
- Custom resolvers for complex cases
### ✅ 6. Optional Caching
- Performance optimization
- Configurable cache TTL
### ✅ 7. Type Safety
- Full TypeScript support
- Compile-time error checking
## Example: Complex Multi-Level Route (5 Layers)
**URL**: `/org/myorg/courses/123/lessons/456/assignments/789/submissions/101`
**Config**:
```typescript
{
path: '/courses',
nestedRoutes: [
{
path: '[id]',
supportsDynamicSegment: true,
titleResolver: async (params) => {
const course = await getCourse(params.param0);
return course.title;
},
nestedRoutes: [
{
path: 'lessons',
titleKey: 'Lessons',
nestedRoutes: [
{
path: '[lessonId]',
supportsDynamicSegment: true,
titleResolver: async (params) => {
const lesson = await getLesson(params.param0, params.param1);
return lesson.title;
},
nestedRoutes: [
{
path: 'assignments',
titleKey: 'Assignments',
nestedRoutes: [
{
path: '[assignmentId]',
supportsDynamicSegment: true,
titleResolver: async (params) => {
const assignment = await getAssignment(
params.param0, // courseId
params.param1, // lessonId
params.param2 // assignmentId
);
return assignment.title;
},
nestedRoutes: [
{
path: 'submissions',
titleKey: 'Submissions',
nestedRoutes: [
{
path: '[submissionId]',
supportsDynamicSegment: true,
titleResolver: async (params) => {
const submission = await getSubmission(
params.param0, // courseId
params.param1, // lessonId
params.param2, // assignmentId
params.param3 // submissionId
);
return `Submission #${submission.id}`;
}
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
```
**Result**: `Courses / React Fundamentals / Lessons / Components / Assignments / Build Todo App / Submissions / Submission #101`
### How the Recursion Works for 5 Layers:
1. **Initial Call** (`depth=0`): Processes `/courses` → finds nav item → adds "Courses"
2. **First Recursion** (`depth=1`): Processes `123` → dynamic segment → resolves course title → adds "React Fundamentals"
3. **Second Recursion** (`depth=2`): Processes `lessons` → static nested route → adds "Lessons"
4. **Third Recursion** (`depth=3`): Processes `456` → dynamic segment → resolves lesson title → adds "Components"
5. **Fourth Recursion** (`depth=4`): Processes `assignments` → static nested route → adds "Assignments"
6. **Fifth Recursion** (`depth=5`): Processes `789` → dynamic segment → resolves assignment title → adds "Build Todo App"
7. **Sixth Recursion** (`depth=6`): Processes `submissions` → static nested route → adds "Submissions"
8. **Seventh Recursion** (`depth=7`): Processes `101` → dynamic segment → resolves submission → adds "Submission #101"
**Depth Limit**: Default `maxDepth` is 10, so this 5-layer example (which uses depth 7) works perfectly.
## How 5 Layers of Nesting Works
### Depth Calculation
The recursive function processes **each path segment** separately, incrementing depth on each recursive call. For a 5-layer breadcrumb path:
**Example Path**: `/courses/123/lessons/456/assignments/789/submissions/101`
**Breadcrumb Result**: `Courses / React Fundamentals / Lessons / Components / Assignments / Build Todo App / Submissions / Submission #101`
**Depth Breakdown**:
- `depth=0`: Top-level nav item (`/courses`) → "Courses"
- `depth=1`: First dynamic segment (`123`) → "React Fundamentals"
- `depth=2`: Static nested route (`lessons`) → "Lessons"
- `depth=3`: Second dynamic segment (`456`) → "Components"
- `depth=4`: Static nested route (`assignments`) → "Assignments"
- `depth=5`: Third dynamic segment (`789`) → "Build Todo App"
- `depth=6`: Static nested route (`submissions`) → "Submissions"
- `depth=7`: Fourth dynamic segment (`101`) → "Submission #101"
**Total Depth**: 7 levels (includes static route labels)
**Default `maxDepth`**: 10, which easily supports 5 layers of actual content nesting.
### Recursive Processing Flow
```typescript
generateNestedBreadcrumbsSync(segments, navItem, currentPath, ..., depth=0)
├─ Process currentSegment (segments[0])
├─ Check: Is it a static nested route?
│ ├─ YES: Add breadcrumb → Recurse with remaining segments (depth+1)
│ └─ NO: Continue to dynamic check
├─ Check: Is it a dynamic segment?
│ ├─ YES: Resolve title → Add breadcrumb → Recurse with remaining segments (depth+1)
│ └─ NO: Return (no more breadcrumbs)
└─ Stop if: depth >= maxDepth OR segments.length === 0
```
### Key Implementation Details
1. **Segment-by-Segment Processing**: Each recursive call processes ONE segment and passes remaining segments to the next call
2. **Static Routes First**: Static nested routes (like `lessons`, `assignments`) are checked before dynamic segments
3. **Nested Route Matching**: When a static route is found, its `nestedRoutes` become the `navItem.nestedRoutes` for the next recursion
4. **Dynamic Segment Handling**: When a dynamic segment is found, it resolves the title and continues with remaining segments if `nestedRoutes` exist
5. **Depth Safety**: The `maxDepth` check prevents infinite recursion (default: 10, configurable)
### Why This Supports 5 Layers
- **Default `maxDepth` of 10** provides plenty of headroom for 5 layers
- **Recursive structure** naturally handles any depth
- **Each layer increments depth by 1**, so 5 layers = depth 5-7 (depending on static route labels)
- **Configurable `maxDepth`** allows adjustment if needed
## Performance Considerations
1. **Caching**: Enable for routes with expensive resolvers
2. **Async Resolvers**: Use sparingly - prefer sync resolvers when possible
3. **Max Depth**: Default is 10 (supports 5 layers easily), but can be customized:
```svelte
<script lang="ts">
const baseConfig = createOrgBreadcrumbConfigWithStore($currentOrgPath, () => $courseStore.title);
const config = $derived({ ...baseConfig, maxDepth: 15 });
</script>
```
4. **Cache Invalidation**: Clear cache when navigation config changes
5. **Recursive Calls**: Each nested level adds one recursive call - 5 layers = ~7-10 calls total (very efficient)
## Testing
```typescript
import { generateBreadcrumbsSync, clearBreadcrumbCache } from './breadcrumb-core';
// Clear cache before tests
beforeEach(() => {
clearBreadcrumbCache();
});
// Test breadcrumb generation
test('generates breadcrumbs for nested route', () => {
const breadcrumbs = generateBreadcrumbsSync(
'/org/test/courses/123/lessons/456',
'',
navItems,
config
);
expect(breadcrumbs).toHaveLength(4);
expect(breadcrumbs[0].label).toBe('Courses');
});
```
## Backward Compatibility
The old functions (`generateBreadcrumbs`, `generateLmsBreadcrumbs`) can be kept as wrappers:
```typescript
// breadcrumb.ts (legacy wrapper - using store-based approach)
import { generateBreadcrumbsSync } from './breadcrumb-core';
import { createOrgBreadcrumbConfigWithStore } from './breadcrumb-configs';
import { courseStore } from '$features/course/utils/store';
export function generateBreadcrumbs(
pathname: string,
searchParams: string,
navItems: NavItem[],
currentOrgPath: string,
getStoreValue?: () => string | null | undefined
) {
const config = createOrgBreadcrumbConfigWithStore(
currentOrgPath,
getStoreValue || (() => $courseStore.title || null)
);
return generateBreadcrumbsSync(pathname, searchParams, navItems, config);
}
```
This allows gradual migration without breaking existing code.
## Reactive Store-Based Title Resolution
### Why Use Stores Instead of Async Requests?
**✅ Benefits:**
- **No API calls** - Data is already loaded in your component
- **Automatic updates** - Breadcrumbs update reactively when stores change
- **Better performance** - No loading states, delays, or network requests
- **Simpler code** - Just read from stores, no async/await needed
- **Reactive by default** - Works seamlessly with Svelte's reactivity system
### How It Works
1. **Store Setup**: Your component/page loads data into a store
2. **Reactive Config**: Create breadcrumb config with a function that reads from stores
3. **Automatic Updates**: When store changes, breadcrumb automatically updates
### Example: Community Post Title
**Store** (`$features/community/utils/store.ts`):
```typescript
export const currentCommunityQuestion = writable<{ title: string | null }>({
title: null
});
```
**Page Component** (`+page.svelte`):
```svelte
<script lang="ts">
import { currentCommunityQuestion } from '$features/community/utils/store';
let question = $state();
async function fetchQuestion() {
const data = await supabase.from('community_question').select('*').single();
question = data;
// Update store - breadcrumb will reactively update!
currentCommunityQuestion.set({ title: data.title });
}
$effect(() => {
fetchQuestion();
});
</script>
```
**Breadcrumb Component** (`lms-breadcrumbs.svelte`):
```svelte
<script lang="ts">
import { createLmsBreadcrumbConfigWithStore } from './breadcrumb-configs';
import { currentCommunityQuestion } from '$features/community/utils/store';
// Reactive config - updates when store changes
const config = $derived(createLmsBreadcrumbConfigWithStore(
() => $currentCommunityQuestion.title // Reads reactively!
));
// Breadcrumbs automatically update when store changes!
const breadcrumbs = $derived(
generateBreadcrumbsSync(page.url.pathname, page.url.search, navItems, config)
);
</script>
```
### Pattern for Multiple Stores
For routes with multiple dynamic segments, use path-based logic:
```svelte
<script lang="ts">
const config = $derived(createOrgBreadcrumbConfigWithStore(
$currentOrgPath,
() => {
const pathSegments = page.url.pathname.split('/').filter(Boolean);
// Determine which store to read based on route
if (pathSegments.includes('submissions')) {
return $submissionStore.title;
}
if (pathSegments.includes('assignments')) {
return $assignmentStore.title;
}
if (pathSegments.includes('lessons')) {
return $lessonStore.title;
}
if (pathSegments.includes('courses')) {
return $courseStore.title;
}
return null;
}
));
</script>
```
### Important Notes
1. **Disable Caching**: When using stores, set `enableCache: false` in config (or use `createOrgBreadcrumbConfigWithStore` which does this automatically)
2. **Use `$derived`**: Always wrap config and breadcrumbs in `$derived` for reactivity
3. **Store Updates**: When you update a store, breadcrumbs automatically update - no manual refresh needed!
4. **No Async Needed**: Store-based resolvers are synchronous - just return the store value
+846
View File
@@ -0,0 +1,846 @@
# Page and Route Creation Guide
This guide explains how to create pages and routes in the dashboard application, following the established patterns and architecture.
## Table of Contents
1. [Route Structure Overview](#route-structure-overview)
2. [Creating Basic Pages](#creating-basic-pages)
3. [Creating Settings Pages](#creating-settings-pages)
4. [Creating Feature Pages](#creating-feature-pages)
5. [Layout Patterns](#layout-patterns)
6. [Form Patterns (Field.Group)](#form-patterns-fieldgroup)
7. [Navigation Setup](#navigation-setup)
8. [Breadcrumb Setup](#breadcrumb-setup)
---
## Route Structure Overview
The dashboard has two main route contexts:
### 1. Organization Routes (`/org/[slug]/...`)
- Uses `OrgSidebar` and `AppHeader`
- Path pattern: `/org/[slug]/[feature]`
- Example: `/org/acme/courses`, `/org/acme/settings`
### 2. LMS Routes (`/lms/...`)
- Uses `LMSSidebar` and `LmsHeader`
- Path pattern: `/lms/[feature]`
- Example: `/lms/community`, `/lms/settings`
---
## Creating Basic Pages
### Step 1: Create the Route File
**Location**: `apps/dashboard/src/routes/[context]/[feature]/+page.svelte`
**Pattern**:
```svelte
<script lang="ts">
import { FeaturePage } from '$features/[feature]/pages';
import { FeatureActionButton } from '$features/[feature]/components';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Feature Name - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('feature.title')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<FeatureActionButton />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<FeaturePage />
{/snippet}
</Page.Body>
</Page.Root>
```
### Step 2: Create the Feature Page Component
**Location**: `apps/dashboard/src/lib/features/[feature]/pages/[feature].svelte`
**Pattern**:
```svelte
<script lang="ts">
import { Search } from '@cio/ui/custom/search';
import { Empty } from '@cio/ui/custom/empty';
import * as Page from '@cio/ui/base/page';
import * as Select from '@cio/ui/base/select';
import SomeIcon from '@lucide/svelte/icons/...';
import { FeatureCard, FeatureLoader } from '../components';
import { t } from '$lib/utils/functions/translations';
let isLoading = $state(false);
let searchValue = $state('');
let data = $state([]);
</script>
<Page.BodyHeader align="right">
<Search placeholder="Search..." bind:value={searchValue} />
<Select.Root>
<!-- Select options -->
</Select.Root>
</Page.BodyHeader>
<div class="w-full">
{#if isLoading}
<FeatureLoader />
{:else if !data.length}
<Empty
title={$t('feature.empty_title')}
description={$t('feature.empty_description')}
icon={SomeIcon}
variant="page"
>
<FeatureActionButton />
</Empty>
{:else}
{#each data as item}
<FeatureCard {item} />
{/each}
{/if}
</div>
```
### Step 3: Export the Page
**Location**: `apps/dashboard/src/lib/features/[feature]/pages/index.ts`
```typescript
export { default as FeaturePage } from './feature.svelte';
```
---
## Creating Settings Pages
Settings pages follow a specific pattern with layouts and subroutes.
### Step 1: Create Settings Layout
**Location**: `apps/dashboard/src/routes/[context]/settings/+layout.svelte`
```svelte
<script lang="ts">
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
import * as Page from '@cio/ui/base/page';
</script>
<Page.Root class="w-full pb-10 md:max-w-3xl lg:mx-auto">
{@render children?.()}
</Page.Root>
```
### Step 2: Create Default Settings Page
**Location**: `apps/dashboard/src/routes/[context]/settings/+page.svelte`
```svelte
<script lang="ts">
import { ProfilePage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let profileComponent: ProfilePage | null = $state(null);
let isLoading = $state(false);
async function handleUpdate() {
isLoading = true;
try {
await profileComponent?.handleUpdate();
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Settings - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.heading')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
{$t('settings.profile.update_profile')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<ProfilePage bind:this={profileComponent} />
{/snippet}
</Page.Body>
```
### Step 3: Create Settings Subroute
**Location**: `apps/dashboard/src/routes/[context]/settings/[subroute]/+page.svelte`
```svelte
<script lang="ts">
import { SubroutePage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Subroute Name - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.subroute.heading')}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<SubroutePage />
{/snippet}
</Page.Body>
```
**Note**: Settings pages use subroutes instead of tabs. Each subroute has its own `+page.svelte` file.
---
## Creating Feature Pages
### Feature Page Structure
Feature pages contain all business logic and should be placed in `lib/features/[feature]/pages/`.
**Key Principles**:
- ✅ All business logic goes in the feature page
- ✅ Use `Page.BodyHeader` for filters, search, and view toggles
- ✅ Use `Empty` component for empty states
- ✅ Use `Search` component for search inputs
- ✅ Handle loading states with skeleton/loader components
- ❌ Don't use `Page.Header` or `Page.Action` in feature pages (these are in route files)
### Example Feature Page
```svelte
<script lang="ts">
import { untrack } from 'svelte';
import { Search } from '@cio/ui/custom/search';
import { Empty } from '@cio/ui/custom/empty';
import * as Page from '@cio/ui/base/page';
import * as Select from '@cio/ui/base/select';
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
import { CommunityListLoader, AskCommunityButton } from '../components';
import { t } from '$lib/utils/functions/translations';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
let isLoading = $state(false);
let searchValue = $state('');
let discussions = $state([]);
let selectedId = $state('');
// Data fetching logic
async function fetchData() {
isLoading = true;
// ... fetch logic
isLoading = false;
}
$effect(() => {
if ($profile.id && $currentOrg.id) {
fetchData();
}
});
</script>
<Page.BodyHeader align="right">
<Search placeholder="Search..." bind:value={searchValue} />
<Select.Root bind:value={selectedId}>
<Select.Trigger>
<Select.Value placeholder="Filter..." />
</Select.Trigger>
<Select.Content>
<Select.Item value="all">All</Select.Item>
<!-- More options -->
</Select.Content>
</Select.Root>
</Page.BodyHeader>
<div class="w-full">
{#if isLoading}
<CommunityListLoader />
<CommunityListLoader />
<CommunityListLoader />
{:else if !discussions.length}
<Empty
title={$t('community.no_question')}
description={$t('community.ask_a_question')}
icon={MessageSquareMoreIcon}
variant="page"
>
<AskCommunityButton />
</Empty>
{:else}
{#each discussions as discussion}
<!-- Discussion content -->
{/each}
{/if}
</div>
```
---
## Layout Patterns
### Organization Layout
**Location**: `apps/dashboard/src/routes/org/[slug]/+layout.svelte`
```svelte
<script lang="ts">
import * as Sidebar from '@cio/ui/base/sidebar';
import { AppHeader } from '$features/ui';
import { OrgSidebar } from '$features/ui/sidebar/org-sidebar';
let { data, children } = $props();
</script>
<Sidebar.Provider>
<OrgSidebar />
<Sidebar.Inset>
<AppHeader />
<div class="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-4 px-4">
{@render children?.()}
</div>
</Sidebar.Inset>
</Sidebar.Provider>
```
### LMS Layout
**Location**: `apps/dashboard/src/routes/lms/+layout.svelte`
```svelte
<script lang="ts">
import * as Sidebar from '@cio/ui/base/sidebar';
import LmsHeader from '$features/ui/navigation/lms-header.svelte';
import { LMSSidebar } from '$features/ui/sidebar/lms-sidebar';
let { children }: Props = $props();
</script>
<Sidebar.Provider>
<LMSSidebar />
<Sidebar.Inset>
<LmsHeader />
<div class="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-4 px-4">
{@render children?.()}
</div>
</Sidebar.Inset>
</Sidebar.Provider>
```
**Key Differences**:
- Org routes use `AppHeader` (includes AppSetup)
- LMS routes use `LmsHeader` (no AppSetup)
- Org routes use `OrgSidebar`
- LMS routes use `LMSSidebar`
---
## Form Patterns (Field.Group)
**IMPORTANT**: Always use `Field.Group` pattern for forms. The old `Row/Grid/Column` pattern is deprecated and removed.
### Field.Group Pattern
**Location**: `apps/dashboard/src/lib/features/[feature]/pages/[feature].svelte`
```svelte
<script lang="ts">
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import { Switch } from '@cio/ui/base/switch';
import { UploadImage, UnsavedChanges } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
let hasUnsavedChanges = $state(false);
let formData = $state({
name: '',
email: '',
enabled: false
});
export async function handleSave() {
// Save logic
hasUnsavedChanges = false;
}
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('form.section_title')}</Field.Legend>
<Field.Group>
<Field.Field>
<Field.Label>{$t('form.name')}</Field.Label>
<Input bind:value={formData.name} oninput={() => (hasUnsavedChanges = true)} />
</Field.Field>
<Field.Field>
<Field.Label>{$t('form.email')}</Field.Label>
<Input bind:value={formData.email} type="email" oninput={() => (hasUnsavedChanges = true)} />
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={formData.enabled} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label>{$t('form.enabled')}</Field.Label>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('form.another_section')}</Field.Legend>
<Field.Field>
<UploadImage
bind:avatar
src={avatarUrl}
change={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Set>
</Field.Group>
```
### Field Components
- `<Field.Group>` - Main container (use `class="max-w-md! w-full px-2"`)
- `<Field.Set>` - Section container
- `<Field.Legend>` - Section title
- `<Field.Field>` - Individual field wrapper
- `<Field.Label>` - Field label
- `<Field.Description>` - Field description/help text
- `<Field.Error>` - Field error message
- `<Field.Separator />` - Visual separator between sections
- `orientation="horizontal"` - For switches/checkboxes with labels side-by-side
### Pattern Rules
- ✅ DO: Use `Field.Group` as the main container
- ✅ DO: Use `Field.Set` for logical sections
- ✅ DO: Use `Field.Separator` between sections
- ✅ DO: Use `Field.Legend` for section titles
- ✅ DO: Wrap form inputs in `Field.Field`
- ✅ DO: Use `Field.Label` for input labels
- ✅ DO: Use `orientation="horizontal"` for switches/checkboxes
- ❌ DON'T: Use `Row`, `Grid`, or `Column` components (deprecated)
- ❌ DON'T: Mix Field.Group with old layout components
---
## Navigation Setup
### Adding Items to Organization Navigation
**Location**: `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts`
```typescript
const baseNavConfig: NavItemConfig[] = [
// ... existing items
{
titleKey: 'org_navigation.new_feature',
path: '/new-feature',
icon: NewFeatureIcon,
matchPattern: '^/org/[^/]+/new-feature(/.*)?$',
// Optional: requires admin access
requiresAdmin: true,
// Optional: nested routes
nestedRoutes: [
{
path: 'subroute',
titleKey: 'Subroute Name'
}
],
// Optional: supports dynamic segments
supportsDynamicSegment: true
}
];
```
### Adding Items to LMS Navigation
**Location**: `apps/dashboard/src/lib/features/ui/navigation/lms-navigation.ts`
```typescript
const baseNavConfig: NavItemConfig[] = [
// ... existing items
{
titleKey: 'lms_navigation.new_feature',
path: '/new-feature',
icon: NewFeatureIcon,
matchPattern: '^/lms/new-feature(/.*)?$',
// Optional: conditional display
show: (currentOrg) => currentOrg?.customization?.feature?.enabled === true,
// Optional: nested routes
nestedRoutes: [
{
path: 'subroute',
titleKey: 'Subroute Name'
}
],
// Optional: supports dynamic segments
supportsDynamicSegment: true
}
];
```
### Navigation Item Properties
- `titleKey` - Translation key for the navigation label
- `path` - Route path (relative to context)
- `icon` - Lucide icon component
- `matchPattern` - Regex pattern for active state matching
- `requiresAdmin` - Only show for admin users (org navigation only)
- `show` - Function to conditionally show/hide item
- `nestedRoutes` - Static nested routes (like `community/ask`)
- `supportsDynamicSegment` - Supports dynamic routes (like `community/[slug]`)
- `useHashUrl` - Use '#' for collapsible parent (settings menu)
- `items` - Nested items for collapsible menus (settings sub-items)
---
## Breadcrumb Setup
Breadcrumbs are automatically generated from navigation configuration.
### For Organization Routes
Breadcrumbs use `generateBreadcrumbs()` from `breadcrumb.ts` which reads from `org-navigation.ts`.
### For LMS Routes
Breadcrumbs use `generateLmsBreadcrumbs()` from `lms-breadcrumb.ts` which reads from `lms-navigation.ts`.
### Adding Dynamic Breadcrumbs
For dynamic routes (like `community/[slug]`), add breadcrumb data in the route's `+page.ts`:
```typescript
// routes/org/[slug]/community/[slug]/+page.ts
export async function load({ params }) {
// Fetch question data
const question = await fetchQuestion(params.slug);
return {
breadcrumb: question.title // This will appear in breadcrumbs
};
}
```
---
## Complete Examples
### Example 1: Simple Feature Page
**Route**: `routes/org/[slug]/courses/+page.svelte`
```svelte
<script lang="ts">
import { CoursesPage } from '$features/courses/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Courses - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('courses.title')}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<CoursesPage />
{/snippet}
</Page.Body>
</Page.Root>
```
### Example 2: Settings Page with Save Action
**Route**: `routes/org/[slug]/settings/customize-lms/+page.svelte`
```svelte
<script lang="ts">
import { CustomizeLmsPage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let customizeLmsComponent: CustomizeLmsPage | null = $state(null);
let isSaving = $state(false);
async function handleSave() {
isSaving = true;
try {
await customizeLmsComponent?.handleSave();
} finally {
isSaving = false;
}
}
</script>
<svelte:head>
<title>Customize LMS - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full md:max-w-4xl lg:mx-auto">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('components.settings.customize_lms.title')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" loading={isSaving} disabled={isSaving} onclick={handleSave}>
{$t('components.settings.customize_lms.save')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CustomizeLmsPage bind:this={customizeLmsComponent} />
{/snippet}
</Page.Body>
</Page.Root>
```
### Example 3: Form Page Component
**Feature Page**: `lib/features/settings/pages/profile.svelte`
```svelte
<script lang="ts">
import { profile } from '$lib/utils/store/user';
import { profileApi } from '$features/auth/api/profile.svelte';
import { t } from '$lib/utils/functions/translations';
import { Input } from '@cio/ui/base/input';
import { UploadImage, UnsavedChanges } from '$features/ui';
import * as Field from '@cio/ui/base/field';
let avatar = $state<string | File | undefined>();
let hasUnsavedChanges = $state(false);
export async function handleUpdate() {
await profileApi.submit({
fullname: $profile.fullname,
username: $profile.username,
avatar
}, $profile);
if (profileApi.success) {
hasUnsavedChanges = false;
avatar = undefined;
}
}
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('settings.profile.profile_picture.heading')}</Field.Legend>
<Field.Field>
<UploadImage
bind:avatar
src={$profile.avatarUrl}
widthHeight="w-16 h-16 lg:w-24 lg:h-24"
change={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.profile.personal_information.heading')}</Field.Legend>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.profile.personal_information.full_name')}</Field.Label>
<Input bind:value={$profile.fullname} oninput={() => (hasUnsavedChanges = true)} />
{#if profileApi.errors.fullname}
<Field.Error>{$t(profileApi.errors.fullname)}</Field.Error>
{/if}
</Field.Field>
<!-- More fields -->
</Field.Group>
</Field.Set>
</Field.Group>
```
---
## Checklist for New Pages
When creating a new page, follow this checklist:
### Route Setup
- [ ] Create route file at `routes/[context]/[feature]/+page.svelte`
- [ ] Add `Page.Root`, `Page.Header`, `Page.Body` structure
- [ ] Add `<svelte:head>` with page title
- [ ] Import and render feature page component
### Feature Page
- [ ] Create feature page at `lib/features/[feature]/pages/[feature].svelte`
- [ ] Export from `lib/features/[feature]/pages/index.ts`
- [ ] Use `Page.BodyHeader` for filters/search
- [ ] Use `Empty` component for empty states
- [ ] Handle loading states
### Forms (if applicable)
- [ ] Use `Field.Group` pattern (NOT Row/Grid/Column)
- [ ] Use `Field.Set` for sections
- [ ] Use `Field.Separator` between sections
- [ ] Use `Field.Legend` for section titles
- [ ] Wrap inputs in `Field.Field`
- [ ] Add `UnsavedChanges` component if needed
### Navigation (if needed)
- [ ] Add item to `org-navigation.ts` or `lms-navigation.ts`
- [ ] Set appropriate `matchPattern` for active state
- [ ] Add icon component
- [ ] Configure nested routes if needed
- [ ] Configure dynamic segments if needed
### Settings Pages (if applicable)
- [ ] Create `+layout.svelte` with `Page.Root` wrapper
- [ ] Create default `+page.svelte` for main settings
- [ ] Create subroute `+page.svelte` files for each subroute
- [ ] Export page components from `$features/settings/pages`
---
## Common Patterns
### Page Width Classes
- `class="w-full"` - Full width
- `class="w-full md:max-w-3xl lg:mx-auto"` - Centered, max width on larger screens (settings pages)
- `class="w-full md:max-w-4xl lg:mx-auto"` - Slightly wider (customize-lms)
- `class="mx-auto w-full max-w-6xl"` - Wide content area (main layouts)
### Page Actions
Actions in `Page.Action` typically include:
- Save/Update buttons
- Create/Add buttons
- Export buttons
- Feature-specific action buttons
### Loading States
Use skeleton loaders or spinner components:
```svelte
{#if isLoading}
<FeatureLoader />
<FeatureLoader />
<FeatureLoader />
{:else}
<!-- Content -->
{/if}
```
### Empty States
Always use the `Empty` component:
```svelte
<Empty
title={$t('feature.empty_title')}
description={$t('feature.empty_description')}
icon={SomeIcon}
variant="page"
>
<ActionButton />
</Empty>
```
---
## Best Practices
1. **Separation of Concerns**: Route files handle routing structure, feature pages handle business logic
2. **Reusability**: Feature pages can be imported and used in different contexts
3. **Consistency**: All pages follow the same structure for maintainability
4. **Design System**: Use established components (`Page.*`, `Field.*`, `Empty`, `Search`)
5. **State Management**: Keep state local to feature pages or use feature-specific stores
6. **Translation Keys**: Always use translation keys from `$t()` function
7. **Type Safety**: Use TypeScript types for props and state
8. **Accessibility**: Use semantic HTML and proper ARIA labels
---
## Migration from Old Patterns
If you encounter old patterns, migrate them:
### Old: Row/Grid/Column Pattern
```svelte
<!-- OLD - DON'T USE -->
<Grid>
<Row>
<Column sm={4} md={4} lg={4}>
<SectionTitle>Title</SectionTitle>
</Column>
<Column sm={8} md={8} lg={8}>
<InputField />
</Column>
</Row>
</Grid>
```
### New: Field.Group Pattern
```svelte
<!-- NEW - USE THIS -->
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>Title</Field.Legend>
<Field.Field>
<Field.Label>Label</Field.Label>
<Input />
</Field.Field>
</Field.Set>
</Field.Group>
```
---
## Additional Resources
- See `ROUTE_PAGE_SETUP.md` for more details on feature page structure
- See existing pages in `routes/org/[slug]/` and `routes/lms/` for examples
- See `lib/features/settings/pages/` for form examples
- See `lib/features/community/pages/` for list page examples
@@ -45,8 +45,8 @@ The route file should be minimal and only contain:
```svelte
<script>
import { FeaturePage } from '$lib/features/[feature]/pages';
import { FeatureActionButton } from '$lib/features/[feature]/components';
import { FeaturePage } from '$features/[feature]/pages';
import { FeatureActionButton } from '$features/[feature]/components';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
@@ -75,8 +75,8 @@ The route file should be minimal and only contain:
### Route File Rules
- ✅ DO: Keep it minimal - only Page structure
- ✅ DO: Import feature page from `$lib/features/[feature]/pages`
- ✅ DO: Import action buttons/components from `$lib/features/[feature]/components`
- ✅ DO: Import feature page from `$features/[feature]/pages`
- ✅ DO: Import action buttons/components from `$features/[feature]/components`
- ✅ DO: Use `Page.Root`, `Page.Header`, `Page.HeaderContent`, `Page.Title`, `Page.Action`, `Page.Body`
- ❌ DON'T: Put business logic in route files
- ❌ DON'T: Put state management in route files
@@ -110,7 +110,6 @@ The feature page contains all the business logic, state management, and UI compo
let isLoading = $state(false);
let searchValue = $state('');
let selectedId = $state('');
</script>
<FeatureModal />
@@ -194,8 +193,8 @@ export { default as FeaturePage } from './feature.svelte';
```svelte
<script>
import { CommunityPage } from '$lib/features/community/pages';
import { AskCommunityButton } from '$lib/features/community/components';
import { CommunityPage } from '$features/community/pages';
import { AskCommunityButton } from '$features/community/components';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
+384 -993
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,7 @@
</head>
<body data-sveltekit-preload-data="hover" class="">
<div style="display: contents">%sveltekit.body%</div>
<div style="display: contents" class="theme-container">%sveltekit.body%</div>
</body>
</html>
@@ -71,7 +71,7 @@
<div class="{className} relative z-[100]">
<IconButton
contained={true}
color="text-primary-700 dark:bg-neutral-500 dark:text-white"
color="ui:text-primary dark:bg-neutral-500 dark:text-white"
size="large"
onClick={() => (openPopover = !openPopover)}
>
@@ -1,3 +0,0 @@
export { default as ActivityCard } from './ActivityCard.svelte';
export { default as HeroProfileCard } from './HeroProfileCard.svelte';
export { default as LoadingPage } from './LoadingPage.svelte';
@@ -1,14 +0,0 @@
<script lang="ts">
interface Props {
className?: string;
children?: import('svelte').Snippet;
}
let { className = '', children }: Props = $props();
</script>
<div
class="{className} flex h-[517px] w-full flex-col items-center justify-center rounded border border-gray-200 dark:border-neutral-600"
>
{@render children?.()}
</div>
@@ -1,30 +0,0 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { Spinner } from '@cio/ui/base/spinner';
import IconButton, { type Props as IconButtonProps } from '$lib/components/IconButton/index.svelte';
interface Props {
onClick?: IconButtonProps['onClick'];
contained?: boolean;
isClosing?: boolean;
color?: string;
size?: IconButtonProps['size'];
toolTipProps?: IconButtonProps['toolTipProps'];
}
let {
onClick = () => {},
contained = false,
isClosing = false,
color = 'text-primary-700 bg-primary-200 dark:bg-neutral-500',
size = 'small'
}: Props = $props();
</script>
<IconButton {onClick} {color} {contained} {size} disabled={isClosing}>
{#if isClosing}
<Spinner class="text-blue-700! size-5" />
{:else}
<XIcon size={size === 'large' ? 24 : 16} class="dark:text-black" />
{/if}
</IconButton>
@@ -1 +0,0 @@
export { default as CloseButton } from './index.svelte';
@@ -1,10 +0,0 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { IconButton } from '$lib/components/IconButton';
let { onClick = () => {} } = $props();
</script>
<IconButton {onClick}>
<XIcon size={16} class="dark:text-white" />
</IconButton>
@@ -1,10 +0,0 @@
<script lang="ts">
import SendHorizontalIcon from '@lucide/svelte/icons/send-horizontal';
import { IconButton } from '$lib/components/IconButton';
let { onClick } = $props();
</script>
<IconButton {onClick}>
<SendHorizontalIcon size={16} class="filled" />
</IconButton>
@@ -1,11 +0,0 @@
<script lang="ts">
interface Props {
name?: string;
}
let { name = '' }: Props = $props();
</script>
<p class="w-fit rounded-md border border-blue-700 px-1.5 text-center text-xs font-semibold text-blue-700">
{name}
</p>
@@ -1,24 +0,0 @@
<script lang="ts">
interface Props {
className?: string;
value?: string | number;
size?: string;
shape?: string;
}
let { className = '', value = '', size = '', shape = 'rounded-md' }: Props = $props();
</script>
<p
class="{className} {size === 'sm' ? 'px-1 py-1' : 'px-2 py-2'} org-avatar {className.includes('bg-')
? ''
: 'bg-gray-200 dark:text-black'} {shape} flex items-center justify-center text-center"
>
{value}
</p>
<style>
.org-avatar {
width: fit-content;
}
</style>
@@ -1,31 +0,0 @@
<script lang="ts">
interface Props {
value?: number;
className?: string;
}
let { value = 0, className = '' }: Props = $props();
</script>
<p class={className ? className : 'bg-set mt-2'}>{value}</p>
<style>
p {
display: inline-block;
min-width: 20px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
text-align: center;
border: 1px solid transparent;
border-radius: 2em;
width: 24px;
height: 24px;
padding-top: 2px;
}
.bg-set {
background-color: #d1d5da80;
}
</style>
@@ -1,6 +0,0 @@
<script>
import { PageNav } from '$lib/components/Page';
</script>
<PageNav title="Attendance" />
All attendance will be here with table
@@ -7,18 +7,17 @@
import { course } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { updateCourse } from '$lib/utils/services/courses';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { saveCertificateValidation } from '$lib/utils/functions/validator';
import { Button } from '@cio/ui/base/button';
import Plain from './templates/Plain.svelte';
import Professional from './templates/Professional.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { UpgradeBanner } from '$lib/features/ui';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { UpgradeBanner } from '$features/ui';
import BlueBadgePattern from './templates/BlueBadgePattern.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import PurpleBadgePattern from './templates/PurpleBadgePattern.svelte';
import BlueProfessionalBadge from './templates/BlueProfessionalBadge.svelte';
import PurpleProfessionalBadge from './templates/PurpleProfessionalBadge.svelte';
@@ -122,18 +121,15 @@
{$t('course.navItem.certificates.and_upload')}
</p>
<PrimaryButton
label={$t('course.navItem.certificates.goto_settings')}
variant={VARIANTS.OUTLINED}
className="rounded-md mt-3"
onClick={() => goto(`/org/${$currentOrg.siteName}/settings`)}
/>
<Button variant="outline" class="mt-3" onclick={() => goto(`/org/${$currentOrg.siteName}/settings`)}>
{$t('course.navItem.certificates.goto_settings')}
</Button>
</div>
<span class="my-4">
<p class="mb-2 mt-4 text-xs font-normal dark:text-gray-100">
{$t('course.navItem.certificates.description')}
</p>
<TextArea
<TextareaField
rows={6}
placeholder={$t('course.navItem.certificates.placeholder')}
bind:value={$course.description}
@@ -173,18 +169,12 @@
</section>
</div>
<div class="h-1/5">
<PrimaryButton
className="rounded-md flex gap-2 items-center"
variant={VARIANTS.CONTAINED_DARK}
onClick={saveCertificate}
isLoading={isSaving}
isDisabled={$isFreePlan}
>
<Button variant="secondary" onclick={saveCertificate} loading={isSaving} disabled={$isFreePlan}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('course.navItem.certificates.save')}
</PrimaryButton>
</Button>
</div>
</main>
@@ -5,10 +5,10 @@
import { issueCertificateModal, resetForm } from './store';
import { preventDefault } from '$lib/utils/functions/svelte';
import Modal from '$lib/components/Modal/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
let isAutomatic = $state(false);
@@ -17,13 +17,16 @@
};
</script>
<Modal
onClose={resetForm}
<Dialog.Root
bind:open={$issueCertificateModal.open}
width="w-3/5"
maxWidth=""
modalHeading="Send Certificate"
onOpenChange={(isOpen) => {
if (!isOpen) resetForm();
}}
>
<Dialog.Content class="w-3/5">
<Dialog.Header>
<Dialog.Title>Send Certificate</Dialog.Title>
</Dialog.Header>
<main>
<div>
<div class="mb-4 flex items-center space-x-2">
@@ -37,25 +40,23 @@
<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">
<TextField
<InputField
label="Email address of the student"
className="w-full my-4"
labelClassName="text-xs font-normal"
inputClassName="text-sm placeholder:text-sm placeholder:font-medium "
placeholder="email,comma seperated"
bind:value={$issueCertificateModal.email}
/>
<TextField
<InputField
label="Schedule date"
className="w-full my-4"
labelClassName="text-xs font-normal"
inputClassName="text-sm placeholder:text-sm placeholder:font-medium "
placeholder="12/06/2023"
bind:value={$issueCertificateModal.date}
/>
</div>
<TextArea
<TextareaField
label="Add a personalized message"
labelClassName="text-xs font-normal"
bind:value={$issueCertificateModal.message}
@@ -65,8 +66,9 @@
/>
<div class="mt-5 flex w-full items-end justify-end">
<PrimaryButton className="px-6 py-3 rounded-md text-sm font-medium" label="Issue certificate" type="submit" />
<Button type="submit">Issue certificate</Button>
</div>
</form>
</main>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -85,7 +85,7 @@
<div class="flex items-center gap-4">
<span class="text-sm">{student.issued}</span>
{#if student.peronalized}
<Badge variant="secondary" class="bg-primary-100 text-primary-600">personalized</Badge>
<Badge variant="secondary" class="bg-primary-100 ui:text-primary">personalized</Badge>
{/if}
</div>
</Table.Cell>
@@ -1,17 +1,14 @@
<script>
import Box from '$lib/components/Box/index.svelte';
import Empty from '@cio/ui/custom/empty/empty.svelte';
import { t } from '$lib/utils/functions/translations';
import LockIcon from '@lucide/svelte/icons/lock';
</script>
<Box>
<div class="flex h-full w-max flex-col items-center justify-center gap-2">
<img src="/images/student-certificate-preview.png" alt="Certificate" class="max-w-[218px]" />
<p class="text-center text-xl font-normal">
{$t('course.navItem.certificates.locked_certificate')}
</p>
<p class="text-center text-sm font-normal">
{$t('course.navItem.certificates.instructor_unlock')} <br />
{$t('course.navItem.certificates.contact_instructor')}
</p>
</div>
</Box>
<div class="w-full flex-1">
<Empty
title={$t('course.navItem.certificates.locked_certificate')}
description={`${$t('course.navItem.certificates.instructor_unlock')} ${$t('course.navItem.certificates.contact_instructor')}`}
icon={LockIcon}
variant="page"
/>
</div>
@@ -5,13 +5,12 @@
import { course } from '$lib/components/Course/store';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import Box from '$lib/components/Box/index.svelte';
import { Button } from '@cio/ui/base/button';
import Empty from '@cio/ui/custom/empty/empty.svelte';
import { t } from '$lib/utils/functions/translations';
import { fetchProfileCourseProgress } from '$lib/utils/services/courses';
import type { ProfileCourseProgress } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { classroomio } from '$lib/utils/services/api';
let isLoading = $state(false);
@@ -19,7 +18,7 @@
let progress: ProfileCourseProgress | undefined;
const downLoadCertificate = async () => {
if (!isCourseComplete) return;
if (!isCourseComplete || !$course.id) return;
isLoading = true;
try {
@@ -85,24 +84,11 @@
);
</script>
<Box>
<div class="flex h-full w-max flex-col items-center justify-center gap-5">
<img src="/images/student-certificate-preview.png" alt="Certificate" class="max-w-[218px]" />
<p class="text-center text-xl font-normal">
{$t(title)}
</p>
<p class="max-w-md text-center text-sm font-normal">
{$t(subtitle)}
</p>
<PrimaryButton
className="flex items-center gap-2"
onClick={downLoadCertificate}
variant={VARIANTS.CONTAINED_DARK}
isDisabled={!isCourseComplete}
{isLoading}
>
<div class="flex-1">
<Empty title={$t(title)} description={$t(subtitle)} icon={DownloadIcon} variant="page">
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
<DownloadIcon size={16} />
{$t('course.navItem.certificates.download_certificate')}
</PrimaryButton>
</div>
</Box>
</Button>
</Empty>
</div>
@@ -1,11 +1,10 @@
<script lang="ts">
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import { Button } from '@cio/ui/base/button';
import * as Dialog from '@cio/ui/base/dialog';
import { t } from '$lib/utils/functions/translations';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { supabase } from '$lib/utils/functions/supabase';
import { course } from '../../store';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
interface Props {
open?: boolean;
@@ -34,13 +33,16 @@
}
</script>
<Modal
onClose={handleClose}
<Dialog.Root
bind:open
width="w-[80%] md:w-[65%]"
maxWidth="max-w-xl"
modalHeading={$t(`course.navItem.lessons.section_prompt.header`)}
onOpenChange={(isOpen) => {
if (!isOpen) handleClose();
}}
>
<Dialog.Content class="w-[80%] md:w-[65%] max-w-xl">
<Dialog.Header>
<Dialog.Title>{$t(`course.navItem.lessons.section_prompt.header`)}</Dialog.Title>
</Dialog.Header>
<div class="flex w-full flex-col items-center">
<div class="mb-8">
<h3 class="text-center text-2xl">
@@ -52,16 +54,19 @@
</div>
<div class="flex gap-2">
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.lessons.section_prompt.cancel')}
onClick={handleClose}
/>
<PrimaryButton
label={$t('course.navItem.lessons.section_prompt.activate')}
onClick={activate}
isLoading={isActivating}
/>
<Button
variant="outline"
onclick={handleClose}
>
{$t('course.navItem.lessons.section_prompt.cancel')}
</Button>
<Button
onclick={activate}
loading={isActivating}
>
{$t('course.navItem.lessons.section_prompt.activate')}
</Button>
</div>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -1,7 +1,6 @@
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
interface Props {
@@ -17,31 +16,35 @@
}
</script>
<Modal
onClose={() => (openDeleteModal = false)}
<Dialog.Root
bind:open={openDeleteModal}
width="w-96"
containerClass="px-6 pt-2 pb-6"
modalHeading={$t('delete_modal.label')}
onOpenChange={(isOpen) => {
if (!isOpen) openDeleteModal = false;
}}
>
<Dialog.Content class="w-96 px-6 pt-2 pb-6">
<Dialog.Header>
<Dialog.Title>{$t('delete_modal.label')}</Dialog.Title>
</Dialog.Header>
<div>
<h1 class="text-lg dark:text-white">
{$t('delete_modal.content')}?
</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('delete_modal.no')}
onClick={() => (openDeleteModal = false)}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('delete_modal.yes')}
onClick={handleDelete}
/>
<Button
variant="outline"
onclick={() => (openDeleteModal = false)}
>
{$t('delete_modal.no')}
</Button>
<Button
variant="outline"
onclick={handleDelete}
>
{$t('delete_modal.yes')}
</Button>
</div>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -1,44 +1,43 @@
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { deleteConfirmation } from '../store/exercise';
let { onDelete = () => {}, onCancel = () => {} } = $props();
</script>
<Modal
onClose={() => ($deleteConfirmation.open = false)}
<Dialog.Root
bind:open={$deleteConfirmation.open}
width="w-96"
modalHeading={$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}
>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}</Dialog.Title>
</Dialog.Header>
<div>
<p class="mt-0 text-base dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
</p>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
onClick={() => {
<Button
variant="outline"
onclick={() => {
$deleteConfirmation.open = false;
onCancel();
}}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.CONTAINED}
label={$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
onClick={() => {
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
</Button>
<Button
onclick={() => {
onDelete();
$deleteConfirmation.open = false;
}}
/>
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
</Button>
</div>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -16,22 +16,20 @@
} from '../store/exercise';
import { preventDefault } from '$lib/utils/functions/svelte';
import { deleteExercise } from '$lib/utils/services/courses';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { QUESTION_TYPE, QUESTION_TYPES } from '$lib/components/Question/constants';
import { Button } from '@cio/ui/base/button';
import { lesson } from '../store/lessons';
import OrderModal from './OrderModal.svelte';
import { t } from '$lib/utils/functions/translations';
import Modal from '$lib/components/Modal/index.svelte';
import { IconButton } from '$lib/components/IconButton';
import TextArea from '$lib/components/Form/TextArea.svelte';
import Checkbox from '$lib/components/Form/Checkbox.svelte';
import RadioItem from '$lib/components/Form/RadioItem.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { IconButton } from '@cio/ui/custom/icon-button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
import { RadioItem } from '@cio/ui/custom/radio-item';
import { InputField } from '@cio/ui/custom/input-field';
import DeleteConfirmationModal from './DeleteConfirmation.svelte';
import ErrorMessage from '$lib/components/ErrorMessage/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import { CircleCheckIcon } from '$features/ui/icons';
import QuestionContainer from '$lib/components/QuestionContainer/index.svelte';
const initialQuestionsLength = $questionnaire.questions.length;
@@ -90,49 +88,43 @@
<OrderModal />
<Modal
onClose={() => (shouldDeleteExercise = false)}
<Dialog.Root
bind:open={shouldDeleteExercise}
width="w-2/4"
modalHeading={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}
onOpenChange={(isOpen) => {
if (!isOpen) shouldDeleteExercise = false;
}}
>
<Dialog.Content class="w-2/4">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}</Dialog.Title>
</Dialog.Header>
<form onsubmit={preventDefault()}>
<h1 class="text-xl dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.no')}
type="submit"
onClick={() => (shouldDeleteExercise = false)}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.CONTAINED}
label={isDeleting
<Button variant="outline" type="submit" onclick={() => (shouldDeleteExercise = false)}>
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.no')}
</Button>
<Button disabled={isDeleting} onclick={handleDelete} loading={isDeleting}>
{isDeleting
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.deleting')
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.yes')}
isDisabled={isDeleting}
onClick={handleDelete}
/>
</Button>
</div>
</form>
</Modal>
</Dialog.Content>
</Dialog.Root>
{#snippet optionActions(question, option)}
<div data-name="option-action" class="ml-2 flex items-center gap-2">
<IconButton value={`${option.id}`} onClick={handleRemoveOption(question.id, option.id)} size="small" bordered>
<IconButton onclick={handleRemoveOption(question.id, option.id)}>
<TrashIcon size={18} />
</IconButton>
<IconButton
value={`${option.id}`}
onClick={handleAnswerSelect(question.id, option.id)}
buttonClassName={option.is_correct ? 'success' : ''}
size="small"
bordered
onclick={handleAnswerSelect(question.id, option.id)}
class={option.is_correct ? 'text-green-500! fill-green-500!' : ''}
>
<CircleCheckIcon size={18} filled={option.is_correct} />
</IconButton>
@@ -142,7 +134,7 @@
<div class="mb-20 w-full">
{#if Object.values(errors).length}
<div class="mb-4 flex w-full justify-center">
<ErrorMessage message={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.error')} />
<p class="text-sm text-red-500">{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.error')}</p>
</div>
{/if}
<div class="questions px-6 pt-6">
@@ -160,11 +152,11 @@
>
<div class="flex items-center justify-between">
<div class="mr-5 w-3/5">
<TextField
<InputField
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.question')}
bind:value={question.title}
isRequired={true}
onChange={() => {
onchange={() => {
question.is_dirty = true;
}}
/>
@@ -194,12 +186,12 @@
{#if typeof question.code === 'string'}
<div class="my-3 flex w-3/5 items-center justify-between">
<TextArea
<TextareaField
bind:value={question.code}
rows={2}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.write')}
/>
<IconButton value="write-code" onClick={() => handleCode(question.id, false)}>
<IconButton onclick={() => handleCode(question.id, false)}>
<TrashIcon size={16} />
</IconButton>
</div>
@@ -213,45 +205,40 @@
isEditable={true}
name={question.title || 'radio-name'}
bind:label={option.label}
onChange={addDynamicValue(question.id, option.id)}
onchange={addDynamicValue(question.id, option.id)}
>
{@render optionActions(question, option)}
</RadioItem>
{:else if QUESTION_TYPE.CHECKBOX === question.question_type.id}
<Checkbox
<CheckboxField
isEditable={true}
name={question?.name || 'checkbox-name'}
bind:label={option.label}
onChange={addDynamicValue(question.id, option.id)}
onchange={addDynamicValue(question.id, option.id)}
>
{@render optionActions(question, option)}
</Checkbox>
</CheckboxField>
{/if}
{/if}
{/each}
{#if QUESTION_TYPE.TEXTAREA === question.question_type.id}
<TextArea bind:value={question.value} disabled={true} />
<TextareaField bind:value={question.value} disabled={true} />
{/if}
{#if getQuestionErrorMsg(errors, question, 'option')}
<ErrorMessage message={getQuestionErrorMsg(errors, question, 'option')} />
<p class="text-sm text-red-500">{getQuestionErrorMsg(errors, question, 'option')}</p>
{/if}
</div>
{#if QUESTION_TYPE.TEXTAREA !== question.question_type.id}
<div class="mt-3 flex items-center">
<PrimaryButton
disablePadding={true}
className="p-2"
variant={VARIANTS.OUTLINED}
onClick={handleAddOption(question.id)}
>
<Button variant="outline" onclick={handleAddOption(question.id)}>
<CirclePlusIcon size={16} />
<p class="ml-2 dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.option')}
</p>
</PrimaryButton>
</Button>
</div>
{/if}
</QuestionContainer>
@@ -7,14 +7,13 @@
import { STATUS } from './constants';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import type { SubmissionIdData } from '$lib/utils/types/submission';
import Preview from './Preview.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { Button } from '@cio/ui/base/button';
// import { useCompletion } from 'ai/svelte';
// import { QUESTION_TYPE } from '$lib/components/Question/constants';
@@ -231,15 +230,16 @@
});
</script>
<Modal
modalHeading={data.title}
<Dialog.Root
bind:open
{onClose}
width="w-4/5 h-[90%]"
containerClass="flex items-start !max-h-full h-[85%] py-0 px-4"
headerClass="py-2"
labelClass="text-base font-semibold"
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
>
<Dialog.Content class="w-4/5 h-[90%] flex items-start !max-h-full h-[85%] py-0 px-4">
<Dialog.Header class="py-2">
<Dialog.Title class="text-base font-semibold">{data.title}</Dialog.Title>
</Dialog.Header>
<div class="mt-2 h-full w-full">
{#if openDeletePrompt}
<div class="mx-auto w-96 rounded-md border border-gray-300 p-3">
@@ -248,20 +248,12 @@
</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('delete_modal.no')}
onClick={() => (openDeletePrompt = false)}
isDisabled={isDeleting}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('delete_modal.yes')}
onClick={handleDeleteSubmission}
isLoading={isDeleting}
/>
<Button variant="outline" onclick={() => (openDeletePrompt = false)} disabled={isDeleting}>
{$t('delete_modal.no')}
</Button>
<Button variant="outline" onclick={handleDeleteSubmission} loading={isDeleting}>
{$t('delete_modal.yes')}
</Button>
</div>
</div>
{:else}
@@ -383,7 +375,7 @@
<p class="font-semibold text-gray-500 dark:text-white">
{$t('course.navItem.submissions.grading_modal.add_comment')}:
</p>
<TextArea
<TextareaField
bgColor="bg-gray-100 dark:bg-neutral-700"
className="font-semibold"
placeholder={$t('course.navItem.submissions.grading_modal.add_comment_placeholder')}
@@ -392,22 +384,22 @@
</div>
<div class="flex w-full flex-col space-y-3 px-3 py-2">
<PrimaryButton onClick={gradeWithAI} variant={VARIANTS.OUTLINED} className="space-x-3 py-3 px-8 w-full ">
<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>
</PrimaryButton>
<PrimaryButton
onClick={() => {
</Button>
<Button
onclick={() => {
handleSave(data);
// onClose();
}}
isLoading={isSaving}
label={$t('course.navItem.submissions.grading_modal.submit_grades')}
variant={VARIANTS.CONTAINED}
className="py-3 px-8 w-full"
/>
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>
@@ -415,7 +407,8 @@
</div> -->
</div>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
<style>
.badge {
@@ -6,7 +6,7 @@
import TablePropertiesIcon from '@lucide/svelte/icons/table-properties';
import { flip } from 'svelte/animate';
import Modal from '$lib/components/Modal/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { questionnaireOrder, questionnaire } from '../store/exercise';
import { filterOutDeleted } from './functions';
@@ -60,12 +60,16 @@
});
</script>
<Modal
onClose={handleClose}
<Dialog.Root
bind:open={$questionnaireOrder.open}
width="w-96"
modalHeading={$t('course.navItem.lessons.exercises.all_exercises.order_questions')}
onOpenChange={(isOpen) => {
if (!isOpen) handleClose();
}}
>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.order_questions')}</Dialog.Title>
</Dialog.Header>
<section
use:dndzone={{
items,
@@ -82,7 +86,7 @@
{#each items as item (item.id)}
<div
animate:flip={{ duration: flipDurationMs }}
class="border-primary-600 flex items-center rounded-md border p-4"
class="ui:border-primary flex items-center rounded-md border p-4"
>
{#if item.type === 1}
<SquareCheckIcon size={16} />
@@ -95,7 +99,8 @@
</div>
{/each}
</section>
</Modal>
</Dialog.Content>
</Dialog.Root>
<style>
section {
@@ -62,7 +62,7 @@
{#each tabs as tab}
<button
class="relative {currentTab === tab.value
? 'text-primary-700'
? 'ui:text-primary'
: 'dark:bg-gray-500 dark:text-white'} mr-4 w-fit px-2 py-3 text-center font-semibold focus:outline-none dark:bg-transparent"
onclick={onChange(tab.value)}
>
@@ -2,9 +2,8 @@
import { questionnaire } from '../store/exercise';
import { t } from '$lib/utils/functions/translations';
import DateTime from '$lib/components/Form/DateTime.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import { InputField } from '@cio/ui/custom/input-field';
import { TextEditor } from '$features/ui';
import QuestionContainer from '$lib/components/QuestionContainer/index.svelte';
interface Props {
@@ -13,9 +12,9 @@
let { preview }: Props = $props();
function getTotalPossibleGrade(questions: { points: string }[]) {
function getTotalPossibleGrade(questions: { points: number | string }[]) {
return questions.reduce((acc, question) => {
acc += parseFloat(question.points, 10);
acc += typeof question.points === 'string' ? parseFloat(question.points) : question.points;
return acc;
}, 0);
}
@@ -24,18 +23,18 @@
<div class="mb-5 {!preview ? 'px-6' : 'px-2'}">
<QuestionContainer isTitle={true}>
{#if !preview}
<TextField
<InputField
placeholder={$t('course.navItem.lessons.exercises.all_exercises.description.title')}
bind:value={$questionnaire.title}
className="mb-2"
onChange={() => ($questionnaire.is_title_dirty = true)}
onchange={() => ($questionnaire.is_title_dirty = true)}
/>
<DateTime
<InputField
label={$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}
className="w-50"
value={$questionnaire.due_by}
onInput={(e) => {
$questionnaire.due_by = e.target.value;
type="datetime-local"
bind:value={$questionnaire.due_by}
onchange={() => {
$questionnaire.is_due_by_dirty = true;
}}
/>
@@ -80,7 +79,7 @@
{/if}
</div>
<article class="preview prose prose-sm mt-3 p-2 sm:prose">
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html $questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')}
</article>
{/if}
@@ -8,10 +8,11 @@
import RadioQuestion from '$lib/components/Question/RadioQuestion/index.svelte';
import CheckboxQuestion from '$lib/components/Question/CheckboxQuestion/index.svelte';
import TextareaQuestion from '$lib/components/Question/TextareaQuestion/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Box from '$lib/components/Box/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import Progress from '$lib/components/Progress/index.svelte';
import { Button } from '@cio/ui/base/button';
import { RoleBasedSecurity } from '$features/ui';
import { Empty } from '@cio/ui/custom/empty';
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
import { Progress } from '@cio/ui/base/progress';
import { removeDuplicate } from '$lib/utils/functions/removeDuplicate';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { STATUS } from './constants';
@@ -259,15 +260,18 @@
<Preview questions={filterOutDeleted($questionnaire.questions)} questionnaireMetaData={$questionnaireMetaData} />
</RoleBasedSecurity>
{:else if !$questionnaire.questions.length}
<Box>
<img src="/images/empty-exercise-icon.svg" alt="Exercise svg" class="my-2.5" />
<h2 class="my-1.5 text-xl">No question added for this exercise</h2>
<p class="px-44 text-center text-sm">
<Empty
title="No question added for this exercise"
description="Click the Edit button to add."
icon={FileQuestionIcon}
variant="page"
>
<RoleBasedSecurity allowedRoles={[1, 2]}>
Click the <span class="text-primary-700">Edit</span> button to add.
</RoleBasedSecurity>
<p class="ui:text-primary text-center text-sm">
Click the <span class="font-semibold">Edit</span> button to add.
</p>
</Box>
</RoleBasedSecurity>
</Empty>
{:else if $questionnaireMetaData.currentQuestionIndex === 0}
<RoleBasedSecurity allowedRoles={[3]}>
<div>
@@ -296,7 +300,7 @@
{@html $questionnaire.description || 'No desription'}
</article>
<PrimaryButton onClick={handleStart} label="Start" className="my-5 float-right" type="button" />
<Button onclick={handleStart} type="button" class="float-right my-5">Start</Button>
</div>
</RoleBasedSecurity>
{:else if $questionnaireMetaData.isFinished}
@@ -1,5 +1,5 @@
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { toggleConfetti } from '$lib/components/Confetti/store';
import { toggleConfetti } from '$features/ui/confetti/store';
export const isAnswerCorrect = (options, answer) => {
return options.some((option) => option.is_correct && option.value === answer);
@@ -18,18 +18,14 @@
} from '../store/exercise';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { upsertExercise } from '$lib/utils/services/courses';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import EditMode from './EditMode.svelte';
import ViewMode from './ViewMode.svelte';
import { PageBody } from '$lib/components/Page';
import Analytics from './Submissions/index.svelte';
import { IconButton } from '$lib/components/IconButton';
import { IconButton } from '@cio/ui/custom/icon-button';
import UpdateDescription from './UpdateDescription.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { RoleBasedSecurity } from '$features/ui';
interface Props {
exerciseId?: string;
@@ -106,7 +102,7 @@
// });
</script>
<PageBody isPageNavHidden={$globalStore.isStudent} padding="px-4 overflow-x-hidden">
<div class="overflow-x-hidden px-4">
<div class="sticky top-0 z-10 mb-3 bg-gray-100 p-2 dark:bg-neutral-800">
<Breadcrumb.Root>
<Breadcrumb.List>
@@ -139,31 +135,20 @@
{#if selectedTab === 'questions'}
<div class="right-0 flex w-full items-center justify-end">
<div class="flex items-center">
<PrimaryButton
className="mr-2"
variant={VARIANTS.CONTAINED}
label={$t('course.navItem.lessons.exercises.all_exercises.save')}
onClick={handleSave}
isLoading={isSaving}
/>
<Button class="mr-2" onclick={handleSave} loading={isSaving}>
{$t('course.navItem.lessons.exercises.all_exercises.save')}
</Button>
<IconButton
onClick={() => (preview = !preview)}
contained={preview}
toolTipProps={{
title: $t('course.navItem.lessons.exercises.all_exercises.preview'),
direction: 'bottom',
hotkeys: []
}}
onclick={() => (preview = !preview)}
tooltip={$t('course.navItem.lessons.exercises.all_exercises.preview')}
tooltipSide="bottom"
>
<EyeIcon size={20} class={preview ? 'filled' : ''} />
</IconButton>
<IconButton
onClick={handleAddQuestion}
toolTipProps={{
title: $t('course.navItem.lessons.exercises.all_exercises.add_question'),
direction: 'bottom',
hotkeys: []
}}
onclick={handleAddQuestion}
tooltip={$t('course.navItem.lessons.exercises.all_exercises.add_question')}
tooltipSide="bottom"
>
<CirclePlusIcon size={20} />
</IconButton>
@@ -205,4 +190,4 @@
{:else if selectedTab === 'submissions'}
<Analytics bind:exerciseId />
{/if}
</PageBody>
</div>
@@ -2,16 +2,14 @@
import { onMount } from 'svelte';
import { Badge } from '@cio/ui/base/badge';
import Modal from '$lib/components/Modal/index.svelte';
import Confetti from '$lib/components/Confetti/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { ComingSoon } from '$lib/features/ui';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { Confetti, ComingSoon } from '$features/ui';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
import { CircleCheckIcon } from '$features/ui/icons';
import { t } from '$lib/utils/functions/translations';
import type { ExerciseTemplate } from '$lib/utils/types';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { type GeneratedTemplates, getAllTemplates, TAGS } from '$lib/mocks';
interface Props {
@@ -126,13 +124,16 @@
// const note = $derived(browser ? getTextFromHTML(content) : '');
</script>
<Modal
onClose={handleCancelAddExercise}
<Dialog.Root
bind:open
modalHeading={$t('course.navItem.lessons.exercises.new_exercise_modal.heading')}
maxWidth="max-w-2xl"
width="w-4/5"
onOpenChange={(isOpen) => {
if (!isOpen) handleCancelAddExercise();
}}
>
<Dialog.Content class="max-w-2xl w-4/5">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.new_exercise_modal.heading')}</Dialog.Title>
</Dialog.Header>
{#if !isLoading && isAIStarted}
<Confetti />
{/if}
@@ -171,11 +172,9 @@
</div>
<div class="mt-8 flex flex-row-reverse items-center">
<PrimaryButton
className="px-6 py-3"
label={$t('course.navItem.lessons.exercises.new_exercise_modal.next')}
onClick={handleNext}
/>
<Button onclick={handleNext}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.next')}
</Button>
</div>
</div>
{:else if step === 1}
@@ -185,7 +184,7 @@
<h2 class="my-5 text-2xl font-medium">
{$t('course.navItem.lessons.exercises.new_exercise_modal.title')}
</h2>
<TextField
<InputField
bind:value={title}
autoFocus={true}
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.title_placeholder')}
@@ -193,17 +192,12 @@
/>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
label={$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
variant={VARIANTS.OUTLINED}
onClick={handleBack}
/>
<PrimaryButton
className="px-6 py-3"
label={$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
onClick={handleAddExercise}
/>
<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>
@@ -258,18 +252,13 @@
</div>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
label={$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
variant={VARIANTS.OUTLINED}
onClick={handleBack}
/>
<PrimaryButton
isDisabled={!selectedTemplateId || !allTemplates}
className="px-6 py-3"
label={$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
isLoading={isTemplateFinishedLoading}
onClick={async () => {
<Button variant="outline" onclick={handleBack}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
</Button>
<Button
disabled={!selectedTemplateId || !allTemplates}
loading={isTemplateFinishedLoading}
onclick={async () => {
isTemplateFinishedLoading = true;
const template = allTemplates?.[selectedTag]?.find((t) => t.id === selectedTemplateId);
@@ -281,7 +270,9 @@
isTemplateFinishedLoading = true;
}}
/>
>
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
</Button>
</div>
</div>
</div>
@@ -294,7 +285,7 @@
<p class="mb-4 text-sm">
{$t('course.navItem.lessons.exercises.new_exercise_modal.choose_questions')}
</p>
<TextField
<InputField
label={$t('course.navItem.lessons.exercises.new_exercise_modal.how_many_questions')}
type="number"
bind:value={questionNumber}
@@ -302,7 +293,7 @@
className="mb-2"
isRequired
/>
<TextField
<InputField
label={$t('course.navItem.lessons.exercises.new_exercise_modal.how_many_options')}
type="number"
bind:value={optionNumber}
@@ -313,14 +304,14 @@
<h3>{$t('course.navItem.lessons.exercises.new_exercise_modal.add_note')}</h3>
{/if}
<div class="mt-5 flex flex-row-reverse items-center">
<PrimaryButton
onClick={callAI}
{isLoading}
isDisabled={isLoading || !note}
variant={VARIANTS.OUTLINED}
<Button
onclick={callAI}
loading={isLoading}
disabled={isLoading || !note}
variant="outline"
>
{$t('course.navItem.lessons.exercises.new_exercise_modal.generate')}
</PrimaryButton>
</Button>
</div>
</div>
<div
@@ -340,20 +331,21 @@
</div>
</div>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
label={$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
variant={VARIANTS.TEXT}
onClick={handleBack}
/>
<PrimaryButton
isDisabled={isLoading || !note}
className="px-6 py-3"
label={$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
onClick={handleAddExercise}
/>
<Button
variant="ghost"
onclick={handleBack}
>
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
</Button>
<Button
disabled={isLoading || !note}
onclick={handleAddExercise}
>
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
</Button>
</div>
</div> -->
{/if}
{/if}
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -13,13 +13,11 @@
import { isQuestionnaireFetching, questionnaire } from '../store/exercise';
import { createExercise, createExerciseFromTemplate } from '$lib/utils/services/courses';
import { PageBody } from '$lib/components/Page';
import Exercise from '../Exercise/index.svelte';
import Backdrop from '$lib/components/Backdrop/index.svelte';
import { Backdrop, RoleBasedSecurity } from '$features/ui';
import * as Empty from '@cio/ui/base/empty';
import { QUESTION_TYPES } from '$lib/components/Question/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { Button } from '@cio/ui/base/button';
import NewExerciseModal from '$lib/components/Course/components/Lesson/Exercises/NewExerciseModal.svelte';
interface Props {
@@ -175,7 +173,7 @@
bind:title={newExercise.title}
/>
<PageBody isPageNavHidden={$globalStore.isStudent}>
<div>
{#snippet header()}
<slot:fragment>
<Breadcrumb.Root class="my-2">
@@ -188,11 +186,9 @@
</Breadcrumb.List>
</Breadcrumb.Root>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<PrimaryButton
className="mr-2 my-2"
label={$t('course.navItem.lessons.exercises.add_button')}
onClick={() => (open = !open)}
/>
<Button class="my-2 mr-2" onclick={() => (open = !open)}>
{$t('course.navItem.lessons.exercises.add_button')}
</Button>
</RoleBasedSecurity>
</slot:fragment>
{/snippet}
@@ -224,5 +220,5 @@
</Empty.Root>
{/each}
</div>
</PageBody>
</div>
{/if}
@@ -6,9 +6,9 @@
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import TextChip from '$lib/components/Chip/Text.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { Chip } from '@cio/ui/custom/chip';
import { InputField } from '@cio/ui/custom/input-field';
import { RoleBasedSecurity } from '$features/ui';
import * as Empty from '@cio/ui/base/empty';
import { lessons, handleSaveLesson } from '$lib/components/Course/components/Lesson/store/lessons';
@@ -67,7 +67,7 @@
});
}
function getLessonOrder(id: string) {
function getLessonOrder(id: string): number | string {
const index = $lessons.findIndex((lesson) => lesson.id === id);
if (index < 9) {
@@ -98,19 +98,14 @@
>
<!-- Number Chip -->
<div class="mr-5">
<TextChip
value={getLessonOrder(lesson.id)}
size="sm"
shape="rounded-full"
className="bg-primary-200 text-primary-600 text-xs"
/>
<Chip value={getLessonOrder(lesson.id)} />
</div>
<!-- Lesson Content -->
<div class="w-4/5">
<!-- Lesson Title -->
{#if lessonEditing === lesson.id}
<TextField bind:value={lesson.title} autoFocus={true} className="max-w-lg" errorMessage={errors?.title} />
<InputField bind:value={lesson.title} autoFocus={true} className="max-w-lg" errorMessage={errors?.title} />
{:else}
<h3 class="m-0 flex items-center text-lg dark:text-white">
<a
@@ -7,16 +7,14 @@
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { Button } from '@cio/ui/base/button';
import TextField from '$lib/components/Form/TextField.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import { IconButton } from '$lib/components/IconButton';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { InputField } from '@cio/ui/custom/input-field';
import { Chip } from '@cio/ui/custom/chip';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
import { globalStore } from '$lib/utils/store/app';
import { course } from '$lib/components/Course/store';
import { handleAddLessonWidget } from '$lib/components/Course/components/Lesson/store';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import {
lessonSections,
handleDelete,
@@ -27,7 +25,7 @@
import { updateLesson, updateLessonSection } from '$lib/utils/services/courses';
import type { Lesson } from '$lib/utils/types';
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { RoleBasedSecurity } from '$features/ui';
type CrudParam = {
sectionId?: string;
@@ -200,7 +198,7 @@
class="mb-2 flex min-h-[50px] items-center justify-between rounded-tl-md rounded-tr-md border-b bg-gray-50 px-3 py-1 dark:bg-neutral-700"
>
{#if lessonEditing === section.id}
<TextField className="w-4/6" bind:value={section.title} errorMessage={errors?.title} />
<InputField className="w-4/6" bind:value={section.title} errorMessage={errors?.title} />
{:else}
<p class="w-4/6 font-semibold">{section.title}</p>
{/if}
@@ -208,20 +206,20 @@
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center">
{#if lessonEditing === section.id}
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.cancel')}
variant={VARIANTS.OUTLINED}
onClick={() => {
<Button
variant="outline"
onclick={() => {
section.title = prevTitle ?? section.title;
resetEdit();
}}
/>
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.save')}
onClick={() => onSave({ sectionId: section.id })}
/>
>
{$t('course.navItem.lessons.add_lesson.cancel')}
</Button>
<Button onclick={() => onSave({ sectionId: section.id })}>
{$t('course.navItem.lessons.add_lesson.save')}
</Button>
{:else}
<IconButton contained size="small" onClick={() => handleAddLesson(section.id)} disabled={!!lessonEditing}>
<IconButton onclick={() => handleAddLesson(section.id)} disabled={!!lessonEditing}>
<PlusIcon size={16} />
</IconButton>
<DropdownMenu.Root>
@@ -272,15 +270,10 @@
animate:flip={{ duration: flipDurationMs }}
>
{#if lessonEditing === lesson.id}
<TextField className="w-4/6" bind:value={lesson.title} errorMessage={errors?.title} />
<InputField className="w-4/6" bind:value={lesson.title} errorMessage={errors?.title} />
{:else}
<div class="flex w-4/5 items-center gap-2">
<TextChip
value={getLessonOrder(section.lessons, lesson.id)}
size="sm"
shape="rounded-full"
className="bg-primary-200 text-primary-600 text-xs"
/>
<Chip value={getLessonOrder(section.lessons, lesson.id)} />
<div>
<a
href={$globalStore.isStudent && !lesson.is_unlocked
@@ -309,18 +302,18 @@
<div class="flex items-center gap-1">
<!-- IS EDITING -->
{#if lessonEditing === lesson.id}
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.cancel')}
variant={VARIANTS.OUTLINED}
onClick={() => {
<Button
variant="outline"
onclick={() => {
lesson.title = prevTitle ?? lesson.title;
resetEdit();
}}
/>
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.save')}
onClick={() => onSave({ lessonId: lesson.id }, lesson)}
/>
>
{$t('course.navItem.lessons.add_lesson.cancel')}
</Button>
<Button onclick={() => onSave({ lessonId: lesson.id }, lesson)}>
{$t('course.navItem.lessons.add_lesson.save')}
</Button>
{:else}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
@@ -3,8 +3,7 @@
import { createEventDispatcher } from 'svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Button } from '@cio/ui/base/button';
import PlayIcon from '@lucide/svelte/icons/play';
import { fetchLesssonLanguageHistory } from '$lib/utils/services/courses';
import { diffLines } from 'diff';
@@ -12,7 +11,7 @@
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import type { TLocale } from '@cio/db/types';
interface LessonHistory {
@@ -172,14 +171,15 @@
<div class="panel bg-white dark:bg-black">
<div class="w-full p-10 pr-80">
<div class="flex items-start gap-x-10">
<PrimaryButton variant={VARIANTS.OUTLINED} onClick={handleDrawerClose}>
<Button variant="outline" onclick={handleDrawerClose}>
<ArrowLeftIcon size={16} />
</PrimaryButton>
</Button>
{#if selectedVersionIndex != 0}
<div class="">
<PrimaryButton isLoading={contentRestoreLoading} onClick={restoreSelectedVersion}
>{$t('course.navItem.lessons.version_history.restore_version')}</PrimaryButton
<Button loading={contentRestoreLoading} onclick={restoreSelectedVersion}>
{$t('course.navItem.lessons.version_history.restore_version')}
</Button>
>
</div>
{/if}
@@ -230,8 +230,9 @@
</button>
{/each}
<div class="mt-2 flex h-10 items-center justify-start px-10">
<PrimaryButton className="h-full" isLoading={isMoreHistoryLoading} onClick={loadMoreHistory}
>{$t('course.navItem.lessons.version_history.fetch_more_versions')}</PrimaryButton
<Button loading={isMoreHistoryLoading} onclick={loadMoreHistory}>
{$t('course.navItem.lessons.version_history.fetch_more_versions')}
</Button>
>
</div>
</div>
@@ -1,16 +1,14 @@
<script lang="ts">
import { lessonDocUpload, resetDocumentUploadStore } from '../../store/lessons';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Button } from '@cio/ui/base/button';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import { DocumentUploader } from '$lib/utils/services/courses/presign';
import { onDestroy, untrack } from 'svelte';
import { UpgradeBanner } from '$lib/features/ui';
import { UpgradeBanner, CloseButton } from '$features/ui';
import { isFreePlan } from '$lib/utils/store/org';
import { lesson } from '../../store/lessons';
import { CloseButton } from '$lib/components/Buttons/Close';
let fileInput: HTMLInputElement | undefined = $state();
let selectedFile: File | null = $state(null);
@@ -246,12 +244,9 @@
class="hidden"
/>
<div class="flex justify-center">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.choose_file')}
variant={VARIANTS.OUTLINED}
{isDisabled}
onClick={() => fileInput?.click()}
/>
<Button variant="outline" disabled={isDisabled} onclick={() => fileInput?.click()}>
{$t('course.navItem.lessons.materials.tabs.document.choose_file')}
</Button>
</div>
{/if}
</div>
@@ -277,11 +272,9 @@
></div>
</div>
<div class="mt-3 flex justify-center">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.cancel_upload')}
variant={VARIANTS.OUTLINED}
onClick={cancelUpload}
/>
<Button variant="outline" onclick={cancelUpload}>
{$t('course.navItem.lessons.materials.tabs.document.cancel_upload')}
</Button>
</div>
</div>
{/if}
@@ -289,16 +282,12 @@
<!-- Upload Button -->
{#if selectedFile && !$lessonDocUpload.isUploading}
<div class="mt-6 flex justify-end space-x-3">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.cancel')}
variant={VARIANTS.OUTLINED}
onClick={removeSelectedFile}
/>
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.upload_document')}
onClick={uploadDocument}
isLoading={$lessonDocUpload.isUploading}
/>
<Button variant="outline" onclick={removeSelectedFile}>
{$t('course.navItem.lessons.materials.tabs.document.cancel')}
</Button>
<Button onclick={uploadDocument} loading={$lessonDocUpload.isUploading} disabled={$lessonDocUpload.isUploading}>
{$t('course.navItem.lessons.materials.tabs.document.upload_document')}
</Button>
</div>
{/if}
@@ -1,6 +1,5 @@
<script lang="ts">
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import MODES from '$lib/utils/constants/mode';
import FileTextIcon from '@lucide/svelte/icons/file-text';
@@ -40,11 +39,9 @@
{#if mode === MODES.edit}
<div class="mb-4">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.add_document')}
onClick={openDocumentUploadModal}
className="mb-4"
/>
<Button onclick={openDocumentUploadModal} class="mb-4">
{$t('course.navItem.lessons.materials.tabs.document.add_document')}
</Button>
</div>
{#if !isEmpty(displayDocuments)}
@@ -115,10 +112,10 @@
</div>
<div class="flex gap-3">
{#if document.type === 'pdf'}
<PrimaryButton variant={VARIANTS.CONTAINED_DARK} onClick={() => handleViewPDF(document)} {isLoading}>
<Button onclick={() => handleViewPDF(document)} loading={isLoading}>
<EyeIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.document.view_pdf')}
</PrimaryButton>
</Button>
{:else}
<a
href={isLoading ? '#' : document.link}
@@ -8,9 +8,9 @@
import { isLessonDirty, lesson } from '$lib/components/Course/components/Lesson/store/lessons';
import { copyToClipboard, getVideoUrls, removeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { IconButton } from '$lib/components/IconButton';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
let genericLinks = $state('');
let error = $state('');
@@ -56,20 +56,17 @@
</script>
<div class="flex w-full items-{error ? 'center' : 'end'} justify-between gap-5">
<TextField
<InputField
label={$t('course.navItem.lessons.materials.tabs.video.embed_link')}
bind:value={genericLinks}
className="flex-1 text-left "
inputClassName="text-sm"
onChange={() => ($isLessonDirty = true)}
className="flex-1"
onchange={() => ($isLessonDirty = true)}
placeholder="https://www.videoplayer.com/"
errorMessage={error}
/>
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.video.add_video.add_video')}
className="rounded-md"
onClick={addVideo}
/>
<Button onclick={addVideo}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_video')}
</Button>
</div>
<p class="mt-4 pl-2 text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}:
@@ -84,10 +81,10 @@
<Badge class="max-w-md truncate" variant="secondary">
{video.link}
</Badge>
<IconButton value="copy-video" onClick={() => copyToClipboard(video.link)}>
<IconButton onclick={() => copyToClipboard(video.link)}>
<CopyIcon size={16} />
</IconButton>
<IconButton value="delete-video" onClick={() => removeVideo(index)}>
<IconButton onclick={() => removeVideo(index)}>
<TrashIcon size={16} />
</IconButton>
</div>
@@ -1,14 +1,13 @@
<script lang="ts">
import { Progress } from '@cio/ui/base/progress';
import { preventDefault } from '$lib/utils/functions/svelte';
import { UpgradeBanner } from '$lib/features/ui';
import { UpgradeBanner } from '$features/ui';
import { lesson, lessonVideoUpload, cancelVideoUpload } from '$lib/components/Course/components/Lesson/store/lessons';
import { isFreePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { VideoUploader } from '$lib/utils/services/courses/presign';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { Button } from '@cio/ui/base/button';
interface Props {
lessonId?: string;
@@ -144,11 +143,9 @@
<Progress class="w-full" value={$lessonVideoUpload.uploadProgress} max={100} />
<p class="text-sm">{helperText}</p>
<div class="mt-3">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.video.add_video.cancel_upload')}
variant={VARIANTS.OUTLINED}
onClick={cancelUpload}
/>
<Button variant="outline" onclick={cancelUpload}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.cancel_upload')}
</Button>
</div>
</div>
{:else}
@@ -187,7 +184,9 @@
{$t('course.navItem.lessons.materials.tabs.video.add_video.maximum_size')}
</p>
</span>
<PrimaryButton label={$t('course.navItem.lessons.materials.tabs.video.add_video.button')} onClick={tryAgain} />
<Button onclick={tryAgain}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.button')}
</Button>
</div>
{:else if formRes?.status !== 200}
<div class="flex h-full w-full flex-col items-center justify-center rounded-xl">
@@ -201,7 +200,9 @@
{$t('course.navItem.lessons.materials.tabs.video.add_video.format')}
</p>
</span>
<PrimaryButton label={$t('course.navItem.lessons.materials.tabs.video.add_video.try_again')} onClick={tryAgain} />
<Button onclick={tryAgain}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.try_again')}
</Button>
</div>
{:else}
<div class=" w-full rounded-md border px-8 py-3">
@@ -214,10 +215,9 @@
</video>
<p>{fileInput?.files?.[0]?.name || fileName}</p>
</div>
<PrimaryButton
label={$t('course.navItem.lessons.materials.button_done')}
onClick={() => ($lessonVideoUpload.isModalOpen = false)}
/>
<Button onclick={() => ($lessonVideoUpload.isModalOpen = false)}>
{$t('course.navItem.lessons.materials.button_done')}
</Button>
</div>
</div>
{/if}
@@ -4,13 +4,13 @@
import TrashIcon from '@lucide/svelte/icons/trash';
import { t } from '$lib/utils/functions/translations';
import { IconButton } from '$lib/components/IconButton';
import { IconButton } from '@cio/ui/custom/icon-button';
import type { LessonVideoType } from '$lib/utils/types';
import { copyToClipboard, getVideoUrls, removeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { lesson, isLessonDirty } from '$lib/components/Course/components/Lesson/store/lessons';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
let youtubeLinks = $state('');
let error = $state('');
@@ -45,20 +45,17 @@
</script>
<div class="flex w-full items-{error ? 'center' : 'end'} justify-between gap-5">
<TextField
<InputField
label={$t('course.navItem.lessons.materials.tabs.video.add_video.youtube_link')}
bind:value={youtubeLinks}
className="flex-1 text-left "
inputClassName="text-sm"
onChange={() => ($isLessonDirty = true)}
className="flex-1 text-left"
onchange={() => ($isLessonDirty = true)}
placeholder="https://www.youtube.com/watch?v="
errorMessage={error}
/>
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.video.add_video.add_video')}
className="rounded-md"
onClick={addVideo}
/>
<Button onclick={addVideo}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_video')}
</Button>
</div>
<p class="mt-4 pl-2 text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}:
@@ -73,10 +70,10 @@
<Badge class="max-w-md truncate" variant="secondary">
{video.link}
</Badge>
<IconButton value="copy-video" onClick={() => copyToClipboard(video.link)}>
<IconButton onclick={() => copyToClipboard(video.link)}>
<CopyIcon size={16} />
</IconButton>
<IconButton value="delete-video" onClick={() => removeVideo(index)}>
<IconButton onclick={() => removeVideo(index)}>
<TrashIcon size={16} />
</IconButton>
</div>
@@ -6,18 +6,17 @@
import { group } from '$lib/components/Course/store';
import * as Avatar from '@cio/ui/base/avatar';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { shortenName } from '$lib/utils/functions/string';
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { DeleteModal } from '$features/ui';
import { Button } from '@cio/ui/base/button';
import { lesson, lessonCommentsChannel } from '$lib/components/Course/components/Lesson/store/lessons';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { getSupabase } from '$lib/utils/functions/supabase';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import type { GroupPerson, LessonComment, LessonCommentInsertPayload } from '$lib/utils/types';
const supabase = getSupabase();
@@ -63,7 +62,7 @@
id: 0,
comment: comment,
name: $t('course.navItem.lessons.comments.you'),
avatar: $profile.avatarUrl,
avatar: $profile.avatarUrl || 'AV',
commentAt: new Date(),
groupmember_id: groupmember.id
},
@@ -279,7 +278,7 @@
<Avatar.Fallback>{shortenName($profile.fullname) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<div class="h-full w-full">
<TextArea
<TextareaField
label={$t('course.navItem.lessons.comments.text_area_title')}
placeholder={$t('course.navItem.lessons.comments.placeholder')}
bind:value={comment}
@@ -288,12 +287,9 @@
</div>
<div class="mt-2 flex flex-row-reverse">
<PrimaryButton
label={$t('course.navItem.lessons.comments.comment_btn')}
onClick={handleSend}
isDisabled={!comment}
isLoading={isSaving}
/>
<Button onclick={handleSend} disabled={!comment} loading={isSaving}>
{$t('course.navItem.lessons.comments.comment_btn')}
</Button>
</div>
</div>
@@ -344,22 +340,17 @@
</div>
{#if editCommentId === commentItem.id}
<TextArea
<TextareaField
placeholder={$t('course.navItem.lessons.comments.placeholder')}
bind:value={commentItem.comment}
/>
<div class="mt-2 flex flex-row-reverse items-center gap-2">
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.lessons.comments.cancel_btn')}
onClick={() => (editCommentId = null)}
/>
<PrimaryButton
label={$t('course.navItem.lessons.comments.comment_btn')}
onClick={() => handleUpdate(commentItem)}
isDisabled={!commentItem.comment}
isLoading={isSaving}
/>
<Button variant="outline" onclick={() => (editCommentId = null)}>
{$t('course.navItem.lessons.comments.cancel_btn')}
</Button>
<Button onclick={() => handleUpdate(commentItem)} disabled={!commentItem.comment} loading={isSaving}>
{$t('course.navItem.lessons.comments.comment_btn')}
</Button>
</div>
{:else}
<article class="prose sm:prose-sm max-w-[300px] dark:text-white">
@@ -372,12 +363,9 @@
</div>
{#if pagination.hasMore}
<div class="mt-2 flex items-center justify-center">
<PrimaryButton
label={$t('course.navItem.lessons.comments.load_more_btn')}
variant={VARIANTS.OUTLINED}
onClick={() => fetchComments($group.people)}
isLoading={isFetching}
/>
<Button variant="outline" onclick={() => fetchComments($group.people)} loading={isFetching}>
{$t('course.navItem.lessons.comments.load_more_btn')}
</Button>
</div>
{/if}
</div>
@@ -1,9 +1,9 @@
<script lang="ts">
import { lesson, deleteLessonDocument } from '$lib/components/Course/components/Lesson/store/lessons';
import { CloseButton } from '$lib/components/Buttons/Close';
import { CloseButton } from '$features/ui';
import { lessonDocUpload } from '$lib/components/Course/components/Lesson/store/lessons';
import MODES from '$lib/utils/constants/mode';
import { IconButton } from '$lib/components/IconButton';
import { IconButton } from '@cio/ui/custom/icon-button';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
@@ -12,7 +12,7 @@
import DocumentList from '../Document/DocumentList.svelte';
import { t } from '$lib/utils/functions/translations';
import type { LessonDocument } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
let { mode = MODES.view } = $props();
@@ -334,30 +334,18 @@
<!-- Navigation Controls -->
{#if !isLoading && !error}
<div class="flex items-center space-x-1">
<IconButton
onClick={prevPage}
disabled={pageNum <= 1}
toolTipProps={{ title: 'Previous page (←)', hotkeys: ['ArrowLeft'] }}
>
<IconButton onclick={prevPage} disabled={pageNum <= 1} tooltip="Previous page (←)">
<ChevronLeftIcon size={16} />
</IconButton>
<IconButton
onClick={nextPage}
disabled={pageNum >= pageCount}
toolTipProps={{ title: 'Next page (→)', hotkeys: ['ArrowRight'] }}
>
<IconButton onclick={nextPage} disabled={pageNum >= pageCount} tooltip="Next page (→)">
<ChevronRightIcon size={16} />
</IconButton>
</div>
<!-- Zoom Controls -->
<div class="flex items-center space-x-1">
<IconButton
onClick={zoomOut}
disabled={scale <= 0.5}
toolTipProps={{ title: 'Zoom out (-)', hotkeys: ['-'] }}
>
<IconButton onclick={zoomOut} disabled={scale <= 0.5} tooltip="Zoom out (-)">
<ZoomOutIcon size={16} />
</IconButton>
@@ -365,11 +353,7 @@
{Math.round(scale * 100)}%
</span>
<IconButton
onClick={zoomIn}
disabled={scale >= 3.0}
toolTipProps={{ title: 'Zoom in (+)', hotkeys: ['+'] }}
>
<IconButton onclick={zoomIn} disabled={scale >= 3.0} tooltip="Zoom in (+)">
<ZoomInIcon size={16} />
</IconButton>
</div>
@@ -18,7 +18,7 @@
import type { Content } from '@cio/ui/custom/editor';
import { course } from '$lib/components/Course/store';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import type { LessonPage } from '$lib/utils/types';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import {
@@ -30,23 +30,23 @@
lessonVideoUpload,
lessonDocUpload
} from '$lib/components/Course/components/Lesson/store/lessons';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { lessonFallbackNote, t } from '$lib/utils/functions/translations';
import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { Button } from '@cio/ui/base/button';
import { Chip } from '@cio/ui/custom/chip';
import Loader from './Loader.svelte';
import Box from '$lib/components/Box/index.svelte';
import Comments from './components/Comments.svelte';
import Tabs from '$lib/components/Tabs/index.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import { IconButton } from '$lib/components/IconButton';
import TextField from '$lib/components/Form/TextField.svelte';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import { Empty } from '@cio/ui/custom/empty';
import VideoIcon from '@lucide/svelte/icons/video';
import * as Dialog from '@cio/ui/base/dialog';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import ComponentNote from './components/ComponentNote.svelte';
import ComponentSlide from './components/ComponentSlide.svelte';
import ComponentVideo from './components/ComponentVideo.svelte';
import TabContent from '$lib/components/TabContent/index.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import { TextEditor } from '$features/ui';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import ComponentDocument from './components/ComponentDocument.svelte';
import AddVideoToLesson from '$lib/components/Course/components/Lesson/Materials/Video/AddVideoToLesson.svelte';
import AddDocumentToLesson from '$lib/components/Course/components/Lesson/Materials/Document/AddDocumentToLesson.svelte';
@@ -106,7 +106,7 @@
});
});
const componentsToRender = $derived(getComponentOrder(tabs));
let currentTab = $derived(tabs[0].value);
let currentTab = $state<string>('');
function onEditorChange(content: Content) {
console.log('content updated:', content);
@@ -124,11 +124,11 @@
$isLessonDirty = true;
}
const onChange = (tab) => {
return () => {
currentTab = tab;
};
};
$effect(() => {
if (tabs.length > 0 && !currentTab) {
currentTab = String(tabs[0].value);
}
});
const getValue = (label: string) => {
const tabValue = tabs.find((tab) => tab.label === label)?.value;
@@ -353,23 +353,33 @@
);
</script>
<Modal
{onClose}
<Dialog.Root
bind:open={$lessonVideoUpload.isModalOpen}
width="w-4/5 w-[90%] h-[80%] md:h-[566px]"
modalHeading={$t('course.navItem.lessons.materials.tabs.video.add_video.title')}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
>
<Dialog.Content class="max-w-4/5 h-[80%] w-[90%] md:h-[566px]">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.video.add_video.title')}</Dialog.Title>
</Dialog.Header>
<AddVideoToLesson {lessonId} />
</Modal>
</Dialog.Content>
</Dialog.Root>
<Modal
onClose={onDocumentClose}
<Dialog.Root
bind:open={$lessonDocUpload.isModalOpen}
width="w-4/5 w-[90%] h-[80%] md:h-[566px]"
modalHeading={$t('course.navItem.lessons.materials.tabs.document.upload_title')}
onOpenChange={(isOpen) => {
if (!isOpen) onDocumentClose();
}}
>
<Dialog.Content class="max-w-4/5 h-[80%] w-[90%] md:h-[566px]">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.document.upload_title')}</Dialog.Title>
</Dialog.Header>
<AddDocumentToLesson />
</Modal>
</Dialog.Content>
</Dialog.Root>
<HtmlRender className="m-auto text-center">
<h1 class="mt-0 text-2xl capitalize md:text-4xl">
@@ -380,28 +390,37 @@
{#if $lesson.isFetching}
<Loader />
{:else if mode === MODES.edit}
<Tabs {tabs} {currentTab} {onChange}>
{#snippet content()}
<slot:fragment>
<TabContent value={getValue('course.navItem.lessons.materials.tabs.note.title')} index={currentTab}>
<UnderlineTabs.Root bind:value={currentTab}>
<UnderlineTabs.List>
{#each tabs as tab}
<UnderlineTabs.Trigger value={String(tab.value)}>
{#if tab.icon}
<tab.icon size={16} />
{/if}
{$t(tab.label)}
{#if typeof tab.badgeValue === 'number' && tab.badgeValue > 0}
<Chip value={`${tab.badgeValue}`} className="ml-1" />
{/if}
</UnderlineTabs.Trigger>
{/each}
</UnderlineTabs.List>
<UnderlineTabs.Content value={String(getValue('course.navItem.lessons.materials.tabs.note.title') || '')}>
<div class="flex justify-end gap-1">
<!-- Update this when ai-sdk is updated -->
<div class="hidden flex-row-reverse">
<Popover.Root>
<Popover.Trigger>
<PrimaryButton
className="flex items-center relative"
onClick={() => {
<Button
variant="outline"
onclick={() => {
openPopover = !openPopover;
}}
isLoading={$isLoading}
isDisabled={$isLoading}
variant={VARIANTS.OUTLINED}
disableScale
loading={$isLoading}
disabled={$isLoading}
>
<WandSparklesIcon size={16} />
AI
</PrimaryButton>
</Button>
</Popover.Trigger>
<Popover.Content align="start" class="w-80">
<div class="p-2">
@@ -436,30 +455,28 @@
placeholder={$t('course.navItem.lessons.materials.tabs.note.placeholder')}
/>
</div>
</TabContent>
</UnderlineTabs.Content>
<TabContent value={getValue('course.navItem.lessons.materials.tabs.slide.title')} index={currentTab}>
<UnderlineTabs.Content value={String(getValue('course.navItem.lessons.materials.tabs.slide.title') || '')}>
{#if mode === MODES.edit}
<TextField
<InputField
label={$t('course.navItem.lessons.materials.tabs.slide.slide_link')}
bind:value={$lesson.materials.slide_url}
onInputChange={() => ($isLessonDirty = true)}
helperMessage={$t('course.navItem.lessons.materials.tabs.slide.helper_message')}
/>
{/if}
</TabContent>
<TabContent value={getValue('course.navItem.lessons.materials.tabs.video.title')} index={currentTab}>
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.video.button')}
onClick={openAddVideoModal}
className="mb-2"
/>
</UnderlineTabs.Content>
<UnderlineTabs.Content value={String(getValue('course.navItem.lessons.materials.tabs.video.title') || '')}>
<Button onclick={openAddVideoModal} class="mb-2">
{$t('course.navItem.lessons.materials.tabs.video.button')}
</Button>
{#if $lesson.materials.videos.length}
<div class="flex h-full w-full flex-col items-start">
{#each $lesson.materials.videos as video, index}
{#if mode === MODES.edit}
<div class="ml-auto">
<IconButton value="delete-video" contained={true} onClick={() => deleteLessonVideo(index)}>
<IconButton onclick={() => deleteLessonVideo(index)}>
<TrashIcon size={16} />
</IconButton>
</div>
@@ -518,13 +535,11 @@
{/each}
</div>
{/if}
</TabContent>
<TabContent value={getValue('course.navItem.lessons.materials.tabs.document.title')} index={currentTab}>
</UnderlineTabs.Content>
<UnderlineTabs.Content value={String(getValue('course.navItem.lessons.materials.tabs.document.title') || '')}>
<ComponentDocument {mode} />
</TabContent>
</slot:fragment>
{/snippet}
</Tabs>
</UnderlineTabs.Content>
</UnderlineTabs.Root>
{:else if !isMaterialsEmpty($lesson.materials, $lessonByTranslation[lessonId])}
{#key lessonId}
<div class="mb-20 flex w-full flex-col gap-6" in:fade={{ delay: 500 }} out:fade>
@@ -534,29 +549,28 @@
{#if $currentOrg.customization?.apps?.comments}
<hr class="my-5" />
{$currentOrg.customization}
<Comments {lessonId} />
{/if}
</div>
{/key}
{:else}
<Box className="text-center">
<img src="/no-video.svg" alt="Video not found" />
<h3 class="py-2 text-xl font-normal dark:text-white">
{$t('course.navItem.lessons.materials.body_heading')}
</h3>
<Empty
title={$t('course.navItem.lessons.materials.body_heading')}
description={$t('course.navItem.lessons.materials.body_content') +
' ' +
$t('course.navItem.lessons.materials.get_started') +
' ' +
$t('course.navItem.lessons.materials.button') +
'.'}
icon={VideoIcon}
variant="page"
class="text-center"
>
{#if !isStudent}
<p class="py-2 text-center text-sm font-normal">
{$t('course.navItem.lessons.materials.body_content')}
<strong>{$t('course.navItem.lessons.materials.get_started')}</strong>
{$t('course.navItem.lessons.materials.button')}.
</p>
<PrimaryButton
label={$t('course.navItem.lessons.materials.get_started')}
className="rounded-md"
onClick={toggleMode}
/>
<Button onclick={toggleMode}>
{$t('course.navItem.lessons.materials.get_started')}
</Button>
{/if}
</Box>
</Empty>
{/if}
@@ -1,9 +1,8 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
// import Select from '$lib/components/Form/Select.svelte';
import { Button } from '@cio/ui/base/button';
import { InputField } from '@cio/ui/custom/input-field';
import {
lessons,
lessonSections,
@@ -11,7 +10,7 @@
handleSaveLessonSection
} from '$lib/components/Course/components/Lesson/store/lessons';
import { course } from '$lib/components/Course/store';
import Modal from '$lib/components/Modal/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { goto } from '$app/navigation';
import { handleAddLessonWidget } from './store';
import { t } from '$lib/utils/functions/translations';
@@ -107,22 +106,26 @@
}
</script>
<Modal
onClose={handleClose}
<Dialog.Root
bind:open={$handleAddLessonWidget.open}
width="w-[80%] md:w-[65%]"
maxWidth="max-w-2xl"
containerClass="overflow-hidden"
modalHeading={$t(
onOpenChange={(isOpen) => {
if (!isOpen) handleClose();
}}
>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
{$t(
`course.navItem.lessons.add_lesson.${$handleAddLessonWidget.isSection ? 'modal_heading_section' : 'modal_heading'}`
)}
>
</Dialog.Title>
</Dialog.Header>
<form
onsubmit={preventDefault(handleSave)}
class="relative m-auto mb-2 flex flex-wrap items-center px-2 py-2 md:mb-4 md:px-5 md:py-3 dark:bg-neutral-800"
class="relative mb-2 flex w-full flex-wrap items-center px-2 py-2 md:mb-4 md:px-5 md:py-3"
>
<div class="w-full">
<TextField
<InputField
label={$t(
`course.navItem.lessons.add_lesson.${
$handleAddLessonWidget.isSection ? 'lesson_section_title' : 'lesson_title'
@@ -130,7 +133,7 @@
)}
bind:value={lesson.title}
autoFocus={true}
className="flex-1 min-w-lg max-w-lg"
className="w-full"
isRequired={true}
errorMessage={errors.title}
/>
@@ -156,7 +159,7 @@
</div>
<div class="flex items-center mb-3 lg:mb-0">
<TextField className="w-[179px]" placeholder="https://meet.google.com/mga-dsjs-fmb" />
<InputField className="w-[179px]" placeholder="https://meet.google.com/mga-dsjs-fmb" />
</div>
</div> -->
{/if}
@@ -164,6 +167,9 @@
</form>
<div class="flex flex-row-reverse">
<PrimaryButton label={$t('course.navItem.lessons.add_lesson.save')} onClick={handleSave} />
<Button onclick={handleSave}>
{$t('course.navItem.lessons.add_lesson.save')}
</Button>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -12,7 +12,7 @@ import type { RealtimeChannel } from '@supabase/supabase-js';
import type { TLocale } from '@cio/db/types';
import type { Writable } from 'svelte/store';
import { lessonValidation } from '$lib/utils/functions/validator';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { writable } from 'svelte/store';
export const lessonVideoUpload = writable({
@@ -1,405 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import Plus from '@lucide/svelte/icons/plus';
import * as Sidebar from '@cio/ui/base/sidebar';
import LockIcon from '@lucide/svelte/icons/lock';
import * as Collapsible from '@cio/ui/base/collapsible';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import UsersIcon from '@lucide/svelte/icons/users';
import AwardIcon from '@lucide/svelte/icons/award';
import PanelTop from '@lucide/svelte/icons/panel-top';
import SettingsIcon from '@lucide/svelte/icons/settings';
import MegaphoneIcon from '@lucide/svelte/icons/megaphone';
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
import BookCheckIcon from '@lucide/svelte/icons/book-check';
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text';
import SquareLibrary from '@lucide/svelte/icons/square-library';
import BookOpenCheck from '@lucide/svelte/icons/book-open-check';
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
import { NAV_IDS } from './constants';
import { profile } from '$lib/utils/store/user';
import { course } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import { handleAddLessonWidget } from '../Lesson/store';
import { getIsLessonComplete } from '../Lesson/functions';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { COURSE_TYPE, COURSE_VERSION } from '$lib/utils/types';
import { lessons, lessonSections } from '../Lesson/store/lessons';
import { getNavItemRoute, getLessonsRoute, getLectureNo } from '$lib/components/Course/function';
import TextChip from '$lib/components/Chip/Text.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
interface Props {
path: string;
isStudent?: boolean;
}
let { path, isStudent = false }: Props = $props();
const navItems = $derived(
[
{
id: NAV_IDS.NEWS_FEED,
title: $t('course.navItems.nav_news_feed'),
url: getNavItemRoute($course.id),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id),
show() {
return isStudent ? $currentOrg.customization?.['course']?.['newsfeed'] : true;
},
icon: getNavIcon(NAV_IDS.NEWS_FEED)
},
{
id: NAV_IDS.LESSONS,
title: $t('course.navItems.nav_content'),
url: getLessonsRoute($course.id),
isActive:
(path || page.url.pathname).includes('/lessons') ||
(path || page.url.pathname) === getLessonsRoute($course.id),
isLesson: true,
icon: getNavIcon(NAV_IDS.LESSONS),
items:
$course.version === COURSE_VERSION.V1
? ($lessons || []).map((lesson, index) => ({
title: lesson.title,
url: getLessonsRoute($course.id, lesson.id),
lesson,
index,
isV1: true
}))
: $lessonSections.flatMap((section) =>
section.lessons.map((lesson) => ({
title: lesson.title,
url: getLessonsRoute($course.id, lesson.id),
lesson,
sectionTitle: section.title,
isV1: false
}))
)
},
{
id: NAV_IDS.ANALYTICS,
title: $t('course.navItems.nav_analytics'),
url: getNavItemRoute($course.id, 'analytics'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'analytics'),
show() {
return !isStudent;
},
icon: getNavIcon(NAV_IDS.ANALYTICS)
},
{
id: NAV_IDS.ATTENDANCE,
title: $t('course.navItems.nav_attendance'),
url: getNavItemRoute($course.id, 'attendance'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'attendance'),
show() {
if ($course.type !== COURSE_TYPE.LIVE_CLASS) return false;
return true;
},
icon: getNavIcon(NAV_IDS.ATTENDANCE)
},
{
id: NAV_IDS.SUBMISSIONS,
title: $t('course.navItems.nav_submissions'),
url: getNavItemRoute($course.id, 'submissions'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'submissions'),
show() {
if (isStudent) return false;
return true;
},
icon: getNavIcon(NAV_IDS.SUBMISSIONS)
},
{
id: NAV_IDS.MARKS,
title: $t('course.navItems.nav_marks'),
url: getNavItemRoute($course.id, 'marks'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'marks'),
show() {
if ($course.type == COURSE_TYPE.LIVE_CLASS) {
return isStudent ? ($currentOrg.customization?.['course']?.['grading'] ?? false) : true;
}
return false;
},
icon: getNavIcon(NAV_IDS.MARKS)
},
{
id: NAV_IDS.CERTIFICATES,
title: $t('course.navItems.nav_certificates'),
url: getNavItemRoute($course.id, 'certificates'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'certificates'),
show() {
if (isStudent && $isFreePlan) {
return false;
}
return true;
},
icon: getNavIcon(NAV_IDS.CERTIFICATES)
},
{
id: NAV_IDS.LANDING_PAGE,
title: $t('course.navItems.nav_landing_page'),
url: getNavItemRoute($course.id, 'landingpage'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'landingpage'),
show() {
return !isStudent;
},
icon: getNavIcon(NAV_IDS.LANDING_PAGE)
},
{
id: NAV_IDS.PEOPLE,
title: $t('course.navItems.nav_people'),
url: getNavItemRoute($course.id, 'people'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'people'),
show() {
return !isStudent;
},
icon: getNavIcon(NAV_IDS.PEOPLE)
},
{
id: NAV_IDS.SETTINGS,
title: $t('course.navItems.nav_settings'),
url: getNavItemRoute($course.id, 'settings'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'settings'),
show() {
return !isStudent;
},
icon: getNavIcon(NAV_IDS.SETTINGS)
}
].filter((item) => !item.show || item.show())
);
function addLesson(isSection: boolean, id: string) {
goto('/courses/' + $course.id + '/lessons');
$handleAddLessonWidget.open = true;
if ($course.version === COURSE_VERSION.V2) {
$handleAddLessonWidget.isSection = isSection ? false : true;
if (isSection) {
$handleAddLessonWidget.id = id;
}
}
}
function getNavIcon(id: string) {
if (!id) return null;
if (id === NAV_IDS.SECTION) {
return TableOfContentsIcon;
} else if (id === NAV_IDS.NEWS_FEED) {
return MegaphoneIcon;
} else if (id === NAV_IDS.LESSONS) {
return ScrollTextIcon;
} else if (id === NAV_IDS.ATTENDANCE) {
return BookOpenCheck;
} else if (id === NAV_IDS.SUBMISSIONS) {
return SquareLibrary;
} else if (id === NAV_IDS.MARKS) {
return BookCheckIcon;
} else if (id === NAV_IDS.PEOPLE) {
return UsersIcon;
} else if (id === NAV_IDS.ANALYTICS) {
return ChartLineIcon;
} else if (id === NAV_IDS.LANDING_PAGE) {
return PanelTop;
} else if (id === NAV_IDS.CERTIFICATES) {
return AwardIcon;
} else if (id === NAV_IDS.SETTINGS) {
return SettingsIcon;
}
return null;
}
function toggleSidebarOnMobile() {}
</script>
<Sidebar.Provider class="flex w-fit items-start gap-2">
<Sidebar.Root collapsible="icon">
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupLabel>Course Navigation</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each navItems as item (item.id)}
<Collapsible.Root open={item.isActive} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
{#if item.isLesson && item.items}
<Collapsible.Trigger>
{#snippet child({ props })}
<a href={item.url}>
<Sidebar.MenuButton
{...props}
tooltipContent={item.title}
class="flex w-full items-center gap-4 py-2 pl-1.5"
>
{@const Icon = item.icon}
<Icon size={16} class="nav-icon group-hover:animate-{item.id}" />
{item.title}
<div class="ml-auto flex items-center gap-1">
<Plus
size={20}
class="rounded-full p-1 hover:bg-gray-200"
onclick={() => addLesson(false, item.id)}
/>
<ChevronRightIcon
size={20}
class="rounded-full p-1 transition-transform duration-200 hover:bg-gray-200 group-data-[state=open]/collapsible:rotate-90"
/>
</div>
</Sidebar.MenuButton>
</a>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub>
{#if $course.version === COURSE_VERSION.V1}
{#each item.items as lessonItem}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton
isActive={(path || page.url.pathname).includes(lessonItem.lesson.id)}
>
{#snippet child({ props })}
<a
href={isStudent && !lessonItem.lesson.is_unlocked
? page.url.pathname
: lessonItem.url}
onclick={toggleSidebarOnMobile}
aria-disabled={!lessonItem.lesson.is_unlocked}
title={lessonItem.lesson.title}
class="flex w-full items-center gap-2 {isStudent && !lessonItem.lesson.is_unlocked
? 'cursor-not-allowed opacity-50'
: ''}"
{...props}
>
{#if lessonItem.isV1 && 'index' in lessonItem}
<TextChip
value={getLectureNo(lessonItem.index + 1)}
className="bg-primary-200 text-primary-600 text-xs"
size="sm"
shape="rounded-full"
/>
{/if}
<span class="flex-1 truncate">{lessonItem.lesson.title}</span>
{#if !lessonItem.lesson.is_unlocked}
<LockIcon size={16} class="shrink-0" />
{:else if getIsLessonComplete(lessonItem.lesson.lesson_completion, $profile.id)}
<span class="shrink-0">
<CircleCheckIcon size={16} filled />
</span>
{/if}
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
{:else}
{#each $lessonSections as section}
<Collapsible.Root open={true} class="group/section">
{#snippet child({ props })}
<Sidebar.MenuSubItem {...props}>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuSubButton
{...props}
class="flex w-full items-center gap-2 font-medium"
>
{@const Icon = getNavIcon(NAV_IDS.SECTION)}
<Icon size={14} />
<span class="flex-1 truncate">{section.title}</span>
<div class="ml-auto flex items-center gap-1">
<Plus
size={20}
class="rounded-full p-1 hover:bg-gray-200"
onclick={() => addLesson(true, section.id)}
/>
<ChevronRightIcon
size={20}
class="rounded-full p-1 transition-transform duration-200 hover:bg-gray-200 group-data-[state=open]/collapsible:rotate-90"
/>
</div>
</Sidebar.MenuSubButton>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub class="ml-2">
{#each section.lessons as lesson}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton
isActive={(path || page.url.pathname).includes(lesson.id)}
>
{#snippet child({ props })}
<a
href={isStudent && !lesson.is_unlocked
? page.url.pathname
: getLessonsRoute($course.id, lesson.id)}
onclick={toggleSidebarOnMobile}
aria-disabled={!lesson.is_unlocked}
title={lesson.title}
class="flex w-full items-center gap-2 {isStudent && !lesson.is_unlocked
? 'cursor-not-allowed opacity-50'
: ''}"
{...props}
>
<span class="flex-1 truncate">{lesson.title}</span>
{#if !lesson.is_unlocked}
<LockIcon size={16} class="shrink-0" />
{:else if getIsLessonComplete(lesson.lesson_completion, $profile.id)}
<span class="shrink-0">
<CircleCheckIcon size={16} filled />
</span>
{/if}
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Sidebar.MenuSubItem>
{/snippet}
</Collapsible.Root>
{/each}
{/if}
</Sidebar.MenuSub>
</Collapsible.Content>
{:else}
<a href={item.url} onclick={toggleSidebarOnMobile}>
<Sidebar.MenuButton
tooltipContent={item.title}
class="flex w-full cursor-pointer items-center gap-4 px-1.5 py-2 {item.isActive
? 'ui:bg-accent ui:text-accent-foreground'
: ''}"
>
{@const Icon = item.icon}
<Icon size={16} class="nav-icon group-hover:animate-{item.id}" />
{item.title}
</Sidebar.MenuButton>
</a>
{/if}
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Rail />
</Sidebar.Root>
<Sidebar.Inset>
<Sidebar.Trigger />
</Sidebar.Inset>
</Sidebar.Provider>
<style>
a {
text-decoration: none;
}
</style>
@@ -1,8 +1,6 @@
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
interface Props {
openDeleteModal?: boolean;
@@ -17,18 +15,27 @@
}
</script>
<Modal onClose={() => (openDeleteModal = false)} bind:open={openDeleteModal} width="w-96" modalHeading="Delete Feed">
<Dialog.Root
bind:open={openDeleteModal}
>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>Delete Feed</Dialog.Title>
</Dialog.Header>
<div>
<h1 class="text-lg dark:text-white">Are you sure you want to delete this feed?</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label="No"
onClick={() => (openDeleteModal = false)}
/>
<PrimaryButton className="px-6 py-3" variant={VARIANTS.OUTLINED} label="Yes" onClick={handleDelete} />
<Button
variant="outline"
onclick={() => (openDeleteModal = false)}
>
No
</Button>
<Button variant="outline" onclick={handleDelete}>
Yes
</Button>
</div>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -1,17 +1,16 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import type { Feed, Author } from '$lib/utils/types/feed';
import { createNewFeed } from '$lib/utils/services/newsfeed';
import { getTextFromHTML } from '$lib/utils/functions/toHtml';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Button } from '@cio/ui/base/button';
import { createNewsfeedValidation } from '$lib/utils/functions/validator';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
import Modal from '$lib/components/Modal/index.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { TextEditor } from '$features/ui';
interface Props {
author?: Author | any;
@@ -113,15 +112,20 @@
};
</script>
<Modal
onClose={resetEditor}
<Dialog.Root
bind:open={$isNewFeedModal.open}
width="w-4/5"
maxWidth="max-w-lg"
modalHeading={edit === true
onOpenChange={(isOpen) => {
if (!isOpen) resetEditor();
}}
>
<Dialog.Content class="w-4/5 max-w-lg">
<Dialog.Header>
<Dialog.Title>
{edit === true
? $t('course.navItem.news_feed.heading_button.edit_post')
: $t('course.navItem.news_feed.heading_button.make_a_post')}
>
</Dialog.Title>
</Dialog.Header>
<section class="w-2/ flex h-full flex-col rounded-xl pb-3">
<TextEditor
content={newPost}
@@ -136,13 +140,17 @@
{/if}
<div class="flex items-center justify-end py-2">
<div class="flex gap-2">
<PrimaryButton
label={$t('course.navItem.news_feed.heading_button.cancel')}
variant={VARIANTS.OUTLINED}
onClick={resetEditor}
/>
<PrimaryButton {isLoading} label={$t('course.navItem.news_feed.heading_button.post')} onClick={onPost} />
<Button
variant="outline"
onclick={resetEditor}
>
{$t('course.navItem.news_feed.heading_button.cancel')}
</Button>
<Button loading={isLoading} onclick={onPost}>
{$t('course.navItem.news_feed.heading_button.post')}
</Button>
</div>
</div>
</section>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -14,10 +14,10 @@
import { addNewsfeedCommentValidation } from '$lib/utils/functions/validator';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
import Chip from '$lib/components/Chip/index.svelte';
import { Chip } from '@cio/ui/custom/chip';
import DeleteFeedConfirmation from './DeleteFeedConfirmation.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { RoleBasedSecurity } from '$features/ui';
interface Props {
feed: Feed;
@@ -102,7 +102,7 @@
const usersReacted = feed.reaction[reactionType] || [];
return usersReacted.includes(author.id)
? 'bg-primary-200 border-primary-600 pl-2'
? 'bg-primary-200 ui:border-primary pl-2'
: 'bg-gray-200 border-gray-600 pl-2';
};
@@ -1,8 +1,6 @@
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { deleteMemberModal } from './store';
@@ -14,32 +12,32 @@
}
</script>
<Modal
onClose={() => ($deleteMemberModal.open = false)}
<Dialog.Root
bind:open={$deleteMemberModal.open}
width="w-96"
containerClass="pt-3"
headerClass="py-2 px-5"
modalHeading={$t('course.navItem.people.delete_confirmation.title')}
>
<Dialog.Content class="w-96 pt-3">
<Dialog.Header class="py-2 px-5">
<Dialog.Title>{$t('course.navItem.people.delete_confirmation.title')}</Dialog.Title>
</Dialog.Header>
<div>
<p class="mt-0 text-base dark:text-white">
{$t('course.navItem.people.delete_confirmation.sure')} <strong>{email}</strong>?
</p>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.people.delete_confirmation.no')}
onClick={() => ($deleteMemberModal.open = false)}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.people.delete_confirmation.yes')}
onClick={handleDelete}
/>
<Button
variant="outline"
onclick={() => ($deleteMemberModal.open = false)}
>
{$t('course.navItem.people.delete_confirmation.no')}
</Button>
<Button
variant="outline"
onclick={handleDelete}
>
{$t('course.navItem.people.delete_confirmation.yes')}
</Button>
</div>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -13,9 +13,9 @@
import { qrInviteNodeStore } from './store';
import { ROLE } from '@cio/utils/constants';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { orgApi } from '$features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import type { OrgTeamMember } from '$lib/utils/types/org';
import { preventDefault } from '$lib/utils/functions/svelte';
import { course, setCourse } from '$lib/components/Course/store';
@@ -25,8 +25,8 @@
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
import ShareQrImage from './ShareQRImage.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
interface Tutor {
id: number;
@@ -176,13 +176,16 @@
});
</script>
<Modal
onClose={() => closeModal()}
<Dialog.Root
open={addPeopleParm === 'true'}
width="w-4/5 md:w-2/5"
maxWidth="max-w-lg"
modalHeading={$t('course.navItem.people.invite_modal.title')}
onOpenChange={(isOpen) => {
if (!isOpen) closeModal();
}}
>
<Dialog.Content class="w-4/5 max-w-lg md:w-2/5">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.people.invite_modal.title')}</Dialog.Title>
</Dialog.Header>
<form onsubmit={preventDefault(onSubmit)}>
<div class="mb-8">
<p class="mb-1 text-base font-semibold">{$t('course.navItem.people.invite_modal.invite')}</p>
@@ -228,7 +231,7 @@
{:else}
<span class="mt-2 block text-sm">
{$t('course.navItem.people.invite_modal.to_add')}
<a href={`/org/${$currentOrg.siteName}/settings/teams`} class="text-primary-600 underline">
<a href={`/org/${$currentOrg.siteName}/settings/teams`} class="ui:text-primary underline">
{$t('course.navItem.people.invite_modal.go_to')}
</a>
</span>
@@ -263,12 +266,9 @@
{$t('course.navItem.people.invite_modal.via_qr')}
</span>
<PrimaryButton
isLoading={isLoadingQRDownload}
onClick={handleQRDownload}
label={$t('course.navItem.people.invite_modal.download_qr')}
className="font-medium"
/>
<Button variant="outline" loading={isLoadingQRDownload} onclick={handleQRDownload}>
{$t('course.navItem.people.invite_modal.download_qr')}
</Button>
</div>
<div class="w-full border-4 border-[#f7f7f7] p-1 md:w-28">
@@ -281,7 +281,10 @@
</div>
<div class="mt-5 flex flex-row-reverse items-center">
<PrimaryButton className="px-6 py-3" label={$t('course.navItem.people.invite_modal.finish')} type="submit" />
<Button variant="secondary" type="submit">
{$t('course.navItem.people.invite_modal.finish')}
</Button>
</div>
</form>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -1,5 +1,5 @@
<script lang="ts">
import type { AccountOrg } from '$lib/features/app/types';
import type { AccountOrg } from '$features/app/types';
import type { Course } from '$lib/utils/types';
import { isFreePlan } from '$lib/utils/store/org';
import { qrInviteNodeStore } from './store';
@@ -28,7 +28,7 @@
<div class="my-4 rounded-xl bg-gray-100 p-2 text-xl">Scan QR</div>
<img src={qrImage} alt="qrcode" />
<div class="pb-4 pt-1">
<p class="text-primary-600 mt-2 text-2xl">{course.title}</p>
<p class="ui:text-primary mt-2 text-2xl">{course.title}</p>
<p class="mt-1 text-lg font-semibold text-black">{currentOrg.name}</p>
</div>
</div>
@@ -1,480 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import * as RadioGroup from '@cio/ui/base/radio-group';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import DragAndDrop from './DragAndDrop.svelte';
import { IconButton } from '$lib/components/IconButton';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { Row, Grid, Column } from '$lib/components/Org/Settings/Layout';
import SectionTitle from '$lib/components/Org/SectionTitle.svelte';
import { UpgradeBanner } from '$lib/features/ui';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
import { settings } from './store';
import Copy from '@lucide/svelte/icons/copy';
import type { Course } from '$lib/utils/types';
import { COURSE_TYPE } from '$lib/utils/types';
import { lessons } from '../Lesson/store/lessons';
import { course } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import { isObject } from '$lib/utils/functions/isObject';
import { snackbar } from '$lib/components/Snackbar/store';
import generateSlug from '$lib/utils/functions/generateSlug';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
import { deleteCourse, updateCourse } from '$lib/utils/services/courses';
import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
import { currentOrg, currentOrgDomain, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
let isSaving = $state(false);
let isLoading = $state(false);
let isDeleting = $state(false);
let errors: {
title: string | undefined;
description: string | undefined;
} = $state({
title: undefined,
description: undefined
});
let avatar: string | undefined;
let hasUnsavedChanges = $state(false);
let openDeleteModal = $state(false);
function widgetControl() {
$handleOpenWidget.open = !$handleOpenWidget.open;
}
function getLessonOrder(id: string) {
const index = $lessons.findIndex((lesson) => lesson.id === id);
if (index < 9) {
return '0' + (index + 1);
} else {
return index + 1;
}
}
const downloadCourse = async () => {
alert('Coming soon');
return;
isLoading = true;
try {
const lessonsList = $lessons.map((lesson) => ({
lessonTitle: lesson.title,
lessonNumber: getLessonOrder(lesson.id),
lessonNote: lesson.note,
slideUrl: lesson.slide_url || '',
video: lesson.videos || ''
}));
const response = await fetch('/downloadCourse', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
courseTitle: $course.title,
orgName: $currentOrg.name,
orgTheme: $currentOrg.theme || '',
lessons: lessonsList
})
});
if (!response.ok) {
throw new Error(await response.text());
}
const data = await response.blob();
const file = new Blob([data], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file);
let a = document.createElement('a');
document.body.append(a);
a.download = $course.title + ' - ' + 'Course ';
a.href = fileURL;
a.click();
a.remove();
snackbar.success('snackbar.course_settings.success.download');
} catch (error) {
snackbar.error('snackbar.course_settings.error.not_right');
}
isLoading = false;
};
const deleteBannerImage = () => {
$settings.logo = '';
hasUnsavedChanges = true;
};
async function handleDeleteCourse() {
isDeleting = true;
try {
await deleteCourse($course.id);
goto($currentOrgPath + '/courses');
} catch (error) {
snackbar.error('snackbar.course_settings.error.went_wrong');
}
isDeleting = false;
}
const handleSave = async () => {
if (!$settings.course_title) {
errors.title = $t('snackbar.course_settings.error.title');
return;
}
if (!$settings.course_description) {
errors.description = $t('snackbar.course_settings.error.description');
return;
}
isSaving = true;
try {
const updatedCourse = {
title: $settings.course_title,
description: $settings.course_description,
type: $settings.type,
logo: $settings.logo,
is_published: $settings.is_published,
metadata: {
...(isObject($course.metadata) ? $course.metadata : {}),
lessonTabsOrder: $settings.tabs,
grading: $settings.grading,
lessonDownload: $settings.lesson_download,
allowNewStudent: $settings.allow_new_students
},
slug: $course.slug
};
await updateCourse($course.id, avatar, updatedCourse);
$course = {
...$course,
...updatedCourse
};
snackbar.success('snackbar.course_settings.success.saved');
hasUnsavedChanges = false;
} catch (error) {
snackbar.error();
}
isSaving = false;
};
const generateNewCourseLink = () => {
$course.slug = generateSlug($course.title);
hasUnsavedChanges = true;
};
async function setDefault(course: Course) {
if (!course || !Object.keys(course).length) return;
untrack(() => {
settings.set({
course_title: course.title,
type: course.type,
course_description: course.description,
logo: course.logo || '',
tabs: course.metadata.lessonTabsOrder || $settings.tabs,
grading: !!course.metadata.grading,
lesson_download: !!course.metadata.lessonDownload,
is_published: !!course.is_published,
allow_new_students: course.metadata.allowNewStudent
});
});
}
$effect(() => {
setDefault($course);
});
let courseLink = $derived(`${$currentOrgDomain}/course/${$course.slug}`);
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<DeleteModal onDelete={handleDeleteCourse} bind:open={openDeleteModal} />
<Grid class="border-c rounded border-gray-200 dark:border-neutral-600">
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.cover_image')}</SectionTitle>
<p>
{$t('course.navItem.settings.optional_image')}
</p>
<span class="flex items-center justify-start">
<PrimaryButton
variant={VARIANTS.CONTAINED_DARK}
label={$t('course.navItem.settings.replace')}
className="mr-2"
onClick={widgetControl}
/>
<PrimaryButton variant={VARIANTS.OUTLINED} label={$t('ai.reset')} onClick={deleteBannerImage} />
</span>
{#if $handleOpenWidget.open}
<UploadWidget
bind:imageURL={$settings.logo}
onchange={() => {
hasUnsavedChanges = true;
}}
/>
{/if}
</Column>
<Column sm={8} md={8} lg={6}>
<div class="relative z-[20] w-fit">
<img
style="min-width:280px; min-height:200px"
alt="About us"
src={$settings.logo ? $settings.logo : '/images/classroomio-course-img-template.jpg'}
class="relative mt-2 h-[200px] w-[280px] rounded-md md:mt-0"
/>
</div>
</Column>
</Row>
<Row id="share" class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.course_details')}</SectionTitle>
</Column>
<Column sm={8} md={8} lg={8}>
<TextField
label={$t('course.navItem.settings.course_title')}
placeholder="Write the course title here"
className="w-full mb-5"
isRequired
bind:value={$settings.course_title}
errorMessage={errors?.title}
onInputChange={() => {
hasUnsavedChanges = true;
}}
/>
<TextArea
label={$t('course.navItem.settings.course_description')}
placeholder={$t('course.navItem.settings.placeholder')}
className="w-full mb-5"
isRequired
bind:value={$settings.course_description}
errorMessage={errors?.description}
onChange={() => {
hasUnsavedChanges = true;
}}
/>
<div id="share">
<p class="text-md mb-2 flex items-center gap-2">
{$t('course.navItem.settings.link')}
<IconButton contained={true} size="small" onClick={generateNewCourseLink}>
<RotateCcwIcon size={16} />
</IconButton>
<span class="grow"></span>
<IconButton contained={true} size="small" onClick={() => goto(courseLink)}>
<ArrowUpRightIcon size={16} />
</IconButton>
</p>
<div class="flex items-center justify-between rounded-md border p-3">
{#if $course.slug}
<p class="text-sm">{courseLink}</p>
<IconButton
contained={true}
size="small"
onClick={() => {
copyToClipboard(courseLink);
}}
>
<Copy size={16} />
</IconButton>
{:else}
<p class="text-sm">Setup landing page to get course link</p>
{/if}
</div>
</div>
</Column>
</Row>
<!-- <Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.grading')}</SectionTitle>
<p>{$t('course.navItem.settings.reports')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
<Toggle size="sm" bind:toggled={$settings.grading}>
<span slot="labelA" style="color: gray">{$t('course.navItem.settings.disabled')}</span>
<span slot="labelB" style="color: gray">{$t('course.navItem.settings.enabled')}</span>
</Toggle>
</Column>
</Row> -->
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.order')}</SectionTitle>
<p>{$t('course.navItem.settings.drag')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
<DragAndDrop
on:change={() => {
hasUnsavedChanges = true;
}}
/>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.lesson_download')}</SectionTitle>
<p>{$t('course.navItem.settings.available')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
{#if $isFreePlan}
<UpgradeBanner>{$t('upgrade.download_lessons')}</UpgradeBanner>
{:else}
<div class="flex items-center space-x-2">
<Switch
id="lesson-download"
bind:checked={$settings.lesson_download}
onCheckedChange={() => {
hasUnsavedChanges = true;
}}
/>
<Label for="lesson-download" class="text-sm text-gray-500">
{$settings.lesson_download ? $t('course.navItem.settings.enabled') : $t('course.navItem.settings.disabled')}
</Label>
</div>
{/if}
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.course_download')}</SectionTitle>
<p>{$t('course.navItem.settings.course_avail')}</p>
</Column>
<Column sm={4} md={4} lg={4}>
{#if $isFreePlan}
<UpgradeBanner>{$t('upgrade.download_course')}</UpgradeBanner>
{:else}
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.settings.download')}
onClick={downloadCourse}
isDisabled={isLoading}
{isLoading}
/>
{/if}
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.type')}</SectionTitle>
<p>{$t('course.navItem.settings.course_type_desc')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
<RadioGroup.Root
value={$settings.type}
onValueChange={(value) => {
$settings.type = value as COURSE_TYPE;
if (hasUnsavedChanges) return;
hasUnsavedChanges = true;
}}
>
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={COURSE_TYPE.LIVE_CLASS} id="live-class" />
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value={COURSE_TYPE.SELF_PACED} id="self-paced" />
<Label for="self-paced">{$t('course.navItem.settings.self_paced')}</Label>
</div>
</RadioGroup.Root>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.allow')}</SectionTitle>
<p>{$t('course.navItem.settings.access')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
<div class="flex items-center space-x-2">
<Switch
id="allow-new-students"
bind:checked={$settings.allow_new_students}
onCheckedChange={() => {
hasUnsavedChanges = true;
}}
/>
<Label for="allow-new-students" class="text-sm text-gray-500">
{$settings.allow_new_students
? $t('course.navItem.settings.enabled')
: $t('course.navItem.settings.disabled')}
</Label>
</div>
</Column>
</Row>
<!-- Publish Course -->
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.publish')}</SectionTitle>
<p>{$t('course.navItem.settings.determines')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
<div class="flex items-center space-x-2">
<Switch
id="is-published"
bind:checked={$settings.is_published}
onCheckedChange={(checked) => {
hasUnsavedChanges = true;
$settings.allow_new_students = checked;
if (!$course.slug) {
generateNewCourseLink();
}
}}
/>
<Label for="is-published" class="text-sm text-gray-500">
{$settings.is_published ? $t('course.navItem.settings.published') : $t('course.navItem.settings.unpublished')}
</Label>
</div>
</Column>
</Row>
<Row id="delete" class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.delete')}</SectionTitle>
<p>{$t('course.navItem.settings.delete_text')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
<PrimaryButton
variant={VARIANTS.CONTAINED_DANGER}
label={$t('course.navItem.settings.delete')}
onClick={() => (openDeleteModal = true)}
isLoading={isDeleting}
isDisabled={isDeleting}
/>
</Column>
</Row>
<div class="flex w-full items-center justify-end p-5">
<PrimaryButton
label={$t('course.navItem.settings.save')}
isLoading={isSaving}
isDisabled={isSaving}
onClick={handleSave}
/>
</div>
</Grid>
@@ -2,16 +2,12 @@
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { Spinner } from '@cio/ui/base/spinner';
import Navigation from '../Course/components/Navigation/index.svelte';
import Backdrop from '$lib/components/Backdrop/index.svelte';
import { Backdrop, Confetti } from '$features/ui';
import { course, group, courseStore } from '../Course/store';
import Confetti from '../Confetti/index.svelte';
import { isMobile } from '$lib/utils/store/useMobile';
import { profile } from '$lib/utils/store/user';
import { globalStore } from '$lib/utils/store/app';
import Modal from '$lib/components/Modal/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { t } from '$lib/utils/functions/translations';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { Button } from '@cio/ui/base/button';
import { isOrgAdmin } from '$lib/utils/store/org';
import type { GroupPerson } from '$lib/utils/types';
@@ -28,10 +24,8 @@
let {
courseId,
path = '',
isExercisePage = false,
isFetching = $bindable(false),
containerClass = '',
onFetchingChange = (_v: boolean) => {},
children,
renderOnlyChildren = false
@@ -69,30 +63,32 @@
</Backdrop>
{/if}
<Modal open={!isPermitted} width="w-96" modalHeading={$t('course.not_permitted.header')}>
<Dialog.Root open={!isPermitted}>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('course.not_permitted.header')}</Dialog.Title>
</Dialog.Header>
<div>
<p class="text-md text-center dark:text-white">
{$t('course.not_permitted.body')}
</p>
<div class="mt-5 flex justify-center">
<PrimaryButton
className="px-6 py-3"
label={$t('course.not_permitted.button')}
onClick={() => {
<Button
onclick={() => {
goto('/org/*');
}}
/>
>
{$t('course.not_permitted.button')}
</Button>
</div>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
{#if renderOnlyChildren}
{@render children?.()}
{:else}
<div class="root">
<Navigation {path} isStudent={$globalStore.isStudent} />
<div class="rightBar {containerClass}" class:isMobile={$isMobile}>
{#if isExercisePage}
<Confetti />
{/if}
@@ -101,18 +97,4 @@
{#if isPermitted}
{@render children?.()}
{/if}
</div>
</div>
{/if}
<style>
.root {
display: flex;
width: 100%;
}
.rightBar {
flex-grow: 1;
width: calc(100% - 360px);
}
</style>
@@ -1,72 +0,0 @@
<script lang="ts">
interface Props {
options?: any;
classNames?: string;
isIcon?: boolean;
children?: import('svelte').Snippet;
}
let { options = [], classNames = 'relative', isIcon = false, children }: Props = $props();
let open = $state(false);
const notIconClass = 'border rounded-lg border-grey p-3 focus:outline-none focus:border-gray-400 focus:bg-gray-200';
function isOptionLast(index) {
return index === options.length - 1;
}
function onClick(option) {
return () => {
open = !open;
if (option && option.onClick) {
option.onClick();
}
};
}
function onBlur() {
// In case an option was clicked, let's wait for the on:click of that button before we close
setTimeout(() => (open = false), 100);
}
</script>
<div class="{classNames} z-10 {!options.length && 'hidden'}">
<button
class="flex flex-row items-center {!isIcon && notIconClass}"
onclick={(e) => {
e.preventDefault();
e.stopImmediatePropagation();
open = !open;
}}
onblur={onBlur}
>
{@render children?.()}
</button>
{#if open}
<div class="dropdown border-grey absolute mt-2 whitespace-nowrap rounded-lg border bg-white py-3 dark:bg-black">
{#each options as option, index}
<button
class="hover:bg-primary-100 block px-4 py-3 text-left {!isOptionLast(index) &&
'border-b'} w-full dark:text-white dark:hover:text-black"
onclick={onClick(option)}
>
{option.label}
</button>
{/each}
</div>
{/if}
</div>
<style>
.dropdown {
box-shadow:
0px 5px 5px -3px rgb(0 0 0 / 20%),
0px 8px 10px 1px rgb(0 0 0 / 14%),
0px 3px 14px 2px rgb(0 0 0 / 12%);
right: 0px;
top: 20px;
left: unset;
}
</style>
@@ -1,5 +0,0 @@
<script lang="ts">
let { message } = $props();
</script>
<p class="text-sm text-red-500">{message}</p>
@@ -1,62 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import LinkIcon from '@lucide/svelte/icons/link';
interface Props {
id?: string;
title?: string;
titleClass?: string;
disableContainerPadding?: boolean;
rootClass?: string;
supportsLink?: boolean;
isExpanded?: boolean;
children?: import('svelte').Snippet;
}
let {
id = '',
title = '',
titleClass = '',
disableContainerPadding = false,
rootClass = '',
supportsLink = false,
isExpanded = $bindable(true),
children
}: Props = $props();
function handleClick(e) {
e.stopPropagation();
isExpanded = !isExpanded;
}
</script>
<div {id} class="w-full bg-white dark:bg-black {rootClass}">
<button
class="relative flex w-full items-center justify-between {!disableContainerPadding && 'p-5'}"
tabindex="0"
onclick={handleClick}
>
<p class="flex items-center dark:text-white {titleClass}">
{title}
{#if supportsLink}
<a class="ml-2" href="{page.url.pathname}#{id}" onclick={(e) => e.stopPropagation()}>
<LinkIcon size={16} />
</a>
{/if}
</p>
{#if isExpanded}
<ChevronUpIcon size={16} />
{:else}
<ChevronDownIcon size={16} />
{/if}
</button>
{#if isExpanded}
<div class="flex flex-col p-5">
{@render children?.()}
</div>
{/if}
</div>
@@ -1,17 +0,0 @@
<script>
</script>
<!-- markup (zero or more items) goes here -->
<div class="my-1">
<hr />
<div class="flex w-full justify-center">
<span class="py-3 text-sm text-gray-700"
>Created with love by
<a class="text-primary-700 underline" href="https://twitter.com/rotimi_best" target="_blank">Rotimi Best</a></span
>
</div>
</div>
<style>
/* your styles go here */
</style>
@@ -1,83 +0,0 @@
<script lang="ts">
interface Props {
label?: string;
value: string | number | null;
placeholder?: string;
inputClassName?: string;
labelClassName?: string;
isDisabled?: boolean;
isRequired?: boolean;
bgColor?: string;
errorMessage?: string;
helperMessage?: string;
onInput?: any;
onKeyDown?: any;
onInputChange?: any;
onChange?: any; // This is to know if element is 'dirty'
}
let {
label = '',
value,
placeholder = '',
inputClassName = '',
labelClassName = 'font-light',
isDisabled = false,
isRequired = false,
bgColor = 'bg-gray-100 focus:bg-primary-50 dark:bg-neutral-800 dark:text-white',
errorMessage = '',
helperMessage = '',
onInput = (e) => {},
onKeyDown = (e) => {},
onInputChange = (e) => {},
onChange = () => {}
}: Props = $props();
let focusClass = $state('');
</script>
{#if label}
<p class="m-0 text-left text-sm dark:text-white {labelClassName}">
{label}
{#if isRequired}
<span class="text-red-700">*</span>
{/if}
</p>
{/if}
<span
class="form-input custom-placeholder form-input mt-2 block min-h-[50px] w-full resize overflow-hidden rounded-t-md border border-b-2 border-l-0 border-r-0 border-t-0 border-gray-200 p-3 pt-3.5 leading-5 outline-none focus:border-b-2 focus:border-l-0 focus:border-r-0 focus:border-t-0 dark:border-neutral-600 dark:bg-white dark:text-black {inputClassName} {focusClass} {isDisabled &&
'opacity-50 hover:cursor-not-allowed'} mt-1 block w-full p-3 dark:text-black {bgColor} {errorMessage
? 'border-red-600'
: ''}"
role="textbox"
tabindex={0}
contenteditable
oninput={onInput}
onkeydown={onKeyDown}
onchange={onInputChange}
onblur={(e) => {
if (focusClass.includes('border-primary-600')) {
focusClass = focusClass.replace('border-primary-600', '');
}
onChange(e);
}}
onfocus={() => (focusClass += ' border-primary-600')}
style="--placeholder: '{placeholder}'">{value}</span
>
{#if errorMessage}
<p class="text-sm text-red-500">{errorMessage}</p>
{:else if helperMessage}
<p class="text-sm dark:text-white">
{helperMessage}
</p>
{/if}
<style>
.custom-placeholder:empty::before {
content: var(--placeholder, 'Default Placeholder');
}
.form-input {
box-shadow: none !important;
}
</style>
@@ -1,28 +0,0 @@
<script lang="ts">
interface Props {
label?: string;
placeholder?: string;
value?: string;
className?: string;
onChange?: any; // This is to know if element is 'dirty'
}
let {
label = '',
placeholder = '',
value = $bindable(''),
className = '',
onChange = (e?: Event) => {}
}: Props = $props();
</script>
<label class="block {className}">
<span class="text-gray-700 dark:bg-neutral-800 dark:text-white">{label}</span>
<input
bind:value
{placeholder}
type="date"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-neutral-800 dark:text-white"
onblur={onChange}
/>
</label>
@@ -1,35 +0,0 @@
<script lang="ts">
interface Props {
label?: string;
placeholder?: string;
value?: string | Date;
className?: string;
onInput?: any;
isRequired?: boolean;
}
let {
label = '',
placeholder = '',
value = $bindable(''),
className = '',
onInput = () => {},
isRequired = false
}: Props = $props();
</script>
<label class="block {className}">
<span class="text-gray-700 dark:text-white"
>{label}
{#if isRequired}
<span class="text-red-700">*</span>
{/if}
</span>
<input
bind:value
oninput={onInput}
{placeholder}
type="datetime-local"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-neutral-800 dark:text-white"
/>
</label>
@@ -1,61 +0,0 @@
<script lang="ts">
interface Props {
label?: string;
value: any;
options?: any;
labelKey?: string;
isRequired?: boolean;
className?: string;
selectClassName?: string;
onChange?: any; // This is to know if element is 'dirty'
}
let {
label = '',
value = $bindable(),
options = [],
labelKey = 'label',
isRequired = false,
className = '',
selectClassName = '',
onChange = () => {}
}: Props = $props();
// onMount(() => {
// value = options[0];
// });
</script>
<label class="block {className}">
{#if label}
<span class="mr-2 text-gray-700 dark:text-white">{label}</span>
{/if}
<select
bind:value
class="form-select mt-1 block w-auto rounded-md border-2 px-2 py-1.5 dark:bg-neutral-700 dark:text-white {selectClassName}"
required={isRequired}
onblur={() => {}}
onchange={onChange}
class:customColor={!!selectClassName}
>
{#each options as option}
<option class="bg-white text-black" value={option}>
{option[labelKey]}
</option>
{/each}
</select>
</label>
<style>
select.customColor {
font-weight: 700;
cursor: pointer;
border-radius: 4px;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' class='text-white' viewBox='0 0 20 20'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
}
select.customColor option {
background-color: #fff;
color: #000;
}
</style>
@@ -1,105 +0,0 @@
<script lang="ts">
// import CustomPromptBtn from '$lib/components/AI/AIButton/CustomPromptBtn.svelte';
import { Popover as PopoverPrimitive } from 'bits-ui';
import { t } from '$lib/utils/functions/translations';
interface Props {
label?: string;
disabled?: boolean;
placeholder?: any;
value?: string;
rows?: number;
// export let maxRows = 3;
className?: string;
labelClassName?: string;
bgColor?: string;
helperMessage?: string;
errorMessage?: string;
isRequired?: boolean;
onChange?: any; // This is to know if element is 'dirty'
ref?: HTMLTextAreaElement | null;
isAIEnabled?: boolean;
initAIPrompt?: string;
aiAlignPopover?: PopoverPrimitive.ContentProps['align'];
iconbutton?: import('svelte').Snippet;
}
let {
label = '',
disabled = false,
placeholder = $t('course.navItem.lessons.exercises.all_exercises.write_your_answer'),
value = $bindable(),
rows = 3,
className = '',
labelClassName = '',
bgColor = 'bg-gray-50 focus:bg-primary-50 dark:bg-neutral-700 dark:text-white',
helperMessage = '',
errorMessage = '',
isRequired = false,
onChange = () => {},
ref = $bindable(null),
// isAIEnabled = false,
initAIPrompt = $bindable(''),
// aiAlignPopover = 'left',
iconbutton
}: Props = $props();
// $: maxHeight = maxRows ? `${1 + maxRows * 1.2}em` : `auto`;
</script>
<label for="text-field" class="relative block w-full {className}">
{#if label}
<p class="flex w-full items-center justify-between text-left dark:text-white {labelClassName}">
<span>
{label}
{#if isRequired}
<span class="text-red-700">*</span>
{/if}
</span>
<!-- {#if isAIEnabled}
<CustomPromptBtn
bind:defaultPrompt={initAIPrompt}
alignPopover={aiAlignPopover}
handleInsert={(v) => {
value = v;
}}
/>
{/if} -->
</p>
{/if}
<!-- <pre
aria-hidden="true"
style="min-height: {minHeight}; max-height: {maxHeight}">
{value + '\n'}
</pre> -->
<textarea
{disabled}
{rows}
{placeholder}
bind:value
bind:this={ref}
required={isRequired}
class="form-textarea focus:border-primary-600 mt-1 block w-full rounded-t-md border-b-2 border-l-0 border-r-0 border-t-0 border-gray-200 p-3 focus:border-b-2 focus:border-l-0 focus:border-r-0 focus:border-t-0 dark:border-neutral-600
{bgColor} {errorMessage ? 'border-red-500' : 'border-gray-300'}"
onchange={onChange}
></textarea>
{#if !!errorMessage}
<p class="text-sm text-red-500">
{errorMessage}
</p>
{:else if helperMessage}
<p class="text-sm text-gray-500 dark:text-white">
{helperMessage}
</p>
{/if}
{@render iconbutton?.()}
</label>
<style>
.form-textarea {
box-shadow: none !important;
}
</style>
@@ -1,156 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { IconButton } from '$lib/components/IconButton';
import PasswordEye from '../Icons/PasswordEye.svelte';
interface Props {
label?: string;
placeholder?: string;
value?: string | number | null;
name?: string;
onKeyDown?: any;
className?: string;
inputClassName?: string;
labelClassName?: string;
bgColor?: string;
type?: string;
isPassword?: boolean;
autoFocus?: boolean;
isRequired?: boolean;
isDisabled?: boolean;
min?: string | number | null | undefined;
max?: string | number | null | undefined;
errorMessage?: string;
helperMessage?: string;
autoComplete?: boolean;
onChange?: any; // This is to know if element is 'dirty'
onInputChange?: any;
}
let {
label = '',
placeholder = '',
value = $bindable(),
name = '',
onKeyDown = (_e) => {},
className = '',
inputClassName = '',
labelClassName = 'font-light',
bgColor = 'bg-gray-100 focus:bg-primary-50 dark:bg-neutral-700 dark:text-white',
type = $bindable('text'),
isPassword = $bindable(false),
autoFocus = false,
isRequired = false,
isDisabled = false,
min = undefined,
max = undefined,
errorMessage = '',
helperMessage = '',
autoComplete = true,
onChange = () => {},
onInputChange = () => {}
}: Props = $props();
let ref: HTMLInputElement | undefined = $state(undefined);
let fieldNode: HTMLInputElement | undefined = undefined;
let focusClass = $state('');
function typeAction(node: HTMLInputElement | undefined) {
if (!node) return;
node.type = type || 'text';
if (isRequired) {
node.required = true;
}
fieldNode = node;
}
function handlePasswordEye() {
type = type === 'password' ? 'text' : 'password';
typeAction(fieldNode);
}
onMount(() => {
if (autoFocus && ref) {
ref.focus();
}
if (type === 'password') {
isPassword = true;
}
});
</script>
<label class="relative block {className}">
{#if label}
<label for="text-field" class="m-0 text-left text-sm dark:text-white {labelClassName}">
{label}
{#if isRequired}
<span class="text-red-700">*</span>
{/if}
</label>
{/if}
<input
use:typeAction
class="form-input rounded-t-md border-b-2 border-l-0 border-r-0 border-t-0 border-gray-200 focus:border-b-2 focus:border-l-0 focus:border-r-0 focus:border-t-0 dark:border-neutral-600 {inputClassName} {focusClass} {isDisabled &&
'opacity-50 hover:cursor-not-allowed'} mt-1 block w-full p-3 dark:text-black {bgColor} {errorMessage
? 'border-red-600'
: ''}"
onkeydown={onKeyDown}
onchange={onInputChange}
{placeholder}
bind:value
bind:this={ref}
required={isRequired}
disabled={isDisabled}
autocomplete={autoComplete ? 'on' : 'off'}
onblur={(e) => {
if (focusClass.includes('border-primary-600')) {
focusClass = focusClass.replace('border-primary-600', '');
}
onChange(e);
}}
onfocus={() => (focusClass += ' border-primary-600')}
{name}
{min}
{max}
/>
{#if isPassword}
<span class="password-eye">
<IconButton value="write-code" onClick={handlePasswordEye}>
<PasswordEye isClosed={type === 'password' ? true : false} />
</IconButton>
</span>
{/if}
{#if errorMessage}
<p class="text-sm text-red-500">{errorMessage}</p>
{:else if helperMessage}
<p class="text-sm opacity-70 dark:text-white">
{helperMessage}
</p>
{/if}
</label>
<style>
.form-input {
box-shadow: none !important;
}
.password-eye {
position: absolute;
top: 30px;
right: 6px;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
/* input[type='number'] {
-moz-appearance: textfield;
} */
</style>
@@ -1,9 +0,0 @@
<script lang="ts">
let { tags = [] } = $props();
</script>
<div class="break-all">
{#each tags as tag}
<a class="text-primary-700 dark:text-primary-500 mr-5 text-sm" href="?filter={tag}">#{tag.toLowerCase()}</a>
{/each}
</div>
@@ -1,20 +0,0 @@
<script lang="ts">
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
let hovering;
function enter() {
hovering = true;
}
function leave() {
hovering = false;
}
</script>
<div onmouseenter={enter} onmouseleave={leave}>
{@render children?.()}
</div>
@@ -1,100 +0,0 @@
<script lang="ts">
import ToolTip from '../ToolTip/index.svelte';
export interface Props {
onClick?: any;
stopPropagation?: boolean;
disabled?: boolean;
selected?: boolean;
buttonClassName?: string;
contained?: boolean;
bordered?: boolean;
value?: string;
type?: 'button' | 'submit' | 'reset' | null | undefined;
size?: 'small' | 'large';
color?: string;
toolTipProps?: {
title: string;
hotkeys: string[];
direction?: string;
};
children?: import('svelte').Snippet;
}
let {
onClick = (_v: string) => {},
stopPropagation = false,
disabled = false,
selected = false,
buttonClassName = '',
contained = false,
bordered = false,
value = '',
type = 'button',
size = 'large',
color = '',
toolTipProps = {
title: '',
hotkeys: [],
direction: ''
},
children
}: Props = $props();
function handleClick(e: Event) {
if (stopPropagation) {
e.stopPropagation();
}
onClick(value);
}
</script>
<ToolTip title={toolTipProps.title} hotkeys={disabled ? [] : toolTipProps.hotkeys} direction={toolTipProps.direction}>
<button
class="root {color} {selected && 'active'} {disabled && 'disabled'} {size} {contained &&
'contained dark:bg-neutral-700'} {bordered && 'border'} {buttonClassName}"
{disabled}
{type}
onclick={handleClick}
>
{@render children?.()}
</button>
</ToolTip>
<style lang="scss">
.root {
flex: 0 0 auto;
padding: 12px;
font-size: 1.5rem;
text-align: center;
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
border-radius: 50%;
cursor: pointer;
margin: 0;
display: flex;
outline: 0;
align-items: center;
justify-content: center;
text-decoration: none;
-webkit-appearance: none;
-webkit-tap-highlight-color: transparent;
width: fit-content;
&.disabled {
cursor: not-allowed;
color: rgba(0, 0, 0, 0.07);
}
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
}
.root.small {
padding: 5px;
}
.contained {
background-color: rgba(0, 0, 0, 0.04);
}
</style>
@@ -1 +0,0 @@
export { default as IconButton } from './index.svelte';
@@ -1,27 +0,0 @@
<svg width="121" height="120" viewBox="0 0 121 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="79.7922" cy="32.4026" r="12.5649" stroke="#0F62FE" stroke-width="1.67532" />
<path
d="M69.7402 40.7794L74.2444 37.7766C77.6914 35.4786 82.0846 35.1362 85.8461 36.8722L90.6818 39.1041"
stroke="#0F62FE"
stroke-width="1.67532"
/>
<circle cx="79.4199" cy="28.68" r="3.81602" stroke="#0F62FE" stroke-width="1.67532" />
<circle cx="106.597" cy="62.5588" r="12.5649" stroke="#0F62FE" stroke-width="1.67532" />
<path
d="M96.5455 70.9357L101.05 67.9329C104.497 65.6349 108.89 65.2924 112.651 67.0285L117.487 69.2603"
stroke="#0F62FE"
stroke-width="1.67532"
/>
<circle cx="106.225" cy="58.8363" r="3.81602" stroke="#0F62FE" stroke-width="1.67532" />
<circle cx="71.4156" cy="79.3128" r="12.5649" stroke="#0F62FE" stroke-width="1.67532" />
<path
d="M61.3636 87.6896L65.8678 84.6868C69.3148 82.3888 73.708 82.0463 77.4695 83.7824L82.3052 86.0143"
stroke="#0F62FE"
stroke-width="1.67532"
/>
<circle cx="71.0433" cy="75.5902" r="3.81602" stroke="#0F62FE" stroke-width="1.67532" />
<rect x="5.5" y="49.5" width="24.5" height="5" rx="2.5" stroke="#1339A6" />
<rect x="5.5" y="61.5" width="44" height="5" rx="2.5" stroke="#1339A6" />
<rect x="5.5" y="73.5" width="44" height="5" rx="2.5" stroke="#1339A6" />
<rect x="5.5" y="85.5" width="44" height="5" rx="2.5" stroke="#1339A6" />
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,16 +0,0 @@
<script lang="ts">
interface Props {
color?: string;
width?: string;
height?: string;
}
let { color = 'currentColor', width = '18', height = '18' }: Props = $props();
</script>
<svg {width} {height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="dark">
<path
d="M18 22.5H6C5.60232 22.4995 5.22105 22.3414 4.93984 22.0602C4.65864 21.7789 4.50046 21.3977 4.5 21V3C4.5004 2.6023 4.65856 2.221 4.93978 1.93978C5.221 1.65856 5.6023 1.5004 6 1.5H18C18.3977 1.50046 18.779 1.65864 19.0602 1.93984C19.3414 2.22105 19.4995 2.60232 19.5 3V15.4636L15.75 13.5886L12 15.4636V3H6V21H18V18H19.5V21C19.4995 21.3977 19.3413 21.7789 19.0601 22.0601C18.7789 22.3413 18.3977 22.4995 18 22.5ZM15.75 11.9114L18 13.0364V3H13.5V13.0364L15.75 11.9114Z"
fill={color}
/>
</svg>
@@ -1,21 +0,0 @@
<script lang="ts">
interface Props {
color?: string;
}
let { color = 'currentColor' }: Props = $props();
</script>
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="dark">
<path
d="M16.9999 2H8.99991C8.46948 2 7.96077 2.21071 7.58569 2.58579C7.21062 2.96086 6.99991 3.46957 6.99991 4V20C6.99991 20.5304 7.21062 21.0391 7.58569 21.4142C7.96077 21.7893 8.46948 22 8.99991 22H20.9999C21.5303 22 22.039 21.7893 22.4141 21.4142C22.7892 21.0391 22.9999 20.5304 22.9999 20V8L16.9999 2Z"
stroke={color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M16.9999 2V8H22.9999" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M18.9999 13H10.9999" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M18.9999 17H10.9999" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M12.9999 9H11.9999H10.9999" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
@@ -1,35 +0,0 @@
<script lang="ts">
interface Props {
isClosed?: boolean;
}
let { isClosed = true }: Props = $props();
</script>
{#if isClosed}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" class="dark">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.6 11.3L3.3 10.6C2.6 9.8 1.9 9 1.5 8C2.5 5.5 5.3 3.5 8 3.5C8.7 3.5 9.4 3.6 10 3.9L10.8 3.1C9.9 2.7 9 2.5 8 2.5C4.7 2.6 1.7 4.7 0.5 7.8V8.1C1 9.3 1.7 10.4 2.6 11.3ZM12.6 4.1C13.9 5 14.9 6.3 15.5 7.8V8.2C14.3 11.3 11.3 13.4 8 13.5C6.7 13.4 5.4 13.1 4.3 12.4L1.7 15L1 14.3L14.3 1L15 1.7L12.6 4.1ZM7.8 6.1C6.9 6.1 6.1 6.9 6 7.9L5.1 8.8C5 8.3 5 7.7 5.1 7.2C5.5 5.6 7.2 4.7 8.7 5.2L7.8 6.1ZM10 8C10 9.1 9.1 10 8 10C7.7 10 7.3 9.9 7 9.7L9.7 7C9.9 7.3 10 7.6 10 8ZM5 11.7C5.9 12.2 7 12.5 8 12.5C10.6 12.5 13.4 10.5 14.5 8C13.9 6.7 13 5.6 11.9 4.8L10.5 6.2C11.2 7.2 11.2 8.6 10.5 9.6C9.5 11 7.7 11.3 6.3 10.4L5 11.7Z"
fill="#ADADAD"
/>
</svg>
{:else}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" class="dark">
<path
d="M0.666748 8.00008C0.666748 8.00008 3.33341 2.66675 8.00008 2.66675C12.6667 2.66675 15.3334 8.00008 15.3334 8.00008C15.3334 8.00008 12.6667 13.3334 8.00008 13.3334C3.33341 13.3334 0.666748 8.00008 0.666748 8.00008Z"
stroke="#ADADAD"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8 10C9.10457 10 10 9.10457 10 8C10 6.89543 9.10457 6 8 6C6.89543 6 6 6.89543 6 8C6 9.10457 6.89543 10 8 10Z"
stroke="#ADADAD"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
@@ -1,24 +0,0 @@
<script lang="ts">
interface Props {
color?: string;
}
let { color = 'currentColor' }: Props = $props();
</script>
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="dark">
<g clip-path="url(#clip0_3647_18153)">
<path
d="M24 15C24 15.5304 23.7893 16.0391 23.4142 16.4142C23.0391 16.7893 22.5304 17 22 17H10L6 21V5C6 4.46957 6.21071 3.96086 6.58579 3.58579C6.96086 3.21071 7.46957 3 8 3H22C22.5304 3 23.0391 3.21071 23.4142 3.58579C23.7893 3.96086 24 4.46957 24 5V15Z"
stroke={color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_3647_18153">
<rect width="24" height="24" fill={color} transform="translate(0.258789)" />
</clipPath>
</defs>
</svg>
@@ -1,6 +0,0 @@
<svg width="61" height="61" viewBox="0 0 61 61" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M30.5 0C13.6564 0 0 13.6564 0 30.5C0 47.3436 13.6564 61 30.5 61C47.3436 61 61 47.3436 61 30.5C61 13.6564 47.3436 0 30.5 0ZM44.6291 22.0629L30.0196 44.3927C30.0158 44.3966 30.0082 44.4004 30.0082 44.4042C29.9319 44.5186 29.89 44.6482 29.7947 44.7549C29.6612 44.8998 29.4897 44.9761 29.3372 45.0866C29.2991 45.1133 29.2609 45.1438 29.219 45.1705C28.9826 45.3192 28.7386 45.4183 28.4718 45.4831C28.3841 45.506 28.304 45.5289 28.2125 45.5441C28.0002 45.5733 27.785 45.5759 27.572 45.5518C27.4216 45.5422 27.2723 45.5193 27.1259 45.4831C26.9749 45.4376 26.8282 45.3789 26.6875 45.3078C26.5655 45.2506 26.4359 45.2353 26.3215 45.1629C26.2376 45.1095 26.1919 45.0256 26.1194 44.9608C26.0889 44.9341 26.0508 44.9265 26.0203 44.8998L17.5146 37.0346C17.0105 36.5505 16.7163 35.8879 16.6955 35.1893C16.6746 34.4906 16.9287 33.8117 17.403 33.2984C17.8774 32.785 18.5343 32.4783 19.2324 32.4441C19.9305 32.4099 20.6142 32.6509 21.1365 33.1154L27.2937 38.8113L40.1609 19.1426C40.5481 18.55 41.1549 18.1356 41.8477 17.9905C42.5406 17.8453 43.2627 17.9814 43.8552 18.3686C44.4477 18.7559 44.8621 19.3627 45.0073 20.0555C45.1524 20.7483 45.0164 21.4704 44.6291 22.0629Z"
fill="#0c891b"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,117 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import type { Component } from 'svelte';
import * as Sidebar from '@cio/ui/base/sidebar';
import UsersIcon from '@lucide/svelte/icons/users';
import MapPlusIcon from '@lucide/svelte/icons/map-plus';
import ListTodoIcon from '@lucide/svelte/icons/list-todo';
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { SidebarFooterMenu } from '$lib/features/ui/sidebar/footer';
interface SideLinks {
name: string;
icon: Component;
link: string;
show?: () => boolean;
}
const user = $derived.by(() => ({
name: $profile.fullname,
email: $profile.email,
avatar: $profile.avatarUrl
}));
function isActive(pagePath: string, itemPath: string) {
const pageLinkItems = pagePath.split('/');
const itemLinkItems = itemPath.split('/');
if (itemLinkItems.length !== pageLinkItems.length) {
return false;
}
return pagePath.includes(itemPath);
}
const sideLinks: SideLinks[] = $derived.by(() =>
[
{
name: $t('lms_navigation.home'),
icon: LayoutDashboardIcon,
link: '/lms'
},
{
name: $t('lms_navigation.my_learning'),
icon: LibraryBigIcon,
link: '/lms/mylearning'
},
{
name: $t('lms_navigation.exercise'),
icon: ListTodoIcon,
link: '/lms/exercises',
show() {
return $currentOrg?.customization?.dashboard?.exercise;
}
},
{
name: $t('lms_navigation.community'),
icon: UsersIcon,
link: '/lms/community',
show() {
return $currentOrg?.customization?.dashboard?.community;
}
},
{
name: $t('lms_navigation.explore'),
icon: MapPlusIcon,
link: '/lms/explore'
}
].filter((link) => (link.show ? link.show() : true))
);
</script>
<Sidebar.Provider class="w-fit">
<Sidebar.Root collapsible="icon">
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupLabel>LMS Navigation</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each sideLinks as item (item.link)}
<Sidebar.MenuItem>
<Sidebar.MenuButton tooltipContent={item.name}>
<a
href={item.link}
class="flex w-full items-center gap-4 {isActive(page.url.pathname, item.link)
? 'ui:bg-accent ui:text-accent-foreground rounded-md px-3 py-2'
: ''}"
>
<item.icon size={16} />
{item.name}
</a>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
{#if user}
<Sidebar.Footer>
<SidebarFooterMenu />
</Sidebar.Footer>
{/if}
<Sidebar.Rail />
</Sidebar.Root>
<!-- TODO: FIGURE OUT WHY THIS APPEARANCE IS BUGGY -->
<!-- TODO: THE SIDEBAR COLLAPSE VIEW IS BAD -->
<!-- <Sidebar.Inset>
<Sidebar.Trigger class="-ml-1" />
</Sidebar.Inset> -->
</Sidebar.Provider>
@@ -1,119 +0,0 @@
<script>
import TextField from '$lib/components/Form/TextField.svelte';
import { Row, Grid, Column } from '$lib/components/Org/Settings/Layout';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import LanguagePicker from '$lib/components/Org/Settings/LanguagePicker.svelte';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import generateUUID from '$lib/utils/functions/generateUUID';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { handleLocaleChange } from '$lib/utils/functions/translations';
let avatar = $state('');
let loading = $state(false);
let hasLangChanged = $state(false);
let locale = $derived($profile.locale);
async function handleUpdate() {
try {
loading = true;
const updates = {
fullname: $profile.fullname,
username: $profile.username,
email: $profile.email,
locale
};
if (avatar) {
const filename = `user/${generateUUID()}.webp`;
const { data } = await supabase.storage.from('avatars').upload(filename, avatar, {
cacheControl: '3600',
upsert: false
});
if (data) {
const { data: response } = await supabase.storage.from('avatars').getPublicUrl(filename);
updates.avatar_url = response.publicUrl;
$profile.avatar_url = response.publicUrl;
}
avatar = undefined;
}
let { error } = await supabase.from('profile').update(updates).match({ id: $profile.id });
profile.update((_profile) => ({
..._profile,
...updates
}));
snackbar.success();
if (hasLangChanged) {
handleLocaleChange(locale);
}
if (error) throw error;
} catch (error) {
let message = error instanceof Error ? error.message : `${error}`;
if (message.includes('profile_username_key')) {
message = $t('username already exists');
}
snackbar.error(`${$t('snackbar.update_failed')}: ${message}`);
loading = false;
} finally {
loading = false;
}
}
</script>
<Grid class="border-c mt-5 w-full rounded border-gray-200 dark:border-neutral-600">
<Row class="border-bottom-c flex flex-col items-center py-7 lg:flex-row lg:items-start ">
<Column sm={4} md={8} lg={4} class="mt-2 md:mt-0">
<p class="font-bold dark:text-white">{$t('settings.profile.profile_picture.heading')}</p>
</Column>
<Column sm={2} md={4} lg={8} class="mt-2 lg:mt-0">
<UploadImage bind:avatar src={$profile.avatar_url} widthHeight="w-16 h-16 lg:w-24 lg:h-24" />
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}>
<p class="font-bold dark:text-white">{$t('settings.profile.personal_information.heading')}</p>
</Column>
<Column sm={8} md={8} lg={8} class="mt-2 lg:mt-0">
<TextField
label={$t('settings.profile.personal_information.full_name')}
bind:value={$profile.fullname}
className="w-full lg:w-60 mb-4"
/>
<TextField
label={$t('settings.profile.personal_information.username')}
bind:value={$profile.username}
className="w-full lg:w-60 mb-4"
/>
<TextField
label={$t('settings.profile.personal_information.email')}
bind:value={$profile.email}
className="w-full lg:w-60 mb-4"
/>
<LanguagePicker bind:hasLangChanged bind:value={$profile.locale} className="w-full lg:w-60 mb-4" />
</Column>
</Row>
<Row class="m-5 flex w-full items-center gap-2 lg:justify-center">
<PrimaryButton
label={$t('settings.profile.update_profile')}
variant={VARIANTS.CONTAINED_DARK}
className="mr-5"
isLoading={loading}
isDisabled={loading}
onClick={handleUpdate}
/>
</Row>
</Grid>
@@ -1,54 +0,0 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
let { value = $bindable(), placeholder = ``, preview } = $props();
const activeClassName = 'border-b-2 border-primary-700';
let isWriteMode = $state(true);
const handleTabClick = (tab) => (event) => {
isWriteMode = tab === 1;
};
</script>
<div class="root">
<div class="flex justify-center">
<button onclick={handleTabClick(1)} class="p-2 focus:outline-none {isWriteMode && `${activeClassName}`}">
{$t('markdown_editor.write')}
</button>
<button onclick={handleTabClick(2)} class="p-2 focus:outline-none {!isWriteMode && `${activeClassName}`}">
{$t('markdown_editor.preview')}
</button>
</div>
<div class="m-2 mb-0 p-0">
{#if isWriteMode}
<textarea bind:value {placeholder} class="border-gray rounded-md border p-2 dark:bg-gray-500"></textarea>
{:else}
<article class="preview prose prose-sm sm:prose m-auto p-2">
{@render preview?.()}
</article>
{/if}
</div>
</div>
<style>
.root {
width: 100%;
min-height: 200px;
}
textarea {
width: 100%;
height: 500px;
}
.preview {
min-height: 150px;
}
.octicon {
display: inline-block;
vertical-align: text-top;
fill: currentColor;
}
</style>
@@ -1,46 +0,0 @@
<script lang="ts">
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { t } from '$lib/utils/functions/translations';
import Modal from './index.svelte';
interface Props {
open?: boolean;
onDelete?: () => void;
isLoading?: boolean;
}
let { open = $bindable(false), onDelete = () => {}, isLoading = false }: Props = $props();
</script>
<Modal
onClose={() => !isLoading && (open = false)}
bind:open
width="w-96"
containerClass="p-6"
headerClass="p-2"
modalHeading={$t('delete_modal.label')}
>
<div>
<p class="text-center text-xl dark:text-white">
{$t('delete_modal.content')}
</p>
<div class="mt-8 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('delete_modal.no')}
onClick={() => (open = false)}
{isLoading}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.CONTAINED_DANGER}
label={$t('delete_modal.yes')}
onClick={onDelete}
{isLoading}
/>
</div>
</div>
</Modal>
@@ -1,92 +0,0 @@
<script lang="ts">
import CloseButton from '../Buttons/Close/index.svelte';
interface Props {
open?: boolean;
onClose?: any;
modalHeading?: string;
headerClass?: string;
labelClass?: string;
width?: string;
maxWidth?: string;
containerClass?: string;
size?: string;
isCloseable?: boolean;
isClosing?: boolean;
children?: import('svelte').Snippet;
}
let {
open = $bindable(false),
onClose = () => {},
modalHeading = '',
headerClass = '',
labelClass = '',
width = '',
maxWidth = '',
containerClass = '',
size = '',
isCloseable = true,
isClosing = false,
children
}: Props = $props();
$effect(() => {
if (open) {
document.getElementsByTagName('body')[0].style.overflow = 'hidden';
} else {
document.getElementsByTagName('body')[0].style.overflow = 'auto';
}
});
</script>
{#if open}
<div
class="dialog z-100 fixed inset-0 flex items-center justify-center overflow-x-hidden bg-gray-100 bg-opacity-50"
onclick={onClose}
role="presentation"
>
<div aria-hidden="true" class="backdrop"></div>
<div
class="{maxWidth || 'container'} {size === 'sm'
? 'small'
: ''} mx-auto bg-white dark:bg-neutral-800 {width} max-h-4/5 overflow-y-auto rounded-md shadow-lg"
onclick={(e) => e.stopPropagation()}
role="presentation"
>
<div
class="{headerClass} sticky top-0 z-10 flex items-center justify-between border border-l-0 border-r-0 border-t-0 border-gray-100 bg-white p-4 px-5 dark:border-neutral-600 dark:bg-neutral-800"
>
<p class="text-md m-0 font-medium dark:text-white {labelClass}">
{modalHeading}
</p>
{#if isCloseable}
<div class="rounded-full">
<CloseButton onClick={onClose} contained={true} {isClosing} />
</div>
{/if}
</div>
<div class="body p-6 {containerClass} ">
{@render children?.()}
</div>
</div>
</div>
{/if}
<style>
.dialog {
background-color: rgba(0, 0, 0, 0.5);
}
.container {
max-width: 1000px;
}
/* .body {
max-height: 60vh;
} */
.small {
max-width: 388px;
}
</style>
@@ -1 +0,0 @@
export { default as Modal } from './index.svelte';
@@ -17,18 +17,17 @@
{#if customLinks}
<div class={isMobile ? 'space-y-4' : 'mr-4 hidden items-center space-x-6 lg:flex'}>
{#each customLinks.links as link}
{#each customLinks.links as link (link.url)}
<li>
<Button
<a
href={link.url}
target={link.openInNewTab ? '_blank' : '_self'}
rel={link.openInNewTab ? 'noopener noreferrer' : ''}
variant="link"
size="sm"
onclick={handleLinkClick}
>
<Button size="sm" variant="ghost" onclick={handleLinkClick}>
{link.label}
</Button>
</a>
</li>
{/each}
</div>
@@ -3,7 +3,7 @@
import BellIcon from '@lucide/svelte/icons/bell';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import { IconButton } from '$lib/components/IconButton';
import { IconButton } from '@cio/ui/custom/icon-button';
import { globalStore } from '$lib/utils/store/app';
import { currentOrgPath } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
@@ -26,7 +26,7 @@
<div class="flex items-center text-white">
{#if isCoursePage}
<li class="hidden md:block">
<IconButton onClick={() => goto(coursesPath)}>
<IconButton onclick={() => goto(coursesPath)}>
<ArrowLeftIcon size={16} class="custom" />
</IconButton>
</li>
@@ -45,12 +45,12 @@
<ul class="flex w-full items-center">
<Logo {logo} {orgName} />
<span class="flex-grow"></span>
<span class="grow"></span>
<!-- Mobile Menu Button - Only show when custom links exist -->
{#if isOrgSite && showLinks}
<button
class="mobile-menu-btn hover:text-primary-600 rounded-md p-2 text-gray-700 transition-colors duration-200 hover:bg-gray-100 lg:hidden"
class="mobile-menu-btn hover:ui:text-primary rounded-md p-2 text-gray-700 transition-colors duration-200 hover:bg-gray-100 lg:hidden"
onclick={toggleMobileMenu}
aria-label="Toggle mobile menu"
>
@@ -1,66 +0,0 @@
<script>
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { currentOrgPlan, isFreePlan } from '$lib/utils/store/org';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
let isRedirecting = $state(false);
async function onOpenBilling() {
console.log({
plan: $currentOrgPlan
});
try {
if ($currentOrgPlan?.provider === 'polar') {
const url = new URL('/api/polar/portal', window.location.origin);
url.searchParams.set('customerId', $currentOrgPlan?.customerId || '');
window.open(url.toString(), '_blank');
}
if ($currentOrgPlan?.provider === 'lmz') {
isRedirecting = true;
const url = new URL('/api/lmz/subscription', window.location.origin);
url.searchParams.set('subscriptionId', $currentOrgPlan?.subscriptionId);
const response = await fetch(url.toString());
const result = await response.json();
isRedirecting = false;
window.open(result.customerPortal, '_blank');
}
} catch (error) {
isRedirecting = false;
console.error(error);
snackbar.error($t('settings.billing.error'));
}
}
</script>
<Grid class="mt-5 w-full">
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}><SectionTitle>{$t('settings.billing.sub_title')}</SectionTitle></Column>
<Column sm={8} md={8} lg={8}>
<h4 class="lg:mt-0 dark:text-white">{$t('settings.billing.manage')}</h4>
{#if $isFreePlan}
{$t('settings.billing.active')}
{:else}
<p class="mb-3">{$t('settings.billing.provider')}</p>
<PrimaryButton
label={$t('settings.billing.open_billing')}
variant={VARIANTS.CONTAINED_DARK}
onClick={onOpenBilling}
isLoading={isRedirecting}
/>
{/if}
</Column>
</Row>
</Grid>
@@ -1,187 +0,0 @@
<script lang="ts">
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import SaveIcon from '@lucide/svelte/icons/save';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { getSupabase } from '$lib/utils/functions/supabase';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
const supabase = getSupabase();
let widgetKey = $state('');
let isSaving = $state(false);
function widgetControl(key: string) {
widgetKey = key;
$handleOpenWidget.open = true;
}
async function handleSave() {
isSaving = true;
const { error } = await supabase
.from('organization')
.update({ customization: $currentOrg.customization })
.match({ id: $currentOrg.id });
if (error) {
const message = error?.message || 'snackbar.lms.error.try_again';
console.error('Error updating customizations', message);
snackbar.error('snackbar.update_failed');
} else {
snackbar.success('snackbar.success_update');
}
isSaving = false;
}
</script>
<Grid class="border-c relative mt-5 w-full rounded border-gray-200 dark:border-neutral-600">
<Row class="border-bottom-c flex flex-col items-start py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}>
<SectionTitle>{$t('components.settings.customize_lms.dashboard.title')}</SectionTitle>
</Column>
<Column sm={8} md={8} lg={8} class="space-y-2">
<div class="flex flex-col gap-3">
<p class="text-sm">{$t('components.settings.customize_lms.dashboard.community')}</p>
<div class="mb-3 flex items-center space-x-2">
<Switch bind:checked={$currentOrg.customization.dashboard.community} />
<Label class="text-sm">
{$currentOrg.customization.dashboard.community
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Label>
</div>
</div>
<div class="flex flex-col gap-3">
<p class="text-sm">{$t('components.settings.customize_lms.dashboard.exercises')}</p>
<div class="mb-3 flex items-center space-x-2">
<Switch bind:checked={$currentOrg.customization.dashboard.exercise} />
<Label class="text-sm">
{$currentOrg.customization.dashboard.exercise
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Label>
</div>
</div>
<div class="mb-3">
<p class="text-sm">{$t('components.settings.customize_lms.dashboard.banner_image')}</p>
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('components.settings.customize_lms.dashboard.banner_image_btn')}
className="mt-3"
onClick={() => widgetControl('banner-image')}
/>
{#if $currentOrg.customization.dashboard.bannerImage}
<img alt="Banner" src={$currentOrg.customization.dashboard.bannerImage} class="mt-2 w-full rounded-md" />
{/if}
{#if $handleOpenWidget.open && widgetKey === 'banner-image'}
<UploadWidget bind:imageURL={$currentOrg.customization.dashboard.bannerImage} />
{/if}
</div>
<div>
<p>{$t('components.settings.customize_lms.dashboard.banner_text')}</p>
<TextField
label={$t('components.settings.customize_lms.dashboard.banner_text_label')}
placeholder={$t('components.settings.customize_lms.dashboard.banner_text_placeholder')}
bind:value={$currentOrg.customization.dashboard.bannerText}
/>
</div>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}
><SectionTitle>{$t('components.settings.customize_lms.course.title')}</SectionTitle></Column
>
<Column sm={8} md={8} lg={8} class="space-y-4">
<div class="flex flex-col gap-3">
<p class="text-sm">{$t('components.settings.customize_lms.course.newsfeed')}</p>
<div class="flex items-center space-x-2">
<Switch bind:checked={$currentOrg.customization.course.newsfeed} />
<Label class="text-gray-600">
{$currentOrg.customization.course.newsfeed
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Label>
</div>
</div>
<div class="flex flex-col gap-3">
<p class="text-sm">{$t('components.settings.customize_lms.course.grading')}</p>
<div class="flex items-center space-x-2">
<Switch bind:checked={$currentOrg.customization.course.grading} />
<Label class="text-gray-600">
{$currentOrg.customization.course.grading
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Label>
</div>
</div>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}
><SectionTitle>{$t('components.settings.customize_lms.apps.title')}</SectionTitle></Column
>
<Column sm={8} md={8} lg={8} class="space-y-4">
<div class="flex flex-col gap-3">
<p class="text-sm">{$t('components.settings.customize_lms.apps.poll')}</p>
<div class="flex items-center space-x-2">
<Switch bind:checked={$currentOrg.customization.apps.poll} />
<Label class="text-gray-600">
{$currentOrg.customization.apps.poll
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Label>
</div>
</div>
<div class="flex flex-col gap-3">
<p class="text-sm">{$t('components.settings.customize_lms.apps.live_comment')}</p>
<div class="flex items-center space-x-2">
<Switch bind:checked={$currentOrg.customization.apps.comments} />
<Label class="text-gray-600">
{$currentOrg.customization.apps.comments
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Label>
</div>
</div>
</Column>
</Row>
<div class="sticky bottom-12 z-[120] float-right mr-2 hidden md:block">
<PrimaryButton
label={$t('components.settings.customize_lms.save')}
onClick={handleSave}
isLoading={isSaving}
isDisabled={isSaving}
/>
</div>
</Grid>
<div class="absolute bottom-8 right-6 z-[120] block md:hidden">
<PrimaryButton onClick={handleSave} isLoading={isSaving} isDisabled={isSaving}>
<SaveIcon size={16} />
</PrimaryButton>
</div>
@@ -1,400 +0,0 @@
<script lang="ts">
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import TrashIcon from '@lucide/svelte/icons/trash';
import Copy from '@lucide/svelte/icons/copy';
import isValidDomain from 'is-valid-domain';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { parse } from 'tldts';
import { t } from '$lib/utils/functions/translations';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo';
import { updateOrgSiteNameValidation } from '$lib/utils/functions/validator';
import { sanitizeDomain, sendDomainRequest } from '$lib/utils/functions/domain';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import { IconButton } from '$lib/components/IconButton';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { ComingSoon } from '$lib/features/ui';
import { UpgradeBanner } from '$lib/features/ui';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
let siteName = $derived($currentOrg.siteName);
let customDomain = $state('');
let customCode = $state('');
let favicon = $state('');
let isLoading = $state(false);
let isCustomDomainLoading = $state(false);
let isRefreshing = $state(false);
const isDomainValid = $derived(isValidDomain(sanitizeDomain(customDomain), { subdomain: true }));
type Error = {
siteName: string;
customDomain: string;
};
let errors: Error = $state({
siteName: '',
customDomain: ''
});
async function handleSaveSiteName() {
errors = updateOrgSiteNameValidation(siteName) as Error;
if (Object.values(errors).length) {
isLoading = false;
return;
}
if (blockedSubdomain.includes(siteName || '')) {
errors.siteName = 'Sitename already exists.';
return;
}
isLoading = true;
const { data: org, error } = await supabase.from('organization').update({ siteName }).match({ id: $currentOrg.id });
console.log('Updating organisation', org);
if (error) {
console.log('Error: create organisation', error);
errors.siteName = $t('add_org.sitename');
} else {
snackbar.success();
$currentOrg.siteName = siteName;
goto(`/org/${$currentOrg.siteName}/settings/domains`);
}
isLoading = false;
}
async function handleSaveCustomDomain() {
if (!isDomainValid) return;
const sanitizedDomain = sanitizeDomain(customDomain);
const details = parse(sanitizedDomain);
if (!details.subdomain) {
errors.customDomain = $t('components.settings.domains.custom_domain_error');
return;
}
if (sanitizedDomain.includes('classroomio.com')) {
errors.customDomain = $t('components.settings.domains.custom_domain_not_classroomio');
return;
}
isCustomDomainLoading = true;
const { error } = await supabase
.from('organization')
.update({ customDomain: sanitizedDomain })
.match({ id: $currentOrg.id });
if (error) {
console.log('Error: create organisation', error);
errors.customDomain = error.message;
isCustomDomainLoading = false;
return;
}
try {
const response = await sendDomainRequest('add_domain', sanitizedDomain);
const data = await response.json();
console.log('added domain to vercel', data);
} catch (error) {
console.log('Error: adding domain to vercel', error);
snackbar.error(error as string);
isCustomDomainLoading = false;
return;
}
snackbar.success('components.settings.domains.custom_domain_success');
$currentOrg.customDomain = sanitizedDomain;
isCustomDomainLoading = false;
}
async function handleRemoveCustomDomain() {
if (!$currentOrg.customDomain) return;
isCustomDomainLoading = true;
const { error } = await supabase
.from('organization')
.update({ customDomain: null, isCustomDomainVerified: false })
.match({ id: $currentOrg.id });
if (error) {
console.log('Error: updating organisation', error);
snackbar.error(error.message);
isCustomDomainLoading = false;
return;
}
try {
const response = await sendDomainRequest('remove_domain', $currentOrg.customDomain);
const data = await response.json();
if (!data.success) {
throw new Error(data.message);
}
console.log('removed domain from vercel', data);
} catch (error) {
console.log('Error: removing domain from vercel', error);
snackbar.error(error as string);
isCustomDomainLoading = false;
return;
}
snackbar.success();
$currentOrg.customDomain = '';
$currentOrg.isCustomDomainVerified = false;
isCustomDomainLoading = false;
}
async function handleRefreshCustomDomain() {
isRefreshing = true;
try {
const response = await sendDomainRequest('verify_domain', $currentOrg.customDomain || '');
const data = await response.json();
console.log('data', data);
if (data.verified && !$currentOrg.isCustomDomainVerified) {
$currentOrg.isCustomDomainVerified = true;
await supabase.from('organization').update({ isCustomDomainVerified: true }).match({ id: $currentOrg.id });
}
} catch (error) {
console.log('Error: refreshing domain', error);
snackbar.error(error as string);
}
isRefreshing = false;
}
function resetErrors(_siteName: string, _customDomain: string) {
untrack(() => {
if (errors.siteName) {
errors.siteName = '';
}
if (errors.customDomain) {
errors.customDomain = '';
}
});
}
function getSubdomain() {
const details = parse($currentOrg.customDomain || '');
return details.subdomain;
}
$effect(() => {
resetErrors(siteName, customDomain);
});
</script>
<Grid class="mt-5 w-full rounded border border-gray-200 dark:border-neutral-600">
<Row class="border-bottom-c py-7">
<Column sm={4} md={4} lg={4} class="text-lg">
<SectionTitle>{$t('components.settings.domains.add')}</SectionTitle>
</Column>
<Column sm={8} md={8} lg={8} class="space-y-2">
<p class="text-md mb-5 text-gray-500 dark:text-white">
{$t('settings.organization.organization_profile.custom_domain.body')}
</p>
<div>
<!-- Org Site Name -->
<TextField
label="URL"
helperMessage={`https://${siteName || ''}.classroomio.com`}
bind:value={siteName}
type="text"
placeholder="e.g traversymedia"
className="mb-5 w-full"
labelClassName=""
errorMessage={errors.siteName}
/>
<div class="mb-6 flex items-center">
<PrimaryButton
label={$t('components.settings.domains.update')}
className="py-2"
variant={VARIANTS.OUTLINED}
onClick={handleSaveSiteName}
isDisabled={isLoading}
/>
<VisitOrgSiteButton />
</div>
</div>
</Column>
</Row>
<Row class="py-7">
<UpgradeBanner className="mb-5">{$t('upgrade.domain')}</UpgradeBanner>
<Column sm={4} md={4} lg={4} class="text-lg">
<SectionTitle>
{$t('components.settings.domains.custom')}
</SectionTitle>
</Column>
<Column sm={8} md={8} lg={8} class="space-y-2">
<div class="border-bottom-c pb-10">
<!-- DNS Configuration -->
{#if $currentOrg.customDomain}
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-md flex items-center gap-2 font-medium">
{$currentOrg.customDomain}
<IconButton contained={true} size="small" onClick={() => goto(`https://${$currentOrg.customDomain}`)}>
<ArrowUpRightIcon size={16} />
</IconButton>
</p>
<div
class="mt-1 h-2 w-2 rounded-full bg-{$currentOrg.isCustomDomainVerified ? 'green' : 'yellow'}-400"
></div>
</div>
{#if $currentOrg.isCustomDomainVerified}
<TextChip value="Verified" className="bg-green-500 text-white text-xs px-3" size="sm" />
{:else}
<TextChip value="Pending verification" className="bg-yellow-500 text-white text-xs px-3" size="sm" />
{/if}
</div>
<div class="mb-4 text-sm text-gray-500">
{$t('components.settings.domains.dns_description')}
</div>
<div class="mb-4 flex items-center gap-10 rounded-md border px-4 py-2">
<div class="flex h-[72px] flex-col justify-evenly">
<p class="text-sm font-light">{$t('components.settings.domains.dns_type')}</p>
<p class="flex h-[40px] items-center">CNAME</p>
</div>
<div class="flex h-[72px] flex-col justify-evenly">
<p class="text-sm font-light">{$t('components.settings.domains.dns_name')}</p>
<p class="flex h-[40px] items-center">
{getSubdomain()}
</p>
</div>
<div class="flex h-[72px] flex-col justify-evenly">
<p class="text-sm font-light">{$t('components.settings.domains.dns_value')}</p>
<p class=" flex items-center gap-1">
cname.vercel-dns.com
<IconButton onClick={() => copyToClipboard('cname.vercel-dns.com')}>
<p>cname.vercel-dns.com</p>
<Copy />
</IconButton>
</p>
</div>
</div>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="py-2 flex items-center gap-2"
onClick={handleRefreshCustomDomain}
isLoading={isRefreshing}
variant={VARIANTS.OUTLINED}
>
{#if !isRefreshing}
<RotateCcwIcon size={16} />
{/if}
{$t('components.settings.domains.refresh')}
</PrimaryButton>
<PrimaryButton
className="py-2 flex items-center gap-2"
onClick={handleRemoveCustomDomain}
isLoading={isCustomDomainLoading}
variant={VARIANTS.CONTAINED_DANGER}
>
{#if !isCustomDomainLoading}
<TrashIcon size={16} />
{/if}
{$t('components.settings.domains.remove')}
</PrimaryButton>
</div>
{:else}
<!-- Add Custom Domain -->
<div class="mb-4 flex items-center gap-5">
<p class="font-bold">{$t('components.settings.domains.your_domain')}</p>
</div>
<TextField
bind:value={customDomain}
type="text"
placeholder="courses.yourwebsite.com"
helperMessage="https://{customDomain || 'course.yourwebsite.com'}"
errorMessage={errors.customDomain}
className="w-4/5"
isDisabled={$isFreePlan}
/>
<div class="mt-5 flex items-center">
<PrimaryButton
label={$t('components.settings.domains.save')}
className="py-2"
onClick={$isFreePlan ? () => {} : handleSaveCustomDomain}
isLoading={isCustomDomainLoading}
isDisabled={isLoading || !isDomainValid}
/>
</div>
{/if}
</div>
<div class="border-bottom-c py-10">
<div class="mb-4 flex items-center gap-5">
<p class="font-bold">{$t('components.settings.domains.custom_favicon')}</p>
<ComingSoon />
</div>
<div class="flex items-center">
<UploadImage
shape="rounded-md"
bind:avatar={favicon}
src={$currentOrg.favicon ?? '/logo-512.png'}
widthHeight="w-16 h-16 lg:w-24 lg:h-24"
flexDirection="flex-row"
isDisabled={true}
/>
</div>
</div>
<div class="py-10">
<div class="mb-4 flex items-center gap-5">
<p class="font-bold">{$t('components.settings.domains.custom_code')}</p>
<ComingSoon />
</div>
<TextArea
bind:value={customCode}
placeholder="e.g <link rel='stylesheet' href='https://example.com/style.css' />"
className="w-4/5"
rows={7}
disabled={true}
/>
</div>
<!-- <div class="flex items-center">
<p class="dark:text-white mr-3">
{$t('components.settings.domains.domain')}
</p>
<ComingSoon />
</div> -->
</Column>
</Row>
</Grid>
@@ -1,94 +0,0 @@
<script lang="ts">
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import ArrowUpRightIcon from '$lib/components/Icons/ArrowTopRight.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
let chatId: number | null = $state(null);
async function addChatId() {
if (!chatId || `${chatId}`.length < 5) return;
const { error } = await supabase
.from('profile')
.update({
telegram_chat_id: chatId
})
.match({ id: $profile.id });
if (error) {
console.log(error);
snackbar.error('snackbar.course_settings.error.failed_integration');
} else {
$profile.telegramChatId = chatId;
chatId = null;
snackbar.success('snackbar.course_settings.success.successful_integration');
}
}
async function deleteChatId() {
const { error } = await supabase.from('profile').update({ telegram_chat_id: null }).match({ id: $profile.id });
if (error) {
snackbar.error('snackbar.course_settings.error.failed_deletion');
console.log(error);
} else {
$profile.telegramChatId = null;
snackbar.success('snackbar.course_settings.success.successful_deletion');
}
}
</script>
<Grid class="mt-5 w-full">
<Row class="border-bottom-c flex flex-col justify-center py-7 lg:flex-row lg:justify-start">
<Column sm={4} md={4} lg={4} class="flex items-center justify-center">
<img src="/telegram-svg.svg" alt="" class="mr-2 w-10" />
<SectionTitle>{$t('settings.integrations.heading')}</SectionTitle></Column
>
<Column sm={8} md={8} lg={8} class="mt-2 flex flex-col items-center lg:mt-0 lg:items-start">
{#if $profile.telegramChatId !== null}
<div class="flex w-full flex-col items-center">
<div class="flex items-center">
<CircleCheckIcon size={16} filled />
<SectionTitle>{$t('settings.integrations.success_message')}</SectionTitle>
</div>
<PrimaryButton className="mt-3" variant={VARIANTS.CONTAINED_DANGER} onClick={deleteChatId}
>{$t('settings.integrations.disconnect')}</PrimaryButton
>
</div>
{:else}
<div>
<h3 class="text-lg font-normal">
{$t('settings.integrations.sub_heading')}
</h3>
<h4 class="font-normal">{$t('settings.integrations.step_authenticate')}</h4>
<PrimaryButton
className="mb-5"
variant={VARIANTS.OUTLINED}
onClick={() => window.open('https://t.me/classroomio_bot', '_blank')}
><span class="mr-2">{$t('settings.integrations.open_bot_button')}</span>
<ArrowUpRightIcon /></PrimaryButton
>
<h4 class="font-normal">{$t('settings.integrations.step_chatId')}</h4>
<TextField
bind:value={chatId}
placeholder="360111"
className="w-full lg:w-60 mb-5"
type="number"
isRequired
/>
<PrimaryButton onClick={addChatId}>{$t('settings.integrations.connect_button')}</PrimaryButton>
</div>
{/if}
</Column>
</Row>
</Grid>
@@ -1,813 +0,0 @@
<script lang="ts">
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import SaveIcon from '@lucide/svelte/icons/save';
import TrashIcon from '@lucide/svelte/icons/trash';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { landingPageSettings } from './store';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { getSupabase } from '$lib/utils/functions/supabase';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import { IconButton } from '$lib/components/IconButton';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
let isSaving = $state(false);
let creatingNewQuestion = $state(false);
let creatingNewCustomLink = $state(false);
let hasUnsavedChanges = $state(false);
let widgetKey = $state('');
const banner = [
{ value: 'video', label: `${$t('settings.landing_page.actions.banner_type.video')}` },
{ value: 'image', label: `${$t('settings.landing_page.actions.banner_type.image')}` }
];
const supabase = getSupabase();
let newQuestion = $state({
title: '',
content: ''
});
let newCustomLink = $state({
label: '',
url: '',
openInNewTab: false
});
function widgetControl(key: string) {
widgetKey = key;
$handleOpenWidget.open = true;
}
function createNewFaq() {
newQuestion = {
title: '',
content: ''
};
creatingNewQuestion = true;
}
function saveNewFAQ() {
if (newQuestion.title !== '' && newQuestion.content !== '') {
$landingPageSettings.faq.questions = [
...$landingPageSettings.faq.questions,
{
id: new Date().getTime(),
title: newQuestion.title,
content: newQuestion.content
}
];
creatingNewQuestion = false;
}
}
function cancelNewFAQ() {
creatingNewQuestion = false;
}
function deleteFaq(id: number) {
let faqs = $landingPageSettings.faq.questions;
const filteredFaq = faqs.filter((faq) => faq.id !== id);
$landingPageSettings.faq.questions = filteredFaq;
}
function createNewCustomLink() {
newCustomLink = {
label: '',
url: '',
openInNewTab: false
};
creatingNewCustomLink = true;
}
function saveNewCustomLink() {
if (newCustomLink.label !== '' && newCustomLink.url !== '') {
$landingPageSettings.customLinks.links = [
...$landingPageSettings.customLinks.links,
{
id: new Date().getTime(),
label: newCustomLink.label,
url: newCustomLink.url,
openInNewTab: newCustomLink.openInNewTab
}
];
creatingNewCustomLink = false;
}
}
function cancelNewCustomLink() {
creatingNewCustomLink = false;
}
function deleteCustomLink(id: number) {
let customLinks = $landingPageSettings.customLinks.links;
const filteredCustomLinks = customLinks.filter((link) => link.id !== id);
$landingPageSettings.customLinks.links = filteredCustomLinks;
}
const checkPrefix = (inputValue: string) => {
if (!inputValue) return;
if (inputValue.trim() !== '') {
if (!inputValue.startsWith('https://')) {
inputValue = 'https://' + inputValue;
}
}
return inputValue;
};
async function handleSave() {
isSaving = true;
$landingPageSettings.footer.twitter = checkPrefix($landingPageSettings.footer.twitter) || '';
$landingPageSettings.footer.linkedin = checkPrefix($landingPageSettings.footer.linkedin) || '';
$landingPageSettings.footer.facebook = checkPrefix($landingPageSettings.footer.facebook) || '';
const { error } = await supabase
.from('organization')
.update({ landingpage: $landingPageSettings })
.match({ id: $currentOrg.id });
if (error) {
const message = error?.message || $t('snackbar.lms.error.try_again');
snackbar.error(`${$t('snackbar.update_failed')}: ${message}`);
} else {
$currentOrg.landingpage = $landingPageSettings as any;
snackbar.success('snackbar.success_update');
hasUnsavedChanges = false;
}
isSaving = false;
}
// Set default from store
currentOrg.subscribe((cOrg) => {
if (cOrg.landingpage && Object.keys(cOrg.landingpage).length) {
const landingpage = { ...(cOrg.landingpage as unknown as OrgLandingPageJson) };
// Fallbacks for new keys we added into JSON, in case a user already saved the old JSON
if (!landingpage?.header?.banner) {
landingpage.header.banner = $landingPageSettings.header.banner;
}
if (!landingpage?.header?.background) {
landingpage.header.background = $landingPageSettings.header.background;
}
if (!landingpage.customLinks) {
landingpage.customLinks = $landingPageSettings.customLinks;
}
$landingPageSettings = {
...landingpage
};
}
});
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Grid class="relative mt-5 w-full">
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}>
<SectionTitle>{$t('settings.landing_page.heading')}</SectionTitle>
<div class="flex items-center space-x-2">
<Switch bind:checked={$landingPageSettings.header.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Label class="text-gray-600">
{$landingPageSettings.header.show
? $t('settings.landing_page.show_section')
: $t('settings.landing_page.hide_section')}
</Label>
</div>
</Column>
<Column sm={8} md={8} lg={8} class="mt-4 lg:mt-0">
<TextField
label={$t('settings.landing_page.title')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.header.title}
onChange={() => (hasUnsavedChanges = true)}
className="w-full mb-5"
/>
<TextField
label={$t('settings.landing_page.title_highlight')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
className="w-full mb-5"
bind:value={$landingPageSettings.header.titleHighlight}
onChange={() => (hasUnsavedChanges = true)}
/>
<TextArea
label={$t('settings.landing_page.subtitle')}
labelClassName="font-light"
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
className="w-full mb-5"
bind:value={$landingPageSettings.header.subtitle}
onChange={() => (hasUnsavedChanges = true)}
isAIEnabled={true}
/>
<SectionTitle>{$t('settings.landing_page.actions.heading')}</SectionTitle>
<TextField
label={$t('settings.landing_page.actions.label')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.label')}
className="w-full mt-3 mb-5"
bind:value={$landingPageSettings.header.action.label}
onChange={() => (hasUnsavedChanges = true)}
/>
<div class="mb-5 gap-2">
<TextField
label={$t('settings.landing_page.actions.link')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.link')}
bind:value={$landingPageSettings.header.action.link}
onChange={() => (hasUnsavedChanges = true)}
/>
<div class="mt-3 flex items-center space-x-2">
<Switch
bind:checked={$landingPageSettings.header.action.redirect}
onCheckedChange={() => (hasUnsavedChanges = true)}
/>
<Label class="text-gray-600">
{$landingPageSettings.header.action.redirect
? $t('settings.landing_page.actions.redirect')
: $t('settings.landing_page.actions.no_redirect')}
</Label>
</div>
</div>
<div class="mb-5 mt-10">
<Label class="mb-3 block font-medium">{$t('settings.landing_page.actions.banner_type.heading')}</Label>
<RadioGroup.Root
bind:value={$landingPageSettings.header.banner.type}
onValueChange={() => {
if (hasUnsavedChanges) return;
hasUnsavedChanges = true;
}}
>
{#each banner as item}
<div class="flex items-center space-x-2">
<RadioGroup.Item value={item.value} id={item.value} />
<Label for={item.value} class="cursor-pointer">{item.label}</Label>
</div>
{/each}
</RadioGroup.Root>
</div>
{#if $landingPageSettings.header.banner.type === 'video'}
<div class="mb-5 mt-3 gap-2">
<TextField
label={$t('settings.landing_page.actions.link')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.video')}
bind:value={$landingPageSettings.header.banner.video}
onChange={() => (hasUnsavedChanges = true)}
/>
</div>
{:else}
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('settings.landing_page.about.select_image')}
className="mt-3"
onClick={() => widgetControl('banner')}
/>
{/if}
{#if $landingPageSettings.header.banner.image && $landingPageSettings.header.banner.type === 'image'}
<img alt="bannerImage" src={$landingPageSettings.header.banner.image} class="mt-2 w-full rounded-md" />
{/if}
<div class="flex items-center space-x-2">
<Switch
bind:checked={$landingPageSettings.header.banner.show}
onCheckedChange={() => (hasUnsavedChanges = true)}
/>
<Label class="text-gray-600">
{$landingPageSettings.header.banner.show
? $t('settings.landing_page.actions.show_banner')
: $t('settings.landing_page.actions.hide_banner')}
</Label>
</div>
{#if $handleOpenWidget.open && widgetKey === 'banner'}
<UploadWidget
bind:imageURL={$landingPageSettings.header.banner.image}
onchange={() => (hasUnsavedChanges = true)}
/>
{/if}
<!-- background -->
<div class="mt-4">
<p class="mb-4">{$t('settings.landing_page.background.title')}</p>
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('settings.landing_page.about.select_image')}
className="mt-3"
onClick={() => widgetControl('background')}
/>
{#if $landingPageSettings.header.background?.image}
<img
alt="backgroundImage"
src={$landingPageSettings.header.background.image}
class="mt-2 w-full rounded-md"
/>
{/if}
<div class="mt-2 flex items-center space-x-2">
<Switch
checked={$landingPageSettings.header.background?.show}
onCheckedChange={() => {
hasUnsavedChanges = true;
if (!$landingPageSettings.header.background) {
$landingPageSettings.header.background = {
image: '',
show: true
};
} else {
$landingPageSettings.header.background.show = !$landingPageSettings.header.background.show;
}
}}
/>
<Label class="text-gray-600">
{$landingPageSettings.header.background?.show
? $t('settings.landing_page.background.show_background')
: $t('settings.landing_page.background.hide_background')}
</Label>
</div>
{#if $handleOpenWidget.open && widgetKey === 'background'}
<UploadWidget
imageURL={$landingPageSettings.header.background?.image}
onchange={(imgUrl) => {
hasUnsavedChanges = true;
if (!$landingPageSettings.header.background) {
$landingPageSettings.header.background = {
image: imgUrl,
show: true
};
} else {
$landingPageSettings.header.background.image = imgUrl;
}
}}
/>
{/if}
</div>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}>
<SectionTitle>{$t('settings.landing_page.about.heading')}</SectionTitle>
<div class="flex items-center space-x-2">
<Switch bind:checked={$landingPageSettings.aboutUs.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Label class="text-gray-600">
{$landingPageSettings.aboutUs.show
? $t('settings.landing_page.show_section')
: $t('settings.landing_page.hide_section')}
</Label>
</div>
</Column>
<Column sm={8} md={8} lg={8} class="mt-4 lg:mt-0">
<TextField
label={$t('settings.landing_page.about.title')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.aboutUs.title}
onChange={() => (hasUnsavedChanges = true)}
className="mb-5"
/>
<TextArea
label={$t('settings.landing_page.about.content')}
labelClassName="font-light"
placeholder={$t('course.navItem.lessons.exercises.all_exercises.content')}
className="mb-5"
bind:value={$landingPageSettings.aboutUs.content}
onChange={() => (hasUnsavedChanges = true)}
isAIEnabled={true}
/>
<div>
<p class="font-bold">{$t('settings.landing_page.about.upload_an_image')}</p>
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('settings.landing_page.about.select_image')}
className="mt-3"
onClick={() => widgetControl('about-us')}
/>
{#if $landingPageSettings.aboutUs.imageUrl}
<img alt="About us" src={$landingPageSettings.aboutUs.imageUrl} class="mt-2 w-full rounded-md" />
{/if}
{#if $handleOpenWidget.open && widgetKey === 'about-us'}
<UploadWidget bind:imageURL={$landingPageSettings.aboutUs.imageUrl} />
{/if}
</div>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}>
<SectionTitle>{$t('settings.landing_page.courses.heading')}</SectionTitle>
<div class="flex items-center space-x-2">
<Switch bind:checked={$landingPageSettings.courses.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Label class="text-gray-600">
{$landingPageSettings.courses.show
? $t('settings.landing_page.courses.show_section')
: $t('settings.landing_page.courses.hide_section')}
</Label>
</div>
</Column>
<Column sm={8} md={8} lg={8} class="mt-4 lg:mt-0">
<TextField
label={$t('settings.landing_page.courses.title')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.courses.title}
onChange={() => (hasUnsavedChanges = true)}
/>
<TextField
label={$t('settings.landing_page.courses.title_highlight')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
bind:value={$landingPageSettings.courses.titleHighlight}
onChange={() => (hasUnsavedChanges = true)}
/>
<TextArea
label={$t('settings.landing_page.courses.subtitle')}
labelClassName="font-light"
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
bind:value={$landingPageSettings.courses.subtitle}
onChange={() => (hasUnsavedChanges = true)}
isAIEnabled={true}
/>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}>
<SectionTitle>{$t('settings.landing_page.faq.heading')}</SectionTitle>
<div class="flex items-center space-x-2">
<Switch bind:checked={$landingPageSettings.faq.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Label class="text-gray-600">
{$landingPageSettings.faq.show
? $t('settings.landing_page.faq.show_section')
: $t('settings.landing_page.faq.hide_section')}
</Label>
</div>
</Column>
<Column sm={8} md={8} lg={8}>
<TextField
label={$t('settings.landing_page.faq.title')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.faq.title}
onChange={() => (hasUnsavedChanges = true)}
className="mb-5"
/>
{#each $landingPageSettings.faq.questions as item (item.id)}
<div class="mb-3">
<TextField
label={$t('settings.landing_page.faq.question')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_question_here')}
bind:value={item.title}
onChange={() => (hasUnsavedChanges = true)}
className="mb-5"
/>
<TextArea
label={$t('settings.landing_page.faq.answer')}
labelClassName="font-light"
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_answer_here')}
bind:value={item.content}
className="mb-5"
onChange={() => (hasUnsavedChanges = true)}
isAIEnabled={true}
/>
<IconButton onClick={() => deleteFaq(item.id)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/each}
{#if creatingNewQuestion}
<TextField
label={$t('settings.landing_page.faq.question')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_question_here')}
bind:value={newQuestion.title}
onChange={() => (hasUnsavedChanges = true)}
className="mb-5"
/>
<TextArea
label={$t('settings.landing_page.faq.answer')}
labelClassName="font-light"
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_answer_here')}
bind:value={newQuestion.content}
onChange={() => (hasUnsavedChanges = true)}
className="mb-5"
isAIEnabled={true}
/>
<div class="flex items-center gap-2">
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('settings.landing_page.faq.save')}
onClick={saveNewFAQ}
/>
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('settings.landing_page.faq.cancel')}
onClick={cancelNewFAQ}
/>
</div>
{:else}
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('settings.landing_page.faq.button')}
onClick={createNewFaq}
/>
{/if}
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}>
<SectionTitle>{$t('settings.landing_page.contact_us.heading')}</SectionTitle>
<div class="flex items-center space-x-2">
<Switch bind:checked={$landingPageSettings.contact.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Label class="text-gray-600">
{$landingPageSettings.contact.show
? $t('settings.landing_page.contact_us.show_section')
: $t('settings.landing_page.contact_us.hide_section')}
</Label>
</div>
</Column>
<Column sm={8} md={8} lg={8}>
<TextField
label={$t('settings.landing_page.contact_us.title')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.contact.title}
onChange={() => (hasUnsavedChanges = true)}
className="mb-5"
/>
<TextField
label={$t('settings.landing_page.contact_us.title_highlight')}
labelClassName="font-light"
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
className="mb-5"
bind:value={$landingPageSettings.contact.titleHighlight}
onChange={() => (hasUnsavedChanges = true)}
/>
<TextArea
label={$t('settings.landing_page.contact_us.subtitle')}
labelClassName="font-light"
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
className="mt-3 mb-5"
bind:value={$landingPageSettings.contact.subtitle}
onChange={() => (hasUnsavedChanges = true)}
isAIEnabled={true}
/>
<TextField
label={$t('settings.landing_page.contact_us.address')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.address')}
className="mt-3 mb-5"
bind:value={$landingPageSettings.contact.address}
onChange={() => (hasUnsavedChanges = true)}
/>
<TextField
label={$t('settings.landing_page.contact_us.phone_number')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.number')}
className="mt-3 mb-5"
bind:value={$landingPageSettings.contact.phone}
onChange={() => (hasUnsavedChanges = true)}
/>
<TextField
label={$t('settings.landing_page.contact_us.email')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.email')}
className="mt-3 mb-5"
bind:value={$landingPageSettings.contact.email}
onChange={() => (hasUnsavedChanges = true)}
/>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}>
<SectionTitle>{$t('settings.landing_page.mailing_list.heading')}</SectionTitle>
<div class="flex items-center space-x-2">
<Switch
bind:checked={$landingPageSettings.mailinglist.show}
onCheckedChange={() => (hasUnsavedChanges = true)}
/>
<Label class="text-gray-600">
{$landingPageSettings.mailinglist.show
? $t('settings.landing_page.mailing_list.show_section')
: $t('settings.landing_page.mailing_list.hide_section')}
</Label>
</div>
</Column>
<Column sm={8} md={8} lg={8} class="mt-4 lg:mt-0">
<TextField
label={$t('settings.landing_page.mailing_list.title')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.mailinglist.title}
onChange={() => (hasUnsavedChanges = true)}
className="mb-5"
/>
<TextArea
label={$t('settings.landing_page.mailing_list.subtitle')}
labelClassName="font-light"
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
className="mb-5"
bind:value={$landingPageSettings.mailinglist.subtitle}
onChange={() => (hasUnsavedChanges = true)}
isAIEnabled={true}
/>
<TextField
label={$t('settings.landing_page.mailing_list.button_label')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.label')}
className="mb-5"
bind:value={$landingPageSettings.mailinglist.buttonLabel}
onChange={() => (hasUnsavedChanges = true)}
/>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}>
<SectionTitle>{$t('settings.landing_page.custom_links.heading')}</SectionTitle>
<div class="flex items-center space-x-2">
<Switch bind:checked={$landingPageSettings.customLinks.show} />
<Label class="text-gray-600">
{$landingPageSettings.customLinks.show
? $t('settings.landing_page.custom_links.show_links')
: $t('settings.landing_page.custom_links.hide_links')}
</Label>
</div>
</Column>
<Column sm={8} md={8} lg={8} class="mt-4 lg:mt-0">
<p class="mb-4 text-sm text-gray-600">
{$t('settings.landing_page.custom_links.description')}
</p>
{#each $landingPageSettings.customLinks.links as link (link.id)}
<div class="mb-4 rounded-lg border border-gray-200 p-4">
<div class="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField
label={$t('settings.landing_page.custom_links.label')}
placeholder={$t('settings.landing_page.custom_links.label_placeholder')}
bind:value={link.label}
className="mb-3"
/>
<TextField
label={$t('settings.landing_page.custom_links.url')}
placeholder={$t('settings.landing_page.custom_links.url_placeholder')}
bind:value={link.url}
className="mb-3"
/>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<input type="checkbox" bind:checked={link.openInNewTab} class="mr-2" />
<span class="text-sm">{$t('settings.landing_page.custom_links.new_tab')}</span>
</label>
<IconButton onClick={() => deleteCustomLink(link.id)}>
<TrashIcon size={16} />
</IconButton>
</div>
</div>
{/each}
{#if creatingNewCustomLink}
<div class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField
label={$t('settings.landing_page.custom_links.label')}
placeholder={$t('settings.landing_page.custom_links.label_placeholder')}
bind:value={newCustomLink.label}
className="mb-3"
/>
<TextField
label={$t('settings.landing_page.custom_links.url')}
placeholder={$t('settings.landing_page.custom_links.url_placeholder')}
bind:value={newCustomLink.url}
className="mb-3"
/>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<input type="checkbox" bind:checked={newCustomLink.openInNewTab} class="mr-2" />
<span class="text-sm">{$t('settings.landing_page.custom_links.new_tab')}</span>
</label>
<div class="flex gap-2">
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('settings.landing_page.custom_links.save')}
onClick={saveNewCustomLink}
className="text-sm px-3 py-1"
/>
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('settings.landing_page.custom_links.cancel')}
onClick={cancelNewCustomLink}
className="text-sm px-3 py-1"
/>
</div>
</div>
</div>
{:else}
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('settings.landing_page.custom_links.add')}
onClick={createNewCustomLink}
className="mt-2"
/>
{/if}
</Column>
</Row>
<Row class="flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}>
<SectionTitle>{$t('settings.landing_page.footer.heading')}</SectionTitle>
<div class="flex items-center space-x-2">
<Switch bind:checked={$landingPageSettings.footer.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Label class="text-gray-600">
{$landingPageSettings.footer.show
? $t('settings.landing_page.footer.show_section')
: $t('settings.landing_page.footer.hide_section')}
</Label>
</div>
</Column>
<Column sm={8} md={8} lg={8} class="mt-4 lg:mt-0">
<TextField
label={$t('settings.landing_page.footer.facebook')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.facebook')}
bind:value={$landingPageSettings.footer.facebook}
onChange={() => (hasUnsavedChanges = true)}
className="mb-5"
/>
<!-- <TextField
label="Instagram"
placeholder="Write your Instagram link here"
className="mb-5"
bind:value={$landingPageSettings.footer.instagram}
/> -->
<TextField
label={$t('settings.landing_page.footer.twitter')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.twitter')}
className="mb-5"
bind:value={$landingPageSettings.footer.twitter}
onChange={() => (hasUnsavedChanges = true)}
/>
<TextField
label={$t('settings.landing_page.footer.linkedin')}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.linkedin')}
className="mb-5"
bind:value={$landingPageSettings.footer.linkedin}
onChange={() => (hasUnsavedChanges = true)}
/>
</Column>
</Row>
<div class="desktop sticky bottom-12 z-[120] float-right mr-2">
<PrimaryButton
label={$t('settings.landing_page.save_changes')}
isLoading={isSaving}
isDisabled={isSaving}
onClick={handleSave}
/>
</div>
</Grid>
<div
class="mobile
absolute bottom-8 right-6 z-[120]"
>
<span>
<IconButton onClick={handleSave} disabled={isSaving}>
<SaveIcon size={16} />
</IconButton>
</span>
</div>
<style>
@media screen and (min-width: 769px) {
.desktop {
display: block;
}
.mobile {
display: none;
}
}
@media screen and (max-width: 768px) {
.desktop {
display: none;
}
.mobile {
display: flex;
}
}
</style>
@@ -1,41 +0,0 @@
<script lang="ts">
interface Props {
sm?: number;
md?: number;
lg?: number;
class?: string;
children?: import('svelte').Snippet;
}
let { sm = 8, md = 8, lg = 8, class: className = '', children }: Props = $props();
const getColSpan = (cols: number) => {
const spanMap = {
1: 'col-span-1',
2: 'col-span-2',
3: 'col-span-3',
4: 'col-span-4',
5: 'col-span-5',
6: 'col-span-6',
7: 'col-span-7',
8: 'col-span-8',
9: 'col-span-9',
10: 'col-span-10',
11: 'col-span-11',
12: 'col-span-12',
13: 'col-span-13',
14: 'col-span-14',
15: 'col-span-15',
16: 'col-span-16'
};
return spanMap[cols] || 'col-span-full';
};
const smClass = getColSpan(sm);
const mdClass = `md:${getColSpan(md)}`;
const lgClass = `lg:${getColSpan(lg)}`;
</script>
<div class="{smClass} {mdClass} {lgClass} {className} grid">
{@render children?.()}
</div>
@@ -1,12 +0,0 @@
<script lang="ts">
interface Props {
class?: string;
children?: import('svelte').Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class="w-full {className}">
{@render children?.()}
</div>
@@ -1,13 +0,0 @@
<script lang="ts">
interface Props {
id?: string;
class?: string;
children?: import('svelte').Snippet;
}
let { id, class: className = '', children }: Props = $props();
</script>
<div {id} class="lg:grid-cols-16 grid grid-cols-1 gap-5 px-3 {className}">
{@render children?.()}
</div>
@@ -1,3 +0,0 @@
export { default as Grid } from './Grid.svelte';
export { default as Row } from './Row.svelte';
export { default as Column } from './Column.svelte';
@@ -1,353 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import ZapIcon from '@lucide/svelte/icons/zap';
import debounce from 'lodash/debounce';
import ColorPicker from 'svelte-awesome-color-picker';
import { t } from '$lib/utils/functions/translations';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { updateOrgNameValidation } from '$lib/utils/functions/validator';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { injectCustomTheme, setCustomTheme, setTheme } from '$lib/utils/functions/theme';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
let avatar = $state<string | undefined>();
let hasUnsavedChanges = $state(false);
type Error = {
orgName: string;
};
let errors: Error = $state({
orgName: ''
});
let loading = $state(false);
let hex = $state('');
$effect(() => {
const theme = $currentOrg.theme;
// if not a custom theme, don't update hex
if (!theme || theme.includes('theme-')) return;
// only update hex with value of `theme` when theme changes not when HEX changes
untrack(() => {
hex = theme;
});
});
const themes = {
rose: 'theme-rose',
green: 'theme-green',
orange: 'theme-orange',
violet: 'theme-violet',
default: ''
};
const saveTheme = debounce(async (theme) => {
const { error, data } = await supabase.from('organization').update({ theme }).match({ id: $currentOrg.id });
console.log('Debounced update theme', data);
if (error) {
snackbar.error('Failed to update theme: ' + error.message);
}
}, 700);
function handleChangeTheme(t = '') {
return async () => {
document.body.className = document.body.className
.split(' ')
.filter((c) => !c.includes('theme'))
.join(' ')
.concat(t ? ' ' : '', t);
$currentOrg.theme = t;
hex = '';
setTheme(t);
saveTheme(t);
};
}
async function handleCustomTheme() {
if (!hex) return;
injectCustomTheme(hex);
setCustomTheme('theme-custom');
$currentOrg.theme = hex;
saveTheme(hex);
}
async function handleUpdate() {
errors = updateOrgNameValidation($currentOrg.name) as Error;
if (Object.values(errors).length) {
loading = false;
return;
}
try {
loading = true;
const updates: Record<string, string> = {
name: $currentOrg.name
};
if (avatar) {
const filename = `user/${$currentOrg.name + Date.now()}.webp`;
const { data } = await supabase.storage.from('avatars').upload(filename, avatar, {
cacheControl: '3600',
upsert: false
});
if (data) {
const { data: response } = supabase.storage.from('avatars').getPublicUrl(filename);
updates.avatarUrl = response.publicUrl;
$currentOrg.avatarUrl = response.publicUrl;
}
avatar = undefined;
}
let { error } = await supabase.from('organization').update(updates).match({ id: $currentOrg.id });
currentOrg.update((_currentOrg) => ({
..._currentOrg,
...updates
}));
snackbar.success('snackbar.course_settings.success.update_successful');
hasUnsavedChanges = false;
if (error) throw error;
} catch (error) {
let message = error as string;
if (message.includes('profile_username_key')) {
message = $t('snackbar.lms.error.username_exists');
}
snackbar.error(`${$t('snackbar.update_failed')}: ${message}`);
loading = false;
} finally {
loading = false;
}
}
function gotoSettings(pathname) {
goto(`${$currentOrgPath}/settings${pathname}`);
}
let isCustomTheme = $derived(hex && !hex.includes('theme-'));
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Grid class="mt-5 w-full">
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}>
<SectionTitle>
{$t('settings.organization.organization_profile.heading')}
</SectionTitle>
</Column>
<Column sm={8} md={8} lg={8} class="mt-2 flex flex-col items-center lg:mt-0 lg:items-start">
<TextField
label={$t('settings.organization.organization_profile.organization_name')}
bind:value={$currentOrg.name}
onChange={() => (hasUnsavedChanges = true)}
className="w-full lg:w-60 mb-5"
errorMessage={errors.orgName}
/>
<UploadImage
bind:avatar
src={$currentOrg.avatarUrl}
shape="rounded-md"
widthHeight="w-24 h-24"
change={() => (hasUnsavedChanges = true)}
/>
<PrimaryButton
label={$t('settings.organization.organization_profile.update_organization')}
className="px-6 py-3 lg:mr-5 mt-5"
isLoading={loading}
isDisabled={loading}
onClick={handleUpdate}
/>
</Column>
</Row>
<Row class="border-bottom-c relative flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}
><SectionTitle>{$t('settings.organization.organization_profile.theme.heading')}</SectionTitle></Column
>
<Column sm={8} md={8} lg={8}>
<h4 class="lg:mt-0 dark:text-white">
{$t('settings.organization.organization_profile.theme.sub_heading')}
</h4>
<div class="flex items-center gap-5">
<button
class="rounded-full border-2 {$currentOrg.theme === themes.default &&
'border-[#1d4ee2]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.default)}
aria-label="Default blue theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#1d4ee2] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.rose &&
'border-[#be1241]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.rose)}
aria-label="Rose theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#be1241] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.green &&
'border-[#0c891b]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.green)}
aria-label="Green theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#0c891b] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.orange &&
'border-[#cc4902]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.orange)}
aria-label="Orange theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#cc4902] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.violet &&
'border-[#cf00ce]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.violet)}
aria-label="Violet theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#cf00ce] md:h-6 md:w-6"></div>
</button>
<div
class="h-auto w-fit border-2 {isCustomTheme
? 'border-primary-700'
: 'dark:border-neutral-700'} group relative rounded-full"
>
<!-- plus icon positioned over the color picker -->
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-200"
>
<svg
class="h-6 w-6 text-{isCustomTheme ? 'white' : 'black'} z-10 opacity-100 dark:text-white"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"></path>
</svg>
</div>
<ColorPicker position="responsive" label="" bind:hex on:input={handleCustomTheme} />
</div>
</div>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}>
<SectionTitle>
{$t('settings.organization.organization_profile.customize_lms.heading')}
</SectionTitle>
</Column>
<Column sm={8} md={8} lg={8}>
<h4 class="lg:mt-0 dark:text-white">
{$t('settings.organization.organization_profile.customize_lms.sub_heading')}
</h4>
<p class="text-sm text-gray-500 dark:text-white">
{$t('settings.organization.organization_profile.customize_lms.body')}
</p>
<PrimaryButton
className="my-7 py-5 px-10 flex items-center gap-2 justify-center"
variant={VARIANTS.OUTLINED}
onClick={() => gotoSettings('/customize-lms')}
>
{$t('settings.organization.organization_profile.customize_lms.button')}
</PrimaryButton>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}
><SectionTitle>{$t('settings.organization.organization_profile.custom_domain.heading')}</SectionTitle></Column
>
<Column sm={8} md={8} lg={8}>
<h4 class="lg:mt-0 dark:text-white">
{$t('settings.organization.organization_profile.custom_domain.sub_heading')}
</h4>
<p class="text-sm text-gray-500 dark:text-white">
{$t('settings.organization.organization_profile.custom_domain.body')}
</p>
<PrimaryButton
className="my-7 py-5 px-10 flex items-center gap-2 justify-center"
variant={VARIANTS.OUTLINED}
onClick={() => gotoSettings('/domains')}
>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('settings.organization.organization_profile.custom_domain.button')}
</PrimaryButton>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}
><SectionTitle>{$t('settings.organization.organization_profile.team.heading')}</SectionTitle></Column
>
<Column sm={8} md={8} lg={8}>
<h4 class="lg:mt-0 dark:text-white">
{$t('settings.organization.organization_profile.team.sub_heading')}
</h4>
<p class="text-sm text-gray-500 dark:text-white">
{$t('settings.organization.organization_profile.team.body')}
</p>
<PrimaryButton
className="my-7 py-5 px-10 flex items-center gap-2 justify-center"
variant={VARIANTS.OUTLINED}
onClick={() => gotoSettings('/teams')}
>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('settings.organization.organization_profile.team.button')}
</PrimaryButton>
</Column>
</Row>
</Grid>
<style>
:global(.dark) {
--cp-text-color: #fff;
--cp-border-color: white;
--cp-text-color: white;
--cp-input-color: #555;
--cp-button-hover-color: #777;
}
:global(.dark .alpha) {
background: #333 !important;
}
</style>
@@ -1,165 +0,0 @@
<script lang="ts">
import type { TLocale } from '@cio/db/types';
import { profile } from '$lib/utils/store/user';
import { CircleCheckBig } from '@lucide/svelte';
import { profileApi } from '$lib/features/auth/api/profile.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { t } from '$lib/utils/functions/translations';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import LanguagePicker from './LanguagePicker.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
let avatar = $state<string | File | undefined>();
let hasLangChanged = $state(false);
let locale = $derived<TLocale | undefined>($profile.locale || undefined);
let hasUnsavedChanges = $state(false);
let email = $derived($profile.email || '');
let isChangingEmail = $state(false);
let emailChangeInitiated = $state(false);
async function handleUpdate() {
await profileApi.submit(
{
fullname: $profile.fullname,
username: $profile.username,
locale,
avatar
},
$profile,
hasLangChanged,
locale
);
if (profileApi.success) {
hasUnsavedChanges = false;
avatar = undefined;
}
}
async function handleEmailChange() {
if (email === $profile.email) {
return;
}
await profileApi.changeEmail({
newEmail: email,
callbackURL: window.location.href
});
if (profileApi.success) {
isChangingEmail = false;
emailChangeInitiated = true;
// Reset email field to current email after successful change request
email = $profile.email || '';
}
}
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Grid class="mt-0 w-full md:mt-5">
<Row class="border-bottom-c flex flex-col items-center lg:flex-row lg:items-start lg:py-7 ">
<Column sm={4} md={8} lg={4} class="mt-2 md:mt-0">
<SectionTitle>{$t('settings.profile.profile_picture.heading')}</SectionTitle>
</Column>
<Column sm={8} md={8} lg={8} class="mt-2 lg:mt-0">
<UploadImage
bind:avatar
src={$profile.avatarUrl}
widthHeight="w-16 h-16 lg:w-24 lg:h-24"
isDisabled={profileApi.isLoading}
change={() => (hasUnsavedChanges = true)}
/>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}>
<SectionTitle>{$t('settings.profile.personal_information.heading')}</SectionTitle>
</Column>
<Column sm={8} md={8} lg={8} class="mt-2 lg:mt-0">
<TextField
label={$t('settings.profile.personal_information.full_name')}
bind:value={$profile.fullname}
className="w-full lg:w-60 mb-4"
errorMessage={profileApi.errors.fullname ? $t(profileApi.errors.fullname) : undefined}
onChange={() => (hasUnsavedChanges = true)}
/>
<TextField
label={$t('settings.profile.personal_information.username')}
bind:value={$profile.username}
className="w-full lg:w-60 mb-4"
errorMessage={profileApi.errors.username ? $t(profileApi.errors.username) : undefined}
onChange={() => (hasUnsavedChanges = true)}
/>
<div class="mb-4 w-full lg:w-60">
<TextField
label={$t('settings.profile.personal_information.email')}
bind:value={email}
className="w-full"
type="email"
isDisabled={profileApi.isLoading}
errorMessage={profileApi.errors.newEmail ? $t(profileApi.errors.newEmail) : undefined}
onChange={() => {
if (email !== $profile.email) {
isChangingEmail = true;
emailChangeInitiated = false;
}
}}
/>
{#if isChangingEmail && email !== $profile.email}
<div class="mt-2 flex gap-2">
<PrimaryButton
label={$t('settings.profile.personal_information.confirm')}
variant={VARIANTS.CONTAINED_DARK}
className="text-sm"
isLoading={profileApi.isLoading}
isDisabled={profileApi.isLoading}
onClick={handleEmailChange}
/>
<PrimaryButton
label={$t('settings.profile.personal_information.cancel')}
variant={VARIANTS.NONE}
className="text-sm text-primary-700"
isDisabled={profileApi.isLoading}
onClick={() => {
email = $profile.email || '';
isChangingEmail = false;
emailChangeInitiated = false;
profileApi.errors.newEmail = '';
}}
/>
</div>
{/if}
{#if emailChangeInitiated}
<div
class="ml-2 mt-2 flex w-4/5 items-center gap-2 rounded-md border border-gray-200 p-2 text-sm text-amber-500"
>
<CircleCheckBig />
<p>{$t('settings.profile.personal_information.email_change_verification_note')}</p>
</div>
{/if}
</div>
<LanguagePicker
change={() => (hasUnsavedChanges = true)}
bind:hasLangChanged
bind:value={locale}
className="w-full lg:w-60 mb-4"
/>
</Column>
</Row>
<div class="m-5 flex w-full items-center gap-2 lg:justify-center">
<PrimaryButton
label={$t('settings.profile.update_profile')}
variant={VARIANTS.CONTAINED_DARK}
className="mr-5 w-fit"
isLoading={profileApi.isLoading}
onClick={handleUpdate}
/>
</div>
</Grid>
@@ -1,101 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import * as Tabs from '@cio/ui/base/tabs';
import Profile from './Profile.svelte';
import Billing from './Billing.svelte';
import { goto } from '$app/navigation';
import OrgSettings from './OrgSettings.svelte';
import Integrations from './Integrations.svelte';
import LandingpageSettings from './LandingpageSettings.svelte';
// import Account from './Account.svelte';
import { isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
interface TabItem {
key: number;
label: string;
tabKey: string;
href: string;
disabled: boolean;
}
let query = $derived(new URLSearchParams(page.url.search));
let tabKey = $derived(query.get('tab') || '');
let tabs: TabItem[] = $derived([
{
key: 0,
label: $t('settings.tabs.profile_tab'),
tabKey: '',
href: page.url.pathname,
disabled: false
},
{
key: 1,
label: $t('settings.tabs.organization_tab'),
tabKey: 'org',
href: `${page.url.pathname}?tab=org`,
disabled: !$isOrgAdmin
},
{
key: 2,
label: $t('settings.tabs.landing_page_tab'),
tabKey: 'landingpage',
href: `${page.url.pathname}?tab=landingpage`,
disabled: !$isOrgAdmin
},
{
key: 3,
label: $t('settings.tabs.billing_tab'),
tabKey: 'billing',
href: `${page.url.pathname}?tab=billing`,
disabled: !$isOrgAdmin
},
{
key: 4,
label: $t('settings.tabs.integrations_tab'),
tabKey: 'integrations',
href: `${page.url.pathname}?tab=integrations`,
disabled: false
}
]);
function onTabChange(value: string) {
const tab = tabs.find((t) => t.tabKey === value);
if (tab) {
goto(tab.href);
}
}
</script>
<Tabs.Root value={tabKey} onValueChange={onTabChange} class="w-full p-0">
<Tabs.List class="w-full">
{#each tabs as tab}
<Tabs.Trigger value={tab.tabKey} disabled={tab.disabled}>
{tab.label}
</Tabs.Trigger>
{/each}
</Tabs.List>
<Tabs.Content value="" class="w-full p-0">
<Profile />
</Tabs.Content>
<Tabs.Content value="org" class="w-full p-0">
<OrgSettings />
</Tabs.Content>
<Tabs.Content value="landingpage" class="w-full p-0">
<LandingpageSettings />
</Tabs.Content>
<Tabs.Content value="billing" class="w-full p-0">
<Billing />
</Tabs.Content>
<Tabs.Content value="integrations" class="w-full p-0">
<Integrations />
</Tabs.Content>
</Tabs.Root>

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