From 67180faaa676c0fa9fd42c22c0d92931ad68ada3 Mon Sep 17 00:00:00 2001 From: Bogdan Stojadinovic Date: Sun, 29 Mar 2026 18:48:06 +0200 Subject: [PATCH 1/2] Improvements for whole app --- .../Controllers/DonationController.cs | 3 + .../Controllers/WalletController.cs | 3 + .../DonationService/DonationService.csproj | 1 + Glense.Server/DonationService/Program.cs | 44 ++- dev.sh | 24 +- docker-compose.yml | 6 + glense.client/src/components/Chat/Chat.jsx | 45 ++- .../src/components/Chat/ChatSidebar.jsx | 75 ++++- .../src/components/Chat/ChatWindow.jsx | 6 +- glense.client/src/components/Feed.jsx | 15 +- glense.client/src/components/Navbar.jsx | 4 +- .../src/components/PlaylistDetail.jsx | 51 +++- glense.client/src/components/Playlists.jsx | 55 ++-- glense.client/src/components/Sidebar.jsx | 6 + glense.client/src/components/Upload.jsx | 16 +- .../src/components/VideoComments.jsx | 89 +++++- glense.client/src/components/VideoStream.jsx | 260 ++++++++++++------ glense.client/src/context/AuthContext.jsx | 25 +- glense.client/src/css/Chat/ChatSidebar.css | 49 ++++ glense.client/src/css/Chat/ChatWindow.css | 1 + glense.client/src/css/PlaylistDetail.css | 73 +++++ glense.client/src/css/Playlists.css | 90 ++++++ glense.client/src/css/Sidebar.css | 7 + glense.client/src/css/Upload.css | 21 ++ glense.client/src/css/VideoComments.css | 63 ++++- glense.client/src/css/VideoStream.css | 88 +++++- glense.client/src/utils/chatService.js | 7 +- glense.client/src/utils/videoApi.js | 59 ++-- scripts/seed.sh | 71 +++-- .../Controllers/InternalController.cs | 2 + .../Controllers/ChatsController.cs | 1 - .../Controllers/MessageRootController.cs | 1 - .../Controllers/MessagesController.cs | 1 - .../Controllers/CommentsController.cs | 58 +++- .../Controllers/PlaylistVideosController.cs | 18 ++ .../Controllers/PlaylistsController.cs | 12 +- .../Controllers/SubscriptionsController.cs | 16 +- .../Controllers/VideoLikesController.cs | 48 ++-- .../Controllers/VideosController.cs | 36 ++- .../Glense.VideoCatalogue/DTOs/CommentDto.cs | 7 + .../DTOs/UploadRequestDTO.cs | 3 + .../DTOs/UploadResponseDTO.cs | 7 + .../Data/VideoCatalogueDbContext.cs | 12 + .../Glense.VideoCatalogue.csproj | 3 +- .../Glense.VideoCatalogue/Models/Comment.cs | 3 + .../Models/CommentLike.cs | 19 ++ .../Glense.VideoCatalogue/Models/Videos.cs | 4 + services/Glense.VideoCatalogue/Program.cs | 38 ++- .../Glense.VideoCatalogue/Services/Upload.cs | 5 +- .../Glense.VideoCatalogue/appsettings.json | 8 +- 50 files changed, 1286 insertions(+), 273 deletions(-) create mode 100644 glense.client/src/css/PlaylistDetail.css create mode 100644 glense.client/src/css/Playlists.css create mode 100644 services/Glense.VideoCatalogue/Models/CommentLike.cs diff --git a/Glense.Server/DonationService/Controllers/DonationController.cs b/Glense.Server/DonationService/Controllers/DonationController.cs index 5cab896..52b4ff1 100644 --- a/Glense.Server/DonationService/Controllers/DonationController.cs +++ b/Glense.Server/DonationService/Controllers/DonationController.cs @@ -1,3 +1,5 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using DonationService.Data; @@ -10,6 +12,7 @@ namespace DonationService.Controllers; [ApiController] [Route("api/[controller]")] [Produces("application/json")] +[Authorize] public class DonationController : ControllerBase { private readonly DonationDbContext _context; diff --git a/Glense.Server/DonationService/Controllers/WalletController.cs b/Glense.Server/DonationService/Controllers/WalletController.cs index 0fd162e..b2fbd4f 100644 --- a/Glense.Server/DonationService/Controllers/WalletController.cs +++ b/Glense.Server/DonationService/Controllers/WalletController.cs @@ -1,3 +1,5 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using DonationService.Data; @@ -9,6 +11,7 @@ namespace DonationService.Controllers; [ApiController] [Route("api/[controller]")] [Produces("application/json")] +[Authorize] public class WalletController : ControllerBase { private readonly DonationDbContext _context; diff --git a/Glense.Server/DonationService/DonationService.csproj b/Glense.Server/DonationService/DonationService.csproj index b27e7d7..ced406e 100644 --- a/Glense.Server/DonationService/DonationService.csproj +++ b/Glense.Server/DonationService/DonationService.csproj @@ -21,6 +21,7 @@ all + diff --git a/Glense.Server/DonationService/Program.cs b/Glense.Server/DonationService/Program.cs index 2e587d9..8ec6b8a 100644 --- a/Glense.Server/DonationService/Program.cs +++ b/Glense.Server/DonationService/Program.cs @@ -1,4 +1,7 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using System.Text; using DonationService.Data; using DonationService.Services; @@ -41,6 +44,33 @@ builder.Services.AddScoped(); +// 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 check endpoint for container orchestration builder.Services.AddHealthChecks(); @@ -62,13 +92,17 @@ app.UseCors(); // Swagger UI available at root path for easy API exploration -app.UseSwagger(); -app.UseSwaggerUI(c => +if (app.Environment.IsDevelopment()) { - c.SwaggerEndpoint("/swagger/v1/swagger.json", "Donation Microservice API v1"); - c.RoutePrefix = string.Empty; -}); + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Donation Microservice API v1"); + c.RoutePrefix = string.Empty; + }); +} +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapHealthChecks("/health"); diff --git a/dev.sh b/dev.sh index 8606932..d8385b1 100755 --- a/dev.sh +++ b/dev.sh @@ -132,6 +132,26 @@ do_prune() { echo "Done" } +do_nuke() { + echo "Stopping ALL containers..." + $RUNTIME stop -a 2>/dev/null + echo "Removing ALL containers..." + $RUNTIME rm -a -f 2>/dev/null + echo "Removing ALL volumes..." + $RUNTIME volume prune -f 2>/dev/null + echo "Removing ALL images..." + $RUNTIME rmi -a -f 2>/dev/null + echo "" + echo "Everything wiped. Run './dev.sh up' to start fresh." +} + +do_reset() { + echo "Full reset: nuke + rebuild + seed" + do_nuke + echo "" + do_up +} + case "${1:-up}" in up) do_up ;; down) do_down ;; @@ -139,5 +159,7 @@ case "${1:-up}" in logs) do_logs "$2" ;; seed) do_seed ;; prune) do_prune ;; - *) echo "Usage: ./dev.sh [up|down|restart|logs|seed|prune]" ;; + nuke) do_nuke ;; + reset) do_reset ;; + *) echo "Usage: ./dev.sh [up|down|restart|logs|seed|nuke|reset|prune]" ;; esac diff --git a/docker-compose.yml b/docker-compose.yml index 0e59c33..4170e2f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -94,6 +94,9 @@ services: - PORT=5100 - DONATION_DB_CONNECTION_STRING=Host=postgres_donation;Port=5432;Database=glense_donation;Username=glense;Password=glense123 - ACCOUNT_SERVICE_URL=http://account_service:5000 + - JwtSettings__Issuer=GlenseAccountService + - JwtSettings__Audience=GlenseApp + - JwtSettings__SecretKey=YourSuperSecretKeyThatIsAtLeast32CharactersLongForHS256Algorithm ports: - "5100:5100" depends_on: @@ -133,6 +136,9 @@ services: - ASPNETCORE_ENVIRONMENT=Development - ConnectionStrings__VideoCatalogue=Host=postgres_video;Port=5432;Database=glense_video;Username=glense;Password=glense123 - ACCOUNT_SERVICE_URL=http://account_service:5000 + - JwtSettings__Issuer=GlenseAccountService + - JwtSettings__Audience=GlenseApp + - JwtSettings__SecretKey=YourSuperSecretKeyThatIsAtLeast32CharactersLongForHS256Algorithm ports: - "5002:5002" depends_on: diff --git a/glense.client/src/components/Chat/Chat.jsx b/glense.client/src/components/Chat/Chat.jsx index 1f61638..e56c56b 100644 --- a/glense.client/src/components/Chat/Chat.jsx +++ b/glense.client/src/components/Chat/Chat.jsx @@ -2,29 +2,48 @@ import { useState, useEffect } from "react"; import ChatWindow from "./ChatWindow"; import ChatSidebar from "./ChatSidebar"; import chatService from "../../utils/chatService"; +import { useAuth } from "../../context/AuthContext"; import "../../css/Chat/Chat.css"; +function getOtherName(topic, myName) { + if (!topic) return 'Chat'; + const parts = topic.split(':'); + if (parts.length === 2) { + return parts[0] === myName ? parts[1] : parts[0]; + } + return topic; +} + function Chat() { const [chats, setChats] = useState([]); const [selectedChat, setSelectedChat] = useState(null); - const [displayName, setDisplayName] = useState(() => { - try { return localStorage.getItem('chat.displayName') || 'Alice'; } catch { return 'Alice'; } - }); + const { user } = useAuth(); + const displayName = user?.username || 'Anonymous'; const localSender = 'user'; const normalizeItems = (res) => { if (!res) return []; if (Array.isArray(res)) return res; - return res.items || res.Items || res.Items || res.Items || res.Items || res.Items || res.Items || res.Items || res.Items || res.Items || res.Items || res.Items || res.Items || res.Items || res.Items || res.items || []; + return res.items || res.Items || []; + }; + + const enrichChat = (chat) => { + const topic = chat.topic || chat.Topic || ''; + return { ...chat, displayName: getOtherName(topic, displayName) }; }; const loadChats = async () => { try { const res = await chatService.getChats(); - const items = normalizeItems(res); + const items = normalizeItems(res) + .filter(c => { + const topic = c.topic || c.Topic || ''; + const parts = topic.split(':'); + return parts.includes(displayName); + }) + .map(enrichChat); setChats(items); if (!selectedChat && items.length) { - // auto-select first const first = items[0]; setSelectedChat(first); await loadMessages(first); @@ -51,7 +70,7 @@ function Chat() { isMe: senderName === displayName }; }); - setSelectedChat(prev => ({ ...chat, messages: msgs })); + setSelectedChat(prev => ({ ...enrichChat(chat), messages: msgs })); } catch (err) { console.error("getMessages", err); } @@ -60,19 +79,19 @@ function Chat() { useEffect(() => { loadChats(); }, []); const handleSelect = async (chat) => { - setSelectedChat(chat); + setSelectedChat(enrichChat(chat)); await loadMessages(chat); }; - const handleCreate = async (topic) => { + const handleCreate = async (otherUsername) => { try { + const topic = `${displayName}:${otherUsername}`; const created = await chatService.createChat({ topic }); - // reload chats and select created await loadChats(); const id = created?.id || created?.Id || created?.chatId; if (id) { - const c = (await chatService.getChat(id)); - handleSelect(c || { id }); + const c = await chatService.getChat(id); + handleSelect(c || { id, topic }); } } catch (err) { console.error('createChat', err); @@ -103,7 +122,7 @@ function Chat() { }; return ( -
+
diff --git a/glense.client/src/components/Chat/ChatSidebar.jsx b/glense.client/src/components/Chat/ChatSidebar.jsx index c900cab..13cfbea 100644 --- a/glense.client/src/components/Chat/ChatSidebar.jsx +++ b/glense.client/src/components/Chat/ChatSidebar.jsx @@ -1,6 +1,8 @@ -import React, { useState } from "react"; -import { Box, List, ListItem, ListItemAvatar, Avatar, ListItemText, TextField, Button, ListItemButton } from "@mui/material"; -import "../../css/Chat/ChatSideBar.css"; +import { useState, useEffect, useRef } from "react"; +import { Box, List, ListItem, ListItemAvatar, Avatar, ListItemText, TextField, ListItemButton, Typography, Stack } from "@mui/material"; +import { profileService } from "../../services/profileService"; +import { useAuth } from "../../context/AuthContext"; +import "../../css/Chat/ChatSidebar.css"; const colors = ['#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3', '#00bcd4', '#009688', '#4caf50', '#ff9800', '#ff5722']; function stringToColor(str) { @@ -10,13 +12,31 @@ function stringToColor(str) { } const ChatSidebar = ({ chats, onSelectChat, onCreate }) => { - const [topic, setTopic] = useState(""); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const { user } = useAuth(); + const timerRef = useRef(null); - const handleCreate = async () => { - if (!topic) return; + useEffect(() => { + if (!query.trim()) { setResults([]); return; } + clearTimeout(timerRef.current); + timerRef.current = setTimeout(async () => { + setSearching(true); + try { + const users = await profileService.searchUsers(query, 10); + setResults((users || []).filter(u => u.id !== user?.id)); + } catch { setResults([]); } + setSearching(false); + }, 300); + return () => clearTimeout(timerRef.current); + }, [query, user?.id]); + + const handleSelectUser = async (selectedUser) => { try { - await onCreate?.(topic); - setTopic(""); + await onCreate?.(selectedUser.username); + setQuery(""); + setResults([]); } catch (err) { console.error("create chat", err); } @@ -24,20 +44,47 @@ const ChatSidebar = ({ chats, onSelectChat, onCreate }) => { return (
- - setTopic(e.target.value)} fullWidth /> - + + setQuery(e.target.value)} + fullWidth + className="chat-search-input" + /> + {results.length > 0 && ( + + {results.map(u => ( + handleSelectUser(u)} + > + + {u.username?.charAt(0).toUpperCase()} + + {u.username} + + ))} + + )} + {searching && Searching...} + {query && !searching && results.length === 0 && ( + No users found + )} {chats.map((chat, index) => ( onSelectChat(chat)}> - - {(chat.topic || chat.Topic || chat.name || '?').charAt(0).toUpperCase()} + + {(chat.displayName || chat.topic || '?').charAt(0).toUpperCase()} - + ))} diff --git a/glense.client/src/components/Chat/ChatWindow.jsx b/glense.client/src/components/Chat/ChatWindow.jsx index ec7ccd5..ce204d9 100644 --- a/glense.client/src/components/Chat/ChatWindow.jsx +++ b/glense.client/src/components/Chat/ChatWindow.jsx @@ -34,10 +34,10 @@ const ChatWindow = ({ chat, onSend }) => {
{/* 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} + + + + ))} + + 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) => ( diff --git a/glense.client/src/components/VideoComments.jsx b/glense.client/src/components/VideoComments.jsx index f709efd..9a35b89 100644 --- a/glense.client/src/components/VideoComments.jsx +++ b/glense.client/src/components/VideoComments.jsx @@ -1,10 +1,10 @@ import { useState, useEffect } from "react"; -import { Stack, Typography, Avatar } from "@mui/material"; -import { ThumbUpOutlined } from "@mui/icons-material"; -import { getComments } from "../utils/videoApi"; +import { Stack, Typography, Avatar, TextField, Button } from "@mui/material"; +import { ThumbUp, ThumbUpOutlined, ThumbDown, ThumbDownOutlined } from "@mui/icons-material"; +import { getComments, postComment, likeComment } from "../utils/videoApi"; +import { useAuth } from "../context/AuthContext"; import "../css/VideoComments.css"; -// Generate a consistent color from a username function stringToColor(str) { let hash = 0; for (let i = 0; i < str.length; i++) { @@ -17,6 +17,10 @@ function stringToColor(str) { function VideoComments({ videoId, id }) { const resolvedVideoId = videoId || id; const [comments, setComments] = useState(null); + const [newComment, setNewComment] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [userLikes, setUserLikes] = useState({}); + const { user } = useAuth(); useEffect(() => { if (!resolvedVideoId) return; @@ -27,6 +31,30 @@ function VideoComments({ videoId, id }) { return () => { mounted = false; }; }, [resolvedVideoId]); + const handleSubmit = async () => { + if (!newComment.trim() || submitting) return; + setSubmitting(true); + try { + const created = await postComment(resolvedVideoId, newComment.trim()); + setComments(prev => [created, ...(prev || [])]); + setNewComment(""); + } catch { + alert("Failed to post comment"); + } + setSubmitting(false); + }; + + const handleCommentLike = async (commentId, isLiked) => { + if (!user) return; + try { + const resp = await likeComment(resolvedVideoId, commentId, isLiked); + setComments(prev => prev.map(c => + c.id === commentId ? { ...c, likeCount: resp.likeCount, dislikeCount: resp.dislikeCount } : c + )); + setUserLikes(prev => ({ ...prev, [commentId]: isLiked })); + } catch { /* ignore */ } + }; + if (comments === null) return ( @@ -34,15 +62,40 @@ function VideoComments({ videoId, id }) { ); - if (comments.length === 0) - return ( - - No comments yet. Be the first! - - ); - return ( + {user && ( + + + {(user.username || "U").charAt(0).toUpperCase()} + + setNewComment(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }} + className="comment-input" + /> + + + )} + + {comments.length === 0 && ( + + No comments yet. Be the first! + + )} + {comments.map((comment) => ( {comment.content} - - - {Number(comment.likeCount).toLocaleString()} - + + + {comment.likeCount || 0} + + {comment.dislikeCount || 0} + ))} diff --git a/glense.client/src/components/VideoStream.jsx b/glense.client/src/components/VideoStream.jsx index 36f3c8d..ec3e053 100644 --- a/glense.client/src/components/VideoStream.jsx +++ b/glense.client/src/components/VideoStream.jsx @@ -1,27 +1,36 @@ import { useState, useEffect } from "react"; -import { useParams } from "react-router-dom"; -import { Link } from "react-router-dom"; +import { useParams, Link } from "react-router-dom"; import ReactPlayer from "react-player"; -import { Typography, Box, Stack, Button, FormControl, Select, MenuItem, Avatar } from "@mui/material"; +import { Typography, Box, Stack, Button, FormControl, Select, MenuItem, Avatar, Snackbar } from "@mui/material"; import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd'; +import EditIcon from '@mui/icons-material/Edit'; import { CheckCircle, + ThumbDown, ThumbDownOutlined, + ThumbUp, ThumbUpOutlined, } from "@mui/icons-material"; import { Videos, VideoComments } from "."; +import { getVideo, getVideos, getPlaylists, addVideoToPlaylist, likeVideo, updateVideoCategory } from "../utils/videoApi"; +import { categories } from "../utils/constants"; +import { useAuth } from "../context/AuthContext"; + +import "../css/VideoStream.css"; + const demoVideoInfo = { title: 'Loading...', channelTitle: '', viewCount: 0, likeCount: 0, dislikeCount: 0, publishedAt: '', tags: [], description: '' }; -import { getVideo, getVideos, getPlaylists, addVideoToPlaylist } from "../utils/videoApi"; -import { useAuth } from "../context/AuthContext"; -import "../css/VideoStream.css"; +function formatDate(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); +} function VideoStream() { - const [showMoreTags, setShowMoreTags] = useState(false); const [showMoreDesc, setShowMoreDesc] = useState(false); const { id } = useParams(); const [video, setVideo] = useState(null); @@ -29,12 +38,20 @@ function VideoStream() { const [playlists, setPlaylists] = useState([]); const [addingTo, setAddingTo] = useState(""); const [adding, setAdding] = useState(false); + const [snackbar, setSnackbar] = useState(""); + const [userLike, setUserLike] = useState(null); + const [editingCategory, setEditingCategory] = useState(false); const { user } = useAuth(); + + const isOwner = user && video && String(user.id) === String(video.uploaderId); + useEffect(() => { let mounted = true; if (!id) return; getVideo(id).then(d => { if (mounted) setVideo(d); }).catch(() => {}); 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); return () => { mounted = false; }; }, [id]); @@ -45,12 +62,60 @@ function VideoStream() { return () => { mounted = false; }; }, [user]); + const handleLike = async (isLiked) => { + if (!user) { setSnackbar("Sign in to like videos"); return; } + if (!video?.id) return; + try { + const resp = await likeVideo(video.id, isLiked); + setVideo(prev => ({ ...prev, likeCount: resp.likeCount, dislikeCount: resp.dislikeCount })); + setUserLike(isLiked); + } catch { + setSnackbar("Failed to update"); + } + }; + + const handleAddToPlaylist = async () => { + if (!addingTo || !video?.id) { setSnackbar("Select a playlist first"); return; } + setAdding(true); + try { + await addVideoToPlaylist(addingTo, video.id); + setSnackbar("Added to playlist!"); + setAddingTo(""); + } catch (e) { + const msg = e.message || ""; + setSnackbar(msg.includes("already") ? "Already in playlist" : "Failed to add to playlist"); + } + setAdding(false); + }; + + const handleCategoryChange = async (newCategory) => { + try { + await updateVideoCategory(video.id, newCategory); + setVideo(prev => ({ ...prev, category: newCategory || null })); + setEditingCategory(false); + setSnackbar("Category updated"); + } catch { + setSnackbar("Failed to update category"); + } + }; + + const selectDarkStyles = { + color: "var(--color-text-secondary)", + "& .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.2)" }, + "&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.4)" }, + "& .MuiSvgIcon-root": { color: "var(--color-text-secondary)" }, + }; + + const darkMenuProps = { PaperProps: { sx: { bgcolor: "var(--color-bg-secondary)", color: "var(--color-text-white)" } } }; + + const descText = video?.description || demoVideoInfo.description; + return ( - - - - - {Number(video?.likeCount ?? demoVideoInfo.likeCount).toLocaleString()} {" | "} - + + + + {Number(video?.likeCount ?? demoVideoInfo.likeCount).toLocaleString()} + | + {Number(video?.dislikeCount ?? demoVideoInfo.dislikeCount).toLocaleString()} - + - - - - - + {user && playlists.length > 0 && ( + + + + + + + )} {/* 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 && ( - + + {showMoreDesc ? ( + <> + {descText} + + + ) : ( + + {descText.length > 250 ? `${descText.substring(0, 250)}...` : descText} + )} + + - - {showMoreDesc - ? (video?.description || demoVideoInfo.description) - : `${(video?.description || demoVideoInfo.description).substring(0, 250)}`} - - + {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/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 From 74b0351d85e01d7656466ff80bb8dd069f64a8c7 Mon Sep 17 00:00:00 2001 From: Bogdan Stojadinovic Date: Sun, 29 Mar 2026 23:24:53 +0200 Subject: [PATCH 2/2] Donation API fix --- glense.client/src/utils/donationApi.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 = {