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:
2026-09-03 08:13:10 +00:00
commit a464e550c9
133 changed files with 17019 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.env
.env.*
*.log
+7
View File
@@ -0,0 +1,7 @@
{
"singleQuote": true,
"semi": true,
"printWidth": 100,
"trailingComma": "all",
"arrowParens": "always"
}
+43
View File
@@ -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
+26
View File
@@ -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: '^_' }],
},
},
);
+14
View File
@@ -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>
+68
View File
@@ -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";
}
}
+3635
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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"
}
}
+27
View File
@@ -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>
);
}
+121
View File
@@ -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');
}
+85
View File
@@ -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;
}
+91
View File
@@ -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>
);
}
+81
View File
@@ -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>
);
}
+36
View File
@@ -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>
))}
</>
);
}
+33
View File
@@ -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>
);
}
+154
View File
@@ -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>
);
}
+72
View File
@@ -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>
);
}
+112
View File
@@ -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>
);
}
+18
View File
@@ -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;
}
+96
View File
@@ -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 };
+19
View File
@@ -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;
}
+66
View File
@@ -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>;
}
+33
View File
@@ -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>,
);
+123
View File
@@ -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>
);
}
+144
View File
@@ -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>
);
}
+603
View File
@@ -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;
}
+138
View File
@@ -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,
};
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+23
View File
@@ -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"]
}
+1
View File
@@ -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"}
+36
View File
@@ -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'],
},
},
},
},
});