Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions src/SpoolManager.Client/Components/FindSpoolModal.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
@implements IAsyncDisposable
@inject NfcService Nfc
@inject LocalizationService L

@if (_visible)
{
<div class="modal fade show d-block" tabindex="-1" style="background:rgba(0,0,0,.55)">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-search me-2"></i>@L["spool.find.title"]
</h5>
<button type="button" class="btn-close" @onclick="CloseAsync"></button>
</div>
<div class="modal-body text-center py-4">
<div class="d-flex align-items-center justify-content-center gap-2 mb-4">
<div class="color-dot" style="background-color: #@Spool.MaterialColorHex; width:1.1rem; height:1.1rem; border-radius:50%"></div>
<span class="fw-semibold">@Spool.MaterialBrand @Spool.MaterialType</span>
</div>

@if (_state == FindState.Found)
{
<div class="text-success mb-2">
<i class="bi bi-check-circle-fill" style="font-size:3.5rem"></i>
</div>
<p class="fw-semibold text-success mb-0">@L["spool.find.found"]</p>
}
else if (_state == FindState.NoTag)
{
<div class="text-warning mb-2">
<i class="bi bi-exclamation-triangle-fill" style="font-size:3rem"></i>
</div>
<p class="text-muted mb-0">@L["spool.find.no.tag"]</p>
}
else
{
<div class="mb-3">
@if (_state == FindState.Mismatch)
{
<i class="bi bi-x-circle-fill text-danger" style="font-size:3rem"></i>
<p class="text-danger fw-semibold mt-2 mb-0">@L["spool.find.wrong"]</p>
<small class="text-muted">@L["spool.find.retry"]</small>
}
else
{
<div class="nfc-pulse-icon mb-2">
<i class="bi bi-broadcast" style="font-size:3rem; color: var(--bs-primary)"></i>
</div>
<p class="text-muted mb-0">@L["spool.find.scanning"]</p>
}
</div>
}
</div>
@if (_state != FindState.Found)
{
<div class="modal-footer justify-content-center">
<button class="btn btn-secondary" @onclick="CloseAsync">@L["common.cancel"]</button>
</div>
}
</div>
</div>
</div>
}

@code {
[Parameter, EditorRequired] public SpoolDto Spool { get; set; } = default!;

private enum FindState { Idle, Scanning, Mismatch, Found, NoTag }

private bool _visible;
private FindState _state = FindState.Idle;
private DotNetObjectReference<FindSpoolModal>? _ref;
private System.Timers.Timer? _resetTimer;

public async Task OpenAsync()
{
if (string.IsNullOrWhiteSpace(Spool.RfidTagUid))
{
_state = FindState.NoTag;
_visible = true;
StateHasChanged();
return;
}

_state = FindState.Scanning;
_visible = true;
StateHasChanged();

_ref ??= DotNetObjectReference.Create(this);
await Nfc.StartReadAsync(_ref);
}

[JSInvokable]
public void OnScanStarted() { }

[JSInvokable]
public async void OnTagRead(string json, string serialNumber)
{
_resetTimer?.Stop();

var uid = serialNumber.Trim().ToLowerInvariant().Replace(":", "").Replace("-", "");
var expected = (Spool.RfidTagUid ?? "").Trim().ToLowerInvariant().Replace(":", "").Replace("-", "");

if (uid == expected)
{
_state = FindState.Found;
await InvokeAsync(StateHasChanged);
await Nfc.StopReadAsync();
await Task.Delay(2000);
await CloseAsync();
}
else
{
_state = FindState.Mismatch;
await InvokeAsync(StateHasChanged);

_resetTimer = new System.Timers.Timer(2000);
_resetTimer.AutoReset = false;
_resetTimer.Elapsed += async (_, _) =>
{
_state = FindState.Scanning;
await InvokeAsync(StateHasChanged);
};
_resetTimer.Start();
}
}

[JSInvokable]
public void OnReadError(string message)
{
_state = FindState.Scanning;
InvokeAsync(StateHasChanged);
}

[JSInvokable]
public void OnWriteSuccess() { }

[JSInvokable]
public void OnWriteError(string message) { }

private async Task CloseAsync()
{
_resetTimer?.Stop();
await Nfc.StopReadAsync();
_state = FindState.Idle;
_visible = false;
await InvokeAsync(StateHasChanged);
}

public async ValueTask DisposeAsync()
{
_resetTimer?.Dispose();
await Nfc.StopReadAsync();
_ref?.Dispose();
}
}
18 changes: 18 additions & 0 deletions src/SpoolManager.Client/Pages/Spools/SpoolDetail.razor
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@ else
@if (_writingNfc) { <span class="spinner-border spinner-border-sm me-2"></span> }
<i class="bi bi-broadcast me-1"></i>@L["tag.nfc.write"]
</button>
@if (!string.IsNullOrWhiteSpace(_spool!.RfidTagUid))
{
<button class="btn btn-outline-success" @onclick="FindSpoolAsync">
<i class="bi bi-search me-1"></i>@L["spool.find.title"]
</button>
}
}
<a href="@Tags.GetDownloadUrl(Id)" class="btn btn-outline-secondary">
<i class="bi bi-download me-1"></i>@L["tag.download.bin"]
Expand All @@ -235,11 +241,17 @@ else
</div>
}

@if (_spool != null)
{
<FindSpoolModal @ref="_findModal" Spool="_spool" />
}

@code {
[Parameter] public Guid Id { get; set; }

private bool _loading = true;
private SpoolDto? _spool;
private FindSpoolModal? _findModal;
private bool _showRemainingForm;
private decimal _remainingGrams;
private decimal _remainingPct;
Expand Down Expand Up @@ -281,6 +293,12 @@ else
private async Task MarkReopenedAsync() { await Spools.MarkReopenedAsync(Id); _spool = await Spools.GetByIdAsync(Id); }
private async Task MarkConsumedAsync() { await Spools.MarkConsumedAsync(Id); _spool = await Spools.GetByIdAsync(Id); }

private async Task FindSpoolAsync()
{
if (_findModal != null)
await _findModal.OpenAsync();
}

private async Task WriteNfcAsync()
{
_writingNfc = true;
Expand Down
24 changes: 24 additions & 0 deletions src/SpoolManager.Client/Pages/Spools/SpoolList.razor
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ else
disabled="@(_nfcWriting && _nfcTarget?.Id == spool.Id)">
<i class="bi bi-broadcast"></i>
</button>
@if (!string.IsNullOrWhiteSpace(spool.RfidTagUid))
{
<button class="btn btn-sm btn-outline-success" title="@L["spool.find.title"]"
@onclick="() => FindSpoolAsync(spool)">
<i class="bi bi-search"></i>
</button>
}
}
<button class="btn btn-sm btn-outline-secondary" title="@L["common.copy"]" @onclick="() => CopyAsync(spool)">
<i class="bi bi-copy"></i>
Expand Down Expand Up @@ -164,6 +171,11 @@ else
</div>
}

@if (_findTarget != null)
{
<FindSpoolModal @ref="_findModal" Spool="_findTarget" />
}

@code {
private bool _loading = true;
private List<SpoolDto> _spools = [];
Expand All @@ -179,6 +191,9 @@ else
private bool _nfcSuccess;
private string? _nfcMessage;

private SpoolDto? _findTarget;
private FindSpoolModal? _findModal;

[JSInvokable]
public void OnWriteSuccess()
{
Expand Down Expand Up @@ -276,6 +291,15 @@ else
await Nfc.WriteAsync(encoded.JsonPayload, DotNetObjectReference.Create(this));
}

private async Task FindSpoolAsync(SpoolDto spool)
{
_findTarget = spool;
StateHasChanged();
await Task.Yield();
if (_findModal != null)
await _findModal.OpenAsync();
}

private static string GetBarClass(decimal p) => p >= 50 ? "high" : p >= 20 ? "medium" : "low";

private static (double elapsed, double total, bool done) GetDryingState(SpoolDto s)
Expand Down
15 changes: 15 additions & 0 deletions src/SpoolManager.Client/wwwroot/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,25 @@
white-space: normal;
}

/* Find Spool pulse animation */
@keyframes nfc-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.1); }
}

.nfc-pulse-icon {
animation: nfc-pulse 1.4s ease-in-out infinite;
display: inline-block;
}

/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}

.nfc-pulse-icon {
animation: none;
}
}
7 changes: 7 additions & 0 deletions src/SpoolManager.Client/wwwroot/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,13 @@
"auth.privacy.accept": "Ich habe die",
"auth.terms.required": "Bitte akzeptiere die Nutzungsbedingungen und die Datenschutzerklärung.",

"spool.find.title": "Spule suchen",
"spool.find.scanning": "Handy an den NFC-Tag der Spule halten...",
"spool.find.found": "Richtige Spule gefunden!",
"spool.find.wrong": "Falsche Spule",
"spool.find.retry": "Nächsten Tag versuchen...",
"spool.find.no.tag": "Diese Spule hat keinen NFC-Tag. Zuerst einen Tag schreiben."

"spoolman.title": "Klipper / Spoolman Integration",
"spoolman.hint": "SpoolHero stellt eine Spoolman-kompatible API bereit. Füge folgendes in deine moonraker.conf ein, um automatisches Filament-Tracking zu aktivieren:",
"spoolman.apikeys": "API-Schlüssel",
Expand Down
8 changes: 8 additions & 0 deletions src/SpoolManager.Client/wwwroot/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,14 @@
"auth.privacy.accept": "I have read and agree to the",
"auth.terms.required": "Please accept the Terms of Use and Privacy Policy.",


"spool.find.title": "Find Spool",
"spool.find.scanning": "Hold your phone near the spool's NFC tag...",
"spool.find.found": "Correct spool found!",
"spool.find.wrong": "Wrong spool",
"spool.find.retry": "Try again with another tag...",
"spool.find.no.tag": "This spool has no NFC tag assigned. Write a tag first."

"spoolman.title": "Klipper / Spoolman Integration",
"spoolman.hint": "SpoolHero provides a Spoolman-compatible API. Add the following to your moonraker.conf to enable automatic filament tracking:",
"spoolman.apikeys": "API Keys",
Expand Down