Commit ·
5e44686
1
Parent(s): 92fcc3b
Jobs Page.
Browse files- backend/main.py +12 -0
- frontend/app/app.css +3 -4
- frontend/app/components/paginator.tsx +37 -0
- frontend/app/components/ui/combobox.tsx +310 -0
- frontend/app/components/ui/input-group.tsx +168 -0
- frontend/app/components/ui/textarea.tsx +18 -0
- frontend/app/hooks/use-pagination.ts +31 -0
- frontend/app/managers/HTTPManager.ts +21 -0
- frontend/app/root.tsx +11 -4
- frontend/app/routes/home.tsx +72 -4
- frontend/app/services/useGetJobs.ts +25 -0
- frontend/app/types/pagination.ts +5 -0
- frontend/package-lock.json +239 -0
- frontend/package.json +4 -0
backend/main.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from contextlib import asynccontextmanager
|
| 2 |
from fastapi import FastAPI
|
|
|
|
| 3 |
import os
|
| 4 |
|
| 5 |
# Import from our modules
|
|
@@ -35,6 +36,17 @@ app = FastAPI(
|
|
| 35 |
lifespan=lifespan
|
| 36 |
)
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
# Include API routes
|
| 39 |
app.include_router(root_router)
|
| 40 |
app.include_router(user_router)
|
|
|
|
| 1 |
from contextlib import asynccontextmanager
|
| 2 |
from fastapi import FastAPI
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
import os
|
| 5 |
|
| 6 |
# Import from our modules
|
|
|
|
| 36 |
lifespan=lifespan
|
| 37 |
)
|
| 38 |
|
| 39 |
+
# Configure CORS for frontend dev server(s)
|
| 40 |
+
# Default to allowing the common Vite port and localhost:3000 for React dev
|
| 41 |
+
origins = os.getenv("CORS_ORIGINS", "http://localhost:5173,http://localhost:3000").split(",")
|
| 42 |
+
app.add_middleware(
|
| 43 |
+
CORSMiddleware,
|
| 44 |
+
allow_origins=[o.strip() for o in origins if o.strip()],
|
| 45 |
+
allow_credentials=True,
|
| 46 |
+
allow_methods=["*"],
|
| 47 |
+
allow_headers=["*"],
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
# Include API routes
|
| 51 |
app.include_router(root_router)
|
| 52 |
app.include_router(user_router)
|
frontend/app/app.css
CHANGED
|
@@ -9,7 +9,6 @@
|
|
| 9 |
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
| 10 |
}
|
| 11 |
|
| 12 |
-
html,
|
| 13 |
body {
|
| 14 |
@apply bg-indigo-50 text-gray-900 dark:bg-gray-900 dark:text-white;
|
| 15 |
|
|
@@ -74,8 +73,8 @@ body {
|
|
| 74 |
--accent: rgb(243, 244, 246);
|
| 75 |
--accent-foreground: rgb(16, 24, 40);
|
| 76 |
--destructive: rgb(231, 0, 11);
|
| 77 |
-
--border: rgb(
|
| 78 |
-
--input: rgb(
|
| 79 |
--ring: rgb(153, 161, 175);
|
| 80 |
--chart-1: rgb(245, 73, 0);
|
| 81 |
--chart-2: rgb(0, 150, 137);
|
|
@@ -107,7 +106,7 @@ body {
|
|
| 107 |
--accent-foreground: rgb(249, 250, 251);
|
| 108 |
--destructive: rgb(255, 100, 103);
|
| 109 |
--border: rgba(255, 255, 255, 0.1);
|
| 110 |
-
--input: rgba(255, 255, 255, 0.
|
| 111 |
--ring: rgb(106, 114, 130);
|
| 112 |
--chart-1: rgb(20, 71, 230);
|
| 113 |
--chart-2: rgb(0, 188, 125);
|
|
|
|
| 9 |
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
| 10 |
}
|
| 11 |
|
|
|
|
| 12 |
body {
|
| 13 |
@apply bg-indigo-50 text-gray-900 dark:bg-gray-900 dark:text-white;
|
| 14 |
|
|
|
|
| 73 |
--accent: rgb(243, 244, 246);
|
| 74 |
--accent-foreground: rgb(16, 24, 40);
|
| 75 |
--destructive: rgb(231, 0, 11);
|
| 76 |
+
--border: rgb(141, 160, 197);
|
| 77 |
+
--input: rgb(141, 160, 197);
|
| 78 |
--ring: rgb(153, 161, 175);
|
| 79 |
--chart-1: rgb(245, 73, 0);
|
| 80 |
--chart-2: rgb(0, 150, 137);
|
|
|
|
| 106 |
--accent-foreground: rgb(249, 250, 251);
|
| 107 |
--destructive: rgb(255, 100, 103);
|
| 108 |
--border: rgba(255, 255, 255, 0.1);
|
| 109 |
+
--input: rgba(255, 255, 255, 0.1);
|
| 110 |
--ring: rgb(106, 114, 130);
|
| 111 |
--chart-1: rgb(20, 71, 230);
|
| 112 |
--chart-2: rgb(0, 188, 125);
|
frontend/app/components/paginator.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button } from "./ui/button";
|
| 2 |
+
import { usePagination } from "~/hooks/use-pagination";
|
| 3 |
+
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "./ui/combobox";
|
| 4 |
+
|
| 5 |
+
export function Paginator({ total }: { total: number }) {
|
| 6 |
+
const { page, limit, setPage, setLimit } = usePagination();
|
| 7 |
+
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, limit)));
|
| 8 |
+
return (
|
| 9 |
+
<footer className="flex place-items-center place-content-between gap-4 flex-wrap">
|
| 10 |
+
<div className="flex flex-wrap gap-4 place-items-center">
|
| 11 |
+
<Button onClick={() => setPage(Math.max(1, page - 1))} disabled={page <= 1}>
|
| 12 |
+
Prev
|
| 13 |
+
</Button>
|
| 14 |
+
<span>Page {page} of {totalPages}</span>
|
| 15 |
+
<Button onClick={() => setPage(Math.min(totalPages, page + 1))} disabled={page >= totalPages}>
|
| 16 |
+
Next
|
| 17 |
+
</Button>
|
| 18 |
+
</div>
|
| 19 |
+
<div className="flex place-items-center gap-4">
|
| 20 |
+
<p>Per page:</p>
|
| 21 |
+
<Combobox items={[10, 25, 50, 100]} value={limit} onValueChange={(value) => { setLimit(value || 10); setPage(1); }}>
|
| 22 |
+
<ComboboxInput placeholder="Choose value" />
|
| 23 |
+
<ComboboxContent>
|
| 24 |
+
<ComboboxEmpty>No items found.</ComboboxEmpty>
|
| 25 |
+
<ComboboxList>
|
| 26 |
+
{(item) => (
|
| 27 |
+
<ComboboxItem key={item} value={item}>
|
| 28 |
+
{item}
|
| 29 |
+
</ComboboxItem>
|
| 30 |
+
)}
|
| 31 |
+
</ComboboxList>
|
| 32 |
+
</ComboboxContent>
|
| 33 |
+
</Combobox>
|
| 34 |
+
</div>
|
| 35 |
+
</footer>
|
| 36 |
+
)
|
| 37 |
+
}
|
frontend/app/components/ui/combobox.tsx
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
| 5 |
+
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "~/lib/utils"
|
| 8 |
+
import { Button } from "~/components/ui/button"
|
| 9 |
+
import {
|
| 10 |
+
InputGroup,
|
| 11 |
+
InputGroupAddon,
|
| 12 |
+
InputGroupButton,
|
| 13 |
+
InputGroupInput,
|
| 14 |
+
} from "~/components/ui/input-group"
|
| 15 |
+
|
| 16 |
+
const Combobox = ComboboxPrimitive.Root
|
| 17 |
+
|
| 18 |
+
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
| 19 |
+
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function ComboboxTrigger({
|
| 23 |
+
className,
|
| 24 |
+
children,
|
| 25 |
+
...props
|
| 26 |
+
}: ComboboxPrimitive.Trigger.Props) {
|
| 27 |
+
return (
|
| 28 |
+
<ComboboxPrimitive.Trigger
|
| 29 |
+
data-slot="combobox-trigger"
|
| 30 |
+
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
| 31 |
+
{...props}
|
| 32 |
+
>
|
| 33 |
+
{children}
|
| 34 |
+
<ChevronDownIcon
|
| 35 |
+
data-slot="combobox-trigger-icon"
|
| 36 |
+
className="text-muted-foreground pointer-events-none size-4"
|
| 37 |
+
/>
|
| 38 |
+
</ComboboxPrimitive.Trigger>
|
| 39 |
+
)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
| 43 |
+
return (
|
| 44 |
+
<ComboboxPrimitive.Clear
|
| 45 |
+
data-slot="combobox-clear"
|
| 46 |
+
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
| 47 |
+
className={cn(className)}
|
| 48 |
+
{...props}
|
| 49 |
+
>
|
| 50 |
+
<XIcon className="pointer-events-none" />
|
| 51 |
+
</ComboboxPrimitive.Clear>
|
| 52 |
+
)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function ComboboxInput({
|
| 56 |
+
className,
|
| 57 |
+
children,
|
| 58 |
+
disabled = false,
|
| 59 |
+
showTrigger = true,
|
| 60 |
+
showClear = false,
|
| 61 |
+
...props
|
| 62 |
+
}: ComboboxPrimitive.Input.Props & {
|
| 63 |
+
showTrigger?: boolean
|
| 64 |
+
showClear?: boolean
|
| 65 |
+
}) {
|
| 66 |
+
return (
|
| 67 |
+
<InputGroup className={cn("w-auto", className)}>
|
| 68 |
+
<ComboboxPrimitive.Input
|
| 69 |
+
render={<InputGroupInput disabled={disabled} />}
|
| 70 |
+
{...props}
|
| 71 |
+
/>
|
| 72 |
+
<InputGroupAddon align="inline-end">
|
| 73 |
+
{showTrigger && (
|
| 74 |
+
<InputGroupButton
|
| 75 |
+
size="icon-xs"
|
| 76 |
+
variant="ghost"
|
| 77 |
+
asChild
|
| 78 |
+
data-slot="input-group-button"
|
| 79 |
+
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
| 80 |
+
disabled={disabled}
|
| 81 |
+
>
|
| 82 |
+
<ComboboxTrigger />
|
| 83 |
+
</InputGroupButton>
|
| 84 |
+
)}
|
| 85 |
+
{showClear && <ComboboxClear disabled={disabled} />}
|
| 86 |
+
</InputGroupAddon>
|
| 87 |
+
{children}
|
| 88 |
+
</InputGroup>
|
| 89 |
+
)
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function ComboboxContent({
|
| 93 |
+
className,
|
| 94 |
+
side = "bottom",
|
| 95 |
+
sideOffset = 6,
|
| 96 |
+
align = "start",
|
| 97 |
+
alignOffset = 0,
|
| 98 |
+
anchor,
|
| 99 |
+
...props
|
| 100 |
+
}: ComboboxPrimitive.Popup.Props &
|
| 101 |
+
Pick<
|
| 102 |
+
ComboboxPrimitive.Positioner.Props,
|
| 103 |
+
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
| 104 |
+
>) {
|
| 105 |
+
return (
|
| 106 |
+
<ComboboxPrimitive.Portal>
|
| 107 |
+
<ComboboxPrimitive.Positioner
|
| 108 |
+
side={side}
|
| 109 |
+
sideOffset={sideOffset}
|
| 110 |
+
align={align}
|
| 111 |
+
alignOffset={alignOffset}
|
| 112 |
+
anchor={anchor}
|
| 113 |
+
className="isolate z-50"
|
| 114 |
+
>
|
| 115 |
+
<ComboboxPrimitive.Popup
|
| 116 |
+
data-slot="combobox-content"
|
| 117 |
+
data-chips={!!anchor}
|
| 118 |
+
className={cn(
|
| 119 |
+
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none",
|
| 120 |
+
className
|
| 121 |
+
)}
|
| 122 |
+
{...props}
|
| 123 |
+
/>
|
| 124 |
+
</ComboboxPrimitive.Positioner>
|
| 125 |
+
</ComboboxPrimitive.Portal>
|
| 126 |
+
)
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
| 130 |
+
return (
|
| 131 |
+
<ComboboxPrimitive.List
|
| 132 |
+
data-slot="combobox-list"
|
| 133 |
+
className={cn(
|
| 134 |
+
"max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0",
|
| 135 |
+
className
|
| 136 |
+
)}
|
| 137 |
+
{...props}
|
| 138 |
+
/>
|
| 139 |
+
)
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function ComboboxItem({
|
| 143 |
+
className,
|
| 144 |
+
children,
|
| 145 |
+
...props
|
| 146 |
+
}: ComboboxPrimitive.Item.Props) {
|
| 147 |
+
return (
|
| 148 |
+
<ComboboxPrimitive.Item
|
| 149 |
+
data-slot="combobox-item"
|
| 150 |
+
className={cn(
|
| 151 |
+
"data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 152 |
+
className
|
| 153 |
+
)}
|
| 154 |
+
{...props}
|
| 155 |
+
>
|
| 156 |
+
{children}
|
| 157 |
+
<ComboboxPrimitive.ItemIndicator
|
| 158 |
+
data-slot="combobox-item-indicator"
|
| 159 |
+
render={
|
| 160 |
+
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
| 161 |
+
}
|
| 162 |
+
>
|
| 163 |
+
<CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" />
|
| 164 |
+
</ComboboxPrimitive.ItemIndicator>
|
| 165 |
+
</ComboboxPrimitive.Item>
|
| 166 |
+
)
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
| 170 |
+
return (
|
| 171 |
+
<ComboboxPrimitive.Group
|
| 172 |
+
data-slot="combobox-group"
|
| 173 |
+
className={cn(className)}
|
| 174 |
+
{...props}
|
| 175 |
+
/>
|
| 176 |
+
)
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function ComboboxLabel({
|
| 180 |
+
className,
|
| 181 |
+
...props
|
| 182 |
+
}: ComboboxPrimitive.GroupLabel.Props) {
|
| 183 |
+
return (
|
| 184 |
+
<ComboboxPrimitive.GroupLabel
|
| 185 |
+
data-slot="combobox-label"
|
| 186 |
+
className={cn(
|
| 187 |
+
"text-muted-foreground px-2 py-1.5 text-xs pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
|
| 188 |
+
className
|
| 189 |
+
)}
|
| 190 |
+
{...props}
|
| 191 |
+
/>
|
| 192 |
+
)
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
| 196 |
+
return (
|
| 197 |
+
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
| 198 |
+
)
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
| 202 |
+
return (
|
| 203 |
+
<ComboboxPrimitive.Empty
|
| 204 |
+
data-slot="combobox-empty"
|
| 205 |
+
className={cn(
|
| 206 |
+
"text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex",
|
| 207 |
+
className
|
| 208 |
+
)}
|
| 209 |
+
{...props}
|
| 210 |
+
/>
|
| 211 |
+
)
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
function ComboboxSeparator({
|
| 215 |
+
className,
|
| 216 |
+
...props
|
| 217 |
+
}: ComboboxPrimitive.Separator.Props) {
|
| 218 |
+
return (
|
| 219 |
+
<ComboboxPrimitive.Separator
|
| 220 |
+
data-slot="combobox-separator"
|
| 221 |
+
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
| 222 |
+
{...props}
|
| 223 |
+
/>
|
| 224 |
+
)
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function ComboboxChips({
|
| 228 |
+
className,
|
| 229 |
+
...props
|
| 230 |
+
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
| 231 |
+
ComboboxPrimitive.Chips.Props) {
|
| 232 |
+
return (
|
| 233 |
+
<ComboboxPrimitive.Chips
|
| 234 |
+
data-slot="combobox-chips"
|
| 235 |
+
className={cn(
|
| 236 |
+
"dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-[3px] has-aria-invalid:ring-[3px] has-data-[slot=combobox-chip]:px-1.5",
|
| 237 |
+
className
|
| 238 |
+
)}
|
| 239 |
+
{...props}
|
| 240 |
+
/>
|
| 241 |
+
)
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
function ComboboxChip({
|
| 245 |
+
className,
|
| 246 |
+
children,
|
| 247 |
+
showRemove = true,
|
| 248 |
+
...props
|
| 249 |
+
}: ComboboxPrimitive.Chip.Props & {
|
| 250 |
+
showRemove?: boolean
|
| 251 |
+
}) {
|
| 252 |
+
return (
|
| 253 |
+
<ComboboxPrimitive.Chip
|
| 254 |
+
data-slot="combobox-chip"
|
| 255 |
+
className={cn(
|
| 256 |
+
"bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
| 257 |
+
className
|
| 258 |
+
)}
|
| 259 |
+
{...props}
|
| 260 |
+
>
|
| 261 |
+
{children}
|
| 262 |
+
{showRemove && (
|
| 263 |
+
<ComboboxPrimitive.ChipRemove
|
| 264 |
+
render={<Button variant="ghost" size="icon-xs" />}
|
| 265 |
+
className="-ml-1 opacity-50 hover:opacity-100"
|
| 266 |
+
data-slot="combobox-chip-remove"
|
| 267 |
+
>
|
| 268 |
+
<XIcon className="pointer-events-none" />
|
| 269 |
+
</ComboboxPrimitive.ChipRemove>
|
| 270 |
+
)}
|
| 271 |
+
</ComboboxPrimitive.Chip>
|
| 272 |
+
)
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
function ComboboxChipsInput({
|
| 276 |
+
className,
|
| 277 |
+
children,
|
| 278 |
+
...props
|
| 279 |
+
}: ComboboxPrimitive.Input.Props) {
|
| 280 |
+
return (
|
| 281 |
+
<ComboboxPrimitive.Input
|
| 282 |
+
data-slot="combobox-chip-input"
|
| 283 |
+
className={cn("min-w-16 flex-1 outline-none", className)}
|
| 284 |
+
{...props}
|
| 285 |
+
/>
|
| 286 |
+
)
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
function useComboboxAnchor() {
|
| 290 |
+
return React.useRef<HTMLDivElement | null>(null)
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
export {
|
| 294 |
+
Combobox,
|
| 295 |
+
ComboboxInput,
|
| 296 |
+
ComboboxContent,
|
| 297 |
+
ComboboxList,
|
| 298 |
+
ComboboxItem,
|
| 299 |
+
ComboboxGroup,
|
| 300 |
+
ComboboxLabel,
|
| 301 |
+
ComboboxCollection,
|
| 302 |
+
ComboboxEmpty,
|
| 303 |
+
ComboboxSeparator,
|
| 304 |
+
ComboboxChips,
|
| 305 |
+
ComboboxChip,
|
| 306 |
+
ComboboxChipsInput,
|
| 307 |
+
ComboboxTrigger,
|
| 308 |
+
ComboboxValue,
|
| 309 |
+
useComboboxAnchor,
|
| 310 |
+
}
|
frontend/app/components/ui/input-group.tsx
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
+
|
| 4 |
+
import { cn } from "~/lib/utils"
|
| 5 |
+
import { Button } from "~/components/ui/button"
|
| 6 |
+
import { Input } from "~/components/ui/input"
|
| 7 |
+
import { Textarea } from "~/components/ui/textarea"
|
| 8 |
+
|
| 9 |
+
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
| 10 |
+
return (
|
| 11 |
+
<div
|
| 12 |
+
data-slot="input-group"
|
| 13 |
+
role="group"
|
| 14 |
+
className={cn(
|
| 15 |
+
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
| 16 |
+
"h-9 min-w-0 has-[>textarea]:h-auto",
|
| 17 |
+
|
| 18 |
+
// Variants based on alignment.
|
| 19 |
+
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
| 20 |
+
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
| 21 |
+
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
| 22 |
+
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
| 23 |
+
|
| 24 |
+
// Focus state.
|
| 25 |
+
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
| 26 |
+
|
| 27 |
+
// Error state.
|
| 28 |
+
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
| 29 |
+
|
| 30 |
+
className
|
| 31 |
+
)}
|
| 32 |
+
{...props}
|
| 33 |
+
/>
|
| 34 |
+
)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const inputGroupAddonVariants = cva(
|
| 38 |
+
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
| 39 |
+
{
|
| 40 |
+
variants: {
|
| 41 |
+
align: {
|
| 42 |
+
"inline-start":
|
| 43 |
+
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
| 44 |
+
"inline-end":
|
| 45 |
+
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
| 46 |
+
"block-start":
|
| 47 |
+
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
| 48 |
+
"block-end":
|
| 49 |
+
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
| 50 |
+
},
|
| 51 |
+
},
|
| 52 |
+
defaultVariants: {
|
| 53 |
+
align: "inline-start",
|
| 54 |
+
},
|
| 55 |
+
}
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
function InputGroupAddon({
|
| 59 |
+
className,
|
| 60 |
+
align = "inline-start",
|
| 61 |
+
...props
|
| 62 |
+
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
| 63 |
+
return (
|
| 64 |
+
<div
|
| 65 |
+
role="group"
|
| 66 |
+
data-slot="input-group-addon"
|
| 67 |
+
data-align={align}
|
| 68 |
+
className={cn(inputGroupAddonVariants({ align }), className)}
|
| 69 |
+
onClick={(e) => {
|
| 70 |
+
if ((e.target as HTMLElement).closest("button")) {
|
| 71 |
+
return
|
| 72 |
+
}
|
| 73 |
+
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
| 74 |
+
}}
|
| 75 |
+
{...props}
|
| 76 |
+
/>
|
| 77 |
+
)
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const inputGroupButtonVariants = cva(
|
| 81 |
+
"text-sm shadow-none flex gap-2 items-center",
|
| 82 |
+
{
|
| 83 |
+
variants: {
|
| 84 |
+
size: {
|
| 85 |
+
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
| 86 |
+
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
| 87 |
+
"icon-xs":
|
| 88 |
+
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
| 89 |
+
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
| 90 |
+
},
|
| 91 |
+
},
|
| 92 |
+
defaultVariants: {
|
| 93 |
+
size: "xs",
|
| 94 |
+
},
|
| 95 |
+
}
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
function InputGroupButton({
|
| 99 |
+
className,
|
| 100 |
+
type = "button",
|
| 101 |
+
variant = "ghost",
|
| 102 |
+
size = "xs",
|
| 103 |
+
...props
|
| 104 |
+
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
| 105 |
+
VariantProps<typeof inputGroupButtonVariants>) {
|
| 106 |
+
return (
|
| 107 |
+
<Button
|
| 108 |
+
type={type}
|
| 109 |
+
data-size={size}
|
| 110 |
+
variant={variant}
|
| 111 |
+
className={cn(inputGroupButtonVariants({ size }), className)}
|
| 112 |
+
{...props}
|
| 113 |
+
/>
|
| 114 |
+
)
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
| 118 |
+
return (
|
| 119 |
+
<span
|
| 120 |
+
className={cn(
|
| 121 |
+
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
| 122 |
+
className
|
| 123 |
+
)}
|
| 124 |
+
{...props}
|
| 125 |
+
/>
|
| 126 |
+
)
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function InputGroupInput({
|
| 130 |
+
className,
|
| 131 |
+
...props
|
| 132 |
+
}: React.ComponentProps<"input">) {
|
| 133 |
+
return (
|
| 134 |
+
<Input
|
| 135 |
+
data-slot="input-group-control"
|
| 136 |
+
className={cn(
|
| 137 |
+
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
| 138 |
+
className
|
| 139 |
+
)}
|
| 140 |
+
{...props}
|
| 141 |
+
/>
|
| 142 |
+
)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
function InputGroupTextarea({
|
| 146 |
+
className,
|
| 147 |
+
...props
|
| 148 |
+
}: React.ComponentProps<"textarea">) {
|
| 149 |
+
return (
|
| 150 |
+
<Textarea
|
| 151 |
+
data-slot="input-group-control"
|
| 152 |
+
className={cn(
|
| 153 |
+
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
| 154 |
+
className
|
| 155 |
+
)}
|
| 156 |
+
{...props}
|
| 157 |
+
/>
|
| 158 |
+
)
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
export {
|
| 162 |
+
InputGroup,
|
| 163 |
+
InputGroupAddon,
|
| 164 |
+
InputGroupButton,
|
| 165 |
+
InputGroupText,
|
| 166 |
+
InputGroupInput,
|
| 167 |
+
InputGroupTextarea,
|
| 168 |
+
}
|
frontend/app/components/ui/textarea.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
import { cn } from "~/lib/utils"
|
| 4 |
+
|
| 5 |
+
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
| 6 |
+
return (
|
| 7 |
+
<textarea
|
| 8 |
+
data-slot="textarea"
|
| 9 |
+
className={cn(
|
| 10 |
+
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
| 11 |
+
className
|
| 12 |
+
)}
|
| 13 |
+
{...props}
|
| 14 |
+
/>
|
| 15 |
+
)
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export { Textarea }
|
frontend/app/hooks/use-pagination.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect } from "react";
|
| 2 |
+
import { useSearchParams } from "react-router";
|
| 3 |
+
|
| 4 |
+
export function usePagination() {
|
| 5 |
+
const [searchParams, setSearchParams] = useSearchParams();
|
| 6 |
+
const page = parseInt(searchParams.get("page") || "1", 10);
|
| 7 |
+
const limit = parseInt(searchParams.get("limit") || "10", 10);
|
| 8 |
+
|
| 9 |
+
// useEffect(() => {
|
| 10 |
+
// if (limit <= 0) {
|
| 11 |
+
// setLimit(10);
|
| 12 |
+
// }
|
| 13 |
+
// }, [limit])
|
| 14 |
+
|
| 15 |
+
function setPage(newPage: number) {
|
| 16 |
+
searchParams.set("page", newPage.toString());
|
| 17 |
+
setSearchParams(searchParams);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function setLimit(newLimit: number) {
|
| 21 |
+
searchParams.set("limit", newLimit.toString());
|
| 22 |
+
setSearchParams(searchParams);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return {
|
| 26 |
+
page,
|
| 27 |
+
limit,
|
| 28 |
+
setPage,
|
| 29 |
+
setLimit,
|
| 30 |
+
};
|
| 31 |
+
}
|
frontend/app/managers/HTTPManager.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from "axios";
|
| 2 |
+
import { toast } from "react-toastify";
|
| 3 |
+
|
| 4 |
+
export const HTTPManager = axios.create({
|
| 5 |
+
baseURL: import.meta.env.VITE_APP_API_URL,
|
| 6 |
+
headers: {
|
| 7 |
+
"Content-Type": "application/json",
|
| 8 |
+
"Accept": "application/json",
|
| 9 |
+
},
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
HTTPManager.interceptors.request.use((config) => {
|
| 13 |
+
const token = localStorage.getItem("token");
|
| 14 |
+
if (token) {
|
| 15 |
+
config.headers["Authorization"] = `Bearer ${token}`;
|
| 16 |
+
}
|
| 17 |
+
return config;
|
| 18 |
+
}, (error) => {
|
| 19 |
+
toast.error("Request error: " + error.message);
|
| 20 |
+
return Promise.reject(error);
|
| 21 |
+
});
|
frontend/app/root.tsx
CHANGED
|
@@ -8,10 +8,14 @@ import {
|
|
| 8 |
} from "react-router";
|
| 9 |
import type { Route } from "./+types/root";
|
| 10 |
import { Header } from "./components/header";
|
|
|
|
|
|
|
| 11 |
import { SidebarProvider } from "./components/sidebar-provider";
|
| 12 |
|
| 13 |
import "./app.css";
|
| 14 |
|
|
|
|
|
|
|
| 15 |
export function Layout({ children }: { children: React.ReactNode }) {
|
| 16 |
return (
|
| 17 |
<html lang="en">
|
|
@@ -26,10 +30,13 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|
| 26 |
<Links />
|
| 27 |
</head>
|
| 28 |
<body>
|
| 29 |
-
<
|
| 30 |
-
<
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
| 33 |
<ScrollRestoration />
|
| 34 |
<Scripts />
|
| 35 |
</body>
|
|
|
|
| 8 |
} from "react-router";
|
| 9 |
import type { Route } from "./+types/root";
|
| 10 |
import { Header } from "./components/header";
|
| 11 |
+
import { ToastContainer } from "react-toastify";
|
| 12 |
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
| 13 |
import { SidebarProvider } from "./components/sidebar-provider";
|
| 14 |
|
| 15 |
import "./app.css";
|
| 16 |
|
| 17 |
+
export const queryClient = new QueryClient();
|
| 18 |
+
|
| 19 |
export function Layout({ children }: { children: React.ReactNode }) {
|
| 20 |
return (
|
| 21 |
<html lang="en">
|
|
|
|
| 30 |
<Links />
|
| 31 |
</head>
|
| 32 |
<body>
|
| 33 |
+
<QueryClientProvider client={queryClient}>
|
| 34 |
+
<SidebarProvider>
|
| 35 |
+
<Header />
|
| 36 |
+
<main>{children}</main>
|
| 37 |
+
</SidebarProvider>
|
| 38 |
+
</QueryClientProvider>
|
| 39 |
+
<ToastContainer />
|
| 40 |
<ScrollRestoration />
|
| 41 |
<Scripts />
|
| 42 |
</body>
|
frontend/app/routes/home.tsx
CHANGED
|
@@ -1,14 +1,82 @@
|
|
|
|
|
| 1 |
import type { Route } from "./+types/home";
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
export function meta({}: Route.MetaArgs) {
|
| 4 |
return [
|
| 5 |
{ title: "Talent Technical Evaluation" },
|
| 6 |
-
{
|
|
|
|
|
|
|
|
|
|
| 7 |
];
|
| 8 |
}
|
| 9 |
|
| 10 |
export default function Home() {
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
}
|
|
|
|
| 1 |
+
import { useNavigate } from "react-router";
|
| 2 |
import type { Route } from "./+types/home";
|
| 3 |
+
import { useGetJobs } from "~/services/useGetJobs";
|
| 4 |
+
import { Paginator } from "~/components/paginator";
|
| 5 |
+
import { Loader2Icon, User2Icon } from "lucide-react";
|
| 6 |
|
| 7 |
export function meta({}: Route.MetaArgs) {
|
| 8 |
return [
|
| 9 |
{ title: "Talent Technical Evaluation" },
|
| 10 |
+
{
|
| 11 |
+
name: "description",
|
| 12 |
+
content: "Welcome to Talent Technical Evaluation!",
|
| 13 |
+
},
|
| 14 |
];
|
| 15 |
}
|
| 16 |
|
| 17 |
export default function Home() {
|
| 18 |
+
const { data: { data: jobs, total } = { data: [] }, isLoading, isError, refetch } = useGetJobs();
|
| 19 |
+
const Navigate = useNavigate();
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<main className="container mx-auto p-4 flex flex-col gap-4">
|
| 23 |
+
<header className="flex place-content-between gap-4 flex-wrap">
|
| 24 |
+
<h1 className="font-bold text-4xl">Active Jobs</h1>
|
| 25 |
+
<p>{total} jobs in total</p>
|
| 26 |
+
</header>
|
| 27 |
+
|
| 28 |
+
<section className="grid grid-cols-[repeat(auto-fill,minmax(400px,1fr))] gap-4">
|
| 29 |
+
{isLoading && (
|
| 30 |
+
<div className="flex flex-col gap-2 place-items-center">
|
| 31 |
+
<Loader2Icon className="animate-spin" />
|
| 32 |
+
<p>Loading jobs...</p>
|
| 33 |
+
</div>
|
| 34 |
+
)}
|
| 35 |
+
{isError && (
|
| 36 |
+
<div className="bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-100 p-4 rounded flex flex-col gap-2 place-items-center">
|
| 37 |
+
<p className="text-center">Failed to load jobs<br />Please try again</p>
|
| 38 |
+
<button
|
| 39 |
+
onClick={() => refetch()}
|
| 40 |
+
className="ml-4 px-3 py-1 cursor-pointer bg-red-500 text-white dark:bg-red-200 dark:text-red-700 rounded"
|
| 41 |
+
>
|
| 42 |
+
Retry
|
| 43 |
+
</button>
|
| 44 |
+
</div>
|
| 45 |
+
)}
|
| 46 |
+
{jobs?.map(job =>
|
| 47 |
+
<div
|
| 48 |
+
key={job.id}
|
| 49 |
+
tabIndex={0}
|
| 50 |
+
className="border p-4 flex flex-col gap-2 rounded mb-4 bg-indigo-100 dark:bg-gray-800 [:is(:hover,:focus)]:shadow-lg [:is(:hover,:focus)]:scale-101 transition-all cursor-pointer"
|
| 51 |
+
onClick={() => Navigate(`/jobs/${job.id}`)}
|
| 52 |
+
>
|
| 53 |
+
<header className="flex flex-wrap place-content-between gap-4">
|
| 54 |
+
<h2 className="font-semibold text-2xl">{job.title}</h2>
|
| 55 |
+
<span className={"px-3 py-1.5 rounded-xl " + {
|
| 56 |
+
"intern": "bg-green-300 dark:bg-green-800",
|
| 57 |
+
"junior": "bg-blue-300 dark:bg-blue-800",
|
| 58 |
+
"mid": "bg-yellow-300 dark:bg-yellow-800",
|
| 59 |
+
"senior": "bg-red-300 dark:bg-red-800",
|
| 60 |
+
}[job.seniority]}>{job.seniority[0].toUpperCase()}{job.seniority.slice(1)}</span>
|
| 61 |
+
</header>
|
| 62 |
+
<p className="line-clamp-2">{job.description}</p>
|
| 63 |
+
<footer className="flex gap-2 mt-auto place-content-between">
|
| 64 |
+
<div className="flex flex-wrap gap-2">
|
| 65 |
+
{job.skill_categories.map((skill) => (
|
| 66 |
+
<span key={skill} className="inline-block bg-indigo-300 px-3 py-1.5 rounded-xl dark:bg-gray-950">
|
| 67 |
+
{skill}
|
| 68 |
+
</span>
|
| 69 |
+
))}
|
| 70 |
+
</div>
|
| 71 |
+
<span className="place-self-end flex gap-2 place-items-center px-3 py-1.5 rounded-xl bg-indigo-50 dark:bg-gray-700">
|
| 72 |
+
<User2Icon />
|
| 73 |
+
<p>{job.applicants_count}</p>
|
| 74 |
+
</span>
|
| 75 |
+
</footer>
|
| 76 |
+
</div>
|
| 77 |
+
)}
|
| 78 |
+
{total && <Paginator total={total} />}
|
| 79 |
+
</section>
|
| 80 |
+
</main>
|
| 81 |
+
);
|
| 82 |
}
|
frontend/app/services/useGetJobs.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useQuery } from "@tanstack/react-query";
|
| 2 |
+
import { usePagination } from "~/hooks/use-pagination";
|
| 3 |
+
import { HTTPManager } from "~/managers/HTTPManager";
|
| 4 |
+
import type { Pagination } from "~/types/pagination";
|
| 5 |
+
|
| 6 |
+
export const GET_JOBS_KEY = "jobs";
|
| 7 |
+
|
| 8 |
+
export type Job = {
|
| 9 |
+
id: string;
|
| 10 |
+
title: string;
|
| 11 |
+
active: boolean;
|
| 12 |
+
description: string;
|
| 13 |
+
applicants_count: number;
|
| 14 |
+
skill_categories: Array<string>;
|
| 15 |
+
seniority: "intern" | "junior" | "mid" | "senior";
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export const useGetJobs = () => {
|
| 19 |
+
const { page, limit } = usePagination();
|
| 20 |
+
const searchParams = new URLSearchParams({ page: String(page), limit: String(limit) });
|
| 21 |
+
return useQuery({
|
| 22 |
+
queryKey: [GET_JOBS_KEY, page, limit],
|
| 23 |
+
queryFn: async () => HTTPManager.get<Pagination<Job>>("/jobs?" + searchParams.toString()).then(response => response.data),
|
| 24 |
+
});
|
| 25 |
+
}
|
frontend/app/types/pagination.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type Pagination<T> = {
|
| 2 |
+
count: number;
|
| 3 |
+
total: number;
|
| 4 |
+
data: Array<T>;
|
| 5 |
+
};
|
frontend/package-lock.json
CHANGED
|
@@ -6,8 +6,11 @@
|
|
| 6 |
"": {
|
| 7 |
"name": "talent-technical-evaluation",
|
| 8 |
"dependencies": {
|
|
|
|
| 9 |
"@react-router/node": "7.12.0",
|
| 10 |
"@react-router/serve": "7.12.0",
|
|
|
|
|
|
|
| 11 |
"class-variance-authority": "^0.7.1",
|
| 12 |
"clsx": "^2.1.1",
|
| 13 |
"isbot": "^5.1.31",
|
|
@@ -16,6 +19,7 @@
|
|
| 16 |
"react": "^19.2.4",
|
| 17 |
"react-dom": "^19.2.4",
|
| 18 |
"react-router": "7.12.0",
|
|
|
|
| 19 |
"tailwind-merge": "^3.4.0"
|
| 20 |
},
|
| 21 |
"devDependencies": {
|
|
@@ -446,6 +450,15 @@
|
|
| 446 |
"@babel/core": "^7.0.0-0"
|
| 447 |
}
|
| 448 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
"node_modules/@babel/template": {
|
| 450 |
"version": "7.28.6",
|
| 451 |
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
|
@@ -494,6 +507,60 @@
|
|
| 494 |
"node": ">=6.9.0"
|
| 495 |
}
|
| 496 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 497 |
"node_modules/@esbuild/aix-ppc64": {
|
| 498 |
"version": "0.27.2",
|
| 499 |
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
|
@@ -3294,6 +3361,43 @@
|
|
| 3294 |
"vite": "^5.2.0 || ^6 || ^7"
|
| 3295 |
}
|
| 3296 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3297 |
"node_modules/@types/estree": {
|
| 3298 |
"version": "1.0.8",
|
| 3299 |
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
|
@@ -3378,6 +3482,23 @@
|
|
| 3378 |
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
| 3379 |
"license": "MIT"
|
| 3380 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3381 |
"node_modules/babel-dead-code-elimination": {
|
| 3382 |
"version": "1.0.12",
|
| 3383 |
"resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz",
|
|
@@ -3604,6 +3725,18 @@
|
|
| 3604 |
"node": ">=6"
|
| 3605 |
}
|
| 3606 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3607 |
"node_modules/compressible": {
|
| 3608 |
"version": "2.0.18",
|
| 3609 |
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
|
@@ -3739,6 +3872,15 @@
|
|
| 3739 |
}
|
| 3740 |
}
|
| 3741 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3742 |
"node_modules/depd": {
|
| 3743 |
"version": "2.0.0",
|
| 3744 |
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
|
@@ -3861,6 +4003,21 @@
|
|
| 3861 |
"node": ">= 0.4"
|
| 3862 |
}
|
| 3863 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3864 |
"node_modules/esbuild": {
|
| 3865 |
"version": "0.27.2",
|
| 3866 |
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
|
@@ -4060,6 +4217,42 @@
|
|
| 4060 |
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
| 4061 |
"license": "MIT"
|
| 4062 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4063 |
"node_modules/forwarded": {
|
| 4064 |
"version": "0.2.0",
|
| 4065 |
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
|
@@ -4208,6 +4401,21 @@
|
|
| 4208 |
"url": "https://github.com/sponsors/ljharb"
|
| 4209 |
}
|
| 4210 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4211 |
"node_modules/hasown": {
|
| 4212 |
"version": "2.0.2",
|
| 4213 |
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
|
@@ -4943,6 +5151,12 @@
|
|
| 4943 |
"node": ">= 0.10"
|
| 4944 |
}
|
| 4945 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4946 |
"node_modules/qs": {
|
| 4947 |
"version": "6.14.1",
|
| 4948 |
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
|
@@ -5194,6 +5408,19 @@
|
|
| 5194 |
}
|
| 5195 |
}
|
| 5196 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5197 |
"node_modules/readdirp": {
|
| 5198 |
"version": "4.1.2",
|
| 5199 |
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
|
@@ -5208,6 +5435,12 @@
|
|
| 5208 |
"url": "https://paulmillr.com/funding/"
|
| 5209 |
}
|
| 5210 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5211 |
"node_modules/rollup": {
|
| 5212 |
"version": "4.57.1",
|
| 5213 |
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
|
@@ -5474,6 +5707,12 @@
|
|
| 5474 |
"node": ">= 0.8"
|
| 5475 |
}
|
| 5476 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5477 |
"node_modules/tailwind-merge": {
|
| 5478 |
"version": "3.4.0",
|
| 5479 |
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
|
|
|
| 6 |
"": {
|
| 7 |
"name": "talent-technical-evaluation",
|
| 8 |
"dependencies": {
|
| 9 |
+
"@base-ui/react": "^1.1.0",
|
| 10 |
"@react-router/node": "7.12.0",
|
| 11 |
"@react-router/serve": "7.12.0",
|
| 12 |
+
"@tanstack/react-query": "^4.43.0",
|
| 13 |
+
"axios": "^1.13.4",
|
| 14 |
"class-variance-authority": "^0.7.1",
|
| 15 |
"clsx": "^2.1.1",
|
| 16 |
"isbot": "^5.1.31",
|
|
|
|
| 19 |
"react": "^19.2.4",
|
| 20 |
"react-dom": "^19.2.4",
|
| 21 |
"react-router": "7.12.0",
|
| 22 |
+
"react-toastify": "^11.0.5",
|
| 23 |
"tailwind-merge": "^3.4.0"
|
| 24 |
},
|
| 25 |
"devDependencies": {
|
|
|
|
| 450 |
"@babel/core": "^7.0.0-0"
|
| 451 |
}
|
| 452 |
},
|
| 453 |
+
"node_modules/@babel/runtime": {
|
| 454 |
+
"version": "7.28.6",
|
| 455 |
+
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
| 456 |
+
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
| 457 |
+
"license": "MIT",
|
| 458 |
+
"engines": {
|
| 459 |
+
"node": ">=6.9.0"
|
| 460 |
+
}
|
| 461 |
+
},
|
| 462 |
"node_modules/@babel/template": {
|
| 463 |
"version": "7.28.6",
|
| 464 |
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
|
|
|
| 507 |
"node": ">=6.9.0"
|
| 508 |
}
|
| 509 |
},
|
| 510 |
+
"node_modules/@base-ui/react": {
|
| 511 |
+
"version": "1.1.0",
|
| 512 |
+
"resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.1.0.tgz",
|
| 513 |
+
"integrity": "sha512-ikcJRNj1mOiF2HZ5jQHrXoVoHcNHdBU5ejJljcBl+VTLoYXR6FidjTN86GjO6hyshi6TZFuNvv0dEOgaOFv6Lw==",
|
| 514 |
+
"license": "MIT",
|
| 515 |
+
"dependencies": {
|
| 516 |
+
"@babel/runtime": "^7.28.4",
|
| 517 |
+
"@base-ui/utils": "0.2.4",
|
| 518 |
+
"@floating-ui/react-dom": "^2.1.6",
|
| 519 |
+
"@floating-ui/utils": "^0.2.10",
|
| 520 |
+
"reselect": "^5.1.1",
|
| 521 |
+
"tabbable": "^6.4.0",
|
| 522 |
+
"use-sync-external-store": "^1.6.0"
|
| 523 |
+
},
|
| 524 |
+
"engines": {
|
| 525 |
+
"node": ">=14.0.0"
|
| 526 |
+
},
|
| 527 |
+
"funding": {
|
| 528 |
+
"type": "opencollective",
|
| 529 |
+
"url": "https://opencollective.com/mui-org"
|
| 530 |
+
},
|
| 531 |
+
"peerDependencies": {
|
| 532 |
+
"@types/react": "^17 || ^18 || ^19",
|
| 533 |
+
"react": "^17 || ^18 || ^19",
|
| 534 |
+
"react-dom": "^17 || ^18 || ^19"
|
| 535 |
+
},
|
| 536 |
+
"peerDependenciesMeta": {
|
| 537 |
+
"@types/react": {
|
| 538 |
+
"optional": true
|
| 539 |
+
}
|
| 540 |
+
}
|
| 541 |
+
},
|
| 542 |
+
"node_modules/@base-ui/utils": {
|
| 543 |
+
"version": "0.2.4",
|
| 544 |
+
"resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.4.tgz",
|
| 545 |
+
"integrity": "sha512-smZwpMhjO29v+jrZusBSc5T+IJ3vBb9cjIiBjtKcvWmRj9Z4DWGVR3efr1eHR56/bqY5a4qyY9ElkOY5ljo3ng==",
|
| 546 |
+
"license": "MIT",
|
| 547 |
+
"dependencies": {
|
| 548 |
+
"@babel/runtime": "^7.28.4",
|
| 549 |
+
"@floating-ui/utils": "^0.2.10",
|
| 550 |
+
"reselect": "^5.1.1",
|
| 551 |
+
"use-sync-external-store": "^1.6.0"
|
| 552 |
+
},
|
| 553 |
+
"peerDependencies": {
|
| 554 |
+
"@types/react": "^17 || ^18 || ^19",
|
| 555 |
+
"react": "^17 || ^18 || ^19",
|
| 556 |
+
"react-dom": "^17 || ^18 || ^19"
|
| 557 |
+
},
|
| 558 |
+
"peerDependenciesMeta": {
|
| 559 |
+
"@types/react": {
|
| 560 |
+
"optional": true
|
| 561 |
+
}
|
| 562 |
+
}
|
| 563 |
+
},
|
| 564 |
"node_modules/@esbuild/aix-ppc64": {
|
| 565 |
"version": "0.27.2",
|
| 566 |
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
|
|
|
| 3361 |
"vite": "^5.2.0 || ^6 || ^7"
|
| 3362 |
}
|
| 3363 |
},
|
| 3364 |
+
"node_modules/@tanstack/query-core": {
|
| 3365 |
+
"version": "4.43.0",
|
| 3366 |
+
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.43.0.tgz",
|
| 3367 |
+
"integrity": "sha512-m1QeUUIpNXDYxmfuuWNFZLky0EwVmbE0hj8ulZ2nIGA1183raJgDCn0IKlxug80NotRqzodxAaoYTKHbE1/P/Q==",
|
| 3368 |
+
"license": "MIT",
|
| 3369 |
+
"funding": {
|
| 3370 |
+
"type": "github",
|
| 3371 |
+
"url": "https://github.com/sponsors/tannerlinsley"
|
| 3372 |
+
}
|
| 3373 |
+
},
|
| 3374 |
+
"node_modules/@tanstack/react-query": {
|
| 3375 |
+
"version": "4.43.0",
|
| 3376 |
+
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.43.0.tgz",
|
| 3377 |
+
"integrity": "sha512-Lj8luFKHQL27oZbw5T8xdTbsfAPp2+bCtSCa2bAVvIwnvNfRP0hpB1GxfKFgCktat8lPcYBHAu8eMTXzz2sQtQ==",
|
| 3378 |
+
"license": "MIT",
|
| 3379 |
+
"dependencies": {
|
| 3380 |
+
"@tanstack/query-core": "4.43.0",
|
| 3381 |
+
"use-sync-external-store": "^1.6.0"
|
| 3382 |
+
},
|
| 3383 |
+
"funding": {
|
| 3384 |
+
"type": "github",
|
| 3385 |
+
"url": "https://github.com/sponsors/tannerlinsley"
|
| 3386 |
+
},
|
| 3387 |
+
"peerDependencies": {
|
| 3388 |
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
| 3389 |
+
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
| 3390 |
+
"react-native": "*"
|
| 3391 |
+
},
|
| 3392 |
+
"peerDependenciesMeta": {
|
| 3393 |
+
"react-dom": {
|
| 3394 |
+
"optional": true
|
| 3395 |
+
},
|
| 3396 |
+
"react-native": {
|
| 3397 |
+
"optional": true
|
| 3398 |
+
}
|
| 3399 |
+
}
|
| 3400 |
+
},
|
| 3401 |
"node_modules/@types/estree": {
|
| 3402 |
"version": "1.0.8",
|
| 3403 |
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
|
|
|
| 3482 |
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
| 3483 |
"license": "MIT"
|
| 3484 |
},
|
| 3485 |
+
"node_modules/asynckit": {
|
| 3486 |
+
"version": "0.4.0",
|
| 3487 |
+
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
| 3488 |
+
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
| 3489 |
+
"license": "MIT"
|
| 3490 |
+
},
|
| 3491 |
+
"node_modules/axios": {
|
| 3492 |
+
"version": "1.13.4",
|
| 3493 |
+
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
|
| 3494 |
+
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
|
| 3495 |
+
"license": "MIT",
|
| 3496 |
+
"dependencies": {
|
| 3497 |
+
"follow-redirects": "^1.15.6",
|
| 3498 |
+
"form-data": "^4.0.4",
|
| 3499 |
+
"proxy-from-env": "^1.1.0"
|
| 3500 |
+
}
|
| 3501 |
+
},
|
| 3502 |
"node_modules/babel-dead-code-elimination": {
|
| 3503 |
"version": "1.0.12",
|
| 3504 |
"resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz",
|
|
|
|
| 3725 |
"node": ">=6"
|
| 3726 |
}
|
| 3727 |
},
|
| 3728 |
+
"node_modules/combined-stream": {
|
| 3729 |
+
"version": "1.0.8",
|
| 3730 |
+
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
| 3731 |
+
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
| 3732 |
+
"license": "MIT",
|
| 3733 |
+
"dependencies": {
|
| 3734 |
+
"delayed-stream": "~1.0.0"
|
| 3735 |
+
},
|
| 3736 |
+
"engines": {
|
| 3737 |
+
"node": ">= 0.8"
|
| 3738 |
+
}
|
| 3739 |
+
},
|
| 3740 |
"node_modules/compressible": {
|
| 3741 |
"version": "2.0.18",
|
| 3742 |
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
|
|
|
| 3872 |
}
|
| 3873 |
}
|
| 3874 |
},
|
| 3875 |
+
"node_modules/delayed-stream": {
|
| 3876 |
+
"version": "1.0.0",
|
| 3877 |
+
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
| 3878 |
+
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
| 3879 |
+
"license": "MIT",
|
| 3880 |
+
"engines": {
|
| 3881 |
+
"node": ">=0.4.0"
|
| 3882 |
+
}
|
| 3883 |
+
},
|
| 3884 |
"node_modules/depd": {
|
| 3885 |
"version": "2.0.0",
|
| 3886 |
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
|
|
|
| 4003 |
"node": ">= 0.4"
|
| 4004 |
}
|
| 4005 |
},
|
| 4006 |
+
"node_modules/es-set-tostringtag": {
|
| 4007 |
+
"version": "2.1.0",
|
| 4008 |
+
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
| 4009 |
+
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
| 4010 |
+
"license": "MIT",
|
| 4011 |
+
"dependencies": {
|
| 4012 |
+
"es-errors": "^1.3.0",
|
| 4013 |
+
"get-intrinsic": "^1.2.6",
|
| 4014 |
+
"has-tostringtag": "^1.0.2",
|
| 4015 |
+
"hasown": "^2.0.2"
|
| 4016 |
+
},
|
| 4017 |
+
"engines": {
|
| 4018 |
+
"node": ">= 0.4"
|
| 4019 |
+
}
|
| 4020 |
+
},
|
| 4021 |
"node_modules/esbuild": {
|
| 4022 |
"version": "0.27.2",
|
| 4023 |
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
|
|
|
| 4217 |
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
| 4218 |
"license": "MIT"
|
| 4219 |
},
|
| 4220 |
+
"node_modules/follow-redirects": {
|
| 4221 |
+
"version": "1.15.11",
|
| 4222 |
+
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
| 4223 |
+
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
| 4224 |
+
"funding": [
|
| 4225 |
+
{
|
| 4226 |
+
"type": "individual",
|
| 4227 |
+
"url": "https://github.com/sponsors/RubenVerborgh"
|
| 4228 |
+
}
|
| 4229 |
+
],
|
| 4230 |
+
"license": "MIT",
|
| 4231 |
+
"engines": {
|
| 4232 |
+
"node": ">=4.0"
|
| 4233 |
+
},
|
| 4234 |
+
"peerDependenciesMeta": {
|
| 4235 |
+
"debug": {
|
| 4236 |
+
"optional": true
|
| 4237 |
+
}
|
| 4238 |
+
}
|
| 4239 |
+
},
|
| 4240 |
+
"node_modules/form-data": {
|
| 4241 |
+
"version": "4.0.5",
|
| 4242 |
+
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
| 4243 |
+
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
| 4244 |
+
"license": "MIT",
|
| 4245 |
+
"dependencies": {
|
| 4246 |
+
"asynckit": "^0.4.0",
|
| 4247 |
+
"combined-stream": "^1.0.8",
|
| 4248 |
+
"es-set-tostringtag": "^2.1.0",
|
| 4249 |
+
"hasown": "^2.0.2",
|
| 4250 |
+
"mime-types": "^2.1.12"
|
| 4251 |
+
},
|
| 4252 |
+
"engines": {
|
| 4253 |
+
"node": ">= 6"
|
| 4254 |
+
}
|
| 4255 |
+
},
|
| 4256 |
"node_modules/forwarded": {
|
| 4257 |
"version": "0.2.0",
|
| 4258 |
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
|
|
|
| 4401 |
"url": "https://github.com/sponsors/ljharb"
|
| 4402 |
}
|
| 4403 |
},
|
| 4404 |
+
"node_modules/has-tostringtag": {
|
| 4405 |
+
"version": "1.0.2",
|
| 4406 |
+
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
| 4407 |
+
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
| 4408 |
+
"license": "MIT",
|
| 4409 |
+
"dependencies": {
|
| 4410 |
+
"has-symbols": "^1.0.3"
|
| 4411 |
+
},
|
| 4412 |
+
"engines": {
|
| 4413 |
+
"node": ">= 0.4"
|
| 4414 |
+
},
|
| 4415 |
+
"funding": {
|
| 4416 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 4417 |
+
}
|
| 4418 |
+
},
|
| 4419 |
"node_modules/hasown": {
|
| 4420 |
"version": "2.0.2",
|
| 4421 |
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
|
|
|
| 5151 |
"node": ">= 0.10"
|
| 5152 |
}
|
| 5153 |
},
|
| 5154 |
+
"node_modules/proxy-from-env": {
|
| 5155 |
+
"version": "1.1.0",
|
| 5156 |
+
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
| 5157 |
+
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
| 5158 |
+
"license": "MIT"
|
| 5159 |
+
},
|
| 5160 |
"node_modules/qs": {
|
| 5161 |
"version": "6.14.1",
|
| 5162 |
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
|
|
|
| 5408 |
}
|
| 5409 |
}
|
| 5410 |
},
|
| 5411 |
+
"node_modules/react-toastify": {
|
| 5412 |
+
"version": "11.0.5",
|
| 5413 |
+
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz",
|
| 5414 |
+
"integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==",
|
| 5415 |
+
"license": "MIT",
|
| 5416 |
+
"dependencies": {
|
| 5417 |
+
"clsx": "^2.1.1"
|
| 5418 |
+
},
|
| 5419 |
+
"peerDependencies": {
|
| 5420 |
+
"react": "^18 || ^19",
|
| 5421 |
+
"react-dom": "^18 || ^19"
|
| 5422 |
+
}
|
| 5423 |
+
},
|
| 5424 |
"node_modules/readdirp": {
|
| 5425 |
"version": "4.1.2",
|
| 5426 |
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
|
|
|
| 5435 |
"url": "https://paulmillr.com/funding/"
|
| 5436 |
}
|
| 5437 |
},
|
| 5438 |
+
"node_modules/reselect": {
|
| 5439 |
+
"version": "5.1.1",
|
| 5440 |
+
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
| 5441 |
+
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
| 5442 |
+
"license": "MIT"
|
| 5443 |
+
},
|
| 5444 |
"node_modules/rollup": {
|
| 5445 |
"version": "4.57.1",
|
| 5446 |
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
|
|
|
| 5707 |
"node": ">= 0.8"
|
| 5708 |
}
|
| 5709 |
},
|
| 5710 |
+
"node_modules/tabbable": {
|
| 5711 |
+
"version": "6.4.0",
|
| 5712 |
+
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
| 5713 |
+
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
|
| 5714 |
+
"license": "MIT"
|
| 5715 |
+
},
|
| 5716 |
"node_modules/tailwind-merge": {
|
| 5717 |
"version": "3.4.0",
|
| 5718 |
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
frontend/package.json
CHANGED
|
@@ -9,8 +9,11 @@
|
|
| 9 |
"typecheck": "react-router typegen && tsc"
|
| 10 |
},
|
| 11 |
"dependencies": {
|
|
|
|
| 12 |
"@react-router/node": "7.12.0",
|
| 13 |
"@react-router/serve": "7.12.0",
|
|
|
|
|
|
|
| 14 |
"class-variance-authority": "^0.7.1",
|
| 15 |
"clsx": "^2.1.1",
|
| 16 |
"isbot": "^5.1.31",
|
|
@@ -19,6 +22,7 @@
|
|
| 19 |
"react": "^19.2.4",
|
| 20 |
"react-dom": "^19.2.4",
|
| 21 |
"react-router": "7.12.0",
|
|
|
|
| 22 |
"tailwind-merge": "^3.4.0"
|
| 23 |
},
|
| 24 |
"devDependencies": {
|
|
|
|
| 9 |
"typecheck": "react-router typegen && tsc"
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
+
"@base-ui/react": "^1.1.0",
|
| 13 |
"@react-router/node": "7.12.0",
|
| 14 |
"@react-router/serve": "7.12.0",
|
| 15 |
+
"@tanstack/react-query": "^4.43.0",
|
| 16 |
+
"axios": "^1.13.4",
|
| 17 |
"class-variance-authority": "^0.7.1",
|
| 18 |
"clsx": "^2.1.1",
|
| 19 |
"isbot": "^5.1.31",
|
|
|
|
| 22 |
"react": "^19.2.4",
|
| 23 |
"react-dom": "^19.2.4",
|
| 24 |
"react-router": "7.12.0",
|
| 25 |
+
"react-toastify": "^11.0.5",
|
| 26 |
"tailwind-merge": "^3.4.0"
|
| 27 |
},
|
| 28 |
"devDependencies": {
|