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:
co-authored by
Abdulmuizz Hamzat
parent
e6a3cf9332
commit
9fb6c7cbaa
@@ -0,0 +1,81 @@
|
||||
# Auth flow
|
||||
|
||||
Documenting my thoughts on how authentication works in the app.
|
||||
|
||||
## Simplified breakdown
|
||||
|
||||
### dashboard (app.classroomio.com)
|
||||
|
||||
- sign up (email, password or google auth)
|
||||
- redirect to onboarding.
|
||||
- redirect to dashboard
|
||||
|
||||
### dashboard - invite (admin or teacher)
|
||||
|
||||
- insert email into organizationmember table
|
||||
- click invite link
|
||||
- sign up or login
|
||||
- update org with role (as role in invite)
|
||||
|
||||
### lms (school.classroomio.com)
|
||||
|
||||
- create email and password
|
||||
- join org as student (automatically).
|
||||
|
||||
### lms - invite
|
||||
|
||||
- click invite link
|
||||
- redirect to login and come back to invite page.
|
||||
- join org as student (automatically).
|
||||
|
||||
---
|
||||
|
||||
## Better Auth to Supabase TODO
|
||||
|
||||
### actions to trigger [in code]
|
||||
|
||||
- [ ] After sign up
|
||||
- [x] create a profile by new user.
|
||||
- [ ] if sign up was triggered from a subdomain (aka is lms), add profile to org with student role
|
||||
- [ ] send verification email
|
||||
|
||||
### migrate from supabase
|
||||
|
||||
- [x] after login redirect to next page
|
||||
- [x] after signup redirect to next page (onboarding | lms | )
|
||||
- [x] implement logout
|
||||
- [x] forgot password flow
|
||||
- [x] complete onboarding flow
|
||||
- [x] change email flow.
|
||||
- [x] google auth flow (local and on test environment)
|
||||
- [ ] support google auth for teacher invite
|
||||
- [ ] support google auth for student invite
|
||||
|
||||
### others
|
||||
|
||||
- [x] block org routes when isOrgSite === true.
|
||||
- [ ] routes belonging to orgsite should proxy to courseapp (site-theme)
|
||||
|
||||
---
|
||||
|
||||
## invite v2.0
|
||||
|
||||
should an admin invite student into an org alone?
|
||||
|
||||
- yes
|
||||
- and also give them access to all resources in the org.
|
||||
|
||||
student
|
||||
|
||||
- send you invite to join course
|
||||
- you click link in email
|
||||
- you are redirected to accept page
|
||||
- you enter your name and password with email prefilled
|
||||
- you click join.
|
||||
|
||||
how do teachers send invite?
|
||||
|
||||
- email input field
|
||||
- teacher can send to multiple emails
|
||||
- teacher can deactivate access.
|
||||
- teacher can see who has accepted the invite and when.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# Route Page Setup Documentation
|
||||
|
||||
This document outlines the pattern for setting up page routes in the dashboard application. Follow this structure to maintain consistency across the codebase.
|
||||
|
||||
## Overview
|
||||
|
||||
The application follows a feature-based architecture where:
|
||||
|
||||
- **Route files** (`src/routes/org/[slug]/[feature]/+page.svelte`) are minimal wrappers that set up the Page structure
|
||||
- **Feature pages** (`src/lib/features/[feature]/pages/[feature].svelte`) contain all business logic, state management, and UI components
|
||||
- **Components** are organized in `src/lib/features/[feature]/components/` and exported via `index.ts`
|
||||
- **Pages** are exported from `src/lib/features/[feature]/pages/index.ts`
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```bash
|
||||
apps/dashboard/src/
|
||||
├── routes/
|
||||
│ └── org/
|
||||
│ └── [slug]/
|
||||
│ └── [feature]/
|
||||
│ └── +page.svelte # Minimal route wrapper
|
||||
└── lib/
|
||||
└── features/
|
||||
└── [feature]/
|
||||
├── components/
|
||||
│ ├── [component].svelte
|
||||
│ └── index.ts # Export all components
|
||||
├── pages/
|
||||
│ ├── [feature].svelte # Main feature page with all logic
|
||||
│ └── index.ts # Export page component
|
||||
├── utils/ # Feature-specific utilities
|
||||
└── api/ # Feature-specific API calls
|
||||
```
|
||||
|
||||
## Route File Pattern (`+page.svelte`)
|
||||
|
||||
The route file should be minimal and only contain:
|
||||
|
||||
1. **Page structure** using `@cio/ui/base/page` components
|
||||
2. **Import and render** the feature page component
|
||||
3. **Page metadata** (title, etc.)
|
||||
|
||||
### Example Route File
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
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>
|
||||
```
|
||||
|
||||
### Route File Rules
|
||||
|
||||
- ✅ DO: Keep it minimal - only Page structure
|
||||
- ✅ 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
|
||||
- ❌ DON'T: Put data fetching in route files
|
||||
- ❌ DON'T: Put filters/search in route files (use `Page.BodyHeader` in feature page)
|
||||
|
||||
## Feature Page Pattern (`pages/[feature].svelte`)
|
||||
|
||||
The feature page contains all the business logic, state management, and UI components.
|
||||
|
||||
### Feature Page Structure
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// 1. UI Component Imports
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { SomeIcon } from '@lucide/svelte/icons/...';
|
||||
|
||||
// 2. Feature Component Imports
|
||||
import { FeatureCard, FeatureLoader, FeatureModal } from '../components';
|
||||
|
||||
// 3. Utility Imports
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { someStore } from '../utils/store';
|
||||
import { fetchFeatureData } from '../api';
|
||||
|
||||
// 4. State Management
|
||||
let isLoading = $state(false);
|
||||
let searchValue = $state('');
|
||||
let selectedId = $state('');
|
||||
</script>
|
||||
|
||||
<FeatureModal />
|
||||
|
||||
<Page.BodyHeader align="right">
|
||||
<Search placeholder="Search..." bind:value={searchValue} />
|
||||
<Select.Root bind:value={selectedId}>
|
||||
<!-- Select options -->
|
||||
</Select.Root>
|
||||
</Page.BodyHeader>
|
||||
|
||||
<!-- Main Content with States -->
|
||||
<div class="w-full">
|
||||
{#if isLoading}
|
||||
<!-- Loading State -->
|
||||
<FeatureLoader />
|
||||
<FeatureLoader />
|
||||
<FeatureLoader />
|
||||
{:else if !data.length}
|
||||
<!-- Empty State -->
|
||||
<Empty
|
||||
title={$t('feature.empty_title')}
|
||||
description={$t('feature.empty_description')}
|
||||
icon={SomeIcon}
|
||||
variant="page"
|
||||
>
|
||||
<FeatureActionButton />
|
||||
</Empty>
|
||||
{:else}
|
||||
<!-- Content -->
|
||||
{#each data as item}
|
||||
<FeatureCard {item} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Feature Page Rules
|
||||
|
||||
- ✅ DO: Use `Page.BodyHeader` for filters, search, and view toggles
|
||||
- ✅ DO: Use `Empty` component from `@cio/ui/custom/empty` for empty states
|
||||
- ✅ DO: Use `Search` component from `@cio/ui/custom/search` for search inputs
|
||||
- ✅ DO: Handle loading states with skeleton/loader components
|
||||
- ✅ DO: Export the page component from `pages/index.ts` as `[Feature]Page`
|
||||
- ✅ DO: Keep all business logic in the feature page
|
||||
- ❌ DON'T: Use `Page.Header` or `Page.Action` in feature pages (these are in route files)
|
||||
- ❌ DON'T: Put route-specific logic in feature pages
|
||||
|
||||
## Component Organization
|
||||
|
||||
### Components Directory
|
||||
|
||||
Create reusable components specific to the feature:
|
||||
|
||||
```bash
|
||||
components/
|
||||
├── feature-card.svelte
|
||||
├── feature-loader.svelte
|
||||
├── feature-modal.svelte
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
### Components Index (`components/index.ts`)
|
||||
|
||||
```typescript
|
||||
export { default as FeatureCard } from './feature-card.svelte';
|
||||
export { default as FeatureLoader } from './feature-loader.svelte';
|
||||
export { default as FeatureModal } from './feature-modal.svelte';
|
||||
export { default as FeatureActionButton } from './feature-action-button.svelte';
|
||||
```
|
||||
|
||||
## Pages Index (`pages/index.ts`)
|
||||
|
||||
```typescript
|
||||
export { default as FeaturePage } from './feature.svelte';
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
### Route File: `routes/org/[slug]/community/+page.svelte`
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
<title>Community - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('community.title')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<AskCommunityButton />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CommunityPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
```
|
||||
|
||||
### Feature Page: `lib/features/community/pages/community.svelte`
|
||||
|
||||
```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 MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
||||
import { CommunityListLoader, AskCommunityButton } from '../components';
|
||||
// ... other imports
|
||||
|
||||
let isLoading = $state(false);
|
||||
let searchValue = $state('');
|
||||
let discussions = $state([]);
|
||||
|
||||
// ... logic
|
||||
</script>
|
||||
|
||||
<Page.BodyHeader>
|
||||
<Search placeholder="Search..." bind:value={searchValue} />
|
||||
<Select.Root>
|
||||
<!-- Select options -->
|
||||
</Select.Root>
|
||||
</Page.BodyHeader>
|
||||
|
||||
{#if isLoading}
|
||||
<CommunityListLoader />
|
||||
{:else}
|
||||
{#each discussions as discussion}
|
||||
<!-- Discussion content -->
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('community.no_question')}
|
||||
description={$t('community.ask_a_question')}
|
||||
icon={MessageSquareMoreIcon}
|
||||
>
|
||||
<AskCommunityButton />
|
||||
</Empty>
|
||||
{/each}
|
||||
{/if}
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
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 `Page.BodyHeader` for filters, `Empty` for empty states, `Search` for search inputs
|
||||
5. **State Management**: Keep state local to feature pages or use feature-specific stores
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
When refactoring an existing route:
|
||||
|
||||
- [ ] Create feature directory structure (`lib/features/[feature]/`)
|
||||
- [ ] Move business logic from route file to feature page
|
||||
- [ ] Extract components to `components/` directory
|
||||
- [ ] Create `components/index.ts` with exports
|
||||
- [ ] Create `pages/index.ts` with page export
|
||||
- [ ] Update route file to be minimal wrapper
|
||||
- [ ] Add `Page.BodyHeader` for filters/search if needed
|
||||
- [ ] Replace custom empty states with `Empty` component
|
||||
- [ ] Replace custom search inputs with `Search` component
|
||||
- [ ] Test all functionality works correctly
|
||||
Reference in New Issue
Block a user