From 07f1725036c3220c9b36e3e4b1744edbf0eacb7b Mon Sep 17 00:00:00 2001 From: Brankonymous Date: Tue, 24 Mar 2026 20:57:30 +0100 Subject: [PATCH 1/4] Glue donation and account with http calls --- DEV_QUICKSTART.md | 107 ++++++++++++++++++ .../Controllers/ProfileProxyController.cs | 13 +++ .../Controllers/DonationController.cs | 32 +++++- Glense.Server/DonationService/Program.cs | 12 ++ Glense.Server/DonationService/README.md | 25 +++- .../Services/AccountServiceClient.cs | 54 +++++++++ .../Services/IAccountServiceClient.cs | 11 ++ README.md | 36 ++++++ docker-compose.yml | 24 ++++ .../components/Donations/DonationModal.jsx | 99 ++++++++++++---- .../src/components/Donations/Donations.jsx | 45 ++++++-- glense.client/src/services/profileService.js | 11 ++ glense.client/src/utils/constants.jsx | 103 ----------------- glense.client/src/utils/donationApi.js | 5 +- scripts/seed-test-users.sh | 47 ++++++++ .../Controllers/InternalController.cs | 34 ++++++ .../Controllers/ProfileController.cs | 38 +++++++ .../DTOs/InternalDTOs.cs | 9 ++ services/Glense.AccountService/Program.cs | 22 +++- services/Glense.AccountService/README.md | 18 +++ .../Services/AuthService.cs | 26 ++++- .../Services/IWalletServiceClient.cs | 7 ++ .../Services/WalletServiceClient.cs | 41 +++++++ 23 files changed, 665 insertions(+), 154 deletions(-) create mode 100644 DEV_QUICKSTART.md create mode 100644 Glense.Server/DonationService/Services/AccountServiceClient.cs create mode 100644 Glense.Server/DonationService/Services/IAccountServiceClient.cs create mode 100755 scripts/seed-test-users.sh create mode 100644 services/Glense.AccountService/Controllers/InternalController.cs create mode 100644 services/Glense.AccountService/DTOs/InternalDTOs.cs create mode 100644 services/Glense.AccountService/Services/IWalletServiceClient.cs create mode 100644 services/Glense.AccountService/Services/WalletServiceClient.cs diff --git a/DEV_QUICKSTART.md b/DEV_QUICKSTART.md new file mode 100644 index 0000000..4f705e4 --- /dev/null +++ b/DEV_QUICKSTART.md @@ -0,0 +1,107 @@ +# Dev Quickstart + +## Prerequisites + +- .NET 8 SDK +- Node.js v22 +- Docker Desktop **or** Podman + +## Start everything + +### 1. Start databases + microservices + +```bash +# Docker Desktop (recommended): +docker compose up --build postgres_account postgres_donation account_service donation_service -d + +# Or Podman: +podman machine start +# Copy the DOCKER_HOST export line from podman's output, then: +docker compose up --build postgres_account postgres_donation account_service donation_service -d +``` + +### 2. Start the API Gateway (new terminal) + +```bash +cd Glense.Server +dotnet run --urls http://localhost:5050 +``` + +### 3. Start the frontend (new terminal) + +```bash +cd glense.client +npm install +npm run dev +``` + +### 4. Seed test users (new terminal) + +```bash +./scripts/seed-test-users.sh +``` + +Creates 3 users (password for all: `Password123!`): + +| Username | Email | Type | Wallet | +|----------|-------|------|--------| +| keki | keki@glense.test | creator | $500 | +| irena | irena@glense.test | creator | $500 | +| branko | branko@glense.test | user | $500 | + +## Ports + +| Service | Port | Notes | +|---------|------|-------| +| Frontend (Vite) | 5173+ | Opens next free port | +| API Gateway | 5050 | All frontend requests go here | +| Account Service | 5001 | Auth, profiles, notifications | +| Donation Service | 5100 | Wallets, donations | +| PostgreSQL (Account) | 5432 | | +| PostgreSQL (Donation) | 5434 | | + +## Quick test with curl + +```bash +# Health checks +curl http://localhost:5050/health +curl http://localhost:5001/health +curl http://localhost:5100/health + +# Register a user +curl -X POST http://localhost:5050/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{"username":"test","email":"test@test.com","password":"Password123!","confirmPassword":"Password123!","accountType":"user"}' + +# Login +curl -X POST http://localhost:5050/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"usernameOrEmail":"test","password":"Password123!"}' + +# Search users +curl "http://localhost:5050/api/profile/search?q=keki" + +# Check wallet (replace with real user ID from register/login response) +curl http://localhost:5050/api/wallet/user/USER_ID + +# Send donation (replace IDs) +curl -X POST http://localhost:5050/api/donation \ + -H "Content-Type: application/json" \ + -d '{"donorUserId":"DONOR_ID","recipientUserId":"RECIPIENT_ID","amount":10,"message":"Nice!"}' +``` + +## Stop everything + +```bash +docker compose down # stop containers +# Ctrl+C on gateway and frontend terminals +podman machine stop # if using podman +``` + +## Swagger docs + +| Service | URL | +|---------|-----| +| Gateway | http://localhost:5050/swagger | +| Account Service | http://localhost:5001/swagger | +| Donation Service | http://localhost:5100 | diff --git a/Glense.Server/Controllers/ProfileProxyController.cs b/Glense.Server/Controllers/ProfileProxyController.cs index a385661..b337c3a 100644 --- a/Glense.Server/Controllers/ProfileProxyController.cs +++ b/Glense.Server/Controllers/ProfileProxyController.cs @@ -19,12 +19,25 @@ public ProfileProxyController(IHttpClientFactory httpClientFactory, ILogger SearchUsers([FromQuery] string? q, [FromQuery] int limit = 20) + { + var path = $"/api/profile/search?q={Uri.EscapeDataString(q ?? "")}&limit={limit}"; + return await ProxyGetToAccountService(path); + } + [HttpGet("me")] public async Task GetCurrentProfile() { return await ProxyGetToAccountService("/api/profile/me"); } + [HttpGet("{userId:guid}")] + public async Task GetUserById(Guid userId) + { + return await ProxyGetToAccountService($"/api/profile/{userId}"); + } + [HttpPut("me")] public async Task UpdateProfile() { diff --git a/Glense.Server/DonationService/Controllers/DonationController.cs b/Glense.Server/DonationService/Controllers/DonationController.cs index 49149f1..5cab896 100644 --- a/Glense.Server/DonationService/Controllers/DonationController.cs +++ b/Glense.Server/DonationService/Controllers/DonationController.cs @@ -3,6 +3,7 @@ using DonationService.Data; using DonationService.Entities; using DonationService.DTOs; +using DonationService.Services; namespace DonationService.Controllers; @@ -13,11 +14,16 @@ public class DonationController : ControllerBase { private readonly DonationDbContext _context; private readonly ILogger _logger; + private readonly IAccountServiceClient _accountService; - public DonationController(DonationDbContext context, ILogger logger) + public DonationController( + DonationDbContext context, + ILogger logger, + IAccountServiceClient accountService) { _context = context; _logger = logger; + _accountService = accountService; } /// @@ -75,6 +81,13 @@ public async Task> CreateDonation([FromBody] Crea return BadRequest(new { message = "Cannot donate to yourself" }); } + // Validate recipient exists in Account service + var recipientUsername = await _accountService.GetUsernameAsync(request.RecipientUserId); + if (recipientUsername == null) + { + return BadRequest(new { message = "Recipient user not found" }); + } + // Check if we can use transactions (not supported by in-memory database) var supportsTransactions = !_context.Database.IsInMemory(); var transaction = supportsTransactions @@ -144,6 +157,23 @@ public async Task> CreateDonation([FromBody] Crea "Donation created: {DonationId}, from user {DonorId} to user {RecipientId}, amount: {Amount}", donation.Id, request.DonorUserId, request.RecipientUserId, request.Amount); + // Send notification to recipient (fire-and-forget, don't fail the donation) + try + { + var donorUsername = await _accountService.GetUsernameAsync(request.DonorUserId) ?? "Someone"; + await _accountService.CreateDonationNotificationAsync( + request.RecipientUserId, + donorUsername, + request.Amount, + donation.Id); + } + catch (Exception notifEx) + { + _logger.LogWarning(notifEx, + "Failed to send donation notification for donation {DonationId}", + donation.Id); + } + var response = new DonationResponse( donation.Id, donation.DonorUserId, donation.RecipientUserId, donation.Amount, donation.Message, donation.CreatedAt); diff --git a/Glense.Server/DonationService/Program.cs b/Glense.Server/DonationService/Program.cs index 290783f..2e587d9 100644 --- a/Glense.Server/DonationService/Program.cs +++ b/Glense.Server/DonationService/Program.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using DonationService.Data; +using DonationService.Services; var builder = WebApplication.CreateBuilder(args); @@ -29,6 +30,17 @@ Console.WriteLine("[WARNING] No connection string found, using in-memory database"); } +// HttpClient for Account Service +builder.Services.AddHttpClient("AccountService", client => +{ + var serviceUrl = Environment.GetEnvironmentVariable("ACCOUNT_SERVICE_URL") + ?? "http://localhost:5001"; + client.BaseAddress = new Uri(serviceUrl); + client.Timeout = TimeSpan.FromSeconds(10); +}); + +builder.Services.AddScoped(); + // Health check endpoint for container orchestration builder.Services.AddHealthChecks(); diff --git a/Glense.Server/DonationService/README.md b/Glense.Server/DonationService/README.md index f8d9fbe..dbca24a 100644 --- a/Glense.Server/DonationService/README.md +++ b/Glense.Server/DonationService/README.md @@ -72,16 +72,18 @@ docker-compose down ## Example Requests +All user IDs are UUIDs (GUIDs). Replace the example IDs below with real ones from your database. + ### Create a Wallet ```bash curl -X POST http://localhost:5100/api/wallet \ -H "Content-Type: application/json" \ - -d '{"userId": 1, "initialBalance": 100.00}' + -d '{"userId": "a1b2c3d4-0000-0000-0000-000000000001", "initialBalance": 100.00}' ``` ### Top Up Wallet ```bash -curl -X POST http://localhost:5100/api/wallet/user/1/topup \ +curl -X POST http://localhost:5100/api/wallet/user/a1b2c3d4-0000-0000-0000-000000000001/topup \ -H "Content-Type: application/json" \ -d '{"amount": 50.00}' ``` @@ -91,8 +93,8 @@ curl -X POST http://localhost:5100/api/wallet/user/1/topup \ curl -X POST http://localhost:5100/api/donation \ -H "Content-Type: application/json" \ -d '{ - "donorUserId": 1, - "recipientUserId": 2, + "donorUserId": "a1b2c3d4-0000-0000-0000-000000000001", + "recipientUserId": "a1b2c3d4-0000-0000-0000-000000000002", "amount": 25.00, "message": "Great content!" }' @@ -100,15 +102,28 @@ curl -X POST http://localhost:5100/api/donation \ ### Get Wallet ```bash -curl http://localhost:5100/api/wallet/user/1 +curl http://localhost:5100/api/wallet/user/a1b2c3d4-0000-0000-0000-000000000001 ``` +## Inter-Service Communication + +The Donation service communicates with the Account service via HTTP calls: + +| Flow | Direction | Description | +|------|-----------|-------------| +| Recipient validation | Donation → Account | Validates recipient exists before processing a donation | +| Notification | Donation → Account | Notifies the recipient after a successful donation | +| Wallet creation | Account → Donation | Account service creates a wallet when a new user registers | + +Notification failures are non-blocking — if the Account service is unavailable, the donation still succeeds. + ## Configuration | Variable | Description | Default | |----------|-------------|---------| | `PORT` | Service port | `5100` | | `DONATION_DB_CONNECTION_STRING` | Neon PostgreSQL connection string | In-memory DB | +| `ACCOUNT_SERVICE_URL` | Account service base URL | `http://localhost:5001` | ## Testing diff --git a/Glense.Server/DonationService/Services/AccountServiceClient.cs b/Glense.Server/DonationService/Services/AccountServiceClient.cs new file mode 100644 index 0000000..a34507d --- /dev/null +++ b/Glense.Server/DonationService/Services/AccountServiceClient.cs @@ -0,0 +1,54 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace DonationService.Services; + +public class AccountServiceClient : IAccountServiceClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public AccountServiceClient(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClient = httpClientFactory.CreateClient("AccountService"); + _logger = logger; + } + + public async Task GetUsernameAsync(Guid userId) + { + var response = await _httpClient.GetAsync($"/api/profile/{userId}"); + + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(JsonOptions); + return json.TryGetProperty("username", out var username) ? username.GetString() : null; + } + + public async Task CreateDonationNotificationAsync( + Guid recipientUserId, + string donorUsername, + decimal amount, + Guid donationId) + { + var request = new + { + UserId = recipientUserId, + Title = "New Donation!", + Message = $"{donorUsername} donated ${amount:F2} to you!", + Type = "donation", + RelatedEntityId = donationId + }; + + var response = await _httpClient.PostAsJsonAsync("/api/internal/notifications", request); + response.EnsureSuccessStatusCode(); + } +} diff --git a/Glense.Server/DonationService/Services/IAccountServiceClient.cs b/Glense.Server/DonationService/Services/IAccountServiceClient.cs new file mode 100644 index 0000000..722e27a --- /dev/null +++ b/Glense.Server/DonationService/Services/IAccountServiceClient.cs @@ -0,0 +1,11 @@ +namespace DonationService.Services; + +public interface IAccountServiceClient +{ + /// + /// Gets the username for a user. Returns null if the user doesn't exist. + /// + Task GetUsernameAsync(Guid userId); + + Task CreateDonationNotificationAsync(Guid recipientUserId, string donorUsername, decimal amount, Guid donationId); +} diff --git a/README.md b/README.md index 4a23f57..8378b2d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,43 @@ Mozda necemo koristiti. Koliko vidim po kursu treba svako da ima svoju bazu? ![Glense Database Schema](schema-Glense.svg) +# Arhitektura + +## Komunikacija izmedju mikroservisa + +Mikroservisi komuniciraju medjusobno putem HTTP poziva: + +``` +Account Service ──HTTP──> Donation Service (kreiranje wallet-a pri registraciji) +Donation Service ──HTTP──> Account Service (validacija korisnika + slanje notifikacija) +``` + +| Flow | Opis | +|------|------| +| Registracija korisnika | Account servis automatski kreira wallet u Donation servisu | +| Kreiranje donacije | Donation servis validira da primalac postoji u Account servisu | +| Posle donacije | Donation servis salje notifikaciju primaocu preko Account servisa | + +Sekundarne operacije (wallet kreiranje, notifikacije) ne blokiraju primarne — ako Account servis nije dostupan, registracija ce i dalje uspeti. + +## Servisi i portovi + +| Servis | Port | Opis | +|--------|------|------| +| API Gateway | 5050 | Proxy za frontend | +| Account Service | 5001 | Auth, profili, notifikacije | +| Donation Service | 5100 | Wallet-i i donacije | +| Video Catalogue | 5002 | Video upload i streaming | +| Chat Service | 5004 | Live chat | + # Kako pokrenuti projekat + +## Docker Compose (preporuka) +```bash +docker-compose up +``` + +## Manuelno 1. Preko konzole lociraj se na `Glense.Server/` folder 2. **dotnet run** diff --git a/docker-compose.yml b/docker-compose.yml index 051e8ab..00db07c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,10 @@ services: - ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_URLS=http://+:5000 - ConnectionStrings__DefaultConnection=Host=postgres_account;Port=5432;Database=glense_account;Username=glense;Password=glense123 + - DONATION_SERVICE_URL=http://donation_service:5100 + - JWT_SECRET_KEY=YourSuperSecretKeyThatIsAtLeast32CharactersLongForHS256Algorithm + - JWT_ISSUER=GlenseAccountService + - JWT_AUDIENCE=GlenseApp ports: - "5001:5000" depends_on: @@ -81,6 +85,26 @@ services: timeout: 5s retries: 5 + # Donation Microservice + donation_service: + build: + context: ./Glense.Server/DonationService + dockerfile: Dockerfile + container_name: glense_donation_service + environment: + - ASPNETCORE_ENVIRONMENT=Development + - 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 + ports: + - "5100:5100" + depends_on: + postgres_donation: + condition: service_healthy + networks: + - glense_network + restart: unless-stopped + # PostgreSQL for Chat Service (placeholder for your teammates) postgres_chat: image: postgres:16-alpine diff --git a/glense.client/src/components/Donations/DonationModal.jsx b/glense.client/src/components/Donations/DonationModal.jsx index da43fe5..6717892 100644 --- a/glense.client/src/components/Donations/DonationModal.jsx +++ b/glense.client/src/components/Donations/DonationModal.jsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Modal, Box, @@ -11,16 +11,54 @@ import { CircularProgress } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; -import { users, recentRecipients } from "../../utils/constants"; +import { profileService } from "../../services/profileService"; +import { demoProfilePicture } from "../../utils/constants"; import "../../css/Donations/DonationModal.css"; const PRESET_AMOUNTS = [5, 10, 25, 50, 100]; -function DonationModal({ open, onClose, onSubmit, isSubmitting = false, currentBalance = 0 }) { +function DonationModal({ open, onClose, onSubmit, isSubmitting = false, currentBalance = 0, currentUserId }) { const [selectedUser, setSelectedUser] = useState(null); const [amount, setAmount] = useState(""); const [message, setMessage] = useState(""); const [showConfirm, setShowConfirm] = useState(false); + const [userOptions, setUserOptions] = useState([]); + const [searchLoading, setSearchLoading] = useState(false); + const [searchInput, setSearchInput] = useState(""); + + // Load initial users when modal opens + useEffect(() => { + if (open) { + loadUsers(""); + } + }, [open]); + + const loadUsers = useCallback(async (query) => { + setSearchLoading(true); + try { + const users = await profileService.searchUsers(query, 20); + // Filter out the current user + const filtered = users.filter(u => u.id !== currentUserId); + setUserOptions(filtered.map(u => ({ + id: u.id, + name: u.username, + handle: u.username, + profileImage: u.profilePictureUrl || demoProfilePicture, + }))); + } catch (err) { + console.error("Failed to search users:", err); + } finally { + setSearchLoading(false); + } + }, [currentUserId]); + + // Debounced search + useEffect(() => { + const timer = setTimeout(() => { + loadUsers(searchInput); + }, 300); + return () => clearTimeout(timer); + }, [searchInput, loadUsers]); const handlePresetAmount = (preset) => { setAmount(preset.toString()); @@ -35,14 +73,11 @@ function DonationModal({ open, onClose, onSubmit, isSubmitting = false, currentB const handleSubmit = () => { if (!selectedUser || !amount || parseInt(amount) <= 0) return; - + const donationAmount = parseInt(amount); - - // Check if user has enough balance - if (donationAmount > currentBalance) { - return; // Button should be disabled anyway - } - + + if (donationAmount > currentBalance) return; + if (!showConfirm) { setShowConfirm(true); return; @@ -57,7 +92,6 @@ function DonationModal({ open, onClose, onSubmit, isSubmitting = false, currentB }; onSubmit(donation); - // Don't reset form here - let parent handle it on success }; const resetForm = () => { @@ -65,10 +99,11 @@ function DonationModal({ open, onClose, onSubmit, isSubmitting = false, currentB setAmount(""); setMessage(""); setShowConfirm(false); + setSearchInput(""); }; const handleClose = () => { - if (isSubmitting) return; // Prevent closing while submitting + if (isSubmitting) return; resetForm(); onClose(); }; @@ -84,8 +119,8 @@ function DonationModal({ open, onClose, onSubmit, isSubmitting = false, currentB return ( - @@ -106,19 +141,19 @@ function DonationModal({ open, onClose, onSubmit, isSubmitting = false, currentB - {/* Recent Recipients */} - {recentRecipients.length > 0 && ( + {/* Quick Send from loaded users */} + {userOptions.length > 0 && !searchInput && (
Quick Send
- {recentRecipients.map((user) => ( + {userOptions.slice(0, 3).map((user) => ( diff --git a/glense.client/src/components/VideoComments.jsx b/glense.client/src/components/VideoComments.jsx index 297fbd7..f709efd 100644 --- a/glense.client/src/components/VideoComments.jsx +++ b/glense.client/src/components/VideoComments.jsx @@ -1,18 +1,46 @@ -import React from "react"; -import { Stack, CardMedia, Typography } from "@mui/material"; +import { useState, useEffect } from "react"; +import { Stack, Typography, Avatar } from "@mui/material"; import { ThumbUpOutlined } from "@mui/icons-material"; - -import { comments } from "../utils/constants"; +import { getComments } from "../utils/videoApi"; import "../css/VideoComments.css"; -function VideoComments({ }) { - if (!comments) +// Generate a consistent color from a username +function stringToColor(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const colors = ['#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3', '#00bcd4', '#009688', '#4caf50', '#ff9800', '#ff5722']; + return colors[Math.abs(hash) % colors.length]; +} + +function VideoComments({ videoId, id }) { + const resolvedVideoId = videoId || id; + const [comments, setComments] = useState(null); + + useEffect(() => { + if (!resolvedVideoId) return; + let mounted = true; + getComments(resolvedVideoId) + .then(data => { if (mounted) setComments(data); }) + .catch(() => { if (mounted) setComments([]); }); + return () => { mounted = false; }; + }, [resolvedVideoId]); + + if (comments === null) return ( Loading comments.. ); + if (comments.length === 0) + return ( + + No comments yet. Be the first! + + ); + return ( {comments.map((comment) => ( @@ -21,19 +49,26 @@ function VideoComments({ }) { className="comment-item" key={comment.id} > - - + + {comment.username?.charAt(0).toUpperCase()} + + - {comment.name} + {comment.username} - {comment.commentText} + {comment.content} - + {Number(comment.likeCount).toLocaleString()} diff --git a/glense.client/src/components/VideoStream.jsx b/glense.client/src/components/VideoStream.jsx index 3786293..94a2548 100644 --- a/glense.client/src/components/VideoStream.jsx +++ b/glense.client/src/components/VideoStream.jsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { useParams } from "react-router-dom"; import { Link } from "react-router-dom"; import ReactPlayer from "react-player"; -import { Typography, Box, Stack, Button, FormControl, Select, MenuItem } from "@mui/material"; +import { Typography, Box, Stack, Button, FormControl, Select, MenuItem, Avatar } from "@mui/material"; import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd'; import { CheckCircle, @@ -11,8 +11,12 @@ import { } from "@mui/icons-material"; import { Videos, VideoComments } from "."; -import { videoInfo as demoVideoInfo } from "../utils/constants"; +const demoVideoInfo = { + title: 'Loading...', channelTitle: '', viewCount: 0, + likeCount: 0, dislikeCount: 0, publishedAt: '', tags: [], description: '' +}; import { getVideo, getVideos, getPlaylists, addVideoToPlaylist } from "../utils/videoApi"; +import { profileService } from "../services/profileService"; import { useAuth } from "../context/AuthContext"; import "../css/VideoStream.css"; @@ -22,6 +26,7 @@ function VideoStream() { const [showMoreDesc, setShowMoreDesc] = useState(false); const { id } = useParams(); const [video, setVideo] = useState(null); + const [uploader, setUploader] = useState(null); const [related, setRelated] = useState([]); const [playlists, setPlaylists] = useState([]); const [addingTo, setAddingTo] = useState(""); @@ -30,7 +35,15 @@ function VideoStream() { useEffect(() => { let mounted = true; if (!id) return; - getVideo(id).then(d => { if (mounted) setVideo(d); }).catch(() => {}); + getVideo(id).then(d => { + if (!mounted) return; + setVideo(d); + if (d?.uploaderId && d.uploaderId !== '00000000-0000-0000-0000-000000000000') { + profileService.getUserById(d.uploaderId) + .then(p => { if (mounted) setUploader(p); }) + .catch(() => {}); + } + }).catch(() => {}); getVideos().then(list => { if (mounted && Array.isArray(list)) setRelated(list.filter(v => String(v.id) !== String(id)).slice(0, 12)); }).catch(() => {}); return () => { mounted = false; }; }, [id]); @@ -56,9 +69,12 @@ function VideoStream() { {video?.title || demoVideoInfo.title} - - - {video?.channelTitle || demoVideoInfo.channelTitle} + + + {(uploader?.username || video?.title || '?').charAt(0).toUpperCase()} + + + {uploader?.username || 'Glense'} @@ -162,25 +178,6 @@ function VideoStream() { Comments - {/* Add to playlist */} - - Add to playlist - - - diff --git a/glense.client/src/css/Chat/MessageBubble.css b/glense.client/src/css/Chat/MessageBubble.css index 1d74f70..0c5f0d8 100644 --- a/glense.client/src/css/Chat/MessageBubble.css +++ b/glense.client/src/css/Chat/MessageBubble.css @@ -9,6 +9,14 @@ .message-bubble.other { justify-content: flex-start !important; + align-items: flex-start !important; +} + +.message-bubble-sender { + font-size: 12px !important; + font-weight: bold !important; + color: #90caf9 !important; + margin-bottom: 2px !important; } .message-bubble-content { diff --git a/glense.client/src/css/Navbar.css b/glense.client/src/css/Navbar.css index fbfbee0..f8e0c24 100644 --- a/glense.client/src/css/Navbar.css +++ b/glense.client/src/css/Navbar.css @@ -68,6 +68,7 @@ .navbar-option-stack { flex-direction: row !important; + align-items: center !important; gap: 20px !important; } diff --git a/glense.client/src/css/Upload.css b/glense.client/src/css/Upload.css index 874c101..0e9a479 100644 --- a/glense.client/src/css/Upload.css +++ b/glense.client/src/css/Upload.css @@ -1,2 +1,69 @@ -.upload-page { padding: 24px; display:flex; justify-content:center; } -.upload-form { max-width:720px; width:100%; display:flex; flex-direction:column; gap:12px; } +.upload-page { + padding: 24px; + display: flex; + justify-content: center; + min-height: calc(100vh - var(--navbar-height)); +} + +.upload-form { + max-width: 720px; + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; +} + +.upload-form .MuiTypography-h5 { + color: var(--color-text-white); + font-weight: bold; + margin-bottom: 8px; +} + +.upload-form .MuiTextField-root .MuiOutlinedInput-root { + color: var(--color-text-white); + background-color: rgba(255, 255, 255, 0.05); +} + +.upload-form .MuiTextField-root .MuiOutlinedInput-root fieldset { + border-color: rgba(255, 255, 255, 0.2); +} + +.upload-form .MuiTextField-root .MuiOutlinedInput-root:hover fieldset { + border-color: rgba(255, 255, 255, 0.4); +} + +.upload-form .MuiTextField-root .MuiOutlinedInput-root.Mui-focused fieldset { + border-color: var(--color-primary); +} + +.upload-form .MuiInputLabel-root { + color: var(--color-text-secondary); +} + +.upload-form .MuiInputLabel-root.Mui-focused { + color: var(--color-primary); +} + +.upload-form input[type="file"] { + color: var(--color-text-secondary); + padding: 12px; + border: 1px dashed rgba(255, 255, 255, 0.2); + border-radius: 8px; + cursor: pointer; +} + +.upload-form input[type="file"]:hover { + border-color: rgba(255, 255, 255, 0.4); +} + +.upload-form .MuiButton-contained { + background-color: var(--color-primary); + text-transform: none; + font-weight: bold; + padding: 10px 24px; + align-self: flex-start; +} + +.upload-form .MuiTypography-root:last-child { + color: var(--color-text-secondary); +} diff --git a/glense.client/src/utils/constants.jsx b/glense.client/src/utils/constants.jsx index f01880d..5fd1d61 100644 --- a/glense.client/src/utils/constants.jsx +++ b/glense.client/src/utils/constants.jsx @@ -11,6 +11,7 @@ import TheaterComedyIcon from "@mui/icons-material/TheaterComedy"; import FitnessCenterIcon from "@mui/icons-material/FitnessCenter"; import DeveloperModeIcon from "@mui/icons-material/DeveloperMode"; +// Sidebar categories (UI-only, not from database) export const categories = [ { name: "New Videos", icon: }, { name: "Music", icon: }, @@ -27,38 +28,7 @@ export const categories = [ { name: "Crypto", icon: }, ]; -export const chats = [ - { - name: "Keki", - profileImage: "http://dergipark.org.tr/assets/app/images/buddy_sample.png", - messages: [ - {sender: "Keki", message: "Hello", time: "12:00", isMe: false}, - {sender: "Branko", message: "Hello", time: "12:05", isMe: true}, - {sender: "Keki", message: "How are you", time: "12:10", isMe: false}, - {sender: "Keki", message: "Ok.", time: "12:10", isMe: false}, - ] - }, - { - name: "Irena", - profileImage: "http://dergipark.org.tr/assets/app/images/buddy_sample.png", - messages: [ - {sender: "Irena", message: "Smorena sam nesto danas", time: "12:00", isMe: false}, - {sender: "Branko", message: "Briga mee", time: "12:05", isMe: true}, - {sender: "Irena", message: ":(", time: "12:10", isMe: false}, - {sender: "Irena", message: "Sta radis", time: "13:10", isMe: false}, - ] - } -]; - -export const videos = []; -for (let i = 0; i < 100; i++) { - videos.push({ - id: { videoId: "haDjmBT9tu4" }, - title: "An Honest Review of Apple Intelligence\... So Far", - url: "https://www.youtube.com/watch?v=haDjmBT9tu4" - }); -} - +// Fallback values for when API data hasn't loaded yet export const demoThumbnailUrl = "https://i.ibb.co/G2L2Gwp/API-Course.png"; export const demoChannelUrl = "/channel/UCmXmlB4-HJytD7wek0Uo97A"; export const demoVideoUrl = "/video/GDa8kZLNhJ4"; @@ -68,32 +38,5 @@ export const demoVideoTitle = export const demoProfilePicture = "http://dergipark.org.tr/assets/app/images/buddy_sample.png"; - -// Comment -export const comments = []; -for (let i = 0; i < 50; i++) { - comments.push({ - id: i, - name: "John Doe", - imageUrl: "https://i.ibb.co/G2L2Gwp/API-Course.png", - commentText: "This is a comment", - likeCount: 10, - }); -} - - -// VideoStream -export const videoInfo = { - publishedAt: 'Nov 22, 2024', - channelId: 'mkbhd', - title: 'An Honest Review of Apple Intelligence... So Far', - description: 'Reviewing every Apple Intelligence feature that\'s come out so far... \n\n Get both the MKBHD Carry-on & Commuter backpack together at http://ridge.com/MKBHD for 30% off\nReviewing every Apple Intelligence feature that\'s come out so far... \n\n Get both the MKBHD Carry-on & Commuter backpack together at http://ridge.com/MKBHD for 30% off\nReviewing every Apple Intelligence feature that\'s come out so far... \n\n Get both the MKBHD Carry-on & Commuter backpack together at http://ridge.com/MKBHD for 30% off\nReviewing every Apple Intelligence feature that\'s come out so far... \n\n Get both the MKBHD Carry-on & Commuter backpack together at http://ridge.com/MKBHD for 30% off\n', - channelTitle: 'Marques Brownlee', - tags: ['Apple'], - viewCount: 2364175, - likeCount: 123456, - dislikeCount: 1234 -}; - -// Video catalogue API -export const VIDEO_CATALOGUE_API = import.meta.env.VITE_VIDEO_CATALOGUE_API || 'http://localhost:5002'; \ No newline at end of file +// Video catalogue API base URL +export const VIDEO_CATALOGUE_API = import.meta.env.VITE_VIDEO_CATALOGUE_API || 'http://localhost:5002'; diff --git a/glense.client/src/utils/videoApi.js b/glense.client/src/utils/videoApi.js index 16c06c8..0db0726 100644 --- a/glense.client/src/utils/videoApi.js +++ b/glense.client/src/utils/videoApi.js @@ -100,6 +100,24 @@ export async function getPlaylists(creatorId = 0) { return handleRes(res); } +export async function getComments(videoId) { + const res = await fetch(`${BASE}/api/videos/${videoId}/comments`); + return handleRes(res); +} + +export async function postComment(videoId, content, userId = '', username = 'Anonymous') { + 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 } : {}), + }, + body: JSON.stringify({ content }), + }); + return handleRes(res); +} + export async function getSubscriptions(userId = 0) { const headers = userId ? { 'X-User-Id': String(userId) } : {}; const res = await fetch(`${BASE}/api/subscriptions`, { headers }); @@ -119,4 +137,6 @@ export default { subscribeTo, unsubscribeFrom, getSubscriptions, + getComments, + postComment, }; diff --git a/scripts/seed-test-users.sh b/scripts/seed-test-users.sh deleted file mode 100755 index e22e4ff..0000000 --- a/scripts/seed-test-users.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# Seed test users and sample data for local development -# Usage: ./scripts/seed-test-users.sh [ACCOUNT_URL] [DONATION_URL] - -ACCOUNT=${1:-http://localhost:5001} -DONATION=${2:-http://localhost:5100} - -echo "=== Seeding test users ===" - -register_or_find() { - local username=$1 email=$2 type=$3 - local result user_id - - result=$(curl -s -X POST "$ACCOUNT/api/auth/register" \ - -H "Content-Type: application/json" \ - -d "{ - \"username\": \"$username\", - \"email\": \"$email\", - \"password\": \"Password123!\", - \"confirmPassword\": \"Password123!\", - \"accountType\": \"$type\" - }") - - user_id=$(echo "$result" | python3 -c "import sys,json; print(json.load(sys.stdin)['user']['id'])" 2>/dev/null) - - if [ -n "$user_id" ]; then - echo " Created $username ($type) -> $user_id" >&2 - curl -s -X POST "$DONATION/api/wallet/user/$user_id/topup" \ - -H "Content-Type: application/json" -d '{"amount": 500}' > /dev/null 2>&1 - echo " Wallet topped up with \$500" >&2 - else - user_id=$(curl -s "$ACCOUNT/api/profile/search?q=$username" | \ - python3 -c "import sys,json; [print(u['id']) for u in json.load(sys.stdin) if u['username']=='$username']" 2>/dev/null) - if [ -n "$user_id" ]; then - echo " $username already exists -> $user_id" >&2 - else - echo " $username: could not register or find" >&2 - fi - fi - - echo "$user_id" -} - -KEKI_ID=$(register_or_find "keki" "keki@glense.test" "creator") -IRENA_ID=$(register_or_find "irena" "irena@glense.test" "creator") -BRANKO_ID=$(register_or_find "branko" "branko@glense.test" "user") - -echo "" -echo "=== Seeding sample donations ===" - -send_donation() { - local from_name=$1 from_id=$2 to_name=$3 to_id=$4 amount=$5 message=$6 - if [ -z "$from_id" ] || [ -z "$to_id" ]; then - echo " Skipping $from_name -> $to_name (missing ID)" - return - fi - curl -s -X POST "$DONATION/api/donation" \ - -H "Content-Type: application/json" \ - -d "{\"donorUserId\":\"$from_id\",\"recipientUserId\":\"$to_id\",\"amount\":$amount,\"message\":\"$message\"}" > /dev/null 2>&1 - echo " $from_name -> $to_name: \$$amount ($message)" -} - -send_donation "branko" "$BRANKO_ID" "keki" "$KEKI_ID" 25 "Great content, keep it up!" -send_donation "branko" "$BRANKO_ID" "irena" "$IRENA_ID" 10 "Love your streams!" -send_donation "keki" "$KEKI_ID" "branko" "$BRANKO_ID" 50 "Thanks for the support!" -send_donation "irena" "$IRENA_ID" "keki" "$KEKI_ID" 15 "Collab soon?" - -echo "" -echo "=== Done! ===" -echo "All users have password: Password123!" -echo "Log in via the frontend at http://localhost:5173 (or next free port)" diff --git a/scripts/seed.sh b/scripts/seed.sh new file mode 100755 index 0000000..19a0b34 --- /dev/null +++ b/scripts/seed.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Seed all test data for local development +# Usage: ./scripts/seed.sh [ACCOUNT_URL] [DONATION_URL] [VIDEO_URL] + +ACCOUNT=${1:-http://localhost:5001} +DONATION=${2:-http://localhost:5100} +VIDEO=${3:-http://localhost:5002} +PG_VIDEO=${DOCKER_PG_VIDEO:-glense_postgres_video} + +echo "=== Seeding test users ===" + +register_or_find() { + local username=$1 email=$2 type=$3 + local result user_id + + result=$(curl -s -X POST "$ACCOUNT/api/auth/register" \ + -H "Content-Type: application/json" \ + -d "{ + \"username\": \"$username\", + \"email\": \"$email\", + \"password\": \"Password123!\", + \"confirmPassword\": \"Password123!\", + \"accountType\": \"$type\" + }") + + user_id=$(echo "$result" | python3 -c "import sys,json; print(json.load(sys.stdin)['user']['id'])" 2>/dev/null) + + if [ -n "$user_id" ]; then + echo " Created $username ($type) -> $user_id" >&2 + curl -s -X POST "$DONATION/api/wallet/user/$user_id/topup" \ + -H "Content-Type: application/json" -d '{"amount": 500}' > /dev/null 2>&1 + echo " Wallet topped up with \$500" >&2 + else + user_id=$(curl -s "$ACCOUNT/api/profile/search?q=$username" | \ + python3 -c "import sys,json; [print(u['id']) for u in json.load(sys.stdin) if u['username']=='$username']" 2>/dev/null) + if [ -n "$user_id" ]; then + echo " $username already exists -> $user_id" >&2 + else + echo " $username: could not register or find" >&2 + fi + fi + + echo "$user_id" +} + +KEKI_ID=$(register_or_find "keki" "keki@glense.test" "creator") +IRENA_ID=$(register_or_find "irena" "irena@glense.test" "creator") +BRANKO_ID=$(register_or_find "branko" "branko@glense.test" "user") + +echo "" +echo "=== Seeding sample donations ===" + +send_donation() { + local from_name=$1 from_id=$2 to_name=$3 to_id=$4 amount=$5 message=$6 + if [ -z "$from_id" ] || [ -z "$to_id" ]; then + echo " Skipping $from_name -> $to_name (missing ID)" + return + fi + curl -s -X POST "$DONATION/api/donation" \ + -H "Content-Type: application/json" \ + -d "{\"donorUserId\":\"$from_id\",\"recipientUserId\":\"$to_id\",\"amount\":$amount,\"message\":\"$message\"}" > /dev/null 2>&1 + echo " $from_name -> $to_name: \$$amount ($message)" +} + +send_donation "branko" "$BRANKO_ID" "keki" "$KEKI_ID" 25 "Great content, keep it up!" +send_donation "branko" "$BRANKO_ID" "irena" "$IRENA_ID" 10 "Love your streams!" +send_donation "keki" "$KEKI_ID" "branko" "$BRANKO_ID" 50 "Thanks for the support!" +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' +import uuid, random, sys +random.seed(42) + +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', 'qw--VYLpSFk', 187000, 9800, 120), + ('Git and GitHub for Beginners', 'Full crash course on Git and GitHub', 'RGOj5yH7evk', 150000, 8700, 95), +] + +comments_list = [ + 'This is amazing content, keep it up!', + 'Finally someone explains this properly', + 'Great video, learned a lot!', + 'I have been waiting for this video', + 'Can you do a follow-up on this topic?', + 'This changed my perspective completely', + 'Subscribed! More content like this please', + 'The production quality is insane', + 'Watching this at 2am, no regrets', + 'This deserves way more views', + 'Thanks for sharing your knowledge', + 'Bookmarked for later reference', +] + +video_ids = [] +for i, (title, desc, ytid, views, likes, dislikes) 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) ' + 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});") + +for vid in video_ids: + for j in range(3): + cid = str(uuid.uuid4()) + ni = (abs(hash(vid)) + j) % 3 + ci = (abs(hash(vid)) + j) % len(comments_list) + lc = random.randint(0, 200) + hrs = random.randint(1, 720) + print(f'INSERT INTO "Comments" (id, video_id, user_id, username, content, like_count, created_at) ' + f"VALUES ('{cid}', '{vid}', '{uids[ni]}', '{names[ni]}', '{comments_list[ci]}', {lc}, NOW() - interval '{hrs} hours');") +PYEOF + + cat "$TMPFILE" | docker 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" +fi + +echo "" +echo "=== Done! ===" +echo "All users have password: Password123!" +echo "Log in via the frontend at http://localhost:5173 (or next free port)" diff --git a/services/Glense.VideoCatalogue/Controllers/CommentsController.cs b/services/Glense.VideoCatalogue/Controllers/CommentsController.cs new file mode 100644 index 0000000..905cbc6 --- /dev/null +++ b/services/Glense.VideoCatalogue/Controllers/CommentsController.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Glense.VideoCatalogue.Data; +using Glense.VideoCatalogue.Models; +using Glense.VideoCatalogue.DTOs; + +namespace Glense.VideoCatalogue.Controllers; + +[ApiController] +[Route("api/videos/{videoId:guid}/comments")] +public class CommentsController : ControllerBase +{ + private readonly VideoCatalogueDbContext _db; + + public CommentsController(VideoCatalogueDbContext db) + { + _db = db; + } + + [HttpGet] + public async Task GetComments(Guid videoId) + { + var comments = await _db.Comments + .Where(c => c.VideoId == videoId) + .OrderByDescending(c => c.CreatedAt) + .Select(c => new CommentResponseDTO + { + Id = c.Id, + VideoId = c.VideoId, + UserId = c.UserId, + Username = c.Username, + Content = c.Content, + LikeCount = c.LikeCount, + CreatedAt = c.CreatedAt + }) + .ToListAsync(); + + return Ok(comments); + } + + [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") + { + if (!ModelState.IsValid) return BadRequest(ModelState); + + var video = await _db.Videos.FindAsync(videoId); + if (video == null) return NotFound("Video not found"); + + var comment = new Comment + { + Id = Guid.NewGuid(), + VideoId = videoId, + UserId = userId, + Username = username, + Content = dto.Content, + LikeCount = 0, + CreatedAt = DateTime.UtcNow + }; + + _db.Comments.Add(comment); + await _db.SaveChangesAsync(); + + var resp = new CommentResponseDTO + { + Id = comment.Id, + VideoId = comment.VideoId, + UserId = comment.UserId, + Username = comment.Username, + Content = comment.Content, + LikeCount = comment.LikeCount, + CreatedAt = comment.CreatedAt + }; + + return Created($"/api/videos/{videoId}/comments", resp); + } + + [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(); + + _db.Comments.Remove(comment); + await _db.SaveChangesAsync(); + return NoContent(); + } +} diff --git a/services/Glense.VideoCatalogue/DTOs/CommentDto.cs b/services/Glense.VideoCatalogue/DTOs/CommentDto.cs new file mode 100644 index 0000000..9e0b134 --- /dev/null +++ b/services/Glense.VideoCatalogue/DTOs/CommentDto.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Glense.VideoCatalogue.DTOs; + +public class CommentResponseDTO +{ + public Guid Id { get; set; } + public Guid VideoId { get; set; } + public Guid UserId { get; set; } + public string Username { get; set; } = null!; + public string Content { get; set; } = null!; + public int LikeCount { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class CreateCommentRequestDTO +{ + [Required] + [MaxLength(2000)] + public string Content { get; set; } = null!; +} diff --git a/services/Glense.VideoCatalogue/Data/VideoCatalogueDbContext.cs b/services/Glense.VideoCatalogue/Data/VideoCatalogueDbContext.cs index 41224d3..76dea9a 100644 --- a/services/Glense.VideoCatalogue/Data/VideoCatalogueDbContext.cs +++ b/services/Glense.VideoCatalogue/Data/VideoCatalogueDbContext.cs @@ -13,6 +13,7 @@ public VideoCatalogueDbContext(DbContextOptions options public DbSet PlaylistVideos { get; set; } = null!; public DbSet Subscriptions { get; set; } = null!; public DbSet VideoLikes { get; set; } = null!; + public DbSet Comments { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -68,5 +69,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.IsLiked).HasColumnName("is_liked"); 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 => e.Id).HasName("PK_Comments"); + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.VideoId).HasColumnName("video_id"); + entity.Property(e => e.UserId).HasColumnName("user_id"); + 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.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/Models/Comment.cs b/services/Glense.VideoCatalogue/Models/Comment.cs new file mode 100644 index 0000000..9346a80 --- /dev/null +++ b/services/Glense.VideoCatalogue/Models/Comment.cs @@ -0,0 +1,39 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Glense.VideoCatalogue.Models; + +[Table("Comments")] +public class Comment +{ + [Key] + [Column("id")] + public Guid Id { get; set; } + + [Required] + [Column("video_id")] + public Guid VideoId { get; set; } + + [Required] + [Column("user_id")] + public Guid UserId { get; set; } + + [Required] + [MaxLength(50)] + [Column("username")] + public string Username { get; set; } = null!; + + [Required] + [MaxLength(2000)] + [Column("content")] + public string Content { get; set; } = null!; + + [Column("like_count")] + public int LikeCount { get; set; } + + [Column("created_at")] + public DateTime CreatedAt { get; set; } + + public Videos? Video { get; set; } +} diff --git a/services/Glense.VideoCatalogue/Program.cs b/services/Glense.VideoCatalogue/Program.cs index 17ddef5..ed9d5af 100644 --- a/services/Glense.VideoCatalogue/Program.cs +++ b/services/Glense.VideoCatalogue/Program.cs @@ -56,50 +56,17 @@ app.MapControllers(); app.MapHealthChecks("/health"); -// Seed demo videos when using in-memory provider to ensure frontend shows content +// Ensure database schema exists using (var scope = app.Services.CreateScope()) { try { var db = scope.ServiceProvider.GetRequiredService(); db.Database.EnsureCreated(); - if (!db.Videos.Any()) - { - var seedVideos = new[] - { - ("An Honest Review of Apple Intelligence... So Far", "Reviewing every Apple Intelligence feature that's come out so far.", "https://i.ytimg.com/vi/haDjmBT9tu4/hqdefault.jpg", "https://www.youtube.com/watch?v=haDjmBT9tu4", 234175, 12300, 40), - ("Build and Deploy 5 JavaScript & React API Projects", "Full course covering 5 real-world API projects.", "https://i.ibb.co/G2L2Gwp/API-Course.png", "https://www.youtube.com/watch?v=GDa8kZLNhJ4", 54321, 4560, 10), - ("How I Built a $1M SaaS in 6 Months", "The story behind launching a profitable SaaS product.", "https://i.ytimg.com/vi/rIuMCxX8tJY/hqdefault.jpg", "https://www.youtube.com/watch?v=rIuMCxX8tJY", 187000, 9800, 120), - ("Microservices Explained in 10 Minutes", "Quick overview of microservice architecture patterns.", "https://i.ytimg.com/vi/lTAcCNbJ7KE/hqdefault.jpg", "https://www.youtube.com/watch?v=lTAcCNbJ7KE", 320000, 15000, 200), - ("Docker in 100 Seconds", "Everything you need to know about Docker, fast.", "https://i.ytimg.com/vi/Gjnup-PuquQ/hqdefault.jpg", "https://www.youtube.com/watch?v=Gjnup-PuquQ", 890000, 42000, 300), - ("Why I Left Google", "My experience leaving a big tech job.", "https://i.ytimg.com/vi/sH4Tq0Zb4Is/hqdefault.jpg", "https://www.youtube.com/watch?v=sH4Tq0Zb4Is", 150000, 8700, 95), - ("The Ultimate Guide to .NET 8", "Everything new in .NET 8 and how to use it.", "https://i.ytimg.com/vi/pFkBm_NnNqI/hqdefault.jpg", "https://www.youtube.com/watch?v=pFkBm_NnNqI", 98000, 5600, 30), - ("React vs Angular vs Vue - Which One?", "Comparing the top 3 frontend frameworks in 2024.", "https://i.ytimg.com/vi/cuHDQhDhvPE/hqdefault.jpg", "https://www.youtube.com/watch?v=cuHDQhDhvPE", 445000, 21000, 1800), - }; - - var rng = new Random(42); - foreach (var (title, desc, thumb, url, views, likes, dislikes) in seedVideos) - { - db.Videos.Add(new Videos - { - Id = Guid.NewGuid(), - Title = title, - Description = desc, - UploadDate = DateTime.UtcNow.AddDays(-rng.Next(1, 60)), - UploaderId = Guid.Empty, - ThumbnailUrl = thumb, - VideoUrl = url, - ViewCount = views, - LikeCount = likes, - DislikeCount = dislikes - }); - } - db.SaveChanges(); - } } catch { - // ignore seeding errors in environments where DB isn't available + // ignore DB errors on startup } } From dcfdf4ebb4249ad313a4d51e55740c49c99f65b4 Mon Sep 17 00:00:00 2001 From: Brankonymous Date: Wed, 25 Mar 2026 09:52:53 +0100 Subject: [PATCH 4/4] Fix color duplication --- glense.client/src/components/Chat/ChatSidebar.jsx | 8 +------- glense.client/src/components/Chat/ChatWindow.jsx | 9 +-------- glense.client/src/components/Chat/MessageBubble.jsx | 8 +------- glense.client/src/components/VideoComments.jsx | 11 +---------- glense.client/src/utils/constants.jsx | 8 ++++++++ 5 files changed, 12 insertions(+), 32 deletions(-) diff --git a/glense.client/src/components/Chat/ChatSidebar.jsx b/glense.client/src/components/Chat/ChatSidebar.jsx index c900cab..ba397fa 100644 --- a/glense.client/src/components/Chat/ChatSidebar.jsx +++ b/glense.client/src/components/Chat/ChatSidebar.jsx @@ -1,13 +1,7 @@ import React, { useState } from "react"; import { Box, List, ListItem, ListItemAvatar, Avatar, ListItemText, TextField, Button, ListItemButton } from "@mui/material"; import "../../css/Chat/ChatSideBar.css"; - -const colors = ['#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3', '#00bcd4', '#009688', '#4caf50', '#ff9800', '#ff5722']; -function stringToColor(str) { - let h = 0; - for (let i = 0; i < (str||'').length; i++) h = str.charCodeAt(i) + ((h << 5) - h); - return colors[Math.abs(h) % colors.length]; -} +import { stringToColor } from "../../utils/constants"; const ChatSidebar = ({ chats, onSelectChat, onCreate }) => { const [topic, setTopic] = useState(""); diff --git a/glense.client/src/components/Chat/ChatWindow.jsx b/glense.client/src/components/Chat/ChatWindow.jsx index ec7ccd5..0fcc074 100644 --- a/glense.client/src/components/Chat/ChatWindow.jsx +++ b/glense.client/src/components/Chat/ChatWindow.jsx @@ -3,14 +3,7 @@ import MessageBubble from "./MessageBubble"; import { Box, TextField, IconButton, Avatar } from "@mui/material"; import SendIcon from "@mui/icons-material/Send"; import "../../css/Chat/ChatWindow.css"; - -const colors = ['#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3', '#00bcd4', '#009688', '#4caf50', '#ff9800', '#ff5722']; -function stringToColor(str) { - let h = 0; - for (let i = 0; i < (str||'').length; i++) h = str.charCodeAt(i) + ((h << 5) - h); - return colors[Math.abs(h) % colors.length]; -} - +import { stringToColor } from "../../utils/constants"; import { useState } from "react"; const ChatWindow = ({ chat, onSend }) => { diff --git a/glense.client/src/components/Chat/MessageBubble.jsx b/glense.client/src/components/Chat/MessageBubble.jsx index 12f34a6..7f002ed 100644 --- a/glense.client/src/components/Chat/MessageBubble.jsx +++ b/glense.client/src/components/Chat/MessageBubble.jsx @@ -1,13 +1,7 @@ import React from "react"; import { Box, Avatar } from "@mui/material"; import "../../css/Chat/MessageBubble.css"; - -const colors = ['#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3', '#00bcd4', '#009688', '#4caf50', '#ff9800', '#ff5722']; -function stringToColor(str) { - let h = 0; - for (let i = 0; i < (str||'').length; i++) h = str.charCodeAt(i) + ((h << 5) - h); - return colors[Math.abs(h) % colors.length]; -} +import { stringToColor } from "../../utils/constants"; const MessageBubble = ({ message }) => { const isMe = message.isMe; diff --git a/glense.client/src/components/VideoComments.jsx b/glense.client/src/components/VideoComments.jsx index f709efd..fcedaf9 100644 --- a/glense.client/src/components/VideoComments.jsx +++ b/glense.client/src/components/VideoComments.jsx @@ -3,16 +3,7 @@ import { Stack, Typography, Avatar } from "@mui/material"; import { ThumbUpOutlined } from "@mui/icons-material"; import { getComments } from "../utils/videoApi"; 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++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - } - const colors = ['#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3', '#00bcd4', '#009688', '#4caf50', '#ff9800', '#ff5722']; - return colors[Math.abs(hash) % colors.length]; -} +import { stringToColor } from "../utils/constants"; function VideoComments({ videoId, id }) { const resolvedVideoId = videoId || id; diff --git a/glense.client/src/utils/constants.jsx b/glense.client/src/utils/constants.jsx index 791a8d7..037666e 100644 --- a/glense.client/src/utils/constants.jsx +++ b/glense.client/src/utils/constants.jsx @@ -64,5 +64,13 @@ export const videoInfo = { dislikeCount: 1234 }; +// Consistent avatar color from a username/string +const avatarColors = ['#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3', '#00bcd4', '#009688', '#4caf50', '#ff9800', '#ff5722']; +export function stringToColor(str) { + let h = 0; + for (let i = 0; i < (str || '').length; i++) h = str.charCodeAt(i) + ((h << 5) - h); + return avatarColors[Math.abs(h) % avatarColors.length]; +} + // Video catalogue API export const VIDEO_CATALOGUE_API = import.meta.env.VITE_VIDEO_CATALOGUE_API || 'http://localhost:5088';