From dae8fbfe5efa9449b60ecaa901ad0f59fc102e54 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Fri, 2 Jan 2026 17:13:39 -0700 Subject: [PATCH] Create node graph page --- .claude/settings.local.json | 6 +- Media.JoshHeaps.Net/Api/AuthApi.cs | 21 +- Media.JoshHeaps.Net/Api/FolderApi.cs | 29 +- Media.JoshHeaps.Net/Api/FolderShareApi.cs | 21 +- Media.JoshHeaps.Net/Api/GraphApi.cs | 381 +++++ Media.JoshHeaps.Net/Api/MediaApi.cs | 25 +- Media.JoshHeaps.Net/Database/007_graphs.sql | 16 + .../Database/008_graph_nodes.sql | 15 + .../Database/009_graph_edges.sql | 22 + Media.JoshHeaps.Net/DbExecutor.cs | 16 + Media.JoshHeaps.Net/Models/Graph.cs | 17 + Media.JoshHeaps.Net/Models/GraphEdge.cs | 17 + Media.JoshHeaps.Net/Models/GraphNode.cs | 16 + .../Pages/{Index.cshtml => Gallery.cshtml} | 36 +- .../{Index.cshtml.cs => Gallery.cshtml.cs} | 12 +- Media.JoshHeaps.Net/Pages/Landing.cshtml | 105 ++ Media.JoshHeaps.Net/Pages/Landing.cshtml.cs | 12 + Media.JoshHeaps.Net/Pages/Login.cshtml.cs | 15 +- Media.JoshHeaps.Net/Pages/NetworkGraph.cshtml | 275 ++++ .../Pages/NetworkGraph.cshtml.cs | 25 + Media.JoshHeaps.Net/Pages/Register.cshtml.cs | 15 +- .../Pages/ResendVerification.cshtml.cs | 15 +- .../Pages/VerifyEmail.cshtml.cs | 11 +- Media.JoshHeaps.Net/Program.cs | 8 + Media.JoshHeaps.Net/Services/EmailService.cs | 51 +- Media.JoshHeaps.Net/Services/FolderService.cs | 99 +- Media.JoshHeaps.Net/Services/GraphService.cs | 810 +++++++++++ Media.JoshHeaps.Net/Services/MediaService.cs | 69 +- Media.JoshHeaps.Net/Services/UserService.cs | 19 +- .../wwwroot/css/Gallery/page.css | 188 +++ Media.JoshHeaps.Net/wwwroot/css/landing.css | 344 +++++ .../wwwroot/css/network-graph.css | 528 +++++++ .../wwwroot/js/network-graph.js | 1251 +++++++++++++++++ run-migrations.ps1 | 73 + 34 files changed, 4294 insertions(+), 269 deletions(-) create mode 100644 Media.JoshHeaps.Net/Api/GraphApi.cs create mode 100644 Media.JoshHeaps.Net/Database/007_graphs.sql create mode 100644 Media.JoshHeaps.Net/Database/008_graph_nodes.sql create mode 100644 Media.JoshHeaps.Net/Database/009_graph_edges.sql create mode 100644 Media.JoshHeaps.Net/Models/Graph.cs create mode 100644 Media.JoshHeaps.Net/Models/GraphEdge.cs create mode 100644 Media.JoshHeaps.Net/Models/GraphNode.cs rename Media.JoshHeaps.Net/Pages/{Index.cshtml => Gallery.cshtml} (86%) rename Media.JoshHeaps.Net/Pages/{Index.cshtml.cs => Gallery.cshtml.cs} (94%) create mode 100644 Media.JoshHeaps.Net/Pages/Landing.cshtml create mode 100644 Media.JoshHeaps.Net/Pages/Landing.cshtml.cs create mode 100644 Media.JoshHeaps.Net/Pages/NetworkGraph.cshtml create mode 100644 Media.JoshHeaps.Net/Pages/NetworkGraph.cshtml.cs create mode 100644 Media.JoshHeaps.Net/Services/GraphService.cs create mode 100644 Media.JoshHeaps.Net/wwwroot/css/Gallery/page.css create mode 100644 Media.JoshHeaps.Net/wwwroot/css/landing.css create mode 100644 Media.JoshHeaps.Net/wwwroot/css/network-graph.css create mode 100644 Media.JoshHeaps.Net/wwwroot/js/network-graph.js create mode 100644 run-migrations.ps1 diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b93b5ca..6755399 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,11 @@ "Bash(powershell:*)", "Bash(dotnet script:*)", "Bash(taskkill:*)", - "Bash(dir:*)" + "Bash(dir:*)", + "Bash(cd:*)", + "Bash(tree:*)", + "Bash(del Index.cshtml Index.cshtml.cs)", + "Bash(dotnet build:*)" ], "deny": [], "ask": [] diff --git a/Media.JoshHeaps.Net/Api/AuthApi.cs b/Media.JoshHeaps.Net/Api/AuthApi.cs index 5a4cfed..c9aecfa 100644 --- a/Media.JoshHeaps.Net/Api/AuthApi.cs +++ b/Media.JoshHeaps.Net/Api/AuthApi.cs @@ -11,17 +11,8 @@ namespace Media.JoshHeaps.Net.Api; [ApiController] [Route("api/auth")] -public class AuthApi : ControllerBase +public class AuthApi(AuthService authService, IConfiguration configuration) : ControllerBase { - private readonly AuthService _authService; - private readonly IConfiguration _configuration; - - public AuthApi(AuthService authService, IConfiguration configuration) - { - _authService = authService; - _configuration = configuration; - } - [HttpPost("login")] public async Task Login([FromBody] LoginRequest request) { @@ -30,7 +21,7 @@ public class AuthApi : ControllerBase return BadRequest(new { error = "Email/username and password are required" }); } - var (success, error, userInfo) = await _authService.LoginAsync(request.EmailOrUsername, request.Password); + var (success, error, userInfo) = await authService.LoginAsync(request.EmailOrUsername, request.Password); if (!success || userInfo == null) { @@ -77,10 +68,10 @@ public class AuthApi : ControllerBase private string GenerateJwtToken(UserLoginInfo user) { - var jwtKey = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured"); - var jwtIssuer = _configuration["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured"); - var jwtAudience = _configuration["Jwt:Audience"] ?? throw new InvalidOperationException("JWT Audience not configured"); - var jwtExpiryDays = int.Parse(_configuration["Jwt:ExpiryInDays"] ?? "30"); + var jwtKey = configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured"); + var jwtIssuer = configuration["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured"); + var jwtAudience = configuration["Jwt:Audience"] ?? throw new InvalidOperationException("JWT Audience not configured"); + var jwtExpiryDays = int.Parse(configuration["Jwt:ExpiryInDays"] ?? "30"); var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); diff --git a/Media.JoshHeaps.Net/Api/FolderApi.cs b/Media.JoshHeaps.Net/Api/FolderApi.cs index 7048680..a493060 100644 --- a/Media.JoshHeaps.Net/Api/FolderApi.cs +++ b/Media.JoshHeaps.Net/Api/FolderApi.cs @@ -6,15 +6,8 @@ namespace Media.JoshHeaps.Net.Api; [ApiController] [Route("api/folder")] -public class FolderApi : ControllerBase +public class FolderApi(FolderService folderService) : ControllerBase { - private readonly FolderService _folderService; - - public FolderApi(FolderService folderService) - { - _folderService = folderService; - } - [HttpGet("list")] public async Task ListFolders([FromQuery] long? folderId = null) { @@ -24,7 +17,7 @@ public class FolderApi : ControllerBase return Unauthorized(new { error = "Not authenticated" }); } - var folders = await _folderService.GetUserFoldersAsync(userId.Value, folderId); + var folders = await folderService.GetUserFoldersAsync(userId.Value, folderId); return Ok(folders); } @@ -37,7 +30,7 @@ public class FolderApi : ControllerBase return Unauthorized(new { error = "Not authenticated" }); } - var path = await _folderService.GetFolderPathAsync(folderId, userId.Value); + var path = await folderService.GetFolderPathAsync(folderId, userId.Value); return Ok(path); } @@ -58,14 +51,14 @@ public class FolderApi : ControllerBase // If parent folder specified, check ownership (can't create in shared folders) if (request.ParentFolderId.HasValue) { - var parentOwnerId = await _folderService.GetFolderOwnerIdAsync(request.ParentFolderId.Value); + var parentOwnerId = await folderService.GetFolderOwnerIdAsync(request.ParentFolderId.Value); if (parentOwnerId != userId.Value) { return Forbid("Cannot create folders in shared folders"); } } - var folder = await _folderService.CreateFolderAsync(userId.Value, request.Name, request.ParentFolderId); + var folder = await folderService.CreateFolderAsync(userId.Value, request.Name, request.ParentFolderId); if (folder == null) { return BadRequest(new { error = "Failed to create folder" }); @@ -89,13 +82,13 @@ public class FolderApi : ControllerBase } // Check ownership (can't rename shared folders) - var ownerId = await _folderService.GetFolderOwnerIdAsync(request.FolderId); + var ownerId = await folderService.GetFolderOwnerIdAsync(request.FolderId); if (ownerId != userId.Value) { return Forbid("Cannot rename shared folders"); } - var success = await _folderService.RenameFolderAsync(request.FolderId, userId.Value, request.NewName); + var success = await folderService.RenameFolderAsync(request.FolderId, userId.Value, request.NewName); if (!success) { return BadRequest(new { error = "Failed to rename folder" }); @@ -114,13 +107,13 @@ public class FolderApi : ControllerBase } // Check ownership (can't delete shared folders) - var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId); + var ownerId = await folderService.GetFolderOwnerIdAsync(folderId); if (ownerId != userId.Value) { return Forbid("Cannot delete shared folders"); } - var success = await _folderService.DeleteFolderAsync(folderId, userId.Value, deleteContents); + var success = await folderService.DeleteFolderAsync(folderId, userId.Value, deleteContents); if (!success) { return BadRequest(new { error = "Failed to delete folder" }); @@ -139,13 +132,13 @@ public class FolderApi : ControllerBase } // Check ownership (can't move shared folders) - var ownerId = await _folderService.GetFolderOwnerIdAsync(request.FolderId); + var ownerId = await folderService.GetFolderOwnerIdAsync(request.FolderId); if (ownerId != userId.Value) { return Forbid("Cannot move shared folders"); } - var success = await _folderService.MoveFolderAsync(request.FolderId, userId.Value, request.NewParentFolderId); + var success = await folderService.MoveFolderAsync(request.FolderId, userId.Value, request.NewParentFolderId); if (!success) { return BadRequest(new { error = "Failed to move folder" }); diff --git a/Media.JoshHeaps.Net/Api/FolderShareApi.cs b/Media.JoshHeaps.Net/Api/FolderShareApi.cs index 2e83f65..d65b62b 100644 --- a/Media.JoshHeaps.Net/Api/FolderShareApi.cs +++ b/Media.JoshHeaps.Net/Api/FolderShareApi.cs @@ -6,17 +6,8 @@ namespace Media.JoshHeaps.Net.Api; [ApiController] [Route("api/folder-share")] -public class FolderShareApi : ControllerBase +public class FolderShareApi(FolderService folderService, UserService userService) : ControllerBase { - private readonly FolderService _folderService; - private readonly UserService _userService; - - public FolderShareApi(FolderService folderService, UserService userService) - { - _folderService = folderService; - _userService = userService; - } - [HttpPost("share")] public async Task ShareFolder([FromBody] ShareFolderRequest request) { @@ -26,7 +17,7 @@ public class FolderShareApi : ControllerBase return Unauthorized(new { error = "Not authenticated" }); } - var share = await _folderService.ShareFolderAsync(request.FolderId, userId.Value, request.SharedWithUserId); + var share = await folderService.ShareFolderAsync(request.FolderId, userId.Value, request.SharedWithUserId); if (share == null) { return BadRequest(new { error = "Failed to share folder" }); @@ -44,7 +35,7 @@ public class FolderShareApi : ControllerBase return Unauthorized(new { error = "Not authenticated" }); } - var success = await _folderService.UnshareFolderAsync(folderId, userId.Value, sharedWithUserId); + var success = await folderService.UnshareFolderAsync(folderId, userId.Value, sharedWithUserId); if (!success) { return BadRequest(new { error = "Failed to unshare folder" }); @@ -62,7 +53,7 @@ public class FolderShareApi : ControllerBase return Unauthorized(new { error = "Not authenticated" }); } - var shares = await _folderService.GetFolderSharesAsync(folderId, userId.Value); + var shares = await folderService.GetFolderSharesAsync(folderId, userId.Value); return Ok(shares); } @@ -75,7 +66,7 @@ public class FolderShareApi : ControllerBase return Unauthorized(new { error = "Not authenticated" }); } - var sharedFolders = await _folderService.GetSharedFoldersAsync(userId.Value); + var sharedFolders = await folderService.GetSharedFoldersAsync(userId.Value); return Ok(sharedFolders); } @@ -93,7 +84,7 @@ public class FolderShareApi : ControllerBase return Ok(new List()); } - var users = await _userService.SearchUsersAsync(query, userId.Value); + var users = await userService.SearchUsersAsync(query, userId.Value); return Ok(users); } diff --git a/Media.JoshHeaps.Net/Api/GraphApi.cs b/Media.JoshHeaps.Net/Api/GraphApi.cs new file mode 100644 index 0000000..a35d6d5 --- /dev/null +++ b/Media.JoshHeaps.Net/Api/GraphApi.cs @@ -0,0 +1,381 @@ +using Media.JoshHeaps.Net.Services; +using Media.JoshHeaps.Net.Models; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Media.JoshHeaps.Net.Api; + +[ApiController] +[Route("api/graph")] +public class GraphApi(GraphService graphService) : ControllerBase +{ + + #region Graph Endpoints + + [HttpGet("list")] + public async Task ListGraphs() + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var graphs = await graphService.GetUserGraphsAsync(userId.Value); + return Ok(graphs); + } + + [HttpGet("{graphId}")] + public async Task GetGraph(long graphId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var graph = await graphService.GetGraphByIdAsync(graphId, userId.Value); + if (graph == null) + { + return NotFound(new { error = "Graph not found" }); + } + + return Ok(graph); + } + + [HttpGet("{graphId}/data")] + public async Task GetGraphData(long graphId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var graphData = await graphService.GetGraphDataAsync(graphId, userId.Value); + if (graphData.Graph == null || graphData.Graph.Id == 0) + { + return NotFound(new { error = "Graph not found" }); + } + + return Ok(graphData); + } + + [HttpPost("create")] + public async Task CreateGraph([FromBody] CreateGraphRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + return BadRequest(new { error = "Graph name is required" }); + } + + var graph = await graphService.CreateGraphAsync(userId.Value, request.Name, request.Description); + if (graph == null) + { + return BadRequest(new { error = "Failed to create graph" }); + } + + return Ok(graph); + } + + [HttpPut("{graphId}")] + public async Task UpdateGraph(long graphId, [FromBody] UpdateGraphRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + return BadRequest(new { error = "Graph name is required" }); + } + + var success = await graphService.UpdateGraphAsync(graphId, userId.Value, request.Name, request.Description); + if (!success) + { + return BadRequest(new { error = "Failed to update graph" }); + } + + return Ok(new { success = true }); + } + + [HttpDelete("{graphId}")] + public async Task DeleteGraph(long graphId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var success = await graphService.DeleteGraphAsync(graphId, userId.Value); + if (!success) + { + return BadRequest(new { error = "Failed to delete graph" }); + } + + return Ok(new { success = true }); + } + + [HttpPost("{graphId}/clone")] + public async Task CloneGraph(long graphId, [FromBody] CloneGraphRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + if (string.IsNullOrWhiteSpace(request.NewName)) + { + return BadRequest(new { error = "New graph name is required" }); + } + + var newGraph = await graphService.CloneGraphAsync(graphId, userId.Value, request.NewName); + if (newGraph == null) + { + return BadRequest(new { error = "Failed to clone graph" }); + } + + return Ok(newGraph); + } + + #endregion + + #region Node Endpoints + + [HttpGet("{graphId}/nodes")] + public async Task GetNodes(long graphId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var nodes = await graphService.GetGraphNodesAsync(graphId, userId.Value); + return Ok(nodes); + } + + [HttpPost("{graphId}/nodes")] + public async Task CreateNode(long graphId, [FromBody] CreateNodeRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + if (string.IsNullOrWhiteSpace(request.Label)) + { + return BadRequest(new { error = "Node label is required" }); + } + + var node = await graphService.CreateNodeAsync(graphId, userId.Value, request.Label, request.Notes); + if (node == null) + { + return BadRequest(new { error = "Failed to create node" }); + } + + return Ok(node); + } + + [HttpPut("nodes/{nodeId}")] + public async Task UpdateNode(long nodeId, [FromBody] UpdateNodeRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + if (string.IsNullOrWhiteSpace(request.Label)) + { + return BadRequest(new { error = "Node label is required" }); + } + + var success = await graphService.UpdateNodeAsync(nodeId, userId.Value, request.Label, request.Notes); + if (!success) + { + return BadRequest(new { error = "Failed to update node" }); + } + + return Ok(new { success = true }); + } + + [HttpDelete("nodes/{nodeId}")] + public async Task DeleteNode(long nodeId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var success = await graphService.DeleteNodeAsync(nodeId, userId.Value); + if (!success) + { + return BadRequest(new { error = "Failed to delete node" }); + } + + return Ok(new { success = true }); + } + + [HttpGet("{graphId}/nodes/search")] + public async Task SearchNodes(long graphId, [FromQuery] string q) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + if (string.IsNullOrWhiteSpace(q)) + { + return BadRequest(new { error = "Search term is required" }); + } + + var nodes = await graphService.SearchNodesAsync(graphId, userId.Value, q); + return Ok(nodes); + } + + #endregion + + #region Edge Endpoints + + [HttpGet("{graphId}/edges")] + public async Task GetEdges(long graphId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var edges = await graphService.GetGraphEdgesAsync(graphId, userId.Value); + return Ok(edges); + } + + [HttpPost("{graphId}/edges")] + public async Task CreateEdge(long graphId, [FromBody] CreateEdgeRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var edge = await graphService.CreateEdgeAsync(graphId, userId.Value, request.SourceNodeId, request.TargetNodeId); + if (edge == null) + { + return BadRequest(new { error = "Failed to create edge" }); + } + + return Ok(edge); + } + + [HttpDelete("edges/{edgeId}")] + public async Task DeleteEdge(long edgeId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var success = await graphService.DeleteEdgeAsync(edgeId, userId.Value); + if (!success) + { + return BadRequest(new { error = "Failed to delete edge" }); + } + + return Ok(new { success = true }); + } + + #endregion + + #region Filter Endpoints + + [HttpGet("{graphId}/nodes/filter/connects-to/{targetNodeId}")] + public async Task FilterConnectsTo(long graphId, long targetNodeId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var nodes = await graphService.FilterConnectsToAsync(graphId, userId.Value, targetNodeId); + return Ok(nodes); + } + + [HttpGet("{graphId}/nodes/filter/not-connects-to/{targetNodeId}")] + public async Task FilterDoesNotConnectTo(long graphId, long targetNodeId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var nodes = await graphService.FilterDoesNotConnectToAsync(graphId, userId.Value, targetNodeId); + return Ok(nodes); + } + + [HttpGet("{graphId}/nodes/filter/two-degrees/{targetNodeId}")] + public async Task FilterTwoDegrees(long graphId, long targetNodeId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var nodes = await graphService.FilterConnectsTwoDegreesAsync(graphId, userId.Value, targetNodeId); + return Ok(nodes); + } + + #endregion + + #region Helper Methods + + private long? GetUserIdFromAuth() + { + // First try JWT claims (for mobile/API authentication) + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId)) + { + return jwtUserId; + } + + // Fall back to session (for web authentication) + var userIdString = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId)) + { + return sessionUserId; + } + + return null; + } + + #endregion +} + +#region Request Models + +public record CreateGraphRequest(string Name, string? Description); +public record UpdateGraphRequest(string Name, string? Description); +public record CloneGraphRequest(string NewName); +public record CreateNodeRequest(string Label, string? Notes); +public record UpdateNodeRequest(string Label, string? Notes); +public record CreateEdgeRequest(long SourceNodeId, long TargetNodeId); + +#endregion diff --git a/Media.JoshHeaps.Net/Api/MediaApi.cs b/Media.JoshHeaps.Net/Api/MediaApi.cs index debd78e..e03f1a9 100644 --- a/Media.JoshHeaps.Net/Api/MediaApi.cs +++ b/Media.JoshHeaps.Net/Api/MediaApi.cs @@ -6,17 +6,8 @@ namespace Media.JoshHeaps.Net.Api; [ApiController] [Route("api/media")] -public class MediaApi : ControllerBase +public class MediaApi(MediaService mediaService, FolderService folderService) : ControllerBase { - private readonly MediaService _mediaService; - private readonly FolderService _folderService; - - public MediaApi(MediaService mediaService, FolderService folderService) - { - _mediaService = mediaService; - _folderService = folderService; - } - [HttpGet("load")] public async Task LoadMedia([FromQuery] int offset = 0, [FromQuery] int limit = 20, [FromQuery] long? folderId = null) { @@ -27,7 +18,7 @@ public class MediaApi : ControllerBase return Unauthorized(); } - var media = await _mediaService.GetUserMediaAsync(userId.Value, offset, limit, folderId, userId.Value); + var media = await mediaService.GetUserMediaAsync(userId.Value, offset, limit, folderId, userId.Value); return Ok(media); } @@ -43,14 +34,14 @@ public class MediaApi : ControllerBase } // Get media metadata (includes ownership check) - var media = await _mediaService.GetMediaByIdAsync(mediaId, userId.Value); + var media = await mediaService.GetMediaByIdAsync(mediaId, userId.Value); if (media == null) { return NotFound(new { error = "Image not found or access denied" }); } // Get decrypted image data - var imageData = await _mediaService.GetDecryptedMediaDataAsync(mediaId, userId.Value); + var imageData = await mediaService.GetDecryptedMediaDataAsync(mediaId, userId.Value); if (imageData == null) { return NotFound(new { error = "Image file not found" }); @@ -73,7 +64,7 @@ public class MediaApi : ControllerBase // Check folder ownership if uploading to a folder (can't upload to shared folders) if (folderId.HasValue) { - var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId.Value); + var ownerId = await folderService.GetFolderOwnerIdAsync(folderId.Value); if (ownerId != userId.Value) { return Forbid("Cannot upload to shared folders"); @@ -99,7 +90,7 @@ public class MediaApi : ControllerBase } // Save media using existing service - var media = await _mediaService.SaveMediaAsync(userId.Value, file, description, folderId); + var media = await mediaService.SaveMediaAsync(userId.Value, file, description, folderId); if (media == null) { return StatusCode(500, new { error = "Failed to upload image" }); @@ -118,7 +109,7 @@ public class MediaApi : ControllerBase return Unauthorized(new { error = "Not authenticated" }); } - var success = await _mediaService.MoveMediaToFolderAsync(request.MediaId, userId.Value, request.FolderId); + var success = await mediaService.MoveMediaToFolderAsync(request.MediaId, userId.Value, request.FolderId); if (!success) { return BadRequest(new { error = "Failed to move media" }); @@ -147,7 +138,7 @@ public class MediaApi : ControllerBase foreach (var mediaId in request.MediaIds) { - var success = await _mediaService.MoveMediaToFolderAsync(mediaId, userId.Value, request.FolderId); + var success = await mediaService.MoveMediaToFolderAsync(mediaId, userId.Value, request.FolderId); if (success) { successCount++; diff --git a/Media.JoshHeaps.Net/Database/007_graphs.sql b/Media.JoshHeaps.Net/Database/007_graphs.sql new file mode 100644 index 0000000..9bff27f --- /dev/null +++ b/Media.JoshHeaps.Net/Database/007_graphs.sql @@ -0,0 +1,16 @@ +-- Network Graphs Table +-- Stores user-created network graphs +CREATE TABLE IF NOT EXISTS app.graphs ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES app.users(id) ON DELETE CASCADE, + -- Ensure graph names are unique per user + UNIQUE (user_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_graphs_user_id ON app.graphs(user_id); +CREATE INDEX IF NOT EXISTS idx_graphs_name ON app.graphs(name); diff --git a/Media.JoshHeaps.Net/Database/008_graph_nodes.sql b/Media.JoshHeaps.Net/Database/008_graph_nodes.sql new file mode 100644 index 0000000..02fcb72 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/008_graph_nodes.sql @@ -0,0 +1,15 @@ +-- Graph Nodes Table +-- Stores nodes within network graphs +-- Node positions are calculated automatically by the client using community detection +CREATE TABLE IF NOT EXISTS app.graph_nodes ( + id BIGSERIAL PRIMARY KEY, + graph_id BIGINT NOT NULL, + label VARCHAR(255) NOT NULL, + notes TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (graph_id) REFERENCES app.graphs(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_graph_nodes_graph_id ON app.graph_nodes(graph_id); +CREATE INDEX IF NOT EXISTS idx_graph_nodes_label ON app.graph_nodes(label); diff --git a/Media.JoshHeaps.Net/Database/009_graph_edges.sql b/Media.JoshHeaps.Net/Database/009_graph_edges.sql new file mode 100644 index 0000000..2ccacbe --- /dev/null +++ b/Media.JoshHeaps.Net/Database/009_graph_edges.sql @@ -0,0 +1,22 @@ +-- Graph Edges Table +-- Stores connections/relationships between nodes in a graph +CREATE TABLE IF NOT EXISTS app.graph_edges ( + id BIGSERIAL PRIMARY KEY, + graph_id BIGINT NOT NULL, + source_node_id BIGINT NOT NULL, + target_node_id BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (graph_id) REFERENCES app.graphs(id) ON DELETE CASCADE, + FOREIGN KEY (source_node_id) REFERENCES app.graph_nodes(id) ON DELETE CASCADE, + FOREIGN KEY (target_node_id) REFERENCES app.graph_nodes(id) ON DELETE CASCADE, + -- Prevent duplicate edges between the same nodes + UNIQUE (graph_id, source_node_id, target_node_id), + -- Prevent self-loops (node connecting to itself) + CHECK (source_node_id != target_node_id) +); + +CREATE INDEX IF NOT EXISTS idx_graph_edges_graph_id ON app.graph_edges(graph_id); +CREATE INDEX IF NOT EXISTS idx_graph_edges_source_node_id ON app.graph_edges(source_node_id); +CREATE INDEX IF NOT EXISTS idx_graph_edges_target_node_id ON app.graph_edges(target_node_id); +-- Composite index for efficient bidirectional queries +CREATE INDEX IF NOT EXISTS idx_graph_edges_nodes ON app.graph_edges(source_node_id, target_node_id); diff --git a/Media.JoshHeaps.Net/DbExecutor.cs b/Media.JoshHeaps.Net/DbExecutor.cs index b9b38ca..fb3119b 100644 --- a/Media.JoshHeaps.Net/DbExecutor.cs +++ b/Media.JoshHeaps.Net/DbExecutor.cs @@ -103,4 +103,20 @@ public class DbExecutor(IConfiguration config) return results; } + + // Executes a non-query command (INSERT, UPDATE, DELETE) and returns rows affected + public async Task ExecuteNonQueryAsync(string query, object? parameters = null) + { + parameters ??= new(); + using var conn = new NpgsqlConnection(ConnectionString); + await conn.OpenAsync(); + using var cmd = new NpgsqlCommand(query, conn); + + foreach (var prop in parameters.GetType().GetProperties()) + { + cmd.Parameters.AddWithValue($"@{prop.Name}", prop.GetValue(parameters) ?? DBNull.Value); + } + + return await cmd.ExecuteNonQueryAsync(); + } } diff --git a/Media.JoshHeaps.Net/Models/Graph.cs b/Media.JoshHeaps.Net/Models/Graph.cs new file mode 100644 index 0000000..5155c22 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/Graph.cs @@ -0,0 +1,17 @@ +namespace Media.JoshHeaps.Net.Models; + +public class Graph +{ + public long Id { get; set; } + public long UserId { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + +public class GraphWithCounts : Graph +{ + public int NodeCount { get; set; } + public int EdgeCount { get; set; } +} diff --git a/Media.JoshHeaps.Net/Models/GraphEdge.cs b/Media.JoshHeaps.Net/Models/GraphEdge.cs new file mode 100644 index 0000000..f64f265 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/GraphEdge.cs @@ -0,0 +1,17 @@ +namespace Media.JoshHeaps.Net.Models; + +public class GraphEdge +{ + public long Id { get; set; } + public long GraphId { get; set; } + public long SourceNodeId { get; set; } + public long TargetNodeId { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class GraphData +{ + public Graph Graph { get; set; } = new(); + public List Nodes { get; set; } = []; + public List Edges { get; set; } = []; +} diff --git a/Media.JoshHeaps.Net/Models/GraphNode.cs b/Media.JoshHeaps.Net/Models/GraphNode.cs new file mode 100644 index 0000000..0a46758 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/GraphNode.cs @@ -0,0 +1,16 @@ +namespace Media.JoshHeaps.Net.Models; + +public class GraphNode +{ + public long Id { get; set; } + public long GraphId { get; set; } + public string Label { get; set; } = string.Empty; + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + +public class GraphNodeWithConnections : GraphNode +{ + public int ConnectionCount { get; set; } +} diff --git a/Media.JoshHeaps.Net/Pages/Index.cshtml b/Media.JoshHeaps.Net/Pages/Gallery.cshtml similarity index 86% rename from Media.JoshHeaps.Net/Pages/Index.cshtml rename to Media.JoshHeaps.Net/Pages/Gallery.cshtml index 6aabf0e..9c8fd76 100644 --- a/Media.JoshHeaps.Net/Pages/Index.cshtml +++ b/Media.JoshHeaps.Net/Pages/Gallery.cshtml @@ -1,18 +1,26 @@ @page "{handler?}" -@model Media.JoshHeaps.Net.Pages.IndexModel +@model Media.JoshHeaps.Net.Pages.GalleryModel @{ - ViewData["Title"] = "Dashboard"; + ViewData["Title"] = "Gallery"; Layout = "_Layout"; } @section Styles { - + }
-

Welcome back, @Model.Dashboard?.Username!

+
+ + + + + + +

Welcome back, @Model.Dashboard?.Username!

+
@if (Model.Dashboard?.EmailVerified == false) { @@ -59,11 +67,11 @@