Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ A microservice-based video streaming platform built with .NET 8, React, and Post
| Service | Port | Database | What it does |
|---------|------|----------|--------------|
| API Gateway (YARP) | 5050 | -- | Routes all requests, CORS whitelist, health checks |
| Account | 5001 (REST), 5003 (gRPC) | PostgreSQL :5432 | Auth, profiles, notifications, gRPC username server |
| Video Catalogue | 5002 | PostgreSQL :5433 | Upload, comments, playlists, subscriptions |
| Account | 5001 (REST), 5003 (gRPC) | PostgreSQL :5432 | Auth, profiles, notifications, user search, gRPC username server |
| Video Catalogue | 5002 | PostgreSQL :5433 | Upload, search, comments, playlists, subscriptions |
| Donation | 5100 | PostgreSQL :5434 | Wallets, donations, balance transfers |
| Chat | 5004 | PostgreSQL :5435 | Chat rooms, messages, real-time via SignalR |
| RabbitMQ | 5672 / 15672 | -- | Async event broker (MassTransit) |
Expand Down Expand Up @@ -158,6 +158,15 @@ Services read from environment variables first, then fall back to `appsettings.j
- **Inter-service auth**: gRPC and HTTP calls between services require `INTERNAL_API_KEY` header
- **Secrets**: No credentials in code or config files -- all in `.env` (gitignored)

## Search

The platform supports searching across videos and channels from a single search bar.

**Backend**: `GET /api/videos/search?q={query}&category={category}`
- Searches video titles and descriptions (case-insensitive, DB-level filtering)
- Optional `category` parameter to narrow results
- Returns videos with resolved uploader usernames (via gRPC)

## Swagger docs

| Service | URL |
Expand Down
4 changes: 3 additions & 1 deletion glense.client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
Donations,
Upload,
Playlists,
PlaylistDetail
PlaylistDetail,
SearchResults
} from "./components";
import Chat from "./components/Chat/Chat";

Expand All @@ -22,6 +23,7 @@ const App = () => {
<Routes>
<Route path='/' element={<Feed />} />

<Route path='/search/:searchTerm' element={<SearchResults />} />
<Route path='/video/:id' element={<VideoStream />} />
<Route path='/channel/:id' element={<ChannelDetail />} />
<Route path='/upload' element={<Upload />} />
Expand Down
148 changes: 148 additions & 0 deletions glense.client/src/components/SearchResults.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { useState, useEffect, useMemo } from "react";
import { useParams, Link } from "react-router-dom";
import { Box, Typography, Stack, CircularProgress, Avatar } from "@mui/material";
import { CheckCircle } from "@mui/icons-material";

import { Videos } from "./";
import { searchVideos } from "../utils/videoApi";
import { profileService } from "../services/profileService";
import { categories } from "../utils/constants";

import "../css/SearchResults.css";

const colors = ['#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3', '#00bcd4', '#009688', '#4caf50', '#ff9800', '#ff5722'];
function stringToColor(str) {
let h = 0;
for (let i = 0; i < (str || '').length; i++) h = str.charCodeAt(i) + ((h << 5) - h);
return colors[Math.abs(h) % colors.length];
}

function SearchResults() {
const { searchTerm } = useParams();
const [videos, setVideos] = useState([]);
const [channels, setChannels] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState(null);

const filteredVideos = useMemo(() => {
if (!selectedCategory) return videos;
return videos.filter(v => v.category && v.category.toLowerCase() === selectedCategory.toLowerCase());
}, [videos, selectedCategory]);

useEffect(() => {
if (!searchTerm) return;

let mounted = true;
setLoading(true);
setVideos([]);
setChannels([]);
setSelectedCategory(null);

Promise.all([
searchVideos(searchTerm).catch(() => []),
profileService.searchUsers(searchTerm).catch(() => []),
]).then(([videoData, userData]) => {
if (!mounted) return;
if (Array.isArray(videoData)) setVideos(videoData);
if (Array.isArray(userData)) setChannels(userData);
setLoading(false);
});

return () => { mounted = false; };
}, [searchTerm]);

if (loading) {
return (
<Box className="search-results-container">
<Box className="search-results-loading">
<CircularProgress sx={{ color: 'var(--color-text-subtle)' }} />
</Box>
</Box>
);
}

const noResults = videos.length === 0 && channels.length === 0;

return (
<Box className="search-results-container">
<Typography variant="h5" className="search-results-heading">
Search results for &quot;{searchTerm}&quot;
</Typography>

{noResults && (
<Typography className="search-results-empty">
No results found. Try a different search term.
</Typography>
)}

{channels.length > 0 && (
<Box className="search-results-section">
<Typography variant="h6" className="search-results-section-title">
Channels
</Typography>
<Stack direction="row" className="search-results-channels">
{channels.map((user) => (
<Link
key={user.id}
to={`/channel/${user.id}`}
className="search-results-channel-link"
>
<Stack direction="column" alignItems="center" className="search-results-channel-card">
<Avatar
sx={{
bgcolor: stringToColor(user.username),
width: 56,
height: 56,
fontSize: 24,
}}
>
{(user.username || '?').charAt(0).toUpperCase()}
</Avatar>
<Typography variant="subtitle2" className="search-results-channel-name">
{user.username}
<CheckCircle style={{ fontSize: 10, color: "gray", marginLeft: 4 }} />
</Typography>
</Stack>
</Link>
))}
</Stack>
</Box>
)}

{videos.length > 0 && (
<Box className="search-results-section">
<Typography variant="h6" className="search-results-section-title">
Videos
</Typography>
<Stack direction="row" className="search-results-categories">
<button
className={`category-btn${selectedCategory === null ? ' selected' : ''}`}
onClick={() => setSelectedCategory(null)}
>
All
</button>
{categories.filter(c => c.name !== "New Videos").map((cat) => (
<button
key={cat.name}
className={`category-btn${selectedCategory === cat.name ? ' selected' : ''}`}
onClick={() => setSelectedCategory(selectedCategory === cat.name ? null : cat.name)}
>
<span>{cat.icon}</span>
<span>{cat.name}</span>
</button>
))}
</Stack>
{filteredVideos.length > 0 ? (
<Videos videos={filteredVideos} />
) : (
<Typography className="search-results-empty">
No videos in this category.
</Typography>
)}
</Box>
)}
</Box>
);
}

export default SearchResults;
5 changes: 3 additions & 2 deletions glense.client/src/components/Searchbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ function SearchBar() {

const handleSubmit = (e) => {
e.preventDefault();
navigate(`/search/${searchTerm}`);
if (!searchTerm.trim()) return;
navigate(`/search/${encodeURIComponent(searchTerm)}`);
setSearchTerm("");
};

Expand All @@ -30,7 +31,7 @@ function SearchBar() {
onChange={(e) => setSearchTerm(e.target.value)}
/>

<IconButton>
<IconButton type="submit">
<Search className='search-icon'/>
</IconButton>
</Paper>
Expand Down
4 changes: 2 additions & 2 deletions glense.client/src/components/VideoCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ function VideoCard({ video }) {
{title.slice(0, 80) + (title.length > 80 ? "..." : "")}
</Typography>
</Link>
<Link to={demoVideoUrl}>
<Link to={video?.uploaderId ? `/channel/${video.uploaderId}` : demoVideoUrl}>
<Typography variant="subtitle2" className="video-card-channel">
{demoChannelTitle}
{video?.uploaderUsername || demoChannelTitle}
<CheckCircle className="video-card-icon" />
</Typography>
</Link>
Expand Down
19 changes: 15 additions & 4 deletions glense.client/src/components/VideoStream.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useParams, Link } from "react-router-dom";
import ReactPlayer from "react-player";
import { Typography, Box, Stack, Button, FormControl, Select, MenuItem, Avatar, Snackbar } from "@mui/material";
Expand All @@ -13,7 +13,7 @@ import {
} from "@mui/icons-material";

import { Videos, VideoComments } from ".";
import { getVideo, getVideos, getPlaylists, addVideoToPlaylist, likeVideo, updateVideoCategory } from "../utils/videoApi";
import { getVideo, getVideos, getPlaylists, addVideoToPlaylist, likeVideo, getUserLike, updateVideoCategory, incrementView } from "../utils/videoApi";
import { categories } from "../utils/constants";
import { useAuth } from "../context/AuthContext";

Expand Down Expand Up @@ -42,6 +42,7 @@ function VideoStream() {
const [userLike, setUserLike] = useState(null);
const [editingCategory, setEditingCategory] = useState(false);
const { user } = useAuth();
const viewCounted = useRef(false);

const isOwner = user && video && String(user.id) === String(video.uploaderId);

Expand All @@ -52,8 +53,12 @@ function VideoStream() {
getVideos().then(list => { if (mounted && Array.isArray(list)) setRelated(list.filter(v => String(v.id) !== String(id)).slice(0, 12)); }).catch(() => {});
setUserLike(null);
setShowMoreDesc(false);
viewCounted.current = false;
if (user && id) {
getUserLike(id).then(data => { if (mounted && data?.liked != null) setUserLike(data.liked); }).catch(() => {});
}
return () => { mounted = false; };
}, [id]);
}, [id, user]);

useEffect(() => {
let mounted = true;
Expand Down Expand Up @@ -120,11 +125,17 @@ function VideoStream() {
controls
width="100%"
height="100%"
onStart={() => {
if (!viewCounted.current) {
viewCounted.current = true;
incrementView(id).catch(() => {});
}
}}
/>
<Typography className="video-title">{video?.title || demoVideoInfo.title}</Typography>

<Stack className="video-details">
<Link to={`/channel/${video?.uploaderUsername || video?.uploaderId}`} style={{ display: 'flex', alignItems: 'center', gap: 10, textDecoration: 'none' }}>
<Link to={`/channel/${video?.uploaderId}`} style={{ display: 'flex', alignItems: 'center', gap: 10, textDecoration: 'none' }}>
<Avatar sx={{ bgcolor: '#c62828', width: 36, height: 36, fontSize: 16 }}>
{(video?.uploaderUsername || video?.title || '?').charAt(0).toUpperCase()}
</Avatar>
Expand Down
3 changes: 2 additions & 1 deletion glense.client/src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ export { default as DonationModal } from './Donations/DonationModal'
export { default as DonationHistory } from './Donations/DonationHistory'
export { default as Upload } from './Upload'
export { default as PlaylistDetail } from './PlaylistDetail'
export { default as Playlists } from './Playlists'
export { default as Playlists } from './Playlists'
export { default as SearchResults } from './SearchResults'
92 changes: 92 additions & 0 deletions glense.client/src/css/SearchResults.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
.search-results-container {
padding: var(--spacing-xl);
padding-top: calc(var(--navbar-height) + var(--spacing-xl));
min-height: 100vh;
background-color: var(--color-bg-primary);
}

.search-results-loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 50vh;
}

.search-results-heading {
color: var(--color-text-primary) !important;
margin-bottom: var(--spacing-xl) !important;
font-weight: bold !important;
}

.search-results-empty {
color: var(--color-text-secondary) !important;
font-size: var(--font-size-lg) !important;
text-align: center;
margin-top: var(--spacing-xl) !important;
}

.search-results-section {
margin-bottom: var(--spacing-xl);
}

.search-results-section-title {
color: var(--color-text-primary) !important;
margin-bottom: var(--spacing-base) !important;
font-weight: bold !important;
}

.search-results-categories {
flex-wrap: wrap !important;
gap: var(--spacing-sm) !important;
margin-bottom: var(--spacing-lg);
}

.search-results-channels {
flex-wrap: wrap !important;
gap: var(--spacing-lg) !important;
margin-bottom: var(--spacing-lg);
}

.search-results-channel-link {
text-decoration: none;
}

.search-results-channel-card {
padding: var(--spacing-base);
border-radius: var(--radius-md);
transition: background-color var(--transition-fast);
min-width: 100px;
align-items: center;
}

.search-results-channel-card:hover {
background-color: var(--color-bg-overlay);
}

.search-results-channel-name {
color: var(--color-text-primary) !important;
margin-top: var(--spacing-sm) !important;
display: flex !important;
align-items: center !important;
font-size: var(--font-size-sm) !important;
}

/* Tablet (1024px) */
@media screen and (max-width: 1024px) {
.search-results-container {
padding: var(--spacing-lg);
padding-top: calc(var(--navbar-height-tablet) + var(--spacing-lg));
}
}

/* Mobile (600px) */
@media screen and (max-width: 600px) {
.search-results-container {
padding: var(--spacing-base);
padding-top: calc(var(--navbar-height-mobile) + var(--spacing-base));
}

.search-results-heading {
font-size: var(--font-size-xl) !important;
}
}
Loading
Loading