feat(halloffame): implement image upload and enhance module functionality

- Added image upload system (JPG/PNG, max 5MB) with live preview and removal option
- Fixed Concurrency Exception during deletion (split transactions for reports and entries)
- Optimized card layout: consistent height and height-based truncation for descriptions
- Added sort direction toggle (Ascending/Descending) with arrow icons for Date, Name, and Year
- Refactored HallOfFameService to use streams for Server/Wasm compatibility
- Improved error handling and UI feedback for upload and delete operations
This commit is contained in:
Adam Gaiswinkler
2026-02-10 17:45:48 +01:00
parent 2d8c6736a7
commit 1bff5ebbbd
18 changed files with 956 additions and 127 deletions

View File

@@ -1,6 +1,7 @@
@using Oqtane.Modules.Controls
@using SZUAbsolventenverein.Module.HallOfFame.Services
@using SZUAbsolventenverein.Module.HallOfFame.Models
@using Microsoft.AspNetCore.Components.Forms
@namespace SZUAbsolventenverein.Module.HallOfFame
@inherits ModuleBase
@@ -20,21 +21,47 @@
<div class="row mb-3 align-items-center">
<Label Class="col-sm-3 col-form-label" For="year" HelpText="Jahrgang (z.B. 2020)" ResourceKey="Year">Jahrgang: </Label>
<div class="col-sm-9">
<input id="year" type="number" class="form-control" @bind="@_year" required min="1990" max="2100" />
<input id="year" type="number" class="form-control" @bind="@_year" required min="1900" max="@Int32.MaxValue" />
<div class="invalid-feedback">Bitte gib einen gültigen Jahrgang ein.</div>
</div>
</div>
<div class="row mb-3 align-items-center">
<Label Class="col-sm-3 col-form-label" For="description" HelpText="Kurzbeschreibung / Werdegang" ResourceKey="Description">Beschreibung: </Label>
<div class="col-sm-9">
<textarea id="description" class="form-control" @bind="@_description" required rows="5" maxlength="1500"></textarea>
<textarea id="description" class="form-control" @bind="@_description" required rows="5" maxlength="500"></textarea>
<div class="text-muted small">@(_description?.Length ?? 0) / 500 Zeichen</div>
<div class="invalid-feedback">Bitte gib eine Beschreibung ein.</div>
</div>
</div>
<div class="row mb-3 align-items-center">
<Label Class="col-sm-3 col-form-label" For="image" HelpText="Bild URL (optional)" ResourceKey="Image">Bild URL: </Label>
<div class="row mb-3">
<Label Class="col-sm-3 col-form-label" For="image" HelpText="Porträtfoto hochladen (JPG/PNG)" ResourceKey="Image">Foto: </Label>
<div class="col-sm-9">
<input id="image" class="form-control" @bind="@_image" />
<div class="mb-2">
@if (!string.IsNullOrEmpty(_image))
{
<div class="position-relative d-inline-block">
<img src="@_image" style="max-height: 150px; border-radius: 8px; margin-bottom: 10px; border: 1px solid #ddd;" alt="Vorschau" />
<button type="button" class="btn btn-sm btn-danger" style="position: absolute; top: 5px; right: 5px;" @onclick="RemoveImage" title="Bild entfernen">
<i class="oi oi-x"></i>
</button>
</div>
}
else
{
<div class="bg-light d-flex align-items-center justify-content-center text-muted" style="height: 150px; width: 150px; border: 2px dashed #ddd; border-radius: 8px;">
Kein Bild
</div>
}
</div>
<InputFile OnChange="HandleFileSelected" class="form-control" accept=".jpg,.jpeg,.png" />
<div class="text-muted small mt-1">Nur JPG oder PNG, max. 5 MB.</div>
@if (_uploading)
{
<div class="spinner-border spinner-border-sm text-primary mt-2" role="status">
<span class="visually-hidden">Wird hochgeladen...</span>
</div>
<span class="small text-primary ms-1">Wird hochgeladen...</span>
}
</div>
</div>
<div class="row mb-3 align-items-center">
@@ -53,8 +80,8 @@
</div>
<div class="mt-4">
<button type="button" class="btn btn-secondary me-2" @onclick="@(() => Save("Draft"))">Als Entwurf speichern</button>
<button type="button" class="btn btn-primary" @onclick="@(() => Save("Published"))">Veröffentlichen</button>
<button type="button" class="btn btn-secondary me-2" @onclick="@(() => Save("Draft"))" disabled="@_uploading">Als Entwurf speichern</button>
<button type="button" class="btn btn-primary" @onclick="@(() => Save("Published"))" disabled="@_uploading">Veröffentlichen</button>
<NavLink class="btn btn-link ms-2" href="@NavigateUrl()">Abbrechen</NavLink>
</div>
@@ -79,6 +106,7 @@
private ElementReference form;
private bool validated = false;
private bool _uploading = false;
private int _id;
private string _name;
@@ -102,7 +130,6 @@
_id = Int32.Parse(PageState.QueryString["id"]);
HallOfFame HallOfFame = await HallOfFameService.GetHallOfFameAsync(_id, ModuleState.ModuleId);
// Security check: only allow editing own entry
if (HallOfFame != null)
{
if (HallOfFame.UserId != PageState.User.UserId)
@@ -126,11 +153,9 @@
}
else // Add Mode
{
// Check if user already has an entry to prevent duplicates
var existing = await HallOfFameService.GetHallOfFameByUserIdAsync(PageState.User.UserId, ModuleState.ModuleId);
if (existing != null)
{
// Use NavigateUrl with parameters properly (simplified here)
NavigationManager.NavigateTo(EditUrl(existing.HallOfFameId.ToString()));
}
}
@@ -142,6 +167,50 @@
}
}
private async Task HandleFileSelected(InputFileChangeEventArgs e)
{
var file = e.File;
if (file == null) return;
if (file.Size > 5 * 1024 * 1024)
{
AddModuleMessage("Die Datei ist zu groß (max. 5 MB allowed).", MessageType.Warning);
return;
}
try
{
_uploading = true;
using var stream = file.OpenReadStream(5 * 1024 * 1024);
var url = await HallOfFameService.UploadFileAsync(stream, file.Name, ModuleState.ModuleId);
if (!string.IsNullOrEmpty(url))
{
_image = url;
AddModuleMessage("Foto erfolgreich hochgeladen.", MessageType.Success);
}
else
{
AddModuleMessage("Fehler beim Hochladen des Fotos.", MessageType.Error);
}
}
catch (Exception ex)
{
await logger.LogError(ex, "Error Uploading File {Error}", ex.Message);
AddModuleMessage("Ein technischer Fehler ist beim Upload aufgetreten.", MessageType.Error);
}
finally
{
_uploading = false;
StateHasChanged();
}
}
private void RemoveImage()
{
_image = string.Empty;
StateHasChanged();
}
private async Task Save(string status)
{
try
@@ -156,7 +225,7 @@
{
HallOfFame HallOfFame = new HallOfFame();
HallOfFame.ModuleId = ModuleState.ModuleId;
HallOfFame.UserId = PageState.User.UserId; // Set Owner
HallOfFame.UserId = PageState.User.UserId;
HallOfFame.Name = _name;
HallOfFame.Year = _year;
HallOfFame.Description = _description;
@@ -170,7 +239,6 @@
else
{
HallOfFame HallOfFame = await HallOfFameService.GetHallOfFameAsync(_id, ModuleState.ModuleId);
// Ensure we don't overwrite with invalid user logic, though server checks too
if (HallOfFame.UserId == PageState.User.UserId)
{
HallOfFame.Name = _name;