This commit is contained in:
2025-11-17 14:37:50 -03:00
parent d2abaec021
commit 7f41704d90
51 changed files with 733 additions and 495 deletions

View File

@@ -64,7 +64,7 @@ function SidebarMenuItemLink({ title, url, icon: Icon }: NavItem) {
return (
<SidebarMenuItem key={title} onClick={onToggle}>
<NavLink to={`/${orgid}${url}`}>
{({ isActive }) => (
{({ isActive, isPending }) => (
<SidebarMenuButton
asChild
className="data-[active=true]:text-lime-500"
@@ -72,7 +72,7 @@ function SidebarMenuItemLink({ title, url, icon: Icon }: NavItem) {
tooltip={title}
>
<span>
{Icon && <Icon />}
{Icon ? <Icon /> : null}
<span>{title}</span>
</span>
</SidebarMenuButton>

View File

@@ -1,34 +0,0 @@
import { Meilisearch, type SearchResponse } from 'meilisearch'
const MAX_HITS_PER_PAGE = 100
export async function createSearch({
query,
filter = undefined,
index,
page,
hitsPerPage,
sort,
env
}: {
query?: string
filter?: string
index: string
page?: number
hitsPerPage: number
sort: string[]
env: Env
}): Promise<SearchResponse> {
const host = env.MEILI_HOST
const apiKey = env.MEILI_API_KEY
const client = new Meilisearch({ host, apiKey })
const index_ = client.index(index)
return index_.search(query, {
sort,
filter,
page,
hitsPerPage:
hitsPerPage > MAX_HITS_PER_PAGE ? MAX_HITS_PER_PAGE : hitsPerPage
})
}

View File

@@ -1,49 +0,0 @@
import type { User } from '@repo/auth/auth'
import { requestIdContext, userContext } from '@repo/auth/context'
import type { LoaderFunctionArgs } from 'react-router'
export enum HttpMethod {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
PATCH = 'PATCH',
DELETE = 'DELETE'
}
type RequestArgs = {
url: string
method?: HttpMethod
headers?: HeadersInit
body?: BodyInit | null
request: LoaderFunctionArgs['request']
context: LoaderFunctionArgs['context']
}
export function request({
url,
method = HttpMethod.GET,
body = null,
headers: _headers = {},
request: { signal },
context
}: RequestArgs): Promise<Response> {
const requestId = context.get(requestIdContext) as string
const user = context.get(userContext) as User
const url_ = new URL(url, context.cloudflare.env.API_URL)
const headers = new Headers({
Authorization: `Bearer ${user.accessToken}`
})
if (_headers instanceof Headers) {
_headers.forEach((value, key) => headers.set(key, value))
} else {
Object.entries(_headers).forEach(([key, value]) => headers.set(key, value))
}
console.log(
`[${new Date().toISOString()}] [${requestId}] ${method} ${url_.toString()}`
)
return fetch(url_.toString(), { method, headers, body, signal })
}

View File

@@ -11,7 +11,7 @@ import { Await, NavLink, useParams, useRevalidator } from 'react-router'
import { toast } from 'sonner'
import { Abbr } from '@/components/abbr'
import { request as req } from '@/lib/request'
import { Skeleton } from '@repo/ui/components/skeleton'
import {
AlertDialog,
@@ -34,6 +34,7 @@ import {
} from '@repo/ui/components/ui/dropdown-menu'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { initials } from '@repo/ui/lib/utils'
import { request as req } from '@repo/util/request'
type Admin = {
sk: string

View File

@@ -6,8 +6,6 @@ import { Suspense, useMemo } from 'react'
import { Await, useSearchParams } from 'react-router'
import placeholder from '@/assets/placeholder.webp'
import { createSearch } from '@/lib/meili'
import { request as req } from '@/lib/request'
import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton'
@@ -26,6 +24,8 @@ import {
} from '@repo/ui/components/ui/empty'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { cn } from '@repo/ui/lib/utils'
import { createSearch } from '@repo/util/meili'
import { request as req } from '@repo/util/request'
type Cert = {
exp_interval: number

View File

@@ -18,7 +18,6 @@ import * as XLSX from 'xlsx'
import { DataTable, DataTableViewOptions } from '@/components/data-table'
import { RangeCalendarFilter } from '@/components/range-calendar-filter'
import { createSearch } from '@/lib/meili'
import { FacetedFilter } from '@repo/ui/components/faceted-filter'
import { SearchForm } from '@repo/ui/components/search-form'
@@ -32,6 +31,7 @@ import {
DropdownMenuTrigger
} from '@repo/ui/components/ui/dropdown-menu'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { createSearch } from '@repo/util/meili'
import { columns, type Enrollment } from './columns'
import { headers, sortings, statuses } from './data'

View File

@@ -4,8 +4,9 @@ import { Suspense } from 'react'
import { Await } from 'react-router'
import { DataTable } from '@/components/data-table'
import { createSearch } from '@/lib/meili'
import { Skeleton } from '@repo/ui/components/skeleton'
import { createSearch } from '@repo/util/meili'
import { columns, type Order } from './columns'
export function meta({}: Route.MetaArgs) {

View File

@@ -1,11 +1,11 @@
import type { Route } from './+types'
import { Suspense } from 'react'
import { request as req } from '@/lib/request'
import { Skeleton } from '@repo/ui/components/skeleton'
import { Await } from 'react-router'
import { Skeleton } from '@repo/ui/components/skeleton'
import { request as req } from '@repo/util/request'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Matrículas agendadas' }]
}

View File

@@ -3,8 +3,6 @@ import type { Route } from './+types'
import { Suspense } from 'react'
import { Await, useOutletContext } from 'react-router'
import { request as req } from '@/lib/request'
import { Skeleton } from '@repo/ui/components/skeleton'
import {
Card,
@@ -18,6 +16,7 @@ import {
NativeSelect,
NativeSelectOption
} from '@repo/ui/components/ui/native-select'
import { request as req } from '@repo/util/request'
import { Button } from '@repo/ui/components/ui/button'
import type { User } from '../_.$orgid.users.$id/route'
@@ -64,9 +63,13 @@ export default function Route({ loaderData: { data } }) {
<CardHeader>
<CardTitle className="text-lg">Email principal</CardTitle>
<CardDescription>
<Kbd className="font-mono border">{user.email}</Kbd> será
usado para mensagens e pode ser usado para redefinições de
senha.
<Kbd className="font-mono border">
<span className="truncate max-lg:max-w-62">
{user.email}
</span>
</Kbd>{' '}
será usado para mensagens e pode ser usado para redefinições
de senha.
</CardDescription>
</CardHeader>
<CardContent>

View File

@@ -7,8 +7,6 @@ import {
type ShouldRevalidateFunctionArgs
} from 'react-router'
import { request as req } from '@/lib/request'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import {
Breadcrumb,
@@ -20,6 +18,7 @@ import {
} from '@repo/ui/components/ui/breadcrumb'
import { Tabs, TabsList, TabsTrigger } from '@repo/ui/components/ui/tabs'
import { initials } from '@repo/ui/lib/utils'
import { request as req } from '@repo/util/request'
export function meta() {
return [
@@ -92,7 +91,9 @@ export default function Route({
<ul>
<li className="font-bold text-lg">{user.name}</li>
<li className="text-muted-foreground text-sm">{user.email}</li>
<li className="text-muted-foreground text-sm truncate max-lg:max-w-62">
{user.email}
</li>
</ul>
</div>

View File

@@ -5,13 +5,13 @@ import { Suspense } from 'react'
import { Await, Link, useSearchParams } from 'react-router'
import { DataTable } from '@/components/data-table'
import { createSearch } from '@/lib/meili'
import { columns, type User } from './columns'
import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton'
import { Button } from '@repo/ui/components/ui/button'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { createSearch } from '@repo/util/meili'
export function meta({}: Route.MetaArgs) {
return [

View File

@@ -43,7 +43,7 @@ import { Input } from '@repo/ui/components/ui/input'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { useWorksapce } from '@/components/workspace-switcher'
import { HttpMethod, request as req } from '@/lib/request'
import { HttpMethod, request as req } from '@repo/util/request'
import { useEffect } from 'react'
const isName = (name: string) => name && name.includes(' ')

View File

@@ -4,7 +4,6 @@ import * as cookie from 'cookie'
import { Outlet, type ShouldRevalidateFunctionArgs } from 'react-router'
import { AppSidebar } from '@/components/app-sidebar'
import { request as req } from '@/lib/request'
import { WorkspaceProvider } from '@/components/workspace-switcher'
import { userContext } from '@repo/auth/context'
@@ -17,6 +16,7 @@ import {
SidebarTrigger
} from '@repo/ui/components/ui/sidebar'
import { Toaster } from '@repo/ui/components/ui/sonner'
import { request as req } from '@repo/util/request'
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]

View File

@@ -2,9 +2,9 @@ import type { Route } from './+types'
import { redirect } from 'react-router'
import { request as req } from '@/lib/request'
import { userContext } from '@repo/auth/context'
import { authMiddleware } from '@repo/auth/middleware/auth'
import { request as req } from '@repo/util/request'
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]

View File

@@ -15,6 +15,7 @@
"@react-router/fs-routes": "^7.9.5",
"@repo/auth": "*",
"@repo/ui": "*",
"@repo/util": "^0.0.0",
"@tanstack/react-table": "^8.21.3",
"cookie": "^1.0.2",
"date-fns": "^4.1.0",

View File

@@ -1,7 +1,8 @@
import { type Scorm12API } from 'scorm-again/scorm12'
import type { Scorm12API, Scorm2004API } from 'scorm-again'
declare global {
interface Window {
API: Scorm12API
API_1484_11: Scorm2004API
}
}

View File

@@ -7,7 +7,7 @@ type ContainerProps = {
export function Container({ children, className }: ContainerProps) {
return (
<main className="px-4">
<main className="p-4">
<div className={cn('container mx-auto', className)}>{children}</div>
</main>
)

View File

@@ -1,150 +0,0 @@
import { Check, PlusCircle } from 'lucide-react'
import { useState } from 'react'
import { Badge } from '@repo/ui/components/ui/badge'
import { Button } from '@repo/ui/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
} from '@repo/ui/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@repo/ui/components/ui/popover'
import { Separator } from '@repo/ui/components/ui/separator'
import { cn } from '@/lib/utils'
interface FacetedFilterProps<TData, TValue> {
value?: string[]
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
onChange?: (values: string[]) => void
}
export function FacetedFilter<TData, TValue>({
value = [],
title,
options,
onChange
}: FacetedFilterProps<TData, TValue>) {
const [selectedValues, setSelectedValues] = useState(new Set(value))
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 border-dashed cursor-pointer"
>
<PlusCircle />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden gap-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selecionados
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>Nenhum resultado encontrado.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<CommandItem
className="cursor-pointer"
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
setSelectedValues(selectedValues)
onChange?.(Array.from(selectedValues))
}}
>
<div
className={cn(
'flex size-4 items-center justify-center rounded-[4px] border',
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'border-input [&_svg]:invisible'
)}
>
<Check className="text-primary-foreground size-3.5" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground size-4" />
)}
<span>{option.label}</span>
</CommandItem>
)
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
setSelectedValues(new Set())
onChange?.([])
}}
className="justify-center text-center"
>
Limpar filtros
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,42 +1,56 @@
'use client'
import { useLocalStorage } from '@/hooks/useLocalStorage'
import SHA256 from 'crypto-js/sha256'
import { omit } from 'ramda'
import { useEffect, useRef, useState } from 'react'
import { Scorm12API } from 'scorm-again/scorm12'
import { Scorm12API, Scorm2004API } from 'scorm-again'
const settings = {
autocommit: true
// mastery_override: true
// logLevel: 2,
export type ScormVersion = '1.2' | '2004'
export type ScormPlayerProps = {
settings: Record<string, any>
scormVersion: ScormVersion
scormState: Record<string, any>
scormContentPath: string
className?: string
onCommit?: (value: any) => void
setValue?: (element: string, value: any) => void
}
// https://scorm.com/scorm-explained/technical-scorm/run-time/run-time-reference
export function ScormPlayer({
settings,
scormVersion,
scormState,
scormContentPath,
className
}: {
scormState: object
scormContentPath: string
className: string
}) {
className,
onCommit
}: ScormPlayerProps) {
const [iframeLoaded, setIframeLoaded] = useState(false)
const scormApiRef = useRef<Scorm12API | null>(null)
const hash = SHA256(scormContentPath).toString()
const [_, setScormState] = useLocalStorage(`scormState.${hash}`, {})
const scormApiRef = useRef<Scorm12API | Scorm2004API | null>(null)
useEffect(() => {
const scormApi = new Scorm12API(settings)
scormApi.loadFromFlattenedJSON(scormState)
scormApi.on('LMSCommit', function () {
console.log('Committed')
setScormState(scormApi.renderCommitCMI(true))
})
const cls = scormVersion === '1.2' ? Scorm12API : Scorm2004API
const scormApi = new cls(settings)
scormApi.loadFromJSON(omit(['interactions'], scormState))
// scormApi.loadFromFlattenedJSON(scormState)
scormApiRef.current = scormApi
window.API = scormApi
if (scormApi instanceof Scorm12API) {
window.API = scormApi
scormApi.on('LMSCommit', function () {
onCommit?.(scormApi.renderCommitCMI(true))
})
}
if (scormApi instanceof Scorm2004API) {
window.API_1484_11 = scormApi
scormApi.on('Commit', function () {
onCommit?.(scormApi.renderCommitCMI(true))
})
}
setIframeLoaded(true)
return () => {
@@ -54,21 +68,30 @@ export function ScormPlayer({
function unload() {
if (unloaded || scormApi.isTerminated()) {
return
return false
}
if (scormApi instanceof Scorm12API) {
scormApi.LMSSetValue('cmi.core.exit', 'suspend')
scormApi.LMSCommit()
scormApi.LMSFinish()
}
if (scormApi instanceof Scorm2004API) {
scormApi.SetValue('cmi.exit', 'suspend')
scormApi.Commit()
scormApi.Terminate()
}
scormApi.LMSSetValue('cmi.core.exit', 'suspend')
scormApi.LMSCommit()
scormApi.LMSFinish()
unloaded = true
}
window.addEventListener('beforeunload', unload)
window.addEventListener('unload', unload)
window.addEventListener('pagehide', unload)
return () => {
window.removeEventListener('beforeunload', unload)
window.removeEventListener('unload', unload)
window.removeEventListener('pagehide', unload)
}
}, [])

View File

@@ -0,0 +1,71 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Scorm12API } from 'scorm-again/scorm12'
import { settings, type ScormPlayerProps } from './scorm-player'
// https://scorm.com/scorm-explained/technical-scorm/run-time/run-time-reference/#section-2
export function Scorm12Player({
scormState,
scormContentPath,
className,
onCommit,
setValue
}: ScormPlayerProps) {
const [iframeLoaded, setIframeLoaded] = useState(false)
const scormApiRef = useRef<Scorm12API | null>(null)
useEffect(() => {
const scormApi = new Scorm12API(settings)
scormApi.loadFromFlattenedJSON(scormState)
scormApi.on('LMSCommit', function () {
onCommit?.(scormApi.renderCommitCMI(true))
})
scormApi.on('LMSSetValue.*', function (element: any, value: any) {
setValue?.(element, value)
})
scormApiRef.current = scormApi
window.API = scormApi
setIframeLoaded(true)
return () => {
scormApiRef.current = null
}
}, [])
useEffect(() => {
if (!scormApiRef.current) {
return
}
const scormApi = scormApiRef.current
let unloaded = false
function unload() {
if (unloaded || scormApi.isTerminated()) {
return false
}
scormApi.LMSSetValue('cmi.core.exit', 'suspend')
scormApi.LMSCommit()
scormApi.LMSFinish()
unloaded = true
}
window.addEventListener('beforeunload', unload)
window.addEventListener('unload', unload)
return () => {
window.removeEventListener('beforeunload', unload)
window.removeEventListener('unload', unload)
}
}, [])
if (iframeLoaded) {
return <iframe src={`/proxy/${scormContentPath}`} className={className} />
}
}

View File

@@ -0,0 +1,70 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Scorm2004API } from 'scorm-again/scorm2004'
import { settings, type ScormPlayerProps } from './scorm-player'
export function Scorm2004Player({
scormState,
scormContentPath,
className,
onCommit,
setValue
}: ScormPlayerProps) {
const [iframeLoaded, setIframeLoaded] = useState(false)
const scormApiRef = useRef<Scorm2004API | null>(null)
useEffect(() => {
const scormApi = new Scorm2004API(settings)
scormApi.loadFromFlattenedJSON(scormState)
scormApi.on('LMSCommit', function () {
onCommit?.(scormApi.renderCommitCMI(true))
})
scormApi.on('LMSSetValue.*', function (element: any, value: any) {
setValue?.(element, value)
})
scormApiRef.current = scormApi
window.API_1484_11 = scormApi
setIframeLoaded(true)
return () => {
scormApiRef.current = null
}
}, [])
useEffect(() => {
if (!scormApiRef.current) {
return
}
const scormApi = scormApiRef.current
let unloaded = false
function unload() {
if (unloaded || scormApi.isTerminated()) {
return false
}
scormApi.SetValue('cmi.exit', 'suspend')
scormApi.Commit()
scormApi.Terminate()
unloaded = true
}
window.addEventListener('beforeunload', unload)
window.addEventListener('unload', unload)
return () => {
window.removeEventListener('beforeunload', unload)
window.removeEventListener('unload', unload)
}
}, [])
if (iframeLoaded) {
return <iframe src={`/proxy/${scormContentPath}`} className={className} />
}
}

View File

@@ -1,40 +0,0 @@
import { useKeyPress } from '@/hooks/use-keypress'
import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from '@repo/ui/components/ui/input-group'
import clsx from 'clsx'
import { debounce } from 'lodash'
import { SearchIcon } from 'lucide-react'
import { useRef } from 'react'
export function SearchForm({
className,
onChange,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
const inputRef = useRef<HTMLInputElement>(null)
useKeyPress('/', () => {
inputRef.current?.focus()
})
return (
<InputGroup className="group">
<InputGroupInput
className={clsx('peer', className)}
placeholder=" "
ref={inputRef}
onChange={debounce(onChange, 200)}
{...props}
/>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupAddon className="font-normal hidden peer-focus-within:hidden peer-placeholder-shown:block">
Filtar curso por nome
</InputGroupAddon>
</InputGroup>
)
}

View File

@@ -1,4 +0,0 @@
import type { User } from '@/middleware/auth'
import { createContext } from 'react-router'
export const userContext = createContext<User | null>(null)

View File

@@ -1,20 +0,0 @@
import { useEffect, useState } from 'react'
export function useLocalStorage<T>(key: string, defaultValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const stored = window.localStorage.getItem(key)
return stored !== null ? JSON.parse(stored) : defaultValue
} catch {
return defaultValue
}
})
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch {}
}, [key, value])
return [value, setValue] as const
}

View File

@@ -1,28 +0,0 @@
import { Meilisearch, type SearchResponse } from 'meilisearch'
export async function createSearch({
query,
filter,
index,
sort,
limit = 100,
env
}: {
query?: string | null
filter?: string | null
index: string
sort: string[]
limit?: number
env: Env
}): Promise<SearchResponse> {
const host = env.MEILI_HOST
const apiKey = env.MEILI_API_KEY
const client = new Meilisearch({ host, apiKey })
const index_ = client.index(index)
return index_.search(query, {
sort,
limit,
filter: filter ?? undefined
})
}

View File

@@ -1,34 +0,0 @@
import { userContext } from '@repo/auth/context'
import type { LoaderFunctionArgs } from 'react-router'
enum Method {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
PATCH = 'PATCH',
DELETE = 'DELETE'
}
export function request({
url,
method = Method.GET,
request,
context
}: {
url: string
method?: Method
request: LoaderFunctionArgs['request']
context: LoaderFunctionArgs['context']
}): Promise<Response> {
const user = context.get(userContext)
// @ts-ignore
const headers = new Headers({ Authorization: `Bearer ${user.accessToken}` })
// @ts-ignore
const endpoint = new URL(url, context.cloudflare.env.API_URL)
return fetch(endpoint.toString(), {
method,
headers,
signal: request.signal
})
}

View File

@@ -11,7 +11,8 @@ export default [
route('certs', 'routes/certs.tsx'),
route('payments', 'routes/payments.tsx'),
route('settings', 'routes/settings.tsx'),
route('player/:course', 'routes/player.tsx'),
route('konviva', 'routes/konviva.ts'),
route('player/:id', 'routes/player.tsx'),
route('proxy/*', 'routes/proxy.tsx')
]),
route('logout', 'routes/auth/logout.ts'),

View File

@@ -1,7 +1,7 @@
import type { Route } from './+types'
import { userContext } from '@/context'
import type { User } from '@/lib/auth'
import type { User } from '@repo/auth/auth'
import { userContext } from '@repo/auth/context'
export const loader = proxy
export const action = proxy

View File

@@ -1,6 +1,5 @@
import type { Route } from './+types'
// import SHA256 from 'crypto-js/sha256'
import {
Empty,
EmptyDescription,
@@ -8,6 +7,7 @@ import {
EmptyMedia,
EmptyTitle
} from '@repo/ui/components/ui/empty'
import Fuse from 'fuse.js'
import {
BanIcon,
CircleCheckIcon,
@@ -18,18 +18,16 @@ import {
TimerIcon,
type LucideIcon
} from 'lucide-react'
// import lzwCompress from 'lzwcompress'
import Fuse from 'fuse.js'
import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
import { Suspense, useMemo } from 'react'
import { Await, useSearchParams } from 'react-router'
import { Await, NavLink, useSearchParams } from 'react-router'
import placeholder from '@/assets/placeholder.webp'
import { createSearch } from '@/lib/meili'
import { Container } from '@/components/container'
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'
@@ -42,27 +40,11 @@ import {
} from '@repo/ui/components/ui/card'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Progress } from '@repo/ui/components/ui/progress'
export const data = [
{
id: 'fbad867a-0022-4605-814f-db8ebe2b17fb',
courseName: 'All Golf',
scormContentPath: 'all-golf-scorm12/shared/launchpage.html'
},
{
id: '5ece4b81-2243-4289-9394-b8d853ed0933',
courseName: 'CIPA',
scormContentPath: 'cipa-pt-1-scorm12/scormdriver/indexAPI.html'
},
{
id: '11ed8481-c6c7-4523-a856-6f7e8bfef022',
courseName: 'NR-18 Sinaleiro e Amarrador de Cargas para Içamento',
scormContentPath: 'nr-18-sinaleiro-pt-1-scorm12/scormdriver/indexAPI.html'
}
]
import { createSearch } from '@repo/util/meili'
type Course = {
name: string
scormset?: string
}
export type Enrollment = {
@@ -86,13 +68,16 @@ export const loader = async ({ request, context }: Route.ActionArgs) => {
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: createSearch({
index: 'betaeducacao-prod-enrollments',
filter: builder.build(),
sort: ['created_at:desc'],
env: context.cloudflare.env
})
data: enrollments
}
}
@@ -207,50 +192,18 @@ function List({ term, hits = [] }: { term: string; hits: Enrollment[] }) {
})
}
function Enrollment({
id,
course,
status,
progress
// scormContentPath,
// courseName
}: Enrollment) {
// const status_ = statusTranslate[status] ?? status
// const { icon: Icon, color } = statusIcon?.[status] ?? defaultIcon
// const [mounted, setMounted] = useState(false)
// const [progress, setProgress] = useState<number>(0)
// useEffect(() => {
// setMounted(true)
// const hash = SHA256(scormContentPath).toString()
// const stored = localStorage.getItem(`scormState.${hash}`)
// if (stored) {
// try {
// const scormState = JSON.parse(stored)
// const suspendData = JSON.parse(scormState?.cmi?.suspend_data || '{}')
// const d = lzwCompress.unpack(suspendData?.d)
// const data = JSON.parse(d || '{}')
// setProgress(data?.progress?.p ?? null)
// } catch (err) {
// console.warn('Erro ao carregar progresso:', err)
// }
// }
// }, [scormContentPath])
function Enrollment({ id, course, progress }: Enrollment) {
return (
<a href={`/player/${id}`} className="hover:scale-105 transition">
<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>
</CardHeader>
<CardContent className="z-1">
{/* Você pode adicionar algo como tempo total aqui, se quiser */}
</CardContent>
<CardContent className="z-1"></CardContent>
<CardFooter className="absolute z-1 bottom-6 w-full flex gap-1.5">
{/* <Badge variant="secondary" className={color}>
<Icon className="stroke-2" />
{status_}
</Badge>*/}
<Progress value={progress} />
<span className="text-xs">{progress}%</span>
</CardFooter>
@@ -260,7 +213,7 @@ function Enrollment({
className="absolute bottom-0 opacity-75"
/>
</Card>
</a>
</NavLink>
)
}
@@ -280,7 +233,7 @@ const statuses: Record<
COMPLETED: {
icon: CircleCheckIcon,
color: 'text-green-400 [&_svg]:text-background [&_svg]:fill-green-500',
label: 'Aprovado'
label: 'Concluído'
},
FAILED: {
icon: CircleXIcon,

View File

@@ -0,0 +1,6 @@
import { redirect } from 'react-router'
import type { Route } from './+types'
export async function loader({ params, context }: Route.LoaderArgs) {
return redirect('https://lms.saladeaula.digital')
}

View File

@@ -57,7 +57,7 @@ export default function Component({ loaderData }: Route.ComponentProps) {
>
<div className="container mx-auto flex items-center">
{/* Desktop Menu */}
<div className="hidden lg:flex gap-8">
<div className="hidden lg:flex items-center gap-8">
<Link to="/">
<ThemedImage />
</Link>

View File

@@ -1,7 +1,10 @@
import type { Route } from './+types'
import { Link } from 'react-router'
import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
import { Await, Link } from 'react-router'
import { type User } from '@repo/auth/auth'
import { userContext } from '@repo/auth/context'
import {
Breadcrumb,
BreadcrumbItem,
@@ -10,39 +13,97 @@ import {
BreadcrumbPage,
BreadcrumbSeparator
} from '@repo/ui/components/ui/breadcrumb'
import { createSearch } from '@repo/util/meili'
import { Container } from '@/components/container'
import { Skeleton } from '@repo/ui/components/skeleton'
import { Card, CardContent } from '@repo/ui/components/ui/card'
import { Suspense } from 'react'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Histórico de compras' }]
}
export default function Component() {
export const loader = async ({ 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 payments = createSearch({
index: 'betaeducacao-prod-orders',
filter: builder.build(),
sort: ['create_date:desc'],
env: context.cloudflare.env
})
return {
data: payments
}
}
export default function Component({ loaderData: { data } }) {
return (
<Container className="space-y-2.5">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="..">Meus cursos</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Histórico de compras</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<Suspense fallback={<Skeleton />}>
<Await resolve={data}>
{({ hits }) => (
<>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="..">Meus cursos</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Histórico de compras</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="space-y-0.5">
<h1 className="text-2xl font-bold tracking-tight">
Histórico de compras
</h1>
<p className="text-muted-foreground">
Acompanhe todos as compras realizadas, visualize pagamentos e mantenha
o controle financeiro.
</p>
</div>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">
Histórico de compras
</h1>
<p className="text-muted-foreground">
Acompanhe todos as compras realizadas, visualize pagamentos e
mantenha o controle financeiro.
</p>
</div>
<Card>
<CardContent>
<table className="table-fixed">
<thead>
<tr>
<th>Forma de pag.</th>
<th>Status</th>
<th>Total</th>
<th>Comprador em</th>
<th>Vencimento em</th>
</tr>
{hits.map(({ payment_method, status }) => {
return (
<tr>
<td>{payment_method}</td>
<td>{status}</td>
</tr>
)
})}
</thead>
</table>
</CardContent>
</Card>
</>
)}
</Await>
</Suspense>
</Container>
)
}

View File

@@ -1,29 +1,87 @@
import type { Route } from './+types'
import { ScormPlayer } from '@/components/scorm-player'
import { useLocalStorage } from '@/hooks/useLocalStorage'
import SHA256 from 'crypto-js/sha256'
import lzwCompress from 'lzwcompress'
import { useBlocker, useFetcher } from 'react-router'
import { HttpMethod, request as req } from '@repo/util/request'
import { ScormPlayer, type ScormVersion } from '@/components/scorm-player'
// import { useLocalStorage } from '@/hooks/useLocalStorage'
// import SHA256 from 'crypto-js/sha256'
export function meta({ params }: Route.MetaArgs) {
return [{ title: '' }]
}
export default function Route({ params }: Route.ComponentProps) {
export const loader = async ({
params,
request,
context
}: Route.ActionArgs) => {
const { id } = params
const r = await req({
url: `/enrollments/${id}/scorm`,
request,
context
})
return { data: await r.json() }
}
export async function action({ params, request, context }: Route.ActionArgs) {
const { id } = params
const body = JSON.stringify(await request.json())
console.log(body)
await req({
url: `/enrollments/${id}`,
method: HttpMethod.POST,
body,
headers: new Headers({ 'Content-Type': 'application/json' }),
request,
context
})
console.log(body)
return {}
}
export default function Route({ loaderData: { data } }: Route.ComponentProps) {
const fetcher = useFetcher()
const course = {
id: 'fbad867a-0022-4605-814f-db8ebe2b17fb',
courseName: 'All Golf',
// courseName: 'All Golf',
scormContentPath:
'nr-33-espacos-confinados-conteudo-de-demonstracao-scorm12/scormdriver/indexAPI.html'
// 'nr-33-espacos-confinados-conteudo-de-demonstracao-scorm12/scormdriver/indexAPI.html'
// 'cipa-pt-1-scorm12/scormdriver/indexAPI.html'
'test-scorm2004_4/scormdriver/indexAPI.html'
}
// const course = data.find((course) => course.id === params.course)
const hash = SHA256(course.scormContentPath).toString()
const [scormState] = useLocalStorage(`scormState.${hash}`, {})
const scormState = data?.['last_commit']?.cmi || {}
// const suspendData = JSON.parse(scormState?.cmi?.suspend_data || '{}')
// const d = lzwCompress.unpack(suspendData?.d)
// const d2 = JSON.parse(d || '{}')
// console.log(d2?.progress?.p ?? null)
return (
<ScormPlayer
settings={{
autocommit: true,
throwExceptions: false,
logLevel: 2,
compatibilityMode: 1
}}
scormVersion="2004"
scormState={scormState}
scormContentPath={course.scormContentPath}
className="w-full h-full border-0"
onCommit={async (data) => {
console.log(data)
await fetcher.submit(JSON.stringify(data), {
method: 'post',
encType: 'application/json'
})
}}
/>
)
}

View File

@@ -1,10 +1,10 @@
import type { Route } from './+types'
import { useForm } from 'react-hook-form'
import { Link } from 'react-router'
import { request as req } from '@/lib/request'
import { Container } from '@/components/container'
import type { User } from '@repo/auth/auth'
import { userContext } from '@repo/auth/context'
import {
@@ -33,7 +33,7 @@ import {
} from '@repo/ui/components/ui/form'
import { Input } from '@repo/ui/components/ui/input'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { useForm } from 'react-hook-form'
import { request as req } from '@repo/util/request'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Minha conta' }]

View File

@@ -14,13 +14,15 @@
"dependencies": {
"@repo/auth": "*",
"@repo/ui": "*",
"@repo/util": "*",
"@tanstack/react-table": "^8.21.3",
"meilisearch-helper": "github:sergiors/meilisearch-helper",
"crypto-js": "^4.2.0",
"fuse.js": "^7.1.0",
"isbot": "^5.1.31",
"lzwcompress": "^1.1.0",
"meilisearch": "^0.54.0",
"meilisearch-helper": "github:sergiors/meilisearch-helper",
"ramda": "^0.32.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router": "^7.9.5",
@@ -29,11 +31,12 @@
"zod": "^4.1.12"
},
"devDependencies": {
"@react-router/dev": "^7.9.5",
"@cloudflare/vite-plugin": "^1.13.17",
"@react-router/dev": "^7.9.5",
"@tailwindcss/vite": "^4.1.16",
"@types/crypto-js": "^4.2.2",
"@types/node": "^24",
"@types/ramda": "^0.31.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"prettier": "^3.6.2",

View File

@@ -1,15 +1,21 @@
import { Meilisearch, type SearchResponse } from 'meilisearch'
const MAX_HITS_PER_PAGE = 100
export async function createSearch({
query,
filter = undefined,
index,
page,
hitsPerPage,
sort,
env
}: {
query?: string
filter?: string
index: string
page?: number
hitsPerPage: number
sort: string[]
env: Env
}): Promise<SearchResponse> {
@@ -21,6 +27,8 @@ export async function createSearch({
return index_.search(query, {
sort,
filter,
limit: 100
page,
hitsPerPage:
hitsPerPage > MAX_HITS_PER_PAGE ? MAX_HITS_PER_PAGE : hitsPerPage
})
}

View File

@@ -31,9 +31,15 @@ export function request({
const requestId = context.get(requestIdContext) as string
const user = context.get(userContext) as User
const url_ = new URL(url, context.cloudflare.env.API_URL)
const headers = new Headers(
Object.assign({ Authorization: `Bearer ${user.accessToken}` }, _headers)
)
const headers = new Headers({
Authorization: `Bearer ${user.accessToken}`
})
if (_headers instanceof Headers) {
_headers.forEach((value, key) => headers.set(key, value))
} else {
Object.entries(_headers).forEach(([key, value]) => headers.set(key, value))
}
console.log(
`[${new Date().toISOString()}] [${requestId}] ${method} ${url_.toString()}`

View File

@@ -1,20 +0,0 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function initials(s: string): string {
const initials = s
.split(' ')
.map((word) => word.charAt(0).toUpperCase()) as string[]
if (initials.length == 0) {
return ''
}
const first = initials[0]
const last = initials[initials.length - 1]
return first + last
}

View File

@@ -138,7 +138,10 @@ function Course({ id, name, access_period, cert, draft }: Course) {
<CardHeader className="z-1 relative">
<CardTitle className="text-xl/6">
{name} {draft ? <>(rascunho)</> : null}
{name}{' '}
{draft ? (
<span className="text-muted-foreground">(rascunho)</span>
) : null}
</CardTitle>
</CardHeader>