This commit is contained in:
2025-11-05 16:26:01 -03:00
parent 488b96dc51
commit 0698cff8cf
76 changed files with 374 additions and 2580 deletions

View File

@@ -0,0 +1,54 @@
'use client'
import { Moon, Sun, SunMoon } from 'lucide-react'
import { useTheme } from 'next-themes'
import dark from './logo-dark.svg'
import light from './logo-light.svg'
import { Button } from './ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from './ui/dropdown-menu'
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="link"
size="icon"
className="cursor-pointer text-muted-foreground"
>
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Alternar tema</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="*:cursor-pointer">
<DropdownMenuItem onClick={() => setTheme('light')}>
<Sun /> Claro
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<Moon /> Escuro
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<SunMoon /> Sistema
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function ThemedImage() {
return (
<>
<img src={light} className="h-8" data-hide-on-theme="dark" />
<img src={dark} className="h-8" data-hide-on-theme="light" />
</>
)
}

View File

@@ -0,0 +1,148 @@
import { CheckIcon } from 'lucide-react'
import { useState } from 'react'
import { Badge } from '@repo/ui/components/ui/badge'
import { Button } from '@repo/ui/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
} from '@repo/ui/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@repo/ui/components/ui/popover'
import { Separator } from '@repo/ui/components/ui/separator'
import { cn } from '@repo/ui/lib/utils'
interface FacetedFilterProps<TData, TValue> {
value?: string[]
title?: string
icon: React.ComponentType
className?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
onChange?: (values: string[]) => void
}
export function FacetedFilter<TData, TValue>({
value = [],
icon: Icon,
title,
options,
onChange,
className
}: FacetedFilterProps<TData, TValue>) {
const [selectedValues, setSelectedValues] = useState(new Set(value))
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn('h-9 border-dashed cursor-pointer', className)}
>
<Icon />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<div className="gap-1 flex">
{selectedValues.size > 2 ? (
<Badge
variant="outline"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selecionados
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="outline"
key={option.value}
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>Nenhum resultado encontrado.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<CommandItem
className="cursor-pointer"
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
setSelectedValues(selectedValues)
onChange?.(Array.from(selectedValues))
}}
>
<div
className={cn(
'flex size-4 items-center justify-center rounded-[4px] border',
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'border-input [&_svg]:invisible'
)}
>
<CheckIcon className="text-primary-foreground size-3.5" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground size-4" />
)}
<span>{option.label}</span>
</CommandItem>
)
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
setSelectedValues(new Set())
onChange?.([])
}}
className="justify-center text-center cursor-pointer"
>
Limpar
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import {
CirclePlayIcon,
DollarSignIcon,
GraduationCapIcon,
LayoutDashboardIcon,
LightbulbIcon,
LogOutIcon,
UserIcon,
type LucideIcon
} from 'lucide-react'
import { Link } from 'react-router'
import { initials } from '../lib/utils'
import { Avatar, AvatarFallback } from './ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from './ui/dropdown-menu'
type NavItem = {
title: string
url: string
icon: LucideIcon
scope?: string[]
}
const apps: NavItem[] = [
{
title: 'Sala de aula',
url: '//scorm.eduseg.workers.dev',
icon: GraduationCapIcon
},
{
title: 'Administrador',
url: '//admin.saladeaula.digital',
icon: LayoutDashboardIcon,
scope: ['apps:admin']
},
{
title: 'EDUSEG® Estúdio',
url: '//studio.saladeaula.digital',
icon: CirclePlayIcon,
scope: ['apps:studio']
},
{
title: 'EDUSEG® Insights',
url: '//insights.saladeaula.digital',
icon: LightbulbIcon,
scope: ['apps:insights']
}
]
export function NavUser({
user
}: {
user: {
name: string
email: string
scope: string
}
}) {
const userScope = 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', 'apps:insights'],
userScope,
'any'
) && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-muted-foreground text-sm">
Aplicações
</DropdownMenuLabel>
</>
)}
{apps.map(({ title, url, scope = [], icon: Icon }, idx) => {
if (grantIfHas(scope, userScope)) {
return (
<DropdownMenuItem key={idx} asChild>
<Link to={url} className="cursor-pointer">
<Icon /> {title}
</Link>
</DropdownMenuItem>
)
}
return <></>
})}
</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,77 @@
import { debounce } from 'lodash'
import { SearchIcon, XIcon } from 'lucide-react'
import { useRef } from 'react'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from '@repo/ui/components/ui/input-group'
import { useKeyPress } from '@repo/ui/hooks/use-keypress'
import { cn } from '@repo/ui/lib/utils'
export function SearchForm({
placeholder,
className,
onChange,
defaultValue = '',
...props
}: {
placeholder?: React.ReactNode
className?: string
onChange?: (value: string) => void
defaultValue?: string
} & React.HTMLAttributes<HTMLDivElement>) {
const inputRef = useRef<HTMLInputElement>(null)
useKeyPress('/', () => {
inputRef.current?.focus()
})
const debouncedOnChange = debounce((value: string) => {
onChange?.(value)
}, 200)
return (
<InputGroup className="group">
<InputGroupInput
className={cn('peer', className)}
placeholder=" "
ref={inputRef}
defaultValue={defaultValue}
onChange={(e) => debouncedOnChange(e.target.value)}
{...props}
/>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
{placeholder && (
<InputGroupAddon className="font-normal hidden peer-focus-within:hidden peer-placeholder-shown:block">
{placeholder}
</InputGroupAddon>
)}
{defaultValue && (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
className="cursor-pointer"
onClick={() => {
if (inputRef.current) {
inputRef.current.value = ''
}
onChange?.('')
inputRef.current?.focus()
}}
>
<XIcon />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
)
}

View File

@@ -0,0 +1,19 @@
import { Skeleton as XSkeleton } from './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>
)
}