Initial commit: Lagerbestand Blazor Projekt
This commit is contained in:
commit
cba9414e92
105 files changed
+36431
No files matched your search
@@ -0,0 +1,451 @@
|
||||
@using Lagerbestand.Models
|
||||
@using Lagerbestand.Services
|
||||
@inject ILocationCodeService LocationCodeService
|
||||
|
||||
<div class="location-code-input" role="group" aria-labelledby="location-input-title">
|
||||
<h4 id="location-input-title" class="input-section-title">Stellplatz-Daten</h4>
|
||||
|
||||
<div class="input-row">
|
||||
@* Area Selection - Required *@
|
||||
<div class="input-group area-select @(areaError != null ? "has-error" : areaTouched && areaError == null ? "has-success" : "")">
|
||||
<label for="area-select">
|
||||
Gebäude <span class="required-indicator" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<select id="area-select"
|
||||
class="form-control @(areaError != null ? "is-invalid" : areaTouched && areaError == null ? "is-valid" : "")"
|
||||
@bind="InternalArea"
|
||||
@bind:after="OnAreaChanged"
|
||||
@onfocus="() => areaTouched = true"
|
||||
@ref="areaSelectRef"
|
||||
aria-required="true"
|
||||
aria-invalid="@(areaError != null ? "true" : "false")"
|
||||
aria-describedby="@(areaError != null ? "area-error" : null)">
|
||||
@if (!areaTouched && InternalArea == default)
|
||||
{
|
||||
<option value="" disabled selected>Gebäude auswählen...</option>
|
||||
}
|
||||
<option value="@AreaType.Werkstatt">Gebäude 1 – Werkstatt</option>
|
||||
<option value="@AreaType.Lager">Gebäude 2 – Lager</option>
|
||||
</select>
|
||||
@if (areaError != null)
|
||||
{
|
||||
<span id="area-error" class="field-error" role="alert">
|
||||
<span class="error-icon">?</span> @areaError
|
||||
</span>
|
||||
}
|
||||
else if (areaTouched)
|
||||
{
|
||||
<span class="field-success">
|
||||
<span class="success-icon">?</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Row Selection - Required *@
|
||||
<div class="input-group row-input @(rowError != null ? "has-error" : rowTouched && rowError == null ? "has-success" : "")">
|
||||
<label for="row-select">
|
||||
Reihe <span class="required-indicator" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<select id="row-select"
|
||||
class="form-control @(rowError != null ? "is-invalid" : rowTouched && rowError == null ? "is-valid" : "")"
|
||||
@bind="InternalRow"
|
||||
@bind:after="OnRowChanged"
|
||||
@onfocus="() => rowTouched = true"
|
||||
aria-required="true"
|
||||
aria-invalid="@(rowError != null ? "true" : "false")"
|
||||
aria-describedby="@(rowError != null ? "row-error" : null)">
|
||||
@for (int r = StorageLocation.MinRow; r <= InternalArea.GetMaxRow(); r++)
|
||||
{
|
||||
<option value="@r">@r</option>
|
||||
}
|
||||
</select>
|
||||
@if (rowError != null)
|
||||
{
|
||||
<span id="row-error" class="field-error" role="alert">
|
||||
<span class="error-icon">?</span> @rowError
|
||||
</span>
|
||||
}
|
||||
else if (rowTouched)
|
||||
{
|
||||
<span class="field-success">
|
||||
<span class="success-icon">?</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Position Selection (Seite/Ebene) - Required *@
|
||||
<div class="input-group position-select">
|
||||
<label for="position-select">
|
||||
Seite/Ebene <span class="required-indicator" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<select id="position-select"
|
||||
class="form-control"
|
||||
@bind="InternalPosition"
|
||||
@bind:after="OnPositionChanged"
|
||||
aria-required="true">
|
||||
@foreach (var pos in ShelfPositionExtensions.All)
|
||||
{
|
||||
<option value="@pos">@pos.GetDisplayName()</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@* Slot Input - Required *@
|
||||
<div class="input-group slot-input @(slotError != null ? "has-error" : slotTouched && slotError == null ? "has-success" : "")">
|
||||
<label for="slot-input">
|
||||
Platz <span class="required-indicator" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input type="number"
|
||||
id="slot-input"
|
||||
class="form-control @(slotError != null ? "is-invalid" : slotTouched && slotError == null ? "is-valid" : "")"
|
||||
min="@StorageLocation.MinSlot"
|
||||
max="@StorageLocation.MaxSlot"
|
||||
placeholder="1-9"
|
||||
@bind="InternalSlot"
|
||||
@bind:after="OnSlotChanged"
|
||||
@onfocus="() => slotTouched = true"
|
||||
@onblur="ValidateSlot"
|
||||
aria-required="true"
|
||||
aria-invalid="@(slotError != null ? "true" : "false")"
|
||||
aria-describedby="@(slotError != null ? "slot-error" : "slot-hint")" />
|
||||
<span id="slot-hint" class="field-hint">@StorageLocation.MinSlot - @StorageLocation.MaxSlot</span>
|
||||
@if (slotError != null)
|
||||
{
|
||||
<span id="slot-error" class="field-error" role="alert">
|
||||
<span class="error-icon">?</span> @slotError
|
||||
</span>
|
||||
}
|
||||
else if (slotTouched)
|
||||
{
|
||||
<span class="field-success">
|
||||
<span class="success-icon">?</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Location Code Preview - Prominent Display *@
|
||||
<div class="code-preview-section">
|
||||
<div class="code-preview-header">
|
||||
<span class="preview-label">Stellplatz-Code</span>
|
||||
</div>
|
||||
<div class="code-preview-display @(IsFormValid ? "valid" : HasAnyError ? "invalid" : "")">
|
||||
<span class="code-value">@(IsFormValid ? GeneratedCode : "----")</span>
|
||||
</div>
|
||||
<div class="code-preview-description">
|
||||
@if (IsFormValid)
|
||||
{
|
||||
<span class="display-text">@DisplayText</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="display-text placeholder">Bitte alle Felder ausfüllen</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Availability Status *@
|
||||
@if (ShowAvailability && IsFormValid)
|
||||
{
|
||||
<div class="availability-status @GetAvailabilityClass()" role="status" aria-live="polite">
|
||||
@if (isChecking)
|
||||
{
|
||||
<span class="status-spinner"></span>
|
||||
<span>Verfügbarkeit wird geprüft...</span>
|
||||
}
|
||||
else if (isAvailable)
|
||||
{
|
||||
<span class="status-icon available-icon">?</span>
|
||||
<span>Verfügbar</span>
|
||||
}
|
||||
else if (locationExists)
|
||||
{
|
||||
<span class="status-icon unavailable-icon">?</span>
|
||||
<span>Dieser Stellplatz ist bereits belegt</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="status-icon new-icon">+</span>
|
||||
<span>Neuer Stellplatz</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ElementReference areaSelectRef;
|
||||
|
||||
// Internal state for two-way binding
|
||||
private AreaType InternalArea { get; set; } = AreaType.Lager;
|
||||
private int InternalRow { get; set; } = 1;
|
||||
private ShelfPosition InternalPosition { get; set; } = ShelfPosition.LinksOben;
|
||||
private int InternalSlot { get; set; } = 1;
|
||||
|
||||
// Touch/validation state
|
||||
private bool areaTouched;
|
||||
private bool rowTouched;
|
||||
private bool slotTouched;
|
||||
|
||||
// Field-level errors
|
||||
private string? areaError;
|
||||
private string? rowError;
|
||||
private string? slotError;
|
||||
|
||||
// Availability check state
|
||||
private bool isChecking;
|
||||
private bool isAvailable;
|
||||
private bool locationExists;
|
||||
private CancellationTokenSource? _checkCts;
|
||||
|
||||
[Parameter]
|
||||
public AreaType SelectedArea { get; set; } = AreaType.Lager;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<AreaType> SelectedAreaChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int SelectedRow { get; set; } = 1;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> SelectedRowChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public ShelfPosition SelectedPosition { get; set; } = ShelfPosition.LinksOben;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ShelfPosition> SelectedPositionChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int SelectedSlot { get; set; } = 1;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> SelectedSlotChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? LocationCode { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> LocationCodeChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool ShowAvailability { get; set; } = true;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<bool> OnAvailabilityChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<bool> OnValidationChanged { get; set; }
|
||||
|
||||
private string GeneratedCode => LocationCodeService.GenerateLocationCode(InternalArea, InternalRow, InternalPosition, InternalSlot);
|
||||
private string DisplayText => $"{InternalArea.GetDisplayName()}, Reihe {InternalRow}, {InternalPosition.GetDisplayName()}, Platz {InternalSlot}";
|
||||
|
||||
private bool IsFormValid => areaError == null && rowError == null && slotError == null &&
|
||||
InternalSlot >= StorageLocation.MinSlot && InternalSlot <= StorageLocation.MaxSlot;
|
||||
private bool HasAnyError => areaError != null || rowError != null || slotError != null;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Initialize internal state from parameters
|
||||
InternalArea = SelectedArea;
|
||||
InternalRow = SelectedRow;
|
||||
InternalPosition = SelectedPosition;
|
||||
InternalSlot = SelectedSlot;
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// Sync internal state with parameters
|
||||
if (InternalArea != SelectedArea) InternalArea = SelectedArea;
|
||||
if (InternalRow != SelectedRow) InternalRow = SelectedRow;
|
||||
if (InternalPosition != SelectedPosition) InternalPosition = SelectedPosition;
|
||||
if (InternalSlot != SelectedSlot) InternalSlot = SelectedSlot;
|
||||
|
||||
// Mark as touched if values are set
|
||||
if (SelectedArea != default) areaTouched = true;
|
||||
if (SelectedRow > 0) rowTouched = true;
|
||||
if (SelectedSlot > 0) slotTouched = true;
|
||||
|
||||
// Only check availability if we have valid values to check
|
||||
// Skip the DB call during initial parameter binding to avoid concurrency issues
|
||||
if (areaTouched && InternalRow > 0 && InternalSlot > 0)
|
||||
{
|
||||
await CheckAvailabilityAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
// Auto-focus the first field
|
||||
try
|
||||
{
|
||||
await areaSelectRef.FocusAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Focus may fail in some scenarios, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GetAvailabilityClass()
|
||||
{
|
||||
if (isChecking) return "checking";
|
||||
if (isAvailable) return "available";
|
||||
if (locationExists) return "unavailable";
|
||||
return "new";
|
||||
}
|
||||
|
||||
private async Task OnAreaChanged()
|
||||
{
|
||||
areaTouched = true;
|
||||
// Clamp row to the new building's max row
|
||||
if (InternalRow > InternalArea.GetMaxRow())
|
||||
InternalRow = InternalArea.GetMaxRow();
|
||||
ValidateArea();
|
||||
ValidateRow();
|
||||
await EmitChanges();
|
||||
}
|
||||
|
||||
private async Task OnPositionChanged()
|
||||
{
|
||||
await EmitChanges();
|
||||
}
|
||||
|
||||
private async Task OnRowChanged()
|
||||
{
|
||||
rowTouched = true;
|
||||
ValidateRow();
|
||||
await EmitChanges();
|
||||
}
|
||||
|
||||
private async Task OnSlotChanged()
|
||||
{
|
||||
slotTouched = true;
|
||||
ValidateSlot();
|
||||
await EmitChanges();
|
||||
}
|
||||
|
||||
private void ValidateArea()
|
||||
{
|
||||
areaError = null;
|
||||
// Area is always valid since it's a select with predefined options
|
||||
}
|
||||
|
||||
private void ValidateRow()
|
||||
{
|
||||
rowError = null;
|
||||
var maxRow = InternalArea.GetMaxRow();
|
||||
if (InternalRow < StorageLocation.MinRow || InternalRow > maxRow)
|
||||
{
|
||||
rowError = $"Reihe muss für {InternalArea.GetDisplayName()} zwischen {StorageLocation.MinRow} und {maxRow} liegen";
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateSlot()
|
||||
{
|
||||
slotError = null;
|
||||
if (InternalSlot < StorageLocation.MinSlot || InternalSlot > StorageLocation.MaxSlot)
|
||||
{
|
||||
slotError = $"Platz muss zwischen {StorageLocation.MinSlot} und {StorageLocation.MaxSlot} liegen";
|
||||
}
|
||||
}
|
||||
|
||||
public bool ValidateAll()
|
||||
{
|
||||
areaTouched = true;
|
||||
rowTouched = true;
|
||||
slotTouched = true;
|
||||
|
||||
ValidateArea();
|
||||
ValidateRow();
|
||||
ValidateSlot();
|
||||
|
||||
StateHasChanged();
|
||||
return IsFormValid;
|
||||
}
|
||||
|
||||
private async Task EmitChanges()
|
||||
{
|
||||
await SelectedAreaChanged.InvokeAsync(InternalArea);
|
||||
await SelectedRowChanged.InvokeAsync(InternalRow);
|
||||
await SelectedPositionChanged.InvokeAsync(InternalPosition);
|
||||
await SelectedSlotChanged.InvokeAsync(InternalSlot);
|
||||
await LocationCodeChanged.InvokeAsync(GeneratedCode);
|
||||
await OnValidationChanged.InvokeAsync(IsFormValid);
|
||||
|
||||
await CheckAvailabilityAsync();
|
||||
}
|
||||
|
||||
private async Task CheckAvailabilityAsync()
|
||||
{
|
||||
if (!ShowAvailability || !IsFormValid)
|
||||
{
|
||||
isAvailable = false;
|
||||
locationExists = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel previous check
|
||||
_checkCts?.Cancel();
|
||||
_checkCts = new CancellationTokenSource();
|
||||
var token = _checkCts.Token;
|
||||
|
||||
isChecking = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// Debounce delay
|
||||
await Task.Delay(300, token);
|
||||
|
||||
var code = GeneratedCode;
|
||||
locationExists = await LocationCodeService.CodeExistsAsync(code);
|
||||
isAvailable = !locationExists || await LocationCodeService.IsLocationAvailableAsync(code);
|
||||
|
||||
await OnAvailabilityChanged.InvokeAsync(isAvailable && !locationExists);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cancelled, ignore
|
||||
}
|
||||
finally
|
||||
{
|
||||
isChecking = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
InternalArea = AreaType.Lager;
|
||||
InternalRow = 1;
|
||||
InternalPosition = ShelfPosition.LinksOben;
|
||||
InternalSlot = 1;
|
||||
areaTouched = false;
|
||||
rowTouched = false;
|
||||
slotTouched = false;
|
||||
areaError = null;
|
||||
rowError = null;
|
||||
slotError = null;
|
||||
isAvailable = false;
|
||||
locationExists = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void SetCode(string code)
|
||||
{
|
||||
if (LocationCodeService.TryParseLocationCode(code, out var parsed) && parsed != null)
|
||||
{
|
||||
InternalArea = parsed.Area;
|
||||
InternalRow = parsed.Row;
|
||||
InternalPosition = parsed.Position;
|
||||
InternalSlot = parsed.SlotNumber;
|
||||
areaTouched = true;
|
||||
rowTouched = true;
|
||||
slotTouched = true;
|
||||
ValidateAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/* ========================================
|
||||
Location Code Input Component
|
||||
Uses global CSS variables from app.css
|
||||
======================================== */
|
||||
|
||||
.location-code-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Section Title
|
||||
======================================== */
|
||||
.input-section-title {
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Input Row - Responsive Grid
|
||||
======================================== */
|
||||
.input-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.input-row {
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Input Group with Validation States
|
||||
======================================== */
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* Required field indicator */
|
||||
.required-indicator {
|
||||
color: var(--color-primary);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Form Controls with Validation States
|
||||
======================================== */
|
||||
.input-group .form-control {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1.5px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--text-base);
|
||||
box-sizing: border-box;
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
|
||||
.input-group .form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.input-group .form-control::placeholder {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* Valid state */
|
||||
.input-group .form-control.is-valid {
|
||||
border-color: rgba(34, 197, 94, 0.6);
|
||||
background: rgba(34, 197, 94, 0.05);
|
||||
}
|
||||
|
||||
.input-group .form-control.is-valid:focus {
|
||||
border-color: var(--color-success);
|
||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
/* Invalid state */
|
||||
.input-group .form-control.is-invalid {
|
||||
border-color: rgba(239, 68, 68, 0.6);
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.input-group .form-control.is-invalid:focus {
|
||||
border-color: var(--color-error);
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Select styling */
|
||||
.input-group select.form-control {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23ffffff' fill-opacity='0.5' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 36px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input-group select.form-control option {
|
||||
background: #1c1c1e;
|
||||
color: var(--text-primary);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Number input styling */
|
||||
.input-group input[type="number"].form-control {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.input-group input[type="number"].form-control::-webkit-outer-spin-button,
|
||||
.input-group input[type="number"].form-control::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Field Hint & Validation Messages
|
||||
======================================== */
|
||||
.field-hint {
|
||||
font-family: var(--font-family-base);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-error-light);
|
||||
margin-top: 4px;
|
||||
animation: slideIn var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.field-success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-success);
|
||||
margin-top: 4px;
|
||||
animation: slideIn var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Input group states */
|
||||
.input-group.has-error .form-control {
|
||||
border-color: rgba(239, 68, 68, 0.6);
|
||||
}
|
||||
|
||||
.input-group.has-success .form-control {
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Code Preview Section - Prominent Display
|
||||
======================================== */
|
||||
.code-preview-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5);
|
||||
background: linear-gradient(135deg, var(--surface-primary) 0%, rgba(0, 0, 0, 0.4) 100%);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.code-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Main code display */
|
||||
.code-preview-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 2px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--duration-normal) var(--ease-default);
|
||||
}
|
||||
|
||||
.code-preview-display.valid {
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
}
|
||||
|
||||
.code-preview-display.invalid {
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.code-value {
|
||||
font-family: var(--font-family-heading);
|
||||
font-size: 2rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--text-primary);
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.code-value {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.code-preview-display.valid .code-value {
|
||||
color: var(--color-success-light);
|
||||
}
|
||||
|
||||
.code-preview-display.invalid .code-value {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* Description text */
|
||||
.code-preview-description {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.display-text {
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.display-text.placeholder {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Availability Status
|
||||
======================================== */
|
||||
.availability-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
|
||||
.availability-status.checking {
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
border: 1px solid rgba(251, 191, 36, 0.25);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.availability-status.available,
|
||||
.availability-status.new {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.availability-status.unavailable {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
color: var(--color-error-light);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.available-icon,
|
||||
.new-icon {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.unavailable-icon {
|
||||
color: var(--color-error-light);
|
||||
}
|
||||
|
||||
/* Loading spinner for availability check */
|
||||
.status-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(251, 191, 36, 0.3);
|
||||
border-top-color: var(--color-warning);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Responsive Adjustments
|
||||
======================================== */
|
||||
@media (max-width: 479.98px) {
|
||||
.code-preview-section {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.code-value {
|
||||
font-size: 1.75rem;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.availability-status {
|
||||
font-size: var(--text-sm);
|
||||
padding: 0.65rem 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion: inherited from app.css (section 17) */
|
||||
@@ -0,0 +1,188 @@
|
||||
@using Lagerbestand.Models
|
||||
@using Lagerbestand.Services
|
||||
@inject ILocationCodeService LocationCodeService
|
||||
|
||||
@if (!string.IsNullOrEmpty(Code))
|
||||
{
|
||||
<div class="location-display @SizeClass @AreaClass @(IsOccupied ? "occupied" : "available")">
|
||||
@if (ShowIcon)
|
||||
{
|
||||
<span class="area-icon">@AreaIcon</span>
|
||||
}
|
||||
|
||||
<div class="location-content">
|
||||
@if (DisplayMode == LocationDisplayMode.Code)
|
||||
{
|
||||
<span class="location-code">@Code</span>
|
||||
}
|
||||
else if (DisplayMode == LocationDisplayMode.Full)
|
||||
{
|
||||
<span class="location-code">@Code</span>
|
||||
<span class="location-text">@DisplayText</span>
|
||||
}
|
||||
else if (DisplayMode == LocationDisplayMode.Text)
|
||||
{
|
||||
<span class="location-text">@DisplayText</span>
|
||||
}
|
||||
else if (DisplayMode == LocationDisplayMode.Compact)
|
||||
{
|
||||
<span class="location-compact">@ShortText</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (ShowStatus)
|
||||
{
|
||||
<span class="status-indicator @(IsOccupied ? "occupied" : "available")" title="@(IsOccupied ? "Belegt" : "Frei")">
|
||||
@(IsOccupied ? "?" : "?")
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="location-empty">-</span>
|
||||
}
|
||||
|
||||
<style>
|
||||
.location-display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Size variants */
|
||||
.location-display.size-sm {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.location-display.size-md {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.location-display.size-lg {
|
||||
font-size: 1.1rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
}
|
||||
|
||||
/* Area color coding */
|
||||
.location-display.area-lager {
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
border: 1px solid rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
.location-display.area-werkstatt {
|
||||
background: rgba(155, 89, 182, 0.1);
|
||||
border: 1px solid rgba(155, 89, 182, 0.3);
|
||||
}
|
||||
|
||||
/* Occupancy styling */
|
||||
.location-display.occupied {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.area-icon {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.location-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.location-code {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.location-text {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.location-compact {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.status-indicator.available {
|
||||
color: var(--color-success, #28a745);
|
||||
}
|
||||
|
||||
.status-indicator.occupied {
|
||||
color: var(--color-error, #dc3545);
|
||||
}
|
||||
|
||||
.location-empty {
|
||||
color: var(--text-muted, #999);
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? Code { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public LocationDisplayMode DisplayMode { get; set; } = LocationDisplayMode.Full;
|
||||
|
||||
[Parameter]
|
||||
public LocationDisplaySize Size { get; set; } = LocationDisplaySize.Medium;
|
||||
|
||||
[Parameter]
|
||||
public bool ShowIcon { get; set; } = true;
|
||||
|
||||
[Parameter]
|
||||
public bool ShowStatus { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsOccupied { get; set; }
|
||||
|
||||
private ParsedLocationCode? _parsed;
|
||||
|
||||
private string SizeClass => Size switch
|
||||
{
|
||||
LocationDisplaySize.Small => "size-sm",
|
||||
LocationDisplaySize.Large => "size-lg",
|
||||
_ => "size-md"
|
||||
};
|
||||
|
||||
private string AreaClass => _parsed?.Area switch
|
||||
{
|
||||
AreaType.Lager => "area-lager",
|
||||
AreaType.Werkstatt => "area-werkstatt",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
private string AreaIcon => _parsed?.Area switch
|
||||
{
|
||||
AreaType.Lager => "??",
|
||||
AreaType.Werkstatt => "??",
|
||||
_ => "??"
|
||||
};
|
||||
|
||||
private string DisplayText => _parsed != null
|
||||
? $"{_parsed.Area.GetDisplayName()}, Reihe {_parsed.Row}, {_parsed.Position.GetDisplayName()}, Platz {_parsed.SlotNumber}"
|
||||
: Code ?? "";
|
||||
|
||||
private string ShortText => _parsed != null
|
||||
? $"{_parsed.Area.GetDisplayName()} R{_parsed.Row} {_parsed.Position.GetShortName()} P{_parsed.SlotNumber}"
|
||||
: Code ?? "";
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_parsed = null;
|
||||
if (!string.IsNullOrEmpty(Code) && LocationCodeService.TryParseLocationCode(Code, out var parsed))
|
||||
{
|
||||
_parsed = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace Lagerbestand.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Display modes for the LocationDisplay component.
|
||||
/// </summary>
|
||||
public enum LocationDisplayMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Just the code "2235"
|
||||
/// </summary>
|
||||
Code,
|
||||
|
||||
/// <summary>
|
||||
/// Code + full text "2235" + "Lager, Reihe 2, Rechts oben, Platz 5"
|
||||
/// </summary>
|
||||
Full,
|
||||
|
||||
/// <summary>
|
||||
/// Full text only "Lager, Reihe 2, Rechts oben, Platz 5"
|
||||
/// </summary>
|
||||
Text,
|
||||
|
||||
/// <summary>
|
||||
/// Compact format "Lager R2 R↑ P5"
|
||||
/// </summary>
|
||||
Compact
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Size variants for the LocationDisplay component.
|
||||
/// </summary>
|
||||
public enum LocationDisplaySize
|
||||
{
|
||||
Small,
|
||||
Medium,
|
||||
Large
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
@*
|
||||
SeoMetaTags Component
|
||||
Renders SEO meta tags dynamically per page via HeadContent
|
||||
Usage: <SeoMetaTags Title="..." Description="..." Keywords="..." />
|
||||
*@
|
||||
|
||||
<HeadContent>
|
||||
@* Primary Meta Tags *@
|
||||
@if (!string.IsNullOrEmpty(Description))
|
||||
{
|
||||
<meta name="description" content="@Description" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Keywords))
|
||||
{
|
||||
<meta name="keywords" content="@Keywords" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Robots))
|
||||
{
|
||||
<meta name="robots" content="@Robots" />
|
||||
<meta name="googlebot" content="@Robots" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Author))
|
||||
{
|
||||
<meta name="author" content="@Author" />
|
||||
}
|
||||
|
||||
@* Canonical URL *@
|
||||
@if (!string.IsNullOrEmpty(CanonicalUrl))
|
||||
{
|
||||
<link rel="canonical" href="@CanonicalUrl" />
|
||||
<link rel="alternate" hreflang="de-DE" href="@CanonicalUrl" />
|
||||
<link rel="alternate" hreflang="x-default" href="@CanonicalUrl" />
|
||||
}
|
||||
|
||||
@* Open Graph Meta Tags *@
|
||||
@if (!string.IsNullOrEmpty(OgTitle ?? Title))
|
||||
{
|
||||
<meta property="og:title" content="@(OgTitle ?? Title)" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(OgDescription ?? Description))
|
||||
{
|
||||
<meta property="og:description" content="@(OgDescription ?? Description)" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(OgType))
|
||||
{
|
||||
<meta property="og:type" content="@OgType" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(OgUrl ?? CanonicalUrl))
|
||||
{
|
||||
<meta property="og:url" content="@(OgUrl ?? CanonicalUrl)" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(OgImage))
|
||||
{
|
||||
<meta property="og:image" content="@OgImage" />
|
||||
@if (OgImageWidth > 0)
|
||||
{
|
||||
<meta property="og:image:width" content="@OgImageWidth" />
|
||||
}
|
||||
@if (OgImageHeight > 0)
|
||||
{
|
||||
<meta property="og:image:height" content="@OgImageHeight" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(OgImageAlt))
|
||||
{
|
||||
<meta property="og:image:alt" content="@OgImageAlt" />
|
||||
}
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(OgLocale))
|
||||
{
|
||||
<meta property="og:locale" content="@OgLocale" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(OgSiteName))
|
||||
{
|
||||
<meta property="og:site_name" content="@OgSiteName" />
|
||||
}
|
||||
|
||||
@* Twitter Card Meta Tags *@
|
||||
@if (!string.IsNullOrEmpty(TwitterCard))
|
||||
{
|
||||
<meta name="twitter:card" content="@TwitterCard" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(TwitterTitle ?? OgTitle ?? Title))
|
||||
{
|
||||
<meta name="twitter:title" content="@(TwitterTitle ?? OgTitle ?? Title)" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(TwitterDescription ?? OgDescription ?? Description))
|
||||
{
|
||||
<meta name="twitter:description" content="@(TwitterDescription ?? OgDescription ?? Description)" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(TwitterImage ?? OgImage))
|
||||
{
|
||||
<meta name="twitter:image" content="@(TwitterImage ?? OgImage)" />
|
||||
@if (!string.IsNullOrEmpty(TwitterImageAlt ?? OgImageAlt))
|
||||
{
|
||||
<meta name="twitter:image:alt" content="@(TwitterImageAlt ?? OgImageAlt)" />
|
||||
}
|
||||
}
|
||||
|
||||
@* Article-specific metadata (for blog posts, news articles) *@
|
||||
@if (!string.IsNullOrEmpty(ArticlePublishedTime))
|
||||
{
|
||||
<meta property="article:published_time" content="@ArticlePublishedTime" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(ArticleModifiedTime))
|
||||
{
|
||||
<meta property="article:modified_time" content="@ArticleModifiedTime" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(ArticleAuthor))
|
||||
{
|
||||
<meta property="article:author" content="@ArticleAuthor" />
|
||||
}
|
||||
|
||||
@* Structured Data (JSON-LD) *@
|
||||
@if (!string.IsNullOrEmpty(JsonLd))
|
||||
{
|
||||
<script type="application/ld+json">@((MarkupString)JsonLd)</script>
|
||||
}
|
||||
</HeadContent>
|
||||
|
||||
@code {
|
||||
/// <summary>Page title (used as fallback for OG/Twitter)</summary>
|
||||
[Parameter] public string? Title { get; set; }
|
||||
|
||||
/// <summary>Meta description for search engines (max 160 chars recommended)</summary>
|
||||
[Parameter] public string? Description { get; set; }
|
||||
|
||||
/// <summary>Comma-separated keywords</summary>
|
||||
[Parameter] public string? Keywords { get; set; }
|
||||
|
||||
/// <summary>Robots directive (e.g., "index, follow" or "noindex, nofollow")</summary>
|
||||
[Parameter] public string? Robots { get; set; } = "index, follow";
|
||||
|
||||
/// <summary>Author name</summary>
|
||||
[Parameter] public string? Author { get; set; }
|
||||
|
||||
/// <summary>Canonical URL for the page</summary>
|
||||
[Parameter] public string? CanonicalUrl { get; set; }
|
||||
|
||||
// Open Graph parameters
|
||||
[Parameter] public string? OgTitle { get; set; }
|
||||
[Parameter] public string? OgDescription { get; set; }
|
||||
[Parameter] public string? OgType { get; set; } = "website";
|
||||
[Parameter] public string? OgUrl { get; set; }
|
||||
[Parameter] public string? OgImage { get; set; }
|
||||
[Parameter] public int OgImageWidth { get; set; } = 1200;
|
||||
[Parameter] public int OgImageHeight { get; set; } = 630;
|
||||
[Parameter] public string? OgImageAlt { get; set; }
|
||||
[Parameter] public string? OgLocale { get; set; } = "de_DE";
|
||||
[Parameter] public string? OgSiteName { get; set; } = "Reifen Späth";
|
||||
|
||||
// Twitter Card parameters
|
||||
[Parameter] public string? TwitterCard { get; set; } = "summary_large_image";
|
||||
[Parameter] public string? TwitterTitle { get; set; }
|
||||
[Parameter] public string? TwitterDescription { get; set; }
|
||||
[Parameter] public string? TwitterImage { get; set; }
|
||||
[Parameter] public string? TwitterImageAlt { get; set; }
|
||||
|
||||
// Article metadata (for content pages)
|
||||
[Parameter] public string? ArticlePublishedTime { get; set; }
|
||||
[Parameter] public string? ArticleModifiedTime { get; set; }
|
||||
[Parameter] public string? ArticleAuthor { get; set; }
|
||||
|
||||
/// <summary>JSON-LD structured data string</summary>
|
||||
[Parameter] public string? JsonLd { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
@* Masked input for tire sizes in the format ###/## R## (e.g. 225/45 R17).
|
||||
The user only types digits; separators are inserted automatically. *@
|
||||
|
||||
<input type="text"
|
||||
class="form-control @CssClass"
|
||||
inputmode="numeric"
|
||||
maxlength="10"
|
||||
placeholder="@Placeholder"
|
||||
value="@Value"
|
||||
@oninput="OnInput" />
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Value { get; set; }
|
||||
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
|
||||
[Parameter] public string Placeholder { get; set; } = "z.B. 225/45 R17";
|
||||
[Parameter] public string? CssClass { get; set; }
|
||||
|
||||
private async Task OnInput(ChangeEventArgs e)
|
||||
{
|
||||
var raw = e.Value?.ToString() ?? string.Empty;
|
||||
var formatted = FormatTireSize(raw);
|
||||
Value = formatted;
|
||||
await ValueChanged.InvokeAsync(formatted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats digit input into the tire size mask ###/## R##.
|
||||
/// Examples: "2254517" -> "225/45 R17", "22545" -> "225/45", "225" -> "225"
|
||||
/// </summary>
|
||||
private static string FormatTireSize(string input)
|
||||
{
|
||||
var digits = new string(input.Where(char.IsDigit).Take(7).ToArray());
|
||||
|
||||
if (digits.Length == 0)
|
||||
return string.Empty;
|
||||
|
||||
if (digits.Length <= 3)
|
||||
return digits;
|
||||
|
||||
if (digits.Length <= 5)
|
||||
return $"{digits[..3]}/{digits[3..]}";
|
||||
|
||||
return $"{digits[..3]}/{digits[3..5]} R{digits[5..]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Lagerbestand.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Helper that wires a page's reload method to the browser's
|
||||
/// <c>visibilitychange</c> event via <c>appInterop.registerVisibilityRefresh</c>.
|
||||
/// The caller owns the <see cref="DotNetObjectReference"/> passed in.
|
||||
/// </summary>
|
||||
public static class VisibilityRefresh
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a .NET callback invoked when the browser tab becomes
|
||||
/// visible again after being hidden for at least <paramref name="thresholdSeconds"/>.
|
||||
/// </summary>
|
||||
public static async Task RegisterAsync<T>(
|
||||
IJSRuntime js,
|
||||
DotNetObjectReference<T> dotNetRef,
|
||||
string methodName,
|
||||
int thresholdSeconds = 120) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
await js.InvokeVoidAsync("appInterop.registerVisibilityRefresh", dotNetRef, methodName, thresholdSeconds);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Circuit may be mid-reconnect — non-fatal.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters the visibility callback. Safe to call multiple times.
|
||||
/// </summary>
|
||||
public static async Task UnregisterAsync(IJSRuntime js)
|
||||
{
|
||||
try
|
||||
{
|
||||
await js.InvokeVoidAsync("appInterop.unregisterVisibilityRefresh");
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user