feat: LinkedIn profile search engine (Django + React + Elasticsearch + ELK)
Full-stack implementation of the Cyberyan full-stack trial task. Backend (Django 5 / DRF) - Normalised PostgreSQL model (Profile, Company, School, Skill, Experience, Education) as the source of truth, with GIN + trigram indexes. - Self-healing CSV reader: the supplied export carries an injected watermark column, missing columns, rows split by broken quoting and a duplicated header. A signature-scored Needleman-Wunsch alignment reconstructs 284 clean records out of 336 physical rows; only 10 rows parse correctly without it. - Pluggable search layer (Strategy): Elasticsearch by default, PostgreSQL full-text search as a fallback selected by a single environment variable. - Keyword search with per-field boosts, fuzziness and phrase boosting, six faceted filters (OR within a filter, AND across filters), an experience range, four sort orders, highlighting and facet aggregations. - JWT auth with rotation + blacklist, three roles, PII masking, scoped rate limits, a single error envelope, OpenAPI schema and health probes. Frontend (React 18 / TypeScript / Vite) - Debounced search with type-ahead, faceted filter panel, result cards, profile drawer, URL-synced search state, automatic token refresh. - Highlight snippets rendered without dangerouslySetInnerHTML. Infrastructure - Hardened multi-stage images (non-root, read-only rootfs, healthchecks). - docker-compose for the full stack plus dev and production overlays. - ELK: structured JSON logs shipped asynchronously to Logstash, routed per event type, with a composable index template and a Kibana setup script. Quality - 61 backend tests (85% coverage), ruff/black/bandit/mypy, ESLint + tsc, GitHub Actions running lint, tests and both image builds. - Persian documentation: README plus architecture, search, API, data, security and operations guides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnFo6fNaHCTAkJbfcdkqtG
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "always"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# =============================================================================
|
||||
# SPA image: build with Node, serve the static bundle with nginx.
|
||||
#
|
||||
# The runtime image contains no Node, no sources and no package manager - just
|
||||
# nginx and a handful of hashed assets, running as an unprivileged user.
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 1 - build
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the manifests first so `npm ci` is cached until a dependency changes.
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY frontend/ ./
|
||||
# Same-origin API path: nginx proxies /api to the Django service.
|
||||
ENV VITE_API_BASE_URL=/api/v1
|
||||
RUN npm run build
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2 - serve
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
|
||||
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf
|
||||
COPY frontend/nginx.conf /etc/nginx/conf.d/app.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# nginx:alpine already ships an unprivileged "nginx" user; the official
|
||||
# unprivileged entrypoint needs these directories to be writable by it.
|
||||
RUN touch /var/run/nginx.pid \
|
||||
&& chown -R nginx:nginx /var/run/nginx.pid /var/cache/nginx /usr/share/nginx/html
|
||||
|
||||
USER nginx
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=4s --start-period=10s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:8080/healthz || exit 1
|
||||
@@ -0,0 +1,26 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'node_modules'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="description" content="Keyword search and faceted filtering over a LinkedIn profile dataset." />
|
||||
<title>Profile Search</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Serves the SPA and proxies /api to the Django container.
|
||||
#
|
||||
# Putting both behind one origin means the browser never issues a cross-origin
|
||||
# request, so no CORS pre-flight, no third-party cookie rules, and the API can
|
||||
# keep its allow-list empty in production.
|
||||
# -----------------------------------------------------------------------------
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# --- Security headers ----------------------------------------------------
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||
# The bundle is fully self-hosted, so the policy can stay strict.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
|
||||
server_tokens off;
|
||||
client_max_body_size 2m;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
add_header Content-Type text/plain;
|
||||
return 200 'ok';
|
||||
}
|
||||
|
||||
# Hashed assets never change under the same name - cache them hard.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Request-ID $request_id;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# Django admin and API documentation share the backend origin.
|
||||
location ~ ^/(admin|static)/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Client-side routing: unknown paths fall back to the SPA shell.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
}
|
||||
Generated
+3635
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "linkedin-search-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"axios": "^1.7.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.12.0",
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"eslint": "^9.12.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.12",
|
||||
"globals": "^15.11.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript-eslint": "^8.8.1",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { SearchPage } from '@/pages/SearchPage';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
export default function App() {
|
||||
const { status } = useAuth();
|
||||
|
||||
if (status === 'loading') {
|
||||
return <p className="state">Restoring your session…</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={status === 'authenticated' ? <Navigate to="/" replace /> : <LoginPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={status === 'authenticated' ? <SearchPage /> : <Navigate to="/login" replace />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Axios instance shared by the whole app.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - prefix every call with the API base URL;
|
||||
* - attach the bearer token;
|
||||
* - transparently refresh an expired access token exactly once per failure,
|
||||
* queueing any parallel requests so a burst of 401s triggers one refresh;
|
||||
* - normalise the backend's error envelope into a plain `Error`.
|
||||
*
|
||||
* Tokens live in `sessionStorage`: they disappear when the tab is closed, and
|
||||
* unlike a cookie they are never attached to cross-site requests, so the API
|
||||
* needs no CSRF protection for its JSON endpoints.
|
||||
*/
|
||||
import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
import type { ApiError } from '@/types';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/v1';
|
||||
const ACCESS_KEY = 'lks.access';
|
||||
const REFRESH_KEY = 'lks.refresh';
|
||||
|
||||
export const tokenStore = {
|
||||
get access(): string | null {
|
||||
return sessionStorage.getItem(ACCESS_KEY);
|
||||
},
|
||||
get refresh(): string | null {
|
||||
return sessionStorage.getItem(REFRESH_KEY);
|
||||
},
|
||||
save(access: string, refresh?: string): void {
|
||||
sessionStorage.setItem(ACCESS_KEY, access);
|
||||
if (refresh) sessionStorage.setItem(REFRESH_KEY, refresh);
|
||||
},
|
||||
clear(): void {
|
||||
sessionStorage.removeItem(ACCESS_KEY);
|
||||
sessionStorage.removeItem(REFRESH_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
export const api: AxiosInstance = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
timeout: 15000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = tokenStore.access;
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
type Waiter = (token: string | null) => void;
|
||||
let refreshing = false;
|
||||
let waiters: Waiter[] = [];
|
||||
|
||||
function notify(token: string | null): void {
|
||||
waiters.forEach((resolve) => resolve(token));
|
||||
waiters = [];
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
const refresh = tokenStore.refresh;
|
||||
if (!refresh) return null;
|
||||
try {
|
||||
const { data } = await axios.post<{ access: string; refresh?: string }>(
|
||||
`${BASE_URL}/auth/refresh/`,
|
||||
{ refresh },
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
tokenStore.save(data.access, data.refresh);
|
||||
return data.access;
|
||||
} catch {
|
||||
tokenStore.clear();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError<ApiError>) => {
|
||||
const original = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined;
|
||||
|
||||
if (error.response?.status === 401 && original && !original._retried) {
|
||||
original._retried = true;
|
||||
|
||||
if (refreshing) {
|
||||
const token = await new Promise<string | null>((resolve) => waiters.push(resolve));
|
||||
if (!token) return Promise.reject(toError(error));
|
||||
original.headers.Authorization = `Bearer ${token}`;
|
||||
return api(original);
|
||||
}
|
||||
|
||||
refreshing = true;
|
||||
const token = await refreshAccessToken();
|
||||
refreshing = false;
|
||||
notify(token);
|
||||
|
||||
if (token) {
|
||||
original.headers.Authorization = `Bearer ${token}`;
|
||||
return api(original);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('auth:expired'));
|
||||
}
|
||||
|
||||
return Promise.reject(toError(error));
|
||||
},
|
||||
);
|
||||
|
||||
export function toError(error: unknown): Error {
|
||||
if (axios.isAxiosError<ApiError>(error)) {
|
||||
const payload = error.response?.data;
|
||||
if (payload?.error) {
|
||||
const enriched = new Error(payload.error.message);
|
||||
enriched.name = payload.error.code;
|
||||
return enriched;
|
||||
}
|
||||
if (error.code === 'ECONNABORTED') return new Error('The request timed out. Please retry.');
|
||||
return new Error(error.message);
|
||||
}
|
||||
return error instanceof Error ? error : new Error('Unexpected error');
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/** Typed wrappers around the REST endpoints. */
|
||||
import { api, tokenStore } from '@/api/client';
|
||||
import type {
|
||||
ProfileDetail,
|
||||
SearchFilters,
|
||||
SearchResponse,
|
||||
TokenPair,
|
||||
User,
|
||||
} from '@/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function login(email: string, password: string): Promise<TokenPair> {
|
||||
const { data } = await api.post<TokenPair>('/auth/login/', { email, password });
|
||||
tokenStore.save(data.access, data.refresh);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function register(payload: {
|
||||
email: string;
|
||||
full_name: string;
|
||||
password: string;
|
||||
password_confirm: string;
|
||||
}): Promise<User> {
|
||||
const { data } = await api.post<User>('/auth/register/', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchMe(): Promise<User> {
|
||||
const { data } = await api.get<User>('/auth/me/');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
const refresh = tokenStore.refresh;
|
||||
try {
|
||||
if (refresh) await api.post('/auth/logout/', { refresh });
|
||||
} finally {
|
||||
tokenStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Turns the UI state into the query string the API expects. */
|
||||
export function toQueryParams(filters: SearchFilters): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.q.trim()) params.set('q', filters.q.trim());
|
||||
if (filters.skills.length) params.set('skills', filters.skills.join(','));
|
||||
if (filters.roles.length) params.set('roles', filters.roles.join(','));
|
||||
if (filters.seniority.length) params.set('seniority', filters.seniority.join(','));
|
||||
if (filters.countries.length) params.set('countries', filters.countries.join(','));
|
||||
if (filters.industries.length) params.set('industries', filters.industries.join(','));
|
||||
if (filters.companies.length) params.set('companies', filters.companies.join(','));
|
||||
if (filters.minExperience !== null) params.set('min_experience', String(filters.minExperience));
|
||||
if (filters.maxExperience !== null) params.set('max_experience', String(filters.maxExperience));
|
||||
if (filters.sort !== 'relevance') params.set('sort', filters.sort);
|
||||
if (filters.page > 1) params.set('page', String(filters.page));
|
||||
if (filters.pageSize !== 20) params.set('page_size', String(filters.pageSize));
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function search(filters: SearchFilters, signal?: AbortSignal): Promise<SearchResponse> {
|
||||
const { data } = await api.get<SearchResponse>('/search/', {
|
||||
params: toQueryParams(filters),
|
||||
signal,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function suggest(prefix: string, signal?: AbortSignal): Promise<string[]> {
|
||||
const { data } = await api.get<{ suggestions: string[] }>('/search/suggest/', {
|
||||
params: { q: prefix },
|
||||
signal,
|
||||
});
|
||||
return data.suggestions;
|
||||
}
|
||||
|
||||
export async function fetchProfile(id: number): Promise<ProfileDetail> {
|
||||
const { data } = await api.get<ProfileDetail>(`/profiles/${id}/`);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { FacetBucket } from '@/types';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
buckets: FacetBucket[];
|
||||
selected: string[];
|
||||
onToggle: (value: string) => void;
|
||||
/** Show a "filter within this facet" box once the list gets long. */
|
||||
searchable?: boolean;
|
||||
initiallyOpen?: boolean;
|
||||
}
|
||||
|
||||
export function FacetGroup({
|
||||
title,
|
||||
buckets,
|
||||
selected,
|
||||
onToggle,
|
||||
searchable = false,
|
||||
initiallyOpen = true,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(initiallyOpen || selected.length > 0);
|
||||
const [term, setTerm] = useState('');
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const needle = term.trim().toLowerCase();
|
||||
const matching = needle
|
||||
? buckets.filter((bucket) => bucket.key.toLowerCase().includes(needle))
|
||||
: buckets;
|
||||
|
||||
// A selected value must stay visible even when the current result set no
|
||||
// longer contains it - otherwise the user cannot untick it.
|
||||
const missing = selected
|
||||
.filter((value) => !matching.some((bucket) => bucket.key === value))
|
||||
.map((value) => ({ key: value, count: 0 }));
|
||||
|
||||
return [...missing, ...matching];
|
||||
}, [buckets, selected, term]);
|
||||
|
||||
if (!buckets.length && !selected.length) return null;
|
||||
|
||||
return (
|
||||
<div className="facet">
|
||||
<button
|
||||
type="button"
|
||||
className="facet__toggle"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<span>
|
||||
{title}
|
||||
{selected.length > 0 && <span className="facet__count"> · {selected.length}</span>}
|
||||
</span>
|
||||
<span aria-hidden="true">{open ? '−' : '+'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
{searchable && buckets.length > 8 && (
|
||||
<input
|
||||
className="facet__filter"
|
||||
type="search"
|
||||
placeholder={`Filter ${title.toLowerCase()}`}
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
aria-label={`Filter ${title}`}
|
||||
/>
|
||||
)}
|
||||
<ul className="facet__list">
|
||||
{visible.map((bucket) => (
|
||||
<li key={bucket.key}>
|
||||
<label className="facet__option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(bucket.key)}
|
||||
onChange={() => onToggle(bucket.key)}
|
||||
/>
|
||||
<span className="facet__label" title={bucket.key}>
|
||||
{bucket.key}
|
||||
</span>
|
||||
<span className="facet__count">{bucket.count}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { FacetGroup } from '@/components/FacetGroup';
|
||||
import type { ListKey } from '@/hooks/useSearchFilters';
|
||||
import type { FacetBucket, FacetName, SearchFilters } from '@/types';
|
||||
|
||||
interface Props {
|
||||
facets: Record<FacetName, FacetBucket[]> | undefined;
|
||||
filters: SearchFilters;
|
||||
activeCount: number;
|
||||
onToggle: (key: ListKey, value: string) => void;
|
||||
onUpdate: (patch: Partial<SearchFilters>) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
/** Facet name in the API response -> label and the filter it drives. */
|
||||
const GROUPS: { facet: FacetName; key: ListKey; title: string; searchable?: boolean }[] = [
|
||||
{ facet: 'skills', key: 'skills', title: 'Skills', searchable: true },
|
||||
{ facet: 'job_title_role', key: 'roles', title: 'Job family' },
|
||||
{ facet: 'seniority', key: 'seniority', title: 'Seniority' },
|
||||
{ facet: 'country', key: 'countries', title: 'Country', searchable: true },
|
||||
{ facet: 'industry', key: 'industries', title: 'Industry', searchable: true },
|
||||
{ facet: 'company', key: 'companies', title: 'Company', searchable: true },
|
||||
];
|
||||
|
||||
export function FilterPanel({ facets, filters, activeCount, onToggle, onUpdate, onReset }: Props) {
|
||||
const parseYears = (raw: string): number | null => {
|
||||
if (raw === '') return null;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) && value >= 0 ? Math.min(value, 70) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="panel panel--sticky" aria-label="Filters">
|
||||
<div className="panel__header">
|
||||
<h2 className="panel__title">Filters</h2>
|
||||
{activeCount > 0 && (
|
||||
<button type="button" className="button button--ghost" onClick={onReset}>
|
||||
Clear all ({activeCount})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="facet">
|
||||
<span className="facet__toggle" aria-hidden="true">
|
||||
Years of experience
|
||||
</span>
|
||||
<div className="range">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={70}
|
||||
placeholder="min"
|
||||
aria-label="Minimum years of experience"
|
||||
value={filters.minExperience ?? ''}
|
||||
onChange={(event) => onUpdate({ minExperience: parseYears(event.target.value) })}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={70}
|
||||
placeholder="max"
|
||||
aria-label="Maximum years of experience"
|
||||
value={filters.maxExperience ?? ''}
|
||||
onChange={(event) => onUpdate({ maxExperience: parseYears(event.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{GROUPS.map(({ facet, key, title, searchable }, index) => (
|
||||
<FacetGroup
|
||||
key={facet}
|
||||
title={title}
|
||||
buckets={facets?.[facet] ?? []}
|
||||
selected={filters[key]}
|
||||
onToggle={(value) => onToggle(key, value)}
|
||||
searchable={searchable}
|
||||
initiallyOpen={index < 3}
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Fragment } from 'react';
|
||||
|
||||
/**
|
||||
* Renders the backend's highlight snippets **without** `dangerouslySetInnerHTML`.
|
||||
*
|
||||
* The snippet is profile data with `<mark>` tags injected around the matched
|
||||
* terms. Injecting it as HTML would hand any script that made it into the
|
||||
* dataset a way into the DOM, so the string is split on the marker tags and
|
||||
* rendered as React text nodes instead - the highlighting still works, and the
|
||||
* content can never become markup.
|
||||
*/
|
||||
const PATTERN = /<mark>(.*?)<\/mark>/gs;
|
||||
|
||||
export function Highlight({ text, fallback }: { text?: string; fallback?: string }) {
|
||||
const value = text ?? fallback ?? '';
|
||||
if (!value.includes('<mark>')) return <>{value}</>;
|
||||
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (const match of value.matchAll(PATTERN)) {
|
||||
const start = match.index ?? 0;
|
||||
if (start > cursor) nodes.push(value.slice(cursor, start));
|
||||
nodes.push(<mark key={`${start}-${match[1]}`}>{match[1]}</mark>);
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
if (cursor < value.length) nodes.push(value.slice(cursor));
|
||||
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node, index) => (
|
||||
<Fragment key={index}>{node}</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
interface Props {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({ page, totalPages, onChange }: Props) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<nav className="pagination" aria-label="Pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => onChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<span aria-live="polite">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => onChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { fetchProfile } from '@/api/endpoints';
|
||||
|
||||
interface Props {
|
||||
profileId: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function period(start: string, end: string): string {
|
||||
if (!start && !end) return '';
|
||||
return `${start || '?'} → ${end || 'present'}`;
|
||||
}
|
||||
|
||||
export function ProfileDrawer({ profileId, onClose }: Props) {
|
||||
const { data, isPending, error } = useQuery({
|
||||
queryKey: ['profile', profileId],
|
||||
queryFn: () => fetchProfile(profileId),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onEscape);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onEscape);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="drawer__backdrop" onClick={onClose} role="presentation">
|
||||
<div
|
||||
className="drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Profile details"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{isPending && <p className="state">Loading profile…</p>}
|
||||
{error && <p className="state state--error">{(error as Error).message}</p>}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<header className="drawer__header">
|
||||
<div>
|
||||
<h2>{data.full_name}</h2>
|
||||
<p className="timeline__meta">{data.headline || data.job_title}</p>
|
||||
</div>
|
||||
<button type="button" className="button" onClick={onClose} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="card__meta">
|
||||
{data.location_name && <span>📍 {data.location_name}</span>}
|
||||
{data.years_experience !== null && <span>🗓 {data.years_experience} yrs</span>}
|
||||
{data.connections !== null && <span>🔗 {data.connections} connections</span>}
|
||||
{data.linkedin_url && (
|
||||
<a href={`https://${data.linkedin_url}`} target="_blank" rel="noreferrer noopener">
|
||||
LinkedIn ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.summary && (
|
||||
<section>
|
||||
<h3>Summary</h3>
|
||||
<p>{data.summary}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data.skills.length > 0 && (
|
||||
<section>
|
||||
<h3>Skills ({data.skills.length})</h3>
|
||||
<div className="chips">
|
||||
{data.skills.map((skill) => (
|
||||
<span key={skill.slug} className="chip">
|
||||
{skill.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data.experiences.length > 0 && (
|
||||
<section>
|
||||
<h3>Experience</h3>
|
||||
<ul className="timeline">
|
||||
{data.experiences.map((item) => (
|
||||
<li key={item.id}>
|
||||
<div className="timeline__title">{item.title || 'Unknown role'}</div>
|
||||
<div className="timeline__meta">
|
||||
{item.company_name}
|
||||
{item.company_name && ' · '}
|
||||
{period(item.start_date, item.end_date)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data.educations.length > 0 && (
|
||||
<section>
|
||||
<h3>Education</h3>
|
||||
<ul className="timeline">
|
||||
{data.educations.map((item) => (
|
||||
<li key={item.id}>
|
||||
<div className="timeline__title">{item.school_name}</div>
|
||||
<div className="timeline__meta">
|
||||
{[...item.degrees, ...item.majors].join(', ')}
|
||||
{item.end_date && ` · ${item.end_date}`}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h3>Contact</h3>
|
||||
{data.pii_masked && (
|
||||
<p className="notice">
|
||||
Contact details are masked for your role. An analyst or administrator sees the
|
||||
full values.
|
||||
</p>
|
||||
)}
|
||||
<ul className="timeline">
|
||||
{data.emails.map((mail) => (
|
||||
<li key={mail.address}>
|
||||
<div className="timeline__title">{mail.address}</div>
|
||||
<div className="timeline__meta">{mail.type}</div>
|
||||
</li>
|
||||
))}
|
||||
{data.phone_numbers.map((phone) => (
|
||||
<li key={phone}>
|
||||
<div className="timeline__title">{phone}</div>
|
||||
</li>
|
||||
))}
|
||||
{data.emails.length === 0 && data.phone_numbers.length === 0 && (
|
||||
<li className="timeline__meta">No contact details in the dataset.</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Highlight } from '@/components/Highlight';
|
||||
import type { SearchHit } from '@/types';
|
||||
|
||||
interface Props {
|
||||
hit: SearchHit;
|
||||
onOpen: (id: number) => void;
|
||||
onSkillClick: (skill: string) => void;
|
||||
selectedSkills: string[];
|
||||
}
|
||||
|
||||
export function ResultCard({ hit, onOpen, onSkillClick, selectedSkills }: Props) {
|
||||
const highlightedSkills = new Set(
|
||||
(hit.highlight.skills ?? []).map((value) => value.replace(/<\/?mark>/g, '')),
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
className="card"
|
||||
onClick={() => onOpen(hit.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onOpen(hit.id);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`Open profile of ${hit.full_name}`}
|
||||
>
|
||||
<div className="card__top">
|
||||
<h3 className="card__name">
|
||||
<Highlight text={hit.highlight.full_name?.[0]} fallback={hit.full_name} />
|
||||
</h3>
|
||||
{hit.score > 0 && <span className="card__score">score {hit.score.toFixed(2)}</span>}
|
||||
</div>
|
||||
|
||||
<p className="card__headline">
|
||||
<Highlight text={hit.highlight.job_title?.[0]} fallback={hit.headline || hit.job_title} />
|
||||
</p>
|
||||
|
||||
<div className="card__meta">
|
||||
{hit.location_name && <span>📍 {hit.location_name}</span>}
|
||||
{hit.years_experience !== null && <span>🗓 {hit.years_experience} yrs</span>}
|
||||
{hit.industry && <span>🏢 {hit.industry}</span>}
|
||||
{hit.seniority_levels.length > 0 && <span>⭑ {hit.seniority_levels.join(', ')}</span>}
|
||||
</div>
|
||||
|
||||
{hit.skills.length > 0 && (
|
||||
<div className="chips">
|
||||
{hit.skills.slice(0, 8).map((skill) => {
|
||||
const active = selectedSkills.includes(skill) || highlightedSkills.has(skill);
|
||||
return (
|
||||
<button
|
||||
key={skill}
|
||||
type="button"
|
||||
className={`chip chip--button${active ? ' chip--active' : ''}`}
|
||||
onClick={(event) => {
|
||||
// The card itself opens the drawer; a chip filters instead.
|
||||
event.stopPropagation();
|
||||
onSkillClick(skill);
|
||||
}}
|
||||
title={`Filter by ${skill}`}
|
||||
>
|
||||
{skill}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { suggest } from '@/api/endpoints';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SearchBar({ value, onChange }: Props) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [cursor, setCursor] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const debounced = useDebouncedValue(draft, 300);
|
||||
|
||||
// Keep the box in sync when the URL changes (back button, cleared filters).
|
||||
useEffect(() => setDraft(value), [value]);
|
||||
|
||||
// One request per settled keystroke, not per keystroke.
|
||||
useEffect(() => {
|
||||
if (debounced !== value) onChange(debounced);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debounced]);
|
||||
|
||||
const { data: suggestions = [] } = useQuery({
|
||||
queryKey: ['suggest', debounced],
|
||||
queryFn: ({ signal }) => suggest(debounced, signal),
|
||||
enabled: open && debounced.trim().length >= 2,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onClickOutside = (event: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
return () => document.removeEventListener('mousedown', onClickOutside);
|
||||
}, []);
|
||||
|
||||
const choose = (item: string) => {
|
||||
setDraft(item);
|
||||
onChange(item);
|
||||
setOpen(false);
|
||||
setCursor(-1);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!suggestions.length) return;
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setCursor((index) => (index + 1) % suggestions.length);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setCursor((index) => (index <= 0 ? suggestions.length - 1 : index - 1));
|
||||
} else if (event.key === 'Enter' && cursor >= 0) {
|
||||
event.preventDefault();
|
||||
const picked = suggestions[cursor];
|
||||
if (picked) choose(picked);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="searchbar" ref={containerRef}>
|
||||
<label className="visually-hidden" htmlFor="search-input">
|
||||
Search profiles
|
||||
</label>
|
||||
<input
|
||||
id="search-input"
|
||||
className="searchbar__input"
|
||||
type="search"
|
||||
autoComplete="off"
|
||||
placeholder='Search by name, title, skill, company or school - try "product manager"'
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setOpen(true);
|
||||
setCursor(-1);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
role="combobox"
|
||||
aria-expanded={open && suggestions.length > 0}
|
||||
aria-controls="search-suggestions"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
{open && suggestions.length > 0 && (
|
||||
<ul className="searchbar__suggestions" id="search-suggestions" role="listbox">
|
||||
{suggestions.map((item, index) => (
|
||||
<li key={item} role="option" aria-selected={index === cursor}>
|
||||
<button
|
||||
type="button"
|
||||
className="searchbar__suggestion"
|
||||
aria-selected={index === cursor}
|
||||
onClick={() => choose(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Delays propagating a rapidly changing value.
|
||||
*
|
||||
* The search box calls this with 300 ms so typing "python" issues one request
|
||||
* instead of six.
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: T, delay = 300): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebounced(value), delay);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Keeps the search state in the URL.
|
||||
*
|
||||
* The URL is the single source of truth for what is on screen, which means a
|
||||
* result page can be bookmarked, shared with a colleague, and survives a
|
||||
* refresh or the browser back button - all for free.
|
||||
*/
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { EMPTY_FILTERS, type SearchFilters } from '@/types';
|
||||
|
||||
const LIST_KEYS = ['skills', 'roles', 'seniority', 'countries', 'industries', 'companies'] as const;
|
||||
type ListKey = (typeof LIST_KEYS)[number];
|
||||
|
||||
function parseList(params: URLSearchParams, key: string): string[] {
|
||||
const raw = params.get(key);
|
||||
return raw ? raw.split(',').filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function parseNumber(params: URLSearchParams, key: string): number | null {
|
||||
const raw = params.get(key);
|
||||
if (raw === null || raw === '') return null;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function useSearchFilters() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
const filters = useMemo<SearchFilters>(
|
||||
() => ({
|
||||
q: params.get('q') ?? '',
|
||||
skills: parseList(params, 'skills'),
|
||||
roles: parseList(params, 'roles'),
|
||||
seniority: parseList(params, 'seniority'),
|
||||
countries: parseList(params, 'countries'),
|
||||
industries: parseList(params, 'industries'),
|
||||
companies: parseList(params, 'companies'),
|
||||
minExperience: parseNumber(params, 'min_experience'),
|
||||
maxExperience: parseNumber(params, 'max_experience'),
|
||||
sort: params.get('sort') ?? 'relevance',
|
||||
page: parseNumber(params, 'page') ?? 1,
|
||||
pageSize: parseNumber(params, 'page_size') ?? 20,
|
||||
}),
|
||||
[params],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
(patch: Partial<SearchFilters>, { resetPage = true } = {}) => {
|
||||
const next: SearchFilters = { ...filters, ...patch };
|
||||
if (resetPage && patch.page === undefined) next.page = 1;
|
||||
|
||||
const search = new URLSearchParams();
|
||||
if (next.q.trim()) search.set('q', next.q.trim());
|
||||
LIST_KEYS.forEach((key) => {
|
||||
const values = next[key as ListKey];
|
||||
if (values.length) search.set(key, values.join(','));
|
||||
});
|
||||
if (next.minExperience !== null) search.set('min_experience', String(next.minExperience));
|
||||
if (next.maxExperience !== null) search.set('max_experience', String(next.maxExperience));
|
||||
if (next.sort !== 'relevance') search.set('sort', next.sort);
|
||||
if (next.page > 1) search.set('page', String(next.page));
|
||||
if (next.pageSize !== 20) search.set('page_size', String(next.pageSize));
|
||||
|
||||
setParams(search, { replace: true });
|
||||
},
|
||||
[filters, setParams],
|
||||
);
|
||||
|
||||
/** Adds or removes one value from a multi-select facet. */
|
||||
const toggle = useCallback(
|
||||
(key: ListKey, value: string) => {
|
||||
const current = filters[key];
|
||||
const next = current.includes(value)
|
||||
? current.filter((item) => item !== value)
|
||||
: [...current, value];
|
||||
update({ [key]: next } as Partial<SearchFilters>);
|
||||
},
|
||||
[filters, update],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => setParams(new URLSearchParams(), { replace: true }), [setParams]);
|
||||
|
||||
const activeCount = useMemo(
|
||||
() =>
|
||||
LIST_KEYS.reduce((total, key) => total + filters[key as ListKey].length, 0) +
|
||||
(filters.minExperience !== null ? 1 : 0) +
|
||||
(filters.maxExperience !== null ? 1 : 0),
|
||||
[filters],
|
||||
);
|
||||
|
||||
return { filters, update, toggle, reset, activeCount, defaults: EMPTY_FILTERS };
|
||||
}
|
||||
|
||||
export type { ListKey };
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Auth context object and its consumer hook (no components, so fast refresh stays happy). */
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { User } from '@/types';
|
||||
|
||||
export interface AuthContextValue {
|
||||
user: User | null;
|
||||
status: 'loading' | 'authenticated' | 'anonymous';
|
||||
signIn: (email: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) throw new Error('useAuth must be used inside <AuthProvider>');
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Authentication state shared by the whole app. */
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
import { tokenStore } from '@/api/client';
|
||||
import * as endpoints from '@/api/endpoints';
|
||||
import { AuthContext, type AuthContextValue } from '@/lib/auth-context';
|
||||
import type { User } from '@/types';
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [status, setStatus] = useState<AuthContextValue['status']>('loading');
|
||||
|
||||
// Restore the session on a page reload: the token survives in sessionStorage,
|
||||
// but the user object does not, so it is fetched back from /auth/me/.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!tokenStore.access) {
|
||||
setStatus('anonymous');
|
||||
return;
|
||||
}
|
||||
endpoints
|
||||
.fetchMe()
|
||||
.then((me) => {
|
||||
if (cancelled) return;
|
||||
setUser(me);
|
||||
setStatus('authenticated');
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
tokenStore.clear();
|
||||
setStatus('anonymous');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The axios interceptor raises this when a refresh token is no longer valid.
|
||||
useEffect(() => {
|
||||
const onExpired = () => {
|
||||
setUser(null);
|
||||
setStatus('anonymous');
|
||||
};
|
||||
window.addEventListener('auth:expired', onExpired);
|
||||
return () => window.removeEventListener('auth:expired', onExpired);
|
||||
}, []);
|
||||
|
||||
const signIn = useCallback(async (email: string, password: string) => {
|
||||
const data = await endpoints.login(email, password);
|
||||
setUser(data.user);
|
||||
setStatus('authenticated');
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await endpoints.logout();
|
||||
setUser(null);
|
||||
setStatus('anonymous');
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({ user, status, signIn, signOut }),
|
||||
[user, status, signIn, signOut],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
import App from '@/App';
|
||||
import { AuthProvider } from '@/lib/auth';
|
||||
import '@/styles/global.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) throw new Error('Root element #root is missing from index.html');
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
|
||||
import { register } from '@/api/endpoints';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
type Mode = 'login' | 'register';
|
||||
|
||||
export function LoginPage() {
|
||||
const { signIn } = useAuth();
|
||||
const [mode, setMode] = useState<Mode>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const onSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (mode === 'register') {
|
||||
await register({
|
||||
email,
|
||||
full_name: fullName,
|
||||
password,
|
||||
password_confirm: passwordConfirm,
|
||||
});
|
||||
}
|
||||
await signIn(email, password);
|
||||
} catch (caught) {
|
||||
setError((caught as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<form className="panel login__card" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 20 }}>Profile Search</h1>
|
||||
<p style={{ color: 'var(--text-muted)', margin: '4px 0 0' }}>
|
||||
{mode === 'login' ? 'Sign in to search the dataset.' : 'Create a viewer account.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mode === 'register' && (
|
||||
<div className="field">
|
||||
<label htmlFor="full_name">Full name</label>
|
||||
<input
|
||||
id="full_name"
|
||||
value={fullName}
|
||||
onChange={(event) => setFullName(event.target.value)}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={10}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === 'register' && (
|
||||
<div className="field">
|
||||
<label htmlFor="password_confirm">Confirm password</label>
|
||||
<input
|
||||
id="password_confirm"
|
||||
type="password"
|
||||
required
|
||||
value={passwordConfirm}
|
||||
onChange={(event) => setPasswordConfirm(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="error-text" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button className="button button--primary" type="submit" disabled={busy}>
|
||||
{busy ? 'Please wait…' : mode === 'login' ? 'Sign in' : 'Create account'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="button button--ghost"
|
||||
onClick={() => {
|
||||
setMode(mode === 'login' ? 'register' : 'login');
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{mode === 'login' ? 'Need an account? Register' : 'Already registered? Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { search } from '@/api/endpoints';
|
||||
import { FilterPanel } from '@/components/FilterPanel';
|
||||
import { Pagination } from '@/components/Pagination';
|
||||
import { ProfileDrawer } from '@/components/ProfileDrawer';
|
||||
import { ResultCard } from '@/components/ResultCard';
|
||||
import { SearchBar } from '@/components/SearchBar';
|
||||
import { useSearchFilters } from '@/hooks/useSearchFilters';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'relevance', label: 'Best match' },
|
||||
{ value: '-experience', label: 'Most experience' },
|
||||
{ value: 'experience', label: 'Least experience' },
|
||||
{ value: 'name', label: 'Name (A→Z)' },
|
||||
];
|
||||
|
||||
export function SearchPage() {
|
||||
const { user, signOut } = useAuth();
|
||||
const { filters, update, toggle, reset, activeCount } = useSearchFilters();
|
||||
const [openProfile, setOpenProfile] = useState<number | null>(null);
|
||||
|
||||
const { data, isPending, isFetching, error } = useQuery({
|
||||
queryKey: ['search', filters],
|
||||
queryFn: ({ signal }) => search(filters, signal),
|
||||
// Keeping the previous page on screen while the next one loads avoids the
|
||||
// list collapsing to zero height on every keystroke.
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<span className="brand">Profile Search</span>
|
||||
<div style={{ flex: 1, maxWidth: 620 }}>
|
||||
<SearchBar value={filters.q} onChange={(q) => update({ q })} />
|
||||
</div>
|
||||
<div className="topbar__spacer" />
|
||||
<div className="identity">
|
||||
<span title={user?.email}>
|
||||
{user?.email} · {user?.role}
|
||||
</span>
|
||||
<button type="button" className="button" onClick={() => void signOut()}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="layout">
|
||||
<FilterPanel
|
||||
facets={data?.facets}
|
||||
filters={filters}
|
||||
activeCount={activeCount}
|
||||
onToggle={toggle}
|
||||
onUpdate={update}
|
||||
onReset={reset}
|
||||
/>
|
||||
|
||||
<main>
|
||||
<div className="results__header">
|
||||
<strong style={{ color: 'var(--text)' }}>
|
||||
{data ? data.count.toLocaleString() : '—'} profiles
|
||||
</strong>
|
||||
{data && (
|
||||
<span>
|
||||
in {data.took_ms} ms · engine: {data.backend}
|
||||
</span>
|
||||
)}
|
||||
{isFetching && !isPending && <span>updating…</span>}
|
||||
<div className="topbar__spacer" />
|
||||
<label htmlFor="sort">Sort</label>
|
||||
<select
|
||||
id="sort"
|
||||
className="button"
|
||||
value={filters.sort}
|
||||
onChange={(event) => update({ sort: event.target.value })}
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="state state--error" role="alert">
|
||||
{(error as Error).message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isPending && (
|
||||
<div className="results__list" aria-busy="true">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<div className="skeleton" key={index} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.results.length === 0 && !isPending && (
|
||||
<div className="state">
|
||||
<p>No profile matches this search.</p>
|
||||
{activeCount > 0 && (
|
||||
<button type="button" className="button" onClick={reset}>
|
||||
Clear the filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.results.length > 0 && (
|
||||
<div className="results__list">
|
||||
{data.results.map((hit) => (
|
||||
<ResultCard
|
||||
key={hit.id}
|
||||
hit={hit}
|
||||
onOpen={setOpenProfile}
|
||||
onSkillClick={(skill) => toggle('skills', skill)}
|
||||
selectedSkills={filters.skills}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
page={data?.page ?? 1}
|
||||
totalPages={data?.total_pages ?? 1}
|
||||
onChange={(page) => {
|
||||
update({ page }, { resetPage: false });
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{openProfile !== null && (
|
||||
<ProfileDrawer profileId={openProfile} onClose={() => setOpenProfile(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
Design tokens. One place to change spacing, colour and radius; every
|
||||
component below only ever references a variable.
|
||||
--------------------------------------------------------------------------- */
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f0f2f5;
|
||||
--border: #dfe3e8;
|
||||
--text: #16202c;
|
||||
--text-muted: #5b6775;
|
||||
--accent: #0a66c2;
|
||||
--accent-soft: #e7f0fb;
|
||||
--danger: #b42318;
|
||||
--mark: #fde68a;
|
||||
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 24px;
|
||||
--space-6: 32px;
|
||||
--shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 4px 12px rgba(16, 24, 40, 0.05);
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--surface: #171c22;
|
||||
--surface-2: #1f262e;
|
||||
--border: #2b333d;
|
||||
--text: #e6eaef;
|
||||
--text-muted: #9aa6b2;
|
||||
--accent: #61a5f2;
|
||||
--accent-soft: #16283c;
|
||||
--danger: #f97066;
|
||||
--mark: #7c5e10;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
mark {
|
||||
background: var(--mark);
|
||||
color: inherit;
|
||||
padding: 0 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* --- Layout --------------------------------------------------------------- */
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 288px minmax(0, 1fr);
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-5);
|
||||
align-items: start;
|
||||
max-width: 1360px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Generic surfaces ----------------------------------------------------- */
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.panel--sticky {
|
||||
position: sticky;
|
||||
top: 76px;
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel__title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* --- Buttons -------------------------------------------------------------- */
|
||||
.button {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 7px 12px;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.button--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.06);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/* --- Search bar ----------------------------------------------------------- */
|
||||
.searchbar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.searchbar__input {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.searchbar__input:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.searchbar__suggestions {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: calc(100% + 4px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
z-index: 15;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.searchbar__suggestion {
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.searchbar__suggestion:hover,
|
||||
.searchbar__suggestion[aria-selected='true'] {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* --- Facets --------------------------------------------------------------- */
|
||||
.facet {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.facet:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.facet__toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.facet__list {
|
||||
list-style: none;
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 232px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.facet__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.facet__option:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.facet__label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.facet__count {
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.facet__filter {
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.range {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.range input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* --- Results -------------------------------------------------------------- */
|
||||
.results__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-4) 0 var(--space-3);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.results__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--space-4);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
text-align: start;
|
||||
transition: border-color 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card__top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.card__name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card__score {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.card__headline {
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chip--active {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.chip--button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* --- Pagination ----------------------------------------------------------- */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5) 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* --- States --------------------------------------------------------------- */
|
||||
.state {
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.state--error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
height: 104px;
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(90deg, var(--surface) 0%, var(--surface-2) 50%, var(--surface) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.2s ease-in-out infinite;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Drawer --------------------------------------------------------------- */
|
||||
.drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(9, 14, 20, 0.45);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
width: min(560px, 100%);
|
||||
height: 100%;
|
||||
background: var(--surface);
|
||||
border-inline-start: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.drawer__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.drawer h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.drawer h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.timeline li {
|
||||
border-inline-start: 2px solid var(--border);
|
||||
padding-inline-start: var(--space-3);
|
||||
}
|
||||
|
||||
.timeline__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline__meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- Login ---------------------------------------------------------------- */
|
||||
.login {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.login__card {
|
||||
width: min(400px, 100%);
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field input {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/** Shapes returned by the API. Kept in one place so a contract change breaks the build. */
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
full_name: string;
|
||||
role: 'admin' | 'analyst' | 'viewer';
|
||||
can_view_pii: boolean;
|
||||
date_joined: string;
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
access: string;
|
||||
refresh: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
id: number;
|
||||
score: number;
|
||||
full_name: string;
|
||||
headline: string;
|
||||
job_title: string;
|
||||
job_title_role: string;
|
||||
seniority_levels: string[];
|
||||
company: string;
|
||||
industry: string;
|
||||
location_name: string;
|
||||
location_country: string;
|
||||
years_experience: number | null;
|
||||
linkedin_url: string;
|
||||
skills: string[];
|
||||
highlight: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface FacetBucket {
|
||||
key: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type FacetName = 'skills' | 'job_title_role' | 'seniority' | 'country' | 'industry' | 'company';
|
||||
|
||||
export interface SearchResponse {
|
||||
count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
took_ms: number;
|
||||
backend: string;
|
||||
results: SearchHit[];
|
||||
facets: Record<FacetName, FacetBucket[]>;
|
||||
}
|
||||
|
||||
export interface ProfileExperience {
|
||||
id: number;
|
||||
title: string;
|
||||
company_name: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
is_primary: boolean;
|
||||
location_name: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ProfileEducation {
|
||||
id: number;
|
||||
school_name: string;
|
||||
degrees: string[];
|
||||
majors: string[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
}
|
||||
|
||||
export interface ProfileDetail {
|
||||
id: number;
|
||||
full_name: string;
|
||||
headline: string;
|
||||
job_title: string;
|
||||
job_title_role: string;
|
||||
seniority_levels: string[];
|
||||
current_company: { id: number; name: string; industry: string; size: string } | null;
|
||||
industry: string;
|
||||
location_name: string;
|
||||
location_country: string;
|
||||
summary: string;
|
||||
years_experience: number | null;
|
||||
connections: number | null;
|
||||
interests: string[];
|
||||
languages: string[];
|
||||
linkedin_url: string;
|
||||
github_url: string;
|
||||
skills: { name: string; slug: string }[];
|
||||
experiences: ProfileExperience[];
|
||||
educations: ProfileEducation[];
|
||||
emails: { address: string; type: string }[];
|
||||
phone_numbers: string[];
|
||||
pii_masked: boolean;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
request_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Every filter the search UI can apply. */
|
||||
export interface SearchFilters {
|
||||
q: string;
|
||||
skills: string[];
|
||||
roles: string[];
|
||||
seniority: string[];
|
||||
countries: string[];
|
||||
industries: string[];
|
||||
companies: string[];
|
||||
minExperience: number | null;
|
||||
maxExperience: number | null;
|
||||
sort: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export const EMPTY_FILTERS: SearchFilters = {
|
||||
q: '',
|
||||
skills: [],
|
||||
roles: [],
|
||||
seniority: [],
|
||||
countries: [],
|
||||
industries: [],
|
||||
companies: [],
|
||||
minExperience: null,
|
||||
maxExperience: null,
|
||||
sort: 'relevance',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
};
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"useDefineForClassFields": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/components/FacetGroup.tsx","./src/components/FilterPanel.tsx","./src/components/Highlight.tsx","./src/components/Pagination.tsx","./src/components/ProfileDrawer.tsx","./src/components/ResultCard.tsx","./src/components/SearchBar.tsx","./src/hooks/useDebouncedValue.ts","./src/hooks/useSearchFilters.ts","./src/lib/auth-context.ts","./src/lib/auth.tsx","./src/pages/LoginPage.tsx","./src/pages/SearchPage.tsx","./src/types/index.ts","./vite.config.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
// Mirrors the "@/*" path mapping in tsconfig.json.
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
// During development the SPA talks to Django through this proxy, so the
|
||||
// browser only ever sees one origin and CORS never enters the picture.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_PROXY_TARGET ?? 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom', 'react-router-dom'],
|
||||
query: ['@tanstack/react-query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user