Author SHA1 Message Date
Josh-Heaps e69e5663ce Add fleshed out admin page 2026-02-09 14:38:58 -07:00
Josh-Heaps 14d748de80 Add user roles and admin page 2026-02-09 13:52:59 -07:00
Josh-Heaps 9bbbe3aaad update claude.md 2026-02-09 13:52:49 -07:00
Josh-Heaps c9aa9886e4 Add claude.md 2026-02-09 13:28:31 -07:00
Josh-Heaps a26ffec104 Fix sql scripts 2026-02-09 13:23:34 -07:00
Josh-Heaps dae8fbfe5e Create node graph page 2026-01-02 17:13:39 -07:00
50 changed files with 5213 additions and 269 deletions
+6 -1
View File
@@ -6,7 +6,12 @@
"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:*)",
"Bash(find:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
+56
View File
@@ -0,0 +1,56 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Full-stack ASP.NET Core 8 media management application (Media.JoshHeaps.Net) with encrypted photo storage, folder organization with sharing, and knowledge graph visualization. PostgreSQL backend, Razor Pages frontend with vanilla JavaScript.
## Build & Run
```bash
# Restore and build
dotnet build Media.JoshHeaps.Net.sln
# Run the app (HTTPS: localhost:7007, HTTP: localhost:5029)
dotnet run --project Media.JoshHeaps.Net
# Run database migrations (requires psql on PATH)
./run-migrations.ps1 -ConnectionString "<connection_string>"
```
Sensitive config (DB connection string, JWT signing key, encryption key, email credentials) is stored in .NET User Secrets (ID: `1ee15d15-1d19-471d-8772-ce72aeaafbd3`). No test project exists.
## Architecture
### Backend Layers
- **Api/** — REST controllers. Routes: `/api/auth/`, `/api/media/`, `/api/folder/`, `/api/folder-share/`, `/api/graph/`
- **Services/** — Business logic (AuthService, MediaService, FolderService, GraphService, EmailService, EncryptionService, UserService). Registered via DI in Program.cs.
- **Models/** — Data models shared between API and Razor Pages
- **Pages/** — Razor Pages for server-rendered UI. Protected pages inherit from `AuthenticatedPageModel` (session-based auth).
### Authentication
Dual auth scheme: **session cookies** for Razor Pages, **JWT Bearer** for API endpoints. Session timeout is 2 hours; JWT expiry is 30 days. Account locks after 5 failed login attempts for 15 minutes.
### Database
PostgreSQL via `DbExecutor` singleton — a custom async query executor using raw Npgsql (no ORM). Parameterized queries use reflection on anonymous objects. Migration scripts in `Database/` are numbered 001011 and must run in order (each script's dependencies come before it). **All database changes must be backwards-compatible with the existing production database — never drop tables, columns, or alter data in ways that could cause data loss. Use additive migrations (ADD COLUMN, CREATE TABLE, etc.) and `IF NOT EXISTS` guards where appropriate.**
### File Storage
Media files are encrypted with AES-256-CBC before storage in `App_Data/media/{userId}/`. Each file gets a random IV. Files are decrypted on-demand when served. Max upload: 10MB, allowed types: JPEG, PNG, GIF, WEBP.
### Frontend
Razor Pages + vanilla JS + Bootstrap 5. Key JS files in `wwwroot/js/`:
- `gallery.js` / `folders.js` / `upload.js` / `drag-drop.js` — gallery and folder UI
- `folder-sharing.js` — sharing modal and permissions
- `network-graph.js` — D3-based graph visualization with community detection
- `context-menu.js` — right-click context menus
- `theme.js` — dark/light theme toggle
### Key Dependencies
Npgsql (PostgreSQL), BCrypt.Net-Next (password hashing), MailKit (email), SixLabors.ImageSharp (image processing), Microsoft.AspNetCore.Authentication.JwtBearer
+184
View File
@@ -0,0 +1,184 @@
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace Media.JoshHeaps.Net.Api;
[ApiController]
[Route("api/admin")]
public class AdminApi(DbExecutor dbExecutor) : ControllerBase
{
[HttpGet("users")]
public async Task<IActionResult> GetUsers([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var authUserId = GetUserIdFromAuth();
if (authUserId == null) return Unauthorized();
if (!await IsAdmin(authUserId.Value)) return Forbid();
if (page < 1) page = 1;
if (pageSize < 1 || pageSize > 100) pageSize = 20;
var offset = (page - 1) * pageSize;
var totalCount = await dbExecutor.ExecuteAsync<long>(
"SELECT COUNT(*) FROM app.users");
var users = await dbExecutor.ExecuteListReaderAsync(
@"SELECT u.id, u.username, u.email, u.is_active
FROM app.users u
ORDER BY u.id
LIMIT @PageSize OFFSET @Offset",
reader => new
{
Id = reader.GetInt64(0),
Username = reader.GetString(1),
Email = reader.GetString(2),
IsActive = reader.GetBoolean(3)
},
new { PageSize = pageSize, Offset = offset });
// Fetch all roles for these users in one query using a subquery
var userRoles = users.Count > 0
? await dbExecutor.ExecuteListReaderAsync(
@"SELECT ur.user_id, r.id, r.name
FROM app.user_roles ur
JOIN app.roles r ON ur.role_id = r.id
WHERE ur.user_id IN (
SELECT u.id FROM app.users u ORDER BY u.id LIMIT @PageSize OFFSET @Offset
)",
reader => new
{
UserId = reader.GetInt64(0),
RoleId = reader.GetInt64(1),
RoleName = reader.GetString(2)
},
new { PageSize = pageSize, Offset = offset })
: [];
var result = users.Select(u => new
{
u.Id,
u.Username,
u.Email,
u.IsActive,
Roles = userRoles.Where(r => r.UserId == u.Id)
.Select(r => new { Id = r.RoleId, Name = r.RoleName })
.ToList()
});
return Ok(new { users = result, totalCount });
}
[HttpGet("roles")]
public async Task<IActionResult> GetRoles()
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await IsAdmin(userId.Value)) return Forbid();
var roles = await dbExecutor.ExecuteListReaderAsync(
"SELECT id, name FROM app.roles ORDER BY id",
reader => new { Id = reader.GetInt64(0), Name = reader.GetString(1) });
return Ok(roles);
}
[HttpPost("roles")]
public async Task<IActionResult> CreateRole([FromBody] CreateRoleRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await IsAdmin(userId.Value)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Role name is required" });
var name = request.Name.Trim().ToLowerInvariant();
var existing = await dbExecutor.ExecuteAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM app.roles WHERE name = @Name)",
new { Name = name });
if (existing)
return BadRequest(new { error = "Role already exists" });
var role = await dbExecutor.ExecuteReaderAsync(
"INSERT INTO app.roles (name) VALUES (@Name) RETURNING id, name",
reader => new { Id = reader.GetInt64(0), Name = reader.GetString(1) },
new { Name = name });
return Ok(role);
}
[HttpPost("users/{targetUserId}/roles/{roleId}")]
public async Task<IActionResult> AssignRole(long targetUserId, long roleId)
{
var adminId = GetUserIdFromAuth();
if (adminId == null) return Unauthorized();
if (!await IsAdmin(adminId.Value)) return Forbid();
var userExists = await dbExecutor.ExecuteAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM app.users WHERE id = @UserId)",
new { UserId = targetUserId });
if (!userExists) return NotFound(new { error = "User not found" });
var roleExists = await dbExecutor.ExecuteAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM app.roles WHERE id = @RoleId)",
new { RoleId = roleId });
if (!roleExists) return NotFound(new { error = "Role not found" });
var alreadyAssigned = await dbExecutor.ExecuteAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM app.user_roles WHERE user_id = @UserId AND role_id = @RoleId)",
new { UserId = targetUserId, RoleId = roleId });
if (alreadyAssigned) return Ok(new { success = true });
await dbExecutor.ExecuteNonQueryAsync(
"INSERT INTO app.user_roles (user_id, role_id) VALUES (@UserId, @RoleId)",
new { UserId = targetUserId, RoleId = roleId });
return Ok(new { success = true });
}
[HttpDelete("users/{targetUserId}/roles/{roleId}")]
public async Task<IActionResult> RemoveRole(long targetUserId, long roleId)
{
var adminId = GetUserIdFromAuth();
if (adminId == null) return Unauthorized();
if (!await IsAdmin(adminId.Value)) return Forbid();
await dbExecutor.ExecuteNonQueryAsync(
"DELETE FROM app.user_roles WHERE user_id = @UserId AND role_id = @RoleId",
new { UserId = targetUserId, RoleId = roleId });
return Ok(new { success = true });
}
private async Task<bool> IsAdmin(long userId)
{
return await dbExecutor.ExecuteAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'admin')",
new { UserId = userId });
}
private long? GetUserIdFromAuth()
{
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
{
return jwtUserId;
}
var userIdString = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
{
return sessionUserId;
}
return null;
}
}
public record CreateRoleRequest(string Name);
+6 -15
View File
@@ -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);
+11 -18
View File
@@ -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 -15
View File
@@ -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);
} }
+381
View File
@@ -0,0 +1,381 @@
using Media.JoshHeaps.Net.Services;
using Media.JoshHeaps.Net.Models;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace Media.JoshHeaps.Net.Api;
[ApiController]
[Route("api/graph")]
public class GraphApi(GraphService graphService) : ControllerBase
{
#region Graph Endpoints
[HttpGet("list")]
public async Task<IActionResult> ListGraphs()
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
var graphs = await graphService.GetUserGraphsAsync(userId.Value);
return Ok(graphs);
}
[HttpGet("{graphId}")]
public async Task<IActionResult> GetGraph(long graphId)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
var graph = await graphService.GetGraphByIdAsync(graphId, userId.Value);
if (graph == null)
{
return NotFound(new { error = "Graph not found" });
}
return Ok(graph);
}
[HttpGet("{graphId}/data")]
public async Task<IActionResult> GetGraphData(long graphId)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
var graphData = await graphService.GetGraphDataAsync(graphId, userId.Value);
if (graphData.Graph == null || graphData.Graph.Id == 0)
{
return NotFound(new { error = "Graph not found" });
}
return Ok(graphData);
}
[HttpPost("create")]
public async Task<IActionResult> CreateGraph([FromBody] CreateGraphRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return BadRequest(new { error = "Graph name is required" });
}
var graph = await graphService.CreateGraphAsync(userId.Value, request.Name, request.Description);
if (graph == null)
{
return BadRequest(new { error = "Failed to create graph" });
}
return Ok(graph);
}
[HttpPut("{graphId}")]
public async Task<IActionResult> UpdateGraph(long graphId, [FromBody] UpdateGraphRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return BadRequest(new { error = "Graph name is required" });
}
var success = await graphService.UpdateGraphAsync(graphId, userId.Value, request.Name, request.Description);
if (!success)
{
return BadRequest(new { error = "Failed to update graph" });
}
return Ok(new { success = true });
}
[HttpDelete("{graphId}")]
public async Task<IActionResult> DeleteGraph(long graphId)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
var success = await graphService.DeleteGraphAsync(graphId, userId.Value);
if (!success)
{
return BadRequest(new { error = "Failed to delete graph" });
}
return Ok(new { success = true });
}
[HttpPost("{graphId}/clone")]
public async Task<IActionResult> CloneGraph(long graphId, [FromBody] CloneGraphRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
if (string.IsNullOrWhiteSpace(request.NewName))
{
return BadRequest(new { error = "New graph name is required" });
}
var newGraph = await graphService.CloneGraphAsync(graphId, userId.Value, request.NewName);
if (newGraph == null)
{
return BadRequest(new { error = "Failed to clone graph" });
}
return Ok(newGraph);
}
#endregion
#region Node Endpoints
[HttpGet("{graphId}/nodes")]
public async Task<IActionResult> GetNodes(long graphId)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
var nodes = await graphService.GetGraphNodesAsync(graphId, userId.Value);
return Ok(nodes);
}
[HttpPost("{graphId}/nodes")]
public async Task<IActionResult> CreateNode(long graphId, [FromBody] CreateNodeRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
if (string.IsNullOrWhiteSpace(request.Label))
{
return BadRequest(new { error = "Node label is required" });
}
var node = await graphService.CreateNodeAsync(graphId, userId.Value, request.Label, request.Notes);
if (node == null)
{
return BadRequest(new { error = "Failed to create node" });
}
return Ok(node);
}
[HttpPut("nodes/{nodeId}")]
public async Task<IActionResult> UpdateNode(long nodeId, [FromBody] UpdateNodeRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
if (string.IsNullOrWhiteSpace(request.Label))
{
return BadRequest(new { error = "Node label is required" });
}
var success = await graphService.UpdateNodeAsync(nodeId, userId.Value, request.Label, request.Notes);
if (!success)
{
return BadRequest(new { error = "Failed to update node" });
}
return Ok(new { success = true });
}
[HttpDelete("nodes/{nodeId}")]
public async Task<IActionResult> DeleteNode(long nodeId)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
var success = await graphService.DeleteNodeAsync(nodeId, userId.Value);
if (!success)
{
return BadRequest(new { error = "Failed to delete node" });
}
return Ok(new { success = true });
}
[HttpGet("{graphId}/nodes/search")]
public async Task<IActionResult> SearchNodes(long graphId, [FromQuery] string q)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
if (string.IsNullOrWhiteSpace(q))
{
return BadRequest(new { error = "Search term is required" });
}
var nodes = await graphService.SearchNodesAsync(graphId, userId.Value, q);
return Ok(nodes);
}
#endregion
#region Edge Endpoints
[HttpGet("{graphId}/edges")]
public async Task<IActionResult> GetEdges(long graphId)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
var edges = await graphService.GetGraphEdgesAsync(graphId, userId.Value);
return Ok(edges);
}
[HttpPost("{graphId}/edges")]
public async Task<IActionResult> CreateEdge(long graphId, [FromBody] CreateEdgeRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
var edge = await graphService.CreateEdgeAsync(graphId, userId.Value, request.SourceNodeId, request.TargetNodeId);
if (edge == null)
{
return BadRequest(new { error = "Failed to create edge" });
}
return Ok(edge);
}
[HttpDelete("edges/{edgeId}")]
public async Task<IActionResult> DeleteEdge(long edgeId)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
var success = await graphService.DeleteEdgeAsync(edgeId, userId.Value);
if (!success)
{
return BadRequest(new { error = "Failed to delete edge" });
}
return Ok(new { success = true });
}
#endregion
#region Filter Endpoints
[HttpGet("{graphId}/nodes/filter/connects-to/{targetNodeId}")]
public async Task<IActionResult> FilterConnectsTo(long graphId, long targetNodeId)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
var nodes = await graphService.FilterConnectsToAsync(graphId, userId.Value, targetNodeId);
return Ok(nodes);
}
[HttpGet("{graphId}/nodes/filter/not-connects-to/{targetNodeId}")]
public async Task<IActionResult> FilterDoesNotConnectTo(long graphId, long targetNodeId)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
var nodes = await graphService.FilterDoesNotConnectToAsync(graphId, userId.Value, targetNodeId);
return Ok(nodes);
}
[HttpGet("{graphId}/nodes/filter/two-degrees/{targetNodeId}")]
public async Task<IActionResult> FilterTwoDegrees(long graphId, long targetNodeId)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(new { error = "Not authenticated" });
}
var nodes = await graphService.FilterConnectsTwoDegreesAsync(graphId, userId.Value, targetNodeId);
return Ok(nodes);
}
#endregion
#region Helper Methods
private long? GetUserIdFromAuth()
{
// First try JWT claims (for mobile/API authentication)
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
{
return jwtUserId;
}
// Fall back to session (for web authentication)
var userIdString = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
{
return sessionUserId;
}
return null;
}
#endregion
}
#region Request Models
public record CreateGraphRequest(string Name, string? Description);
public record UpdateGraphRequest(string Name, string? Description);
public record CloneGraphRequest(string NewName);
public record CreateNodeRequest(string Label, string? Notes);
public record UpdateNodeRequest(string Label, string? Notes);
public record CreateEdgeRequest(long SourceNodeId, long TargetNodeId);
#endregion
+8 -17
View File
@@ -6,17 +6,8 @@ namespace Media.JoshHeaps.Net.Api;
[ApiController] [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,2 @@
CREATE SCHEMA IF NOT EXISTS app
AUTHORIZATION josh;
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS app.users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
email_verified BOOLEAN DEFAULT FALSE,
failed_login_attempts INTEGER DEFAULT 0,
locked_until TIMESTAMP NULL,
last_login TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_email ON app.users(email);
CREATE INDEX IF NOT EXISTS idx_username ON app.users(username);
@@ -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);
@@ -0,0 +1,20 @@
-- Roles table for role-based access control
CREATE TABLE IF NOT EXISTS app.roles (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Seed the admin role
INSERT INTO app.roles (name) VALUES ('admin') ON CONFLICT (name) DO NOTHING;
-- User roles junction table
CREATE TABLE IF NOT EXISTS app.user_roles (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES app.users(id),
role_id BIGINT NOT NULL REFERENCES app.roles(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_user_role UNIQUE (user_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON app.user_roles(user_id);
+16
View File
@@ -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();
}
} }
+17
View File
@@ -0,0 +1,17 @@
namespace Media.JoshHeaps.Net.Models;
public class Graph
{
public long Id { get; set; }
public long UserId { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
public class GraphWithCounts : Graph
{
public int NodeCount { get; set; }
public int EdgeCount { get; set; }
}
+17
View File
@@ -0,0 +1,17 @@
namespace Media.JoshHeaps.Net.Models;
public class GraphEdge
{
public long Id { get; set; }
public long GraphId { get; set; }
public long SourceNodeId { get; set; }
public long TargetNodeId { get; set; }
public DateTime CreatedAt { get; set; }
}
public class GraphData
{
public Graph Graph { get; set; } = new();
public List<GraphNodeWithConnections> Nodes { get; set; } = [];
public List<GraphEdge> Edges { get; set; } = [];
}
+16
View File
@@ -0,0 +1,16 @@
namespace Media.JoshHeaps.Net.Models;
public class GraphNode
{
public long Id { get; set; }
public long GraphId { get; set; }
public string Label { get; set; } = string.Empty;
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
public class GraphNodeWithConnections : GraphNode
{
public int ConnectionCount { get; set; }
}
+64
View File
@@ -0,0 +1,64 @@
@page
@model Media.JoshHeaps.Net.Pages.AdminModel
@{
ViewData["Title"] = "Admin";
Layout = "_Layout";
}
@section Styles {
<link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
}
<div class="dashboard-container">
<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>Admin</h1>
</div>
<div class="quick-actions">
<a href="/Profile" class="btn btn-secondary">Profile</a>
<a href="/Logout" class="btn btn-danger">Logout</a>
</div>
</div>
<div class="admin-section">
<div class="section-header">
<h2>Roles</h2>
</div>
<div class="roles-list" id="rolesList"></div>
<div class="create-role-form">
<input type="text" id="newRoleName" placeholder="New role name" class="form-input" />
<button id="createRoleBtn" class="btn btn-primary">Create Role</button>
</div>
</div>
<div class="admin-section">
<div class="section-header">
<h2>Users</h2>
</div>
<div class="table-container">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Roles</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="usersTableBody">
</tbody>
</table>
</div>
<div class="pagination" id="pagination"></div>
</div>
</div>
@section Scripts {
<script src="~/js/admin.js" asp-append-version="true"></script>
}
+20
View File
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Pages
{
public class AdminModel(DbExecutor dbExecutor) : AuthenticatedPageModel
{
private readonly DbExecutor _dbExecutor = dbExecutor;
public async Task<IActionResult> OnGetAsync()
{
RequireAuthentication();
LoadUserSession();
var denied = await RequireRole("admin", _dbExecutor);
if (denied != null) return denied;
return Page();
}
}
}
@@ -5,6 +5,15 @@ namespace Media.JoshHeaps.Net.Pages;
public abstract class AuthenticatedPageModel : PageModel public abstract class AuthenticatedPageModel : PageModel
{ {
protected async Task<IActionResult?> RequireRole(string role, DbExecutor dbExecutor)
{
var hasRole = await dbExecutor.ExecuteAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = @Role)",
new { UserId, Role = role });
return hasRole ? null : NotFound();
}
public long UserId { get; private set; } public long UserId { get; private set; }
protected string Username { get; private set; } = string.Empty; protected string Username { get; private set; } = string.Empty;
protected string Email { get; private set; } = string.Empty; protected string Email { get; private set; } = string.Empty;
@@ -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">
<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> <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"/>
@@ -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)
{ {
+133
View File
@@ -0,0 +1,133 @@
@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>
@if (Model.IsAdmin)
{
<a href="/Admin" 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">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
</svg>
</div>
</div>
<div class="card-text">
<h2>Admin</h2>
<p class="card-description">Site administration and management tools</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,21 @@
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Pages
{
public class LandingModel(DbExecutor dbExecutor) : AuthenticatedPageModel
{
public bool IsAdmin { get; set; }
public async Task<IActionResult> OnGetAsync()
{
RequireAuthentication();
LoadUserSession();
IsAdmin = await dbExecutor.ExecuteAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'admin')",
new { UserId });
return Page();
}
}
}
+4 -11
View File
@@ -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()">&times;</button>
</div>
<div class="modal-body">
<form id="graph-form">
<div class="form-group">
<label for="graph-name">Graph Name *</label>
<input type="text" id="graph-name" class="form-control" required />
</div>
<div class="form-group">
<label for="graph-description">Description</label>
<textarea id="graph-description" class="form-control" rows="3"></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeGraphModal()">Cancel</button>
<button type="submit" class="btn btn-primary" id="save-graph-btn">Create Graph</button>
</div>
</form>
</div>
</div>
</div>
<!-- Node Modal -->
<div id="node-modal" class="modal" style="display: none;">
<div class="modal-backdrop" onclick="closeNodeModal()"></div>
<div class="modal-dialog modal-dialog-large">
<div class="modal-header">
<h3 id="node-modal-title">New Node</h3>
<button type="button" class="modal-close" onclick="closeNodeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="node-form">
<div class="form-group">
<label for="node-label">Label *</label>
<input type="text" id="node-label" class="form-control" required />
</div>
<div class="form-group">
<label for="node-notes">Notes</label>
<textarea id="node-notes" class="form-control" rows="5" placeholder="Add detailed notes about this node..."></textarea>
</div>
<!-- Connections Section (only shown when editing) -->
<div id="node-connections-section" style="display: none;">
<div class="form-group">
<label>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
Connections
</label>
<!-- Add Connection -->
<div class="connection-search-container">
<input type="text" id="connection-search" class="form-control" placeholder="Search nodes to connect..." autocomplete="off" />
<div id="connection-search-results" class="search-results-dropdown" style="display: none;"></div>
</div>
<!-- Current Connections List -->
<div id="node-connections-list" class="connections-list"></div>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeNodeModal()">Cancel</button>
<button type="submit" class="btn btn-primary" id="save-node-btn">Create Node</button>
</div>
</form>
</div>
</div>
</div>
<!-- Context Menu -->
<div id="node-context-menu" class="context-menu" style="display: none;">
<div class="context-menu-item" data-action="edit">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
Edit Node
</div>
<div class="context-menu-item" data-action="link">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
Link to Selected
</div>
<div class="context-menu-item" data-action="select">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 11 12 14 22 4"></polyline>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
Add to Selection
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item danger" data-action="delete">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
Delete Node
</div>
</div>
@section Scripts {
<script>
const selectedGraphId = @(Model.SelectedGraphId?.ToString() ?? "null");
</script>
<script src="~/js/network-graph.js" asp-append-version="true"></script>
}
@@ -0,0 +1,25 @@
using Media.JoshHeaps.Net.Models;
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Pages
{
public class NetworkGraphModel(GraphService graphService) : AuthenticatedPageModel
{
private readonly GraphService _graphService = graphService;
public List<GraphWithCounts> Graphs { get; set; } = [];
public long? SelectedGraphId { get; set; }
public async Task<IActionResult> OnGetAsync([FromQuery] long? graphId = null)
{
RequireAuthentication();
LoadUserSession();
Graphs = await _graphService.GetUserGraphsAsync(UserId);
SelectedGraphId = graphId;
return Page();
}
}
}
+3 -12
View File
@@ -5,11 +5,8 @@ using System.Text.RegularExpressions;
namespace Media.JoshHeaps.Net.Pages; 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;
+8
View File
@@ -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();
+21 -30
View File
@@ -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;
} }
} }
+45 -54
View File
@@ -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
}
+27 -42
View File
@@ -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;
} }
} }
+6 -13
View File
@@ -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;
}
+355
View File
@@ -0,0 +1,355 @@
.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;
}
.quick-actions {
display: flex;
gap: 12px;
}
.btn {
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s ease;
}
.btn-primary {
background: var(--accent-primary);
color: #fff;
border-color: var(--accent-primary);
}
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-primary);
}
.btn-secondary:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.btn-danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.btn-danger:hover {
background: var(--danger-hover);
border-color: var(--danger-hover);
}
/* Admin sections */
.admin-section {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
}
.section-header {
margin-bottom: 16px;
}
.section-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
/* Roles list */
.roles-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.role-pill {
display: inline-flex;
align-items: center;
padding: 6px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 20px;
font-size: 13px;
color: var(--text-primary);
}
/* Create role form */
.create-role-form {
display: flex;
gap: 8px;
align-items: center;
}
.form-input {
padding: 8px 12px;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 6px;
color: var(--text-primary);
font-size: 14px;
outline: none;
transition: border-color 0.2s ease;
}
.form-input:focus {
border-color: var(--accent-primary);
}
/* Users table */
.table-container {
overflow-x: auto;
}
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-secondary);
}
.admin-table th {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
background: var(--bg-tertiary);
}
.admin-table td {
font-size: 14px;
color: var(--text-primary);
}
.admin-table tbody tr:hover {
background: var(--bg-hover);
}
/* Role badges in table */
.role-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.role-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
background: var(--accent-primary);
color: #fff;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.role-badge .remove-role {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: none;
background: rgba(255, 255, 255, 0.2);
color: #fff;
border-radius: 50%;
font-size: 11px;
cursor: pointer;
line-height: 1;
padding: 0;
transition: background 0.2s ease;
}
.role-badge .remove-role:hover {
background: rgba(255, 255, 255, 0.4);
}
/* Add role dropdown */
.add-role-wrapper {
position: relative;
display: inline-block;
}
.add-role-btn {
padding: 4px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 6px;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.add-role-btn:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.add-role-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 10;
min-width: 140px;
display: none;
}
.add-role-dropdown.open {
display: block;
}
.add-role-dropdown button {
display: block;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
color: var(--text-primary);
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.add-role-dropdown button:hover {
background: var(--bg-hover);
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
}
.pagination button {
padding: 8px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
}
.pagination button:hover:not(:disabled) {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.pagination button:disabled {
opacity: 0.4;
cursor: default;
}
.pagination .page-info {
font-size: 13px;
color: var(--text-secondary);
}
/* Responsive */
@media (max-width: 768px) {
.welcome-section {
flex-direction: column;
gap: 16px;
padding: 20px;
align-items: flex-start;
}
.admin-table th,
.admin-table td {
padding: 8px 10px;
}
.create-role-form {
flex-direction: column;
align-items: stretch;
}
}
+344
View File
@@ -0,0 +1,344 @@
/* Landing Page Styles */
.landing-wrapper {
min-height: 100vh;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.landing-container {
width: 100%;
max-width: 1000px;
margin: 0 auto;
}
/* Header Section */
.landing-header {
text-align: center;
margin-bottom: 4rem;
}
.logo-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.logo-circle {
width: 72px;
height: 72px;
background: linear-gradient(135deg, var(--accent-primary), #3d8bdb);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
box-shadow: 0 4px 12px rgba(88, 166, 255, 0.3);
}
.logo-circle svg {
width: 36px;
height: 36px;
color: white;
}
.landing-header h1 {
font-size: 2.75rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.02em;
}
.landing-header .subtitle {
font-size: 1.125rem;
color: var(--text-secondary);
margin: 0;
font-weight: 400;
}
/* Cards Grid */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
/* Card Styles */
.landing-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 16px;
padding: 0;
text-decoration: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
min-height: 280px;
}
.landing-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg,
rgba(88, 166, 255, 0.05) 0%,
rgba(88, 166, 255, 0) 50%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.landing-card:hover {
transform: translateY(-4px);
border-color: var(--accent-primary);
box-shadow:
0 12px 24px -10px rgba(88, 166, 255, 0.2),
0 0 0 1px var(--accent-primary);
}
.landing-card:hover::before {
opacity: 1;
}
.landing-card:active {
transform: translateY(-2px);
}
/* Card Content */
.card-content {
padding: 2rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.card-icon-wrapper {
display: flex;
align-items: center;
justify-content: flex-start;
}
.card-icon {
width: 56px;
height: 56px;
background: linear-gradient(135deg,
rgba(88, 166, 255, 0.15),
rgba(88, 166, 255, 0.05));
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-primary);
transition: all 0.3s ease;
}
.landing-card:hover .card-icon {
transform: scale(1.05);
background: linear-gradient(135deg,
rgba(88, 166, 255, 0.2),
rgba(88, 166, 255, 0.1));
}
.card-icon svg {
width: 28px;
height: 28px;
}
.card-text {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.landing-card h2 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.01em;
}
.card-description {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
/* Card Footer */
.card-footer {
padding: 1.25rem 2rem;
border-top: 1px solid var(--border-primary);
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(88, 166, 255, 0.02);
transition: background 0.3s ease;
}
.landing-card:hover .card-footer {
background: rgba(88, 166, 255, 0.05);
}
.card-link-text {
font-size: 0.875rem;
font-weight: 500;
color: var(--accent-primary);
transition: color 0.2s ease;
}
.landing-card:hover .card-link-text {
color: var(--accent-hover);
}
.card-arrow {
width: 20px;
height: 20px;
color: var(--accent-primary);
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.landing-card:hover .card-arrow {
transform: translateX(4px);
color: var(--accent-hover);
}
.card-arrow svg {
width: 100%;
height: 100%;
}
/* Footer */
.landing-footer {
text-align: center;
padding-top: 2rem;
border-top: 1px solid var(--border-primary);
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.footer-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
transition: color 0.2s ease;
}
.footer-link:hover {
color: var(--accent-primary);
}
.footer-separator {
color: var(--border-primary);
font-size: 0.875rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.landing-wrapper {
padding: 1.5rem;
}
.landing-header {
margin-bottom: 3rem;
}
.logo-circle {
width: 64px;
height: 64px;
}
.logo-circle svg {
width: 32px;
height: 32px;
}
.landing-header h1 {
font-size: 2.25rem;
}
.landing-header .subtitle {
font-size: 1rem;
}
.cards-grid {
grid-template-columns: 1fr;
gap: 1.25rem;
margin-bottom: 2.5rem;
}
.landing-card {
min-height: 260px;
}
.card-content {
padding: 1.75rem;
}
.card-icon {
width: 48px;
height: 48px;
}
.card-icon svg {
width: 24px;
height: 24px;
}
.landing-card h2 {
font-size: 1.375rem;
}
.card-footer {
padding: 1rem 1.75rem;
}
}
@media (max-width: 480px) {
.landing-wrapper {
padding: 1rem;
}
.landing-header {
margin-bottom: 2.5rem;
}
.logo-circle {
width: 56px;
height: 56px;
}
.logo-circle svg {
width: 28px;
height: 28px;
}
.landing-header h1 {
font-size: 1.875rem;
}
.card-content {
padding: 1.5rem;
}
.card-footer {
padding: 1rem 1.5rem;
}
}
@@ -0,0 +1,528 @@
/* Network Graph Page Styles */
/* Graph Manager Section */
.graph-manager-section {
margin-bottom: 32px;
}
.graph-selector-card {
max-width: 100%;
}
.graphs-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 20px;
}
.graph-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: var(--bg-tertiary);
border: 2px solid var(--border-primary);
border-radius: 8px;
transition: all 0.2s ease;
cursor: pointer;
}
.graph-item:hover {
border-color: var(--border-secondary);
background: var(--bg-hover);
}
.graph-item.active {
border-color: var(--accent-primary);
background: rgba(88, 166, 255, 0.05);
}
.graph-info {
flex: 1;
}
.graph-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.graph-stats {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--text-secondary);
}
.graph-stats .stat {
display: flex;
align-items: center;
gap: 4px;
}
.graph-actions {
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.2s ease;
}
.graph-item:hover .graph-actions {
opacity: 1;
}
.btn-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-icon:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.btn-icon.danger:hover {
border-color: var(--danger);
color: var(--danger);
}
/* Graph Workspace */
.graph-workspace {
display: flex;
flex-direction: column;
gap: 16px;
}
.graph-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
flex-wrap: wrap;
gap: 16px;
}
.toolbar-section {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.search-input {
padding: 8px 12px;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 14px;
min-width: 200px;
}
.search-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.filter-select {
padding: 8px 12px;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: var(--accent-primary);
}
/* Canvas */
.graph-canvas-container {
position: relative;
height: 600px;
padding: 0;
overflow: hidden;
}
#graph-canvas {
width: 100%;
height: 100%;
cursor: grab;
}
#graph-canvas:active {
cursor: grabbing;
}
.canvas-controls {
position: absolute;
bottom: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.canvas-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.canvas-btn:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.canvas-help-text {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 8px 16px;
font-size: 13px;
color: var(--text-secondary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
pointer-events: none;
user-select: none;
z-index: 10;
}
/* Context Menu */
.context-menu {
position: fixed;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
z-index: 2000;
min-width: 180px;
overflow: hidden;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
cursor: pointer;
color: var(--text-primary);
font-size: 14px;
transition: background 0.2s ease;
}
.context-menu-item:hover {
background: var(--bg-hover);
}
.context-menu-item svg {
flex-shrink: 0;
}
.context-menu-item.danger {
color: var(--danger);
}
.context-menu-item.danger:hover {
background: rgba(239, 68, 68, 0.1);
}
.context-menu-divider {
height: 1px;
background: var(--border-primary);
margin: 4px 0;
}
/* Modal Styles */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.modal-dialog {
position: relative;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
z-index: 1001;
}
.modal-dialog-large {
max-width: 650px;
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-primary);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 24px;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
}
.modal-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.modal-body {
padding: 24px;
overflow-y: auto;
max-height: calc(90vh - 120px);
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-primary);
font-size: 14px;
}
.form-control {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--accent-primary);
}
textarea.form-control {
resize: vertical;
min-height: 80px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
/* Connection Management in Modal */
.connection-search-container {
position: relative;
margin-bottom: 16px;
}
.search-results-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 6px;
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
}
.search-result-item {
padding: 10px 12px;
cursor: pointer;
transition: background 0.2s ease;
border-bottom: 1px solid var(--border-primary);
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover {
background: var(--bg-hover);
}
.search-result-label {
font-weight: 500;
color: var(--text-primary);
font-size: 14px;
}
.search-result-notes {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.connections-list {
margin-top: 12px;
border: 1px solid var(--border-primary);
border-radius: 6px;
overflow: hidden;
}
.connection-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-primary);
}
.connection-item:last-child {
border-bottom: none;
}
.connection-info {
flex: 1;
min-width: 0;
}
.connection-label {
font-weight: 500;
color: var(--text-primary);
font-size: 14px;
}
.connection-type {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
}
.connection-remove {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
margin-left: 8px;
}
.connection-remove:hover {
background: var(--danger);
border-color: var(--danger);
color: white;
}
.connections-empty {
padding: 20px;
text-align: center;
color: var(--text-secondary);
font-size: 14px;
background: var(--bg-tertiary);
}
/* Responsive */
@media (max-width: 768px) {
.graph-toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar-section {
width: 100%;
justify-content: space-between;
}
.search-input {
flex: 1;
min-width: 0;
}
.graph-canvas-container {
height: 400px;
}
.modal-dialog {
width: 95%;
}
}
+155
View File
@@ -0,0 +1,155 @@
(function () {
let allRoles = [];
let currentPage = 1;
const pageSize = 20;
document.addEventListener('DOMContentLoaded', init);
async function init() {
await loadRoles();
await loadUsers(1);
document.getElementById('createRoleBtn').addEventListener('click', createRole);
document.getElementById('newRoleName').addEventListener('keydown', (e) => {
if (e.key === 'Enter') createRole();
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.add-role-wrapper')) {
document.querySelectorAll('.add-role-dropdown.open').forEach(d => d.classList.remove('open'));
}
});
}
async function loadRoles() {
const res = await fetch('/api/admin/roles');
if (!res.ok) return;
allRoles = await res.json();
renderRolesList();
}
function renderRolesList() {
const container = document.getElementById('rolesList');
container.innerHTML = allRoles.map(r =>
`<span class="role-pill">${escapeHtml(r.name)}</span>`
).join('');
}
async function createRole() {
const input = document.getElementById('newRoleName');
const name = input.value.trim();
if (!name) return;
const res = await fetch('/api/admin/roles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!res.ok) {
const err = await res.json();
alert(err.error || 'Failed to create role');
return;
}
input.value = '';
await loadRoles();
await loadUsers(currentPage);
}
async function loadUsers(page) {
currentPage = page;
const res = await fetch(`/api/admin/users?page=${page}&pageSize=${pageSize}`);
if (!res.ok) return;
const data = await res.json();
renderUsersTable(data.users);
renderPagination(data.totalCount);
}
function renderUsersTable(users) {
const tbody = document.getElementById('usersTableBody');
tbody.innerHTML = users.map(user => {
const roleBadges = user.roles.map(r =>
`<span class="role-badge">
${escapeHtml(r.name)}
<button class="remove-role" onclick="adminRemoveRole(${user.id}, ${r.id})" title="Remove role">&times;</button>
</span>`
).join('');
const availableRoles = allRoles.filter(r => !user.roles.some(ur => ur.id === r.id));
const addDropdown = availableRoles.length > 0
? `<div class="add-role-wrapper">
<button class="add-role-btn" onclick="toggleRoleDropdown(this)">+ Add Role</button>
<div class="add-role-dropdown">
${availableRoles.map(r =>
`<button onclick="adminAddRole(${user.id}, ${r.id})">${escapeHtml(r.name)}</button>`
).join('')}
</div>
</div>`
: '';
return `<tr>
<td>${user.id}</td>
<td>${escapeHtml(user.username)}</td>
<td><div class="role-badges">${roleBadges}</div></td>
<td>${addDropdown}</td>
</tr>`;
}).join('');
}
function renderPagination(totalCount) {
const totalPages = Math.ceil(totalCount / pageSize);
const container = document.getElementById('pagination');
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
container.innerHTML = `
<button onclick="adminGoToPage(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>Previous</button>
<span class="page-info">Page ${currentPage} of ${totalPages}</span>
<button onclick="adminGoToPage(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>Next</button>
`;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Global functions for inline event handlers
window.adminAddRole = async function (userId, roleId) {
const res = await fetch(`/api/admin/users/${userId}/roles/${roleId}`, { method: 'POST' });
if (!res.ok) {
const err = await res.json();
alert(err.error || 'Failed to add role');
return;
}
await loadUsers(currentPage);
};
window.adminRemoveRole = async function (userId, roleId) {
const res = await fetch(`/api/admin/users/${userId}/roles/${roleId}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json();
alert(err.error || 'Failed to remove role');
return;
}
await loadUsers(currentPage);
};
window.toggleRoleDropdown = function (btn) {
const dropdown = btn.nextElementSibling;
document.querySelectorAll('.add-role-dropdown.open').forEach(d => {
if (d !== dropdown) d.classList.remove('open');
});
dropdown.classList.toggle('open');
};
window.adminGoToPage = function (page) {
loadUsers(page);
};
})();
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
# Run Database Migrations
# This script executes all SQL files in the Database folder in order
param(
[string]$ConnectionString = ""
)
if ([string]::IsNullOrEmpty($ConnectionString)) {
Write-Host "Usage: .\run-migrations.ps1 -ConnectionString 'your-connection-string'"
Write-Host ""
Write-Host "Example:"
Write-Host ".\run-migrations.ps1 -ConnectionString 'Host=localhost;Database=mydb;Username=postgres;Password=mypassword'"
exit 1
}
# Get all SQL files in the Database folder, sorted by name
$sqlFiles = Get-ChildItem -Path ".\Media.JoshHeaps.Net\Database\*.sql" | Sort-Object Name
if ($sqlFiles.Count -eq 0) {
Write-Host "No SQL migration files found in .\Media.JoshHeaps.Net\Database\"
exit 1
}
Write-Host "Found $($sqlFiles.Count) migration files to execute:"
$sqlFiles | ForEach-Object { Write-Host " - $($_.Name)" }
Write-Host ""
# Check if psql is available
$psqlPath = Get-Command psql -ErrorAction SilentlyContinue
if (-not $psqlPath) {
Write-Host "ERROR: psql command not found. Please install PostgreSQL client tools."
Write-Host ""
Write-Host "Alternatively, you can manually run each SQL file using your preferred PostgreSQL client:"
$sqlFiles | ForEach-Object {
Write-Host " psql `"$ConnectionString`" -f `"$($_.FullName)`""
}
exit 1
}
# Execute each migration
$successCount = 0
$failCount = 0
foreach ($file in $sqlFiles) {
Write-Host "Executing: $($file.Name)... " -NoNewline
try {
$result = psql $ConnectionString -f $file.FullName 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "SUCCESS" -ForegroundColor Green
$successCount++
} else {
Write-Host "FAILED" -ForegroundColor Red
Write-Host " Error: $result"
$failCount++
}
} catch {
Write-Host "FAILED" -ForegroundColor Red
Write-Host " Error: $($_.Exception.Message)"
$failCount++
}
}
Write-Host ""
Write-Host "Migration Summary:"
Write-Host " Successful: $successCount" -ForegroundColor Green
Write-Host " Failed: $failCount" -ForegroundColor $(if ($failCount -gt 0) { "Red" } else { "Green" })
if ($failCount -gt 0) {
exit 1
}