add other projects

This commit is contained in:
2025-11-04 15:00:49 -03:00
parent 80ff884ceb
commit 0b0ef528df
218 changed files with 58699 additions and 0 deletions

View 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
})
}

View 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
}
}

View 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) }
})
}

View 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 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)
}

View 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>
)
}

View 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>
)
}