Create node graph page
This commit is contained in:
@@ -6,7 +6,11 @@
|
|||||||
"Bash(powershell:*)",
|
"Bash(powershell:*)",
|
||||||
"Bash(dotnet script:*)",
|
"Bash(dotnet script:*)",
|
||||||
"Bash(taskkill:*)",
|
"Bash(taskkill:*)",
|
||||||
"Bash(dir:*)"
|
"Bash(dir:*)",
|
||||||
|
"Bash(cd:*)",
|
||||||
|
"Bash(tree:*)",
|
||||||
|
"Bash(del Index.cshtml Index.cshtml.cs)",
|
||||||
|
"Bash(dotnet build:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@@ -11,17 +11,8 @@ namespace Media.JoshHeaps.Net.Api;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/auth")]
|
[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")]
|
[HttpPost("login")]
|
||||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
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" });
|
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)
|
if (!success || userInfo == null)
|
||||||
{
|
{
|
||||||
@@ -77,10 +68,10 @@ public class AuthApi : ControllerBase
|
|||||||
|
|
||||||
private string GenerateJwtToken(UserLoginInfo user)
|
private string GenerateJwtToken(UserLoginInfo user)
|
||||||
{
|
{
|
||||||
var jwtKey = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured");
|
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 jwtIssuer = configuration["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured");
|
||||||
var jwtAudience = _configuration["Jwt:Audience"] ?? throw new InvalidOperationException("JWT Audience not configured");
|
var jwtAudience = configuration["Jwt:Audience"] ?? throw new InvalidOperationException("JWT Audience not configured");
|
||||||
var jwtExpiryDays = int.Parse(_configuration["Jwt:ExpiryInDays"] ?? "30");
|
var jwtExpiryDays = int.Parse(configuration["Jwt:ExpiryInDays"] ?? "30");
|
||||||
|
|
||||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
|
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
|
||||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||||
|
|||||||
@@ -6,15 +6,8 @@ namespace Media.JoshHeaps.Net.Api;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/folder")]
|
[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")]
|
[HttpGet("list")]
|
||||||
public async Task<IActionResult> ListFolders([FromQuery] long? folderId = null)
|
public async Task<IActionResult> ListFolders([FromQuery] long? folderId = null)
|
||||||
{
|
{
|
||||||
@@ -24,7 +17,7 @@ public class FolderApi : ControllerBase
|
|||||||
return Unauthorized(new { error = "Not authenticated" });
|
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);
|
return Ok(folders);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +30,7 @@ public class FolderApi : ControllerBase
|
|||||||
return Unauthorized(new { error = "Not authenticated" });
|
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);
|
return Ok(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,14 +51,14 @@ public class FolderApi : ControllerBase
|
|||||||
// If parent folder specified, check ownership (can't create in shared folders)
|
// If parent folder specified, check ownership (can't create in shared folders)
|
||||||
if (request.ParentFolderId.HasValue)
|
if (request.ParentFolderId.HasValue)
|
||||||
{
|
{
|
||||||
var parentOwnerId = await _folderService.GetFolderOwnerIdAsync(request.ParentFolderId.Value);
|
var parentOwnerId = await folderService.GetFolderOwnerIdAsync(request.ParentFolderId.Value);
|
||||||
if (parentOwnerId != userId.Value)
|
if (parentOwnerId != userId.Value)
|
||||||
{
|
{
|
||||||
return Forbid("Cannot create folders in shared folders");
|
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)
|
if (folder == null)
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Failed to create folder" });
|
return BadRequest(new { error = "Failed to create folder" });
|
||||||
@@ -89,13 +82,13 @@ public class FolderApi : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check ownership (can't rename shared folders)
|
// 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)
|
if (ownerId != userId.Value)
|
||||||
{
|
{
|
||||||
return Forbid("Cannot rename shared folders");
|
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)
|
if (!success)
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Failed to rename folder" });
|
return BadRequest(new { error = "Failed to rename folder" });
|
||||||
@@ -114,13 +107,13 @@ public class FolderApi : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check ownership (can't delete shared folders)
|
// Check ownership (can't delete shared folders)
|
||||||
var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId);
|
var ownerId = await folderService.GetFolderOwnerIdAsync(folderId);
|
||||||
if (ownerId != userId.Value)
|
if (ownerId != userId.Value)
|
||||||
{
|
{
|
||||||
return Forbid("Cannot delete shared folders");
|
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)
|
if (!success)
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Failed to delete folder" });
|
return BadRequest(new { error = "Failed to delete folder" });
|
||||||
@@ -139,13 +132,13 @@ public class FolderApi : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check ownership (can't move shared folders)
|
// 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)
|
if (ownerId != userId.Value)
|
||||||
{
|
{
|
||||||
return Forbid("Cannot move shared folders");
|
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)
|
if (!success)
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Failed to move folder" });
|
return BadRequest(new { error = "Failed to move folder" });
|
||||||
|
|||||||
@@ -6,17 +6,8 @@ namespace Media.JoshHeaps.Net.Api;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/folder-share")]
|
[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")]
|
[HttpPost("share")]
|
||||||
public async Task<IActionResult> ShareFolder([FromBody] ShareFolderRequest request)
|
public async Task<IActionResult> ShareFolder([FromBody] ShareFolderRequest request)
|
||||||
{
|
{
|
||||||
@@ -26,7 +17,7 @@ public class FolderShareApi : ControllerBase
|
|||||||
return Unauthorized(new { error = "Not authenticated" });
|
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)
|
if (share == null)
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Failed to share folder" });
|
return BadRequest(new { error = "Failed to share folder" });
|
||||||
@@ -44,7 +35,7 @@ public class FolderShareApi : ControllerBase
|
|||||||
return Unauthorized(new { error = "Not authenticated" });
|
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)
|
if (!success)
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Failed to unshare folder" });
|
return BadRequest(new { error = "Failed to unshare folder" });
|
||||||
@@ -62,7 +53,7 @@ public class FolderShareApi : ControllerBase
|
|||||||
return Unauthorized(new { error = "Not authenticated" });
|
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);
|
return Ok(shares);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +66,7 @@ public class FolderShareApi : ControllerBase
|
|||||||
return Unauthorized(new { error = "Not authenticated" });
|
return Unauthorized(new { error = "Not authenticated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
var sharedFolders = await _folderService.GetSharedFoldersAsync(userId.Value);
|
var sharedFolders = await folderService.GetSharedFoldersAsync(userId.Value);
|
||||||
return Ok(sharedFolders);
|
return Ok(sharedFolders);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +84,7 @@ public class FolderShareApi : ControllerBase
|
|||||||
return Ok(new List<object>());
|
return Ok(new List<object>());
|
||||||
}
|
}
|
||||||
|
|
||||||
var users = await _userService.SearchUsersAsync(query, userId.Value);
|
var users = await userService.SearchUsersAsync(query, userId.Value);
|
||||||
return Ok(users);
|
return Ok(users);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,17 +6,8 @@ namespace Media.JoshHeaps.Net.Api;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/media")]
|
[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")]
|
[HttpGet("load")]
|
||||||
public async Task<IActionResult> LoadMedia([FromQuery] int offset = 0, [FromQuery] int limit = 20, [FromQuery] long? folderId = null)
|
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();
|
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);
|
return Ok(media);
|
||||||
}
|
}
|
||||||
@@ -43,14 +34,14 @@ public class MediaApi : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get media metadata (includes ownership check)
|
// 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)
|
if (media == null)
|
||||||
{
|
{
|
||||||
return NotFound(new { error = "Image not found or access denied" });
|
return NotFound(new { error = "Image not found or access denied" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get decrypted image data
|
// Get decrypted image data
|
||||||
var imageData = await _mediaService.GetDecryptedMediaDataAsync(mediaId, userId.Value);
|
var imageData = await mediaService.GetDecryptedMediaDataAsync(mediaId, userId.Value);
|
||||||
if (imageData == null)
|
if (imageData == null)
|
||||||
{
|
{
|
||||||
return NotFound(new { error = "Image file not found" });
|
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)
|
// Check folder ownership if uploading to a folder (can't upload to shared folders)
|
||||||
if (folderId.HasValue)
|
if (folderId.HasValue)
|
||||||
{
|
{
|
||||||
var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId.Value);
|
var ownerId = await folderService.GetFolderOwnerIdAsync(folderId.Value);
|
||||||
if (ownerId != userId.Value)
|
if (ownerId != userId.Value)
|
||||||
{
|
{
|
||||||
return Forbid("Cannot upload to shared folders");
|
return Forbid("Cannot upload to shared folders");
|
||||||
@@ -99,7 +90,7 @@ public class MediaApi : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save media using existing service
|
// 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)
|
if (media == null)
|
||||||
{
|
{
|
||||||
return StatusCode(500, new { error = "Failed to upload image" });
|
return StatusCode(500, new { error = "Failed to upload image" });
|
||||||
@@ -118,7 +109,7 @@ public class MediaApi : ControllerBase
|
|||||||
return Unauthorized(new { error = "Not authenticated" });
|
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)
|
if (!success)
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Failed to move media" });
|
return BadRequest(new { error = "Failed to move media" });
|
||||||
@@ -147,7 +138,7 @@ public class MediaApi : ControllerBase
|
|||||||
|
|
||||||
foreach (var mediaId in request.MediaIds)
|
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)
|
if (success)
|
||||||
{
|
{
|
||||||
successCount++;
|
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);
|
||||||
@@ -103,4 +103,20 @@ public class DbExecutor(IConfiguration config)
|
|||||||
|
|
||||||
return results;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; } = [];
|
||||||
|
}
|
||||||
@@ -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?}"
|
@page "{handler?}"
|
||||||
@model Media.JoshHeaps.Net.Pages.IndexModel
|
@model Media.JoshHeaps.Net.Pages.GalleryModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Dashboard";
|
ViewData["Title"] = "Gallery";
|
||||||
Layout = "_Layout";
|
Layout = "_Layout";
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Styles {
|
@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" />
|
<link rel="stylesheet" href="~/css/gallery.css" asp-append-version="true" />
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="dashboard-container">
|
<div class="dashboard-container">
|
||||||
<div class="welcome-section">
|
<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">
|
<div class="quick-actions">
|
||||||
@if (Model.Dashboard?.EmailVerified == false)
|
@if (Model.Dashboard?.EmailVerified == false)
|
||||||
{
|
{
|
||||||
@@ -59,11 +67,11 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="gallery-header">
|
<div class="gallery-header">
|
||||||
<div class="breadcrumb-nav">
|
<div class="breadcrumb-nav">
|
||||||
<a href="/[email protected]">Home</a>
|
<a href="/Gallery[email protected]">Home</a>
|
||||||
@foreach (var folder in Model.FolderPath)
|
@foreach (var folder in Model.FolderPath)
|
||||||
{
|
{
|
||||||
<span class="breadcrumb-separator">/</span>
|
<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))
|
@if (Model.IsSharedFolder && !string.IsNullOrEmpty(Model.SharedByUsername))
|
||||||
{
|
{
|
||||||
@@ -72,9 +80,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<div class="view-mode-toggle">
|
<div class="view-mode-toggle">
|
||||||
<a href="/?view=own" class="view-mode-btn @(Model.ViewMode == "own" ? "active" : "")">My Folders</a>
|
<a href="/Gallery?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="/Gallery?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=all" class="view-mode-btn @(Model.ViewMode == "all" ? "active" : "")">All</a>
|
||||||
</div>
|
</div>
|
||||||
@if (!Model.IsSharedFolder)
|
@if (!Model.IsSharedFolder)
|
||||||
{
|
{
|
||||||
@@ -97,8 +105,8 @@
|
|||||||
@if (Model.CurrentFolderId.HasValue)
|
@if (Model.CurrentFolderId.HasValue)
|
||||||
{
|
{
|
||||||
var backUrl = Model.FolderPath.Count > 1
|
var backUrl = Model.FolderPath.Count > 1
|
||||||
? $"/?folderId={Model.FolderPath[Model.FolderPath.Count - 2].Id}&view={Model.ViewMode}"
|
? $"/Gallery?folderId={Model.FolderPath[Model.FolderPath.Count - 2].Id}&view={Model.ViewMode}"
|
||||||
: $"/?view={Model.ViewMode}";
|
: $"/Gallery?view={Model.ViewMode}";
|
||||||
<div class="gallery-item folder-item" data-folder-id="back" onclick="window.location.href='@backUrl'">
|
<div class="gallery-item folder-item" data-folder-id="back" onclick="window.location.href='@backUrl'">
|
||||||
<div class="folder-icon">
|
<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">
|
<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)
|
@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">
|
<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">
|
<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"/>
|
<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)
|
@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">
|
<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">
|
<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"/>
|
<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/folder-sharing.js" asp-append-version="true"></script>
|
||||||
<script src="~/js/drag-drop.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>
|
<script src="~/js/context-menu.js" asp-append-version="true"></script>
|
||||||
}
|
}
|
||||||
+6
-6
@@ -4,16 +4,16 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
|
|
||||||
namespace Media.JoshHeaps.Net.Pages
|
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 MediaService _mediaService = mediaService;
|
||||||
private readonly FolderService _folderService = folderService;
|
private readonly FolderService _folderService = folderService;
|
||||||
|
|
||||||
public UserDashboard? Dashboard { get; set; }
|
public UserDashboard? Dashboard { get; set; }
|
||||||
public List<UserMedia> MediaItems { get; set; } = new();
|
public List<UserMedia> MediaItems { get; set; } = [];
|
||||||
public List<Folder> Folders { get; set; } = new();
|
public List<Folder> Folders { get; set; } = [];
|
||||||
public List<Folder> FolderPath { get; set; } = new();
|
public List<Folder> FolderPath { get; set; } = [];
|
||||||
public List<SharedFolderInfo> SharedFolders { get; set; } = new();
|
public List<SharedFolderInfo> SharedFolders { get; set; } = [];
|
||||||
public long? CurrentFolderId { get; set; }
|
public long? CurrentFolderId { get; set; }
|
||||||
public bool IsSharedFolder { get; set; }
|
public bool IsSharedFolder { get; set; }
|
||||||
public long? FolderOwnerId { get; set; }
|
public long? FolderOwnerId { get; set; }
|
||||||
@@ -64,7 +64,7 @@ namespace Media.JoshHeaps.Net.Pages
|
|||||||
SharedFolders = await _folderService.GetSharedFoldersAsync(UserId);
|
SharedFolders = await _folderService.GetSharedFoldersAsync(UserId);
|
||||||
if (!CurrentFolderId.HasValue)
|
if (!CurrentFolderId.HasValue)
|
||||||
{
|
{
|
||||||
Folders = new List<Folder>();
|
Folders = [];
|
||||||
}
|
}
|
||||||
else if (IsSharedFolder && FolderOwnerId.HasValue)
|
else if (IsSharedFolder && FolderOwnerId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,8 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
|
|||||||
|
|
||||||
namespace Media.JoshHeaps.Net.Pages;
|
namespace Media.JoshHeaps.Net.Pages;
|
||||||
|
|
||||||
public class LoginModel : PageModel
|
public class LoginModel(AuthService authService) : PageModel
|
||||||
{
|
{
|
||||||
private readonly AuthService _authService;
|
|
||||||
|
|
||||||
[BindProperty]
|
[BindProperty]
|
||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
@@ -22,18 +20,13 @@ public class LoginModel : PageModel
|
|||||||
public string? SuccessMessage { get; set; }
|
public string? SuccessMessage { get; set; }
|
||||||
public string? WarningMessage { get; set; }
|
public string? WarningMessage { get; set; }
|
||||||
|
|
||||||
public LoginModel(AuthService authService)
|
|
||||||
{
|
|
||||||
_authService = authService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnGet([FromQuery] string? registered, [FromQuery] string? verified)
|
public void OnGet([FromQuery] string? registered, [FromQuery] string? verified)
|
||||||
{
|
{
|
||||||
// Check if user is already logged in
|
// Check if user is already logged in
|
||||||
var userId = HttpContext.Session.GetString("UserId");
|
var userId = HttpContext.Session.GetString("UserId");
|
||||||
if (!string.IsNullOrEmpty(userId))
|
if (!string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
Response.Redirect("/");
|
Response.Redirect("/Landing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +51,7 @@ public class LoginModel : PageModel
|
|||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
|
|
||||||
var (success, error, userInfo) = await _authService.LoginAsync(Email, Password);
|
var (success, error, userInfo) = await authService.LoginAsync(Email, Password);
|
||||||
|
|
||||||
if (!success || userInfo == null)
|
if (!success || userInfo == null)
|
||||||
{
|
{
|
||||||
@@ -91,6 +84,6 @@ public class LoginModel : PageModel
|
|||||||
Response.Cookies.Append("RememberMe", userInfo.Id.ToString(), cookieOptions);
|
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()">×</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()">×</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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,8 @@ using System.Text.RegularExpressions;
|
|||||||
|
|
||||||
namespace Media.JoshHeaps.Net.Pages;
|
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]
|
[BindProperty]
|
||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
@@ -24,12 +21,6 @@ public class RegisterModel : PageModel
|
|||||||
|
|
||||||
public string? ErrorMessage { get; set; }
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
public RegisterModel(AuthService authService, EmailService emailService)
|
|
||||||
{
|
|
||||||
_authService = authService;
|
|
||||||
_emailService = emailService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnGet()
|
public void OnGet()
|
||||||
{
|
{
|
||||||
// Check if user is already logged in
|
// Check if user is already logged in
|
||||||
@@ -79,7 +70,7 @@ public class RegisterModel : PageModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register user
|
// Register user
|
||||||
var (success, error, verificationToken) = await _authService.RegisterUserAsync(Email, Username, Password);
|
var (success, error, verificationToken) = await authService.RegisterUserAsync(Email, Username, Password);
|
||||||
|
|
||||||
if (!success)
|
if (!success)
|
||||||
{
|
{
|
||||||
@@ -90,7 +81,7 @@ public class RegisterModel : PageModel
|
|||||||
// Send verification email
|
// Send verification email
|
||||||
if (!string.IsNullOrEmpty(verificationToken))
|
if (!string.IsNullOrEmpty(verificationToken))
|
||||||
{
|
{
|
||||||
await _emailService.SendVerificationEmailAsync(Email, Username, verificationToken);
|
await emailService.SendVerificationEmailAsync(Email, Username, verificationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to verification pending page
|
// Redirect to verification pending page
|
||||||
|
|||||||
@@ -4,23 +4,14 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
|
|||||||
|
|
||||||
namespace Media.JoshHeaps.Net.Pages;
|
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]
|
[BindProperty]
|
||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string? ErrorMessage { get; set; }
|
public string? ErrorMessage { get; set; }
|
||||||
public string? SuccessMessage { get; set; }
|
public string? SuccessMessage { get; set; }
|
||||||
|
|
||||||
public ResendVerificationModel(AuthService authService, EmailService emailService)
|
|
||||||
{
|
|
||||||
_authService = authService;
|
|
||||||
_emailService = emailService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnGet([FromQuery] string? email)
|
public void OnGet([FromQuery] string? email)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(email))
|
if (!string.IsNullOrEmpty(email))
|
||||||
@@ -37,7 +28,7 @@ public class ResendVerificationModel : PageModel
|
|||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
|
|
||||||
var (success, error, verificationToken) = await _authService.ResendVerificationTokenAsync(Email);
|
var (success, error, verificationToken) = await authService.ResendVerificationTokenAsync(Email);
|
||||||
|
|
||||||
if (!success)
|
if (!success)
|
||||||
{
|
{
|
||||||
@@ -48,7 +39,7 @@ public class ResendVerificationModel : PageModel
|
|||||||
// Send verification email
|
// Send verification email
|
||||||
if (!string.IsNullOrEmpty(verificationToken))
|
if (!string.IsNullOrEmpty(verificationToken))
|
||||||
{
|
{
|
||||||
var emailSent = await _emailService.SendVerificationEmailAsync(Email, Email, verificationToken);
|
var emailSent = await emailService.SendVerificationEmailAsync(Email, Email, verificationToken);
|
||||||
|
|
||||||
if (emailSent)
|
if (emailSent)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,19 +4,12 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
|
|||||||
|
|
||||||
namespace Media.JoshHeaps.Net.Pages;
|
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 bool Success { get; set; }
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
public bool ShowResendLink { get; set; }
|
public bool ShowResendLink { get; set; }
|
||||||
|
|
||||||
public VerifyEmailModel(AuthService authService)
|
|
||||||
{
|
|
||||||
_authService = authService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync([FromQuery] string? token)
|
public async Task<IActionResult> OnGetAsync([FromQuery] string? token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(token))
|
if (string.IsNullOrEmpty(token))
|
||||||
@@ -27,7 +20,7 @@ public class VerifyEmailModel : PageModel
|
|||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
|
|
||||||
var (success, error) = await _authService.VerifyEmailAsync(token);
|
var (success, error) = await authService.VerifyEmailAsync(token);
|
||||||
|
|
||||||
Success = success;
|
Success = success;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ builder.Services.AddScoped<EmailService>();
|
|||||||
builder.Services.AddScoped<UserService>();
|
builder.Services.AddScoped<UserService>();
|
||||||
builder.Services.AddScoped<MediaService>();
|
builder.Services.AddScoped<MediaService>();
|
||||||
builder.Services.AddScoped<FolderService>();
|
builder.Services.AddScoped<FolderService>();
|
||||||
|
builder.Services.AddScoped<GraphService>();
|
||||||
|
|
||||||
// Add session support
|
// Add session support
|
||||||
builder.Services.AddDistributedMemoryCache();
|
builder.Services.AddDistributedMemoryCache();
|
||||||
@@ -71,4 +72,11 @@ app.UseAuthorization();
|
|||||||
app.MapRazorPages();
|
app.MapRazorPages();
|
||||||
app.MapControllers(); // Map API controllers
|
app.MapControllers(); // Map API controllers
|
||||||
|
|
||||||
|
// Redirect root to Landing page
|
||||||
|
app.MapGet("/", context =>
|
||||||
|
{
|
||||||
|
context.Response.Redirect("/Landing");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -4,28 +4,19 @@ using MimeKit;
|
|||||||
|
|
||||||
namespace Media.JoshHeaps.Net.Services;
|
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)
|
public async Task<bool> SendVerificationEmailAsync(string toEmail, string username, string verificationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var appUrl = _config["AppUrl"] ?? "https://media.joshheaps.net";
|
var appUrl = config["AppUrl"] ?? "https://media.joshheaps.net";
|
||||||
var verificationUrl = $"{appUrl}/VerifyEmail?token={verificationToken}";
|
var verificationUrl = $"{appUrl}/VerifyEmail?token={verificationToken}";
|
||||||
|
|
||||||
var message = new MimeMessage();
|
var message = new MimeMessage();
|
||||||
message.From.Add(new MailboxAddress(
|
message.From.Add(new MailboxAddress(
|
||||||
_config["Email:FromName"] ?? "Media App",
|
config["Email:FromName"] ?? "Media App",
|
||||||
_config["Email:FromEmail"] ?? "[email protected]"
|
config["Email:FromEmail"] ?? "[email protected]"
|
||||||
));
|
));
|
||||||
message.To.Add(new MailboxAddress(username, toEmail));
|
message.To.Add(new MailboxAddress(username, toEmail));
|
||||||
message.Subject = "Verify Your Email Address";
|
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();
|
using var client = new SmtpClient();
|
||||||
|
|
||||||
var smtpHost = _config["Email:SmtpHost"];
|
var smtpHost = config["Email:SmtpHost"];
|
||||||
var smtpPort = int.Parse(_config["Email:SmtpPort"] ?? "587");
|
var smtpPort = int.Parse(config["Email:SmtpPort"] ?? "587");
|
||||||
var smtpUsername = _config["Email:SmtpUsername"];
|
var smtpUsername = config["Email:SmtpUsername"];
|
||||||
var smtpPassword = _config["Email:SmtpPassword"];
|
var smtpPassword = config["Email:SmtpPassword"];
|
||||||
var enableSsl = bool.Parse(_config["Email:EnableSsl"] ?? "true");
|
var enableSsl = bool.Parse(config["Email:EnableSsl"] ?? "true");
|
||||||
|
|
||||||
await client.ConnectAsync(smtpHost, smtpPort, enableSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.None);
|
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.SendAsync(message);
|
||||||
await client.DisconnectAsync(true);
|
await client.DisconnectAsync(true);
|
||||||
|
|
||||||
_logger.LogInformation($"Verification email sent to {toEmail}");
|
logger.LogInformation($"Verification email sent to {toEmail}");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,13 +106,13 @@ If you didn't create an account, you can safely ignore this email.
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var appUrl = _config["AppUrl"] ?? "http://localhost:5000";
|
var appUrl = config["AppUrl"] ?? "http://localhost:5000";
|
||||||
var resetUrl = $"{appUrl}/ResetPassword?token={resetToken}";
|
var resetUrl = $"{appUrl}/ResetPassword?token={resetToken}";
|
||||||
|
|
||||||
var message = new MimeMessage();
|
var message = new MimeMessage();
|
||||||
message.From.Add(new MailboxAddress(
|
message.From.Add(new MailboxAddress(
|
||||||
_config["Email:FromName"] ?? "Media App",
|
config["Email:FromName"] ?? "Media App",
|
||||||
_config["Email:FromEmail"] ?? "[email protected]"
|
config["Email:FromEmail"] ?? "[email protected]"
|
||||||
));
|
));
|
||||||
message.To.Add(new MailboxAddress(username, toEmail));
|
message.To.Add(new MailboxAddress(username, toEmail));
|
||||||
message.Subject = "Password Reset Request";
|
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();
|
using var client = new SmtpClient();
|
||||||
|
|
||||||
var smtpHost = _config["Email:SmtpHost"];
|
var smtpHost = config["Email:SmtpHost"];
|
||||||
var smtpPort = int.Parse(_config["Email:SmtpPort"] ?? "587");
|
var smtpPort = int.Parse(config["Email:SmtpPort"] ?? "587");
|
||||||
var smtpUsername = _config["Email:SmtpUsername"];
|
var smtpUsername = config["Email:SmtpUsername"];
|
||||||
var smtpPassword = _config["Email:SmtpPassword"];
|
var smtpPassword = config["Email:SmtpPassword"];
|
||||||
var enableSsl = bool.Parse(_config["Email:EnableSsl"] ?? "true");
|
var enableSsl = bool.Parse(config["Email:EnableSsl"] ?? "true");
|
||||||
|
|
||||||
await client.ConnectAsync(smtpHost, smtpPort, enableSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.None);
|
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.SendAsync(message);
|
||||||
await client.DisconnectAsync(true);
|
await client.DisconnectAsync(true);
|
||||||
|
|
||||||
_logger.LogInformation($"Password reset email sent to {toEmail}");
|
logger.LogInformation($"Password reset email sent to {toEmail}");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,8 @@ using Media.JoshHeaps.Net.Models;
|
|||||||
|
|
||||||
namespace Media.JoshHeaps.Net.Services;
|
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)
|
public async Task<Folder?> CreateFolderAsync(long userId, string name, long? parentFolderId = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -20,7 +11,7 @@ public class FolderService
|
|||||||
// Validate folder name
|
// Validate folder name
|
||||||
if (string.IsNullOrWhiteSpace(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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +19,7 @@ public class FolderService
|
|||||||
var sanitizedName = SanitizeFolderName(name);
|
var sanitizedName = SanitizeFolderName(name);
|
||||||
if (string.IsNullOrWhiteSpace(sanitizedName))
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +29,7 @@ public class FolderService
|
|||||||
var parentExists = await FolderExistsAsync(parentFolderId.Value, userId);
|
var parentExists = await FolderExistsAsync(parentFolderId.Value, userId);
|
||||||
if (!parentExists)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,7 +39,7 @@ public class FolderService
|
|||||||
VALUES (@userId, @name, @parentFolderId, @createdAt, @updatedAt)
|
VALUES (@userId, @name, @parentFolderId, @createdAt, @updatedAt)
|
||||||
RETURNING id, user_id, name, parent_folder_id, created_at, updated_at";
|
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
|
return new Folder
|
||||||
{
|
{
|
||||||
@@ -72,7 +63,7 @@ public class FolderService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,7 +90,7 @@ public class FolderService
|
|||||||
ORDER BY name ASC";
|
ORDER BY name ASC";
|
||||||
}
|
}
|
||||||
|
|
||||||
var folders = await _db.ExecuteListReaderAsync(query, reader =>
|
var folders = await db.ExecuteListReaderAsync(query, reader =>
|
||||||
{
|
{
|
||||||
return new Folder
|
return new Folder
|
||||||
{
|
{
|
||||||
@@ -116,8 +107,8 @@ public class FolderService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to get folders for user {UserId}, parent {ParentFolderId}", userId, parentFolderId);
|
logger.LogError(ex, "Failed to get folders for user {UserId}, parent {ParentFolderId}", userId, parentFolderId);
|
||||||
return new List<Folder>();
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +121,7 @@ public class FolderService
|
|||||||
FROM app.folders
|
FROM app.folders
|
||||||
WHERE id = @folderId AND user_id = @userId";
|
WHERE id = @folderId AND user_id = @userId";
|
||||||
|
|
||||||
var folder = await _db.ExecuteReaderAsync(query, reader =>
|
var folder = await db.ExecuteReaderAsync(query, reader =>
|
||||||
{
|
{
|
||||||
return new Folder
|
return new Folder
|
||||||
{
|
{
|
||||||
@@ -147,7 +138,7 @@ public class FolderService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +150,7 @@ public class FolderService
|
|||||||
var sanitizedName = SanitizeFolderName(newName);
|
var sanitizedName = SanitizeFolderName(newName);
|
||||||
if (string.IsNullOrWhiteSpace(sanitizedName))
|
if (string.IsNullOrWhiteSpace(sanitizedName))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Cannot rename folder to empty name");
|
logger.LogWarning("Cannot rename folder to empty name");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +159,7 @@ public class FolderService
|
|||||||
SET name = @newName, updated_at = @updatedAt
|
SET name = @newName, updated_at = @updatedAt
|
||||||
WHERE id = @folderId AND user_id = @userId";
|
WHERE id = @folderId AND user_id = @userId";
|
||||||
|
|
||||||
await _db.ExecuteAsync<object>(query, new
|
await db.ExecuteAsync<object>(query, new
|
||||||
{
|
{
|
||||||
folderId,
|
folderId,
|
||||||
userId,
|
userId,
|
||||||
@@ -180,7 +171,7 @@ public class FolderService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,12 +184,12 @@ public class FolderService
|
|||||||
var hasSubfoldersQuery = "SELECT COUNT(*) FROM app.folders WHERE parent_folder_id = @folderId";
|
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 hasMediaQuery = "SELECT COUNT(*) FROM app.user_media WHERE folder_id = @folderId";
|
||||||
|
|
||||||
var subfolderCount = await _db.ExecuteReaderAsync(hasSubfoldersQuery, 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 });
|
var mediaCount = await db.ExecuteReaderAsync(hasMediaQuery, reader => reader.GetInt64(0), new { folderId });
|
||||||
|
|
||||||
if (!deleteContents && (subfolderCount > 0 || mediaCount > 0))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +205,7 @@ public class FolderService
|
|||||||
SET parent_folder_id = @parentFolderId, updated_at = @updatedAt
|
SET parent_folder_id = @parentFolderId, updated_at = @updatedAt
|
||||||
WHERE parent_folder_id = @folderId AND user_id = @userId";
|
WHERE parent_folder_id = @folderId AND user_id = @userId";
|
||||||
|
|
||||||
await _db.ExecuteAsync<object>(moveSubfoldersQuery, new
|
await db.ExecuteAsync<object>(moveSubfoldersQuery, new
|
||||||
{
|
{
|
||||||
parentFolderId = folder.ParentFolderId,
|
parentFolderId = folder.ParentFolderId,
|
||||||
folderId,
|
folderId,
|
||||||
@@ -228,7 +219,7 @@ public class FolderService
|
|||||||
SET folder_id = @parentFolderId, updated_at = @updatedAt
|
SET folder_id = @parentFolderId, updated_at = @updatedAt
|
||||||
WHERE folder_id = @folderId AND user_id = @userId";
|
WHERE folder_id = @folderId AND user_id = @userId";
|
||||||
|
|
||||||
await _db.ExecuteAsync<object>(moveMediaQuery, new
|
await db.ExecuteAsync<object>(moveMediaQuery, new
|
||||||
{
|
{
|
||||||
parentFolderId = folder.ParentFolderId,
|
parentFolderId = folder.ParentFolderId,
|
||||||
folderId,
|
folderId,
|
||||||
@@ -240,13 +231,13 @@ public class FolderService
|
|||||||
|
|
||||||
// Delete the folder
|
// Delete the folder
|
||||||
var deleteQuery = "DELETE FROM app.folders WHERE id = @folderId AND user_id = @userId";
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +266,7 @@ public class FolderService
|
|||||||
var isDescendant = await IsFolderDescendantAsync(newParentFolderId.Value, folderId);
|
var isDescendant = await IsFolderDescendantAsync(newParentFolderId.Value, folderId);
|
||||||
if (isDescendant)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,7 +276,7 @@ public class FolderService
|
|||||||
SET parent_folder_id = @newParentFolderId, updated_at = @updatedAt
|
SET parent_folder_id = @newParentFolderId, updated_at = @updatedAt
|
||||||
WHERE id = @folderId AND user_id = @userId";
|
WHERE id = @folderId AND user_id = @userId";
|
||||||
|
|
||||||
await _db.ExecuteAsync<object>(query, new
|
await db.ExecuteAsync<object>(query, new
|
||||||
{
|
{
|
||||||
folderId,
|
folderId,
|
||||||
userId,
|
userId,
|
||||||
@@ -297,7 +288,7 @@ public class FolderService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,8 +321,8 @@ public class FolderService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to get folder path for folder {FolderId}", folderId);
|
logger.LogError(ex, "Failed to get folder path for folder {FolderId}", folderId);
|
||||||
return new List<Folder>();
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +331,7 @@ public class FolderService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var query = "SELECT COUNT(*) FROM app.folders WHERE id = @folderId AND user_id = @userId";
|
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;
|
return count > 0;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -371,7 +362,7 @@ public class FolderService
|
|||||||
visited.Add(currentId);
|
visited.Add(currentId);
|
||||||
|
|
||||||
var query = "SELECT parent_folder_id FROM app.folders WHERE id = @folderId";
|
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)
|
if (!parentId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -385,7 +376,7 @@ public class FolderService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,7 +412,7 @@ public class FolderService
|
|||||||
var folder = await GetFolderByIdAsync(folderId, ownerId);
|
var folder = await GetFolderByIdAsync(folderId, ownerId);
|
||||||
if (folder == null)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,7 +428,7 @@ public class FolderService
|
|||||||
VALUES (@folderId, @ownerId, @sharedWithUserId, @permissionLevel, @includeSubfolders, @createdAt, @updatedAt)
|
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";
|
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
|
return new FolderShare
|
||||||
{
|
{
|
||||||
@@ -465,7 +456,7 @@ public class FolderService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -478,12 +469,12 @@ public class FolderService
|
|||||||
DELETE FROM app.folder_shares
|
DELETE FROM app.folder_shares
|
||||||
WHERE folder_id = @folderId AND owner_user_id = @ownerId AND shared_with_user_id = @sharedWithUserId";
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,7 +491,7 @@ public class FolderService
|
|||||||
WHERE fs.folder_id = @folderId AND fs.owner_user_id = @ownerId
|
WHERE fs.folder_id = @folderId AND fs.owner_user_id = @ownerId
|
||||||
ORDER BY u.username ASC";
|
ORDER BY u.username ASC";
|
||||||
|
|
||||||
var shares = await _db.ExecuteListReaderAsync(query, reader =>
|
var shares = await db.ExecuteListReaderAsync(query, reader =>
|
||||||
{
|
{
|
||||||
return new FolderShareWithUser
|
return new FolderShareWithUser
|
||||||
{
|
{
|
||||||
@@ -519,8 +510,8 @@ public class FolderService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to get shares for folder {FolderId}", folderId);
|
logger.LogError(ex, "Failed to get shares for folder {FolderId}", folderId);
|
||||||
return new List<FolderShareWithUser>();
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,7 +527,7 @@ public class FolderService
|
|||||||
WHERE fs.shared_with_user_id = @userId
|
WHERE fs.shared_with_user_id = @userId
|
||||||
ORDER BY f.name ASC";
|
ORDER BY f.name ASC";
|
||||||
|
|
||||||
var sharedFolders = await _db.ExecuteListReaderAsync(query, reader =>
|
var sharedFolders = await db.ExecuteListReaderAsync(query, reader =>
|
||||||
{
|
{
|
||||||
return new SharedFolderInfo
|
return new SharedFolderInfo
|
||||||
{
|
{
|
||||||
@@ -553,8 +544,8 @@ public class FolderService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to get shared folders for user {UserId}", userId);
|
logger.LogError(ex, "Failed to get shared folders for user {UserId}", userId);
|
||||||
return new List<SharedFolderInfo>();
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +558,7 @@ public class FolderService
|
|||||||
FROM app.folder_shares
|
FROM app.folder_shares
|
||||||
WHERE folder_id = @folderId AND shared_with_user_id = @sharedWithUserId";
|
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
|
return new FolderShare
|
||||||
{
|
{
|
||||||
@@ -596,7 +587,7 @@ public class FolderService
|
|||||||
{
|
{
|
||||||
// Check if user is the owner
|
// Check if user is the owner
|
||||||
var query = "SELECT COUNT(*) FROM app.folders WHERE id = @folderId AND user_id = @userId";
|
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;
|
if (isOwner > 0) return true;
|
||||||
|
|
||||||
// Check if folder is shared with user (including parent folders due to subfolders)
|
// 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
|
INNER JOIN folder_hierarchy fh ON fs.folder_id = fh.id
|
||||||
WHERE fs.shared_with_user_id = @userId AND fs.include_subfolders = true";
|
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;
|
return hasShare > 0;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -634,7 +625,7 @@ public class FolderService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var query = "SELECT user_id FROM app.folders WHERE id = @folderId";
|
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;
|
return ownerId;
|
||||||
}
|
}
|
||||||
catch
|
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
|
||||||
|
}
|
||||||
@@ -2,23 +2,8 @@ using Media.JoshHeaps.Net.Models;
|
|||||||
|
|
||||||
namespace Media.JoshHeaps.Net.Services;
|
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)
|
public async Task<UserMedia?> SaveMediaAsync(long userId, IFormFile file, string? description = null, long? folderId = null)
|
||||||
{
|
{
|
||||||
string? tempFilePath = null;
|
string? tempFilePath = null;
|
||||||
@@ -31,7 +16,7 @@ public class MediaService
|
|||||||
var uniqueFileName = $"{Guid.NewGuid()}{fileExtension}.enc"; // .enc for encrypted
|
var uniqueFileName = $"{Guid.NewGuid()}{fileExtension}.enc"; // .enc for encrypted
|
||||||
|
|
||||||
// Store outside wwwroot in App_Data folder
|
// 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);
|
Directory.CreateDirectory(mediaFolder);
|
||||||
|
|
||||||
tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||||
@@ -57,12 +42,12 @@ public class MediaService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
// Encrypt and save
|
||||||
await _encryption.EncryptFileAsync(tempFilePath, encryptedFilePath);
|
await encryption.EncryptFileAsync(tempFilePath, encryptedFilePath);
|
||||||
|
|
||||||
// Delete temp file
|
// Delete temp file
|
||||||
File.Delete(tempFilePath);
|
File.Delete(tempFilePath);
|
||||||
@@ -77,7 +62,7 @@ public class MediaService
|
|||||||
VALUES (@userId, @fileName, @filePath, @fileSize, @mimeType, @width, @height, @description, @isEncrypted, @folderId, @createdAt, @updatedAt)
|
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";
|
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
|
return new UserMedia
|
||||||
{
|
{
|
||||||
@@ -115,7 +100,7 @@ public class MediaService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
// Cleanup on error
|
||||||
if (tempFilePath != null && File.Exists(tempFilePath))
|
if (tempFilePath != null && File.Exists(tempFilePath))
|
||||||
@@ -139,14 +124,14 @@ public class MediaService
|
|||||||
var effectiveUserId = userId;
|
var effectiveUserId = userId;
|
||||||
if (requestingUserId.HasValue && folderId.HasValue)
|
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)
|
if (!hasAccess)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("User {RequestingUserId} does not have access to folder {FolderId}", requestingUserId.Value, folderId.Value);
|
logger.LogWarning("User {RequestingUserId} does not have access to folder {FolderId}", requestingUserId.Value, folderId.Value);
|
||||||
return new List<UserMedia>();
|
return [];
|
||||||
}
|
}
|
||||||
// Get the actual owner of the folder
|
// Get the actual owner of the folder
|
||||||
var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId.Value);
|
var ownerId = await folderService.GetFolderOwnerIdAsync(folderId.Value);
|
||||||
if (ownerId.HasValue)
|
if (ownerId.HasValue)
|
||||||
{
|
{
|
||||||
effectiveUserId = ownerId.Value;
|
effectiveUserId = ownerId.Value;
|
||||||
@@ -173,7 +158,7 @@ public class MediaService
|
|||||||
OFFSET @offset LIMIT @limit";
|
OFFSET @offset LIMIT @limit";
|
||||||
}
|
}
|
||||||
|
|
||||||
var mediaList = await _db.ExecuteListReaderAsync(query, reader =>
|
var mediaList = await db.ExecuteListReaderAsync(query, reader =>
|
||||||
{
|
{
|
||||||
return new UserMedia
|
return new UserMedia
|
||||||
{
|
{
|
||||||
@@ -197,8 +182,8 @@ public class MediaService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to get media for user {UserId}", userId);
|
logger.LogError(ex, "Failed to get media for user {UserId}", userId);
|
||||||
return new List<UserMedia>();
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +197,7 @@ public class MediaService
|
|||||||
FROM app.user_media
|
FROM app.user_media
|
||||||
WHERE id = @mediaId";
|
WHERE id = @mediaId";
|
||||||
|
|
||||||
var media = await _db.ExecuteReaderAsync(query, reader =>
|
var media = await db.ExecuteReaderAsync(query, reader =>
|
||||||
{
|
{
|
||||||
return new UserMedia
|
return new UserMedia
|
||||||
{
|
{
|
||||||
@@ -244,7 +229,7 @@ public class MediaService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has access via shared folder
|
// 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;
|
return media;
|
||||||
}
|
}
|
||||||
@@ -254,7 +239,7 @@ public class MediaService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,21 +251,21 @@ public class MediaService
|
|||||||
var media = await GetMediaByIdAsync(mediaId, userId);
|
var media = await GetMediaByIdAsync(mediaId, userId);
|
||||||
if (media == null)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var fullPath = Path.Combine(_environment.ContentRootPath, media.FilePath);
|
var fullPath = Path.Combine(environment.ContentRootPath, media.FilePath);
|
||||||
|
|
||||||
if (!File.Exists(fullPath))
|
if (!File.Exists(fullPath))
|
||||||
{
|
{
|
||||||
_logger.LogError("Media file not found at {FilePath}", fullPath);
|
logger.LogError("Media file not found at {FilePath}", fullPath);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (media.IsEncrypted)
|
if (media.IsEncrypted)
|
||||||
{
|
{
|
||||||
return await _encryption.DecryptFileAsync(fullPath);
|
return await encryption.DecryptFileAsync(fullPath);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -289,7 +274,7 @@ public class MediaService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,16 +285,16 @@ public class MediaService
|
|||||||
{
|
{
|
||||||
// Get media info first
|
// Get media info first
|
||||||
var query = "SELECT file_path FROM app.user_media WHERE id = @mediaId AND user_id = @userId";
|
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;
|
if (filePath == null) return false;
|
||||||
|
|
||||||
// Delete from database
|
// Delete from database
|
||||||
var deleteQuery = "DELETE FROM app.user_media WHERE id = @mediaId AND user_id = @userId";
|
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
|
// Delete physical file
|
||||||
var physicalPath = Path.Combine(_environment.ContentRootPath, filePath);
|
var physicalPath = Path.Combine(environment.ContentRootPath, filePath);
|
||||||
if (File.Exists(physicalPath))
|
if (File.Exists(physicalPath))
|
||||||
{
|
{
|
||||||
File.Delete(physicalPath);
|
File.Delete(physicalPath);
|
||||||
@@ -319,7 +304,7 @@ public class MediaService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,7 +318,7 @@ public class MediaService
|
|||||||
SET folder_id = @folderId, updated_at = @updatedAt
|
SET folder_id = @folderId, updated_at = @updatedAt
|
||||||
WHERE id = @mediaId AND user_id = @userId";
|
WHERE id = @mediaId AND user_id = @userId";
|
||||||
|
|
||||||
await _db.ExecuteAsync<object>(query, new
|
await db.ExecuteAsync<object>(query, new
|
||||||
{
|
{
|
||||||
mediaId,
|
mediaId,
|
||||||
userId,
|
userId,
|
||||||
@@ -345,7 +330,7 @@ public class MediaService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,8 @@ using Media.JoshHeaps.Net.Models;
|
|||||||
|
|
||||||
namespace Media.JoshHeaps.Net.Services;
|
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)
|
public async Task<UserDashboard?> GetUserDashboardAsync(long userId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -23,7 +16,7 @@ public class UserService
|
|||||||
LEFT JOIN app.user_profiles p ON u.id = p.user_id
|
LEFT JOIN app.user_profiles p ON u.id = p.user_id
|
||||||
WHERE u.id = @userId";
|
WHERE u.id = @userId";
|
||||||
|
|
||||||
var dashboard = await _db.ExecuteReaderAsync(query, reader =>
|
var dashboard = await db.ExecuteReaderAsync(query, reader =>
|
||||||
{
|
{
|
||||||
return new UserDashboard
|
return new UserDashboard
|
||||||
{
|
{
|
||||||
@@ -62,7 +55,7 @@ public class UserService
|
|||||||
FROM app.user_profiles
|
FROM app.user_profiles
|
||||||
WHERE user_id = @userId";
|
WHERE user_id = @userId";
|
||||||
|
|
||||||
var profile = await _db.ExecuteReaderAsync(query, reader =>
|
var profile = await db.ExecuteReaderAsync(query, reader =>
|
||||||
{
|
{
|
||||||
return new UserProfile
|
return new UserProfile
|
||||||
{
|
{
|
||||||
@@ -87,7 +80,7 @@ public class UserService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _db.ExecuteAsync<object>(
|
await db.ExecuteAsync<object>(
|
||||||
@"UPDATE app.user_profiles
|
@"UPDATE app.user_profiles
|
||||||
SET bio = @bio, avatar_url = @avatarUrl, location = @location,
|
SET bio = @bio, avatar_url = @avatarUrl, location = @location,
|
||||||
website = @website, updated_at = @updatedAt
|
website = @website, updated_at = @updatedAt
|
||||||
@@ -116,7 +109,7 @@ public class UserService
|
|||||||
ORDER BY username ASC
|
ORDER BY username ASC
|
||||||
LIMIT 10";
|
LIMIT 10";
|
||||||
|
|
||||||
var users = await _db.ExecuteListReaderAsync(searchQuery, reader =>
|
var users = await db.ExecuteListReaderAsync(searchQuery, reader =>
|
||||||
{
|
{
|
||||||
return new UserSearchResult
|
return new UserSearchResult
|
||||||
{
|
{
|
||||||
@@ -130,7 +123,7 @@ public class UserService
|
|||||||
}
|
}
|
||||||
catch (Exception)
|
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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user