{/* Chat Header */}
-
- {(chat.topic || chat.Topic || chat.name || '?').charAt(0).toUpperCase()}
+
+ {(chat.displayName || chat.topic || '?').charAt(0).toUpperCase()}
- {chat.name || chat.Topic || chat.topic || chat.title || 'Chat'}
+ {chat.displayName || chat.topic || 'Chat'}
{/* Chat Messages */}
diff --git a/glense.client/src/components/Feed.jsx b/glense.client/src/components/Feed.jsx
index 7d87cd4..27723fa 100644
--- a/glense.client/src/components/Feed.jsx
+++ b/glense.client/src/components/Feed.jsx
@@ -1,14 +1,12 @@
-import { useState, useEffect } from "react";
+import { useState, useEffect, useMemo } from "react";
import { Box, Stack } from "@mui/material";
import { Sidebar, Videos } from "./";
import "../css/Feed.css";
import { getVideos } from "../utils/videoApi";
-// Fetches videos from VideoCatalogue service and shows them in feed.
-
function Feed() {
- const [selectedCategory, setSelectedCategory] = useState("Category changed");
+ const [selectedCategory, setSelectedCategory] = useState("New Videos");
const [items, setItems] = useState([]);
useEffect(() => {
@@ -19,6 +17,11 @@ function Feed() {
return () => { mounted = false; };
}, []);
+ const filtered = useMemo(() => {
+ if (!selectedCategory || selectedCategory === "New Videos") return items;
+ return items.filter(v => v.category === selectedCategory);
+ }, [items, selectedCategory]);
+
return (
@@ -29,10 +32,10 @@ function Feed() {
-
+
);
}
-export default Feed;
\ No newline at end of file
+export default Feed;
diff --git a/glense.client/src/components/Navbar.jsx b/glense.client/src/components/Navbar.jsx
index f0c92d4..52a53a9 100644
--- a/glense.client/src/components/Navbar.jsx
+++ b/glense.client/src/components/Navbar.jsx
@@ -1,5 +1,5 @@
import { Stack, Typography, Button, IconButton, Menu, MenuItem, Avatar } from "@mui/material";
-import { Link } from "react-router-dom";
+import { Link, useNavigate } from "react-router-dom";
import logo from "../assets/logo_transparent.png";
import SearchBar from "../components/SearchBar";
import { useState } from "react";
@@ -12,6 +12,7 @@ function Navbar() {
const [open, setOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState(null);
const { isAuthenticated, user, logout } = useAuth();
+ const navigate = useNavigate();
const channelId = user?.username || "mkbhd";
const handleMenuOpen = (event) => {
@@ -25,6 +26,7 @@ function Navbar() {
const handleLogout = () => {
logout();
handleMenuClose();
+ navigate('/');
};
return (
diff --git a/glense.client/src/components/PlaylistDetail.jsx b/glense.client/src/components/PlaylistDetail.jsx
index 72230a1..6ee8a71 100644
--- a/glense.client/src/components/PlaylistDetail.jsx
+++ b/glense.client/src/components/PlaylistDetail.jsx
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
-import { Box, Typography, Grid, Button, Card, CardMedia, CardContent } from "@mui/material";
+import { Box, Typography, Grid, Button, Stack, Snackbar } from "@mui/material";
+import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { getPlaylistVideos, removeVideoFromPlaylist } from "../utils/videoApi";
+import "../css/PlaylistDetail.css";
function PlaylistDetail() {
const { id } = useParams();
@@ -14,31 +16,56 @@ function PlaylistDetail() {
return () => { mounted = false; };
}, [id]);
+ const [snackbar, setSnackbar] = useState("");
+
const handleRemove = async (videoId) => {
try {
await removeVideoFromPlaylist(id, videoId);
setVideos(prev => prev.filter(v => String(v.id) !== String(videoId)));
- } catch (e) { alert('Failed to remove'); }
+ setSnackbar("Video removed");
+ } catch { setSnackbar("Failed to remove video"); }
};
return (
-
- Playlist
-
+
+
+ ← Back to playlists
+ Playlist
+
+
+ {videos.length === 0 && (
+ No videos in this playlist yet.
+ )}
+
+
{videos.map(v => (
-
+
-
+
-
- {v.title}
-
-
-
+
+
+ {v.title}
+
+ }
+ onClick={() => handleRemove(v.id)}
+ className="playlist-video-remove"
+ >
+ Remove
+
+
+
))}
+
+ setSnackbar("")} message={snackbar} />
);
}
diff --git a/glense.client/src/components/Playlists.jsx b/glense.client/src/components/Playlists.jsx
index cb3dd29..c3aea84 100644
--- a/glense.client/src/components/Playlists.jsx
+++ b/glense.client/src/components/Playlists.jsx
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
-import { Box, Typography, TextField, Button, List, ListItem, ListItemText } from "@mui/material";
+import { Box, Typography, TextField, Button, Stack } from "@mui/material";
import { getPlaylists, createPlaylist } from "../utils/videoApi";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
+import "../css/Playlists.css";
function Playlists() {
const { user } = useAuth();
@@ -10,6 +11,7 @@ function Playlists() {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [message, setMessage] = useState("");
+ const [messageType, setMessageType] = useState("error");
useEffect(() => {
let mounted = true;
@@ -19,37 +21,54 @@ function Playlists() {
const handleCreate = async (e) => {
e.preventDefault();
+ if (!name.trim()) {
+ setMessage("Please enter a playlist name.");
+ setMessageType("error");
+ return;
+ }
try {
- const resp = await createPlaylist(name, description, user?.id || 0);
+ const resp = await createPlaylist(name, description);
setPlaylists(prev => [resp, ...prev]);
setName(""); setDescription("");
- setMessage("Playlist created");
+ setMessage("Playlist created!");
+ setMessageType("success");
} catch (err) {
setMessage(err.message || String(err));
+ setMessageType("error");
}
};
return (
-
- Your Playlists
+
+
+ Your Playlists
-
- setName(e.target.value)} />
- setDescription(e.target.value)} />
-
-
+
+ setName(e.target.value)} fullWidth />
+ setDescription(e.target.value)} fullWidth multiline rows={2} />
+
+
- {message && {message}}
+ {message && (
+
+ {message}
+
+ )}
+
-
+
+ {playlists.length === 0 && (
+ No playlists yet. Create one above!
+ )}
{playlists.map(p => (
-
- {p.name}
- } secondary={p.description} />
-
+
+ {p.name}
+ {p.description && (
+ {p.description}
+ )}
+
))}
-
+
);
}
diff --git a/glense.client/src/components/Sidebar.jsx b/glense.client/src/components/Sidebar.jsx
index 7fbeb6f..4ebd236 100644
--- a/glense.client/src/components/Sidebar.jsx
+++ b/glense.client/src/components/Sidebar.jsx
@@ -1,4 +1,6 @@
import { Stack } from "@mui/material";
+import { Link } from "react-router-dom";
+import PlaylistPlayIcon from "@mui/icons-material/PlaylistPlay";
import { categories } from "../utils/constants";
import "../css/Sidebar.css";
@@ -6,6 +8,10 @@ import "../css/Sidebar.css";
function Sidebar({ setSelectedCategory }) {
return (
+
+
+ Playlists
+
{categories.map((category) => (
+ {user && playlists.length > 0 && (
+
+
+
+
+ }
+ disabled={adding || !addingTo}
+ onClick={handleAddToPlaylist}
+ className="playlist-add-btn"
+ >
+ {adding ? "Adding..." : "Add"}
+
+
+ )}
{/* Description */}
-
+ { if (!showMoreDesc) setShowMoreDesc(true); }}>
{Number(video?.viewCount ?? demoVideoInfo.viewCount).toLocaleString()} views
- Published at {video?.uploadDate ?? demoVideoInfo.publishedAt}
+ {formatDate(video?.uploadDate)}
-
- {(video?.tags || demoVideoInfo.tags || []).map((tag, index) =>
- (video?.tags || demoVideoInfo.tags).length > 10 ? (
-
- {showMoreTags ? tag : `#${tag.substring(0, 5)}`}
-
- ) : (
-
- #{tag}
-
- )
+ {video?.category && (
+ {video.category}
)}
- {(video?.tags || demoVideoInfo.tags).length > 10 && (
- setShowMoreTags(!showMoreTags)}
- >
- {showMoreTags ? "Show less" : "..."}
-
+
+ {showMoreDesc ? (
+ <>
+ {descText}
+ { e.stopPropagation(); setShowMoreDesc(false); }}>
+ Show less
+
+ >
+ ) : (
+
+ {descText.length > 250 ? `${descText.substring(0, 250)}...` : descText}
+
)}
+
+
-
- {showMoreDesc
- ? (video?.description || demoVideoInfo.description)
- : `${(video?.description || demoVideoInfo.description).substring(0, 250)}`}
- setShowMoreDesc(!showMoreDesc)}
+ {/* Category edit (owner only) */}
+ {isOwner && (
+
+ {editingCategory ? (
+
+
+
+ ) : (
+ }
+ onClick={() => setEditingCategory(true)}
+ className="category-edit-btn"
>
- {showMoreDesc ? "Show less" : "Show more"}
-
-
+ {video?.category ? `Category: ${video.category}` : "Set category"}
+
+ )}
-
+ )}
{/* Comments section */}
Comments
@@ -175,8 +250,15 @@ function VideoStream() {
+
+
setSnackbar("")}
+ message={snackbar}
+ />
);
}
-export default VideoStream;
\ No newline at end of file
+export default VideoStream;
diff --git a/glense.client/src/context/AuthContext.jsx b/glense.client/src/context/AuthContext.jsx
index 75a7858..41a1d19 100644
--- a/glense.client/src/context/AuthContext.jsx
+++ b/glense.client/src/context/AuthContext.jsx
@@ -24,11 +24,28 @@ export const AuthProvider = ({ children }) => {
};
useEffect(() => {
- // Check if user is already logged in
+ // Check if user is already logged in and token is still valid
const currentUser = authService.getCurrentUser();
- if (currentUser) {
- setUser(currentUser);
- setIsAuthenticated(true);
+ const token = authService.getToken();
+ if (currentUser && token) {
+ // Verify token is still accepted by the server
+ const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:5050';
+ fetch(`${baseUrl}/api/chats?pageSize=1`, { headers: { 'Authorization': `Bearer ${token}` } })
+ .then(res => {
+ if (res.ok) {
+ setUser(currentUser);
+ setIsAuthenticated(true);
+ } else {
+ authService.logout();
+ }
+ })
+ .catch(() => {
+ // Network error - keep auth state, user might be offline
+ setUser(currentUser);
+ setIsAuthenticated(true);
+ })
+ .finally(() => setIsLoading(false));
+ return;
}
setIsLoading(false);
diff --git a/glense.client/src/css/Chat/ChatSidebar.css b/glense.client/src/css/Chat/ChatSidebar.css
index 295d89c..4d81995 100644
--- a/glense.client/src/css/Chat/ChatSidebar.css
+++ b/glense.client/src/css/Chat/ChatSidebar.css
@@ -16,6 +16,55 @@
flex-shrink: 0;
}
+.chat-search-input .MuiOutlinedInput-root {
+ color: var(--color-text-white);
+ background-color: rgba(255, 255, 255, 0.05);
+}
+
+.chat-search-input .MuiOutlinedInput-root fieldset {
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.chat-search-input .MuiOutlinedInput-root:hover fieldset {
+ border-color: rgba(255, 255, 255, 0.4);
+}
+
+.chat-search-results {
+ position: absolute;
+ left: 8px;
+ right: 8px;
+ top: 48px;
+ background-color: #1a1a1a;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 8px;
+ z-index: 10;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.chat-search-item {
+ padding: 8px 12px !important;
+ gap: 10px !important;
+ align-items: center !important;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.chat-search-item:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+}
+
+.chat-search-name {
+ color: var(--color-text-white) !important;
+ font-size: 14px !important;
+}
+
+.chat-search-hint {
+ color: var(--color-text-secondary) !important;
+ font-size: 12px !important;
+ padding: 4px 8px !important;
+}
+
.chat-sidebar-item {
padding: 15px !important;
cursor: pointer !important;
diff --git a/glense.client/src/css/Chat/ChatWindow.css b/glense.client/src/css/Chat/ChatWindow.css
index 284d16a..9a8a1ac 100644
--- a/glense.client/src/css/Chat/ChatWindow.css
+++ b/glense.client/src/css/Chat/ChatWindow.css
@@ -17,6 +17,7 @@
border-bottom: 1px solid var(--chat-border) !important;
display: flex !important;
align-items: center !important;
+ gap: 12px !important;
flex-shrink: 0;
}
diff --git a/glense.client/src/css/PlaylistDetail.css b/glense.client/src/css/PlaylistDetail.css
new file mode 100644
index 0000000..c67062f
--- /dev/null
+++ b/glense.client/src/css/PlaylistDetail.css
@@ -0,0 +1,73 @@
+.playlist-detail-page {
+ padding: 24px;
+ min-height: calc(100vh - var(--navbar-height));
+}
+
+.playlist-back-link {
+ color: var(--color-text-secondary);
+ text-decoration: none;
+ font-size: var(--font-size-base);
+ transition: color var(--transition-fast);
+}
+
+.playlist-back-link:hover {
+ color: var(--color-text-white);
+}
+
+.playlist-detail-title {
+ color: var(--color-text-white) !important;
+ font-weight: bold !important;
+}
+
+.playlist-detail-empty {
+ color: var(--color-text-secondary) !important;
+ margin-top: 16px !important;
+}
+
+.playlist-video-card {
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ background-color: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ transition: background-color var(--transition-fast);
+}
+
+.playlist-video-card:hover {
+ background-color: rgba(255, 255, 255, 0.08);
+}
+
+.playlist-video-thumb {
+ height: 160px;
+ background-size: cover;
+ background-position: center;
+ background-color: var(--color-bg-secondary);
+}
+
+.playlist-video-info {
+ padding: 12px;
+}
+
+.playlist-video-link {
+ text-decoration: none;
+}
+
+.playlist-video-title {
+ color: var(--color-text-white) !important;
+ font-weight: bold !important;
+ font-size: var(--font-size-base) !important;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.playlist-video-remove {
+ color: var(--color-text-secondary) !important;
+ text-transform: none !important;
+ justify-content: flex-start !important;
+ padding: 4px 0 !important;
+ font-size: var(--font-size-sm) !important;
+}
+
+.playlist-video-remove:hover {
+ color: var(--color-accent-red) !important;
+}
diff --git a/glense.client/src/css/Playlists.css b/glense.client/src/css/Playlists.css
new file mode 100644
index 0000000..f7cf564
--- /dev/null
+++ b/glense.client/src/css/Playlists.css
@@ -0,0 +1,90 @@
+.playlists-page {
+ padding: 24px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ min-height: calc(100vh - var(--navbar-height));
+}
+
+.playlists-form {
+ max-width: 720px;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.playlists-form .MuiTypography-h5 {
+ color: var(--color-text-white);
+ font-weight: bold;
+ margin-bottom: 8px;
+}
+
+.playlists-form .MuiTextField-root .MuiOutlinedInput-root {
+ color: var(--color-text-white);
+ background-color: rgba(255, 255, 255, 0.05);
+}
+
+.playlists-form .MuiTextField-root .MuiOutlinedInput-root fieldset {
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.playlists-form .MuiTextField-root .MuiOutlinedInput-root:hover fieldset {
+ border-color: rgba(255, 255, 255, 0.4);
+}
+
+.playlists-form .MuiTextField-root .MuiOutlinedInput-root.Mui-focused fieldset {
+ border-color: var(--color-primary);
+}
+
+.playlists-form .MuiInputLabel-root {
+ color: var(--color-text-secondary);
+}
+
+.playlists-form .MuiInputLabel-root.Mui-focused {
+ color: var(--color-primary);
+}
+
+.playlists-form .MuiButton-contained {
+ background-color: var(--color-primary);
+ text-transform: none;
+ font-weight: bold;
+ padding: 10px 24px;
+ align-self: flex-start;
+}
+
+.playlists-list {
+ max-width: 720px;
+ width: 100%;
+ margin-top: 24px;
+}
+
+.playlists-empty {
+ color: var(--color-text-secondary) !important;
+}
+
+.playlist-item {
+ display: block;
+ text-decoration: none;
+ padding: 14px 18px;
+ border-radius: var(--radius-md);
+ background-color: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ transition: background-color var(--transition-fast);
+}
+
+.playlist-item:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+}
+
+.playlist-item-name {
+ color: var(--color-text-white) !important;
+ font-weight: bold !important;
+ font-size: var(--font-size-lg) !important;
+}
+
+.playlist-item-desc {
+ color: var(--color-text-secondary) !important;
+ font-size: var(--font-size-base) !important;
+ margin-top: 4px !important;
+}
diff --git a/glense.client/src/css/Sidebar.css b/glense.client/src/css/Sidebar.css
index b36923f..f5ede4e 100644
--- a/glense.client/src/css/Sidebar.css
+++ b/glense.client/src/css/Sidebar.css
@@ -3,6 +3,13 @@
* Note: .category-btn base styles are in index.css to avoid duplication
*/
+.sidebar-link,
+.sidebar-link:visited,
+.sidebar-link:active {
+ text-decoration: none;
+ color: var(--color-text-primary);
+}
+
.sidebar-stack {
margin-top: var(--navbar-height);
overflow-y: auto;
diff --git a/glense.client/src/css/Upload.css b/glense.client/src/css/Upload.css
index dddf258..96520b5 100644
--- a/glense.client/src/css/Upload.css
+++ b/glense.client/src/css/Upload.css
@@ -70,6 +70,27 @@
border-radius: 8px;
}
+.upload-category-select .MuiOutlinedInput-root {
+ color: var(--color-text-white);
+ background-color: rgba(255, 255, 255, 0.05);
+}
+
+.upload-category-select .MuiOutlinedInput-root fieldset {
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.upload-category-select .MuiOutlinedInput-root:hover fieldset {
+ border-color: rgba(255, 255, 255, 0.4);
+}
+
+.upload-category-select .MuiInputLabel-root {
+ color: var(--color-text-secondary);
+}
+
+.upload-category-select .MuiSvgIcon-root {
+ color: var(--color-text-secondary);
+}
+
.upload-form .MuiButton-contained {
background-color: var(--color-primary);
text-transform: none;
diff --git a/glense.client/src/css/VideoComments.css b/glense.client/src/css/VideoComments.css
index b1e0e1f..3c642e2 100644
--- a/glense.client/src/css/VideoComments.css
+++ b/glense.client/src/css/VideoComments.css
@@ -48,8 +48,67 @@
padding-top: 0.3rem !important;
}
-.comment-thumbs-up {
- padding-right: 0.3rem !important;
+.comment-actions {
+ align-items: center !important;
+ gap: 4px !important;
+ margin-top: 4px !important;
+}
+
+.comment-like-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 2px;
+ display: flex;
+ align-items: center;
+}
+
+.comment-icon {
+ font-size: 16px !important;
+ color: var(--color-text-secondary) !important;
+ transition: color var(--transition-fast);
+}
+
+.comment-icon.active {
+ color: var(--color-text-white) !important;
+}
+
+.comment-like-btn:hover .comment-icon {
+ color: var(--color-text-white) !important;
+}
+
+.comment-count {
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+ min-width: 12px;
+}
+
+.comment-form {
+ gap: 12px !important;
+ align-items: flex-start !important;
+ margin-bottom: 16px !important;
+}
+
+.comment-input .MuiOutlinedInput-root {
+ color: var(--color-text-white);
+ background-color: rgba(255, 255, 255, 0.05);
+}
+
+.comment-input .MuiOutlinedInput-root fieldset {
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.comment-input .MuiOutlinedInput-root:hover fieldset {
+ border-color: rgba(255, 255, 255, 0.4);
+}
+
+.comment-input .MuiOutlinedInput-root.Mui-focused fieldset {
+ border-color: var(--color-primary);
+}
+
+.comment-submit-btn {
+ white-space: nowrap !important;
+ text-transform: none !important;
}
/* Tablet / Mobile landscape (768px) */
diff --git a/glense.client/src/css/VideoStream.css b/glense.client/src/css/VideoStream.css
index ac3fd1a..b70caa2 100644
--- a/glense.client/src/css/VideoStream.css
+++ b/glense.client/src/css/VideoStream.css
@@ -76,19 +76,67 @@
margin-left: 5px !important;
}
+.video-actions {
+ display: flex !important;
+ align-items: center !important;
+ gap: 10px !important;
+ flex-wrap: wrap;
+}
+
.like-dislike {
- opacity: 0.7 !important;
background-color: var(--color-bg-overlay) !important;
- padding: var(--spacing-sm) !important;
+ padding: 6px 12px !important;
border-radius: var(--radius-2xl) !important;
display: flex !important;
align-items: center !important;
- gap: 5px !important;
+ gap: 6px !important;
+ color: var(--color-text-secondary) !important;
+ font-size: var(--font-size-base) !important;
+}
+
+.like-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 2px;
+ display: flex;
+ align-items: center;
+}
+
+.like-separator {
+ opacity: 0.4;
+ margin: 0 2px;
}
.thumb-icon {
font-size: 20px !important;
color: var(--color-text-secondary) !important;
+ transition: color var(--transition-fast);
+}
+
+.thumb-icon.active {
+ color: var(--color-text-white) !important;
+}
+
+.like-btn:hover .thumb-icon {
+ color: var(--color-text-white) !important;
+}
+
+.playlist-add-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.playlist-add-btn {
+ color: var(--color-text-secondary) !important;
+ border-color: rgba(255, 255, 255, 0.2) !important;
+ text-transform: none !important;
+ white-space: nowrap;
+}
+
+.playlist-add-btn:hover {
+ border-color: rgba(255, 255, 255, 0.4) !important;
}
.description-container {
@@ -96,6 +144,40 @@
border-radius: var(--radius-lg) !important;
padding: 15px !important;
margin-top: var(--spacing-md) !important;
+ cursor: pointer;
+ transition: background-color var(--transition-fast);
+}
+
+.description-container:hover:not(.expanded) {
+ background-color: var(--color-bg-overlay-hover) !important;
+}
+
+.description-container.expanded {
+ cursor: default;
+}
+
+.video-category-badge {
+ display: inline-block !important;
+ background-color: rgba(255, 255, 255, 0.1);
+ color: var(--color-text-white) !important;
+ padding: 2px 10px;
+ border-radius: var(--radius-full);
+ font-size: var(--font-size-sm) !important;
+ margin: 4px 0 !important;
+}
+
+.category-edit-row {
+ margin-top: var(--spacing-sm);
+}
+
+.category-edit-btn {
+ color: var(--color-text-secondary) !important;
+ text-transform: none !important;
+ font-size: var(--font-size-sm) !important;
+}
+
+.category-edit-btn:hover {
+ color: var(--color-text-white) !important;
}
.description-details {
diff --git a/glense.client/src/utils/chatService.js b/glense.client/src/utils/chatService.js
index d0a2984..2e9680f 100644
--- a/glense.client/src/utils/chatService.js
+++ b/glense.client/src/utils/chatService.js
@@ -12,10 +12,15 @@ function normalizeBase(raw) {
const BASE = normalizeBase(RAW_BASE);
try { console.info('ChatService BASE =', BASE); } catch {}
+function authHeaders() {
+ const token = localStorage.getItem('glense_auth_token');
+ return token ? { 'Authorization': `Bearer ${token}` } : {};
+}
+
async function request(path, opts = {}) {
try {
const res = await fetch(`${BASE}${path}`, {
- headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
+ headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(opts.headers || {}) },
...opts,
});
if (!res.ok) {
diff --git a/glense.client/src/utils/donationApi.js b/glense.client/src/utils/donationApi.js
index cc0c9f2..bb525b4 100644
--- a/glense.client/src/utils/donationApi.js
+++ b/glense.client/src/utils/donationApi.js
@@ -11,11 +11,17 @@ const DONATION_API_BASE = `${API_BASE_URL}/api`;
/**
* Generic fetch wrapper with error handling
*/
+function authHeaders() {
+ const token = localStorage.getItem('glense_auth_token');
+ return token ? { 'Authorization': `Bearer ${token}` } : {};
+}
+
async function apiFetch(endpoint, options = {}) {
const url = `${DONATION_API_BASE}${endpoint}`;
-
+
const defaultHeaders = {
'Content-Type': 'application/json',
+ ...authHeaders(),
};
const config = {
diff --git a/glense.client/src/utils/videoApi.js b/glense.client/src/utils/videoApi.js
index 9968bf8..5cae3de 100644
--- a/glense.client/src/utils/videoApi.js
+++ b/glense.client/src/utils/videoApi.js
@@ -1,5 +1,10 @@
const BASE = import.meta.env.VITE_API_URL || 'http://localhost:5050';
+function authHeaders() {
+ const token = localStorage.getItem('glense_auth_token');
+ return token ? { 'Authorization': `Bearer ${token}` } : {};
+}
+
async function handleRes(res) {
if (!res.ok) {
const txt = await res.text();
@@ -18,30 +23,36 @@ export async function getVideo(id) {
return handleRes(res);
}
-export async function uploadVideo(file, title, description, uploaderId = 0, thumbnail = null) {
+export async function updateVideoCategory(videoId, category) {
+ const res = await fetch(`${BASE}/api/videos/${videoId}/category`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
+ body: JSON.stringify({ category: category || null }),
+ });
+ return handleRes(res);
+}
+
+export async function uploadVideo(file, title, description, thumbnail = null, category = null) {
const fd = new FormData();
fd.append('file', file);
if (title) fd.append('title', title);
if (description) fd.append('description', description);
if (thumbnail) fd.append('thumbnail', thumbnail);
+ if (category) fd.append('category', category);
- const token = localStorage.getItem('glense_auth_token');
const res = await fetch(`${BASE}/api/videos/upload`, {
method: 'POST',
body: fd,
- headers: {
- ...(uploaderId ? { 'X-Uploader-Id': String(uploaderId) } : {}),
- ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
- },
+ headers: authHeaders(),
});
return handleRes(res);
}
-export async function createPlaylist(name, description, creatorId = 0) {
+export async function createPlaylist(name, description) {
const res = await fetch(`${BASE}/api/playlists`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json', ...(creatorId ? { 'X-Creator-Id': String(creatorId) } : {}) },
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ name, description }),
});
return handleRes(res);
@@ -50,7 +61,7 @@ export async function createPlaylist(name, description, creatorId = 0) {
export async function addVideoToPlaylist(playlistId, videoId) {
const res = await fetch(`${BASE}/api/playlistvideos`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ playlistId, videoId }),
});
return handleRes(res);
@@ -64,34 +75,34 @@ export async function getPlaylistVideos(playlistId) {
export async function removeVideoFromPlaylist(playlistId, videoId) {
const res = await fetch(`${BASE}/api/playlistvideos`, {
method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ playlistId, videoId }),
});
return handleRes(res);
}
-export async function likeVideo(videoId, isLiked = true, userId = 0) {
+export async function likeVideo(videoId, isLiked = true) {
const res = await fetch(`${BASE}/api/videolikes`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json', ...(userId ? { 'X-User-Id': String(userId) } : {}) },
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ videoId, isLiked }),
});
return handleRes(res);
}
-export async function subscribeTo(subscribedToId, userId = 0) {
+export async function subscribeTo(subscribedToId) {
const res = await fetch(`${BASE}/api/subscriptions`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json', ...(userId ? { 'X-User-Id': String(userId) } : {}) },
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ subscribedToId }),
});
return handleRes(res);
}
-export async function unsubscribeFrom(subscribedToId, userId = 0) {
+export async function unsubscribeFrom(subscribedToId) {
const res = await fetch(`${BASE}/api/subscriptions`, {
method: 'DELETE',
- headers: { 'Content-Type': 'application/json', ...(userId ? { 'X-User-Id': String(userId) } : {}) },
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ subscribedToId }),
});
return handleRes(res);
@@ -108,13 +119,21 @@ export async function getComments(videoId) {
return handleRes(res);
}
-export async function postComment(videoId, content, userId = '', username = 'Anonymous') {
+export async function likeComment(videoId, commentId, isLiked) {
+ const res = await fetch(`${BASE}/api/videos/${videoId}/comments/${commentId}/like`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
+ body: JSON.stringify({ isLiked }),
+ });
+ return handleRes(res);
+}
+
+export async function postComment(videoId, content) {
const res = await fetch(`${BASE}/api/videos/${videoId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
- ...(userId ? { 'X-User-Id': String(userId) } : {}),
- ...(username ? { 'X-Username': username } : {}),
+ ...authHeaders(),
},
body: JSON.stringify({ content }),
});
@@ -142,4 +161,6 @@ export default {
getSubscriptions,
getComments,
postComment,
+ likeComment,
+ updateVideoCategory,
};
diff --git a/scripts/seed.sh b/scripts/seed.sh
index 4a693a2..c4954dd 100755
--- a/scripts/seed.sh
+++ b/scripts/seed.sh
@@ -23,6 +23,34 @@ echo "Using container runtime: $CONTAINER_CMD"
PG_VIDEO=${PG_VIDEO:-glense_postgres_video}
+# ── Clean all databases before seeding ──
+echo "=== Cleaning all databases ==="
+
+clean_db() {
+ local container=$1 db=$2
+ echo " Cleaning $db ($container)..."
+ $CONTAINER_CMD exec -i "$container" psql -U glense -d "$db" -c "
+ DO \$\$
+ DECLARE r RECORD;
+ BEGIN
+ FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
+ EXECUTE 'TRUNCATE TABLE \"' || r.tablename || '\" CASCADE';
+ END LOOP;
+ END \$\$;
+ " > /dev/null 2>&1
+ if [ $? -eq 0 ]; then
+ echo " Done"
+ else
+ echo " WARNING: Failed to clean $db (is $container running?)"
+ fi
+}
+
+clean_db "glense_postgres_account" "glense_account"
+clean_db "glense_postgres_video" "glense_video"
+clean_db "glense_postgres_donation" "glense_donation"
+clean_db "glense_postgres_chat" "glense_chat"
+
+echo ""
echo "=== Seeding test users ==="
register_or_find() {
@@ -86,12 +114,8 @@ send_donation "irena" "$IRENA_ID" "keki" "$KEKI_ID" 15 "Collab soon?"
echo ""
echo "=== Seeding videos & comments ==="
-VIDEO_COUNT=$(curl -s "$VIDEO/api/videos" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null)
-if [ "$VIDEO_COUNT" -gt 0 ] 2>/dev/null; then
- echo " Videos already seeded ($VIDEO_COUNT found), skipping"
-else
- TMPFILE=$(mktemp)
- python3 - "$KEKI_ID" "$IRENA_ID" "$BRANKO_ID" > "$TMPFILE" << 'PYEOF'
+TMPFILE=$(mktemp)
+python3 - "$KEKI_ID" "$IRENA_ID" "$BRANKO_ID" > "$TMPFILE" << 'PYEOF'
import uuid, random, sys
random.seed(42)
@@ -99,14 +123,14 @@ uids = sys.argv[1:4]
names = ['keki', 'irena', 'branko']
videos = [
- ('Microservices Explained in 5 Minutes', 'Quick overview of microservice architecture patterns', 'lL_j7ilk7rc', 320000, 15000, 200),
- ('Docker in 100 Seconds', 'Everything you need to know about Docker, fast', 'Gjnup-PuquQ', 890000, 42000, 300),
- ('How Do APIs Work?', 'APIs explained with real-world examples', 's7wmiS2mSXY', 234175, 12300, 40),
- ('Build and Deploy 5 JavaScript and React API Projects', 'Full course covering 5 real-world API projects', 'GDa8kZLNhJ4', 54321, 4560, 10),
- ('.NET 8 Full Course for Beginners', 'Complete beginner guide to .NET 8 and C#', 'AhAxLiGC7Pc', 98000, 5600, 30),
- ('Node.js Ultimate Beginners Guide', 'Learn Node.js from scratch in this crash course', 'ENrzD9HAZK4', 445000, 21000, 1800),
- ('PostgreSQL Tutorial for Beginners', 'Learn PostgreSQL from the ground up', 'SpfIwlAYaKk', 187000, 9800, 120),
- ('Git and GitHub for Beginners', 'Full crash course on Git and GitHub', 'RGOj5yH7evk', 150000, 8700, 95),
+ ('Microservices Explained in 5 Minutes', 'Quick overview of microservice architecture patterns', 'lL_j7ilk7rc', 320000, 15000, 200, 'Education'),
+ ('Docker in 100 Seconds', 'Everything you need to know about Docker, fast', 'Gjnup-PuquQ', 890000, 42000, 300, 'Education'),
+ ('How Do APIs Work?', 'APIs explained with real-world examples', 's7wmiS2mSXY', 234175, 12300, 40, 'Education'),
+ ('Build and Deploy 5 JavaScript and React API Projects', 'Full course covering 5 real-world API projects', 'GDa8kZLNhJ4', 54321, 4560, 10, 'Education'),
+ ('.NET 8 Full Course for Beginners', 'Complete beginner guide to .NET 8 and C#', 'AhAxLiGC7Pc', 98000, 5600, 30, 'Education'),
+ ('Node.js Ultimate Beginners Guide', 'Learn Node.js from scratch in this crash course', 'ENrzD9HAZK4', 445000, 21000, 1800, 'Podcast'),
+ ('PostgreSQL Tutorial for Beginners', 'Learn PostgreSQL from the ground up', 'SpfIwlAYaKk', 187000, 9800, 120, 'Education'),
+ ('Git and GitHub for Beginners', 'Full crash course on Git and GitHub', 'RGOj5yH7evk', 150000, 8700, 95, 'Education'),
]
comments_list = [
@@ -125,14 +149,14 @@ comments_list = [
]
video_ids = []
-for i, (title, desc, ytid, views, likes, dislikes) in enumerate(videos):
+for i, (title, desc, ytid, views, likes, dislikes, cat) in enumerate(videos):
vid = str(uuid.uuid4())
video_ids.append(vid)
uid = uids[i % 3]
days = i * 7 + 1
- print(f'INSERT INTO "Videos" (id, title, description, upload_date, uploader_id, thumbnail_url, video_url, view_count, like_count, dislike_count) '
+ print(f'INSERT INTO "Videos" (id, title, description, upload_date, uploader_id, thumbnail_url, video_url, view_count, like_count, dislike_count, category) '
f"VALUES ('{vid}', '{title}', '{desc}', NOW() - interval '{days} days', '{uid}', "
- f"'https://img.youtube.com/vi/{ytid}/hqdefault.jpg', 'https://www.youtube.com/watch?v={ytid}', {views}, {likes}, {dislikes});")
+ f"'https://img.youtube.com/vi/{ytid}/hqdefault.jpg', 'https://www.youtube.com/watch?v={ytid}', {views}, {likes}, {dislikes}, '{cat}');")
for vid in video_ids:
for j in range(3):
@@ -145,14 +169,13 @@ for vid in video_ids:
f"VALUES ('{cid}', '{vid}', '{uids[ni]}', '{names[ni]}', '{comments_list[ci]}', {lc}, NOW() - interval '{hrs} hours');")
PYEOF
- cat "$TMPFILE" | $CONTAINER_CMD exec -i "$PG_VIDEO" psql -U glense -d glense_video > /dev/null 2>&1
- if [ $? -eq 0 ]; then
- echo " Inserted 8 videos with comments"
- else
- echo " ERROR: Failed to insert videos (is $PG_VIDEO running?)"
- fi
- rm -f "$TMPFILE"
+cat "$TMPFILE" | $CONTAINER_CMD exec -i "$PG_VIDEO" psql -U glense -d glense_video > /dev/null 2>&1
+if [ $? -eq 0 ]; then
+ echo " Inserted 8 videos with comments"
+else
+ echo " ERROR: Failed to insert videos (is $PG_VIDEO running?)"
fi
+rm -f "$TMPFILE"
echo ""
echo "=== Done! ==="
diff --git a/services/Glense.AccountService/Controllers/InternalController.cs b/services/Glense.AccountService/Controllers/InternalController.cs
index 7e94d62..1e8c704 100644
--- a/services/Glense.AccountService/Controllers/InternalController.cs
+++ b/services/Glense.AccountService/Controllers/InternalController.cs
@@ -1,3 +1,4 @@
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Glense.AccountService.DTOs;
using Glense.AccountService.Services;
@@ -6,6 +7,7 @@ namespace Glense.AccountService.Controllers
{
[ApiController]
[Route("api/internal")]
+ [Authorize]
public class InternalController : ControllerBase
{
private readonly INotificationService _notificationService;
diff --git a/services/Glense.ChatService/Controllers/ChatsController.cs b/services/Glense.ChatService/Controllers/ChatsController.cs
index 5aa05bf..705737a 100644
--- a/services/Glense.ChatService/Controllers/ChatsController.cs
+++ b/services/Glense.ChatService/Controllers/ChatsController.cs
@@ -6,7 +6,6 @@
namespace Glense.ChatService.Controllers;
[ApiController]
-[AllowAnonymous]
[Authorize]
[Route("api/[controller]")]
public class ChatsController : ControllerBase
diff --git a/services/Glense.ChatService/Controllers/MessageRootController.cs b/services/Glense.ChatService/Controllers/MessageRootController.cs
index d5f7028..244b249 100644
--- a/services/Glense.ChatService/Controllers/MessageRootController.cs
+++ b/services/Glense.ChatService/Controllers/MessageRootController.cs
@@ -5,7 +5,6 @@
namespace Glense.ChatService.Controllers;
[ApiController]
-[AllowAnonymous]
[Authorize]
[Route("api/messages")]
public class MessageRootController : ControllerBase
diff --git a/services/Glense.ChatService/Controllers/MessagesController.cs b/services/Glense.ChatService/Controllers/MessagesController.cs
index 58809b6..88c2928 100644
--- a/services/Glense.ChatService/Controllers/MessagesController.cs
+++ b/services/Glense.ChatService/Controllers/MessagesController.cs
@@ -7,7 +7,6 @@
namespace Glense.ChatService.Controllers;
[ApiController]
-[AllowAnonymous]
[Authorize]
[Route("api/chats/{chatId:guid}/[controller]")]
public class MessagesController : ControllerBase
diff --git a/services/Glense.VideoCatalogue/Controllers/CommentsController.cs b/services/Glense.VideoCatalogue/Controllers/CommentsController.cs
index 905cbc6..b9b544c 100644
--- a/services/Glense.VideoCatalogue/Controllers/CommentsController.cs
+++ b/services/Glense.VideoCatalogue/Controllers/CommentsController.cs
@@ -1,3 +1,5 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Glense.VideoCatalogue.Data;
@@ -17,6 +19,19 @@ public CommentsController(VideoCatalogueDbContext db)
_db = db;
}
+ private Guid GetCurrentUserId()
+ {
+ var claim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ return Guid.TryParse(claim, out var id) ? id : Guid.Empty;
+ }
+
+ private string GetCurrentUsername()
+ {
+ return User.FindFirst(ClaimTypes.Name)?.Value
+ ?? User.FindFirst("unique_name")?.Value
+ ?? "Anonymous";
+ }
+
[HttpGet]
public async Task GetComments(Guid videoId)
{
@@ -31,6 +46,7 @@ public async Task GetComments(Guid videoId)
Username = c.Username,
Content = c.Content,
LikeCount = c.LikeCount,
+ DislikeCount = c.DislikeCount,
CreatedAt = c.CreatedAt
})
.ToListAsync();
@@ -38,15 +54,17 @@ public async Task GetComments(Guid videoId)
return Ok(comments);
}
+ [Authorize]
[HttpPost]
public async Task CreateComment(
Guid videoId,
- [FromBody] CreateCommentRequestDTO dto,
- [FromHeader(Name = "X-User-Id")] Guid userId = default,
- [FromHeader(Name = "X-Username")] string username = "Anonymous")
+ [FromBody] CreateCommentRequestDTO dto)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
+ var userId = GetCurrentUserId();
+ var username = GetCurrentUsername();
+
var video = await _db.Videos.FindAsync(videoId);
if (video == null) return NotFound("Video not found");
@@ -72,18 +90,52 @@ public async Task CreateComment(
Username = comment.Username,
Content = comment.Content,
LikeCount = comment.LikeCount,
+ DislikeCount = comment.DislikeCount,
CreatedAt = comment.CreatedAt
};
return Created($"/api/videos/{videoId}/comments", resp);
}
+ [Authorize]
+ [HttpPost("{commentId:guid}/like")]
+ public async Task LikeComment(Guid videoId, Guid commentId, [FromBody] CommentLikeRequestDTO dto)
+ {
+ var comment = await _db.Comments.FirstOrDefaultAsync(c => c.Id == commentId && c.VideoId == videoId);
+ if (comment == null) return NotFound();
+
+ var userId = GetCurrentUserId();
+ var existing = await _db.CommentLikes.FirstOrDefaultAsync(cl => cl.UserId == userId && cl.CommentId == commentId);
+
+ if (existing == null)
+ {
+ _db.CommentLikes.Add(new CommentLike { UserId = userId, CommentId = commentId, IsLiked = dto.IsLiked });
+ if (dto.IsLiked) comment.LikeCount++;
+ else comment.DislikeCount++;
+ }
+ else if (existing.IsLiked != dto.IsLiked)
+ {
+ existing.IsLiked = dto.IsLiked;
+ if (dto.IsLiked) { comment.LikeCount++; comment.DislikeCount = Math.Max(0, comment.DislikeCount - 1); }
+ else { comment.DislikeCount++; comment.LikeCount = Math.Max(0, comment.LikeCount - 1); }
+ }
+
+ await _db.SaveChangesAsync();
+
+ return Ok(new { likeCount = comment.LikeCount, dislikeCount = comment.DislikeCount });
+ }
+
+ [Authorize]
[HttpDelete("{commentId:guid}")]
public async Task DeleteComment(Guid videoId, Guid commentId)
{
var comment = await _db.Comments.FirstOrDefaultAsync(c => c.Id == commentId && c.VideoId == videoId);
if (comment == null) return NotFound();
+ var currentUserId = GetCurrentUserId();
+ if (comment.UserId != currentUserId)
+ return Forbid();
+
_db.Comments.Remove(comment);
await _db.SaveChangesAsync();
return NoContent();
diff --git a/services/Glense.VideoCatalogue/Controllers/PlaylistVideosController.cs b/services/Glense.VideoCatalogue/Controllers/PlaylistVideosController.cs
index 4e17bf2..e8730b0 100644
--- a/services/Glense.VideoCatalogue/Controllers/PlaylistVideosController.cs
+++ b/services/Glense.VideoCatalogue/Controllers/PlaylistVideosController.cs
@@ -1,4 +1,6 @@
+using System.Security.Claims;
using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Glense.VideoCatalogue.Data;
using Glense.VideoCatalogue.Models;
@@ -16,11 +18,22 @@ public PlaylistVideosController(VideoCatalogueDbContext db)
_db = db;
}
+ private Guid GetCurrentUserId()
+ {
+ var claim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ return Guid.TryParse(claim, out var id) ? id : Guid.Empty;
+ }
+
+ [Authorize]
[HttpPost]
public async Task Add([FromBody] DTOs.AddPlaylistVideoRequestDTO dto)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
+ var playlist = await _db.Playlists.FindAsync(dto.PlaylistId);
+ if (playlist == null) return NotFound("Playlist not found");
+ if (playlist.CreatorId != GetCurrentUserId()) return Forbid();
+
var exists = await _db.PlaylistVideos.AnyAsync(pv => pv.PlaylistId == dto.PlaylistId && pv.VideoId == dto.VideoId);
if (exists) return Conflict("Video already in playlist");
@@ -30,9 +43,14 @@ public async Task Add([FromBody] DTOs.AddPlaylistVideoRequestDTO
return NoContent();
}
+ [Authorize]
[HttpDelete]
public async Task Remove([FromBody] DTOs.AddPlaylistVideoRequestDTO dto)
{
+ var playlist = await _db.Playlists.FindAsync(dto.PlaylistId);
+ if (playlist == null) return NotFound("Playlist not found");
+ if (playlist.CreatorId != GetCurrentUserId()) return Forbid();
+
var pv = await _db.PlaylistVideos.FirstOrDefaultAsync(p => p.PlaylistId == dto.PlaylistId && p.VideoId == dto.VideoId);
if (pv == null) return NotFound();
_db.PlaylistVideos.Remove(pv);
diff --git a/services/Glense.VideoCatalogue/Controllers/PlaylistsController.cs b/services/Glense.VideoCatalogue/Controllers/PlaylistsController.cs
index 7b44f94..1870ee4 100644
--- a/services/Glense.VideoCatalogue/Controllers/PlaylistsController.cs
+++ b/services/Glense.VideoCatalogue/Controllers/PlaylistsController.cs
@@ -1,4 +1,6 @@
+using System.Security.Claims;
using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Glense.VideoCatalogue.Data;
using Glense.VideoCatalogue.Models;
@@ -16,11 +18,19 @@ public PlaylistsController(VideoCatalogueDbContext db)
_db = db;
}
+ private Guid GetCurrentUserId()
+ {
+ var claim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ return Guid.TryParse(claim, out var id) ? id : Guid.Empty;
+ }
+
+ [Authorize]
[HttpPost]
- public async Task Create([FromBody] DTOs.CreatePlaylistRequestDTO dto, [FromHeader(Name = "X-Creator-Id")] Guid creatorId = default)
+ public async Task Create([FromBody] DTOs.CreatePlaylistRequestDTO dto)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
+ var creatorId = GetCurrentUserId();
var playlist = new Playlists
{
Id = System.Guid.NewGuid(),
diff --git a/services/Glense.VideoCatalogue/Controllers/SubscriptionsController.cs b/services/Glense.VideoCatalogue/Controllers/SubscriptionsController.cs
index 3e56b1f..dcb5513 100644
--- a/services/Glense.VideoCatalogue/Controllers/SubscriptionsController.cs
+++ b/services/Glense.VideoCatalogue/Controllers/SubscriptionsController.cs
@@ -1,4 +1,6 @@
+using System.Security.Claims;
using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Glense.VideoCatalogue.Data;
using Glense.VideoCatalogue.Models;
@@ -16,11 +18,19 @@ public SubscriptionsController(VideoCatalogueDbContext db)
_db = db;
}
+ private Guid GetCurrentUserId()
+ {
+ var claim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ return Guid.TryParse(claim, out var id) ? id : Guid.Empty;
+ }
+
+ [Authorize]
[HttpPost]
- public async Task Subscribe([FromBody] DTOs.SubscribeRequestDTO dto, [FromHeader(Name = "X-User-Id")] Guid subscriberId = default)
+ public async Task Subscribe([FromBody] DTOs.SubscribeRequestDTO dto)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
+ var subscriberId = GetCurrentUserId();
var exists = await _db.Subscriptions.AnyAsync(s => s.SubscriberId == subscriberId && s.SubscribedToId == dto.SubscribedToId);
if (exists) return Conflict("Already subscribed");
@@ -32,9 +42,11 @@ public async Task Subscribe([FromBody] DTOs.SubscribeRequestDTO d
return Created(string.Empty, resp);
}
+ [Authorize]
[HttpDelete]
- public async Task Unsubscribe([FromBody] DTOs.SubscribeRequestDTO dto, [FromHeader(Name = "X-User-Id")] Guid subscriberId = default)
+ public async Task Unsubscribe([FromBody] DTOs.SubscribeRequestDTO dto)
{
+ var subscriberId = GetCurrentUserId();
var s = await _db.Subscriptions.FirstOrDefaultAsync(x => x.SubscriberId == subscriberId && x.SubscribedToId == dto.SubscribedToId);
if (s == null) return NotFound();
_db.Subscriptions.Remove(s);
diff --git a/services/Glense.VideoCatalogue/Controllers/VideoLikesController.cs b/services/Glense.VideoCatalogue/Controllers/VideoLikesController.cs
index 66a2fab..17a3e0d 100644
--- a/services/Glense.VideoCatalogue/Controllers/VideoLikesController.cs
+++ b/services/Glense.VideoCatalogue/Controllers/VideoLikesController.cs
@@ -1,4 +1,6 @@
+using System.Security.Claims;
using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Glense.VideoCatalogue.Data;
using Glense.VideoCatalogue.Models;
@@ -16,37 +18,43 @@ public VideoLikesController(VideoCatalogueDbContext db)
_db = db;
}
+ private Guid GetCurrentUserId()
+ {
+ var claim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ return Guid.TryParse(claim, out var id) ? id : Guid.Empty;
+ }
+
+ [Authorize]
[HttpPost]
- public async Task Like([FromBody] DTOs.LikeRequestDTO dto, [FromHeader(Name = "X-User-Id")] Guid userId = default)
+ public async Task Like([FromBody] DTOs.LikeRequestDTO dto)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
- var like = await _db.VideoLikes.FirstOrDefaultAsync(l => l.UserId == userId && l.VideoId == dto.VideoId);
- if (like == null)
- {
- like = new VideoLikes { UserId = userId, VideoId = dto.VideoId, IsLiked = dto.IsLiked };
- _db.VideoLikes.Add(like);
- }
- else
+ var userId = GetCurrentUserId();
+ var video = await _db.Videos.FirstOrDefaultAsync(v => v.Id == dto.VideoId);
+ if (video == null) return NotFound();
+
+ var existing = await _db.VideoLikes.FirstOrDefaultAsync(l => l.UserId == userId && l.VideoId == dto.VideoId);
+
+ if (existing == null)
{
- like.IsLiked = dto.IsLiked;
- _db.VideoLikes.Update(like);
+ // New vote
+ _db.VideoLikes.Add(new VideoLikes { UserId = userId, VideoId = dto.VideoId, IsLiked = dto.IsLiked });
+ if (dto.IsLiked) video.LikeCount++;
+ else video.DislikeCount++;
}
-
- // Update counts
- var video = await _db.Videos.FirstOrDefaultAsync(v => v.Id == dto.VideoId);
- if (video != null)
+ else if (existing.IsLiked != dto.IsLiked)
{
- var likes = await _db.VideoLikes.CountAsync(vl => vl.VideoId == dto.VideoId && vl.IsLiked);
- var dislikes = await _db.VideoLikes.CountAsync(vl => vl.VideoId == dto.VideoId && !vl.IsLiked);
- video.LikeCount = likes;
- video.DislikeCount = dislikes;
- _db.Videos.Update(video);
+ // Switching vote
+ existing.IsLiked = dto.IsLiked;
+ if (dto.IsLiked) { video.LikeCount++; video.DislikeCount = Math.Max(0, video.DislikeCount - 1); }
+ else { video.DislikeCount++; video.LikeCount = Math.Max(0, video.LikeCount - 1); }
}
+ // else: same vote again, no change
await _db.SaveChangesAsync();
- var resp = new DTOs.LikeResponseDTO { VideoId = dto.VideoId, IsLiked = dto.IsLiked, LikeCount = video?.LikeCount ?? 0, DislikeCount = video?.DislikeCount ?? 0 };
+ var resp = new DTOs.LikeResponseDTO { VideoId = dto.VideoId, IsLiked = dto.IsLiked, LikeCount = video.LikeCount, DislikeCount = video.DislikeCount };
return Ok(resp);
}
}
diff --git a/services/Glense.VideoCatalogue/Controllers/VideosController.cs b/services/Glense.VideoCatalogue/Controllers/VideosController.cs
index 01eb976..64e2b5a 100644
--- a/services/Glense.VideoCatalogue/Controllers/VideosController.cs
+++ b/services/Glense.VideoCatalogue/Controllers/VideosController.cs
@@ -2,8 +2,10 @@
using System.IO;
using System.Net;
using System.Net.Http.Json;
+using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Glense.VideoCatalogue.Data;
@@ -33,6 +35,12 @@ public VideosController(Upload uploader, VideoCatalogueDbContext db, IVideoStora
_logger = logger;
}
+ private Guid GetCurrentUserId()
+ {
+ var claim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ return Guid.TryParse(claim, out var id) ? id : Guid.Empty;
+ }
+
private static string? ResolveThumbnailUrl(Guid videoId, string? thumbnailUrl)
{
if (string.IsNullOrEmpty(thumbnailUrl)) return null;
@@ -66,12 +74,14 @@ private async Task> ResolveUsernamesAsync(IEnumerable Upload([FromForm] DTOs.UploadRequestDTO dto, [FromHeader(Name = "X-Uploader-Id")] Guid uploaderId = default)
+ public async Task Upload([FromForm] DTOs.UploadRequestDTO dto)
{
if (dto == null || dto.File == null || dto.File.Length == 0) return BadRequest("No file provided");
- var video = await _uploader.UploadFileAsync(dto.File, dto.Title, dto.Description, uploaderId, dto.Thumbnail);
+ var uploaderId = GetCurrentUserId();
+ var video = await _uploader.UploadFileAsync(dto.File, dto.Title, dto.Description, uploaderId, dto.Thumbnail, dto.Category);
var resp = new DTOs.UploadResponseDTO
{
@@ -84,7 +94,8 @@ public async Task Upload([FromForm] DTOs.UploadRequestDTO dto, [F
UploaderId = video.UploaderId,
ViewCount = video.ViewCount,
LikeCount = video.LikeCount,
- DislikeCount = video.DislikeCount
+ DislikeCount = video.DislikeCount,
+ Category = video.Category
};
return CreatedAtAction(nameof(Get), new { id = resp.Id }, resp);
@@ -108,7 +119,8 @@ public async Task List()
UploaderUsername = usernames.GetValueOrDefault(video.UploaderId),
ViewCount = video.ViewCount,
LikeCount = video.LikeCount,
- DislikeCount = video.DislikeCount
+ DislikeCount = video.DislikeCount,
+ Category = video.Category
}).ToList();
return Ok(vids);
@@ -134,12 +146,26 @@ public async Task Get(Guid id)
UploaderUsername = username,
ViewCount = video.ViewCount,
LikeCount = video.LikeCount,
- DislikeCount = video.DislikeCount
+ DislikeCount = video.DislikeCount,
+ Category = video.Category
};
return Ok(resp);
}
+ [Authorize]
+ [HttpPatch("{id:guid}/category")]
+ public async Task UpdateCategory(Guid id, [FromBody] DTOs.UpdateCategoryDTO dto)
+ {
+ var video = await _db.Videos.FirstOrDefaultAsync(v => v.Id == id);
+ if (video == null) return NotFound();
+ if (video.UploaderId != GetCurrentUserId()) return Forbid();
+
+ video.Category = dto.Category;
+ await _db.SaveChangesAsync();
+ return Ok(new { category = video.Category });
+ }
+
[HttpGet("{id:guid}/thumbnail")]
public async Task Thumbnail(Guid id)
{
diff --git a/services/Glense.VideoCatalogue/DTOs/CommentDto.cs b/services/Glense.VideoCatalogue/DTOs/CommentDto.cs
index 9e0b134..24ee1f7 100644
--- a/services/Glense.VideoCatalogue/DTOs/CommentDto.cs
+++ b/services/Glense.VideoCatalogue/DTOs/CommentDto.cs
@@ -11,6 +11,7 @@ public class CommentResponseDTO
public string Username { get; set; } = null!;
public string Content { get; set; } = null!;
public int LikeCount { get; set; }
+ public int DislikeCount { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -20,3 +21,9 @@ public class CreateCommentRequestDTO
[MaxLength(2000)]
public string Content { get; set; } = null!;
}
+
+public class CommentLikeRequestDTO
+{
+ [Required]
+ public bool IsLiked { get; set; }
+}
diff --git a/services/Glense.VideoCatalogue/DTOs/UploadRequestDTO.cs b/services/Glense.VideoCatalogue/DTOs/UploadRequestDTO.cs
index 20a5296..66444e1 100644
--- a/services/Glense.VideoCatalogue/DTOs/UploadRequestDTO.cs
+++ b/services/Glense.VideoCatalogue/DTOs/UploadRequestDTO.cs
@@ -14,4 +14,7 @@ public class UploadRequestDTO
public string? Description { get; set; }
public IFormFile? Thumbnail { get; set; }
+
+ [MaxLength(50)]
+ public string? Category { get; set; }
}
diff --git a/services/Glense.VideoCatalogue/DTOs/UploadResponseDTO.cs b/services/Glense.VideoCatalogue/DTOs/UploadResponseDTO.cs
index 10f2ce1..70ad313 100644
--- a/services/Glense.VideoCatalogue/DTOs/UploadResponseDTO.cs
+++ b/services/Glense.VideoCatalogue/DTOs/UploadResponseDTO.cs
@@ -20,4 +20,11 @@ public class UploadResponseDTO
public int ViewCount { get; set; }
public int LikeCount { get; set; }
public int DislikeCount { get; set; }
+ public string? Category { get; set; }
+}
+
+public class UpdateCategoryDTO
+{
+ [MaxLength(50)]
+ public string? Category { get; set; }
}
diff --git a/services/Glense.VideoCatalogue/Data/VideoCatalogueDbContext.cs b/services/Glense.VideoCatalogue/Data/VideoCatalogueDbContext.cs
index 76dea9a..63f74af 100644
--- a/services/Glense.VideoCatalogue/Data/VideoCatalogueDbContext.cs
+++ b/services/Glense.VideoCatalogue/Data/VideoCatalogueDbContext.cs
@@ -14,6 +14,7 @@ public VideoCatalogueDbContext(DbContextOptions options
public DbSet Subscriptions { get; set; } = null!;
public DbSet VideoLikes { get; set; } = null!;
public DbSet Comments { get; set; } = null!;
+ public DbSet CommentLikes { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -32,6 +33,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
entity.Property(e => e.ViewCount).HasColumnName("view_count");
entity.Property(e => e.LikeCount).HasColumnName("like_count");
entity.Property(e => e.DislikeCount).HasColumnName("dislike_count");
+ entity.Property(e => e.Category).HasColumnName("category").HasMaxLength(50);
});
modelBuilder.Entity(entity =>
@@ -70,6 +72,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
entity.HasOne(e => e.Video).WithMany(v => v.VideoLikes).HasForeignKey(e => e.VideoId).HasConstraintName("FK_VideoLikes_Videos_video_id");
});
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => new { e.UserId, e.CommentId }).HasName("PK_CommentLikes");
+ entity.Property(e => e.UserId).HasColumnName("user_id");
+ entity.Property(e => e.CommentId).HasColumnName("comment_id");
+ entity.Property(e => e.IsLiked).HasColumnName("is_liked");
+ entity.HasOne(e => e.Comment).WithMany().HasForeignKey(e => e.CommentId).HasConstraintName("FK_CommentLikes_Comments_comment_id");
+ });
+
modelBuilder.Entity(entity =>
{
entity.HasKey(e => e.Id).HasName("PK_Comments");
@@ -79,6 +90,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
entity.Property(e => e.Username).HasColumnName("username").HasMaxLength(50).IsRequired();
entity.Property(e => e.Content).HasColumnName("content").HasMaxLength(2000).IsRequired();
entity.Property(e => e.LikeCount).HasColumnName("like_count");
+ entity.Property(e => e.DislikeCount).HasColumnName("dislike_count");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
entity.HasOne(e => e.Video).WithMany().HasForeignKey(e => e.VideoId).HasConstraintName("FK_Comments_Videos_video_id");
entity.HasIndex(e => e.VideoId).HasDatabaseName("IX_Comments_video_id");
diff --git a/services/Glense.VideoCatalogue/Glense.VideoCatalogue.csproj b/services/Glense.VideoCatalogue/Glense.VideoCatalogue.csproj
index 08926d1..66e5ecb 100644
--- a/services/Glense.VideoCatalogue/Glense.VideoCatalogue.csproj
+++ b/services/Glense.VideoCatalogue/Glense.VideoCatalogue.csproj
@@ -10,7 +10,8 @@
-
+
+
diff --git a/services/Glense.VideoCatalogue/Models/Comment.cs b/services/Glense.VideoCatalogue/Models/Comment.cs
index 9346a80..37cea60 100644
--- a/services/Glense.VideoCatalogue/Models/Comment.cs
+++ b/services/Glense.VideoCatalogue/Models/Comment.cs
@@ -32,6 +32,9 @@ public class Comment
[Column("like_count")]
public int LikeCount { get; set; }
+ [Column("dislike_count")]
+ public int DislikeCount { get; set; }
+
[Column("created_at")]
public DateTime CreatedAt { get; set; }
diff --git a/services/Glense.VideoCatalogue/Models/CommentLike.cs b/services/Glense.VideoCatalogue/Models/CommentLike.cs
new file mode 100644
index 0000000..2f2663c
--- /dev/null
+++ b/services/Glense.VideoCatalogue/Models/CommentLike.cs
@@ -0,0 +1,19 @@
+using System;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Glense.VideoCatalogue.Models;
+
+[Table("CommentLikes")]
+public class CommentLike
+{
+ [Column("user_id")]
+ public Guid UserId { get; set; }
+
+ [Column("comment_id")]
+ public Guid CommentId { get; set; }
+
+ [Column("is_liked")]
+ public bool IsLiked { get; set; }
+
+ public Comment? Comment { get; set; }
+}
diff --git a/services/Glense.VideoCatalogue/Models/Videos.cs b/services/Glense.VideoCatalogue/Models/Videos.cs
index 063359c..ad53d72 100644
--- a/services/Glense.VideoCatalogue/Models/Videos.cs
+++ b/services/Glense.VideoCatalogue/Models/Videos.cs
@@ -45,6 +45,10 @@ public class Videos
[Column("dislike_count")]
public int DislikeCount { get; set; }
+ [MaxLength(50)]
+ [Column("category")]
+ public string? Category { get; set; }
+
public ICollection? PlaylistVideos { get; set; }
public ICollection? VideoLikes { get; set; }
}
diff --git a/services/Glense.VideoCatalogue/Program.cs b/services/Glense.VideoCatalogue/Program.cs
index 346733e..9c9d4c3 100644
--- a/services/Glense.VideoCatalogue/Program.cs
+++ b/services/Glense.VideoCatalogue/Program.cs
@@ -1,8 +1,11 @@
using Glense.VideoCatalogue.Data;
using Glense.VideoCatalogue.Services;
using Microsoft.EntityFrameworkCore;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.IdentityModel.Tokens;
using System;
using System.Linq;
+using System.Text;
using Glense.VideoCatalogue.Models;
var builder = WebApplication.CreateBuilder(args);
@@ -48,6 +51,33 @@
client.Timeout = TimeSpan.FromSeconds(10);
});
+// JWT Authentication
+var jwtIssuer = builder.Configuration["JwtSettings:Issuer"] ?? "GlenseAccountService";
+var jwtAudience = builder.Configuration["JwtSettings:Audience"] ?? "GlenseApp";
+var jwtSecret = builder.Configuration["JwtSettings:SecretKey"]
+ ?? Environment.GetEnvironmentVariable("JWT_SECRET_KEY")
+ ?? throw new InvalidOperationException("JWT secret key is not configured");
+
+builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
+ options.SaveToken = true;
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuer = jwtIssuer,
+ ValidateAudience = true,
+ ValidAudience = jwtAudience,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.FromSeconds(30)
+ };
+ });
+
+builder.Services.AddAuthorization();
+
// Health checks
builder.Services.AddHealthChecks();
@@ -59,13 +89,17 @@
var app = builder.Build();
// Configure the HTTP request pipeline.
-app.UseSwagger();
-app.UseSwaggerUI();
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
app.UseHttpsRedirection();
app.UseCors("AllowAll");
+app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
diff --git a/services/Glense.VideoCatalogue/Services/Upload.cs b/services/Glense.VideoCatalogue/Services/Upload.cs
index c866693..4c2fb00 100644
--- a/services/Glense.VideoCatalogue/Services/Upload.cs
+++ b/services/Glense.VideoCatalogue/Services/Upload.cs
@@ -17,7 +17,7 @@ public Upload(IVideoStorage storage, VideoCatalogueDbContext db)
_db = db;
}
- public async Task UploadFileAsync(IFormFile file, string? title, string? description, Guid uploaderId, IFormFile? thumbnail = null, CancellationToken cancellationToken = default)
+ public async Task UploadFileAsync(IFormFile file, string? title, string? description, Guid uploaderId, IFormFile? thumbnail = null, string? category = null, CancellationToken cancellationToken = default)
{
if (file == null) throw new ArgumentNullException(nameof(file));
@@ -42,7 +42,8 @@ public async Task UploadFileAsync(IFormFile file, string? title, string?
VideoUrl = storedName,
ViewCount = 0,
LikeCount = 0,
- DislikeCount = 0
+ DislikeCount = 0,
+ Category = category
};
_db.Videos.Add(video);
diff --git a/services/Glense.VideoCatalogue/appsettings.json b/services/Glense.VideoCatalogue/appsettings.json
index b7b4241..b802754 100644
--- a/services/Glense.VideoCatalogue/appsettings.json
+++ b/services/Glense.VideoCatalogue/appsettings.json
@@ -5,8 +5,12 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
- ,
+ "AllowedHosts": "*",
+ "JwtSettings": {
+ "Issuer": "GlenseAccountService",
+ "Audience": "GlenseApp",
+ "SecretKey": ""
+ },
"VideoStorage": {
"BasePath": "Videos",
"RequestBufferSize": 81920