update
This commit is contained in:
54
packages/ui/src/components/dark-mode.tsx
Normal file
54
packages/ui/src/components/dark-mode.tsx
Normal 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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
148
packages/ui/src/components/faceted-filter.tsx
Normal file
148
packages/ui/src/components/faceted-filter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
packages/ui/src/components/nav-user.tsx
Normal file
173
packages/ui/src/components/nav-user.tsx
Normal 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))
|
||||
}
|
||||
77
packages/ui/src/components/search-form.tsx
Normal file
77
packages/ui/src/components/search-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
packages/ui/src/components/skeleton.tsx
Normal file
19
packages/ui/src/components/skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
packages/ui/src/hooks/use-keypress.ts
Normal file
22
packages/ui/src/hooks/use-keypress.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { throttle } from 'lodash'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function useKeyPress(targetKey: string, callback: CallableFunction) {
|
||||
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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react'
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
@@ -10,9 +10,9 @@ export function useIsMobile() {
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
mql.addEventListener('change', onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
|
||||
Reference in New Issue
Block a user