Skip to content

Commit 2872b4b

Browse files
committed
feat(frontend): add SemanticSearchBar component, basic VideoCard, Cypress E2E test and Next.js scaffolding for semantic search UI
1 parent a8e9257 commit 2872b4b

4 files changed

Lines changed: 193 additions & 0 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React, { useState, FormEvent } from "react";
2+
import VideoCard from "./VideoCard";
3+
4+
export interface VideoSummary {
5+
videoId: string;
6+
title: string;
7+
thumbnailUrl?: string | null;
8+
userId: string;
9+
submittedAt: string;
10+
content_rating?: string | null;
11+
category?: string | null;
12+
views: number;
13+
averageRating?: number | null;
14+
}
15+
16+
interface Pagination {
17+
currentPage: number;
18+
pageSize: number;
19+
totalItems: number;
20+
totalPages: number;
21+
}
22+
23+
interface PaginatedResponse {
24+
data: VideoSummary[];
25+
pagination: Pagination;
26+
}
27+
28+
interface SemanticSearchBarProps {
29+
/**
30+
* Optional base URL for the API. Defaults to relative path
31+
* so that the component works when the front-end and backend
32+
* are served from the same origin (e.g. behind a reverse proxy).
33+
*/
34+
apiBaseUrl?: string;
35+
/**
36+
* Number of results to request per page (default: 10)
37+
*/
38+
pageSize?: number;
39+
}
40+
41+
const SemanticSearchBar: React.FC<SemanticSearchBarProps> = ({
42+
apiBaseUrl = "",
43+
pageSize = 10,
44+
}) => {
45+
const [query, setQuery] = useState<string>("");
46+
const [results, setResults] = useState<VideoSummary[]>([]);
47+
const [loading, setLoading] = useState<boolean>(false);
48+
const [error, setError] = useState<string | null>(null);
49+
50+
const handleSubmit = async (e: FormEvent) => {
51+
e.preventDefault();
52+
if (!query.trim()) {
53+
return;
54+
}
55+
await performSearch(query.trim());
56+
};
57+
58+
const performSearch = async (q: string) => {
59+
try {
60+
setLoading(true);
61+
setError(null);
62+
const params = new URLSearchParams();
63+
params.set("query", q);
64+
params.set("mode", "semantic");
65+
params.set("page", "1");
66+
params.set("pageSize", pageSize.toString());
67+
68+
const response = await fetch(
69+
`${apiBaseUrl}/api/v1/search/videos?${params.toString()}`
70+
);
71+
72+
if (!response.ok) {
73+
throw new Error(`Backend search failed: ${response.status}`);
74+
}
75+
76+
const payload: PaginatedResponse = await response.json();
77+
setResults(payload.data);
78+
} catch (err: any) {
79+
console.error(err);
80+
setError(err.message ?? "Unknown error");
81+
} finally {
82+
setLoading(false);
83+
}
84+
};
85+
86+
return (
87+
<div className="semantic-search-bar">
88+
<form onSubmit={handleSubmit} className="search-form">
89+
<input
90+
type="text"
91+
placeholder="Search videos…"
92+
aria-label="Search videos"
93+
value={query}
94+
onChange={(e) => setQuery(e.target.value)}
95+
disabled={loading}
96+
/>
97+
<button type="submit" disabled={loading || !query.trim()}>
98+
{loading ? "Searching…" : "Search"}
99+
</button>
100+
</form>
101+
102+
{error && <p className="error">{error}</p>}
103+
104+
<div className="results">
105+
{results.map((video) => (
106+
<VideoCard key={video.videoId} video={video} />
107+
))}
108+
</div>
109+
</div>
110+
);
111+
};
112+
113+
export default SemanticSearchBar;

frontend/components/VideoCard.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React from "react";
2+
import { VideoSummary } from "./SemanticSearchBar";
3+
4+
interface VideoCardProps {
5+
video: VideoSummary;
6+
}
7+
8+
const VideoCard: React.FC<VideoCardProps> = ({ video }) => {
9+
return (
10+
<div className="video-card">
11+
{video.thumbnailUrl && (
12+
// eslint-disable-next-line @next/next/no-img-element -- plain img is fine here
13+
<img
14+
src={video.thumbnailUrl}
15+
alt={video.title}
16+
className="thumbnail"
17+
/>
18+
)}
19+
<h3>{video.title}</h3>
20+
</div>
21+
);
22+
};
23+
24+
export default VideoCard;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
describe("Semantic Search", () => {
2+
it("returns results when user searches", () => {
3+
cy.intercept("GET", "/api/v1/search/videos*", {
4+
statusCode: 200,
5+
body: {
6+
data: [
7+
{
8+
videoId: "00000000-0000-0000-0000-000000000001",
9+
title: "Test Video",
10+
thumbnailUrl: null,
11+
userId: "user",
12+
submittedAt: new Date().toISOString(),
13+
views: 0,
14+
averageRating: null,
15+
},
16+
],
17+
pagination: {
18+
currentPage: 1,
19+
pageSize: 10,
20+
totalItems: 1,
21+
totalPages: 1,
22+
},
23+
},
24+
}).as("search");
25+
26+
cy.visit("/");
27+
cy.get('input[aria-label="Search videos"]').type("test{enter}");
28+
cy.wait("@search");
29+
cy.contains("Test Video");
30+
});
31+
});

frontend/package.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "killrvideo-frontend",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "next dev",
7+
"build": "next build",
8+
"start": "next start",
9+
"lint": "next lint",
10+
"cypress": "cypress open"
11+
},
12+
"dependencies": {
13+
"next": "^14.0.0",
14+
"react": "^18.2.0",
15+
"react-dom": "^18.2.0"
16+
},
17+
"devDependencies": {
18+
"typescript": "^5.4.0",
19+
"@types/react": "^18.2.42",
20+
"@types/node": "^20.6.1",
21+
"eslint": "^8.53.0",
22+
"eslint-config-next": "^14.0.0",
23+
"cypress": "^13.3.0"
24+
}
25+
}

0 commit comments

Comments
 (0)