Add reusable UI components and toast notification system
- Introduce ActionPanel, EnvelopeInfoCard, TfaForm, ConfirmDialog, StatusPage, and Toast components for modular, presentational UI - Add ToastService for pub/sub toast notifications; register in DI - Refactor AccessCodeForm for improved UX and parameterization - Enhance MainLayout with Toast integration and better error handling - Standardize and extend app.css for new components and responsive design - All new components are "dumb" (no service/API knowledge), using EventCallbacks for parent interaction - ConfirmDialog supports awaitable user confirmation via TaskCompletionSource
This commit is contained in:
@@ -1,56 +1,168 @@
|
||||
@* DUMB COMPONENT: Kennt keine Services, nur Parameter und Events *@
|
||||
@* AccessCodeForm: Zeigt das Zugangscode-Eingabeformular.
|
||||
Entspricht dem AccessCode-Teil von EnvelopeLocked.cshtml im Web-Projekt.
|
||||
|
||||
"Dumme" Komponente: Keine Services, keine API-Calls.
|
||||
- Bekommt Daten über [Parameter]
|
||||
- Gibt Eingaben über EventCallback an die Eltern-Page zurück
|
||||
- Die Page entscheidet, was mit dem Code passiert *@
|
||||
|
||||
<div class="page">
|
||||
@* ── Header: Icon + Titel ── *@
|
||||
<header class="text-center">
|
||||
<div class="status-icon locked mt-4 mb-1">
|
||||
<i class="bi bi-shield-lock"></i>
|
||||
</div>
|
||||
<h1>Zugangscode eingeben</h1>
|
||||
</header>
|
||||
|
||||
@* ── Erklärungstext ── *@
|
||||
<section class="text-center mb-4">
|
||||
<p class="text-muted">
|
||||
Ein Zugangscode wurde an Ihre E-Mail-Adresse gesendet.
|
||||
Bitte geben Sie diesen unten ein.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@* ── Formular ── *@
|
||||
<div class="access-code-container">
|
||||
<h2>Zugangscode eingeben</h2>
|
||||
<p>Ein Zugangscode wurde an Ihre E-Mail-Adresse gesendet.</p>
|
||||
|
||||
<EditForm Model="_model" OnValidSubmit="Submit">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="form-group">
|
||||
@* Code-Eingabefeld: type="password" damit niemand mitlesen kann *@
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="_model.Code"
|
||||
type="password"
|
||||
class="form-control code-input"
|
||||
placeholder="000000"
|
||||
maxlength="6" />
|
||||
id="accessCodeInput"
|
||||
placeholder="Zugangscode" />
|
||||
<label for="accessCodeInput">Zugangscode</label>
|
||||
<ValidationMessage For="() => _model.Code" />
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
@* TFA-Switch: Nur sichtbar wenn der Umschlag TFA aktiviert hat.
|
||||
Im Web-Projekt ist das der "2FA per SMS"-Toggle.
|
||||
Disabled wenn der Empfänger keine Telefonnummer hat. *@
|
||||
@if (TfaEnabled)
|
||||
{
|
||||
<div class="alert alert-danger mt-2">@ErrorMessage</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
id="preferSmsSwitch"
|
||||
checked="@_preferSms"
|
||||
disabled="@(!HasPhoneNumber)"
|
||||
@onchange="ToggleSms" />
|
||||
<label class="form-check-label" for="preferSmsSwitch">
|
||||
2FA per SMS
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="submit" class="btn btn-primary mt-3" disabled="@_isSubmitting">
|
||||
@* Fehlermeldung: Wird von der Eltern-Page gesetzt
|
||||
wenn der AccessCode falsch war *@
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">@ErrorMessage</div>
|
||||
}
|
||||
|
||||
@* Submit-Button mit Loading-State *@
|
||||
<button type="submit" class="btn btn-primary w-100" disabled="@_isSubmitting">
|
||||
@if (_isSubmitting)
|
||||
{
|
||||
<LoadingIndicator Small="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>
|
||||
<span>Bestätigen</span>
|
||||
}
|
||||
</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// Parameter von der Eltern-Page
|
||||
[Parameter] public required string EnvelopeKey { get; set; }
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
@* ── Hilfe-Bereich: Wer hat dieses Dokument gesendet? ──
|
||||
Im Web-Projekt ist das der <details>-Bereich ganz unten.
|
||||
Zeigt Sender-Mail und Umschlag-Titel, damit der Empfänger
|
||||
bei Problemen weiß, an wen er sich wenden kann. *@
|
||||
@if (!string.IsNullOrEmpty(SenderEmail))
|
||||
{
|
||||
<section class="text-center mt-4">
|
||||
<details>
|
||||
<summary class="text-muted">Woher kommt dieser Code?</summary>
|
||||
<p class="text-muted mt-2">
|
||||
Dieses Dokument
|
||||
@if (!string.IsNullOrEmpty(Title))
|
||||
{
|
||||
<span>«@Title» </span>
|
||||
}
|
||||
wurde Ihnen von <a href="mailto:@SenderEmail">@SenderEmail</a> zugesendet.
|
||||
Der Zugangscode wurde ebenfalls an Ihre E-Mail-Adresse geschickt.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
// EventCallback: Informiert die Page, dass ein Code eingegeben wurde
|
||||
[Parameter] public EventCallback<string> OnSubmit { get; set; }
|
||||
@code {
|
||||
// ── Parameter von der Eltern-Page ──
|
||||
|
||||
/// <summary>Der Envelope-Key aus der URL</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string EnvelopeKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Fehlermeldung (z.B. "Falscher Zugangscode")</summary>
|
||||
[Parameter]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>E-Mail des Absenders — für den Hilfe-Bereich unten</summary>
|
||||
[Parameter]
|
||||
public string? SenderEmail { get; set; }
|
||||
|
||||
/// <summary>Titel des Umschlags — für den Hilfe-Bereich unten</summary>
|
||||
[Parameter]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>Ob TFA für diesen Umschlag aktiviert ist — zeigt den SMS-Switch</summary>
|
||||
[Parameter]
|
||||
public bool TfaEnabled { get; set; }
|
||||
|
||||
/// <summary>Ob der Empfänger eine Telefonnummer hat — sonst ist SMS-Switch disabled</summary>
|
||||
[Parameter]
|
||||
public bool HasPhoneNumber { get; set; }
|
||||
|
||||
// ── Event: Gibt Code + SMS-Präferenz an die Page zurück ──
|
||||
|
||||
/// <summary>
|
||||
/// Callback wenn der Benutzer das Formular abschickt.
|
||||
/// Gibt ein Tuple zurück: (Code, PreferSms)
|
||||
/// Die Page entscheidet dann, was damit passiert (API-Call etc.)
|
||||
///
|
||||
/// WARUM ein Tuple statt nur string?
|
||||
/// Weil die Page auch wissen muss, ob der Benutzer SMS bevorzugt.
|
||||
/// Im Web-Projekt wird das als separates Form-Feld "userSelectSMS" gesendet.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<(string Code, bool PreferSms)> OnSubmit { get; set; }
|
||||
|
||||
// ── Interner State ──
|
||||
|
||||
private AccessCodeModel _model = new();
|
||||
private bool _isSubmitting;
|
||||
private bool _preferSms;
|
||||
|
||||
private void ToggleSms(ChangeEventArgs e)
|
||||
{
|
||||
_preferSms = (bool)(e.Value ?? false);
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
_isSubmitting = true;
|
||||
await OnSubmit.InvokeAsync(_model.Code);
|
||||
await OnSubmit.InvokeAsync((_model.Code, _preferSms));
|
||||
_isSubmitting = false;
|
||||
}
|
||||
|
||||
// ── Validierungs-Model ──
|
||||
|
||||
private class AccessCodeModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Bitte Zugangscode eingeben")]
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
@* ActionPanel: Fixierte Button-Leiste am unteren Bildschirmrand.
|
||||
Entspricht dem <div id="flex-action-panel"> in ShowEnvelope.cshtml.
|
||||
|
||||
Wird angezeigt wenn der Empfänger das Dokument sieht (Status = ShowDocument).
|
||||
Bei ReadOnly-Dokumenten wird nichts angezeigt.
|
||||
|
||||
Die Buttons lösen EventCallbacks aus. Die Eltern-Komponente (EnvelopePage)
|
||||
entscheidet, was passiert (API-Call etc.).
|
||||
|
||||
Der ConfirmDialog wird über eine Referenz aufgerufen:
|
||||
var confirmed = await _confirmDialog.ShowAsync("Titel", "Text");
|
||||
|
||||
WARUM EventCallbacks statt direkte API-Calls?
|
||||
- Die Komponente bleibt "dumm" (keine Services, kein API-Wissen)
|
||||
- Die Eltern-Page kann den gleichen Button-Click anders behandeln
|
||||
- Einfacher zu testen *@
|
||||
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
@* position-fixed: Bleibt am unteren Rand auch beim Scrollen.
|
||||
Gleiche Positionierung wie im Web-Projekt. *@
|
||||
<div class="position-fixed bottom-0 end-0 p-3 d-flex gap-2" style="z-index: 1050;">
|
||||
|
||||
@* Zurücksetzen-Button: Setzt alle Signaturen zurück.
|
||||
Im Web-Projekt ist das der graue Button mit dem
|
||||
Pfeil-gegen-den-Uhrzeigersinn-Icon. *@
|
||||
<button class="btn btn-secondary" @onclick="HandleRefresh" title="Zurücksetzen">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</button>
|
||||
|
||||
@* Ablehnen-Button: Öffnet ConfirmDialog, dann EventCallback.
|
||||
Im Web-Projekt ist das der rote Button mit dem X-Icon. *@
|
||||
<button class="btn btn-danger" @onclick="HandleReject" title="Ablehnen">
|
||||
<i class="bi bi-x-lg me-1"></i>
|
||||
<span class="d-none d-md-inline">Ablehnen</span>
|
||||
</button>
|
||||
|
||||
@* Unterschreiben-Button: Öffnet ConfirmDialog, dann EventCallback.
|
||||
Im Web-Projekt ist das der grüne Button mit dem Briefumschlag-Icon. *@
|
||||
<button class="btn btn-success" @onclick="HandleSign" title="Unterschreiben">
|
||||
<i class="bi bi-pen me-1"></i>
|
||||
<span class="d-none d-md-inline">Unterschreiben</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ConfirmDialog: Wird nur gerendert wenn nötig (wenn ShowAsync aufgerufen wird).
|
||||
Die Referenz (_confirmDialog) erlaubt uns, ShowAsync von den Button-Handlern aufzurufen. *@
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@code {
|
||||
// ── Parameter ──
|
||||
|
||||
/// <summary>Bei ReadOnly wird das gesamte Panel ausgeblendet</summary>
|
||||
[Parameter]
|
||||
public bool ReadOnly { get; set; }
|
||||
|
||||
/// <summary>Wird ausgelöst wenn der Benutzer "Unterschreiben" bestätigt</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnSign { get; set; }
|
||||
|
||||
/// <summary>Wird ausgelöst wenn der Benutzer "Ablehnen" bestätigt</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnReject { get; set; }
|
||||
|
||||
/// <summary>Wird ausgelöst wenn der Benutzer "Zurücksetzen" klickt</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnRefresh { get; set; }
|
||||
|
||||
// ── Referenz auf den ConfirmDialog ──
|
||||
|
||||
/// <summary>
|
||||
/// Referenz auf die ConfirmDialog-Komponente.
|
||||
///
|
||||
/// WAS IST @ref?
|
||||
/// Mit @ref speichert Blazor eine Referenz auf eine Kind-Komponente.
|
||||
/// Dann kann man Methoden darauf aufrufen: _confirmDialog.ShowAsync(...)
|
||||
/// Das ist wie document.getElementById() in JavaScript, nur typsicher.
|
||||
/// </summary>
|
||||
private ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
// ── Button-Handler ──
|
||||
|
||||
/// <summary>
|
||||
/// Unterschreiben: Erst bestätigen, dann Event auslösen.
|
||||
/// Der ConfirmDialog zeigt "Möchten Sie das Dokument unterschreiben?"
|
||||
/// Nur wenn der Benutzer "Ja" klickt, wird OnSign ausgelöst.
|
||||
/// </summary>
|
||||
private async Task HandleSign()
|
||||
{
|
||||
var confirmed = await _confirmDialog.ShowAsync(
|
||||
"Unterschreiben",
|
||||
"Möchten Sie das Dokument wirklich unterschreiben? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
confirmText: "Unterschreiben",
|
||||
confirmColor: "success");
|
||||
|
||||
if (confirmed)
|
||||
await OnSign.InvokeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ablehnen: Erst bestätigen, dann Event auslösen.
|
||||
/// Roter Button im ConfirmDialog, weil Ablehnen destruktiv ist.
|
||||
/// </summary>
|
||||
private async Task HandleReject()
|
||||
{
|
||||
var confirmed = await _confirmDialog.ShowAsync(
|
||||
"Ablehnen",
|
||||
"Möchten Sie das Dokument wirklich ablehnen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
confirmText: "Ablehnen",
|
||||
confirmColor: "danger");
|
||||
|
||||
if (confirmed)
|
||||
await OnReject.InvokeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zurücksetzen: Kein ConfirmDialog nötig, weil es nicht destruktiv ist.
|
||||
/// Setzt nur die Signatur-Positionen zurück, der Empfänger kann danach
|
||||
/// erneut signieren.
|
||||
/// </summary>
|
||||
private async Task HandleRefresh()
|
||||
{
|
||||
await OnRefresh.InvokeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
@* EnvelopeInfoCard: Zeigt die Umschlag-Infos oberhalb des PDF-Viewers.
|
||||
Entspricht dem Card-Bereich in ShowEnvelope.cshtml im Web-Projekt.
|
||||
|
||||
Wird angezeigt wenn der Empfänger erfolgreich authentifiziert ist
|
||||
und das Dokument sehen darf.
|
||||
|
||||
"Dumme" Komponente: Keine Services, nur Parameter → Anzeige. *@
|
||||
|
||||
<div class="card mb-3">
|
||||
@* ── Card Header: Logo + Fortschrittsbalken ──
|
||||
Im Web-Projekt gibt es hier das signFLOW-Logo und darunter
|
||||
"2/3 Signatures". Wir zeigen den Fortschritt nur wenn es
|
||||
KEIN ReadOnly-Dokument ist (ReadOnly = nur lesen, nicht signieren). *@
|
||||
<div class="card-header bg-white">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong class="text-primary">signFLOW</strong>
|
||||
|
||||
@if (!ReadOnly && SignatureTotal > 0)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="progress" style="width: 120px; height: 8px;">
|
||||
<div class="progress-bar bg-success"
|
||||
role="progressbar"
|
||||
style="width: @ProgressPercent%"
|
||||
aria-valuenow="@SignaturesDone"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="@SignatureTotal">
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">@SignaturesDone/@SignatureTotal</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ReadOnly)
|
||||
{
|
||||
<span class="badge bg-secondary">Nur Ansicht</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Card Body: Titel, Nachricht, Sender-Info ── *@
|
||||
<div class="card-body">
|
||||
@* Titel des Umschlags *@
|
||||
<h5 class="card-title">@Title</h5>
|
||||
|
||||
@* Nachricht des Absenders — im Web-Projekt wird das mit Marked.js
|
||||
als Markdown gerendert. Hier erstmal als Plain Text.
|
||||
Markdown-Rendering kommt in Phase 6 (Feinschliff). *@
|
||||
@if (!string.IsNullOrEmpty(Message))
|
||||
{
|
||||
<p class="card-text">@Message</p>
|
||||
}
|
||||
|
||||
@* Sender-Info: Wer hat es gesendet, wann?
|
||||
Im Web-Projekt steht hier:
|
||||
"Gesendet am 15.03.2026 von Max Mustermann (max@firma.de)" *@
|
||||
<p class="card-text">
|
||||
<small class="text-muted">
|
||||
@if (!string.IsNullOrEmpty(SenderName) && !string.IsNullOrEmpty(SenderEmail))
|
||||
{
|
||||
<span>
|
||||
Gesendet
|
||||
@if (SentDate is not null)
|
||||
{
|
||||
<span>am @SentDate.Value.ToString("dd.MM.yyyy")</span>
|
||||
}
|
||||
von @SenderName
|
||||
(<a href="mailto:@SenderEmail">@SenderEmail</a>)
|
||||
</span>
|
||||
}
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// ── Parameter ──
|
||||
|
||||
/// <summary>Titel des Umschlags (z.B. "Vertragsdokument")</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Nachricht des Absenders</summary>
|
||||
[Parameter]
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>Name des Absenders (z.B. "Max Mustermann")</summary>
|
||||
[Parameter]
|
||||
public string? SenderName { get; set; }
|
||||
|
||||
/// <summary>E-Mail des Absenders</summary>
|
||||
[Parameter]
|
||||
public string? SenderEmail { get; set; }
|
||||
|
||||
/// <summary>Datum an dem der Umschlag gesendet wurde</summary>
|
||||
[Parameter]
|
||||
public DateTime? SentDate { get; set; }
|
||||
|
||||
/// <summary>Ob das Dokument nur zum Lesen ist (kein Signieren)</summary>
|
||||
[Parameter]
|
||||
public bool ReadOnly { get; set; }
|
||||
|
||||
/// <summary>Anzahl bereits geleisteter Unterschriften</summary>
|
||||
[Parameter]
|
||||
public int SignaturesDone { get; set; }
|
||||
|
||||
/// <summary>Gesamtanzahl benötigter Unterschriften</summary>
|
||||
[Parameter]
|
||||
public int SignatureTotal { get; set; }
|
||||
|
||||
// ── Berechnete Werte ──
|
||||
|
||||
/// <summary>
|
||||
/// Fortschritt in Prozent für den Balken.
|
||||
/// Vermeidet Division durch Null.
|
||||
/// </summary>
|
||||
private int ProgressPercent =>
|
||||
SignatureTotal > 0
|
||||
? (int)((double)SignaturesDone / SignatureTotal * 100)
|
||||
: 0;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
@* TfaForm: Zwei-Faktor-Authentifizierung — SMS oder Authenticator.
|
||||
Entspricht dem TFA-Teil von EnvelopeLocked.cshtml im Web-Projekt.
|
||||
|
||||
Wird angezeigt NACH dem AccessCode-Schritt, wenn der Umschlag TFA erfordert.
|
||||
|
||||
Zwei Varianten:
|
||||
- TfaType="sms" → SMS wurde gesendet, Countdown-Timer zeigt verbleibende Zeit
|
||||
- TfaType="authenticator" → Benutzer gibt Code aus seiner Authenticator-App ein
|
||||
|
||||
"Dumme" Komponente: Keine Services, keine API-Calls. *@
|
||||
|
||||
@implements IDisposable
|
||||
|
||||
<div class="page">
|
||||
@* ── Header: Icon + Titel ── *@
|
||||
<header class="text-center">
|
||||
<div class="status-icon locked mt-4 mb-1">
|
||||
<i class="bi bi-shield-check"></i>
|
||||
</div>
|
||||
<h1>Zwei-Faktor-Authentifizierung</h1>
|
||||
</header>
|
||||
|
||||
@* ── Erklärungstext: Unterschiedlich je nach TFA-Typ ── *@
|
||||
<section class="text-center mb-4">
|
||||
@if (TfaType == "sms")
|
||||
{
|
||||
<p class="text-muted">
|
||||
Ein Bestätigungscode wurde per SMS an Ihre Telefonnummer gesendet.
|
||||
</p>
|
||||
@* SMS-Countdown-Timer: Zeigt wie lange der Code noch gültig ist.
|
||||
Im Web-Projekt ist das JavaScript (setInterval).
|
||||
Hier machen wir es mit einem C#-Timer — kein JS nötig. *@
|
||||
@if (_remainingTime is not null)
|
||||
{
|
||||
<div class="alert @(_remainingTime > TimeSpan.Zero ? "alert-primary" : "alert-warning")">
|
||||
@if (_remainingTime > TimeSpan.Zero)
|
||||
{
|
||||
<span>Code gültig für: @_remainingTime.Value.ToString("mm\\:ss")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Code abgelaufen. Bitte fordern Sie einen neuen Code an.</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted">
|
||||
Öffnen Sie Ihre Authenticator-App und geben Sie den angezeigten Code ein.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@* ── Formular ── *@
|
||||
<div class="access-code-container">
|
||||
<EditForm Model="_model" OnValidSubmit="Submit">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="_model.Code"
|
||||
type="password"
|
||||
class="form-control code-input"
|
||||
id="tfaCodeInput"
|
||||
placeholder="Code" />
|
||||
<label for="tfaCodeInput">
|
||||
@(TfaType == "sms" ? "SMS-Code" : "Authenticator-Code")
|
||||
</label>
|
||||
<ValidationMessage For="() => _model.Code" />
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">@ErrorMessage</div>
|
||||
}
|
||||
|
||||
<button type="submit"
|
||||
class="btn btn-primary w-100"
|
||||
disabled="@(_isSubmitting || _remainingTime <= TimeSpan.Zero)">
|
||||
@if (_isSubmitting)
|
||||
{
|
||||
<LoadingIndicator Small="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-unlock me-2"></i>
|
||||
<span>Bestätigen</span>
|
||||
}
|
||||
</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// ── Parameter von der Eltern-Page ──
|
||||
|
||||
/// <summary>Der Envelope-Key aus der URL</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string EnvelopeKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// "sms" oder "authenticator" — bestimmt welche Variante angezeigt wird.
|
||||
/// Kommt aus der API-Antwort nach dem AccessCode-Schritt.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string TfaType { get; set; } = "authenticator";
|
||||
|
||||
/// <summary>
|
||||
/// Ablaufzeit des SMS-Codes. Nur bei TfaType="sms" relevant.
|
||||
/// Der Timer zählt von jetzt bis zu diesem Zeitpunkt runter.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public DateTime? TfaExpiration { get; set; }
|
||||
|
||||
/// <summary>Ob der Empfänger eine Telefonnummer hat</summary>
|
||||
[Parameter]
|
||||
public bool HasPhoneNumber { get; set; }
|
||||
|
||||
/// <summary>Fehlermeldung (z.B. "Falscher Code")</summary>
|
||||
[Parameter]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback: Gibt (Code, Type) an die Page zurück.
|
||||
/// Die Page sendet das dann an die API.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<(string Code, string Type)> OnSubmit { get; set; }
|
||||
|
||||
// ── Interner State ──
|
||||
|
||||
private TfaCodeModel _model = new();
|
||||
private bool _isSubmitting;
|
||||
private TimeSpan? _remainingTime;
|
||||
private System.Threading.Timer? _timer;
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
/// <summary>
|
||||
/// Wird aufgerufen wenn die Komponente initialisiert wird.
|
||||
/// Startet den SMS-Countdown-Timer falls nötig.
|
||||
///
|
||||
/// OnInitialized (nicht Async) reicht hier, weil wir keinen
|
||||
/// await brauchen — der Timer läuft über einen Callback.
|
||||
/// </summary>
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (TfaType == "sms" && TfaExpiration is not null)
|
||||
{
|
||||
// Restzeit berechnen
|
||||
_remainingTime = TfaExpiration.Value - DateTime.Now;
|
||||
|
||||
// Timer: Jede Sekunde _remainingTime aktualisieren.
|
||||
// InvokeAsync + StateHasChanged sagt Blazor: "Zeichne die UI neu".
|
||||
// Das ist das Blazor-Äquivalent zu setInterval + DOM-Update in JavaScript.
|
||||
_timer = new System.Threading.Timer(_ =>
|
||||
{
|
||||
_remainingTime = TfaExpiration.Value - DateTime.Now;
|
||||
|
||||
if (_remainingTime <= TimeSpan.Zero)
|
||||
{
|
||||
_remainingTime = TimeSpan.Zero;
|
||||
_timer?.Dispose();
|
||||
}
|
||||
|
||||
// InvokeAsync ist nötig, weil der Timer auf einem anderen Thread läuft.
|
||||
// Blazor erlaubt UI-Updates nur auf dem UI-Thread.
|
||||
InvokeAsync(StateHasChanged);
|
||||
},
|
||||
state: null,
|
||||
dueTime: TimeSpan.Zero, // Sofort starten
|
||||
period: TimeSpan.FromSeconds(1) // Jede Sekunde
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
_isSubmitting = true;
|
||||
await OnSubmit.InvokeAsync((_model.Code, TfaType));
|
||||
_isSubmitting = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aufräumen: Timer stoppen wenn die Komponente entfernt wird.
|
||||
/// Ohne Dispose würde der Timer weiterlaufen und Fehler verursachen,
|
||||
/// weil er versucht eine nicht mehr existierende UI zu aktualisieren.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_timer?.Dispose();
|
||||
}
|
||||
|
||||
// ── Validierungs-Model ──
|
||||
|
||||
private class TfaCodeModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Bitte Code eingeben")]
|
||||
[System.ComponentModel.DataAnnotations.StringLength(6, MinimumLength = 6, ErrorMessage = "Der Code muss 6 Zeichen lang sein")]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
@* ConfirmDialog: Ersetzt SweetAlert2 aus dem Web-Projekt.
|
||||
|
||||
Zeigt einen modalen Bestätigungsdialog mit Titel, Text und Ja/Nein-Buttons.
|
||||
Wird NICHT über Parameter gesteuert, sondern über eine Methode:
|
||||
|
||||
var confirmed = await _dialog.ShowAsync("Titel", "Text");
|
||||
|
||||
WARUM eine Methode statt Parameter?
|
||||
- Ein Dialog ist ein "einmaliges Ereignis", kein dauerhafter Zustand
|
||||
- Die aufrufende Komponente will auf das Ergebnis WARTEN (await)
|
||||
- Mit Parametern müsste man den State manuell hin- und herschalten
|
||||
|
||||
WARUM kein SweetAlert2?
|
||||
- SweetAlert2 ist eine JavaScript-Bibliothek
|
||||
- In Blazor können wir das nativ in C# lösen, ohne JS-Interop
|
||||
- Weniger Abhängigkeiten = weniger Wartung = weniger Fehlerquellen *@
|
||||
|
||||
@if (_isVisible)
|
||||
{
|
||||
@* Hintergrund-Overlay: Dunkler Hintergrund hinter dem Dialog.
|
||||
Im Web-Projekt macht SweetAlert2 das automatisch.
|
||||
Hier bauen wir es selbst mit CSS. *@
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
|
||||
@* Modal-Dialog: Bootstrap 5 Modal-Markup.
|
||||
Normalerweise braucht Bootstrap-Modal JavaScript (bootstrap.js)
|
||||
um zu öffnen/schließen. Wir steuern die Sichtbarkeit stattdessen
|
||||
über die _isVisible-Variable — Blazor macht das DOM-Update. *@
|
||||
<div class="modal fade show d-block" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
|
||||
@* Header: Titel + Schließen-Button *@
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@_title</h5>
|
||||
<button type="button" class="btn-close" @onclick="Cancel"></button>
|
||||
</div>
|
||||
|
||||
@* Body: Beschreibungstext *@
|
||||
<div class="modal-body">
|
||||
<p>@_message</p>
|
||||
</div>
|
||||
|
||||
@* Footer: Abbrechen + Bestätigen *@
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="Cancel">
|
||||
@_cancelText
|
||||
</button>
|
||||
<button type="button" class="btn btn-@_confirmColor" @onclick="Confirm">
|
||||
@_confirmText
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
// ── Interner State (NICHT Parameter — wird über ShowAsync gesetzt) ──
|
||||
|
||||
private bool _isVisible;
|
||||
private string _title = string.Empty;
|
||||
private string _message = string.Empty;
|
||||
private string _confirmText = "Ja";
|
||||
private string _cancelText = "Abbrechen";
|
||||
private string _confirmColor = "primary";
|
||||
|
||||
/// <summary>
|
||||
/// TaskCompletionSource: Das Herzstück dieser Komponente.
|
||||
///
|
||||
/// WAS IST DAS?
|
||||
/// Ein TaskCompletionSource erstellt einen Task, der erst dann
|
||||
/// "fertig" wird, wenn jemand SetResult() aufruft.
|
||||
///
|
||||
/// WIE FUNKTIONIERT ES?
|
||||
/// 1. ShowAsync() erstellt ein neues TaskCompletionSource
|
||||
/// 2. ShowAsync() gibt dessen Task zurück → der Aufrufer wartet (await)
|
||||
/// 3. Der Benutzer klickt "Ja" → Confirm() ruft SetResult(true) auf
|
||||
/// 4. Der await in der aufrufenden Komponente kommt zurück mit true
|
||||
///
|
||||
/// Das ist wie ein "Promise" in JavaScript, nur in C#.
|
||||
/// </summary>
|
||||
private TaskCompletionSource<bool>? _tcs;
|
||||
|
||||
/// <summary>
|
||||
/// Zeigt den Dialog und wartet auf die Benutzer-Entscheidung.
|
||||
///
|
||||
/// Beispiel-Aufruf:
|
||||
/// var confirmed = await _dialog.ShowAsync(
|
||||
/// "Unterschreiben",
|
||||
/// "Möchten Sie das Dokument wirklich unterschreiben?");
|
||||
/// </summary>
|
||||
/// <param name="title">Dialog-Überschrift</param>
|
||||
/// <param name="message">Beschreibungstext</param>
|
||||
/// <param name="confirmText">Text auf dem Bestätigen-Button (Standard: "Ja")</param>
|
||||
/// <param name="cancelText">Text auf dem Abbrechen-Button (Standard: "Abbrechen")</param>
|
||||
/// <param name="confirmColor">Bootstrap-Farbe des Bestätigen-Buttons (Standard: "primary")</param>
|
||||
/// <returns>true wenn bestätigt, false wenn abgebrochen</returns>
|
||||
public Task<bool> ShowAsync(
|
||||
string title,
|
||||
string message,
|
||||
string confirmText = "Ja",
|
||||
string cancelText = "Abbrechen",
|
||||
string confirmColor = "primary")
|
||||
{
|
||||
_title = title;
|
||||
_message = message;
|
||||
_confirmText = confirmText;
|
||||
_cancelText = cancelText;
|
||||
_confirmColor = confirmColor;
|
||||
|
||||
_tcs = new TaskCompletionSource<bool>();
|
||||
_isVisible = true;
|
||||
StateHasChanged();
|
||||
|
||||
return _tcs.Task;
|
||||
}
|
||||
|
||||
private void Confirm()
|
||||
{
|
||||
_isVisible = false;
|
||||
_tcs?.TrySetResult(true);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
_isVisible = false;
|
||||
_tcs?.TrySetResult(false);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
@* StatusPage: Wiederverwendbare Status-Seite für alle Endzustände.
|
||||
Ersetzt EnvelopeSigned.cshtml, EnvelopeRejected.cshtml, Error-Views und Expired.
|
||||
"Dumme" Komponente: Keine Services, keine API-Calls, nur Parameter → Anzeige. *@
|
||||
|
||||
<div class="page">
|
||||
<header class="text-center">
|
||||
@switch (Type)
|
||||
{
|
||||
case "signed":
|
||||
<div class="status-icon signed">
|
||||
<i class="bi bi-check-circle"></i>
|
||||
</div>
|
||||
<h1>Dokument erfolgreich unterschrieben</h1>
|
||||
<p class="text-muted">
|
||||
Sie erhalten eine Bestätigung per E-Mail, sobald alle Empfänger unterschrieben haben.
|
||||
</p>
|
||||
break;
|
||||
|
||||
case "rejected":
|
||||
<div class="status-icon rejected">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
</div>
|
||||
<h1>Dokument wurde abgelehnt</h1>
|
||||
<p class="text-muted">
|
||||
@if (!string.IsNullOrEmpty(Title) && !string.IsNullOrEmpty(SenderEmail))
|
||||
{
|
||||
<span>
|
||||
Das Dokument «@Title» wurde abgelehnt.
|
||||
Bei Fragen wenden Sie sich an
|
||||
<a href="mailto:@SenderEmail">@SenderEmail</a>.
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Dieses Dokument wurde von einem Empfänger abgelehnt.</span>
|
||||
}
|
||||
</p>
|
||||
break;
|
||||
|
||||
case "not_found":
|
||||
<div class="status-icon locked">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</div>
|
||||
<h1>Dokument nicht gefunden</h1>
|
||||
<p class="text-muted">
|
||||
Dieses Dokument existiert nicht oder ist nicht mehr verfügbar.
|
||||
Wenn Sie diese URL per E-Mail erhalten haben, wenden Sie sich bitte an das IT-Team.
|
||||
</p>
|
||||
break;
|
||||
|
||||
case "expired":
|
||||
<div class="status-icon locked">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
</div>
|
||||
<h1>Link abgelaufen</h1>
|
||||
<p class="text-muted">
|
||||
Der Zugang zu diesem Dokument ist abgelaufen.
|
||||
</p>
|
||||
break;
|
||||
}
|
||||
</header>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Bestimmt welche Status-Variante angezeigt wird.
|
||||
/// Erlaubte Werte: "signed", "rejected", "not_found", "expired"
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>E-Mail des Absenders — nur bei "rejected" relevant.</summary>
|
||||
[Parameter]
|
||||
public string? SenderEmail { get; set; }
|
||||
|
||||
/// <summary>Titel des Umschlags — nur bei "rejected" relevant.</summary>
|
||||
[Parameter]
|
||||
public string? Title { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@* Toast: Zeigt kurze Benachrichtigungen an. Ersetzt AlertifyJS aus dem Web-Projekt.
|
||||
|
||||
Wird einmal im MainLayout platziert. Hört auf den ToastService.
|
||||
Wenn eine Komponente irgendwo _toast.ShowSuccess("Text") aufruft,
|
||||
erscheint hier ein Toast und verschwindet nach 4 Sekunden automatisch.
|
||||
|
||||
Bootstrap Toast-Klassen werden genutzt:
|
||||
- toast-container: Positionierung (oben rechts)
|
||||
- toast: Einzelner Toast
|
||||
- bg-success/bg-danger/etc: Farbe basierend auf Typ *@
|
||||
|
||||
@inject ToastService ToastService
|
||||
@implements IDisposable
|
||||
|
||||
@if (ToastService.Messages.Count > 0)
|
||||
{
|
||||
@* toast-container: Bootstrap-Klasse die Toasts oben rechts positioniert.
|
||||
position-fixed: Bleibt an der Stelle auch beim Scrollen.
|
||||
z-index 1080: Über dem Modal-Backdrop (1070) damit Toasts
|
||||
auch während eines ConfirmDialogs sichtbar sind. *@
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1080;">
|
||||
@foreach (var message in ToastService.Messages)
|
||||
{
|
||||
<div class="toast show align-items-center text-white bg-@message.Type border-0"
|
||||
role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi @message.IconClass me-2"></i>
|
||||
@message.Text
|
||||
</div>
|
||||
<button type="button"
|
||||
class="btn-close btn-close-white me-2 m-auto"
|
||||
@onclick="() => ToastService.Dismiss(message)">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Subscriber: Wenn der ToastService eine Änderung meldet,
|
||||
// zeichnet diese Komponente sich neu (StateHasChanged).
|
||||
// So erscheinen/verschwinden Toasts automatisch.
|
||||
ToastService.OnChange += HandleChange;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// InvokeAsync ist nötig weil OnChange von einem
|
||||
/// async void (dem Timer im ToastService) ausgelöst wird.
|
||||
/// Das kann auf einem anderen Thread passieren.
|
||||
/// InvokeAsync wechselt zurück auf den Blazor UI-Thread.
|
||||
/// </summary>
|
||||
private async void HandleChange()
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event-Handler abmelden wenn die Komponente entfernt wird.
|
||||
/// Ohne das würde der ToastService eine Referenz auf eine
|
||||
/// nicht mehr existierende Komponente halten → Memory Leak.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
ToastService.OnChange -= HandleChange;
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,6 @@ builder.Services.AddScoped<IEnvelopeService, EnvelopeService>();
|
||||
// State: Ein State-Objekt pro Browser-Tab
|
||||
builder.Services.AddScoped<EnvelopeState>();
|
||||
|
||||
builder.Services.AddScoped<ToastService>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service für Toast-Benachrichtigungen. Ersetzt AlertifyJS aus dem Web-Projekt.
|
||||
///
|
||||
/// WARUM ein Service und keine Komponente mit Parametern?
|
||||
/// - Toasts können von ÜBERALL ausgelöst werden (Pages, Services, andere Komponenten)
|
||||
/// - Ein Service ist über Dependency Injection überall verfügbar
|
||||
/// - Die Toast-Komponente im Layout hört auf diesen Service und rendert die Nachrichten
|
||||
///
|
||||
/// PATTERN: Pub/Sub (Publisher/Subscriber)
|
||||
/// - Publisher: Jede Komponente die _toast.ShowSuccess("...") aufruft
|
||||
/// - Subscriber: Die Toast-Komponente im MainLayout, die auf OnChange hört
|
||||
///
|
||||
/// Das ist das gleiche Pattern wie beim EnvelopeState.
|
||||
/// </summary>
|
||||
public class ToastService
|
||||
{
|
||||
/// <summary>
|
||||
/// Liste aller aktuell sichtbaren Toasts.
|
||||
/// Mehrere Toasts können gleichzeitig angezeigt werden (gestapelt).
|
||||
/// </summary>
|
||||
public List<ToastMessage> Messages { get; } = [];
|
||||
|
||||
/// <summary>Event: Informiert die Toast-Komponente über Änderungen</summary>
|
||||
public event Action? OnChange;
|
||||
|
||||
public void ShowSuccess(string text) => Show(text, "success");
|
||||
public void ShowError(string text) => Show(text, "danger");
|
||||
public void ShowInfo(string text) => Show(text, "info");
|
||||
public void ShowWarning(string text) => Show(text, "warning");
|
||||
|
||||
/// <summary>
|
||||
/// Fügt einen Toast hinzu und entfernt ihn nach der angegebenen Dauer automatisch.
|
||||
///
|
||||
/// WARUM async void?
|
||||
/// Normalerweise vermeidet man async void. Hier ist es ok, weil:
|
||||
/// - Es ist ein Fire-and-Forget-Timer (wir warten nicht auf das Ergebnis)
|
||||
/// - Fehler im Delay können die App nicht zum Absturz bringen
|
||||
/// - Das ist ein gängiges Pattern für Auto-Dismiss-Logik
|
||||
/// </summary>
|
||||
private async void Show(string text, string type, int durationMs = 4000)
|
||||
{
|
||||
var message = new ToastMessage(text, type);
|
||||
Messages.Add(message);
|
||||
OnChange?.Invoke();
|
||||
|
||||
// Nach Ablauf der Dauer automatisch entfernen
|
||||
await Task.Delay(durationMs);
|
||||
Messages.Remove(message);
|
||||
OnChange?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>Entfernt einen Toast sofort (z.B. wenn der Benutzer auf X klickt)</summary>
|
||||
public void Dismiss(ToastMessage message)
|
||||
{
|
||||
Messages.Remove(message);
|
||||
OnChange?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein einzelner Toast-Eintrag.
|
||||
///
|
||||
/// WARUM ein record statt class?
|
||||
/// - Records haben automatisch Equals/GetHashCode basierend auf allen Properties
|
||||
/// - Wir brauchen das für Messages.Remove() — es vergleicht über Referenz-Gleichheit
|
||||
/// - Die Id (Guid) macht jeden Toast einzigartig, auch bei gleichem Text
|
||||
/// </summary>
|
||||
public record ToastMessage(string Text, string Type)
|
||||
{
|
||||
/// <summary>Eindeutige Id — damit zwei Toasts mit gleichem Text unterscheidbar sind</summary>
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// Gibt die Bootstrap-Icon-Klasse basierend auf dem Typ zurück.
|
||||
/// success → check-circle, danger → x-circle, etc.
|
||||
/// </summary>
|
||||
public string IconClass => Type switch
|
||||
{
|
||||
"success" => "bi-check-circle-fill",
|
||||
"danger" => "bi-x-circle-fill",
|
||||
"warning" => "bi-exclamation-triangle-fill",
|
||||
_ => "bi-info-circle-fill"
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,35 @@
|
||||
@inherits LayoutComponentBase
|
||||
@* MainLayout: Das Grundgerüst jeder Seite.
|
||||
Entspricht _Layout.cshtml im Web-Projekt.
|
||||
|
||||
Aufbau:
|
||||
- Header: signFLOW-Logo/Titel (oben, sticky)
|
||||
- Main: Der Seiteninhalt (@Body) mit ErrorBoundary
|
||||
- Footer: Copyright + Privacy-Link (unten)
|
||||
|
||||
Sticky Footer Pattern: Der Footer klebt immer am unteren Rand,
|
||||
auch wenn der Inhalt wenig Platz braucht. Das funktioniert über
|
||||
Flexbox in app.css (.app-container mit min-height: 100vh). *@
|
||||
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="app-container">
|
||||
<Toast />
|
||||
|
||||
@* ── Header ── *@
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
@* Im Web-Projekt steht hier ein <img> mit dem signFLOW-Logo.
|
||||
Wir nutzen erstmal Text. Das Logo kommt in Phase 6
|
||||
wenn wir die Bilder aus dem Web-Projekt portieren. *@
|
||||
<span class="app-title">signFLOW</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@* ── Main: Seiteninhalt mit Error-Schutz ──
|
||||
ErrorBoundary fängt unbehandelte Exceptions in Komponenten ab.
|
||||
Ohne ErrorBoundary würde die gesamte App abstürzen.
|
||||
Mit ErrorBoundary zeigen wir stattdessen eine Fehlermeldung
|
||||
und einen "Erneut versuchen"-Button. *@
|
||||
<main class="app-main">
|
||||
<ErrorBoundary @ref="_errorBoundary">
|
||||
<ChildContent>
|
||||
@@ -14,21 +37,51 @@
|
||||
</ChildContent>
|
||||
<ErrorContent Context="ex">
|
||||
<div class="error-container text-center py-5">
|
||||
<h2>😵 Ein unerwarteter Fehler ist aufgetreten</h2>
|
||||
<div class="status-icon locked mb-3">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
</div>
|
||||
<h2>Ein unerwarteter Fehler ist aufgetreten</h2>
|
||||
<p class="text-muted">Bitte versuchen Sie es erneut.</p>
|
||||
<button class="btn btn-primary" @onclick="Recover">Erneut versuchen</button>
|
||||
<button class="btn btn-primary" @onclick="Recover">
|
||||
<i class="bi bi-arrow-counterclockwise me-2"></i>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</ErrorContent>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer text-center py-2 text-muted">
|
||||
<small>© @DateTime.Now.Year Digital Data GmbH</small>
|
||||
@* ── Footer ──
|
||||
Im Web-Projekt gibt es hier drei Elemente:
|
||||
1. Copyright + Link zur Firmenwebsite
|
||||
2. Sprachauswahl (Dropdown mit Flaggen) → kommt in Phase 6
|
||||
3. Privacy-Link (Datenschutzerklärung)
|
||||
|
||||
Die Datenschutz-HTML-Dateien existieren im Web-Projekt unter
|
||||
wwwroot/privacy-policy.de-DE.html. Wir verlinken vorerst
|
||||
auf eine statische URL. Die Datei selbst portieren wir in Phase 6. *@
|
||||
<footer class="app-footer">
|
||||
<small>
|
||||
© signFLOW @DateTime.Now.Year
|
||||
<a href="https://digitaldata.works" target="_blank" class="text-muted text-decoration-none">
|
||||
Digital Data GmbH
|
||||
</a>
|
||||
</small>
|
||||
|
||||
@* Platzhalter für Sprachauswahl — kommt in Phase 6 *@
|
||||
|
||||
<a href="/privacy-policy.de-DE.html" target="_blank" class="text-muted text-decoration-none">
|
||||
<small>Datenschutz</small>
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ErrorBoundary? _errorBoundary;
|
||||
|
||||
/// <summary>
|
||||
/// Setzt die ErrorBoundary zurück.
|
||||
/// Blazor rendert dann @Body erneut statt der Fehlermeldung.
|
||||
/// </summary>
|
||||
private void Recover() => _errorBoundary?.Recover();
|
||||
}
|
||||
@@ -124,9 +124,17 @@ display: flex;
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.status - icon.signed { color: var(--sf - success); }
|
||||
.status - icon.rejected { color: var(--sf - danger); }
|
||||
.status - icon.locked { color: var(--sf - muted); }
|
||||
.status-icon.signed {
|
||||
color: var(--sf-success);
|
||||
}
|
||||
|
||||
.status-icon.rejected {
|
||||
color: var(--sf-danger);
|
||||
}
|
||||
|
||||
.status-icon.locked {
|
||||
color: var(--sf-muted);
|
||||
}
|
||||
|
||||
/* ----- PDF-Container -----
|
||||
WARUM: PSPDFKit braucht einen Container mit fester Höhe.
|
||||
|
||||
Reference in New Issue
Block a user