Initial commit - Media.RCL

This commit is contained in:
2026-08-05 21:16:09 +03:00
commit 685015a3a4
166 changed files with 7590 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
@inject IJSRuntime js
<div class="flex p-64 h-screen w-screen">
<div id="dropzone" name="file" class="w-full h-96 bg-gray-300 rounded-2xl " type="file"></div>
<img id="cropped-image" class="w-full h-screen py-60" src="" alt="">
<div id="cropped" class="hidden">
<img id="cropped-image" class="w-full h-96" src="" alt="">
<div class="flex">
<button type="button" @onclick="RemoveCropped" class="btn btn-primary">test</button>
</div>
</div>
</div>
@code {
[Parameter]
public string UploadLink { get; set; }
protected override async Task OnInitializedAsync()
{
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await js.InvokeVoidAsync("startCropper");
}
}
async Task RemoveCropped()
{
await js.InvokeVoidAsync("removeCropped");
}
public async Task<string> UploadPhoto()
{
var photoLink = "";
return photoLink;
}
}
@* @namespace MediaComponentLib
@using Microsoft.Extensions.Localization
@using Microsoft.AspNetCore.Components.Forms
@inject IStringLocalizer<Cropper> Loc
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
<div class="flex flex-col p-4 w-full h-full min-h-[500px] items-center gap-4">
@if (string.IsNullOrEmpty(_imageData))
{
<div
class="w-full h-64 bg-gray-100 rounded-2xl flex flex-col items-center justify-center relative border-2 border-dashed border-gray-300 hover:bg-gray-50 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-gray-400 mb-2" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="text-lg text-gray-500 font-medium">Drop image here or click to upload</span>
<InputFile class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" OnChange="HandleFileSelected"
accept=".jpg,.jpeg,.png,.webp" />
</div>
}
else if (!_isCropped)
{
<div class="w-full flex-1 bg-black rounded-lg overflow-hidden relative" style="min-height: 400px;">
<!-- Unique ID used by Interop -->
<img id="@_uniqueId" src="@_imageData" @onload="OnImageLoaded" class="max-w-full max-h-full block mx-auto"
style="display: block;" />
</div>
<div class="flex gap-2 w-full justify-end">
<button class="btn btn-ghost" @onclick="Reset">@Loc["Cancel"]</button>
<button class="btn btn-primary" @onclick="CropImage">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
@Loc["Crop"]
</button>
</div>
}
else
{
<div class="flex flex-col gap-4 items-center w-full">
<div class="bg-base-200 p-2 rounded-lg shadow-sm">
<img src="@_croppedData" class="max-w-full max-h-[500px] rounded" />
</div>
<button class="btn btn-warning" @onclick="Reset">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
@Loc["Remove"]
</button>
</div>
}
</div>
@code {
[Parameter] public string? UploadLink { get; set; }
// Unique ID for this instance
private string _uniqueId = "cropper-" + Guid.NewGuid().ToString("N");
private string? _imageData;
private string? _croppedData;
private bool _isCropped;
private MediaJsInterop? _mediaInterop;
private DotNetObjectReference<Cropper>? _objRef;
protected override void OnInitialized()
{
_mediaInterop = new MediaJsInterop(JSRuntime);
_objRef = DotNetObjectReference.Create(this);
}
private async Task HandleFileSelected(InputFileChangeEventArgs e)
{
try
{
var file = e.File;
if (file == null) return;
// Limit to 10MB
var maxFileSize = 10L * 1024 * 1024;
var stream = file.OpenReadStream(maxFileSize);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var base64 = Convert.ToBase64String(ms.ToArray());
_imageData = $"data:{file.ContentType};base64,{base64}";
_isCropped = false;
}
catch (Exception ex)
{
Console.WriteLine($"Error reading file: {ex.Message}");
}
}
private async Task OnImageLoaded()
{
if (_mediaInterop != null && !string.IsNullOrEmpty(_imageData))
{
// Small delay to ensure render limits are applied by browser
await Task.Delay(50);
await _mediaInterop.InitCropperAsync(_uniqueId, 16.0 / 9.0, _objRef!);
}
}
private async Task CropImage()
{
if (_mediaInterop != null)
{
_croppedData = await _mediaInterop.GetCroppedImageAsync();
await _mediaInterop.DestroyCropperAsync();
_isCropped = true;
}
}
private async Task Reset()
{
if (_mediaInterop != null)
await _mediaInterop.DestroyCropperAsync();
_imageData = null;
_croppedData = null;
_isCropped = false;
}
public async ValueTask DisposeAsync()
{
if (_mediaInterop != null)
{
try { await _mediaInterop.DestroyCropperAsync(); } catch { }
}
_objRef?.Dispose();
}
} *@
+24
View File
@@ -0,0 +1,24 @@
@using Microsoft.AspNetCore.Components.Web
<div class="flex gap-2 w-full mt-4">
<input type="text" @bind="_url" @bind:event="oninput" placeholder="Add YouTube, Vimeo or Image URL..."
class="input input-bordered flex-1" />
<button type="button" class="btn btn-primary" @onclick="AddLink" disabled="@string.IsNullOrWhiteSpace(_url)">
Add Link
</button>
</div>
@code {
[Parameter] public EventCallback<string> OnLinkAdded { get; set; }
private string _url = "";
private async Task AddLink()
{
if (!string.IsNullOrWhiteSpace(_url))
{
await OnLinkAdded.InvokeAsync(_url);
_url = "";
}
}
}
+280
View File
@@ -0,0 +1,280 @@
@inject Microsoft.JSInterop.IJSRuntime JSRuntime
@using Microsoft.JSInterop
@implements IDisposable
<div class="flex flex-col gap-4 w-full h-full border rounded-lg bg-base-100 p-4 shadow-sm relative transition-all">
@if (_showSuccessMessage)
{
<div
class="absolute inset-0 z-50 flex items-center justify-center bg-base-100/90 backdrop-blur-sm rounded-lg animate-fade-in">
<div class="alert alert-success max-w-sm shadow-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Image Cropped Successfully!</span>
</div>
</div>
}
<!-- Section 1.1: Image Viewer / Cropper Area -->
<div class="flex-1 relative bg-base-300 rounded-lg overflow-hidden min-h-[400px] flex items-center justify-center">
@if (_imageDataUrl != null)
{
<!-- Unique ID -->
<img id="@_uniqueId" src="@_imageDataUrl" @onload="OnImageLoaded"
class="max-w-full max-h-[600px] object-contain block" style="display:block;" />
}
else
{
<div class="text-base-content/30 flex flex-col items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-2" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>Select an image to view or crop</span>
</div>
}
</div>
<!-- Section 1.2: Controls -->
<div class="flex flex-wrap gap-4 items-center justify-between border-t border-base-200 pt-4">
<div class="flex gap-2 items-center @(_isDisabled ? "opacity-50 pointer-events-none" : "")">
<span class="font-bold text-sm">Ratio (W:H):</span>
<div class="join">
<input type="number" class="join-item input input-bordered input-sm w-16" placeholder="W" min="1"
@bind="_ratioW" @bind:after="OnRatioInput" />
<span class="join-item btn btn-sm btn-disabled bg-base-200 border-base-300">:</span>
<input type="number" class="join-item input input-bordered input-sm w-16" placeholder="H" min="1"
@bind="_ratioH" @bind:after="OnRatioInput" />
</div>
<button class="btn btn-xs btn-ghost" @onclick="ClearRatio" title="Reset to Free">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div class="divider divider-horizontal mx-0"></div>
<!-- Maximize Buttons -->
<button class="btn btn-xs btn-ghost" @onclick="SetFullWidth" title="Maximize Width">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16" />
</svg>
W
</button>
<button class="btn btn-xs btn-ghost" @onclick="SetFullHeight" title="Maximize Height">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 4v16M16 4v16" />
</svg>
H
</button>
</div>
<div class="flex gap-2">
@if (_isCropping)
{
<button class="btn btn-ghost" @onclick="CancelCropMode">Cancel</button>
<button class="btn btn-primary" @onclick="Save">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Apply Crop
</button>
}
else
{
<button class="btn btn-warning" @onclick="StartCropping" disabled="@_isDisabled">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
Edit / Crop
</button>
}
</div>
</div>
</div>
@code {
[Parameter] public MediaItemModel? Item { get; set; }
[Parameter] public EventCallback<MediaItemModel> OnCropSaved { get; set; }
[Parameter] public EventCallback OnCancel { get; set; } // May act as clearing selection
[Parameter] public double? AspectRatio { get; set; }
[Parameter] public int? TargetWidth { get; set; }
[Parameter] public int? TargetHeight { get; set; }
[Parameter] public string? AiApiKey { get; set; }
private string _uniqueId = "cropper-" + Guid.NewGuid().ToString("N");
// Removed _isOpen as it's always visible
private bool _isCropping = false;
private string? _imageDataUrl;
private MediaJsInterop _interop = default!;
private DotNetObjectReference<ImageCropper>? _objRef;
private double? _ratioW = 16;
private double? _ratioH = 9;
private bool _showSuccessMessage = false;
private bool _isDisabled => Item == null;
protected override void OnInitialized()
{
_interop = new MediaJsInterop(JSRuntime);
_objRef = DotNetObjectReference.Create(this);
}
public async Task OpenAsync(MediaItemModel item)
{
// Close existing if open
try { await _interop.DestroyCropperAsync(); } catch { }
Item = item;
_isCropping = false;
_imageDataUrl = null;
_imageLoaded = false;
StateHasChanged();
if (item.IsLocal && item.File != null && string.IsNullOrEmpty(item.PreviewUrl))
{
// Try to load preview if not ready
await LoadPreviewInternal(item);
}
_imageDataUrl = !string.IsNullOrEmpty(item.PreviewUrl) ? item.PreviewUrl : item.Url;
StateHasChanged();
}
private async Task LoadPreviewInternal(MediaItemModel item)
{
try
{
var maxFileSize = 10L * 1024 * 1024;
var buffer = new byte[item.File.Size];
await item.File.OpenReadStream(maxAllowedSize: maxFileSize).ReadAsync(buffer);
var base64 = Convert.ToBase64String(buffer);
item.PreviewUrl = $"data:{item.File.ContentType};base64,{base64}";
}
catch (Exception ex)
{
Console.WriteLine($"Error reading file: {ex.Message}");
}
}
private bool _imageLoaded = false;
private async Task OnImageLoaded()
{
_imageLoaded = true;
// Optional: Auto start crop? User said "select image ... viewed ... then buttons enabled to start cropping IF you want"
// So we just stay in view mode.
}
private async Task StartCropping()
{
if (_isCropping || Item == null) return;
_isCropping = true;
StateHasChanged();
if (!string.IsNullOrEmpty(_imageDataUrl) && _imageLoaded)
{
await InitCropperInternal();
}
}
private async Task InitCropperInternal()
{
await Task.Delay(50);
// Use input ratio if set, else Free (NaN)
double ratio = double.NaN;
if (_ratioW > 0 && _ratioH > 0)
{
ratio = _ratioW.Value / _ratioH.Value;
}
await _interop.InitCropperAsync(_uniqueId, ratio, _objRef!);
}
private async Task OnRatioInput()
{
if (_ratioW.HasValue && _ratioW < 1) _ratioW = 1;
if (_ratioH.HasValue && _ratioH < 1) _ratioH = 1;
if (_ratioW > 0 && _ratioH > 0)
{
await _interop.SetAspectRatioAsync(_ratioW.Value / _ratioH.Value);
}
else
{
await _interop.SetAspectRatioAsync(double.NaN);
}
}
private async Task ClearRatio()
{
_ratioW = null;
_ratioH = null;
await _interop.SetAspectRatioAsync(double.NaN);
}
private async Task SetFullWidth()
{
await _interop.SetFullWidthAsync();
}
private async Task SetFullHeight()
{
await _interop.SetFullHeightAsync();
}
private async Task Save()
{
var croppedBase64 = await _interop.GetCroppedImageAsync();
if (!string.IsNullOrEmpty(croppedBase64) && Item != null)
{
Item.PreviewUrl = croppedBase64;
// If we want to replace the image in the view immediately:
_imageDataUrl = croppedBase64;
}
await _interop.DestroyCropperAsync();
_isCropping = false;
// Success Feedback
_showSuccessMessage = true;
StateHasChanged();
await OnCropSaved.InvokeAsync(Item);
// Hide message after delay
await Task.Delay(2000);
_showSuccessMessage = false;
StateHasChanged();
}
private async Task CancelCropMode()
{
try
{
await _interop.DestroyCropperAsync();
}
catch { }
_isCropping = false;
StateHasChanged();
}
public void Dispose()
{
_objRef?.Dispose();
}
}
+30
View File
@@ -0,0 +1,30 @@
@using Generic.Media.Components
<MediaUploadContainer @ref="_container" InitialUrls="@InitialUrls" OnChange="@OnChange" MaxItems="@MaxItems"
AspectRatio="@AspectRatio" TargetWidth="@TargetWidth" TargetHeight="@TargetHeight"
SingleUploadMode="@SingleUploadMode" AiModelApiKey="@AiModelApiKey" CdnBaseUrl="@CdnBaseUrl" />
@code {
[Parameter] public List<string> InitialUrls { get; set; } = new();
[Parameter] public EventCallback<List<string>> OnChange { get; set; }
[Parameter] public int MaxItems { get; set; } = 10;
[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 MediaUploadContainer? _container;
public async Task<MediaUploadContainer.UploadResult> ProcessUploadsAsync()
{
if (_container == null) return new MediaUploadContainer.UploadResult();
return await _container.ProcessUploadsAsync();
}
public void Clear()
{
_container?.Clear();
}
}
+49
View File
@@ -0,0 +1,49 @@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Web
<div class="relative border-2 border-dashed border-base-300 rounded-lg text-center bg-base-100 cursor-pointer hover:bg-base-200 hover:border-primary transition-all flex items-center justify-center
@(_isDragOver ? "border-primary bg-blue-50" : "")
@(CompactMode ? "h-full w-full p-2" : "p-8")" @ondragenter="HandleDragEnter" @ondragleave="HandleDragLeave"
@ondragover="HandleDragOver" @ondrop="HandleDrop">
<InputFile OnChange="HandleInputFileChange" multiple accept="image/*,video/*"
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" />
<div class="pointer-events-none grid grid-cols-6 gap-2">
@if (CompactMode)
{
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-base-content/50" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span class="text-xs font-bold text-base-content/60 mt-1">Add Media</span>
}
else
{
<i class="icon-upload text-4xl mb-2 text-base-content/50"></i>
<p class="font-bold text-base-content">Click to upload <span class="font-normal">or drag and drop</span></p>
<span class="text-sm text-base-content/60 mt-1">SVG, PNG, JPG or GIF (max. 800x400px)</span>
}
</div>
</div>
@code {
[Parameter] public EventCallback<InputFileChangeEventArgs> OnFileDropped { get; set; }
[Parameter] public bool CompactMode { get; set; }
private bool _isDragOver = false;
private void HandleDragEnter() => _isDragOver = true;
private void HandleDragLeave() => _isDragOver = false;
private void HandleDragOver(DragEventArgs e) { }
private async Task HandleDrop(DragEventArgs e)
{
_isDragOver = false;
}
private async Task HandleInputFileChange(InputFileChangeEventArgs e)
{
await OnFileDropped.InvokeAsync(e);
}
}
+59
View File
@@ -0,0 +1,59 @@
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Web
<div class="relative card card-compact bg-base-100 shadow-sm border border-base-200 overflow-hidden cursor-grab hover:shadow-md transition-all active:cursor-grabbing h-full"
draggable="true" @ondragstart="@(() => OnDragStart.InvokeAsync(Item))" @ondragend="@OnDragEnd"
@ondragenter="@(() => OnDragEnter.InvokeAsync(Item))">
<div class="h-36 bg-base-200 flex items-center justify-center overflow-hidden relative cursor-pointer group hover:opacity-90 transition-opacity"
@onclick="@(() => OnEdit.InvokeAsync(Item))" title="Click to Edit / View">
@if (Item.Type == MediaType.Image)
{
<img src="@GetPreviewSrc()" alt="@Item.AltText" class="w-full h-full object-cover" />
}
else if (Item.Type == MediaType.Video)
{
<div class="flex flex-col items-center text-base-content/50">
<i class="icon-video text-2xl"></i> <!-- Placeholder icon -->
<span class="text-xs mt-1">Video</span>
</div>
}
else if (Item.Type == MediaType.YouTube || Item.Type == MediaType.Vimeo)
{
<div class="flex flex-col items-center text-base-content/50">
<i class="icon-play text-2xl"></i> <!-- Placeholder icon -->
<span class="text-xs mt-1">External Video</span>
</div>
}
</div>
<div class="absolute top-1 right-1 flex gap-1">
<button class="btn btn-sm btn-circle btn-ghost bg-base-100/90 shadow-sm hover:bg-base-100 text-error"
title="Remove" @onclick="@(() => OnRemove.InvokeAsync(Item))" @onclick:stopPropagation>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="p-2 text-xs text-base-content/70 truncate">
<span>@(Item.File?.Name ?? Item.Url)</span>
</div>
</div>
@code {
[Parameter] public MediaItemModel Item { get; set; }
[Parameter] public EventCallback<MediaItemModel> OnRemove { get; set; }
[Parameter] public EventCallback<MediaItemModel> OnEdit { get; set; }
// Drag parameters
[Parameter] public EventCallback<MediaItemModel> OnDragStart { get; set; }
[Parameter] public EventCallback OnDragEnd { get; set; }
[Parameter] public EventCallback<MediaItemModel> OnDragEnter { get; set; }
private string GetPreviewSrc()
{
// Ideally use generated Blob URL for local files
return Item.Url ?? Item.PreviewUrl ?? "";
}
}
+47
View File
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Components.Forms;
using System;
namespace Generic.Media.Components
{
public class MediaItemModel
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string? Url { get; set; }
public IBrowserFile? File { get; set; }
public MediaType Type { get; set; }
public string? AltText { get; set; }
public int Order { get; set; }
public bool IsLocal { get; set; }
public string? PreviewUrl { get; set; } // Blob URL for local files
}
public enum MediaType
{
Image,
Video,
YouTube,
Vimeo,
Unknown
}
public static class MediaHelper
{
public static MediaType GetType(string url)
{
if (string.IsNullOrWhiteSpace(url)) return MediaType.Image;
url = url.ToLower();
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") || url.EndsWith(".ogg")) return MediaType.Video;
return MediaType.Image;
}
public static string GetYouTubeId(string url)
{
if (url.Contains("youtu.be/")) return url.Split("youtu.be/")[1].Split("?")[0];
if (url.Contains("v=")) return url.Split("v=")[1].Split("&")[0];
return "";
}
}
}
+347
View File
@@ -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; }
}
}
+57
View File
@@ -0,0 +1,57 @@
@using Microsoft.AspNetCore.Components
@using System.Collections.Generic
@using Microsoft.AspNetCore.Components.Web
<div class="grid grid-cols-[repeat(auto-fill,minmax(110px,1fr))] gap-3 py-4">
@foreach (var item in Items)
{
<MediaItemCard Item="@item" OnRemove="@OnRemove" OnEdit="@OnEdit" OnDragStart="@HandleDragStart"
OnDragEnter="@HandleDragEnter" OnDragEnd="@HandleDragEnd" @key="item.Id" />
<!-- Key is crucial for correct blazor diffing during reorder -->
}
@if (AppendContent != null)
{
<div class="h-full min-h-[9rem]"> <!-- Match Card Height logic -->
@AppendContent
</div>
}
</div>
@code {
[Parameter] public List<MediaItemModel> Items { get; set; }
[Parameter] public EventCallback<MediaItemModel> OnRemove { get; set; }
[Parameter] public EventCallback<MediaItemModel> OnEdit { get; set; }
[Parameter] public EventCallback<List<MediaItemModel>> OnSort { get; set; }
[Parameter] public RenderFragment? AppendContent { get; set; }
private MediaItemModel? _dragPayload;
private void HandleDragStart(MediaItemModel item)
{
_dragPayload = item;
}
private void HandleDragEnd()
{
_dragPayload = null;
}
private void HandleDragEnter(MediaItemModel targetItem)
{
if (_dragPayload == null || targetItem == _dragPayload) return;
var oldIndex = Items.IndexOf(_dragPayload);
var newIndex = Items.IndexOf(targetItem);
if (oldIndex != -1 && newIndex != -1)
{
// Remove and insert
Items.RemoveAt(oldIndex);
Items.Insert(newIndex, _dragPayload);
// Notify parent
OnSort.InvokeAsync(Items);
}
}
}