Initial commit - Media.RCL
This commit is contained in:
@@ -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();
|
||||
}
|
||||
} *@
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user