diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c72243a..b93b5ca 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,8 @@ "Bash(dotnet run:*)", "Bash(powershell:*)", "Bash(dotnet script:*)", - "Bash(taskkill:*)" + "Bash(taskkill:*)", + "Bash(dir:*)" ], "deny": [], "ask": [] diff --git a/Media.JoshHeaps.Net/Api/FolderApi.cs b/Media.JoshHeaps.Net/Api/FolderApi.cs new file mode 100644 index 0000000..2c66abf --- /dev/null +++ b/Media.JoshHeaps.Net/Api/FolderApi.cs @@ -0,0 +1,148 @@ +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Media.JoshHeaps.Net.Api; + +[ApiController] +[Route("api/folder")] +public class FolderApi : ControllerBase +{ + private readonly FolderService _folderService; + + public FolderApi(FolderService folderService) + { + _folderService = folderService; + } + + [HttpGet("list")] + public async Task ListFolders([FromQuery] long? folderId = null) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var folders = await _folderService.GetUserFoldersAsync(userId.Value, folderId); + return Ok(folders); + } + + [HttpGet("path")] + public async Task GetFolderPath([FromQuery] long? folderId = null) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var path = await _folderService.GetFolderPathAsync(folderId, userId.Value); + return Ok(path); + } + + [HttpPost("create")] + public async Task CreateFolder([FromBody] CreateFolderRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + return BadRequest(new { error = "Folder name is required" }); + } + + var folder = await _folderService.CreateFolderAsync(userId.Value, request.Name, request.ParentFolderId); + if (folder == null) + { + return BadRequest(new { error = "Failed to create folder" }); + } + + return Ok(folder); + } + + [HttpPut("rename")] + public async Task RenameFolder([FromBody] RenameFolderRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + if (string.IsNullOrWhiteSpace(request.NewName)) + { + return BadRequest(new { error = "New folder name is required" }); + } + + var success = await _folderService.RenameFolderAsync(request.FolderId, userId.Value, request.NewName); + if (!success) + { + return BadRequest(new { error = "Failed to rename folder" }); + } + + return Ok(new { success = true }); + } + + [HttpDelete("delete")] + public async Task DeleteFolder([FromQuery] long folderId, [FromQuery] bool deleteContents = false) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var success = await _folderService.DeleteFolderAsync(folderId, userId.Value, deleteContents); + if (!success) + { + return BadRequest(new { error = "Failed to delete folder" }); + } + + return Ok(new { success = true }); + } + + [HttpPut("move")] + public async Task MoveFolder([FromBody] MoveFolderRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var success = await _folderService.MoveFolderAsync(request.FolderId, userId.Value, request.NewParentFolderId); + if (!success) + { + return BadRequest(new { error = "Failed to move folder" }); + } + + return Ok(new { success = true }); + } + + 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; + } +} + +public record CreateFolderRequest(string Name, long? ParentFolderId); +public record RenameFolderRequest(long FolderId, string NewName); +public record MoveFolderRequest(long FolderId, long? NewParentFolderId); diff --git a/Media.JoshHeaps.Net/Api/MediaApi.cs b/Media.JoshHeaps.Net/Api/MediaApi.cs index 780445e..b5bccdc 100644 --- a/Media.JoshHeaps.Net/Api/MediaApi.cs +++ b/Media.JoshHeaps.Net/Api/MediaApi.cs @@ -16,7 +16,7 @@ public class MediaApi : ControllerBase } [HttpGet("load")] - public async Task LoadMedia([FromQuery] int offset = 0, [FromQuery] int limit = 20) + public async Task LoadMedia([FromQuery] int offset = 0, [FromQuery] int limit = 20, [FromQuery] long? folderId = null) { // Get user ID from JWT claims or session var userId = GetUserIdFromAuth(); @@ -25,7 +25,7 @@ public class MediaApi : ControllerBase return Unauthorized(); } - var media = await _mediaService.GetUserMediaAsync(userId.Value, offset, limit); + var media = await _mediaService.GetUserMediaAsync(userId.Value, offset, limit, folderId); return Ok(media); } @@ -58,6 +58,102 @@ public class MediaApi : ControllerBase return File(imageData, media.MimeType); } + [HttpPost("upload")] + public async Task UploadImage([FromForm] IFormFile file, [FromForm] string? description, [FromForm] long? folderId) + { + // Get user ID from JWT claims or session + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + if (file == null || file.Length == 0) + { + return BadRequest(new { error = "No file provided" }); + } + + // Validate file type + var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" }; + if (!allowedTypes.Contains(file.ContentType.ToLower())) + { + return BadRequest(new { error = "Invalid file type. Only images (JPG, PNG, GIF, WEBP) are allowed" }); + } + + // Validate file size (10MB limit) + if (file.Length > 10 * 1024 * 1024) + { + return BadRequest(new { error = "File size exceeds 10MB limit" }); + } + + // Save media using existing service + var media = await _mediaService.SaveMediaAsync(userId.Value, file, description, folderId); + if (media == null) + { + return StatusCode(500, new { error = "Failed to upload image" }); + } + + return Ok(media); + } + + [HttpPut("move")] + public async Task MoveMedia([FromBody] MoveMediaRequest request) + { + // Get user ID from JWT claims or session + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var success = await _mediaService.MoveMediaToFolderAsync(request.MediaId, userId.Value, request.FolderId); + if (!success) + { + return BadRequest(new { error = "Failed to move media" }); + } + + return Ok(new { success = true }); + } + + [HttpPut("move-bulk")] + public async Task MoveBulkMedia([FromBody] MoveBulkMediaRequest request) + { + // Get user ID from JWT claims or session + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + if (request.MediaIds == null || !request.MediaIds.Any()) + { + return BadRequest(new { error = "No media IDs provided" }); + } + + var successCount = 0; + var failedIds = new List(); + + foreach (var mediaId in request.MediaIds) + { + var success = await _mediaService.MoveMediaToFolderAsync(mediaId, userId.Value, request.FolderId); + if (success) + { + successCount++; + } + else + { + failedIds.Add(mediaId); + } + } + + return Ok(new { + success = failedIds.Count == 0, + movedCount = successCount, + failedCount = failedIds.Count, + failedIds = failedIds + }); + } + /// /// Gets user ID from either JWT claims (for API) or session (for web) /// @@ -80,3 +176,6 @@ public class MediaApi : ControllerBase return null; } } + +public record MoveMediaRequest(long MediaId, long? FolderId); +public record MoveBulkMediaRequest(List MediaIds, long? FolderId); diff --git a/Media.JoshHeaps.Net/Database/004_folders.sql b/Media.JoshHeaps.Net/Database/004_folders.sql new file mode 100644 index 0000000..8a42c12 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/004_folders.sql @@ -0,0 +1,18 @@ +-- User Folders Table +-- Stores folder hierarchy for organizing media +CREATE TABLE IF NOT EXISTS app.folders ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + parent_folder_id BIGINT NULL, -- NULL means root level folder + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES app.users(id) ON DELETE CASCADE, + FOREIGN KEY (parent_folder_id) REFERENCES app.folders(id) ON DELETE CASCADE, + -- Ensure folder names are unique within the same parent folder for a user + UNIQUE (user_id, parent_folder_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_folders_user_id ON app.folders(user_id); +CREATE INDEX IF NOT EXISTS idx_folders_parent_folder_id ON app.folders(parent_folder_id); +CREATE INDEX IF NOT EXISTS idx_folders_user_parent ON app.folders(user_id, parent_folder_id); diff --git a/Media.JoshHeaps.Net/Database/005_alter_user_media_add_folder.sql b/Media.JoshHeaps.Net/Database/005_alter_user_media_add_folder.sql new file mode 100644 index 0000000..f4de4d3 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/005_alter_user_media_add_folder.sql @@ -0,0 +1,15 @@ +-- Add folder support to user_media table +-- This is backwards compatible - existing media will have NULL folder_id (root level) +ALTER TABLE app.user_media +ADD COLUMN IF NOT EXISTS folder_id BIGINT NULL; + +-- Add foreign key constraint +ALTER TABLE app.user_media +ADD CONSTRAINT fk_user_media_folder +FOREIGN KEY (folder_id) REFERENCES app.folders(id) ON DELETE SET NULL; + +-- Create index for efficient folder-based queries +CREATE INDEX IF NOT EXISTS idx_user_media_folder_id ON app.user_media(folder_id); + +-- Create compound index for user + folder queries +CREATE INDEX IF NOT EXISTS idx_user_media_user_folder ON app.user_media(user_id, folder_id); diff --git a/Media.JoshHeaps.Net/Models/Folder.cs b/Media.JoshHeaps.Net/Models/Folder.cs new file mode 100644 index 0000000..7f357c7 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/Folder.cs @@ -0,0 +1,11 @@ +namespace Media.JoshHeaps.Net.Models; + +public class Folder +{ + public long Id { get; set; } + public long UserId { get; set; } + public string Name { get; set; } = string.Empty; + public long? ParentFolderId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/Media.JoshHeaps.Net/Models/UserMedia.cs b/Media.JoshHeaps.Net/Models/UserMedia.cs index cb33ebd..3c0690b 100644 --- a/Media.JoshHeaps.Net/Models/UserMedia.cs +++ b/Media.JoshHeaps.Net/Models/UserMedia.cs @@ -12,6 +12,7 @@ public class UserMedia public int? Height { get; set; } public string? Description { get; set; } public bool IsEncrypted { get; set; } = true; + public long? FolderId { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } diff --git a/Media.JoshHeaps.Net/Pages/Index.cshtml b/Media.JoshHeaps.Net/Pages/Index.cshtml index 670cd30..35ca9c3 100644 --- a/Media.JoshHeaps.Net/Pages/Index.cshtml +++ b/Media.JoshHeaps.Net/Pages/Index.cshtml @@ -57,11 +57,61 @@
-

My Images (@Model.MediaItems.Count)

+ +

+ @if (Model.CurrentFolderId.HasValue) + { + @(Model.FolderPath.LastOrDefault()?.Name ?? "Folder") + } + else + { + My Media + } + (@(Model.Folders.Count + Model.MediaItems.Count) items) +