feat: redo settings and all org pages. (#601)

* feat: new components, deploy storybook

* refactor: simplify component field definitions and enhance button loading state

- Removed unnecessary type imports and simplified the FIELDS array definitions across multiple components in Storybook.
- Updated button component to use a loading state instead of isLoading, improving clarity and consistency in props.
- Adjusted button stories to reflect the new loading prop and enhanced the button's loading visual feedback.
- Minor CSS adjustments for better layout and styling consistency.

* chore: add field, card, password components

* feat: improve page component & update AuthUI path

* chore: update bits-ui to version 2.14.4 and all org related UI's

- Updated bits-ui dependency in pnpm-lock.yaml and package.json to version 2.14.4.
- Refactored imports for RoleBasedSecurity and UpgradeBanner components to use the new ui feature directory.
- Removed unused Course components and related files to clean up the codebase.
- Enhanced various components to improve consistency and maintainability.

* chore: move all setting buttons to the top and update auth ui styles

* chore: remove svelte-loading-spinners and replace with @cio/ui/base/spinner

- Removed all instances of svelte-loading-spinners from the project.
- Updated components to use the new Spinner from @cio/ui/base/spinner.
- Cleaned up pnpm-lock.yaml and package.json to reflect the removal of svelte-loading-spinners.
- Added new CSS animations for rocket launch effects in app.css.
- Enhanced email change flow with updated verification messages in translations.

* chore: remove supabase file upload from dashboard and move to r2

* chore: update organization API and UI components

- Added new API methods for organization plan operations and team management.
- Introduced middleware for API key authentication and organization admin checks.
- Refactored organization-related components to utilize the new API structure.
- Updated various UI components to improve consistency and maintainability.
- Enhanced error handling and validation for organization-related actions.
- Removed deprecated services and streamlined the codebase.
This commit is contained in:
Rotimi Best
2025-12-02 09:54:38 +01:00
committed by GitHub
parent 531516a8f7
commit c7854efae3
350 changed files with 13928 additions and 3895 deletions
@@ -0,0 +1 @@
// Components exports will be added here when needed
@@ -0,0 +1,133 @@
<script lang="ts">
import * as Table from '@cio/ui/base/table';
import { page as pageStore } from '$app/state';
import { Skeleton } from '@cio/ui/base/skeleton';
import { Search } from '@cio/ui/custom/search';
import * as Pagination from '@cio/ui/base/pagination';
import UsersIcon from '@lucide/svelte/icons/users';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty';
import { UpgradeBanner } from '$lib/features/ui';
import { currentOrg, currentOrgMaxAudience } from '$lib/utils/store/org';
import Avatar from '$lib/components/Avatar/index.svelte';
import * as Page from '@cio/ui/base/page';
$effect(() => {
orgApi.getOrgAudience($currentOrg.id);
});
const headers = [
{ key: 'name', value: $t('audience.name') },
{ key: 'email', value: $t('audience.email') },
{ key: 'date_joined', value: $t('audience.date_joined') }
];
let pageSize = $state(5);
let currentPage = $state(1);
let searchValue = $state('');
const filteredRows = $derived(
searchValue
? orgApi.audience.filter((row) =>
Object.values(row).some((val) => String(val).toLowerCase().includes(searchValue.toLowerCase()))
)
: orgApi.audience
);
const totalPages = $derived(Math.ceil(filteredRows.length / pageSize));
const startIndex = $derived((currentPage - 1) * pageSize);
const endIndex = $derived(startIndex + pageSize);
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
$effect(() => {
if (filteredRows.length > 0 && currentPage > totalPages) {
currentPage = 1;
}
});
</script>
{#if orgApi.audience.length >= $currentOrgMaxAudience}
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
{/if}
<Page.BodyHeader>
<Search placeholder="Search..." bind:value={searchValue} class="" />
</Page.BodyHeader>
{#if orgApi.audience.length || orgApi.isLoading}
<div class="w-full space-y-4">
<!-- Table -->
{#if orgApi.isLoading}
<div class="space-y-2">
{#each Array(pageSize) as _}
<Skeleton class="h-12 w-full" />
{/each}
</div>
{:else}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each headers as header}
<Table.Head>{header.value}</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedRows as row}
<Table.Row>
<Table.Cell>
<a
href={`${pageStore.url.href}/${row.id}/${$currentOrg.id}`}
class="text-primary-700 flex items-center gap-2 hover:underline"
>
<Avatar src={row.avatar_url} width="w-5" height="h-5" />
{row.name}
</a>
</Table.Cell>
<Table.Cell>{row.email}</Table.Cell>
<Table.Cell>{row.date_joined}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
<!-- Pagination -->
<div class="flex items-center justify-between">
<Pagination.Root
count={filteredRows.length}
perPage={pageSize}
page={currentPage}
onPageChange={(page) => (currentPage = page)}
>
{#snippet children({ pages, currentPage: activePage })}
<Pagination.Content>
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={activePage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
</div>
{:else}
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
{/if}
@@ -0,0 +1,2 @@
export { default as AudiencePage } from './audience.svelte';
export { default as UserAnalyticsPage } from './user-analytics.svelte';
@@ -0,0 +1,206 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { Badge } from '@cio/ui/base/badge';
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
import Progress from '$lib/components/Progress/index.svelte';
import { ActivityCard, HeroProfileCard, LoadingPage } from '$lib/components/Analytics';
import { t } from '$lib/utils/functions/translations';
import { currentOrgPath } from '$lib/utils/store/org';
import { snackbar } from '$lib/components/Snackbar/store';
import { getAccessToken } from '$lib/utils/functions/supabase';
import type { UserAnalytics } from '$lib/utils/types/analytics';
let { data } = $props();
let userAnalytics: UserAnalytics | undefined = $state();
let courseFilter = $state('all');
function toggleCourseFilter(filter: 'completed' | 'incomplete') {
if (courseFilter === filter) {
courseFilter = 'all';
} else {
courseFilter = filter;
}
}
async function fetchUserAnalytics() {
const accessToken = await getAccessToken();
const response = await fetch('/api/analytics/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify(data)
});
if (!response.ok) {
console.error(response);
snackbar.error('Failed to fetch analytics data');
}
userAnalytics = (await response.json()) as UserAnalytics;
}
onMount(() => {
fetchUserAnalytics();
});
let userMetrics = $derived([
{
icon: BookOpenIcon,
title: $t('analytics.enrolled_courses'),
description: $t('analytics.enrolled_courses_description'),
percentage: userAnalytics?.courses?.length || 0,
hidePercentage: true
},
{
icon: ChartLineIcon,
title: $t('analytics.overall_course_progress'),
description: $t('analytics.overall_course_progress_description'),
percentage: userAnalytics?.overallCourseProgress || 0
},
{
icon: UnfoldVerticalIcon,
title: $t('analytics.total_average_grade'),
description: $t('analytics.total_average_grade_description'),
percentage: userAnalytics?.overallAverageGrade || 0
}
]);
let completedCourses = $derived(
userAnalytics?.courses?.filter((course) => course.lessons_count === course.lessons_completed)?.length
);
let incompleteCourses = $derived(
userAnalytics?.courses?.filter((course) => course.lessons_count !== course.lessons_completed)?.length
);
let filteredCourses = $derived(
userAnalytics?.courses?.filter((course) => {
if (courseFilter === 'all') {
return true;
}
return (course.lessons_count === course.lessons_completed) === (courseFilter === 'completed');
})
);
</script>
{#if userAnalytics}
<div class="p-5">
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/audience`}>
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
</div>
<div class="px-5 py-1">
<HeroProfileCard user={userAnalytics!.user} />
<div class="mt-5 px-0">
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each userMetrics as activity}
<ActivityCard {activity} />
{/each}
</div>
</div>
<div class="mt-5 rounded-md border p-3 md:p-5 dark:border-neutral-600">
<h3 class="text-2xl">
{$t('analytics.courses')}
</h3>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
{$t('analytics.progress')}
</p>
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
{$t('analytics.progress_description', {
value: completedCourses,
total: userAnalytics!.courses.length
})}
</p>
</div>
<Progress value={userAnalytics!.overallCourseProgress} />
<div class="flex items-center justify-between">
<Badge
type={courseFilter === 'incomplete' ? 'secondary' : 'outline'}
class="text-yellow-700 dark:text-yellow-500"
onclick={() => toggleCourseFilter('incomplete')}
>
{incompleteCourses}
{$t('analytics.incomplete')}
</Badge>
<Badge
type={courseFilter === 'completed' ? 'secondary' : 'outline'}
class="text-green-700 dark:text-green-500"
onclick={() => toggleCourseFilter('completed')}
>
{completedCourses}
{$t('analytics.complete')}
</Badge>
</div>
</div>
{#each filteredCourses as course, index}
{#key index}
<div
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
course.lessons_count === course.lessons_completed
? 'border-green-200 bg-green-50 dark:bg-green-100'
: 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100'
}`}
transition:fade={{ duration: 300 }}
>
<div class="flex items-center justify-between gap-4">
<div class="flex w-4/5 items-center gap-4">
<img
src={course.logo || '/images/classroomio-course-img-template.jpg'}
alt={course.title}
class="h-20 w-24 rounded-md"
/>
<div class="mb-4 gap-4">
<a href={`/courses/${course.id}`}>
<p class="text-lg font-semibold text-gray-600">
{course.title}
</p>
</a>
<p class="line-clamp-2 text-sm text-gray-600">
{course.description}
</p>
</div>
</div>
<Badge
class={`${
course.lessons_count === course.lessons_completed
? 'bg-green-200 text-green-700'
: 'bg-yellow-200 text-yellow-700'
}`}
>
{course.lessons_count === course.lessons_completed
? $t('analytics.completed')
: $t('analytics.incomplete')}
</Badge>
</div>
<div class="flex w-full items-center gap-1">
<Progress value={course.progress_percentage} />
<p>{course.progress_percentage}%</p>
</div>
</div>
{/key}
{/each}
</div>
</div>
{:else}
<LoadingPage />
{/if}