brestok commited on
Commit
90723a6
·
0 Parent(s):
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Eu Scrapper Ui
3
+ emoji: 📊
4
+ colorFrom: yellow
5
+ colorTo: yellow
6
+ sdk: static
7
+ pinned: false
8
+ ---
9
+
10
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
index.html ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>EU Job Scraper</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Montserrat:wght@700;800;900&display=swap" rel="stylesheet">
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/main.jsx"></script>
15
+ </body>
16
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "eu-parser-ui",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "devDependencies": {
12
+ "@tailwindcss/vite": "^4.1.13",
13
+ "@vitejs/plugin-react": "^5.0.2",
14
+ "autoprefixer": "^10.4.21",
15
+ "postcss": "^8.5.6",
16
+ "tailwindcss": "^4.1.13",
17
+ "vite": "^7.1.2"
18
+ },
19
+ "dependencies": {
20
+ "axios": "^1.12.0",
21
+ "date-fns": "^4.1.0",
22
+ "react": "^19.1.1",
23
+ "react-day-picker": "^9.9.0",
24
+ "react-dom": "^19.1.1",
25
+ "react-markdown": "^10.1.0",
26
+ "react-router-dom": "^7.8.2",
27
+ "react-select": "^5.10.2"
28
+ }
29
+ }
public/vite.svg ADDED
src/api/http.js ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios'
2
+
3
+ const http = axios.create({
4
+ baseURL: 'https://brestok-eu-scrapper.hf.space',
5
+ timeout: 15000,
6
+ headers: {
7
+ 'Content-Type': 'application/json',
8
+ },
9
+ })
10
+
11
+ http.interceptors.response.use(
12
+ r => r,
13
+ err => Promise.reject(err)
14
+ )
15
+
16
+ export default http
17
+
18
+
src/api/jobs.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import http from './http.js'
2
+
3
+ export async function fetchJobs({ pageIndex = 0, pageSize = 25 } = {}) {
4
+ const res = await http.get('/api/job/all', { params: { pageIndex, pageSize } })
5
+ return res.data
6
+ }
7
+
8
+ export async function fetchJobById(jobId) {
9
+ const res = await http.get(`/api/job/${encodeURIComponent(jobId)}`)
10
+ return res.data
11
+ }
12
+
13
+ export async function searchJobOptions(field, value) {
14
+ const res = await http.post(`/api/job/option/${encodeURIComponent(field)}/search`, { value })
15
+ return res.data
16
+ }
17
+
18
+ export async function filterJobs({ filter, pageIndex = 0, pageSize = 25 }) {
19
+ const res = await http.post('/api/job/filter', { filter, pageIndex, pageSize })
20
+ return res.data
21
+ }
22
+
23
+
24
+ export async function fetchStatistics() {
25
+ const res = await http.get('/api/job/statistics')
26
+ return res.data
27
+ }
28
+
29
+
src/counter.js ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ export function setupCounter(element) {
2
+ let counter = 0
3
+ const setCounter = (count) => {
4
+ counter = count
5
+ element.innerHTML = `count is ${counter}`
6
+ }
7
+ element.addEventListener('click', () => setCounter(counter + 1))
8
+ setCounter(0)
9
+ }
src/javascript.svg ADDED
src/main.jsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import { createBrowserRouter, RouterProvider } from 'react-router-dom'
4
+ import './style.css'
5
+ import AppLayout from './ui/AppLayout.jsx'
6
+ import JobsPage from './views/JobsPage.jsx'
7
+ import JobDetailPage from './views/JobDetailPage.jsx'
8
+
9
+ const router = createBrowserRouter([
10
+ {
11
+ path: '/',
12
+ element: <AppLayout />,
13
+ children: [
14
+ { index: true, element: <JobsPage /> },
15
+ { path: 'job/:jobId', element: <JobDetailPage /> },
16
+ ],
17
+ },
18
+ ])
19
+
20
+ const root = createRoot(document.getElementById('root'))
21
+ root.render(<RouterProvider router={router} />)
22
+
23
+
24
+
src/style.css ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ @theme {
4
+ --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
5
+ --font-display: Montserrat, Inter, ui-sans-serif, system-ui;
6
+ --color-bg: #141922;
7
+ --color-card: #171d28;
8
+ --color-muted: #b2bac7;
9
+ --color-foreground: #f5f8fb;
10
+ --color-primary: #8f8cf8;
11
+ --color-primary-600: #7a76f2;
12
+ --radius-xl: 18px;
13
+ }
14
+
15
+ @layer base {
16
+ html { font-family: var(--font-sans); }
17
+ body { @apply bg-[var(--color-bg)] text-[var(--color-foreground)]; }
18
+ }
19
+
20
+ .display-gradient { background: radial-gradient(1200px 600px at 70% 10%, rgba(124,104,238,0.12), rgba(0,0,0,0)), radial-gradient(800px 500px at 90% 30%, rgba(159,134,255,0.12), rgba(0,0,0,0)); }
21
+
22
+ .glass { @apply bg-[var(--color-card)]/70 backdrop-blur-xl border border-white/10 shadow-xl rounded-2xl; }
23
+
24
+ .card-subtle { @apply bg-white/[0.04] border border-white/10 rounded-xl; }
25
+
26
+ .prose-md h1, .prose-md h2, .prose-md h3, .prose-md h4 { @apply font-semibold text-white/90 mt-4 mb-2; }
27
+ .prose-md p { @apply text-white/80 leading-relaxed mt-2; }
28
+ .prose-md ul { @apply list-disc pl-6 mt-2; }
29
+ .prose-md ol { @apply list-decimal pl-6 mt-2; }
30
+ .prose-md li { @apply mt-1; }
31
+ .prose-md a { @apply text-[var(--color-primary)] underline; }
32
+ .prose-md code { @apply bg-white/10 rounded px-1 py-0.5 text-white; }
33
+ .prose-md pre { @apply bg-white/10 rounded p-3 overflow-x-auto; }
34
+ .prose-md blockquote { @apply border-l-4 border-white/20 pl-4 italic text-white/80; }
35
+
36
+ @layer utilities {
37
+ .skeleton { position: relative; overflow: hidden; background-color: rgba(255,255,255,0.08); border-radius: 8px; }
38
+ .skeleton::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,0.18), rgba(255,255,255,0)); animation: skeleton-shimmer 1.2s infinite; }
39
+ @keyframes skeleton-shimmer { 100% { transform: translateX(100%); } }
40
+ }
src/ui/AppLayout.jsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import { Outlet, Link } from 'react-router-dom'
3
+
4
+ export default function AppLayout() {
5
+ return (
6
+ <div className="min-h-screen display-gradient">
7
+ <header className="flex items-center justify-between px-8 py-6">
8
+ <Link to="/" className="flex items-center gap-2">
9
+ <span className="text-3xl font-extrabold tracking-tight" style={{fontFamily:'Montserrat'}}>hector</span>
10
+ <span className="w-2 h-2 rounded-sm bg-[var(--color-primary)] block"></span>
11
+ </Link>
12
+ </header>
13
+ <main className="px-8">
14
+ <Hero />
15
+ <div className="mt-12">
16
+ <Outlet />
17
+ </div>
18
+ </main>
19
+ <footer className="px-8 py-10 text-xs text-white/50 text-center">© 2025 EU Job Scraper</footer>
20
+ </div>
21
+ )
22
+ }
23
+
24
+ function Hero() {
25
+ return (
26
+ <section className="relative isolate grid grid-cols-12 items-center rounded-[2.5rem] overflow-hidden bg-gradient-to-br from-[#141922] to-[#0f141c] border border-white/10">
27
+ <div className="col-span-12 md:col-span-6 px-10 py-16">
28
+ <p className="text-[16px] text-white/70">unleash a new era of</p>
29
+ <h1 className="mt-2 text-[56px] leading-[1.05] font-extrabold" style={{fontFamily:'Montserrat'}}>automation</h1>
30
+ <p className="mt-6 text-white/70 max-w-xl">EU Job Scraper aggregates vacancies across Europe into one searchable platform.</p>
31
+ <div className="mt-8 flex items-center gap-4">
32
+ <Link to="/" className="px-5 py-3 rounded-xl bg-gradient-to-r from-[var(--color-primary-600)] to-[var(--color-primary)] text-black font-semibold">Explore jobs</Link>
33
+ </div>
34
+ </div>
35
+ <div className="col-span-12 md:col-span-6 relative py-16">
36
+ <div className="absolute inset-0 opacity-30 blur-3xl pointer-events-none" aria-hidden="true"></div>
37
+ <div className="mx-10 glass p-6">
38
+ <div className="h-64 rounded-xl bg-[radial-gradient(circle_at_70%_30%,#6d28d9_0%,transparent_60%),radial-gradient(circle_at_30%_70%,#a855f7_0%,transparent_55%)]"></div>
39
+ </div>
40
+ </div>
41
+ </section>
42
+ )
43
+ }
44
+
45
+
src/utils/date.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function formatDateTime(input) {
2
+ if (!input) return ''
3
+ const date = input instanceof Date ? input : new Date(input)
4
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
5
+ return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: tz }).format(date)
6
+ }
7
+
8
+ export function formatDate(input) {
9
+ if (!input) return ''
10
+ const date = input instanceof Date ? input : new Date(input)
11
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
12
+ return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeZone: tz }).format(date)
13
+ }
14
+
15
+
src/views/JobDetailPage.jsx ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react'
2
+ import ReactMarkdown from 'react-markdown'
3
+ import { useParams, Link } from 'react-router-dom'
4
+ import { fetchJobById } from '../api/jobs.js'
5
+ import { formatDateTime } from '../utils/date.js'
6
+
7
+ export default function JobDetailPage() {
8
+ const { jobId } = useParams()
9
+ const [job, setJob] = useState(null)
10
+ const [isLoading, setIsLoading] = useState(true)
11
+
12
+ useEffect(() => {
13
+ let mounted = true
14
+ async function load() {
15
+ setIsLoading(true)
16
+ try {
17
+ const api = await fetchJobById(jobId)
18
+ if (!mounted) return
19
+ setJob(api?.data || null)
20
+ } catch (e) {
21
+ if (!mounted) return
22
+ setJob(null)
23
+ } finally {
24
+ if (!mounted) return
25
+ setIsLoading(false)
26
+ }
27
+ }
28
+ load()
29
+ return () => { mounted = false }
30
+ }, [jobId])
31
+
32
+ if (isLoading) {
33
+ return (
34
+ <div className="glass p-6 mt-10">
35
+ <div className="flex items-start justify-between gap-6">
36
+ <div className="flex-1">
37
+ <div className="skeleton h-7 w-64"></div>
38
+ <div className="skeleton h-4 w-80 mt-3"></div>
39
+ </div>
40
+ <span className="skeleton h-6 w-16 rounded-full"></span>
41
+ </div>
42
+ <div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
43
+ <div className="p-4 rounded-xl bg-white/5 border border-white/10">
44
+ <div className="skeleton h-4 w-24"></div>
45
+ <div className="skeleton h-5 w-40 mt-3"></div>
46
+ </div>
47
+ <div className="p-4 rounded-xl bg-white/5 border border-white/10">
48
+ <div className="skeleton h-4 w-24"></div>
49
+ <div className="skeleton h-5 w-32 mt-3"></div>
50
+ </div>
51
+ </div>
52
+ <div className="mt-8 space-y-8">
53
+ <div>
54
+ <div className="skeleton h-5 w-36"></div>
55
+ <div className="space-y-2 mt-3">
56
+ <div className="skeleton h-4 w-full"></div>
57
+ <div className="skeleton h-4 w-[90%]"></div>
58
+ <div className="skeleton h-4 w-[85%]"></div>
59
+ </div>
60
+ </div>
61
+ <div>
62
+ <div className="skeleton h-5 w-40"></div>
63
+ <div className="space-y-2 mt-3">
64
+ <div className="skeleton h-4 w-full"></div>
65
+ <div className="skeleton h-4 w-[92%]"></div>
66
+ <div className="skeleton h-4 w-[70%]"></div>
67
+ </div>
68
+ <div className="mt-6">
69
+ <span className="skeleton h-9 w-32 inline-block rounded-lg"></span>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ </div>
74
+ )
75
+ }
76
+
77
+ if (!job) {
78
+ return (
79
+ <div className="glass p-6 mt-10">
80
+ <p className="text-white/70">Job not found.</p>
81
+ <Link to="/" className="inline-block mt-4 text-[var(--color-primary)]">Back to jobs</Link>
82
+ </div>
83
+ )
84
+ }
85
+
86
+ return (
87
+ <section className="mt-10">
88
+ <div className="glass p-8">
89
+ <div className="flex items-start justify-between gap-6">
90
+ <div>
91
+ <h1 className="text-3xl font-bold text-white/90">{job?.title || '—'}</h1>
92
+ <p className="text-white/70 mt-2">{job?.company || '—'} • {job?.location || '—'}</p>
93
+ </div>
94
+ {Boolean(job?.isTop5) && <span className="px-3 py-1 rounded-full text-xs bg-emerald-400/20 text-emerald-300">Top 5</span>}
95
+ </div>
96
+
97
+ <div className="mt-6 flex flex-col md:flex-row md:items-start gap-4 md:gap-8 text-sm">
98
+ <div className="p-4 rounded-xl bg-white/5 border border-white/10 w-full md:w-[360px]">
99
+ <div className="text-white/60">Salary</div>
100
+ <div className="mt-1 font-semibold">
101
+ {(() => {
102
+ const s = job?.salary
103
+ if (!s || (s.min == null && s.max == null)) return '—'
104
+ const a = typeof s.min === 'number' ? `€${s.min.toLocaleString()}` : null
105
+ const b = typeof s.max === 'number' ? `€${s.max.toLocaleString()}` : null
106
+ if (a && b) return `${a} - ${b}`
107
+ return a || b || '—'
108
+ })()}
109
+ </div>
110
+ </div>
111
+ <div className="p-4 rounded-xl bg-white/5 border border-white/10 w-full md:w-[360px]">
112
+ <div className="text-white/60">Inserted</div>
113
+ <div className="mt-1 font-semibold">{job?.datetimeInserted ? formatDateTime(job.datetimeInserted) : '—'}</div>
114
+ </div>
115
+ </div>
116
+
117
+ <div className="mt-8 space-y-8">
118
+ <div>
119
+ <h3 className="text-xl font-semibold">Description</h3>
120
+ {job?.description ? (
121
+ <div className="prose-md mt-2">
122
+ <ReactMarkdown>{job.description}</ReactMarkdown>
123
+ </div>
124
+ ) : (
125
+ <p className="mt-2 text-white/70">—</p>
126
+ )}
127
+ </div>
128
+ <div>
129
+ <h3 className="text-xl font-semibold">Requirements</h3>
130
+ {job?.requirements ? (
131
+ <div className="prose-md mt-2">
132
+ <ReactMarkdown>{job.requirements}</ReactMarkdown>
133
+ </div>
134
+ ) : (
135
+ <p className="mt-2 text-white/70">—</p>
136
+ )}
137
+ <div className="mt-6">
138
+ {job?.sourceUrl ? (
139
+ <a href={job.sourceUrl} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center px-4 py-2 rounded-lg bg-gradient-to-r from-[var(--color-primary-600)] to-[var(--color-primary)] text-black font-semibold">Open Source</a>
140
+ ) : (
141
+ <span className="inline-flex items-center justify-center px-4 py-2 rounded-lg bg-white/5 border border-white/10 opacity-50">Open Source</span>
142
+ )}
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ </section>
148
+ )
149
+ }
150
+
151
+
src/views/JobsPage.jsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import { FiltersBar } from '../widgets/FiltersBar.jsx'
3
+ import { JobsTable } from '../widgets/JobsTable.jsx'
4
+ import { Pagination } from '../widgets/Pagination.jsx'
5
+ import { useJobs } from '../widgets/useJobs.js'
6
+
7
+ export default function JobsPage() {
8
+ const { jobs, totalJobs, pageIndex, pageCount, pageSize, setPage, setSize, isLoading, lastUpdatedAt, nextPlannedAt, refresh, setFilters, filters, domain, resetFilters, submitFilters } = useJobs()
9
+
10
+ return (
11
+ <section id="jobs" className="mt-10">
12
+ <div className="flex items-center justify-between gap-4 flex-wrap">
13
+ <div>
14
+ <h2 className="text-2xl font-semibold">All EU Vacancies</h2>
15
+ <p className="text-sm text-white/70">Advanced scraping for ATS platforms and hyperscalers</p>
16
+ </div>
17
+ <button onClick={refresh} disabled={isLoading} className="px-5 py-3 rounded-xl bg-gradient-to-r from-[var(--color-primary-600)] to-[var(--color-primary)] text-black font-semibold disabled:opacity-60">Refresh</button>
18
+ </div>
19
+
20
+ <div className="mt-6">
21
+ <FiltersBar value={filters} onChange={setFilters} domain={domain} onRefresh={refresh} lastUpdatedAt={lastUpdatedAt} nextPlannedAt={nextPlannedAt} onSubmit={submitFilters} onClear={resetFilters} isLoading={isLoading} />
22
+ </div>
23
+
24
+ <div className="mt-6">
25
+ <div className="glass p-2">
26
+ <JobsTable jobs={jobs} isLoading={isLoading} />
27
+ <Pagination pageIndex={pageIndex} pageCount={pageCount} pageSize={pageSize} total={totalJobs} onPageChange={setPage} onSizeChange={setSize} isLoading={isLoading} />
28
+ </div>
29
+ </div>
30
+ </section>
31
+ )
32
+ }
33
+
34
+
src/widgets/FiltersBar.jsx ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, {useMemo, useRef, useState} from 'react'
2
+ import Select from 'react-select'
3
+ import {DayPicker} from 'react-day-picker'
4
+ import 'react-day-picker/dist/style.css'
5
+ import {searchJobOptions} from '../api/jobs.js'
6
+ import { formatDate } from '../utils/date.js'
7
+
8
+ export function FiltersBar({value, onChange, domain, onRefresh, lastUpdatedAt, nextPlannedAt, onSubmit, onClear, isLoading}) {
9
+ function set(partial) {
10
+ onChange({...value, ...partial})
11
+ }
12
+
13
+ const hasAny = useMemo(() => {
14
+ return Boolean(
15
+ value.titles.length || value.companies.length || value.locations.length ||
16
+ value.salaryMin !== '' || value.salaryMax !== '' || value.top || value.dateFrom || value.dateTo
17
+ )
18
+ }, [value])
19
+ const [openCalendar, setOpenCalendar] = useState(false)
20
+
21
+ const selectStyles = {
22
+ control: (base) => ({
23
+ ...base,
24
+ minHeight: 44,
25
+ backgroundColor: 'rgba(255,255,255,0.06)',
26
+ borderColor: 'rgba(255,255,255,0.2)',
27
+ color: 'white',
28
+ boxShadow: 'none',
29
+ }),
30
+ menu: (base) => ({
31
+ ...base,
32
+ backgroundColor: '#171d28',
33
+ color: 'white',
34
+ border: '1px solid rgba(255,255,255,0.12)'
35
+ }),
36
+ menuPortal: (base) => ({...base, zIndex: 60}),
37
+ singleValue: (base) => ({...base, color: 'white'}),
38
+ input: (base) => ({...base, color: 'white'}),
39
+ multiValue: (base) => ({...base, backgroundColor: 'rgba(183,116,255,0.25)'}),
40
+ multiValueLabel: (base) => ({...base, color: 'white'}),
41
+ multiValueRemove: (base) => ({
42
+ ...base,
43
+ color: 'white',
44
+ ':hover': {backgroundColor: 'rgba(183,116,255,0.4)', color: '#0b0c10'}
45
+ }),
46
+ option: (base, s) => ({
47
+ ...base,
48
+ background: s.isSelected ? 'rgba(183,116,255,0.25)' : s.isFocused ? 'rgba(255,255,255,0.08)' : 'transparent',
49
+ color: 'white'
50
+ }),
51
+ }
52
+
53
+ const [titleOptions, setTitleOptions] = useState([])
54
+ const [companyOptions, setCompanyOptions] = useState([])
55
+ const [locationOptions, setLocationOptions] = useState([])
56
+ const debounceIdRef = useRef(null)
57
+
58
+ async function loadOptions(field, query, setter) {
59
+ try {
60
+ const api = await searchJobOptions(field, query)
61
+ const arr = api?.data?.data || []
62
+ setter(arr.map(v => ({value: v, label: v})))
63
+ } catch (e) {
64
+ setter([])
65
+ }
66
+ }
67
+
68
+ function handleMenuOpen(field, setter) {
69
+ loadOptions(field, '', setter)
70
+ }
71
+
72
+ function handleInputChange(field, setter) {
73
+ return (inputValue, meta) => {
74
+ if (meta.action === 'input-change') {
75
+ if (debounceIdRef.current) clearTimeout(debounceIdRef.current)
76
+ debounceIdRef.current = setTimeout(() => {
77
+ loadOptions(field, inputValue || '', setter)
78
+ }, 500)
79
+ }
80
+ return inputValue
81
+ }
82
+ }
83
+
84
+ return (
85
+ <div className="glass p-4 relative z-20">
86
+ <div className="flex flex-wrap items-center gap-4 justify-between text-xs text-white/60">
87
+ <div>Last update: <span className="text-white">{isLoading ? <span className="skeleton h-3 w-28 inline-block align-middle"></span> : lastUpdatedAt}</span></div>
88
+ <div>Next planned: <span className="text-white">{isLoading ? <span className="skeleton h-3 w-28 inline-block align-middle"></span> : nextPlannedAt}</span></div>
89
+ <div className="flex-1"></div>
90
+ {hasAny && (
91
+ <div className="flex items-center gap-2 order-2 sm:order-none">
92
+ <button onClick={onSubmit} disabled={isLoading}
93
+ className="px-4 py-2 rounded-lg bg-gradient-to-r from-[var(--color-primary-600)] to-[var(--color-primary)] text-black font-semibold disabled:opacity-60">Submit
94
+ </button>
95
+ <button onClick={onClear} disabled={isLoading}
96
+ className="px-4 py-2 rounded-lg bg-white/5 border border-white/10 text-white/80 disabled:opacity-60">Clear
97
+ All
98
+ </button>
99
+ </div>
100
+ )}
101
+ </div>
102
+
103
+ <div className="mt-4 grid grid-cols-12 gap-4">
104
+ <div className="col-span-12 md:col-span-4">
105
+ <label className="text-xs text-white/60">Title</label>
106
+ <div className="mt-1">
107
+ <Select isMulti isDisabled={isLoading} isLoading={isLoading} options={titleOptions} value={value.titles.map(v => ({value: v, label: v}))}
108
+ onChange={vals => set({titles: vals.map(v => v.value)})}
109
+ onMenuOpen={() => handleMenuOpen('title', setTitleOptions)}
110
+ onInputChange={handleInputChange('title', setTitleOptions)} styles={selectStyles}
111
+ classNamePrefix="rs" menuPortalTarget={document.body}/>
112
+ </div>
113
+ </div>
114
+ <div className="col-span-12 md:col-span-4">
115
+ <label className="text-xs text-white/60">Company</label>
116
+ <div className="mt-1">
117
+ <Select isMulti isDisabled={isLoading} isLoading={isLoading} options={companyOptions}
118
+ value={value.companies.map(v => ({value: v, label: v}))}
119
+ onChange={vals => set({companies: vals.map(v => v.value)})}
120
+ onMenuOpen={() => handleMenuOpen('company', setCompanyOptions)}
121
+ onInputChange={handleInputChange('company', setCompanyOptions)} styles={selectStyles}
122
+ classNamePrefix="rs" menuPortalTarget={document.body}/>
123
+ </div>
124
+ </div>
125
+ <div className="col-span-12 md:col-span-4">
126
+ <label className="text-xs text-white/60">Location</label>
127
+ <div className="mt-1">
128
+ <Select isMulti isDisabled={isLoading} isLoading={isLoading} options={locationOptions}
129
+ value={value.locations.map(v => ({value: v, label: v}))}
130
+ onChange={vals => set({locations: vals.map(v => v.value)})}
131
+ onMenuOpen={() => handleMenuOpen('location', setLocationOptions)}
132
+ onInputChange={handleInputChange('location', setLocationOptions)} styles={selectStyles}
133
+ classNamePrefix="rs" menuPortalTarget={document.body}/>
134
+ </div>
135
+ </div>
136
+ <div className="col-span-6 md:col-span-3">
137
+ <div>
138
+ <label className="text-xs text-white/60">Salary from</label>
139
+ <input type="number" value={value.salaryMin} onChange={e => set({salaryMin: e.target.value})} disabled={isLoading}
140
+ className="mt-1 h-[44px] w-full px-3 rounded-lg bg-white/5 border border-white/10 outline-none focus:border-[var(--color-primary)] disabled:opacity-60"/>
141
+ </div>
142
+ </div>
143
+ <div className="col-span-6 md:col-span-3">
144
+ <div>
145
+ <label className="text-xs text-white/60">Salary to</label>
146
+ <input type="number" value={value.salaryMax} onChange={e => set({salaryMax: e.target.value})} disabled={isLoading}
147
+ className="mt-1 h-[44px] w-full px-3 rounded-lg bg-white/5 border border-white/10 outline-none focus:border-[var(--color-primary)] disabled:opacity-60"/>
148
+ </div>
149
+ </div>
150
+ <div className="col-span-12 md:col-span-2">
151
+ <label className="text-xs text-white/60">Top 5</label>
152
+ <div
153
+ className="mt-1 h-[44px] w-full rounded-lg bg-white/5 border border-white/10 flex items-center px-3 gap-2">
154
+ <input type="checkbox" checked={value.top} onChange={e => set({top: e.target.checked})} disabled={isLoading}
155
+ className="size-5 accent-[var(--color-primary-600)]"/>
156
+ <span className="text-sm">Only top 5</span>
157
+ </div>
158
+ </div>
159
+ <div className="col-span-12 md:col-span-4">
160
+ <label className="text-xs text-white/60">Date inserted (range)</label>
161
+ <button type="button" onClick={() => setOpenCalendar(v => !v)} disabled={isLoading}
162
+ className="mt-1 h-[44px] w-full text-left px-3 rounded-lg bg-white/5 border border-white/10 flex items-center justify-between disabled:opacity-60">
163
+ <span
164
+ className="text-white/80 text-sm">{value.dateFrom ? formatDate(value.dateFrom) : 'Start'} — {value.dateTo ? formatDate(value.dateTo) : 'End'}</span>
165
+ <span className="text-white/50">▾</span>
166
+ </button>
167
+ {openCalendar && (
168
+ <div
169
+ className="absolute z-50 mt-2 bg-[#0f1117] border border-white/10 rounded-xl shadow-2xl p-2">
170
+ <DayPicker
171
+ mode="range"
172
+ selected={{
173
+ from: value.dateFrom ? new Date(value.dateFrom) : undefined,
174
+ to: value.dateTo ? new Date(value.dateTo) : undefined
175
+ }}
176
+ onSelect={(range) => {
177
+ let from = range?.from ?? null
178
+ let to = range?.to ?? null
179
+ const isSameDay = from && to && from.toDateString() === to.toDateString()
180
+ if (isSameDay) {
181
+ to = null
182
+ }
183
+ set({dateFrom: from, dateTo: to})
184
+ if (from && to && !isSameDay) setOpenCalendar(false)
185
+ }}
186
+ styles={{
187
+ caption: {color: 'white'},
188
+ day: {color: 'white'},
189
+ day_selected: {backgroundColor: '#8f8cf8', color: '#0b0c10'},
190
+ day_range_start: {backgroundColor: '#7a76f2', color: '#0b0c10'},
191
+ day_range_end: {backgroundColor: '#7a76f2', color: '#0b0c10'},
192
+ day_range_middle: {backgroundColor: 'rgba(143,140,248,0.4)', color: 'white'},
193
+ }}
194
+ />
195
+ </div>
196
+ )}
197
+ </div>
198
+ </div>
199
+ </div>
200
+ )
201
+ }
202
+
203
+
src/widgets/JobsFilters.jsx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+
3
+ export function JobsFilters({ value, onChange }) {
4
+ const companies = ['all', 'Meta', 'AWS']
5
+ const locations = ['all', 'Dublin, IE', 'Amsterdam, NL', 'Frankfurt, DE', 'Warsaw, PL']
6
+
7
+ function set(partial) { onChange({ ...value, ...partial }) }
8
+
9
+ return (
10
+ <div className="space-y-4">
11
+ <div>
12
+ <label className="text-xs text-white/60">Search</label>
13
+ <input value={value.query} onChange={e => set({ query: e.target.value })} placeholder="Title, company, location" className="mt-1 w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 outline-none focus:border-[var(--color-primary)]" />
14
+ </div>
15
+ <div className="grid grid-cols-2 gap-3">
16
+ <div>
17
+ <label className="text-xs text-white/60">Company</label>
18
+ <select value={value.company} onChange={e => set({ company: e.target.value })} className="mt-1 w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10">
19
+ {companies.map(c => <option key={c} value={c}>{c}</option>)}
20
+ </select>
21
+ </div>
22
+ <div>
23
+ <label className="text-xs text-white/60">Location</label>
24
+ <select value={value.location} onChange={e => set({ location: e.target.value })} className="mt-1 w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10">
25
+ {locations.map(l => <option key={l} value={l}>{l}</option>)}
26
+ </select>
27
+ </div>
28
+ </div>
29
+ <div className="flex items-center gap-2">
30
+ <input id="top5" type="checkbox" checked={value.top} onChange={e => set({ top: e.target.checked })} className="size-4" />
31
+ <label htmlFor="top5" className="text-sm">Top 5</label>
32
+ </div>
33
+ </div>
34
+ )
35
+ }
36
+
37
+
38
+
src/widgets/JobsTable.jsx ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import { formatDateTime } from '../utils/date.js'
3
+ import { Link } from 'react-router-dom'
4
+
5
+ export function JobsTable({ jobs, isLoading }) {
6
+ if (isLoading) {
7
+ const skeletonRows = Array.from({ length: 8 })
8
+ return (
9
+ <div className="overflow-x-auto w-full relative z-10">
10
+ <table className="min-w-full text-sm">
11
+ <thead>
12
+ <tr className="text-left text-white/70">
13
+ <th className="p-3">Title</th>
14
+ <th className="p-3">Company</th>
15
+ <th className="p-3">Location</th>
16
+ <th className="p-3">Salary</th>
17
+ <th className="p-3">Inserted</th>
18
+ <th className="p-3"></th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ {skeletonRows.map((_, idx) => (
23
+ <tr key={idx} className={`border-t border-white/10 ${idx % 2 === 0 ? 'bg-white/[0.02]' : ''}`}>
24
+ <td className="p-3">
25
+ <div className="flex items-center gap-2">
26
+ <span className="skeleton h-4 w-48"></span>
27
+ </div>
28
+ </td>
29
+ <td className="p-3"><span className="skeleton h-4 w-32"></span></td>
30
+ <td className="p-3"><span className="skeleton h-4 w-24"></span></td>
31
+ <td className="p-3"><span className="skeleton h-4 w-28"></span></td>
32
+ <td className="p-3"><span className="skeleton h-4 w-36"></span></td>
33
+ <td className="p-3 text-right"><span className="skeleton h-7 w-16 inline-block"></span></td>
34
+ </tr>
35
+ ))}
36
+ </tbody>
37
+ </table>
38
+ </div>
39
+ )
40
+ }
41
+
42
+ function formatSalary(salary) {
43
+ if (!salary || (salary.min == null && salary.max == null)) return '—'
44
+ const a = typeof salary.min === 'number' ? `€${salary.min.toLocaleString()}` : null
45
+ const b = typeof salary.max === 'number' ? `€${salary.max.toLocaleString()}` : null
46
+ if (a && b) return `${a} - ${b}`
47
+ return a || b || '—'
48
+ }
49
+
50
+ return (
51
+ <div className="overflow-x-auto w-full relative z-10">
52
+ <table className="min-w-full text-sm">
53
+ <thead>
54
+ <tr className="text-left text-white/70">
55
+ <th className="p-3">Title</th>
56
+ <th className="p-3">Company</th>
57
+ <th className="p-3">Location</th>
58
+ <th className="p-3">Salary</th>
59
+ <th className="p-3">Inserted</th>
60
+ <th className="p-3"></th>
61
+ </tr>
62
+ </thead>
63
+ <tbody>
64
+ {jobs.map((j, idx) => (
65
+ <tr key={j?.id ?? idx} className={`border-t border-white/10 ${idx % 2 === 0 ? 'bg-white/[0.02]' : ''} hover:bg-white/[0.06]`}>
66
+ <td className="p-3">
67
+ <div className="flex items-center gap-2">
68
+ {Boolean(j?.isTop5) && <span className="size-2 rounded-full bg-emerald-400"></span>}
69
+ <span className="font-medium text-white/90">{j?.title || '—'}</span>
70
+ </div>
71
+ </td>
72
+ <td className="p-3">{j?.company || '—'}</td>
73
+ <td className="p-3">{j?.location || '—'}</td>
74
+ <td className="p-3">{formatSalary(j?.salary)}</td>
75
+ <td className="p-3">{j?.datetimeInserted ? formatDateTime(j.datetimeInserted) : '—'}</td>
76
+ <td className="p-3 text-right">
77
+ {j?.id ? (
78
+ <Link to={`/job/${j.id}`} className="px-3 py-1 rounded-lg bg-white/5 border border-white/10 hover:border-[var(--color-primary)]">View</Link>
79
+ ) : (
80
+ <span className="px-3 py-1 rounded-lg bg-white/5 border border-white/10 opacity-50">View</span>
81
+ )}
82
+ </td>
83
+ </tr>
84
+ ))}
85
+ </tbody>
86
+ </table>
87
+ </div>
88
+ )
89
+ }
90
+
91
+
src/widgets/Pagination.jsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+
3
+ export function Pagination({ pageIndex, pageCount, pageSize, total, onPageChange, onSizeChange, isLoading }) {
4
+ const canPrev = pageIndex > 0
5
+ const canNext = pageIndex < pageCount - 1
6
+ const sizes = [10, 25, 50, 100]
7
+
8
+ return (
9
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-3 px-2 py-3">
10
+ <div className="text-sm text-white/70">
11
+ {isLoading ? (
12
+ <span className="skeleton h-4 w-40 inline-block"></span>
13
+ ) : (
14
+ `${Math.min(pageIndex * pageSize + 1, total)}–${Math.min((pageIndex + 1) * pageSize, total)} of ${total}`
15
+ )}
16
+ </div>
17
+ <div className="flex items-center gap-2">
18
+ <label className="text-sm text-white/70">Rows per page</label>
19
+ <select value={pageSize} onChange={e => onSizeChange(Number(e.target.value))} disabled={isLoading} className="h-[36px] px-2 rounded-md bg-white/5 border border-white/10 disabled:opacity-60">
20
+ {sizes.map(s => <option key={s} value={s}>{s}</option>)}
21
+ </select>
22
+ <div className="ml-2 flex items-center gap-2">
23
+ <button onClick={() => onPageChange(0)} disabled={!canPrev || isLoading} className="px-2 py-1 rounded-md bg-white/5 border border-white/10 disabled:opacity-40">«</button>
24
+ <button onClick={() => onPageChange(pageIndex - 1)} disabled={!canPrev || isLoading} className="px-2 py-1 rounded-md bg-white/5 border border-white/10 disabled:opacity-40">‹</button>
25
+ <span className="text-sm text-white/70 px-2">{isLoading ? <span className="skeleton h-4 w-28 inline-block align-middle"></span> : `Page ${pageIndex + 1} / ${pageCount}`}</span>
26
+ <button onClick={() => onPageChange(pageIndex + 1)} disabled={!canNext || isLoading} className="px-2 py-1 rounded-md bg-white/5 border border-white/10 disabled:opacity-40">›</button>
27
+ <button onClick={() => onPageChange(pageCount - 1)} disabled={!canNext || isLoading} className="px-2 py-1 rounded-md bg-white/5 border border-white/10 disabled:opacity-40">»</button>
28
+ </div>
29
+ </div>
30
+ </div>
31
+ )
32
+ }
33
+
34
+
35
+
src/widgets/useJobs.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { fetchJobs, filterJobs, fetchStatistics } from '../api/jobs.js'
3
+ import { formatDateTime } from '../utils/date.js'
4
+
5
+ export const INITIAL_FILTERS = {
6
+ titles: [],
7
+ companies: [],
8
+ locations: [],
9
+ salaryMin: '',
10
+ salaryMax: '',
11
+ top: false,
12
+ dateFrom: null,
13
+ dateTo: null,
14
+ }
15
+
16
+ export function useJobs() {
17
+ const [jobs, setJobs] = useState([])
18
+ const [isLoading, setIsLoading] = useState(false)
19
+ const [filters, setFilters] = useState(INITIAL_FILTERS)
20
+ const [lastUpdatedAt, setLastUpdatedAt] = useState(formatDateTime(new Date()))
21
+ const [nextPlannedAt, setNextPlannedAt] = useState(formatDateTime(new Date(Date.now() + 6 * 36e5)))
22
+ const [pageIndex, setPageIndex] = useState(0)
23
+ const [pageSize, setPageSize] = useState(25)
24
+ const [totalJobs, setTotalJobs] = useState(0)
25
+ const [serverFilter, setServerFilter] = useState(null)
26
+
27
+ async function load() {
28
+ setIsLoading(true)
29
+ try {
30
+ let api
31
+ if (serverFilter) {
32
+ api = await filterJobs({ filter: serverFilter, pageIndex, pageSize })
33
+ } else {
34
+ api = await fetchJobs({ pageIndex, pageSize })
35
+ }
36
+ const payload = api?.data
37
+ const items = payload?.data || []
38
+ const paging = payload?.paging || {}
39
+ setJobs(items)
40
+ setTotalJobs(Number(paging.totalCount) || 0)
41
+ try {
42
+ const stats = await fetchStatistics()
43
+ const last = stats?.data?.lastUpdate
44
+ const next = stats?.data?.nextUpdate
45
+ setLastUpdatedAt(last ? formatDateTime(last) : formatDateTime(new Date()))
46
+ setNextPlannedAt(next ? formatDateTime(next) : formatDateTime(new Date(Date.now() + 6 * 36e5)))
47
+ } catch (_) {
48
+ setLastUpdatedAt(formatDateTime(new Date()))
49
+ setNextPlannedAt(formatDateTime(new Date(Date.now() + 6 * 36e5)))
50
+ }
51
+ } catch (e) {
52
+ setJobs([])
53
+ setTotalJobs(0)
54
+ } finally {
55
+ setIsLoading(false)
56
+ }
57
+ }
58
+
59
+ useEffect(() => {
60
+ load()
61
+ }, [pageIndex, pageSize, serverFilter])
62
+
63
+ const pageCount = useMemo(() => Math.max(1, Math.ceil((totalJobs || 0) / pageSize)), [totalJobs, pageSize])
64
+
65
+ function setPage(newIndex) {
66
+ const clamped = Math.max(0, Math.min(newIndex, pageCount - 1))
67
+ setPageIndex(clamped)
68
+ }
69
+
70
+ function setSize(newSize) {
71
+ setPageSize(newSize)
72
+ setPageIndex(0)
73
+ }
74
+
75
+ function refresh() {
76
+ load()
77
+ }
78
+
79
+ function buildServerFilter() {
80
+ const filter = {}
81
+ if (filters.titles && filters.titles.length) filter.titles = filters.titles
82
+ if (filters.companies && filters.companies.length) filter.companies = filters.companies
83
+ if (filters.locations && filters.locations.length) filter.locations = filters.locations
84
+ if (filters.salaryMin !== '' && !Number.isNaN(Number(filters.salaryMin))) filter.minSalary = Number(filters.salaryMin)
85
+ if (filters.salaryMax !== '' && !Number.isNaN(Number(filters.salaryMax))) filter.maxSalary = Number(filters.salaryMax)
86
+ if (filters.top) filter.isTop5 = true
87
+ if (filters.dateFrom) filter.minDate = new Date(filters.dateFrom).toISOString().slice(0, 10)
88
+ if (filters.dateTo) filter.maxDate = new Date(filters.dateTo).toISOString().slice(0, 10)
89
+ return filter
90
+ }
91
+
92
+ function submitFilters() {
93
+ const f = buildServerFilter()
94
+ setServerFilter(Object.keys(f).length ? f : null)
95
+ setPageIndex(0)
96
+ }
97
+
98
+ const domain = useMemo(() => {
99
+ const unique = (arr) => Array.from(new Set(arr)).sort()
100
+ return {
101
+ titles: unique(jobs.map(j => j.title)),
102
+ companies: unique(jobs.map(j => j.company)),
103
+ locations: unique(jobs.map(j => j.location)),
104
+ }
105
+ }, [jobs])
106
+
107
+ function resetFilters() {
108
+ setFilters(INITIAL_FILTERS)
109
+ setServerFilter(null)
110
+ setPageIndex(0)
111
+ }
112
+
113
+ return { jobs, totalJobs, pageIndex, pageCount, pageSize, setPage, setSize, isLoading, lastUpdatedAt, nextPlannedAt, refresh, setFilters, filters, domain, resetFilters, submitFilters }
114
+ }
115
+
116
+
vite.config.js ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import tailwind from '@tailwindcss/vite'
4
+
5
+ export default defineConfig({
6
+ plugins: [react(), tailwind()],
7
+ })
8
+
9
+