add user
This commit is contained in:
@@ -46,6 +46,11 @@ app.include_router(orders.router, prefix='/orders')
|
||||
app.include_router(orgs.custom_pricing, prefix='/orgs')
|
||||
|
||||
|
||||
@app.get('/health')
|
||||
def health():
|
||||
return {'status': 'available'}
|
||||
|
||||
|
||||
@app.exception_handler(ServiceError)
|
||||
def exc_error(exc: ServiceError):
|
||||
return JSONResponse(
|
||||
|
||||
45
api.saladeaula.digital/app/keep_warm.py
Normal file
45
api.saladeaula.digital/app/keep_warm.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import requests
|
||||
from aws_lambda_powertools import Logger, Tracer
|
||||
from aws_lambda_powertools.utilities.data_classes import (
|
||||
EventBridgeEvent,
|
||||
event_source,
|
||||
)
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
|
||||
logger = Logger(__name__)
|
||||
tracer = Tracer()
|
||||
urls = ['https://bcs7fgb9og.execute-api.sa-east-1.amazonaws.com/health']
|
||||
|
||||
|
||||
@tracer.capture_lambda_handler
|
||||
@event_source(data_class=EventBridgeEvent)
|
||||
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||
results = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||
futures = [executor.submit(ping, url) for url in urls]
|
||||
|
||||
for future in as_completed(futures):
|
||||
results.append(future.result())
|
||||
|
||||
logger.info(results)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def ping(url: str):
|
||||
try:
|
||||
r = requests.get(url, timeout=4)
|
||||
r.raise_for_status()
|
||||
except requests.exceptions.RequestException as exc:
|
||||
return {
|
||||
'url': url,
|
||||
'error': str(exc),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'url': url,
|
||||
'status': r.status_code,
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
AWSTemplateFormatVersion: "2010-09-09"
|
||||
Transform: "AWS::Serverless-2016-10-31"
|
||||
AWSTemplateFormatVersion: '2010-09-09'
|
||||
Transform: 'AWS::Serverless-2016-10-31'
|
||||
|
||||
Parameters:
|
||||
UserTable:
|
||||
@@ -46,11 +46,16 @@ Resources:
|
||||
Properties:
|
||||
RetentionInDays: 90
|
||||
|
||||
ScheduleLog:
|
||||
Type: AWS::Logs::LogGroup
|
||||
Properties:
|
||||
RetentionInDays: 7
|
||||
|
||||
HttpApi:
|
||||
Type: AWS::Serverless::HttpApi
|
||||
Properties:
|
||||
CorsConfiguration:
|
||||
AllowOrigins: ["*"]
|
||||
AllowOrigins: ['*']
|
||||
AllowMethods: [GET, POST, PUT, DELETE, PATCH, OPTIONS]
|
||||
AllowHeaders: [Content-Type, X-Requested-With, Authorization]
|
||||
AllowCredentials: false
|
||||
@@ -59,13 +64,13 @@ Resources:
|
||||
DefaultAuthorizer: OAuth2Authorizer
|
||||
Authorizers:
|
||||
OAuth2Authorizer:
|
||||
IdentitySource: "$request.header.Authorization"
|
||||
IdentitySource: '$request.header.Authorization'
|
||||
JwtConfiguration:
|
||||
issuer: "https://id.saladeaula.digital"
|
||||
issuer: 'https://id.saladeaula.digital'
|
||||
audience:
|
||||
- "1a5483ab-4521-4702-9115-5857ac676851"
|
||||
- "1db63660-063d-4280-b2ea-388aca4a9459"
|
||||
- "78a0819e-1f9b-4da1-b05f-40ec0eaed0c8"
|
||||
- '1a5483ab-4521-4702-9115-5857ac676851'
|
||||
- '1db63660-063d-4280-b2ea-388aca4a9459'
|
||||
- '78a0819e-1f9b-4da1-b05f-40ec0eaed0c8'
|
||||
|
||||
HttpApiFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
@@ -97,12 +102,33 @@ Resources:
|
||||
Path: /{proxy+}
|
||||
Method: ANY
|
||||
ApiId: !Ref HttpApi
|
||||
Health:
|
||||
Type: HttpApi
|
||||
Properties:
|
||||
Path: /health
|
||||
Method: GET
|
||||
ApiId: !Ref HttpApi
|
||||
Auth:
|
||||
Authorizer: NONE
|
||||
|
||||
EventKeepWarmScheduledFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: keep_warm.lambda_handler
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref ScheduleLog
|
||||
Events:
|
||||
ScheduleEvent:
|
||||
Type: ScheduleV2
|
||||
Properties:
|
||||
ScheduleExpression: 'cron(*/3 5-23 * * ? *)'
|
||||
ScheduleExpressionTimezone: America/Sao_Paulo
|
||||
|
||||
Outputs:
|
||||
HttpApiUrl:
|
||||
Description: URL of your API endpoint
|
||||
Value:
|
||||
Fn::Sub: "https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}"
|
||||
Fn::Sub: 'https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}'
|
||||
HttpApiId:
|
||||
Description: Api ID of HttpApi
|
||||
Value:
|
||||
|
||||
5
api.saladeaula.digital/tests/test_keep_warm.py
Normal file
5
api.saladeaula.digital/tests/test_keep_warm.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import keep_warm
|
||||
|
||||
|
||||
def test_keep_warm():
|
||||
keep_warm.lambda_handler({}, {})
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
|
||||
import { NavMain } from '@/components/nav-main'
|
||||
import { OrgSwitcher } from '@/components/org-switcher'
|
||||
import { WorkspaceSwitcher } from '@/components/workspace-switcher'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -42,11 +42,6 @@ const data = {
|
||||
url: '/admins',
|
||||
icon: ShieldUserIcon
|
||||
}
|
||||
// {
|
||||
// title: 'Webhooks',
|
||||
// url: '/webhooks',
|
||||
// icon: WebhookIcon
|
||||
// }
|
||||
],
|
||||
navContent: [
|
||||
{
|
||||
@@ -76,7 +71,7 @@ export function AppSidebar({ orgs = [] }) {
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<OrgSwitcher orgs={orgs} />
|
||||
<WorkspaceSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain data={data} />
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@repo/ui/components/ui/table'
|
||||
import { cn } from '@repo/ui/lib/utils'
|
||||
import { DataTablePagination } from './pagination'
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
@@ -145,7 +146,7 @@ export function DataTable<TData, TValue>({
|
||||
<CardContent>
|
||||
{children}
|
||||
|
||||
<Table_>
|
||||
<Table_ className="table-auto">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
@@ -154,7 +155,13 @@ export function DataTable<TData, TValue>({
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} className="p-2.5">
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={cn(
|
||||
'p-2.5',
|
||||
header.column.columnDef.meta?.className
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
@@ -176,7 +183,13 @@ export function DataTable<TData, TValue>({
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="p-2.5">
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
'p-2.5',
|
||||
cell.column.columnDef.meta?.className
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
|
||||
import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import { useLocation, useParams } from 'react-router'
|
||||
|
||||
import {
|
||||
@@ -23,23 +23,60 @@ import {
|
||||
} from '@repo/ui/components/ui/sidebar'
|
||||
import { initials } from '@repo/ui/lib/utils'
|
||||
|
||||
type Org = {
|
||||
type Workspace = {
|
||||
id: string
|
||||
name: string
|
||||
cnpj: string
|
||||
}
|
||||
|
||||
export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
|
||||
type WorkspaceContextProps = {
|
||||
workspaces: Workspace[]
|
||||
activeWorkspace: Workspace | null
|
||||
setActiveWorkspace: React.Dispatch<React.SetStateAction<Workspace | null>>
|
||||
}
|
||||
|
||||
const WorkspaceContext = createContext<WorkspaceContextProps | null>(null)
|
||||
|
||||
export function useWorksapce() {
|
||||
const ctx = useContext(WorkspaceContext)
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('WorkspaceContext is null')
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function WorkspaceProvider({
|
||||
workspaces,
|
||||
children
|
||||
}: {
|
||||
workspaces: Workspace[]
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { orgid } = useParams()
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(
|
||||
() => workspaces.find((ws) => ws.id === orgid) ?? null
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkspaceContext
|
||||
value={{ workspaces, activeWorkspace, setActiveWorkspace }}
|
||||
>
|
||||
{children}
|
||||
</WorkspaceContext>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspaceSwitcher() {
|
||||
const location = useLocation()
|
||||
const { isMobile, state } = useSidebar()
|
||||
const { orgid } = useParams()
|
||||
const org = orgs.find((org) => org.id === orgid) as Org
|
||||
const [activeOrg, setActiveOrg] = useState<Org>(org)
|
||||
const { activeWorkspace, setActiveWorkspace, workspaces } = useWorksapce()
|
||||
const [, fragment, _] = location.pathname.slice(1).split('/')
|
||||
|
||||
const onSelect = (org: Org) => {
|
||||
setActiveOrg(org)
|
||||
window.location.assign(`/${org.id}/${fragment}`)
|
||||
const onSelect = (ws: Workspace) => {
|
||||
setActiveWorkspace(ws)
|
||||
window.location.assign(`/${ws.id}/${fragment}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -55,17 +92,20 @@ export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
|
||||
className="aria-expanded:border flex aspect-square size-8 items-center justify-center rounded-lg"
|
||||
aria-expanded={state === 'expanded'}
|
||||
>
|
||||
{initials(activeOrg?.name)}
|
||||
{initials(activeWorkspace?.name)}
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{activeOrg?.name}</span>
|
||||
<span className="truncate font-medium">
|
||||
{activeWorkspace?.name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{formatCNPJ(activeOrg?.cnpj)}
|
||||
{formatCNPJ(activeWorkspace?.cnpj)}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
align="start"
|
||||
@@ -75,20 +115,23 @@ export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
|
||||
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
||||
Empresas
|
||||
</DropdownMenuLabel>
|
||||
{orgs.map((org, index) => (
|
||||
|
||||
{workspaces.map((workspace, index) => (
|
||||
<DropdownMenuItem
|
||||
key={index}
|
||||
onClick={() => onSelect(org)}
|
||||
onClick={() => onSelect(workspace)}
|
||||
className="group gap-2 p-2 cursor-pointer aria-selected:pointer-events-none"
|
||||
aria-selected={org.id === activeOrg.id}
|
||||
aria-selected={workspace.id === activeWorkspace?.id}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-lg border">
|
||||
{initials(org?.name)}
|
||||
{initials(workspace?.name)}
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{org?.name}</span>
|
||||
<span className="truncate font-medium">
|
||||
{workspace?.name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{formatCNPJ(org?.cnpj)}
|
||||
{formatCNPJ(workspace?.cnpj)}
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenuShortcut className="not-group-aria-selected:hidden">
|
||||
@@ -96,7 +139,9 @@ export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem className="gap-2 p-2">
|
||||
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
|
||||
<PlusIcon className="size-4" />
|
||||
@@ -43,4 +43,4 @@ export default async function handleRequest(
|
||||
}
|
||||
|
||||
// https://reactrouter.com/how-to/suspense#timeouts
|
||||
export const streamTimeout = 6_000
|
||||
export const streamTimeout = 7_000
|
||||
|
||||
@@ -31,9 +31,15 @@ export function request({
|
||||
const requestId = context.get(requestIdContext) as string
|
||||
const user = context.get(userContext) as User
|
||||
const url_ = new URL(url, context.cloudflare.env.API_URL)
|
||||
const headers = new Headers(
|
||||
Object.assign({ Authorization: `Bearer ${user.accessToken}` }, _headers)
|
||||
)
|
||||
const headers = new Headers({
|
||||
Authorization: `Bearer ${user.accessToken}`
|
||||
})
|
||||
|
||||
if (_headers instanceof Headers) {
|
||||
_headers.forEach((value, key) => headers.set(key, value))
|
||||
} else {
|
||||
Object.entries(_headers).forEach(([key, value]) => headers.set(key, value))
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] [${requestId}] ${method} ${url_.toString()}`
|
||||
|
||||
@@ -119,9 +119,6 @@ export default function Route({ loaderData: { data } }) {
|
||||
{selectedRows.length ? (
|
||||
<>
|
||||
<div className="flex gap-2.5 items-center">
|
||||
<Button variant="outline">
|
||||
<TagIcon /> Marcador
|
||||
</Button>
|
||||
<DropdownMenuExport
|
||||
headers={headers}
|
||||
selectedRows={selectedRows}
|
||||
|
||||
@@ -43,7 +43,14 @@ export const columns: ColumnDef<Order>[] = [
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Data de venc.',
|
||||
header: 'Comprado em',
|
||||
cell: ({ row }) => {
|
||||
const createdAt = new Date(row.original.create_date)
|
||||
return formatted.format(createdAt)
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Vencimento em',
|
||||
cell: ({ row }) => {
|
||||
try {
|
||||
const dueDate = new Date(row.original.due_date)
|
||||
@@ -54,10 +61,14 @@ export const columns: ColumnDef<Order>[] = [
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Comprado em',
|
||||
header: 'Pago em',
|
||||
cell: ({ row }) => {
|
||||
const createdAt = new Date(row.original.create_date)
|
||||
if (row.original.payment_date) {
|
||||
const createdAt = new Date(row.original.payment_date)
|
||||
return formatted.format(createdAt)
|
||||
}
|
||||
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { Route } from './+types'
|
||||
|
||||
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PatternFormat } from 'react-number-format'
|
||||
import { Link, useOutletContext } from 'react-router'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { User } from '@/routes/_.$orgid.users.$id/route'
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
@@ -27,13 +24,8 @@ import { Input } from '@repo/ui/components/ui/input'
|
||||
import { Spinner } from '@repo/ui/components/ui/spinner'
|
||||
import { useForm } from 'react-hook-form'
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().trim().nonempty('Digite seu nome'),
|
||||
email: z.email(),
|
||||
cpf: z
|
||||
.string('CPF obrigatório')
|
||||
.refine(isValidCPF, { message: 'CPF inválido' })
|
||||
})
|
||||
import type { User } from '../_.$orgid.users.$id/route'
|
||||
import { formSchema, type Schema } from '../_.$orgid.users.add/route'
|
||||
|
||||
export default function Route() {
|
||||
const { user } = useOutletContext() as { user: User }
|
||||
@@ -43,7 +35,7 @@ export default function Route() {
|
||||
})
|
||||
const { handleSubmit, control, formState } = form
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof formSchema>) => {
|
||||
const onSubmit = async (data: Schema) => {
|
||||
console.log(data)
|
||||
}
|
||||
|
||||
@@ -133,7 +125,7 @@ export default function Route() {
|
||||
disabled={formState.isSubmitting}
|
||||
>
|
||||
{formState.isSubmitting && <Spinner />}
|
||||
Editar colaborador
|
||||
Editar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import type { Route } from './+types'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { Await } from 'react-router'
|
||||
import { Await, useOutletContext } from 'react-router'
|
||||
|
||||
import { request as req } from '@/lib/request'
|
||||
|
||||
import { Skeleton } from '@repo/ui/components/skeleton'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@repo/ui/components/ui/card'
|
||||
import { Item, ItemContent, ItemGroup } from '@repo/ui/components/ui/item'
|
||||
import { Kbd } from '@repo/ui/components/ui/kbd'
|
||||
import {
|
||||
NativeSelect,
|
||||
NativeSelectOption
|
||||
} from '@repo/ui/components/ui/native-select'
|
||||
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
import type { User } from '../_.$orgid.users.$id/route'
|
||||
|
||||
export async function loader({ params, request, context }: Route.LoaderArgs) {
|
||||
const { id } = params
|
||||
@@ -18,16 +35,58 @@ export async function loader({ params, request, context }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
export default function Route({ loaderData: { data } }) {
|
||||
const { user } = useOutletContext() as { user: User }
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<Await resolve={data}>
|
||||
{({ items = [] }) => (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Emails</CardTitle>
|
||||
<CardDescription>
|
||||
Podem ser associados vários emails a uma conta. É possível
|
||||
usar qualquer email para recuperar a senha, mas apenas o email
|
||||
principal receberá as mensagens.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul>
|
||||
{items.map(({ sk }: { sk: string }, idx: number) => {
|
||||
const [, email] = sk.split('#')
|
||||
return <li key={idx}>{email}</li>
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Email principal</CardTitle>
|
||||
<CardDescription>
|
||||
<Kbd className="font-mono border">{user.email}</Kbd> será
|
||||
usado para mensagens e pode ser usado para redefinições de
|
||||
senha.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex gap-1.5">
|
||||
<NativeSelect value={user.email}>
|
||||
{items.map(({ sk }: { sk: string }, idx: number) => {
|
||||
const [, email] = sk.split('#')
|
||||
return (
|
||||
<NativeSelectOption key={idx} value={email}>
|
||||
{email}
|
||||
</NativeSelectOption>
|
||||
)
|
||||
})}
|
||||
</NativeSelect>
|
||||
<Button>Mudar</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Await } from 'react-router'
|
||||
|
||||
import type { Route } from './+types'
|
||||
|
||||
import { request as req } from '@/lib/request'
|
||||
import { Skeleton } from '@repo/ui/components/skeleton'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export async function loader({ params, request, context }: Route.LoaderArgs) {
|
||||
const { id } = params
|
||||
const r = req({
|
||||
url: `/users/${id}/orgs`,
|
||||
request,
|
||||
context
|
||||
}).then((r) => r.json())
|
||||
|
||||
return { data: r }
|
||||
}
|
||||
|
||||
export default function Route({ loaderData: { data } }) {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<Await resolve={data}>
|
||||
{({ items = [] }) => (
|
||||
<ul>
|
||||
{items.map(
|
||||
({ name, cnpj }: { name: string; cnpj: string }, idx: number) => {
|
||||
return (
|
||||
<li key={idx}>
|
||||
{name} {cnpj}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'react-router'
|
||||
|
||||
import { request as req } from '@/lib/request'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -90,7 +91,7 @@ export default function Route({
|
||||
</Avatar>
|
||||
|
||||
<ul>
|
||||
<li className="font-bold">{user.name}</li>
|
||||
<li className="font-bold text-lg">{user.name}</li>
|
||||
<li className="text-muted-foreground text-sm">{user.email}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,23 @@
|
||||
|
||||
import { formatCPF } from '@brazilian-utils/brazilian-utils'
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import {
|
||||
ArrowRight,
|
||||
EllipsisVerticalIcon,
|
||||
PencilIcon,
|
||||
UserRoundMinusIcon
|
||||
} from 'lucide-react'
|
||||
import { NavLink } from 'react-router'
|
||||
|
||||
import { Abbr } from '@/components/abbr'
|
||||
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@repo/ui/components/ui/dropdown-menu'
|
||||
import { Spinner } from '@repo/ui/components/ui/spinner'
|
||||
import { initials } from '@repo/ui/lib/utils'
|
||||
|
||||
@@ -65,13 +76,6 @@ export const columns: ColumnDef<User>[] = [
|
||||
return <></>
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Cadastrado em',
|
||||
cell: ({ row }) => {
|
||||
const created_at = new Date(row.original.createDate)
|
||||
return formatted.format(created_at)
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Último accesso',
|
||||
cell: ({ row }) => {
|
||||
@@ -85,30 +89,47 @@ export const columns: ColumnDef<User>[] = [
|
||||
}
|
||||
},
|
||||
{
|
||||
header: ' ',
|
||||
header: 'Cadastrado em',
|
||||
meta: {
|
||||
className: 'w-1/12'
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
const created_at = new Date(row.original.createDate)
|
||||
return formatted.format(created_at)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative group"
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground cursor-pointer"
|
||||
size="icon-sm"
|
||||
>
|
||||
<NavLink to={`${row.original?.id}`}>
|
||||
<EllipsisVerticalIcon />
|
||||
<span className="sr-only">Abrir menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36 *:cursor-pointer">
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<NavLink to={`${row.id}`}>
|
||||
{({ isPending }) => (
|
||||
<>
|
||||
{isPending && <Spinner className="absolute" />}
|
||||
<span className="group-[.pending]:invisible">
|
||||
{isPending ? <Spinner /> : <PencilIcon />}
|
||||
Editar
|
||||
</span>{' '}
|
||||
<ArrowRight className="group-[.pending]:invisible" />
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<UserRoundMinusIcon /> Desvincular
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -61,6 +61,7 @@ export default function Route({ loaderData: { data } }) {
|
||||
{({ hits, page, hitsPerPage, totalHits }) => {
|
||||
return (
|
||||
<DataTable
|
||||
sort={[{ id: 'created_at', desc: true }]}
|
||||
columns={columns}
|
||||
data={hits as User[]}
|
||||
pageIndex={page - 1}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Link } from 'react-router'
|
||||
import type { Route } from './+types'
|
||||
|
||||
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { PatternFormat } from 'react-number-format'
|
||||
import { Link, useFetcher } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -8,6 +16,7 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from '@repo/ui/components/ui/breadcrumb'
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -15,8 +24,91 @@ import {
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@repo/ui/components/ui/card'
|
||||
import { Checkbox } from '@repo/ui/components/ui/checkbox'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@repo/ui/components/ui/form'
|
||||
import { Input } from '@repo/ui/components/ui/input'
|
||||
import { Label } from '@repo/ui/components/ui/label'
|
||||
import { Spinner } from '@repo/ui/components/ui/spinner'
|
||||
|
||||
import { useWorksapce } from '@/components/workspace-switcher'
|
||||
import { HttpMethod, request as req } from '@/lib/request'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const isName = (name: string) => name && name.includes(' ')
|
||||
|
||||
export const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.nonempty('Digite um nome')
|
||||
.refine(isName, { message: 'Nome inválido' }),
|
||||
email: z.email('Email inválido').trim().toLowerCase(),
|
||||
cpf: z
|
||||
.string('CPF obrigatório')
|
||||
.refine(isValidCPF, { message: 'CPF inválido' })
|
||||
})
|
||||
|
||||
export type Schema = z.infer<typeof formSchema>
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Adicionar colaborador' }]
|
||||
}
|
||||
|
||||
export async function action({ request, context }: Route.ActionArgs) {
|
||||
const body = await request.json()
|
||||
const r = await req({
|
||||
url: `users`,
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||
method: HttpMethod.POST,
|
||||
body: JSON.stringify(body),
|
||||
request,
|
||||
context
|
||||
})
|
||||
|
||||
console.log(r)
|
||||
|
||||
if (!r.ok) {
|
||||
const error = await r.json().catch(() => ({}))
|
||||
return { ok: false, error }
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export default function Route() {
|
||||
const fetcher = useFetcher()
|
||||
const { activeWorkspace } = useWorksapce()
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema)
|
||||
})
|
||||
const { handleSubmit, control, formState, reset } = form
|
||||
|
||||
const onSubmit = async (user: Schema) => {
|
||||
await fetcher.submit(JSON.stringify({ user, org: activeWorkspace }), {
|
||||
method: 'post',
|
||||
encType: 'application/json'
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.data?.ok) {
|
||||
toast.success('O colaborador foi adicionado')
|
||||
return reset()
|
||||
}
|
||||
|
||||
switch (fetcher.data?.error?.type) {
|
||||
case 'UserConflictError':
|
||||
toast.error('O colaborador já foi vinculado anteriormente')
|
||||
}
|
||||
}, [fetcher.data])
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<Breadcrumb>
|
||||
@@ -33,17 +125,97 @@ export default function Route() {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div className="lg:max-w-2xl mx-auto space-y-2.5">
|
||||
<div className="lg:max-w-2xl mx-auto">
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Adicionar colaborador</CardTitle>
|
||||
<CardTitle className="text-2xl">
|
||||
Adicionar colaborador
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Siga os passos abaixo para cadastrar um novo colaborador
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent></CardContent>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nome</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<FormField
|
||||
control={control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="terms" tabIndex={-1} />
|
||||
<Label htmlFor="terms">
|
||||
Usar um email fornecido pela plataforma.
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="cpf"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ref, ...props } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>CPF</FormLabel>
|
||||
<FormControl>
|
||||
<PatternFormat
|
||||
format="###.###.###-##"
|
||||
mask="_"
|
||||
placeholder="___.___.___-__"
|
||||
customInput={Input}
|
||||
getInputRef={ref}
|
||||
onValueChange={({ value }) => {
|
||||
onChange(value)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
className="cursor-pointer"
|
||||
disabled={formState.isSubmitting}
|
||||
>
|
||||
{formState.isSubmitting && <Spinner />}
|
||||
Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Outlet, type ShouldRevalidateFunctionArgs } from 'react-router'
|
||||
import { AppSidebar } from '@/components/app-sidebar'
|
||||
import { request as req } from '@/lib/request'
|
||||
|
||||
import { WorkspaceProvider } from '@/components/workspace-switcher'
|
||||
import { userContext } from '@repo/auth/context'
|
||||
import { authMiddleware } from '@repo/auth/middleware/auth'
|
||||
import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode'
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
SidebarProvider,
|
||||
SidebarTrigger
|
||||
} from '@repo/ui/components/ui/sidebar'
|
||||
import { Toaster } from '@repo/ui/components/ui/sonner'
|
||||
|
||||
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
|
||||
|
||||
@@ -59,8 +61,9 @@ export default function Route({ loaderData }: Route.ComponentProps) {
|
||||
const { user, orgs, sidebar_state } = loaderData
|
||||
|
||||
return (
|
||||
<WorkspaceProvider workspaces={orgs}>
|
||||
<SidebarProvider defaultOpen={sidebar_state === 'true'} className="flex">
|
||||
<AppSidebar orgs={orgs} />
|
||||
<AppSidebar />
|
||||
|
||||
<SidebarInset className="relative flex flex-col flex-1 min-w-0">
|
||||
<header
|
||||
@@ -79,11 +82,13 @@ export default function Route({ loaderData }: Route.ComponentProps) {
|
||||
</header>
|
||||
|
||||
<main className="p-4">
|
||||
<div className="container mx-auto">
|
||||
<div className="container mx-auto relative">
|
||||
<Outlet />
|
||||
<Toaster position="top-center" richColors={true} />
|
||||
</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</WorkspaceProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import type { User } from '@/middleware/auth'
|
||||
import { createContext } from 'react-router'
|
||||
|
||||
export const userContext = createContext<User | null>(null)
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LoaderFunctionArgs } from 'react-router'
|
||||
|
||||
import type { User } from '@repo/auth/auth'
|
||||
import { userContext } from '@repo/auth/context'
|
||||
import { requestIdContext, userContext } from '@repo/auth/context'
|
||||
|
||||
import type { LoaderFunctionArgs } from 'react-router'
|
||||
|
||||
export enum HttpMethod {
|
||||
GET = 'GET',
|
||||
@@ -23,23 +23,21 @@ type RequestArgs = {
|
||||
export function request({
|
||||
url,
|
||||
method = HttpMethod.GET,
|
||||
headers: _headers = {},
|
||||
body = null,
|
||||
headers: _headers = {},
|
||||
request: { signal },
|
||||
context
|
||||
}: RequestArgs): Promise<Response> {
|
||||
const requestId = context.get(requestIdContext) as string
|
||||
const user = context.get(userContext) as User
|
||||
// @ts-ignore
|
||||
const url_ = new URL(url, context.cloudflare.env.API_URL)
|
||||
const headers = new Headers(
|
||||
Object.assign(_headers, { Authorization: `Bearer ${user.accessToken}` })
|
||||
Object.assign({ Authorization: `Bearer ${user.accessToken}` }, _headers)
|
||||
)
|
||||
// @ts-ignore
|
||||
const endpoint = new URL(url, context.cloudflare.env.API_URL)
|
||||
|
||||
return fetch(endpoint.toString(), {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal
|
||||
})
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] [${requestId}] ${method} ${url_.toString()}`
|
||||
)
|
||||
|
||||
return fetch(url_.toString(), { method, headers, body, signal })
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { Route } from './+types/edit'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
FileBadgeIcon,
|
||||
FileCode2Icon,
|
||||
MoreHorizontalIcon
|
||||
} from 'lucide-react'
|
||||
import { 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'
|
||||
@@ -15,15 +10,7 @@ import { z } from 'zod'
|
||||
|
||||
import { HttpMethod, request as req } from '@/lib/request'
|
||||
|
||||
import type { User } from '@repo/auth/auth'
|
||||
import { userContext } from '@repo/auth/context'
|
||||
|
||||
import { Skeleton } from '@repo/ui/components/skeleton'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle
|
||||
} from '@repo/ui/components/ui/alert'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -66,7 +53,7 @@ import {
|
||||
import { Spinner } from '@repo/ui/components/ui/spinner'
|
||||
import { Switch } from '@repo/ui/components/ui/switch'
|
||||
|
||||
const schema = z
|
||||
const formSchema = z
|
||||
.object({
|
||||
given_cert: z.coerce.boolean(),
|
||||
never_expires: z.coerce.boolean(),
|
||||
@@ -94,7 +81,7 @@ const schema = z
|
||||
}
|
||||
)
|
||||
|
||||
type Schema = z.infer<typeof schema>
|
||||
type Schema = z.infer<typeof formSchema>
|
||||
|
||||
type Cert = {
|
||||
exp_interval: number
|
||||
@@ -132,16 +119,12 @@ export const loader = async ({
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
@@ -180,7 +163,7 @@ function Editing() {
|
||||
const fetcher = useFetcher()
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
draft: false,
|
||||
given_cert: !!course?.cert,
|
||||
@@ -415,7 +398,7 @@ function Editing() {
|
||||
disabled={formState.isSubmitting}
|
||||
>
|
||||
{formState.isSubmitting && <Spinner />}
|
||||
Editar curso
|
||||
Editar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -10,7 +10,7 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
from boto3clients import dynamodb_client
|
||||
from config import API_URL, COURSE_TABLE
|
||||
|
||||
course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
|
||||
dyn = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
@event_source(data_class=DynamoDBStreamEvent)
|
||||
@@ -21,7 +21,7 @@ def lambda_handler(event: DynamoDBStreamEvent, context: LambdaContext) -> bool:
|
||||
data = r.json()
|
||||
now_ = now()
|
||||
|
||||
with course_layer.transact_writer() as transact:
|
||||
with dyn.transact_writer() as transact:
|
||||
for course in data:
|
||||
transact.update(
|
||||
key=KeyPair(course['id'], '0'),
|
||||
@@ -14,7 +14,7 @@ Globals:
|
||||
Architectures:
|
||||
- x86_64
|
||||
Layers:
|
||||
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:83
|
||||
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:100
|
||||
Environment:
|
||||
Variables:
|
||||
TZ: America/Sao_Paulo
|
||||
@@ -28,19 +28,19 @@ Resources:
|
||||
EventLog:
|
||||
Type: AWS::Logs::LogGroup
|
||||
Properties:
|
||||
RetentionInDays: 90
|
||||
RetentionInDays: 7
|
||||
|
||||
EventDailySyncCourseMetadataFunction:
|
||||
EventCopyCourseMetadataScheduledFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: events.daily_sync_course_metadata.lambda_handler
|
||||
Handler: events.copy_course_metadata.lambda_handler
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref EventLog
|
||||
Policies:
|
||||
- DynamoDBWritePolicy:
|
||||
TableName: !Ref CourseTable
|
||||
Events:
|
||||
Rule:
|
||||
ScheduleEvent:
|
||||
Type: ScheduleV2
|
||||
Properties:
|
||||
ScheduleExpression: cron(0 0 * * ? *)
|
||||
|
||||
@@ -3,7 +3,7 @@ from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
|
||||
|
||||
def test_daily_sync_course_metadata(
|
||||
def test_copy_course_metadata(
|
||||
dynamodb_client,
|
||||
dynamodb_seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
@@ -20,5 +20,7 @@ export const loggingMiddleware = async (
|
||||
`[${new Date().toISOString()}] [${requestId}] Response ${response.status} (${duration}ms)`
|
||||
)
|
||||
|
||||
response.headers.set('Server-Timing', `page;req=${requestId};dur=${duration}`)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export function ModeToggle() {
|
||||
|
||||
type ThemedImageProps = {
|
||||
children?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ThemedImage({ children, ...props }: ThemedImageProps) {
|
||||
|
||||
193
packages/ui/src/components/ui/item.tsx
Normal file
193
packages/ui/src/components/ui/item.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
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"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn("group/item-group flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border-border",
|
||||
muted: "bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "p-4 gap-4 ",
|
||||
sm: "py-3 px-4 gap-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> &
|
||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
48
packages/ui/src/components/ui/native-select.tsx
Normal file
48
packages/ui/src/components/ui/native-select.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function NativeSelect({ className, ...props }: React.ComponentProps<"select">) {
|
||||
return (
|
||||
<div
|
||||
className="group/native-select relative w-fit has-[select:disabled]:opacity-50"
|
||||
data-slot="native-select-wrapper"
|
||||
>
|
||||
<select
|
||||
data-slot="native-select"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 h-9 w-full min-w-0 appearance-none rounded-md border bg-transparent px-3 py-2 pr-9 text-sm shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed",
|
||||
"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}
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 opacity-50 select-none"
|
||||
aria-hidden="true"
|
||||
data-slot="native-select-icon"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NativeSelectOption({ ...props }: React.ComponentProps<"option">) {
|
||||
return <option data-slot="native-select-option" {...props} />
|
||||
}
|
||||
|
||||
function NativeSelectOptGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"optgroup">) {
|
||||
return (
|
||||
<optgroup
|
||||
data-slot="native-select-optgroup"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user