Files
saladeaula.digital/apps/studio.saladeaula.digital/app/routes/edit.tsx

528 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 } }) {
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)
}