feat: implement PDF preview popup on sender dashboard

Replace the placeholder preview popup with a fully functional DxPdfViewer
popup:

- Popup resized to 80vw × 90vh with drag, resize and background shading.
- OpenPreviewAsync: unmounts viewer, fetches PDF bytes via
  GetDocumentOfSenderAsync, then mounts DxPdfViewer only after bytes are
  ready (prevents DevExpress ProcessDocumentChangedAsync NRE).
- Loading spinner shown while fetching; typed error panel shown on
  NotFoundException or general failure.
- OnPreviewClosed: unmounts viewer before nulling byte array so DevExpress
  async render never touches a disposed reference.
- Inject ReceiverPageDataService, EnvelopeAuthService and ILogger.
This commit is contained in:
2026-09-23 15:33:17 +02:00
parent aa2a31e993
commit 454dd0b6cc

View File

@@ -4,13 +4,18 @@
@using EnvelopeGenerator.Domain.Constants
@using EnvelopeGenerator.Server.Client.Models
@using DevExpress.Blazor
@using DevExpress.Blazor.PdfViewer
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Common.Dto
@using EnvelopeGenerator.Server.Services
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
@inject IEnvelopeAuthService EnvelopeAuthService
@inject ILogger<EnvelopeSenderPage> Logger
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
@@ -428,22 +433,63 @@
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
<DxPopup @bind-Visible="@_previewVisible"
HeaderText="@($"Vorschau — Umschlag #{_selectedEnvelope?.Id}")"
Width="480px"
HeaderText="@($"Vorschau — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
Width="80vw"
MaxWidth="80vw"
Height="90vh"
MaxHeight="90vh"
CloseOnOutsideClick="true"
ShowFooter="true">
ShowFooter="false"
CssClass="sender-preview-popup"
Closed="OnPreviewClosed"
AllowResize="true"
ApplyBackgroundShading="true"
Scrollable="true"
AllowDrag=true
EnableAdaptivity=true
ShowHeader=true>
<BodyContentTemplate>
<div style="padding: 1rem;">
<p><strong>Umschlag-ID:</strong> @_selectedEnvelope?.Id</p>
<p><strong>Titel:</strong> @_selectedEnvelope?.Title</p>
</div>
@if (_previewLoading)
{
<div style="display:flex;justify-content:center;align-items:center;height:100%;min-height:300px;">
<div class="text-center">
<div class="spinner-border mb-3" style="width:3rem;height:3rem;" role="status">
<span class="visually-hidden">Lädt...</span>
</div>
<p style="color:#6b7280;font-size:0.875rem;">Dokument wird geladen…</p>
</div>
</div>
}
else if (_previewErrorMessage is not null)
{
<div style="display:flex;gap:1rem;align-items:flex-start;padding:1.5rem;">
<div style="flex-shrink:0;width:40px;height:40px;border-radius:50%;
background:#fee2e2;display:flex;align-items:center;justify-content:center;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#dc2626" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
</svg>
</div>
<div>
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
Dokument konnte nicht geladen werden
</p>
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;word-break:break-word;">
@_previewErrorMessage
</p>
</div>
</div>
}
else if (_previewPdfBytes is not null && _previewViewerMounted)
{
<div class="sender-preview-viewer-wrap" style="padding: 0 5.5rem;">
<DxPdfViewer DocumentContent="@_previewPdfBytes"
ZoomLevel="-2"
IsSinglePagePreview="true"
CssClass="sender-preview-pdf-viewer" />
</div>
}
</BodyContentTemplate>
<FooterContentTemplate>
<DxButton CssClass="popup-button my-1 ms-2"
RenderStyle="ButtonRenderStyle.Secondary"
Text="Schließen"
Click="@(() => _previewVisible = false)" />
</FooterContentTemplate>
</DxPopup>
@code {
@@ -464,6 +510,10 @@
// Preview popup
private bool _previewVisible = false;
private bool _previewLoading = false;
private bool _previewViewerMounted = false;
private byte[]? _previewPdfBytes = null;
private string? _previewErrorMessage = null;
// Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts
private const string GridKeyActive = "sender.active-envelopes";
@@ -599,13 +649,69 @@
var clicked = e.Grid.GetDataItem(e.VisibleIndex) as EnvelopeDto;
if (clicked == null) return;
_selectedEnvelope = clicked;
_previewVisible = true;
_ = OpenPreviewAsync(clicked);
}
void ShowPreview()
async Task ShowPreview()
{
if (_selectedEnvelope == null) return;
await OpenPreviewAsync(_selectedEnvelope);
}
private async Task OpenPreviewAsync(EnvelopeDto envelope)
{
// Unmount the viewer first — let the previous instance fully dispose
_previewViewerMounted = false;
_previewPdfBytes = null;
_previewErrorMessage = null;
_previewLoading = true;
_previewVisible = true;
await InvokeAsync(StateHasChanged);
try
{
var user = EnvelopeAuthService.GetCurrentUser();
if (user?.Identity is null || !user.Identity.IsAuthenticated)
{
_previewErrorMessage = "Nicht authentifiziert. Bitte melden Sie sich erneut an.";
return;
}
_previewPdfBytes = await ReceiverPageDataService.GetDocumentOfSenderAsync(user, envelope.Id);
// PDF ready — mount the viewer now
_previewViewerMounted = true;
}
catch (DigitalData.Core.Exceptions.NotFoundException)
{
_previewErrorMessage = $"Das Dokument für Umschlag #{envelope.Id} wurde nicht gefunden oder Sie haben keinen Zugriff darauf.";
Logger.LogWarning("Preview: document not found for envelope {EnvelopeId}", envelope.Id);
}
catch (Exception ex)
{
_previewErrorMessage = $"Fehler beim Laden des Dokuments: {ex.Message}";
Logger.LogError(ex, "Preview: failed to load document for envelope {EnvelopeId}", envelope.Id);
}
finally
{
_previewLoading = false;
await InvokeAsync(StateHasChanged);
}
}
void OnPreviewClosed()
{
// Unmount the viewer first — prevent DevExpress async render from
// accessing the already-disposed byte array
_previewViewerMounted = false;
_ = InvokeAsync(async () =>
{
await Task.Yield(); // wait one render cycle for the viewer to be removed from the DOM
_previewPdfBytes = null;
_previewErrorMessage = null;
_previewLoading = false;
await InvokeAsync(StateHasChanged);
});
}
// ── Grid Layout Persistence ───────────────────────────────────────────────