Files
saladeaula.digital/apps/saladeaula.digital/app/routes/index.tsx

279 lines
7.2 KiB
TypeScript

import type { Route } from './+types'
import Fuse from 'fuse.js'
import {
BanIcon,
CircleCheckIcon,
CircleIcon,
CircleOffIcon,
CirclePlusIcon,
CircleXIcon,
EllipsisIcon,
FileBadgeIcon,
TimerIcon,
type LucideIcon
} from 'lucide-react'
import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
import { Suspense, useMemo } from 'react'
import { Await, NavLink, useSearchParams } from 'react-router'
import type { User } from '@repo/auth/auth'
import { userContext } from '@repo/auth/context'
import { FacetedFilter } from '@repo/ui/components/faceted-filter'
import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@repo/ui/components/ui/dropdown-menu'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from '@repo/ui/components/ui/empty'
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
CardAction
} from '@repo/ui/components/ui/card'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Progress } from '@repo/ui/components/ui/progress'
import { createSearch } from '@repo/util/meili'
import placeholder from '@/assets/placeholder.webp'
import { Container } from '@/components/container'
import { Button } from '@repo/ui/components/ui/button'
type Course = {
name: string
scormset?: string
}
export type Enrollment = {
id: string
course: Course
status: number
progress: number
}
export function meta({}: Route.MetaArgs) {
return [{ title: 'Meus cursos' }]
}
export async function loader({ request, context }: Route.ActionArgs) {
const user = context.get(userContext) as User
const { searchParams } = new URL(request.url)
const status = searchParams.getAll('status') || []
let builder = new MeiliSearchFilterBuilder().where('user.id', '=', user.sub)
if (status.length) {
builder = builder.where('status', 'in', status)
}
const enrollments = createSearch({
index: 'betaeducacao-prod-enrollments',
filter: builder.build(),
sort: ['created_at:desc'],
hitsPerPage: 100,
env: context.cloudflare.env
})
return {
data: enrollments
}
}
export default function Component({
loaderData: { data }
}: Route.ComponentProps) {
const [searchParams, setSearchParams] = useSearchParams()
const term = searchParams.get('term') as string
return (
<Container className="space-y-4">
<Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">Meus cursos</h1>
<p className="text-muted-foreground">
Aqui você encontra todos os cursos em que está matriculado,
organizados em um lugar. Acompanhe seu progresso e continue de
onde parou.
</p>
</div>
<div className="flex gap-2.5">
<div className="w-full xl:w-1/3">
<SearchForm
defaultValue={term || ''}
placeholder={
<>
Digite <Kbd>/</Kbd> para pesquisar
</>
}
onChange={(value) =>
setSearchParams((searchParams) => {
searchParams.set('term', String(value))
return searchParams
})
}
/>
</div>
<FacetedFilter
icon={CirclePlusIcon}
value={searchParams.getAll('status')}
onChange={(statuses) => {
setSearchParams((searchParams) => {
searchParams.delete('status')
if (statuses.length) {
statuses.forEach((s) =>
searchParams.has('status', s)
? null
: searchParams.append('status', s)
)
}
return searchParams
})
}}
title="Status"
options={Object.entries(statuses).map(([key, value]) => ({
value: key,
...value
}))}
/>
</div>
<Await resolve={data}>
{({ hits = [] }) => <List term={term} hits={hits as Enrollment[]} />}
</Await>
</Suspense>
</Container>
)
}
function List({ term, hits = [] }: { term: string; hits: Enrollment[] }) {
const fuse = useMemo(() => {
return new Fuse(hits, {
keys: ['course.name'],
threshold: 0.3,
includeMatches: true
})
}, [hits])
const hits_ = useMemo(() => {
if (!term) {
return hits
}
return fuse.search(term).map(({ item }) => item)
}, [term, fuse, hits])
if (hits_.length === 0) {
return (
<Empty className="border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<BanIcon />
</EmptyMedia>
<EmptyTitle>Nada encontrado</EmptyTitle>
<EmptyDescription>
Nenhum resultado para <mark>{term}</mark>.
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
return (
<div className="grid lg:grid-cols-4 gap-5">
{hits_.map((props: Enrollment, idx) => {
return <Enrollment key={idx} {...props} />
})}
</div>
)
}
function Enrollment({ id, course, progress }: Enrollment) {
return (
<NavLink
to={course?.scormset ? `/player/${id}` : '/konviva'}
className="hover:scale-105 transition"
>
<Card className="overflow-hidden relative h-96">
<CardHeader className="z-1 relative">
<CardTitle className="text-xl/6">{course.name}</CardTitle>
<CardAction>
<ActionMenu />
</CardAction>
</CardHeader>
<CardFooter className="absolute z-1 bottom-6 w-full flex gap-1.5">
<Progress value={progress} />
<span className="text-xs">{progress}%</span>
</CardFooter>
<img
src={placeholder}
alt={course.name}
className="absolute bottom-0 opacity-75"
/>
</Card>
</NavLink>
)
}
function ActionMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" className="cursor-pointer">
<EllipsisIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={true}>
<FileBadgeIcon /> Baixar certificado
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
const statuses: Record<
string,
{ icon: LucideIcon; color?: string; label: string }
> = {
PENDING: {
icon: CircleIcon,
label: 'Não iniciado'
},
IN_PROGRESS: {
icon: TimerIcon,
color: 'text-blue-400 [&_svg]:text-blue-500',
label: 'Em andamento'
},
COMPLETED: {
icon: CircleCheckIcon,
color: 'text-green-400 [&_svg]:text-background [&_svg]:fill-green-500',
label: 'Concluído'
},
FAILED: {
icon: CircleXIcon,
color: 'text-red-400 [&_svg]:text-red-500',
label: 'Reprovado'
},
CANCELED: {
icon: CircleOffIcon,
color: 'text-orange-400 [&_svg]:text-orange-500',
label: 'Cancelado'
}
}