Create node graph page

This commit is contained in:
Josh-Heaps
2026-01-02 17:13:39 -07:00
parent d101a0f175
commit dae8fbfe5e
34 changed files with 4294 additions and 269 deletions
+5 -1
View File
@@ -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": []
+6 -15
View File
@@ -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<IActionResult> 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);
+11 -18
View File
@@ -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<IActionResult> 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" });
+6 -15
View File
@@ -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<IActionResult> 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<object>());
}
var users = await _userService.SearchUsersAsync(query, userId.Value);
var users = await userService.SearchUsersAsync(query, userId.Value);
return Ok(users);
}
+381
View File
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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
+8 -17
View File
@@ -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<IActionResult> 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++;
@@ -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);
@@ -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);
@@ -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);
+16
View File
@@ -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<int> 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();
}
}
+17
View File
@@ -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; }
}
+17
View File
@@ -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<GraphNodeWithConnections> Nodes { get; set; } = [];
public List<GraphEdge> Edges { get; set; } = [];
}
+16
View File
@@ -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; }
}
@@ -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 {
<link rel="stylesheet" href="~/css/Home/page.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/Gallery/page.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/gallery.css" asp-append-version="true" />
}
<div class="dashboard-container">
<div class="welcome-section">
<h1>Welcome back, @Model.Dashboard?.Username!</h1>
<div class="welcome-left">
<a href="/Landing" class="back-button" title="Back to Home">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</a>
<h1>Welcome back, @Model.Dashboard?.Username!</h1>
</div>
<div class="quick-actions">
@if (Model.Dashboard?.EmailVerified == false)
{
@@ -59,11 +67,11 @@
<div class="card">
<div class="gallery-header">
<div class="breadcrumb-nav">
<a href="/[email protected]">Home</a>
<a href="/Gallery[email protected]">Home</a>
@foreach (var folder in Model.FolderPath)
{
<span class="breadcrumb-separator">/</span>
<a href="/[email protected]&[email protected]">@folder.Name</a>
<a href="/Gallery[email protected]&[email protected]">@folder.Name</a>
}
@if (Model.IsSharedFolder && !string.IsNullOrEmpty(Model.SharedByUsername))
{
@@ -72,9 +80,9 @@
</div>
<div class="header-actions">
<div class="view-mode-toggle">
<a href="/?view=own" class="view-mode-btn @(Model.ViewMode == "own" ? "active" : "")">My Folders</a>
<a href="/?view=shared" class="view-mode-btn @(Model.ViewMode == "shared" ? "active" : "")">Shared with Me</a>
<a href="/?view=all" class="view-mode-btn @(Model.ViewMode == "all" ? "active" : "")">All</a>
<a href="/Gallery?view=own" class="view-mode-btn @(Model.ViewMode == "own" ? "active" : "")">My Folders</a>
<a href="/Gallery?view=shared" class="view-mode-btn @(Model.ViewMode == "shared" ? "active" : "")">Shared with Me</a>
<a href="/Gallery?view=all" class="view-mode-btn @(Model.ViewMode == "all" ? "active" : "")">All</a>
</div>
@if (!Model.IsSharedFolder)
{
@@ -97,8 +105,8 @@
@if (Model.CurrentFolderId.HasValue)
{
var backUrl = Model.FolderPath.Count > 1
? $"/?folderId={Model.FolderPath[Model.FolderPath.Count - 2].Id}&view={Model.ViewMode}"
: $"/?view={Model.ViewMode}";
? $"/Gallery?folderId={Model.FolderPath[Model.FolderPath.Count - 2].Id}&view={Model.ViewMode}"
: $"/Gallery?view={Model.ViewMode}";
<div class="gallery-item folder-item" data-folder-id="back" onclick="window.location.href='@backUrl'">
<div class="folder-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -110,7 +118,7 @@
}
@foreach (var sharedFolder in Model.SharedFolders)
{
<div class="gallery-item folder-item shared-folder-item" data-folder-id="@sharedFolder.FolderId" onclick="window.location.href='/[email protected]&[email protected]'">
<div class="gallery-item folder-item shared-folder-item" data-folder-id="@sharedFolder.FolderId" onclick="window.location.href='/Gallery[email protected]&[email protected]'">
<div class="folder-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
@@ -125,7 +133,7 @@
}
@foreach (var folder in Model.Folders)
{
<div class="gallery-item folder-item @(folder.UserId != Model.UserId ? "shared-folder-item" : "")" data-folder-id="@folder.Id" data-owner-id="@folder.UserId" onclick="window.location.href='/[email protected]&[email protected]'">
<div class="gallery-item folder-item @(folder.UserId != Model.UserId ? "shared-folder-item" : "")" data-folder-id="@folder.Id" data-owner-id="@folder.UserId" onclick="window.location.href='/Gallery[email protected]&[email protected]'">
<div class="folder-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
@@ -226,4 +234,4 @@
<script src="~/js/folder-sharing.js" asp-append-version="true"></script>
<script src="~/js/drag-drop.js" asp-append-version="true"></script>
<script src="~/js/context-menu.js" asp-append-version="true"></script>
}
}
@@ -4,16 +4,16 @@ using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Pages
{
public class IndexModel(UserService userService, MediaService mediaService, FolderService folderService) : AuthenticatedPageModel
public class GalleryModel(UserService userService, MediaService mediaService, FolderService folderService) : AuthenticatedPageModel
{
private readonly MediaService _mediaService = mediaService;
private readonly FolderService _folderService = folderService;
public UserDashboard? Dashboard { get; set; }
public List<UserMedia> MediaItems { get; set; } = new();
public List<Folder> Folders { get; set; } = new();
public List<Folder> FolderPath { get; set; } = new();
public List<SharedFolderInfo> SharedFolders { get; set; } = new();
public List<UserMedia> MediaItems { get; set; } = [];
public List<Folder> Folders { get; set; } = [];
public List<Folder> FolderPath { get; set; } = [];
public List<SharedFolderInfo> SharedFolders { get; set; } = [];
public long? CurrentFolderId { get; set; }
public bool IsSharedFolder { get; set; }
public long? FolderOwnerId { get; set; }
@@ -64,7 +64,7 @@ namespace Media.JoshHeaps.Net.Pages
SharedFolders = await _folderService.GetSharedFoldersAsync(UserId);
if (!CurrentFolderId.HasValue)
{
Folders = new List<Folder>();
Folders = [];
}
else if (IsSharedFolder && FolderOwnerId.HasValue)
{
+105
View File
@@ -0,0 +1,105 @@
@page
@model Media.JoshHeaps.Net.Pages.LandingModel
@{
ViewData["Title"] = "Home";
Layout = null;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - Media Manager</title>
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/landing.css" asp-append-version="true" />
<script src="~/js/theme.js" asp-append-version="true"></script>
</head>
<body>
<div class="landing-wrapper">
<div class="landing-container">
<div class="landing-header">
<div class="logo-area">
<div class="logo-circle">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
</div>
<h1>Welcome Back</h1>
<p class="subtitle">Choose a workspace to continue</p>
</div>
</div>
<div class="cards-grid">
<a href="/Gallery" class="landing-card">
<div class="card-content">
<div class="card-icon-wrapper">
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</div>
</div>
<div class="card-text">
<h2>Media Storage</h2>
<p class="card-description">Upload, organize, and manage your images in folders with sharing capabilities</p>
</div>
</div>
<div class="card-footer">
<span class="card-link-text">Open workspace</span>
<div class="card-arrow">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</div>
</div>
</a>
<a href="/NetworkGraph" class="landing-card">
<div class="card-content">
<div class="card-icon-wrapper">
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<circle cx="6" cy="18" r="2"></circle>
<circle cx="18" cy="18" r="2"></circle>
<circle cx="18" cy="6" r="2"></circle>
<circle cx="6" cy="6" r="2"></circle>
<line x1="13.5" y1="10.5" x2="16.5" y2="7.5"></line>
<line x1="10.5" y1="10.5" x2="7.5" y2="7.5"></line>
<line x1="10.5" y1="13.5" x2="7.5" y2="16.5"></line>
<line x1="13.5" y1="13.5" x2="16.5" y2="16.5"></line>
</svg>
</div>
</div>
<div class="card-text">
<h2>Network Graphs</h2>
<p class="card-description">Create and visualize network graphs to map relationships and connections</p>
</div>
</div>
<div class="card-footer">
<span class="card-link-text">Open workspace</span>
<div class="card-arrow">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</div>
</div>
</a>
</div>
<div class="landing-footer">
<a href="/Profile" class="footer-link">Profile</a>
<span class="footer-separator">•</span>
<a href="/Logout" class="footer-link">Logout</a>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Media.JoshHeaps.Net.Pages
{
public class LandingModel : AuthenticatedPageModel
{
public void OnGet()
{
RequireAuthentication();
}
}
}
+4 -11
View File
@@ -5,10 +5,8 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Media.JoshHeaps.Net.Pages;
public class LoginModel : PageModel
public class LoginModel(AuthService authService) : PageModel
{
private readonly AuthService _authService;
[BindProperty]
public string Email { get; set; } = string.Empty;
@@ -22,18 +20,13 @@ public class LoginModel : PageModel
public string? SuccessMessage { get; set; }
public string? WarningMessage { get; set; }
public LoginModel(AuthService authService)
{
_authService = authService;
}
public void OnGet([FromQuery] string? registered, [FromQuery] string? verified)
{
// Check if user is already logged in
var userId = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userId))
{
Response.Redirect("/");
Response.Redirect("/Landing");
return;
}
@@ -58,7 +51,7 @@ public class LoginModel : PageModel
return Page();
}
var (success, error, userInfo) = await _authService.LoginAsync(Email, Password);
var (success, error, userInfo) = await authService.LoginAsync(Email, Password);
if (!success || userInfo == null)
{
@@ -91,6 +84,6 @@ public class LoginModel : PageModel
Response.Cookies.Append("RememberMe", userInfo.Id.ToString(), cookieOptions);
}
return Redirect("/");
return Redirect("/Landing");
}
}
@@ -0,0 +1,275 @@
@page
@model Media.JoshHeaps.Net.Pages.NetworkGraphModel
@{
ViewData["Title"] = "Network Graphs";
Layout = "_Layout";
}
@section Styles {
<link rel="stylesheet" href="~/css/Gallery/page.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/network-graph.css" asp-append-version="true" />
}
<div class="dashboard-container">
<!-- Header Section -->
<div class="welcome-section">
<div class="welcome-left">
<a href="/Landing" class="back-button" title="Back to Home">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</a>
<h1>Network Graphs</h1>
</div>
<div class="quick-actions">
<button type="button" class="btn btn-primary" id="new-graph-btn">New Graph</button>
<a href="/Profile" class="btn btn-secondary">Profile</a>
<a href="/Logout" class="btn btn-danger">Logout</a>
</div>
</div>
<!-- Graph Selection & Management -->
<div class="graph-manager-section">
<div class="graph-selector-card card">
<h2>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 8px;">
<path d="M3 3v18h18"></path>
<path d="M18 17V9"></path>
<path d="M13 17V5"></path>
<path d="M8 17v-3"></path>
</svg>
Your Graphs
</h2>
<div class="graphs-list" id="graphs-list">
@if (Model.Graphs.Count == 0)
{
<div class="empty-state">
<p>No graphs yet. Create your first graph to get started!</p>
</div>
}
else
{
foreach (var graph in Model.Graphs)
{
<div class="graph-item @(graph.Id == Model.SelectedGraphId ? "active" : "")" data-graph-id="@graph.Id">
<div class="graph-info" onclick="selectGraph(@graph.Id)">
<div class="graph-name">@graph.Name</div>
<div class="graph-stats">
<span class="stat">@graph.NodeCount nodes</span>
<span class="stat">@graph.EdgeCount edges</span>
</div>
</div>
<div class="graph-actions">
<button type="button" class="btn-icon" onclick="event.stopPropagation(); editGraph(@graph.Id, '@graph.Name', '@(graph.Description ?? "")')" title="Edit">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button type="button" class="btn-icon" onclick="event.stopPropagation(); cloneGraph(@graph.Id, '@graph.Name')" title="Clone">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
<button type="button" class="btn-icon danger" onclick="event.stopPropagation(); deleteGraph(@graph.Id, '@graph.Name')" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</div>
}
}
</div>
</div>
</div>
<!-- Graph Visualization & Tools -->
<div class="graph-workspace" id="graph-workspace" style="display: none;">
<!-- Toolbar -->
<div class="graph-toolbar card">
<div class="toolbar-section">
<button type="button" class="btn btn-secondary" id="add-node-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="16"></line>
<line x1="8" y1="12" x2="16" y2="12"></line>
</svg>
Add Node
</button>
<button type="button" class="btn btn-secondary" id="link-nodes-btn" disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
Link Selected
</button>
</div>
<div class="toolbar-section">
<input type="text" id="search-nodes" class="search-input" placeholder="Search nodes..." />
<input type="number" id="degrees-input" class="search-input" placeholder="Degrees" min="1" max="10" value="1" style="width: 100px; display: none;" />
<select id="filter-mode-select" class="filter-select" style="display: none;">
<option value="exact">Exactly</option>
<option value="within">Within</option>
<option value="outside">Outside</option>
</select>
<button type="button" class="btn btn-secondary" id="apply-filter-btn" style="display: none;">Apply Filter</button>
<button type="button" class="btn btn-secondary" id="clear-filter-btn" style="display: none;">Clear</button>
</div>
</div>
<!-- Canvas -->
<div class="graph-canvas-container card">
<canvas id="graph-canvas"></canvas>
<div class="canvas-help-text">
<span>💡 Right-click nodes for options • Ctrl+Click to select multiple • Double-click to edit</span>
</div>
<div class="canvas-controls">
<button type="button" class="canvas-btn" id="zoom-in-btn" title="Zoom In">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="11" y1="8" x2="11" y2="14"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
</button>
<button type="button" class="canvas-btn" id="zoom-out-btn" title="Zoom Out">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
</button>
<button type="button" class="canvas-btn" id="reset-view-btn" title="Reset View">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path>
<path d="M3 21v-5h5"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- New Graph Modal -->
<div id="graph-modal" class="modal" style="display: none;">
<div class="modal-backdrop" onclick="closeGraphModal()"></div>
<div class="modal-dialog">
<div class="modal-header">
<h3 id="graph-modal-title">New Graph</h3>
<button type="button" class="modal-close" onclick="closeGraphModal()">&times;</button>
</div>
<div class="modal-body">
<form id="graph-form">
<div class="form-group">
<label for="graph-name">Graph Name *</label>
<input type="text" id="graph-name" class="form-control" required />
</div>
<div class="form-group">
<label for="graph-description">Description</label>
<textarea id="graph-description" class="form-control" rows="3"></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeGraphModal()">Cancel</button>
<button type="submit" class="btn btn-primary" id="save-graph-btn">Create Graph</button>
</div>
</form>
</div>
</div>
</div>
<!-- Node Modal -->
<div id="node-modal" class="modal" style="display: none;">
<div class="modal-backdrop" onclick="closeNodeModal()"></div>
<div class="modal-dialog modal-dialog-large">
<div class="modal-header">
<h3 id="node-modal-title">New Node</h3>
<button type="button" class="modal-close" onclick="closeNodeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="node-form">
<div class="form-group">
<label for="node-label">Label *</label>
<input type="text" id="node-label" class="form-control" required />
</div>
<div class="form-group">
<label for="node-notes">Notes</label>
<textarea id="node-notes" class="form-control" rows="5" placeholder="Add detailed notes about this node..."></textarea>
</div>
<!-- Connections Section (only shown when editing) -->
<div id="node-connections-section" style="display: none;">
<div class="form-group">
<label>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
Connections
</label>
<!-- Add Connection -->
<div class="connection-search-container">
<input type="text" id="connection-search" class="form-control" placeholder="Search nodes to connect..." autocomplete="off" />
<div id="connection-search-results" class="search-results-dropdown" style="display: none;"></div>
</div>
<!-- Current Connections List -->
<div id="node-connections-list" class="connections-list"></div>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeNodeModal()">Cancel</button>
<button type="submit" class="btn btn-primary" id="save-node-btn">Create Node</button>
</div>
</form>
</div>
</div>
</div>
<!-- Context Menu -->
<div id="node-context-menu" class="context-menu" style="display: none;">
<div class="context-menu-item" data-action="edit">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
Edit Node
</div>
<div class="context-menu-item" data-action="link">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
Link to Selected
</div>
<div class="context-menu-item" data-action="select">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 11 12 14 22 4"></polyline>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
Add to Selection
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item danger" data-action="delete">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
Delete Node
</div>
</div>
@section Scripts {
<script>
const selectedGraphId = @(Model.SelectedGraphId?.ToString() ?? "null");
</script>
<script src="~/js/network-graph.js" asp-append-version="true"></script>
}
@@ -0,0 +1,25 @@
using Media.JoshHeaps.Net.Models;
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Pages
{
public class NetworkGraphModel(GraphService graphService) : AuthenticatedPageModel
{
private readonly GraphService _graphService = graphService;
public List<GraphWithCounts> Graphs { get; set; } = [];
public long? SelectedGraphId { get; set; }
public async Task<IActionResult> OnGetAsync([FromQuery] long? graphId = null)
{
RequireAuthentication();
LoadUserSession();
Graphs = await _graphService.GetUserGraphsAsync(UserId);
SelectedGraphId = graphId;
return Page();
}
}
}
+3 -12
View File
@@ -5,11 +5,8 @@ using System.Text.RegularExpressions;
namespace Media.JoshHeaps.Net.Pages;
public class RegisterModel : PageModel
public class RegisterModel(AuthService authService, EmailService emailService) : PageModel
{
private readonly AuthService _authService;
private readonly EmailService _emailService;
[BindProperty]
public string Email { get; set; } = string.Empty;
@@ -24,12 +21,6 @@ public class RegisterModel : PageModel
public string? ErrorMessage { get; set; }
public RegisterModel(AuthService authService, EmailService emailService)
{
_authService = authService;
_emailService = emailService;
}
public void OnGet()
{
// Check if user is already logged in
@@ -79,7 +70,7 @@ public class RegisterModel : PageModel
}
// Register user
var (success, error, verificationToken) = await _authService.RegisterUserAsync(Email, Username, Password);
var (success, error, verificationToken) = await authService.RegisterUserAsync(Email, Username, Password);
if (!success)
{
@@ -90,7 +81,7 @@ public class RegisterModel : PageModel
// Send verification email
if (!string.IsNullOrEmpty(verificationToken))
{
await _emailService.SendVerificationEmailAsync(Email, Username, verificationToken);
await emailService.SendVerificationEmailAsync(Email, Username, verificationToken);
}
// Redirect to verification pending page
@@ -4,23 +4,14 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Media.JoshHeaps.Net.Pages;
public class ResendVerificationModel : PageModel
public class ResendVerificationModel(AuthService authService, EmailService emailService) : PageModel
{
private readonly AuthService _authService;
private readonly EmailService _emailService;
[BindProperty]
public string Email { get; set; } = string.Empty;
public string? ErrorMessage { get; set; }
public string? SuccessMessage { get; set; }
public ResendVerificationModel(AuthService authService, EmailService emailService)
{
_authService = authService;
_emailService = emailService;
}
public void OnGet([FromQuery] string? email)
{
if (!string.IsNullOrEmpty(email))
@@ -37,7 +28,7 @@ public class ResendVerificationModel : PageModel
return Page();
}
var (success, error, verificationToken) = await _authService.ResendVerificationTokenAsync(Email);
var (success, error, verificationToken) = await authService.ResendVerificationTokenAsync(Email);
if (!success)
{
@@ -48,7 +39,7 @@ public class ResendVerificationModel : PageModel
// Send verification email
if (!string.IsNullOrEmpty(verificationToken))
{
var emailSent = await _emailService.SendVerificationEmailAsync(Email, Email, verificationToken);
var emailSent = await emailService.SendVerificationEmailAsync(Email, Email, verificationToken);
if (emailSent)
{
@@ -4,19 +4,12 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Media.JoshHeaps.Net.Pages;
public class VerifyEmailModel : PageModel
public class VerifyEmailModel(AuthService authService) : PageModel
{
private readonly AuthService _authService;
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public bool ShowResendLink { get; set; }
public VerifyEmailModel(AuthService authService)
{
_authService = authService;
}
public async Task<IActionResult> OnGetAsync([FromQuery] string? token)
{
if (string.IsNullOrEmpty(token))
@@ -27,7 +20,7 @@ public class VerifyEmailModel : PageModel
return Page();
}
var (success, error) = await _authService.VerifyEmailAsync(token);
var (success, error) = await authService.VerifyEmailAsync(token);
Success = success;
+8
View File
@@ -16,6 +16,7 @@ builder.Services.AddScoped<EmailService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<MediaService>();
builder.Services.AddScoped<FolderService>();
builder.Services.AddScoped<GraphService>();
// Add session support
builder.Services.AddDistributedMemoryCache();
@@ -71,4 +72,11 @@ app.UseAuthorization();
app.MapRazorPages();
app.MapControllers(); // Map API controllers
// Redirect root to Landing page
app.MapGet("/", context =>
{
context.Response.Redirect("/Landing");
return Task.CompletedTask;
});
app.Run();
+21 -30
View File
@@ -4,28 +4,19 @@ using MimeKit;
namespace Media.JoshHeaps.Net.Services;
public class EmailService
public class EmailService(IConfiguration config, ILogger<EmailService> logger)
{
private readonly IConfiguration _config;
private readonly ILogger<EmailService> _logger;
public EmailService(IConfiguration config, ILogger<EmailService> logger)
{
_config = config;
_logger = logger;
}
public async Task<bool> SendVerificationEmailAsync(string toEmail, string username, string verificationToken)
{
try
{
var appUrl = _config["AppUrl"] ?? "https://media.joshheaps.net";
var appUrl = config["AppUrl"] ?? "https://media.joshheaps.net";
var verificationUrl = $"{appUrl}/VerifyEmail?token={verificationToken}";
var message = new MimeMessage();
message.From.Add(new MailboxAddress(
_config["Email:FromName"] ?? "Media App",
_config["Email:FromEmail"] ?? "[email protected]"
config["Email:FromName"] ?? "Media App",
config["Email:FromEmail"] ?? "[email protected]"
));
message.To.Add(new MailboxAddress(username, toEmail));
message.Subject = "Verify Your Email Address";
@@ -85,11 +76,11 @@ If you didn't create an account, you can safely ignore this email.
using var client = new SmtpClient();
var smtpHost = _config["Email:SmtpHost"];
var smtpPort = int.Parse(_config["Email:SmtpPort"] ?? "587");
var smtpUsername = _config["Email:SmtpUsername"];
var smtpPassword = _config["Email:SmtpPassword"];
var enableSsl = bool.Parse(_config["Email:EnableSsl"] ?? "true");
var smtpHost = config["Email:SmtpHost"];
var smtpPort = int.Parse(config["Email:SmtpPort"] ?? "587");
var smtpUsername = config["Email:SmtpUsername"];
var smtpPassword = config["Email:SmtpPassword"];
var enableSsl = bool.Parse(config["Email:EnableSsl"] ?? "true");
await client.ConnectAsync(smtpHost, smtpPort, enableSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.None);
@@ -101,12 +92,12 @@ If you didn't create an account, you can safely ignore this email.
await client.SendAsync(message);
await client.DisconnectAsync(true);
_logger.LogInformation($"Verification email sent to {toEmail}");
logger.LogInformation($"Verification email sent to {toEmail}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to send verification email to {toEmail}");
logger.LogError(ex, $"Failed to send verification email to {toEmail}");
return false;
}
}
@@ -115,13 +106,13 @@ If you didn't create an account, you can safely ignore this email.
{
try
{
var appUrl = _config["AppUrl"] ?? "http://localhost:5000";
var appUrl = config["AppUrl"] ?? "http://localhost:5000";
var resetUrl = $"{appUrl}/ResetPassword?token={resetToken}";
var message = new MimeMessage();
message.From.Add(new MailboxAddress(
_config["Email:FromName"] ?? "Media App",
_config["Email:FromEmail"] ?? "[email protected]"
config["Email:FromName"] ?? "Media App",
config["Email:FromEmail"] ?? "[email protected]"
));
message.To.Add(new MailboxAddress(username, toEmail));
message.Subject = "Password Reset Request";
@@ -184,11 +175,11 @@ If you didn't request a password reset, you can safely ignore this email.
using var client = new SmtpClient();
var smtpHost = _config["Email:SmtpHost"];
var smtpPort = int.Parse(_config["Email:SmtpPort"] ?? "587");
var smtpUsername = _config["Email:SmtpUsername"];
var smtpPassword = _config["Email:SmtpPassword"];
var enableSsl = bool.Parse(_config["Email:EnableSsl"] ?? "true");
var smtpHost = config["Email:SmtpHost"];
var smtpPort = int.Parse(config["Email:SmtpPort"] ?? "587");
var smtpUsername = config["Email:SmtpUsername"];
var smtpPassword = config["Email:SmtpPassword"];
var enableSsl = bool.Parse(config["Email:EnableSsl"] ?? "true");
await client.ConnectAsync(smtpHost, smtpPort, enableSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.None);
@@ -200,12 +191,12 @@ If you didn't request a password reset, you can safely ignore this email.
await client.SendAsync(message);
await client.DisconnectAsync(true);
_logger.LogInformation($"Password reset email sent to {toEmail}");
logger.LogInformation($"Password reset email sent to {toEmail}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to send password reset email to {toEmail}");
logger.LogError(ex, $"Failed to send password reset email to {toEmail}");
return false;
}
}
+45 -54
View File
@@ -2,17 +2,8 @@ using Media.JoshHeaps.Net.Models;
namespace Media.JoshHeaps.Net.Services;
public class FolderService
public class FolderService(DbExecutor db, ILogger<FolderService> logger)
{
private readonly DbExecutor _db;
private readonly ILogger<FolderService> _logger;
public FolderService(DbExecutor db, ILogger<FolderService> logger)
{
_db = db;
_logger = logger;
}
public async Task<Folder?> CreateFolderAsync(long userId, string name, long? parentFolderId = null)
{
try
@@ -20,7 +11,7 @@ public class FolderService
// Validate folder name
if (string.IsNullOrWhiteSpace(name))
{
_logger.LogWarning("Cannot create folder with empty name for user {UserId}", userId);
logger.LogWarning("Cannot create folder with empty name for user {UserId}", userId);
return null;
}
@@ -28,7 +19,7 @@ public class FolderService
var sanitizedName = SanitizeFolderName(name);
if (string.IsNullOrWhiteSpace(sanitizedName))
{
_logger.LogWarning("Folder name became empty after sanitization for user {UserId}", userId);
logger.LogWarning("Folder name became empty after sanitization for user {UserId}", userId);
return null;
}
@@ -38,7 +29,7 @@ public class FolderService
var parentExists = await FolderExistsAsync(parentFolderId.Value, userId);
if (!parentExists)
{
_logger.LogWarning("Parent folder {ParentFolderId} not found for user {UserId}", parentFolderId.Value, userId);
logger.LogWarning("Parent folder {ParentFolderId} not found for user {UserId}", parentFolderId.Value, userId);
return null;
}
}
@@ -48,7 +39,7 @@ public class FolderService
VALUES (@userId, @name, @parentFolderId, @createdAt, @updatedAt)
RETURNING id, user_id, name, parent_folder_id, created_at, updated_at";
var folder = await _db.ExecuteReaderAsync(query, reader =>
var folder = await db.ExecuteReaderAsync(query, reader =>
{
return new Folder
{
@@ -72,7 +63,7 @@ public class FolderService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create folder '{Name}' for user {UserId}", name, userId);
logger.LogError(ex, "Failed to create folder '{Name}' for user {UserId}", name, userId);
return null;
}
}
@@ -99,7 +90,7 @@ public class FolderService
ORDER BY name ASC";
}
var folders = await _db.ExecuteListReaderAsync(query, reader =>
var folders = await db.ExecuteListReaderAsync(query, reader =>
{
return new Folder
{
@@ -116,8 +107,8 @@ public class FolderService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get folders for user {UserId}, parent {ParentFolderId}", userId, parentFolderId);
return new List<Folder>();
logger.LogError(ex, "Failed to get folders for user {UserId}, parent {ParentFolderId}", userId, parentFolderId);
return [];
}
}
@@ -130,7 +121,7 @@ public class FolderService
FROM app.folders
WHERE id = @folderId AND user_id = @userId";
var folder = await _db.ExecuteReaderAsync(query, reader =>
var folder = await db.ExecuteReaderAsync(query, reader =>
{
return new Folder
{
@@ -147,7 +138,7 @@ public class FolderService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get folder {FolderId} for user {UserId}", folderId, userId);
logger.LogError(ex, "Failed to get folder {FolderId} for user {UserId}", folderId, userId);
return null;
}
}
@@ -159,7 +150,7 @@ public class FolderService
var sanitizedName = SanitizeFolderName(newName);
if (string.IsNullOrWhiteSpace(sanitizedName))
{
_logger.LogWarning("Cannot rename folder to empty name");
logger.LogWarning("Cannot rename folder to empty name");
return false;
}
@@ -168,7 +159,7 @@ public class FolderService
SET name = @newName, updated_at = @updatedAt
WHERE id = @folderId AND user_id = @userId";
await _db.ExecuteAsync<object>(query, new
await db.ExecuteAsync<object>(query, new
{
folderId,
userId,
@@ -180,7 +171,7 @@ public class FolderService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to rename folder {FolderId} for user {UserId}", folderId, userId);
logger.LogError(ex, "Failed to rename folder {FolderId} for user {UserId}", folderId, userId);
return false;
}
}
@@ -193,12 +184,12 @@ public class FolderService
var hasSubfoldersQuery = "SELECT COUNT(*) FROM app.folders WHERE parent_folder_id = @folderId";
var hasMediaQuery = "SELECT COUNT(*) FROM app.user_media WHERE folder_id = @folderId";
var subfolderCount = await _db.ExecuteReaderAsync(hasSubfoldersQuery, reader => reader.GetInt64(0), new { folderId });
var mediaCount = await _db.ExecuteReaderAsync(hasMediaQuery, reader => reader.GetInt64(0), new { folderId });
var subfolderCount = await db.ExecuteReaderAsync(hasSubfoldersQuery, reader => reader.GetInt64(0), new { folderId });
var mediaCount = await db.ExecuteReaderAsync(hasMediaQuery, reader => reader.GetInt64(0), new { folderId });
if (!deleteContents && (subfolderCount > 0 || mediaCount > 0))
{
_logger.LogWarning("Cannot delete folder {FolderId} - it contains items", folderId);
logger.LogWarning("Cannot delete folder {FolderId} - it contains items", folderId);
return false;
}
@@ -214,7 +205,7 @@ public class FolderService
SET parent_folder_id = @parentFolderId, updated_at = @updatedAt
WHERE parent_folder_id = @folderId AND user_id = @userId";
await _db.ExecuteAsync<object>(moveSubfoldersQuery, new
await db.ExecuteAsync<object>(moveSubfoldersQuery, new
{
parentFolderId = folder.ParentFolderId,
folderId,
@@ -228,7 +219,7 @@ public class FolderService
SET folder_id = @parentFolderId, updated_at = @updatedAt
WHERE folder_id = @folderId AND user_id = @userId";
await _db.ExecuteAsync<object>(moveMediaQuery, new
await db.ExecuteAsync<object>(moveMediaQuery, new
{
parentFolderId = folder.ParentFolderId,
folderId,
@@ -240,13 +231,13 @@ public class FolderService
// Delete the folder
var deleteQuery = "DELETE FROM app.folders WHERE id = @folderId AND user_id = @userId";
await _db.ExecuteAsync<object>(deleteQuery, new { folderId, userId });
await db.ExecuteAsync<object>(deleteQuery, new { folderId, userId });
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete folder {FolderId} for user {UserId}", folderId, userId);
logger.LogError(ex, "Failed to delete folder {FolderId} for user {UserId}", folderId, userId);
return false;
}
}
@@ -275,7 +266,7 @@ public class FolderService
var isDescendant = await IsFolderDescendantAsync(newParentFolderId.Value, folderId);
if (isDescendant)
{
_logger.LogWarning("Cannot move folder {FolderId} into its descendant {ParentFolderId}", folderId, newParentFolderId.Value);
logger.LogWarning("Cannot move folder {FolderId} into its descendant {ParentFolderId}", folderId, newParentFolderId.Value);
return false;
}
}
@@ -285,7 +276,7 @@ public class FolderService
SET parent_folder_id = @newParentFolderId, updated_at = @updatedAt
WHERE id = @folderId AND user_id = @userId";
await _db.ExecuteAsync<object>(query, new
await db.ExecuteAsync<object>(query, new
{
folderId,
userId,
@@ -297,7 +288,7 @@ public class FolderService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to move folder {FolderId} for user {UserId}", folderId, userId);
logger.LogError(ex, "Failed to move folder {FolderId} for user {UserId}", folderId, userId);
return false;
}
}
@@ -330,8 +321,8 @@ public class FolderService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get folder path for folder {FolderId}", folderId);
return new List<Folder>();
logger.LogError(ex, "Failed to get folder path for folder {FolderId}", folderId);
return [];
}
}
@@ -340,7 +331,7 @@ public class FolderService
try
{
var query = "SELECT COUNT(*) FROM app.folders WHERE id = @folderId AND user_id = @userId";
var count = await _db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId, userId });
var count = await db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId, userId });
return count > 0;
}
catch
@@ -371,7 +362,7 @@ public class FolderService
visited.Add(currentId);
var query = "SELECT parent_folder_id FROM app.folders WHERE id = @folderId";
var parentId = await _db.ExecuteReaderAsync(query, reader => reader.IsDBNull(0) ? (long?)null : reader.GetInt64(0), new { folderId = currentId });
var parentId = await db.ExecuteReaderAsync(query, reader => reader.IsDBNull(0) ? (long?)null : reader.GetInt64(0), new { folderId = currentId });
if (!parentId.HasValue)
{
@@ -385,7 +376,7 @@ public class FolderService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to check if folder {PotentialDescendantId} is descendant of {AncestorId}", potentialDescendantId, ancestorId);
logger.LogError(ex, "Failed to check if folder {PotentialDescendantId} is descendant of {AncestorId}", potentialDescendantId, ancestorId);
return false;
}
}
@@ -421,7 +412,7 @@ public class FolderService
var folder = await GetFolderByIdAsync(folderId, ownerId);
if (folder == null)
{
_logger.LogWarning("Cannot share folder {FolderId} - not found or not owned by {OwnerId}", folderId, ownerId);
logger.LogWarning("Cannot share folder {FolderId} - not found or not owned by {OwnerId}", folderId, ownerId);
return null;
}
@@ -437,7 +428,7 @@ public class FolderService
VALUES (@folderId, @ownerId, @sharedWithUserId, @permissionLevel, @includeSubfolders, @createdAt, @updatedAt)
RETURNING id, folder_id, owner_user_id, shared_with_user_id, permission_level, include_subfolders, created_at, updated_at";
var share = await _db.ExecuteReaderAsync(query, reader =>
var share = await db.ExecuteReaderAsync(query, reader =>
{
return new FolderShare
{
@@ -465,7 +456,7 @@ public class FolderService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to share folder {FolderId} with user {SharedWithUserId}", folderId, sharedWithUserId);
logger.LogError(ex, "Failed to share folder {FolderId} with user {SharedWithUserId}", folderId, sharedWithUserId);
return null;
}
}
@@ -478,12 +469,12 @@ public class FolderService
DELETE FROM app.folder_shares
WHERE folder_id = @folderId AND owner_user_id = @ownerId AND shared_with_user_id = @sharedWithUserId";
await _db.ExecuteAsync<object>(query, new { folderId, ownerId, sharedWithUserId });
await db.ExecuteAsync<object>(query, new { folderId, ownerId, sharedWithUserId });
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to unshare folder {FolderId} from user {SharedWithUserId}", folderId, sharedWithUserId);
logger.LogError(ex, "Failed to unshare folder {FolderId} from user {SharedWithUserId}", folderId, sharedWithUserId);
return false;
}
}
@@ -500,7 +491,7 @@ public class FolderService
WHERE fs.folder_id = @folderId AND fs.owner_user_id = @ownerId
ORDER BY u.username ASC";
var shares = await _db.ExecuteListReaderAsync(query, reader =>
var shares = await db.ExecuteListReaderAsync(query, reader =>
{
return new FolderShareWithUser
{
@@ -519,8 +510,8 @@ public class FolderService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get shares for folder {FolderId}", folderId);
return new List<FolderShareWithUser>();
logger.LogError(ex, "Failed to get shares for folder {FolderId}", folderId);
return [];
}
}
@@ -536,7 +527,7 @@ public class FolderService
WHERE fs.shared_with_user_id = @userId
ORDER BY f.name ASC";
var sharedFolders = await _db.ExecuteListReaderAsync(query, reader =>
var sharedFolders = await db.ExecuteListReaderAsync(query, reader =>
{
return new SharedFolderInfo
{
@@ -553,8 +544,8 @@ public class FolderService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get shared folders for user {UserId}", userId);
return new List<SharedFolderInfo>();
logger.LogError(ex, "Failed to get shared folders for user {UserId}", userId);
return [];
}
}
@@ -567,7 +558,7 @@ public class FolderService
FROM app.folder_shares
WHERE folder_id = @folderId AND shared_with_user_id = @sharedWithUserId";
var share = await _db.ExecuteReaderAsync(query, reader =>
var share = await db.ExecuteReaderAsync(query, reader =>
{
return new FolderShare
{
@@ -596,7 +587,7 @@ public class FolderService
{
// Check if user is the owner
var query = "SELECT COUNT(*) FROM app.folders WHERE id = @folderId AND user_id = @userId";
var isOwner = await _db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId, userId });
var isOwner = await db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId, userId });
if (isOwner > 0) return true;
// Check if folder is shared with user (including parent folders due to subfolders)
@@ -619,12 +610,12 @@ public class FolderService
INNER JOIN folder_hierarchy fh ON fs.folder_id = fh.id
WHERE fs.shared_with_user_id = @userId AND fs.include_subfolders = true";
var hasShare = await _db.ExecuteReaderAsync(shareQuery, reader => reader.GetInt64(0), new { folderId, userId });
var hasShare = await db.ExecuteReaderAsync(shareQuery, reader => reader.GetInt64(0), new { folderId, userId });
return hasShare > 0;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to check folder access for folder {FolderId} and user {UserId}", folderId, userId);
logger.LogError(ex, "Failed to check folder access for folder {FolderId} and user {UserId}", folderId, userId);
return false;
}
}
@@ -634,7 +625,7 @@ public class FolderService
try
{
var query = "SELECT user_id FROM app.folders WHERE id = @folderId";
var ownerId = await _db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId });
var ownerId = await db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId });
return ownerId;
}
catch
@@ -0,0 +1,810 @@
using Media.JoshHeaps.Net.Models;
namespace Media.JoshHeaps.Net.Services;
public class GraphService(DbExecutor db, ILogger<GraphService> logger)
{
#region Graph Operations
public async Task<List<GraphWithCounts>> GetUserGraphsAsync(long userId)
{
try
{
var query = @"
SELECT
g.id, g.user_id, g.name, g.description, g.created_at, g.updated_at,
COUNT(DISTINCT n.id) as node_count,
COUNT(DISTINCT e.id) as edge_count
FROM app.graphs g
LEFT JOIN app.graph_nodes n ON g.id = n.graph_id
LEFT JOIN app.graph_edges e ON g.id = e.graph_id
WHERE g.user_id = @userId
GROUP BY g.id, g.user_id, g.name, g.description, g.created_at, g.updated_at
ORDER BY g.updated_at DESC";
return await db.ExecuteListReaderAsync(query, reader =>
{
return new GraphWithCounts
{
Id = reader.GetInt64(0),
UserId = reader.GetInt64(1),
Name = reader.GetString(2),
Description = reader.IsDBNull(3) ? null : reader.GetString(3),
CreatedAt = reader.GetDateTime(4),
UpdatedAt = reader.GetDateTime(5),
NodeCount = reader.GetInt32(6),
EdgeCount = reader.GetInt32(7)
};
}, new { userId });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get graphs for user {UserId}", userId);
return [];
}
}
public async Task<Graph?> GetGraphByIdAsync(long graphId, long userId)
{
try
{
var query = @"
SELECT id, user_id, name, description, created_at, updated_at
FROM app.graphs
WHERE id = @graphId AND user_id = @userId";
return await db.ExecuteReaderAsync(query, reader =>
{
return new Graph
{
Id = reader.GetInt64(0),
UserId = reader.GetInt64(1),
Name = reader.GetString(2),
Description = reader.IsDBNull(3) ? null : reader.GetString(3),
CreatedAt = reader.GetDateTime(4),
UpdatedAt = reader.GetDateTime(5)
};
}, new { graphId, userId });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get graph {GraphId} for user {UserId}", graphId, userId);
return null;
}
}
public async Task<Graph?> CreateGraphAsync(long userId, string name, string? description = null)
{
try
{
if (string.IsNullOrWhiteSpace(name))
{
logger.LogWarning("Cannot create graph with empty name for user {UserId}", userId);
return null;
}
var query = @"
INSERT INTO app.graphs (user_id, name, description, created_at, updated_at)
VALUES (@userId, @name, @description, @createdAt, @updatedAt)
RETURNING id, user_id, name, description, created_at, updated_at";
return await db.ExecuteReaderAsync(query, reader =>
{
return new Graph
{
Id = reader.GetInt64(0),
UserId = reader.GetInt64(1),
Name = reader.GetString(2),
Description = reader.IsDBNull(3) ? null : reader.GetString(3),
CreatedAt = reader.GetDateTime(4),
UpdatedAt = reader.GetDateTime(5)
};
}, new
{
userId,
name = name.Trim(),
description,
createdAt = DateTime.UtcNow,
updatedAt = DateTime.UtcNow
});
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create graph '{Name}' for user {UserId}", name, userId);
return null;
}
}
public async Task<bool> UpdateGraphAsync(long graphId, long userId, string name, string? description)
{
try
{
if (string.IsNullOrWhiteSpace(name))
{
logger.LogWarning("Cannot update graph with empty name");
return false;
}
var query = @"
UPDATE app.graphs
SET name = @name, description = @description, updated_at = @updatedAt
WHERE id = @graphId AND user_id = @userId";
var rowsAffected = await db.ExecuteNonQueryAsync(query, new
{
graphId,
userId,
name = name.Trim(),
description,
updatedAt = DateTime.UtcNow
});
return rowsAffected > 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update graph {GraphId}", graphId);
return false;
}
}
public async Task<bool> DeleteGraphAsync(long graphId, long userId)
{
try
{
var query = "DELETE FROM app.graphs WHERE id = @graphId AND user_id = @userId";
var rowsAffected = await db.ExecuteNonQueryAsync(query, new { graphId, userId });
return rowsAffected > 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete graph {GraphId}", graphId);
return false;
}
}
public async Task<Graph?> CloneGraphAsync(long graphId, long userId, string newName)
{
try
{
// Get original graph
var originalGraph = await GetGraphByIdAsync(graphId, userId);
if (originalGraph == null) return null;
// Create new graph
var newGraph = await CreateGraphAsync(userId, newName, originalGraph.Description);
if (newGraph == null) return null;
// Get all nodes from original graph
var nodes = await GetGraphNodesAsync(graphId, userId);
var nodeMapping = new Dictionary<long, long>(); // old ID -> new ID
// Clone nodes
foreach (var node in nodes)
{
var newNode = await CreateNodeAsync(newGraph.Id, userId, node.Label, node.Notes);
if (newNode != null)
{
nodeMapping[node.Id] = newNode.Id;
}
}
// Get all edges from original graph
var edges = await GetGraphEdgesAsync(graphId, userId);
// Clone edges with new node IDs
foreach (var edge in edges)
{
if (nodeMapping.ContainsKey(edge.SourceNodeId) && nodeMapping.ContainsKey(edge.TargetNodeId))
{
await CreateEdgeAsync(newGraph.Id, userId, nodeMapping[edge.SourceNodeId], nodeMapping[edge.TargetNodeId]);
}
}
return newGraph;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to clone graph {GraphId}", graphId);
return null;
}
}
#endregion
#region Node Operations
public async Task<List<GraphNodeWithConnections>> GetGraphNodesAsync(long graphId, long userId)
{
try
{
// Verify user owns the graph
var graph = await GetGraphByIdAsync(graphId, userId);
if (graph == null) return [];
var query = @"
SELECT
n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at,
COUNT(DISTINCT e1.id) + COUNT(DISTINCT e2.id) as connection_count
FROM app.graph_nodes n
LEFT JOIN app.graph_edges e1 ON n.id = e1.source_node_id
LEFT JOIN app.graph_edges e2 ON n.id = e2.target_node_id
WHERE n.graph_id = @graphId
GROUP BY n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at
ORDER BY n.label";
return await db.ExecuteListReaderAsync(query, reader =>
{
return new GraphNodeWithConnections
{
Id = reader.GetInt64(0),
GraphId = reader.GetInt64(1),
Label = reader.GetString(2),
Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
CreatedAt = reader.GetDateTime(4),
UpdatedAt = reader.GetDateTime(5),
ConnectionCount = reader.GetInt32(6)
};
}, new { graphId });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get nodes for graph {GraphId}", graphId);
return [];
}
}
public async Task<GraphNode?> CreateNodeAsync(long graphId, long userId, string label, string? notes = null)
{
try
{
// Verify user owns the graph
var graph = await GetGraphByIdAsync(graphId, userId);
if (graph == null) return null;
if (string.IsNullOrWhiteSpace(label))
{
logger.LogWarning("Cannot create node with empty label");
return null;
}
var query = @"
INSERT INTO app.graph_nodes (graph_id, label, notes, created_at, updated_at)
VALUES (@graphId, @label, @notes, @createdAt, @updatedAt)
RETURNING id, graph_id, label, notes, created_at, updated_at";
var node = await db.ExecuteReaderAsync(query, reader =>
{
return new GraphNode
{
Id = reader.GetInt64(0),
GraphId = reader.GetInt64(1),
Label = reader.GetString(2),
Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
CreatedAt = reader.GetDateTime(4),
UpdatedAt = reader.GetDateTime(5)
};
}, new
{
graphId,
label = label.Trim(),
notes,
createdAt = DateTime.UtcNow,
updatedAt = DateTime.UtcNow
});
// Update graph's updated_at
await TouchGraphAsync(graphId);
return node;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create node in graph {GraphId}", graphId);
return null;
}
}
public async Task<bool> UpdateNodeAsync(long nodeId, long userId, string label, string? notes)
{
try
{
if (string.IsNullOrWhiteSpace(label))
{
logger.LogWarning("Cannot update node with empty label");
return false;
}
// Get node to verify ownership through graph
var node = await GetNodeByIdAsync(nodeId);
if (node == null) return false;
var graph = await GetGraphByIdAsync(node.GraphId, userId);
if (graph == null) return false;
var query = @"
UPDATE app.graph_nodes
SET label = @label, notes = @notes, updated_at = @updatedAt
WHERE id = @nodeId";
var rowsAffected = await db.ExecuteNonQueryAsync(query, new
{
nodeId,
label = label.Trim(),
notes,
updatedAt = DateTime.UtcNow
});
if (rowsAffected > 0)
{
await TouchGraphAsync(node.GraphId);
}
return rowsAffected > 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update node {NodeId}", nodeId);
return false;
}
}
public async Task<bool> DeleteNodeAsync(long nodeId, long userId)
{
try
{
// Get node to verify ownership and get graph ID
var node = await GetNodeByIdAsync(nodeId);
if (node == null) return false;
var graph = await GetGraphByIdAsync(node.GraphId, userId);
if (graph == null) return false;
var query = "DELETE FROM app.graph_nodes WHERE id = @nodeId";
var rowsAffected = await db.ExecuteNonQueryAsync(query, new { nodeId });
if (rowsAffected > 0)
{
await TouchGraphAsync(node.GraphId);
}
return rowsAffected > 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete node {NodeId}", nodeId);
return false;
}
}
public async Task<List<GraphNodeWithConnections>> SearchNodesAsync(long graphId, long userId, string searchTerm)
{
try
{
// Verify user owns the graph
var graph = await GetGraphByIdAsync(graphId, userId);
if (graph == null) return [];
var query = @"
SELECT
n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at,
COUNT(DISTINCT e1.id) + COUNT(DISTINCT e2.id) as connection_count
FROM app.graph_nodes n
LEFT JOIN app.graph_edges e1 ON n.id = e1.source_node_id
LEFT JOIN app.graph_edges e2 ON n.id = e2.target_node_id
WHERE n.graph_id = @graphId
AND (LOWER(n.label) LIKE @searchPattern OR LOWER(n.notes) LIKE @searchPattern)
GROUP BY n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at
ORDER BY n.label";
var searchPattern = $"%{searchTerm.ToLower()}%";
return await db.ExecuteListReaderAsync(query, reader =>
{
return new GraphNodeWithConnections
{
Id = reader.GetInt64(0),
GraphId = reader.GetInt64(1),
Label = reader.GetString(2),
Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
CreatedAt = reader.GetDateTime(4),
UpdatedAt = reader.GetDateTime(5),
ConnectionCount = reader.GetInt32(6)
};
}, new { graphId, searchPattern });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to search nodes in graph {GraphId}", graphId);
return [];
}
}
private async Task<GraphNode?> GetNodeByIdAsync(long nodeId)
{
try
{
var query = @"
SELECT id, graph_id, label, notes, created_at, updated_at
FROM app.graph_nodes
WHERE id = @nodeId";
return await db.ExecuteReaderAsync(query, reader =>
{
return new GraphNode
{
Id = reader.GetInt64(0),
GraphId = reader.GetInt64(1),
Label = reader.GetString(2),
Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
CreatedAt = reader.GetDateTime(4),
UpdatedAt = reader.GetDateTime(5)
};
}, new { nodeId });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get node {NodeId}", nodeId);
return null;
}
}
#endregion
#region Edge Operations
public async Task<List<GraphEdge>> GetGraphEdgesAsync(long graphId, long userId)
{
try
{
// Verify user owns the graph
var graph = await GetGraphByIdAsync(graphId, userId);
if (graph == null) return [];
var query = @"
SELECT id, graph_id, source_node_id, target_node_id, created_at
FROM app.graph_edges
WHERE graph_id = @graphId
ORDER BY created_at";
return await db.ExecuteListReaderAsync(query, reader =>
{
return new GraphEdge
{
Id = reader.GetInt64(0),
GraphId = reader.GetInt64(1),
SourceNodeId = reader.GetInt64(2),
TargetNodeId = reader.GetInt64(3),
CreatedAt = reader.GetDateTime(4)
};
}, new { graphId });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get edges for graph {GraphId}", graphId);
return [];
}
}
public async Task<GraphEdge?> CreateEdgeAsync(long graphId, long userId, long sourceNodeId, long targetNodeId)
{
try
{
// Verify user owns the graph
var graph = await GetGraphByIdAsync(graphId, userId);
if (graph == null) return null;
// Verify both nodes belong to this graph
var sourceNode = await GetNodeByIdAsync(sourceNodeId);
var targetNode = await GetNodeByIdAsync(targetNodeId);
if (sourceNode == null || targetNode == null ||
sourceNode.GraphId != graphId || targetNode.GraphId != graphId)
{
logger.LogWarning("Invalid nodes for edge creation");
return null;
}
var query = @"
INSERT INTO app.graph_edges (graph_id, source_node_id, target_node_id, created_at)
VALUES (@graphId, @sourceNodeId, @targetNodeId, @createdAt)
RETURNING id, graph_id, source_node_id, target_node_id, created_at";
var edge = await db.ExecuteReaderAsync(query, reader =>
{
return new GraphEdge
{
Id = reader.GetInt64(0),
GraphId = reader.GetInt64(1),
SourceNodeId = reader.GetInt64(2),
TargetNodeId = reader.GetInt64(3),
CreatedAt = reader.GetDateTime(4)
};
}, new
{
graphId,
sourceNodeId,
targetNodeId,
createdAt = DateTime.UtcNow
});
await TouchGraphAsync(graphId);
return edge;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create edge in graph {GraphId}", graphId);
return null;
}
}
public async Task<bool> DeleteEdgeAsync(long edgeId, long userId)
{
try
{
// Get edge to verify ownership through graph
var edge = await GetEdgeByIdAsync(edgeId);
if (edge == null) return false;
var graph = await GetGraphByIdAsync(edge.GraphId, userId);
if (graph == null) return false;
var query = "DELETE FROM app.graph_edges WHERE id = @edgeId";
var rowsAffected = await db.ExecuteNonQueryAsync(query, new { edgeId });
if (rowsAffected > 0)
{
await TouchGraphAsync(edge.GraphId);
}
return rowsAffected > 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete edge {EdgeId}", edgeId);
return false;
}
}
private async Task<GraphEdge?> GetEdgeByIdAsync(long edgeId)
{
try
{
var query = @"
SELECT id, graph_id, source_node_id, target_node_id, created_at
FROM app.graph_edges
WHERE id = @edgeId";
return await db.ExecuteReaderAsync(query, reader =>
{
return new GraphEdge
{
Id = reader.GetInt64(0),
GraphId = reader.GetInt64(1),
SourceNodeId = reader.GetInt64(2),
TargetNodeId = reader.GetInt64(3),
CreatedAt = reader.GetDateTime(4)
};
}, new { edgeId });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get edge {EdgeId}", edgeId);
return null;
}
}
#endregion
#region Filter Operations
public async Task<List<GraphNodeWithConnections>> FilterConnectsToAsync(long graphId, long userId, long targetNodeId)
{
try
{
// Verify user owns the graph
var graph = await GetGraphByIdAsync(graphId, userId);
if (graph == null) return [];
var query = @"
SELECT
n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at,
COUNT(DISTINCT e1.id) + COUNT(DISTINCT e2.id) as connection_count
FROM app.graph_nodes n
LEFT JOIN app.graph_edges e1 ON n.id = e1.source_node_id
LEFT JOIN app.graph_edges e2 ON n.id = e2.target_node_id
WHERE n.graph_id = @graphId
AND EXISTS (
SELECT 1 FROM app.graph_edges e
WHERE e.graph_id = @graphId
AND (
(e.source_node_id = n.id AND e.target_node_id = @targetNodeId)
OR (e.target_node_id = n.id AND e.source_node_id = @targetNodeId)
)
)
GROUP BY n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at
ORDER BY n.label";
return await db.ExecuteListReaderAsync(query, reader =>
{
return new GraphNodeWithConnections
{
Id = reader.GetInt64(0),
GraphId = reader.GetInt64(1),
Label = reader.GetString(2),
Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
CreatedAt = reader.GetDateTime(4),
UpdatedAt = reader.GetDateTime(5),
ConnectionCount = reader.GetInt32(6)
};
}, new { graphId, targetNodeId });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to filter nodes connecting to {NodeId}", targetNodeId);
return [];
}
}
public async Task<List<GraphNodeWithConnections>> FilterDoesNotConnectToAsync(long graphId, long userId, long targetNodeId)
{
try
{
// Verify user owns the graph
var graph = await GetGraphByIdAsync(graphId, userId);
if (graph == null) return [];
var query = @"
SELECT
n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at,
COUNT(DISTINCT e1.id) + COUNT(DISTINCT e2.id) as connection_count
FROM app.graph_nodes n
LEFT JOIN app.graph_edges e1 ON n.id = e1.source_node_id
LEFT JOIN app.graph_edges e2 ON n.id = e2.target_node_id
WHERE n.graph_id = @graphId
AND n.id != @targetNodeId
AND NOT EXISTS (
SELECT 1 FROM app.graph_edges e
WHERE e.graph_id = @graphId
AND (
(e.source_node_id = n.id AND e.target_node_id = @targetNodeId)
OR (e.target_node_id = n.id AND e.source_node_id = @targetNodeId)
)
)
GROUP BY n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at
ORDER BY n.label";
return await db.ExecuteListReaderAsync(query, reader =>
{
return new GraphNodeWithConnections
{
Id = reader.GetInt64(0),
GraphId = reader.GetInt64(1),
Label = reader.GetString(2),
Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
CreatedAt = reader.GetDateTime(4),
UpdatedAt = reader.GetDateTime(5),
ConnectionCount = reader.GetInt32(6)
};
}, new { graphId, targetNodeId });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to filter nodes not connecting to {NodeId}", targetNodeId);
return [];
}
}
public async Task<List<GraphNodeWithConnections>> FilterConnectsTwoDegreesAsync(long graphId, long userId, long targetNodeId)
{
try
{
// Verify user owns the graph
var graph = await GetGraphByIdAsync(graphId, userId);
if (graph == null) return [];
// Find nodes that connect to nodes that connect to the target (bidirectional)
var query = @"
SELECT DISTINCT
n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at,
COUNT(DISTINCT e1.id) + COUNT(DISTINCT e2.id) as connection_count
FROM app.graph_nodes n
LEFT JOIN app.graph_edges e1 ON n.id = e1.source_node_id
LEFT JOIN app.graph_edges e2 ON n.id = e2.target_node_id
WHERE n.graph_id = @graphId
AND n.id != @targetNodeId
AND EXISTS (
SELECT 1 FROM app.graph_edges e_first, app.graph_edges e_second
WHERE e_first.graph_id = @graphId
AND e_second.graph_id = @graphId
-- n connects to intermediate node (bidirectional)
AND (
(e_first.source_node_id = n.id AND e_second.source_node_id = e_first.target_node_id)
OR (e_first.source_node_id = n.id AND e_second.target_node_id = e_first.target_node_id)
OR (e_first.target_node_id = n.id AND e_second.source_node_id = e_first.source_node_id)
OR (e_first.target_node_id = n.id AND e_second.target_node_id = e_first.source_node_id)
)
-- intermediate node connects to target (bidirectional)
AND (
e_second.source_node_id = @targetNodeId
OR e_second.target_node_id = @targetNodeId
)
-- Exclude direct connections
AND NOT EXISTS (
SELECT 1 FROM app.graph_edges e_direct
WHERE e_direct.graph_id = @graphId
AND (
(e_direct.source_node_id = n.id AND e_direct.target_node_id = @targetNodeId)
OR (e_direct.target_node_id = n.id AND e_direct.source_node_id = @targetNodeId)
)
)
)
GROUP BY n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at
ORDER BY n.label";
return await db.ExecuteListReaderAsync(query, reader =>
{
return new GraphNodeWithConnections
{
Id = reader.GetInt64(0),
GraphId = reader.GetInt64(1),
Label = reader.GetString(2),
Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
CreatedAt = reader.GetDateTime(4),
UpdatedAt = reader.GetDateTime(5),
ConnectionCount = reader.GetInt32(6)
};
}, new { graphId, targetNodeId });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to filter nodes with two-degree connections to {NodeId}", targetNodeId);
return [];
}
}
#endregion
#region Helper Methods
private async Task TouchGraphAsync(long graphId)
{
try
{
var query = "UPDATE app.graphs SET updated_at = @updatedAt WHERE id = @graphId";
await db.ExecuteNonQueryAsync(query, new { graphId, updatedAt = DateTime.UtcNow });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to touch graph {GraphId}", graphId);
}
}
public async Task<GraphData> GetGraphDataAsync(long graphId, long userId)
{
try
{
var graph = await GetGraphByIdAsync(graphId, userId);
if (graph == null) return new GraphData();
var nodes = await GetGraphNodesAsync(graphId, userId);
var edges = await GetGraphEdgesAsync(graphId, userId);
return new GraphData
{
Graph = graph,
Nodes = nodes,
Edges = edges
};
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get graph data for {GraphId}", graphId);
return new GraphData();
}
}
#endregion
}
+27 -42
View File
@@ -2,23 +2,8 @@ using Media.JoshHeaps.Net.Models;
namespace Media.JoshHeaps.Net.Services;
public class MediaService
public class MediaService(DbExecutor db, IWebHostEnvironment environment, EncryptionService encryption, ILogger<MediaService> logger, FolderService folderService)
{
private readonly DbExecutor _db;
private readonly IWebHostEnvironment _environment;
private readonly EncryptionService _encryption;
private readonly ILogger<MediaService> _logger;
private readonly FolderService _folderService;
public MediaService(DbExecutor db, IWebHostEnvironment environment, EncryptionService encryption, ILogger<MediaService> logger, FolderService folderService)
{
_db = db;
_environment = environment;
_encryption = encryption;
_logger = logger;
_folderService = folderService;
}
public async Task<UserMedia?> SaveMediaAsync(long userId, IFormFile file, string? description = null, long? folderId = null)
{
string? tempFilePath = null;
@@ -31,7 +16,7 @@ public class MediaService
var uniqueFileName = $"{Guid.NewGuid()}{fileExtension}.enc"; // .enc for encrypted
// Store outside wwwroot in App_Data folder
var mediaFolder = Path.Combine(_environment.ContentRootPath, "App_Data", "media", userId.ToString());
var mediaFolder = Path.Combine(environment.ContentRootPath, "App_Data", "media", userId.ToString());
Directory.CreateDirectory(mediaFolder);
tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
@@ -57,12 +42,12 @@ public class MediaService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read image dimensions for {FileName}", file.FileName);
logger.LogWarning(ex, "Failed to read image dimensions for {FileName}", file.FileName);
}
}
// Encrypt and save
await _encryption.EncryptFileAsync(tempFilePath, encryptedFilePath);
await encryption.EncryptFileAsync(tempFilePath, encryptedFilePath);
// Delete temp file
File.Delete(tempFilePath);
@@ -77,7 +62,7 @@ public class MediaService
VALUES (@userId, @fileName, @filePath, @fileSize, @mimeType, @width, @height, @description, @isEncrypted, @folderId, @createdAt, @updatedAt)
RETURNING id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, folder_id, created_at, updated_at";
var media = await _db.ExecuteReaderAsync(query, reader =>
var media = await db.ExecuteReaderAsync(query, reader =>
{
return new UserMedia
{
@@ -115,7 +100,7 @@ public class MediaService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save media for user {UserId}", userId);
logger.LogError(ex, "Failed to save media for user {UserId}", userId);
// Cleanup on error
if (tempFilePath != null && File.Exists(tempFilePath))
@@ -139,14 +124,14 @@ public class MediaService
var effectiveUserId = userId;
if (requestingUserId.HasValue && folderId.HasValue)
{
var hasAccess = await _folderService.HasFolderAccessAsync(folderId.Value, requestingUserId.Value);
var hasAccess = await folderService.HasFolderAccessAsync(folderId.Value, requestingUserId.Value);
if (!hasAccess)
{
_logger.LogWarning("User {RequestingUserId} does not have access to folder {FolderId}", requestingUserId.Value, folderId.Value);
return new List<UserMedia>();
logger.LogWarning("User {RequestingUserId} does not have access to folder {FolderId}", requestingUserId.Value, folderId.Value);
return [];
}
// Get the actual owner of the folder
var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId.Value);
var ownerId = await folderService.GetFolderOwnerIdAsync(folderId.Value);
if (ownerId.HasValue)
{
effectiveUserId = ownerId.Value;
@@ -173,7 +158,7 @@ public class MediaService
OFFSET @offset LIMIT @limit";
}
var mediaList = await _db.ExecuteListReaderAsync(query, reader =>
var mediaList = await db.ExecuteListReaderAsync(query, reader =>
{
return new UserMedia
{
@@ -197,8 +182,8 @@ public class MediaService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get media for user {UserId}", userId);
return new List<UserMedia>();
logger.LogError(ex, "Failed to get media for user {UserId}", userId);
return [];
}
}
@@ -212,7 +197,7 @@ public class MediaService
FROM app.user_media
WHERE id = @mediaId";
var media = await _db.ExecuteReaderAsync(query, reader =>
var media = await db.ExecuteReaderAsync(query, reader =>
{
return new UserMedia
{
@@ -244,7 +229,7 @@ public class MediaService
}
// Check if user has access via shared folder
if (media.FolderId.HasValue && await _folderService.HasFolderAccessAsync(media.FolderId.Value, userId))
if (media.FolderId.HasValue && await folderService.HasFolderAccessAsync(media.FolderId.Value, userId))
{
return media;
}
@@ -254,7 +239,7 @@ public class MediaService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get media {MediaId} for user {UserId}", mediaId, userId);
logger.LogError(ex, "Failed to get media {MediaId} for user {UserId}", mediaId, userId);
return null;
}
}
@@ -266,21 +251,21 @@ public class MediaService
var media = await GetMediaByIdAsync(mediaId, userId);
if (media == null)
{
_logger.LogWarning("Media {MediaId} not found or user {UserId} doesn't have access", mediaId, userId);
logger.LogWarning("Media {MediaId} not found or user {UserId} doesn't have access", mediaId, userId);
return null;
}
var fullPath = Path.Combine(_environment.ContentRootPath, media.FilePath);
var fullPath = Path.Combine(environment.ContentRootPath, media.FilePath);
if (!File.Exists(fullPath))
{
_logger.LogError("Media file not found at {FilePath}", fullPath);
logger.LogError("Media file not found at {FilePath}", fullPath);
return null;
}
if (media.IsEncrypted)
{
return await _encryption.DecryptFileAsync(fullPath);
return await encryption.DecryptFileAsync(fullPath);
}
else
{
@@ -289,7 +274,7 @@ public class MediaService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get decrypted media data for {MediaId}", mediaId);
logger.LogError(ex, "Failed to get decrypted media data for {MediaId}", mediaId);
return null;
}
}
@@ -300,16 +285,16 @@ public class MediaService
{
// Get media info first
var query = "SELECT file_path FROM app.user_media WHERE id = @mediaId AND user_id = @userId";
var filePath = await _db.ExecuteReaderAsync(query, reader => reader.GetString(0), new { mediaId, userId });
var filePath = await db.ExecuteReaderAsync(query, reader => reader.GetString(0), new { mediaId, userId });
if (filePath == null) return false;
// Delete from database
var deleteQuery = "DELETE FROM app.user_media WHERE id = @mediaId AND user_id = @userId";
await _db.ExecuteAsync<object>(deleteQuery, new { mediaId, userId });
await db.ExecuteAsync<object>(deleteQuery, new { mediaId, userId });
// Delete physical file
var physicalPath = Path.Combine(_environment.ContentRootPath, filePath);
var physicalPath = Path.Combine(environment.ContentRootPath, filePath);
if (File.Exists(physicalPath))
{
File.Delete(physicalPath);
@@ -319,7 +304,7 @@ public class MediaService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete media {MediaId} for user {UserId}", mediaId, userId);
logger.LogError(ex, "Failed to delete media {MediaId} for user {UserId}", mediaId, userId);
return false;
}
}
@@ -333,7 +318,7 @@ public class MediaService
SET folder_id = @folderId, updated_at = @updatedAt
WHERE id = @mediaId AND user_id = @userId";
await _db.ExecuteAsync<object>(query, new
await db.ExecuteAsync<object>(query, new
{
mediaId,
userId,
@@ -345,7 +330,7 @@ public class MediaService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to move media {MediaId} to folder {FolderId} for user {UserId}", mediaId, folderId, userId);
logger.LogError(ex, "Failed to move media {MediaId} to folder {FolderId} for user {UserId}", mediaId, folderId, userId);
return false;
}
}
+6 -13
View File
@@ -2,15 +2,8 @@ using Media.JoshHeaps.Net.Models;
namespace Media.JoshHeaps.Net.Services;
public class UserService
public class UserService(DbExecutor db)
{
private readonly DbExecutor _db;
public UserService(DbExecutor db)
{
_db = db;
}
public async Task<UserDashboard?> GetUserDashboardAsync(long userId)
{
try
@@ -23,7 +16,7 @@ public class UserService
LEFT JOIN app.user_profiles p ON u.id = p.user_id
WHERE u.id = @userId";
var dashboard = await _db.ExecuteReaderAsync(query, reader =>
var dashboard = await db.ExecuteReaderAsync(query, reader =>
{
return new UserDashboard
{
@@ -62,7 +55,7 @@ public class UserService
FROM app.user_profiles
WHERE user_id = @userId";
var profile = await _db.ExecuteReaderAsync(query, reader =>
var profile = await db.ExecuteReaderAsync(query, reader =>
{
return new UserProfile
{
@@ -87,7 +80,7 @@ public class UserService
{
try
{
await _db.ExecuteAsync<object>(
await db.ExecuteAsync<object>(
@"UPDATE app.user_profiles
SET bio = @bio, avatar_url = @avatarUrl, location = @location,
website = @website, updated_at = @updatedAt
@@ -116,7 +109,7 @@ public class UserService
ORDER BY username ASC
LIMIT 10";
var users = await _db.ExecuteListReaderAsync(searchQuery, reader =>
var users = await db.ExecuteListReaderAsync(searchQuery, reader =>
{
return new UserSearchResult
{
@@ -130,7 +123,7 @@ public class UserService
}
catch (Exception)
{
return new List<UserSearchResult>();
return [];
}
}
}
@@ -0,0 +1,188 @@
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
}
.welcome-section {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
color: var(--text-primary);
padding: 32px 40px;
border-radius: 8px;
margin-bottom: 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.welcome-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-button {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s ease;
flex-shrink: 0;
}
.back-button:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
transform: translateX(-2px);
}
.back-button svg {
width: 20px;
height: 20px;
}
.welcome-section h1 {
margin: 0;
font-size: 28px;
font-weight: 600;
letter-spacing: -0.5px;
}
.welcome-section p {
margin: 0;
opacity: 0.8;
font-size: 16px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 24px;
transition: border-color 0.2s ease;
}
.card:hover {
border-color: var(--border-secondary);
}
.card h2 {
margin: 0 0 20px 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
border-bottom: 1px solid var(--border-primary);
padding-bottom: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--border-secondary);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-weight: 500;
color: var(--text-secondary);
}
.info-value {
color: var(--text-primary);
font-weight: 500;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.status-verified {
background: rgba(63, 185, 80, 0.15);
color: var(--success);
}
.status-unverified {
background: rgba(187, 128, 9, 0.15);
color: #d29922;
}
.quick-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-content: space-evenly;
}
.btn {
padding: 8px 16px;
border-radius: 6px;
text-decoration: none;
font-weight: 500;
font-size: 14px;
display: inline-block;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.btn-primary {
background: var(--accent-primary);
color: var(--bg-primary);
border-color: var(--accent-primary);
}
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--border-primary);
}
.btn-secondary:hover {
background: var(--bg-hover);
border-color: var(--border-secondary);
}
.btn-danger {
background: transparent;
color: var(--danger);
border-color: var(--danger);
}
.btn-danger:hover {
background: var(--danger);
color: var(--text-primary);
}
.empty-state {
text-align: center;
color: var(--text-secondary);
padding: 20px;
font-style: italic;
}
+344
View File
@@ -0,0 +1,344 @@
/* Landing Page Styles */
.landing-wrapper {
min-height: 100vh;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.landing-container {
width: 100%;
max-width: 1000px;
margin: 0 auto;
}
/* Header Section */
.landing-header {
text-align: center;
margin-bottom: 4rem;
}
.logo-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.logo-circle {
width: 72px;
height: 72px;
background: linear-gradient(135deg, var(--accent-primary), #3d8bdb);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
box-shadow: 0 4px 12px rgba(88, 166, 255, 0.3);
}
.logo-circle svg {
width: 36px;
height: 36px;
color: white;
}
.landing-header h1 {
font-size: 2.75rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.02em;
}
.landing-header .subtitle {
font-size: 1.125rem;
color: var(--text-secondary);
margin: 0;
font-weight: 400;
}
/* Cards Grid */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
/* Card Styles */
.landing-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 16px;
padding: 0;
text-decoration: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
min-height: 280px;
}
.landing-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg,
rgba(88, 166, 255, 0.05) 0%,
rgba(88, 166, 255, 0) 50%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.landing-card:hover {
transform: translateY(-4px);
border-color: var(--accent-primary);
box-shadow:
0 12px 24px -10px rgba(88, 166, 255, 0.2),
0 0 0 1px var(--accent-primary);
}
.landing-card:hover::before {
opacity: 1;
}
.landing-card:active {
transform: translateY(-2px);
}
/* Card Content */
.card-content {
padding: 2rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.card-icon-wrapper {
display: flex;
align-items: center;
justify-content: flex-start;
}
.card-icon {
width: 56px;
height: 56px;
background: linear-gradient(135deg,
rgba(88, 166, 255, 0.15),
rgba(88, 166, 255, 0.05));
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-primary);
transition: all 0.3s ease;
}
.landing-card:hover .card-icon {
transform: scale(1.05);
background: linear-gradient(135deg,
rgba(88, 166, 255, 0.2),
rgba(88, 166, 255, 0.1));
}
.card-icon svg {
width: 28px;
height: 28px;
}
.card-text {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.landing-card h2 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.01em;
}
.card-description {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
/* Card Footer */
.card-footer {
padding: 1.25rem 2rem;
border-top: 1px solid var(--border-primary);
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(88, 166, 255, 0.02);
transition: background 0.3s ease;
}
.landing-card:hover .card-footer {
background: rgba(88, 166, 255, 0.05);
}
.card-link-text {
font-size: 0.875rem;
font-weight: 500;
color: var(--accent-primary);
transition: color 0.2s ease;
}
.landing-card:hover .card-link-text {
color: var(--accent-hover);
}
.card-arrow {
width: 20px;
height: 20px;
color: var(--accent-primary);
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.landing-card:hover .card-arrow {
transform: translateX(4px);
color: var(--accent-hover);
}
.card-arrow svg {
width: 100%;
height: 100%;
}
/* Footer */
.landing-footer {
text-align: center;
padding-top: 2rem;
border-top: 1px solid var(--border-primary);
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.footer-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
transition: color 0.2s ease;
}
.footer-link:hover {
color: var(--accent-primary);
}
.footer-separator {
color: var(--border-primary);
font-size: 0.875rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.landing-wrapper {
padding: 1.5rem;
}
.landing-header {
margin-bottom: 3rem;
}
.logo-circle {
width: 64px;
height: 64px;
}
.logo-circle svg {
width: 32px;
height: 32px;
}
.landing-header h1 {
font-size: 2.25rem;
}
.landing-header .subtitle {
font-size: 1rem;
}
.cards-grid {
grid-template-columns: 1fr;
gap: 1.25rem;
margin-bottom: 2.5rem;
}
.landing-card {
min-height: 260px;
}
.card-content {
padding: 1.75rem;
}
.card-icon {
width: 48px;
height: 48px;
}
.card-icon svg {
width: 24px;
height: 24px;
}
.landing-card h2 {
font-size: 1.375rem;
}
.card-footer {
padding: 1rem 1.75rem;
}
}
@media (max-width: 480px) {
.landing-wrapper {
padding: 1rem;
}
.landing-header {
margin-bottom: 2.5rem;
}
.logo-circle {
width: 56px;
height: 56px;
}
.logo-circle svg {
width: 28px;
height: 28px;
}
.landing-header h1 {
font-size: 1.875rem;
}
.card-content {
padding: 1.5rem;
}
.card-footer {
padding: 1rem 1.5rem;
}
}
@@ -0,0 +1,528 @@
/* Network Graph Page Styles */
/* Graph Manager Section */
.graph-manager-section {
margin-bottom: 32px;
}
.graph-selector-card {
max-width: 100%;
}
.graphs-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 20px;
}
.graph-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: var(--bg-tertiary);
border: 2px solid var(--border-primary);
border-radius: 8px;
transition: all 0.2s ease;
cursor: pointer;
}
.graph-item:hover {
border-color: var(--border-secondary);
background: var(--bg-hover);
}
.graph-item.active {
border-color: var(--accent-primary);
background: rgba(88, 166, 255, 0.05);
}
.graph-info {
flex: 1;
}
.graph-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.graph-stats {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--text-secondary);
}
.graph-stats .stat {
display: flex;
align-items: center;
gap: 4px;
}
.graph-actions {
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.2s ease;
}
.graph-item:hover .graph-actions {
opacity: 1;
}
.btn-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-icon:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.btn-icon.danger:hover {
border-color: var(--danger);
color: var(--danger);
}
/* Graph Workspace */
.graph-workspace {
display: flex;
flex-direction: column;
gap: 16px;
}
.graph-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
flex-wrap: wrap;
gap: 16px;
}
.toolbar-section {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.search-input {
padding: 8px 12px;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 14px;
min-width: 200px;
}
.search-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.filter-select {
padding: 8px 12px;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: var(--accent-primary);
}
/* Canvas */
.graph-canvas-container {
position: relative;
height: 600px;
padding: 0;
overflow: hidden;
}
#graph-canvas {
width: 100%;
height: 100%;
cursor: grab;
}
#graph-canvas:active {
cursor: grabbing;
}
.canvas-controls {
position: absolute;
bottom: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.canvas-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.canvas-btn:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.canvas-help-text {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 8px 16px;
font-size: 13px;
color: var(--text-secondary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
pointer-events: none;
user-select: none;
z-index: 10;
}
/* Context Menu */
.context-menu {
position: fixed;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
z-index: 2000;
min-width: 180px;
overflow: hidden;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
cursor: pointer;
color: var(--text-primary);
font-size: 14px;
transition: background 0.2s ease;
}
.context-menu-item:hover {
background: var(--bg-hover);
}
.context-menu-item svg {
flex-shrink: 0;
}
.context-menu-item.danger {
color: var(--danger);
}
.context-menu-item.danger:hover {
background: rgba(239, 68, 68, 0.1);
}
.context-menu-divider {
height: 1px;
background: var(--border-primary);
margin: 4px 0;
}
/* Modal Styles */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.modal-dialog {
position: relative;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
z-index: 1001;
}
.modal-dialog-large {
max-width: 650px;
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-primary);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 24px;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
}
.modal-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.modal-body {
padding: 24px;
overflow-y: auto;
max-height: calc(90vh - 120px);
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-primary);
font-size: 14px;
}
.form-control {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--accent-primary);
}
textarea.form-control {
resize: vertical;
min-height: 80px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
/* Connection Management in Modal */
.connection-search-container {
position: relative;
margin-bottom: 16px;
}
.search-results-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 6px;
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
}
.search-result-item {
padding: 10px 12px;
cursor: pointer;
transition: background 0.2s ease;
border-bottom: 1px solid var(--border-primary);
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover {
background: var(--bg-hover);
}
.search-result-label {
font-weight: 500;
color: var(--text-primary);
font-size: 14px;
}
.search-result-notes {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.connections-list {
margin-top: 12px;
border: 1px solid var(--border-primary);
border-radius: 6px;
overflow: hidden;
}
.connection-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-primary);
}
.connection-item:last-child {
border-bottom: none;
}
.connection-info {
flex: 1;
min-width: 0;
}
.connection-label {
font-weight: 500;
color: var(--text-primary);
font-size: 14px;
}
.connection-type {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
}
.connection-remove {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
margin-left: 8px;
}
.connection-remove:hover {
background: var(--danger);
border-color: var(--danger);
color: white;
}
.connections-empty {
padding: 20px;
text-align: center;
color: var(--text-secondary);
font-size: 14px;
background: var(--bg-tertiary);
}
/* Responsive */
@media (max-width: 768px) {
.graph-toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar-section {
width: 100%;
justify-content: space-between;
}
.search-input {
flex: 1;
min-width: 0;
}
.graph-canvas-container {
height: 400px;
}
.modal-dialog {
width: 95%;
}
}
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
# Run Database Migrations
# This script executes all SQL files in the Database folder in order
param(
[string]$ConnectionString = ""
)
if ([string]::IsNullOrEmpty($ConnectionString)) {
Write-Host "Usage: .\run-migrations.ps1 -ConnectionString 'your-connection-string'"
Write-Host ""
Write-Host "Example:"
Write-Host ".\run-migrations.ps1 -ConnectionString 'Host=localhost;Database=mydb;Username=postgres;Password=mypassword'"
exit 1
}
# Get all SQL files in the Database folder, sorted by name
$sqlFiles = Get-ChildItem -Path ".\Media.JoshHeaps.Net\Database\*.sql" | Sort-Object Name
if ($sqlFiles.Count -eq 0) {
Write-Host "No SQL migration files found in .\Media.JoshHeaps.Net\Database\"
exit 1
}
Write-Host "Found $($sqlFiles.Count) migration files to execute:"
$sqlFiles | ForEach-Object { Write-Host " - $($_.Name)" }
Write-Host ""
# Check if psql is available
$psqlPath = Get-Command psql -ErrorAction SilentlyContinue
if (-not $psqlPath) {
Write-Host "ERROR: psql command not found. Please install PostgreSQL client tools."
Write-Host ""
Write-Host "Alternatively, you can manually run each SQL file using your preferred PostgreSQL client:"
$sqlFiles | ForEach-Object {
Write-Host " psql `"$ConnectionString`" -f `"$($_.FullName)`""
}
exit 1
}
# Execute each migration
$successCount = 0
$failCount = 0
foreach ($file in $sqlFiles) {
Write-Host "Executing: $($file.Name)... " -NoNewline
try {
$result = psql $ConnectionString -f $file.FullName 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "SUCCESS" -ForegroundColor Green
$successCount++
} else {
Write-Host "FAILED" -ForegroundColor Red
Write-Host " Error: $result"
$failCount++
}
} catch {
Write-Host "FAILED" -ForegroundColor Red
Write-Host " Error: $($_.Exception.Message)"
$failCount++
}
}
Write-Host ""
Write-Host "Migration Summary:"
Write-Host " Successful: $successCount" -ForegroundColor Green
Write-Host " Failed: $failCount" -ForegroundColor $(if ($failCount -gt 0) { "Red" } else { "Green" })
if ($failCount -gt 0) {
exit 1
}