add other projects
This commit is contained in:
38
apps/studio.saladeaula.digital/app/routes/api.ts
Normal file
38
apps/studio.saladeaula.digital/app/routes/api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Route } from './+types'
|
||||
|
||||
import { userContext } from '@/context'
|
||||
import type { User } from '@/lib/auth'
|
||||
|
||||
export const loader = proxy
|
||||
export const action = proxy
|
||||
|
||||
async function proxy({
|
||||
request,
|
||||
context
|
||||
}: Route.ActionArgs): Promise<Response> {
|
||||
const pathname = new URL(request.url).pathname.replace(/^\/api\//, '')
|
||||
const user = context.get(userContext) as User
|
||||
const url = new URL(pathname, context.cloudflare.env.API_URL)
|
||||
const headers = new Headers(request.headers)
|
||||
|
||||
headers.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
|
||||
const r = await fetch(url.toString(), {
|
||||
method: request.method,
|
||||
headers,
|
||||
...(['GET', 'HEAD'].includes(request.method)
|
||||
? {}
|
||||
: { body: await request.text() })
|
||||
})
|
||||
|
||||
const contentType = r.headers.get('content-type') || ''
|
||||
const body =
|
||||
contentType.includes('application/json') || contentType.startsWith('text/')
|
||||
? await r.text()
|
||||
: await r.arrayBuffer()
|
||||
|
||||
return new Response(body, {
|
||||
status: r.status,
|
||||
headers: r.headers
|
||||
})
|
||||
}
|
||||
48
apps/studio.saladeaula.digital/app/routes/auth/login.ts
Normal file
48
apps/studio.saladeaula.digital/app/routes/auth/login.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Route } from './+types'
|
||||
|
||||
import { redirect } from 'react-router'
|
||||
|
||||
import { createAuth, type User } from '@/lib/auth'
|
||||
import { createSessionStorage } from '@/lib/session'
|
||||
|
||||
export async function loader({ request, context }: Route.ActionArgs) {
|
||||
const sessionStorage = createSessionStorage(context.cloudflare.env)
|
||||
const session = await sessionStorage.getSession(request.headers.get('cookie'))
|
||||
const returnTo = session.has('returnTo') ? session.get('returnTo') : '/'
|
||||
const user = session.get('user') as User | null
|
||||
|
||||
if (user) {
|
||||
return redirect(returnTo)
|
||||
}
|
||||
|
||||
try {
|
||||
const authenticator = createAuth(context.cloudflare.env)
|
||||
const user = await authenticator.authenticate('oidc', request)
|
||||
session.set('user', user)
|
||||
|
||||
console.log(`Redirecting the user to ${returnTo}`)
|
||||
// Redirect to the home page after successful login
|
||||
return redirect(returnTo, {
|
||||
headers: {
|
||||
'Set-Cookie': await sessionStorage.commitSession(session)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
if (error instanceof Error) {
|
||||
return Response.json(
|
||||
{ error: error.message },
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; utf-8'
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Re-throw any other errors (including redirects)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
24
apps/studio.saladeaula.digital/app/routes/auth/logout.ts
Normal file
24
apps/studio.saladeaula.digital/app/routes/auth/logout.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Route } from './+types'
|
||||
|
||||
import { createAuth, type User } from '@/lib/auth'
|
||||
import { createSessionStorage } from '@/lib/session'
|
||||
import { redirect } from 'react-router'
|
||||
import type { OAuth2Strategy } from 'remix-auth-oauth2'
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const authenticator = createAuth(context.cloudflare.env)
|
||||
const sessionStorage = createSessionStorage(context.cloudflare.env)
|
||||
const session = await sessionStorage.getSession(request.headers.get('cookie'))
|
||||
const user = session.get('user') as User
|
||||
const strategy = authenticator.get<OAuth2Strategy<User>>('oidc')
|
||||
|
||||
if (user?.accessToken && strategy) {
|
||||
await strategy.revokeToken(user.accessToken)
|
||||
}
|
||||
|
||||
console.log(await sessionStorage.destroySession(session))
|
||||
|
||||
return redirect('/login', {
|
||||
headers: { 'Set-Cookie': await sessionStorage.destroySession(session) }
|
||||
})
|
||||
}
|
||||
547
apps/studio.saladeaula.digital/app/routes/edit.tsx
Normal file
547
apps/studio.saladeaula.digital/app/routes/edit.tsx
Normal file
@@ -0,0 +1,547 @@
|
||||
import type { Route } from './+types/edit'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
FileBadgeIcon,
|
||||
FileCode2Icon,
|
||||
MoreHorizontalIcon
|
||||
} from 'lucide-react'
|
||||
import { Suspense, useState, type ReactNode } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Await, useAsyncValue, useFetcher } from 'react-router'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Skeleton } from '@/components/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from '@/components/ui/breadcrumb'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput
|
||||
} from '@/components/ui/input-group'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { userContext } from '@/context'
|
||||
import type { User } from '@/lib/auth'
|
||||
import { HttpMethod, request as req } from '@/lib/request'
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
given_cert: z.coerce.boolean(),
|
||||
never_expires: z.coerce.boolean(),
|
||||
name: z.string().min(1),
|
||||
access_period: z.coerce.number().min(1),
|
||||
cert: z.object({
|
||||
exp_interval: z.coerce.number()
|
||||
}),
|
||||
rawfile: z
|
||||
.instanceof(File, { message: 'Anexe um arquivo HTML' })
|
||||
.optional(),
|
||||
draft: z.boolean()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data?.never_expires) {
|
||||
return true
|
||||
}
|
||||
|
||||
return data?.cert?.exp_interval > 0
|
||||
},
|
||||
{
|
||||
message: 'Deve ser maior ou igual a 1',
|
||||
path: ['cert', 'exp_interval']
|
||||
}
|
||||
)
|
||||
|
||||
type Schema = z.infer<typeof schema>
|
||||
|
||||
type Cert = {
|
||||
exp_interval: number
|
||||
s3_uri?: string
|
||||
}
|
||||
|
||||
export type Course = {
|
||||
id: string
|
||||
name: string
|
||||
access_period: number
|
||||
cert?: Cert
|
||||
draft?: boolean
|
||||
}
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Editar curso' }]
|
||||
}
|
||||
|
||||
export const loader = async ({
|
||||
params,
|
||||
context,
|
||||
request
|
||||
}: Route.LoaderArgs) => {
|
||||
const r = await req({
|
||||
url: `/courses/${params.id}`,
|
||||
context,
|
||||
request
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
throw new Response(null, { status: r.status })
|
||||
}
|
||||
|
||||
return { data: r.json() as Promise<Course> }
|
||||
}
|
||||
|
||||
export async function action({ params, request, context }: Route.ActionArgs) {
|
||||
const user = context.get(userContext) as User
|
||||
const formData = await request.formData()
|
||||
|
||||
const r = await req({
|
||||
url: `courses/${params.id}`,
|
||||
method: HttpMethod.PUT,
|
||||
body: formData,
|
||||
headers: new Headers({
|
||||
Authorization: `Bearer ${user.accessToken}`
|
||||
}),
|
||||
request,
|
||||
context
|
||||
})
|
||||
|
||||
return { ok: r.status === 200 }
|
||||
}
|
||||
|
||||
export default function Component({ loaderData: { data } }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/">Cursos</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Editar curso</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<div className="lg:max-w-2xl mx-auto">
|
||||
<Await resolve={data}>
|
||||
<Editing />
|
||||
</Await>
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Editing() {
|
||||
const course = useAsyncValue() as Course
|
||||
const fetcher = useFetcher()
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
draft: false,
|
||||
given_cert: !!course?.cert,
|
||||
never_expires: !course?.cert?.exp_interval,
|
||||
...course
|
||||
}
|
||||
})
|
||||
const { handleSubmit, formState, watch } = form
|
||||
const givenCert = watch('given_cert')
|
||||
const neverExpires = watch('never_expires') as boolean
|
||||
|
||||
const onSubmit = async ({ given_cert, never_expires, ...data }: Schema) => {
|
||||
const formData = new FormData()
|
||||
const data_ = flattenObject(data, '.')
|
||||
|
||||
for (const k in data_) {
|
||||
// @ts-ignore
|
||||
const v = data_[k]
|
||||
|
||||
if (v) {
|
||||
formData.append(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// If `s3_uri` exists, restore it
|
||||
if (course?.cert?.s3_uri) {
|
||||
formData.append('cert.s3_uri', course.cert.s3_uri)
|
||||
}
|
||||
|
||||
await fetcher.submit(formData, {
|
||||
method: 'post',
|
||||
encType: 'multipart/form-data'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{formState.isSubmitSuccessful ? (
|
||||
<Alert variant="default">
|
||||
<CircleCheckIcon />
|
||||
<AlertTitle>Curso atualizado!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Tudo pronto! As mudanças foram salvas e seu curso já está
|
||||
atualizado.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Editar curso</CardTitle>
|
||||
<CardDescription>
|
||||
Configurar as informações gerais para este curso
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Curso</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="access_period"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Tempo de acesso
|
||||
<span className="text-xs text-white/30 font-normal">
|
||||
(em dias)
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="given_cert"
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className="flex flex-row items-center justify-between
|
||||
rounded-lg border p-3 shadow-sm
|
||||
dark:has-[[aria-checked=true]]:border-blue-900
|
||||
dark:has-[[aria-checked=true]]:bg-blue-950"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<FormLabel>Habilitar certificação</FormLabel>
|
||||
<FormDescription>
|
||||
Emita automaticamente o certificado de conclusão para os
|
||||
participantes.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{givenCert ? (
|
||||
<div className="space-y-4 border rounded-lg p-4 bg-accent/50">
|
||||
<h3 className="font-medium">Configurações do certificado</h3>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cert.exp_interval"
|
||||
disabled={neverExpires}
|
||||
defaultValue={''}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Período de validade
|
||||
<span className="text-xs text-white/30 font-normal">
|
||||
(em dias)
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
className="disabled:text-transparent"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="never_expires"
|
||||
control={form.control}
|
||||
render={({ field: { onChange, value, ...field } }) => {
|
||||
return (
|
||||
<FormItem className="flex items-center gap-1.5">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal text-muted-foreground">
|
||||
O certificado não possui prazo de validade.
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rawfile"
|
||||
render={({ field: { onChange, value, ...field } }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex justify-between gap-4">
|
||||
<span>Template do certificado</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
type="file"
|
||||
accept="text/html"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
|
||||
if (file) {
|
||||
onChange(file)
|
||||
}
|
||||
}}
|
||||
{...field}
|
||||
/>
|
||||
|
||||
{course?.cert?.s3_uri ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<DownloadMenu
|
||||
course_id={course.id}
|
||||
s3_uri={course?.cert?.s3_uri}
|
||||
/>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Anexe o arquivo HTML que será utilizado na emissão dos
|
||||
certificados.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="draft"
|
||||
render={({ field: { onChange, value, ...field } }) => (
|
||||
<FormItem className="flex items-center gap-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>Ocultar o curso no catálogo.</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-lime-400 cursor-pointer"
|
||||
disabled={formState.isSubmitting}
|
||||
>
|
||||
{formState.isSubmitting && <Spinner />}
|
||||
Editar curso
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
type DownloadMenuProps = {
|
||||
course_id: string
|
||||
s3_uri: string
|
||||
}
|
||||
|
||||
function DownloadMenu({ course_id, s3_uri }: DownloadMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<InputGroupButton
|
||||
variant="ghost"
|
||||
aria-label="Mais"
|
||||
size="icon-xs"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</InputGroupButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DownloadMenuItem
|
||||
course_id={course_id}
|
||||
s3_uri={s3_uri}
|
||||
url={`/api/courses/${course_id}/sample`}
|
||||
>
|
||||
{({ isLoading }) => (
|
||||
<>{isLoading ? <Spinner /> : <FileBadgeIcon />} Baixar amostra</>
|
||||
)}
|
||||
</DownloadMenuItem>
|
||||
|
||||
<DownloadMenuItem
|
||||
course_id={course_id}
|
||||
s3_uri={s3_uri}
|
||||
url={`/api/courses/${course_id}/template`}
|
||||
>
|
||||
{({ isLoading }) => (
|
||||
<>
|
||||
{isLoading ? <Spinner /> : <FileCode2Icon />}
|
||||
<span>Baixar template</span>
|
||||
<span className="text-xs text-muted-foreground">(html)</span>
|
||||
</>
|
||||
)}
|
||||
</DownloadMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
type DownloadMenuItemProps = {
|
||||
course_id: string
|
||||
url: string
|
||||
s3_uri: string
|
||||
children: (props: { isLoading: boolean }) => ReactNode
|
||||
}
|
||||
|
||||
function DownloadMenuItem({
|
||||
children,
|
||||
course_id,
|
||||
url,
|
||||
s3_uri
|
||||
}: DownloadMenuItemProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ s3_uri })
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
|
||||
if (r.ok) {
|
||||
const url = URL.createObjectURL(await r.blob())
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = url
|
||||
link.download = course_id
|
||||
document.body.appendChild(link)
|
||||
|
||||
link.click()
|
||||
link.remove()
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem asChild>
|
||||
<button className="w-full cursor-pointer" onClick={handleClick}>
|
||||
{children({ isLoading })}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function flattenObject<
|
||||
T extends Record<string, any>,
|
||||
R extends Record<string, any> = Record<string, any>
|
||||
>(obj: T, delimiter: string = '.', prefix: string = ''): R {
|
||||
return Object.keys(obj).reduce((acc, key) => {
|
||||
const value = obj[key]
|
||||
const newKey = prefix ? `${prefix}${delimiter}${key}` : key
|
||||
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length > 0
|
||||
) {
|
||||
Object.assign(acc, flattenObject(value, delimiter, newKey))
|
||||
} else {
|
||||
;(acc as any)[newKey] = value
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {} as R)
|
||||
}
|
||||
183
apps/studio.saladeaula.digital/app/routes/index.tsx
Normal file
183
apps/studio.saladeaula.digital/app/routes/index.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { Route } from './+types/index'
|
||||
|
||||
import Fuse from 'fuse.js'
|
||||
import { AwardIcon, BanIcon, FileBadgeIcon, LaptopIcon } from 'lucide-react'
|
||||
import { Suspense, useMemo } from 'react'
|
||||
import { Await, NavLink, useSearchParams } from 'react-router'
|
||||
|
||||
import placeholder from '@/assets/placeholder.webp'
|
||||
import { SearchForm } from '@/components/search-form'
|
||||
import { Skeleton } from '@/components/skeleton'
|
||||
import { Card, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle
|
||||
} from '@/components/ui/empty'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from '@/components/ui/tooltip'
|
||||
import { createSearch } from '@/lib/meili'
|
||||
import type { Course } from './edit'
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Gerenciar seus cursos' }]
|
||||
}
|
||||
|
||||
export const loader = async ({ context }: Route.ActionArgs) => {
|
||||
return {
|
||||
data: createSearch({
|
||||
index: 'saladeaula_courses',
|
||||
sort: ['created_at:desc'],
|
||||
env: context.cloudflare.env
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default function Component({ loaderData: { data } }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const term = searchParams.get('term') as string
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-0.5 mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Cursos</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gerencie seus cursos com facilidade e organize seu conteúdo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<div className="w-full xl:w-92">
|
||||
<SearchForm
|
||||
placeholder={
|
||||
<>
|
||||
Pressione <Kbd>/</Kbd> para filtrar...
|
||||
</>
|
||||
}
|
||||
defaultValue={term}
|
||||
onChange={(e) => {
|
||||
setSearchParams({ term: e.target.value })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-4 gap-5">
|
||||
<Await resolve={data}>
|
||||
{({ hits = [] }) => {
|
||||
return <List term={term} hits={hits} />
|
||||
}}
|
||||
</Await>
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function List({ term, hits = [] }: { term: string; hits: Course[] }) {
|
||||
const fuse = useMemo(() => {
|
||||
return new Fuse(hits, {
|
||||
keys: ['name'],
|
||||
threshold: 0.3,
|
||||
includeMatches: true
|
||||
})
|
||||
}, [hits])
|
||||
|
||||
const hits_ = useMemo(() => {
|
||||
if (!term) {
|
||||
return hits
|
||||
}
|
||||
|
||||
return fuse.search(term).map(({ item }) => item)
|
||||
}, [term, fuse, hits])
|
||||
|
||||
if (hits_.length === 0) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<BanIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nada encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Nenhum resultado para <mark>{term}</mark>.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
return hits_.map((props: Course, idx) => {
|
||||
return <Course key={idx} {...props} />
|
||||
})
|
||||
}
|
||||
|
||||
function Course({ id, name, access_period, cert, draft }: Course) {
|
||||
return (
|
||||
<NavLink to={`/edit/${id}`} className="hover:scale-105 transition">
|
||||
{({ isPending }) => (
|
||||
<Card className="overflow-hidden relative h-96">
|
||||
{isPending && (
|
||||
<div className="absolute bottom-0 right-0 p-6 z-1">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="z-1 relative">
|
||||
<CardTitle className="text-xl/6">
|
||||
{name} {draft ? <>(rascunho)</> : null}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardFooter className="text-gray-300 text-sm absolute z-1 bottom-6 w-full flex gap-1.5">
|
||||
<ul className="flex gap-2.5">
|
||||
<li>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex gap-0.5 items-center">
|
||||
<LaptopIcon className="size-4" />
|
||||
<span>{access_period}d</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Tempo de acesso ao curso</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</li>
|
||||
|
||||
{cert?.exp_interval && (
|
||||
<li>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex gap-0.5 items-center">
|
||||
<AwardIcon className="size-4" />
|
||||
<span>{cert.exp_interval}d</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Perído de validade do certificado</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{cert?.s3_uri && (
|
||||
<li className="flex items-center">
|
||||
<FileBadgeIcon className="size-4" />
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardFooter>
|
||||
|
||||
<img
|
||||
src={placeholder}
|
||||
alt={name}
|
||||
className="absolute bottom-0 opacity-75"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
45
apps/studio.saladeaula.digital/app/routes/layout.tsx
Normal file
45
apps/studio.saladeaula.digital/app/routes/layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Route } from './+types'
|
||||
|
||||
import { Link, Outlet } from 'react-router'
|
||||
|
||||
import logo from '@/components/logo.svg'
|
||||
import { NavUser } from '@/components/nav-user'
|
||||
import { userContext } from '@/context'
|
||||
import { authMiddleware } from '@/middleware/auth'
|
||||
|
||||
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
|
||||
|
||||
export async function loader({ context }: Route.ActionArgs) {
|
||||
const user = context.get(userContext)
|
||||
return Response.json({ user })
|
||||
}
|
||||
|
||||
export default function Component({ loaderData }: Route.ComponentProps) {
|
||||
const { user } = loaderData
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col flex-1 min-w-0">
|
||||
<header
|
||||
className="bg-background/15 backdrop-blur-sm
|
||||
px-4 py-2 lg:py-4 sticky top-0 z-5"
|
||||
>
|
||||
<div className="container mx-auto flex items-center">
|
||||
<Link to="/" className="flex items-start gap-1">
|
||||
<img src={logo} className="h-6 lg:h-8" />
|
||||
<span className="text-muted-foreground text-xs">Estúdio</span>
|
||||
</Link>
|
||||
|
||||
<div className="ml-auto">
|
||||
<NavUser user={user} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="p-4">
|
||||
<div className="container mx-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user