File Manamegement Tenant bazlı çalışması
This commit is contained in:
parent
6a06bf24c8
commit
96b61e7801
5 changed files with 241 additions and 146 deletions
|
|
@ -13,6 +13,8 @@ public class CreateFolderDto
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string? ParentId { get; set; }
|
public string? ParentId { get; set; }
|
||||||
|
|
||||||
|
public string? TenantId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RenameItemDto
|
public class RenameItemDto
|
||||||
|
|
@ -20,11 +22,15 @@ public class RenameItemDto
|
||||||
[Required]
|
[Required]
|
||||||
[StringLength(255)]
|
[StringLength(255)]
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? TenantId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MoveItemDto
|
public class MoveItemDto
|
||||||
{
|
{
|
||||||
public string? TargetFolderId { get; set; }
|
public string? TargetFolderId { get; set; }
|
||||||
|
|
||||||
|
public string? TenantId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UploadFileDto
|
public class UploadFileDto
|
||||||
|
|
@ -34,6 +40,8 @@ public class UploadFileDto
|
||||||
|
|
||||||
public string? ParentId { get; set; }
|
public string? ParentId { get; set; }
|
||||||
|
|
||||||
|
public string? TenantId { get; set; }
|
||||||
|
|
||||||
// NoteModal pattern - Files array
|
// NoteModal pattern - Files array
|
||||||
public IRemoteStreamContent[]? Files { get; set; }
|
public IRemoteStreamContent[]? Files { get; set; }
|
||||||
}
|
}
|
||||||
|
|
@ -44,12 +52,16 @@ public class SearchFilesDto
|
||||||
public string Query { get; set; } = string.Empty;
|
public string Query { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string? ParentId { get; set; }
|
public string? ParentId { get; set; }
|
||||||
|
|
||||||
|
public string? TenantId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BulkDeleteDto
|
public class BulkDeleteDto
|
||||||
{
|
{
|
||||||
[Required]
|
[Required]
|
||||||
public List<string> ItemIds { get; set; } = new();
|
public List<string> ItemIds { get; set; } = new();
|
||||||
|
|
||||||
|
public string? TenantId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CopyItemsDto
|
public class CopyItemsDto
|
||||||
|
|
@ -58,6 +70,8 @@ public class CopyItemsDto
|
||||||
public List<string> ItemIds { get; set; } = new();
|
public List<string> ItemIds { get; set; } = new();
|
||||||
|
|
||||||
public string? TargetFolderId { get; set; }
|
public string? TargetFolderId { get; set; }
|
||||||
|
|
||||||
|
public string? TenantId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MoveItemsDto
|
public class MoveItemsDto
|
||||||
|
|
@ -66,4 +80,6 @@ public class MoveItemsDto
|
||||||
public List<string> ItemIds { get; set; } = new();
|
public List<string> ItemIds { get; set; } = new();
|
||||||
|
|
||||||
public string? TargetFolderId { get; set; }
|
public string? TargetFolderId { get; set; }
|
||||||
|
|
||||||
|
public string? TenantId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,33 +9,18 @@ namespace Sozsoft.Platform.FileManagement;
|
||||||
|
|
||||||
public interface IFileManagementAppService : IApplicationService
|
public interface IFileManagementAppService : IApplicationService
|
||||||
{
|
{
|
||||||
Task<GetFilesDto> GetItemsAsync();
|
Task<GetFilesDto> GetItemsAsync(string? tenantId = null);
|
||||||
|
Task<GetFilesDto> GetItemsByParentAsync(string parentId, string? tenantId = null);
|
||||||
Task<GetFilesDto> GetItemsByParentAsync(string parentId);
|
|
||||||
|
|
||||||
Task<FileItemDto> CreateFolderAsync(CreateFolderDto input);
|
Task<FileItemDto> CreateFolderAsync(CreateFolderDto input);
|
||||||
|
|
||||||
Task<FileItemDto> UploadFileAsync(UploadFileDto input);
|
Task<FileItemDto> UploadFileAsync(UploadFileDto input);
|
||||||
|
|
||||||
Task<FileItemDto> RenameItemAsync(string id, RenameItemDto input);
|
Task<FileItemDto> RenameItemAsync(string id, RenameItemDto input);
|
||||||
|
|
||||||
Task<FileItemDto> MoveItemAsync(string id, MoveItemDto input);
|
Task<FileItemDto> MoveItemAsync(string id, MoveItemDto input);
|
||||||
|
Task<Stream> DownloadFileAsync(string id, string? tenantId = null);
|
||||||
Task DeleteItemAsync(string id);
|
Task<Stream> GetFilePreviewAsync(string id, string? tenantId = null);
|
||||||
|
|
||||||
Task<Stream> DownloadFileAsync(string id);
|
|
||||||
|
|
||||||
Task<Stream> GetFilePreviewAsync(string id);
|
|
||||||
|
|
||||||
Task<GetFilesDto> SearchItemsAsync(SearchFilesDto input);
|
Task<GetFilesDto> SearchItemsAsync(SearchFilesDto input);
|
||||||
|
Task<FolderPathDto> GetFolderPathAsync(string? tenantId = null);
|
||||||
Task<FolderPathDto> GetFolderPathAsync();
|
Task<FolderPathDto> GetFolderPathByIdAsync(string folderId, string? tenantId = null);
|
||||||
|
|
||||||
Task<FolderPathDto> GetFolderPathByIdAsync(string folderId);
|
|
||||||
|
|
||||||
Task BulkDeleteItemsAsync(BulkDeleteDto input);
|
Task BulkDeleteItemsAsync(BulkDeleteDto input);
|
||||||
|
|
||||||
Task<List<FileItemDto>> CopyItemsAsync(CopyItemsDto input);
|
Task<List<FileItemDto>> CopyItemsAsync(CopyItemsDto input);
|
||||||
|
|
||||||
Task<List<FileItemDto>> MoveItemsAsync(MoveItemsDto input);
|
Task<List<FileItemDto>> MoveItemsAsync(MoveItemsDto input);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,11 @@ using Microsoft.Extensions.Logging;
|
||||||
using Volo.Abp;
|
using Volo.Abp;
|
||||||
using Volo.Abp.Application.Services;
|
using Volo.Abp.Application.Services;
|
||||||
using Volo.Abp.MultiTenancy;
|
using Volo.Abp.MultiTenancy;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
namespace Sozsoft.Platform.FileManagement;
|
namespace Sozsoft.Platform.FileManagement;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
public class FileManagementAppService : ApplicationService, IFileManagementAppService
|
public class FileManagementAppService : ApplicationService, IFileManagementAppService
|
||||||
{
|
{
|
||||||
private readonly ICurrentTenant _currentTenant;
|
private readonly ICurrentTenant _currentTenant;
|
||||||
|
|
@ -47,11 +49,10 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetTenantPrefix()
|
private string GetTenantPrefix(string tenantId) => $"tenants/{tenantId}/";
|
||||||
{
|
|
||||||
var tenantId = _currentTenant.Id?.ToString() ?? "host";
|
private string GetEffectiveTenantId(string? inputTenantId) =>
|
||||||
return $"tenants/{tenantId}/";
|
!string.IsNullOrEmpty(inputTenantId) ? inputTenantId : (_currentTenant.Id?.ToString() ?? "host");
|
||||||
}
|
|
||||||
|
|
||||||
private string EncodePathAsId(string path)
|
private string EncodePathAsId(string path)
|
||||||
{
|
{
|
||||||
|
|
@ -95,12 +96,12 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
Logger.LogInformation($"Folder {decodedPath} is not protected, allowing {operation}");
|
Logger.LogInformation($"Folder {decodedPath} is not protected, allowing {operation}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<FileMetadata>> GetFolderIndexAsync(string? parentId = null)
|
private async Task<List<FileMetadata>> GetFolderIndexAsync(string? parentId, string tenantId)
|
||||||
{
|
{
|
||||||
return await GetRealCdnContentsAsync(parentId);
|
return await GetRealCdnContentsAsync(parentId, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<FileMetadata>> GetRealCdnContentsAsync(string? folderPath)
|
private async Task<List<FileMetadata>> GetRealCdnContentsAsync(string? folderPath, string tenantId)
|
||||||
{
|
{
|
||||||
var items = new List<FileMetadata>();
|
var items = new List<FileMetadata>();
|
||||||
var cdnBasePath = _configuration["App:CdnPath"];
|
var cdnBasePath = _configuration["App:CdnPath"];
|
||||||
|
|
@ -111,7 +112,6 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantId = _currentTenant.Id?.ToString() ?? "host";
|
|
||||||
var fullPath = Path.Combine(cdnBasePath, tenantId);
|
var fullPath = Path.Combine(cdnBasePath, tenantId);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(folderPath))
|
if (!string.IsNullOrEmpty(folderPath))
|
||||||
|
|
@ -158,7 +158,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
Path = relativePath,
|
Path = relativePath,
|
||||||
ParentId = folderPath ?? "",
|
ParentId = folderPath ?? "",
|
||||||
IsReadOnly = false,
|
IsReadOnly = false,
|
||||||
TenantId = _currentTenant.Id?.ToString(),
|
TenantId = tenantId == "host" ? null : tenantId,
|
||||||
ChildCount = childCount
|
ChildCount = childCount
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -181,7 +181,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
Path = relativePath,
|
Path = relativePath,
|
||||||
ParentId = folderPath ?? "",
|
ParentId = folderPath ?? "",
|
||||||
IsReadOnly = false,
|
IsReadOnly = false,
|
||||||
TenantId = _currentTenant.Id?.ToString()
|
TenantId = tenantId == "host" ? null : tenantId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,9 +194,9 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveFolderIndexAsync(List<FileMetadata> items, string? parentId = null)
|
private async Task SaveFolderIndexAsync(List<FileMetadata> items, string tenantId, string? parentId = null)
|
||||||
{
|
{
|
||||||
var indexPath = GetTenantPrefix() + (string.IsNullOrEmpty(parentId) ? IndexFileName : $"{parentId}/{IndexFileName}");
|
var indexPath = GetTenantPrefix(tenantId) + (string.IsNullOrEmpty(parentId) ? IndexFileName : $"{parentId}/{IndexFileName}");
|
||||||
|
|
||||||
var indexJson = JsonSerializer.Serialize(items, new JsonSerializerOptions
|
var indexJson = JsonSerializer.Serialize(items, new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
|
|
@ -207,20 +207,23 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
await _blobContainer.SaveAsync(indexPath, indexBytes);
|
await _blobContainer.SaveAsync(indexPath, indexBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<GetFilesDto> GetItemsAsync()
|
[HttpGet("api/app/file-management/items/{tenantId}")]
|
||||||
|
public async Task<GetFilesDto> GetItemsAsync(string? tenantId = null)
|
||||||
{
|
{
|
||||||
return await GetItemsInternalAsync(null);
|
return await GetItemsInternalAsync(null, GetEffectiveTenantId(tenantId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<GetFilesDto> GetItemsByParentAsync(string parentId)
|
[HttpGet("api/app/file-management/items-by-parent")]
|
||||||
|
public async Task<GetFilesDto> GetItemsByParentAsync(string parentId, string? tenantId = null)
|
||||||
{
|
{
|
||||||
|
var effectiveTenantId = GetEffectiveTenantId(tenantId);
|
||||||
var decodedParentId = DecodeIdAsPath(parentId);
|
var decodedParentId = DecodeIdAsPath(parentId);
|
||||||
return await GetItemsInternalAsync(decodedParentId);
|
return await GetItemsInternalAsync(decodedParentId, effectiveTenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<GetFilesDto> GetItemsInternalAsync(string? parentId)
|
private async Task<GetFilesDto> GetItemsInternalAsync(string? parentId, string tenantId)
|
||||||
{
|
{
|
||||||
var items = await GetFolderIndexAsync(parentId);
|
var items = await GetFolderIndexAsync(parentId, tenantId);
|
||||||
|
|
||||||
var result = items.Select(metadata =>
|
var result = items.Select(metadata =>
|
||||||
{
|
{
|
||||||
|
|
@ -249,6 +252,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
return new GetFilesDto { Items = result };
|
return new GetFilesDto { Items = result };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/app/file-management/folder")]
|
||||||
public async Task<FileItemDto> CreateFolderAsync(CreateFolderDto input)
|
public async Task<FileItemDto> CreateFolderAsync(CreateFolderDto input)
|
||||||
{
|
{
|
||||||
ValidateFileName(input.Name);
|
ValidateFileName(input.Name);
|
||||||
|
|
@ -259,7 +263,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
throw new UserFriendlyException("CDN path is not configured");
|
throw new UserFriendlyException("CDN path is not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantId = _currentTenant.Id?.ToString() ?? "host";
|
var tenantId = GetEffectiveTenantId(input.TenantId);
|
||||||
var parentPath = Path.Combine(cdnBasePath, tenantId);
|
var parentPath = Path.Combine(cdnBasePath, tenantId);
|
||||||
|
|
||||||
string? decodedParentId = null;
|
string? decodedParentId = null;
|
||||||
|
|
@ -298,7 +302,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
Path = newFolderPath,
|
Path = newFolderPath,
|
||||||
ParentId = decodedParentId ?? string.Empty,
|
ParentId = decodedParentId ?? string.Empty,
|
||||||
IsReadOnly = false,
|
IsReadOnly = false,
|
||||||
TenantId = _currentTenant.Id?.ToString()
|
TenantId = tenantId == "host" ? null : tenantId
|
||||||
};
|
};
|
||||||
|
|
||||||
return new FileItemDto
|
return new FileItemDto
|
||||||
|
|
@ -311,14 +315,17 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
Path = metadata.Path,
|
Path = metadata.Path,
|
||||||
ParentId = input.ParentId ?? string.Empty,
|
ParentId = input.ParentId ?? string.Empty,
|
||||||
IsReadOnly = metadata.IsReadOnly,
|
IsReadOnly = metadata.IsReadOnly,
|
||||||
TenantId = _currentTenant.Id?.ToString()
|
TenantId = tenantId == "host" ? null : tenantId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/app/file-management/upload-file")]
|
||||||
public async Task<FileItemDto> UploadFileAsync([FromForm] UploadFileDto input)
|
public async Task<FileItemDto> UploadFileAsync([FromForm] UploadFileDto input)
|
||||||
{
|
{
|
||||||
ValidateFileName(input.FileName);
|
ValidateFileName(input.FileName);
|
||||||
|
|
||||||
|
var tenantId = GetEffectiveTenantId(input.TenantId);
|
||||||
|
|
||||||
// Decode parent ID if provided
|
// Decode parent ID if provided
|
||||||
string? decodedParentId = null;
|
string? decodedParentId = null;
|
||||||
if (!string.IsNullOrEmpty(input.ParentId))
|
if (!string.IsNullOrEmpty(input.ParentId))
|
||||||
|
|
@ -326,7 +333,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
decodedParentId = DecodeIdAsPath(input.ParentId);
|
decodedParentId = DecodeIdAsPath(input.ParentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var items = await GetFolderIndexAsync(decodedParentId);
|
var items = await GetFolderIndexAsync(decodedParentId, tenantId);
|
||||||
|
|
||||||
// Generate unique filename if file already exists
|
// Generate unique filename if file already exists
|
||||||
var uniqueFileName = GetUniqueFileName(items, input.FileName);
|
var uniqueFileName = GetUniqueFileName(items, input.FileName);
|
||||||
|
|
@ -344,7 +351,6 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
throw new UserFriendlyException("CDN path is not configured");
|
throw new UserFriendlyException("CDN path is not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantId = _currentTenant.Id?.ToString() ?? "host";
|
|
||||||
var fullCdnPath = Path.Combine(cdnBasePath, tenantId);
|
var fullCdnPath = Path.Combine(cdnBasePath, tenantId);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(decodedParentId))
|
if (!string.IsNullOrEmpty(decodedParentId))
|
||||||
|
|
@ -387,7 +393,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
Path = filePath,
|
Path = filePath,
|
||||||
ParentId = decodedParentId ?? string.Empty,
|
ParentId = decodedParentId ?? string.Empty,
|
||||||
IsReadOnly = false,
|
IsReadOnly = false,
|
||||||
TenantId = _currentTenant.Id?.ToString()
|
TenantId = tenantId == "host" ? null : tenantId
|
||||||
};
|
};
|
||||||
|
|
||||||
// File system'e kaydedildi, index güncellemeye gerek yok
|
// File system'e kaydedildi, index güncellemeye gerek yok
|
||||||
|
|
@ -404,10 +410,11 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
Path = metadata.Path,
|
Path = metadata.Path,
|
||||||
ParentId = input.ParentId ?? string.Empty,
|
ParentId = input.ParentId ?? string.Empty,
|
||||||
IsReadOnly = metadata.IsReadOnly,
|
IsReadOnly = metadata.IsReadOnly,
|
||||||
TenantId = _currentTenant.Id?.ToString()
|
TenantId = tenantId == "host" ? null : tenantId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/app/file-management/{id}/rename-item")]
|
||||||
public async Task<FileItemDto> RenameItemAsync(string id, RenameItemDto input)
|
public async Task<FileItemDto> RenameItemAsync(string id, RenameItemDto input)
|
||||||
{
|
{
|
||||||
// Check if this is a protected system folder
|
// Check if this is a protected system folder
|
||||||
|
|
@ -415,24 +422,26 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
|
|
||||||
ValidateFileName(input.Name);
|
ValidateFileName(input.Name);
|
||||||
|
|
||||||
var metadata = await FindItemMetadataAsync(id);
|
var tenantId = GetEffectiveTenantId(input.TenantId);
|
||||||
|
|
||||||
|
var metadata = await FindItemMetadataAsync(id, tenantId);
|
||||||
if (metadata == null)
|
if (metadata == null)
|
||||||
{
|
{
|
||||||
throw new UserFriendlyException("Item not found");
|
throw new UserFriendlyException("Item not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
var parentItems = await GetFolderIndexAsync(metadata.ParentId == string.Empty ? null : metadata.ParentId);
|
var parentItems = await GetFolderIndexAsync(metadata.ParentId == string.Empty ? null : metadata.ParentId, tenantId);
|
||||||
|
|
||||||
if (parentItems.Any(x => x.Id != id && x.Name.Equals(input.Name, StringComparison.OrdinalIgnoreCase)))
|
if (parentItems.Any(x => x.Id != id && x.Name.Equals(input.Name, StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
throw new UserFriendlyException("An item with this name already exists");
|
throw new UserFriendlyException("An item with this name already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
var oldBlobPath = GetTenantPrefix() + metadata.Path;
|
var oldBlobPath = GetTenantPrefix(tenantId) + metadata.Path;
|
||||||
var newPath = string.IsNullOrEmpty(metadata.ParentId)
|
var newPath = string.IsNullOrEmpty(metadata.ParentId)
|
||||||
? input.Name
|
? input.Name
|
||||||
: $"{metadata.ParentId}/{input.Name}";
|
: $"{metadata.ParentId}/{input.Name}";
|
||||||
var newBlobPath = GetTenantPrefix() + newPath;
|
var newBlobPath = GetTenantPrefix(tenantId) + newPath;
|
||||||
|
|
||||||
if (metadata.Type == "folder")
|
if (metadata.Type == "folder")
|
||||||
{
|
{
|
||||||
|
|
@ -457,7 +466,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
itemToUpdate.Path = newPath;
|
itemToUpdate.Path = newPath;
|
||||||
itemToUpdate.ModifiedAt = DateTime.UtcNow;
|
itemToUpdate.ModifiedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
await SaveFolderIndexAsync(parentItems, metadata.ParentId == string.Empty ? null : metadata.ParentId);
|
await SaveFolderIndexAsync(parentItems, tenantId, metadata.ParentId == string.Empty ? null : metadata.ParentId);
|
||||||
|
|
||||||
return new FileItemDto
|
return new FileItemDto
|
||||||
{
|
{
|
||||||
|
|
@ -472,19 +481,22 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
Path = metadata.Path,
|
Path = metadata.Path,
|
||||||
ParentId = metadata.ParentId,
|
ParentId = metadata.ParentId,
|
||||||
IsReadOnly = metadata.IsReadOnly,
|
IsReadOnly = metadata.IsReadOnly,
|
||||||
TenantId = _currentTenant.Id?.ToString()
|
TenantId = tenantId == "host" ? null : tenantId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/app/file-management/{id}/move-item")]
|
||||||
public async Task<FileItemDto> MoveItemAsync(string id, MoveItemDto input)
|
public async Task<FileItemDto> MoveItemAsync(string id, MoveItemDto input)
|
||||||
{
|
{
|
||||||
var metadata = await FindItemMetadataAsync(id);
|
var tenantId = GetEffectiveTenantId(input.TenantId);
|
||||||
|
|
||||||
|
var metadata = await FindItemMetadataAsync(id, tenantId);
|
||||||
if (metadata == null)
|
if (metadata == null)
|
||||||
{
|
{
|
||||||
throw new UserFriendlyException("Item not found");
|
throw new UserFriendlyException("Item not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetItems = await GetFolderIndexAsync(input.TargetFolderId);
|
var targetItems = await GetFolderIndexAsync(input.TargetFolderId, tenantId);
|
||||||
|
|
||||||
if (targetItems.Any(x => x.Name.Equals(metadata.Name, StringComparison.OrdinalIgnoreCase)))
|
if (targetItems.Any(x => x.Name.Equals(metadata.Name, StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
|
|
@ -492,16 +504,16 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from source
|
// Remove from source
|
||||||
var sourceItems = await GetFolderIndexAsync(metadata.ParentId == string.Empty ? null : metadata.ParentId);
|
var sourceItems = await GetFolderIndexAsync(metadata.ParentId == string.Empty ? null : metadata.ParentId, tenantId);
|
||||||
sourceItems.RemoveAll(x => x.Id == id);
|
sourceItems.RemoveAll(x => x.Id == id);
|
||||||
await SaveFolderIndexAsync(sourceItems, metadata.ParentId == string.Empty ? null : metadata.ParentId);
|
await SaveFolderIndexAsync(sourceItems, tenantId, metadata.ParentId == string.Empty ? null : metadata.ParentId);
|
||||||
|
|
||||||
// Move blob
|
// Move blob
|
||||||
var oldBlobPath = GetTenantPrefix() + metadata.Path;
|
var oldBlobPath = GetTenantPrefix(tenantId) + metadata.Path;
|
||||||
var newPath = string.IsNullOrEmpty(input.TargetFolderId)
|
var newPath = string.IsNullOrEmpty(input.TargetFolderId)
|
||||||
? metadata.Name
|
? metadata.Name
|
||||||
: $"{input.TargetFolderId}/{metadata.Name}";
|
: $"{input.TargetFolderId}/{metadata.Name}";
|
||||||
var newBlobPath = GetTenantPrefix() + newPath;
|
var newBlobPath = GetTenantPrefix(tenantId) + newPath;
|
||||||
|
|
||||||
if (metadata.Type == "folder")
|
if (metadata.Type == "folder")
|
||||||
{
|
{
|
||||||
|
|
@ -520,7 +532,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
|
|
||||||
// Add to target
|
// Add to target
|
||||||
targetItems.Add(metadata);
|
targetItems.Add(metadata);
|
||||||
await SaveFolderIndexAsync(targetItems, input.TargetFolderId);
|
await SaveFolderIndexAsync(targetItems, tenantId, input.TargetFolderId);
|
||||||
|
|
||||||
return new FileItemDto
|
return new FileItemDto
|
||||||
{
|
{
|
||||||
|
|
@ -535,11 +547,12 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
Path = metadata.Path,
|
Path = metadata.Path,
|
||||||
ParentId = metadata.ParentId,
|
ParentId = metadata.ParentId,
|
||||||
IsReadOnly = metadata.IsReadOnly,
|
IsReadOnly = metadata.IsReadOnly,
|
||||||
TenantId = _currentTenant.Id?.ToString()
|
TenantId = tenantId == "host" ? null : tenantId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteItemAsync(string id)
|
[HttpDelete("api/app/file-management/items/{id}/item/{tenantId}")]
|
||||||
|
public async Task DeleteItemAsync(string id, string? tenantId = null)
|
||||||
{
|
{
|
||||||
// Check if this is a protected system folder
|
// Check if this is a protected system folder
|
||||||
ValidateNotProtectedFolder(id, "delete");
|
ValidateNotProtectedFolder(id, "delete");
|
||||||
|
|
@ -550,9 +563,9 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
throw new UserFriendlyException("CDN path is not configured");
|
throw new UserFriendlyException("CDN path is not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantId = _currentTenant.Id?.ToString() ?? "host";
|
var effectiveTenantId = GetEffectiveTenantId(tenantId);
|
||||||
var actualPath = DecodeIdAsPath(id);
|
var actualPath = DecodeIdAsPath(id);
|
||||||
var fullPath = Path.Combine(cdnBasePath, tenantId, actualPath);
|
var fullPath = Path.Combine(cdnBasePath, effectiveTenantId, actualPath);
|
||||||
|
|
||||||
if (Directory.Exists(fullPath))
|
if (Directory.Exists(fullPath))
|
||||||
{
|
{
|
||||||
|
|
@ -570,6 +583,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/app/file-management/bulk-delete-items")]
|
||||||
public async Task BulkDeleteItemsAsync(BulkDeleteDto input)
|
public async Task BulkDeleteItemsAsync(BulkDeleteDto input)
|
||||||
{
|
{
|
||||||
if (input.ItemIds == null || !input.ItemIds.Any())
|
if (input.ItemIds == null || !input.ItemIds.Any())
|
||||||
|
|
@ -583,7 +597,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
throw new UserFriendlyException("CDN path is not configured");
|
throw new UserFriendlyException("CDN path is not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantId = _currentTenant.Id?.ToString() ?? "host";
|
var tenantId = GetEffectiveTenantId(input.TenantId);
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
|
|
||||||
foreach (var itemId in input.ItemIds)
|
foreach (var itemId in input.ItemIds)
|
||||||
|
|
@ -623,6 +637,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/app/file-management/copy-items")]
|
||||||
public async Task<List<FileItemDto>> CopyItemsAsync(CopyItemsDto input)
|
public async Task<List<FileItemDto>> CopyItemsAsync(CopyItemsDto input)
|
||||||
{
|
{
|
||||||
if (input.ItemIds == null || !input.ItemIds.Any())
|
if (input.ItemIds == null || !input.ItemIds.Any())
|
||||||
|
|
@ -636,7 +651,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
throw new UserFriendlyException("CDN path is not configured");
|
throw new UserFriendlyException("CDN path is not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantId = _currentTenant.Id?.ToString() ?? "host";
|
var tenantId = GetEffectiveTenantId(input.TenantId);
|
||||||
var basePath = Path.Combine(cdnBasePath, tenantId);
|
var basePath = Path.Combine(cdnBasePath, tenantId);
|
||||||
|
|
||||||
string? targetPath = null;
|
string? targetPath = null;
|
||||||
|
|
@ -681,7 +696,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
Path = finalTargetPath,
|
Path = finalTargetPath,
|
||||||
ParentId = input.TargetFolderId ?? string.Empty,
|
ParentId = input.TargetFolderId ?? string.Empty,
|
||||||
IsReadOnly = false,
|
IsReadOnly = false,
|
||||||
TenantId = _currentTenant.Id?.ToString()
|
TenantId = tenantId == "host" ? null : tenantId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else if (File.Exists(sourceFullPath))
|
else if (File.Exists(sourceFullPath))
|
||||||
|
|
@ -711,7 +726,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
Path = finalTargetPath,
|
Path = finalTargetPath,
|
||||||
ParentId = input.TargetFolderId ?? string.Empty,
|
ParentId = input.TargetFolderId ?? string.Empty,
|
||||||
IsReadOnly = false,
|
IsReadOnly = false,
|
||||||
TenantId = _currentTenant.Id?.ToString()
|
TenantId = tenantId == "host" ? null : tenantId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -733,6 +748,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
return copiedItems;
|
return copiedItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/app/file-management/move-items")]
|
||||||
public async Task<List<FileItemDto>> MoveItemsAsync(MoveItemsDto input)
|
public async Task<List<FileItemDto>> MoveItemsAsync(MoveItemsDto input)
|
||||||
{
|
{
|
||||||
if (input.ItemIds == null || !input.ItemIds.Any())
|
if (input.ItemIds == null || !input.ItemIds.Any())
|
||||||
|
|
@ -746,7 +762,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
throw new UserFriendlyException("CDN path is not configured");
|
throw new UserFriendlyException("CDN path is not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantId = _currentTenant.Id?.ToString() ?? "host";
|
var tenantId = GetEffectiveTenantId(input.TenantId);
|
||||||
var basePath = Path.Combine(cdnBasePath, tenantId);
|
var basePath = Path.Combine(cdnBasePath, tenantId);
|
||||||
|
|
||||||
string? targetPath = null;
|
string? targetPath = null;
|
||||||
|
|
@ -808,7 +824,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
Path = finalTargetPath,
|
Path = finalTargetPath,
|
||||||
ParentId = input.TargetFolderId ?? string.Empty,
|
ParentId = input.TargetFolderId ?? string.Empty,
|
||||||
IsReadOnly = false,
|
IsReadOnly = false,
|
||||||
TenantId = _currentTenant.Id?.ToString()
|
TenantId = tenantId == "host" ? null : tenantId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else if (File.Exists(sourceFullPath))
|
else if (File.Exists(sourceFullPath))
|
||||||
|
|
@ -838,7 +854,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
Path = finalTargetPath,
|
Path = finalTargetPath,
|
||||||
ParentId = input.TargetFolderId ?? string.Empty,
|
ParentId = input.TargetFolderId ?? string.Empty,
|
||||||
IsReadOnly = false,
|
IsReadOnly = false,
|
||||||
TenantId = _currentTenant.Id?.ToString()
|
TenantId = tenantId == "host" ? null : tenantId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -860,7 +876,8 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
return movedItems;
|
return movedItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Stream> DownloadFileAsync(string id)
|
[HttpPost("api/app/file-management/{id}/download-file/{tenantId}")]
|
||||||
|
public async Task<Stream> DownloadFileAsync(string id, string? tenantId = null)
|
||||||
{
|
{
|
||||||
var cdnBasePath = _configuration["App:CdnPath"];
|
var cdnBasePath = _configuration["App:CdnPath"];
|
||||||
if (string.IsNullOrEmpty(cdnBasePath))
|
if (string.IsNullOrEmpty(cdnBasePath))
|
||||||
|
|
@ -868,9 +885,9 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
throw new UserFriendlyException("CDN path is not configured");
|
throw new UserFriendlyException("CDN path is not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantId = _currentTenant.Id?.ToString() ?? "host";
|
var effectiveTenantId = GetEffectiveTenantId(tenantId);
|
||||||
var actualPath = DecodeIdAsPath(id);
|
var actualPath = DecodeIdAsPath(id);
|
||||||
var fullFilePath = Path.Combine(cdnBasePath, tenantId, actualPath);
|
var fullFilePath = Path.Combine(cdnBasePath, effectiveTenantId, actualPath);
|
||||||
|
|
||||||
if (!File.Exists(fullFilePath))
|
if (!File.Exists(fullFilePath))
|
||||||
{
|
{
|
||||||
|
|
@ -880,14 +897,17 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
return File.OpenRead(fullFilePath);
|
return File.OpenRead(fullFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Stream> GetFilePreviewAsync(string id)
|
[HttpGet("api/app/file-management/{id}/file-preview/{tenantId}")]
|
||||||
|
public async Task<Stream> GetFilePreviewAsync(string id, string? tenantId = null)
|
||||||
{
|
{
|
||||||
return await DownloadFileAsync(id);
|
return await DownloadFileAsync(id, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/app/file-management/search-items")]
|
||||||
public async Task<GetFilesDto> SearchItemsAsync(SearchFilesDto input)
|
public async Task<GetFilesDto> SearchItemsAsync(SearchFilesDto input)
|
||||||
{
|
{
|
||||||
var allItems = await GetAllItemsRecursivelyAsync(input.ParentId);
|
var tenantId = GetEffectiveTenantId(input.TenantId);
|
||||||
|
var allItems = await GetAllItemsRecursivelyAsync(tenantId, input.ParentId);
|
||||||
var query = input.Query.ToLowerInvariant();
|
var query = input.Query.ToLowerInvariant();
|
||||||
|
|
||||||
var filteredItems = allItems
|
var filteredItems = allItems
|
||||||
|
|
@ -905,7 +925,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
Path = metadata.Path,
|
Path = metadata.Path,
|
||||||
ParentId = metadata.ParentId,
|
ParentId = metadata.ParentId,
|
||||||
IsReadOnly = metadata.IsReadOnly,
|
IsReadOnly = metadata.IsReadOnly,
|
||||||
TenantId = _currentTenant.Id?.ToString()
|
TenantId = tenantId == "host" ? null : tenantId
|
||||||
})
|
})
|
||||||
.OrderBy(x => x.Type == "folder" ? 0 : 1)
|
.OrderBy(x => x.Type == "folder" ? 0 : 1)
|
||||||
.ThenBy(x => x.Name)
|
.ThenBy(x => x.Name)
|
||||||
|
|
@ -914,9 +934,10 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
return new GetFilesDto { Items = filteredItems };
|
return new GetFilesDto { Items = filteredItems };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<FolderPathDto> GetFolderPathAsync(string? folderId = null)
|
[HttpGet("api/app/file-management/folder-path/{tenantId}")]
|
||||||
|
public async Task<FolderPathDto> GetFolderPathAsync(string? tenantId = null)
|
||||||
{
|
{
|
||||||
return await GetFolderPathInternalAsync(folderId);
|
return await GetFolderPathInternalAsync(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Private Helper Methods
|
#region Private Helper Methods
|
||||||
|
|
@ -944,30 +965,28 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
return uniqueName;
|
return uniqueName;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<FileMetadata?> FindItemMetadataAsync(string id)
|
private async Task<FileMetadata?> FindItemMetadataAsync(string id, string tenantId)
|
||||||
{
|
{
|
||||||
// This is not efficient, but IBlobContainer doesn't have built-in search
|
var allItems = await GetAllItemsRecursivelyAsync(tenantId);
|
||||||
// In a real scenario, you might want to use a database index or search service
|
|
||||||
var allItems = await GetAllItemsRecursivelyAsync();
|
|
||||||
return allItems.FirstOrDefault(x => x.Id == id);
|
return allItems.FirstOrDefault(x => x.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<FileMetadata?> FindItemByPathAsync(string path)
|
private async Task<FileMetadata?> FindItemByPathAsync(string path, string tenantId)
|
||||||
{
|
{
|
||||||
var allItems = await GetAllItemsRecursivelyAsync();
|
var allItems = await GetAllItemsRecursivelyAsync(tenantId);
|
||||||
return allItems.FirstOrDefault(x => x.Path.Equals(path, StringComparison.OrdinalIgnoreCase));
|
return allItems.FirstOrDefault(x => x.Path.Equals(path, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<FileMetadata>> GetAllItemsRecursivelyAsync(string? parentId = null, List<FileMetadata>? result = null)
|
private async Task<List<FileMetadata>> GetAllItemsRecursivelyAsync(string tenantId, string? parentId = null, List<FileMetadata>? result = null)
|
||||||
{
|
{
|
||||||
result ??= [];
|
result ??= [];
|
||||||
|
|
||||||
var items = await GetFolderIndexAsync(parentId);
|
var items = await GetFolderIndexAsync(parentId, tenantId);
|
||||||
result.AddRange(items);
|
result.AddRange(items);
|
||||||
|
|
||||||
foreach (var folder in items.Where(x => x.Type == "folder"))
|
foreach (var folder in items.Where(x => x.Type == "folder"))
|
||||||
{
|
{
|
||||||
await GetAllItemsRecursivelyAsync(folder.Id, result);
|
await GetAllItemsRecursivelyAsync(tenantId, folder.Id, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -1029,12 +1048,8 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<FolderPathDto> GetFolderPathAsync()
|
[HttpGet("api/app/file-management/folder-path-by-id")]
|
||||||
{
|
public async Task<FolderPathDto> GetFolderPathByIdAsync(string folderId, string? tenantId = null)
|
||||||
return await GetFolderPathInternalAsync(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<FolderPathDto> GetFolderPathByIdAsync(string folderId)
|
|
||||||
{
|
{
|
||||||
return await GetFolderPathInternalAsync(folderId);
|
return await GetFolderPathInternalAsync(folderId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,33 +10,42 @@ import type {
|
||||||
|
|
||||||
class FileManagementService {
|
class FileManagementService {
|
||||||
// Get files and folders for a specific directory
|
// Get files and folders for a specific directory
|
||||||
async getItems(parentId?: string): Promise<{ data: { items: FileItem[] } }> {
|
async getItems(parentId?: string, tenantId?: string): Promise<{ data: { items: FileItem[] } }> {
|
||||||
|
const effectiveTenantId = tenantId || 'host'
|
||||||
const url = parentId
|
const url = parentId
|
||||||
? `/api/app/file-management/items-by-parent/${parentId}`
|
? `/api/app/file-management/items-by-parent`
|
||||||
: `/api/app/file-management/items`
|
: `/api/app/file-management/items/${effectiveTenantId}`
|
||||||
|
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (parentId) params['parentId'] = parentId
|
||||||
|
if (parentId && tenantId) params['tenantId'] = tenantId
|
||||||
|
|
||||||
return ApiService.fetchData<{ items: FileItem[] }>({
|
return ApiService.fetchData<{ items: FileItem[] }>({
|
||||||
url,
|
url,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
params: Object.keys(params).length ? params : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new folder
|
// Create a new folder
|
||||||
async createFolder(request: CreateFolderRequest): Promise<{ data: FileItem }> {
|
async createFolder(request: CreateFolderRequest, tenantId?: string): Promise<{ data: FileItem }> {
|
||||||
return ApiService.fetchData<FileItem>({
|
return ApiService.fetchData<FileItem>({
|
||||||
url: `/api/app/file-management/folder`,
|
url: `/api/app/file-management/folder`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: request as any,
|
data: { ...request, tenantId } as any,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload a file (DTO pattern)
|
// Upload a file (DTO pattern)
|
||||||
async uploadFile(request: UploadFileRequest): Promise<{ data: FileItem }> {
|
async uploadFile(request: UploadFileRequest, tenantId?: string): Promise<{ data: FileItem }> {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('fileName', request.fileName)
|
formData.append('fileName', request.fileName)
|
||||||
if (request.parentId) {
|
if (request.parentId) {
|
||||||
formData.append('parentId', request.parentId)
|
formData.append('parentId', request.parentId)
|
||||||
}
|
}
|
||||||
|
if (tenantId) {
|
||||||
|
formData.append('tenantId', tenantId)
|
||||||
|
}
|
||||||
|
|
||||||
// NoteModal pattern - Files array
|
// NoteModal pattern - Files array
|
||||||
request.files.forEach(file => {
|
request.files.forEach(file => {
|
||||||
|
|
@ -47,16 +56,16 @@ class FileManagementService {
|
||||||
url: `/api/app/file-management/upload-file`,
|
url: `/api/app/file-management/upload-file`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: formData as any,
|
data: formData as any,
|
||||||
// Browser otomatik olarak Content-Type'ı multipart/form-data boundary ile set eder
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload a file directly with FormData (NoteModal pattern)
|
// Upload a file directly with FormData (NoteModal pattern)
|
||||||
async uploadFileDirectly(formData: FormData): Promise<{ data: FileItem }> {
|
async uploadFileDirectly(formData: FormData, tenantId?: string): Promise<{ data: FileItem }> {
|
||||||
try {
|
try {
|
||||||
console.log('Uploading file directly with FormData')
|
console.log('Uploading file directly with FormData')
|
||||||
|
if (tenantId) {
|
||||||
// ABP convention-based routing: UploadFileAsync -> upload-file (Async suffix kaldırılır)
|
formData.append('tenantId', tenantId)
|
||||||
|
}
|
||||||
return await ApiService.fetchData<FileItem>({
|
return await ApiService.fetchData<FileItem>({
|
||||||
url: 'api/app/file-management/upload-file',
|
url: 'api/app/file-management/upload-file',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -69,35 +78,37 @@ class FileManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename a file or folder
|
// Rename a file or folder
|
||||||
async renameItem(request: RenameItemRequest): Promise<{ data: FileItem }> {
|
async renameItem(request: RenameItemRequest, tenantId?: string): Promise<{ data: FileItem }> {
|
||||||
return ApiService.fetchData<FileItem>({
|
return ApiService.fetchData<FileItem>({
|
||||||
url: `/api/app/file-management/${request.id}/rename-item`,
|
url: `/api/app/file-management/${request.id}/rename-item`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { name: request.newName },
|
data: { name: request.newName, tenantId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move a file or folder
|
// Move a file or folder
|
||||||
async moveItem(request: MoveItemRequest): Promise<{ data: FileItem }> {
|
async moveItem(request: MoveItemRequest, tenantId?: string): Promise<{ data: FileItem }> {
|
||||||
return ApiService.fetchData<FileItem>({
|
return ApiService.fetchData<FileItem>({
|
||||||
url: `/api/app/file-management/${request.itemId}/move-item`,
|
url: `/api/app/file-management/${request.itemId}/move-item`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { targetFolderId: request.targetFolderId },
|
data: { targetFolderId: request.targetFolderId, tenantId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete a file or folder
|
// Delete a file or folder
|
||||||
async deleteItem(request: DeleteItemRequest): Promise<void> {
|
async deleteItem(request: DeleteItemRequest, tenantId?: string): Promise<void> {
|
||||||
|
const effectiveTenantId = tenantId || 'host'
|
||||||
await ApiService.fetchData<void>({
|
await ApiService.fetchData<void>({
|
||||||
url: `/api/app/file-management/${request.id}/item`,
|
url: `/api/app/file-management/${request.id}/item/${effectiveTenantId}`,
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download a file
|
// Download a file
|
||||||
async downloadFile(fileId: string): Promise<Blob> {
|
async downloadFile(fileId: string, tenantId?: string): Promise<Blob> {
|
||||||
|
const effectiveTenantId = tenantId || 'host'
|
||||||
const response = await ApiService.fetchData<Blob>({
|
const response = await ApiService.fetchData<Blob>({
|
||||||
url: `/api/app/file-management/${fileId}/download-file`,
|
url: `/api/app/file-management/${fileId}/download-file/${effectiveTenantId}`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
})
|
})
|
||||||
|
|
@ -105,9 +116,10 @@ class FileManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file preview/thumbnail
|
// Get file preview/thumbnail
|
||||||
async getFilePreview(fileId: string): Promise<string> {
|
async getFilePreview(fileId: string, tenantId?: string): Promise<string> {
|
||||||
|
const effectiveTenantId = tenantId || 'host'
|
||||||
const response = await ApiService.fetchData<Blob>({
|
const response = await ApiService.fetchData<Blob>({
|
||||||
url: `/api/app/file-management/${fileId}/file-preview`,
|
url: `/api/app/file-management/${fileId}/file-preview/${effectiveTenantId}`,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
})
|
})
|
||||||
|
|
@ -115,10 +127,11 @@ class FileManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search files and folders
|
// Search files and folders
|
||||||
async searchItems(query: string, folderId?: string): Promise<{ data: { items: FileItem[] } }> {
|
async searchItems(query: string, folderId?: string, tenantId?: string): Promise<{ data: { items: FileItem[] } }> {
|
||||||
const data = {
|
const data = {
|
||||||
query: query,
|
query,
|
||||||
...(folderId && { parentId: folderId }),
|
...(folderId && { parentId: folderId }),
|
||||||
|
...(tenantId && { tenantId }),
|
||||||
}
|
}
|
||||||
|
|
||||||
return ApiService.fetchData<{ items: FileItem[] }>({
|
return ApiService.fetchData<{ items: FileItem[] }>({
|
||||||
|
|
@ -129,41 +142,47 @@ class FileManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get folder breadcrumb path
|
// Get folder breadcrumb path
|
||||||
async getFolderPath(folderId?: string): Promise<{ data: { path: Array<{ id: string; name: string }> } }> {
|
async getFolderPath(folderId?: string, tenantId?: string): Promise<{ data: { path: Array<{ id: string; name: string }> } }> {
|
||||||
|
const effectiveTenantId = tenantId || 'host'
|
||||||
const url = folderId
|
const url = folderId
|
||||||
? `/api/app/file-management/folder-path-by-id/${folderId}`
|
? `/api/app/file-management/folder-path-by-id`
|
||||||
: `/api/app/file-management/folder-path`
|
: `/api/app/file-management/folder-path/${effectiveTenantId}`
|
||||||
|
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (folderId) params['folderId'] = folderId
|
||||||
|
if (folderId && tenantId) params['tenantId'] = tenantId
|
||||||
|
|
||||||
return ApiService.fetchData<{ path: Array<{ id: string; name: string }> }>({
|
return ApiService.fetchData<{ path: Array<{ id: string; name: string }> }>({
|
||||||
url,
|
url,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
params: Object.keys(params).length ? params : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk delete items
|
// Bulk delete items
|
||||||
async bulkDeleteItems(itemIds: string[]): Promise<{ data: any }> {
|
async bulkDeleteItems(itemIds: string[], tenantId?: string): Promise<{ data: any }> {
|
||||||
return ApiService.fetchData<any>({
|
return ApiService.fetchData<any>({
|
||||||
url: `/api/app/file-management/bulk-delete-items`,
|
url: `/api/app/file-management/bulk-delete-items`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { itemIds },
|
data: { itemIds, tenantId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy items to target folder
|
// Copy items to target folder
|
||||||
async copyItems(itemIds: string[], targetFolderId?: string): Promise<{ data: FileItem[] }> {
|
async copyItems(itemIds: string[], targetFolderId?: string, tenantId?: string): Promise<{ data: FileItem[] }> {
|
||||||
return ApiService.fetchData<FileItem[]>({
|
return ApiService.fetchData<FileItem[]>({
|
||||||
url: `/api/app/file-management/copy-items`,
|
url: `/api/app/file-management/copy-items`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { itemIds, targetFolderId },
|
data: { itemIds, targetFolderId, tenantId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move items to target folder
|
// Move items to target folder
|
||||||
async moveItems(itemIds: string[], targetFolderId?: string): Promise<{ data: FileItem[] }> {
|
async moveItems(itemIds: string[], targetFolderId?: string, tenantId?: string): Promise<{ data: FileItem[] }> {
|
||||||
return ApiService.fetchData<FileItem[]>({
|
return ApiService.fetchData<FileItem[]>({
|
||||||
url: `/api/app/file-management/move-items`,
|
url: `/api/app/file-management/move-items`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { itemIds, targetFolderId },
|
data: { itemIds, targetFolderId, tenantId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,13 @@ import {
|
||||||
FaEdit,
|
FaEdit,
|
||||||
FaDownload,
|
FaDownload,
|
||||||
FaPaste,
|
FaPaste,
|
||||||
|
FaBuilding,
|
||||||
} from 'react-icons/fa'
|
} from 'react-icons/fa'
|
||||||
import Container from '@/components/shared/Container'
|
import Container from '@/components/shared/Container'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import fileManagementService from '@/services/fileManagement.service'
|
import fileManagementService from '@/services/fileManagement.service'
|
||||||
|
import { getTenants } from '@/services/tenant.service'
|
||||||
|
import type { TenantDto } from '@/proxy/config/models'
|
||||||
import Breadcrumb from './components/Breadcrumb'
|
import Breadcrumb from './components/Breadcrumb'
|
||||||
import FileItem from './components/FileItem'
|
import FileItem from './components/FileItem'
|
||||||
import FileUploadModal from './components/FileUploadModal'
|
import FileUploadModal from './components/FileUploadModal'
|
||||||
|
|
@ -64,17 +67,46 @@ const FileManager = () => {
|
||||||
const [itemToRename, setItemToRename] = useState<FileItemType | undefined>()
|
const [itemToRename, setItemToRename] = useState<FileItemType | undefined>()
|
||||||
const [itemsToDelete, setItemsToDelete] = useState<FileItemType[]>([])
|
const [itemsToDelete, setItemsToDelete] = useState<FileItemType[]>([])
|
||||||
|
|
||||||
|
// Tenant state
|
||||||
|
const [tenants, setTenants] = useState<TenantDto[]>([])
|
||||||
|
const [tenantsLoading, setTenantsLoading] = useState(false)
|
||||||
|
const [selectedTenant, setSelectedTenant] = useState<{ id: string; name: string } | undefined>(undefined)
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [renaming, setRenaming] = useState(false)
|
const [renaming, setRenaming] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
// Fetch tenants
|
||||||
|
const fetchTenants = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setTenantsLoading(true)
|
||||||
|
const response = await getTenants(0, 1000)
|
||||||
|
setTenants(response.data.items || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch tenants:', error)
|
||||||
|
} finally {
|
||||||
|
setTenantsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTenants()
|
||||||
|
}, [fetchTenants])
|
||||||
|
|
||||||
|
// Reset navigation when tenant changes
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentFolderId(undefined)
|
||||||
|
setSelectedItems([])
|
||||||
|
setBreadcrumbItems([{ name: 'Files', path: '', id: undefined }])
|
||||||
|
}, [selectedTenant])
|
||||||
|
|
||||||
// Fetch items from API
|
// Fetch items from API
|
||||||
const fetchItems = useCallback(async (folderId?: string) => {
|
const fetchItems = useCallback(async (folderId?: string) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const response = await fileManagementService.getItems(folderId)
|
const response = await fileManagementService.getItems(folderId, selectedTenant?.id)
|
||||||
// Backend returns GetFilesDto which has Items property
|
// Backend returns GetFilesDto which has Items property
|
||||||
const items = response.data.items || []
|
const items = response.data.items || []
|
||||||
// Manual protection for system folders
|
// Manual protection for system folders
|
||||||
|
|
@ -107,7 +139,7 @@ const FileManager = () => {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [selectedTenant])
|
||||||
|
|
||||||
// Fetch breadcrumb path
|
// Fetch breadcrumb path
|
||||||
const fetchBreadcrumb = useCallback(async (folderId?: string) => {
|
const fetchBreadcrumb = useCallback(async (folderId?: string) => {
|
||||||
|
|
@ -117,7 +149,7 @@ const FileManager = () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fileManagementService.getFolderPath(folderId)
|
const response = await fileManagementService.getFolderPath(folderId, selectedTenant?.id)
|
||||||
// console.log('Breadcrumb response for folderId:', folderId, response)
|
// console.log('Breadcrumb response for folderId:', folderId, response)
|
||||||
const pathItems: BreadcrumbItem[] = [
|
const pathItems: BreadcrumbItem[] = [
|
||||||
{ name: 'Files', path: '', id: undefined },
|
{ name: 'Files', path: '', id: undefined },
|
||||||
|
|
@ -131,7 +163,7 @@ const FileManager = () => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch breadcrumb:', error)
|
console.error('Failed to fetch breadcrumb:', error)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [selectedTenant])
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -237,7 +269,7 @@ const FileManager = () => {
|
||||||
formData.append('parentId', currentFolderId)
|
formData.append('parentId', currentFolderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
await fileManagementService.uploadFileDirectly(formData)
|
await fileManagementService.uploadFileDirectly(formData, selectedTenant?.id)
|
||||||
}
|
}
|
||||||
await fetchItems(currentFolderId)
|
await fetchItems(currentFolderId)
|
||||||
toast.push(<Notification type="success">Files uploaded successfully</Notification>)
|
toast.push(<Notification type="success">Files uploaded successfully</Notification>)
|
||||||
|
|
@ -256,7 +288,7 @@ const FileManager = () => {
|
||||||
await fileManagementService.createFolder({
|
await fileManagementService.createFolder({
|
||||||
name,
|
name,
|
||||||
parentId: currentFolderId,
|
parentId: currentFolderId,
|
||||||
})
|
}, selectedTenant?.id)
|
||||||
await fetchItems(currentFolderId)
|
await fetchItems(currentFolderId)
|
||||||
toast.push(<Notification type="success">Folder created successfully</Notification>)
|
toast.push(<Notification type="success">Folder created successfully</Notification>)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -276,7 +308,7 @@ const FileManager = () => {
|
||||||
await fileManagementService.renameItem({
|
await fileManagementService.renameItem({
|
||||||
id: itemToRename.id,
|
id: itemToRename.id,
|
||||||
newName,
|
newName,
|
||||||
})
|
}, selectedTenant?.id)
|
||||||
await fetchItems(currentFolderId)
|
await fetchItems(currentFolderId)
|
||||||
toast.push(<Notification type="success">Item renamed successfully</Notification>)
|
toast.push(<Notification type="success">Item renamed successfully</Notification>)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -294,11 +326,11 @@ const FileManager = () => {
|
||||||
|
|
||||||
if (itemsToDelete.length === 1) {
|
if (itemsToDelete.length === 1) {
|
||||||
// Single item delete - use existing API
|
// Single item delete - use existing API
|
||||||
await fileManagementService.deleteItem({ id: itemsToDelete[0].id })
|
await fileManagementService.deleteItem({ id: itemsToDelete[0].id }, selectedTenant?.id)
|
||||||
} else {
|
} else {
|
||||||
// Multiple items - use bulk delete API
|
// Multiple items - use bulk delete API
|
||||||
const itemIds = itemsToDelete.map((item) => item.id)
|
const itemIds = itemsToDelete.map((item) => item.id)
|
||||||
await fileManagementService.bulkDeleteItems(itemIds)
|
await fileManagementService.bulkDeleteItems(itemIds, selectedTenant?.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchItems(currentFolderId)
|
await fetchItems(currentFolderId)
|
||||||
|
|
@ -315,7 +347,7 @@ const FileManager = () => {
|
||||||
|
|
||||||
const handleDownload = async (item: FileItemType) => {
|
const handleDownload = async (item: FileItemType) => {
|
||||||
try {
|
try {
|
||||||
const blob = await fileManagementService.downloadFile(item.id)
|
const blob = await fileManagementService.downloadFile(item.id, selectedTenant?.id)
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
|
|
@ -456,7 +488,7 @@ const FileManager = () => {
|
||||||
// Check if a folder contains files (for security purposes)
|
// Check if a folder contains files (for security purposes)
|
||||||
const checkFolderHasFiles = async (folderId: string): Promise<boolean> => {
|
const checkFolderHasFiles = async (folderId: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagementService.getItems(folderId)
|
const response = await fileManagementService.getItems(folderId, selectedTenant?.id)
|
||||||
const items = response.data.items || []
|
const items = response.data.items || []
|
||||||
// Check if folder contains any files (not just other folders)
|
// Check if folder contains any files (not just other folders)
|
||||||
return items.some(item => item.type === 'file')
|
return items.some(item => item.type === 'file')
|
||||||
|
|
@ -608,7 +640,7 @@ const FileManager = () => {
|
||||||
if (clipboard.operation === 'copy') {
|
if (clipboard.operation === 'copy') {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await fileManagementService.copyItems(itemIds, currentFolderId)
|
await fileManagementService.copyItems(itemIds, currentFolderId, selectedTenant?.id)
|
||||||
await fetchItems(currentFolderId)
|
await fetchItems(currentFolderId)
|
||||||
toast.push(
|
toast.push(
|
||||||
<Notification title="Success" type="success">
|
<Notification title="Success" type="success">
|
||||||
|
|
@ -638,7 +670,7 @@ const FileManager = () => {
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await fileManagementService.moveItems(itemIds, currentFolderId)
|
await fileManagementService.moveItems(itemIds, currentFolderId, selectedTenant?.id)
|
||||||
await fetchItems(currentFolderId)
|
await fetchItems(currentFolderId)
|
||||||
// Clipboard'ı temizle
|
// Clipboard'ı temizle
|
||||||
localStorage.removeItem('fileManager_clipboard')
|
localStorage.removeItem('fileManager_clipboard')
|
||||||
|
|
@ -669,7 +701,7 @@ const FileManager = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="px-3 sm:px-4 md:px-6">
|
<Container className="px-3">
|
||||||
<Helmet
|
<Helmet
|
||||||
titleTemplate={`%s | ${APP_NAME}`}
|
titleTemplate={`%s | ${APP_NAME}`}
|
||||||
title={translate('::' + 'App.Files')}
|
title={translate('::' + 'App.Files')}
|
||||||
|
|
@ -678,10 +710,40 @@ const FileManager = () => {
|
||||||
|
|
||||||
{/* Enhanced Unified Toolbar */}
|
{/* Enhanced Unified Toolbar */}
|
||||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 sm:p-4 mb-4 mt-2">
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 sm:p-4 mb-4 mt-2">
|
||||||
|
|
||||||
|
|
||||||
{/* Main Toolbar Row */}
|
{/* Main Toolbar Row */}
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 sm:gap-4">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 sm:gap-4">
|
||||||
{/* Left Section - Primary Actions */}
|
{/* Left Section - Primary Actions */}
|
||||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||||
|
{/* Tenant Selector Row */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaBuilding className="text-gray-500 flex-shrink-0" />
|
||||||
|
<Select
|
||||||
|
size="xs"
|
||||||
|
isLoading={tenantsLoading}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Host' },
|
||||||
|
...tenants.map((t) => ({ value: t.id ?? '', label: t.name ?? '' })),
|
||||||
|
]}
|
||||||
|
value={{
|
||||||
|
value: selectedTenant ? selectedTenant.id : '',
|
||||||
|
label: selectedTenant ? selectedTenant.name : 'Host',
|
||||||
|
}}
|
||||||
|
onChange={(option) => {
|
||||||
|
if (option && 'value' in option) {
|
||||||
|
const val = option.value as string
|
||||||
|
if (!val) {
|
||||||
|
setSelectedTenant(undefined)
|
||||||
|
} else {
|
||||||
|
const found = tenants.find((t) => t.id === val)
|
||||||
|
if (found) setSelectedTenant({ id: found.id!, name: found.name ?? '' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* File Operations */}
|
{/* File Operations */}
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -764,7 +826,6 @@ const FileManager = () => {
|
||||||
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
|
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
|
||||||
title="Rename selected item"
|
title="Rename selected item"
|
||||||
>
|
>
|
||||||
Rename
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -795,7 +856,6 @@ const FileManager = () => {
|
||||||
className="text-gray-600 hover:text-green-600 flex-shrink-0"
|
className="text-gray-600 hover:text-green-600 flex-shrink-0"
|
||||||
title="Download selected file"
|
title="Download selected file"
|
||||||
>
|
>
|
||||||
Download
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue