Initial commit - Media.RCL
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
@using Generic.Media.Components
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using System.Collections.Generic
|
||||
@using System.Linq
|
||||
@using System.Net.Http.Json
|
||||
@using System.Net.Http.Headers
|
||||
@using Blazored.LocalStorage
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject ILocalStorageService LocalStorage
|
||||
|
||||
@if (SingleUploadMode && _items.Count == 1)
|
||||
{
|
||||
// Auto-select first item in single mode if not selected
|
||||
// Done in OnInitialized or after upload
|
||||
}
|
||||
|
||||
<div class="flex flex-col gap-6 w-full">
|
||||
|
||||
<!-- Section 1: Cropper / Viewer (Always Available) -->
|
||||
<div class="w-full">
|
||||
<ImageCropper @ref="_cropper" Item="@_selectedItem" OnCropSaved="@OnCropSaved" OnCancel="@OnCropCancel"
|
||||
AspectRatio="@AspectRatio" TargetWidth="@TargetWidth" TargetHeight="@TargetHeight"
|
||||
AiApiKey="@AiModelApiKey" />
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Dropzone & List -->
|
||||
<div class="w-full">
|
||||
<div class="mb-4">
|
||||
<MediaDropZone OnFileDropped="@HandleFileDrop" CompactMode="@(_items.Any())" />
|
||||
</div>
|
||||
|
||||
@if (_items.Any())
|
||||
{
|
||||
<SortableMediaList Items="@_items" OnRemove="@RemoveItem" OnSort="@UpdateOrder"
|
||||
OnEdit="@((item) => SelectItem(item))" />
|
||||
}
|
||||
|
||||
@if (!SingleUploadMode && _items.Count < MaxItems)
|
||||
{
|
||||
<div class="mt-4">
|
||||
<ExternalLinkInput OnLinkAdded="@HandleLinkAdded" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public List<string> InitialUrls { get; set; } = new();
|
||||
[Parameter] public EventCallback<List<string>> OnChange { get; set; }
|
||||
[Parameter] public int MaxItems { get; set; } = 10;
|
||||
|
||||
// Config Parameters
|
||||
[Parameter] public double? AspectRatio { get; set; }
|
||||
[Parameter] public int? TargetWidth { get; set; }
|
||||
[Parameter] public int? TargetHeight { get; set; }
|
||||
[Parameter] public bool SingleUploadMode { get; set; }
|
||||
[Parameter] public string? AiModelApiKey { get; set; }
|
||||
[Parameter] public string CdnBaseUrl { get; set; } = "http://localhost:2003/";
|
||||
|
||||
private List<MediaItemModel> _items = new();
|
||||
private List<string> _deletedUrls = new();
|
||||
private ImageCropper? _cropper;
|
||||
private MediaItemModel? _selectedItem;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (SingleUploadMode) MaxItems = 1;
|
||||
|
||||
if (InitialUrls != null)
|
||||
{
|
||||
foreach (var url in InitialUrls)
|
||||
{
|
||||
_items.Add(new MediaItemModel
|
||||
{
|
||||
Url = url,
|
||||
Type = DetectMediaType(url),
|
||||
Order = _items.Count
|
||||
});
|
||||
}
|
||||
// Auto Select first item
|
||||
if (_items.Any()) _selectedItem = _items.First();
|
||||
}
|
||||
}
|
||||
|
||||
// Called after render to ensure cropper updates with initially selected item if needed
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && _selectedItem != null && _cropper != null)
|
||||
{
|
||||
await _cropper.OpenAsync(_selectedItem);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleFileDrop(InputFileChangeEventArgs e)
|
||||
{
|
||||
var files = e.GetMultipleFiles(SingleUploadMode ? 1 : 20); // 20 as arbitrary max for batch
|
||||
|
||||
if (SingleUploadMode && files.Count > 0)
|
||||
{
|
||||
// Clear existing for single mode
|
||||
var allItems = _items.ToList();
|
||||
foreach (var item in allItems) RemoveItem(item);
|
||||
}
|
||||
|
||||
MediaItemModel? lastAdded = null;
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (_items.Count >= MaxItems) break;
|
||||
|
||||
var newItem = new MediaItemModel
|
||||
{
|
||||
File = file,
|
||||
Type = file.ContentType.StartsWith("video") ? MediaType.Video : MediaType.Image,
|
||||
IsLocal = true,
|
||||
Order = _items.Count
|
||||
};
|
||||
|
||||
_items.Add(newItem);
|
||||
lastAdded = newItem;
|
||||
|
||||
// Generate Preview for Images
|
||||
if (newItem.Type == MediaType.Image)
|
||||
{
|
||||
// Read async to avoid blocking UI?
|
||||
// Fire and forget task to load preview
|
||||
_ = LoadPreviewAsync(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto select the last added item to show in cropper
|
||||
if (lastAdded != null && lastAdded.Type == MediaType.Image)
|
||||
{
|
||||
await SelectItem(lastAdded);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPreviewAsync(MediaItemModel item)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.File == null) return;
|
||||
var maxFileSize = 10L * 1024 * 1024; // 10MB
|
||||
var buffer = new byte[item.File.Size];
|
||||
await item.File.OpenReadStream(maxFileSize).ReadAsync(buffer);
|
||||
var base64 = Convert.ToBase64String(buffer);
|
||||
item.PreviewUrl = $"data:{item.File.ContentType};base64,{base64}";
|
||||
// If this item is currently selected, trigger update
|
||||
if (_selectedItem == item)
|
||||
{
|
||||
await SelectItem(item);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to load preview: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleLinkAdded(string url)
|
||||
{
|
||||
if (_items.Count >= MaxItems) return;
|
||||
if (SingleUploadMode && _items.Count > 0)
|
||||
{
|
||||
var allItems = _items.ToList();
|
||||
foreach (var item in allItems) RemoveItem(item);
|
||||
}
|
||||
|
||||
var newItem = new MediaItemModel
|
||||
{
|
||||
Url = url,
|
||||
Type = DetectMediaType(url),
|
||||
Order = _items.Count
|
||||
};
|
||||
|
||||
_items.Add(newItem);
|
||||
if (newItem.Type == MediaType.Image) _ = SelectItem(newItem);
|
||||
}
|
||||
|
||||
private void RemoveItem(MediaItemModel item)
|
||||
{
|
||||
_items.Remove(item);
|
||||
if (_selectedItem == item)
|
||||
{
|
||||
_selectedItem = null;
|
||||
// Clear cropper?
|
||||
if (_cropper != null)
|
||||
{
|
||||
// We can't really "clear" it easily without a Clear method,
|
||||
// but passing null to something that expects it might work.
|
||||
// For now, re-render will pass Item=null to ImageCropper
|
||||
}
|
||||
}
|
||||
|
||||
// Track deletion if it was a remote URL
|
||||
if (!string.IsNullOrEmpty(item.Url) && !item.IsLocal)
|
||||
{
|
||||
_deletedUrls.Add(item.Url);
|
||||
}
|
||||
|
||||
Reindex();
|
||||
}
|
||||
|
||||
private async Task SelectItem(MediaItemModel item)
|
||||
{
|
||||
_selectedItem = item;
|
||||
if (item.Type == MediaType.Image && _cropper != null)
|
||||
{
|
||||
await _cropper.OpenAsync(item);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnCropSaved(MediaItemModel item)
|
||||
{
|
||||
// Item is modified by reference
|
||||
// Force refresh list to show new thumb
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnCropCancel()
|
||||
{
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
private void UpdateOrder(List<MediaItemModel> sortedItems)
|
||||
{
|
||||
_items = sortedItems;
|
||||
Reindex();
|
||||
}
|
||||
|
||||
private void Reindex()
|
||||
{
|
||||
for (int i = 0; i < _items.Count; i++)
|
||||
{
|
||||
_items[i].Order = i;
|
||||
}
|
||||
}
|
||||
|
||||
private MediaType DetectMediaType(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return MediaType.Image;
|
||||
if (url.Contains("youtube.com") || url.Contains("youtu.be")) return MediaType.YouTube;
|
||||
if (url.Contains("vimeo.com")) return MediaType.Vimeo;
|
||||
if (url.EndsWith(".mp4") || url.EndsWith(".webm")) return MediaType.Video;
|
||||
return MediaType.Image;
|
||||
}
|
||||
|
||||
public class UploadResult
|
||||
{
|
||||
public List<string> FinalUrls { get; set; } = new();
|
||||
public List<string> DeletedUrls { get; set; } = new();
|
||||
}
|
||||
|
||||
public async Task<UploadResult> ProcessUploadsAsync()
|
||||
{
|
||||
var result = new UploadResult();
|
||||
result.DeletedUrls = new List<string>(_deletedUrls);
|
||||
|
||||
var client = HttpClientFactory.CreateClient("CDN");
|
||||
var token = await LocalStorage.GetItemAsync<string>("authToken");
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
foreach (var item in _items)
|
||||
{
|
||||
if (item.IsLocal)
|
||||
{
|
||||
// Handle Local File Upload
|
||||
try
|
||||
{
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
if (!string.IsNullOrEmpty(item.PreviewUrl) && item.PreviewUrl.StartsWith("data:"))
|
||||
{
|
||||
// Cropped or preview base64
|
||||
var base64Data = item.PreviewUrl.Split(',')[1];
|
||||
var bytes = Convert.FromBase64String(base64Data);
|
||||
var byteContent = new ByteArrayContent(bytes);
|
||||
byteContent.Headers.ContentType = new MediaTypeHeaderValue("image/webp");
|
||||
content.Add(byteContent, "file", "image.webp");
|
||||
}
|
||||
else if (item.File != null)
|
||||
{
|
||||
// Original file
|
||||
var fileContent = new StreamContent(item.File.OpenReadStream(10 * 1024 * 1024));
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue(item.File.ContentType);
|
||||
content.Add(fileContent, "file", item.File.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var response = await client.PostAsync("files/upload", content);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var uploadRes = await response.Content.ReadFromJsonAsync<UploadResponse>();
|
||||
if (uploadRes != null && (!string.IsNullOrEmpty(uploadRes.Path) || !string.IsNullOrEmpty(uploadRes.Url)))
|
||||
{
|
||||
var fullUrl = !string.IsNullOrEmpty(uploadRes.Url)
|
||||
? uploadRes.Url
|
||||
: $"{CdnBaseUrl.TrimEnd('/')}/{uploadRes.Path?.TrimStart('/')}";
|
||||
|
||||
result.FinalUrls.Add(fullUrl);
|
||||
item.Url = fullUrl;
|
||||
item.IsLocal = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var errorMsg = await response.Content.ReadAsStringAsync();
|
||||
throw new Exception($"Upload failed with status {response.StatusCode}: {errorMsg}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Upload Error: {ex.Message}");
|
||||
throw; // Re-throw to block save in parent
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(item.Url))
|
||||
{
|
||||
result.FinalUrls.Add(item.Url);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_items.Clear();
|
||||
_deletedUrls.Clear();
|
||||
_selectedItem = null;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private class UploadResponse
|
||||
{
|
||||
public string? Path { get; set; }
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user