add other projects
This commit is contained in:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user