update
This commit is contained in:
22
packages/auth/package.json
Normal file
22
packages/auth/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@repo/auth",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./auth": "./src/auth.ts",
|
||||
"./session": "./src/session.ts",
|
||||
"./context": "./src/context.ts",
|
||||
"./middleware/*": "./src/middleware/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"jose": "^6.1.0",
|
||||
"remix-auth-oauth2": "^3.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react-router": "^7.9.5",
|
||||
"@types/node": "^24.9.2",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
43
packages/auth/src/auth.ts
Normal file
43
packages/auth/src/auth.ts
Normal 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) {
|
||||
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
|
||||
}
|
||||
5
packages/auth/src/context.ts
Normal file
5
packages/auth/src/context.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { User } from '@/auth'
|
||||
import { createContext } from 'react-router'
|
||||
|
||||
export const userContext = createContext<User | null>(null)
|
||||
export const requestIdContext = createContext<string | null>(null)
|
||||
70
packages/auth/src/middleware/auth.ts
Normal file
70
packages/auth/src/middleware/auth.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { requestIdContext, userContext } from '@/context'
|
||||
import { createSessionStorage } from '@/session'
|
||||
|
||||
import { createAuth, type User } from '@/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>
|
||||
): 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'))
|
||||
const requestId = context.get(requestIdContext)
|
||||
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 accessTokenExp = accessToken.exp * 1000
|
||||
const leeway = 120 * 1000 // 2 minutes
|
||||
|
||||
if (Date.now() > accessTokenExp - leeway) {
|
||||
const tokens = await (strategy as any).refreshToken(user.refreshToken)
|
||||
|
||||
user = {
|
||||
...user,
|
||||
accessToken: tokens.accessToken(),
|
||||
refreshToken: tokens.refreshToken()
|
||||
}
|
||||
|
||||
console.debug(`[${requestId}] Refresh token retrieved`, user)
|
||||
// Should replace the user in the session
|
||||
session.set('user', user)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[${requestId}]`, error?.stack)
|
||||
|
||||
// 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('/login', {
|
||||
headers: new Headers({
|
||||
'Set-Cookie': await sessionStorage.commitSession(session)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
context.set(userContext, user)
|
||||
|
||||
const response = await next()
|
||||
const sessionCookie = await sessionStorage.commitSession(session)
|
||||
response.headers.set('Set-Cookie', sessionCookie)
|
||||
return response
|
||||
}
|
||||
20
packages/auth/src/middleware/logging.ts
Normal file
20
packages/auth/src/middleware/logging.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { requestIdContext } from '@/context'
|
||||
import { type LoaderFunctionArgs } from 'react-router'
|
||||
|
||||
export const loggingMiddleware = async (
|
||||
{ request, context }: LoaderFunctionArgs,
|
||||
next
|
||||
) => {
|
||||
const requestId = crypto.randomUUID()
|
||||
context.set(requestIdContext, requestId)
|
||||
|
||||
console.log(`[${requestId}] ${request.method} ${request.url}`)
|
||||
|
||||
const start = performance.now()
|
||||
const response = await next()
|
||||
const duration = performance.now() - start
|
||||
|
||||
console.log(`[${requestId}] Response ${response.status} (${duration}ms)`)
|
||||
|
||||
return response
|
||||
}
|
||||
16
packages/auth/src/session.ts
Normal file
16
packages/auth/src/session.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createCookieSessionStorage } from 'react-router'
|
||||
|
||||
export function createSessionStorage(env) {
|
||||
const sessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: '__session',
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
secrets: [env.SESSION_SECRET],
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 86400 * 7 // 7 days
|
||||
}
|
||||
})
|
||||
return sessionStorage
|
||||
}
|
||||
11
packages/auth/tsconfig.json
Normal file
11
packages/auth/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -4,8 +4,6 @@
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./globals.css": "./src/globals.css",
|
||||
"./postcss.config": "./postcss.config.js",
|
||||
"./tailwind.config": "./tailwind.config.ts",
|
||||
"./lib/*": "./src/lib/*.ts",
|
||||
"./hooks/*": [
|
||||
"./src/hooks/*.ts",
|
||||
@@ -15,6 +13,7 @@
|
||||
"./components/*.svg": "./src/components/*.svg"
|
||||
},
|
||||
"dependencies": {
|
||||
"@brazilian-utils/brazilian-utils": "^1.0.0-rc.12",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -34,14 +33,22 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.548.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.6",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"react-number-format": "^5.4.4",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.20",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"strict": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["vite/client"],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss()]
|
||||
plugins: [tailwindcss(), tsconfigPaths()]
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user