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,3 @@
# [studio.saladeaula.digital](https://studio.saladeaula.digital)
O código-fonte para [studio.saladeaula.digital](https://studio.saladeaula.digital), construído com [React Router](https://github.com/remix-run/react-router).

View File

@@ -0,0 +1,133 @@
@import 'tailwindcss' source('.');
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans:
'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}
html,
body {
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

View File

@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1072.73 329.6" width="111" height="36" title="EDUSEG">
<g>
<g>
<path
fill="#8cd366"
d="M152.18,217.62l-68.61-30.91c-1.88-1.2-4.28-1.2-6.16,0l-68.61,30.91c-3.81,2.43-8.8-.3-8.8-4.82V8.98C0,5.82,2.56,3.26,5.72,3.26h149.54c3.16,0,5.72,2.56,5.72,5.72v203.81c0,4.52-5,7.26-8.8,4.82Z"
></path>
<path fill="#2e3524" d="M93.97,74.01H26.61v20.16h67.36v-20.16Z"></path>
<path fill="#2e3524" d="M107.44,111.16H26.61v26.8h80.83v-26.8Z"></path>
<path fill="#2e3524" d="M107.44,30.27H26.61v26.8h80.83v-26.8Z"></path>
<path
fill="#2e3524"
d="M134.38,131.23c0-3.72-3.02-6.73-6.73-6.73s-6.73,3.02-6.73,6.73,3.02,6.73,6.73,6.73,6.73-3.02,6.73-6.73Z"
></path>
</g>
<g>
<path fill="#f9f7e8" d="M244.7,3.24h92.33v44.43h-44.15v88.85h39.38v39.62h-39.38v105.77h44.15v44.42h-92.33V3.24Z"
></path>
<path
fill="#f9f7e8"
d="M362.72,3.24h57.79c10.71,0,20.47,2.35,29.29,7.06,8.83,4.7,15.79,11.18,20.87,19.39,5.08,8.21,7.63,17.45,7.63,27.67v214.88c0,10.22-2.48,19.46-7.42,27.67-4.96,8.21-11.83,14.69-20.68,19.39-8.83,4.7-18.73,7.08-29.7,7.08h-57.79V3.24ZM427.55,283.88c1.74-1.87,2.6-4.18,2.6-6.86V52.56c-.26-2.69-1.34-4.97-3.22-6.86-1.88-1.87-4.15-2.83-6.82-2.83h-14v243.85h14.41c2.93,0,5.27-.94,7.01-2.83h.02Z"
></path>
<path
fill="#f9f7e8"
d="M531.5,322.49c-8.71-4.7-15.6-11.16-20.68-19.39-5.08-8.21-7.63-17.42-7.63-27.67V3.24h48.15v279.41c0,2.69.93,4.99,2.82,6.86,1.86,1.9,4.15,2.83,6.82,2.83,2.93,0,5.27-.94,7.01-2.83,1.74-1.87,2.6-4.18,2.6-6.86V3.24h48.16v272.21c0,10.25-2.48,19.46-7.42,27.67-4.96,8.21-11.83,14.69-20.68,19.39-8.85,4.7-18.73,7.08-29.7,7.08s-20.8-2.35-29.5-7.08l.05-.02Z"
></path>
<path
fill="#f9f7e8"
d="M672.79,322.49c-8.7-4.7-15.6-11.16-20.68-19.39-5.08-8.21-7.63-17.42-7.63-27.67v-78.75h48.16v85.95c0,2.69.93,4.99,2.82,6.87,1.86,1.9,4.15,2.83,6.82,2.83,2.93,0,5.27-.94,7.01-2.83,1.74-1.87,2.6-4.18,2.6-6.87v-77.88c0-5.66-2.22-10.3-6.63-13.94-4.41-3.62-11.57-8.02-21.47-13.13-8.3-4.3-15.05-8.14-20.27-11.52-5.22-3.36-9.71-7.87-13.45-13.54-3.75-5.66-5.63-12.24-5.63-19.8V54.12c0-10.22,2.53-19.44,7.63-27.67,5.08-8.21,11.97-14.66,20.68-19.39,8.68-4.7,18.53-7.06,29.5-7.06s20.87,2.35,29.69,7.06c8.83,4.7,15.72,11.18,20.68,19.39,4.96,8.21,7.42,17.45,7.42,27.67v71.09h-48.16V46.92c0-2.69-.88-4.97-2.6-6.86-1.74-1.87-4.08-2.83-7.01-2.83-2.67,0-4.96.94-6.82,2.83-1.89,1.9-2.82,4.18-2.82,6.86v69.79c0,6.19,2.34,11.26,7.04,15.14,4.67,3.91,12.24,8.83,22.68,14.74,8.04,4.32,14.57,8.09,19.68,11.3,5.08,3.24,9.37,7.46,12.83,12.72,3.48,5.26,5.22,11.26,5.22,17.98v86.83c0,10.25-2.48,19.46-7.42,27.67-4.96,8.21-11.83,14.69-20.68,19.39-8.85,4.71-18.72,7.08-29.7,7.08s-20.8-2.35-29.5-7.08Z"
></path>
<path fill="#f9f7e8" d="M784.56,3.24h92.33v44.43h-44.15v88.85h39.38v39.62h-39.38v105.77h44.15v44.42h-92.33V3.24Z"
></path>
<path
fill="#f9f7e8"
d="M920.63,322.49c-5.63-4.18-10.11-10.1-13.45-17.76-3.34-7.68-5.01-16.49-5.01-26.45V53.71c0-9.96,2.53-19.06,7.63-27.26,5.08-8.21,12.05-14.66,20.87-19.39,8.83-4.7,18.6-7.06,29.32-7.06s20.54,2.35,29.5,7.06c8.97,4.7,15.91,11.18,20.87,19.39,4.96,8.21,7.42,17.3,7.42,27.26v94.51h-48.16V46.92c0-2.69-.88-4.97-2.6-6.86-1.74-1.87-4.08-2.83-7.01-2.83-2.67,0-4.96.94-6.82,2.83-1.89,1.9-2.82,4.18-2.82,6.86v231.36c0,2.69.93,4.99,2.82,6.87,1.86,1.9,4.15,2.83,6.82,2.83,2.93,0,5.27-.94,7.01-2.83,1.74-1.87,2.6-4.18,2.6-6.87v-46.03h-11.64v-51.29h59.8v145.4h-48.16v-14.14c-2.96,5.4-6.82,9.48-11.64,12.31-4.82,2.83-10.83,4.25-18.06,4.25s-13.64-2.09-19.27-6.26l-.02-.02Z"
></path>
<path
fill="#f9f7e8"
d="M1053.27,25.05h-6.13l-.06-3.69h5.48c.83-.02,1.61-.15,2.33-.4.72-.27,1.3-.64,1.73-1.14.44-.51.65-1.14.65-1.87,0-.93-.16-1.67-.48-2.22-.3-.55-.83-.94-1.59-1.16-.74-.25-1.74-.37-3.01-.37h-3.78v20.42h-4.12V10.54h7.9c1.87,0,3.49.27,4.86.82,1.38.53,2.44,1.34,3.18,2.44.76,1.08,1.14,2.43,1.14,4.06,0,1.02-.24,1.93-.71,2.73-.47.8-1.17,1.49-2.1,2.07-.91.57-2.03,1.03-3.35,1.39-.06,0-.12.07-.2.2-.06.13-.11.2-.17.2-.32.19-.53.33-.63.43-.08.08-.16.12-.25.14-.08.02-.31.03-.68.03ZM1052.99,25.05l.6-2.81c2.95,0,4.97.64,6.05,1.93,1.08,1.27,1.62,2.89,1.62,4.86v1.53c0,.7.03,1.37.08,2.02.08.62.21,1.15.4,1.59v.45h-4.23c-.19-.49-.3-1.19-.34-2.1-.02-.91-.03-1.57-.03-1.99v-1.48c0-1.38-.31-2.39-.94-3.04s-1.69-.97-3.21-.97ZM1035.75,22.92c0,2.52.43,4.87,1.28,7.04.87,2.16,2.08,4.05,3.64,5.68,1.55,1.61,3.34,2.87,5.37,3.78,2.05.89,4.22,1.33,6.53,1.33s4.51-.44,6.53-1.33c2.03-.91,3.8-2.17,5.34-3.78,1.53-1.63,2.74-3.52,3.61-5.68.87-2.18,1.31-4.52,1.31-7.04s-.44-4.86-1.31-7.01c-.87-2.16-2.07-4.04-3.61-5.65-1.53-1.61-3.31-2.86-5.34-3.75-2.03-.91-4.2-1.36-6.53-1.36s-4.49.45-6.53,1.36c-2.03.89-3.81,2.14-5.37,3.75-1.55,1.61-2.77,3.49-3.64,5.65-.85,2.16-1.28,4.5-1.28,7.01ZM1032.4,22.92c0-3.01.52-5.8,1.56-8.38,1.04-2.57,2.49-4.82,4.34-6.73,1.86-1.93,4-3.43,6.42-4.49,2.44-1.08,5.06-1.62,7.84-1.62s5.39.54,7.81,1.62c2.44,1.06,4.58,2.56,6.42,4.49,1.86,1.91,3.31,4.16,4.35,6.73,1.06,2.57,1.59,5.37,1.59,8.38s-.53,5.8-1.59,8.38c-1.04,2.57-2.49,4.84-4.35,6.79-1.83,1.93-3.97,3.44-6.42,4.52-2.42,1.08-5.03,1.62-7.81,1.62s-5.39-.54-7.84-1.62c-2.42-1.08-4.56-2.58-6.42-4.52-1.85-1.95-3.3-4.21-4.34-6.79-1.04-2.57-1.56-5.37-1.56-8.38Z"
></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,140 @@
'use client'
import {
DollarSignIcon,
GraduationCapIcon,
LayoutDashboardIcon,
LogOutIcon,
UserIcon
} from 'lucide-react'
import { Link } from 'react-router'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { initials } from '@/lib/utils'
export function NavUser({
user
}: {
user: {
name: string
email: string
scope: string
}
}) {
const scopes = user.scope.split(' ')
return (
<DropdownMenu>
<DropdownMenuTrigger className="cursor-pointer" asChild>
<Avatar className="size-10">
<AvatarFallback>{initials(user.name)}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="size-10">
<AvatarFallback>{initials(user.name)}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
to="//scorm.eduseg.workers.dev/settings"
className="cursor-pointer"
>
<UserIcon />
Minha conta
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
to="//scorm.eduseg.workers.dev/payments"
className="cursor-pointer"
>
<DollarSignIcon />
Histórico de compras
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuGroup>
{grantIfHas(['apps:admin', 'apps:studio'], scopes, 'any') && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-white/50 text-sm">
Aplicações
</DropdownMenuLabel>
</>
)}
<DropdownMenuItem asChild>
<Link to="//scorm.eduseg.workers.dev" className="cursor-pointer">
<GraduationCapIcon />
Sala de aula
</Link>
</DropdownMenuItem>
{grantIfHas(['apps:admin'], scopes) && (
<>
<DropdownMenuItem asChild>
<Link
to="//admin.saladeaula.digital"
className="cursor-pointer"
>
<LayoutDashboardIcon />
Administrador
</Link>
</DropdownMenuItem>
</>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/logout" className="cursor-pointer" reloadDocument>
<LogOutIcon />
Sair
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
function grantIfHas(
required: string[],
granted: string[],
mode: 'all' | 'any' = 'all'
): boolean {
const grantedSet: Set<string> = new Set(granted)
if (mode === 'all') {
return required.every((scope) => grantedSet.has(scope))
}
return required.some((scope) => grantedSet.has(scope))
}

View File

@@ -0,0 +1,47 @@
import { debounce } from 'lodash'
import { SearchIcon } from 'lucide-react'
import { useRef } from 'react'
import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from '@/components/ui/input-group'
import { useKeyPress } from '@/hooks/use-keypress'
import { cn } from '@/lib/utils'
export function SearchForm({
placeholder,
className,
onChange,
...props
}: {
placeholder?: React.ReactNode
className?: string
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
} & React.HTMLAttributes<HTMLDivElement>) {
const inputRef = useRef<HTMLInputElement>(null)
useKeyPress('/', () => {
inputRef.current?.focus()
})
return (
<InputGroup className="group">
<InputGroupInput
className={cn('peer', className)}
placeholder=" "
ref={inputRef}
onChange={debounce(onChange, 200)}
{...props}
/>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupAddon className="font-normal hidden peer-focus-within:hidden peer-placeholder-shown:block">
{placeholder}
</InputGroupAddon>
</InputGroup>
)
}

View File

@@ -0,0 +1,19 @@
import { Skeleton as XSkeleton } from '@/components/ui/skeleton'
export function Skeleton() {
return (
<div className="lg:max-w-2xl mx-auto flex flex-col space-y-3">
<div className="space-y-2">
<XSkeleton className="h-4 w-2/6" />
<XSkeleton className="h-4 w-4/6" />
<XSkeleton className="h-4 w-5/6" />
<XSkeleton className="h-4 w-6/6" />
<XSkeleton className="h-4 w-6/6" />
<XSkeleton className="h-4 w-3/6" />
<XSkeleton className="h-4 w-5/6" />
<XSkeleton className="h-4 w-4/6" />
<XSkeleton className="h-4 w-4/6" />
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

View File

@@ -0,0 +1,167 @@
'use client'
import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import * as React from 'react'
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues
} from 'react-hook-form'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn('grid gap-2', className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? '') : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
</p>
)
}
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField
}

View File

@@ -0,0 +1,168 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,4 @@
import type { User } from '@/middleware/auth'
import { createContext } from 'react-router'
export const userContext = createContext<User | null>(null)

View File

@@ -0,0 +1,46 @@
import { isbot } from 'isbot'
import { renderToReadableStream } from 'react-dom/server'
import type { AppLoadContext, EntryContext } from 'react-router'
import { ServerRouter } from 'react-router'
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext
) {
let shellRendered = false
const userAgent = request.headers.get('user-agent')
const body = await renderToReadableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
onError(error: unknown) {
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error)
}
}
}
)
shellRendered = true
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
await body.allReady
}
responseHeaders.set('Content-Type', 'text/html')
return new Response(body, {
headers: responseHeaders,
status: responseStatusCode
})
}
// https://reactrouter.com/how-to/suspense#timeouts
export const streamTimeout = 6_000

View File

@@ -0,0 +1,22 @@
import { throttle } from 'lodash'
import { useEffect } from 'react'
export function useKeyPress(targetKey, callback) {
useEffect(() => {
const onKeyDown = throttle((event) => {
if (event.key === targetKey) {
event.preventDefault()
callback(event)
}
}, 300)
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
onKeyDown.cancel?.()
}
}, [targetKey, callback])
return null
}

View File

@@ -0,0 +1,43 @@
import type { OAuth2Tokens } from 'arctic'
import { decodeJwt } from 'jose'
import { Authenticator } from 'remix-auth'
import { CodeChallengeMethod, OAuth2Strategy } from 'remix-auth-oauth2'
export type User = {
sub: string
email: string
name: string
scope: string
email_verified: boolean
accessToken: string
refreshToken: string
}
export function createAuth(env: Env) {
const authenticator = new Authenticator()
const strategy = new OAuth2Strategy(
{
clientId: env.CLIENT_ID,
clientSecret: env.CLIENT_SECRET,
redirectURI: env.REDIRECT_URI,
authorizationEndpoint: `${env.ISSUER_URL}/authorize`,
tokenEndpoint: `${env.ISSUER_URL}/token`,
tokenRevocationEndpoint: `${env.ISSUER_URL}/revoke`,
scopes: env.SCOPE.split(' '),
codeChallengeMethod: CodeChallengeMethod.S256
},
async ({ tokens }: { tokens: OAuth2Tokens }) => {
const user = decodeJwt(tokens.idToken())
return {
...user,
accessToken: tokens.accessToken(),
refreshToken: tokens.hasRefreshToken() ? tokens.refreshToken() : null
}
}
)
authenticator.use(strategy, 'oidc')
return authenticator
}

View File

@@ -0,0 +1,26 @@
import { Meilisearch, type SearchResponse } from 'meilisearch'
export async function createSearch({
query,
filter = undefined,
index,
sort,
env
}: {
query?: string
filter?: string
index: string
sort: string[]
env: Env
}): Promise<SearchResponse> {
const host = env.MEILI_HOST
const apiKey = env.MEILI_API_KEY
const client = new Meilisearch({ host, apiKey })
const index_ = client.index(index)
return index_.search(query, {
sort,
filter,
limit: 100
})
}

View File

@@ -0,0 +1,44 @@
import { userContext } from '@/context'
import type { User } from '@/lib/auth'
import type { LoaderFunctionArgs } from 'react-router'
export enum HttpMethod {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
PATCH = 'PATCH',
DELETE = 'DELETE'
}
type RequestArgs = {
url: string
method?: HttpMethod
headers?: HeadersInit
body?: BodyInit | null
request: LoaderFunctionArgs['request']
context: LoaderFunctionArgs['context']
}
export function request({
url,
method = HttpMethod.GET,
headers: _headers = {},
body = null,
request: { signal },
context
}: RequestArgs): Promise<Response> {
const user = context.get(userContext) as User
// @ts-ignore
const headers = new Headers(
Object.assign(_headers, { Authorization: `Bearer ${user.accessToken}` })
)
// @ts-ignore
const endpoint = new URL(url, context.cloudflare.env.API_URL)
return fetch(endpoint.toString(), {
method,
headers,
body,
signal
})
}

View File

@@ -0,0 +1,15 @@
import { createCookieSessionStorage } from 'react-router'
export function createSessionStorage(env: Env) {
const sessionStorage = createCookieSessionStorage({
cookie: {
name: '__session',
httpOnly: true,
secure: false,
secrets: [env.SESSION_SECRET],
sameSite: 'lax',
maxAge: 86400 * 7 // 7 days
}
})
return sessionStorage
}

View File

@@ -0,0 +1,20 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function initials(s: string): string {
const initials = s
.split(' ')
.map((word) => word.charAt(0).toUpperCase()) as string[]
if (initials.length == 0) {
return ''
}
const first = initials[0]
const last = initials[initials.length - 1]
return first + last
}

View File

@@ -0,0 +1,69 @@
import { userContext } from '@/context'
import { createSessionStorage } from '@/lib/session'
import { createAuth, type User } from '@/lib/auth'
import { decodeJwt } from 'jose'
import { redirect, type LoaderFunctionArgs } from 'react-router'
import type { OAuth2Strategy } from 'remix-auth-oauth2'
export const authMiddleware = async (
{ request, context }: LoaderFunctionArgs,
next
): Promise<Response> => {
const sessionStorage = createSessionStorage(context.cloudflare.env)
const authenticator = createAuth(context.cloudflare.env)
const strategy = authenticator.get<OAuth2Strategy<User>>('oidc')
const session = await sessionStorage.getSession(request.headers.get('cookie'))
let user = session.get('user') as User | null
session.set('returnTo', new URL(request.url).toString())
if (!user) {
console.log('There is no user logged in')
return redirect('/login', {
headers: new Headers({
'Set-Cookie': await sessionStorage.commitSession(session)
})
})
}
try {
const accessToken = decodeJwt(user.accessToken) as { exp: number }
const exp = accessToken.exp * 1000
const leeway = 30 * 1000
if (Date.now() > exp - leeway) {
// @ts-ignore
const tokens = await strategy.refreshToken(user.refreshToken)
user = {
...user,
accessToken: tokens.accessToken(),
refreshToken: tokens.refreshToken()
}
session.set('user', user)
}
} catch (error) {
console.error(error)
// If refreshing the token fails, remove the user from the current session
// so the user is forced to sign in again
session.unset('user')
return redirect('/', {
headers: new Headers({
'Set-Cookie': await sessionStorage.commitSession(session)
})
})
}
context.set(userContext, user)
const response = await next()
response.headers.set(
'Set-Cookie',
await sessionStorage.commitSession(session)
)
return response
}

View File

@@ -0,0 +1,68 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration
} from 'react-router'
import type { Route } from './+types/root'
import './app.css'
export const links: Route.LinksFunction = () => []
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-br" className="dark">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>studio.saladeaula.digital</title>
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export default function App() {
return <Outlet />
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = 'Oops!'
let details = 'Ocorreu um erro inesperado.'
let stack: string | undefined
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Erro'
details =
error.status === 404
? 'A página solicitada não foi encontrada.'
: error.statusText || details
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message
stack = error.stack
}
console.error(error)
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
)
}

View File

@@ -0,0 +1,16 @@
import {
index,
layout,
route,
type RouteConfig
} from '@react-router/dev/routes'
export default [
layout('routes/layout.tsx', [
index('routes/index.tsx'),
route('api/*', 'routes/api.ts'),
route('edit/:id', 'routes/edit.tsx')
]),
route('logout', 'routes/auth/logout.ts'),
route('login', 'routes/auth/login.ts')
] satisfies RouteConfig

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

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/app.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
{
"name": "studio.saladeaula.digital",
"private": true,
"type": "module",
"scripts": {
"build": "react-router build",
"cf-typegen": "wrangler types",
"deploy": "npm run build && wrangler deploy",
"dev": "react-router dev",
"postinstall": "npm run cf-typegen",
"preview": "npm run build && vite preview",
"typecheck": "npm run cf-typegen && react-router typegen && tsc -b"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@remix-run/form-data-parser": "^0.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fuse.js": "^7.1.0",
"isbot": "^5.1.31",
"jose": "^6.1.0",
"lodash": "^4.17.21",
"lucide-react": "^0.548.0",
"meilisearch": "^0.54.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.65.0",
"react-router": "^7.9.5",
"remix-auth-oauth2": "^3.4.1",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.13.17",
"@react-router/dev": "^7.9.5",
"@tailwindcss/vite": "^4.1.16",
"@types/node": "^24",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"tailwindcss": "^4.1.16",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.1.12",
"vite-tsconfig-paths": "^5.1.4",
"wrangler": "^4.45.2"
}
}

View File

@@ -0,0 +1,8 @@
<svg width="41" height="40" viewBox="0 0 41 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.54126" width="40" height="40" rx="8" fill="#2E3524"/>
<path d="M30.1297 34.3155L21.0128 30.2083C20.7628 30.0489 20.4441 30.0489 20.1941 30.2083L11.0773 34.3155C10.5705 34.6388 9.90771 34.2754 9.90771 33.6745V6.59115C9.90771 6.17148 10.2483 5.83093 10.6679 5.83093H30.539C30.9587 5.83093 31.2992 6.17148 31.2992 6.59115V33.6745C31.2992 34.2754 30.6353 34.6388 30.1297 34.3155Z" fill="#8CD366"/>
<path d="M22.3944 15.2321H13.4438V17.9107H22.3944V15.2321Z" fill="#2E3524"/>
<path d="M24.1843 20.1695H13.4438V23.731H24.1843V20.1695Z" fill="#2E3524"/>
<path d="M24.1843 9.41989H13.4438V12.9813H24.1843V9.41989Z" fill="#2E3524"/>
<path d="M27.7643 22.836C27.7643 22.3418 27.3636 21.9411 26.8693 21.9411C26.375 21.9411 25.9744 22.3418 25.9744 22.836C25.9744 23.3303 26.375 23.731 26.8693 23.731C27.3636 23.731 27.7643 23.3303 27.7643 22.836Z" fill="#2E3524"/>
</svg>

After

Width:  |  Height:  |  Size: 991 B

View File

@@ -0,0 +1,9 @@
import type { Config } from '@react-router/dev/config'
export default {
ssr: true,
future: {
unstable_viteEnvironmentApi: true,
v8_middleware: true
}
} satisfies Config

View File

@@ -0,0 +1,28 @@
{
"extends": "./tsconfig.json",
"include": [
".react-router/types/**/*",
"app/**/*",
"app/**/.server/**/*",
"app/**/.client/**/*",
"workers/**/*",
"worker-configuration.d.ts"
],
"compilerOptions": {
"composite": true,
"strict": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"baseUrl": ".",
"rootDirs": [".", "./.react-router/types"],
"paths": {
"@/*": ["./app/*"]
},
"esModuleInterop": true,
"resolveJsonModule": true
}
}

View File

@@ -0,0 +1,18 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.cloudflare.json" }
],
"compilerOptions": {
"checkJs": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["./app/*"]
}
}
}

View File

@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"include": ["vite.config.ts"],
"compilerOptions": {
"composite": true,
"strict": true,
"types": ["node"],
"lib": ["ES2022"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler"
}
}

View File

@@ -0,0 +1,14 @@
import { cloudflare } from '@cloudflare/vite-plugin'
import { reactRouter } from '@react-router/dev/vite'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [
cloudflare({ viteEnvironment: { name: 'ssr' } }),
tailwindcss(),
reactRouter(),
tsconfigPaths()
]
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
import { createRequestHandler, RouterContextProvider } from 'react-router'
declare module 'react-router' {
export interface AppLoadContext {
cloudflare: {
env: Env
ctx: ExecutionContext
}
}
}
const requestHandler = createRequestHandler(
() => import('virtual:react-router/server-build'),
import.meta.env.MODE
)
export default {
async fetch(request, env, ctx) {
const context = new RouterContextProvider()
return requestHandler(
request,
Object.assign(context, { cloudflare: { env, ctx } })
)
}
} satisfies ExportedHandler<Env>

View File

@@ -0,0 +1,17 @@
name = "studio-saladeaula-digital"
compatibility_date = "2025-04-04"
main = "./workers/app.ts"
routes = [
{ pattern = "studio.saladeaula.digital", custom_domain = true }
]
[vars]
CLIENT_ID = "78a0819e-1f9b-4da1-b05f-40ec0eaed0c8"
REDIRECT_URI = "https://studio.saladeaula.digital/login"
SCOPE = "openid profile email offline_access apps:studio"
API_URL = "https://bcs7fgb9og.execute-api.sa-east-1.amazonaws.com"
ISSUER_URL = "https://id.saladeaula.digital"
MEILI_HOST = "https://search.saladeaula.digital"
[observability.logs]
enabled = true