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,5 +1,7 @@
@using SZUAbsolventenverein.Module.HallOfFame.Services
@using SZUAbsolventenverein.Module.HallOfFame.Models
@using Oqtane.Security
@using Oqtane.Shared
@namespace SZUAbsolventenverein.Module.HallOfFame
@inherits ModuleBase
@@ -13,30 +15,44 @@
}
else
{
<div class="row mb-4">
<div class="col text-end">
<div class="row mb-4 align-items-center">
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text"><i class="oi oi-magnifying-glass"></i></span>
<input type="text" class="form-control" placeholder="Suchen nach Namen oder Begriffen..." @bind="_searchText" @bind:event="oninput" />
</div>
</div>
<div class="col-md-3">
<div class="input-group">
<select class="form-select" @bind="_sortOption">
<option value="CreatedOn">Datum</option>
<option value="Name">Name</option>
<option value="Year">Jahrgang</option>
</select>
<button class="btn btn-outline-secondary" type="button" @onclick="ToggleSortDirection" title="@(_sortAscending ? "Aufsteigend" : "Absteigend")">
<i class="oi @(_sortAscending ? "oi-arrow-top" : "oi-arrow-bottom")"></i>
</button>
</div>
</div>
<div class="col-md-3 text-end">
@if (PageState.User != null)
{
if (_myEntry != null)
{
<ActionLink Action="Edit" Parameters="@($"id=" + _myEntry.HallOfFameId.ToString())" Text="Hall-of-Fame-Eintrag bearbeiten" />
<ActionLink Action="Edit" Parameters="@($"id=" + _myEntry.HallOfFameId.ToString())" Text="Mein Eintrag" />
}
else
{
<ActionLink Action="Add" Text="Neuen Hall-of-Fame-Eintrag erstellen" />
<ActionLink Action="Add" Text="Eintragen" />
}
}
else
{
<p class="text-muted">Einloggen, um einen Eintrag zu erstellen.</p>
}
</div>
</div>
@if (@_HallOfFames.Count != 0)
{
<div class="row">
@foreach (var item in _HallOfFames)
@foreach (var item in FilteredHallOfFames)
{
<div class="col-md-4 mb-3">
<div class="card h-100">
@@ -44,13 +60,35 @@ else
{
<img src="@item.Image" class="card-img-top" alt="@item.Name" style="max-height: 200px; object-fit: cover;">
}
<div class="card-body">
<div class="card-body d-flex flex-column">
<h5 class="card-title">@item.Name (@item.Year)</h5>
<p class="card-text">@item.Description</p>
@if (!string.IsNullOrEmpty(item.Link))
@if (item.IsReported && UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin + ";" + RoleNames.Host))
{
<a href="@item.Link" target="_blank" class="btn btn-sm btn-outline-primary">Mehr Infos</a>
<div class="alert alert-danger p-1 mb-2" style="font-size: 0.8rem;">
<i class="oi oi-warning me-1"></i> <strong>Dieser Eintrag wurde gemeldet!</strong>
</div>
}
<div class="hof-description-container" style="
max-height: 150px;
overflow: hidden;
position: relative;
flex-grow: 1;
margin-bottom: 0.5rem;
">
@foreach (var line in (item.Description?.Replace("\t", " ").Split('\n') ?? Array.Empty<string>()))
{
<div class="hof-description-line">@line</div>
}
</div>
<div class="d-flex justify-content-between align-items-center">
<ActionLink Action="Details" Parameters="@($"id=" + item.HallOfFameId.ToString())" Class="btn btn-sm btn-outline-primary" Text="Details ansehen" />
<div>
@if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin + ";" + RoleNames.Host))
{
<button class="btn btn-sm btn-outline-danger me-1" @onclick="@(() => Delete(item.HallOfFameId))" title="Löschen"><i class="oi oi-trash"></i></button>
}
</div>
</div>
</div>
</div>
</div>
@@ -66,7 +104,6 @@ else
}
@code {
public override string RenderMode => RenderModes.Static;
public override List<Resource> Resources => new List<Resource>()
{
@@ -76,8 +113,53 @@ else
List<HallOfFame> _HallOfFames;
HallOfFame _myEntry;
string _searchText = "";
string _sortOption = "CreatedOn";
bool _sortAscending = false;
IEnumerable<HallOfFame> FilteredHallOfFames
{
get
{
var items = _HallOfFames.AsEnumerable();
if (!string.IsNullOrEmpty(_searchText))
{
items = items.Where(i =>
(i.Name?.Contains(_searchText, StringComparison.OrdinalIgnoreCase) ?? false) ||
(i.Description?.Contains(_searchText, StringComparison.OrdinalIgnoreCase) ?? false)
);
}
items = _sortOption switch
{
"Name" => _sortAscending ? items.OrderBy(i => i.Name) : items.OrderByDescending(i => i.Name),
"Year" => _sortAscending ? items.OrderBy(i => i.Year) : items.OrderByDescending(i => i.Year),
"CreatedOn" or _ => _sortAscending ? items.OrderBy(i => i.CreatedOn) : items.OrderByDescending(i => i.CreatedOn)
};
return items;
}
}
private string TruncateDescription(string description)
{
if (string.IsNullOrEmpty(description)) return "";
const int maxLength = 150;
if (description.Length <= maxLength) return description;
return description.Substring(0, maxLength).TrimEnd() + "...";
}
private void ToggleSortDirection()
{
_sortAscending = !_sortAscending;
}
protected override async Task OnInitializedAsync()
{
await LoadData();
}
private async Task LoadData()
{
try
{
@@ -94,4 +176,19 @@ else
AddModuleMessage(Localizer["Message.LoadError"], MessageType.Error);
}
}
private async Task Delete(int hallOfFameId)
{
try
{
await HallOfFameService.DeleteHallOfFameAsync(hallOfFameId, ModuleState.ModuleId);
AddModuleMessage("Eintrag wurde gelöscht.", MessageType.Success);
await LoadData();
}
catch (Exception ex)
{
await logger.LogError(ex, "Error Deleting HallOfFame {Error}", ex.Message);
AddModuleMessage("Fehler beim Löschen des Eintrags.", MessageType.Error);
}
}
}