Skip to content

Commit 06ea590

Browse files
committed
feat: Add job list filtering by language and tags, sort jobs by repository, and introduce a new Select UI component.
1 parent 3268f49 commit 06ea590

File tree

4 files changed

+915
-28
lines changed

4 files changed

+915
-28
lines changed

components/job-list.tsx

Lines changed: 111 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"use client";
2+
13
import { Job } from "@/lib/csv";
24
import {
35
Table,
@@ -7,72 +9,153 @@ import {
79
TableHeader,
810
TableRow,
911
} from "@/components/ui/table";
12+
import {
13+
Select,
14+
SelectContent,
15+
SelectItem,
16+
SelectTrigger,
17+
SelectValue,
18+
} from "@/components/ui/select";
19+
import { useState, useMemo } from "react";
1020

1121
interface JobListProps {
1222
jobs: Job[];
1323
}
1424

1525
export function JobList({ jobs }: JobListProps) {
26+
const [selectedLanguage, setSelectedLanguage] = useState<string>("all");
27+
const [selectedTag, setSelectedTag] = useState<string>("all");
28+
29+
const languages = useMemo(() => {
30+
const langs = new Set(jobs.map((job) => job.language).filter(Boolean));
31+
return Array.from(langs).sort();
32+
}, [jobs]);
33+
34+
const tags = useMemo(() => {
35+
const t = new Set(jobs.flatMap((job) => job.tags));
36+
return Array.from(t).sort();
37+
}, [jobs]);
38+
39+
const filteredAndSortedJobs = useMemo(() => {
40+
let result = [...jobs];
41+
42+
if (selectedLanguage && selectedLanguage !== "all") {
43+
result = result.filter((job) => job.language === selectedLanguage);
44+
}
45+
46+
if (selectedTag && selectedTag !== "all") {
47+
result = result.filter((job) => job.tags.includes(selectedTag));
48+
}
49+
50+
result.sort((a, b) => {
51+
const repoA = a.repository.split("/")[1] || a.repository;
52+
const repoB = b.repository.split("/")[1] || b.repository;
53+
return repoA.localeCompare(repoB);
54+
});
55+
56+
return result;
57+
}, [jobs, selectedLanguage, selectedTag]);
58+
1659
return (
17-
<div className="w-full">
60+
<div className="w-full space-y-4">
61+
<div className="flex gap-4">
62+
<Select value={selectedLanguage} onValueChange={setSelectedLanguage}>
63+
<SelectTrigger className="w-[180px]">
64+
<SelectValue placeholder="Filter by Language" />
65+
</SelectTrigger>
66+
<SelectContent>
67+
<SelectItem value="all">All Languages</SelectItem>
68+
{languages.map((lang) => (
69+
<SelectItem key={lang} value={lang}>
70+
{lang}
71+
</SelectItem>
72+
))}
73+
</SelectContent>
74+
</Select>
75+
76+
<Select value={selectedTag} onValueChange={setSelectedTag}>
77+
<SelectTrigger className="w-[180px]">
78+
<SelectValue placeholder="Filter by Tag" />
79+
</SelectTrigger>
80+
<SelectContent>
81+
<SelectItem value="all">All Tags</SelectItem>
82+
{tags.map((tag) => (
83+
<SelectItem key={tag} value={tag}>
84+
{tag}
85+
</SelectItem>
86+
))}
87+
</SelectContent>
88+
</Select>
89+
</div>
90+
1891
<Table>
1992
<TableHeader>
2093
<TableRow>
2194
<TableHead className="w-[300px]">Repository</TableHead>
95+
<TableHead>Language</TableHead>
96+
<TableHead>Tags</TableHead>
2297
<TableHead>Description</TableHead>
2398
<TableHead className="text-right">Job Page</TableHead>
2499
</TableRow>
25100
</TableHeader>
26101
<TableBody>
27-
{jobs.map((job) => (
28-
<TableRow key={job.repository}>
29-
<TableCell className="font-medium">
30-
<div className="flex flex-col gap-2">
102+
{filteredAndSortedJobs.map((job) => {
103+
const [org, repo] = job.repository.split("/");
104+
return (
105+
<TableRow key={job.repository}>
106+
<TableCell className="font-medium">
31107
<div className="flex items-center gap-2 flex-wrap">
32108
<a
33109
href={`https://github.com/${job.repository}`}
34110
target="_blank"
35111
rel="noopener noreferrer"
36-
className="font-semibold hover:underline text-base"
112+
className="text-base hover:underline"
37113
>
38-
{job.repository}
114+
<span className="text-zinc-500 dark:text-zinc-400">
115+
{org}/
116+
</span>
117+
<span className="font-semibold">{repo}</span>
39118
</a>
40119
<img
41120
src={`https://img.shields.io/github/stars/${job.repository}.svg?style=social&label=%20`}
42121
alt={`${job.repository} stars`}
43122
className="h-5"
44123
/>
45124
</div>
125+
</TableCell>
126+
<TableCell>
127+
{job.language && (
128+
<span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10 dark:bg-blue-900/30 dark:text-blue-400 dark:ring-blue-400/30">
129+
{job.language}
130+
</span>
131+
)}
132+
</TableCell>
133+
<TableCell>
46134
<div className="flex flex-wrap gap-1">
47-
{job.language && (
48-
<span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
49-
{job.language}
50-
</span>
51-
)}
52135
{job.tags.slice(0, 3).map((tag) => (
53136
<span
54137
key={tag}
55-
className="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10"
138+
className="inline-flex items-center rounded-md bg-zinc-50 px-2 py-1 text-xs font-medium text-zinc-600 ring-1 ring-inset ring-zinc-500/10 dark:bg-zinc-800 dark:text-zinc-400 dark:ring-zinc-700"
56139
>
57140
{tag}
58141
</span>
59142
))}
60143
</div>
61-
</div>
62-
</TableCell>
63-
<TableCell>{job.description}</TableCell>
64-
<TableCell className="text-right">
65-
<a
66-
href={job.jobPage}
67-
target="_blank"
68-
rel="noopener noreferrer"
69-
className="text-blue-600 hover:underline dark:text-blue-400"
70-
>
71-
Apply
72-
</a>
73-
</TableCell>
74-
</TableRow>
75-
))}
144+
</TableCell>
145+
<TableCell>{job.description}</TableCell>
146+
<TableCell className="text-right">
147+
<a
148+
href={job.jobPage}
149+
target="_blank"
150+
rel="noopener noreferrer"
151+
className="text-blue-600 hover:underline dark:text-blue-400"
152+
>
153+
Apply
154+
</a>
155+
</TableCell>
156+
</TableRow>
157+
);
158+
})}
76159
</TableBody>
77160
</Table>
78161
</div>

components/ui/select.tsx

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import * as SelectPrimitive from "@radix-ui/react-select"
5+
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
6+
7+
import { cn } from "@/lib/utils"
8+
9+
function Select({
10+
...props
11+
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
12+
return <SelectPrimitive.Root data-slot="select" {...props} />
13+
}
14+
15+
function SelectGroup({
16+
...props
17+
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
18+
return <SelectPrimitive.Group data-slot="select-group" {...props} />
19+
}
20+
21+
function SelectValue({
22+
...props
23+
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
24+
return <SelectPrimitive.Value data-slot="select-value" {...props} />
25+
}
26+
27+
function SelectTrigger({
28+
className,
29+
size = "default",
30+
children,
31+
...props
32+
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
33+
size?: "sm" | "default"
34+
}) {
35+
return (
36+
<SelectPrimitive.Trigger
37+
data-slot="select-trigger"
38+
data-size={size}
39+
className={cn(
40+
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]: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 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
41+
className
42+
)}
43+
{...props}
44+
>
45+
{children}
46+
<SelectPrimitive.Icon asChild>
47+
<ChevronDownIcon className="size-4 opacity-50" />
48+
</SelectPrimitive.Icon>
49+
</SelectPrimitive.Trigger>
50+
)
51+
}
52+
53+
function SelectContent({
54+
className,
55+
children,
56+
position = "popper",
57+
align = "center",
58+
...props
59+
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
60+
return (
61+
<SelectPrimitive.Portal>
62+
<SelectPrimitive.Content
63+
data-slot="select-content"
64+
className={cn(
65+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
66+
position === "popper" &&
67+
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
68+
className
69+
)}
70+
position={position}
71+
align={align}
72+
{...props}
73+
>
74+
<SelectScrollUpButton />
75+
<SelectPrimitive.Viewport
76+
className={cn(
77+
"p-1",
78+
position === "popper" &&
79+
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
80+
)}
81+
>
82+
{children}
83+
</SelectPrimitive.Viewport>
84+
<SelectScrollDownButton />
85+
</SelectPrimitive.Content>
86+
</SelectPrimitive.Portal>
87+
)
88+
}
89+
90+
function SelectLabel({
91+
className,
92+
...props
93+
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
94+
return (
95+
<SelectPrimitive.Label
96+
data-slot="select-label"
97+
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
98+
{...props}
99+
/>
100+
)
101+
}
102+
103+
function SelectItem({
104+
className,
105+
children,
106+
...props
107+
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
108+
return (
109+
<SelectPrimitive.Item
110+
data-slot="select-item"
111+
className={cn(
112+
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
113+
className
114+
)}
115+
{...props}
116+
>
117+
<span className="absolute right-2 flex size-3.5 items-center justify-center">
118+
<SelectPrimitive.ItemIndicator>
119+
<CheckIcon className="size-4" />
120+
</SelectPrimitive.ItemIndicator>
121+
</span>
122+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
123+
</SelectPrimitive.Item>
124+
)
125+
}
126+
127+
function SelectSeparator({
128+
className,
129+
...props
130+
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
131+
return (
132+
<SelectPrimitive.Separator
133+
data-slot="select-separator"
134+
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
135+
{...props}
136+
/>
137+
)
138+
}
139+
140+
function SelectScrollUpButton({
141+
className,
142+
...props
143+
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
144+
return (
145+
<SelectPrimitive.ScrollUpButton
146+
data-slot="select-scroll-up-button"
147+
className={cn(
148+
"flex cursor-default items-center justify-center py-1",
149+
className
150+
)}
151+
{...props}
152+
>
153+
<ChevronUpIcon className="size-4" />
154+
</SelectPrimitive.ScrollUpButton>
155+
)
156+
}
157+
158+
function SelectScrollDownButton({
159+
className,
160+
...props
161+
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
162+
return (
163+
<SelectPrimitive.ScrollDownButton
164+
data-slot="select-scroll-down-button"
165+
className={cn(
166+
"flex cursor-default items-center justify-center py-1",
167+
className
168+
)}
169+
{...props}
170+
>
171+
<ChevronDownIcon className="size-4" />
172+
</SelectPrimitive.ScrollDownButton>
173+
)
174+
}
175+
176+
export {
177+
Select,
178+
SelectContent,
179+
SelectGroup,
180+
SelectItem,
181+
SelectLabel,
182+
SelectScrollDownButton,
183+
SelectScrollUpButton,
184+
SelectSeparator,
185+
SelectTrigger,
186+
SelectValue,
187+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"update-repos": "npx tsx scripts/update-repos.ts"
1111
},
1212
"dependencies": {
13+
"@radix-ui/react-select": "^2.2.6",
1314
"class-variance-authority": "^0.7.1",
1415
"clsx": "^2.1.1",
1516
"lucide-react": "^0.554.0",

0 commit comments

Comments
 (0)