529 lines
15 KiB
TypeScript
529 lines
15 KiB
TypeScript
import type { Route } from './+types/edit'
|
|
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { 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 { toast } from 'sonner'
|
|
import { z } from 'zod'
|
|
|
|
import { request as req, HttpMethod } from '@repo/util/request'
|
|
import { Skeleton } from '@repo/ui/components/skeleton'
|
|
import {
|
|
Breadcrumb,
|
|
BreadcrumbItem,
|
|
BreadcrumbLink,
|
|
BreadcrumbList,
|
|
BreadcrumbPage,
|
|
BreadcrumbSeparator
|
|
} from '@repo/ui/components/ui/breadcrumb'
|
|
import { Button } from '@repo/ui/components/ui/button'
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle
|
|
} from '@repo/ui/components/ui/card'
|
|
import { Checkbox } from '@repo/ui/components/ui/checkbox'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger
|
|
} from '@repo/ui/components/ui/dropdown-menu'
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage
|
|
} from '@repo/ui/components/ui/form'
|
|
import { Input } from '@repo/ui/components/ui/input'
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupButton,
|
|
InputGroupInput
|
|
} from '@repo/ui/components/ui/input-group'
|
|
import { Spinner } from '@repo/ui/components/ui/spinner'
|
|
import { Switch } from '@repo/ui/components/ui/switch'
|
|
|
|
const formSchema = 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(),
|
|
unlisted: 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 formSchema>
|
|
|
|
type Cert = {
|
|
exp_interval: number
|
|
s3_uri?: string
|
|
}
|
|
|
|
export type Course = {
|
|
id: string
|
|
name: string
|
|
access_period: number
|
|
cert?: Cert
|
|
unlisted?: 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 formData = await request.formData()
|
|
|
|
const r = await req({
|
|
url: `courses/${params.id}`,
|
|
method: HttpMethod.PUT,
|
|
body: formData,
|
|
request,
|
|
context
|
|
})
|
|
|
|
return { ok: r.status === 200 }
|
|
}
|
|
|
|
export default function Component({
|
|
loaderData: { data }
|
|
}: Route.ComponentProps) {
|
|
return (
|
|
<Suspense fallback={<Skeleton />}>
|
|
<div className="space-y-4">
|
|
<Breadcrumb>
|
|
<BreadcrumbList>
|
|
<BreadcrumbItem>
|
|
<BreadcrumbLink href="/">Cursos</BreadcrumbLink>
|
|
</BreadcrumbItem>
|
|
<BreadcrumbSeparator />
|
|
<BreadcrumbItem>
|
|
<BreadcrumbPage>Editar curso</BreadcrumbPage>
|
|
</BreadcrumbItem>
|
|
</BreadcrumbList>
|
|
</Breadcrumb>
|
|
|
|
<div className="lg:max-w-2xl mx-auto">
|
|
<Await resolve={data}>
|
|
<Editing />
|
|
</Await>
|
|
</div>
|
|
</div>
|
|
</Suspense>
|
|
)
|
|
}
|
|
|
|
function Editing() {
|
|
const course = useAsyncValue() as Course
|
|
const fetcher = useFetcher()
|
|
const form = useForm({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: {
|
|
unlisted: 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'
|
|
})
|
|
|
|
toast.success('O curso foi atualizado')
|
|
}
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
<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:border-blue-900
|
|
dark:has-aria-checked: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="unlisted"
|
|
render={({ field: { onChange, value, ...field } }) => (
|
|
<FormItem className="flex items-center gap-2">
|
|
<FormControl>
|
|
<Checkbox
|
|
checked={value}
|
|
onCheckedChange={onChange}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormLabel>Não listar no catálogo de cursos</FormLabel>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex justify-end">
|
|
<Button
|
|
type="submit"
|
|
className="cursor-pointer"
|
|
disabled={formState.isSubmitting}
|
|
>
|
|
{formState.isSubmitting && <Spinner />}
|
|
Editar
|
|
</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)
|
|
}
|