feat: Rich-Text-Editor, Bild-Skalierung, PDF-Fix & Zeichenlimit
- Bild-Skalierung in Kartenansicht gefixt (object-position: top, 300px) - Admin-Slider für Zeichenlimit (4–32.000) als Modul-Setting - Textarea durch RichTextEditor (Quill.js) ersetzt - PDF: HTML-Parsing, Einzelperson-Filter, Autorisierung für alle User
This commit is contained in:
@@ -75,10 +75,7 @@ else
|
||||
</div>
|
||||
}
|
||||
<div class="lead text-dark mb-4" style="line-height: 1.7; font-size: 1.1rem;">
|
||||
@foreach (var line in (_item.Description?.Replace("\t", " ").Split('\n') ?? Array.Empty<string>()))
|
||||
{
|
||||
<div class="hof-description-line">@line</div>
|
||||
}
|
||||
@((MarkupString)(_item.Description ?? ""))
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -250,9 +247,16 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private string GetPdfApiBase()
|
||||
{
|
||||
var aliasPath = PageState.Alias.Path;
|
||||
var prefix = !string.IsNullOrEmpty(aliasPath) ? $"/{aliasPath}" : "";
|
||||
return $"{prefix}/api/HallOfFamePdf";
|
||||
}
|
||||
|
||||
private void ShowPdfPreview()
|
||||
{
|
||||
_pdfPreviewUrl = $"/api/HallOfFamePdf?moduleid={ModuleState.ModuleId}";
|
||||
_pdfPreviewUrl = $"{GetPdfApiBase()}?moduleid={ModuleState.ModuleId}&id={_id}&authmoduleid={ModuleState.ModuleId}";
|
||||
_showPdfModal = true;
|
||||
}
|
||||
|
||||
@@ -264,8 +268,8 @@ else
|
||||
|
||||
private async Task DownloadPdf()
|
||||
{
|
||||
var url = $"/api/HallOfFamePdf?moduleid={ModuleState.ModuleId}&download=true";
|
||||
await JSRuntime.InvokeVoidAsync("eval", $"var a = document.createElement('a'); a.href = '{url}'; a.download = 'HallOfFame.pdf'; document.body.appendChild(a); a.click(); document.body.removeChild(a);");
|
||||
var url = $"{GetPdfApiBase()}?moduleid={ModuleState.ModuleId}&id={_id}&download=true&authmoduleid={ModuleState.ModuleId}";
|
||||
await JSRuntime.InvokeVoidAsync("eval", $"var a = document.createElement('a'); a.href = '{url}'; a.download = 'HallOfFame_{_item?.Name ?? "export"}.pdf'; document.body.appendChild(a); a.click(); document.body.removeChild(a);");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,47 +8,60 @@
|
||||
@inject IHallOfFameService HallOfFameService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IStringLocalizer<Edit> Localizer
|
||||
@inject ISettingService SettingService
|
||||
|
||||
<form @ref="form" class="@(validated ? " was-validated" : "needs-validation" )" novalidate>
|
||||
<div class="container">
|
||||
<div class="row mb-3 align-items-center">
|
||||
<Label Class="col-sm-3 col-form-label" For="name" HelpText="Gib deinen Namen ein" ResourceKey="Name">Name: </Label>
|
||||
<Label Class="col-sm-3 col-form-label" For="name" HelpText="Gib deinen Namen ein" ResourceKey="Name">Name:
|
||||
</Label>
|
||||
<div class="col-sm-9">
|
||||
<input id="name" class="form-control" @bind="@_name" required maxlength="120" />
|
||||
<div class="invalid-feedback">Bitte gib einen Namen ein (max. 120 Zeichen).</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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="1900" max="@Int32.MaxValue" />
|
||||
<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="row mb-3">
|
||||
<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="500"></textarea>
|
||||
<div class="text-muted small">@(_description?.Length ?? 0) / 500 Zeichen</div>
|
||||
<div class="invalid-feedback">Bitte gib eine Beschreibung ein.</div>
|
||||
@if (_descriptionLoaded)
|
||||
{
|
||||
<RichTextEditor Content="@_description" @ref="@_richTextEditorRef"
|
||||
Placeholder="Beschreibe deinen Werdegang..."></RichTextEditor>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<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="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">
|
||||
<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">
|
||||
<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;">
|
||||
<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>
|
||||
}
|
||||
@@ -65,24 +78,27 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3 align-items-center">
|
||||
<Label Class="col-sm-3 col-form-label" For="link" HelpText="Externer Link (optional)" ResourceKey="Link">Link: </Label>
|
||||
<Label Class="col-sm-3 col-form-label" For="link" HelpText="Externer Link (optional)"
|
||||
ResourceKey="Link">Link: </Label>
|
||||
<div class="col-sm-9">
|
||||
<input id="link" type="url" class="form-control" @bind="@_link" placeholder="https://" />
|
||||
<div class="invalid-feedback">Bitte gib eine gültige URL ein (startet mit http:// oder https://).</div>
|
||||
<div class="invalid-feedback">Bitte gib eine gültige URL ein (startet mit http:// oder https://).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3 align-items-center">
|
||||
<div class="row mb-3 align-items-center">
|
||||
<Label Class="col-sm-3 col-form-label" For="status" HelpText="Status" ResourceKey="Status">Status: </Label>
|
||||
<div class="col-sm-9">
|
||||
<p>Aktuell: <strong>@(_status ?? "Neu")</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-4 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
@if (PageState.Action == "Edit")
|
||||
@@ -96,7 +112,8 @@
|
||||
<br /><br />
|
||||
@if (PageState.Action == "Edit")
|
||||
{
|
||||
<AuditInfo CreatedBy="@_createdby" CreatedOn="@_createdon" ModifiedBy="@_modifiedby" ModifiedOn="@_modifiedon"></AuditInfo>
|
||||
<AuditInfo CreatedBy="@_createdby" CreatedOn="@_createdon" ModifiedBy="@_modifiedby" ModifiedOn="@_modifiedon">
|
||||
</AuditInfo>
|
||||
}
|
||||
</form>
|
||||
|
||||
@@ -115,6 +132,8 @@
|
||||
private ElementReference form;
|
||||
private bool validated = false;
|
||||
private bool _uploading = false;
|
||||
private RichTextEditor _richTextEditorRef;
|
||||
private bool _descriptionLoaded = false;
|
||||
|
||||
private int _id;
|
||||
private string _name;
|
||||
@@ -128,22 +147,33 @@
|
||||
private DateTime _createdon;
|
||||
private string _modifiedby;
|
||||
private DateTime _modifiedon;
|
||||
private int _charLimit = 500;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Load character limit setting
|
||||
var settings = await SettingService.GetModuleSettingsAsync(ModuleState.ModuleId);
|
||||
var charLimitStr = SettingService.GetSetting(settings, "CharLimit", "500");
|
||||
if (int.TryParse(charLimitStr, out var parsed) && parsed >= 4 && parsed <= 32000)
|
||||
{
|
||||
_charLimit = parsed;
|
||||
}
|
||||
|
||||
_descriptionLoaded = false;
|
||||
|
||||
if (PageState.Action == "Edit")
|
||||
{
|
||||
_id = Int32.Parse(PageState.QueryString["id"]);
|
||||
HallOfFame HallOfFame = await HallOfFameService.GetHallOfFameAsync(_id, ModuleState.ModuleId);
|
||||
|
||||
|
||||
if (HallOfFame != null)
|
||||
{
|
||||
if (HallOfFame.UserId != PageState.User.UserId)
|
||||
if (HallOfFame.UserId != PageState.User.UserId)
|
||||
{
|
||||
NavigationManager.NavigateTo(NavigateUrl());
|
||||
return;
|
||||
NavigationManager.NavigateTo(NavigateUrl());
|
||||
return;
|
||||
}
|
||||
|
||||
_name = HallOfFame.Name;
|
||||
@@ -152,7 +182,7 @@
|
||||
_image = HallOfFame.Image;
|
||||
_link = HallOfFame.Link;
|
||||
_status = HallOfFame.Status;
|
||||
|
||||
|
||||
_createdby = HallOfFame.CreatedBy;
|
||||
_createdon = HallOfFame.CreatedOn;
|
||||
_modifiedby = HallOfFame.ModifiedBy;
|
||||
@@ -161,12 +191,13 @@
|
||||
}
|
||||
else // Add Mode
|
||||
{
|
||||
var existing = await HallOfFameService.GetHallOfFameByUserIdAsync(PageState.User.UserId, ModuleState.ModuleId);
|
||||
if (existing != null)
|
||||
{
|
||||
NavigationManager.NavigateTo(EditUrl(existing.HallOfFameId.ToString()));
|
||||
}
|
||||
var existing = await HallOfFameService.GetHallOfFameByUserIdAsync(PageState.User.UserId, ModuleState.ModuleId);
|
||||
if (existing != null)
|
||||
{
|
||||
NavigationManager.NavigateTo(EditUrl(existing.HallOfFameId.ToString()));
|
||||
}
|
||||
}
|
||||
_descriptionLoaded = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -224,6 +255,12 @@
|
||||
try
|
||||
{
|
||||
validated = true;
|
||||
// Get the HTML content from the rich text editor
|
||||
if (_richTextEditorRef != null)
|
||||
{
|
||||
_description = await _richTextEditorRef.GetHtml();
|
||||
}
|
||||
|
||||
var interop = new Oqtane.UI.Interop(JSRuntime);
|
||||
if (await interop.FormValid(form))
|
||||
{
|
||||
@@ -247,7 +284,7 @@
|
||||
else
|
||||
{
|
||||
HallOfFame HallOfFame = await HallOfFameService.GetHallOfFameAsync(_id, ModuleState.ModuleId);
|
||||
if (HallOfFame.UserId == PageState.User.UserId)
|
||||
if (HallOfFame.UserId == PageState.User.UserId)
|
||||
{
|
||||
HallOfFame.Name = _name;
|
||||
HallOfFame.Year = _year;
|
||||
@@ -255,7 +292,7 @@
|
||||
HallOfFame.Image = _image;
|
||||
HallOfFame.Link = _link;
|
||||
HallOfFame.Status = _status;
|
||||
|
||||
|
||||
await HallOfFameService.UpdateHallOfFameAsync(HallOfFame);
|
||||
await logger.LogInformation("HallOfFame Updated {HallOfFame}", HallOfFame);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@inject IHallOfFameService HallOfFameService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IStringLocalizer<Index> Localizer
|
||||
@inject ISettingService SettingService
|
||||
|
||||
@if (_HallOfFames == null)
|
||||
{
|
||||
@@ -16,53 +17,79 @@
|
||||
else
|
||||
{
|
||||
<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 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 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">
|
||||
<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 class="col-md-3 text-end">
|
||||
@if (PageState.User != null)
|
||||
</div>
|
||||
<div class="col-md-3 text-end">
|
||||
@if (PageState.User != null)
|
||||
{
|
||||
if (_myEntry != null)
|
||||
{
|
||||
<ActionLink Action="Edit" Parameters="@($"id=" + _myEntry.HallOfFameId.ToString())" Text="Mein Eintrag" />
|
||||
<ActionLink Action="Edit" Parameters="@($"id=" + _myEntry.HallOfFameId.ToString())" Text="Mein Eintrag" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<ActionLink Action="Add" Text="Eintragen" />
|
||||
<ActionLink Action="Add" Text="Eintragen" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin + ";" + RoleNames.Host))
|
||||
{
|
||||
<div class="row mb-3 align-items-center">
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<label class="form-label mb-0 text-nowrap" style="font-size: 0.85rem;"><i
|
||||
class="oi oi-pencil me-1"></i>Zeichenlimit:</label>
|
||||
<input type="range" class="form-range flex-grow-1" min="4" max="32000" step="1" value="@_charLimit"
|
||||
@oninput="OnCharLimitChanged" style="min-width: 200px;" />
|
||||
<input type="number" class="form-control form-control-sm" style="width: 90px;" min="4" max="32000"
|
||||
value="@_charLimit" @onchange="OnCharLimitInputChanged" />
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="SaveCharLimit" title="Zeichenlimit speichern">
|
||||
<i class="oi oi-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 text-muted" style="font-size: 0.8rem;">
|
||||
Maximale Zeichen für Beschreibungen
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (@_HallOfFames.Count != 0)
|
||||
{
|
||||
<div class="row">
|
||||
@foreach (var item in FilteredHallOfFames)
|
||||
@foreach (var item in FilteredHallOfFames)
|
||||
{
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card h-100">
|
||||
@if (!string.IsNullOrEmpty(item.Image))
|
||||
{
|
||||
<img src="@item.Image" class="card-img-top" alt="@item.Name" style="max-height: 200px; object-fit: cover;">
|
||||
<img src="@item.Image" class="card-img-top" alt="@item.Name"
|
||||
style="height: 300px; object-fit: cover; object-position: top;">
|
||||
}
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h5 class="card-title">@item.Name (@item.Year)</h5>
|
||||
@if (item.IsReported && UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin + ";" + RoleNames.Host))
|
||||
@if (item.IsReported && UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin + ";" +
|
||||
RoleNames.Host))
|
||||
{
|
||||
<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>
|
||||
@@ -75,17 +102,16 @@ else
|
||||
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>
|
||||
}
|
||||
@((MarkupString)(item.Description ?? ""))
|
||||
</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" />
|
||||
<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>
|
||||
<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>
|
||||
@@ -98,24 +124,25 @@ else
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
Es sind noch keine Hall-of-Fame-Einträge veröffentlicht.
|
||||
</div>
|
||||
Es sind noch keine Hall-of-Fame-Einträge veröffentlicht.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
public override List<Resource> Resources => new List<Resource>()
|
||||
{
|
||||
new Stylesheet("_content/SZUAbsolventenverein.Module.HallOfFame/Module.css"),
|
||||
new Script("_content/SZUAbsolventenverein.Module.HallOfFame/Module.js")
|
||||
};
|
||||
{
|
||||
new Stylesheet("_content/SZUAbsolventenverein.Module.HallOfFame/Module.css"),
|
||||
new Script("_content/SZUAbsolventenverein.Module.HallOfFame/Module.js")
|
||||
};
|
||||
|
||||
List<HallOfFame> _HallOfFames;
|
||||
HallOfFame _myEntry;
|
||||
string _searchText = "";
|
||||
string _sortOption = "CreatedOn";
|
||||
bool _sortAscending = false;
|
||||
int _charLimit = 500;
|
||||
|
||||
IEnumerable<HallOfFame> FilteredHallOfFames
|
||||
{
|
||||
@@ -124,19 +151,19 @@ else
|
||||
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 = 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;
|
||||
}
|
||||
}
|
||||
@@ -165,11 +192,19 @@ else
|
||||
{
|
||||
var allEntries = await HallOfFameService.GetHallOfFamesAsync(ModuleState.ModuleId);
|
||||
_HallOfFames = allEntries.Where(i => i.Status == "Published").ToList();
|
||||
|
||||
|
||||
if (PageState.User != null)
|
||||
{
|
||||
_myEntry = await HallOfFameService.GetHallOfFameByUserIdAsync(PageState.User.UserId, ModuleState.ModuleId);
|
||||
}
|
||||
|
||||
// Load character limit setting
|
||||
var settings = await SettingService.GetModuleSettingsAsync(ModuleState.ModuleId);
|
||||
var charLimitStr = SettingService.GetSetting(settings, "CharLimit", "500");
|
||||
if (int.TryParse(charLimitStr, out var parsed) && parsed >= 4 && parsed <= 32000)
|
||||
{
|
||||
_charLimit = parsed;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -178,6 +213,38 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCharLimitChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (int.TryParse(e.Value?.ToString(), out var val) && val >= 4 && val <= 32000)
|
||||
{
|
||||
_charLimit = val;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCharLimitInputChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (int.TryParse(e.Value?.ToString(), out var val))
|
||||
{
|
||||
_charLimit = Math.Clamp(val, 4, 32000);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveCharLimit()
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = await SettingService.GetModuleSettingsAsync(ModuleState.ModuleId);
|
||||
SettingService.SetSetting(settings, "CharLimit", _charLimit.ToString());
|
||||
await SettingService.UpdateModuleSettingsAsync(settings, ModuleState.ModuleId);
|
||||
AddModuleMessage($"Zeichenlimit auf {_charLimit} gesetzt.", MessageType.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await logger.LogError(ex, "Error Saving CharLimit {Error}", ex.Message);
|
||||
AddModuleMessage("Fehler beim Speichern des Zeichenlimits.", MessageType.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Delete(int hallOfFameId)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# PDF-Design: Modern Glassmorphism Layout
|
||||
|
||||
## Design-Konzept
|
||||
|
||||
Das PDF-Layout verwendet ein **modernes Glassmorphism-Design** — inspiriert von Apple Keynote-Slides und Behance Case Studies. Jede Seite besteht aus einem vollflächigen Hintergrundbild mit zwei **schwebenden Glass-Cards** (Titel oben, Beschreibung unten).
|
||||
|
||||
## Aufbau (Layer-System)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ LAYER 1: Vollbild-Hintergrund │ ← Edge-to-Edge Bild
|
||||
│ │
|
||||
│ LAYER 2: Oberes Dark-Overlay │ ← Kontrast für Titelkarte
|
||||
│ ┌───────────────────────────┐ │
|
||||
│ │ ███ TITELKARTE (Glass) ███ │ ← Name + Jahr
|
||||
│ └───────────────────────────┘ │
|
||||
│ │
|
||||
│ (Bild sichtbar) │
|
||||
│ │
|
||||
│ LAYER 3: Unteres Dark-Overlay │ ← Kontrast für Beschreibung
|
||||
│ ┌───────────────────────────┐ │
|
||||
│ │ ███ BESCHREIBUNG (Glass) ██ │ ← Bio-Text
|
||||
│ └───────────────────────────┘ │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Glassmorphism-Technik (Build-Safe)
|
||||
|
||||
Jede Karte nutzt die `Decoration` API mit mehrfachen `.Before()` Layern:
|
||||
|
||||
```csharp
|
||||
.Decoration(card =>
|
||||
{
|
||||
// 1. Weicher Schatten (sehr dezent, außen)
|
||||
card.Before()
|
||||
.Border(4f).BorderColor("#18000000")
|
||||
.CornerRadius(20);
|
||||
|
||||
// 2. Lichtkante (innerer Glas-Rim)
|
||||
card.Before()
|
||||
.Border(1f).BorderColor("#44FFFFFF")
|
||||
.CornerRadius(20);
|
||||
|
||||
// 3. Halbtransparenter dunkler Hintergrund
|
||||
card.Before()
|
||||
.Background("#C8181828")
|
||||
.CornerRadius(20);
|
||||
|
||||
// 4. Inhalt (Text)
|
||||
card.Content()
|
||||
.PaddingVertical(28)
|
||||
.PaddingHorizontal(36)
|
||||
.Column(inner => { /* ... */ });
|
||||
});
|
||||
```
|
||||
|
||||
## Design-Details
|
||||
|
||||
| Element | Stil |
|
||||
|---------|------|
|
||||
| **Name** | 36pt, ExtraBold, Uppercase, Weiß, Letter-Spacing 0.5 |
|
||||
| **Trennlinie** | 1.5pt, halbtransparent Weiß (#55FFFFFF) |
|
||||
| **Jahrgang** | 15pt, gedämpft (#CCFFFFFF), Letter-Spacing 1.5 |
|
||||
| **Beschreibungs-Header** | 14pt, SemiBold, gedämpft, Letter-Spacing 2 |
|
||||
| **Beschreibungstext** | 11pt, 1.5 Zeilenhöhe, leicht gedämpft (#E8FFFFFF) |
|
||||
| **Titelkarte Radius** | 20px |
|
||||
| **Beschreibungskarte Radius** | 16px |
|
||||
| **Overlay oben** | 220pt Höhe, #99000000 |
|
||||
| **Overlay unten** | 280pt Höhe, #AA000000 |
|
||||
| **Seiten-Padding** | 40pt rundum |
|
||||
|
||||
## Vorteile dieses Ansatzes
|
||||
|
||||
- ✅ **100% Build-Safe**: Keine SkiaSharp-Abhängigkeit, rein QuestPDF Fluent API
|
||||
- ✅ **Stabil auf jedem Bild**: Kontrast-Overlays garantieren Lesbarkeit
|
||||
- ✅ **Visuell hochwertig**: Glasskarte + Tiefe + Typografie-Hierarchie
|
||||
- ✅ **Strukturell unverändert**: Name oben, Beschreibung unten, Bild vollflächig
|
||||
|
||||
## Build-Befehl
|
||||
|
||||
```bash
|
||||
dotnet build Server/SZUAbsolventenverein.Module.HallOfFame.Server.csproj
|
||||
```
|
||||
|
||||
Letzte erfolgreiche Kompilierung: 19. Februar 2026 — 0 Fehler, 0 Warnungen.
|
||||
@@ -8,7 +8,10 @@ using Oqtane.Infrastructure;
|
||||
using Oqtane.Controllers;
|
||||
using SZUAbsolventenverein.Module.HallOfFame.Services;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Net;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
@@ -27,10 +30,10 @@ namespace SZUAbsolventenverein.Module.HallOfFame.Controllers
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
// GET: api/<controller>?moduleid=x&download=true/false
|
||||
// GET: api/<controller>?moduleid=x&download=true/false&id=y
|
||||
[HttpGet]
|
||||
[Authorize(Policy = PolicyNames.ViewModule)]
|
||||
public async Task<IActionResult> Get(string moduleid, bool download = false)
|
||||
public async Task<IActionResult> Get(string moduleid, bool download = false, int? id = null)
|
||||
{
|
||||
int ModuleId;
|
||||
if (int.TryParse(moduleid, out ModuleId) && IsAuthorizedEntityId(EntityNames.Module, ModuleId))
|
||||
@@ -38,6 +41,16 @@ namespace SZUAbsolventenverein.Module.HallOfFame.Controllers
|
||||
var entries = await _hallOfFameService.GetHallOfFamesAsync(ModuleId);
|
||||
var publishedEntries = entries.Where(e => e.Status == "Published").ToList();
|
||||
|
||||
// If a specific entry ID is provided, filter to just that entry
|
||||
if (id.HasValue)
|
||||
{
|
||||
publishedEntries = publishedEntries.Where(e => e.HallOfFameId == id.Value).ToList();
|
||||
if (!publishedEntries.Any())
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
foreach (var entry in publishedEntries)
|
||||
@@ -116,11 +129,7 @@ namespace SZUAbsolventenverein.Module.HallOfFame.Controllers
|
||||
});
|
||||
|
||||
// ═══ BESCHREIBUNGSKARTE (unten) ═══
|
||||
var description = entry.Description ?? "";
|
||||
var sections = description.Split('\n')
|
||||
.Select(s => s.Trim())
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.ToArray();
|
||||
var sections = ConvertHtmlToLines(entry.Description ?? "");
|
||||
|
||||
column.Item().ExtendVertical().AlignBottom()
|
||||
.Border(5f).BorderColor("#20000000")
|
||||
@@ -134,7 +143,7 @@ namespace SZUAbsolventenverein.Module.HallOfFame.Controllers
|
||||
.PaddingHorizontal(32)
|
||||
.Column(descColumn =>
|
||||
{
|
||||
if (sections.Length > 0)
|
||||
if (sections.Count > 0)
|
||||
{
|
||||
// Überschrift
|
||||
descColumn.Item()
|
||||
@@ -184,5 +193,79 @@ namespace SZUAbsolventenverein.Module.HallOfFame.Controllers
|
||||
return Forbid();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts HTML content (from Quill.js rich text editor) to a list of plain text lines
|
||||
/// suitable for rendering in QuestPDF.
|
||||
/// Handles: ol/ul lists → numbered/bulleted lines, p → paragraphs, br → newlines,
|
||||
/// strips all other tags and decodes HTML entities.
|
||||
/// </summary>
|
||||
private static List<string> ConvertHtmlToLines(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return new List<string>();
|
||||
|
||||
var lines = new List<string>();
|
||||
|
||||
// Process ordered lists: <ol>...</ol>
|
||||
html = Regex.Replace(html, @"<ol[^>]*>(.*?)</ol>", match =>
|
||||
{
|
||||
var listContent = match.Groups[1].Value;
|
||||
var items = Regex.Matches(listContent, @"<li[^>]*>(.*?)</li>", RegexOptions.Singleline);
|
||||
int counter = 1;
|
||||
var result = "";
|
||||
foreach (Match item in items)
|
||||
{
|
||||
var text = StripHtmlTags(item.Groups[1].Value).Trim();
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
result += $"\n{counter}. {text}";
|
||||
counter++;
|
||||
}
|
||||
return result;
|
||||
}, RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
|
||||
// Process unordered lists: <ul>...</ul>
|
||||
html = Regex.Replace(html, @"<ul[^>]*>(.*?)</ul>", match =>
|
||||
{
|
||||
var listContent = match.Groups[1].Value;
|
||||
var items = Regex.Matches(listContent, @"<li[^>]*>(.*?)</li>", RegexOptions.Singleline);
|
||||
var result = "";
|
||||
foreach (Match item in items)
|
||||
{
|
||||
var text = StripHtmlTags(item.Groups[1].Value).Trim();
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
result += $"\n• {text}";
|
||||
}
|
||||
return result;
|
||||
}, RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
|
||||
// Replace <br>, <br/>, <br /> with newline
|
||||
html = Regex.Replace(html, @"<br\s*/?>", "\n", RegexOptions.IgnoreCase);
|
||||
|
||||
// Replace </p>, </div>, </h1>-</h6> with newline
|
||||
html = Regex.Replace(html, @"</(?:p|div|h[1-6])>", "\n", RegexOptions.IgnoreCase);
|
||||
|
||||
// Strip all remaining HTML tags
|
||||
html = StripHtmlTags(html);
|
||||
|
||||
// Decode HTML entities ( & etc.)
|
||||
html = WebUtility.HtmlDecode(html);
|
||||
|
||||
// Split into lines, trim, filter empty
|
||||
var rawLines = html.Split('\n')
|
||||
.Select(s => s.Trim())
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.ToList();
|
||||
|
||||
return rawLines;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips all HTML tags from a string, leaving only the text content.
|
||||
/// </summary>
|
||||
private static string StripHtmlTags(string html)
|
||||
{
|
||||
return Regex.Replace(html, @"<[^>]+>", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user