Merge pull request #3 from JoshHeaps/feature/Folders
Add folder sharing
This commit is contained in:
@@ -55,6 +55,16 @@ public class FolderApi : ControllerBase
|
||||
return BadRequest(new { error = "Folder name is required" });
|
||||
}
|
||||
|
||||
// If parent folder specified, check ownership (can't create in shared folders)
|
||||
if (request.ParentFolderId.HasValue)
|
||||
{
|
||||
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);
|
||||
if (folder == null)
|
||||
{
|
||||
@@ -78,6 +88,13 @@ public class FolderApi : ControllerBase
|
||||
return BadRequest(new { error = "New folder name is required" });
|
||||
}
|
||||
|
||||
// Check ownership (can't rename shared folders)
|
||||
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);
|
||||
if (!success)
|
||||
{
|
||||
@@ -96,6 +113,13 @@ public class FolderApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
// Check ownership (can't delete shared folders)
|
||||
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);
|
||||
if (!success)
|
||||
{
|
||||
@@ -114,6 +138,13 @@ public class FolderApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
// Check ownership (can't move shared folders)
|
||||
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);
|
||||
if (!success)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/folder-share")]
|
||||
public class FolderShareApi : 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)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var share = await _folderService.ShareFolderAsync(request.FolderId, userId.Value, request.SharedWithUserId);
|
||||
if (share == null)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to share folder" });
|
||||
}
|
||||
|
||||
return Ok(share);
|
||||
}
|
||||
|
||||
[HttpDelete("unshare")]
|
||||
public async Task<IActionResult> UnshareFolder([FromQuery] long folderId, [FromQuery] long sharedWithUserId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var success = await _folderService.UnshareFolderAsync(folderId, userId.Value, sharedWithUserId);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to unshare folder" });
|
||||
}
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpGet("list-shares")]
|
||||
public async Task<IActionResult> ListShares([FromQuery] long folderId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var shares = await _folderService.GetFolderSharesAsync(folderId, userId.Value);
|
||||
return Ok(shares);
|
||||
}
|
||||
|
||||
[HttpGet("shared-with-me")]
|
||||
public async Task<IActionResult> GetSharedWithMe()
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var sharedFolders = await _folderService.GetSharedFoldersAsync(userId.Value);
|
||||
return Ok(sharedFolders);
|
||||
}
|
||||
|
||||
[HttpGet("search-users")]
|
||||
public async Task<IActionResult> SearchUsers([FromQuery] string query)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query) || query.Length < 2)
|
||||
{
|
||||
return Ok(new List<object>());
|
||||
}
|
||||
|
||||
var users = await _userService.SearchUsersAsync(query, userId.Value);
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
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 ShareFolderRequest(long FolderId, long SharedWithUserId);
|
||||
@@ -9,10 +9,12 @@ namespace Media.JoshHeaps.Net.Api;
|
||||
public class MediaApi : ControllerBase
|
||||
{
|
||||
private readonly MediaService _mediaService;
|
||||
private readonly FolderService _folderService;
|
||||
|
||||
public MediaApi(MediaService mediaService)
|
||||
public MediaApi(MediaService mediaService, FolderService folderService)
|
||||
{
|
||||
_mediaService = mediaService;
|
||||
_folderService = folderService;
|
||||
}
|
||||
|
||||
[HttpGet("load")]
|
||||
@@ -25,7 +27,7 @@ public class MediaApi : ControllerBase
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var media = await _mediaService.GetUserMediaAsync(userId.Value, offset, limit, folderId);
|
||||
var media = await _mediaService.GetUserMediaAsync(userId.Value, offset, limit, folderId, userId.Value);
|
||||
|
||||
return Ok(media);
|
||||
}
|
||||
@@ -68,6 +70,16 @@ public class MediaApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
// Check folder ownership if uploading to a folder (can't upload to shared folders)
|
||||
if (folderId.HasValue)
|
||||
{
|
||||
var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId.Value);
|
||||
if (ownerId != userId.Value)
|
||||
{
|
||||
return Forbid("Cannot upload to shared folders");
|
||||
}
|
||||
}
|
||||
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { error = "No file provided" });
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Folder Shares Table
|
||||
-- Stores folder sharing permissions between users
|
||||
CREATE TABLE IF NOT EXISTS app.folder_shares (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
folder_id BIGINT NOT NULL,
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
shared_with_user_id BIGINT NOT NULL,
|
||||
permission_level VARCHAR(50) DEFAULT 'read_only', -- Future: could be 'read_write'
|
||||
include_subfolders BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (folder_id) REFERENCES app.folders(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES app.users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (shared_with_user_id) REFERENCES app.users(id) ON DELETE CASCADE,
|
||||
-- Ensure a folder can only be shared once with each user
|
||||
UNIQUE (folder_id, shared_with_user_id),
|
||||
-- Prevent self-sharing
|
||||
CHECK (owner_user_id != shared_with_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_folder_shares_folder_id ON app.folder_shares(folder_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_folder_shares_shared_with_user_id ON app.folder_shares(shared_with_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_folder_shares_owner_user_id ON app.folder_shares(owner_user_id);
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class FolderShare
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long FolderId { get; set; }
|
||||
public long OwnerUserId { get; set; }
|
||||
public long SharedWithUserId { get; set; }
|
||||
public string PermissionLevel { get; set; } = "read_only";
|
||||
public bool IncludeSubfolders { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class FolderShareWithUser
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long FolderId { get; set; }
|
||||
public long OwnerUserId { get; set; }
|
||||
public long SharedWithUserId { get; set; }
|
||||
public string SharedWithUsername { get; set; } = string.Empty;
|
||||
public string SharedWithEmail { get; set; } = string.Empty;
|
||||
public string PermissionLevel { get; set; } = "read_only";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class SharedFolderInfo
|
||||
{
|
||||
public long FolderId { get; set; }
|
||||
public string FolderName { get; set; } = string.Empty;
|
||||
public long OwnerUserId { get; set; }
|
||||
public string OwnerUsername { get; set; } = string.Empty;
|
||||
public string PermissionLevel { get; set; } = "read_only";
|
||||
public DateTime SharedAt { get; set; }
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public abstract class AuthenticatedPageModel : PageModel
|
||||
{
|
||||
protected long UserId { get; private set; }
|
||||
public long UserId { get; private set; }
|
||||
protected string Username { get; private set; } = string.Empty;
|
||||
protected string Email { get; private set; } = string.Empty;
|
||||
protected bool EmailVerified { get; private set; }
|
||||
|
||||
@@ -59,14 +59,28 @@
|
||||
<div class="card">
|
||||
<div class="gallery-header">
|
||||
<div class="breadcrumb-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/[email protected]">Home</a>
|
||||
@foreach (var folder in Model.FolderPath)
|
||||
{
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<a href="/[email protected]">@folder.Name</a>
|
||||
<a href="/[email protected]&[email protected]">@folder.Name</a>
|
||||
}
|
||||
@if (Model.IsSharedFolder && !string.IsNullOrEmpty(Model.SharedByUsername))
|
||||
{
|
||||
<span class="shared-indicator">(Shared by @Model.SharedByUsername)</span>
|
||||
}
|
||||
</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>
|
||||
</div>
|
||||
@if (!Model.IsSharedFolder)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary" id="new-folder-btn">New Folder</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<h2>
|
||||
@if (Model.CurrentFolderId.HasValue)
|
||||
@@ -82,7 +96,10 @@
|
||||
<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() : "")'">
|
||||
var backUrl = Model.FolderPath.Count > 1
|
||||
? $"/?folderId={Model.FolderPath[Model.FolderPath.Count - 2].Id}&view={Model.ViewMode}"
|
||||
: $"/?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">
|
||||
<path d="M9 6l6 6-6 6"/>
|
||||
@@ -91,19 +108,38 @@
|
||||
<div class="folder-name">..</div>
|
||||
</div>
|
||||
}
|
||||
@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="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"/>
|
||||
<path d="M20 12h-2m-2 0h-2" stroke-width="1.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="folder-name">
|
||||
@sharedFolder.FolderName
|
||||
<span class="shared-badge">Shared by @sharedFolder.OwnerUsername</span>
|
||||
</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="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="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>
|
||||
@if (folder.UserId == Model.UserId && !Model.IsSharedFolder)
|
||||
{
|
||||
<div class="folder-actions">
|
||||
<button type="button" class="btn-action" onclick="event.stopPropagation(); showShareModal(@folder.Id, '@folder.Name')">Share</button>
|
||||
<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)
|
||||
@@ -147,18 +183,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Add Files Button -->
|
||||
<button type="button" class="floating-add-btn" id="add-files-btn" title="Add Files">
|
||||
<!-- Share Folder Modal -->
|
||||
<div id="share-modal" class="modal" style="display: none;">
|
||||
<div class="modal-backdrop" onclick="closeShareModal()"></div>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<h3>Share Folder: <span id="share-folder-name"></span></h3>
|
||||
<button type="button" class="modal-close" onclick="closeShareModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="user-search">Search users by email or username</label>
|
||||
<input type="text" id="user-search" class="form-control" placeholder="Type to search..." />
|
||||
<div id="user-search-results" class="search-results"></div>
|
||||
</div>
|
||||
<div class="shared-users-section">
|
||||
<h4>Shared with:</h4>
|
||||
<div id="shared-users-list" class="shared-users-list">
|
||||
<p class="text-muted">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Add Files Button (hidden in shared folders) -->
|
||||
@if (!Model.IsSharedFolder)
|
||||
{
|
||||
<button type="button" class="floating-add-btn" id="add-files-btn" title="Add Files">
|
||||
<span class="btn-icon">+</span>
|
||||
</button>
|
||||
</button>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
const currentFolderId = @(Model.CurrentFolderId?.ToString() ?? "null");
|
||||
const isSharedFolder = @(Model.IsSharedFolder.ToString().ToLower());
|
||||
</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/folder-sharing.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>
|
||||
}
|
||||
@@ -13,7 +13,12 @@ namespace Media.JoshHeaps.Net.Pages
|
||||
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 long? CurrentFolderId { get; set; }
|
||||
public bool IsSharedFolder { get; set; }
|
||||
public long? FolderOwnerId { get; set; }
|
||||
public string? SharedByUsername { get; set; }
|
||||
public string ViewMode { get; set; } = "own"; // "own", "shared", "all"
|
||||
|
||||
[BindProperty]
|
||||
public IFormFile? UploadedFile { get; set; }
|
||||
@@ -27,24 +32,76 @@ namespace Media.JoshHeaps.Net.Pages
|
||||
public string? UploadMessage { get; set; }
|
||||
public bool UploadSuccess { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(long? folderId = null)
|
||||
public async Task<IActionResult> OnGetAsync(long? folderId = null, string? view = "own")
|
||||
{
|
||||
RequireAuthentication();
|
||||
LoadUserSession();
|
||||
|
||||
CurrentFolderId = folderId;
|
||||
ViewMode = view ?? "own";
|
||||
|
||||
// Load user dashboard data
|
||||
Dashboard = await userService.GetUserDashboardAsync(UserId);
|
||||
|
||||
// Load folders in current directory
|
||||
// Check if current folder is shared with user
|
||||
if (CurrentFolderId.HasValue)
|
||||
{
|
||||
FolderOwnerId = await _folderService.GetFolderOwnerIdAsync(CurrentFolderId.Value);
|
||||
IsSharedFolder = FolderOwnerId != UserId;
|
||||
|
||||
if (IsSharedFolder && FolderOwnerId.HasValue)
|
||||
{
|
||||
// Get owner info for display
|
||||
var ownerDashboard = await userService.GetUserDashboardAsync(FolderOwnerId.Value);
|
||||
SharedByUsername = ownerDashboard?.Username;
|
||||
}
|
||||
}
|
||||
|
||||
// Load folders based on view mode
|
||||
if (ViewMode == "shared")
|
||||
{
|
||||
// Show only shared folders at root level
|
||||
SharedFolders = await _folderService.GetSharedFoldersAsync(UserId);
|
||||
if (!CurrentFolderId.HasValue)
|
||||
{
|
||||
Folders = new List<Folder>();
|
||||
}
|
||||
else if (IsSharedFolder && FolderOwnerId.HasValue)
|
||||
{
|
||||
Folders = await _folderService.GetUserFoldersAsync(FolderOwnerId.Value, CurrentFolderId);
|
||||
}
|
||||
}
|
||||
else if (ViewMode == "own")
|
||||
{
|
||||
// Show only own folders
|
||||
if (!IsSharedFolder)
|
||||
{
|
||||
Folders = await _folderService.GetUserFoldersAsync(UserId, CurrentFolderId);
|
||||
}
|
||||
}
|
||||
else // "all"
|
||||
{
|
||||
// Show both own and shared
|
||||
Folders = await _folderService.GetUserFoldersAsync(UserId, CurrentFolderId);
|
||||
if (!CurrentFolderId.HasValue)
|
||||
{
|
||||
SharedFolders = await _folderService.GetSharedFoldersAsync(UserId);
|
||||
}
|
||||
}
|
||||
|
||||
// Load folder path (breadcrumbs)
|
||||
if (IsSharedFolder && FolderOwnerId.HasValue)
|
||||
{
|
||||
FolderPath = await _folderService.GetFolderPathAsync(CurrentFolderId, FolderOwnerId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
FolderPath = await _folderService.GetFolderPathAsync(CurrentFolderId, UserId);
|
||||
}
|
||||
|
||||
// Load initial set of media
|
||||
MediaItems = await _mediaService.GetUserMediaAsync(UserId, 0, 20, CurrentFolderId);
|
||||
// Load initial set of media (with access check for shared folders)
|
||||
var effectiveUserId = IsSharedFolder && FolderOwnerId.HasValue ? FolderOwnerId.Value : UserId;
|
||||
MediaItems = await _mediaService.GetUserMediaAsync(effectiveUserId, 0, 20, CurrentFolderId, UserId);
|
||||
|
||||
return Page();
|
||||
}
|
||||
|
||||
@@ -410,4 +410,236 @@ public class FolderService
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// Folder Sharing Methods
|
||||
|
||||
public async Task<FolderShare?> ShareFolderAsync(long folderId, long ownerId, long sharedWithUserId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Verify folder exists and belongs to owner
|
||||
var folder = await GetFolderByIdAsync(folderId, ownerId);
|
||||
if (folder == null)
|
||||
{
|
||||
_logger.LogWarning("Cannot share folder {FolderId} - not found or not owned by {OwnerId}", folderId, ownerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if already shared
|
||||
var existingShare = await GetFolderShareAsync(folderId, sharedWithUserId);
|
||||
if (existingShare != null)
|
||||
{
|
||||
return existingShare;
|
||||
}
|
||||
|
||||
var query = @"
|
||||
INSERT INTO app.folder_shares (folder_id, owner_user_id, shared_with_user_id, permission_level, include_subfolders, created_at, updated_at)
|
||||
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 =>
|
||||
{
|
||||
return new FolderShare
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
FolderId = reader.GetInt64(1),
|
||||
OwnerUserId = reader.GetInt64(2),
|
||||
SharedWithUserId = reader.GetInt64(3),
|
||||
PermissionLevel = reader.GetString(4),
|
||||
IncludeSubfolders = reader.GetBoolean(5),
|
||||
CreatedAt = reader.GetDateTime(6),
|
||||
UpdatedAt = reader.GetDateTime(7)
|
||||
};
|
||||
}, new
|
||||
{
|
||||
folderId,
|
||||
ownerId,
|
||||
sharedWithUserId,
|
||||
permissionLevel = "read_only",
|
||||
includeSubfolders = true,
|
||||
createdAt = DateTime.UtcNow,
|
||||
updatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
return share;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to share folder {FolderId} with user {SharedWithUserId}", folderId, sharedWithUserId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UnshareFolderAsync(long folderId, long ownerId, long sharedWithUserId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
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 });
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to unshare folder {FolderId} from user {SharedWithUserId}", folderId, sharedWithUserId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<FolderShareWithUser>> GetFolderSharesAsync(long folderId, long ownerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
SELECT fs.id, fs.folder_id, fs.owner_user_id, fs.shared_with_user_id,
|
||||
u.username, u.email, fs.permission_level, fs.created_at
|
||||
FROM app.folder_shares fs
|
||||
JOIN app.users u ON fs.shared_with_user_id = u.id
|
||||
WHERE fs.folder_id = @folderId AND fs.owner_user_id = @ownerId
|
||||
ORDER BY u.username ASC";
|
||||
|
||||
var shares = await _db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new FolderShareWithUser
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
FolderId = reader.GetInt64(1),
|
||||
OwnerUserId = reader.GetInt64(2),
|
||||
SharedWithUserId = reader.GetInt64(3),
|
||||
SharedWithUsername = reader.GetString(4),
|
||||
SharedWithEmail = reader.GetString(5),
|
||||
PermissionLevel = reader.GetString(6),
|
||||
CreatedAt = reader.GetDateTime(7)
|
||||
};
|
||||
}, new { folderId, ownerId });
|
||||
|
||||
return shares;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get shares for folder {FolderId}", folderId);
|
||||
return new List<FolderShareWithUser>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<SharedFolderInfo>> GetSharedFoldersAsync(long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
SELECT fs.folder_id, f.name, fs.owner_user_id, u.username, fs.permission_level, fs.created_at
|
||||
FROM app.folder_shares fs
|
||||
JOIN app.folders f ON fs.folder_id = f.id
|
||||
JOIN app.users u ON fs.owner_user_id = u.id
|
||||
WHERE fs.shared_with_user_id = @userId
|
||||
ORDER BY f.name ASC";
|
||||
|
||||
var sharedFolders = await _db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new SharedFolderInfo
|
||||
{
|
||||
FolderId = reader.GetInt64(0),
|
||||
FolderName = reader.GetString(1),
|
||||
OwnerUserId = reader.GetInt64(2),
|
||||
OwnerUsername = reader.GetString(3),
|
||||
PermissionLevel = reader.GetString(4),
|
||||
SharedAt = reader.GetDateTime(5)
|
||||
};
|
||||
}, new { userId });
|
||||
|
||||
return sharedFolders;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get shared folders for user {UserId}", userId);
|
||||
return new List<SharedFolderInfo>();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<FolderShare?> GetFolderShareAsync(long folderId, long sharedWithUserId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
SELECT id, folder_id, owner_user_id, shared_with_user_id, permission_level, include_subfolders, created_at, updated_at
|
||||
FROM app.folder_shares
|
||||
WHERE folder_id = @folderId AND shared_with_user_id = @sharedWithUserId";
|
||||
|
||||
var share = await _db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new FolderShare
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
FolderId = reader.GetInt64(1),
|
||||
OwnerUserId = reader.GetInt64(2),
|
||||
SharedWithUserId = reader.GetInt64(3),
|
||||
PermissionLevel = reader.GetString(4),
|
||||
IncludeSubfolders = reader.GetBoolean(5),
|
||||
CreatedAt = reader.GetDateTime(6),
|
||||
UpdatedAt = reader.GetDateTime(7)
|
||||
};
|
||||
}, new { folderId, sharedWithUserId });
|
||||
|
||||
return share;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> HasFolderAccessAsync(long folderId, long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 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 });
|
||||
if (isOwner > 0) return true;
|
||||
|
||||
// Check if folder is shared with user (including parent folders due to subfolders)
|
||||
var shareQuery = @"
|
||||
WITH RECURSIVE folder_hierarchy AS (
|
||||
-- Base case: the folder itself
|
||||
SELECT id, parent_folder_id
|
||||
FROM app.folders
|
||||
WHERE id = @folderId
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: parent folders
|
||||
SELECT f.id, f.parent_folder_id
|
||||
FROM app.folders f
|
||||
INNER JOIN folder_hierarchy fh ON f.id = fh.parent_folder_id
|
||||
)
|
||||
SELECT COUNT(*)
|
||||
FROM app.folder_shares fs
|
||||
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 });
|
||||
return hasShare > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to check folder access for folder {FolderId} and user {UserId}", folderId, userId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<long?> GetFolderOwnerIdAsync(long folderId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = "SELECT user_id FROM app.folders WHERE id = @folderId";
|
||||
var ownerId = await _db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId });
|
||||
return ownerId;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,15 @@ public class MediaService
|
||||
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)
|
||||
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)
|
||||
@@ -129,17 +131,35 @@ public class MediaService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<UserMedia>> GetUserMediaAsync(long userId, int offset = 0, int limit = 20, long? folderId = null)
|
||||
public async Task<List<UserMedia>> GetUserMediaAsync(long userId, int offset = 0, int limit = 20, long? folderId = null, long? requestingUserId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// If requestingUserId is provided, check access for shared folders
|
||||
var effectiveUserId = userId;
|
||||
if (requestingUserId.HasValue && folderId.HasValue)
|
||||
{
|
||||
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>();
|
||||
}
|
||||
// Get the actual owner of the folder
|
||||
var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId.Value);
|
||||
if (ownerId.HasValue)
|
||||
{
|
||||
effectiveUserId = ownerId.Value;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
WHERE user_id = @effectiveUserId AND folder_id = @folderId
|
||||
ORDER BY created_at DESC
|
||||
OFFSET @offset LIMIT @limit";
|
||||
}
|
||||
@@ -148,7 +168,7 @@ public class MediaService
|
||||
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
|
||||
WHERE user_id = @effectiveUserId AND folder_id IS NULL
|
||||
ORDER BY created_at DESC
|
||||
OFFSET @offset LIMIT @limit";
|
||||
}
|
||||
@@ -171,7 +191,7 @@ public class MediaService
|
||||
CreatedAt = reader.GetDateTime(11),
|
||||
UpdatedAt = reader.GetDateTime(12)
|
||||
};
|
||||
}, new { userId, folderId, offset, limit });
|
||||
}, new { effectiveUserId, folderId, offset, limit });
|
||||
|
||||
return mediaList;
|
||||
}
|
||||
@@ -186,10 +206,11 @@ public class MediaService
|
||||
{
|
||||
try
|
||||
{
|
||||
// First, get the media item
|
||||
var 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 id = @mediaId AND user_id = @userId";
|
||||
WHERE id = @mediaId";
|
||||
|
||||
var media = await _db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
@@ -209,10 +230,28 @@ public class MediaService
|
||||
CreatedAt = reader.GetDateTime(11),
|
||||
UpdatedAt = reader.GetDateTime(12)
|
||||
};
|
||||
}, new { mediaId, userId });
|
||||
}, new { mediaId });
|
||||
|
||||
if (media == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if user owns the media
|
||||
if (media.UserId == userId)
|
||||
{
|
||||
return media;
|
||||
}
|
||||
|
||||
// Check if user has access via shared folder
|
||||
if (media.FolderId.HasValue && await _folderService.HasFolderAccessAsync(media.FolderId.Value, userId))
|
||||
{
|
||||
return media;
|
||||
}
|
||||
|
||||
// User doesn't have access
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get media {MediaId} for user {UserId}", mediaId, userId);
|
||||
|
||||
@@ -102,4 +102,42 @@ public class UserService
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<UserSearchResult>> SearchUsersAsync(string query, long excludeUserId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var searchQuery = @"
|
||||
SELECT id, username, email
|
||||
FROM app.users
|
||||
WHERE (LOWER(username) LIKE @query OR LOWER(email) LIKE @query)
|
||||
AND id != @excludeUserId
|
||||
AND email_verified = true
|
||||
ORDER BY username ASC
|
||||
LIMIT 10";
|
||||
|
||||
var users = await _db.ExecuteListReaderAsync(searchQuery, reader =>
|
||||
{
|
||||
return new UserSearchResult
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
Username = reader.GetString(1),
|
||||
Email = reader.GetString(2)
|
||||
};
|
||||
}, new { query = $"%{query.ToLower()}%", excludeUserId });
|
||||
|
||||
return users;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new List<UserSearchResult>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class UserSearchResult
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -112,6 +112,14 @@
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.breadcrumb-nav {
|
||||
@@ -136,6 +144,13 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.shared-indicator {
|
||||
color: var(--accent-primary);
|
||||
font-size: 0.9em;
|
||||
font-style: italic;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.item-count {
|
||||
color: var(--text-secondary);
|
||||
font-weight: normal;
|
||||
@@ -224,6 +239,48 @@
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.shared-folder-item {
|
||||
border-color: var(--accent-primary);
|
||||
background: rgba(66, 153, 225, 0.05);
|
||||
}
|
||||
|
||||
.shared-badge {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* View Mode Toggle */
|
||||
.view-mode-toggle {
|
||||
display: flex;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.view-mode-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.view-mode-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.view-mode-btn.active {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.gallery-item img {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
@@ -560,6 +617,198 @@
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Share Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1100;
|
||||
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.7);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
position: relative;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
z-index: 1101;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 1;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
max-height: calc(80vh - 70px);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
margin-top: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.search-result-item,
|
||||
.shared-user-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.search-result-item:last-child,
|
||||
.shared-user-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-info strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-add,
|
||||
.btn-remove {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
background: var(--danger);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.shared-users-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.shared-users-section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.shared-users-list {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.gallery-grid {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Folder sharing functionality
|
||||
let currentShareFolderId = null;
|
||||
let searchTimeout = null;
|
||||
|
||||
function showShareModal(folderId, folderName) {
|
||||
currentShareFolderId = folderId;
|
||||
const modal = document.getElementById('share-modal');
|
||||
const folderNameSpan = document.getElementById('share-folder-name');
|
||||
const userSearch = document.getElementById('user-search');
|
||||
|
||||
folderNameSpan.textContent = folderName;
|
||||
userSearch.value = '';
|
||||
modal.style.display = 'block';
|
||||
|
||||
// Load existing shares
|
||||
loadSharedUsers(folderId);
|
||||
|
||||
// Clear search results
|
||||
document.getElementById('user-search-results').innerHTML = '';
|
||||
}
|
||||
|
||||
function closeShareModal() {
|
||||
const modal = document.getElementById('share-modal');
|
||||
modal.style.display = 'none';
|
||||
currentShareFolderId = null;
|
||||
}
|
||||
|
||||
async function loadSharedUsers(folderId) {
|
||||
const list = document.getElementById('shared-users-list');
|
||||
list.innerHTML = '<p class="text-muted">Loading...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/folder-share/list-shares?folderId=${folderId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load shares');
|
||||
}
|
||||
|
||||
const shares = await response.json();
|
||||
|
||||
if (shares.length === 0) {
|
||||
list.innerHTML = '<p class="text-muted">Not shared with anyone yet</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = '';
|
||||
shares.forEach(share => {
|
||||
const userItem = document.createElement('div');
|
||||
userItem.className = 'shared-user-item';
|
||||
userItem.innerHTML = `
|
||||
<div class="user-info">
|
||||
<strong>${share.sharedWithUsername}</strong>
|
||||
<span class="user-email">${share.sharedWithEmail}</span>
|
||||
</div>
|
||||
<button type="button" class="btn-remove" onclick="removeShare(${folderId}, ${share.sharedWithUserId}, '${share.sharedWithUsername}')">
|
||||
Remove
|
||||
</button>
|
||||
`;
|
||||
list.appendChild(userItem);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading shares:', error);
|
||||
list.innerHTML = '<p class="text-error">Failed to load shares</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function removeShare(folderId, sharedWithUserId, username) {
|
||||
if (!confirm(`Remove access for ${username}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/folder-share/unshare?folderId=${folderId}&sharedWithUserId=${sharedWithUserId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to remove share');
|
||||
}
|
||||
|
||||
// Reload the shared users list
|
||||
loadSharedUsers(folderId);
|
||||
} catch (error) {
|
||||
console.error('Error removing share:', error);
|
||||
alert('Failed to remove access');
|
||||
}
|
||||
}
|
||||
|
||||
// User search with debouncing
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userSearch = document.getElementById('user-search');
|
||||
if (!userSearch) return;
|
||||
|
||||
userSearch.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = this.value.trim();
|
||||
|
||||
if (query.length < 2) {
|
||||
document.getElementById('user-search-results').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(() => searchUsers(query), 300);
|
||||
});
|
||||
});
|
||||
|
||||
async function searchUsers(query) {
|
||||
const resultsDiv = document.getElementById('user-search-results');
|
||||
resultsDiv.innerHTML = '<p class="text-muted">Searching...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/folder-share/search-users?query=${encodeURIComponent(query)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Search failed');
|
||||
}
|
||||
|
||||
const users = await response.json();
|
||||
|
||||
if (users.length === 0) {
|
||||
resultsDiv.innerHTML = '<p class="text-muted">No users found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = '';
|
||||
users.forEach(user => {
|
||||
const userItem = document.createElement('div');
|
||||
userItem.className = 'search-result-item';
|
||||
userItem.innerHTML = `
|
||||
<div class="user-info">
|
||||
<strong>${user.username}</strong>
|
||||
<span class="user-email">${user.email}</span>
|
||||
</div>
|
||||
<button type="button" class="btn-add" onclick="shareWithUser(${currentShareFolderId}, ${user.id}, '${user.username}')">
|
||||
Share
|
||||
</button>
|
||||
`;
|
||||
resultsDiv.appendChild(userItem);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error searching users:', error);
|
||||
resultsDiv.innerHTML = '<p class="text-error">Search failed</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function shareWithUser(folderId, userId, username) {
|
||||
try {
|
||||
const response = await fetch('/api/folder-share/share', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folderId: folderId,
|
||||
sharedWithUserId: userId
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to share');
|
||||
}
|
||||
|
||||
// Clear search
|
||||
document.getElementById('user-search').value = '';
|
||||
document.getElementById('user-search-results').innerHTML = '';
|
||||
|
||||
// Reload shared users list
|
||||
loadSharedUsers(folderId);
|
||||
|
||||
alert(`Folder shared with ${username}`);
|
||||
} catch (error) {
|
||||
console.error('Error sharing folder:', error);
|
||||
alert('Failed to share folder: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeShareModal();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user