Add folders
This commit is contained in:
@@ -5,7 +5,8 @@
|
||||
"Bash(dotnet run:*)",
|
||||
"Bash(powershell:*)",
|
||||
"Bash(dotnet script:*)",
|
||||
"Bash(taskkill:*)"
|
||||
"Bash(taskkill:*)",
|
||||
"Bash(dir:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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);
|
||||
@@ -16,7 +16,7 @@ public class MediaApi : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("load")]
|
||||
public async Task<IActionResult> LoadMedia([FromQuery] int offset = 0, [FromQuery] int limit = 20)
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<long>();
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets user ID from either JWT claims (for API) or session (for web)
|
||||
/// </summary>
|
||||
@@ -80,3 +176,6 @@ public class MediaApi : ControllerBase
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public record MoveMediaRequest(long MediaId, long? FolderId);
|
||||
public record MoveBulkMediaRequest(List<long> MediaIds, long? FolderId);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -57,11 +57,61 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>My Images (@Model.MediaItems.Count)</h2>
|
||||
<div class="gallery-header">
|
||||
<div class="breadcrumb-nav">
|
||||
<a href="/">Home</a>
|
||||
@foreach (var folder in Model.FolderPath)
|
||||
{
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<a href="/[email protected]">@folder.Name</a>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" id="new-folder-btn">New Folder</button>
|
||||
</div>
|
||||
<h2>
|
||||
@if (Model.CurrentFolderId.HasValue)
|
||||
{
|
||||
<span>@(Model.FolderPath.LastOrDefault()?.Name ?? "Folder")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>My Media</span>
|
||||
}
|
||||
<span class="item-count">(@(Model.Folders.Count + Model.MediaItems.Count) items)</span>
|
||||
</h2>
|
||||
<div id="gallery" class="gallery-grid">
|
||||
@if (Model.CurrentFolderId.HasValue)
|
||||
{
|
||||
<div class="gallery-item folder-item" data-folder-id="back" onclick="window.location.href='/?folderId=@(Model.FolderPath.Count > 1 ? Model.FolderPath[Model.FolderPath.Count - 2].Id.ToString() : "")'">
|
||||
<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="M9 6l6 6-6 6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="folder-name">..</div>
|
||||
</div>
|
||||
}
|
||||
@foreach (var folder in Model.Folders)
|
||||
{
|
||||
<div class="gallery-item folder-item" data-folder-id="@folder.Id" onclick="window.location.href='/[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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="folder-name">@folder.Name</div>
|
||||
<div class="folder-actions">
|
||||
<button type="button" class="btn-action" onclick="event.stopPropagation(); renameFolder(@folder.Id, '@folder.Name')">Rename</button>
|
||||
<button type="button" class="btn-action" onclick="event.stopPropagation(); deleteFolder(@folder.Id)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@foreach (var media in Model.MediaItems)
|
||||
{
|
||||
<div class="gallery-item" data-media-id="@media.Id">
|
||||
<div class="gallery-item media-item" data-media-id="@media.Id" draggable="true">
|
||||
<div class="selection-checkbox">
|
||||
<input type="checkbox" class="media-select" data-media-id="@media.Id" />
|
||||
</div>
|
||||
<img src="/api/media/image/@media.Id" alt="@media.FileName" loading="lazy" />
|
||||
@if (!string.IsNullOrEmpty(media.Description))
|
||||
{
|
||||
@@ -84,12 +134,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selection Action Bar -->
|
||||
<div id="selection-bar" class="selection-bar" style="display: none;">
|
||||
<div class="selection-info">
|
||||
<span id="selection-count">0 images selected</span>
|
||||
<button type="button" class="btn btn-link" id="select-all-btn">Select All</button>
|
||||
<button type="button" class="btn btn-link" id="deselect-all-btn">Deselect All</button>
|
||||
</div>
|
||||
<div class="selection-actions">
|
||||
<button type="button" class="btn btn-primary" id="move-here-btn">Move Here</button>
|
||||
<button type="button" class="btn btn-secondary" id="cancel-selection-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Add Files Button -->
|
||||
<button type="button" class="floating-add-btn" id="add-files-btn" title="Add Files">
|
||||
<span class="btn-icon">+</span>
|
||||
</button>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
const currentFolderId = @(Model.CurrentFolderId?.ToString() ?? "null");
|
||||
</script>
|
||||
<script src="~/js/gallery.js" asp-append-version="true"></script>
|
||||
<script src="~/js/upload.js" asp-append-version="true"></script>
|
||||
<script src="~/js/folders.js" asp-append-version="true"></script>
|
||||
<script src="~/js/drag-drop.js" asp-append-version="true"></script>
|
||||
<script src="~/js/context-menu.js" asp-append-version="true"></script>
|
||||
}
|
||||
@@ -4,12 +4,16 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages
|
||||
{
|
||||
public class IndexModel(UserService userService, MediaService mediaService) : AuthenticatedPageModel
|
||||
public class IndexModel(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 long? CurrentFolderId { get; set; }
|
||||
|
||||
[BindProperty]
|
||||
public IFormFile? UploadedFile { get; set; }
|
||||
@@ -23,27 +27,38 @@ namespace Media.JoshHeaps.Net.Pages
|
||||
public string? UploadMessage { get; set; }
|
||||
public bool UploadSuccess { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
public async Task<IActionResult> OnGetAsync(long? folderId = null)
|
||||
{
|
||||
RequireAuthentication();
|
||||
LoadUserSession();
|
||||
|
||||
CurrentFolderId = folderId;
|
||||
|
||||
// Load user dashboard data
|
||||
Dashboard = await userService.GetUserDashboardAsync(UserId);
|
||||
|
||||
// Load folders in current directory
|
||||
Folders = await _folderService.GetUserFoldersAsync(UserId, CurrentFolderId);
|
||||
|
||||
// Load folder path (breadcrumbs)
|
||||
FolderPath = await _folderService.GetFolderPathAsync(CurrentFolderId, UserId);
|
||||
|
||||
// Load initial set of media
|
||||
MediaItems = await _mediaService.GetUserMediaAsync(UserId, 0, 20);
|
||||
MediaItems = await _mediaService.GetUserMediaAsync(UserId, 0, 20, CurrentFolderId);
|
||||
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
public async Task<IActionResult> OnPostAsync(long? folderId = null)
|
||||
{
|
||||
RequireAuthentication();
|
||||
LoadUserSession();
|
||||
|
||||
CurrentFolderId = folderId;
|
||||
Dashboard = await userService.GetUserDashboardAsync(UserId);
|
||||
MediaItems = await _mediaService.GetUserMediaAsync(UserId, 0, 20);
|
||||
Folders = await _folderService.GetUserFoldersAsync(UserId, CurrentFolderId);
|
||||
FolderPath = await _folderService.GetFolderPathAsync(CurrentFolderId, UserId);
|
||||
MediaItems = await _mediaService.GetUserMediaAsync(UserId, 0, 20, CurrentFolderId);
|
||||
|
||||
return Page();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ builder.Services.AddScoped<AuthService>();
|
||||
builder.Services.AddScoped<EmailService>();
|
||||
builder.Services.AddScoped<UserService>();
|
||||
builder.Services.AddScoped<MediaService>();
|
||||
builder.Services.AddScoped<FolderService>();
|
||||
|
||||
// Add session support
|
||||
builder.Services.AddDistributedMemoryCache();
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Services;
|
||||
|
||||
public class FolderService
|
||||
{
|
||||
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
|
||||
{
|
||||
// Validate folder name
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
_logger.LogWarning("Cannot create folder with empty name for user {UserId}", userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sanitize folder name (remove invalid characters)
|
||||
var sanitizedName = SanitizeFolderName(name);
|
||||
if (string.IsNullOrWhiteSpace(sanitizedName))
|
||||
{
|
||||
_logger.LogWarning("Folder name became empty after sanitization for user {UserId}", userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// If parent folder is specified, verify it exists and belongs to user
|
||||
if (parentFolderId.HasValue)
|
||||
{
|
||||
var parentExists = await FolderExistsAsync(parentFolderId.Value, userId);
|
||||
if (!parentExists)
|
||||
{
|
||||
_logger.LogWarning("Parent folder {ParentFolderId} not found for user {UserId}", parentFolderId.Value, userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var query = @"
|
||||
INSERT INTO app.folders (user_id, name, parent_folder_id, created_at, updated_at)
|
||||
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 =>
|
||||
{
|
||||
return new Folder
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
UserId = reader.GetInt64(1),
|
||||
Name = reader.GetString(2),
|
||||
ParentFolderId = reader.IsDBNull(3) ? null : reader.GetInt64(3),
|
||||
CreatedAt = reader.GetDateTime(4),
|
||||
UpdatedAt = reader.GetDateTime(5)
|
||||
};
|
||||
}, new
|
||||
{
|
||||
userId,
|
||||
name = sanitizedName,
|
||||
parentFolderId,
|
||||
createdAt = DateTime.UtcNow,
|
||||
updatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
return folder;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create folder '{Name}' for user {UserId}", name, userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Folder>> GetUserFoldersAsync(long userId, long? parentFolderId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string query;
|
||||
if (parentFolderId.HasValue)
|
||||
{
|
||||
query = @"
|
||||
SELECT id, user_id, name, parent_folder_id, created_at, updated_at
|
||||
FROM app.folders
|
||||
WHERE user_id = @userId AND parent_folder_id = @parentFolderId
|
||||
ORDER BY name ASC";
|
||||
}
|
||||
else
|
||||
{
|
||||
query = @"
|
||||
SELECT id, user_id, name, parent_folder_id, created_at, updated_at
|
||||
FROM app.folders
|
||||
WHERE user_id = @userId AND parent_folder_id IS NULL
|
||||
ORDER BY name ASC";
|
||||
}
|
||||
|
||||
var folders = await _db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new Folder
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
UserId = reader.GetInt64(1),
|
||||
Name = reader.GetString(2),
|
||||
ParentFolderId = reader.IsDBNull(3) ? null : reader.GetInt64(3),
|
||||
CreatedAt = reader.GetDateTime(4),
|
||||
UpdatedAt = reader.GetDateTime(5)
|
||||
};
|
||||
}, new { userId, parentFolderId });
|
||||
|
||||
return folders;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get folders for user {UserId}, parent {ParentFolderId}", userId, parentFolderId);
|
||||
return new List<Folder>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Folder?> GetFolderByIdAsync(long folderId, long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
SELECT id, user_id, name, parent_folder_id, created_at, updated_at
|
||||
FROM app.folders
|
||||
WHERE id = @folderId AND user_id = @userId";
|
||||
|
||||
var folder = await _db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new Folder
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
UserId = reader.GetInt64(1),
|
||||
Name = reader.GetString(2),
|
||||
ParentFolderId = reader.IsDBNull(3) ? null : reader.GetInt64(3),
|
||||
CreatedAt = reader.GetDateTime(4),
|
||||
UpdatedAt = reader.GetDateTime(5)
|
||||
};
|
||||
}, new { folderId, userId });
|
||||
|
||||
return folder;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get folder {FolderId} for user {UserId}", folderId, userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> RenameFolderAsync(long folderId, long userId, string newName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sanitizedName = SanitizeFolderName(newName);
|
||||
if (string.IsNullOrWhiteSpace(sanitizedName))
|
||||
{
|
||||
_logger.LogWarning("Cannot rename folder to empty name");
|
||||
return false;
|
||||
}
|
||||
|
||||
var query = @"
|
||||
UPDATE app.folders
|
||||
SET name = @newName, updated_at = @updatedAt
|
||||
WHERE id = @folderId AND user_id = @userId";
|
||||
|
||||
await _db.ExecuteAsync<object>(query, new
|
||||
{
|
||||
folderId,
|
||||
userId,
|
||||
newName = sanitizedName,
|
||||
updatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to rename folder {FolderId} for user {UserId}", folderId, userId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteFolderAsync(long folderId, long userId, bool deleteContents = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if folder has subfolders or media
|
||||
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 });
|
||||
|
||||
if (!deleteContents && (subfolderCount > 0 || mediaCount > 0))
|
||||
{
|
||||
_logger.LogWarning("Cannot delete folder {FolderId} - it contains items", folderId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deleteContents)
|
||||
{
|
||||
// Move contents to parent folder instead of deleting
|
||||
var folder = await GetFolderByIdAsync(folderId, userId);
|
||||
if (folder != null)
|
||||
{
|
||||
// Move subfolders to parent
|
||||
var moveSubfoldersQuery = @"
|
||||
UPDATE app.folders
|
||||
SET parent_folder_id = @parentFolderId, updated_at = @updatedAt
|
||||
WHERE parent_folder_id = @folderId AND user_id = @userId";
|
||||
|
||||
await _db.ExecuteAsync<object>(moveSubfoldersQuery, new
|
||||
{
|
||||
parentFolderId = folder.ParentFolderId,
|
||||
folderId,
|
||||
userId,
|
||||
updatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
// Move media to parent
|
||||
var moveMediaQuery = @"
|
||||
UPDATE app.user_media
|
||||
SET folder_id = @parentFolderId, updated_at = @updatedAt
|
||||
WHERE folder_id = @folderId AND user_id = @userId";
|
||||
|
||||
await _db.ExecuteAsync<object>(moveMediaQuery, new
|
||||
{
|
||||
parentFolderId = folder.ParentFolderId,
|
||||
folderId,
|
||||
userId,
|
||||
updatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the folder
|
||||
var deleteQuery = "DELETE FROM app.folders WHERE id = @folderId AND user_id = @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);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> MoveFolderAsync(long folderId, long userId, long? newParentFolderId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Verify folder exists and belongs to user
|
||||
var folder = await GetFolderByIdAsync(folderId, userId);
|
||||
if (folder == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// If new parent is specified, verify it exists and isn't a descendant
|
||||
if (newParentFolderId.HasValue)
|
||||
{
|
||||
var parentExists = await FolderExistsAsync(newParentFolderId.Value, userId);
|
||||
if (!parentExists)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent moving folder into its own descendant
|
||||
var isDescendant = await IsFolderDescendantAsync(newParentFolderId.Value, folderId);
|
||||
if (isDescendant)
|
||||
{
|
||||
_logger.LogWarning("Cannot move folder {FolderId} into its descendant {ParentFolderId}", folderId, newParentFolderId.Value);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var query = @"
|
||||
UPDATE app.folders
|
||||
SET parent_folder_id = @newParentFolderId, updated_at = @updatedAt
|
||||
WHERE id = @folderId AND user_id = @userId";
|
||||
|
||||
await _db.ExecuteAsync<object>(query, new
|
||||
{
|
||||
folderId,
|
||||
userId,
|
||||
newParentFolderId,
|
||||
updatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to move folder {FolderId} for user {UserId}", folderId, userId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Folder>> GetFolderPathAsync(long? folderId, long userId)
|
||||
{
|
||||
var path = new List<Folder>();
|
||||
|
||||
if (!folderId.HasValue)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var currentFolderId = folderId;
|
||||
while (currentFolderId.HasValue)
|
||||
{
|
||||
var folder = await GetFolderByIdAsync(currentFolderId.Value, userId);
|
||||
if (folder == null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
path.Insert(0, folder);
|
||||
currentFolderId = folder.ParentFolderId;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get folder path for folder {FolderId}", folderId);
|
||||
return new List<Folder>();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> FolderExistsAsync(long folderId, long userId)
|
||||
{
|
||||
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 });
|
||||
return count > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsFolderDescendantAsync(long potentialDescendantId, long ancestorId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentId = potentialDescendantId;
|
||||
var visited = new HashSet<long>();
|
||||
|
||||
while (currentId != 0)
|
||||
{
|
||||
if (currentId == ancestorId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (visited.Contains(currentId))
|
||||
{
|
||||
// Circular reference detected
|
||||
return false;
|
||||
}
|
||||
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 });
|
||||
|
||||
if (!parentId.HasValue)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
currentId = parentId.Value;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to check if folder {PotentialDescendantId} is descendant of {AncestorId}", potentialDescendantId, ancestorId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string SanitizeFolderName(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Remove invalid filename characters
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var sanitized = string.Join("", name.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
// Trim whitespace and limit length
|
||||
sanitized = sanitized.Trim();
|
||||
if (sanitized.Length > 255)
|
||||
{
|
||||
sanitized = sanitized.Substring(0, 255);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ public class MediaService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<UserMedia?> SaveMediaAsync(long userId, IFormFile file, string? description = null)
|
||||
public async Task<UserMedia?> SaveMediaAsync(long userId, IFormFile file, string? description = null, long? folderId = null)
|
||||
{
|
||||
string? tempFilePath = null;
|
||||
string? encryptedFilePath = null;
|
||||
@@ -71,9 +71,9 @@ public class MediaService
|
||||
|
||||
// Insert into database
|
||||
var query = @"
|
||||
INSERT INTO app.user_media (user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, created_at, updated_at)
|
||||
VALUES (@userId, @fileName, @filePath, @fileSize, @mimeType, @width, @height, @description, @isEncrypted, @createdAt, @updatedAt)
|
||||
RETURNING id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, created_at, updated_at";
|
||||
INSERT INTO app.user_media (user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, folder_id, created_at, updated_at)
|
||||
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 =>
|
||||
{
|
||||
@@ -89,8 +89,9 @@ public class MediaService
|
||||
Height = reader.IsDBNull(7) ? null : reader.GetInt32(7),
|
||||
Description = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
IsEncrypted = reader.GetBoolean(9),
|
||||
CreatedAt = reader.GetDateTime(10),
|
||||
UpdatedAt = reader.GetDateTime(11)
|
||||
FolderId = reader.IsDBNull(10) ? null : reader.GetInt64(10),
|
||||
CreatedAt = reader.GetDateTime(11),
|
||||
UpdatedAt = reader.GetDateTime(12)
|
||||
};
|
||||
}, new
|
||||
{
|
||||
@@ -103,6 +104,7 @@ public class MediaService
|
||||
height,
|
||||
description,
|
||||
isEncrypted = true,
|
||||
folderId,
|
||||
createdAt = DateTime.UtcNow,
|
||||
updatedAt = DateTime.UtcNow
|
||||
});
|
||||
@@ -127,16 +129,29 @@ public class MediaService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<UserMedia>> GetUserMediaAsync(long userId, int offset = 0, int limit = 20)
|
||||
public async Task<List<UserMedia>> GetUserMediaAsync(long userId, int offset = 0, int limit = 20, long? folderId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
SELECT id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, created_at, updated_at
|
||||
FROM app.user_media
|
||||
WHERE user_id = @userId
|
||||
ORDER BY created_at DESC
|
||||
OFFSET @offset LIMIT @limit";
|
||||
string query;
|
||||
if (folderId.HasValue)
|
||||
{
|
||||
query = @"
|
||||
SELECT id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, folder_id, created_at, updated_at
|
||||
FROM app.user_media
|
||||
WHERE user_id = @userId AND folder_id = @folderId
|
||||
ORDER BY created_at DESC
|
||||
OFFSET @offset LIMIT @limit";
|
||||
}
|
||||
else
|
||||
{
|
||||
query = @"
|
||||
SELECT id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, folder_id, created_at, updated_at
|
||||
FROM app.user_media
|
||||
WHERE user_id = @userId AND folder_id IS NULL
|
||||
ORDER BY created_at DESC
|
||||
OFFSET @offset LIMIT @limit";
|
||||
}
|
||||
|
||||
var mediaList = await _db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
@@ -152,10 +167,11 @@ public class MediaService
|
||||
Height = reader.IsDBNull(7) ? null : reader.GetInt32(7),
|
||||
Description = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
IsEncrypted = reader.GetBoolean(9),
|
||||
CreatedAt = reader.GetDateTime(10),
|
||||
UpdatedAt = reader.GetDateTime(11)
|
||||
FolderId = reader.IsDBNull(10) ? null : reader.GetInt64(10),
|
||||
CreatedAt = reader.GetDateTime(11),
|
||||
UpdatedAt = reader.GetDateTime(12)
|
||||
};
|
||||
}, new { userId, offset, limit });
|
||||
}, new { userId, folderId, offset, limit });
|
||||
|
||||
return mediaList;
|
||||
}
|
||||
@@ -171,7 +187,7 @@ public class MediaService
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
SELECT id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, created_at, updated_at
|
||||
SELECT id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, folder_id, created_at, updated_at
|
||||
FROM app.user_media
|
||||
WHERE id = @mediaId AND user_id = @userId";
|
||||
|
||||
@@ -189,8 +205,9 @@ public class MediaService
|
||||
Height = reader.IsDBNull(7) ? null : reader.GetInt32(7),
|
||||
Description = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
IsEncrypted = reader.GetBoolean(9),
|
||||
CreatedAt = reader.GetDateTime(10),
|
||||
UpdatedAt = reader.GetDateTime(11)
|
||||
FolderId = reader.IsDBNull(10) ? null : reader.GetInt64(10),
|
||||
CreatedAt = reader.GetDateTime(11),
|
||||
UpdatedAt = reader.GetDateTime(12)
|
||||
};
|
||||
}, new { mediaId, userId });
|
||||
|
||||
@@ -267,4 +284,30 @@ public class MediaService
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> MoveMediaToFolderAsync(long mediaId, long userId, long? folderId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
UPDATE app.user_media
|
||||
SET folder_id = @folderId, updated_at = @updatedAt
|
||||
WHERE id = @mediaId AND user_id = @userId";
|
||||
|
||||
await _db.ExecuteAsync<object>(query, new
|
||||
{
|
||||
mediaId,
|
||||
userId,
|
||||
folderId,
|
||||
updatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to move media {MediaId} to folder {FolderId} for user {UserId}", mediaId, folderId, userId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,44 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Gallery Header */
|
||||
.gallery-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb-nav a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb-nav a:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.item-count {
|
||||
color: var(--text-secondary);
|
||||
font-weight: normal;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Gallery Grid Layout */
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
@@ -126,6 +164,66 @@
|
||||
border-color: var(--border-secondary);
|
||||
}
|
||||
|
||||
/* Folder Items */
|
||||
.folder-item {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
min-height: 200px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.folder-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
color: var(--accent-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.folder-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.folder-item:hover .folder-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background: transparent;
|
||||
color: var(--accent-primary);
|
||||
border: 1px solid var(--accent-primary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.gallery-item img {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
@@ -323,6 +421,145 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Selection UI */
|
||||
.selection-checkbox {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.media-item:hover .selection-checkbox,
|
||||
.media-item.selected .selection-checkbox {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.media-select {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.media-item.selected {
|
||||
border: 2px solid var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.2);
|
||||
}
|
||||
|
||||
.media-item.selected img {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Selection Action Bar */
|
||||
.selection-bar {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 12px 20px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.selection-info span {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selection-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Drag and Drop */
|
||||
.media-item.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.folder-item.drop-target {
|
||||
background: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.folder-item.drop-target .folder-icon {
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.folder-item.drop-target .folder-name {
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.drag-preview {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Context Menu */
|
||||
.context-menu {
|
||||
position: absolute;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
min-width: 150px;
|
||||
padding: 4px 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--border-primary);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.gallery-grid {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// Context menu functionality for media items
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let contextMenu = null;
|
||||
let currentMediaId = null;
|
||||
|
||||
// Create context menu element
|
||||
function createContextMenu() {
|
||||
if (contextMenu) return contextMenu;
|
||||
|
||||
contextMenu = document.createElement('div');
|
||||
contextMenu.className = 'context-menu';
|
||||
contextMenu.innerHTML = `
|
||||
<div class="context-menu-item" data-action="move">Move to...</div>
|
||||
<div class="context-menu-item" data-action="delete">Delete</div>
|
||||
<div class="context-menu-divider"></div>
|
||||
<div class="context-menu-item" data-action="select">Select</div>
|
||||
`;
|
||||
document.body.appendChild(contextMenu);
|
||||
|
||||
// Handle menu item clicks
|
||||
contextMenu.addEventListener('click', function(e) {
|
||||
const menuItem = e.target.closest('.context-menu-item');
|
||||
if (!menuItem) return;
|
||||
|
||||
const action = menuItem.dataset.action;
|
||||
handleContextAction(action);
|
||||
hideContextMenu();
|
||||
});
|
||||
|
||||
return contextMenu;
|
||||
}
|
||||
|
||||
// Show context menu
|
||||
function showContextMenu(x, y, mediaId) {
|
||||
createContextMenu();
|
||||
currentMediaId = mediaId;
|
||||
|
||||
// Update select/deselect text
|
||||
const selectItem = contextMenu.querySelector('[data-action="select"]');
|
||||
if (selectItem && window.gallerySelection) {
|
||||
const isSelected = window.gallerySelection.getSelectedIds().includes(mediaId);
|
||||
selectItem.textContent = isSelected ? 'Deselect' : 'Select';
|
||||
}
|
||||
|
||||
// Position menu
|
||||
contextMenu.style.left = x + 'px';
|
||||
contextMenu.style.top = y + 'px';
|
||||
contextMenu.style.display = 'block';
|
||||
|
||||
// Adjust if menu goes off screen
|
||||
const rect = contextMenu.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) {
|
||||
contextMenu.style.left = (window.innerWidth - rect.width - 10) + 'px';
|
||||
}
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
contextMenu.style.top = (window.innerHeight - rect.height - 10) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
// Hide context menu
|
||||
function hideContextMenu() {
|
||||
if (contextMenu) {
|
||||
contextMenu.style.display = 'none';
|
||||
}
|
||||
currentMediaId = null;
|
||||
}
|
||||
|
||||
// Handle context menu actions
|
||||
function handleContextAction(action) {
|
||||
if (!currentMediaId) return;
|
||||
|
||||
switch (action) {
|
||||
case 'move':
|
||||
// Add to selection and enter move mode
|
||||
if (window.gallerySelection) {
|
||||
// If not already selected, clear selection and select this one
|
||||
if (!window.gallerySelection.getSelectedIds().includes(currentMediaId)) {
|
||||
window.gallerySelection.clearSelection();
|
||||
window.gallerySelection.addSelection(currentMediaId);
|
||||
}
|
||||
// Selection bar will show with "Move Here" button
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
deleteMedia(currentMediaId);
|
||||
break;
|
||||
|
||||
case 'select':
|
||||
if (window.gallerySelection) {
|
||||
const isSelected = window.gallerySelection.getSelectedIds().includes(currentMediaId);
|
||||
if (isSelected) {
|
||||
// Deselect
|
||||
const checkbox = document.querySelector(`.media-select[data-media-id="${currentMediaId}"]`);
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
checkbox.dispatchEvent(new Event('change'));
|
||||
}
|
||||
} else {
|
||||
// Select
|
||||
window.gallerySelection.addSelection(currentMediaId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete media function
|
||||
async function deleteMedia(mediaId) {
|
||||
if (!confirm('Are you sure you want to delete this image?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaItem = document.querySelector(`.media-item[data-media-id="${mediaId}"]`);
|
||||
const deleteForm = mediaItem?.querySelector('form[action*="Delete"]');
|
||||
|
||||
if (deleteForm) {
|
||||
// Use existing form submission
|
||||
deleteForm.submit();
|
||||
} else {
|
||||
// Fallback: remove from UI (would need delete API endpoint)
|
||||
console.warn('Delete form not found for media', mediaId);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for right-click on media items
|
||||
document.addEventListener('contextmenu', function(e) {
|
||||
const mediaItem = e.target.closest('.media-item');
|
||||
if (!mediaItem) {
|
||||
hideContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const mediaId = parseInt(mediaItem.dataset.mediaId);
|
||||
showContextMenu(e.pageX, e.pageY, mediaId);
|
||||
});
|
||||
|
||||
// Hide context menu on click outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (contextMenu && !contextMenu.contains(e.target)) {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Hide context menu on scroll
|
||||
document.addEventListener('scroll', hideContextMenu);
|
||||
|
||||
// Hide context menu on escape
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
// Drag and drop functionality for media items
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let draggedMediaIds = [];
|
||||
let dragCounter = 0;
|
||||
|
||||
// Handle drag start on media items
|
||||
document.addEventListener('dragstart', function(e) {
|
||||
const mediaItem = e.target.closest('.media-item');
|
||||
if (!mediaItem) return;
|
||||
|
||||
const mediaId = parseInt(mediaItem.dataset.mediaId);
|
||||
|
||||
// If dragging a selected item, drag all selected items
|
||||
if (window.gallerySelection && window.gallerySelection.getSelectedIds().includes(mediaId)) {
|
||||
draggedMediaIds = window.gallerySelection.getSelectedIds();
|
||||
} else {
|
||||
draggedMediaIds = [mediaId];
|
||||
}
|
||||
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', draggedMediaIds.join(','));
|
||||
|
||||
// Add visual feedback
|
||||
mediaItem.classList.add('dragging');
|
||||
if (draggedMediaIds.length > 1) {
|
||||
// Create drag preview showing count
|
||||
const dragPreview = document.createElement('div');
|
||||
dragPreview.className = 'drag-preview';
|
||||
dragPreview.textContent = `${draggedMediaIds.length} images`;
|
||||
dragPreview.style.position = 'absolute';
|
||||
dragPreview.style.top = '-1000px';
|
||||
document.body.appendChild(dragPreview);
|
||||
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
||||
setTimeout(() => dragPreview.remove(), 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle drag end
|
||||
document.addEventListener('dragend', function(e) {
|
||||
const mediaItem = e.target.closest('.media-item');
|
||||
if (mediaItem) {
|
||||
mediaItem.classList.remove('dragging');
|
||||
}
|
||||
|
||||
// Remove all drop-target highlights
|
||||
document.querySelectorAll('.drop-target').forEach(el => {
|
||||
el.classList.remove('drop-target');
|
||||
});
|
||||
|
||||
dragCounter = 0;
|
||||
});
|
||||
|
||||
// Handle drag over folders
|
||||
document.addEventListener('dragover', function(e) {
|
||||
const folderItem = e.target.closest('.folder-item');
|
||||
if (!folderItem) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
});
|
||||
|
||||
// Handle drag enter on folders
|
||||
document.addEventListener('dragenter', function(e) {
|
||||
const folderItem = e.target.closest('.folder-item');
|
||||
if (!folderItem) return;
|
||||
|
||||
dragCounter++;
|
||||
folderItem.classList.add('drop-target');
|
||||
});
|
||||
|
||||
// Handle drag leave on folders
|
||||
document.addEventListener('dragleave', function(e) {
|
||||
const folderItem = e.target.closest('.folder-item');
|
||||
if (!folderItem) return;
|
||||
|
||||
dragCounter--;
|
||||
if (dragCounter === 0) {
|
||||
folderItem.classList.remove('drop-target');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle drop on folders
|
||||
document.addEventListener('drop', async function(e) {
|
||||
const folderItem = e.target.closest('.folder-item');
|
||||
if (!folderItem) return;
|
||||
|
||||
e.preventDefault();
|
||||
folderItem.classList.remove('drop-target');
|
||||
dragCounter = 0;
|
||||
|
||||
const targetFolderId = folderItem.dataset.folderId === 'back'
|
||||
? null
|
||||
: parseInt(folderItem.dataset.folderId);
|
||||
|
||||
if (!draggedMediaIds || draggedMediaIds.length === 0) return;
|
||||
|
||||
// Don't move if dropping on back button without a valid parent
|
||||
if (folderItem.dataset.folderId === 'back' && !targetFolderId && !currentFolderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/media/move-bulk', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mediaIds: draggedMediaIds,
|
||||
folderId: targetFolderId
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert('Failed to move media: ' + (error.error || 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.movedCount > 0) {
|
||||
// Remove moved items from gallery
|
||||
draggedMediaIds.forEach(mediaId => {
|
||||
const item = document.querySelector(`.media-item[data-media-id="${mediaId}"]`);
|
||||
if (item) item.remove();
|
||||
});
|
||||
|
||||
// Clear selection and sessionStorage if items were selected
|
||||
if (window.gallerySelection) {
|
||||
sessionStorage.removeItem('selectedMediaIds');
|
||||
window.gallerySelection.clearSelection();
|
||||
}
|
||||
|
||||
if (result.failedCount > 0) {
|
||||
alert(`Moved ${result.movedCount} image(s). ${result.failedCount} failed.`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error moving media:', error);
|
||||
alert('Failed to move media');
|
||||
} finally {
|
||||
draggedMediaIds = [];
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// Folder management functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const newFolderBtn = document.getElementById('new-folder-btn');
|
||||
|
||||
if (newFolderBtn) {
|
||||
newFolderBtn.addEventListener('click', showCreateFolderModal);
|
||||
}
|
||||
});
|
||||
|
||||
function showCreateFolderModal() {
|
||||
const folderName = prompt('Enter folder name:');
|
||||
if (folderName && folderName.trim()) {
|
||||
createFolder(folderName.trim());
|
||||
}
|
||||
}
|
||||
|
||||
async function createFolder(name) {
|
||||
try {
|
||||
const response = await fetch('/api/folder/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
parentFolderId: currentFolderId
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert('Failed to create folder: ' + (error.error || 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload page to show new folder
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error creating folder:', error);
|
||||
alert('Failed to create folder');
|
||||
}
|
||||
}
|
||||
|
||||
async function renameFolder(folderId, currentName) {
|
||||
const newName = prompt('Enter new name for folder:', currentName);
|
||||
if (!newName || !newName.trim() || newName.trim() === currentName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/folder/rename', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folderId: folderId,
|
||||
newName: newName.trim()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert('Failed to rename folder: ' + (error.error || 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload page to show updated name
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error renaming folder:', error);
|
||||
alert('Failed to rename folder');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFolder(folderId) {
|
||||
if (!confirm('Are you sure you want to delete this folder? Its contents will be moved to the parent folder.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/folder/delete?folderId=${folderId}&deleteContents=true`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert('Failed to delete folder: ' + (error.error || 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload page to reflect deletion
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error deleting folder:', error);
|
||||
alert('Failed to delete folder');
|
||||
}
|
||||
}
|
||||
|
||||
async function moveMedia(mediaId, targetFolderId) {
|
||||
try {
|
||||
const response = await fetch('/api/media/move', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mediaId: mediaId,
|
||||
folderId: targetFolderId
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert('Failed to move media: ' + (error.error || 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove item from gallery or reload page
|
||||
const mediaElement = document.querySelector(`[data-media-id="${mediaId}"]`);
|
||||
if (mediaElement) {
|
||||
mediaElement.remove();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error moving media:', error);
|
||||
alert('Failed to move media');
|
||||
}
|
||||
}
|
||||
|
||||
async function moveFolder(folderId, targetParentFolderId) {
|
||||
try {
|
||||
const response = await fetch('/api/folder/move', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folderId: folderId,
|
||||
newParentFolderId: targetParentFolderId
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert('Failed to move folder: ' + (error.error || 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload page to reflect move
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error moving folder:', error);
|
||||
alert('Failed to move folder');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,27 @@
|
||||
// Global state for selection
|
||||
let selectedMediaIds = new Set();
|
||||
|
||||
// Wait for DOM to be fully loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Restore selection from sessionStorage
|
||||
const savedSelection = sessionStorage.getItem('selectedMediaIds');
|
||||
if (savedSelection) {
|
||||
try {
|
||||
const savedIds = JSON.parse(savedSelection);
|
||||
savedIds.forEach(id => {
|
||||
selectedMediaIds.add(id);
|
||||
const checkbox = document.querySelector(`.media-select[data-media-id="${id}"]`);
|
||||
if (checkbox) {
|
||||
checkbox.checked = true;
|
||||
checkbox.closest('.gallery-item').classList.add('selected');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to restore selection:', e);
|
||||
sessionStorage.removeItem('selectedMediaIds');
|
||||
}
|
||||
}
|
||||
|
||||
// Upload form toggle functionality
|
||||
const addFilesBtn = document.getElementById('add-files-btn');
|
||||
const uploadFormCard = document.getElementById('upload-form-card');
|
||||
@@ -73,7 +95,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
loadingElement.style.display = 'block';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/media/load?offset=${currentOffset}&limit=20`);
|
||||
const folderParam = currentFolderId ? `&folderId=${currentFolderId}` : '';
|
||||
const response = await fetch(`/api/media/load?offset=${currentOffset}&limit=20${folderParam}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load images');
|
||||
@@ -197,4 +220,164 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
// Selection functionality
|
||||
const selectionBar = document.getElementById('selection-bar');
|
||||
const selectionCount = document.getElementById('selection-count');
|
||||
const selectAllBtn = document.getElementById('select-all-btn');
|
||||
const deselectAllBtn = document.getElementById('deselect-all-btn');
|
||||
const moveHereBtn = document.getElementById('move-here-btn');
|
||||
const cancelSelectionBtn = document.getElementById('cancel-selection-btn');
|
||||
|
||||
// Initialize selection UI after elements are available
|
||||
if (selectedMediaIds.size > 0) {
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
// Delegate event listener for checkboxes
|
||||
if (galleryElement) {
|
||||
galleryElement.addEventListener('change', function(e) {
|
||||
if (e.target.classList.contains('media-select')) {
|
||||
const mediaId = parseInt(e.target.dataset.mediaId);
|
||||
if (e.target.checked) {
|
||||
selectedMediaIds.add(mediaId);
|
||||
e.target.closest('.gallery-item').classList.add('selected');
|
||||
} else {
|
||||
selectedMediaIds.delete(mediaId);
|
||||
e.target.closest('.gallery-item').classList.remove('selected');
|
||||
}
|
||||
updateSelectionUI();
|
||||
}
|
||||
});
|
||||
|
||||
// Click on image to toggle selection
|
||||
galleryElement.addEventListener('click', function(e) {
|
||||
if (e.target.tagName === 'IMG' && !e.target.closest('.folder-item')) {
|
||||
const galleryItem = e.target.closest('.gallery-item');
|
||||
if (galleryItem) {
|
||||
const checkbox = galleryItem.querySelector('.media-select');
|
||||
if (checkbox && selectedMediaIds.size > 0) {
|
||||
// If we're in selection mode, toggle checkbox
|
||||
checkbox.checked = !checkbox.checked;
|
||||
checkbox.dispatchEvent(new Event('change'));
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (selectAllBtn) {
|
||||
selectAllBtn.addEventListener('click', function() {
|
||||
const checkboxes = document.querySelectorAll('.media-select');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = true;
|
||||
const mediaId = parseInt(checkbox.dataset.mediaId);
|
||||
selectedMediaIds.add(mediaId);
|
||||
checkbox.closest('.gallery-item').classList.add('selected');
|
||||
});
|
||||
updateSelectionUI();
|
||||
});
|
||||
}
|
||||
|
||||
if (deselectAllBtn) {
|
||||
deselectAllBtn.addEventListener('click', function() {
|
||||
clearSelection();
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelSelectionBtn) {
|
||||
cancelSelectionBtn.addEventListener('click', function() {
|
||||
clearSelection();
|
||||
});
|
||||
}
|
||||
|
||||
if (moveHereBtn) {
|
||||
moveHereBtn.addEventListener('click', async function() {
|
||||
if (selectedMediaIds.size === 0) return;
|
||||
|
||||
const confirmed = confirm(`Move ${selectedMediaIds.size} image(s) to this folder?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/media/move-bulk', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mediaIds: Array.from(selectedMediaIds),
|
||||
folderId: currentFolderId
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert('Failed to move media: ' + (error.error || 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.movedCount > 0) {
|
||||
// Remove moved items from gallery
|
||||
selectedMediaIds.forEach(mediaId => {
|
||||
const item = document.querySelector(`[data-media-id="${mediaId}"]`);
|
||||
if (item) item.remove();
|
||||
});
|
||||
// Clear selection and sessionStorage
|
||||
sessionStorage.removeItem('selectedMediaIds');
|
||||
clearSelection();
|
||||
|
||||
if (result.failedCount > 0) {
|
||||
alert(`Moved ${result.movedCount} image(s). ${result.failedCount} failed.`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error moving media:', error);
|
||||
alert('Failed to move media');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelectionUI() {
|
||||
const count = selectedMediaIds.size;
|
||||
if (count > 0) {
|
||||
selectionBar.style.display = 'flex';
|
||||
selectionCount.textContent = `${count} image${count > 1 ? 's' : ''} selected`;
|
||||
// Save to sessionStorage
|
||||
sessionStorage.setItem('selectedMediaIds', JSON.stringify([...selectedMediaIds]));
|
||||
} else {
|
||||
selectionBar.style.display = 'none';
|
||||
// Clear from sessionStorage if empty
|
||||
sessionStorage.removeItem('selectedMediaIds');
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedMediaIds.clear();
|
||||
const checkboxes = document.querySelectorAll('.media-select');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = false;
|
||||
checkbox.closest('.gallery-item').classList.remove('selected');
|
||||
});
|
||||
sessionStorage.removeItem('selectedMediaIds');
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
window.gallerySelection = {
|
||||
getSelectedIds: () => Array.from(selectedMediaIds),
|
||||
addSelection: (mediaId) => {
|
||||
selectedMediaIds.add(mediaId);
|
||||
const checkbox = document.querySelector(`.media-select[data-media-id="${mediaId}"]`);
|
||||
if (checkbox) {
|
||||
checkbox.checked = true;
|
||||
checkbox.closest('.gallery-item').classList.add('selected');
|
||||
}
|
||||
updateSelectionUI();
|
||||
},
|
||||
clearSelection: clearSelection,
|
||||
updateUI: updateSelectionUI
|
||||
};
|
||||
}); // End of DOMContentLoaded
|
||||
|
||||
Reference in New Issue
Block a user