Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e69e5663ce | ||
|
|
14d748de80 | ||
|
|
9bbbe3aaad | ||
|
|
c9aa9886e4 | ||
|
|
a26ffec104 | ||
|
|
dae8fbfe5e |
@@ -6,7 +6,12 @@
|
||||
"Bash(powershell:*)",
|
||||
"Bash(dotnet script:*)",
|
||||
"Bash(taskkill:*)",
|
||||
"Bash(dir:*)"
|
||||
"Bash(dir:*)",
|
||||
"Bash(cd:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(del Index.cshtml Index.cshtml.cs)",
|
||||
"Bash(dotnet build:*)",
|
||||
"Bash(find:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -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 001–011 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
|
||||
@@ -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);
|
||||
@@ -11,17 +11,8 @@ namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthApi : ControllerBase
|
||||
public class AuthApi(AuthService authService, IConfiguration configuration) : ControllerBase
|
||||
{
|
||||
private readonly AuthService _authService;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public AuthApi(AuthService authService, IConfiguration configuration)
|
||||
{
|
||||
_authService = authService;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
@@ -30,7 +21,7 @@ public class AuthApi : ControllerBase
|
||||
return BadRequest(new { error = "Email/username and password are required" });
|
||||
}
|
||||
|
||||
var (success, error, userInfo) = await _authService.LoginAsync(request.EmailOrUsername, request.Password);
|
||||
var (success, error, userInfo) = await authService.LoginAsync(request.EmailOrUsername, request.Password);
|
||||
|
||||
if (!success || userInfo == null)
|
||||
{
|
||||
@@ -77,10 +68,10 @@ public class AuthApi : ControllerBase
|
||||
|
||||
private string GenerateJwtToken(UserLoginInfo user)
|
||||
{
|
||||
var jwtKey = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured");
|
||||
var jwtIssuer = _configuration["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured");
|
||||
var jwtAudience = _configuration["Jwt:Audience"] ?? throw new InvalidOperationException("JWT Audience not configured");
|
||||
var jwtExpiryDays = int.Parse(_configuration["Jwt:ExpiryInDays"] ?? "30");
|
||||
var jwtKey = configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured");
|
||||
var jwtIssuer = configuration["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured");
|
||||
var jwtAudience = configuration["Jwt:Audience"] ?? throw new InvalidOperationException("JWT Audience not configured");
|
||||
var jwtExpiryDays = int.Parse(configuration["Jwt:ExpiryInDays"] ?? "30");
|
||||
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
|
||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
@@ -6,15 +6,8 @@ namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/folder")]
|
||||
public class FolderApi : ControllerBase
|
||||
public class FolderApi(FolderService folderService) : ControllerBase
|
||||
{
|
||||
private readonly FolderService _folderService;
|
||||
|
||||
public FolderApi(FolderService folderService)
|
||||
{
|
||||
_folderService = folderService;
|
||||
}
|
||||
|
||||
[HttpGet("list")]
|
||||
public async Task<IActionResult> ListFolders([FromQuery] long? folderId = null)
|
||||
{
|
||||
@@ -24,7 +17,7 @@ public class FolderApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var folders = await _folderService.GetUserFoldersAsync(userId.Value, folderId);
|
||||
var folders = await folderService.GetUserFoldersAsync(userId.Value, folderId);
|
||||
return Ok(folders);
|
||||
}
|
||||
|
||||
@@ -37,7 +30,7 @@ public class FolderApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var path = await _folderService.GetFolderPathAsync(folderId, userId.Value);
|
||||
var path = await folderService.GetFolderPathAsync(folderId, userId.Value);
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
@@ -58,14 +51,14 @@ public class FolderApi : ControllerBase
|
||||
// If parent folder specified, check ownership (can't create in shared folders)
|
||||
if (request.ParentFolderId.HasValue)
|
||||
{
|
||||
var parentOwnerId = await _folderService.GetFolderOwnerIdAsync(request.ParentFolderId.Value);
|
||||
var parentOwnerId = await folderService.GetFolderOwnerIdAsync(request.ParentFolderId.Value);
|
||||
if (parentOwnerId != userId.Value)
|
||||
{
|
||||
return Forbid("Cannot create folders in shared folders");
|
||||
}
|
||||
}
|
||||
|
||||
var folder = await _folderService.CreateFolderAsync(userId.Value, request.Name, request.ParentFolderId);
|
||||
var folder = await folderService.CreateFolderAsync(userId.Value, request.Name, request.ParentFolderId);
|
||||
if (folder == null)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to create folder" });
|
||||
@@ -89,13 +82,13 @@ public class FolderApi : ControllerBase
|
||||
}
|
||||
|
||||
// Check ownership (can't rename shared folders)
|
||||
var ownerId = await _folderService.GetFolderOwnerIdAsync(request.FolderId);
|
||||
var ownerId = await folderService.GetFolderOwnerIdAsync(request.FolderId);
|
||||
if (ownerId != userId.Value)
|
||||
{
|
||||
return Forbid("Cannot rename shared folders");
|
||||
}
|
||||
|
||||
var success = await _folderService.RenameFolderAsync(request.FolderId, userId.Value, request.NewName);
|
||||
var success = await folderService.RenameFolderAsync(request.FolderId, userId.Value, request.NewName);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to rename folder" });
|
||||
@@ -114,13 +107,13 @@ public class FolderApi : ControllerBase
|
||||
}
|
||||
|
||||
// Check ownership (can't delete shared folders)
|
||||
var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId);
|
||||
var ownerId = await folderService.GetFolderOwnerIdAsync(folderId);
|
||||
if (ownerId != userId.Value)
|
||||
{
|
||||
return Forbid("Cannot delete shared folders");
|
||||
}
|
||||
|
||||
var success = await _folderService.DeleteFolderAsync(folderId, userId.Value, deleteContents);
|
||||
var success = await folderService.DeleteFolderAsync(folderId, userId.Value, deleteContents);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to delete folder" });
|
||||
@@ -139,13 +132,13 @@ public class FolderApi : ControllerBase
|
||||
}
|
||||
|
||||
// Check ownership (can't move shared folders)
|
||||
var ownerId = await _folderService.GetFolderOwnerIdAsync(request.FolderId);
|
||||
var ownerId = await folderService.GetFolderOwnerIdAsync(request.FolderId);
|
||||
if (ownerId != userId.Value)
|
||||
{
|
||||
return Forbid("Cannot move shared folders");
|
||||
}
|
||||
|
||||
var success = await _folderService.MoveFolderAsync(request.FolderId, userId.Value, request.NewParentFolderId);
|
||||
var success = await folderService.MoveFolderAsync(request.FolderId, userId.Value, request.NewParentFolderId);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to move folder" });
|
||||
|
||||
@@ -6,17 +6,8 @@ namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/folder-share")]
|
||||
public class FolderShareApi : ControllerBase
|
||||
public class FolderShareApi(FolderService folderService, UserService userService) : ControllerBase
|
||||
{
|
||||
private readonly FolderService _folderService;
|
||||
private readonly UserService _userService;
|
||||
|
||||
public FolderShareApi(FolderService folderService, UserService userService)
|
||||
{
|
||||
_folderService = folderService;
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
[HttpPost("share")]
|
||||
public async Task<IActionResult> ShareFolder([FromBody] ShareFolderRequest request)
|
||||
{
|
||||
@@ -26,7 +17,7 @@ public class FolderShareApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var share = await _folderService.ShareFolderAsync(request.FolderId, userId.Value, request.SharedWithUserId);
|
||||
var share = await folderService.ShareFolderAsync(request.FolderId, userId.Value, request.SharedWithUserId);
|
||||
if (share == null)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to share folder" });
|
||||
@@ -44,7 +35,7 @@ public class FolderShareApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var success = await _folderService.UnshareFolderAsync(folderId, userId.Value, sharedWithUserId);
|
||||
var success = await folderService.UnshareFolderAsync(folderId, userId.Value, sharedWithUserId);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to unshare folder" });
|
||||
@@ -62,7 +53,7 @@ public class FolderShareApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var shares = await _folderService.GetFolderSharesAsync(folderId, userId.Value);
|
||||
var shares = await folderService.GetFolderSharesAsync(folderId, userId.Value);
|
||||
return Ok(shares);
|
||||
}
|
||||
|
||||
@@ -75,7 +66,7 @@ public class FolderShareApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var sharedFolders = await _folderService.GetSharedFoldersAsync(userId.Value);
|
||||
var sharedFolders = await folderService.GetSharedFoldersAsync(userId.Value);
|
||||
return Ok(sharedFolders);
|
||||
}
|
||||
|
||||
@@ -93,7 +84,7 @@ public class FolderShareApi : ControllerBase
|
||||
return Ok(new List<object>());
|
||||
}
|
||||
|
||||
var users = await _userService.SearchUsersAsync(query, userId.Value);
|
||||
var users = await userService.SearchUsersAsync(query, userId.Value);
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/graph")]
|
||||
public class GraphApi(GraphService graphService) : ControllerBase
|
||||
{
|
||||
|
||||
#region Graph Endpoints
|
||||
|
||||
[HttpGet("list")]
|
||||
public async Task<IActionResult> ListGraphs()
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var graphs = await graphService.GetUserGraphsAsync(userId.Value);
|
||||
return Ok(graphs);
|
||||
}
|
||||
|
||||
[HttpGet("{graphId}")]
|
||||
public async Task<IActionResult> GetGraph(long graphId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var graph = await graphService.GetGraphByIdAsync(graphId, userId.Value);
|
||||
if (graph == null)
|
||||
{
|
||||
return NotFound(new { error = "Graph not found" });
|
||||
}
|
||||
|
||||
return Ok(graph);
|
||||
}
|
||||
|
||||
[HttpGet("{graphId}/data")]
|
||||
public async Task<IActionResult> GetGraphData(long graphId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var graphData = await graphService.GetGraphDataAsync(graphId, userId.Value);
|
||||
if (graphData.Graph == null || graphData.Graph.Id == 0)
|
||||
{
|
||||
return NotFound(new { error = "Graph not found" });
|
||||
}
|
||||
|
||||
return Ok(graphData);
|
||||
}
|
||||
|
||||
[HttpPost("create")]
|
||||
public async Task<IActionResult> CreateGraph([FromBody] CreateGraphRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return BadRequest(new { error = "Graph name is required" });
|
||||
}
|
||||
|
||||
var graph = await graphService.CreateGraphAsync(userId.Value, request.Name, request.Description);
|
||||
if (graph == null)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to create graph" });
|
||||
}
|
||||
|
||||
return Ok(graph);
|
||||
}
|
||||
|
||||
[HttpPut("{graphId}")]
|
||||
public async Task<IActionResult> UpdateGraph(long graphId, [FromBody] UpdateGraphRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return BadRequest(new { error = "Graph name is required" });
|
||||
}
|
||||
|
||||
var success = await graphService.UpdateGraphAsync(graphId, userId.Value, request.Name, request.Description);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to update graph" });
|
||||
}
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("{graphId}")]
|
||||
public async Task<IActionResult> DeleteGraph(long graphId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var success = await graphService.DeleteGraphAsync(graphId, userId.Value);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to delete graph" });
|
||||
}
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpPost("{graphId}/clone")]
|
||||
public async Task<IActionResult> CloneGraph(long graphId, [FromBody] CloneGraphRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.NewName))
|
||||
{
|
||||
return BadRequest(new { error = "New graph name is required" });
|
||||
}
|
||||
|
||||
var newGraph = await graphService.CloneGraphAsync(graphId, userId.Value, request.NewName);
|
||||
if (newGraph == null)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to clone graph" });
|
||||
}
|
||||
|
||||
return Ok(newGraph);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Node Endpoints
|
||||
|
||||
[HttpGet("{graphId}/nodes")]
|
||||
public async Task<IActionResult> GetNodes(long graphId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var nodes = await graphService.GetGraphNodesAsync(graphId, userId.Value);
|
||||
return Ok(nodes);
|
||||
}
|
||||
|
||||
[HttpPost("{graphId}/nodes")]
|
||||
public async Task<IActionResult> CreateNode(long graphId, [FromBody] CreateNodeRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Label))
|
||||
{
|
||||
return BadRequest(new { error = "Node label is required" });
|
||||
}
|
||||
|
||||
var node = await graphService.CreateNodeAsync(graphId, userId.Value, request.Label, request.Notes);
|
||||
if (node == null)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to create node" });
|
||||
}
|
||||
|
||||
return Ok(node);
|
||||
}
|
||||
|
||||
[HttpPut("nodes/{nodeId}")]
|
||||
public async Task<IActionResult> UpdateNode(long nodeId, [FromBody] UpdateNodeRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Label))
|
||||
{
|
||||
return BadRequest(new { error = "Node label is required" });
|
||||
}
|
||||
|
||||
var success = await graphService.UpdateNodeAsync(nodeId, userId.Value, request.Label, request.Notes);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to update node" });
|
||||
}
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("nodes/{nodeId}")]
|
||||
public async Task<IActionResult> DeleteNode(long nodeId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var success = await graphService.DeleteNodeAsync(nodeId, userId.Value);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to delete node" });
|
||||
}
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpGet("{graphId}/nodes/search")]
|
||||
public async Task<IActionResult> SearchNodes(long graphId, [FromQuery] string q)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
return BadRequest(new { error = "Search term is required" });
|
||||
}
|
||||
|
||||
var nodes = await graphService.SearchNodesAsync(graphId, userId.Value, q);
|
||||
return Ok(nodes);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Endpoints
|
||||
|
||||
[HttpGet("{graphId}/edges")]
|
||||
public async Task<IActionResult> GetEdges(long graphId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var edges = await graphService.GetGraphEdgesAsync(graphId, userId.Value);
|
||||
return Ok(edges);
|
||||
}
|
||||
|
||||
[HttpPost("{graphId}/edges")]
|
||||
public async Task<IActionResult> CreateEdge(long graphId, [FromBody] CreateEdgeRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var edge = await graphService.CreateEdgeAsync(graphId, userId.Value, request.SourceNodeId, request.TargetNodeId);
|
||||
if (edge == null)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to create edge" });
|
||||
}
|
||||
|
||||
return Ok(edge);
|
||||
}
|
||||
|
||||
[HttpDelete("edges/{edgeId}")]
|
||||
public async Task<IActionResult> DeleteEdge(long edgeId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var success = await graphService.DeleteEdgeAsync(edgeId, userId.Value);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to delete edge" });
|
||||
}
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Filter Endpoints
|
||||
|
||||
[HttpGet("{graphId}/nodes/filter/connects-to/{targetNodeId}")]
|
||||
public async Task<IActionResult> FilterConnectsTo(long graphId, long targetNodeId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var nodes = await graphService.FilterConnectsToAsync(graphId, userId.Value, targetNodeId);
|
||||
return Ok(nodes);
|
||||
}
|
||||
|
||||
[HttpGet("{graphId}/nodes/filter/not-connects-to/{targetNodeId}")]
|
||||
public async Task<IActionResult> FilterDoesNotConnectTo(long graphId, long targetNodeId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var nodes = await graphService.FilterDoesNotConnectToAsync(graphId, userId.Value, targetNodeId);
|
||||
return Ok(nodes);
|
||||
}
|
||||
|
||||
[HttpGet("{graphId}/nodes/filter/two-degrees/{targetNodeId}")]
|
||||
public async Task<IActionResult> FilterTwoDegrees(long graphId, long targetNodeId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var nodes = await graphService.FilterConnectsTwoDegreesAsync(graphId, userId.Value, targetNodeId);
|
||||
return Ok(nodes);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private long? GetUserIdFromAuth()
|
||||
{
|
||||
// First try JWT claims (for mobile/API authentication)
|
||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
|
||||
{
|
||||
return jwtUserId;
|
||||
}
|
||||
|
||||
// Fall back to session (for web authentication)
|
||||
var userIdString = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
|
||||
{
|
||||
return sessionUserId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Request Models
|
||||
|
||||
public record CreateGraphRequest(string Name, string? Description);
|
||||
public record UpdateGraphRequest(string Name, string? Description);
|
||||
public record CloneGraphRequest(string NewName);
|
||||
public record CreateNodeRequest(string Label, string? Notes);
|
||||
public record UpdateNodeRequest(string Label, string? Notes);
|
||||
public record CreateEdgeRequest(long SourceNodeId, long TargetNodeId);
|
||||
|
||||
#endregion
|
||||
@@ -6,17 +6,8 @@ namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/media")]
|
||||
public class MediaApi : ControllerBase
|
||||
public class MediaApi(MediaService mediaService, FolderService folderService) : ControllerBase
|
||||
{
|
||||
private readonly MediaService _mediaService;
|
||||
private readonly FolderService _folderService;
|
||||
|
||||
public MediaApi(MediaService mediaService, FolderService folderService)
|
||||
{
|
||||
_mediaService = mediaService;
|
||||
_folderService = folderService;
|
||||
}
|
||||
|
||||
[HttpGet("load")]
|
||||
public async Task<IActionResult> LoadMedia([FromQuery] int offset = 0, [FromQuery] int limit = 20, [FromQuery] long? folderId = null)
|
||||
{
|
||||
@@ -27,7 +18,7 @@ public class MediaApi : ControllerBase
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var media = await _mediaService.GetUserMediaAsync(userId.Value, offset, limit, folderId, userId.Value);
|
||||
var media = await mediaService.GetUserMediaAsync(userId.Value, offset, limit, folderId, userId.Value);
|
||||
|
||||
return Ok(media);
|
||||
}
|
||||
@@ -43,14 +34,14 @@ public class MediaApi : ControllerBase
|
||||
}
|
||||
|
||||
// Get media metadata (includes ownership check)
|
||||
var media = await _mediaService.GetMediaByIdAsync(mediaId, userId.Value);
|
||||
var media = await mediaService.GetMediaByIdAsync(mediaId, userId.Value);
|
||||
if (media == null)
|
||||
{
|
||||
return NotFound(new { error = "Image not found or access denied" });
|
||||
}
|
||||
|
||||
// Get decrypted image data
|
||||
var imageData = await _mediaService.GetDecryptedMediaDataAsync(mediaId, userId.Value);
|
||||
var imageData = await mediaService.GetDecryptedMediaDataAsync(mediaId, userId.Value);
|
||||
if (imageData == null)
|
||||
{
|
||||
return NotFound(new { error = "Image file not found" });
|
||||
@@ -73,7 +64,7 @@ public class MediaApi : ControllerBase
|
||||
// Check folder ownership if uploading to a folder (can't upload to shared folders)
|
||||
if (folderId.HasValue)
|
||||
{
|
||||
var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId.Value);
|
||||
var ownerId = await folderService.GetFolderOwnerIdAsync(folderId.Value);
|
||||
if (ownerId != userId.Value)
|
||||
{
|
||||
return Forbid("Cannot upload to shared folders");
|
||||
@@ -99,7 +90,7 @@ public class MediaApi : ControllerBase
|
||||
}
|
||||
|
||||
// Save media using existing service
|
||||
var media = await _mediaService.SaveMediaAsync(userId.Value, file, description, folderId);
|
||||
var media = await mediaService.SaveMediaAsync(userId.Value, file, description, folderId);
|
||||
if (media == null)
|
||||
{
|
||||
return StatusCode(500, new { error = "Failed to upload image" });
|
||||
@@ -118,7 +109,7 @@ public class MediaApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var success = await _mediaService.MoveMediaToFolderAsync(request.MediaId, userId.Value, request.FolderId);
|
||||
var success = await mediaService.MoveMediaToFolderAsync(request.MediaId, userId.Value, request.FolderId);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to move media" });
|
||||
@@ -147,7 +138,7 @@ public class MediaApi : ControllerBase
|
||||
|
||||
foreach (var mediaId in request.MediaIds)
|
||||
{
|
||||
var success = await _mediaService.MoveMediaToFolderAsync(mediaId, userId.Value, request.FolderId);
|
||||
var success = await mediaService.MoveMediaToFolderAsync(mediaId, userId.Value, request.FolderId);
|
||||
if (success)
|
||||
{
|
||||
successCount++;
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
@@ -103,4 +103,20 @@ public class DbExecutor(IConfiguration config)
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Executes a non-query command (INSERT, UPDATE, DELETE) and returns rows affected
|
||||
public async Task<int> ExecuteNonQueryAsync(string query, object? parameters = null)
|
||||
{
|
||||
parameters ??= new();
|
||||
using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
using var cmd = new NpgsqlCommand(query, conn);
|
||||
|
||||
foreach (var prop in parameters.GetType().GetProperties())
|
||||
{
|
||||
cmd.Parameters.AddWithValue($"@{prop.Name}", prop.GetValue(parameters) ?? DBNull.Value);
|
||||
}
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class Graph
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class GraphWithCounts : Graph
|
||||
{
|
||||
public int NodeCount { get; set; }
|
||||
public int EdgeCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class GraphEdge
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long GraphId { get; set; }
|
||||
public long SourceNodeId { get; set; }
|
||||
public long TargetNodeId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class GraphData
|
||||
{
|
||||
public Graph Graph { get; set; } = new();
|
||||
public List<GraphNodeWithConnections> Nodes { get; set; } = [];
|
||||
public List<GraphEdge> Edges { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class GraphNode
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long GraphId { get; set; }
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class GraphNodeWithConnections : GraphNode
|
||||
{
|
||||
public int ConnectionCount { get; set; }
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
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; }
|
||||
protected string Username { get; private set; } = string.Empty;
|
||||
protected string Email { get; private set; } = string.Empty;
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
@page "{handler?}"
|
||||
@model Media.JoshHeaps.Net.Pages.IndexModel
|
||||
@model Media.JoshHeaps.Net.Pages.GalleryModel
|
||||
@{
|
||||
ViewData["Title"] = "Dashboard";
|
||||
ViewData["Title"] = "Gallery";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/Home/page.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/css/Gallery/page.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/css/gallery.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
<div class="dashboard-container">
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-left">
|
||||
<a href="/Landing" class="back-button" title="Back to Home">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
<h1>Welcome back, @Model.Dashboard?.Username!</h1>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
@if (Model.Dashboard?.EmailVerified == false)
|
||||
{
|
||||
@@ -59,11 +67,11 @@
|
||||
<div class="card">
|
||||
<div class="gallery-header">
|
||||
<div class="breadcrumb-nav">
|
||||
<a href="/[email protected]">Home</a>
|
||||
<a href="/Gallery[email protected]">Home</a>
|
||||
@foreach (var folder in Model.FolderPath)
|
||||
{
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<a href="/[email protected]&[email protected]">@folder.Name</a>
|
||||
<a href="/Gallery[email protected]&[email protected]">@folder.Name</a>
|
||||
}
|
||||
@if (Model.IsSharedFolder && !string.IsNullOrEmpty(Model.SharedByUsername))
|
||||
{
|
||||
@@ -72,9 +80,9 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="view-mode-toggle">
|
||||
<a href="/?view=own" class="view-mode-btn @(Model.ViewMode == "own" ? "active" : "")">My Folders</a>
|
||||
<a href="/?view=shared" class="view-mode-btn @(Model.ViewMode == "shared" ? "active" : "")">Shared with Me</a>
|
||||
<a href="/?view=all" class="view-mode-btn @(Model.ViewMode == "all" ? "active" : "")">All</a>
|
||||
<a href="/Gallery?view=own" class="view-mode-btn @(Model.ViewMode == "own" ? "active" : "")">My Folders</a>
|
||||
<a href="/Gallery?view=shared" class="view-mode-btn @(Model.ViewMode == "shared" ? "active" : "")">Shared with Me</a>
|
||||
<a href="/Gallery?view=all" class="view-mode-btn @(Model.ViewMode == "all" ? "active" : "")">All</a>
|
||||
</div>
|
||||
@if (!Model.IsSharedFolder)
|
||||
{
|
||||
@@ -97,8 +105,8 @@
|
||||
@if (Model.CurrentFolderId.HasValue)
|
||||
{
|
||||
var backUrl = Model.FolderPath.Count > 1
|
||||
? $"/?folderId={Model.FolderPath[Model.FolderPath.Count - 2].Id}&view={Model.ViewMode}"
|
||||
: $"/?view={Model.ViewMode}";
|
||||
? $"/Gallery?folderId={Model.FolderPath[Model.FolderPath.Count - 2].Id}&view={Model.ViewMode}"
|
||||
: $"/Gallery?view={Model.ViewMode}";
|
||||
<div class="gallery-item folder-item" data-folder-id="back" onclick="window.location.href='@backUrl'">
|
||||
<div class="folder-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -110,7 +118,7 @@
|
||||
}
|
||||
@foreach (var sharedFolder in Model.SharedFolders)
|
||||
{
|
||||
<div class="gallery-item folder-item shared-folder-item" data-folder-id="@sharedFolder.FolderId" onclick="window.location.href='/[email protected]&[email protected]'">
|
||||
<div class="gallery-item folder-item shared-folder-item" data-folder-id="@sharedFolder.FolderId" onclick="window.location.href='/Gallery[email protected]&[email protected]'">
|
||||
<div class="folder-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
@@ -125,7 +133,7 @@
|
||||
}
|
||||
@foreach (var folder in Model.Folders)
|
||||
{
|
||||
<div class="gallery-item folder-item @(folder.UserId != Model.UserId ? "shared-folder-item" : "")" data-folder-id="@folder.Id" data-owner-id="@folder.UserId" onclick="window.location.href='/[email protected]&[email protected]'">
|
||||
<div class="gallery-item folder-item @(folder.UserId != Model.UserId ? "shared-folder-item" : "")" data-folder-id="@folder.Id" data-owner-id="@folder.UserId" onclick="window.location.href='/Gallery[email protected]&[email protected]'">
|
||||
<div class="folder-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
+6
-6
@@ -4,16 +4,16 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages
|
||||
{
|
||||
public class IndexModel(UserService userService, MediaService mediaService, FolderService folderService) : AuthenticatedPageModel
|
||||
public class GalleryModel(UserService userService, MediaService mediaService, FolderService folderService) : AuthenticatedPageModel
|
||||
{
|
||||
private readonly MediaService _mediaService = mediaService;
|
||||
private readonly FolderService _folderService = folderService;
|
||||
|
||||
public UserDashboard? Dashboard { get; set; }
|
||||
public List<UserMedia> MediaItems { get; set; } = new();
|
||||
public List<Folder> Folders { get; set; } = new();
|
||||
public List<Folder> FolderPath { get; set; } = new();
|
||||
public List<SharedFolderInfo> SharedFolders { get; set; } = new();
|
||||
public List<UserMedia> MediaItems { get; set; } = [];
|
||||
public List<Folder> Folders { get; set; } = [];
|
||||
public List<Folder> FolderPath { get; set; } = [];
|
||||
public List<SharedFolderInfo> SharedFolders { get; set; } = [];
|
||||
public long? CurrentFolderId { get; set; }
|
||||
public bool IsSharedFolder { get; set; }
|
||||
public long? FolderOwnerId { get; set; }
|
||||
@@ -64,7 +64,7 @@ namespace Media.JoshHeaps.Net.Pages
|
||||
SharedFolders = await _folderService.GetSharedFoldersAsync(UserId);
|
||||
if (!CurrentFolderId.HasValue)
|
||||
{
|
||||
Folders = new List<Folder>();
|
||||
Folders = [];
|
||||
}
|
||||
else if (IsSharedFolder && FolderOwnerId.HasValue)
|
||||
{
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,8 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public class LoginModel : PageModel
|
||||
public class LoginModel(AuthService authService) : PageModel
|
||||
{
|
||||
private readonly AuthService _authService;
|
||||
|
||||
[BindProperty]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
@@ -22,18 +20,13 @@ public class LoginModel : PageModel
|
||||
public string? SuccessMessage { get; set; }
|
||||
public string? WarningMessage { get; set; }
|
||||
|
||||
public LoginModel(AuthService authService)
|
||||
{
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
public void OnGet([FromQuery] string? registered, [FromQuery] string? verified)
|
||||
{
|
||||
// Check if user is already logged in
|
||||
var userId = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
Response.Redirect("/");
|
||||
Response.Redirect("/Landing");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,7 +51,7 @@ public class LoginModel : PageModel
|
||||
return Page();
|
||||
}
|
||||
|
||||
var (success, error, userInfo) = await _authService.LoginAsync(Email, Password);
|
||||
var (success, error, userInfo) = await authService.LoginAsync(Email, Password);
|
||||
|
||||
if (!success || userInfo == null)
|
||||
{
|
||||
@@ -91,6 +84,6 @@ public class LoginModel : PageModel
|
||||
Response.Cookies.Append("RememberMe", userInfo.Id.ToString(), cookieOptions);
|
||||
}
|
||||
|
||||
return Redirect("/");
|
||||
return Redirect("/Landing");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
@page
|
||||
@model Media.JoshHeaps.Net.Pages.NetworkGraphModel
|
||||
@{
|
||||
ViewData["Title"] = "Network Graphs";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/Gallery/page.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/css/network-graph.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
<div class="dashboard-container">
|
||||
<!-- Header Section -->
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-left">
|
||||
<a href="/Landing" class="back-button" title="Back to Home">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
<h1>Network Graphs</h1>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<button type="button" class="btn btn-primary" id="new-graph-btn">New Graph</button>
|
||||
<a href="/Profile" class="btn btn-secondary">Profile</a>
|
||||
<a href="/Logout" class="btn btn-danger">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Graph Selection & Management -->
|
||||
<div class="graph-manager-section">
|
||||
<div class="graph-selector-card card">
|
||||
<h2>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 8px;">
|
||||
<path d="M3 3v18h18"></path>
|
||||
<path d="M18 17V9"></path>
|
||||
<path d="M13 17V5"></path>
|
||||
<path d="M8 17v-3"></path>
|
||||
</svg>
|
||||
Your Graphs
|
||||
</h2>
|
||||
<div class="graphs-list" id="graphs-list">
|
||||
@if (Model.Graphs.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
<p>No graphs yet. Create your first graph to get started!</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var graph in Model.Graphs)
|
||||
{
|
||||
<div class="graph-item @(graph.Id == Model.SelectedGraphId ? "active" : "")" data-graph-id="@graph.Id">
|
||||
<div class="graph-info" onclick="selectGraph(@graph.Id)">
|
||||
<div class="graph-name">@graph.Name</div>
|
||||
<div class="graph-stats">
|
||||
<span class="stat">@graph.NodeCount nodes</span>
|
||||
<span class="stat">@graph.EdgeCount edges</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="graph-actions">
|
||||
<button type="button" class="btn-icon" onclick="event.stopPropagation(); editGraph(@graph.Id, '@graph.Name', '@(graph.Description ?? "")')" title="Edit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="btn-icon" onclick="event.stopPropagation(); cloneGraph(@graph.Id, '@graph.Name')" title="Clone">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="btn-icon danger" onclick="event.stopPropagation(); deleteGraph(@graph.Id, '@graph.Name')" title="Delete">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Graph Visualization & Tools -->
|
||||
<div class="graph-workspace" id="graph-workspace" style="display: none;">
|
||||
<!-- Toolbar -->
|
||||
<div class="graph-toolbar card">
|
||||
<div class="toolbar-section">
|
||||
<button type="button" class="btn btn-secondary" id="add-node-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="16"></line>
|
||||
<line x1="8" y1="12" x2="16" y2="12"></line>
|
||||
</svg>
|
||||
Add Node
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" id="link-nodes-btn" disabled>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
Link Selected
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar-section">
|
||||
<input type="text" id="search-nodes" class="search-input" placeholder="Search nodes..." />
|
||||
<input type="number" id="degrees-input" class="search-input" placeholder="Degrees" min="1" max="10" value="1" style="width: 100px; display: none;" />
|
||||
<select id="filter-mode-select" class="filter-select" style="display: none;">
|
||||
<option value="exact">Exactly</option>
|
||||
<option value="within">Within</option>
|
||||
<option value="outside">Outside</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary" id="apply-filter-btn" style="display: none;">Apply Filter</button>
|
||||
<button type="button" class="btn btn-secondary" id="clear-filter-btn" style="display: none;">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvas -->
|
||||
<div class="graph-canvas-container card">
|
||||
<canvas id="graph-canvas"></canvas>
|
||||
<div class="canvas-help-text">
|
||||
<span>💡 Right-click nodes for options • Ctrl+Click to select multiple • Double-click to edit</span>
|
||||
</div>
|
||||
<div class="canvas-controls">
|
||||
<button type="button" class="canvas-btn" id="zoom-in-btn" title="Zoom In">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
<line x1="11" y1="8" x2="11" y2="14"></line>
|
||||
<line x1="8" y1="11" x2="14" y2="11"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="canvas-btn" id="zoom-out-btn" title="Zoom Out">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
<line x1="8" y1="11" x2="14" y2="11"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="canvas-btn" id="reset-view-btn" title="Reset View">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path>
|
||||
<path d="M21 3v5h-5"></path>
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path>
|
||||
<path d="M3 21v-5h5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Graph Modal -->
|
||||
<div id="graph-modal" class="modal" style="display: none;">
|
||||
<div class="modal-backdrop" onclick="closeGraphModal()"></div>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<h3 id="graph-modal-title">New Graph</h3>
|
||||
<button type="button" class="modal-close" onclick="closeGraphModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="graph-form">
|
||||
<div class="form-group">
|
||||
<label for="graph-name">Graph Name *</label>
|
||||
<input type="text" id="graph-name" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="graph-description">Description</label>
|
||||
<textarea id="graph-description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeGraphModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" id="save-graph-btn">Create Graph</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node Modal -->
|
||||
<div id="node-modal" class="modal" style="display: none;">
|
||||
<div class="modal-backdrop" onclick="closeNodeModal()"></div>
|
||||
<div class="modal-dialog modal-dialog-large">
|
||||
<div class="modal-header">
|
||||
<h3 id="node-modal-title">New Node</h3>
|
||||
<button type="button" class="modal-close" onclick="closeNodeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="node-form">
|
||||
<div class="form-group">
|
||||
<label for="node-label">Label *</label>
|
||||
<input type="text" id="node-label" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="node-notes">Notes</label>
|
||||
<textarea id="node-notes" class="form-control" rows="5" placeholder="Add detailed notes about this node..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Connections Section (only shown when editing) -->
|
||||
<div id="node-connections-section" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
Connections
|
||||
</label>
|
||||
|
||||
<!-- Add Connection -->
|
||||
<div class="connection-search-container">
|
||||
<input type="text" id="connection-search" class="form-control" placeholder="Search nodes to connect..." autocomplete="off" />
|
||||
<div id="connection-search-results" class="search-results-dropdown" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Current Connections List -->
|
||||
<div id="node-connections-list" class="connections-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeNodeModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" id="save-node-btn">Create Node</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div id="node-context-menu" class="context-menu" style="display: none;">
|
||||
<div class="context-menu-item" data-action="edit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
Edit Node
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
Link to Selected
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="select">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 11 12 14 22 4"></polyline>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||
</svg>
|
||||
Add to Selection
|
||||
</div>
|
||||
<div class="context-menu-divider"></div>
|
||||
<div class="context-menu-item danger" data-action="delete">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
Delete Node
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
const selectedGraphId = @(Model.SelectedGraphId?.ToString() ?? "null");
|
||||
</script>
|
||||
<script src="~/js/network-graph.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages
|
||||
{
|
||||
public class NetworkGraphModel(GraphService graphService) : AuthenticatedPageModel
|
||||
{
|
||||
private readonly GraphService _graphService = graphService;
|
||||
|
||||
public List<GraphWithCounts> Graphs { get; set; } = [];
|
||||
public long? SelectedGraphId { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync([FromQuery] long? graphId = null)
|
||||
{
|
||||
RequireAuthentication();
|
||||
LoadUserSession();
|
||||
|
||||
Graphs = await _graphService.GetUserGraphsAsync(UserId);
|
||||
SelectedGraphId = graphId;
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,8 @@ using System.Text.RegularExpressions;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public class RegisterModel : PageModel
|
||||
public class RegisterModel(AuthService authService, EmailService emailService) : PageModel
|
||||
{
|
||||
private readonly AuthService _authService;
|
||||
private readonly EmailService _emailService;
|
||||
|
||||
[BindProperty]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
@@ -24,12 +21,6 @@ public class RegisterModel : PageModel
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public RegisterModel(AuthService authService, EmailService emailService)
|
||||
{
|
||||
_authService = authService;
|
||||
_emailService = emailService;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
// Check if user is already logged in
|
||||
@@ -79,7 +70,7 @@ public class RegisterModel : PageModel
|
||||
}
|
||||
|
||||
// Register user
|
||||
var (success, error, verificationToken) = await _authService.RegisterUserAsync(Email, Username, Password);
|
||||
var (success, error, verificationToken) = await authService.RegisterUserAsync(Email, Username, Password);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
@@ -90,7 +81,7 @@ public class RegisterModel : PageModel
|
||||
// Send verification email
|
||||
if (!string.IsNullOrEmpty(verificationToken))
|
||||
{
|
||||
await _emailService.SendVerificationEmailAsync(Email, Username, verificationToken);
|
||||
await emailService.SendVerificationEmailAsync(Email, Username, verificationToken);
|
||||
}
|
||||
|
||||
// Redirect to verification pending page
|
||||
|
||||
@@ -4,23 +4,14 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public class ResendVerificationModel : PageModel
|
||||
public class ResendVerificationModel(AuthService authService, EmailService emailService) : PageModel
|
||||
{
|
||||
private readonly AuthService _authService;
|
||||
private readonly EmailService _emailService;
|
||||
|
||||
[BindProperty]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string? SuccessMessage { get; set; }
|
||||
|
||||
public ResendVerificationModel(AuthService authService, EmailService emailService)
|
||||
{
|
||||
_authService = authService;
|
||||
_emailService = emailService;
|
||||
}
|
||||
|
||||
public void OnGet([FromQuery] string? email)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(email))
|
||||
@@ -37,7 +28,7 @@ public class ResendVerificationModel : PageModel
|
||||
return Page();
|
||||
}
|
||||
|
||||
var (success, error, verificationToken) = await _authService.ResendVerificationTokenAsync(Email);
|
||||
var (success, error, verificationToken) = await authService.ResendVerificationTokenAsync(Email);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
@@ -48,7 +39,7 @@ public class ResendVerificationModel : PageModel
|
||||
// Send verification email
|
||||
if (!string.IsNullOrEmpty(verificationToken))
|
||||
{
|
||||
var emailSent = await _emailService.SendVerificationEmailAsync(Email, Email, verificationToken);
|
||||
var emailSent = await emailService.SendVerificationEmailAsync(Email, Email, verificationToken);
|
||||
|
||||
if (emailSent)
|
||||
{
|
||||
|
||||
@@ -4,19 +4,12 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public class VerifyEmailModel : PageModel
|
||||
public class VerifyEmailModel(AuthService authService) : PageModel
|
||||
{
|
||||
private readonly AuthService _authService;
|
||||
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public bool ShowResendLink { get; set; }
|
||||
|
||||
public VerifyEmailModel(AuthService authService)
|
||||
{
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnGetAsync([FromQuery] string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
@@ -27,7 +20,7 @@ public class VerifyEmailModel : PageModel
|
||||
return Page();
|
||||
}
|
||||
|
||||
var (success, error) = await _authService.VerifyEmailAsync(token);
|
||||
var (success, error) = await authService.VerifyEmailAsync(token);
|
||||
|
||||
Success = success;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ builder.Services.AddScoped<EmailService>();
|
||||
builder.Services.AddScoped<UserService>();
|
||||
builder.Services.AddScoped<MediaService>();
|
||||
builder.Services.AddScoped<FolderService>();
|
||||
builder.Services.AddScoped<GraphService>();
|
||||
|
||||
// Add session support
|
||||
builder.Services.AddDistributedMemoryCache();
|
||||
@@ -71,4 +72,11 @@ app.UseAuthorization();
|
||||
app.MapRazorPages();
|
||||
app.MapControllers(); // Map API controllers
|
||||
|
||||
// Redirect root to Landing page
|
||||
app.MapGet("/", context =>
|
||||
{
|
||||
context.Response.Redirect("/Landing");
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -4,28 +4,19 @@ using MimeKit;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Services;
|
||||
|
||||
public class EmailService
|
||||
public class EmailService(IConfiguration config, ILogger<EmailService> logger)
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<EmailService> _logger;
|
||||
|
||||
public EmailService(IConfiguration config, ILogger<EmailService> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> SendVerificationEmailAsync(string toEmail, string username, string verificationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appUrl = _config["AppUrl"] ?? "https://media.joshheaps.net";
|
||||
var appUrl = config["AppUrl"] ?? "https://media.joshheaps.net";
|
||||
var verificationUrl = $"{appUrl}/VerifyEmail?token={verificationToken}";
|
||||
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress(
|
||||
_config["Email:FromName"] ?? "Media App",
|
||||
_config["Email:FromEmail"] ?? "[email protected]"
|
||||
config["Email:FromName"] ?? "Media App",
|
||||
config["Email:FromEmail"] ?? "[email protected]"
|
||||
));
|
||||
message.To.Add(new MailboxAddress(username, toEmail));
|
||||
message.Subject = "Verify Your Email Address";
|
||||
@@ -85,11 +76,11 @@ If you didn't create an account, you can safely ignore this email.
|
||||
|
||||
using var client = new SmtpClient();
|
||||
|
||||
var smtpHost = _config["Email:SmtpHost"];
|
||||
var smtpPort = int.Parse(_config["Email:SmtpPort"] ?? "587");
|
||||
var smtpUsername = _config["Email:SmtpUsername"];
|
||||
var smtpPassword = _config["Email:SmtpPassword"];
|
||||
var enableSsl = bool.Parse(_config["Email:EnableSsl"] ?? "true");
|
||||
var smtpHost = config["Email:SmtpHost"];
|
||||
var smtpPort = int.Parse(config["Email:SmtpPort"] ?? "587");
|
||||
var smtpUsername = config["Email:SmtpUsername"];
|
||||
var smtpPassword = config["Email:SmtpPassword"];
|
||||
var enableSsl = bool.Parse(config["Email:EnableSsl"] ?? "true");
|
||||
|
||||
await client.ConnectAsync(smtpHost, smtpPort, enableSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.None);
|
||||
|
||||
@@ -101,12 +92,12 @@ If you didn't create an account, you can safely ignore this email.
|
||||
await client.SendAsync(message);
|
||||
await client.DisconnectAsync(true);
|
||||
|
||||
_logger.LogInformation($"Verification email sent to {toEmail}");
|
||||
logger.LogInformation($"Verification email sent to {toEmail}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Failed to send verification email to {toEmail}");
|
||||
logger.LogError(ex, $"Failed to send verification email to {toEmail}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -115,13 +106,13 @@ If you didn't create an account, you can safely ignore this email.
|
||||
{
|
||||
try
|
||||
{
|
||||
var appUrl = _config["AppUrl"] ?? "http://localhost:5000";
|
||||
var appUrl = config["AppUrl"] ?? "http://localhost:5000";
|
||||
var resetUrl = $"{appUrl}/ResetPassword?token={resetToken}";
|
||||
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress(
|
||||
_config["Email:FromName"] ?? "Media App",
|
||||
_config["Email:FromEmail"] ?? "[email protected]"
|
||||
config["Email:FromName"] ?? "Media App",
|
||||
config["Email:FromEmail"] ?? "[email protected]"
|
||||
));
|
||||
message.To.Add(new MailboxAddress(username, toEmail));
|
||||
message.Subject = "Password Reset Request";
|
||||
@@ -184,11 +175,11 @@ If you didn't request a password reset, you can safely ignore this email.
|
||||
|
||||
using var client = new SmtpClient();
|
||||
|
||||
var smtpHost = _config["Email:SmtpHost"];
|
||||
var smtpPort = int.Parse(_config["Email:SmtpPort"] ?? "587");
|
||||
var smtpUsername = _config["Email:SmtpUsername"];
|
||||
var smtpPassword = _config["Email:SmtpPassword"];
|
||||
var enableSsl = bool.Parse(_config["Email:EnableSsl"] ?? "true");
|
||||
var smtpHost = config["Email:SmtpHost"];
|
||||
var smtpPort = int.Parse(config["Email:SmtpPort"] ?? "587");
|
||||
var smtpUsername = config["Email:SmtpUsername"];
|
||||
var smtpPassword = config["Email:SmtpPassword"];
|
||||
var enableSsl = bool.Parse(config["Email:EnableSsl"] ?? "true");
|
||||
|
||||
await client.ConnectAsync(smtpHost, smtpPort, enableSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.None);
|
||||
|
||||
@@ -200,12 +191,12 @@ If you didn't request a password reset, you can safely ignore this email.
|
||||
await client.SendAsync(message);
|
||||
await client.DisconnectAsync(true);
|
||||
|
||||
_logger.LogInformation($"Password reset email sent to {toEmail}");
|
||||
logger.LogInformation($"Password reset email sent to {toEmail}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Failed to send password reset email to {toEmail}");
|
||||
logger.LogError(ex, $"Failed to send password reset email to {toEmail}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,8 @@ using Media.JoshHeaps.Net.Models;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Services;
|
||||
|
||||
public class FolderService
|
||||
public class FolderService(DbExecutor db, ILogger<FolderService> logger)
|
||||
{
|
||||
private readonly DbExecutor _db;
|
||||
private readonly ILogger<FolderService> _logger;
|
||||
|
||||
public FolderService(DbExecutor db, ILogger<FolderService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Folder?> CreateFolderAsync(long userId, string name, long? parentFolderId = null)
|
||||
{
|
||||
try
|
||||
@@ -20,7 +11,7 @@ public class FolderService
|
||||
// Validate folder name
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
_logger.LogWarning("Cannot create folder with empty name for user {UserId}", userId);
|
||||
logger.LogWarning("Cannot create folder with empty name for user {UserId}", userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -28,7 +19,7 @@ public class FolderService
|
||||
var sanitizedName = SanitizeFolderName(name);
|
||||
if (string.IsNullOrWhiteSpace(sanitizedName))
|
||||
{
|
||||
_logger.LogWarning("Folder name became empty after sanitization for user {UserId}", userId);
|
||||
logger.LogWarning("Folder name became empty after sanitization for user {UserId}", userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -38,7 +29,7 @@ public class FolderService
|
||||
var parentExists = await FolderExistsAsync(parentFolderId.Value, userId);
|
||||
if (!parentExists)
|
||||
{
|
||||
_logger.LogWarning("Parent folder {ParentFolderId} not found for user {UserId}", parentFolderId.Value, userId);
|
||||
logger.LogWarning("Parent folder {ParentFolderId} not found for user {UserId}", parentFolderId.Value, userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +39,7 @@ public class FolderService
|
||||
VALUES (@userId, @name, @parentFolderId, @createdAt, @updatedAt)
|
||||
RETURNING id, user_id, name, parent_folder_id, created_at, updated_at";
|
||||
|
||||
var folder = await _db.ExecuteReaderAsync(query, reader =>
|
||||
var folder = await db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new Folder
|
||||
{
|
||||
@@ -72,7 +63,7 @@ public class FolderService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create folder '{Name}' for user {UserId}", name, userId);
|
||||
logger.LogError(ex, "Failed to create folder '{Name}' for user {UserId}", name, userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -99,7 +90,7 @@ public class FolderService
|
||||
ORDER BY name ASC";
|
||||
}
|
||||
|
||||
var folders = await _db.ExecuteListReaderAsync(query, reader =>
|
||||
var folders = await db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new Folder
|
||||
{
|
||||
@@ -116,8 +107,8 @@ public class FolderService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get folders for user {UserId}, parent {ParentFolderId}", userId, parentFolderId);
|
||||
return new List<Folder>();
|
||||
logger.LogError(ex, "Failed to get folders for user {UserId}, parent {ParentFolderId}", userId, parentFolderId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +121,7 @@ public class FolderService
|
||||
FROM app.folders
|
||||
WHERE id = @folderId AND user_id = @userId";
|
||||
|
||||
var folder = await _db.ExecuteReaderAsync(query, reader =>
|
||||
var folder = await db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new Folder
|
||||
{
|
||||
@@ -147,7 +138,7 @@ public class FolderService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get folder {FolderId} for user {UserId}", folderId, userId);
|
||||
logger.LogError(ex, "Failed to get folder {FolderId} for user {UserId}", folderId, userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -159,7 +150,7 @@ public class FolderService
|
||||
var sanitizedName = SanitizeFolderName(newName);
|
||||
if (string.IsNullOrWhiteSpace(sanitizedName))
|
||||
{
|
||||
_logger.LogWarning("Cannot rename folder to empty name");
|
||||
logger.LogWarning("Cannot rename folder to empty name");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -168,7 +159,7 @@ public class FolderService
|
||||
SET name = @newName, updated_at = @updatedAt
|
||||
WHERE id = @folderId AND user_id = @userId";
|
||||
|
||||
await _db.ExecuteAsync<object>(query, new
|
||||
await db.ExecuteAsync<object>(query, new
|
||||
{
|
||||
folderId,
|
||||
userId,
|
||||
@@ -180,7 +171,7 @@ public class FolderService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to rename folder {FolderId} for user {UserId}", folderId, userId);
|
||||
logger.LogError(ex, "Failed to rename folder {FolderId} for user {UserId}", folderId, userId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -193,12 +184,12 @@ public class FolderService
|
||||
var hasSubfoldersQuery = "SELECT COUNT(*) FROM app.folders WHERE parent_folder_id = @folderId";
|
||||
var hasMediaQuery = "SELECT COUNT(*) FROM app.user_media WHERE folder_id = @folderId";
|
||||
|
||||
var subfolderCount = await _db.ExecuteReaderAsync(hasSubfoldersQuery, reader => reader.GetInt64(0), new { folderId });
|
||||
var mediaCount = await _db.ExecuteReaderAsync(hasMediaQuery, reader => reader.GetInt64(0), new { folderId });
|
||||
var subfolderCount = await db.ExecuteReaderAsync(hasSubfoldersQuery, reader => reader.GetInt64(0), new { folderId });
|
||||
var mediaCount = await db.ExecuteReaderAsync(hasMediaQuery, reader => reader.GetInt64(0), new { folderId });
|
||||
|
||||
if (!deleteContents && (subfolderCount > 0 || mediaCount > 0))
|
||||
{
|
||||
_logger.LogWarning("Cannot delete folder {FolderId} - it contains items", folderId);
|
||||
logger.LogWarning("Cannot delete folder {FolderId} - it contains items", folderId);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -214,7 +205,7 @@ public class FolderService
|
||||
SET parent_folder_id = @parentFolderId, updated_at = @updatedAt
|
||||
WHERE parent_folder_id = @folderId AND user_id = @userId";
|
||||
|
||||
await _db.ExecuteAsync<object>(moveSubfoldersQuery, new
|
||||
await db.ExecuteAsync<object>(moveSubfoldersQuery, new
|
||||
{
|
||||
parentFolderId = folder.ParentFolderId,
|
||||
folderId,
|
||||
@@ -228,7 +219,7 @@ public class FolderService
|
||||
SET folder_id = @parentFolderId, updated_at = @updatedAt
|
||||
WHERE folder_id = @folderId AND user_id = @userId";
|
||||
|
||||
await _db.ExecuteAsync<object>(moveMediaQuery, new
|
||||
await db.ExecuteAsync<object>(moveMediaQuery, new
|
||||
{
|
||||
parentFolderId = folder.ParentFolderId,
|
||||
folderId,
|
||||
@@ -240,13 +231,13 @@ public class FolderService
|
||||
|
||||
// Delete the folder
|
||||
var deleteQuery = "DELETE FROM app.folders WHERE id = @folderId AND user_id = @userId";
|
||||
await _db.ExecuteAsync<object>(deleteQuery, new { folderId, userId });
|
||||
await db.ExecuteAsync<object>(deleteQuery, new { folderId, userId });
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete folder {FolderId} for user {UserId}", folderId, userId);
|
||||
logger.LogError(ex, "Failed to delete folder {FolderId} for user {UserId}", folderId, userId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -275,7 +266,7 @@ public class FolderService
|
||||
var isDescendant = await IsFolderDescendantAsync(newParentFolderId.Value, folderId);
|
||||
if (isDescendant)
|
||||
{
|
||||
_logger.LogWarning("Cannot move folder {FolderId} into its descendant {ParentFolderId}", folderId, newParentFolderId.Value);
|
||||
logger.LogWarning("Cannot move folder {FolderId} into its descendant {ParentFolderId}", folderId, newParentFolderId.Value);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -285,7 +276,7 @@ public class FolderService
|
||||
SET parent_folder_id = @newParentFolderId, updated_at = @updatedAt
|
||||
WHERE id = @folderId AND user_id = @userId";
|
||||
|
||||
await _db.ExecuteAsync<object>(query, new
|
||||
await db.ExecuteAsync<object>(query, new
|
||||
{
|
||||
folderId,
|
||||
userId,
|
||||
@@ -297,7 +288,7 @@ public class FolderService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to move folder {FolderId} for user {UserId}", folderId, userId);
|
||||
logger.LogError(ex, "Failed to move folder {FolderId} for user {UserId}", folderId, userId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -330,8 +321,8 @@ public class FolderService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get folder path for folder {FolderId}", folderId);
|
||||
return new List<Folder>();
|
||||
logger.LogError(ex, "Failed to get folder path for folder {FolderId}", folderId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +331,7 @@ public class FolderService
|
||||
try
|
||||
{
|
||||
var query = "SELECT COUNT(*) FROM app.folders WHERE id = @folderId AND user_id = @userId";
|
||||
var count = await _db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId, userId });
|
||||
var count = await db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId, userId });
|
||||
return count > 0;
|
||||
}
|
||||
catch
|
||||
@@ -371,7 +362,7 @@ public class FolderService
|
||||
visited.Add(currentId);
|
||||
|
||||
var query = "SELECT parent_folder_id FROM app.folders WHERE id = @folderId";
|
||||
var parentId = await _db.ExecuteReaderAsync(query, reader => reader.IsDBNull(0) ? (long?)null : reader.GetInt64(0), new { folderId = currentId });
|
||||
var parentId = await db.ExecuteReaderAsync(query, reader => reader.IsDBNull(0) ? (long?)null : reader.GetInt64(0), new { folderId = currentId });
|
||||
|
||||
if (!parentId.HasValue)
|
||||
{
|
||||
@@ -385,7 +376,7 @@ public class FolderService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to check if folder {PotentialDescendantId} is descendant of {AncestorId}", potentialDescendantId, ancestorId);
|
||||
logger.LogError(ex, "Failed to check if folder {PotentialDescendantId} is descendant of {AncestorId}", potentialDescendantId, ancestorId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -421,7 +412,7 @@ public class FolderService
|
||||
var folder = await GetFolderByIdAsync(folderId, ownerId);
|
||||
if (folder == null)
|
||||
{
|
||||
_logger.LogWarning("Cannot share folder {FolderId} - not found or not owned by {OwnerId}", folderId, ownerId);
|
||||
logger.LogWarning("Cannot share folder {FolderId} - not found or not owned by {OwnerId}", folderId, ownerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -437,7 +428,7 @@ public class FolderService
|
||||
VALUES (@folderId, @ownerId, @sharedWithUserId, @permissionLevel, @includeSubfolders, @createdAt, @updatedAt)
|
||||
RETURNING id, folder_id, owner_user_id, shared_with_user_id, permission_level, include_subfolders, created_at, updated_at";
|
||||
|
||||
var share = await _db.ExecuteReaderAsync(query, reader =>
|
||||
var share = await db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new FolderShare
|
||||
{
|
||||
@@ -465,7 +456,7 @@ public class FolderService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to share folder {FolderId} with user {SharedWithUserId}", folderId, sharedWithUserId);
|
||||
logger.LogError(ex, "Failed to share folder {FolderId} with user {SharedWithUserId}", folderId, sharedWithUserId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -478,12 +469,12 @@ public class FolderService
|
||||
DELETE FROM app.folder_shares
|
||||
WHERE folder_id = @folderId AND owner_user_id = @ownerId AND shared_with_user_id = @sharedWithUserId";
|
||||
|
||||
await _db.ExecuteAsync<object>(query, new { folderId, ownerId, sharedWithUserId });
|
||||
await db.ExecuteAsync<object>(query, new { folderId, ownerId, sharedWithUserId });
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to unshare folder {FolderId} from user {SharedWithUserId}", folderId, sharedWithUserId);
|
||||
logger.LogError(ex, "Failed to unshare folder {FolderId} from user {SharedWithUserId}", folderId, sharedWithUserId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -500,7 +491,7 @@ public class FolderService
|
||||
WHERE fs.folder_id = @folderId AND fs.owner_user_id = @ownerId
|
||||
ORDER BY u.username ASC";
|
||||
|
||||
var shares = await _db.ExecuteListReaderAsync(query, reader =>
|
||||
var shares = await db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new FolderShareWithUser
|
||||
{
|
||||
@@ -519,8 +510,8 @@ public class FolderService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get shares for folder {FolderId}", folderId);
|
||||
return new List<FolderShareWithUser>();
|
||||
logger.LogError(ex, "Failed to get shares for folder {FolderId}", folderId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,7 +527,7 @@ public class FolderService
|
||||
WHERE fs.shared_with_user_id = @userId
|
||||
ORDER BY f.name ASC";
|
||||
|
||||
var sharedFolders = await _db.ExecuteListReaderAsync(query, reader =>
|
||||
var sharedFolders = await db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new SharedFolderInfo
|
||||
{
|
||||
@@ -553,8 +544,8 @@ public class FolderService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get shared folders for user {UserId}", userId);
|
||||
return new List<SharedFolderInfo>();
|
||||
logger.LogError(ex, "Failed to get shared folders for user {UserId}", userId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,7 +558,7 @@ public class FolderService
|
||||
FROM app.folder_shares
|
||||
WHERE folder_id = @folderId AND shared_with_user_id = @sharedWithUserId";
|
||||
|
||||
var share = await _db.ExecuteReaderAsync(query, reader =>
|
||||
var share = await db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new FolderShare
|
||||
{
|
||||
@@ -596,7 +587,7 @@ public class FolderService
|
||||
{
|
||||
// Check if user is the owner
|
||||
var query = "SELECT COUNT(*) FROM app.folders WHERE id = @folderId AND user_id = @userId";
|
||||
var isOwner = await _db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId, userId });
|
||||
var isOwner = await db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId, userId });
|
||||
if (isOwner > 0) return true;
|
||||
|
||||
// Check if folder is shared with user (including parent folders due to subfolders)
|
||||
@@ -619,12 +610,12 @@ public class FolderService
|
||||
INNER JOIN folder_hierarchy fh ON fs.folder_id = fh.id
|
||||
WHERE fs.shared_with_user_id = @userId AND fs.include_subfolders = true";
|
||||
|
||||
var hasShare = await _db.ExecuteReaderAsync(shareQuery, reader => reader.GetInt64(0), new { folderId, userId });
|
||||
var hasShare = await db.ExecuteReaderAsync(shareQuery, reader => reader.GetInt64(0), new { folderId, userId });
|
||||
return hasShare > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to check folder access for folder {FolderId} and user {UserId}", folderId, userId);
|
||||
logger.LogError(ex, "Failed to check folder access for folder {FolderId} and user {UserId}", folderId, userId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -634,7 +625,7 @@ public class FolderService
|
||||
try
|
||||
{
|
||||
var query = "SELECT user_id FROM app.folders WHERE id = @folderId";
|
||||
var ownerId = await _db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId });
|
||||
var ownerId = await db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId });
|
||||
return ownerId;
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -0,0 +1,810 @@
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Services;
|
||||
|
||||
public class GraphService(DbExecutor db, ILogger<GraphService> logger)
|
||||
{
|
||||
|
||||
#region Graph Operations
|
||||
|
||||
public async Task<List<GraphWithCounts>> GetUserGraphsAsync(long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
SELECT
|
||||
g.id, g.user_id, g.name, g.description, g.created_at, g.updated_at,
|
||||
COUNT(DISTINCT n.id) as node_count,
|
||||
COUNT(DISTINCT e.id) as edge_count
|
||||
FROM app.graphs g
|
||||
LEFT JOIN app.graph_nodes n ON g.id = n.graph_id
|
||||
LEFT JOIN app.graph_edges e ON g.id = e.graph_id
|
||||
WHERE g.user_id = @userId
|
||||
GROUP BY g.id, g.user_id, g.name, g.description, g.created_at, g.updated_at
|
||||
ORDER BY g.updated_at DESC";
|
||||
|
||||
return await db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new GraphWithCounts
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
UserId = reader.GetInt64(1),
|
||||
Name = reader.GetString(2),
|
||||
Description = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
CreatedAt = reader.GetDateTime(4),
|
||||
UpdatedAt = reader.GetDateTime(5),
|
||||
NodeCount = reader.GetInt32(6),
|
||||
EdgeCount = reader.GetInt32(7)
|
||||
};
|
||||
}, new { userId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get graphs for user {UserId}", userId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Graph?> GetGraphByIdAsync(long graphId, long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
SELECT id, user_id, name, description, created_at, updated_at
|
||||
FROM app.graphs
|
||||
WHERE id = @graphId AND user_id = @userId";
|
||||
|
||||
return await db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new Graph
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
UserId = reader.GetInt64(1),
|
||||
Name = reader.GetString(2),
|
||||
Description = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
CreatedAt = reader.GetDateTime(4),
|
||||
UpdatedAt = reader.GetDateTime(5)
|
||||
};
|
||||
}, new { graphId, userId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get graph {GraphId} for user {UserId}", graphId, userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Graph?> CreateGraphAsync(long userId, string name, string? description = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
logger.LogWarning("Cannot create graph with empty name for user {UserId}", userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var query = @"
|
||||
INSERT INTO app.graphs (user_id, name, description, created_at, updated_at)
|
||||
VALUES (@userId, @name, @description, @createdAt, @updatedAt)
|
||||
RETURNING id, user_id, name, description, created_at, updated_at";
|
||||
|
||||
return await db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new Graph
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
UserId = reader.GetInt64(1),
|
||||
Name = reader.GetString(2),
|
||||
Description = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
CreatedAt = reader.GetDateTime(4),
|
||||
UpdatedAt = reader.GetDateTime(5)
|
||||
};
|
||||
}, new
|
||||
{
|
||||
userId,
|
||||
name = name.Trim(),
|
||||
description,
|
||||
createdAt = DateTime.UtcNow,
|
||||
updatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create graph '{Name}' for user {UserId}", name, userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateGraphAsync(long graphId, long userId, string name, string? description)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
logger.LogWarning("Cannot update graph with empty name");
|
||||
return false;
|
||||
}
|
||||
|
||||
var query = @"
|
||||
UPDATE app.graphs
|
||||
SET name = @name, description = @description, updated_at = @updatedAt
|
||||
WHERE id = @graphId AND user_id = @userId";
|
||||
|
||||
var rowsAffected = await db.ExecuteNonQueryAsync(query, new
|
||||
{
|
||||
graphId,
|
||||
userId,
|
||||
name = name.Trim(),
|
||||
description,
|
||||
updatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to update graph {GraphId}", graphId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteGraphAsync(long graphId, long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = "DELETE FROM app.graphs WHERE id = @graphId AND user_id = @userId";
|
||||
var rowsAffected = await db.ExecuteNonQueryAsync(query, new { graphId, userId });
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete graph {GraphId}", graphId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Graph?> CloneGraphAsync(long graphId, long userId, string newName)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get original graph
|
||||
var originalGraph = await GetGraphByIdAsync(graphId, userId);
|
||||
if (originalGraph == null) return null;
|
||||
|
||||
// Create new graph
|
||||
var newGraph = await CreateGraphAsync(userId, newName, originalGraph.Description);
|
||||
if (newGraph == null) return null;
|
||||
|
||||
// Get all nodes from original graph
|
||||
var nodes = await GetGraphNodesAsync(graphId, userId);
|
||||
var nodeMapping = new Dictionary<long, long>(); // old ID -> new ID
|
||||
|
||||
// Clone nodes
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var newNode = await CreateNodeAsync(newGraph.Id, userId, node.Label, node.Notes);
|
||||
if (newNode != null)
|
||||
{
|
||||
nodeMapping[node.Id] = newNode.Id;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all edges from original graph
|
||||
var edges = await GetGraphEdgesAsync(graphId, userId);
|
||||
|
||||
// Clone edges with new node IDs
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (nodeMapping.ContainsKey(edge.SourceNodeId) && nodeMapping.ContainsKey(edge.TargetNodeId))
|
||||
{
|
||||
await CreateEdgeAsync(newGraph.Id, userId, nodeMapping[edge.SourceNodeId], nodeMapping[edge.TargetNodeId]);
|
||||
}
|
||||
}
|
||||
|
||||
return newGraph;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to clone graph {GraphId}", graphId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Node Operations
|
||||
|
||||
public async Task<List<GraphNodeWithConnections>> GetGraphNodesAsync(long graphId, long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Verify user owns the graph
|
||||
var graph = await GetGraphByIdAsync(graphId, userId);
|
||||
if (graph == null) return [];
|
||||
|
||||
var query = @"
|
||||
SELECT
|
||||
n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at,
|
||||
COUNT(DISTINCT e1.id) + COUNT(DISTINCT e2.id) as connection_count
|
||||
FROM app.graph_nodes n
|
||||
LEFT JOIN app.graph_edges e1 ON n.id = e1.source_node_id
|
||||
LEFT JOIN app.graph_edges e2 ON n.id = e2.target_node_id
|
||||
WHERE n.graph_id = @graphId
|
||||
GROUP BY n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at
|
||||
ORDER BY n.label";
|
||||
|
||||
return await db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new GraphNodeWithConnections
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
GraphId = reader.GetInt64(1),
|
||||
Label = reader.GetString(2),
|
||||
Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
CreatedAt = reader.GetDateTime(4),
|
||||
UpdatedAt = reader.GetDateTime(5),
|
||||
ConnectionCount = reader.GetInt32(6)
|
||||
};
|
||||
}, new { graphId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get nodes for graph {GraphId}", graphId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GraphNode?> CreateNodeAsync(long graphId, long userId, string label, string? notes = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Verify user owns the graph
|
||||
var graph = await GetGraphByIdAsync(graphId, userId);
|
||||
if (graph == null) return null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
{
|
||||
logger.LogWarning("Cannot create node with empty label");
|
||||
return null;
|
||||
}
|
||||
|
||||
var query = @"
|
||||
INSERT INTO app.graph_nodes (graph_id, label, notes, created_at, updated_at)
|
||||
VALUES (@graphId, @label, @notes, @createdAt, @updatedAt)
|
||||
RETURNING id, graph_id, label, notes, created_at, updated_at";
|
||||
|
||||
var node = await db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new GraphNode
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
GraphId = reader.GetInt64(1),
|
||||
Label = reader.GetString(2),
|
||||
Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
CreatedAt = reader.GetDateTime(4),
|
||||
UpdatedAt = reader.GetDateTime(5)
|
||||
};
|
||||
}, new
|
||||
{
|
||||
graphId,
|
||||
label = label.Trim(),
|
||||
notes,
|
||||
createdAt = DateTime.UtcNow,
|
||||
updatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
// Update graph's updated_at
|
||||
await TouchGraphAsync(graphId);
|
||||
|
||||
return node;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create node in graph {GraphId}", graphId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateNodeAsync(long nodeId, long userId, string label, string? notes)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
{
|
||||
logger.LogWarning("Cannot update node with empty label");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get node to verify ownership through graph
|
||||
var node = await GetNodeByIdAsync(nodeId);
|
||||
if (node == null) return false;
|
||||
|
||||
var graph = await GetGraphByIdAsync(node.GraphId, userId);
|
||||
if (graph == null) return false;
|
||||
|
||||
var query = @"
|
||||
UPDATE app.graph_nodes
|
||||
SET label = @label, notes = @notes, updated_at = @updatedAt
|
||||
WHERE id = @nodeId";
|
||||
|
||||
var rowsAffected = await db.ExecuteNonQueryAsync(query, new
|
||||
{
|
||||
nodeId,
|
||||
label = label.Trim(),
|
||||
notes,
|
||||
updatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
if (rowsAffected > 0)
|
||||
{
|
||||
await TouchGraphAsync(node.GraphId);
|
||||
}
|
||||
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to update node {NodeId}", nodeId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteNodeAsync(long nodeId, long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get node to verify ownership and get graph ID
|
||||
var node = await GetNodeByIdAsync(nodeId);
|
||||
if (node == null) return false;
|
||||
|
||||
var graph = await GetGraphByIdAsync(node.GraphId, userId);
|
||||
if (graph == null) return false;
|
||||
|
||||
var query = "DELETE FROM app.graph_nodes WHERE id = @nodeId";
|
||||
var rowsAffected = await db.ExecuteNonQueryAsync(query, new { nodeId });
|
||||
|
||||
if (rowsAffected > 0)
|
||||
{
|
||||
await TouchGraphAsync(node.GraphId);
|
||||
}
|
||||
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete node {NodeId}", nodeId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<GraphNodeWithConnections>> SearchNodesAsync(long graphId, long userId, string searchTerm)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Verify user owns the graph
|
||||
var graph = await GetGraphByIdAsync(graphId, userId);
|
||||
if (graph == null) return [];
|
||||
|
||||
var query = @"
|
||||
SELECT
|
||||
n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at,
|
||||
COUNT(DISTINCT e1.id) + COUNT(DISTINCT e2.id) as connection_count
|
||||
FROM app.graph_nodes n
|
||||
LEFT JOIN app.graph_edges e1 ON n.id = e1.source_node_id
|
||||
LEFT JOIN app.graph_edges e2 ON n.id = e2.target_node_id
|
||||
WHERE n.graph_id = @graphId
|
||||
AND (LOWER(n.label) LIKE @searchPattern OR LOWER(n.notes) LIKE @searchPattern)
|
||||
GROUP BY n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at
|
||||
ORDER BY n.label";
|
||||
|
||||
var searchPattern = $"%{searchTerm.ToLower()}%";
|
||||
|
||||
return await db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new GraphNodeWithConnections
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
GraphId = reader.GetInt64(1),
|
||||
Label = reader.GetString(2),
|
||||
Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
CreatedAt = reader.GetDateTime(4),
|
||||
UpdatedAt = reader.GetDateTime(5),
|
||||
ConnectionCount = reader.GetInt32(6)
|
||||
};
|
||||
}, new { graphId, searchPattern });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to search nodes in graph {GraphId}", graphId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<GraphNode?> GetNodeByIdAsync(long nodeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
SELECT id, graph_id, label, notes, created_at, updated_at
|
||||
FROM app.graph_nodes
|
||||
WHERE id = @nodeId";
|
||||
|
||||
return await db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new GraphNode
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
GraphId = reader.GetInt64(1),
|
||||
Label = reader.GetString(2),
|
||||
Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
CreatedAt = reader.GetDateTime(4),
|
||||
UpdatedAt = reader.GetDateTime(5)
|
||||
};
|
||||
}, new { nodeId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get node {NodeId}", nodeId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Operations
|
||||
|
||||
public async Task<List<GraphEdge>> GetGraphEdgesAsync(long graphId, long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Verify user owns the graph
|
||||
var graph = await GetGraphByIdAsync(graphId, userId);
|
||||
if (graph == null) return [];
|
||||
|
||||
var query = @"
|
||||
SELECT id, graph_id, source_node_id, target_node_id, created_at
|
||||
FROM app.graph_edges
|
||||
WHERE graph_id = @graphId
|
||||
ORDER BY created_at";
|
||||
|
||||
return await db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new GraphEdge
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
GraphId = reader.GetInt64(1),
|
||||
SourceNodeId = reader.GetInt64(2),
|
||||
TargetNodeId = reader.GetInt64(3),
|
||||
CreatedAt = reader.GetDateTime(4)
|
||||
};
|
||||
}, new { graphId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get edges for graph {GraphId}", graphId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GraphEdge?> CreateEdgeAsync(long graphId, long userId, long sourceNodeId, long targetNodeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Verify user owns the graph
|
||||
var graph = await GetGraphByIdAsync(graphId, userId);
|
||||
if (graph == null) return null;
|
||||
|
||||
// Verify both nodes belong to this graph
|
||||
var sourceNode = await GetNodeByIdAsync(sourceNodeId);
|
||||
var targetNode = await GetNodeByIdAsync(targetNodeId);
|
||||
|
||||
if (sourceNode == null || targetNode == null ||
|
||||
sourceNode.GraphId != graphId || targetNode.GraphId != graphId)
|
||||
{
|
||||
logger.LogWarning("Invalid nodes for edge creation");
|
||||
return null;
|
||||
}
|
||||
|
||||
var query = @"
|
||||
INSERT INTO app.graph_edges (graph_id, source_node_id, target_node_id, created_at)
|
||||
VALUES (@graphId, @sourceNodeId, @targetNodeId, @createdAt)
|
||||
RETURNING id, graph_id, source_node_id, target_node_id, created_at";
|
||||
|
||||
var edge = await db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new GraphEdge
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
GraphId = reader.GetInt64(1),
|
||||
SourceNodeId = reader.GetInt64(2),
|
||||
TargetNodeId = reader.GetInt64(3),
|
||||
CreatedAt = reader.GetDateTime(4)
|
||||
};
|
||||
}, new
|
||||
{
|
||||
graphId,
|
||||
sourceNodeId,
|
||||
targetNodeId,
|
||||
createdAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await TouchGraphAsync(graphId);
|
||||
|
||||
return edge;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create edge in graph {GraphId}", graphId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteEdgeAsync(long edgeId, long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get edge to verify ownership through graph
|
||||
var edge = await GetEdgeByIdAsync(edgeId);
|
||||
if (edge == null) return false;
|
||||
|
||||
var graph = await GetGraphByIdAsync(edge.GraphId, userId);
|
||||
if (graph == null) return false;
|
||||
|
||||
var query = "DELETE FROM app.graph_edges WHERE id = @edgeId";
|
||||
var rowsAffected = await db.ExecuteNonQueryAsync(query, new { edgeId });
|
||||
|
||||
if (rowsAffected > 0)
|
||||
{
|
||||
await TouchGraphAsync(edge.GraphId);
|
||||
}
|
||||
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete edge {EdgeId}", edgeId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<GraphEdge?> GetEdgeByIdAsync(long edgeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
SELECT id, graph_id, source_node_id, target_node_id, created_at
|
||||
FROM app.graph_edges
|
||||
WHERE id = @edgeId";
|
||||
|
||||
return await db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new GraphEdge
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
GraphId = reader.GetInt64(1),
|
||||
SourceNodeId = reader.GetInt64(2),
|
||||
TargetNodeId = reader.GetInt64(3),
|
||||
CreatedAt = reader.GetDateTime(4)
|
||||
};
|
||||
}, new { edgeId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get edge {EdgeId}", edgeId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Filter Operations
|
||||
|
||||
public async Task<List<GraphNodeWithConnections>> FilterConnectsToAsync(long graphId, long userId, long targetNodeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Verify user owns the graph
|
||||
var graph = await GetGraphByIdAsync(graphId, userId);
|
||||
if (graph == null) return [];
|
||||
|
||||
var query = @"
|
||||
SELECT
|
||||
n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at,
|
||||
COUNT(DISTINCT e1.id) + COUNT(DISTINCT e2.id) as connection_count
|
||||
FROM app.graph_nodes n
|
||||
LEFT JOIN app.graph_edges e1 ON n.id = e1.source_node_id
|
||||
LEFT JOIN app.graph_edges e2 ON n.id = e2.target_node_id
|
||||
WHERE n.graph_id = @graphId
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM app.graph_edges e
|
||||
WHERE e.graph_id = @graphId
|
||||
AND (
|
||||
(e.source_node_id = n.id AND e.target_node_id = @targetNodeId)
|
||||
OR (e.target_node_id = n.id AND e.source_node_id = @targetNodeId)
|
||||
)
|
||||
)
|
||||
GROUP BY n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at
|
||||
ORDER BY n.label";
|
||||
|
||||
return await db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new GraphNodeWithConnections
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
GraphId = reader.GetInt64(1),
|
||||
Label = reader.GetString(2),
|
||||
Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
CreatedAt = reader.GetDateTime(4),
|
||||
UpdatedAt = reader.GetDateTime(5),
|
||||
ConnectionCount = reader.GetInt32(6)
|
||||
};
|
||||
}, new { graphId, targetNodeId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to filter nodes connecting to {NodeId}", targetNodeId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<GraphNodeWithConnections>> FilterDoesNotConnectToAsync(long graphId, long userId, long targetNodeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Verify user owns the graph
|
||||
var graph = await GetGraphByIdAsync(graphId, userId);
|
||||
if (graph == null) return [];
|
||||
|
||||
var query = @"
|
||||
SELECT
|
||||
n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at,
|
||||
COUNT(DISTINCT e1.id) + COUNT(DISTINCT e2.id) as connection_count
|
||||
FROM app.graph_nodes n
|
||||
LEFT JOIN app.graph_edges e1 ON n.id = e1.source_node_id
|
||||
LEFT JOIN app.graph_edges e2 ON n.id = e2.target_node_id
|
||||
WHERE n.graph_id = @graphId
|
||||
AND n.id != @targetNodeId
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM app.graph_edges e
|
||||
WHERE e.graph_id = @graphId
|
||||
AND (
|
||||
(e.source_node_id = n.id AND e.target_node_id = @targetNodeId)
|
||||
OR (e.target_node_id = n.id AND e.source_node_id = @targetNodeId)
|
||||
)
|
||||
)
|
||||
GROUP BY n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at
|
||||
ORDER BY n.label";
|
||||
|
||||
return await db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new GraphNodeWithConnections
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
GraphId = reader.GetInt64(1),
|
||||
Label = reader.GetString(2),
|
||||
Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
CreatedAt = reader.GetDateTime(4),
|
||||
UpdatedAt = reader.GetDateTime(5),
|
||||
ConnectionCount = reader.GetInt32(6)
|
||||
};
|
||||
}, new { graphId, targetNodeId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to filter nodes not connecting to {NodeId}", targetNodeId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<GraphNodeWithConnections>> FilterConnectsTwoDegreesAsync(long graphId, long userId, long targetNodeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Verify user owns the graph
|
||||
var graph = await GetGraphByIdAsync(graphId, userId);
|
||||
if (graph == null) return [];
|
||||
|
||||
// Find nodes that connect to nodes that connect to the target (bidirectional)
|
||||
var query = @"
|
||||
SELECT DISTINCT
|
||||
n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at,
|
||||
COUNT(DISTINCT e1.id) + COUNT(DISTINCT e2.id) as connection_count
|
||||
FROM app.graph_nodes n
|
||||
LEFT JOIN app.graph_edges e1 ON n.id = e1.source_node_id
|
||||
LEFT JOIN app.graph_edges e2 ON n.id = e2.target_node_id
|
||||
WHERE n.graph_id = @graphId
|
||||
AND n.id != @targetNodeId
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM app.graph_edges e_first, app.graph_edges e_second
|
||||
WHERE e_first.graph_id = @graphId
|
||||
AND e_second.graph_id = @graphId
|
||||
-- n connects to intermediate node (bidirectional)
|
||||
AND (
|
||||
(e_first.source_node_id = n.id AND e_second.source_node_id = e_first.target_node_id)
|
||||
OR (e_first.source_node_id = n.id AND e_second.target_node_id = e_first.target_node_id)
|
||||
OR (e_first.target_node_id = n.id AND e_second.source_node_id = e_first.source_node_id)
|
||||
OR (e_first.target_node_id = n.id AND e_second.target_node_id = e_first.source_node_id)
|
||||
)
|
||||
-- intermediate node connects to target (bidirectional)
|
||||
AND (
|
||||
e_second.source_node_id = @targetNodeId
|
||||
OR e_second.target_node_id = @targetNodeId
|
||||
)
|
||||
-- Exclude direct connections
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM app.graph_edges e_direct
|
||||
WHERE e_direct.graph_id = @graphId
|
||||
AND (
|
||||
(e_direct.source_node_id = n.id AND e_direct.target_node_id = @targetNodeId)
|
||||
OR (e_direct.target_node_id = n.id AND e_direct.source_node_id = @targetNodeId)
|
||||
)
|
||||
)
|
||||
)
|
||||
GROUP BY n.id, n.graph_id, n.label, n.notes, n.created_at, n.updated_at
|
||||
ORDER BY n.label";
|
||||
|
||||
return await db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new GraphNodeWithConnections
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
GraphId = reader.GetInt64(1),
|
||||
Label = reader.GetString(2),
|
||||
Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
CreatedAt = reader.GetDateTime(4),
|
||||
UpdatedAt = reader.GetDateTime(5),
|
||||
ConnectionCount = reader.GetInt32(6)
|
||||
};
|
||||
}, new { graphId, targetNodeId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to filter nodes with two-degree connections to {NodeId}", targetNodeId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task TouchGraphAsync(long graphId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = "UPDATE app.graphs SET updated_at = @updatedAt WHERE id = @graphId";
|
||||
await db.ExecuteNonQueryAsync(query, new { graphId, updatedAt = DateTime.UtcNow });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to touch graph {GraphId}", graphId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GraphData> GetGraphDataAsync(long graphId, long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var graph = await GetGraphByIdAsync(graphId, userId);
|
||||
if (graph == null) return new GraphData();
|
||||
|
||||
var nodes = await GetGraphNodesAsync(graphId, userId);
|
||||
var edges = await GetGraphEdgesAsync(graphId, userId);
|
||||
|
||||
return new GraphData
|
||||
{
|
||||
Graph = graph,
|
||||
Nodes = nodes,
|
||||
Edges = edges
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get graph data for {GraphId}", graphId);
|
||||
return new GraphData();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -2,23 +2,8 @@ using Media.JoshHeaps.Net.Models;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Services;
|
||||
|
||||
public class MediaService
|
||||
public class MediaService(DbExecutor db, IWebHostEnvironment environment, EncryptionService encryption, ILogger<MediaService> logger, FolderService folderService)
|
||||
{
|
||||
private readonly DbExecutor _db;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly EncryptionService _encryption;
|
||||
private readonly ILogger<MediaService> _logger;
|
||||
private readonly FolderService _folderService;
|
||||
|
||||
public MediaService(DbExecutor db, IWebHostEnvironment environment, EncryptionService encryption, ILogger<MediaService> logger, FolderService folderService)
|
||||
{
|
||||
_db = db;
|
||||
_environment = environment;
|
||||
_encryption = encryption;
|
||||
_logger = logger;
|
||||
_folderService = folderService;
|
||||
}
|
||||
|
||||
public async Task<UserMedia?> SaveMediaAsync(long userId, IFormFile file, string? description = null, long? folderId = null)
|
||||
{
|
||||
string? tempFilePath = null;
|
||||
@@ -31,7 +16,7 @@ public class MediaService
|
||||
var uniqueFileName = $"{Guid.NewGuid()}{fileExtension}.enc"; // .enc for encrypted
|
||||
|
||||
// Store outside wwwroot in App_Data folder
|
||||
var mediaFolder = Path.Combine(_environment.ContentRootPath, "App_Data", "media", userId.ToString());
|
||||
var mediaFolder = Path.Combine(environment.ContentRootPath, "App_Data", "media", userId.ToString());
|
||||
Directory.CreateDirectory(mediaFolder);
|
||||
|
||||
tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
@@ -57,12 +42,12 @@ public class MediaService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read image dimensions for {FileName}", file.FileName);
|
||||
logger.LogWarning(ex, "Failed to read image dimensions for {FileName}", file.FileName);
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt and save
|
||||
await _encryption.EncryptFileAsync(tempFilePath, encryptedFilePath);
|
||||
await encryption.EncryptFileAsync(tempFilePath, encryptedFilePath);
|
||||
|
||||
// Delete temp file
|
||||
File.Delete(tempFilePath);
|
||||
@@ -77,7 +62,7 @@ public class MediaService
|
||||
VALUES (@userId, @fileName, @filePath, @fileSize, @mimeType, @width, @height, @description, @isEncrypted, @folderId, @createdAt, @updatedAt)
|
||||
RETURNING id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, folder_id, created_at, updated_at";
|
||||
|
||||
var media = await _db.ExecuteReaderAsync(query, reader =>
|
||||
var media = await db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new UserMedia
|
||||
{
|
||||
@@ -115,7 +100,7 @@ public class MediaService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save media for user {UserId}", userId);
|
||||
logger.LogError(ex, "Failed to save media for user {UserId}", userId);
|
||||
|
||||
// Cleanup on error
|
||||
if (tempFilePath != null && File.Exists(tempFilePath))
|
||||
@@ -139,14 +124,14 @@ public class MediaService
|
||||
var effectiveUserId = userId;
|
||||
if (requestingUserId.HasValue && folderId.HasValue)
|
||||
{
|
||||
var hasAccess = await _folderService.HasFolderAccessAsync(folderId.Value, requestingUserId.Value);
|
||||
var hasAccess = await folderService.HasFolderAccessAsync(folderId.Value, requestingUserId.Value);
|
||||
if (!hasAccess)
|
||||
{
|
||||
_logger.LogWarning("User {RequestingUserId} does not have access to folder {FolderId}", requestingUserId.Value, folderId.Value);
|
||||
return new List<UserMedia>();
|
||||
logger.LogWarning("User {RequestingUserId} does not have access to folder {FolderId}", requestingUserId.Value, folderId.Value);
|
||||
return [];
|
||||
}
|
||||
// Get the actual owner of the folder
|
||||
var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId.Value);
|
||||
var ownerId = await folderService.GetFolderOwnerIdAsync(folderId.Value);
|
||||
if (ownerId.HasValue)
|
||||
{
|
||||
effectiveUserId = ownerId.Value;
|
||||
@@ -173,7 +158,7 @@ public class MediaService
|
||||
OFFSET @offset LIMIT @limit";
|
||||
}
|
||||
|
||||
var mediaList = await _db.ExecuteListReaderAsync(query, reader =>
|
||||
var mediaList = await db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new UserMedia
|
||||
{
|
||||
@@ -197,8 +182,8 @@ public class MediaService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get media for user {UserId}", userId);
|
||||
return new List<UserMedia>();
|
||||
logger.LogError(ex, "Failed to get media for user {UserId}", userId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +197,7 @@ public class MediaService
|
||||
FROM app.user_media
|
||||
WHERE id = @mediaId";
|
||||
|
||||
var media = await _db.ExecuteReaderAsync(query, reader =>
|
||||
var media = await db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new UserMedia
|
||||
{
|
||||
@@ -244,7 +229,7 @@ public class MediaService
|
||||
}
|
||||
|
||||
// Check if user has access via shared folder
|
||||
if (media.FolderId.HasValue && await _folderService.HasFolderAccessAsync(media.FolderId.Value, userId))
|
||||
if (media.FolderId.HasValue && await folderService.HasFolderAccessAsync(media.FolderId.Value, userId))
|
||||
{
|
||||
return media;
|
||||
}
|
||||
@@ -254,7 +239,7 @@ public class MediaService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get media {MediaId} for user {UserId}", mediaId, userId);
|
||||
logger.LogError(ex, "Failed to get media {MediaId} for user {UserId}", mediaId, userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -266,21 +251,21 @@ public class MediaService
|
||||
var media = await GetMediaByIdAsync(mediaId, userId);
|
||||
if (media == null)
|
||||
{
|
||||
_logger.LogWarning("Media {MediaId} not found or user {UserId} doesn't have access", mediaId, userId);
|
||||
logger.LogWarning("Media {MediaId} not found or user {UserId} doesn't have access", mediaId, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_environment.ContentRootPath, media.FilePath);
|
||||
var fullPath = Path.Combine(environment.ContentRootPath, media.FilePath);
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogError("Media file not found at {FilePath}", fullPath);
|
||||
logger.LogError("Media file not found at {FilePath}", fullPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (media.IsEncrypted)
|
||||
{
|
||||
return await _encryption.DecryptFileAsync(fullPath);
|
||||
return await encryption.DecryptFileAsync(fullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -289,7 +274,7 @@ public class MediaService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get decrypted media data for {MediaId}", mediaId);
|
||||
logger.LogError(ex, "Failed to get decrypted media data for {MediaId}", mediaId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -300,16 +285,16 @@ public class MediaService
|
||||
{
|
||||
// Get media info first
|
||||
var query = "SELECT file_path FROM app.user_media WHERE id = @mediaId AND user_id = @userId";
|
||||
var filePath = await _db.ExecuteReaderAsync(query, reader => reader.GetString(0), new { mediaId, userId });
|
||||
var filePath = await db.ExecuteReaderAsync(query, reader => reader.GetString(0), new { mediaId, userId });
|
||||
|
||||
if (filePath == null) return false;
|
||||
|
||||
// Delete from database
|
||||
var deleteQuery = "DELETE FROM app.user_media WHERE id = @mediaId AND user_id = @userId";
|
||||
await _db.ExecuteAsync<object>(deleteQuery, new { mediaId, userId });
|
||||
await db.ExecuteAsync<object>(deleteQuery, new { mediaId, userId });
|
||||
|
||||
// Delete physical file
|
||||
var physicalPath = Path.Combine(_environment.ContentRootPath, filePath);
|
||||
var physicalPath = Path.Combine(environment.ContentRootPath, filePath);
|
||||
if (File.Exists(physicalPath))
|
||||
{
|
||||
File.Delete(physicalPath);
|
||||
@@ -319,7 +304,7 @@ public class MediaService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete media {MediaId} for user {UserId}", mediaId, userId);
|
||||
logger.LogError(ex, "Failed to delete media {MediaId} for user {UserId}", mediaId, userId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -333,7 +318,7 @@ public class MediaService
|
||||
SET folder_id = @folderId, updated_at = @updatedAt
|
||||
WHERE id = @mediaId AND user_id = @userId";
|
||||
|
||||
await _db.ExecuteAsync<object>(query, new
|
||||
await db.ExecuteAsync<object>(query, new
|
||||
{
|
||||
mediaId,
|
||||
userId,
|
||||
@@ -345,7 +330,7 @@ public class MediaService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to move media {MediaId} to folder {FolderId} for user {UserId}", mediaId, folderId, userId);
|
||||
logger.LogError(ex, "Failed to move media {MediaId} to folder {FolderId} for user {UserId}", mediaId, folderId, userId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,8 @@ using Media.JoshHeaps.Net.Models;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Services;
|
||||
|
||||
public class UserService
|
||||
public class UserService(DbExecutor db)
|
||||
{
|
||||
private readonly DbExecutor _db;
|
||||
|
||||
public UserService(DbExecutor db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<UserDashboard?> GetUserDashboardAsync(long userId)
|
||||
{
|
||||
try
|
||||
@@ -23,7 +16,7 @@ public class UserService
|
||||
LEFT JOIN app.user_profiles p ON u.id = p.user_id
|
||||
WHERE u.id = @userId";
|
||||
|
||||
var dashboard = await _db.ExecuteReaderAsync(query, reader =>
|
||||
var dashboard = await db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new UserDashboard
|
||||
{
|
||||
@@ -62,7 +55,7 @@ public class UserService
|
||||
FROM app.user_profiles
|
||||
WHERE user_id = @userId";
|
||||
|
||||
var profile = await _db.ExecuteReaderAsync(query, reader =>
|
||||
var profile = await db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new UserProfile
|
||||
{
|
||||
@@ -87,7 +80,7 @@ public class UserService
|
||||
{
|
||||
try
|
||||
{
|
||||
await _db.ExecuteAsync<object>(
|
||||
await db.ExecuteAsync<object>(
|
||||
@"UPDATE app.user_profiles
|
||||
SET bio = @bio, avatar_url = @avatarUrl, location = @location,
|
||||
website = @website, updated_at = @updatedAt
|
||||
@@ -116,7 +109,7 @@ public class UserService
|
||||
ORDER BY username ASC
|
||||
LIMIT 10";
|
||||
|
||||
var users = await _db.ExecuteListReaderAsync(searchQuery, reader =>
|
||||
var users = await db.ExecuteListReaderAsync(searchQuery, reader =>
|
||||
{
|
||||
return new UserSearchResult
|
||||
{
|
||||
@@ -130,7 +123,7 @@ public class UserService
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new List<UserSearchResult>();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
.dashboard-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
color: var(--text-primary);
|
||||
padding: 32px 40px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.welcome-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.back-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.welcome-section h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.welcome-section p {
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border-secondary);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-verified {
|
||||
background: rgba(63, 185, 80, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-unverified {
|
||||
background: rgba(187, 128, 9, 0.15);
|
||||
color: #d29922;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-content: space-evenly;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-secondary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--danger);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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">×</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
@@ -0,0 +1,73 @@
|
||||
# Run Database Migrations
|
||||
# This script executes all SQL files in the Database folder in order
|
||||
|
||||
param(
|
||||
[string]$ConnectionString = ""
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrEmpty($ConnectionString)) {
|
||||
Write-Host "Usage: .\run-migrations.ps1 -ConnectionString 'your-connection-string'"
|
||||
Write-Host ""
|
||||
Write-Host "Example:"
|
||||
Write-Host ".\run-migrations.ps1 -ConnectionString 'Host=localhost;Database=mydb;Username=postgres;Password=mypassword'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Get all SQL files in the Database folder, sorted by name
|
||||
$sqlFiles = Get-ChildItem -Path ".\Media.JoshHeaps.Net\Database\*.sql" | Sort-Object Name
|
||||
|
||||
if ($sqlFiles.Count -eq 0) {
|
||||
Write-Host "No SQL migration files found in .\Media.JoshHeaps.Net\Database\"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($sqlFiles.Count) migration files to execute:"
|
||||
$sqlFiles | ForEach-Object { Write-Host " - $($_.Name)" }
|
||||
Write-Host ""
|
||||
|
||||
# Check if psql is available
|
||||
$psqlPath = Get-Command psql -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not $psqlPath) {
|
||||
Write-Host "ERROR: psql command not found. Please install PostgreSQL client tools."
|
||||
Write-Host ""
|
||||
Write-Host "Alternatively, you can manually run each SQL file using your preferred PostgreSQL client:"
|
||||
$sqlFiles | ForEach-Object {
|
||||
Write-Host " psql `"$ConnectionString`" -f `"$($_.FullName)`""
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Execute each migration
|
||||
$successCount = 0
|
||||
$failCount = 0
|
||||
|
||||
foreach ($file in $sqlFiles) {
|
||||
Write-Host "Executing: $($file.Name)... " -NoNewline
|
||||
|
||||
try {
|
||||
$result = psql $ConnectionString -f $file.FullName 2>&1
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "SUCCESS" -ForegroundColor Green
|
||||
$successCount++
|
||||
} else {
|
||||
Write-Host "FAILED" -ForegroundColor Red
|
||||
Write-Host " Error: $result"
|
||||
$failCount++
|
||||
}
|
||||
} catch {
|
||||
Write-Host "FAILED" -ForegroundColor Red
|
||||
Write-Host " Error: $($_.Exception.Message)"
|
||||
$failCount++
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Migration Summary:"
|
||||
Write-Host " Successful: $successCount" -ForegroundColor Green
|
||||
Write-Host " Failed: $failCount" -ForegroundColor $(if ($failCount -gt 0) { "Red" } else { "Green" })
|
||||
|
||||
if ($failCount -gt 0) {
|
||||
exit 1
|
||||
}
|
||||
Reference in New Issue
Block a user