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

@@ -33,6 +33,14 @@ def get_course(course_id: str):
) )
@router.post('/<course_id>/scormset/<scormset_id>')
def get_scormset(course_id: str, scormset_id: str):
return dyn.collection.get_item(
KeyPair(course_id, f'SCORMSET#{scormset_id}'),
exc_cls=NotFoundError,
)
class Cert(BaseModel): class Cert(BaseModel):
exp_interval: int | None = None exp_interval: int | None = None
s3_uri: str | None = None s3_uri: str | None = None
@@ -47,6 +55,7 @@ class Course(BaseModel):
access_period: int access_period: int
cert: Cert cert: Cert
draft: bool = False draft: bool = False
demo: bool = False
rawfile: bytes | None = None rawfile: bytes | None = None

View File

@@ -1,7 +1,16 @@
from http import HTTPStatus
from os import rename
from typing import Annotated
from aws_lambda_powertools import Logger from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.api_gateway import Router
from layercake.dynamodb import DynamoDBPersistenceLayer, SortKey, TransactKey from aws_lambda_powertools.event_handler.exceptions import NotFoundError
from aws_lambda_powertools.event_handler.openapi.params import Body
from glom import T, glom
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey, TransactKey
from api_gateway import JSONResponse
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE from config import ENROLLMENT_TABLE
@@ -20,5 +29,62 @@ dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@router.get('/<enrollment_id>') @router.get('/<enrollment_id>')
def get_enrollment(enrollment_id: str): def get_enrollment(enrollment_id: str):
return dyn.collection.get_items( return dyn.collection.get_items(
TransactKey(enrollment_id) + SortKey('0') + SortKey('ORG') + SortKey('LOCK'), TransactKey(enrollment_id)
+ SortKey('0')
+ SortKey('ORG')
+ SortKey('LOCK')
+ SortKey('SCORM_COMMIT#LAST', rename_key='last_commit'),
) )
@router.get('/<enrollment_id>/scorm')
def get_scorm(enrollment_id: str):
enrollment = dyn.collection.get_items(
TransactKey(enrollment_id) + SortKey('0') + SortKey('SCORM_COMMIT#LAST')
)
if not enrollment:
raise NotFoundError('Enrollment not found')
spec = {'id': 'course.id', 'scormset': 'course.scormset'}
course_id, scormset_id = glom(enrollment, spec).values()
scormset = dyn.collection.get_item(
KeyPair(course_id, f'SCORMSET#{scormset_id}'),
raise_on_error=False,
default={},
)
return enrollment | {'scormset': scormset}
@router.post('/<enrollment_id>')
def post_enrollment(
enrollment_id: str,
cmi: Annotated[dict, Body(embed=True)],
):
now_ = now()
with dyn.transact_writer() as transact:
transact.condition(
key=KeyPair(enrollment_id, '0'),
cond_expr='attribute_exists(sk)',
exc_cls=NotFoundError,
)
transact.put(
item={
'id': enrollment_id,
'sk': 'SCORM_COMMIT#LAST',
'cmi': cmi,
'created_at': now_,
}
)
transact.put(
item={
'id': f'SCORM_COMMIT#{enrollment_id}',
'sk': now_.isoformat(),
'cmi': cmi,
'created_at': now_,
}
)
return JSONResponse(HTTPStatus.NO_CONTENT)

View File

@@ -102,3 +102,19 @@ def test_template(
lambda_context, lambda_context,
) )
assert r['statusCode'] == HTTPStatus.OK assert r['statusCode'] == HTTPStatus.OK
def test_get_scormset(
app,
seeds,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
r = app.lambda_handler(
http_api_proxy(
raw_path='/courses/c27d1b4f-575c-4b6b-82a1-9b91ff369e0b/scormset/76c75561-d972-43ef-9818-497d8fc6edbe',
method=HTTPMethod.GET,
),
lambda_context,
)
assert r['statusCode'] == HTTPStatus.OK

View File

@@ -1,6 +1,8 @@
import json import json
from http import HTTPMethod, HTTPStatus from http import HTTPMethod, HTTPStatus
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from ..conftest import HttpApiProxy, LambdaContext from ..conftest import HttpApiProxy, LambdaContext
@@ -22,3 +24,73 @@ def test_get_enrollment(
body = json.loads(r['body']) body = json.loads(r['body'])
assert 'user' in body assert 'user' in body
assert 'course' in body assert 'course' in body
def test_get_scormset(
app,
seeds,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
r = app.lambda_handler(
http_api_proxy(
raw_path='/enrollments/9c166c5e-890f-4e77-9855-769c29aaeb2e/scorm',
method=HTTPMethod.GET,
),
lambda_context,
)
assert r['statusCode'] == HTTPStatus.OK
body = json.loads(r['body'])
print(body)
def test_post_scormset(
app,
seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
scormbody = {
'suspend_data': '{"v":2,"d":[123,34,112,114,111,103,114,101,115,115,34,58,256,108,263,115,111,110,265,267,34,48,266,256,112,266,49,53,44,34,105,278,276,287,99,281,284,286,275,277,275,290,58,49,125,300,284,49,289,291,285,287,295,256,297,299,302,304,298,125,284,50,313,299,301,34,317,275,293,123,320,51,287,324,320,52,328,278,320,53,332,267,320,54,336,325,315,34,55,340,320,56,345,342,57,348,302,308,306,337,342,49,303,323,333,356,322,256,329,300,365,125],"cpv":"_lnxccXW"}',
'launch_data': '',
'comments': '',
'comments_from_lms': '',
'core': {
'student_id': '',
'student_name': '',
'lesson_location': '',
'credit': '',
'lesson_status': 'incomplete',
'entry': '',
'lesson_mode': 'normal',
'exit': 'suspend',
'session_time': '00:00:00',
'score': {'raw': '', 'min': '', 'max': '100'},
'total_time': '00:00:00',
},
'objectives': {},
'student_data': {
'mastery_score': '',
'max_time_allowed': '',
'time_limit_action': '',
},
'student_preference': {'audio': '', 'language': '', 'speed': '', 'text': ''},
'interactions': {},
}
r = app.lambda_handler(
http_api_proxy(
raw_path='/enrollments/578ec87f-94c7-4840-8780-bb4839cc7e64',
method=HTTPMethod.POST,
body=scormbody,
),
lambda_context,
)
assert r['statusCode'] == HTTPStatus.NO_CONTENT
r = dynamodb_persistence_layer.collection.get_item(
KeyPair('578ec87f-94c7-4840-8780-bb4839cc7e64', 'SCORM_COMMIT#LAST')
)
assert r['cmi']['suspend_data'] == scormbody['suspend_data']

View File

@@ -9,6 +9,7 @@
// Enrollments // Enrollments
{"id": "578ec87f-94c7-4840-8780-bb4839cc7e64", "sk": "0", "course": {"id": "af3258f0-bccf-4781-aec6-d4c618d234a7", "name": "pytest", "access_period": 180}, "user": {"id": "068b4600-cc36-4b55-b832-bb620021705a", "name": "Benjamin Burnley", "email": "burnley@breakingbenjamin.com"}} {"id": "578ec87f-94c7-4840-8780-bb4839cc7e64", "sk": "0", "course": {"id": "af3258f0-bccf-4781-aec6-d4c618d234a7", "name": "pytest", "access_period": 180}, "user": {"id": "068b4600-cc36-4b55-b832-bb620021705a", "name": "Benjamin Burnley", "email": "burnley@breakingbenjamin.com"}}
{"id": "9c166c5e-890f-4e77-9855-769c29aaeb2e", "sk": "0", "course": {"id": "c27d1b4f-575c-4b6b-82a1-9b91ff369e0b", "name": "pytest", "access_period": 180, "scormset": "76c75561-d972-43ef-9818-497d8fc6edbe"}, "user": {"id": "068b4600-cc36-4b55-b832-bb620021705a", "name": "Layne Staley", "email": "layne@aliceinchains.com"}}
// Orgs // Orgs
{"id": "2a8963fc-4694-4fe2-953a-316d1b10f1f5", "sk": "0", "name": "pytest", "cnpj": "04978826000180"} {"id": "2a8963fc-4694-4fe2-953a-316d1b10f1f5", "sk": "0", "name": "pytest", "cnpj": "04978826000180"}
@@ -28,4 +29,7 @@
{"id": "cpf", "sk": "07879819908", "user_id": "15bacf02-1535-4bee-9022-19d106fd7518"} {"id": "cpf", "sk": "07879819908", "user_id": "15bacf02-1535-4bee-9022-19d106fd7518"}
// Emails // Emails
{"id": "email", "sk": "sergio@somosbeta.com.br", "user_id": "15bacf02-1535-4bee-9022-19d106fd7518"} {"id": "email", "sk": "sergio@somosbeta.com.br", "user_id": "15bacf02-1535-4bee-9022-19d106fd7518"}
// Course scormset
{"id": "c27d1b4f-575c-4b6b-82a1-9b91ff369e0b", "sk": "SCORMSET#76c75561-d972-43ef-9818-497d8fc6edbe", "packages": [{"version": "1.2", "scormdriver": "s3://saladeaula.digital/scorm/nr-33-espacos-confinados-conteudo-de-demonstracao-scorm12/scormdriver/indexAPI.html"}]}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ import { Input } from '@repo/ui/components/ui/input'
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
import { useWorksapce } from '@/components/workspace-switcher' 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' import { useEffect } from 'react'
const isName = (name: string) => name && name.includes(' ') 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 { Outlet, type ShouldRevalidateFunctionArgs } from 'react-router'
import { AppSidebar } from '@/components/app-sidebar' import { AppSidebar } from '@/components/app-sidebar'
import { request as req } from '@/lib/request'
import { WorkspaceProvider } from '@/components/workspace-switcher' import { WorkspaceProvider } from '@/components/workspace-switcher'
import { userContext } from '@repo/auth/context' import { userContext } from '@repo/auth/context'
@@ -17,6 +16,7 @@ import {
SidebarTrigger SidebarTrigger
} from '@repo/ui/components/ui/sidebar' } from '@repo/ui/components/ui/sidebar'
import { Toaster } from '@repo/ui/components/ui/sonner' import { Toaster } from '@repo/ui/components/ui/sonner'
import { request as req } from '@repo/util/request'
export const middleware: Route.MiddlewareFunction[] = [authMiddleware] export const middleware: Route.MiddlewareFunction[] = [authMiddleware]

View File

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

View File

@@ -15,6 +15,7 @@
"@react-router/fs-routes": "^7.9.5", "@react-router/fs-routes": "^7.9.5",
"@repo/auth": "*", "@repo/auth": "*",
"@repo/ui": "*", "@repo/ui": "*",
"@repo/util": "^0.0.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"date-fns": "^4.1.0", "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 { declare global {
interface Window { interface Window {
API: Scorm12API API: Scorm12API
API_1484_11: Scorm2004API
} }
} }

View File

@@ -7,7 +7,7 @@ type ContainerProps = {
export function Container({ children, className }: ContainerProps) { export function Container({ children, className }: ContainerProps) {
return ( return (
<main className="px-4"> <main className="p-4">
<div className={cn('container mx-auto', className)}>{children}</div> <div className={cn('container mx-auto', className)}>{children}</div>
</main> </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' 'use client'
import { useLocalStorage } from '@/hooks/useLocalStorage' import { omit } from 'ramda'
import SHA256 from 'crypto-js/sha256'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Scorm12API } from 'scorm-again/scorm12' import { Scorm12API, Scorm2004API } from 'scorm-again'
const settings = { export type ScormVersion = '1.2' | '2004'
autocommit: true
// mastery_override: true export type ScormPlayerProps = {
// logLevel: 2, 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({ export function ScormPlayer({
settings,
scormVersion,
scormState, scormState,
scormContentPath, scormContentPath,
className className,
}: { onCommit
scormState: object }: ScormPlayerProps) {
scormContentPath: string
className: string
}) {
const [iframeLoaded, setIframeLoaded] = useState(false) const [iframeLoaded, setIframeLoaded] = useState(false)
const scormApiRef = useRef<Scorm12API | null>(null) const scormApiRef = useRef<Scorm12API | Scorm2004API | null>(null)
const hash = SHA256(scormContentPath).toString()
const [_, setScormState] = useLocalStorage(`scormState.${hash}`, {})
useEffect(() => { useEffect(() => {
const scormApi = new Scorm12API(settings) const cls = scormVersion === '1.2' ? Scorm12API : Scorm2004API
scormApi.loadFromFlattenedJSON(scormState) const scormApi = new cls(settings)
scormApi.loadFromJSON(omit(['interactions'], scormState))
scormApi.on('LMSCommit', function () { // scormApi.loadFromFlattenedJSON(scormState)
console.log('Committed')
setScormState(scormApi.renderCommitCMI(true))
})
scormApiRef.current = scormApi 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) setIframeLoaded(true)
return () => { return () => {
@@ -54,21 +68,30 @@ export function ScormPlayer({
function unload() { function unload() {
if (unloaded || scormApi.isTerminated()) { 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 unloaded = true
} }
window.addEventListener('beforeunload', unload) window.addEventListener('beforeunload', unload)
window.addEventListener('unload', unload) window.addEventListener('pagehide', unload)
return () => { return () => {
window.removeEventListener('beforeunload', unload) 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('certs', 'routes/certs.tsx'),
route('payments', 'routes/payments.tsx'), route('payments', 'routes/payments.tsx'),
route('settings', 'routes/settings.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('proxy/*', 'routes/proxy.tsx')
]), ]),
route('logout', 'routes/auth/logout.ts'), route('logout', 'routes/auth/logout.ts'),

View File

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

View File

@@ -1,6 +1,5 @@
import type { Route } from './+types' import type { Route } from './+types'
// import SHA256 from 'crypto-js/sha256'
import { import {
Empty, Empty,
EmptyDescription, EmptyDescription,
@@ -8,6 +7,7 @@ import {
EmptyMedia, EmptyMedia,
EmptyTitle EmptyTitle
} from '@repo/ui/components/ui/empty' } from '@repo/ui/components/ui/empty'
import Fuse from 'fuse.js'
import { import {
BanIcon, BanIcon,
CircleCheckIcon, CircleCheckIcon,
@@ -18,18 +18,16 @@ import {
TimerIcon, TimerIcon,
type LucideIcon type LucideIcon
} from 'lucide-react' } from 'lucide-react'
// import lzwCompress from 'lzwcompress'
import Fuse from 'fuse.js'
import { MeiliSearchFilterBuilder } from 'meilisearch-helper' import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
import { Suspense, useMemo } from 'react' 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 placeholder from '@/assets/placeholder.webp'
import { createSearch } from '@/lib/meili'
import { Container } from '@/components/container' import { Container } from '@/components/container'
import type { User } from '@repo/auth/auth' import type { User } from '@repo/auth/auth'
import { userContext } from '@repo/auth/context' import { userContext } from '@repo/auth/context'
import { FacetedFilter } from '@repo/ui/components/faceted-filter' import { FacetedFilter } from '@repo/ui/components/faceted-filter'
import { SearchForm } from '@repo/ui/components/search-form' import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton' import { Skeleton } from '@repo/ui/components/skeleton'
@@ -42,27 +40,11 @@ import {
} from '@repo/ui/components/ui/card' } from '@repo/ui/components/ui/card'
import { Kbd } from '@repo/ui/components/ui/kbd' import { Kbd } from '@repo/ui/components/ui/kbd'
import { Progress } from '@repo/ui/components/ui/progress' import { Progress } from '@repo/ui/components/ui/progress'
import { createSearch } from '@repo/util/meili'
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'
}
]
type Course = { type Course = {
name: string name: string
scormset?: string
} }
export type Enrollment = { export type Enrollment = {
@@ -86,13 +68,16 @@ export const loader = async ({ request, context }: Route.ActionArgs) => {
builder = builder.where('status', 'in', status) 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 { return {
data: createSearch({ data: enrollments
index: 'betaeducacao-prod-enrollments',
filter: builder.build(),
sort: ['created_at:desc'],
env: context.cloudflare.env
})
} }
} }
@@ -207,50 +192,18 @@ function List({ term, hits = [] }: { term: string; hits: Enrollment[] }) {
}) })
} }
function Enrollment({ function Enrollment({ id, course, progress }: 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])
return ( 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"> <Card className="overflow-hidden relative h-96">
<CardHeader className="z-1 relative"> <CardHeader className="z-1 relative">
<CardTitle className="text-xl/6">{course.name}</CardTitle> <CardTitle className="text-xl/6">{course.name}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="z-1"> <CardContent className="z-1"></CardContent>
{/* Você pode adicionar algo como tempo total aqui, se quiser */}
</CardContent>
<CardFooter className="absolute z-1 bottom-6 w-full flex gap-1.5"> <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} /> <Progress value={progress} />
<span className="text-xs">{progress}%</span> <span className="text-xs">{progress}%</span>
</CardFooter> </CardFooter>
@@ -260,7 +213,7 @@ function Enrollment({
className="absolute bottom-0 opacity-75" className="absolute bottom-0 opacity-75"
/> />
</Card> </Card>
</a> </NavLink>
) )
} }
@@ -280,7 +233,7 @@ const statuses: Record<
COMPLETED: { COMPLETED: {
icon: CircleCheckIcon, icon: CircleCheckIcon,
color: 'text-green-400 [&_svg]:text-background [&_svg]:fill-green-500', color: 'text-green-400 [&_svg]:text-background [&_svg]:fill-green-500',
label: 'Aprovado' label: 'Concluído'
}, },
FAILED: { FAILED: {
icon: CircleXIcon, 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"> <div className="container mx-auto flex items-center">
{/* Desktop Menu */} {/* Desktop Menu */}
<div className="hidden lg:flex gap-8"> <div className="hidden lg:flex items-center gap-8">
<Link to="/"> <Link to="/">
<ThemedImage /> <ThemedImage />
</Link> </Link>

View File

@@ -1,7 +1,10 @@
import type { Route } from './+types' 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 { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -10,39 +13,97 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator BreadcrumbSeparator
} from '@repo/ui/components/ui/breadcrumb' } from '@repo/ui/components/ui/breadcrumb'
import { createSearch } from '@repo/util/meili'
import { Container } from '@/components/container' 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) { export function meta({}: Route.MetaArgs) {
return [{ title: 'Histórico de compras' }] 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 ( return (
<Container className="space-y-2.5"> <Container className="space-y-2.5">
<Breadcrumb> <Suspense fallback={<Skeleton />}>
<BreadcrumbList> <Await resolve={data}>
<BreadcrumbItem> {({ hits }) => (
<BreadcrumbLink asChild> <>
<Link to="..">Meus cursos</Link> <Breadcrumb>
</BreadcrumbLink> <BreadcrumbList>
</BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbSeparator /> <BreadcrumbLink asChild>
<BreadcrumbItem> <Link to="..">Meus cursos</Link>
<BreadcrumbPage>Histórico de compras</BreadcrumbPage> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
</BreadcrumbList> <BreadcrumbSeparator />
</Breadcrumb> <BreadcrumbItem>
<BreadcrumbPage>Histórico de compras</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="space-y-0.5"> <div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-bold tracking-tight">
Histórico de compras Histórico de compras
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Acompanhe todos as compras realizadas, visualize pagamentos e mantenha Acompanhe todos as compras realizadas, visualize pagamentos e
o controle financeiro. mantenha o controle financeiro.
</p> </p>
</div> </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> </Container>
) )
} }

View File

@@ -1,29 +1,87 @@
import type { Route } from './+types' import type { Route } from './+types'
import { ScormPlayer } from '@/components/scorm-player' import lzwCompress from 'lzwcompress'
import { useLocalStorage } from '@/hooks/useLocalStorage' import { useBlocker, useFetcher } from 'react-router'
import SHA256 from 'crypto-js/sha256'
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) { export function meta({ params }: Route.MetaArgs) {
return [{ title: '' }] 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 = { const course = {
id: 'fbad867a-0022-4605-814f-db8ebe2b17fb', id: 'fbad867a-0022-4605-814f-db8ebe2b17fb',
courseName: 'All Golf', // courseName: 'All Golf',
scormContentPath: 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 = data?.['last_commit']?.cmi || {}
const [scormState] = useLocalStorage(`scormState.${hash}`, {}) // 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 ( return (
<ScormPlayer <ScormPlayer
settings={{
autocommit: true,
throwExceptions: false,
logLevel: 2,
compatibilityMode: 1
}}
scormVersion="2004"
scormState={scormState} scormState={scormState}
scormContentPath={course.scormContentPath} scormContentPath={course.scormContentPath}
className="w-full h-full border-0" 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 type { Route } from './+types'
import { useForm } from 'react-hook-form'
import { Link } from 'react-router' import { Link } from 'react-router'
import { request as req } from '@/lib/request'
import { Container } from '@/components/container' import { Container } from '@/components/container'
import type { User } from '@repo/auth/auth' import type { User } from '@repo/auth/auth'
import { userContext } from '@repo/auth/context' import { userContext } from '@repo/auth/context'
import { import {
@@ -33,7 +33,7 @@ import {
} from '@repo/ui/components/ui/form' } from '@repo/ui/components/ui/form'
import { Input } from '@repo/ui/components/ui/input' import { Input } from '@repo/ui/components/ui/input'
import { Spinner } from '@repo/ui/components/ui/spinner' 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) { export function meta({}: Route.MetaArgs) {
return [{ title: 'Minha conta' }] return [{ title: 'Minha conta' }]

View File

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

View File

@@ -1,15 +1,21 @@
import { Meilisearch, type SearchResponse } from 'meilisearch' import { Meilisearch, type SearchResponse } from 'meilisearch'
const MAX_HITS_PER_PAGE = 100
export async function createSearch({ export async function createSearch({
query, query,
filter = undefined, filter = undefined,
index, index,
page,
hitsPerPage,
sort, sort,
env env
}: { }: {
query?: string query?: string
filter?: string filter?: string
index: string index: string
page?: number
hitsPerPage: number
sort: string[] sort: string[]
env: Env env: Env
}): Promise<SearchResponse> { }): Promise<SearchResponse> {
@@ -21,6 +27,8 @@ export async function createSearch({
return index_.search(query, { return index_.search(query, {
sort, sort,
filter, 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 requestId = context.get(requestIdContext) as string
const user = context.get(userContext) as User const user = context.get(userContext) as User
const url_ = new URL(url, context.cloudflare.env.API_URL) const url_ = new URL(url, context.cloudflare.env.API_URL)
const headers = new Headers( const headers = new Headers({
Object.assign({ Authorization: `Bearer ${user.accessToken}` }, _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( console.log(
`[${new Date().toISOString()}] [${requestId}] ${method} ${url_.toString()}` `[${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"> <CardHeader className="z-1 relative">
<CardTitle className="text-xl/6"> <CardTitle className="text-xl/6">
{name} {draft ? <>(rascunho)</> : null} {name}{' '}
{draft ? (
<span className="text-muted-foreground">(rascunho)</span>
) : null}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>

View File

@@ -1,5 +1,3 @@
from http.cookies import SimpleCookie
from authlib.oauth2.rfc6749 import errors from authlib.oauth2.rfc6749 import errors
from authlib.oauth2.rfc6749.util import scope_to_list from authlib.oauth2.rfc6749.util import scope_to_list
from aws_lambda_powertools import Logger from aws_lambda_powertools import Logger
@@ -15,6 +13,7 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import OAUTH2_TABLE from config import OAUTH2_TABLE
from oauth2 import server from oauth2 import server
from util import parse_cookies
router = Router() router = Router()
logger = Logger(__name__) logger = Logger(__name__)
@@ -24,7 +23,7 @@ dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
@router.get('/authorize') @router.get('/authorize')
def authorize(): def authorize():
current_event = router.current_event current_event = router.current_event
cookies = _parse_cookies(current_event.get('cookies', [])) cookies = parse_cookies(current_event.get('cookies', []))
session = cookies.get('__session') session = cookies.get('__session')
if not session: if not session:
@@ -80,18 +79,4 @@ def _user_scopes(sub: str) -> set:
) )
def _parse_cookies(cookies: list[str] | None) -> dict[str, str]:
parsed_cookies = {}
if not cookies:
return parsed_cookies
for s in cookies:
c = SimpleCookie()
c.load(s)
parsed_cookies.update({k: morsel.value for k, morsel in c.items()})
return parsed_cookies
class InvalidSession(BadRequestError): ... class InvalidSession(BadRequestError): ...

View File

@@ -1,4 +1,5 @@
import os import os
from http.cookies import SimpleCookie
ROOT = os.path.abspath(os.path.dirname(__file__)) ROOT = os.path.abspath(os.path.dirname(__file__))
@@ -10,3 +11,17 @@ def get_file_path(name: str) -> str:
def read_file_path(name: str) -> str: def read_file_path(name: str) -> str:
with open(get_file_path(name)) as f: with open(get_file_path(name)) as f:
return f.read() return f.read()
def parse_cookies(cookies: list[str] | None) -> dict[str, str]:
parsed_cookies = {}
if not cookies:
return parsed_cookies
for s in cookies:
c = SimpleCookie()
c.load(s)
parsed_cookies.update({k: morsel.value for k, morsel in c.items()})
return parsed_cookies

View File

@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 2 revision = 3
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]] [[package]]
@@ -497,7 +497,7 @@ dev = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.11.0" version = "0.11.1"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },

70
package-lock.json generated
View File

@@ -28,6 +28,7 @@
"@react-router/fs-routes": "^7.9.5", "@react-router/fs-routes": "^7.9.5",
"@repo/auth": "*", "@repo/auth": "*",
"@repo/ui": "*", "@repo/ui": "*",
"@repo/util": "^0.0.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -156,6 +157,7 @@
"dependencies": { "dependencies": {
"@repo/auth": "*", "@repo/auth": "*",
"@repo/ui": "*", "@repo/ui": "*",
"@repo/util": "*",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
@@ -163,6 +165,7 @@
"lzwcompress": "^1.1.0", "lzwcompress": "^1.1.0",
"meilisearch": "^0.54.0", "meilisearch": "^0.54.0",
"meilisearch-helper": "github:sergiors/meilisearch-helper", "meilisearch-helper": "github:sergiors/meilisearch-helper",
"ramda": "^0.32.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router": "^7.9.5", "react-router": "^7.9.5",
@@ -176,6 +179,7 @@
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/node": "^24", "@types/node": "^24",
"@types/ramda": "^0.31.1",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
"prettier": "^3.6.2", "prettier": "^3.6.2",
@@ -4087,6 +4091,10 @@
"resolved": "packages/ui", "resolved": "packages/ui",
"link": true "link": true
}, },
"node_modules/@repo/util": {
"resolved": "packages/util",
"link": true
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.52.5", "version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz",
@@ -4771,6 +4779,16 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ramda": {
"version": "0.31.1",
"resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.31.1.tgz",
"integrity": "sha512-Vt6sFXnuRpzaEj+yeutA0q3bcAsK7wdPuASIzR9LXqL4gJPyFw8im9qchlbp4ltuf3kDEIRmPJTD/Fkg60dn7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"types-ramda": "^0.31.0"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.2", "version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
@@ -6373,6 +6391,16 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/ramda": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.32.0.tgz",
"integrity": "sha512-GQWAHhxhxWBWA8oIBr1XahFVjQ9Fic6MK9ikijfd4TZHfE2+urfk+irVlR5VOn48uwMgM+loRRBJd6Yjsbc0zQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ramda"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.0", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
@@ -7062,6 +7090,13 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/ts-toolbelt": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz",
"integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tsconfck": { "node_modules/tsconfck": {
"version": "3.1.6", "version": "3.1.6",
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
@@ -7200,6 +7235,16 @@
"url": "https://github.com/sponsors/Wombosvideo" "url": "https://github.com/sponsors/Wombosvideo"
} }
}, },
"node_modules/types-ramda": {
"version": "0.31.0",
"resolved": "https://registry.npmjs.org/types-ramda/-/types-ramda-0.31.0.tgz",
"integrity": "sha512-vaoC35CRC3xvL8Z6HkshDbi6KWM1ezK0LHN0YyxXWUn9HKzBNg/T3xSGlJZjCYspnOD3jE7bcizsp0bUXZDxnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ts-toolbelt": "^9.6.0"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.2", "version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
@@ -8282,6 +8327,31 @@
"vite": "^7.2.0", "vite": "^7.2.0",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
} }
},
"packages/util": {
"name": "@repo/util",
"version": "0.0.0",
"devDependencies": {
"@repo/auth": "*",
"@types/node": "^24.9.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3"
}
},
"packages/util/node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
} }
} }
} }

View File

@@ -45,7 +45,7 @@ export function ModeToggle() {
} }
type ThemedImageProps = { type ThemedImageProps = {
children?: string children?: string | React.ReactNode
className?: string className?: string
} }

View File

@@ -0,0 +1,15 @@
{
"name": "@repo/util",
"version": "0.0.0",
"private": true,
"exports": {
"./*": "./src/*.ts"
},
"devDependencies": {
"@repo/auth": "*",
"@types/node": "^24.9.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3"
}
}

View File

@@ -7,7 +7,7 @@ export async function createSearch({
filter = undefined, filter = undefined,
index, index,
page, page,
hitsPerPage, hitsPerPage = 25,
sort, sort,
env env
}: { }: {
@@ -15,9 +15,9 @@ export async function createSearch({
filter?: string filter?: string
index: string index: string
page?: number page?: number
hitsPerPage: number hitsPerPage?: number
sort: string[] sort: string[]
env: Env env: any
}): Promise<SearchResponse> { }): Promise<SearchResponse> {
const host = env.MEILI_HOST const host = env.MEILI_HOST
const apiKey = env.MEILI_API_KEY const apiKey = env.MEILI_API_KEY

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules"]
}