From d8a7a739354548e48b5608fc32be5deb5319b69e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 18:09:19 +0000 Subject: [PATCH 01/19] Initial plan From 5ea19eb147faf1869d873a3493da2464e2b99f73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 18:19:38 +0000 Subject: [PATCH 02/19] feat: create /src architecture with Core, Application, Infrastructure, Cloud, EdgeAgent, Tests.Unit Agent-Logs-Url: https://github.com/akinbender/MakerPrompt/sessions/b6160242-d611-4cd5-aefd-669f3ced06f5 Co-authored-by: akinbender <40242943+akinbender@users.noreply.github.com> --- MakerPrompt.sln | 92 ++++++++++ .../MakerPrompt.Application.csproj | 17 ++ .../Services/PrinterFleetService.cs | 162 ++++++++++++++++++ .../Services/TelemetryAggregationService.cs | 67 ++++++++ .../MakerPrompt.Cloud.csproj | 15 ++ src/MakerPrompt.Cloud/Program.cs | 69 ++++++++ .../Abstractions/IEdgeAgentClient.cs | 28 +++ .../IPrinterCommunicationService.cs | 88 ++++++++++ .../Abstractions/IPrinterProvider.cs | 44 +++++ .../Abstractions/ITelemetryStore.cs | 30 ++++ src/MakerPrompt.Core/MakerPrompt.Core.csproj | 9 + .../Models/PrinterConnectionSettings.cs | 37 ++++ .../Models/PrinterConnectionType.cs | 37 ++++ src/MakerPrompt.Core/Models/PrinterInfo.cs | 28 +++ src/MakerPrompt.Core/Models/PrinterStatus.cs | 22 +++ .../Models/PrinterTelemetry.cs | 55 ++++++ .../MakerPrompt.EdgeAgent.csproj | 20 +++ src/MakerPrompt.EdgeAgent/Program.cs | 22 +++ .../Workers/PrinterPollingWorker.cs | 77 +++++++++ .../MakerPrompt.Infrastructure.csproj | 14 ++ .../Telemetry/InMemoryTelemetryStore.cs | 76 ++++++++ .../Application/PrinterFleetServiceTests.cs | 142 +++++++++++++++ .../Core/InMemoryTelemetryStoreTests.cs | 94 ++++++++++ .../Helpers/FakePrinterService.cs | 80 +++++++++ .../MakerPrompt.Tests.Unit.csproj | 30 ++++ 25 files changed, 1355 insertions(+) create mode 100644 src/MakerPrompt.Application/MakerPrompt.Application.csproj create mode 100644 src/MakerPrompt.Application/Services/PrinterFleetService.cs create mode 100644 src/MakerPrompt.Application/Services/TelemetryAggregationService.cs create mode 100644 src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj create mode 100644 src/MakerPrompt.Cloud/Program.cs create mode 100644 src/MakerPrompt.Core/Abstractions/IEdgeAgentClient.cs create mode 100644 src/MakerPrompt.Core/Abstractions/IPrinterCommunicationService.cs create mode 100644 src/MakerPrompt.Core/Abstractions/IPrinterProvider.cs create mode 100644 src/MakerPrompt.Core/Abstractions/ITelemetryStore.cs create mode 100644 src/MakerPrompt.Core/MakerPrompt.Core.csproj create mode 100644 src/MakerPrompt.Core/Models/PrinterConnectionSettings.cs create mode 100644 src/MakerPrompt.Core/Models/PrinterConnectionType.cs create mode 100644 src/MakerPrompt.Core/Models/PrinterInfo.cs create mode 100644 src/MakerPrompt.Core/Models/PrinterStatus.cs create mode 100644 src/MakerPrompt.Core/Models/PrinterTelemetry.cs create mode 100644 src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj create mode 100644 src/MakerPrompt.EdgeAgent/Program.cs create mode 100644 src/MakerPrompt.EdgeAgent/Workers/PrinterPollingWorker.cs create mode 100644 src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj create mode 100644 src/MakerPrompt.Infrastructure/Telemetry/InMemoryTelemetryStore.cs create mode 100644 src/MakerPrompt.Tests.Unit/Application/PrinterFleetServiceTests.cs create mode 100644 src/MakerPrompt.Tests.Unit/Core/InMemoryTelemetryStoreTests.cs create mode 100644 src/MakerPrompt.Tests.Unit/Helpers/FakePrinterService.cs create mode 100644 src/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj diff --git a/MakerPrompt.sln b/MakerPrompt.sln index 210fac7..53036cc 100644 --- a/MakerPrompt.sln +++ b/MakerPrompt.sln @@ -31,6 +31,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.E2E.Wasm", "Mak EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.E2E.Maui", "MakerPrompt.E2E.Maui\MakerPrompt.E2E.Maui.csproj", "{A03CF971-E571-4E00-849E-48675DAB24D7}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B4F2D4E1-0000-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Core", "src\MakerPrompt.Core\MakerPrompt.Core.csproj", "{B4F2D4E1-0001-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Application", "src\MakerPrompt.Application\MakerPrompt.Application.csproj", "{B4F2D4E1-0002-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Infrastructure", "src\MakerPrompt.Infrastructure\MakerPrompt.Infrastructure.csproj", "{B4F2D4E1-0003-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Cloud", "src\MakerPrompt.Cloud\MakerPrompt.Cloud.csproj", "{B4F2D4E1-0004-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.EdgeAgent", "src\MakerPrompt.EdgeAgent\MakerPrompt.EdgeAgent.csproj", "{B4F2D4E1-0005-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Tests.Unit", "src\MakerPrompt.Tests.Unit\MakerPrompt.Tests.Unit.csproj", "{B4F2D4E1-0006-0000-0000-000000000001}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -114,12 +128,90 @@ Global {A03CF971-E571-4E00-849E-48675DAB24D7}.Release|x64.Build.0 = Release|Any CPU {A03CF971-E571-4E00-849E-48675DAB24D7}.Release|x86.ActiveCfg = Release|Any CPU {A03CF971-E571-4E00-849E-48675DAB24D7}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {8EC462FD-D22E-90A8-E5CE-7E832BA40C5D} + {B4F2D4E1-0001-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0002-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0003-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0004-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0005-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0006-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {545A45A2-4075-429A-AC75-ABFBE72CC15A} diff --git a/src/MakerPrompt.Application/MakerPrompt.Application.csproj b/src/MakerPrompt.Application/MakerPrompt.Application.csproj new file mode 100644 index 0000000..03a0896 --- /dev/null +++ b/src/MakerPrompt.Application/MakerPrompt.Application.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/MakerPrompt.Application/Services/PrinterFleetService.cs b/src/MakerPrompt.Application/Services/PrinterFleetService.cs new file mode 100644 index 0000000..c34315f --- /dev/null +++ b/src/MakerPrompt.Application/Services/PrinterFleetService.cs @@ -0,0 +1,162 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Application.Services; + +/// +/// Application service that manages a fleet of printers. +/// +/// Responsibilities +/// ---------------- +/// • Maintains a registry of active instances, +/// keyed by a stable printer identifier. +/// • Surfaces aggregated fleet status and per-printer telemetry. +/// • Coordinates with implementations to discover +/// printers exposed by cloud/farm accounts, then creates the appropriate +/// backend connection service for each one. +/// +/// Design note +/// ----------- +/// This service lives in the Application layer and has no dependency on Blazor, +/// making it equally usable from the EdgeAgent, the Cloud backend, and any future +/// CLI or native UI host. +/// +public sealed class PrinterFleetService : IAsyncDisposable +{ + private readonly ILogger _logger; + private readonly Dictionary _connections = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + /// Raised when any printer in the fleet changes state. + public event EventHandler? FleetChanged; + + public PrinterFleetService(ILogger logger) + { + _logger = logger; + } + + /// + /// Returns a read-only snapshot of all registered printer identifiers. + /// + public IReadOnlyCollection PrinterIds + { + get + { + lock (_connections) + return _connections.Keys.ToArray(); + } + } + + /// + /// Returns the communication service for the given printer, or null if not registered. + /// + public IPrinterCommunicationService? GetConnection(string printerId) + { + lock (_connections) + return _connections.GetValueOrDefault(printerId); + } + + /// + /// Registers a printer and immediately attempts to connect using the supplied settings. + /// If a connection for already exists it is disconnected + /// and replaced. + /// + public async Task AddAndConnectAsync( + string printerId, + IPrinterCommunicationService service, + PrinterConnectionSettings settings, + CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (_connections.TryGetValue(printerId, out var existing)) + { + await existing.DisconnectAsync(cancellationToken); + await existing.DisposeAsync(); + } + + service.ConnectionStateChanged += (_, connected) => OnFleetChanged(); + service.TelemetryUpdated += (_, _) => OnFleetChanged(); + + var connected = await service.ConnectAsync(settings, cancellationToken); + _connections[printerId] = service; + OnFleetChanged(); + return connected; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect printer {PrinterId}", printerId); + return false; + } + finally + { + _lock.Release(); + } + } + + /// + /// Disconnects and removes the printer with the given identifier from the fleet. + /// + public async Task RemoveAsync(string printerId, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (!_connections.Remove(printerId, out var service)) return; + await service.DisconnectAsync(cancellationToken); + await service.DisposeAsync(); + OnFleetChanged(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing printer {PrinterId}", printerId); + } + finally + { + _lock.Release(); + } + } + + /// + /// Returns the latest telemetry for all connected printers, keyed by printer identifier. + /// + public IReadOnlyDictionary GetFleetTelemetry() + { + lock (_connections) + { + return _connections + .Where(kv => kv.Value.IsConnected) + .ToDictionary(kv => kv.Key, kv => kv.Value.LastTelemetry); + } + } + + private void OnFleetChanged() => FleetChanged?.Invoke(this, EventArgs.Empty); + + public async ValueTask DisposeAsync() + { + await _lock.WaitAsync(); + try + { + foreach (var service in _connections.Values) + { + try + { + await service.DisconnectAsync(); + await service.DisposeAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error disposing printer connection"); + } + } + _connections.Clear(); + } + finally + { + _lock.Release(); + _lock.Dispose(); + } + } +} diff --git a/src/MakerPrompt.Application/Services/TelemetryAggregationService.cs b/src/MakerPrompt.Application/Services/TelemetryAggregationService.cs new file mode 100644 index 0000000..6c28f7b --- /dev/null +++ b/src/MakerPrompt.Application/Services/TelemetryAggregationService.cs @@ -0,0 +1,67 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Application.Services; + +/// +/// Aggregates telemetry from multiple printer connections and forwards snapshots +/// to a for persistence and cloud forwarding. +/// +/// This service is the Application-layer glue between the live +/// and the storage / cloud pipeline: +/// +/// [PrinterFleetService] --(telemetry events)--> [TelemetryAggregationService] +/// | +/// [ITelemetryStore] +/// | +/// (Cloud / SQLite / etc.) +/// +public sealed class TelemetryAggregationService : IDisposable +{ + private readonly PrinterFleetService _fleet; + private readonly ITelemetryStore _store; + private readonly ILogger _logger; + private bool _disposed; + + public TelemetryAggregationService( + PrinterFleetService fleet, + ITelemetryStore store, + ILogger logger) + { + _fleet = fleet; + _store = store; + _logger = logger; + + _fleet.FleetChanged += OnFleetChanged; + } + + private void OnFleetChanged(object? sender, EventArgs e) + { + var snapshot = _fleet.GetFleetTelemetry(); + _ = PersistSnapshotAsync(snapshot); + } + + private async Task PersistSnapshotAsync(IReadOnlyDictionary snapshot) + { + foreach (var (printerId, telemetry) in snapshot) + { + try + { + await _store.SaveAsync(printerId, telemetry); + } + catch (Exception ex) + { + // Telemetry persistence errors are swallowed — never spam logs. + _logger.LogDebug(ex, "Failed to persist telemetry for {PrinterId}", printerId); + } + } + } + + public void Dispose() + { + if (_disposed) return; + _fleet.FleetChanged -= OnFleetChanged; + _disposed = true; + } +} diff --git a/src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj b/src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj new file mode 100644 index 0000000..797615e --- /dev/null +++ b/src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + + + + + + + + + diff --git a/src/MakerPrompt.Cloud/Program.cs b/src/MakerPrompt.Cloud/Program.cs new file mode 100644 index 0000000..6a5f8f2 --- /dev/null +++ b/src/MakerPrompt.Cloud/Program.cs @@ -0,0 +1,69 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Telemetry; +using Microsoft.AspNetCore.Mvc; + +var builder = WebApplication.CreateBuilder(args); + +// ── Services ───────────────────────────────────────────────────────────────── + +// Local in-memory telemetry store (swap for a real persistence layer in production). +builder.Services.AddSingleton(); + +var app = builder.Build(); + +// ── Middleware ──────────────────────────────────────────────────────────────── + +app.UseHttpsRedirection(); + +// ── Endpoints ───────────────────────────────────────────────────────────────── + +// Health check +app.MapGet("/health", () => Results.Ok(new { status = "healthy", utc = DateTimeOffset.UtcNow })) + .WithName("HealthCheck") + .WithTags("System"); + +// Ingest telemetry from an EdgeAgent +app.MapPost("/api/telemetry/{printerId}", async ( + string printerId, + [FromBody] PrinterTelemetry telemetry, + ITelemetryStore store, + CancellationToken ct) => +{ + await store.SaveAsync(printerId, telemetry, ct); + return Results.Accepted(); +}) +.WithName("IngestTelemetry") +.WithTags("Telemetry"); + +// Retrieve the latest telemetry for a printer +app.MapGet("/api/telemetry/{printerId}/latest", async ( + string printerId, + ITelemetryStore store, + CancellationToken ct) => +{ + var latest = await store.GetLatestAsync(printerId, ct); + return latest is null ? Results.NotFound() : Results.Ok(latest); +}) +.WithName("GetLatestTelemetry") +.WithTags("Telemetry"); + +// Retrieve telemetry history for a printer +app.MapGet("/api/telemetry/{printerId}/history", async ( + string printerId, + ITelemetryStore store, + [FromQuery] int count = 100, + CancellationToken ct = default) => +{ + var history = await store.GetHistoryAsync(printerId, count, ct); + return Results.Ok(history); +}) +.WithName("GetTelemetryHistory") +.WithTags("Telemetry"); + +// ── Run ─────────────────────────────────────────────────────────────────────── + +app.Run(); + +// Make the Program class visible for integration tests +public partial class Program { } diff --git a/src/MakerPrompt.Core/Abstractions/IEdgeAgentClient.cs b/src/MakerPrompt.Core/Abstractions/IEdgeAgentClient.cs new file mode 100644 index 0000000..52c9c77 --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IEdgeAgentClient.cs @@ -0,0 +1,28 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Contract for the local EdgeAgent component that runs in the hackerspace / farm +/// and bridges printers to the cloud backend. +/// +/// The EdgeAgent is responsible for: +/// • Connecting to configured printers via . +/// • Polling telemetry on a regular interval. +/// • Forwarding telemetry snapshots to the cloud via . +/// • Optionally capturing webcam snapshots. +/// +public interface IEdgeAgentClient +{ + /// + /// Submits a telemetry snapshot to the cloud backend. + /// Implementations should retry on transient failures and swallow permanent errors silently. + /// + Task SendTelemetryAsync(string printerId, PrinterTelemetry telemetry, + CancellationToken cancellationToken = default); + + /// + /// Checks connectivity to the cloud backend. + /// + Task IsReachableAsync(CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/IPrinterCommunicationService.cs b/src/MakerPrompt.Core/Abstractions/IPrinterCommunicationService.cs new file mode 100644 index 0000000..18e016a --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IPrinterCommunicationService.cs @@ -0,0 +1,88 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Controls a single printer connection. +/// +/// Architecture note +/// ----------------- +/// This interface is intentionally scoped to ONE printer. Fleet / multi-printer +/// scenarios are handled at the Application layer via +/// and the fleet orchestration services that compose multiple +/// instances. +/// +public interface IPrinterCommunicationService : IAsyncDisposable +{ + // ── Events ────────────────────────────────────────────────────────────── + + /// Raised when the connection state changes (true = connected, false = disconnected). + event EventHandler ConnectionStateChanged; + + /// Raised whenever fresh telemetry is available from the printer. + event EventHandler TelemetryUpdated; + + // ── State ─────────────────────────────────────────────────────────────── + + /// Backend protocol in use. + PrinterConnectionType ConnectionType { get; } + + /// Most recently received telemetry snapshot. + PrinterTelemetry LastTelemetry { get; } + + /// Human-readable name for this connection (e.g. "Workshop Prusa MK4"). + string ConnectionName { get; } + + /// true when a live connection is established. + bool IsConnected { get; } + + /// true when a print job is actively running on this printer. + bool IsPrinting { get; } + + // ── Lifecycle ─────────────────────────────────────────────────────────── + + /// Establishes the connection using the supplied settings. + /// true on success, false on failure. + Task ConnectAsync(PrinterConnectionSettings settings, CancellationToken cancellationToken = default); + + /// Gracefully closes the connection and releases backend resources. + Task DisconnectAsync(CancellationToken cancellationToken = default); + + // ── Data transfer ─────────────────────────────────────────────────────── + + /// Sends a raw G-code command string to the printer. + Task WriteDataAsync(string command, CancellationToken cancellationToken = default); + + /// Fetches an up-to-date telemetry snapshot from the printer. + Task GetTelemetryAsync(CancellationToken cancellationToken = default); + + /// Returns the list of files available on the printer's storage. + Task> GetFilesAsync(CancellationToken cancellationToken = default); + + // ── Print control ─────────────────────────────────────────────────────── + + /// Sets the hotend target temperature. Pass 0 to turn off. + Task SetHotendTempAsync(int targetCelsius, CancellationToken cancellationToken = default); + + /// Sets the heated bed target temperature. Pass 0 to turn off. + Task SetBedTempAsync(int targetCelsius, CancellationToken cancellationToken = default); + + /// Homes the specified axes. + Task HomeAsync(bool x = true, bool y = true, bool z = true, CancellationToken cancellationToken = default); + + /// Performs a relative move on the given axes at mm/min. + Task RelativeMoveAsync(int feedRate, float x = 0f, float y = 0f, float z = 0f, float e = 0f, + CancellationToken cancellationToken = default); + + /// Sets the part-cooling fan speed (0–100 %). + Task SetFanSpeedAsync(int speedPercent, CancellationToken cancellationToken = default); + + /// Sets the print feed-rate override (typically 10–200 %). + Task SetPrintSpeedAsync(int speedPercent, CancellationToken cancellationToken = default); + + /// Sets the extrusion flow-rate override (typically 10–200 %). + Task SetPrintFlowAsync(int flowPercent, CancellationToken cancellationToken = default); + + /// Starts printing the specified file from printer storage. + Task StartPrintAsync(string fileName, CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/IPrinterProvider.cs b/src/MakerPrompt.Core/Abstractions/IPrinterProvider.cs new file mode 100644 index 0000000..52ae3e2 --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IPrinterProvider.cs @@ -0,0 +1,44 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Abstracts a service that can enumerate printers from a remote provider account +/// (e.g. PrusaConnect, OctoPrint farm, future cloud providers). +/// +/// Architecture note — provider vs. connection +/// -------------------------------------------- +/// An answers the question: +/// "Which printers does this account know about?" +/// +/// An answers the question: +/// "How do I talk to this specific printer?" +/// +/// These concerns are intentionally separate so that one provider can expose +/// many printers, each controlled by its own communication service instance. +/// +/// Usage pattern +/// ------------- +/// 1. Call with a bearer/API token. +/// 2. Call to enumerate available printers. +/// 3. Pass a to the Application layer to create +/// the matching for that printer. +/// +public interface IPrinterProvider +{ + /// The provider type this implementation represents. + PrinterConnectionType ProviderType { get; } + + /// + /// Configures the provider with the credentials needed to reach the upstream API. + /// Must be called before . + /// + Task ConfigureAsync(string bearerToken, CancellationToken cancellationToken = default); + + /// + /// Returns the list of printers available under the configured account. + /// Returns an empty list on authentication failure or transient network errors + /// (callers should not treat an empty result as fatal). + /// + Task> GetPrintersAsync(CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/ITelemetryStore.cs b/src/MakerPrompt.Core/Abstractions/ITelemetryStore.cs new file mode 100644 index 0000000..98ce8df --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/ITelemetryStore.cs @@ -0,0 +1,30 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Persists and retrieves telemetry snapshots for reporting, analytics, and the +/// cloud backend. Implementations may write to a local SQLite database (EdgeAgent), +/// an in-memory store (tests), or a remote REST API (Cloud-side projections). +/// +public interface ITelemetryStore +{ + /// + /// Persists a telemetry snapshot. The store is responsible for tagging the + /// record with and the current UTC timestamp. + /// + Task SaveAsync(string printerId, PrinterTelemetry telemetry, CancellationToken cancellationToken = default); + + /// + /// Returns the most recent telemetry snapshot for the given printer, + /// or null if no snapshot has been stored yet. + /// + Task GetLatestAsync(string printerId, CancellationToken cancellationToken = default); + + /// + /// Returns up to telemetry snapshots for the given printer, + /// ordered from most-recent to oldest. + /// + Task> GetHistoryAsync( + string printerId, int count = 100, CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/MakerPrompt.Core.csproj b/src/MakerPrompt.Core/MakerPrompt.Core.csproj new file mode 100644 index 0000000..93f5ab4 --- /dev/null +++ b/src/MakerPrompt.Core/MakerPrompt.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/MakerPrompt.Core/Models/PrinterConnectionSettings.cs b/src/MakerPrompt.Core/Models/PrinterConnectionSettings.cs new file mode 100644 index 0000000..8d1fc17 --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrinterConnectionSettings.cs @@ -0,0 +1,37 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Settings required to establish a connection to a single printer backend. +/// +public sealed class PrinterConnectionSettings +{ + /// Backend protocol to use. + public PrinterConnectionType ConnectionType { get; set; } = PrinterConnectionType.Demo; + + // ── HTTP / WebSocket backends ─────────────────────────────────────────── + + /// Base URL of the printer's HTTP API (e.g. "http://192.168.1.10"). + public string? ApiUrl { get; set; } + + /// Username for API authentication (if required). + public string? UserName { get; set; } + + /// Password or API key for authentication. + public string? Password { get; set; } + + // ── Serial / USB backends ─────────────────────────────────────────────── + + /// Serial port name (e.g. "COM3" on Windows, "/dev/ttyUSB0" on Linux). + public string? PortName { get; set; } + + /// Serial baud rate (default 115 200). + public int BaudRate { get; set; } = 115_200; + + // ── Provider-backed backends ──────────────────────────────────────────── + + /// + /// Provider printer identifier returned by . + /// Required when connecting to a specific printer within a fleet provider. + /// + public string? ProviderId { get; set; } +} diff --git a/src/MakerPrompt.Core/Models/PrinterConnectionType.cs b/src/MakerPrompt.Core/Models/PrinterConnectionType.cs new file mode 100644 index 0000000..dc229f4 --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrinterConnectionType.cs @@ -0,0 +1,37 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Identifies the backend protocol used to communicate with a printer. +/// Single-printer backends connect directly to one machine; provider-backed +/// types (e.g. PrusaConnect, OctoPrint) expose multiple printers through a +/// single account and are resolved via . +/// +public enum PrinterConnectionType +{ + /// In-memory demo backend — no real hardware required. + Demo, + + /// Direct USB/serial connection (Marlin, RepRap firmware, etc.). + Serial, + + /// Moonraker HTTP + WebSocket backend (Klipper firmware). + Moonraker, + + /// PrusaLink single-printer HTTP/JSON API (MK4, XL, etc.). + PrusaLink, + + /// + /// PrusaConnect cloud account — a provider that may expose multiple printers. + /// Resolved via . + /// + PrusaConnect, + + /// BambuLab proprietary MQTT + HTTP backend. + BambuLab, + + /// + /// OctoPrint server — may act as a farm hub exposing multiple printers. + /// Resolved via . + /// + OctoPrint, +} diff --git a/src/MakerPrompt.Core/Models/PrinterInfo.cs b/src/MakerPrompt.Core/Models/PrinterInfo.cs new file mode 100644 index 0000000..4930c86 --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrinterInfo.cs @@ -0,0 +1,28 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Describes a printer discovered from a provider account (e.g. PrusaConnect, OctoPrint farm). +/// This model is intentionally minimal — the provider fills in whatever the upstream API exposes. +/// +public sealed class PrinterInfo +{ + /// + /// Unique identifier assigned by the provider (e.g. the PrusaConnect UUID, OctoPrint printer key). + /// + public string Id { get; set; } = string.Empty; + + /// User-visible printer name as reported by the provider account. + public string Name { get; set; } = string.Empty; + + /// Hardware model string (e.g. "MK4", "XL", "X1C"). May be empty. + public string Model { get; set; } = string.Empty; + + /// Raw status string returned by the provider. Use for typed access. + public string RawStatus { get; set; } = string.Empty; + + /// Typed printer status derived from . + public PrinterStatus Status { get; set; } = PrinterStatus.Disconnected; + + /// The provider type that surfaced this printer. + public PrinterConnectionType ProviderType { get; set; } +} diff --git a/src/MakerPrompt.Core/Models/PrinterStatus.cs b/src/MakerPrompt.Core/Models/PrinterStatus.cs new file mode 100644 index 0000000..1073fca --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrinterStatus.cs @@ -0,0 +1,22 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Operational status of a managed printer. +/// +public enum PrinterStatus +{ + /// No connection has been established. + Disconnected, + + /// Connected and idle — ready to accept commands. + Connected, + + /// A print job is actively running. + Printing, + + /// Print job is paused (awaiting user action or filament change). + Paused, + + /// The printer has reported a fault condition. + Error, +} diff --git a/src/MakerPrompt.Core/Models/PrinterTelemetry.cs b/src/MakerPrompt.Core/Models/PrinterTelemetry.cs new file mode 100644 index 0000000..47f6e6a --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrinterTelemetry.cs @@ -0,0 +1,55 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Snapshot of live telemetry data received from a connected printer. +/// +public sealed class PrinterTelemetry +{ + /// Display name of the printer (populated by the backend). + public string PrinterName { get; set; } = string.Empty; + + /// Current hotend temperature in °C. + public double HotendTemp { get; set; } + + /// Hotend target temperature in °C (0 = heater off). + public double HotendTarget { get; set; } + + /// Current heated-bed temperature in °C. + public double BedTemp { get; set; } + + /// Heated-bed target temperature in °C (0 = off). + public double BedTarget { get; set; } + + /// Chamber temperature in °C (0 if not supported). + public double ChamberTemp { get; set; } + + /// Chamber target temperature in °C (0 = off or not supported). + public double ChamberTarget { get; set; } + + /// Current operational status. + public PrinterStatus Status { get; set; } = PrinterStatus.Disconnected; + + /// Feed-rate override percentage (100 = nominal speed). + public int FeedRate { get; set; } = 100; + + /// Flow-rate override percentage (100 = nominal extrusion). + public int FlowRate { get; set; } = 100; + + /// Part cooling fan speed (0–100 %). + public int FanSpeed { get; set; } + + /// Name of the currently active print job (empty when idle). + public string PrintJobName { get; set; } = string.Empty; + + /// Elapsed time since the print job started. + public TimeSpan PrintDuration { get; set; } + + /// Filament consumed in the current job (mm). + public double FilamentUsed { get; set; } + + /// Print progress (0–100 %). 0 when not printing. + public double PrintProgress { get; set; } + + /// UTC timestamp when this snapshot was captured. + public DateTimeOffset CapturedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj b/src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj new file mode 100644 index 0000000..433f298 --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + MakerPrompt.EdgeAgent + + + + + + + + + + + + + diff --git a/src/MakerPrompt.EdgeAgent/Program.cs b/src/MakerPrompt.EdgeAgent/Program.cs new file mode 100644 index 0000000..66ffd79 --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/Program.cs @@ -0,0 +1,22 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Abstractions; +using MakerPrompt.EdgeAgent.Workers; +using MakerPrompt.Infrastructure.Telemetry; + +var builder = Host.CreateApplicationBuilder(args); + +// ── Services ────────────────────────────────────────────────────────────────── + +// Local in-memory telemetry store (swap for SQLite persistence in production). +builder.Services.AddSingleton(); + +// Application-layer fleet manager. +builder.Services.AddSingleton(); + +// Background worker that polls printers and forwards telemetry. +builder.Services.AddHostedService(); + +// ── Build & Run ─────────────────────────────────────────────────────────────── + +var host = builder.Build(); +await host.RunAsync(); diff --git a/src/MakerPrompt.EdgeAgent/Workers/PrinterPollingWorker.cs b/src/MakerPrompt.EdgeAgent/Workers/PrinterPollingWorker.cs new file mode 100644 index 0000000..40fc732 --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/Workers/PrinterPollingWorker.cs @@ -0,0 +1,77 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Abstractions; + +namespace MakerPrompt.EdgeAgent.Workers; + +/// +/// Background worker that polls registered printers on a fixed interval, +/// collects telemetry snapshots, and forwards them to the configured +/// (and optionally to the cloud backend via +/// ). +/// +/// Polling errors are swallowed silently to avoid log spam — only unexpected +/// exceptions that indicate a fatal misconfiguration are propagated. +/// +public sealed class PrinterPollingWorker : BackgroundService +{ + private readonly PrinterFleetService _fleet; + private readonly ITelemetryStore _store; + private readonly ILogger _logger; + private readonly TimeSpan _pollInterval; + + public PrinterPollingWorker( + PrinterFleetService fleet, + ITelemetryStore store, + ILogger logger, + IConfiguration configuration) + { + _fleet = fleet; + _store = store; + _logger = logger; + + var intervalSeconds = configuration.GetValue("EdgeAgent:PollIntervalSeconds", 5); + _pollInterval = TimeSpan.FromSeconds(Math.Max(1, intervalSeconds)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation( + "PrinterPollingWorker started — poll interval {Interval}", _pollInterval); + + while (!stoppingToken.IsCancellationRequested) + { + await PollAllPrintersAsync(stoppingToken); + await Task.Delay(_pollInterval, stoppingToken).ConfigureAwait(false); + } + + _logger.LogInformation("PrinterPollingWorker stopped"); + } + + private async Task PollAllPrintersAsync(CancellationToken cancellationToken) + { + var printerIds = _fleet.PrinterIds; + if (printerIds.Count == 0) return; + + foreach (var printerId in printerIds) + { + var service = _fleet.GetConnection(printerId); + if (service is null || !service.IsConnected) continue; + + try + { + var telemetry = await service.GetTelemetryAsync(cancellationToken); + await _store.SaveAsync(printerId, telemetry, cancellationToken); + } + catch (OperationCanceledException) + { + // Shutdown requested — exit cleanly. + return; + } + catch (Exception ex) + { + // Swallow per-printer polling errors — log at Debug to avoid spam. + _logger.LogDebug(ex, "Polling error for printer {PrinterId}", printerId); + } + } + } +} diff --git a/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj b/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj new file mode 100644 index 0000000..c28033c --- /dev/null +++ b/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/src/MakerPrompt.Infrastructure/Telemetry/InMemoryTelemetryStore.cs b/src/MakerPrompt.Infrastructure/Telemetry/InMemoryTelemetryStore.cs new file mode 100644 index 0000000..229f951 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Telemetry/InMemoryTelemetryStore.cs @@ -0,0 +1,76 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Infrastructure.Telemetry; + +/// +/// In-memory implementation of . +/// Retains the last N snapshots per printer in a bounded ring buffer. +/// Suitable for tests and development; does not survive process restart. +/// +public sealed class InMemoryTelemetryStore : ITelemetryStore +{ + private readonly int _maxPerPrinter; + private readonly Dictionary> _data = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + /// Maximum snapshots retained per printer ID (default 500). + public InMemoryTelemetryStore(int maxPerPrinter = 500) + { + _maxPerPrinter = maxPerPrinter; + } + + public async Task SaveAsync(string printerId, PrinterTelemetry telemetry, + CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (!_data.TryGetValue(printerId, out var list)) + { + list = new LinkedList(); + _data[printerId] = list; + } + + list.AddFirst(telemetry); + + while (list.Count > _maxPerPrinter) + list.RemoveLast(); + } + finally + { + _lock.Release(); + } + } + + public async Task GetLatestAsync(string printerId, + CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + return _data.TryGetValue(printerId, out var list) ? list.First?.Value : null; + } + finally + { + _lock.Release(); + } + } + + public async Task> GetHistoryAsync( + string printerId, int count = 100, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (!_data.TryGetValue(printerId, out var list)) + return []; + + return list.Take(count).ToList().AsReadOnly(); + } + finally + { + _lock.Release(); + } + } +} diff --git a/src/MakerPrompt.Tests.Unit/Application/PrinterFleetServiceTests.cs b/src/MakerPrompt.Tests.Unit/Application/PrinterFleetServiceTests.cs new file mode 100644 index 0000000..968d548 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Application/PrinterFleetServiceTests.cs @@ -0,0 +1,142 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Models; +using MakerPrompt.Tests.Unit.Helpers; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MakerPrompt.Tests.Unit.Application; + +/// +/// Unit tests for . +/// +public sealed class PrinterFleetServiceTests : IAsyncDisposable +{ + private readonly PrinterFleetService _sut = new(NullLogger.Instance); + + // ── AddAndConnect ──────────────────────────────────────────────────────── + + [Fact] + public async Task AddAndConnect_SuccessfulConnect_ReturnsTrueAndPrinterIsTracked() + { + var fake = new FakePrinterService { ConnectShouldSucceed = true }; + + var result = await _sut.AddAndConnectAsync("p1", fake, new PrinterConnectionSettings()); + + Assert.True(result); + Assert.Contains("p1", _sut.PrinterIds); + } + + [Fact] + public async Task AddAndConnect_FailedConnect_ReturnsFalseButPrinterStillRegistered() + { + var fake = new FakePrinterService { ConnectShouldSucceed = false }; + + var result = await _sut.AddAndConnectAsync("p1", fake, new PrinterConnectionSettings()); + + Assert.False(result); + // Service is registered even on connection failure so the caller can retry. + Assert.Contains("p1", _sut.PrinterIds); + } + + [Fact] + public async Task AddAndConnect_ReplacesExistingConnection() + { + var first = new FakePrinterService(); + var second = new FakePrinterService(); + + await _sut.AddAndConnectAsync("p1", first, new PrinterConnectionSettings()); + await _sut.AddAndConnectAsync("p1", second, new PrinterConnectionSettings()); + + // first should have been disconnected when replaced + Assert.False(first.IsConnected); + Assert.True(second.IsConnected); + } + + // ── Remove ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Remove_DisconnectsAndUnregisters() + { + var fake = new FakePrinterService(); + await _sut.AddAndConnectAsync("p1", fake, new PrinterConnectionSettings()); + + await _sut.RemoveAsync("p1"); + + Assert.DoesNotContain("p1", _sut.PrinterIds); + Assert.False(fake.IsConnected); + } + + [Fact] + public async Task Remove_UnknownId_DoesNotThrow() + { + await _sut.RemoveAsync("nonexistent"); + // No exception expected + } + + // ── GetConnection ──────────────────────────────────────────────────────── + + [Fact] + public async Task GetConnection_ReturnsServiceForKnownId() + { + var fake = new FakePrinterService(); + await _sut.AddAndConnectAsync("p1", fake, new PrinterConnectionSettings()); + + var retrieved = _sut.GetConnection("p1"); + + Assert.Same(fake, retrieved); + } + + [Fact] + public void GetConnection_ReturnsNullForUnknownId() + { + Assert.Null(_sut.GetConnection("unknown")); + } + + // ── GetFleetTelemetry ──────────────────────────────────────────────────── + + [Fact] + public async Task GetFleetTelemetry_IncludesOnlyConnectedPrinters() + { + var connected = new FakePrinterService { ConnectShouldSucceed = true }; + var disconnected = new FakePrinterService { ConnectShouldSucceed = false }; + + await _sut.AddAndConnectAsync("p-connected", connected, new PrinterConnectionSettings()); + await _sut.AddAndConnectAsync("p-disconnected", disconnected, new PrinterConnectionSettings()); + + var telemetry = _sut.GetFleetTelemetry(); + + Assert.Contains("p-connected", telemetry.Keys); + Assert.DoesNotContain("p-disconnected", telemetry.Keys); + } + + // ── FleetChanged event ─────────────────────────────────────────────────── + + [Fact] + public async Task AddAndConnect_RaisesFleetChangedEvent() + { + var fake = new FakePrinterService(); + var raised = false; + _sut.FleetChanged += (_, _) => raised = true; + + await _sut.AddAndConnectAsync("p1", fake, new PrinterConnectionSettings()); + + Assert.True(raised); + } + + // ── Disposal ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Dispose_DisconnectsAllPrinters() + { + var p1 = new FakePrinterService(); + var p2 = new FakePrinterService(); + await _sut.AddAndConnectAsync("p1", p1, new PrinterConnectionSettings()); + await _sut.AddAndConnectAsync("p2", p2, new PrinterConnectionSettings()); + + await _sut.DisposeAsync(); + + Assert.False(p1.IsConnected); + Assert.False(p2.IsConnected); + } + + public async ValueTask DisposeAsync() => await _sut.DisposeAsync(); +} diff --git a/src/MakerPrompt.Tests.Unit/Core/InMemoryTelemetryStoreTests.cs b/src/MakerPrompt.Tests.Unit/Core/InMemoryTelemetryStoreTests.cs new file mode 100644 index 0000000..1dd5aaf --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Core/InMemoryTelemetryStoreTests.cs @@ -0,0 +1,94 @@ +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Telemetry; + +namespace MakerPrompt.Tests.Unit.Core; + +/// +/// Unit tests for . +/// +public sealed class InMemoryTelemetryStoreTests +{ + private readonly InMemoryTelemetryStore _sut = new(); + + [Fact] + public async Task GetLatest_WhenNoDataSaved_ReturnsNull() + { + var result = await _sut.GetLatestAsync("unknown"); + Assert.Null(result); + } + + [Fact] + public async Task SaveAndGetLatest_ReturnsMostRecentSnapshot() + { + var first = new PrinterTelemetry { HotendTemp = 200 }; + var second = new PrinterTelemetry { HotendTemp = 210 }; + + await _sut.SaveAsync("p1", first); + await _sut.SaveAsync("p1", second); + + var latest = await _sut.GetLatestAsync("p1"); + + Assert.NotNull(latest); + Assert.Equal(210, latest.HotendTemp); + } + + [Fact] + public async Task GetHistory_ReturnsSnapshotsInDescendingOrder() + { + for (var i = 1; i <= 5; i++) + await _sut.SaveAsync("p1", new PrinterTelemetry { HotendTemp = i * 10 }); + + var history = await _sut.GetHistoryAsync("p1", count: 5); + + Assert.Equal(5, history.Count); + // Most-recent first (50 °C was saved last) + Assert.Equal(50, history[0].HotendTemp); + } + + [Fact] + public async Task GetHistory_CountParameter_LimitsResults() + { + for (var i = 0; i < 20; i++) + await _sut.SaveAsync("p1", new PrinterTelemetry()); + + var history = await _sut.GetHistoryAsync("p1", count: 5); + + Assert.Equal(5, history.Count); + } + + [Fact] + public async Task GetHistory_UnknownPrinterId_ReturnsEmpty() + { + var result = await _sut.GetHistoryAsync("nonexistent"); + Assert.Empty(result); + } + + [Fact] + public async Task BoundedBuffer_OldestSnapshotEvicted() + { + var bounded = new InMemoryTelemetryStore(maxPerPrinter: 3); + + for (var i = 1; i <= 5; i++) + await bounded.SaveAsync("p1", new PrinterTelemetry { HotendTemp = i * 10 }); + + var history = await bounded.GetHistoryAsync("p1", count: 10); + + Assert.Equal(3, history.Count); + // Should retain the 3 newest: 50, 40, 30 + Assert.DoesNotContain(history, t => t.HotendTemp == 10); + Assert.DoesNotContain(history, t => t.HotendTemp == 20); + } + + [Fact] + public async Task MultiPrinter_DataIsIsolated() + { + await _sut.SaveAsync("printer-a", new PrinterTelemetry { HotendTemp = 190 }); + await _sut.SaveAsync("printer-b", new PrinterTelemetry { HotendTemp = 240 }); + + var a = await _sut.GetLatestAsync("printer-a"); + var b = await _sut.GetLatestAsync("printer-b"); + + Assert.Equal(190, a?.HotendTemp); + Assert.Equal(240, b?.HotendTemp); + } +} diff --git a/src/MakerPrompt.Tests.Unit/Helpers/FakePrinterService.cs b/src/MakerPrompt.Tests.Unit/Helpers/FakePrinterService.cs new file mode 100644 index 0000000..87b5581 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Helpers/FakePrinterService.cs @@ -0,0 +1,80 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Tests.Unit.Helpers; + +/// +/// A minimal, fully-controllable in-memory implementation of +/// for use in unit tests. +/// +public sealed class FakePrinterService : IPrinterCommunicationService +{ + public PrinterConnectionType ConnectionType { get; set; } = PrinterConnectionType.Demo; + public PrinterTelemetry LastTelemetry { get; set; } = new(); + public string ConnectionName { get; set; } = "FakePrinter"; + public bool IsConnected { get; private set; } + public bool IsPrinting { get; private set; } + + public bool ConnectShouldSucceed { get; set; } = true; + public PrinterTelemetry? TelemetryToReturn { get; set; } + + public event EventHandler? ConnectionStateChanged; + public event EventHandler? TelemetryUpdated; + + public Task ConnectAsync(PrinterConnectionSettings settings, + CancellationToken cancellationToken = default) + { + IsConnected = ConnectShouldSucceed; + ConnectionStateChanged?.Invoke(this, IsConnected); + return Task.FromResult(IsConnected); + } + + public Task DisconnectAsync(CancellationToken cancellationToken = default) + { + IsConnected = false; + IsPrinting = false; + ConnectionStateChanged?.Invoke(this, false); + return Task.CompletedTask; + } + + public Task WriteDataAsync(string command, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task GetTelemetryAsync(CancellationToken cancellationToken = default) + { + var t = TelemetryToReturn ?? LastTelemetry; + TelemetryUpdated?.Invoke(this, t); + return Task.FromResult(t); + } + + public Task> GetFilesAsync(CancellationToken cancellationToken = default) + => Task.FromResult>([]); + + public Task SetHotendTempAsync(int targetCelsius, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task SetBedTempAsync(int targetCelsius, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task HomeAsync(bool x = true, bool y = true, bool z = true, + CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task RelativeMoveAsync(int feedRate, float x = 0, float y = 0, float z = 0, float e = 0, + CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task SetFanSpeedAsync(int speedPercent, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task SetPrintSpeedAsync(int speedPercent, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task SetPrintFlowAsync(int flowPercent, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task StartPrintAsync(string fileName, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/src/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj b/src/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj new file mode 100644 index 0000000..3266cb7 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + From 1a55dcb182f94e84ca5b3eb0e56dd5bc01b207d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 18:22:28 +0000 Subject: [PATCH 03/19] fix: remove Application reference from Infrastructure (clean arch), use Task.Run for telemetry persistence Agent-Logs-Url: https://github.com/akinbender/MakerPrompt/sessions/b6160242-d611-4cd5-aefd-669f3ced06f5 Co-authored-by: akinbender <40242943+akinbender@users.noreply.github.com> --- .../Services/TelemetryAggregationService.cs | 2 +- .../MakerPrompt.Infrastructure.csproj | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/MakerPrompt.Application/Services/TelemetryAggregationService.cs b/src/MakerPrompt.Application/Services/TelemetryAggregationService.cs index 6c28f7b..bb08629 100644 --- a/src/MakerPrompt.Application/Services/TelemetryAggregationService.cs +++ b/src/MakerPrompt.Application/Services/TelemetryAggregationService.cs @@ -39,7 +39,7 @@ public TelemetryAggregationService( private void OnFleetChanged(object? sender, EventArgs e) { var snapshot = _fleet.GetFleetTelemetry(); - _ = PersistSnapshotAsync(snapshot); + Task.Run(() => PersistSnapshotAsync(snapshot)); } private async Task PersistSnapshotAsync(IReadOnlyDictionary snapshot) diff --git a/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj b/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj index c28033c..8b420dc 100644 --- a/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj +++ b/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj @@ -8,7 +8,6 @@ - From d5c3c1cc2e634a43dca7b68089dab8bf5f11ea8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 18:59:30 +0000 Subject: [PATCH 04/19] =?UTF-8?q?feat:=20Phase=202=20/src=20restructure=20?= =?UTF-8?q?=E2=80=94=20domain=20models,=20application=20services,=20infras?= =?UTF-8?q?tructure=20stores,=2050=20new=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/akinbender/MakerPrompt/sessions/49d91096-22a4-435b-ad0d-36a1b88eb29e Co-authored-by: akinbender <40242943+akinbender@users.noreply.github.com> --- .../Services/AnalyticsService.cs | 69 +++++++ .../Services/FarmService.cs | 107 +++++++++++ .../Services/FilamentInventoryService.cs | 71 ++++++++ .../Services/PrintProjectService.cs | 163 +++++++++++++++++ .../Abstractions/IFarmRepository.cs | 18 ++ .../Abstractions/IFilamentInventoryStore.cs | 24 +++ .../Abstractions/IPrintJobAnalyticsStore.cs | 21 +++ .../Abstractions/IPrintProjectRepository.cs | 34 ++++ .../Models/FarmConfiguration.cs | 47 +++++ src/MakerPrompt.Core/Models/FilamentSpool.cs | 30 ++++ .../Models/NotificationRecord.cs | 29 +++ .../Models/PrintJobUsageRecord.cs | 35 ++++ src/MakerPrompt.Core/Models/PrintProject.cs | 47 +++++ .../InMemoryPrintJobAnalyticsStore.cs | 63 +++++++ .../Farm/InMemoryFarmRepository.cs | 41 +++++ .../InMemoryFilamentInventoryStore.cs | 53 ++++++ .../InMemoryPrintProjectRepository.cs | 76 ++++++++ .../Application/AnalyticsServiceTests.cs | 123 +++++++++++++ .../Application/FarmServiceTests.cs | 143 +++++++++++++++ .../FilamentInventoryServiceTests.cs | 124 +++++++++++++ .../Application/PrintProjectServiceTests.cs | 168 ++++++++++++++++++ .../Infrastructure/InMemoryStoreTests.cs | 168 ++++++++++++++++++ 22 files changed, 1654 insertions(+) create mode 100644 src/MakerPrompt.Application/Services/AnalyticsService.cs create mode 100644 src/MakerPrompt.Application/Services/FarmService.cs create mode 100644 src/MakerPrompt.Application/Services/FilamentInventoryService.cs create mode 100644 src/MakerPrompt.Application/Services/PrintProjectService.cs create mode 100644 src/MakerPrompt.Core/Abstractions/IFarmRepository.cs create mode 100644 src/MakerPrompt.Core/Abstractions/IFilamentInventoryStore.cs create mode 100644 src/MakerPrompt.Core/Abstractions/IPrintJobAnalyticsStore.cs create mode 100644 src/MakerPrompt.Core/Abstractions/IPrintProjectRepository.cs create mode 100644 src/MakerPrompt.Core/Models/FarmConfiguration.cs create mode 100644 src/MakerPrompt.Core/Models/FilamentSpool.cs create mode 100644 src/MakerPrompt.Core/Models/NotificationRecord.cs create mode 100644 src/MakerPrompt.Core/Models/PrintJobUsageRecord.cs create mode 100644 src/MakerPrompt.Core/Models/PrintProject.cs create mode 100644 src/MakerPrompt.Infrastructure/Analytics/InMemoryPrintJobAnalyticsStore.cs create mode 100644 src/MakerPrompt.Infrastructure/Farm/InMemoryFarmRepository.cs create mode 100644 src/MakerPrompt.Infrastructure/Inventory/InMemoryFilamentInventoryStore.cs create mode 100644 src/MakerPrompt.Infrastructure/Projects/InMemoryPrintProjectRepository.cs create mode 100644 src/MakerPrompt.Tests.Unit/Application/AnalyticsServiceTests.cs create mode 100644 src/MakerPrompt.Tests.Unit/Application/FarmServiceTests.cs create mode 100644 src/MakerPrompt.Tests.Unit/Application/FilamentInventoryServiceTests.cs create mode 100644 src/MakerPrompt.Tests.Unit/Application/PrintProjectServiceTests.cs create mode 100644 src/MakerPrompt.Tests.Unit/Infrastructure/InMemoryStoreTests.cs diff --git a/src/MakerPrompt.Application/Services/AnalyticsService.cs b/src/MakerPrompt.Application/Services/AnalyticsService.cs new file mode 100644 index 0000000..0b62bfc --- /dev/null +++ b/src/MakerPrompt.Application/Services/AnalyticsService.cs @@ -0,0 +1,69 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Application.Services; + +/// +/// Application service for print-job analytics. +/// +/// Provides aggregation helpers on top of : +/// - Total print hours +/// - Total filament consumed (all printers / by printer / by spool) +/// +public sealed class AnalyticsService +{ + private readonly IPrintJobAnalyticsStore _store; + private readonly ILogger _logger; + + /// Raised whenever a new usage record is added. + public event EventHandler? AnalyticsUpdated; + + public AnalyticsService(IPrintJobAnalyticsStore store, ILogger logger) + { + _store = store; + _logger = logger; + } + + public Task> GetRecordsAsync(CancellationToken cancellationToken = default) + => _store.GetAllAsync(cancellationToken); + + public async Task RecordUsageAsync(PrintJobUsageRecord record, CancellationToken cancellationToken = default) + { + try + { + await _store.SaveAsync(record, cancellationToken); + AnalyticsUpdated?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to record print job usage"); + } + } + + // ── Aggregations ───────────────────────────────────────────────────────── + + public async Task GetTotalPrintTimeAsync(CancellationToken cancellationToken = default) + { + var records = await _store.GetAllAsync(cancellationToken); + return TimeSpan.FromTicks(records.Sum(r => r.Duration.Ticks)); + } + + public async Task GetTotalFilamentConsumedGramsAsync(CancellationToken cancellationToken = default) + { + var records = await _store.GetAllAsync(cancellationToken); + return records.Sum(r => r.EffectiveFilamentGrams); + } + + public async Task GetFilamentConsumedByPrinterAsync(Guid printerId, CancellationToken cancellationToken = default) + { + var records = await _store.GetByPrinterAsync(printerId, cancellationToken); + return records.Sum(r => r.EffectiveFilamentGrams); + } + + public async Task GetFilamentConsumedBySpoolAsync(Guid spoolId, CancellationToken cancellationToken = default) + { + var records = await _store.GetBySpoolAsync(spoolId, cancellationToken); + return records.Sum(r => r.EffectiveFilamentGrams); + } +} diff --git a/src/MakerPrompt.Application/Services/FarmService.cs b/src/MakerPrompt.Application/Services/FarmService.cs new file mode 100644 index 0000000..19cfb9c --- /dev/null +++ b/src/MakerPrompt.Application/Services/FarmService.cs @@ -0,0 +1,107 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Application.Services; + +/// +/// Application service for managing farm configurations. +/// +/// Responsibilities: +/// - CRUD for farm profiles. +/// - Switching the active farm (saving current printer state, loading the new one). +/// - Import / export as JSON. +/// +public sealed class FarmService +{ + private readonly IFarmRepository _repo; + private readonly ILogger _logger; + + /// Raised whenever the list of farms changes. + public event EventHandler? FarmsChanged; + + public FarmService(IFarmRepository repo, ILogger logger) + { + _repo = repo; + _logger = logger; + } + + public Task> GetFarmsAsync(CancellationToken cancellationToken = default) + => _repo.GetAllAsync(cancellationToken); + + public Task GetFarmAsync(Guid farmId, CancellationToken cancellationToken = default) + => _repo.GetByIdAsync(farmId, cancellationToken); + + /// Creates a new farm profile with the given name. + public async Task CreateFarmAsync(string name, CancellationToken cancellationToken = default) + { + var farm = new FarmConfiguration { Name = name.Trim() }; + await _repo.SaveAsync(farm, cancellationToken); + OnFarmsChanged(); + return farm; + } + + /// Updates the display name of a farm. + public async Task RenameFarmAsync(Guid farmId, string newName, CancellationToken cancellationToken = default) + { + var farm = await _repo.GetByIdAsync(farmId, cancellationToken); + if (farm is null) + { + _logger.LogWarning("RenameFarm: farm {FarmId} not found", farmId); + return; + } + + farm.Name = newName.Trim(); + await _repo.SaveAsync(farm, cancellationToken); + OnFarmsChanged(); + } + + /// + /// Saves a snapshot of the given printer definitions into the farm, then persists. + /// Used when switching away from this farm to preserve its current printer list. + /// + public async Task SnapshotPrintersAsync(Guid farmId, + IEnumerable currentPrinters, + CancellationToken cancellationToken = default) + { + var farm = await _repo.GetByIdAsync(farmId, cancellationToken); + if (farm is null) return; + + farm.Printers = currentPrinters.ToList(); + await _repo.SaveAsync(farm, cancellationToken); + } + + /// Deletes a farm profile. + public async Task DeleteFarmAsync(Guid farmId, CancellationToken cancellationToken = default) + { + await _repo.DeleteAsync(farmId, cancellationToken); + OnFarmsChanged(); + } + + /// + /// Exports a farm as a JSON string suitable for file download / transfer. + /// + public async Task ExportFarmAsync(Guid farmId, CancellationToken cancellationToken = default) + { + var farm = await _repo.GetByIdAsync(farmId, cancellationToken); + if (farm is null) return "{}"; + return System.Text.Json.JsonSerializer.Serialize(farm, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } + + /// + /// Imports a farm from a JSON string. Assigns a fresh ID to prevent collisions. + /// + public async Task ImportFarmAsync(string json, CancellationToken cancellationToken = default) + { + var farm = System.Text.Json.JsonSerializer.Deserialize(json) + ?? throw new InvalidOperationException("Invalid farm configuration JSON."); + + farm.Id = Guid.NewGuid(); + farm.CreatedAt = DateTime.UtcNow; + await _repo.SaveAsync(farm, cancellationToken); + OnFarmsChanged(); + return farm; + } + + private void OnFarmsChanged() => FarmsChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/src/MakerPrompt.Application/Services/FilamentInventoryService.cs b/src/MakerPrompt.Application/Services/FilamentInventoryService.cs new file mode 100644 index 0000000..f551c98 --- /dev/null +++ b/src/MakerPrompt.Application/Services/FilamentInventoryService.cs @@ -0,0 +1,71 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Application.Services; + +/// +/// Application service for filament spool inventory management. +/// +/// Wraps with business rules: +/// - Deduction clamping (never negative) +/// - Event publication on any change +/// - Aggregation helpers (total filament consumed per spool) +/// +public sealed class FilamentInventoryService +{ + private readonly IFilamentInventoryStore _store; + private readonly ILogger _logger; + + /// Raised whenever the inventory is modified. + public event EventHandler? InventoryChanged; + + public FilamentInventoryService(IFilamentInventoryStore store, ILogger logger) + { + _store = store; + _logger = logger; + } + + public Task> GetSpoolsAsync(CancellationToken cancellationToken = default) + => _store.GetAllAsync(cancellationToken); + + public Task GetSpoolAsync(Guid id, CancellationToken cancellationToken = default) + => _store.GetByIdAsync(id, cancellationToken); + + public async Task AddSpoolAsync(FilamentSpool spool, CancellationToken cancellationToken = default) + { + await _store.SaveAsync(spool, cancellationToken); + OnInventoryChanged(); + } + + public async Task UpdateSpoolAsync(FilamentSpool spool, CancellationToken cancellationToken = default) + { + await _store.SaveAsync(spool, cancellationToken); + OnInventoryChanged(); + } + + public async Task DeleteSpoolAsync(Guid id, CancellationToken cancellationToken = default) + { + await _store.DeleteAsync(id, cancellationToken); + OnInventoryChanged(); + } + + /// + /// Deducts consumed filament from the spool. + /// Logs a warning if the spool is not found. + /// + public async Task DeductFilamentAsync(Guid spoolId, double grams, CancellationToken cancellationToken = default) + { + var spool = await _store.GetByIdAsync(spoolId, cancellationToken); + if (spool is null) + { + _logger.LogWarning("DeductFilament: spool {SpoolId} not found", spoolId); + return; + } + + await _store.DeductFilamentAsync(spoolId, grams, cancellationToken); + OnInventoryChanged(); + } + + private void OnInventoryChanged() => InventoryChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/src/MakerPrompt.Application/Services/PrintProjectService.cs b/src/MakerPrompt.Application/Services/PrintProjectService.cs new file mode 100644 index 0000000..1eb68f7 --- /dev/null +++ b/src/MakerPrompt.Application/Services/PrintProjectService.cs @@ -0,0 +1,163 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Application.Services; + +/// +/// Application service for print project management. +/// +/// Business rules enforced here: +/// - Project names are trimmed before persistence. +/// - Deleting a project also deletes all stored G-code files. +/// - Job status transitions are validated. +/// +public sealed class PrintProjectService +{ + private readonly IPrintProjectRepository _repo; + private readonly ILogger _logger; + + /// Raised when any project or job is created, updated, or deleted. + public event EventHandler? ProjectsChanged; + + public PrintProjectService(IPrintProjectRepository repo, ILogger logger) + { + _repo = repo; + _logger = logger; + } + + public Task> GetProjectsAsync(CancellationToken cancellationToken = default) + => _repo.GetAllAsync(cancellationToken); + + public Task GetProjectAsync(Guid projectId, CancellationToken cancellationToken = default) + => _repo.GetByIdAsync(projectId, cancellationToken); + + // ── Project CRUD ────────────────────────────────────────────────────────── + + public async Task CreateProjectAsync(string name, string? notes = null, + CancellationToken cancellationToken = default) + { + var project = new PrintProject { Name = name.Trim(), Notes = notes }; + await _repo.SaveAsync(project, cancellationToken); + OnProjectsChanged(); + return project; + } + + public async Task RenameProjectAsync(Guid projectId, string newName, + CancellationToken cancellationToken = default) + { + var project = await _repo.GetByIdAsync(projectId, cancellationToken) + ?? throw new InvalidOperationException($"Project {projectId} not found."); + + project.Name = newName.Trim(); + await _repo.SaveAsync(project, cancellationToken); + OnProjectsChanged(); + } + + public async Task DeleteProjectAsync(Guid projectId, CancellationToken cancellationToken = default) + { + var project = await _repo.GetByIdAsync(projectId, cancellationToken); + if (project is null) return; + + foreach (var job in project.Jobs) + { + try + { + await _repo.DeleteJobFileAsync(job.StoragePath, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete G-code file {Path}", job.StoragePath); + } + } + + await _repo.DeleteAsync(projectId, cancellationToken); + OnProjectsChanged(); + } + + // ── Job management ──────────────────────────────────────────────────────── + + /// + /// Uploads a G-code file into the project and registers a new job. + /// + public async Task AddJobAsync(Guid projectId, string fileName, Stream fileContent, + CancellationToken cancellationToken = default) + { + var project = await _repo.GetByIdAsync(projectId, cancellationToken) + ?? throw new InvalidOperationException($"Project {projectId} not found."); + + var storagePath = await _repo.SaveJobFileAsync(projectId, fileName, fileContent, cancellationToken); + + var job = new PrintJob + { + FileName = fileName, + StoragePath = storagePath, + Size = fileContent.CanSeek ? fileContent.Length : 0, + }; + + project.Jobs.Add(job); + await _repo.SaveAsync(project, cancellationToken); + OnProjectsChanged(); + return job; + } + + /// + /// Removes a job and deletes its stored G-code file. + /// + public async Task RemoveJobAsync(Guid projectId, Guid jobId, CancellationToken cancellationToken = default) + { + var project = await _repo.GetByIdAsync(projectId, cancellationToken); + var job = project?.Jobs.FirstOrDefault(j => j.Id == jobId); + if (project is null || job is null) return; + + try { await _repo.DeleteJobFileAsync(job.StoragePath, cancellationToken); } + catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete file {Path}", job.StoragePath); } + + project.Jobs.Remove(job); + await _repo.SaveAsync(project, cancellationToken); + OnProjectsChanged(); + } + + /// + /// Assigns a job to a printer and sets it to . + /// + public async Task AssignJobAsync(Guid projectId, Guid jobId, Guid printerId, string printerName, + CancellationToken cancellationToken = default) + { + var project = await _repo.GetByIdAsync(projectId, cancellationToken); + var job = project?.Jobs.FirstOrDefault(j => j.Id == jobId); + if (job is null) return; + + job.AssignedPrinterId = printerId; + job.AssignedPrinterName = printerName; + job.Status = PrintJobStatus.Printing; + await _repo.SaveAsync(project!, cancellationToken); + OnProjectsChanged(); + } + + /// + /// Updates the status of a job (typically to Completed or Failed). + /// + public async Task UpdateJobStatusAsync(Guid projectId, Guid jobId, PrintJobStatus status, + CancellationToken cancellationToken = default) + { + var project = await _repo.GetByIdAsync(projectId, cancellationToken); + var job = project?.Jobs.FirstOrDefault(j => j.Id == jobId); + if (job is null) return; + + job.Status = status; + await _repo.SaveAsync(project!, cancellationToken); + OnProjectsChanged(); + } + + /// Returns the G-code file stream for a job (null if not found). + public async Task OpenJobFileAsync(Guid projectId, Guid jobId, + CancellationToken cancellationToken = default) + { + var project = await _repo.GetByIdAsync(projectId, cancellationToken); + var job = project?.Jobs.FirstOrDefault(j => j.Id == jobId); + return job is null ? null : await _repo.OpenJobFileAsync(job.StoragePath, cancellationToken); + } + + private void OnProjectsChanged() => ProjectsChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/src/MakerPrompt.Core/Abstractions/IFarmRepository.cs b/src/MakerPrompt.Core/Abstractions/IFarmRepository.cs new file mode 100644 index 0000000..fad64bb --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IFarmRepository.cs @@ -0,0 +1,18 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Manages farm configurations — named groups of printer connections that +/// can be saved, switched, imported, and exported. +/// +public interface IFarmRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task GetByIdAsync(Guid farmId, CancellationToken cancellationToken = default); + + Task SaveAsync(FarmConfiguration farm, CancellationToken cancellationToken = default); + + Task DeleteAsync(Guid farmId, CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/IFilamentInventoryStore.cs b/src/MakerPrompt.Core/Abstractions/IFilamentInventoryStore.cs new file mode 100644 index 0000000..3a6ebde --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IFilamentInventoryStore.cs @@ -0,0 +1,24 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Persists and retrieves filament spools for inventory tracking. +/// Implementations may use local JSON storage, a database, or a cloud API. +/// +public interface IFilamentInventoryStore +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task SaveAsync(FilamentSpool spool, CancellationToken cancellationToken = default); + + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Deducts from the spool's remaining weight. + /// Clamps to zero — never goes negative. + /// + Task DeductFilamentAsync(Guid spoolId, double grams, CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/IPrintJobAnalyticsStore.cs b/src/MakerPrompt.Core/Abstractions/IPrintJobAnalyticsStore.cs new file mode 100644 index 0000000..28c82af --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IPrintJobAnalyticsStore.cs @@ -0,0 +1,21 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Stores and queries print-job analytics records. +/// +public interface IPrintJobAnalyticsStore +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task SaveAsync(PrintJobUsageRecord record, CancellationToken cancellationToken = default); + + /// Returns records for a specific printer, ordered newest-first. + Task> GetByPrinterAsync( + Guid printerId, CancellationToken cancellationToken = default); + + /// Returns records for a specific spool, ordered newest-first. + Task> GetBySpoolAsync( + Guid spoolId, CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/IPrintProjectRepository.cs b/src/MakerPrompt.Core/Abstractions/IPrintProjectRepository.cs new file mode 100644 index 0000000..9e464a0 --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IPrintProjectRepository.cs @@ -0,0 +1,34 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Manages print projects and their associated G-code jobs. +/// Business logic lives in the Application layer; this interface +/// describes the persistence contract for both storage and application use. +/// +public interface IPrintProjectRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task GetByIdAsync(Guid projectId, CancellationToken cancellationToken = default); + + Task SaveAsync(PrintProject project, CancellationToken cancellationToken = default); + + Task DeleteAsync(Guid projectId, CancellationToken cancellationToken = default); + + /// + /// Opens the binary content of a job's G-code file from storage. + /// Returns null if the file does not exist. + /// + Task OpenJobFileAsync(string storagePath, CancellationToken cancellationToken = default); + + /// + /// Stores the binary content of a G-code file and returns the storage path assigned to it. + /// + Task SaveJobFileAsync(Guid projectId, string fileName, Stream content, + CancellationToken cancellationToken = default); + + /// Deletes the physical G-code file for a job. + Task DeleteJobFileAsync(string storagePath, CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Models/FarmConfiguration.cs b/src/MakerPrompt.Core/Models/FarmConfiguration.cs new file mode 100644 index 0000000..a950690 --- /dev/null +++ b/src/MakerPrompt.Core/Models/FarmConfiguration.cs @@ -0,0 +1,47 @@ +namespace MakerPrompt.Core.Models; + +/// +/// A saved farm profile that bundles a named group of printer connections. +/// Users can switch between farms (e.g. "Workshop A" vs "Hackerspace B"). +/// +public sealed class FarmConfiguration +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Snapshot of printer connection definitions belonging to this farm. + /// Populated when the farm is saved or exported. + /// + public List Printers { get; set; } = []; +} + +/// +/// Persistent connection profile for a single printer. +/// +public sealed class PrinterConnectionDefinition +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + /// User-friendly display name (e.g. "Workshop Prusa MK4"). + public string Name { get; set; } = string.Empty; + + public PrinterConnectionType ConnectionType { get; set; } = PrinterConnectionType.Demo; + + /// Connection settings (URL, credentials, or serial port). + public PrinterConnectionSettings Settings { get; set; } = new(); + + /// Auto-connect this printer on app startup. + public bool AutoConnect { get; set; } + + /// Optional hex color for the Fleet card UI. + public string? Color { get; set; } + + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? LastConnectedAt { get; set; } + + /// The filament spool currently loaded in this printer. + public Guid? AssignedFilamentSpoolId { get; set; } +} diff --git a/src/MakerPrompt.Core/Models/FilamentSpool.cs b/src/MakerPrompt.Core/Models/FilamentSpool.cs new file mode 100644 index 0000000..2178c59 --- /dev/null +++ b/src/MakerPrompt.Core/Models/FilamentSpool.cs @@ -0,0 +1,30 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Represents a spool of filament tracked in the inventory. +/// +public sealed class FilamentSpool +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string Material { get; set; } = string.Empty; + public string Brand { get; set; } = string.Empty; + public string Color { get; set; } = string.Empty; + + /// Filament diameter in mm (typically 1.75 or 2.85). + public double Diameter { get; set; } = 1.75; + + /// Total spool weight in grams as purchased. + public double TotalWeightGrams { get; set; } = 1000; + + /// Estimated remaining weight in grams (decremented as jobs complete). + public double RemainingWeightGrams { get; set; } = 1000; + + /// Cost paid for this spool. + public decimal Cost { get; set; } + + public DateTime PurchaseDate { get; set; } = DateTime.UtcNow; + + /// Archived spools are hidden from active selections but kept for history. + public bool IsArchived { get; set; } +} diff --git a/src/MakerPrompt.Core/Models/NotificationRecord.cs b/src/MakerPrompt.Core/Models/NotificationRecord.cs new file mode 100644 index 0000000..da7b2f3 --- /dev/null +++ b/src/MakerPrompt.Core/Models/NotificationRecord.cs @@ -0,0 +1,29 @@ +namespace MakerPrompt.Core.Models; + +public enum NotificationLevel +{ + Info, + Warning, + Error, + Critical, +} + +/// +/// A persisted notification event (print completion, error, low filament alert, etc.). +/// +public sealed class NotificationRecord +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public NotificationLevel Level { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + /// Associated printer (if notification relates to a specific printer). + public Guid? PrinterId { get; set; } + + /// Associated filament spool (e.g. low-filament warnings). + public Guid? FilamentSpoolId { get; set; } + + public bool IsRead { get; set; } +} diff --git a/src/MakerPrompt.Core/Models/PrintJobUsageRecord.cs b/src/MakerPrompt.Core/Models/PrintJobUsageRecord.cs new file mode 100644 index 0000000..cfe5989 --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrintJobUsageRecord.cs @@ -0,0 +1,35 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Audit record for a completed (or in-progress) print job. +/// Used for analytics — print hours, filament consumption per printer/spool. +/// +public sealed class PrintJobUsageRecord +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + /// ID of the printer that ran this job. + public Guid PrinterId { get; set; } + + /// ID of the filament spool consumed by this job (empty = unknown). + public Guid FilamentSpoolId { get; set; } + + public string JobName { get; set; } = string.Empty; + + /// Total elapsed print time. + public TimeSpan Duration { get; set; } + + /// Pre-slice estimated filament consumption in grams. + public double EstimatedFilamentUsedGrams { get; set; } + + /// Actual filament consumed in grams (0 = not measured). + public double ActualFilamentUsedGrams { get; set; } + + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + /// + /// Returns the best available filament figure: actual if measured, otherwise estimated. + /// + public double EffectiveFilamentGrams => + ActualFilamentUsedGrams > 0 ? ActualFilamentUsedGrams : EstimatedFilamentUsedGrams; +} diff --git a/src/MakerPrompt.Core/Models/PrintProject.cs b/src/MakerPrompt.Core/Models/PrintProject.cs new file mode 100644 index 0000000..d0bc2fd --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrintProject.cs @@ -0,0 +1,47 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Groups a set of G-code files under a single project name. +/// Files are dispatched to printers; status is tracked per job. +/// +public sealed class PrintProject +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public List Jobs { get; set; } = []; +} + +/// +/// A single G-code file within a . +/// +public sealed class PrintJob +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + /// Original filename (e.g. "benchy.gcode"). + public string FileName { get; set; } = string.Empty; + + /// Storage path within IStorageProvider (e.g. "PrintProjects/{projectId}/{filename}"). + public string StoragePath { get; set; } = string.Empty; + + /// File size in bytes (0 if not measurable at upload time). + public long Size { get; set; } + + public PrintJobStatus Status { get; set; } = PrintJobStatus.Queued; + + /// The printer this job is assigned to (null = unassigned). + public Guid? AssignedPrinterId { get; set; } + + /// Friendly printer name kept for display when the printer is offline. + public string? AssignedPrinterName { get; set; } +} + +public enum PrintJobStatus +{ + Queued, + Printing, + Completed, + Failed, +} diff --git a/src/MakerPrompt.Infrastructure/Analytics/InMemoryPrintJobAnalyticsStore.cs b/src/MakerPrompt.Infrastructure/Analytics/InMemoryPrintJobAnalyticsStore.cs new file mode 100644 index 0000000..d73d852 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Analytics/InMemoryPrintJobAnalyticsStore.cs @@ -0,0 +1,63 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Infrastructure.Analytics; + +/// +/// Thread-safe, in-memory implementation of . +/// +public sealed class InMemoryPrintJobAnalyticsStore : IPrintJobAnalyticsStore +{ + private readonly List _records = []; + private readonly SemaphoreSlim _lock = new(1, 1); + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { return _records.AsReadOnly(); } + finally { _lock.Release(); } + } + + public async Task SaveAsync(PrintJobUsageRecord record, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + // Replace if already present, otherwise append. + var idx = _records.FindIndex(r => r.Id == record.Id); + if (idx >= 0) _records[idx] = record; + else _records.Add(record); + } + finally { _lock.Release(); } + } + + public async Task> GetByPrinterAsync( + Guid printerId, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + return _records + .Where(r => r.PrinterId == printerId) + .OrderByDescending(r => r.Timestamp) + .ToList() + .AsReadOnly(); + } + finally { _lock.Release(); } + } + + public async Task> GetBySpoolAsync( + Guid spoolId, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + return _records + .Where(r => r.FilamentSpoolId == spoolId) + .OrderByDescending(r => r.Timestamp) + .ToList() + .AsReadOnly(); + } + finally { _lock.Release(); } + } +} diff --git a/src/MakerPrompt.Infrastructure/Farm/InMemoryFarmRepository.cs b/src/MakerPrompt.Infrastructure/Farm/InMemoryFarmRepository.cs new file mode 100644 index 0000000..090cc5e --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Farm/InMemoryFarmRepository.cs @@ -0,0 +1,41 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Infrastructure.Farm; + +/// +/// Thread-safe, in-memory implementation of . +/// +public sealed class InMemoryFarmRepository : IFarmRepository +{ + private readonly Dictionary _farms = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { return _farms.Values.OrderBy(f => f.CreatedAt).ToList().AsReadOnly(); } + finally { _lock.Release(); } + } + + public async Task GetByIdAsync(Guid farmId, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { return _farms.GetValueOrDefault(farmId); } + finally { _lock.Release(); } + } + + public async Task SaveAsync(FarmConfiguration farm, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { _farms[farm.Id] = farm; } + finally { _lock.Release(); } + } + + public async Task DeleteAsync(Guid farmId, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { _farms.Remove(farmId); } + finally { _lock.Release(); } + } +} diff --git a/src/MakerPrompt.Infrastructure/Inventory/InMemoryFilamentInventoryStore.cs b/src/MakerPrompt.Infrastructure/Inventory/InMemoryFilamentInventoryStore.cs new file mode 100644 index 0000000..441bb3b --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Inventory/InMemoryFilamentInventoryStore.cs @@ -0,0 +1,53 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Infrastructure.Inventory; + +/// +/// Thread-safe, in-memory implementation of . +/// Suitable for unit tests and development; does not survive process restart. +/// +public sealed class InMemoryFilamentInventoryStore : IFilamentInventoryStore +{ + private readonly Dictionary _spools = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { return _spools.Values.ToList().AsReadOnly(); } + finally { _lock.Release(); } + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { return _spools.GetValueOrDefault(id); } + finally { _lock.Release(); } + } + + public async Task SaveAsync(FilamentSpool spool, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { _spools[spool.Id] = spool; } + finally { _lock.Release(); } + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { _spools.Remove(id); } + finally { _lock.Release(); } + } + + public async Task DeductFilamentAsync(Guid spoolId, double grams, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (_spools.TryGetValue(spoolId, out var spool)) + spool.RemainingWeightGrams = Math.Max(0, spool.RemainingWeightGrams - grams); + } + finally { _lock.Release(); } + } +} diff --git a/src/MakerPrompt.Infrastructure/Projects/InMemoryPrintProjectRepository.cs b/src/MakerPrompt.Infrastructure/Projects/InMemoryPrintProjectRepository.cs new file mode 100644 index 0000000..06fff67 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Projects/InMemoryPrintProjectRepository.cs @@ -0,0 +1,76 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Infrastructure.Projects; + +/// +/// Thread-safe, in-memory implementation of . +/// G-code file "storage" is kept in-memory as byte arrays. +/// +public sealed class InMemoryPrintProjectRepository : IPrintProjectRepository +{ + private readonly Dictionary _projects = new(); + private readonly Dictionary _files = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { return _projects.Values.ToList().AsReadOnly(); } + finally { _lock.Release(); } + } + + public async Task GetByIdAsync(Guid projectId, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { return _projects.GetValueOrDefault(projectId); } + finally { _lock.Release(); } + } + + public async Task SaveAsync(PrintProject project, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { _projects[project.Id] = project; } + finally { _lock.Release(); } + } + + public async Task DeleteAsync(Guid projectId, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { _projects.Remove(projectId); } + finally { _lock.Release(); } + } + + public async Task OpenJobFileAsync(string storagePath, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + return _files.TryGetValue(storagePath, out var data) + ? new MemoryStream(data, writable: false) + : null; + } + finally { _lock.Release(); } + } + + public async Task SaveJobFileAsync(Guid projectId, string fileName, Stream content, + CancellationToken cancellationToken = default) + { + var storagePath = $"PrintProjects/{projectId}/{fileName}"; + using var ms = new MemoryStream(); + await content.CopyToAsync(ms, cancellationToken); + + await _lock.WaitAsync(cancellationToken); + try { _files[storagePath] = ms.ToArray(); } + finally { _lock.Release(); } + + return storagePath; + } + + public async Task DeleteJobFileAsync(string storagePath, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { _files.Remove(storagePath); } + finally { _lock.Release(); } + } +} diff --git a/src/MakerPrompt.Tests.Unit/Application/AnalyticsServiceTests.cs b/src/MakerPrompt.Tests.Unit/Application/AnalyticsServiceTests.cs new file mode 100644 index 0000000..180a469 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Application/AnalyticsServiceTests.cs @@ -0,0 +1,123 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Analytics; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MakerPrompt.Tests.Unit.Application; + +/// +/// Unit tests for . +/// +public sealed class AnalyticsServiceTests +{ + private static AnalyticsService CreateSut() => + new(new InMemoryPrintJobAnalyticsStore(), NullLogger.Instance); + + private static readonly Guid PrinterA = Guid.NewGuid(); + private static readonly Guid PrinterB = Guid.NewGuid(); + private static readonly Guid SpoolX = Guid.NewGuid(); + + [Fact] + public async Task GetRecords_Initially_ReturnsEmpty() + { + var sut = CreateSut(); + var records = await sut.GetRecordsAsync(); + Assert.Empty(records); + } + + [Fact] + public async Task RecordUsage_AppearsInGetRecords() + { + var sut = CreateSut(); + var record = new PrintJobUsageRecord + { + PrinterId = PrinterA, + JobName = "Benchy", + Duration = TimeSpan.FromHours(2), + EstimatedFilamentUsedGrams = 30, + }; + + await sut.RecordUsageAsync(record); + + var records = await sut.GetRecordsAsync(); + Assert.Single(records); + Assert.Equal("Benchy", records[0].JobName); + } + + [Fact] + public async Task GetTotalPrintTime_SumsAllDurations() + { + var sut = CreateSut(); + await sut.RecordUsageAsync(new PrintJobUsageRecord { Duration = TimeSpan.FromHours(1) }); + await sut.RecordUsageAsync(new PrintJobUsageRecord { Duration = TimeSpan.FromHours(2.5) }); + + var total = await sut.GetTotalPrintTimeAsync(); + + Assert.Equal(TimeSpan.FromHours(3.5), total); + } + + [Fact] + public async Task GetTotalFilamentConsumed_UsesActualWhenPresent() + { + var sut = CreateSut(); + await sut.RecordUsageAsync(new PrintJobUsageRecord + { + EstimatedFilamentUsedGrams = 50, + ActualFilamentUsedGrams = 48, // actual overrides estimated + }); + await sut.RecordUsageAsync(new PrintJobUsageRecord + { + EstimatedFilamentUsedGrams = 30, + ActualFilamentUsedGrams = 0, // use estimated when actual is 0 + }); + + var total = await sut.GetTotalFilamentConsumedGramsAsync(); + + Assert.Equal(78, total); // 48 + 30 + } + + [Fact] + public async Task GetFilamentConsumedByPrinter_FiltersCorrectly() + { + var sut = CreateSut(); + await sut.RecordUsageAsync(new PrintJobUsageRecord { PrinterId = PrinterA, EstimatedFilamentUsedGrams = 40 }); + await sut.RecordUsageAsync(new PrintJobUsageRecord { PrinterId = PrinterB, EstimatedFilamentUsedGrams = 60 }); + await sut.RecordUsageAsync(new PrintJobUsageRecord { PrinterId = PrinterA, EstimatedFilamentUsedGrams = 20 }); + + var consumed = await sut.GetFilamentConsumedByPrinterAsync(PrinterA); + + Assert.Equal(60, consumed); // 40 + 20 + } + + [Fact] + public async Task GetFilamentConsumedBySpool_FiltersCorrectly() + { + var sut = CreateSut(); + await sut.RecordUsageAsync(new PrintJobUsageRecord { FilamentSpoolId = SpoolX, EstimatedFilamentUsedGrams = 35 }); + await sut.RecordUsageAsync(new PrintJobUsageRecord { FilamentSpoolId = Guid.NewGuid(), EstimatedFilamentUsedGrams = 100 }); + + var consumed = await sut.GetFilamentConsumedBySpoolAsync(SpoolX); + + Assert.Equal(35, consumed); + } + + [Fact] + public async Task RecordUsage_RaisesAnalyticsUpdatedEvent() + { + var sut = CreateSut(); + var raised = false; + sut.AnalyticsUpdated += (_, _) => raised = true; + + await sut.RecordUsageAsync(new PrintJobUsageRecord()); + + Assert.True(raised); + } + + [Fact] + public async Task GetTotalPrintTime_WhenNoRecords_ReturnsZero() + { + var sut = CreateSut(); + var total = await sut.GetTotalPrintTimeAsync(); + Assert.Equal(TimeSpan.Zero, total); + } +} diff --git a/src/MakerPrompt.Tests.Unit/Application/FarmServiceTests.cs b/src/MakerPrompt.Tests.Unit/Application/FarmServiceTests.cs new file mode 100644 index 0000000..c0ba0a3 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Application/FarmServiceTests.cs @@ -0,0 +1,143 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Farm; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MakerPrompt.Tests.Unit.Application; + +/// +/// Unit tests for . +/// +public sealed class FarmServiceTests +{ + private static FarmService CreateSut() => + new(new InMemoryFarmRepository(), NullLogger.Instance); + + [Fact] + public async Task GetFarms_Initially_ReturnsEmpty() + { + var sut = CreateSut(); + var farms = await sut.GetFarmsAsync(); + Assert.Empty(farms); + } + + [Fact] + public async Task CreateFarm_AppearsInList() + { + var sut = CreateSut(); + + var farm = await sut.CreateFarmAsync("Hackerspace A"); + + var farms = await sut.GetFarmsAsync(); + Assert.Single(farms); + Assert.Equal("Hackerspace A", farms[0].Name); + } + + [Fact] + public async Task CreateFarm_TrimsName() + { + var sut = CreateSut(); + var farm = await sut.CreateFarmAsync(" Workshop "); + Assert.Equal("Workshop", farm.Name); + } + + [Fact] + public async Task RenameFarm_UpdatesName() + { + var sut = CreateSut(); + var farm = await sut.CreateFarmAsync("Old"); + + await sut.RenameFarmAsync(farm.Id, "New"); + + var retrieved = await sut.GetFarmAsync(farm.Id); + Assert.Equal("New", retrieved?.Name); + } + + [Fact] + public async Task RenameFarm_UnknownId_DoesNotThrow() + { + var sut = CreateSut(); + await sut.RenameFarmAsync(Guid.NewGuid(), "Whatever"); + // No exception expected + } + + [Fact] + public async Task DeleteFarm_RemovesFromList() + { + var sut = CreateSut(); + var farm = await sut.CreateFarmAsync("To Delete"); + + await sut.DeleteFarmAsync(farm.Id); + + var farms = await sut.GetFarmsAsync(); + Assert.Empty(farms); + } + + [Fact] + public async Task SnapshotPrinters_StoresPrinterListInFarm() + { + var sut = CreateSut(); + var farm = await sut.CreateFarmAsync("F"); + var printers = new[] + { + new PrinterConnectionDefinition { Name = "MK4 Alpha" }, + new PrinterConnectionDefinition { Name = "MK4 Beta" }, + }; + + await sut.SnapshotPrintersAsync(farm.Id, printers); + + var retrieved = await sut.GetFarmAsync(farm.Id); + Assert.Equal(2, retrieved!.Printers.Count); + Assert.Contains(retrieved.Printers, p => p.Name == "MK4 Alpha"); + } + + [Fact] + public async Task ExportFarm_ReturnsValidJson() + { + var sut = CreateSut(); + var farm = await sut.CreateFarmAsync("Export Test"); + + var json = await sut.ExportFarmAsync(farm.Id); + + Assert.Contains("Export Test", json); + Assert.Contains("\"Name\"", json); + } + + [Fact] + public async Task ImportFarm_AddsNewFarmWithFreshId() + { + var sut = CreateSut(); + var original = await sut.CreateFarmAsync("Original Farm"); + var json = await sut.ExportFarmAsync(original.Id); + + var imported = await sut.ImportFarmAsync(json); + + // Imported farm should get a new ID + Assert.NotEqual(original.Id, imported.Id); + // But same name + Assert.Equal("Original Farm", imported.Name); + + var farms = await sut.GetFarmsAsync(); + Assert.Equal(2, farms.Count); + } + + [Fact] + public async Task FarmsChanged_RaisedOnCreate() + { + var sut = CreateSut(); + var raised = false; + sut.FarmsChanged += (_, _) => raised = true; + + await sut.CreateFarmAsync("Event Farm"); + + Assert.True(raised); + } + + [Fact] + public async Task ExportFarm_UnknownId_ReturnsEmptyObject() + { + var sut = CreateSut(); + var result = await sut.ExportFarmAsync(Guid.NewGuid()); + Assert.Equal("{}", result); + } +} diff --git a/src/MakerPrompt.Tests.Unit/Application/FilamentInventoryServiceTests.cs b/src/MakerPrompt.Tests.Unit/Application/FilamentInventoryServiceTests.cs new file mode 100644 index 0000000..ee9416d --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Application/FilamentInventoryServiceTests.cs @@ -0,0 +1,124 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Inventory; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MakerPrompt.Tests.Unit.Application; + +/// +/// Unit tests for . +/// +public sealed class FilamentInventoryServiceTests +{ + private static FilamentInventoryService CreateSut() => + new(new InMemoryFilamentInventoryStore(), NullLogger.Instance); + + [Fact] + public async Task GetSpools_Initially_ReturnsEmpty() + { + var sut = CreateSut(); + var spools = await sut.GetSpoolsAsync(); + Assert.Empty(spools); + } + + [Fact] + public async Task AddSpool_SpoolAppearsInList() + { + var sut = CreateSut(); + var spool = new FilamentSpool { Name = "PETG Black", Material = "PETG" }; + + await sut.AddSpoolAsync(spool); + + var spools = await sut.GetSpoolsAsync(); + Assert.Single(spools); + Assert.Equal("PETG Black", spools[0].Name); + } + + [Fact] + public async Task UpdateSpool_ReplacesExistingEntry() + { + var sut = CreateSut(); + var spool = new FilamentSpool { Name = "PLA White" }; + await sut.AddSpoolAsync(spool); + + spool.Name = "PLA White (renamed)"; + await sut.UpdateSpoolAsync(spool); + + var retrieved = await sut.GetSpoolAsync(spool.Id); + Assert.Equal("PLA White (renamed)", retrieved?.Name); + } + + [Fact] + public async Task DeleteSpool_SpoolNoLongerInList() + { + var sut = CreateSut(); + var spool = new FilamentSpool { Name = "ABS Red" }; + await sut.AddSpoolAsync(spool); + + await sut.DeleteSpoolAsync(spool.Id); + + var spools = await sut.GetSpoolsAsync(); + Assert.Empty(spools); + } + + [Fact] + public async Task DeductFilament_ReducesRemainingWeight() + { + var sut = CreateSut(); + var spool = new FilamentSpool { RemainingWeightGrams = 500 }; + await sut.AddSpoolAsync(spool); + + await sut.DeductFilamentAsync(spool.Id, 100); + + var retrieved = await sut.GetSpoolAsync(spool.Id); + Assert.Equal(400, retrieved!.RemainingWeightGrams); + } + + [Fact] + public async Task DeductFilament_ClampsAtZero_NeverGoesNegative() + { + var sut = CreateSut(); + var spool = new FilamentSpool { RemainingWeightGrams = 50 }; + await sut.AddSpoolAsync(spool); + + await sut.DeductFilamentAsync(spool.Id, 999); + + var retrieved = await sut.GetSpoolAsync(spool.Id); + Assert.Equal(0, retrieved!.RemainingWeightGrams); + } + + [Fact] + public async Task DeductFilament_UnknownSpool_DoesNotThrow() + { + var sut = CreateSut(); + // Should log a warning and return gracefully + await sut.DeductFilamentAsync(Guid.NewGuid(), 100); + } + + [Fact] + public async Task AddSpool_RaisesInventoryChangedEvent() + { + var sut = CreateSut(); + var raised = false; + sut.InventoryChanged += (_, _) => raised = true; + + await sut.AddSpoolAsync(new FilamentSpool { Name = "TPU" }); + + Assert.True(raised); + } + + [Fact] + public async Task DeleteSpool_RaisesInventoryChangedEvent() + { + var sut = CreateSut(); + var spool = new FilamentSpool(); + await sut.AddSpoolAsync(spool); + + var raised = false; + sut.InventoryChanged += (_, _) => raised = true; + + await sut.DeleteSpoolAsync(spool.Id); + + Assert.True(raised); + } +} diff --git a/src/MakerPrompt.Tests.Unit/Application/PrintProjectServiceTests.cs b/src/MakerPrompt.Tests.Unit/Application/PrintProjectServiceTests.cs new file mode 100644 index 0000000..810a448 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Application/PrintProjectServiceTests.cs @@ -0,0 +1,168 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Projects; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MakerPrompt.Tests.Unit.Application; + +/// +/// Unit tests for . +/// +public sealed class PrintProjectServiceTests +{ + private static PrintProjectService CreateSut() => + new(new InMemoryPrintProjectRepository(), NullLogger.Instance); + + [Fact] + public async Task GetProjects_Initially_ReturnsEmpty() + { + var sut = CreateSut(); + var projects = await sut.GetProjectsAsync(); + Assert.Empty(projects); + } + + [Fact] + public async Task CreateProject_AppearsInList() + { + var sut = CreateSut(); + + var project = await sut.CreateProjectAsync("Test Print", "Some notes"); + + var projects = await sut.GetProjectsAsync(); + Assert.Single(projects); + Assert.Equal("Test Print", projects[0].Name); + Assert.Equal("Some notes", projects[0].Notes); + } + + [Fact] + public async Task CreateProject_TrimsName() + { + var sut = CreateSut(); + + var project = await sut.CreateProjectAsync(" Trimmed "); + + Assert.Equal("Trimmed", project.Name); + } + + [Fact] + public async Task RenameProject_UpdatesName() + { + var sut = CreateSut(); + var project = await sut.CreateProjectAsync("Old Name"); + + await sut.RenameProjectAsync(project.Id, "New Name"); + + var retrieved = await sut.GetProjectAsync(project.Id); + Assert.Equal("New Name", retrieved?.Name); + } + + [Fact] + public async Task DeleteProject_RemovesFromList() + { + var sut = CreateSut(); + var project = await sut.CreateProjectAsync("To Delete"); + + await sut.DeleteProjectAsync(project.Id); + + var projects = await sut.GetProjectsAsync(); + Assert.Empty(projects); + } + + [Fact] + public async Task AddJob_JobAppearsInProject() + { + var sut = CreateSut(); + var project = await sut.CreateProjectAsync("Batch"); + var content = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("G28\nG0 X10\n")); + + await sut.AddJobAsync(project.Id, "test.gcode", content); + + var retrieved = await sut.GetProjectAsync(project.Id); + Assert.Single(retrieved!.Jobs); + Assert.Equal("test.gcode", retrieved.Jobs[0].FileName); + } + + [Fact] + public async Task RemoveJob_JobRemovedFromProject() + { + var sut = CreateSut(); + var project = await sut.CreateProjectAsync("P"); + var content = new MemoryStream(new byte[] { 1, 2, 3 }); + await sut.AddJobAsync(project.Id, "file.gcode", content); + + var retrieved = await sut.GetProjectAsync(project.Id); + var jobId = retrieved!.Jobs[0].Id; + await sut.RemoveJobAsync(project.Id, jobId); + + retrieved = await sut.GetProjectAsync(project.Id); + Assert.Empty(retrieved!.Jobs); + } + + [Fact] + public async Task AssignJob_SetsPrinterAndStatusToPrinting() + { + var sut = CreateSut(); + var project = await sut.CreateProjectAsync("P"); + var content = new MemoryStream(new byte[] { 1 }); + await sut.AddJobAsync(project.Id, "job.gcode", content); + + var retrieved = await sut.GetProjectAsync(project.Id); + var jobId = retrieved!.Jobs[0].Id; + var printerId = Guid.NewGuid(); + + await sut.AssignJobAsync(project.Id, jobId, printerId, "MK4 Alpha"); + + retrieved = await sut.GetProjectAsync(project.Id); + var job = retrieved!.Jobs[0]; + Assert.Equal(PrintJobStatus.Printing, job.Status); + Assert.Equal(printerId, job.AssignedPrinterId); + Assert.Equal("MK4 Alpha", job.AssignedPrinterName); + } + + [Fact] + public async Task UpdateJobStatus_ChangesStatus() + { + var sut = CreateSut(); + var project = await sut.CreateProjectAsync("P"); + await sut.AddJobAsync(project.Id, "j.gcode", new MemoryStream(new byte[] { 1 })); + + var retrieved = await sut.GetProjectAsync(project.Id); + var jobId = retrieved!.Jobs[0].Id; + + await sut.UpdateJobStatusAsync(project.Id, jobId, PrintJobStatus.Completed); + + retrieved = await sut.GetProjectAsync(project.Id); + Assert.Equal(PrintJobStatus.Completed, retrieved!.Jobs[0].Status); + } + + [Fact] + public async Task OpenJobFile_ReturnsFileContent() + { + var sut = CreateSut(); + var project = await sut.CreateProjectAsync("P"); + var originalBytes = System.Text.Encoding.UTF8.GetBytes("G28\nG1 X100\n"); + await sut.AddJobAsync(project.Id, "file.gcode", new MemoryStream(originalBytes)); + + var retrieved = await sut.GetProjectAsync(project.Id); + var jobId = retrieved!.Jobs[0].Id; + + await using var stream = await sut.OpenJobFileAsync(project.Id, jobId); + + Assert.NotNull(stream); + using var reader = new System.IO.StreamReader(stream!); + var content = await reader.ReadToEndAsync(); + Assert.Equal("G28\nG1 X100\n", content); + } + + [Fact] + public async Task ProjectsChanged_RaisedOnCreate() + { + var sut = CreateSut(); + var raised = false; + sut.ProjectsChanged += (_, _) => raised = true; + + await sut.CreateProjectAsync("Event Test"); + + Assert.True(raised); + } +} diff --git a/src/MakerPrompt.Tests.Unit/Infrastructure/InMemoryStoreTests.cs b/src/MakerPrompt.Tests.Unit/Infrastructure/InMemoryStoreTests.cs new file mode 100644 index 0000000..b4e9343 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Infrastructure/InMemoryStoreTests.cs @@ -0,0 +1,168 @@ +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Inventory; +using MakerPrompt.Infrastructure.Analytics; +using MakerPrompt.Infrastructure.Farm; +using MakerPrompt.Infrastructure.Projects; + +namespace MakerPrompt.Tests.Unit.Infrastructure; + +/// +/// Verifies the thread-safety guarantees and contract compliance of +/// the in-memory infrastructure store implementations. +/// +public sealed class InMemoryStoreTests +{ + // ── FilamentInventoryStore ──────────────────────────────────────────────── + + [Fact] + public async Task FilamentStore_Save_And_GetById_RoundTrip() + { + var store = new InMemoryFilamentInventoryStore(); + var spool = new FilamentSpool { Name = "PLA Blue", RemainingWeightGrams = 800 }; + + await store.SaveAsync(spool); + var result = await store.GetByIdAsync(spool.Id); + + Assert.NotNull(result); + Assert.Equal("PLA Blue", result.Name); + } + + [Fact] + public async Task FilamentStore_Delete_RemovesEntry() + { + var store = new InMemoryFilamentInventoryStore(); + var spool = new FilamentSpool(); + await store.SaveAsync(spool); + + await store.DeleteAsync(spool.Id); + + Assert.Null(await store.GetByIdAsync(spool.Id)); + } + + [Fact] + public async Task FilamentStore_Deduct_ClampsAtZero() + { + var store = new InMemoryFilamentInventoryStore(); + var spool = new FilamentSpool { RemainingWeightGrams = 10 }; + await store.SaveAsync(spool); + + await store.DeductFilamentAsync(spool.Id, 1000); + + var result = await store.GetByIdAsync(spool.Id); + Assert.Equal(0, result!.RemainingWeightGrams); + } + + // ── AnalyticsStore ──────────────────────────────────────────────────────── + + [Fact] + public async Task AnalyticsStore_Save_AppearsInGetAll() + { + var store = new InMemoryPrintJobAnalyticsStore(); + var record = new PrintJobUsageRecord { JobName = "Test Job" }; + + await store.SaveAsync(record); + var all = await store.GetAllAsync(); + + Assert.Single(all); + Assert.Equal("Test Job", all[0].JobName); + } + + [Fact] + public async Task AnalyticsStore_GetByPrinter_FiltersCorrectly() + { + var store = new InMemoryPrintJobAnalyticsStore(); + var printerId = Guid.NewGuid(); + await store.SaveAsync(new PrintJobUsageRecord { PrinterId = printerId }); + await store.SaveAsync(new PrintJobUsageRecord { PrinterId = Guid.NewGuid() }); + + var result = await store.GetByPrinterAsync(printerId); + + Assert.Single(result); + Assert.Equal(printerId, result[0].PrinterId); + } + + // ── FarmRepository ──────────────────────────────────────────────────────── + + [Fact] + public async Task FarmRepo_Save_And_GetById_RoundTrip() + { + var repo = new InMemoryFarmRepository(); + var farm = new FarmConfiguration { Name = "Test Farm" }; + + await repo.SaveAsync(farm); + var result = await repo.GetByIdAsync(farm.Id); + + Assert.NotNull(result); + Assert.Equal("Test Farm", result.Name); + } + + [Fact] + public async Task FarmRepo_Delete_RemovesEntry() + { + var repo = new InMemoryFarmRepository(); + var farm = new FarmConfiguration { Name = "Delete Me" }; + await repo.SaveAsync(farm); + + await repo.DeleteAsync(farm.Id); + + Assert.Null(await repo.GetByIdAsync(farm.Id)); + } + + [Fact] + public async Task FarmRepo_GetAll_OrderedByCreationDate() + { + var repo = new InMemoryFarmRepository(); + var older = new FarmConfiguration { Name = "Older", CreatedAt = DateTime.UtcNow.AddDays(-1) }; + var newer = new FarmConfiguration { Name = "Newer", CreatedAt = DateTime.UtcNow }; + await repo.SaveAsync(newer); + await repo.SaveAsync(older); + + var all = await repo.GetAllAsync(); + + Assert.Equal("Older", all[0].Name); + Assert.Equal("Newer", all[1].Name); + } + + // ── PrintProjectRepository ──────────────────────────────────────────────── + + [Fact] + public async Task ProjectRepo_Save_And_GetAll_RoundTrip() + { + var repo = new InMemoryPrintProjectRepository(); + var project = new PrintProject { Name = "Test Project" }; + + await repo.SaveAsync(project); + var all = await repo.GetAllAsync(); + + Assert.Single(all); + Assert.Equal("Test Project", all[0].Name); + } + + [Fact] + public async Task ProjectRepo_SaveJobFile_And_OpenJobFile_RoundTrip() + { + var repo = new InMemoryPrintProjectRepository(); + var projectId = Guid.NewGuid(); + var gcode = System.Text.Encoding.UTF8.GetBytes("G28\nG0 X10\n"); + + var storagePath = await repo.SaveJobFileAsync(projectId, "test.gcode", new MemoryStream(gcode)); + + await using var stream = await repo.OpenJobFileAsync(storagePath); + Assert.NotNull(stream); + using var reader = new System.IO.StreamReader(stream!); + var content = await reader.ReadToEndAsync(); + Assert.Equal("G28\nG0 X10\n", content); + } + + [Fact] + public async Task ProjectRepo_DeleteJobFile_FileNoLongerAccessible() + { + var repo = new InMemoryPrintProjectRepository(); + var storagePath = await repo.SaveJobFileAsync(Guid.NewGuid(), "delete.gcode", + new MemoryStream(new byte[] { 1 })); + + await repo.DeleteJobFileAsync(storagePath); + + Assert.Null(await repo.OpenJobFileAsync(storagePath)); + } +} From 0c7a63592762402205a4a58f06a5ac5bbe8adfd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 20:01:12 +0000 Subject: [PATCH 05/19] =?UTF-8?q?feat:=20Phase=203+7+6=20=E2=80=94=20UI.Co?= =?UTF-8?q?mponents=20RCL,=20UI.Blazor=20host,=20UI.MAUI=20host=20w/=20ser?= =?UTF-8?q?ial,=20Cloud=20OIDC=20auth"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/akinbender/MakerPrompt/sessions/905d1ff3-25bd-41d3-91f1-5bc466640ec6 Co-authored-by: akinbender <40242943+akinbender@users.noreply.github.com> --- MakerPrompt.sln | 46 +++ .../MakerPrompt.Cloud.csproj | 5 + src/MakerPrompt.Cloud/Program.cs | 118 ++++++- .../appsettings.Development.json | 15 + src/MakerPrompt.Cloud/appsettings.json | 16 + .../Serial/SerialCommunicationServiceBase.cs | 333 ++++++++++++++++++ .../MakerPrompt.UI.Blazor.csproj | 23 ++ src/MakerPrompt.UI.Blazor/Program.cs | 34 ++ src/MakerPrompt.UI.Blazor/wwwroot/css/app.css | 9 + src/MakerPrompt.UI.Blazor/wwwroot/index.html | 31 ++ src/MakerPrompt.UI.Components/App.razor | 13 + .../Layout/MainLayout.razor | 17 + .../Layout/NavMenu.razor | 32 ++ .../MakerPrompt.UI.Components.csproj | 20 ++ .../Pages/AnalyticsPage.razor | 153 ++++++++ .../Pages/DashboardPage.razor | 192 ++++++++++ .../Pages/FilamentInventoryPage.razor | 209 +++++++++++ .../Pages/FleetPage.razor | 125 +++++++ .../Pages/SettingsPage.razor | 65 ++++ src/MakerPrompt.UI.Components/_Imports.razor | 12 + src/MakerPrompt.UI.MAUI/App.cs | 9 + .../Components/Routes.razor | 12 + src/MakerPrompt.UI.MAUI/MainPage.cs | 20 ++ .../MakerPrompt.UI.MAUI.csproj | 77 ++++ src/MakerPrompt.UI.MAUI/MauiProgram.cs | 57 +++ .../Resources/AppIcon/appicon.png | Bin 0 -> 69 bytes .../SerialCommunicationService.Android.cs | 111 ++++++ .../SerialCommunicationService.MacOS.cs | 126 +++++++ .../SerialCommunicationService.Windows.cs | 149 ++++++++ .../Services/SerialCommunicationService.cs | 28 ++ .../SerialCommunicationService.iOS.cs | 27 ++ src/MakerPrompt.UI.MAUI/wwwroot/css/app.css | 9 + src/MakerPrompt.UI.MAUI/wwwroot/index.html | 16 + 33 files changed, 2101 insertions(+), 8 deletions(-) create mode 100644 src/MakerPrompt.Cloud/appsettings.Development.json create mode 100644 src/MakerPrompt.Cloud/appsettings.json create mode 100644 src/MakerPrompt.Infrastructure/Serial/SerialCommunicationServiceBase.cs create mode 100644 src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj create mode 100644 src/MakerPrompt.UI.Blazor/Program.cs create mode 100644 src/MakerPrompt.UI.Blazor/wwwroot/css/app.css create mode 100644 src/MakerPrompt.UI.Blazor/wwwroot/index.html create mode 100644 src/MakerPrompt.UI.Components/App.razor create mode 100644 src/MakerPrompt.UI.Components/Layout/MainLayout.razor create mode 100644 src/MakerPrompt.UI.Components/Layout/NavMenu.razor create mode 100644 src/MakerPrompt.UI.Components/MakerPrompt.UI.Components.csproj create mode 100644 src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor create mode 100644 src/MakerPrompt.UI.Components/Pages/DashboardPage.razor create mode 100644 src/MakerPrompt.UI.Components/Pages/FilamentInventoryPage.razor create mode 100644 src/MakerPrompt.UI.Components/Pages/FleetPage.razor create mode 100644 src/MakerPrompt.UI.Components/Pages/SettingsPage.razor create mode 100644 src/MakerPrompt.UI.Components/_Imports.razor create mode 100644 src/MakerPrompt.UI.MAUI/App.cs create mode 100644 src/MakerPrompt.UI.MAUI/Components/Routes.razor create mode 100644 src/MakerPrompt.UI.MAUI/MainPage.cs create mode 100644 src/MakerPrompt.UI.MAUI/MakerPrompt.UI.MAUI.csproj create mode 100644 src/MakerPrompt.UI.MAUI/MauiProgram.cs create mode 100644 src/MakerPrompt.UI.MAUI/Resources/AppIcon/appicon.png create mode 100644 src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Android.cs create mode 100644 src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.MacOS.cs create mode 100644 src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Windows.cs create mode 100644 src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.cs create mode 100644 src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.iOS.cs create mode 100644 src/MakerPrompt.UI.MAUI/wwwroot/css/app.css create mode 100644 src/MakerPrompt.UI.MAUI/wwwroot/index.html diff --git a/MakerPrompt.sln b/MakerPrompt.sln index 53036cc..90eff88 100644 --- a/MakerPrompt.sln +++ b/MakerPrompt.sln @@ -45,6 +45,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.EdgeAgent", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Tests.Unit", "src\MakerPrompt.Tests.Unit\MakerPrompt.Tests.Unit.csproj", "{B4F2D4E1-0006-0000-0000-000000000001}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.UI.Components", "src\MakerPrompt.UI.Components\MakerPrompt.UI.Components.csproj", "{B4F2D4E1-0007-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.UI.Blazor", "src\MakerPrompt.UI.Blazor\MakerPrompt.UI.Blazor.csproj", "{B4F2D4E1-0008-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.UI.MAUI", "src\MakerPrompt.UI.MAUI\MakerPrompt.UI.MAUI.csproj", "{B4F2D4E1-0009-0000-0000-000000000001}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -200,6 +206,43 @@ Global {B4F2D4E1-0006-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU {B4F2D4E1-0006-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU {B4F2D4E1-0006-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -212,6 +255,9 @@ Global {B4F2D4E1-0004-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} {B4F2D4E1-0005-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} {B4F2D4E1-0006-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0007-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0008-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0009-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {545A45A2-4075-429A-AC75-ABFBE72CC15A} diff --git a/src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj b/src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj index 797615e..bfdd308 100644 --- a/src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj +++ b/src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj @@ -6,6 +6,11 @@ enable + + + + + diff --git a/src/MakerPrompt.Cloud/Program.cs b/src/MakerPrompt.Cloud/Program.cs index 6a5f8f2..c851cb2 100644 --- a/src/MakerPrompt.Cloud/Program.cs +++ b/src/MakerPrompt.Cloud/Program.cs @@ -1,29 +1,126 @@ using MakerPrompt.Core.Abstractions; using MakerPrompt.Core.Models; using MakerPrompt.Infrastructure.Telemetry; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); +// ── Authentication — JWT Bearer / OIDC ─────────────────────────────────────── +// +// The Cloud API validates JWTs issued by any OIDC-compliant provider +// (Azure AD, Auth0, Keycloak, etc.). Configure the authority and audience +// via environment variables or appsettings.json: +// +// MakerPrompt:Auth:Authority – OIDC issuer URL (e.g. https://tenant.auth0.com/) +// MakerPrompt:Auth:Audience – API identifier (e.g. https://api.makerprompt.io) +// MakerPrompt:Auth:RequireHttpsMetadata – true in production, false in local dev +// +// Edge Agents send a machine-to-machine token; member clients send user tokens. + +var authSection = builder.Configuration.GetSection("MakerPrompt:Auth"); +var authority = authSection["Authority"]; +var audience = authSection["Audience"]; +var requireHttps = authSection.GetValue("RequireHttpsMetadata", true); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + // If no Authority is configured, we run in open/dev mode. + if (!string.IsNullOrWhiteSpace(authority)) + { + options.Authority = authority; + options.Audience = audience; + options.RequireHttpsMetadata = requireHttps; + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = !string.IsNullOrWhiteSpace(audience), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(5), + }; + } + else + { + // Development fallback: accept any well-formed token but skip signature. + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = false, + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + SignatureValidator = (token, _) => + { + // Only allow the bypass in Development. + if (!builder.Environment.IsDevelopment()) + throw new SecurityTokenValidationException( + "Auth:Authority must be configured in non-Development environments."); + + return new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(token); + } + }; + } + + // Swallow token validation exceptions — return 401 instead of 500. + options.Events = new JwtBearerEvents + { + OnAuthenticationFailed = ctx => + { + ctx.Response.Headers.Append("WWW-Authenticate", + $"Bearer error=\"invalid_token\", " + + $"error_description=\"{Uri.EscapeDataString(ctx.Exception.Message)}\""); + return Task.CompletedTask; + } + }; + }); + +builder.Services.AddAuthorization(options => +{ + // Default policy: require any authenticated user / machine. + options.DefaultPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + + // "EdgeAgent" policy: must have the edge-agent scope claim. + options.AddPolicy("EdgeAgent", policy => + policy.RequireAuthenticatedUser() + .RequireClaim("scope", "makerprompt:ingest")); + + // "Member" read policy: authenticated users may read telemetry. + options.AddPolicy("MemberRead", policy => + policy.RequireAuthenticatedUser()); +}); + // ── Services ───────────────────────────────────────────────────────────────── // Local in-memory telemetry store (swap for a real persistence layer in production). builder.Services.AddSingleton(); +// API Explorer for potential future Swagger integration. +builder.Services.AddEndpointsApiExplorer(); + var app = builder.Build(); // ── Middleware ──────────────────────────────────────────────────────────────── app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); // ── Endpoints ───────────────────────────────────────────────────────────────── -// Health check +// Health check — public, no auth required. app.MapGet("/health", () => Results.Ok(new { status = "healthy", utc = DateTimeOffset.UtcNow })) .WithName("HealthCheck") - .WithTags("System"); + .WithTags("System") + .AllowAnonymous(); -// Ingest telemetry from an EdgeAgent +// Ingest telemetry from an EdgeAgent. +// Requires the "makerprompt:ingest" scope (machine-to-machine token from EdgeAgent). app.MapPost("/api/telemetry/{printerId}", async ( string printerId, [FromBody] PrinterTelemetry telemetry, @@ -34,9 +131,11 @@ return Results.Accepted(); }) .WithName("IngestTelemetry") -.WithTags("Telemetry"); +.WithTags("Telemetry") +.RequireAuthorization("EdgeAgent"); -// Retrieve the latest telemetry for a printer +// Retrieve the latest telemetry for a printer. +// Requires any authenticated user (member read access). app.MapGet("/api/telemetry/{printerId}/latest", async ( string printerId, ITelemetryStore store, @@ -46,9 +145,11 @@ return latest is null ? Results.NotFound() : Results.Ok(latest); }) .WithName("GetLatestTelemetry") -.WithTags("Telemetry"); +.WithTags("Telemetry") +.RequireAuthorization("MemberRead"); -// Retrieve telemetry history for a printer +// Retrieve telemetry history for a printer. +// Requires any authenticated user (member read access). app.MapGet("/api/telemetry/{printerId}/history", async ( string printerId, ITelemetryStore store, @@ -59,7 +160,8 @@ return Results.Ok(history); }) .WithName("GetTelemetryHistory") -.WithTags("Telemetry"); +.WithTags("Telemetry") +.RequireAuthorization("MemberRead"); // ── Run ─────────────────────────────────────────────────────────────────────── diff --git a/src/MakerPrompt.Cloud/appsettings.Development.json b/src/MakerPrompt.Cloud/appsettings.Development.json new file mode 100644 index 0000000..5def1c3 --- /dev/null +++ b/src/MakerPrompt.Cloud/appsettings.Development.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + }, + "MakerPrompt": { + "Auth": { + "Authority": "", + "Audience": "", + "RequireHttpsMetadata": false + } + } +} diff --git a/src/MakerPrompt.Cloud/appsettings.json b/src/MakerPrompt.Cloud/appsettings.json new file mode 100644 index 0000000..98bd1a2 --- /dev/null +++ b/src/MakerPrompt.Cloud/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "MakerPrompt": { + "Auth": { + "Authority": "", + "Audience": "", + "RequireHttpsMetadata": false + } + } +} diff --git a/src/MakerPrompt.Infrastructure/Serial/SerialCommunicationServiceBase.cs b/src/MakerPrompt.Infrastructure/Serial/SerialCommunicationServiceBase.cs new file mode 100644 index 0000000..6c8f77f --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Serial/SerialCommunicationServiceBase.cs @@ -0,0 +1,333 @@ +using System.Text; +using System.Text.RegularExpressions; +using System.Timers; +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Infrastructure.Serial; + +/// +/// Base class for serial/USB printer communication services (Marlin / RepRap firmware). +/// +/// Architecture +/// ------------ +/// This class implements all methods that +/// are protocol-level (G-code command building, Marlin response parsing, telemetry +/// polling timer). Platform-specific transport (opening the port, writing/reading +/// bytes) is left to the concrete subclass via and +/// the lifecycle hooks / . +/// +/// Dependency +/// ---------- +/// Only references MakerPrompt.Core — no Blazor, no MAUI, no platform APIs. +/// Platform subclasses live in the host projects (MakerPrompt.UI.MAUI). +/// +public abstract class SerialCommunicationServiceBase : IPrinterCommunicationService +{ + // ── Regex patterns for Marlin response parsing ─────────────────────────── + private static readonly Regex TempRegex = + new(@"T:([\d.]+)\s*/\s*([\d.]+)\s+B:([\d.]+)\s*/\s*([\d.]+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex PrintProgressRegex = + new(@"SD printing byte (\d+)/(\d+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + // ── Events ─────────────────────────────────────────────────────────────── + public event EventHandler? ConnectionStateChanged; + public event EventHandler? TelemetryUpdated; + + // ── State ──────────────────────────────────────────────────────────────── + public PrinterConnectionType ConnectionType => PrinterConnectionType.Serial; + public PrinterTelemetry LastTelemetry { get; private set; } = new(); + public string ConnectionName { get; protected set; } = string.Empty; + public bool IsConnected { get; protected set; } + public bool IsPrinting { get; protected set; } + + // ── Private fields ─────────────────────────────────────────────────────── + private readonly System.Timers.Timer _telemetryTimer = new(TimeSpan.FromSeconds(3)); + private readonly StringBuilder _receiveBuffer = new(); + + protected SerialCommunicationServiceBase() + { + _telemetryTimer.Elapsed += async (_, _) => await PollTelemetryAsync(); + _telemetryTimer.AutoReset = true; + } + + // ── Abstract transport hooks ───────────────────────────────────────────── + + /// + /// Opens the underlying transport (serial port, USB driver, etc.) using the + /// supplied connection settings. Called by . + /// + protected abstract Task OpenTransportAsync(PrinterConnectionSettings settings, + CancellationToken cancellationToken); + + /// + /// Closes the underlying transport. Called by + /// and . + /// + protected abstract Task CloseTransportAsync(CancellationToken cancellationToken); + + /// + /// Writes (a single G-code command line) to the transport. + /// The base class appends a newline; implementations should send the bytes as-is + /// or append their own framing. + /// + protected abstract Task WriteTransportAsync(string data, CancellationToken cancellationToken); + + // ── IPrinterCommunicationService: Lifecycle ────────────────────────────── + + public async Task ConnectAsync(PrinterConnectionSettings settings, + CancellationToken cancellationToken = default) + { + if (IsConnected) return true; + + try + { + await OpenTransportAsync(settings, cancellationToken); + IsConnected = true; + ConnectionName = settings.PortName ?? settings.ConnectionType.ToString(); + LastTelemetry = new PrinterTelemetry { Status = PrinterStatus.Connected }; + _telemetryTimer.Start(); + RaiseConnectionChanged(); + return true; + } + catch + { + IsConnected = false; + return false; + } + } + + public async Task DisconnectAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected) return; + + _telemetryTimer.Stop(); + IsConnected = false; + IsPrinting = false; + + try + { + await CloseTransportAsync(cancellationToken); + } + catch + { + // Swallow close errors — we are already marking as disconnected. + } + + RaiseConnectionChanged(); + } + + // ── IPrinterCommunicationService: Data transfer ────────────────────────── + + public Task WriteDataAsync(string command, CancellationToken cancellationToken = default) + => IsConnected ? WriteTransportAsync(command, cancellationToken) : Task.CompletedTask; + + public async Task GetTelemetryAsync( + CancellationToken cancellationToken = default) + { + await WriteTransportAsync("M105", cancellationToken); // temperatures + await WriteTransportAsync("M27", cancellationToken); // SD print progress + await Task.Delay(200, cancellationToken); + return LastTelemetry; + } + + public Task> GetFilesAsync(CancellationToken cancellationToken = default) + => Task.FromResult>([]); + + // ── IPrinterCommunicationService: Print control ────────────────────────── + + public Task SetHotendTempAsync(int targetCelsius, CancellationToken cancellationToken = default) + { + if (!IsConnected) return Task.CompletedTask; + return WriteTransportAsync($"M104 S{targetCelsius}", cancellationToken); + } + + public Task SetBedTempAsync(int targetCelsius, CancellationToken cancellationToken = default) + { + if (!IsConnected) return Task.CompletedTask; + return WriteTransportAsync($"M140 S{targetCelsius}", cancellationToken); + } + + public async Task HomeAsync(bool x = true, bool y = true, bool z = true, + CancellationToken cancellationToken = default) + { + if (!IsConnected) return; + + var axes = string.Concat( + x ? "X" : "", + y ? "Y" : "", + z ? "Z" : ""); + + await WriteTransportAsync(axes.Length > 0 ? $"G28 {axes}" : "G28", cancellationToken); + } + + public async Task RelativeMoveAsync(int feedRate, + float x = 0f, float y = 0f, float z = 0f, float e = 0f, + CancellationToken cancellationToken = default) + { + if (!IsConnected) return; + + var sb = new StringBuilder("G1"); + if (x != 0f) sb.Append($" X{x:0.0}"); + if (y != 0f) sb.Append($" Y{y:0.0}"); + if (z != 0f) sb.Append($" Z{z:0.0}"); + if (e != 0f) sb.Append($" E{e:0.0}"); + sb.Append($" F{feedRate}"); + + await WriteTransportAsync("G91", cancellationToken); + await WriteTransportAsync(sb.ToString(), cancellationToken); + await WriteTransportAsync("G90", cancellationToken); + } + + public Task SetFanSpeedAsync(int speedPercent, CancellationToken cancellationToken = default) + { + if (!IsConnected) return Task.CompletedTask; + if (speedPercent <= 0) + return WriteTransportAsync("M107", cancellationToken); + + var value = (int)Math.Clamp(speedPercent * 2.55, 0, 255); + return WriteTransportAsync($"M106 S{value}", cancellationToken); + } + + public Task SetPrintSpeedAsync(int speedPercent, CancellationToken cancellationToken = default) + { + if (!IsConnected) return Task.CompletedTask; + return WriteTransportAsync($"M220 S{speedPercent}", cancellationToken); + } + + public Task SetPrintFlowAsync(int flowPercent, CancellationToken cancellationToken = default) + { + if (!IsConnected) return Task.CompletedTask; + return WriteTransportAsync($"M221 S{flowPercent}", cancellationToken); + } + + public Task StartPrintAsync(string fileName, CancellationToken cancellationToken = default) + { + if (!IsConnected) return Task.CompletedTask; + IsPrinting = true; + return WriteTransportAsync($"M23 {fileName}", cancellationToken); + } + + // ── Response parsing (called by platform subclasses) ───────────────────── + + /// + /// Appends to the receive buffer and processes any + /// complete lines. Platform subclasses call this from their read loops. + /// + protected void ProcessReceivedData(string data) + { + _receiveBuffer.Append(data); + + while (true) + { + var bufferStr = _receiveBuffer.ToString(); + var newlineIndex = bufferStr.IndexOf('\n'); + if (newlineIndex < 0) break; + + var line = bufferStr[..(newlineIndex + 1)].Trim('\r', '\n', ' '); + if (!string.IsNullOrEmpty(line)) + ParseLine(line); + + _receiveBuffer.Remove(0, newlineIndex + 1); + } + } + + private void ParseLine(string line) + { + try + { + // Temperature response: ok T:200.00 /200.00 B:60.00 /60.00 + if (line.StartsWith("ok T:", StringComparison.Ordinal) || + line.StartsWith("T:", StringComparison.Ordinal)) + { + var m = TempRegex.Match(line); + if (m.Success) + { + LastTelemetry.HotendTemp = double.Parse(m.Groups[1].Value, + System.Globalization.CultureInfo.InvariantCulture); + LastTelemetry.HotendTarget = double.Parse(m.Groups[2].Value, + System.Globalization.CultureInfo.InvariantCulture); + LastTelemetry.BedTemp = double.Parse(m.Groups[3].Value, + System.Globalization.CultureInfo.InvariantCulture); + LastTelemetry.BedTarget = double.Parse(m.Groups[4].Value, + System.Globalization.CultureInfo.InvariantCulture); + LastTelemetry.CapturedAt = DateTimeOffset.UtcNow; + LastTelemetry.Status = IsConnected ? PrinterStatus.Connected : PrinterStatus.Disconnected; + } + } + // SD progress: SD printing byte 12345/67890 + else if (line.Contains("SD printing byte", StringComparison.Ordinal)) + { + var m = PrintProgressRegex.Match(line); + if (m.Success) + { + var done = double.Parse(m.Groups[1].Value, + System.Globalization.CultureInfo.InvariantCulture); + var total = double.Parse(m.Groups[2].Value, + System.Globalization.CultureInfo.InvariantCulture); + if (total > 0) + { + LastTelemetry.PrintProgress = done / total * 100.0; + LastTelemetry.Status = PrinterStatus.Printing; + IsPrinting = true; + } + } + } + // Print complete + else if (line.Equals("Done printing file", StringComparison.OrdinalIgnoreCase)) + { + LastTelemetry.PrintProgress = 100; + LastTelemetry.Status = PrinterStatus.Connected; + IsPrinting = false; + } + + RaiseTelemetryUpdated(); + } + catch + { + // Swallow parse errors — never crash the receive loop. + } + } + + // ── Telemetry polling ──────────────────────────────────────────────────── + + private async Task PollTelemetryAsync() + { + if (!IsConnected) return; + try + { + await WriteTransportAsync("M105", CancellationToken.None); + await WriteTransportAsync("M27", CancellationToken.None); + } + catch + { + // Telemetry polling errors are swallowed silently — no log spam. + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + protected void RaiseConnectionChanged() => + ConnectionStateChanged?.Invoke(this, IsConnected); + + protected void RaiseTelemetryUpdated() => + TelemetryUpdated?.Invoke(this, LastTelemetry); + + // ── IAsyncDisposable ───────────────────────────────────────────────────── + + public async ValueTask DisposeAsync() + { + _telemetryTimer.Stop(); + _telemetryTimer.Dispose(); + + if (IsConnected) + { + await DisconnectAsync(); + } + + GC.SuppressFinalize(this); + } +} diff --git a/src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj b/src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj new file mode 100644 index 0000000..f55995b --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + true + 2 + + + + + + + + + + + + + + + diff --git a/src/MakerPrompt.UI.Blazor/Program.cs b/src/MakerPrompt.UI.Blazor/Program.cs new file mode 100644 index 0000000..ee322b0 --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/Program.cs @@ -0,0 +1,34 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Infrastructure.Analytics; +using MakerPrompt.Infrastructure.Farm; +using MakerPrompt.Infrastructure.Inventory; +using MakerPrompt.Infrastructure.Projects; +using MakerPrompt.Infrastructure.Telemetry; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +// ── Infrastructure stores (in-memory; swap for SQLite/cloud in production) ── +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// ── Application services ───────────────────────────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// ── Logging ────────────────────────────────────────────────────────────────── +builder.Logging.SetMinimumLevel(LogLevel.Warning); + +await builder.Build().RunAsync(); diff --git a/src/MakerPrompt.UI.Blazor/wwwroot/css/app.css b/src/MakerPrompt.UI.Blazor/wwwroot/css/app.css new file mode 100644 index 0000000..7be0b49 --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/wwwroot/css/app.css @@ -0,0 +1,9 @@ +/* MakerPrompt.UI.Blazor – minimal shell styles */ +html, body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; height: 100%; } +.page { display: flex; min-height: 100vh; } +.sidebar { width: 220px; background-color: #1a1a2e; color: #eee; padding-top: 1rem; flex-shrink: 0; } +.sidebar .nav-link { color: #ccc; font-size: .9rem; padding: .35rem 1rem; } +.sidebar .nav-link.active, .sidebar .nav-link:hover { color: #fff; background: rgba(255,255,255,.1); border-radius: .25rem; } +main { flex: 1; display: flex; flex-direction: column; overflow: auto; } +.top-row { padding: .5rem 1rem; background: #f8f9fa; border-bottom: 1px solid #dee2e6; text-align: right; } +.content { padding: 1.5rem; flex: 1; } diff --git a/src/MakerPrompt.UI.Blazor/wwwroot/index.html b/src/MakerPrompt.UI.Blazor/wwwroot/index.html new file mode 100644 index 0000000..1fc2c3a --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/wwwroot/index.html @@ -0,0 +1,31 @@ + + + + + + + MakerPrompt + + + + + + + +
+ + + + + +
+
+ Loading… +
+
+
+ + + + + diff --git a/src/MakerPrompt.UI.Components/App.razor b/src/MakerPrompt.UI.Components/App.razor new file mode 100644 index 0000000..71ea9e3 --- /dev/null +++ b/src/MakerPrompt.UI.Components/App.razor @@ -0,0 +1,13 @@ + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/src/MakerPrompt.UI.Components/Layout/MainLayout.razor b/src/MakerPrompt.UI.Components/Layout/MainLayout.razor new file mode 100644 index 0000000..7e2bd8c --- /dev/null +++ b/src/MakerPrompt.UI.Components/Layout/MainLayout.razor @@ -0,0 +1,17 @@ +@inherits LayoutComponentBase + +
+ + +
+
+ About +
+ +
+ @Body +
+
+
diff --git a/src/MakerPrompt.UI.Components/Layout/NavMenu.razor b/src/MakerPrompt.UI.Components/Layout/NavMenu.razor new file mode 100644 index 0000000..873c22a --- /dev/null +++ b/src/MakerPrompt.UI.Components/Layout/NavMenu.razor @@ -0,0 +1,32 @@ + diff --git a/src/MakerPrompt.UI.Components/MakerPrompt.UI.Components.csproj b/src/MakerPrompt.UI.Components/MakerPrompt.UI.Components.csproj new file mode 100644 index 0000000..d7b32b4 --- /dev/null +++ b/src/MakerPrompt.UI.Components/MakerPrompt.UI.Components.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor b/src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor new file mode 100644 index 0000000..3aa90dc --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor @@ -0,0 +1,153 @@ +@page "/analytics" +@inject AnalyticsService Analytics +@inject ILogger Logger +@implements IDisposable + +

Analytics

+ +@if (_records is null) +{ +

Loading…

+} +else +{ + +
+
+
+
+

Total Jobs

+

@_records.Count

+
+
+
+
+
+
+

Total Print Time

+

@FormatDuration(_totalPrintTime)

+
+
+
+
+
+
+

Total Filament (g)

+

@_totalFilament.ToString("F0")

+
+
+
+
+ +
+ +
+
+
Print Time by Printer
+
+ @if (!_byPrinter.Any()) + { +

No data recorded yet.

+ } + else + { + + + + + + @foreach (var (id, time, grams) in _byPrinter) + { + + + + + + } + +
Printer IDTimeFilament (g)
@id.ToString()[..8]…@FormatDuration(time)@grams.ToString("F1")
+ } +
+
+
+ + +
+
+
Recent Jobs
+
+ @if (!_records.Any()) + { +

No jobs recorded yet.

+ } + else + { + + + + + + @foreach (var r in _records.OrderByDescending(r => r.Timestamp).Take(20)) + { + + + + + + } + +
JobDurationDate
@r.JobName@FormatDuration(r.Duration)@r.Timestamp.ToString("MM/dd HH:mm")
+ } +
+
+
+
+} + +@code { + private IReadOnlyList? _records; + private TimeSpan _totalPrintTime; + private double _totalFilament; + private List<(Guid Id, TimeSpan Time, double Grams)> _byPrinter = []; + + protected override async Task OnInitializedAsync() + { + Analytics.AnalyticsUpdated += OnAnalyticsUpdated; + await LoadAsync(); + } + + private async Task LoadAsync() + { + try + { + _records = await Analytics.GetRecordsAsync(); + + _totalPrintTime = TimeSpan.FromTicks(_records.Sum(r => r.Duration.Ticks)); + _totalFilament = _records.Sum(r => r.EffectiveFilamentGrams); + + _byPrinter = _records + .GroupBy(r => r.PrinterId) + .Select(g => ( + Id: g.Key, + Time: TimeSpan.FromTicks(g.Sum(r => r.Duration.Ticks)), + Grams: g.Sum(r => r.EffectiveFilamentGrams))) + .OrderByDescending(x => x.Time) + .ToList(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load analytics"); + _records = []; + } + } + + private void OnAnalyticsUpdated(object? sender, EventArgs e) => + InvokeAsync(async () => { await LoadAsync(); StateHasChanged(); }); + + private static string FormatDuration(TimeSpan ts) + => ts.TotalHours >= 1 + ? $"{(int)ts.TotalHours}h {ts.Minutes:D2}m" + : $"{ts.Minutes}m {ts.Seconds:D2}s"; + + public void Dispose() => Analytics.AnalyticsUpdated -= OnAnalyticsUpdated; +} diff --git a/src/MakerPrompt.UI.Components/Pages/DashboardPage.razor b/src/MakerPrompt.UI.Components/Pages/DashboardPage.razor new file mode 100644 index 0000000..b51bd93 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/DashboardPage.razor @@ -0,0 +1,192 @@ +@page "/dashboard" +@inject PrinterFleetService FleetService +@inject ILogger Logger +@implements IDisposable + +

Dashboard

+ +@if (!_printerIds.Any()) +{ +
+ No printers connected. Visit the Fleet page to add one. +
+} +else +{ +
+ + +
+ + @if (_selected is not null) + { + var t = _selected.LastTelemetry; +
+ +
+
+
+
Status
+

@t.Status

+ @if (t.Status == PrinterStatus.Printing && !string.IsNullOrEmpty(t.PrintJobName)) + { +

@t.PrintJobName

+ } +
+
+
+ + +
+
+
+
Temperature
+ + + + + + + + + + + @if (t.ChamberTemp > 0) + { + + + + + } + +
Hotend@t.HotendTemp.ToString("F1") / @t.HotendTarget.ToString("F0") °C
Bed@t.BedTemp.ToString("F1") / @t.BedTarget.ToString("F0") °C
Chamber@t.ChamberTemp.ToString("F1") / @t.ChamberTarget.ToString("F0") °C
+
+
+
+ + + @if (t.Status == PrinterStatus.Printing) + { +
+
+
+
Print Progress
+
+
+
+
+

@t.PrintProgress.ToString("F1") %

+ + Duration: @FormatDuration(t.PrintDuration) +  |  Filament: @t.FilamentUsed.ToString("F1") mm + +
+
+
+ } + + +
+
+
+
Controls
+
+ + + +
+
+
+
+
+ } +} + +@code { + private IReadOnlyCollection _printerIds = []; + private string? _selectedId; + private IPrinterCommunicationService? _selected; + + protected override void OnInitialized() + { + FleetService.FleetChanged += OnFleetChanged; + Refresh(); + } + + private void Refresh() + { + _printerIds = FleetService.PrinterIds; + if (_selectedId is null || !_printerIds.Contains(_selectedId)) + _selectedId = _printerIds.FirstOrDefault(); + + _selected = _selectedId is not null + ? FleetService.GetConnection(_selectedId) : null; + } + + private void OnFleetChanged(object? sender, EventArgs e) => + InvokeAsync(() => { Refresh(); StateHasChanged(); }); + + private void OnPrinterSelected(ChangeEventArgs e) + { + _selectedId = e.Value?.ToString(); + _selected = _selectedId is not null + ? FleetService.GetConnection(_selectedId) : null; + } + + private async Task HomeAllAxes() + { + if (_selected is null) return; + try { await _selected.HomeAsync(); } + catch (Exception ex) { Logger.LogError(ex, "Home failed"); } + } + + private async Task TurnOffHeaters() + { + if (_selected is null) return; + try + { + await _selected.SetHotendTempAsync(0); + await _selected.SetBedTempAsync(0); + } + catch (Exception ex) { Logger.LogError(ex, "Heaters off failed"); } + } + + private async Task FanOff() + { + if (_selected is null) return; + try { await _selected.SetFanSpeedAsync(0); } + catch (Exception ex) { Logger.LogError(ex, "Fan off failed"); } + } + + private static string FormatDuration(TimeSpan ts) + => ts.TotalHours >= 1 + ? $"{(int)ts.TotalHours}h {ts.Minutes:D2}m" + : $"{ts.Minutes}m {ts.Seconds:D2}s"; + + public void Dispose() => FleetService.FleetChanged -= OnFleetChanged; +} diff --git a/src/MakerPrompt.UI.Components/Pages/FilamentInventoryPage.razor b/src/MakerPrompt.UI.Components/Pages/FilamentInventoryPage.razor new file mode 100644 index 0000000..3bffb56 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/FilamentInventoryPage.razor @@ -0,0 +1,209 @@ +@page "/filament" +@inject FilamentInventoryService InventoryService +@inject ILogger Logger +@implements IDisposable + +

Filament Inventory

+ +@if (_spools is null) +{ +

Loading…

+} +else +{ +
+ +
+ + @if (_showForm) + { +
+
@(_editingSpool is null ? "Add Spool" : "Edit Spool")
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ @if (!string.IsNullOrEmpty(_formError)) + { +
@_formError
+ } +
+
+ } + + @if (!_spools.Any()) + { +
No spools tracked yet. Add one above.
+ } + else + { + + + + + + + + + + + + + @foreach (var spool in _spools) + { + var pct = spool.TotalWeightGrams > 0 + ? (int)(spool.RemainingWeightGrams / spool.TotalWeightGrams * 100) + : 0; + + + + + + + + + } + +
MaterialBrandColorRemainingTotal
@spool.Material@spool.Brand@spool.Color +
+
+
+
+
+ @spool.RemainingWeightGrams.ToString("F0") g +
+
@spool.TotalWeightGrams.ToString("F0") g + + +
+ } +} + +@code { + private IReadOnlyList? _spools; + private bool _showForm; + private FilamentSpool? _editingSpool; + private string _formMaterial = ""; + private string _formBrand = ""; + private string _formColor = ""; + private double _formTotalWeight = 1000; + private double _formRemainingWeight = 1000; + private string _formError = ""; + + protected override async Task OnInitializedAsync() + { + InventoryService.InventoryChanged += OnInventoryChanged; + await LoadAsync(); + } + + private async Task LoadAsync() + { + try { _spools = await InventoryService.GetSpoolsAsync(); } + catch (Exception ex) { Logger.LogError(ex, "Failed to load spools"); } + } + + private void OnInventoryChanged(object? sender, EventArgs e) => + InvokeAsync(async () => { await LoadAsync(); StateHasChanged(); }); + + private void ShowAddForm() + { + _editingSpool = null; + _formMaterial = ""; + _formBrand = ""; + _formColor = ""; + _formTotalWeight = 1000; + _formRemainingWeight = 1000; + _formError = ""; + _showForm = true; + } + + private void EditSpool(FilamentSpool spool) + { + _editingSpool = spool; + _formMaterial = spool.Material; + _formBrand = spool.Brand; + _formColor = spool.Color; + _formTotalWeight = spool.TotalWeightGrams; + _formRemainingWeight = spool.RemainingWeightGrams; + _formError = ""; + _showForm = true; + } + + private void CancelForm() => _showForm = false; + + private async Task SaveSpoolAsync() + { + _formError = ""; + if (string.IsNullOrWhiteSpace(_formMaterial)) + { + _formError = "Material is required."; + return; + } + + try + { + var spool = _editingSpool ?? new FilamentSpool { Id = Guid.NewGuid() }; + spool.Material = _formMaterial; + spool.Brand = _formBrand; + spool.Color = _formColor; + spool.TotalWeightGrams = _formTotalWeight; + spool.RemainingWeightGrams = _formRemainingWeight; + + if (_editingSpool is null) + await InventoryService.AddSpoolAsync(spool); + else + await InventoryService.UpdateSpoolAsync(spool); + + _showForm = false; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to save spool"); + _formError = "Save failed. Please try again."; + } + } + + private async Task DeleteSpoolAsync(Guid id) + { + try { await InventoryService.DeleteSpoolAsync(id); } + catch (Exception ex) { Logger.LogError(ex, "Failed to delete spool"); } + } + + public void Dispose() => InventoryService.InventoryChanged -= OnInventoryChanged; +} diff --git a/src/MakerPrompt.UI.Components/Pages/FleetPage.razor b/src/MakerPrompt.UI.Components/Pages/FleetPage.razor new file mode 100644 index 0000000..c4ff866 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/FleetPage.razor @@ -0,0 +1,125 @@ +@page "/fleet" +@inject PrinterFleetService FleetService +@inject ILogger Logger +@implements IDisposable + +

Printer Fleet

+ +@if (!_printerIds.Any()) +{ +
+ No printers connected. Add a printer to get started. +
+} +else +{ +
+ @foreach (var id in _printerIds) + { + var conn = FleetService.GetConnection(id); + if (conn is null) continue; +
+
+
+ + + @(string.IsNullOrEmpty(conn.ConnectionName) ? id : conn.ConnectionName) + + @conn.ConnectionType +
+
+ @if (conn.IsConnected) + { + var t = conn.LastTelemetry; +
+
Status
+
@t.Status
+
Hotend
+
@t.HotendTemp.ToString("F1") °C / @t.HotendTarget.ToString("F0") °C
+
Bed
+
@t.BedTemp.ToString("F1") °C / @t.BedTarget.ToString("F0") °C
+ @if (t.Status == PrinterStatus.Printing) + { +
Progress
+
+
+
+
+
+ @t.PrintProgress.ToString("F1") % +
+ } +
+ } + else + { +

Not connected

+ } +
+ +
+
+ } +
+} + +@code { + private IReadOnlyCollection _printerIds = []; + + protected override void OnInitialized() + { + FleetService.FleetChanged += OnFleetChanged; + Refresh(); + } + + private void Refresh() => + _printerIds = FleetService.PrinterIds; + + private void OnFleetChanged(object? sender, EventArgs e) => + InvokeAsync(() => { Refresh(); StateHasChanged(); }); + + private async Task DisconnectAsync(string printerId) + { + var conn = FleetService.GetConnection(printerId); + if (conn is null) return; + try + { + await conn.DisconnectAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error disconnecting printer {PrinterId}", printerId); + } + } + + private async Task RemoveAsync(string printerId) + { + try + { + await FleetService.RemoveAsync(printerId); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error removing printer {PrinterId}", printerId); + } + } + + public void Dispose() => FleetService.FleetChanged -= OnFleetChanged; +} diff --git a/src/MakerPrompt.UI.Components/Pages/SettingsPage.razor b/src/MakerPrompt.UI.Components/Pages/SettingsPage.razor new file mode 100644 index 0000000..78390b1 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/SettingsPage.razor @@ -0,0 +1,65 @@ +@page "/settings" + +

Settings

+ +
+
+
+
About MakerPrompt
+
+
+
Version
+
0.5.0
+
Architecture
+
Core / Application / Infrastructure
+
Source
+
+ + GitHub + +
+
+
+
+
+ +
+
+
Cloud Connection
+
+
+ + +
+
+ + +
+ + @if (_saved) + { + Saved! + } +
+
+
+
+ +@code { + private string _cloudUrl = ""; + private string _apiKey = ""; + private bool _saved; + + private void SaveSettings() + { + // Persist via IAppLocalStorageProvider in future phases. + _saved = true; + _ = Task.Delay(2000).ContinueWith(_ => InvokeAsync(() => { _saved = false; StateHasChanged(); })); + } +} diff --git a/src/MakerPrompt.UI.Components/_Imports.razor b/src/MakerPrompt.UI.Components/_Imports.razor new file mode 100644 index 0000000..643a970 --- /dev/null +++ b/src/MakerPrompt.UI.Components/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.Extensions.Logging +@using MakerPrompt.Core.Abstractions +@using MakerPrompt.Core.Models +@using MakerPrompt.Application.Services +@using MakerPrompt.UI.Components.Layout +@using MakerPrompt.UI.Components.Pages diff --git a/src/MakerPrompt.UI.MAUI/App.cs b/src/MakerPrompt.UI.MAUI/App.cs new file mode 100644 index 0000000..c832cb0 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/App.cs @@ -0,0 +1,9 @@ +namespace MakerPrompt.UI.MAUI; + +public class App : Application +{ + public App() + { + MainPage = new MainPage(); + } +} diff --git a/src/MakerPrompt.UI.MAUI/Components/Routes.razor b/src/MakerPrompt.UI.MAUI/Components/Routes.razor new file mode 100644 index 0000000..267f9a1 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/Components/Routes.razor @@ -0,0 +1,12 @@ + + + + + + + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/src/MakerPrompt.UI.MAUI/MainPage.cs b/src/MakerPrompt.UI.MAUI/MainPage.cs new file mode 100644 index 0000000..cd1c57f --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/MainPage.cs @@ -0,0 +1,20 @@ +namespace MakerPrompt.UI.MAUI; + +public partial class MainPage : ContentPage +{ + public MainPage() + { + Content = new BlazorWebView + { + HostPage = "wwwroot/index.html", + RootComponents = + { + new RootComponent + { + Selector = "#app", + ComponentType = typeof(MakerPrompt.UI.Components.App) + } + } + }; + } +} diff --git a/src/MakerPrompt.UI.MAUI/MakerPrompt.UI.MAUI.csproj b/src/MakerPrompt.UI.MAUI/MakerPrompt.UI.MAUI.csproj new file mode 100644 index 0000000..1052411 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/MakerPrompt.UI.MAUI.csproj @@ -0,0 +1,77 @@ + + + + net10.0-android;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 + + android-arm64;android-x64 + maccatalyst-x64;maccatalyst-arm64 + + Exe + MakerPrompt.UI.MAUI + true + true + enable + false + enable + + MakerPrompt + com.makerprompt.maui.v2 + 0.5.0 + 0.5.0 + + None + + 15.0 + 24.0 + 10.0.17763.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MakerPrompt.UI.MAUI/MauiProgram.cs b/src/MakerPrompt.UI.MAUI/MauiProgram.cs new file mode 100644 index 0000000..eb2335d --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/MauiProgram.cs @@ -0,0 +1,57 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Infrastructure.Analytics; +using MakerPrompt.Infrastructure.Farm; +using MakerPrompt.Infrastructure.Inventory; +using MakerPrompt.Infrastructure.Projects; +using MakerPrompt.Infrastructure.Telemetry; + +namespace MakerPrompt.UI.MAUI; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + }); + + builder.Services.AddMauiBlazorWebView(); + + // Enable GPU rasterisation in the embedded WebView2 on Windows. + var webViewArgs = "--ignore-gpu-blocklist --enable-gpu-rasterization"; +#if DEBUG + webViewArgs += " --remote-debugging-port=9223"; + builder.Services.AddBlazorWebViewDeveloperTools(); + builder.Logging.AddDebug(); +#endif + Environment.SetEnvironmentVariable("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", webViewArgs); + + // ── Infrastructure stores (in-memory) ──────────────────────────────── + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // ── Application services ───────────────────────────────────────────── + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // ── Platform-specific serial service ───────────────────────────────── + // SerialCommunicationService is a partial class with platform-specific + // transport implementations compiled conditionally per-platform. + // It implements IPrinterCommunicationService via SerialCommunicationServiceBase. + builder.Services.AddTransient(); + + return builder.Build(); + } +} diff --git a/src/MakerPrompt.UI.MAUI/Resources/AppIcon/appicon.png b/src/MakerPrompt.UI.MAUI/Resources/AppIcon/appicon.png new file mode 100644 index 0000000000000000000000000000000000000000..94381b429d7f7fe87e1bade52d893ab348ae29cc GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yfBgS%&%pYR=^yWV Rw;e!n22WQ%mvv4FO#tpo5$gZ| literal 0 HcmV?d00001 diff --git a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Android.cs b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Android.cs new file mode 100644 index 0000000..23867a4 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Android.cs @@ -0,0 +1,111 @@ +using System.Text; +using MakerPrompt.Core.Models; +using UsbSerialForAndroid.Net; +using UsbSerialForAndroid.Net.Drivers; +using UsbSerialForAndroid.Net.Helper; + +namespace MakerPrompt.UI.MAUI.Services; + +public partial class SerialCommunicationService +{ + // ── Android state ──────────────────────────────────────────────────────── + private UsbDriverBase? _usbDriver; + private CancellationTokenSource? _androidCts; + private Task? _androidReceiveTask; + + // ── Transport hooks ────────────────────────────────────────────────────── + + protected override Task OpenTransportAsync(PrinterConnectionSettings settings, + CancellationToken cancellationToken) + { + var deviceName = settings.PortName + ?? throw new ArgumentException("PortName (device name) is required for Android serial connections"); + var baudRate = settings.BaudRate == 0 ? 250_000 : settings.BaudRate; + + // Locate the USB device by name. + var usbDevice = UsbManagerHelper.GetAllUsbDevices() + .FirstOrDefault(d => d.DeviceName == deviceName) + ?? throw new InvalidOperationException($"USB device '{deviceName}' not found."); + + // Request permission if not yet granted. + if (!UsbManagerHelper.HasPermission(usbDevice)) + UsbManagerHelper.RequestPermission(usbDevice); + + _usbDriver = UsbDriverFactory.CreateUsbDriver(usbDevice.DeviceId); + _usbDriver.Open(baudRate, + dataBits: 8, + stopBits: UsbSerialForAndroid.Net.Enums.StopBits.One, + parity: UsbSerialForAndroid.Net.Enums.Parity.None); + + _androidCts?.Dispose(); + _androidCts = new CancellationTokenSource(); + _androidReceiveTask = Task.Run(() => ReceiveLoopAsync(_androidCts.Token)); + + return Task.CompletedTask; + } + + protected override async Task CloseTransportAsync(CancellationToken cancellationToken) + { + _androidCts?.Cancel(); + + if (_androidReceiveTask is not null) + await _androidReceiveTask.ContinueWith(_ => { }); + + try { _usbDriver?.Close(); } + catch { /* Ignore close errors */ } + + _usbDriver = null; + _androidCts?.Dispose(); + _androidCts = null; + } + + protected override Task WriteTransportAsync(string data, CancellationToken cancellationToken) + { + if (_usbDriver is null) return Task.CompletedTask; + + // Android USB driver uses synchronous write — run on thread pool. + return Task.Run(() => + { + var bytes = Encoding.ASCII.GetBytes(data + "\n"); + _usbDriver.Write(bytes); + }, cancellationToken); + } + + // ── Receive loop (Android) ─────────────────────────────────────────────── + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested && IsConnected) + { + try + { + if (_usbDriver is null) break; + + // Android driver read is synchronous; offload to thread pool. + var bytes = await Task.Run(() => _usbDriver.Read(4096), ct); + if (bytes.Length > 0) + ProcessReceivedData(Encoding.ASCII.GetString(bytes)); + + await Task.Delay(10, ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Console.WriteLine($"[SerialService.Android] Receive error: {ex.Message}"); + await DisconnectAsync(); + break; + } + } + } + + // ── Available ports (Android — USB device names) ───────────────────────── + + public static partial Task> GetAvailablePortsAsync() + => Task.FromResult>( + UsbManagerHelper.GetAllUsbDevices() + .Select(d => d.DeviceName) + .ToArray()); +} diff --git a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.MacOS.cs b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.MacOS.cs new file mode 100644 index 0000000..c52e46d --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.MacOS.cs @@ -0,0 +1,126 @@ +using System.Text; +using System.Threading.Tasks.Dataflow; +using MakerPrompt.Core.Models; +using UsbSerialForMacOS; + +namespace MakerPrompt.UI.MAUI.Services; + +public partial class SerialCommunicationService +{ + // ── macOS state ────────────────────────────────────────────────────────── + private UsbSerialManager? _manager; + private readonly BufferBlock _macCommandQueue = new(); + private CancellationTokenSource? _macCts; + private Task? _macSendTask; + private Task? _macReceiveTask; + + // ── Transport hooks ────────────────────────────────────────────────────── + + protected override Task OpenTransportAsync(PrinterConnectionSettings settings, + CancellationToken cancellationToken) + { + var portName = settings.PortName + ?? throw new ArgumentException("PortName is required for macOS serial connections"); + var baudRate = settings.BaudRate == 0 ? 250_000 : settings.BaudRate; + + _manager?.Close(); + _manager = new UsbSerialManager(); + + var opened = _manager.Open(portName, baudRate); + if (!opened) + throw new InvalidOperationException($"Failed to open serial port '{portName}'"); + + _macCts?.Dispose(); + _macCts = new CancellationTokenSource(); + + _macSendTask = Task.Run(() => SendLoopAsync(_macCts.Token)); + _macReceiveTask = Task.Run(() => ReceiveLoopAsync(_macCts.Token)); + + return Task.CompletedTask; + } + + protected override async Task CloseTransportAsync(CancellationToken cancellationToken) + { + _macCts?.Cancel(); + + if (_macSendTask is not null) + await _macSendTask.ContinueWith(_ => { }); + if (_macReceiveTask is not null) + await _macReceiveTask.ContinueWith(_ => { }); + + try { _manager?.Close(); } + catch { /* Swallow close errors */ } + + _manager = null; + _macCts?.Dispose(); + _macCts = null; + } + + protected override Task WriteTransportAsync(string data, CancellationToken cancellationToken) + { + if (_manager is null) return Task.CompletedTask; + return _macCommandQueue.SendAsync(data, cancellationToken).AsTask(); + } + + // ── Send loop (macOS) ──────────────────────────────────────────────────── + + private async Task SendLoopAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested && IsConnected) + { + var command = await _macCommandQueue.ReceiveAsync(ct); + var mgr = _manager; + if (mgr is null || ct.IsCancellationRequested) break; + + mgr.Write(command + "\n"); + await Task.Delay(10, ct); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Console.WriteLine($"[SerialService.MacOS] Send loop error: {ex.Message}"); + } + } + + // ── Receive loop (macOS) ───────────────────────────────────────────────── + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested && IsConnected) + { + try + { + var mgr = _manager; + if (mgr is null) break; + + var bytes = mgr.Read(4096); + if (bytes.Length > 0) + ProcessReceivedData(Encoding.UTF8.GetString(bytes.ToArray())); + + await Task.Delay(10, ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Console.WriteLine($"[SerialService.MacOS] Receive error: {ex.Message}"); + await DisconnectAsync(); + break; + } + } + } + + // ── Available ports (macOS) ────────────────────────────────────────────── + + public static partial Task> GetAvailablePortsAsync() + { + var mgr = new UsbSerialManager(); + return Task.FromResult>( + mgr.AvailablePorts().OrderBy(p => p).ToArray()); + } +} diff --git a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Windows.cs b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Windows.cs new file mode 100644 index 0000000..3bb4a26 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Windows.cs @@ -0,0 +1,149 @@ +using System.IO.Ports; +using System.Text; +using System.Threading.Tasks.Dataflow; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.UI.MAUI.Services; + +public partial class SerialCommunicationService +{ + // ── Windows state ──────────────────────────────────────────────────────── + private SerialPort? _serialPort; + private readonly BufferBlock _commandQueue = new(); + private CancellationTokenSource? _cts; + private Task? _sendTask; + private Task? _receiveTask; + + // ── Transport hooks ────────────────────────────────────────────────────── + + protected override Task OpenTransportAsync(PrinterConnectionSettings settings, + CancellationToken cancellationToken) + { + _serialPort = new SerialPort + { + PortName = settings.PortName + ?? throw new ArgumentException("PortName is required for Serial connections"), + BaudRate = settings.BaudRate == 0 ? 250_000 : settings.BaudRate, + DataBits = 8, + Parity = Parity.None, + StopBits = StopBits.One, + Handshake = Handshake.None, + DtrEnable = true, + RtsEnable = true, + ReadTimeout = 2000, + WriteTimeout = 5000, + NewLine = "\n", + Encoding = Encoding.ASCII + }; + + _serialPort.Open(); + + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + + _sendTask = Task.Run(() => SendLoopAsync(_cts.Token)); + _receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token)); + + return Task.CompletedTask; + } + + protected override async Task CloseTransportAsync(CancellationToken cancellationToken) + { + if (_cts is { } cts) + { + cts.Cancel(); + + if (_sendTask is not null) + await _sendTask.ContinueWith(_ => { }); // suppress exceptions + if (_receiveTask is not null) + await _receiveTask.ContinueWith(_ => { }); + } + + if (_serialPort is { IsOpen: true }) + { + await Task.Run(() => + { + try + { + _serialPort.DiscardInBuffer(); + _serialPort.DiscardOutBuffer(); + _serialPort.Close(); + } + catch + { + // Ignore close errors during shutdown. + } + }); + } + + _serialPort?.Dispose(); + _serialPort = null; + _cts?.Dispose(); + _cts = null; + } + + protected override async Task WriteTransportAsync(string data, CancellationToken cancellationToken) + { + if (_serialPort is not { IsOpen: true }) return; + await _commandQueue.SendAsync(data, cancellationToken); + } + + // ── Send loop (Windows) ────────────────────────────────────────────────── + + private async Task SendLoopAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested && IsConnected) + { + var command = await _commandQueue.ReceiveAsync(ct); + if (_serialPort is not { IsOpen: true }) break; + + var payload = Encoding.ASCII.GetBytes(command + "\n"); + await _serialPort.BaseStream.WriteAsync(payload, ct); + await _serialPort.BaseStream.FlushAsync(ct); + await Task.Delay(10, ct); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Console.WriteLine($"[SerialService.Windows] Send loop error: {ex.Message}"); + } + } + + // ── Receive loop (Windows) ─────────────────────────────────────────────── + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + var buffer = new byte[4096]; + while (!ct.IsCancellationRequested && IsConnected) + { + try + { + if (_serialPort is not { IsOpen: true }) break; + + var bytesRead = await _serialPort.BaseStream.ReadAsync(buffer, ct); + if (bytesRead > 0) + ProcessReceivedData(Encoding.ASCII.GetString(buffer, 0, bytesRead)); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + // Swallow receive errors — surface only as disconnection. + Console.WriteLine($"[SerialService.Windows] Receive loop error: {ex.Message}"); + await DisconnectAsync(); + break; + } + } + } + + // ── Available ports (Windows) ──────────────────────────────────────────── + + public static partial Task> GetAvailablePortsAsync() + => Task.FromResult>( + SerialPort.GetPortNames().OrderBy(p => p).ToArray()); +} diff --git a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.cs b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.cs new file mode 100644 index 0000000..15e0d22 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.cs @@ -0,0 +1,28 @@ +using MakerPrompt.Infrastructure.Serial; + +namespace MakerPrompt.UI.MAUI.Services; + +/// +/// Platform-specific serial communication service for the new /src architecture. +/// +/// This is a partial class split across per-platform files: +/// SerialCommunicationService.Windows.cs – System.IO.Ports.SerialPort +/// SerialCommunicationService.Android.cs – UsbSerialForAndroid.Net +/// SerialCommunicationService.MacOS.cs – UsbSerialForMacOS +/// SerialCommunicationService.iOS.cs – stub (not supported) +/// +/// The base class () handles all G-code +/// command building and Marlin response parsing. Each platform implementation +/// overrides only the three transport hooks: +/// • OpenTransportAsync – open the hardware port +/// • CloseTransportAsync – close and dispose hardware resources +/// • WriteTransportAsync – write a single G-code line to the port +/// +/// The receive loop is also started per-platform in +/// and cancelled in . +/// +public partial class SerialCommunicationService : SerialCommunicationServiceBase +{ + // Enumerates available ports / device identifiers on the current platform. + public static partial Task> GetAvailablePortsAsync(); +} diff --git a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.iOS.cs b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.iOS.cs new file mode 100644 index 0000000..e89d930 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.iOS.cs @@ -0,0 +1,27 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.UI.MAUI.Services; + +/// +/// iOS serial service stub. +/// Direct USB/serial connections are not supported on iOS (sandboxing restrictions). +/// The class satisfies the partial class requirement but throws on any attempt to connect. +/// +public partial class SerialCommunicationService +{ + protected override Task OpenTransportAsync(PrinterConnectionSettings settings, + CancellationToken cancellationToken) + => throw new PlatformNotSupportedException( + "Direct USB/serial connections are not supported on iOS. " + + "Use a network-based backend (Moonraker, PrusaLink) instead."); + + protected override Task CloseTransportAsync(CancellationToken cancellationToken) + => Task.CompletedTask; + + protected override Task WriteTransportAsync(string data, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException( + "Direct USB/serial connections are not supported on iOS."); + + public static partial Task> GetAvailablePortsAsync() + => Task.FromResult>([]); +} diff --git a/src/MakerPrompt.UI.MAUI/wwwroot/css/app.css b/src/MakerPrompt.UI.MAUI/wwwroot/css/app.css new file mode 100644 index 0000000..7be0b49 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/wwwroot/css/app.css @@ -0,0 +1,9 @@ +/* MakerPrompt.UI.Blazor – minimal shell styles */ +html, body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; height: 100%; } +.page { display: flex; min-height: 100vh; } +.sidebar { width: 220px; background-color: #1a1a2e; color: #eee; padding-top: 1rem; flex-shrink: 0; } +.sidebar .nav-link { color: #ccc; font-size: .9rem; padding: .35rem 1rem; } +.sidebar .nav-link.active, .sidebar .nav-link:hover { color: #fff; background: rgba(255,255,255,.1); border-radius: .25rem; } +main { flex: 1; display: flex; flex-direction: column; overflow: auto; } +.top-row { padding: .5rem 1rem; background: #f8f9fa; border-bottom: 1px solid #dee2e6; text-align: right; } +.content { padding: 1.5rem; flex: 1; } diff --git a/src/MakerPrompt.UI.MAUI/wwwroot/index.html b/src/MakerPrompt.UI.MAUI/wwwroot/index.html new file mode 100644 index 0000000..04d90cb --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/wwwroot/index.html @@ -0,0 +1,16 @@ + + + + + + MakerPrompt + + + + + + +
Loading…
+ + + From c22ff13839fee23ad46aa204cc43c0ab94f7e1c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 20:06:38 +0000 Subject: [PATCH 06/19] =?UTF-8?q?fix:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20constants,=20safe=20continuations,=20defensive=20ID?= =?UTF-8?q?=20truncation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/akinbender/MakerPrompt/sessions/905d1ff3-25bd-41d3-91f1-5bc466640ec6 Co-authored-by: akinbender <40242943+akinbender@users.noreply.github.com> --- src/MakerPrompt.Cloud/Program.cs | 6 +++++- .../Serial/SerialCommunicationServiceBase.cs | 16 ++++++++++++++-- .../Pages/AnalyticsPage.razor | 8 +++++++- .../Pages/SettingsPage.razor | 9 ++++++++- .../SerialCommunicationService.Android.cs | 4 ++-- .../Services/SerialCommunicationService.MacOS.cs | 6 +++--- .../SerialCommunicationService.Windows.cs | 7 ++++--- src/MakerPrompt.UI.MAUI/wwwroot/css/app.css | 2 +- 8 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/MakerPrompt.Cloud/Program.cs b/src/MakerPrompt.Cloud/Program.cs index c851cb2..22e30c2 100644 --- a/src/MakerPrompt.Cloud/Program.cs +++ b/src/MakerPrompt.Cloud/Program.cs @@ -8,6 +8,10 @@ var builder = WebApplication.CreateBuilder(args); +// ── Constants ───────────────────────────────────────────────────────────────── + +const int JwtClockSkewMinutes = 5; + // ── Authentication — JWT Bearer / OIDC ─────────────────────────────────────── // // The Cloud API validates JWTs issued by any OIDC-compliant provider @@ -41,7 +45,7 @@ ValidateIssuer = true, ValidateAudience = !string.IsNullOrWhiteSpace(audience), ValidateLifetime = true, - ClockSkew = TimeSpan.FromMinutes(5), + ClockSkew = TimeSpan.FromMinutes(JwtClockSkewMinutes), }; } else diff --git a/src/MakerPrompt.Infrastructure/Serial/SerialCommunicationServiceBase.cs b/src/MakerPrompt.Infrastructure/Serial/SerialCommunicationServiceBase.cs index 6c8f77f..39d49d5 100644 --- a/src/MakerPrompt.Infrastructure/Serial/SerialCommunicationServiceBase.cs +++ b/src/MakerPrompt.Infrastructure/Serial/SerialCommunicationServiceBase.cs @@ -45,15 +45,27 @@ public abstract class SerialCommunicationServiceBase : IPrinterCommunicationServ public bool IsPrinting { get; protected set; } // ── Private fields ─────────────────────────────────────────────────────── - private readonly System.Timers.Timer _telemetryTimer = new(TimeSpan.FromSeconds(3)); + /// Default baud rate for Marlin/RepRap firmware. 250 000 bps is standard. + protected const int DefaultBaudRate = 250_000; + private static readonly TimeSpan TelemetryPollInterval = TimeSpan.FromSeconds(3); + + private readonly System.Timers.Timer _telemetryTimer = new(TelemetryPollInterval); private readonly StringBuilder _receiveBuffer = new(); protected SerialCommunicationServiceBase() { - _telemetryTimer.Elapsed += async (_, _) => await PollTelemetryAsync(); + _telemetryTimer.Elapsed += (_, _) => SafePollTelemetry(); _telemetryTimer.AutoReset = true; } + // Non-async timer callback that fires-and-forgets with exception guarding. + private void SafePollTelemetry() + { + _ = PollTelemetryAsync().ContinueWith( + t => Console.WriteLine($"[SerialCommunicationServiceBase] Telemetry poll error: {t.Exception?.GetBaseException().Message}"), + System.Threading.Tasks.TaskContinuationOptions.OnlyOnFaulted); + } + // ── Abstract transport hooks ───────────────────────────────────────────── /// diff --git a/src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor b/src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor index 3aa90dc..a682580 100644 --- a/src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor +++ b/src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor @@ -59,7 +59,7 @@ else @foreach (var (id, time, grams) in _byPrinter) { - @id.ToString()[..8]… + @TruncateId(id) @FormatDuration(time) @grams.ToString("F1") @@ -149,5 +149,11 @@ else ? $"{(int)ts.TotalHours}h {ts.Minutes:D2}m" : $"{ts.Minutes}m {ts.Seconds:D2}s"; + private static string TruncateId(Guid id) + { + var s = id.ToString(); + return s.Length > 8 ? s[..8] + "…" : s; + } + public void Dispose() => Analytics.AnalyticsUpdated -= OnAnalyticsUpdated; } diff --git a/src/MakerPrompt.UI.Components/Pages/SettingsPage.razor b/src/MakerPrompt.UI.Components/Pages/SettingsPage.razor index 78390b1..45d2df4 100644 --- a/src/MakerPrompt.UI.Components/Pages/SettingsPage.razor +++ b/src/MakerPrompt.UI.Components/Pages/SettingsPage.razor @@ -60,6 +60,13 @@ { // Persist via IAppLocalStorageProvider in future phases. _saved = true; - _ = Task.Delay(2000).ContinueWith(_ => InvokeAsync(() => { _saved = false; StateHasChanged(); })); + // Fire-and-forget to clear the saved flag after 2 s; exceptions are logged to console. + Task.Delay(2000) + .ContinueWith( + _ => InvokeAsync(() => { _saved = false; StateHasChanged(); }), + TaskContinuationOptions.OnlyOnRanToCompletion) + .ContinueWith( + t => Console.WriteLine($"[SettingsPage] Error clearing saved flag: {t.Exception?.GetBaseException().Message}"), + TaskContinuationOptions.OnlyOnFaulted); } } diff --git a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Android.cs b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Android.cs index 23867a4..8085e0a 100644 --- a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Android.cs +++ b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Android.cs @@ -20,7 +20,7 @@ protected override Task OpenTransportAsync(PrinterConnectionSettings settings, { var deviceName = settings.PortName ?? throw new ArgumentException("PortName (device name) is required for Android serial connections"); - var baudRate = settings.BaudRate == 0 ? 250_000 : settings.BaudRate; + var baudRate = settings.BaudRate == 0 ? DefaultBaudRate : settings.BaudRate; // Locate the USB device by name. var usbDevice = UsbManagerHelper.GetAllUsbDevices() @@ -49,7 +49,7 @@ protected override async Task CloseTransportAsync(CancellationToken cancellation _androidCts?.Cancel(); if (_androidReceiveTask is not null) - await _androidReceiveTask.ContinueWith(_ => { }); + await _androidReceiveTask.ContinueWith(_ => { }, TaskContinuationOptions.None); try { _usbDriver?.Close(); } catch { /* Ignore close errors */ } diff --git a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.MacOS.cs b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.MacOS.cs index c52e46d..e1baa23 100644 --- a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.MacOS.cs +++ b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.MacOS.cs @@ -21,7 +21,7 @@ protected override Task OpenTransportAsync(PrinterConnectionSettings settings, { var portName = settings.PortName ?? throw new ArgumentException("PortName is required for macOS serial connections"); - var baudRate = settings.BaudRate == 0 ? 250_000 : settings.BaudRate; + var baudRate = settings.BaudRate == 0 ? DefaultBaudRate : settings.BaudRate; _manager?.Close(); _manager = new UsbSerialManager(); @@ -44,9 +44,9 @@ protected override async Task CloseTransportAsync(CancellationToken cancellation _macCts?.Cancel(); if (_macSendTask is not null) - await _macSendTask.ContinueWith(_ => { }); + await _macSendTask.ContinueWith(_ => { }, TaskContinuationOptions.None); if (_macReceiveTask is not null) - await _macReceiveTask.ContinueWith(_ => { }); + await _macReceiveTask.ContinueWith(_ => { }, TaskContinuationOptions.None); try { _manager?.Close(); } catch { /* Swallow close errors */ } diff --git a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Windows.cs b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Windows.cs index 3bb4a26..e989e25 100644 --- a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Windows.cs +++ b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Windows.cs @@ -23,7 +23,7 @@ protected override Task OpenTransportAsync(PrinterConnectionSettings settings, { PortName = settings.PortName ?? throw new ArgumentException("PortName is required for Serial connections"), - BaudRate = settings.BaudRate == 0 ? 250_000 : settings.BaudRate, + BaudRate = settings.BaudRate == 0 ? DefaultBaudRate : settings.BaudRate, DataBits = 8, Parity = Parity.None, StopBits = StopBits.One, @@ -54,9 +54,10 @@ protected override async Task CloseTransportAsync(CancellationToken cancellation cts.Cancel(); if (_sendTask is not null) - await _sendTask.ContinueWith(_ => { }); // suppress exceptions + // Suppress faults from the shutting-down send loop — intentional. + await _sendTask.ContinueWith(_ => { }, TaskContinuationOptions.None); if (_receiveTask is not null) - await _receiveTask.ContinueWith(_ => { }); + await _receiveTask.ContinueWith(_ => { }, TaskContinuationOptions.None); } if (_serialPort is { IsOpen: true }) diff --git a/src/MakerPrompt.UI.MAUI/wwwroot/css/app.css b/src/MakerPrompt.UI.MAUI/wwwroot/css/app.css index 7be0b49..416f91d 100644 --- a/src/MakerPrompt.UI.MAUI/wwwroot/css/app.css +++ b/src/MakerPrompt.UI.MAUI/wwwroot/css/app.css @@ -1,4 +1,4 @@ -/* MakerPrompt.UI.Blazor – minimal shell styles */ +/* MakerPrompt.UI.MAUI – minimal shell styles */ html, body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; height: 100%; } .page { display: flex; min-height: 100vh; } .sidebar { width: 220px; background-color: #1a1a2e; color: #eee; padding-top: 1rem; flex-shrink: 0; } From 5f9ca0cecac42ac16f44244022223a8075bb23e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 20:34:19 +0000 Subject: [PATCH 07/19] =?UTF-8?q?feat:=20Phase=205+6=20=E2=80=94=20camera?= =?UTF-8?q?=20abstraction,=20MJPEG=20provider,=20SQLite=20+=20InfluxDB=20s?= =?UTF-8?q?tores,=2082=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/akinbender/MakerPrompt/sessions/1706cabd-c655-4242-a826-7f439d624e29 Co-authored-by: akinbender <40242943+akinbender@users.noreply.github.com> --- MakerPrompt.sln | 30 +++ src/MakerPrompt.Cloud/Program.cs | 55 +++- .../Abstractions/ICameraProvider.cs | 37 +++ .../Abstractions/ICameraSnapshotStore.cs | 29 ++ src/MakerPrompt.Core/Models/CameraSnapshot.cs | 29 ++ src/MakerPrompt.EdgeAgent/Program.cs | 19 +- .../Workers/CameraPollingWorker.cs | 142 ++++++++++ src/MakerPrompt.EdgeAgent/appsettings.json | 23 ++ .../InfluxDbTelemetryStore.cs | 224 ++++++++++++++++ ...MakerPrompt.Infrastructure.InfluxDb.csproj | 20 ++ .../MakerPrompt.Infrastructure.Sqlite.csproj | 20 ++ .../SqliteCameraSnapshotStore.cs | 178 +++++++++++++ .../SqliteTelemetryStore.cs | 176 ++++++++++++ .../Camera/InMemoryCameraSnapshotStore.cs | 84 ++++++ .../Camera/MjpegCameraProvider.cs | 181 +++++++++++++ .../MakerPrompt.Infrastructure.csproj | 4 + .../Infrastructure/CameraStoreTests.cs | 102 +++++++ .../Infrastructure/SqliteStoreTests.cs | 252 ++++++++++++++++++ .../MakerPrompt.Tests.Unit.csproj | 1 + 19 files changed, 1600 insertions(+), 6 deletions(-) create mode 100644 src/MakerPrompt.Core/Abstractions/ICameraProvider.cs create mode 100644 src/MakerPrompt.Core/Abstractions/ICameraSnapshotStore.cs create mode 100644 src/MakerPrompt.Core/Models/CameraSnapshot.cs create mode 100644 src/MakerPrompt.EdgeAgent/Workers/CameraPollingWorker.cs create mode 100644 src/MakerPrompt.EdgeAgent/appsettings.json create mode 100644 src/MakerPrompt.Infrastructure.InfluxDb/InfluxDbTelemetryStore.cs create mode 100644 src/MakerPrompt.Infrastructure.InfluxDb/MakerPrompt.Infrastructure.InfluxDb.csproj create mode 100644 src/MakerPrompt.Infrastructure.Sqlite/MakerPrompt.Infrastructure.Sqlite.csproj create mode 100644 src/MakerPrompt.Infrastructure.Sqlite/SqliteCameraSnapshotStore.cs create mode 100644 src/MakerPrompt.Infrastructure.Sqlite/SqliteTelemetryStore.cs create mode 100644 src/MakerPrompt.Infrastructure/Camera/InMemoryCameraSnapshotStore.cs create mode 100644 src/MakerPrompt.Infrastructure/Camera/MjpegCameraProvider.cs create mode 100644 src/MakerPrompt.Tests.Unit/Infrastructure/CameraStoreTests.cs create mode 100644 src/MakerPrompt.Tests.Unit/Infrastructure/SqliteStoreTests.cs diff --git a/MakerPrompt.sln b/MakerPrompt.sln index 90eff88..c7b3e4c 100644 --- a/MakerPrompt.sln +++ b/MakerPrompt.sln @@ -51,6 +51,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.UI.Blazor", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.UI.MAUI", "src\MakerPrompt.UI.MAUI\MakerPrompt.UI.MAUI.csproj", "{B4F2D4E1-0009-0000-0000-000000000001}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Infrastructure.Sqlite", "src\MakerPrompt.Infrastructure.Sqlite\MakerPrompt.Infrastructure.Sqlite.csproj", "{6D881570-CC49-44BC-84D6-15F602342899}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Infrastructure.InfluxDb", "src\MakerPrompt.Infrastructure.InfluxDb\MakerPrompt.Infrastructure.InfluxDb.csproj", "{61DEF2BC-BA90-405D-B32B-CD9EC5928E63}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -243,6 +247,30 @@ Global {B4F2D4E1-0009-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU {B4F2D4E1-0009-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU {B4F2D4E1-0009-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Debug|x64.ActiveCfg = Debug|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Debug|x64.Build.0 = Debug|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Debug|x86.ActiveCfg = Debug|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Debug|x86.Build.0 = Debug|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Release|Any CPU.Build.0 = Release|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Release|x64.ActiveCfg = Release|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Release|x64.Build.0 = Release|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Release|x86.ActiveCfg = Release|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Release|x86.Build.0 = Release|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|x64.ActiveCfg = Debug|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|x64.Build.0 = Debug|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|x86.ActiveCfg = Debug|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|x86.Build.0 = Debug|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|Any CPU.Build.0 = Release|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|x64.ActiveCfg = Release|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|x64.Build.0 = Release|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|x86.ActiveCfg = Release|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -258,6 +286,8 @@ Global {B4F2D4E1-0007-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} {B4F2D4E1-0008-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} {B4F2D4E1-0009-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {6D881570-CC49-44BC-84D6-15F602342899} = {B4F2D4E1-0000-0000-0000-000000000001} + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63} = {B4F2D4E1-0000-0000-0000-000000000001} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {545A45A2-4075-429A-AC75-ABFBE72CC15A} diff --git a/src/MakerPrompt.Cloud/Program.cs b/src/MakerPrompt.Cloud/Program.cs index 22e30c2..eaae7fc 100644 --- a/src/MakerPrompt.Cloud/Program.cs +++ b/src/MakerPrompt.Cloud/Program.cs @@ -1,5 +1,6 @@ using MakerPrompt.Core.Abstractions; using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Camera; using MakerPrompt.Infrastructure.Telemetry; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; @@ -101,9 +102,12 @@ // ── Services ───────────────────────────────────────────────────────────────── -// Local in-memory telemetry store (swap for a real persistence layer in production). +// Local in-memory telemetry store (swap for SqliteTelemetryStore / InfluxDbTelemetryStore in production). builder.Services.AddSingleton(); +// In-memory camera snapshot store (swap for SqliteCameraSnapshotStore in production). +builder.Services.AddSingleton(); + // API Explorer for potential future Swagger integration. builder.Services.AddEndpointsApiExplorer(); @@ -167,6 +171,55 @@ .WithTags("Telemetry") .RequireAuthorization("MemberRead"); +// ── Camera endpoints ────────────────────────────────────────────────────────── + +// Ingest a camera snapshot from an EdgeAgent. +// Requires the "makerprompt:ingest" scope (same as telemetry ingest). +app.MapPost("/api/camera/{cameraId}/snapshot", async ( + string cameraId, + [FromBody] CameraSnapshot snapshot, + ICameraSnapshotStore cameraStore, + CancellationToken ct) => +{ + snapshot.CameraId = cameraId; + await cameraStore.SaveAsync(snapshot, ct); + return Results.Accepted(); +}) +.WithName("IngestCameraSnapshot") +.WithTags("Camera") +.RequireAuthorization("EdgeAgent"); + +// Retrieve the latest JPEG snapshot for a camera (returns raw JPEG bytes). +app.MapGet("/api/camera/{cameraId}/latest", async ( + string cameraId, + ICameraSnapshotStore cameraStore, + CancellationToken ct) => +{ + var snapshot = await cameraStore.GetLatestAsync(cameraId, ct); + if (snapshot is null) return Results.NotFound(); + + return snapshot.JpegData.Length > 0 + ? Results.File(snapshot.JpegData, "image/jpeg") + : Results.NotFound(); +}) +.WithName("GetLatestCameraSnapshot") +.WithTags("Camera") +.RequireAuthorization("MemberRead"); + +// Retrieve snapshot metadata history for a camera (no image data). +app.MapGet("/api/camera/{cameraId}/history", async ( + string cameraId, + ICameraSnapshotStore cameraStore, + [FromQuery] int count = 20, + CancellationToken ct = default) => +{ + var history = await cameraStore.GetHistoryAsync(cameraId, count, ct); + return Results.Ok(history); +}) +.WithName("GetCameraSnapshotHistory") +.WithTags("Camera") +.RequireAuthorization("MemberRead"); + // ── Run ─────────────────────────────────────────────────────────────────────── app.Run(); diff --git a/src/MakerPrompt.Core/Abstractions/ICameraProvider.cs b/src/MakerPrompt.Core/Abstractions/ICameraProvider.cs new file mode 100644 index 0000000..ac8552a --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/ICameraProvider.cs @@ -0,0 +1,37 @@ +namespace MakerPrompt.Core.Abstractions; + +/// +/// Abstraction for a camera feed associated with a printer or a hackerspace bay. +/// +/// Implementations may read from: +/// - an MJPEG HTTP stream (most webcams, OctoPrint, Mainsail) +/// - an RTSP stream (IP cameras) +/// - a local V4L2 device (Linux EdgeAgent) +/// +/// The camera is identified by its which correlates snapshots +/// with the printer they are mounted next to (same ID as the printer it monitors). +/// +public interface ICameraProvider : IAsyncDisposable +{ + /// Unique identifier for this camera, typically matching a printer ID. + string CameraId { get; } + + /// Human-readable label (e.g. "Ender-3 Webcam"). + string Label { get; } + + /// Whether the camera stream is currently reachable. + bool IsAvailable { get; } + + /// + /// Captures a single JPEG snapshot from the camera stream. + /// + /// Cancellation token. + /// Raw JPEG bytes, or an empty array if the camera is unavailable. + Task CaptureSnapshotAsync(CancellationToken cancellationToken = default); + + /// + /// Verifies the camera is reachable and updates . + /// Called during EdgeAgent startup and periodically during health checks. + /// + Task CheckAvailabilityAsync(CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/ICameraSnapshotStore.cs b/src/MakerPrompt.Core/Abstractions/ICameraSnapshotStore.cs new file mode 100644 index 0000000..e5943cc --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/ICameraSnapshotStore.cs @@ -0,0 +1,29 @@ +namespace MakerPrompt.Core.Abstractions; + +/// +/// Persists and retrieves camera snapshots. +/// Implementations: in-memory (tests), SQLite (EdgeAgent), Cloud REST projection. +/// +public interface ICameraSnapshotStore +{ + /// + /// Persists a snapshot. The store associates it with the + /// and the + /// capture timestamp already embedded in the model. + /// + Task SaveAsync(Core.Models.CameraSnapshot snapshot, CancellationToken cancellationToken = default); + + /// + /// Returns the most recent snapshot for , + /// or null if none has been stored. + /// + Task GetLatestAsync(string cameraId, CancellationToken cancellationToken = default); + + /// + /// Returns up to snapshots for , + /// ordered from most-recent to oldest. Only metadata is returned by default; + /// implementations may omit the JpegData blob to reduce memory pressure. + /// + Task> GetHistoryAsync( + string cameraId, int count = 20, CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Models/CameraSnapshot.cs b/src/MakerPrompt.Core/Models/CameraSnapshot.cs new file mode 100644 index 0000000..6469157 --- /dev/null +++ b/src/MakerPrompt.Core/Models/CameraSnapshot.cs @@ -0,0 +1,29 @@ +namespace MakerPrompt.Core.Models; + +/// +/// A single camera frame snapshot — JPEG bytes plus metadata. +/// Forwarded from an EdgeAgent to the Cloud API alongside telemetry. +/// +public sealed class CameraSnapshot +{ + /// + /// Unique identifier of the camera (typically matches a printer ID so snapshots + /// can be correlated with telemetry). + /// + public string CameraId { get; set; } = string.Empty; + + /// Human-readable camera label. + public string Label { get; set; } = string.Empty; + + /// Raw JPEG image data. + public byte[] JpegData { get; set; } = []; + + /// UTC timestamp when the frame was captured. + public DateTimeOffset CapturedAt { get; set; } = DateTimeOffset.UtcNow; + + /// Image width in pixels (0 if unknown). + public int Width { get; set; } + + /// Image height in pixels (0 if unknown). + public int Height { get; set; } +} diff --git a/src/MakerPrompt.EdgeAgent/Program.cs b/src/MakerPrompt.EdgeAgent/Program.cs index 66ffd79..d74f277 100644 --- a/src/MakerPrompt.EdgeAgent/Program.cs +++ b/src/MakerPrompt.EdgeAgent/Program.cs @@ -1,21 +1,30 @@ using MakerPrompt.Application.Services; using MakerPrompt.Core.Abstractions; using MakerPrompt.EdgeAgent.Workers; +using MakerPrompt.Infrastructure.Camera; using MakerPrompt.Infrastructure.Telemetry; var builder = Host.CreateApplicationBuilder(args); +var configuration = builder.Configuration; -// ── Services ────────────────────────────────────────────────────────────────── - -// Local in-memory telemetry store (swap for SQLite persistence in production). +// ── Telemetry store ─────────────────────────────────────────────────────────── +// Default: in-memory ring buffer. +// Swap to SqliteTelemetryStore or InfluxDbTelemetryStore via DI registration below. builder.Services.AddSingleton(); -// Application-layer fleet manager. +// ── Camera snapshot store ───────────────────────────────────────────────────── +builder.Services.AddSingleton(); + +// ── Application-layer fleet manager ────────────────────────────────────────── builder.Services.AddSingleton(); -// Background worker that polls printers and forwards telemetry. +// ── Background workers ──────────────────────────────────────────────────────── +// Telemetry polling — polls each connected printer, saves to ITelemetryStore. builder.Services.AddHostedService(); +// Camera polling — captures MJPEG snapshots, saves to ICameraSnapshotStore. +builder.Services.AddHostedService(); + // ── Build & Run ─────────────────────────────────────────────────────────────── var host = builder.Build(); diff --git a/src/MakerPrompt.EdgeAgent/Workers/CameraPollingWorker.cs b/src/MakerPrompt.EdgeAgent/Workers/CameraPollingWorker.cs new file mode 100644 index 0000000..9b14360 --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/Workers/CameraPollingWorker.cs @@ -0,0 +1,142 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Camera; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Configuration; + +namespace MakerPrompt.EdgeAgent.Workers; + +/// +/// Background worker that captures periodic snapshots from all configured camera +/// feeds and persists them via . +/// +/// Cameras are configured in appsettings.json (or environment variables) +/// under the EdgeAgent:Cameras array: +/// +/// +/// "EdgeAgent": { +/// "CameraIntervalSeconds": 10, +/// "Cameras": [ +/// { "CameraId": "printer-1", "Label": "Ender-3 Cam", "MjpegUrl": "http://192.168.1.10:8080/?action=snapshot" }, +/// { "CameraId": "printer-2", "Label": "Voron Cam", "MjpegUrl": "http://192.168.1.11/webcam/?action=snapshot" } +/// ] +/// } +/// +/// +/// Each camera entry must supply a CameraId (matching the printer it monitors), +/// a Label, and an MJPEG snapshot or stream MjpegUrl. +/// +public sealed class CameraPollingWorker : BackgroundService +{ + private readonly ICameraSnapshotStore _store; + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; + private readonly TimeSpan _captureInterval; + private readonly List _cameras = []; + + public CameraPollingWorker( + ICameraSnapshotStore store, + ILoggerFactory loggerFactory, + IConfiguration configuration) + { + _store = store; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); + + var intervalSeconds = configuration.GetValue("EdgeAgent:CameraIntervalSeconds", 10); + _captureInterval = TimeSpan.FromSeconds(Math.Max(1, intervalSeconds)); + + // Build providers from configuration. + var camerasSection = configuration.GetSection("EdgeAgent:Cameras"); + foreach (var cam in camerasSection.GetChildren()) + { + var cameraId = cam["CameraId"] ?? string.Empty; + var label = cam["Label"] ?? cameraId; + var url = cam["MjpegUrl"] ?? string.Empty; + + if (string.IsNullOrWhiteSpace(cameraId) || string.IsNullOrWhiteSpace(url)) + { + _logger.LogWarning( + "Camera entry is missing CameraId or MjpegUrl — skipping"); + continue; + } + + _cameras.Add(new MjpegCameraProvider( + cameraId, label, url, + loggerFactory.CreateLogger())); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (_cameras.Count == 0) + { + _logger.LogInformation( + "CameraPollingWorker: no cameras configured — worker idle"); + return; + } + + _logger.LogInformation( + "CameraPollingWorker started — {Count} camera(s), interval {Interval}", + _cameras.Count, _captureInterval); + + // Verify availability before first capture cycle. + foreach (var cam in _cameras) + await cam.CheckAvailabilityAsync(stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + await CaptureAllAsync(stoppingToken); + await Task.Delay(_captureInterval, stoppingToken).ConfigureAwait(false); + } + + _logger.LogInformation("CameraPollingWorker stopped"); + } + + private async Task CaptureAllAsync(CancellationToken ct) + { + foreach (var cam in _cameras) + { + try + { + var jpeg = await cam.CaptureSnapshotAsync(ct); + if (jpeg.Length == 0) continue; + + var snapshot = new CameraSnapshot + { + CameraId = cam.CameraId, + Label = cam.Label, + JpegData = jpeg, + CapturedAt = DateTimeOffset.UtcNow + }; + + await _store.SaveAsync(snapshot, ct); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + // Swallow per-camera errors — log at Debug to avoid spam. + _logger.LogDebug(ex, + "Capture error for camera {CameraId}", cam.CameraId); + } + } + } + + public override void Dispose() + { + // DisposeAsync() is not available as an override on BackgroundService in .NET 10. + // Use synchronous dispose here and rely on the host's graceful shutdown for cleanup. + foreach (var cam in _cameras) + { + var disposeTask = cam.DisposeAsync(); + if (!disposeTask.IsCompleted) + disposeTask.AsTask().GetAwaiter().GetResult(); + } + + base.Dispose(); + } +} diff --git a/src/MakerPrompt.EdgeAgent/appsettings.json b/src/MakerPrompt.EdgeAgent/appsettings.json new file mode 100644 index 0000000..749970f --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/appsettings.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "EdgeAgent": { + "PollIntervalSeconds": 5, + "CameraIntervalSeconds": 10, + "Cameras": [ + { + "CameraId": "printer-1", + "Label": "Ender-3 Webcam", + "MjpegUrl": "http://192.168.1.10:8080/?action=snapshot" + } + ] + }, + "CloudApi": { + "BaseUrl": "https://makerprompt.example.com", + "ApiToken": "" + } +} diff --git a/src/MakerPrompt.Infrastructure.InfluxDb/InfluxDbTelemetryStore.cs b/src/MakerPrompt.Infrastructure.InfluxDb/InfluxDbTelemetryStore.cs new file mode 100644 index 0000000..66e2f8f --- /dev/null +++ b/src/MakerPrompt.Infrastructure.InfluxDb/InfluxDbTelemetryStore.cs @@ -0,0 +1,224 @@ +using InfluxDB.Client; +using InfluxDB.Client.Api.Domain; +using InfluxDB.Client.Writes; +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Infrastructure.InfluxDb; + +/// +/// InfluxDB 2.x / 3.x (compatibility API) implementation of . +/// +/// Data model — Line Protocol +/// -------------------------- +/// Each telemetry snapshot is stored as a single data point in the +/// printer_telemetry measurement with these fields and tags: +/// +/// Tags (indexed, low-cardinality): +/// printer_id — unique printer identifier +/// printer_name — display name +/// status — PrinterStatus enum value +/// +/// Fields (numeric/string values): +/// hotend_temp, hotend_target, bed_temp, bed_target, chamber_temp, chamber_target +/// feed_rate, flow_rate, fan_speed +/// print_progress, filament_used, print_duration_secs +/// print_job_name +/// +/// Configuration +/// ------------- +/// Configure via DI: +/// +/// builder.Services.AddSingleton<ITelemetryStore>(sp => +/// new InfluxDbTelemetryStore( +/// url: "http://influxdb:8086", +/// token: "my-api-token", +/// org: "makerprompt", +/// bucket: "telemetry", +/// sp.GetRequiredService<ILogger<InfluxDbTelemetryStore>>())); +/// +/// +/// Or use environment variables: +/// INFLUXDB_URL, INFLUXDB_TOKEN, INFLUXDB_ORG, INFLUXDB_BUCKET +/// +public sealed class InfluxDbTelemetryStore : ITelemetryStore, IAsyncDisposable +{ + private const string Measurement = "printer_telemetry"; + + private readonly InfluxDBClient _client; + private readonly string _org; + private readonly string _bucket; + private readonly ILogger _logger; + + /// InfluxDB base URL (e.g. "http://influxdb:8086"). + /// InfluxDB API token. + /// InfluxDB organisation name. + /// InfluxDB bucket name. + /// Logger. + public InfluxDbTelemetryStore( + string url, + string token, + string org, + string bucket, + ILogger logger) + { + _org = org; + _bucket = bucket; + _logger = logger; + _client = new InfluxDBClient(url, token); + } + + // ── ITelemetryStore ─────────────────────────────────────────────────────── + + public async Task SaveAsync(string printerId, PrinterTelemetry telemetry, + CancellationToken cancellationToken = default) + { + try + { + var point = BuildPoint(printerId, telemetry); + var writeApi = _client.GetWriteApiAsync(); + await writeApi.WritePointAsync(point, _bucket, _org, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, + "[InfluxDbTelemetryStore] Failed to write point for printer {PrinterId}", printerId); + } + } + + public async Task GetLatestAsync(string printerId, + CancellationToken cancellationToken = default) + { + var flux = $""" + from(bucket: "{EscapeFlux(_bucket)}") + |> range(start: -30d) + |> filter(fn: (r) => r["_measurement"] == "{EscapeFlux(Measurement)}") + |> filter(fn: (r) => r["printer_id"] == "{EscapeFlux(printerId)}") + |> last() + |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") + """; + + var tables = await QueryAsync(flux, cancellationToken); + return tables.SelectMany(t => t.Records) + .OrderByDescending(r => r.GetTime()) + .Select(MapRecord) + .FirstOrDefault(); + } + + public async Task> GetHistoryAsync( + string printerId, int count = 100, CancellationToken cancellationToken = default) + { + var flux = $""" + from(bucket: "{EscapeFlux(_bucket)}") + |> range(start: -30d) + |> filter(fn: (r) => r["_measurement"] == "{EscapeFlux(Measurement)}") + |> filter(fn: (r) => r["printer_id"] == "{EscapeFlux(printerId)}") + |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") + |> sort(columns: ["_time"], desc: true) + |> limit(n: {count}) + """; + + var tables = await QueryAsync(flux, cancellationToken); + return tables + .SelectMany(t => t.Records) + .Select(MapRecord) + .Where(t => t is not null) + .Select(t => t!) + .ToList() + .AsReadOnly(); + } + + // ── Line Protocol helpers ───────────────────────────────────────────────── + + private static PointData BuildPoint(string printerId, PrinterTelemetry t) + { + return PointData + .Measurement(Measurement) + .Tag("printer_id", printerId) + .Tag("printer_name", t.PrinterName) + .Tag("status", t.Status.ToString()) + .Field("hotend_temp", t.HotendTemp) + .Field("hotend_target", t.HotendTarget) + .Field("bed_temp", t.BedTemp) + .Field("bed_target", t.BedTarget) + .Field("chamber_temp", t.ChamberTemp) + .Field("chamber_target", t.ChamberTarget) + .Field("feed_rate", (long)t.FeedRate) + .Field("flow_rate", (long)t.FlowRate) + .Field("fan_speed", (long)t.FanSpeed) + .Field("print_progress", t.PrintProgress) + .Field("filament_used", t.FilamentUsed) + .Field("print_duration_secs", (long)t.PrintDuration.TotalSeconds) + .Field("print_job_name", t.PrintJobName) + .Timestamp(t.CapturedAt.UtcDateTime, WritePrecision.Ns); + } + + // ── Flux query helpers ──────────────────────────────────────────────────── + + private async Task> QueryAsync( + string flux, CancellationToken cancellationToken) + { + try + { + var queryApi = _client.GetQueryApi(); + return await queryApi.QueryAsync(flux, _org, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "[InfluxDbTelemetryStore] Flux query failed"); + return []; + } + } + + private static PrinterTelemetry? MapRecord(InfluxDB.Client.Core.Flux.Domain.FluxRecord r) + { + try + { + return new PrinterTelemetry + { + PrinterName = GetString(r, "printer_name"), + Status = Enum.TryParse(GetString(r, "status"), out var s) ? s : PrinterStatus.Disconnected, + HotendTemp = GetDouble(r, "hotend_temp"), + HotendTarget = GetDouble(r, "hotend_target"), + BedTemp = GetDouble(r, "bed_temp"), + BedTarget = GetDouble(r, "bed_target"), + ChamberTemp = GetDouble(r, "chamber_temp"), + ChamberTarget = GetDouble(r, "chamber_target"), + FeedRate = (int)GetLong(r, "feed_rate", 100), + FlowRate = (int)GetLong(r, "flow_rate", 100), + FanSpeed = (int)GetLong(r, "fan_speed"), + PrintProgress = GetDouble(r, "print_progress"), + FilamentUsed = GetDouble(r, "filament_used"), + PrintDuration = TimeSpan.FromSeconds(GetLong(r, "print_duration_secs")), + PrintJobName = GetString(r, "print_job_name"), + CapturedAt = r.GetTime() is { } t + ? new DateTimeOffset(t.ToDateTimeUtc(), TimeSpan.Zero) + : DateTimeOffset.UtcNow + }; + } + catch + { + return null; + } + } + + private static string GetString(InfluxDB.Client.Core.Flux.Domain.FluxRecord r, string key) + => r.GetValueByKey(key) is string v ? v : string.Empty; + + private static double GetDouble(InfluxDB.Client.Core.Flux.Domain.FluxRecord r, string key) + => r.GetValueByKey(key) is double v ? v : 0.0; + + private static long GetLong(InfluxDB.Client.Core.Flux.Domain.FluxRecord r, string key, long fallback = 0) + => r.GetValueByKey(key) is long v ? v : fallback; + + /// Escapes special characters in Flux string literals. + private static string EscapeFlux(string value) + => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + public ValueTask DisposeAsync() + { + _client.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/MakerPrompt.Infrastructure.InfluxDb/MakerPrompt.Infrastructure.InfluxDb.csproj b/src/MakerPrompt.Infrastructure.InfluxDb/MakerPrompt.Infrastructure.InfluxDb.csproj new file mode 100644 index 0000000..b38d51a --- /dev/null +++ b/src/MakerPrompt.Infrastructure.InfluxDb/MakerPrompt.Infrastructure.InfluxDb.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + MakerPrompt.Infrastructure.InfluxDb + + + + + + + + + + + + + diff --git a/src/MakerPrompt.Infrastructure.Sqlite/MakerPrompt.Infrastructure.Sqlite.csproj b/src/MakerPrompt.Infrastructure.Sqlite/MakerPrompt.Infrastructure.Sqlite.csproj new file mode 100644 index 0000000..2ecb19e --- /dev/null +++ b/src/MakerPrompt.Infrastructure.Sqlite/MakerPrompt.Infrastructure.Sqlite.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + MakerPrompt.Infrastructure.Sqlite + + + + + + + + + + + + + diff --git a/src/MakerPrompt.Infrastructure.Sqlite/SqliteCameraSnapshotStore.cs b/src/MakerPrompt.Infrastructure.Sqlite/SqliteCameraSnapshotStore.cs new file mode 100644 index 0000000..08652f9 --- /dev/null +++ b/src/MakerPrompt.Infrastructure.Sqlite/SqliteCameraSnapshotStore.cs @@ -0,0 +1,178 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Infrastructure.Sqlite; + +/// +/// SQLite-backed implementation of . +/// +/// Schema +/// ------ +/// +/// CREATE TABLE camera_snapshots ( +/// id INTEGER PRIMARY KEY AUTOINCREMENT, +/// camera_id TEXT NOT NULL, +/// label TEXT NOT NULL, +/// captured_at TEXT NOT NULL, -- ISO-8601 UTC +/// width INTEGER NOT NULL DEFAULT 0, +/// height INTEGER NOT NULL DEFAULT 0, +/// jpeg_data BLOB NOT NULL +/// ); +/// CREATE INDEX ix_camera_captured ON camera_snapshots (camera_id, captured_at DESC); +/// +/// +/// Usage +/// ----- +/// +/// builder.Services.AddSingleton<ICameraSnapshotStore>(sp => +/// new SqliteCameraSnapshotStore("Data Source=cameras.db", sp.GetRequiredService<ILogger<SqliteCameraSnapshotStore>>())); +/// +/// +public sealed class SqliteCameraSnapshotStore : ICameraSnapshotStore, IAsyncDisposable +{ + private readonly string _connectionString; + private readonly ILogger _logger; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + /// SQLite connection string, e.g. "Data Source=cameras.db". + /// Logger. + public SqliteCameraSnapshotStore(string connectionString, ILogger logger) + { + _connectionString = connectionString; + _logger = logger; + InitialiseSchema(); + } + + // ── ICameraSnapshotStore ────────────────────────────────────────────────── + + public async Task SaveAsync(CameraSnapshot snapshot, CancellationToken cancellationToken = default) + { + await _writeLock.WaitAsync(cancellationToken); + try + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO camera_snapshots (camera_id, label, captured_at, width, height, jpeg_data) + VALUES ($cameraId, $label, $capturedAt, $width, $height, $jpegData); + """; + cmd.Parameters.AddWithValue("$cameraId", snapshot.CameraId); + cmd.Parameters.AddWithValue("$label", snapshot.Label); + cmd.Parameters.AddWithValue("$capturedAt", snapshot.CapturedAt.ToString("O")); + cmd.Parameters.AddWithValue("$width", snapshot.Width); + cmd.Parameters.AddWithValue("$height", snapshot.Height); + cmd.Parameters.AddWithValue("$jpegData", snapshot.JpegData); + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + finally + { + _writeLock.Release(); + } + } + + public async Task GetLatestAsync(string cameraId, + CancellationToken cancellationToken = default) + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT camera_id, label, captured_at, width, height, jpeg_data + FROM camera_snapshots + WHERE camera_id = $cameraId + ORDER BY captured_at DESC + LIMIT 1; + """; + cmd.Parameters.AddWithValue("$cameraId", cameraId); + + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) return null; + + return ReadSnapshot(reader, includeBlob: true); + } + + public async Task> GetHistoryAsync( + string cameraId, int count = 20, CancellationToken cancellationToken = default) + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + // History returns metadata only — JPEG blob is excluded to save memory. + cmd.CommandText = """ + SELECT camera_id, label, captured_at, width, height, NULL as jpeg_data + FROM camera_snapshots + WHERE camera_id = $cameraId + ORDER BY captured_at DESC + LIMIT $count; + """; + cmd.Parameters.AddWithValue("$cameraId", cameraId); + cmd.Parameters.AddWithValue("$count", count); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + results.Add(ReadSnapshot(reader, includeBlob: false)); + + return results.AsReadOnly(); + } + + // ── Schema initialisation ───────────────────────────────────────────────── + + private void InitialiseSchema() + { + using var conn = OpenConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS camera_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + camera_id TEXT NOT NULL, + label TEXT NOT NULL, + captured_at TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + jpeg_data BLOB NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_camera_captured + ON camera_snapshots (camera_id, captured_at DESC); + """; + cmd.ExecuteNonQuery(); + _logger.LogDebug("[SqliteCameraSnapshotStore] Schema initialised ({Connection})", _connectionString); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task OpenConnectionAsync(CancellationToken cancellationToken) + { + var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(cancellationToken); + return conn; + } + + private SqliteConnection OpenConnection() + { + var conn = new SqliteConnection(_connectionString); + conn.Open(); + return conn; + } + + private static CameraSnapshot ReadSnapshot(SqliteDataReader reader, bool includeBlob) + { + return new CameraSnapshot + { + CameraId = reader.GetString(0), + Label = reader.GetString(1), + CapturedAt = DateTimeOffset.Parse(reader.GetString(2)), + Width = reader.GetInt32(3), + Height = reader.GetInt32(4), + JpegData = includeBlob && !reader.IsDBNull(5) + ? (byte[])reader.GetValue(5) + : [] + }; + } + + public ValueTask DisposeAsync() + { + _writeLock.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/MakerPrompt.Infrastructure.Sqlite/SqliteTelemetryStore.cs b/src/MakerPrompt.Infrastructure.Sqlite/SqliteTelemetryStore.cs new file mode 100644 index 0000000..cbecbb6 --- /dev/null +++ b/src/MakerPrompt.Infrastructure.Sqlite/SqliteTelemetryStore.cs @@ -0,0 +1,176 @@ +using System.Text.Json; +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Infrastructure.Sqlite; + +/// +/// SQLite-backed implementation of . +/// +/// Schema +/// ------ +/// +/// CREATE TABLE telemetry_snapshots ( +/// id INTEGER PRIMARY KEY AUTOINCREMENT, +/// printer_id TEXT NOT NULL, +/// captured_at TEXT NOT NULL, -- ISO-8601 UTC +/// payload TEXT NOT NULL -- JSON-serialised PrinterTelemetry +/// ); +/// CREATE INDEX ix_telemetry_printer_captured ON telemetry_snapshots (printer_id, captured_at DESC); +/// +/// +/// The full model is stored as a JSON payload so the +/// schema never needs to be migrated when new fields are added. +/// +/// Usage +/// ----- +/// Register via DI in the EdgeAgent or Cloud host: +/// +/// builder.Services.AddSingleton<ITelemetryStore>(sp => +/// new SqliteTelemetryStore("Data Source=telemetry.db", sp.GetRequiredService<ILogger<SqliteTelemetryStore>>())); +/// +/// +public sealed class SqliteTelemetryStore : ITelemetryStore, IAsyncDisposable +{ + private static readonly JsonSerializerOptions JsonOpts = + new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + private readonly string _connectionString; + private readonly ILogger _logger; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + /// SQLite connection string, e.g. "Data Source=telemetry.db". + /// Logger. + public SqliteTelemetryStore(string connectionString, ILogger logger) + { + _connectionString = connectionString; + _logger = logger; + InitialiseSchema(); + } + + // ── ITelemetryStore ─────────────────────────────────────────────────────── + + public async Task SaveAsync(string printerId, PrinterTelemetry telemetry, + CancellationToken cancellationToken = default) + { + var json = JsonSerializer.Serialize(telemetry, JsonOpts); + + await _writeLock.WaitAsync(cancellationToken); + try + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO telemetry_snapshots (printer_id, captured_at, payload) + VALUES ($printerId, $capturedAt, $payload); + """; + cmd.Parameters.AddWithValue("$printerId", printerId); + cmd.Parameters.AddWithValue("$capturedAt", telemetry.CapturedAt.ToString("O")); + cmd.Parameters.AddWithValue("$payload", json); + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + finally + { + _writeLock.Release(); + } + } + + public async Task GetLatestAsync(string printerId, + CancellationToken cancellationToken = default) + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT payload FROM telemetry_snapshots + WHERE printer_id = $printerId + ORDER BY captured_at DESC + LIMIT 1; + """; + cmd.Parameters.AddWithValue("$printerId", printerId); + + var json = (string?)await cmd.ExecuteScalarAsync(cancellationToken); + return json is null ? null : Deserialise(json); + } + + public async Task> GetHistoryAsync( + string printerId, int count = 100, CancellationToken cancellationToken = default) + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT payload FROM telemetry_snapshots + WHERE printer_id = $printerId + ORDER BY captured_at DESC + LIMIT $count; + """; + cmd.Parameters.AddWithValue("$printerId", printerId); + cmd.Parameters.AddWithValue("$count", count); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var item = Deserialise(reader.GetString(0)); + if (item is not null) results.Add(item); + } + + return results.AsReadOnly(); + } + + // ── Schema initialisation ───────────────────────────────────────────────── + + private void InitialiseSchema() + { + using var conn = OpenConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS telemetry_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + printer_id TEXT NOT NULL, + captured_at TEXT NOT NULL, + payload TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_telemetry_printer_captured + ON telemetry_snapshots (printer_id, captured_at DESC); + """; + cmd.ExecuteNonQuery(); + _logger.LogDebug("[SqliteTelemetryStore] Schema initialised ({Connection})", _connectionString); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task OpenConnectionAsync(CancellationToken cancellationToken) + { + var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(cancellationToken); + return conn; + } + + private SqliteConnection OpenConnection() + { + var conn = new SqliteConnection(_connectionString); + conn.Open(); + return conn; + } + + private PrinterTelemetry? Deserialise(string json) + { + try + { + return JsonSerializer.Deserialize(json, JsonOpts); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "[SqliteTelemetryStore] Failed to deserialise telemetry row"); + return null; + } + } + + public ValueTask DisposeAsync() + { + _writeLock.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/MakerPrompt.Infrastructure/Camera/InMemoryCameraSnapshotStore.cs b/src/MakerPrompt.Infrastructure/Camera/InMemoryCameraSnapshotStore.cs new file mode 100644 index 0000000..1a7fc44 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Camera/InMemoryCameraSnapshotStore.cs @@ -0,0 +1,84 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Infrastructure.Camera; + +/// +/// In-memory implementation of . +/// Retains the last N snapshots per camera in a bounded ring buffer. +/// Suitable for tests and EdgeAgent scenarios where the process is long-running. +/// +public sealed class InMemoryCameraSnapshotStore : ICameraSnapshotStore +{ + private readonly int _maxPerCamera; + private readonly Dictionary> _data = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + /// Maximum snapshots retained per camera (default 50). + public InMemoryCameraSnapshotStore(int maxPerCamera = 50) + { + _maxPerCamera = maxPerCamera; + } + + public async Task SaveAsync(CameraSnapshot snapshot, + CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (!_data.TryGetValue(snapshot.CameraId, out var list)) + { + list = new LinkedList(); + _data[snapshot.CameraId] = list; + } + + list.AddFirst(snapshot); + while (list.Count > _maxPerCamera) + list.RemoveLast(); + } + finally + { + _lock.Release(); + } + } + + public async Task GetLatestAsync(string cameraId, + CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + return _data.TryGetValue(cameraId, out var list) ? list.First?.Value : null; + } + finally + { + _lock.Release(); + } + } + + public async Task> GetHistoryAsync( + string cameraId, int count = 20, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (!_data.TryGetValue(cameraId, out var list)) + return []; + + // Return metadata only (no JPEG data) to reduce memory pressure. + return list.Take(count).Select(s => new CameraSnapshot + { + CameraId = s.CameraId, + Label = s.Label, + CapturedAt = s.CapturedAt, + Width = s.Width, + Height = s.Height, + JpegData = [] + }).ToList().AsReadOnly(); + } + finally + { + _lock.Release(); + } + } +} diff --git a/src/MakerPrompt.Infrastructure/Camera/MjpegCameraProvider.cs b/src/MakerPrompt.Infrastructure/Camera/MjpegCameraProvider.cs new file mode 100644 index 0000000..0ba491f --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Camera/MjpegCameraProvider.cs @@ -0,0 +1,181 @@ +using MakerPrompt.Core.Abstractions; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Infrastructure.Camera; + +/// +/// Camera provider that reads a single JPEG frame from an MJPEG HTTP stream. +/// +/// Compatibility +/// ------------- +/// Works with any device that exposes a standard MJPEG endpoint, including: +/// - OctoPrint (e.g. http://printer:8080/?action=snapshot) +/// - Mainsail / Fluidd webcam streams +/// - Generic USB webcams served via mjpg-streamer +/// - Any IP camera that exposes an MJPEG endpoint +/// +/// The provider captures a single frame per call +/// by reading only the first JPEG segment of the multipart MJPEG stream. +/// +public sealed class MjpegCameraProvider : ICameraProvider +{ + private static readonly HttpClient SharedClient = new() + { + Timeout = TimeSpan.FromSeconds(10) + }; + + private readonly string _streamUrl; + private readonly ILogger _logger; + + /// + public string CameraId { get; } + + /// + public string Label { get; } + + /// + public bool IsAvailable { get; private set; } + + /// Printer/camera ID this feed belongs to. + /// Human-readable label for the camera. + /// + /// MJPEG snapshot URL (e.g. http://printer:8080/?action=snapshot). + /// May be a snapshot endpoint (returns a single JPEG) or a live MJPEG + /// stream (the provider will extract the first frame automatically). + /// + /// Logger. + public MjpegCameraProvider( + string cameraId, + string label, + string streamUrl, + ILogger logger) + { + CameraId = cameraId; + Label = label; + _streamUrl = streamUrl; + _logger = logger; + } + + /// + public async Task CaptureSnapshotAsync(CancellationToken cancellationToken = default) + { + if (!IsAvailable) return []; + + try + { + using var response = await SharedClient.GetAsync( + _streamUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + + response.EnsureSuccessStatusCode(); + + var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty; + + if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) || + contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) + { + // Snapshot endpoint — response is already a single JPEG. + return await response.Content.ReadAsByteArrayAsync(cancellationToken); + } + else if (contentType.StartsWith("multipart/x-mixed-replace", StringComparison.OrdinalIgnoreCase)) + { + // MJPEG stream — extract the first JPEG frame. + return await ExtractFirstMjpegFrameAsync(response, cancellationToken); + } + else + { + _logger.LogWarning( + "[CameraProvider:{CameraId}] Unexpected content-type: {ContentType}", + CameraId, contentType); + return []; + } + } + catch (OperationCanceledException) + { + return []; + } + catch (Exception ex) + { + // Swallow capture errors — camera may be temporarily unavailable. + _logger.LogDebug(ex, "[CameraProvider:{CameraId}] Snapshot capture failed", CameraId); + IsAvailable = false; + return []; + } + } + + /// + public async Task CheckAvailabilityAsync(CancellationToken cancellationToken = default) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Head, _streamUrl); + using var response = await SharedClient.SendAsync( + request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + + IsAvailable = response.IsSuccessStatusCode; + } + catch + { + IsAvailable = false; + } + + return IsAvailable; + } + + // ── MJPEG frame extraction ──────────────────────────────────────────────── + + private static async Task ExtractFirstMjpegFrameAsync( + HttpResponseMessage response, CancellationToken ct) + { + // MJPEG multipart boundary is declared in the Content-Type header. + // e.g. multipart/x-mixed-replace;boundary=--myboundary + // We scan for the JPEG SOI marker (0xFF 0xD8) and EOF marker (0xFF 0xD9). + await using var stream = await response.Content.ReadAsStreamAsync(ct); + + using var ms = new MemoryStream(); + var buf = new byte[8192]; + bool inJpeg = false; + int soi0 = -1; + + while (true) + { + ct.ThrowIfCancellationRequested(); + int read = await stream.ReadAsync(buf, ct); + if (read == 0) break; + + if (!inJpeg) + { + for (int i = 0; i < read - 1; i++) + { + if (buf[i] == 0xFF && buf[i + 1] == 0xD8) + { + soi0 = i; + inJpeg = true; + ms.Write(buf, i, read - i); + break; + } + } + } + else + { + ms.Write(buf, 0, read); + + // Check for EOI marker (0xFF 0xD9). + var data = ms.GetBuffer(); + var len = (int)ms.Length; + for (int i = len - 2; i >= Math.Max(0, len - read - 2); i--) + { + if (data[i] == 0xFF && data[i + 1] == 0xD9) + return ms.ToArray()[..(i + 2)]; + } + } + + // Safety valve: don't buffer more than 5 MB. + if (ms.Length > 5 * 1024 * 1024) + break; + } + + return ms.Length > 0 ? ms.ToArray() : []; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj b/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj index 8b420dc..03a0896 100644 --- a/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj +++ b/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj @@ -6,6 +6,10 @@ enable + + + + diff --git a/src/MakerPrompt.Tests.Unit/Infrastructure/CameraStoreTests.cs b/src/MakerPrompt.Tests.Unit/Infrastructure/CameraStoreTests.cs new file mode 100644 index 0000000..195e8c5 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Infrastructure/CameraStoreTests.cs @@ -0,0 +1,102 @@ +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Camera; + +namespace MakerPrompt.Tests.Unit.Infrastructure; + +/// +/// Tests for the in-memory camera snapshot store and related types. +/// +public sealed class CameraStoreTests +{ + [Fact] + public async Task InMemoryCameraStore_Save_And_GetLatest_RoundTrip() + { + var store = new InMemoryCameraSnapshotStore(); + var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 }; + var snapshot = new CameraSnapshot + { + CameraId = "cam-a", + Label = "Test Camera", + JpegData = jpeg, + Width = 1280, + Height = 720, + CapturedAt = DateTimeOffset.UtcNow + }; + + await store.SaveAsync(snapshot); + var latest = await store.GetLatestAsync("cam-a"); + + Assert.NotNull(latest); + Assert.Equal("Test Camera", latest.Label); + Assert.Equal(jpeg, latest.JpegData); + Assert.Equal(1280, latest.Width); + Assert.Equal(720, latest.Height); + } + + [Fact] + public async Task InMemoryCameraStore_GetLatest_ReturnsNull_WhenEmpty() + { + var store = new InMemoryCameraSnapshotStore(); + Assert.Null(await store.GetLatestAsync("cam-nonexistent")); + } + + [Fact] + public async Task InMemoryCameraStore_GetHistory_ExcludesJpegBlob() + { + var store = new InMemoryCameraSnapshotStore(); + for (int i = 0; i < 3; i++) + { + await store.SaveAsync(new CameraSnapshot + { + CameraId = "cam-b", + JpegData = new byte[500], + CapturedAt = DateTimeOffset.UtcNow.AddSeconds(i) + }); + } + + var history = await store.GetHistoryAsync("cam-b", count: 10); + + Assert.Equal(3, history.Count); + Assert.All(history, s => Assert.Empty(s.JpegData)); + } + + [Fact] + public async Task InMemoryCameraStore_RingBuffer_DropsOldestWhenFull() + { + const int max = 3; + var store = new InMemoryCameraSnapshotStore(maxPerCamera: max); + + for (int i = 1; i <= max + 2; i++) + { + await store.SaveAsync(new CameraSnapshot + { + CameraId = "cam-c", + Label = $"Snap-{i}", + JpegData = new byte[] { (byte)i }, + CapturedAt = DateTimeOffset.UtcNow.AddSeconds(i) + }); + } + + // Latest should be the newest (Snap-5). + var latest = await store.GetLatestAsync("cam-c"); + Assert.Equal("Snap-5", latest!.Label); + + // History should only contain max entries. + var history = await store.GetHistoryAsync("cam-c", count: 100); + Assert.Equal(max, history.Count); + } + + [Fact] + public async Task InMemoryCameraStore_IsolatesCameras() + { + var store = new InMemoryCameraSnapshotStore(); + await store.SaveAsync(new CameraSnapshot { CameraId = "camX", Label = "X", JpegData = new byte[] { 1 } }); + await store.SaveAsync(new CameraSnapshot { CameraId = "camY", Label = "Y", JpegData = new byte[] { 2 } }); + + var latestX = await store.GetLatestAsync("camX"); + var latestY = await store.GetLatestAsync("camY"); + + Assert.Equal("X", latestX!.Label); + Assert.Equal("Y", latestY!.Label); + } +} diff --git a/src/MakerPrompt.Tests.Unit/Infrastructure/SqliteStoreTests.cs b/src/MakerPrompt.Tests.Unit/Infrastructure/SqliteStoreTests.cs new file mode 100644 index 0000000..8dc281f --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Infrastructure/SqliteStoreTests.cs @@ -0,0 +1,252 @@ +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MakerPrompt.Tests.Unit.Infrastructure; + +/// +/// Integration-style tests for the SQLite-backed infrastructure stores. +/// Each test creates a unique temp-file SQLite database and cleans it up on dispose. +/// +public sealed class SqliteStoreTests +{ + // ── Helper: unique temp-file SQLite database per test ──────────────────── + + // SQLite in-memory databases disappear when the last connection closes. + // Using a temp file per test ensures the schema (created in the constructor) + // persists across multiple connection open/close cycles in the same test. + // The returned TempDb is IDisposable and deletes the file on dispose. + private static TempDb CreateTempDb() + { + var path = Path.Combine(Path.GetTempPath(), $"makerprompt_test_{Guid.NewGuid():N}.db"); + return new TempDb(path); + } + + private sealed class TempDb : IDisposable + { + public string ConnectionString { get; } + private readonly string _path; + + public TempDb(string path) + { + _path = path; + ConnectionString = $"Data Source={path}"; + } + + public void Dispose() + { + try { if (File.Exists(_path)) File.Delete(_path); } + catch { /* best-effort cleanup */ } + } + } + + // ── SqliteTelemetryStore ────────────────────────────────────────────────── + + [Fact] + public async Task TelemetryStore_Save_And_GetLatest_RoundTrip() + { + using var db = CreateTempDb(); + await using var store = new SqliteTelemetryStore( + db.ConnectionString, + NullLogger.Instance); + + var telemetry = new PrinterTelemetry + { + PrinterName = "Ender-3", + HotendTemp = 215.0, + HotendTarget = 215.0, + BedTemp = 60.0, + Status = PrinterStatus.Printing, + PrintProgress = 45.0, + CapturedAt = DateTimeOffset.UtcNow + }; + + await store.SaveAsync("printer-1", telemetry); + var latest = await store.GetLatestAsync("printer-1"); + + Assert.NotNull(latest); + Assert.Equal("Ender-3", latest.PrinterName); + Assert.Equal(215.0, latest.HotendTemp); + Assert.Equal(PrinterStatus.Printing, latest.Status); + Assert.Equal(45.0, latest.PrintProgress); + } + + [Fact] + public async Task TelemetryStore_GetLatest_ReturnsNull_WhenEmpty() + { + using var db = CreateTempDb(); + await using var store = new SqliteTelemetryStore( + db.ConnectionString, + NullLogger.Instance); + + var result = await store.GetLatestAsync("printer-x"); + Assert.Null(result); + } + + [Fact] + public async Task TelemetryStore_GetHistory_ReturnsMultipleSnapshots_OrderedNewestFirst() + { + using var db = CreateTempDb(); + await using var store = new SqliteTelemetryStore( + db.ConnectionString, + NullLogger.Instance); + + var baseTime = DateTimeOffset.UtcNow; + for (int i = 0; i < 5; i++) + { + await store.SaveAsync("p1", new PrinterTelemetry + { + PrinterName = $"Snap-{i}", + HotendTemp = 200 + i, + CapturedAt = baseTime.AddSeconds(i) + }); + } + + var history = await store.GetHistoryAsync("p1", count: 5); + + Assert.Equal(5, history.Count); + // Newest (highest HotendTemp) should be first. + Assert.Equal(204.0, history[0].HotendTemp); + } + + [Fact] + public async Task TelemetryStore_GetHistory_RespectsCountLimit() + { + using var db = CreateTempDb(); + await using var store = new SqliteTelemetryStore( + db.ConnectionString, + NullLogger.Instance); + + for (int i = 0; i < 10; i++) + await store.SaveAsync("p2", new PrinterTelemetry { CapturedAt = DateTimeOffset.UtcNow.AddSeconds(i) }); + + var history = await store.GetHistoryAsync("p2", count: 3); + + Assert.Equal(3, history.Count); + } + + [Fact] + public async Task TelemetryStore_IsolatePrinters_DifferentPrinterIdsDontMix() + { + using var db = CreateTempDb(); + await using var store = new SqliteTelemetryStore( + db.ConnectionString, + NullLogger.Instance); + + await store.SaveAsync("printerA", new PrinterTelemetry { HotendTemp = 190 }); + await store.SaveAsync("printerB", new PrinterTelemetry { HotendTemp = 230 }); + + var latestA = await store.GetLatestAsync("printerA"); + var latestB = await store.GetLatestAsync("printerB"); + + Assert.Equal(190.0, latestA!.HotendTemp); + Assert.Equal(230.0, latestB!.HotendTemp); + } + + [Fact] + public async Task TelemetryStore_GetLatest_ReturnsNewestSnapshot() + { + using var db = CreateTempDb(); + await using var store = new SqliteTelemetryStore( + db.ConnectionString, + NullLogger.Instance); + + var older = new PrinterTelemetry { HotendTemp = 100, CapturedAt = DateTimeOffset.UtcNow.AddSeconds(-10) }; + var newer = new PrinterTelemetry { HotendTemp = 210, CapturedAt = DateTimeOffset.UtcNow }; + + await store.SaveAsync("p3", older); + await store.SaveAsync("p3", newer); + + var latest = await store.GetLatestAsync("p3"); + Assert.Equal(210.0, latest!.HotendTemp); + } + + // ── SqliteCameraSnapshotStore ───────────────────────────────────────────── + + [Fact] + public async Task CameraStore_Save_And_GetLatest_RoundTrip() + { + using var db = CreateTempDb(); + await using var store = new SqliteCameraSnapshotStore( + db.ConnectionString, + NullLogger.Instance); + + var jpeg = new byte[] { 0xFF, 0xD8, 0x00, 0x01, 0xFF, 0xD9 }; // minimal JPEG + var snapshot = new CameraSnapshot + { + CameraId = "cam-1", + Label = "Ender-3 Cam", + JpegData = jpeg, + Width = 640, + Height = 480, + CapturedAt = DateTimeOffset.UtcNow + }; + + await store.SaveAsync(snapshot); + var latest = await store.GetLatestAsync("cam-1"); + + Assert.NotNull(latest); + Assert.Equal("Ender-3 Cam", latest.Label); + Assert.Equal(640, latest.Width); + Assert.Equal(480, latest.Height); + Assert.Equal(jpeg, latest.JpegData); + } + + [Fact] + public async Task CameraStore_GetLatest_ReturnsNull_WhenEmpty() + { + using var db = CreateTempDb(); + await using var store = new SqliteCameraSnapshotStore( + db.ConnectionString, + NullLogger.Instance); + + Assert.Null(await store.GetLatestAsync("cam-x")); + } + + [Fact] + public async Task CameraStore_GetHistory_ExcludesJpegBlob() + { + using var db = CreateTempDb(); + await using var store = new SqliteCameraSnapshotStore( + db.ConnectionString, + NullLogger.Instance); + + for (int i = 0; i < 3; i++) + { + await store.SaveAsync(new CameraSnapshot + { + CameraId = "cam-2", + Label = "Test", + JpegData = new byte[1000], + CapturedAt = DateTimeOffset.UtcNow.AddSeconds(i) + }); + } + + var history = await store.GetHistoryAsync("cam-2", count: 10); + + Assert.Equal(3, history.Count); + // History should strip JPEG data (returns metadata only). + Assert.All(history, s => Assert.Empty(s.JpegData)); + } + + [Fact] + public async Task CameraStore_GetHistory_RespectsCountLimit() + { + using var db = CreateTempDb(); + await using var store = new SqliteCameraSnapshotStore( + db.ConnectionString, + NullLogger.Instance); + + for (int i = 0; i < 5; i++) + { + await store.SaveAsync(new CameraSnapshot + { + CameraId = "cam-3", + CapturedAt = DateTimeOffset.UtcNow.AddSeconds(i) + }); + } + + var history = await store.GetHistoryAsync("cam-3", count: 2); + Assert.Equal(2, history.Count); + } +} diff --git a/src/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj b/src/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj index 3266cb7..fe3541b 100644 --- a/src/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj +++ b/src/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj @@ -25,6 +25,7 @@ + From a56e3ff951eec517ff7a7c1b271b33d3e599fba7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 15:43:03 +0000 Subject: [PATCH 08/19] =?UTF-8?q?feat:=20add=20AppDeploymentMode=20?= =?UTF-8?q?=E2=80=94=20Standalone/CloudMakerspace=20two-mode=20support=20w?= =?UTF-8?q?ith=20OIDC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/akinbender/MakerPrompt/sessions/714c7093-18df-4d98-8e8f-0d46624d9be4 Co-authored-by: akinbender <40242943+akinbender@users.noreply.github.com> --- MakerPrompt.Blazor/MakerPrompt.Blazor.csproj | 1 + MakerPrompt.Blazor/Program.cs | 19 +++ .../Services/AppConfigurationService.cs | 46 +++++- MakerPrompt.Blazor/wwwroot/appsettings.json | 14 ++ MakerPrompt.Shared/Pages/Fleet.razor | 33 ++-- MakerPrompt.Shared/Pages/Settings.razor | 152 ++++++++++-------- MakerPrompt.Shared/Utils/AppConfiguration.cs | 15 ++ MakerPrompt.Shared/Utils/AppDeploymentMode.cs | 24 +++ MakerPrompt.sln | 90 ----------- 9 files changed, 219 insertions(+), 175 deletions(-) create mode 100644 MakerPrompt.Blazor/wwwroot/appsettings.json create mode 100644 MakerPrompt.Shared/Utils/AppDeploymentMode.cs diff --git a/MakerPrompt.Blazor/MakerPrompt.Blazor.csproj b/MakerPrompt.Blazor/MakerPrompt.Blazor.csproj index 976f6fe..1f5842d 100644 --- a/MakerPrompt.Blazor/MakerPrompt.Blazor.csproj +++ b/MakerPrompt.Blazor/MakerPrompt.Blazor.csproj @@ -17,6 +17,7 @@ + diff --git a/MakerPrompt.Blazor/Program.cs b/MakerPrompt.Blazor/Program.cs index 26c1fa6..fdc99c1 100644 --- a/MakerPrompt.Blazor/Program.cs +++ b/MakerPrompt.Blazor/Program.cs @@ -18,6 +18,25 @@ // WASM: AES-GCM not supported in browser — use Base64 encoding fallback builder.Services.AddSingleton(); +// ── Deployment-mode-specific services ──────────────────────────────────────── +// Read the deployment mode from appsettings.json at startup so that auth +// middleware is configured before the first render. The same value is later +// applied to AppConfiguration by AppConfigurationService.InitializeAsync(). +var deploymentModeStr = builder.Configuration["MakerPrompt:DeploymentMode"] ?? string.Empty; +var isCloudMode = string.Equals(deploymentModeStr, nameof(AppDeploymentMode.CloudMakerspace), + StringComparison.OrdinalIgnoreCase); + +if (isCloudMode) +{ + // Configure OIDC / OpenID Connect authentication for Cloud/Makerspace mode. + // Authority, ClientId, and scopes are read from appsettings.json: + // MakerPrompt:Oidc:Authority / ClientId / DefaultScopes + builder.Services.AddOidcAuthentication(options => + { + builder.Configuration.Bind("MakerPrompt:Oidc", options.ProviderOptions); + }); +} + var host = builder.Build(); const string defaultCulture = "en-US"; diff --git a/MakerPrompt.Blazor/Services/AppConfigurationService.cs b/MakerPrompt.Blazor/Services/AppConfigurationService.cs index aa2471d..d36b6ad 100644 --- a/MakerPrompt.Blazor/Services/AppConfigurationService.cs +++ b/MakerPrompt.Blazor/Services/AppConfigurationService.cs @@ -1,5 +1,6 @@ using MakerPrompt.Shared.Infrastructure; using MakerPrompt.Shared.Utils; +using Microsoft.Extensions.Configuration; using Microsoft.JSInterop; using System.Text.Json; @@ -9,22 +10,28 @@ public class AppConfigurationService : IAppConfigurationService, IAsyncDisposabl { private const string StorageKey = "AppConfig"; private readonly IJSRuntime _jsRuntime; + private readonly IConfiguration _configuration; private AppConfiguration _config = new(); public AppConfiguration Configuration => _config; - public AppConfigurationService(IJSRuntime jsRuntime) + public AppConfigurationService(IJSRuntime jsRuntime, IConfiguration configuration) { _jsRuntime = jsRuntime; + _configuration = configuration; } public async Task InitializeAsync() { var json = await _jsRuntime.InvokeAsync("localStorage.getItem", StorageKey); - _config = json != null - ? JsonSerializer.Deserialize(json) ?? new AppConfiguration() - : new AppConfiguration(); - } + _config = json != null + ? JsonSerializer.Deserialize(json) ?? new AppConfiguration() + : new AppConfiguration(); + + // Overlay deployment-time settings from appsettings.json. + // These cannot be changed at runtime — they reflect the deployment environment. + ApplyDeploymentSettings(); + } public async Task SaveConfigurationAsync() { @@ -35,9 +42,38 @@ await _jsRuntime.InvokeVoidAsync("localStorage.setItem", StorageKey, public async Task ResetToDefaultsAsync() { _config = new AppConfiguration(); + ApplyDeploymentSettings(); await SaveConfigurationAsync(); } public async ValueTask DisposeAsync() => await SaveConfigurationAsync(); + + // ── private ────────────────────────────────────────────────────────── + + /// + /// Reads deployment-time settings from appsettings.json and overlays them + /// onto the in-memory configuration. These values are not stored in localStorage + /// because they are controlled by whoever deploys the application, not the end user. + /// + private void ApplyDeploymentSettings() + { + var modeStr = _configuration["MakerPrompt:DeploymentMode"] ?? string.Empty; + _config.DeploymentMode = Enum.TryParse(modeStr, true, out var parsedMode) + ? parsedMode + : AppDeploymentMode.Standalone; + + _config.CloudApiBaseUrl = _configuration["MakerPrompt:CloudApiBaseUrl"] ?? string.Empty; + + // In CloudMakerspace mode, farm mode is always enabled and the farm name + // comes from the deployment configuration. + if (_config.DeploymentMode == AppDeploymentMode.CloudMakerspace) + { + _config.FarmModeEnabled = true; + + var configuredFarmName = _configuration["MakerPrompt:FarmName"]; + if (!string.IsNullOrWhiteSpace(configuredFarmName)) + _config.FarmName = configuredFarmName; + } + } } } diff --git a/MakerPrompt.Blazor/wwwroot/appsettings.json b/MakerPrompt.Blazor/wwwroot/appsettings.json new file mode 100644 index 0000000..de71fe5 --- /dev/null +++ b/MakerPrompt.Blazor/wwwroot/appsettings.json @@ -0,0 +1,14 @@ +{ + "MakerPrompt": { + "DeploymentMode": "Standalone", + "CloudApiBaseUrl": "", + "FarmName": "", + "Oidc": { + "Authority": "", + "ClientId": "", + "ResponseType": "code", + "DefaultScopes": [ "openid", "profile", "email" ], + "AdditionalProviderParameters": {} + } + } +} diff --git a/MakerPrompt.Shared/Pages/Fleet.razor b/MakerPrompt.Shared/Pages/Fleet.razor index 06b3ae3..533c34e 100644 --- a/MakerPrompt.Shared/Pages/Fleet.razor +++ b/MakerPrompt.Shared/Pages/Fleet.razor @@ -45,17 +45,15 @@ else {
@* ── Toolbar ── *@ -
- - +
+ @if (ConfigService.Configuration.DeploymentMode != AppDeploymentMode.CloudMakerspace) + { + + }
- - @if (!ConnectionManager.Printers.Any()) {
@@ -101,12 +99,15 @@ else } - - + @if (ConfigService.Configuration.DeploymentMode != AppDeploymentMode.CloudMakerspace) + { + + + }
}
@@ -209,8 +210,6 @@ else @code { [CascadingParameter] private PrinterConnectionModal? ConnectionModal { get; set; } - private PrusaConnectImportModal? _importModal; - private readonly Dictionary _cameras = new(); // Selection state diff --git a/MakerPrompt.Shared/Pages/Settings.razor b/MakerPrompt.Shared/Pages/Settings.razor index eed7133..516d194 100644 --- a/MakerPrompt.Shared/Pages/Settings.razor +++ b/MakerPrompt.Shared/Pages/Settings.razor @@ -14,11 +14,6 @@
-
-
-

@aboutContent

-
-
@@ -26,50 +21,66 @@ @localizer[Resources.Settings_FarmMode]
-
- - -
@localizer[Resources.Settings_FarmModeDescription]
-
- - @if (_config.FarmModeEnabled) + @if (ConfigService.Configuration.DeploymentMode == AppDeploymentMode.CloudMakerspace) { -
-
@localizer[Resources.Settings_FarmConfigurations]
-
-
- - - + -
-
- - + +
+
+
+
+ + +
+
+
+ +
-
-
- - -
+ } }
@@ -80,36 +91,52 @@
- +
@localizer[Resources.Settings_FilamentInventoryDescription]
- +
@localizer[Resources.Settings_PrintAnalyticsDescription]
+
+
+ @localizer[Resources.Settings_TelemetryAnalytics] +
+
+
+ + +
@localizer[Resources.Settings_TelemetryDescription]
+
+
+ +
+
+
+ +
+ @code { private AppConfiguration _config = new(); private static readonly string[] _hiddenPrefixes = ["MakerPrompt.PrinterConnections", "MakerPrompt.PrintProjects"]; private Guid? _selectedFarmId; private string _newFarmName = string.Empty; - private MarkupString aboutContent; protected override async Task OnInitializedAsync() { - aboutContent = new MarkupString(localizer[Resources.AboutPage_Content]); _config = new AppConfiguration { Theme = ConfigService.Configuration.Theme, @@ -126,15 +153,21 @@ await FarmService.InitializeAsync(); } - private async Task OnFarmModeChangedAsync() + private async Task SaveSettingsAsync() { - var wasEnabled = ConfigService.Configuration.FarmModeEnabled; - var enabling = _config.FarmModeEnabled; + var wasInFarmMode = ConfigService.Configuration.FarmModeEnabled; - ConfigService.Configuration.FarmModeEnabled = enabling; + ConfigService.Configuration.Theme = _config.Theme; + ConfigService.Configuration.Language = _config.Language; + ConfigService.Configuration.FarmModeEnabled = _config.FarmModeEnabled; + ConfigService.Configuration.ActiveFarmId = _config.ActiveFarmId; + ConfigService.Configuration.AnalyticsEnabled = _config.AnalyticsEnabled; + ConfigService.Configuration.EnableFilamentInventory = _config.EnableFilamentInventory; + ConfigService.Configuration.EnablePrintAnalytics = _config.EnablePrintAnalytics; ConfigService.Configuration.LastUpdated = DateTime.UtcNow; - if (wasEnabled && !enabling) + // When disabling farm mode, clear the farm-loaded printers and reset the farm name + if (wasInFarmMode && !_config.FarmModeEnabled) { ConfigService.Configuration.FarmName = string.Empty; ConfigService.Configuration.ActiveFarmId = null; @@ -142,21 +175,14 @@ } await ConfigService.SaveConfigurationAsync(); + ToastService.Notify(new ToastMessage(ToastType.Success, localizer[Resources.Settings_Toast_Saved], localizer[Resources.Settings_Toast_SavedMessage])); - if (!wasEnabled && enabling) + if (!wasInFarmMode && _config.FarmModeEnabled) Navigation.NavigateTo("/fleet", replace: true); - else if (wasEnabled && !enabling) + else if (wasInFarmMode && !_config.FarmModeEnabled) Navigation.NavigateTo("/dashboard", replace: true); } - private async Task SaveFeaturesAsync() - { - ConfigService.Configuration.EnableFilamentInventory = _config.EnableFilamentInventory; - ConfigService.Configuration.EnablePrintAnalytics = _config.EnablePrintAnalytics; - ConfigService.Configuration.LastUpdated = DateTime.UtcNow; - await ConfigService.SaveConfigurationAsync(); - } - private async Task CreateFarmAsync() { if (string.IsNullOrWhiteSpace(_newFarmName)) return; diff --git a/MakerPrompt.Shared/Utils/AppConfiguration.cs b/MakerPrompt.Shared/Utils/AppConfiguration.cs index 8c62d91..4bb2e90 100644 --- a/MakerPrompt.Shared/Utils/AppConfiguration.cs +++ b/MakerPrompt.Shared/Utils/AppConfiguration.cs @@ -12,5 +12,20 @@ public class AppConfiguration public bool EnableFilamentInventory { get; set; } = false; public bool EnablePrintAnalytics { get; set; } = false; public DateTime? LastUpdated { get; set; } + + // ── Deployment mode ───────────────────────────────────────────────── + // Sourced from appsettings.json (MakerPrompt:DeploymentMode) at startup. + // NOT persisted to localStorage — this is a deployment-time decision. + // Default: Standalone (no auth, direct printer connections). + [System.Text.Json.Serialization.JsonIgnore] + public AppDeploymentMode DeploymentMode { get; set; } = AppDeploymentMode.Standalone; + + /// + /// Base URL of the MakerPrompt Cloud API. + /// Required when DeploymentMode == CloudMakerspace. + /// Sourced from appsettings.json (MakerPrompt:CloudApiBaseUrl). + /// + [System.Text.Json.Serialization.JsonIgnore] + public string CloudApiBaseUrl { get; set; } = string.Empty; } } diff --git a/MakerPrompt.Shared/Utils/AppDeploymentMode.cs b/MakerPrompt.Shared/Utils/AppDeploymentMode.cs new file mode 100644 index 0000000..21f0b2e --- /dev/null +++ b/MakerPrompt.Shared/Utils/AppDeploymentMode.cs @@ -0,0 +1,24 @@ +namespace MakerPrompt.Shared.Utils +{ + /// + /// Controls which deployment mode the app is running in. + /// The value is read from appsettings.json (MakerPrompt:DeploymentMode) at startup + /// and cannot be changed at runtime — it is a deployment-time decision. + /// + public enum AppDeploymentMode + { + /// + /// Default single-user / local mode. + /// Direct printer connections, local fleet management, no authentication required. + /// All existing backends (Moonraker, PrusaLink, Bambu, WebSerial, etc.) are available. + /// + Standalone = 0, + + /// + /// Makerspace / cloud-hosted mode. + /// OIDC authentication is required. Fleet configuration is served from the cloud API + /// and cannot be modified by the user. Farm mode is automatically enabled. + /// + CloudMakerspace = 1 + } +} diff --git a/MakerPrompt.sln b/MakerPrompt.sln index c7b3e4c..ae37cf7 100644 --- a/MakerPrompt.sln +++ b/MakerPrompt.sln @@ -33,28 +33,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.E2E.Maui", "Mak EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B4F2D4E1-0000-0000-0000-000000000001}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Core", "src\MakerPrompt.Core\MakerPrompt.Core.csproj", "{B4F2D4E1-0001-0000-0000-000000000001}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Application", "src\MakerPrompt.Application\MakerPrompt.Application.csproj", "{B4F2D4E1-0002-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Infrastructure", "src\MakerPrompt.Infrastructure\MakerPrompt.Infrastructure.csproj", "{B4F2D4E1-0003-0000-0000-000000000001}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Cloud", "src\MakerPrompt.Cloud\MakerPrompt.Cloud.csproj", "{B4F2D4E1-0004-0000-0000-000000000001}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.EdgeAgent", "src\MakerPrompt.EdgeAgent\MakerPrompt.EdgeAgent.csproj", "{B4F2D4E1-0005-0000-0000-000000000001}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Tests.Unit", "src\MakerPrompt.Tests.Unit\MakerPrompt.Tests.Unit.csproj", "{B4F2D4E1-0006-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.UI.Components", "src\MakerPrompt.UI.Components\MakerPrompt.UI.Components.csproj", "{B4F2D4E1-0007-0000-0000-000000000001}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.UI.Blazor", "src\MakerPrompt.UI.Blazor\MakerPrompt.UI.Blazor.csproj", "{B4F2D4E1-0008-0000-0000-000000000001}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.UI.MAUI", "src\MakerPrompt.UI.MAUI\MakerPrompt.UI.MAUI.csproj", "{B4F2D4E1-0009-0000-0000-000000000001}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Infrastructure.Sqlite", "src\MakerPrompt.Infrastructure.Sqlite\MakerPrompt.Infrastructure.Sqlite.csproj", "{6D881570-CC49-44BC-84D6-15F602342899}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Infrastructure.InfluxDb", "src\MakerPrompt.Infrastructure.InfluxDb\MakerPrompt.Infrastructure.InfluxDb.csproj", "{61DEF2BC-BA90-405D-B32B-CD9EC5928E63}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -138,18 +126,6 @@ Global {A03CF971-E571-4E00-849E-48675DAB24D7}.Release|x64.Build.0 = Release|Any CPU {A03CF971-E571-4E00-849E-48675DAB24D7}.Release|x86.ActiveCfg = Release|Any CPU {A03CF971-E571-4E00-849E-48675DAB24D7}.Release|x86.Build.0 = Release|Any CPU - {B4F2D4E1-0001-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B4F2D4E1-0001-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B4F2D4E1-0001-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU - {B4F2D4E1-0001-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU - {B4F2D4E1-0001-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU - {B4F2D4E1-0001-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU - {B4F2D4E1-0001-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B4F2D4E1-0001-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU - {B4F2D4E1-0001-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU - {B4F2D4E1-0001-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU - {B4F2D4E1-0001-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU - {B4F2D4E1-0001-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU {B4F2D4E1-0002-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B4F2D4E1-0002-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU {B4F2D4E1-0002-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -174,30 +150,6 @@ Global {B4F2D4E1-0003-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU {B4F2D4E1-0003-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU {B4F2D4E1-0003-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU - {B4F2D4E1-0004-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B4F2D4E1-0004-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B4F2D4E1-0004-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU - {B4F2D4E1-0004-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU - {B4F2D4E1-0004-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU - {B4F2D4E1-0004-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU - {B4F2D4E1-0004-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B4F2D4E1-0004-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU - {B4F2D4E1-0004-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU - {B4F2D4E1-0004-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU - {B4F2D4E1-0004-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU - {B4F2D4E1-0004-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU - {B4F2D4E1-0005-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B4F2D4E1-0005-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B4F2D4E1-0005-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU - {B4F2D4E1-0005-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU - {B4F2D4E1-0005-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU - {B4F2D4E1-0005-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU - {B4F2D4E1-0005-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B4F2D4E1-0005-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU - {B4F2D4E1-0005-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU - {B4F2D4E1-0005-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU - {B4F2D4E1-0005-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU - {B4F2D4E1-0005-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU {B4F2D4E1-0006-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B4F2D4E1-0006-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU {B4F2D4E1-0006-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -222,18 +174,6 @@ Global {B4F2D4E1-0007-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU {B4F2D4E1-0007-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU {B4F2D4E1-0007-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU - {B4F2D4E1-0008-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B4F2D4E1-0008-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B4F2D4E1-0008-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU - {B4F2D4E1-0008-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU - {B4F2D4E1-0008-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU - {B4F2D4E1-0008-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU - {B4F2D4E1-0008-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B4F2D4E1-0008-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU - {B4F2D4E1-0008-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU - {B4F2D4E1-0008-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU - {B4F2D4E1-0008-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU - {B4F2D4E1-0008-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU {B4F2D4E1-0009-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B4F2D4E1-0009-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU {B4F2D4E1-0009-0000-0000-000000000001}.Debug|Any CPU.Deploy.0 = Debug|Any CPU @@ -247,47 +187,17 @@ Global {B4F2D4E1-0009-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU {B4F2D4E1-0009-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU {B4F2D4E1-0009-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU - {6D881570-CC49-44BC-84D6-15F602342899}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6D881570-CC49-44BC-84D6-15F602342899}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6D881570-CC49-44BC-84D6-15F602342899}.Debug|x64.ActiveCfg = Debug|Any CPU - {6D881570-CC49-44BC-84D6-15F602342899}.Debug|x64.Build.0 = Debug|Any CPU - {6D881570-CC49-44BC-84D6-15F602342899}.Debug|x86.ActiveCfg = Debug|Any CPU - {6D881570-CC49-44BC-84D6-15F602342899}.Debug|x86.Build.0 = Debug|Any CPU - {6D881570-CC49-44BC-84D6-15F602342899}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6D881570-CC49-44BC-84D6-15F602342899}.Release|Any CPU.Build.0 = Release|Any CPU - {6D881570-CC49-44BC-84D6-15F602342899}.Release|x64.ActiveCfg = Release|Any CPU - {6D881570-CC49-44BC-84D6-15F602342899}.Release|x64.Build.0 = Release|Any CPU - {6D881570-CC49-44BC-84D6-15F602342899}.Release|x86.ActiveCfg = Release|Any CPU - {6D881570-CC49-44BC-84D6-15F602342899}.Release|x86.Build.0 = Release|Any CPU - {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|Any CPU.Build.0 = Debug|Any CPU - {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|x64.ActiveCfg = Debug|Any CPU - {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|x64.Build.0 = Debug|Any CPU - {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|x86.ActiveCfg = Debug|Any CPU - {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|x86.Build.0 = Debug|Any CPU - {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|Any CPU.ActiveCfg = Release|Any CPU - {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|Any CPU.Build.0 = Release|Any CPU - {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|x64.ActiveCfg = Release|Any CPU - {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|x64.Build.0 = Release|Any CPU - {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|x86.ActiveCfg = Release|Any CPU - {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {8EC462FD-D22E-90A8-E5CE-7E832BA40C5D} - {B4F2D4E1-0001-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} {B4F2D4E1-0002-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} {B4F2D4E1-0003-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} - {B4F2D4E1-0004-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} - {B4F2D4E1-0005-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} {B4F2D4E1-0006-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} {B4F2D4E1-0007-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} - {B4F2D4E1-0008-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} {B4F2D4E1-0009-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} - {6D881570-CC49-44BC-84D6-15F602342899} = {B4F2D4E1-0000-0000-0000-000000000001} - {61DEF2BC-BA90-405D-B32B-CD9EC5928E63} = {B4F2D4E1-0000-0000-0000-000000000001} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {545A45A2-4075-429A-AC75-ABFBE72CC15A} From 14806dbe52db392a281fa62d2fbec0b1f9f3539a Mon Sep 17 00:00:00 2001 From: akinbender Date: Mon, 25 May 2026 18:58:33 +0200 Subject: [PATCH 09/19] feat(phase1): complete MAUI migration to src/MakerPrompt.UI.MAUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Copy Platforms/ entry points (maccatalyst, android, ios, windows) with namespace rename - Add Components/_Imports.razor with correct UI.Components/UI.MAUI namespaces - Port old SerialService platform files (Android/iOS/MacOS/Windows) using UI.Components types - Fix App.cs to use Microsoft.Maui.Controls.Application (avoids MakerPrompt.Application collision) - Fix MainPage.cs to add using Microsoft.AspNetCore.Components.WebView.Maui - Fix Routes.razor to use typeof(MauiProgram).Assembly + AdditionalAssemblies - Fix SerialCommunicationService.MacOS.cs: remove spurious .AsTask() on Task - Remove ISerialService from SerialCommunicationService (Phase 2 concern) - MauiProgram.cs wired: RegisterMakerPromptSharedServices Phase 1 status: ✅ UI.Components migrated (namespace rename, csproj, App.razor) ✅ UI.Blazor builds clean (Program.cs, OIDC, index.html, appsettings.json) ✅ UI.MAUI builds clean (maccatalyst) ✅ 147 tests pass (root-level projects untouched) --- .../MakerPrompt.UI.Blazor.csproj | 14 +- src/MakerPrompt.UI.Blazor/Program.cs | 79 +- .../Properties/launchSettings.json | 41 + .../Services/AppConfigurationService.cs | 79 + .../Services/WebSerialService.cs | 168 + .../Storage/BlazorAppLocalStorageProvider.cs | 104 + src/MakerPrompt.UI.Blazor/_Imports.razor | 17 + .../wwwroot/appsettings.json | 9 + src/MakerPrompt.UI.Blazor/wwwroot/index.html | 80 +- .../wwwroot/manifest.webmanifest | 17 + .../wwwroot/serialJsInterop.js | 195 + .../wwwroot/service-worker.js | 4 + .../wwwroot/service-worker.published.js | 55 + src/MakerPrompt.UI.Components/App.razor | 31 +- .../BrailleRAP/Models/BrailleCell.cs | 43 + .../BrailleRAP/Models/BrailleDot.cs | 19 + .../BrailleRAP/Models/BraillePageLayout.cs | 29 + .../BrailleRAP/Models/GeomPoint.cs | 7 + .../BrailleRAP/Models/MachineConfig.cs | 48 + .../BrailleRAP/Models/PageConfig.cs | 31 + .../Services/BrailleGCodeGenerator.cs | 113 + .../BrailleRAP/Services/BraillePaginator.cs | 160 + .../BrailleRAP/Services/BrailleRAPService.cs | 130 + .../BrailleRAP/Services/BrailleToGeometry.cs | 137 + .../BrailleRAP/Services/BrailleTranslator.cs | 306 ++ .../Components/AppInitializer.razor | 39 + .../Calculators/BeltStepsCalculator.razor | 80 + .../Calculators/ExtruderStepsCalculator.razor | 70 + .../Calculators/LeadScrewCalculator.razor | 88 + .../Calculators/PrintPriceCalculator.razor | 5 + .../Components/Calibration.razor | 34 + .../Components/CommandPrompt.razor | 179 + .../Components/ControlPanel.razor | 465 ++ .../Components/CultureSelector.razor | 47 + .../Components/FileExplorer.razor | 215 + .../Components/GCodeViewer.razor | 203 + .../Components/GlobalErrorBoundary.cs | 43 + .../Components/LocalizedLabel.razor | 26 + .../Components/LocalizedTitle.razor | 36 + .../Components/PidCalibration.razor | 66 + .../Components/PrintQueue.razor | 398 ++ .../Components/PrinterConnectionModal.razor | 318 ++ .../Components/ProcessError.razor | 34 + .../Components/StorageExplorer.razor | 233 + .../Components/TestCommandModal.razor | 50 + .../Components/ThemeSelector.razor | 33 + .../Components/ThermalModelCalibration.razor | 47 + .../Components/WebcamPanel.razor | 54 + .../Components/WebcamViewer.razor | 147 + .../BasePrinterConnectionService.cs | 32 + .../Infrastructure/BaseSerialService.cs | 288 ++ .../Infrastructure/ConnectionComponentBase.cs | 92 + .../IAppConfigurationService.cs | 10 + .../IPrinterCommunicationService.cs | 33 + .../Infrastructure/IPrinterProvider.cs | 11 + .../Infrastructure/ISerialService.cs | 15 + .../Infrastructure/IStorageProvider.cs | 19 + .../Infrastructure/PrinterStorageProvider.cs | 52 + .../Layout/MainLayout.razor | 139 +- .../Layout/NavConnection.razor | 268 + .../Layout/NavMenu.razor | 135 +- .../Layout/NavPrinters.razor | 496 ++ .../Layout/NavTop.razor | 21 + .../MakerPrompt.UI.Components.csproj | 26 +- .../Models/CalibrationParameters.cs | 8 + .../Models/FarmConfiguration.cs | 14 + .../Models/FilamentSpool.cs | 17 + .../Models/FileEntry.cs | 10 + .../Models/GCodeCommand.cs | 38 + .../Models/GCodeParameter.cs | 11 + .../Models/ManagedPrinterState.cs | 54 + .../Models/NotificationRecord.cs | 22 + .../Models/PrintJobUsageRecord.cs | 14 + .../Models/PrintProject.cs | 77 + .../Models/PrinterCamera.cs | 26 + .../Models/PrinterConnectionDefinition.cs | 59 + .../Models/PrinterConnectionSettings.cs | 54 + .../Models/PrinterTelemetry.cs | 156 + .../Models/RemotePrinterInfo.cs | 12 + .../Pages/About.razor | 17 + .../Pages/Analytics.razor | 132 + .../Pages/AnalyticsPage.razor | 159 - .../Pages/BrailleRAPPage.razor | 413 ++ .../Pages/Calculators.razor | 86 + .../Pages/CheatSheet.razor | 211 + .../Pages/Dashboard.razor | 49 + .../Pages/DashboardPage.razor | 192 - .../Pages/FilamentInventory.razor | 162 + .../Pages/FilamentInventoryPage.razor | 209 - .../Pages/Fleet.razor | 385 ++ .../Pages/FleetPage.razor | 125 - .../Pages/Home.razor | 5 + .../Pages/Index.razor | 14 + .../Pages/Settings.razor | 287 ++ .../Pages/SettingsPage.razor | 72 - .../Properties/Resources.Designer.cs | 2388 +++++++++ .../Properties/Resources.de-DE.resx | 795 +++ .../Properties/Resources.de.resx | 693 +++ .../Properties/Resources.es-ES.resx | 796 +++ .../Properties/Resources.es.resx | 694 +++ .../Properties/Resources.fr-FR.resx | 795 +++ .../Properties/Resources.fr.resx | 693 +++ .../Properties/Resources.he-IL.resx | 353 ++ .../Properties/Resources.it-IT.resx | 353 ++ .../Properties/Resources.pl-PL.resx | 796 +++ .../Properties/Resources.pl.resx | 694 +++ .../Properties/Resources.resx | 1005 ++++ .../Properties/Resources.tr-TR.resx | 796 +++ .../Properties/Resources.tr.resx | 694 +++ .../Properties/Resources.zh-CN.resx | 353 ++ .../Services/AnalyticsService.cs | 80 + .../Services/BambuLabApiService.cs | 585 +++ .../Services/CameraProxyService.cs | 32 + .../Services/ConnectionEncryptionService.cs | 145 + .../Services/DemoPrinterService.cs | 285 ++ .../Services/FarmConfigurationService.cs | 219 + .../Services/FilamentInventoryService.cs | 136 + .../Services/GCodeDocumentService.cs | 50 + .../Services/LocalizedTitleService.cs | 35 + .../Services/MakerPromptJsInterop.cs | 74 + .../Services/MoonrakerApiService.cs | 621 +++ .../Services/NotificationService.cs | 116 + .../Services/OctoPrintApiService.cs | 523 ++ .../Services/PrintProjectService.cs | 210 + .../Services/PrinterCameraProvider.cs | 70 + .../PrinterCommunicationServiceFactory.cs | 83 + .../Services/PrinterConnectionManager.cs | 603 +++ .../Services/PrusaConnectApiService.cs | 360 ++ .../Services/PrusaConnectPrinterService.cs | 423 ++ .../Services/PrusaConnectProvider.cs | 86 + .../Services/PrusaLinkApiService.cs | 542 ++ .../Services/ThemeService.cs | 119 + .../Utils/AppConfiguration.cs | 31 + .../Utils/AppDeploymentMode.cs | 24 + src/MakerPrompt.UI.Components/Utils/Enums.cs | 147 + .../Utils/GCodeCommands.cs | 274 + .../Utils/SerialException.cs | 6 + .../Utils/ServiceCollectionExtensions.cs | 48 + src/MakerPrompt.UI.Components/_Imports.razor | 29 +- src/MakerPrompt.UI.Components/libman.json | 37 + src/MakerPrompt.UI.Components/usings.cs | 15 + .../wwwroot/css/app.css | 402 ++ .../wwwroot/favicon.png | Bin 0 -> 32501 bytes .../wwwroot/js/makerpromptJsInterop.js | 93 + .../wwwroot/js/themeJsInterop.js | 24 + .../bootstrap-icons/font/bootstrap-icons.json | 2052 ++++++++ .../font/bootstrap-icons.min.css | 5 + .../font/fonts/bootstrap-icons.woff | Bin 0 -> 176032 bytes .../font/fonts/bootstrap-icons.woff2 | Bin 0 -> 130396 bytes .../lib/bootstrap/css/bootstrap.min.css | 6 + .../lib/bootstrap/js/bootstrap.bundle.js.map | 1 + .../lib/bootstrap/js/bootstrap.bundle.min.js | 7 + .../wwwroot/lib/bootstrap/js/bootstrap.min.js | 7 + .../lib/gcode-preview/gcode-preview.bundle.js | 4472 +++++++++++++++++ .../gcode-viewer/_bundles/gcode-viewer.min.js | 193 + .../_bundles/gcode-viewer.min.js.map | 1 + .../wwwroot/logo.svg | 57 + src/MakerPrompt.UI.MAUI/App.cs | 8 +- .../Components/Routes.razor | 30 +- .../Components/_Imports.razor | 16 + src/MakerPrompt.UI.MAUI/MainPage.cs | 3 + src/MakerPrompt.UI.MAUI/MauiProgram.cs | 84 +- .../Platforms/Android/AndroidManifest.xml | 8 + .../Platforms/Android/MainActivity.cs | 11 + .../Platforms/Android/MainApplication.cs | 16 + .../Android/Resources/values/colors.xml | 6 + .../Platforms/MacCatalyst/AppDelegate.cs | 10 + .../Platforms/MacCatalyst/Entitlements.plist | 19 + .../Platforms/MacCatalyst/Info.plist | 41 + .../Platforms/MacCatalyst/Program.cs | 16 + .../Platforms/Tizen/Main.cs | 17 + .../Platforms/Tizen/tizen-manifest.xml | 15 + .../Platforms/Windows/App.xaml | 8 + .../Platforms/Windows/App.xaml.cs | 23 + .../Platforms/Windows/Package.appxmanifest | 46 + .../Platforms/Windows/app.manifest | 15 + .../Platforms/iOS/AppDelegate.cs | 10 + .../Platforms/iOS/Info.plist | 32 + .../Platforms/iOS/Program.cs | 16 + .../iOS/Resources/PrivacyInfo.xcprivacy | 51 + .../Services/AppConfigurationService.cs | 33 + .../Services/MauiCameraProxyService.cs | 43 + ...rialCommunicationService.ISerialService.cs | 19 + .../SerialCommunicationService.MacOS.cs | 2 +- .../Services/SerialService.Android.cs | 117 + .../Services/SerialService.MacOS.cs | 190 + .../Services/SerialService.Windows.cs | 204 + .../Services/SerialService.iOS.cs | 31 + .../Storage/MauiAppLocalStorageProvider.cs | 69 + 189 files changed, 35068 insertions(+), 920 deletions(-) create mode 100644 src/MakerPrompt.UI.Blazor/Properties/launchSettings.json create mode 100644 src/MakerPrompt.UI.Blazor/Services/AppConfigurationService.cs create mode 100644 src/MakerPrompt.UI.Blazor/Services/WebSerialService.cs create mode 100644 src/MakerPrompt.UI.Blazor/Storage/BlazorAppLocalStorageProvider.cs create mode 100644 src/MakerPrompt.UI.Blazor/_Imports.razor create mode 100644 src/MakerPrompt.UI.Blazor/wwwroot/appsettings.json create mode 100644 src/MakerPrompt.UI.Blazor/wwwroot/manifest.webmanifest create mode 100644 src/MakerPrompt.UI.Blazor/wwwroot/serialJsInterop.js create mode 100644 src/MakerPrompt.UI.Blazor/wwwroot/service-worker.js create mode 100644 src/MakerPrompt.UI.Blazor/wwwroot/service-worker.published.js create mode 100644 src/MakerPrompt.UI.Components/BrailleRAP/Models/BrailleCell.cs create mode 100644 src/MakerPrompt.UI.Components/BrailleRAP/Models/BrailleDot.cs create mode 100644 src/MakerPrompt.UI.Components/BrailleRAP/Models/BraillePageLayout.cs create mode 100644 src/MakerPrompt.UI.Components/BrailleRAP/Models/GeomPoint.cs create mode 100644 src/MakerPrompt.UI.Components/BrailleRAP/Models/MachineConfig.cs create mode 100644 src/MakerPrompt.UI.Components/BrailleRAP/Models/PageConfig.cs create mode 100644 src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleGCodeGenerator.cs create mode 100644 src/MakerPrompt.UI.Components/BrailleRAP/Services/BraillePaginator.cs create mode 100644 src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleRAPService.cs create mode 100644 src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleToGeometry.cs create mode 100644 src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleTranslator.cs create mode 100644 src/MakerPrompt.UI.Components/Components/AppInitializer.razor create mode 100644 src/MakerPrompt.UI.Components/Components/Calculators/BeltStepsCalculator.razor create mode 100644 src/MakerPrompt.UI.Components/Components/Calculators/ExtruderStepsCalculator.razor create mode 100644 src/MakerPrompt.UI.Components/Components/Calculators/LeadScrewCalculator.razor create mode 100644 src/MakerPrompt.UI.Components/Components/Calculators/PrintPriceCalculator.razor create mode 100644 src/MakerPrompt.UI.Components/Components/Calibration.razor create mode 100644 src/MakerPrompt.UI.Components/Components/CommandPrompt.razor create mode 100644 src/MakerPrompt.UI.Components/Components/ControlPanel.razor create mode 100644 src/MakerPrompt.UI.Components/Components/CultureSelector.razor create mode 100644 src/MakerPrompt.UI.Components/Components/FileExplorer.razor create mode 100644 src/MakerPrompt.UI.Components/Components/GCodeViewer.razor create mode 100644 src/MakerPrompt.UI.Components/Components/GlobalErrorBoundary.cs create mode 100644 src/MakerPrompt.UI.Components/Components/LocalizedLabel.razor create mode 100644 src/MakerPrompt.UI.Components/Components/LocalizedTitle.razor create mode 100644 src/MakerPrompt.UI.Components/Components/PidCalibration.razor create mode 100644 src/MakerPrompt.UI.Components/Components/PrintQueue.razor create mode 100644 src/MakerPrompt.UI.Components/Components/PrinterConnectionModal.razor create mode 100644 src/MakerPrompt.UI.Components/Components/ProcessError.razor create mode 100644 src/MakerPrompt.UI.Components/Components/StorageExplorer.razor create mode 100644 src/MakerPrompt.UI.Components/Components/TestCommandModal.razor create mode 100644 src/MakerPrompt.UI.Components/Components/ThemeSelector.razor create mode 100644 src/MakerPrompt.UI.Components/Components/ThermalModelCalibration.razor create mode 100644 src/MakerPrompt.UI.Components/Components/WebcamPanel.razor create mode 100644 src/MakerPrompt.UI.Components/Components/WebcamViewer.razor create mode 100644 src/MakerPrompt.UI.Components/Infrastructure/BasePrinterConnectionService.cs create mode 100644 src/MakerPrompt.UI.Components/Infrastructure/BaseSerialService.cs create mode 100644 src/MakerPrompt.UI.Components/Infrastructure/ConnectionComponentBase.cs create mode 100644 src/MakerPrompt.UI.Components/Infrastructure/IAppConfigurationService.cs create mode 100644 src/MakerPrompt.UI.Components/Infrastructure/IPrinterCommunicationService.cs create mode 100644 src/MakerPrompt.UI.Components/Infrastructure/IPrinterProvider.cs create mode 100644 src/MakerPrompt.UI.Components/Infrastructure/ISerialService.cs create mode 100644 src/MakerPrompt.UI.Components/Infrastructure/IStorageProvider.cs create mode 100644 src/MakerPrompt.UI.Components/Infrastructure/PrinterStorageProvider.cs create mode 100644 src/MakerPrompt.UI.Components/Layout/NavConnection.razor create mode 100644 src/MakerPrompt.UI.Components/Layout/NavPrinters.razor create mode 100644 src/MakerPrompt.UI.Components/Layout/NavTop.razor create mode 100644 src/MakerPrompt.UI.Components/Models/CalibrationParameters.cs create mode 100644 src/MakerPrompt.UI.Components/Models/FarmConfiguration.cs create mode 100644 src/MakerPrompt.UI.Components/Models/FilamentSpool.cs create mode 100644 src/MakerPrompt.UI.Components/Models/FileEntry.cs create mode 100644 src/MakerPrompt.UI.Components/Models/GCodeCommand.cs create mode 100644 src/MakerPrompt.UI.Components/Models/GCodeParameter.cs create mode 100644 src/MakerPrompt.UI.Components/Models/ManagedPrinterState.cs create mode 100644 src/MakerPrompt.UI.Components/Models/NotificationRecord.cs create mode 100644 src/MakerPrompt.UI.Components/Models/PrintJobUsageRecord.cs create mode 100644 src/MakerPrompt.UI.Components/Models/PrintProject.cs create mode 100644 src/MakerPrompt.UI.Components/Models/PrinterCamera.cs create mode 100644 src/MakerPrompt.UI.Components/Models/PrinterConnectionDefinition.cs create mode 100644 src/MakerPrompt.UI.Components/Models/PrinterConnectionSettings.cs create mode 100644 src/MakerPrompt.UI.Components/Models/PrinterTelemetry.cs create mode 100644 src/MakerPrompt.UI.Components/Models/RemotePrinterInfo.cs create mode 100644 src/MakerPrompt.UI.Components/Pages/About.razor create mode 100644 src/MakerPrompt.UI.Components/Pages/Analytics.razor delete mode 100644 src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor create mode 100644 src/MakerPrompt.UI.Components/Pages/BrailleRAPPage.razor create mode 100644 src/MakerPrompt.UI.Components/Pages/Calculators.razor create mode 100644 src/MakerPrompt.UI.Components/Pages/CheatSheet.razor create mode 100644 src/MakerPrompt.UI.Components/Pages/Dashboard.razor delete mode 100644 src/MakerPrompt.UI.Components/Pages/DashboardPage.razor create mode 100644 src/MakerPrompt.UI.Components/Pages/FilamentInventory.razor delete mode 100644 src/MakerPrompt.UI.Components/Pages/FilamentInventoryPage.razor create mode 100644 src/MakerPrompt.UI.Components/Pages/Fleet.razor delete mode 100644 src/MakerPrompt.UI.Components/Pages/FleetPage.razor create mode 100644 src/MakerPrompt.UI.Components/Pages/Home.razor create mode 100644 src/MakerPrompt.UI.Components/Pages/Index.razor create mode 100644 src/MakerPrompt.UI.Components/Pages/Settings.razor delete mode 100644 src/MakerPrompt.UI.Components/Pages/SettingsPage.razor create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.Designer.cs create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.de-DE.resx create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.de.resx create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.es-ES.resx create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.es.resx create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.fr-FR.resx create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.fr.resx create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.he-IL.resx create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.it-IT.resx create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.pl-PL.resx create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.pl.resx create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.resx create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.tr-TR.resx create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.tr.resx create mode 100644 src/MakerPrompt.UI.Components/Properties/Resources.zh-CN.resx create mode 100644 src/MakerPrompt.UI.Components/Services/AnalyticsService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/BambuLabApiService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/CameraProxyService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/ConnectionEncryptionService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/DemoPrinterService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/FarmConfigurationService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/FilamentInventoryService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/GCodeDocumentService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/LocalizedTitleService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/MakerPromptJsInterop.cs create mode 100644 src/MakerPrompt.UI.Components/Services/MoonrakerApiService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/NotificationService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/OctoPrintApiService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/PrintProjectService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/PrinterCameraProvider.cs create mode 100644 src/MakerPrompt.UI.Components/Services/PrinterCommunicationServiceFactory.cs create mode 100644 src/MakerPrompt.UI.Components/Services/PrinterConnectionManager.cs create mode 100644 src/MakerPrompt.UI.Components/Services/PrusaConnectApiService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/PrusaConnectPrinterService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/PrusaConnectProvider.cs create mode 100644 src/MakerPrompt.UI.Components/Services/PrusaLinkApiService.cs create mode 100644 src/MakerPrompt.UI.Components/Services/ThemeService.cs create mode 100644 src/MakerPrompt.UI.Components/Utils/AppConfiguration.cs create mode 100644 src/MakerPrompt.UI.Components/Utils/AppDeploymentMode.cs create mode 100644 src/MakerPrompt.UI.Components/Utils/Enums.cs create mode 100644 src/MakerPrompt.UI.Components/Utils/GCodeCommands.cs create mode 100644 src/MakerPrompt.UI.Components/Utils/SerialException.cs create mode 100644 src/MakerPrompt.UI.Components/Utils/ServiceCollectionExtensions.cs create mode 100644 src/MakerPrompt.UI.Components/libman.json create mode 100644 src/MakerPrompt.UI.Components/usings.cs create mode 100644 src/MakerPrompt.UI.Components/wwwroot/css/app.css create mode 100644 src/MakerPrompt.UI.Components/wwwroot/favicon.png create mode 100644 src/MakerPrompt.UI.Components/wwwroot/js/makerpromptJsInterop.js create mode 100644 src/MakerPrompt.UI.Components/wwwroot/js/themeJsInterop.js create mode 100644 src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap-icons/font/bootstrap-icons.json create mode 100644 src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap-icons/font/bootstrap-icons.min.css create mode 100644 src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap-icons/font/fonts/bootstrap-icons.woff create mode 100644 src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap-icons/font/fonts/bootstrap-icons.woff2 create mode 100644 src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap/css/bootstrap.min.css create mode 100644 src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap/js/bootstrap.bundle.js.map create mode 100644 src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js create mode 100644 src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap/js/bootstrap.min.js create mode 100644 src/MakerPrompt.UI.Components/wwwroot/lib/gcode-preview/gcode-preview.bundle.js create mode 100644 src/MakerPrompt.UI.Components/wwwroot/lib/gcode-viewer/_bundles/gcode-viewer.min.js create mode 100644 src/MakerPrompt.UI.Components/wwwroot/lib/gcode-viewer/_bundles/gcode-viewer.min.js.map create mode 100644 src/MakerPrompt.UI.Components/wwwroot/logo.svg create mode 100644 src/MakerPrompt.UI.MAUI/Components/_Imports.razor create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/Android/AndroidManifest.xml create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/Android/MainActivity.cs create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/Android/MainApplication.cs create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/Android/Resources/values/colors.xml create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/MacCatalyst/AppDelegate.cs create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/MacCatalyst/Entitlements.plist create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/MacCatalyst/Info.plist create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/MacCatalyst/Program.cs create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/Tizen/Main.cs create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/Tizen/tizen-manifest.xml create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/Windows/App.xaml create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/Windows/App.xaml.cs create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/Windows/Package.appxmanifest create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/Windows/app.manifest create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/iOS/AppDelegate.cs create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/iOS/Info.plist create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/iOS/Program.cs create mode 100644 src/MakerPrompt.UI.MAUI/Platforms/iOS/Resources/PrivacyInfo.xcprivacy create mode 100644 src/MakerPrompt.UI.MAUI/Services/AppConfigurationService.cs create mode 100644 src/MakerPrompt.UI.MAUI/Services/MauiCameraProxyService.cs create mode 100644 src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.ISerialService.cs create mode 100644 src/MakerPrompt.UI.MAUI/Services/SerialService.Android.cs create mode 100644 src/MakerPrompt.UI.MAUI/Services/SerialService.MacOS.cs create mode 100644 src/MakerPrompt.UI.MAUI/Services/SerialService.Windows.cs create mode 100644 src/MakerPrompt.UI.MAUI/Services/SerialService.iOS.cs create mode 100644 src/MakerPrompt.UI.MAUI/Storage/MauiAppLocalStorageProvider.cs diff --git a/src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj b/src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj index f55995b..155bc98 100644 --- a/src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj +++ b/src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj @@ -4,20 +4,28 @@ net10.0 enable enable + service-worker-assets.js true + 2 + 0.4.0 + $(Version) + $(Version) + $(Version) + - + + + + - - diff --git a/src/MakerPrompt.UI.Blazor/Program.cs b/src/MakerPrompt.UI.Blazor/Program.cs index ee322b0..d4653d6 100644 --- a/src/MakerPrompt.UI.Blazor/Program.cs +++ b/src/MakerPrompt.UI.Blazor/Program.cs @@ -1,34 +1,59 @@ -using MakerPrompt.Application.Services; -using MakerPrompt.Core.Abstractions; -using MakerPrompt.Infrastructure.Analytics; -using MakerPrompt.Infrastructure.Farm; -using MakerPrompt.Infrastructure.Inventory; -using MakerPrompt.Infrastructure.Projects; -using MakerPrompt.Infrastructure.Telemetry; +using System.Globalization; +using MakerPrompt.UI.Blazor.Services; +using MakerPrompt.UI.Blazor.Storage; +using MakerPrompt.UI.Components.Infrastructure; +using MakerPrompt.UI.Components.Services; +using MakerPrompt.UI.Components.Utils; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.JSInterop; var builder = WebAssemblyHostBuilder.CreateDefault(args); - builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); -// ── Infrastructure stores (in-memory; swap for SQLite/cloud in production) ── -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// ── Application services ───────────────────────────────────────────────────── -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// ── Logging ────────────────────────────────────────────────────────────────── -builder.Logging.SetMinimumLevel(LogLevel.Warning); - -await builder.Build().RunAsync(); +builder.Services.RegisterMakerPromptSharedServices(); +builder.Services.AddScoped(); +// WASM: AES-GCM not supported in browser — use Base64 encoding fallback +builder.Services.AddSingleton(); + +// ── Deployment-mode-specific services ──────────────────────────────────────── +// OIDC is enabled when an Authority URL is present in the "Local" config section. +// This drives IDeploymentModeService.IsAuthEnabled and locks the farm in +// cloud/makerspace mode. Config lives in wwwroot/appsettings.json under "Local". +var oidcAuthority = builder.Configuration["Local:Authority"] ?? string.Empty; +if (!string.IsNullOrWhiteSpace(oidcAuthority)) +{ + // Authorization Code + PKCE (never implicit grant). + // All options (ClientId, ResponseType, DefaultScopes, etc.) are bound from + // the "Local" section, which follows the Microsoft.AspNetCore.Components + // .WebAssembly.Authentication convention. + builder.Services.AddOidcAuthentication(options => + { + builder.Configuration.Bind("Local", options.ProviderOptions); + // Enforce PKCE / code flow explicitly. + options.ProviderOptions.ResponseType = "code"; + }); +} + +var host = builder.Build(); +const string defaultCulture = "en-US"; + +// Initialize configuration from localStorage before the app renders so that +// components see the persisted values on the very first render. +var configService = host.Services.GetRequiredService(); +await configService.InitializeAsync(); + +var js = host.Services.GetRequiredService(); +var result = await js.InvokeAsync("blazorCulture.get"); +var culture = CultureInfo.GetCultureInfo(result ?? defaultCulture); + +if (result == null) +{ + await js.InvokeVoidAsync("blazorCulture.set", defaultCulture); +} + +CultureInfo.DefaultThreadCurrentCulture = culture; +CultureInfo.DefaultThreadCurrentUICulture = culture; + +await host.RunAsync(); diff --git a/src/MakerPrompt.UI.Blazor/Properties/launchSettings.json b/src/MakerPrompt.UI.Blazor/Properties/launchSettings.json new file mode 100644 index 0000000..7d47cd9 --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:37469", + "sslPort": 44313 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5059", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7257;http://localhost:5059", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/MakerPrompt.UI.Blazor/Services/AppConfigurationService.cs b/src/MakerPrompt.UI.Blazor/Services/AppConfigurationService.cs new file mode 100644 index 0000000..1615124 --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/Services/AppConfigurationService.cs @@ -0,0 +1,79 @@ +using MakerPrompt.UI.Components.Infrastructure; +using MakerPrompt.UI.Components.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.JSInterop; +using System.Text.Json; + +namespace MakerPrompt.UI.Blazor.Services +{ + public class AppConfigurationService : IAppConfigurationService, IAsyncDisposable + { + private const string StorageKey = "AppConfig"; + private readonly IJSRuntime _jsRuntime; + private readonly IConfiguration _configuration; + private AppConfiguration _config = new(); + + public AppConfiguration Configuration => _config; + + public AppConfigurationService(IJSRuntime jsRuntime, IConfiguration configuration) + { + _jsRuntime = jsRuntime; + _configuration = configuration; + } + + public async Task InitializeAsync() + { + var json = await _jsRuntime.InvokeAsync("localStorage.getItem", StorageKey); + _config = json != null + ? JsonSerializer.Deserialize(json) ?? new AppConfiguration() + : new AppConfiguration(); + + // Overlay deployment-time settings from appsettings.json. + // These cannot be changed at runtime — they reflect the deployment environment. + ApplyDeploymentSettings(); + } + + public async Task SaveConfigurationAsync() + { + await _jsRuntime.InvokeVoidAsync("localStorage.setItem", StorageKey, + JsonSerializer.Serialize(_config)); + } + + public async Task ResetToDefaultsAsync() + { + _config = new AppConfiguration(); + ApplyDeploymentSettings(); + await SaveConfigurationAsync(); + } + + public async ValueTask DisposeAsync() => await SaveConfigurationAsync(); + + // ── private ────────────────────────────────────────────────────────── + + /// + /// Reads deployment-time settings from appsettings.json and overlays them + /// onto the in-memory configuration. These values are not stored in localStorage + /// because they are controlled by whoever deploys the application, not the end user. + /// + private void ApplyDeploymentSettings() + { + var modeStr = _configuration["MakerPrompt:DeploymentMode"] ?? string.Empty; + _config.DeploymentMode = Enum.TryParse(modeStr, true, out var parsedMode) + ? parsedMode + : AppDeploymentMode.Standalone; + + _config.CloudApiBaseUrl = _configuration["MakerPrompt:CloudApiBaseUrl"] ?? string.Empty; + + // In CloudMakerspace mode, farm mode is always enabled and the farm name + // comes from the deployment configuration. + if (_config.DeploymentMode == AppDeploymentMode.CloudMakerspace) + { + _config.FarmModeEnabled = true; + + var configuredFarmName = _configuration["MakerPrompt:FarmName"]; + if (!string.IsNullOrWhiteSpace(configuredFarmName)) + _config.FarmName = configuredFarmName; + } + } + } +} diff --git a/src/MakerPrompt.UI.Blazor/Services/WebSerialService.cs b/src/MakerPrompt.UI.Blazor/Services/WebSerialService.cs new file mode 100644 index 0000000..dcd0c8e --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/Services/WebSerialService.cs @@ -0,0 +1,168 @@ +using MakerPrompt.UI.Components.Infrastructure; +using MakerPrompt.UI.Components.Models; +using MakerPrompt.UI.Components.Services; +using Microsoft.JSInterop; + +namespace MakerPrompt.UI.Blazor.Services +{ + public class WebSerialService : BaseSerialService, ISerialService, IAsyncDisposable + { + private readonly Lazy> _moduleTask; + private DotNetObjectReference? _dotNetRef; + private IJSObjectReference? _portReference; + + public bool IsSupported { get; private set; } = false; + + public WebSerialService(IJSRuntime jsRuntime) + { + _moduleTask = new(() => jsRuntime.InvokeAsync( + "import", "./serialJsInterop.js").AsTask()); + _dotNetRef = DotNetObjectReference.Create(this); + } + + public async Task CheckSupportedAsync() + { + var module = await _moduleTask.Value; + IsSupported = await module.InvokeAsync("checkSupported"); + return IsSupported; + } + + public async Task RequestPortAsync() + { + var module = await _moduleTask.Value; + await module.InvokeVoidAsync("requestPort"); + } + + public async Task> GetAvailablePortsAsync() + { + await RequestPortAsync(); + var module = await _moduleTask.Value; + var ports = await module.InvokeAsync>("getGrantedPorts"); + return ports.Select(p => $"{p.Name} ({p.Manufacturer})"); + } + + public async Task DisconnectAsync() + { + // Stop telemetry timer and detach handler first + updateTimer.Stop(); + updateTimer.Elapsed -= OnUpdateTimerElapsed; + + if (_portReference != null) + { + var module = await _moduleTask.Value; + await module.InvokeVoidAsync("closePort", _portReference); + _portReference = null; + } + + IsConnected = false; + RaiseConnectionChanged(); + } + + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + { + if (connectionSettings.ConnectionType != ConnectionType || connectionSettings.Serial == null) throw new ArgumentException(); + await OpenPortAsync(connectionSettings.Serial.PortName, connectionSettings.Serial.BaudRate); + return IsConnected; + } + + public async Task OpenPortAsync(string port, int baudRate, int dataBits = 8, + int stopBits = 1, string parity = "none", string flowControl = "none") + { + var module = await _moduleTask.Value; + var options = new { baudRate, dataBits, stopBits, parity, flowControl }; + _portReference = await module.InvokeAsync("openPort", options, _dotNetRef); + IsConnected = true; + ConnectionName = port; + + // Ensure only one subscription to the telemetry timer + updateTimer.Stop(); + updateTimer.Elapsed -= OnUpdateTimerElapsed; + updateTimer.Elapsed += OnUpdateTimerElapsed; + updateTimer.Start(); + + RaiseConnectionChanged(); + } + + private async void OnUpdateTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e) + { + // Guard against callbacks after disconnect + if (!IsConnected || _portReference == null) + { + return; + } + + try + { + await GetPrinterTelemetryAsync(); + } + catch + { + // Swallow background telemetry errors + } + } + + public override async Task WriteDataAsync(string data) + { + if (_portReference == null) throw new InvalidOperationException("Port not open"); + var module = await _moduleTask.Value; + await module.InvokeVoidAsync("writeData", _portReference, data); + } + + public Task StartPrint(GCodeDoc gcodeDoc) + { + if (!IsConnected || string.IsNullOrEmpty(gcodeDoc.Content)) + { + return Task.CompletedTask; + } + + return Task.Run(async () => + { + await foreach (var command in gcodeDoc.EnumerateCommandsAsync()) + { + if (!IsConnected) + { + break; + } + + await WriteDataAsync(command); + } + }); + } + + [JSInvokable] + public void OnDataReceived(string data) + { + ProcessReceivedData(data); + } + + [JSInvokable] + public void OnConnectionChanged(bool isConnected) + { + IsConnected = isConnected; + if (!IsConnected) + { + updateTimer.Stop(); + } + RaiseConnectionChanged(); + } + + public override async ValueTask DisposeAsync() + { + await DisconnectAsync(); + + if (_moduleTask.IsValueCreated) + { + var module = await _moduleTask.Value; + await module.DisposeAsync(); + } + + _dotNetRef?.Dispose(); + } + } + + public class SerialPortInfo + { + public string Name { get; set; } = string.Empty; + public string Manufacturer { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Blazor/Storage/BlazorAppLocalStorageProvider.cs b/src/MakerPrompt.UI.Blazor/Storage/BlazorAppLocalStorageProvider.cs new file mode 100644 index 0000000..930e9e5 --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/Storage/BlazorAppLocalStorageProvider.cs @@ -0,0 +1,104 @@ +using MakerPrompt.UI.Components.Models; +using MakerPrompt.UI.Components.Infrastructure; +using Microsoft.JSInterop; +using System.Text.Json; + +namespace MakerPrompt.UI.Blazor.Storage +{ + public sealed class BlazorAppLocalStorageProvider : IAppLocalStorageProvider + { + private const string ManifestKey = "MakerPrompt.LocalFiles.Manifest"; + private const string FilePrefix = "MakerPrompt.LocalFiles.File."; + private readonly IJSRuntime js; + + public BlazorAppLocalStorageProvider(IJSRuntime jsRuntime) + { + js = jsRuntime; + } + + public string DisplayName => "App storage"; + public string Key => "app"; + public string RootPath => "localStorage://MakerPrompt/LocalFiles"; + + public async Task> ListFilesAsync(CancellationToken cancellationToken = default) + { + var json = await js.InvokeAsync("localStorage.getItem", ManifestKey); + var entries = string.IsNullOrEmpty(json) ? [] : (JsonSerializer.Deserialize>(json) ?? []); + return entries.Select(e => new FileEntry + { + FullPath = e.Name, + Size = e.Size, + ModifiedDate = e.Modified, + IsAvailable = true + }).ToList(); + } + + public async Task OpenReadAsync(string fullPath, CancellationToken cancellationToken = default) + { + var key = FilePrefix + fullPath; + var base64 = await js.InvokeAsync("localStorage.getItem", key); + if (string.IsNullOrEmpty(base64)) return null; + try + { + var bytes = Convert.FromBase64String(base64); + return new MemoryStream(bytes); + } + catch + { + return null; + } + } + + public async Task SaveFileAsync(string fullPath, Stream content, CancellationToken cancellationToken = default) + { + using var ms = new MemoryStream(); + await content.CopyToAsync(ms, cancellationToken); + var bytes = ms.ToArray(); + var base64 = Convert.ToBase64String(bytes); + var key = FilePrefix + fullPath; + await js.InvokeVoidAsync("localStorage.setItem", key, base64); + + var manifest = await GetManifestAsync(); + var existing = manifest.FirstOrDefault(m => m.Name == fullPath); + var now = DateTime.Now; + if (existing != null) + { + existing.Size = bytes.Length; + existing.Modified = now; + } + else + { + manifest.Add(new ManifestEntry { Name = fullPath, Size = bytes.Length, Modified = now }); + } + await SaveManifestAsync(manifest); + } + + public async Task DeleteFileAsync(string fullPath, CancellationToken cancellationToken = default) + { + var key = FilePrefix + fullPath; + await js.InvokeVoidAsync("localStorage.removeItem", key); + var manifest = await GetManifestAsync(); + manifest = manifest.Where(m => m.Name != fullPath).ToList(); + await SaveManifestAsync(manifest); + } + + private async Task> GetManifestAsync() + { + var json = await js.InvokeAsync("localStorage.getItem", ManifestKey); + return string.IsNullOrEmpty(json) ? [] : (JsonSerializer.Deserialize>(json) ?? []); + } + + private Task SaveManifestAsync(List entries) + { + var json = JsonSerializer.Serialize(entries); + return js.InvokeVoidAsync("localStorage.setItem", ManifestKey, json).AsTask(); + } + + private sealed class ManifestEntry + { + public string Name { get; set; } = string.Empty; + public int Size { get; set; } + public DateTime? Modified { get; set; } + } + } +} diff --git a/src/MakerPrompt.UI.Blazor/_Imports.razor b/src/MakerPrompt.UI.Blazor/_Imports.razor new file mode 100644 index 0000000..5d61dc9 --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/_Imports.razor @@ -0,0 +1,17 @@ +@using System.Text.Json +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using MakerPrompt.UI.Blazor +@using MakerPrompt.UI.Components +@using MakerPrompt.UI.Components.Components +@using MakerPrompt.UI.Components.Layout +@using MakerPrompt.UI.Components.Pages +@using MakerPrompt.UI.Components.Infrastructure +@using MakerPrompt.UI.Components.Utils +@using BlazorBootstrap diff --git a/src/MakerPrompt.UI.Blazor/wwwroot/appsettings.json b/src/MakerPrompt.UI.Blazor/wwwroot/appsettings.json new file mode 100644 index 0000000..d037a99 --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/wwwroot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Local": { + "Authority": "", + "ClientId": "", + "ResponseType": "code", + "ValidateAuthority": true, + "DefaultScopes": [ "openid", "profile", "email" ] + } +} diff --git a/src/MakerPrompt.UI.Blazor/wwwroot/index.html b/src/MakerPrompt.UI.Blazor/wwwroot/index.html index 1fc2c3a..96ce954 100644 --- a/src/MakerPrompt.UI.Blazor/wwwroot/index.html +++ b/src/MakerPrompt.UI.Blazor/wwwroot/index.html @@ -5,27 +5,83 @@ MakerPrompt + + + + + - - - + + + + + + + + +
- - - - + + + -
-
- Loading… -
-
+
+
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + + + diff --git a/src/MakerPrompt.UI.Blazor/wwwroot/manifest.webmanifest b/src/MakerPrompt.UI.Blazor/wwwroot/manifest.webmanifest new file mode 100644 index 0000000..a71efeb --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/wwwroot/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "MakerPrompt.Blazor", + "short_name": "MakerPrompt.Blazor", + "id": "./", + "start_url": "./", + "display": "standalone", + "background_color": "#151E3F", + "theme_color": "#85D4FF", + "prefer_related_applications": false, + "icons": [ + { + "src": "_content/MakerPrompt.Shared/favicon.png", + "type": "image/png", + "sizes": "512x512" + } + ] +} diff --git a/src/MakerPrompt.UI.Blazor/wwwroot/serialJsInterop.js b/src/MakerPrompt.UI.Blazor/wwwroot/serialJsInterop.js new file mode 100644 index 0000000..3d0f4dc --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/wwwroot/serialJsInterop.js @@ -0,0 +1,195 @@ +// Individual exported functions following Microsoft's pattern +export async function checkSupported() { + return navigator.serial != undefined; +} + +export async function requestPort() { + try { + const port = await navigator.serial.requestPort(); + console.debug('[WebSerial] Port requested:', port); + return { + name: port.name || 'Unknown', + manufacturer: getManufacturerInfo(port) + }; + } catch (error) { + // User can cancel the chooser dialog; treat that as a benign case. + if (error && error.name === 'NotFoundError') { + console.warn('Serial port selection canceled by user.'); + return null; + } + + console.error('Port request failed:', error); + throw error; + } +} + +export async function getGrantedPorts() { + const ports = await navigator.serial.getPorts(); + console.debug('[WebSerial] Granted ports:', ports); + return ports.map(port => ({ + name: port.name || 'Unknown', + manufacturer: getManufacturerInfo(port) + })); +} + +export async function openPort(options, dotNetRef) { + let port; + try { + console.debug('[WebSerial] Opening port with options:', options); + port = await navigator.serial.requestPort(); + + // If the port is already open, avoid reopening and just start reading. + if (!port.readable && !port.writable) { + await port.open({ + baudRate: options.baudRate, + dataBits: options.dataBits, + stopBits: options.stopBits, + parity: options.parity, + flowControl: options.flowControl + }); + console.debug('[WebSerial] Port opened.'); + } else { + console.debug('[WebSerial] Port was already open, reusing existing streams.'); + } + + startReading(port, dotNetRef); + return port; + } catch (error) { + // User canceled the dialog - do not treat this as a fatal error. + if (error && error.name === 'NotFoundError') { + console.warn('Serial port open canceled by user.'); + return null; + } + + console.error('Error opening port:', error); + + // Only attempt a close if the port exists and is actually open. + if (port && (port.readable || port.writable)) { + await safeClosePort(port); + } + + throw error; + } +} + +export async function writeData(port, data) { + let writer; + try { + if (!port || !port.writable) { + console.warn('[WebSerial] writeData called with no writable port.'); + return; + } + + writer = port.writable.getWriter(); + const encoder = new TextEncoder(); + const text = data.endsWith('\n') ? data : data + '\n'; + console.debug('[WebSerial] Writing data:', text); + await writer.write(encoder.encode(text)); + } catch (error) { + console.error('[WebSerial] Error while writing data:', error); + } finally { + try { + writer?.releaseLock(); + } catch { + // ignore release errors + } + } +} + +export async function closePort(port) { + await safeClosePort(port); +} + +// Track active readers per port to avoid locking the same stream multiple times +const activeReaders = new WeakMap(); + +// Helper functions +function getManufacturerInfo(port) { + const info = port.getInfo(); + if (info.usbVendorId && info.usbProductId) { + return `USB ${info.usbVendorId}:${info.usbProductId}`; + } + return 'Generic Serial'; +} + +async function startReading(port, dotNetRef) { + if (!port.readable) { + console.warn('[WebSerial] startReading called but port.readable is null.'); + return; + } + + // If we already have a reader for this port, do not create another + if (activeReaders.has(port)) { + console.debug('[WebSerial] Reader already active for port, skipping startReading.'); + return; + } + + console.debug('[WebSerial] Starting reader for port.'); + const reader = port.readable.getReader(); + activeReaders.set(port, reader); + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + console.debug('[WebSerial] Reader loop done.'); + break; + } + + if (value) { + const text = new TextDecoder().decode(value); + console.debug('[WebSerial] Data received:', text); + dotNetRef.invokeMethodAsync('OnDataReceived', text); + } + } + } catch (error) { + // Framing errors and similar serial exceptions are expected on some devices. + console.error('[WebSerial] Read error:', error); + try { + await dotNetRef.invokeMethodAsync('OnConnectionChanged', false); + } catch { + // Swallow interop errors so we do not crash the app on shutdown. + } + } finally { + try { + reader.releaseLock(); + } catch { + // ignore release errors + } + activeReaders.delete(port); + console.debug('[WebSerial] Reader released and removed for port.'); + } +} + +async function safeClosePort(port) { + try { + console.debug('[WebSerial] Closing port.'); + const reader = activeReaders.get(port); + if (reader) { + try { + await reader.cancel(); + } catch { + // ignore cancel errors + } + try { + reader.releaseLock(); + } catch { + // ignore release errors + } + activeReaders.delete(port); + console.debug('[WebSerial] Active reader cancelled and released during close.'); + } + + // If there is no readable or writable, the port is already closed. + if (!port.readable && !port.writable) { + console.debug('[WebSerial] Port already closed.'); + return; + } + + await port.close(); + console.debug('[WebSerial] Port closed.'); + } catch (error) { + // Some browsers throw if the stream is locked when closing; just log and move on. + console.error('[WebSerial] Error closing port:', error); + } +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Blazor/wwwroot/service-worker.js b/src/MakerPrompt.UI.Blazor/wwwroot/service-worker.js new file mode 100644 index 0000000..c6d0085 --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/wwwroot/service-worker.js @@ -0,0 +1,4 @@ +// In development, always fetch from the network and do not enable offline support. +// This is because caching would make development more difficult (changes would not +// be reflected on the first load after each change). +self.addEventListener('fetch', () => { }); diff --git a/src/MakerPrompt.UI.Blazor/wwwroot/service-worker.published.js b/src/MakerPrompt.UI.Blazor/wwwroot/service-worker.published.js new file mode 100644 index 0000000..003e3e7 --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/wwwroot/service-worker.published.js @@ -0,0 +1,55 @@ +// Caution! Be sure you understand the caveats before publishing an application with +// offline support. See https://aka.ms/blazor-offline-considerations + +self.importScripts('./service-worker-assets.js'); +self.addEventListener('install', event => event.waitUntil(onInstall(event))); +self.addEventListener('activate', event => event.waitUntil(onActivate(event))); +self.addEventListener('fetch', event => event.respondWith(onFetch(event))); + +const cacheNamePrefix = 'offline-cache-'; +const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; +const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ]; +const offlineAssetsExclude = [ /^service-worker\.js$/ ]; + +// Replace with your base path if you are hosting on a subfolder. Ensure there is a trailing '/'. +const base = "/"; +const baseUrl = new URL(base, self.origin); +const manifestUrlList = self.assetsManifest.assets.map(asset => new URL(asset.url, baseUrl).href); + +async function onInstall(event) { + console.info('Service worker: Install'); + + // Fetch and cache all matching items from the assets manifest + const assetsRequests = self.assetsManifest.assets + .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url))) + .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url))) + .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' })); + await caches.open(cacheName).then(cache => cache.addAll(assetsRequests)); +} + +async function onActivate(event) { + console.info('Service worker: Activate'); + + // Delete unused caches + const cacheKeys = await caches.keys(); + await Promise.all(cacheKeys + .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) + .map(key => caches.delete(key))); +} + +async function onFetch(event) { + let cachedResponse = null; + if (event.request.method === 'GET') { + // For all navigation requests, try to serve index.html from cache, + // unless that request is for an offline resource. + // If you need some URLs to be server-rendered, edit the following check to exclude those URLs + const shouldServeIndexHtml = event.request.mode === 'navigate' + && !manifestUrlList.some(url => url === event.request.url); + + const request = shouldServeIndexHtml ? 'index.html' : event.request; + const cache = await caches.open(cacheName); + cachedResponse = await cache.match(request); + } + + return cachedResponse || fetch(event.request); +} diff --git a/src/MakerPrompt.UI.Components/App.razor b/src/MakerPrompt.UI.Components/App.razor index 71ea9e3..e0938f6 100644 --- a/src/MakerPrompt.UI.Components/App.razor +++ b/src/MakerPrompt.UI.Components/App.razor @@ -1,13 +1,18 @@ - - - - - - - Not found - -

Sorry, there's nothing at this address.

-
-
-
+ + + + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
+
+
+
\ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/BrailleRAP/Models/BrailleCell.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Models/BrailleCell.cs new file mode 100644 index 0000000..67b60a2 --- /dev/null +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Models/BrailleCell.cs @@ -0,0 +1,43 @@ +namespace MakerPrompt.UI.Components.BrailleRAP.Models +{ + /// + /// Represents a single Braille cell using Unicode Braille Patterns (U+2800 to U+28FF). + /// + public class BrailleCell + { + /// + /// The Unicode character representing this Braille cell. + /// + public char Character { get; set; } + + /// + /// Creates a Braille cell from a Unicode Braille character. + /// + public BrailleCell(char character) + { + Character = character; + } + + /// + /// Gets the value (0-255) representing the dot pattern. + /// + public int GetValue() + { + return Character - 0x2800; + } + + /// + /// Checks if a specific dot is raised in this cell. + /// + public bool HasDot(BrailleDotPosition position) + { + int value = GetValue(); + return (value & (1 << (int)position)) != 0; + } + + public override string ToString() + { + return Character.ToString(); + } + } +} diff --git a/src/MakerPrompt.UI.Components/BrailleRAP/Models/BrailleDot.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Models/BrailleDot.cs new file mode 100644 index 0000000..d401e25 --- /dev/null +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Models/BrailleDot.cs @@ -0,0 +1,19 @@ +namespace MakerPrompt.UI.Components.BrailleRAP.Models +{ + /// + /// Represents the position of a dot in a Braille cell (0-7 for 8-dot Braille). + /// Standard 6-dot positions: 0-5 + /// Extended 8-dot positions: 0-7 + /// + public enum BrailleDotPosition + { + Dot1 = 0, // Top-left + Dot2 = 1, // Middle-left + Dot3 = 2, // Bottom-left + Dot4 = 3, // Top-right + Dot5 = 4, // Middle-right + Dot6 = 5, // Bottom-right + Dot7 = 6, // Extended bottom-left + Dot8 = 7 // Extended bottom-right + } +} diff --git a/src/MakerPrompt.UI.Components/BrailleRAP/Models/BraillePageLayout.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Models/BraillePageLayout.cs new file mode 100644 index 0000000..d68d2fa --- /dev/null +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Models/BraillePageLayout.cs @@ -0,0 +1,29 @@ +namespace MakerPrompt.UI.Components.BrailleRAP.Models +{ + /// + /// Represents a paginated layout of Braille text. + /// + public class BraillePageLayout + { + /// + /// Pages of Braille lines. + /// Each page contains multiple lines, each line is a string of Braille characters. + /// + public List> Pages { get; set; } = []; + + /// + /// Gets the total number of pages. + /// + public int PageCount => Pages.Count; + + /// + /// Gets a specific page by index. + /// + public List GetPage(int pageIndex) + { + if (pageIndex < 0 || pageIndex >= Pages.Count) + return []; + return Pages[pageIndex]; + } + } +} diff --git a/src/MakerPrompt.UI.Components/BrailleRAP/Models/GeomPoint.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Models/GeomPoint.cs new file mode 100644 index 0000000..006641f --- /dev/null +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Models/GeomPoint.cs @@ -0,0 +1,7 @@ +namespace MakerPrompt.UI.Components.BrailleRAP.Models +{ + /// + /// Represents a geometric point in 2D space for BrailleRAP positioning. + /// + public record GeomPoint(double X, double Y); +} diff --git a/src/MakerPrompt.UI.Components/BrailleRAP/Models/MachineConfig.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Models/MachineConfig.cs new file mode 100644 index 0000000..2301852 --- /dev/null +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Models/MachineConfig.cs @@ -0,0 +1,48 @@ +namespace MakerPrompt.UI.Components.BrailleRAP.Models +{ + /// + /// Configuration for BrailleRAP machine parameters. + /// + public class MachineConfig + { + /// + /// Horizontal spacing between dots in a cell (mm). + /// + public double DotPaddingX { get; set; } = 2.2; + + /// + /// Vertical spacing between dots in a cell (mm). + /// + public double DotPaddingY { get; set; } = 2.2; + + /// + /// Horizontal spacing between cells (mm). + /// + public double CellPaddingX { get; set; } = 6.0; + + /// + /// Vertical spacing between lines (mm). + /// + public double CellPaddingY { get; set; } = 12.0; + + /// + /// Feed rate for movement (mm/min). + /// + public int FeedRate { get; set; } = 6000; + + /// + /// X-axis offset from origin (mm). + /// + public double OffsetX { get; set; } = 0.0; + + /// + /// Y-axis offset from origin (mm). + /// + public double OffsetY { get; set; } = 0.0; + + /// + /// Y-coordinate for return position after printing (mm). + /// + public double ReturnPositionY { get; set; } = 300.0; + } +} diff --git a/src/MakerPrompt.UI.Components/BrailleRAP/Models/PageConfig.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Models/PageConfig.cs new file mode 100644 index 0000000..d0ba44c --- /dev/null +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Models/PageConfig.cs @@ -0,0 +1,31 @@ +namespace MakerPrompt.UI.Components.BrailleRAP.Models +{ + /// + /// Configuration for Braille page layout. + /// + public class PageConfig + { + /// + /// Number of Braille cells per line. + /// + public int Columns { get; set; } = 28; + + /// + /// Maximum number of lines per page. + /// + public int Rows { get; set; } = 21; + + /// + /// Line spacing multiplier (0 = normal, 1 = double space, etc.). + /// + public int LineSpacing { get; set; } = 0; + + /// + /// Gets the computed number of rows based on spacing. + /// + public int GetComputedRows() + { + return (int)Math.Floor(Rows / ((LineSpacing * 0.5) + 1)); + } + } +} diff --git a/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleGCodeGenerator.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleGCodeGenerator.cs new file mode 100644 index 0000000..e300a74 --- /dev/null +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleGCodeGenerator.cs @@ -0,0 +1,113 @@ +using MakerPrompt.UI.Components.BrailleRAP.Models; + +namespace MakerPrompt.UI.Components.BrailleRAP.Services +{ + /// + /// Generates G-code for BrailleRAP embossing from geometric points. + /// Ported from AccessBrailleRAP's GeomToGCode.js + /// + public class BrailleGCodeGenerator + { + private readonly MachineConfig _config; + + public BrailleGCodeGenerator(MachineConfig config) + { + _config = config; + } + + /// + /// Generates complete G-code from geometric points. + /// + public string GenerateGCode(List points) + { + var gcode = new StringBuilder(); + + // Initialize + gcode.Append(Home()); + gcode.Append(SetSpeed(_config.FeedRate)); + gcode.Append(MoveTo(0, 0)); + + // Process each point + foreach (var point in points) + { + gcode.Append(MoveTo(point.X, point.Y)); + gcode.Append(PrintDot()); + } + + // Return to home position + gcode.Append(MoveTo(0, _config.ReturnPositionY)); + gcode.Append(MotorOff()); + + return gcode.ToString(); + } + + /// + /// Generates G-code from a Braille page layout. + /// + public string GenerateGCodeFromLayout(BraillePageLayout layout, int pageIndex = 0) + { + var page = layout.GetPage(pageIndex); + if (page.Count == 0) + return string.Empty; + + var geometry = new BrailleToGeometry(_config); + var points = geometry.BraillePageToGeom(page, _config.OffsetX, _config.OffsetY); + + return GenerateGCode(points); + } + + private string MotorOff() + { + return "M84;\r\n"; + } + + private string Home() + { + var sb = new StringBuilder(); + sb.Append("G28 X;\r\n"); + sb.Append("G28 Y;\r\n"); + return sb.ToString(); + } + + private string GCodePosition(double? x, double? y) + { + if (x == null && y == null) + { + throw new ArgumentException("At least one coordinate must be specified"); + } + + var code = new StringBuilder(); + + if (x.HasValue) + { + code.Append($" X{x.Value:F2}"); + } + + if (y.HasValue) + { + code.Append($" Y{y.Value:F2}"); + } + + code.Append(";\r\n"); + return code.ToString(); + } + + private string SetSpeed(int speed) + { + return $"G1 F{speed};\r\n"; + } + + private string MoveTo(double x, double y) + { + return "G1" + GCodePosition(x, y); + } + + private string PrintDot() + { + var sb = new StringBuilder(); + sb.Append("M3 S1;\r\n"); + sb.Append("M3 S0;\r\n"); + return sb.ToString(); + } + } +} diff --git a/src/MakerPrompt.UI.Components/BrailleRAP/Services/BraillePaginator.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BraillePaginator.cs new file mode 100644 index 0000000..e1713d7 --- /dev/null +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BraillePaginator.cs @@ -0,0 +1,160 @@ +using MakerPrompt.UI.Components.BrailleRAP.Models; + +namespace MakerPrompt.UI.Components.BrailleRAP.Services +{ + /// + /// Paginates Braille text into pages based on column and row constraints. + /// Ported from AccessBrailleRAP's BraillePaginator.js + /// + public class BraillePaginator + { + private const char BlankBrailleCell = '\u2800'; + + private PageConfig _config; + private List _sourceLines; + private List> _pages; + private List _currentPage; + private int _computedRows; + + public BraillePaginator() + { + _config = new PageConfig(); + _sourceLines = []; + _pages = []; + _currentPage = []; + _computedRows = _config.GetComputedRows(); + } + + /// + /// Sets the page configuration. + /// + public void SetConfig(PageConfig config) + { + _config = config; + ComputeRows(); + Update(); + } + + /// + /// Sets the source Braille lines to paginate. + /// + public void SetSourceLines(List lines) + { + _sourceLines = lines; + Update(); + } + + /// + /// Gets the paginated layout. + /// + public BraillePageLayout GetLayout() + { + return new BraillePageLayout { Pages = new List>(_pages) }; + } + + private void ComputeRows() + { + _computedRows = _config.GetComputedRows(); + } + + private void AddLine(string line) + { + _currentPage.Add(line); + if (_currentPage.Count >= _computedRows) + { + _pages.Add(new List(_currentPage)); + _currentPage.Clear(); + } + } + + private void FlushLine() + { + if (_currentPage.Count > 0) + { + _pages.Add(new List(_currentPage)); + _currentPage.Clear(); + } + } + + private void Update() + { + if (_sourceLines == null) + return; + + _pages.Clear(); + _currentPage.Clear(); + ComputeRows(); + + foreach (var srcLine in _sourceLines) + { + // Split by blank Braille cells (U+2800 is the blank cell used as space) + var words = srcLine.Split(BlankBrailleCell); + + var currentLine = ""; + + foreach (var word in words) + { + // Check for form feed + if (word == "\f") + { + AddLine(currentLine); + currentLine = ""; + FlushLine(); + continue; + } + + // Check if adding this word would exceed column limit + if (word.Length + currentLine.Length >= _config.Columns) + { + if (currentLine.Length > 0) + { + // Add current line and start new one + AddLine(currentLine); + + if (word.Length < _config.Columns) + { + currentLine = word + BlankBrailleCell; + } + else + { + // Word is too long, need to split it + currentLine = ""; + SplitLongWord(word); + } + } + else + { + // Need to split a long word + SplitLongWord(word); + currentLine = ""; + } + } + else + { + currentLine += word; + currentLine += BlankBrailleCell; + } + } + + if (currentLine.Length > 0) + { + AddLine(currentLine); + } + } + + FlushLine(); + } + + private void SplitLongWord(string word) + { + int start = 0; + while (start < word.Length) + { + int chunkSize = Math.Min(_config.Columns, word.Length - start); + string chunk = word.Substring(start, chunkSize); + AddLine(chunk); + start += chunkSize; + } + } + } +} diff --git a/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleRAPService.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleRAPService.cs new file mode 100644 index 0000000..28c744c --- /dev/null +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleRAPService.cs @@ -0,0 +1,130 @@ +using MakerPrompt.UI.Components.BrailleRAP.Models; + +namespace MakerPrompt.UI.Components.BrailleRAP.Services +{ + /// + /// Main service for BrailleRAP operations. + /// Coordinates translation, pagination, and G-code generation. + /// + public class BrailleRAPService + { + private readonly BrailleTranslator _translator; + private readonly BraillePaginator _paginator; + private PageConfig _pageConfig; + private MachineConfig _machineConfig; + private BrailleLanguage _currentLanguage; + + public BrailleRAPService() + { + _translator = new BrailleTranslator(); + _paginator = new BraillePaginator(); + _pageConfig = new PageConfig(); + _machineConfig = new MachineConfig(); + _currentLanguage = BrailleLanguage.EnglishGrade1; + } + + /// + /// Sets the Braille translation language. + /// + public void SetLanguage(BrailleLanguage language) + { + _currentLanguage = language; + _translator.SetLanguage(language); + } + + /// + /// Gets the current Braille language. + /// + public BrailleLanguage GetLanguage() => _currentLanguage; + + /// + /// Sets the page configuration. + /// + public void SetPageConfig(PageConfig config) + { + _pageConfig = config; + _paginator.SetConfig(config); + } + + /// + /// Sets the machine configuration. + /// + public void SetMachineConfig(MachineConfig config) + { + _machineConfig = config; + } + + /// + /// Gets the current page configuration. + /// + public PageConfig GetPageConfig() => _pageConfig; + + /// + /// Gets the current machine configuration. + /// + public MachineConfig GetMachineConfig() => _machineConfig; + + /// + /// Translates text to Braille and returns the layout. + /// + public BraillePageLayout TranslateAndLayout(string text) + { + // Translate text to Braille + var brailleLines = _translator.Translate(text); + + // Paginate + _paginator.SetSourceLines(brailleLines); + + return _paginator.GetLayout(); + } + + /// + /// Generates G-code from text for a specific page. + /// + public string GenerateGCode(string text, int pageIndex = 0) + { + var layout = TranslateAndLayout(text); + + if (layout.PageCount == 0 || pageIndex >= layout.PageCount) + return string.Empty; + + var generator = new BrailleGCodeGenerator(_machineConfig); + return generator.GenerateGCodeFromLayout(layout, pageIndex); + } + + /// + /// Gets a preview of the Braille text for a specific page. + /// + public List GetBraillePreview(string text, int pageIndex = 0) + { + var layout = TranslateAndLayout(text); + + if (layout.PageCount == 0 || pageIndex >= layout.PageCount) + return []; + + return layout.GetPage(pageIndex); + } + + /// + /// Gets statistics about the translated and paginated text. + /// + public (int PageCount, int TotalLines, int TotalCharacters) GetStatistics(string text) + { + var layout = TranslateAndLayout(text); + + int totalLines = 0; + int totalChars = 0; + + foreach (var page in layout.Pages) + { + totalLines += page.Count; + foreach (var line in page) + { + totalChars += line.Length; + } + } + + return (layout.PageCount, totalLines, totalChars); + } + } +} diff --git a/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleToGeometry.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleToGeometry.cs new file mode 100644 index 0000000..cef9c99 --- /dev/null +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleToGeometry.cs @@ -0,0 +1,137 @@ +using MakerPrompt.UI.Components.BrailleRAP.Models; + +namespace MakerPrompt.UI.Components.BrailleRAP.Services +{ + /// + /// Converts Braille cells to geometric points for embossing. + /// Ported from AccessBrailleRAP's BrailleToGeometry.js + /// + public class BrailleToGeometry + { + // Standard 8-dot Braille dot positions + private static readonly (int X, int Y)[] DotPositions = new[] + { + (0, 0), // Dot 1 + (0, 1), // Dot 2 + (0, 2), // Dot 3 + (1, 0), // Dot 4 + (1, 1), // Dot 5 + (1, 2), // Dot 6 + (0, 3), // Dot 7 + (1, 3) // Dot 8 + }; + + private readonly MachineConfig _config; + + public BrailleToGeometry(MachineConfig config) + { + _config = config; + } + + /// + /// Converts a single Braille character to geometric points. + /// + public List BrailleCharToGeom(char brailleChar, double offsetX, double offsetY) + { + var points = new List(); + int value = brailleChar - 0x2800; + + for (int i = 0; i < 8; i++) + { + if ((value & (1 << i)) != 0) + { + var dot = DotPositions[i]; + var point = new GeomPoint( + dot.X * _config.DotPaddingX + offsetX, + dot.Y * _config.DotPaddingY + offsetY + ); + points.Add(point); + } + } + + return points; + } + + /// + /// Converts a page of Braille text to geometric points. + /// + public List BraillePageToGeom(List lines, double offsetX, double offsetY) + { + var geometry = new List(); + var startY = offsetY; + + foreach (var line in lines) + { + var startX = offsetX; + + foreach (var ch in line) + { + var points = BrailleCharToGeom(ch, startX, startY); + geometry.AddRange(points); + startX += _config.CellPaddingX; + } + + startY += _config.CellPaddingY; + } + + // Sort geometry + SortGeom(geometry); + + // Apply zig-zag optimization for efficient printing + var sorted = SortGeomZigZag(geometry); + + return sorted; + } + + /// + /// Sorts geometry by Y then X coordinates. + /// + private void SortGeom(List geom) + { + geom.Sort((a, b) => + { + if (Math.Abs(a.Y - b.Y) < 0.001) + return a.X.CompareTo(b.X); + return a.Y.CompareTo(b.Y); + }); + } + + /// + /// Sorts geometry in a zig-zag pattern for efficient printing. + /// Alternates direction for each row to minimize travel distance. + /// + private List SortGeomZigZag(List geom) + { + if (geom == null || geom.Count == 0) + return []; + + var sorted = new List(); + int start = 0; + int end = 0; + int direction = 1; + + while (end < geom.Count) + { + // Find all points with the same Y coordinate + while (end < geom.Count && Math.Abs(geom[start].Y - geom[end].Y) < 0.001) + { + end++; + } + + // Extract this row + var row = geom.GetRange(start, end - start); + + // Sort by X, alternating direction + row.Sort((a, b) => direction * a.X.CompareTo(b.X)); + + sorted.AddRange(row); + + // Alternate direction for next row + direction = -direction; + start = end; + } + + return sorted; + } + } +} diff --git a/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleTranslator.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleTranslator.cs new file mode 100644 index 0000000..48c170c --- /dev/null +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleTranslator.cs @@ -0,0 +1,306 @@ +namespace MakerPrompt.UI.Components.BrailleRAP.Services +{ + /// + /// Supported Braille languages and translation tables. + /// + public enum BrailleLanguage + { + EnglishGrade1, + FrenchGrade1, + GermanGrade1, + SpanishGrade1 + } + + /// + /// Translates text to Braille using character mapping for multiple languages. + /// + public class BrailleTranslator + { + // Braille character constants + private const char CapitalIndicator = '\u2820'; + private const char BlankCell = '\u2800'; + private const char NumberIndicator = '\u283C'; + + private BrailleLanguage _currentLanguage = BrailleLanguage.EnglishGrade1; + + /// + /// Sets the translation language. + /// + public void SetLanguage(BrailleLanguage language) + { + _currentLanguage = language; + } + + /// + /// Gets the current language. + /// + public BrailleLanguage GetLanguage() => _currentLanguage; + + // Basic English Grade 1 Braille mapping (simplified) + // Unicode Braille patterns start at U+2800 + private static readonly Dictionary EnglishGrade1Map = new() + { + // Letters (a-z) + { 'a', '\u2801' }, { 'b', '\u2803' }, { 'c', '\u2809' }, { 'd', '\u2819' }, + { 'e', '\u2811' }, { 'f', '\u280B' }, { 'g', '\u281B' }, { 'h', '\u2813' }, + { 'i', '\u280A' }, { 'j', '\u281A' }, { 'k', '\u2805' }, { 'l', '\u2807' }, + { 'm', '\u280D' }, { 'n', '\u281D' }, { 'o', '\u2815' }, { 'p', '\u280F' }, + { 'q', '\u281F' }, { 'r', '\u2817' }, { 's', '\u280E' }, { 't', '\u281E' }, + { 'u', '\u2825' }, { 'v', '\u2827' }, { 'w', '\u283A' }, { 'x', '\u282D' }, + { 'y', '\u283D' }, { 'z', '\u2835' }, + + // Capital letter indicator + { 'A', CapitalIndicator }, { 'B', CapitalIndicator }, { 'C', CapitalIndicator }, { 'D', CapitalIndicator }, + { 'E', CapitalIndicator }, { 'F', CapitalIndicator }, { 'G', CapitalIndicator }, { 'H', CapitalIndicator }, + { 'I', CapitalIndicator }, { 'J', CapitalIndicator }, { 'K', CapitalIndicator }, { 'L', CapitalIndicator }, + { 'M', CapitalIndicator }, { 'N', CapitalIndicator }, { 'O', CapitalIndicator }, { 'P', CapitalIndicator }, + { 'Q', CapitalIndicator }, { 'R', CapitalIndicator }, { 'S', CapitalIndicator }, { 'T', CapitalIndicator }, + { 'U', CapitalIndicator }, { 'V', CapitalIndicator }, { 'W', CapitalIndicator }, { 'X', CapitalIndicator }, + { 'Y', CapitalIndicator }, { 'Z', CapitalIndicator }, + + // Numbers + { '1', '\u2801' }, { '2', '\u2803' }, { '3', '\u2809' }, { '4', '\u2819' }, + { '5', '\u2811' }, { '6', '\u280B' }, { '7', '\u281B' }, { '8', '\u2813' }, + { '9', '\u280A' }, { '0', '\u281A' }, + + // Common punctuation + { ' ', BlankCell }, // Blank cell for space + { ',', '\u2802' }, + { '.', '\u2832' }, + { '?', '\u2826' }, + { '!', '\u2816' }, + { '\'', '\u2804' }, + { '-', '\u2824' }, + { ':', '\u2812' }, + { ';', '\u2822' }, + { '(', '\u2823' }, + { ')', '\u281C' }, + { '\n', '\n' }, // Preserve newlines + { '\f', '\f' }, // Preserve form feeds + }; + + // French Grade 1 Braille mapping + private static readonly Dictionary FrenchGrade1Map = new() + { + // Letters (a-z) - same as English + { 'a', '\u2801' }, { 'b', '\u2803' }, { 'c', '\u2809' }, { 'd', '\u2819' }, + { 'e', '\u2811' }, { 'f', '\u280B' }, { 'g', '\u281B' }, { 'h', '\u2813' }, + { 'i', '\u280A' }, { 'j', '\u281A' }, { 'k', '\u2805' }, { 'l', '\u2807' }, + { 'm', '\u280D' }, { 'n', '\u281D' }, { 'o', '\u2815' }, { 'p', '\u280F' }, + { 'q', '\u281F' }, { 'r', '\u2817' }, { 's', '\u280E' }, { 't', '\u281E' }, + { 'u', '\u2825' }, { 'v', '\u2827' }, { 'w', '\u283A' }, { 'x', '\u282D' }, + { 'y', '\u283D' }, { 'z', '\u2835' }, + + // Capital letters + { 'A', CapitalIndicator }, { 'B', CapitalIndicator }, { 'C', CapitalIndicator }, { 'D', CapitalIndicator }, + { 'E', CapitalIndicator }, { 'F', CapitalIndicator }, { 'G', CapitalIndicator }, { 'H', CapitalIndicator }, + { 'I', CapitalIndicator }, { 'J', CapitalIndicator }, { 'K', CapitalIndicator }, { 'L', CapitalIndicator }, + { 'M', CapitalIndicator }, { 'N', CapitalIndicator }, { 'O', CapitalIndicator }, { 'P', CapitalIndicator }, + { 'Q', CapitalIndicator }, { 'R', CapitalIndicator }, { 'S', CapitalIndicator }, { 'T', CapitalIndicator }, + { 'U', CapitalIndicator }, { 'V', CapitalIndicator }, { 'W', CapitalIndicator }, { 'X', CapitalIndicator }, + { 'Y', CapitalIndicator }, { 'Z', CapitalIndicator }, + + // Numbers + { '1', '\u2801' }, { '2', '\u2803' }, { '3', '\u2809' }, { '4', '\u2819' }, + { '5', '\u2811' }, { '6', '\u280B' }, { '7', '\u281B' }, { '8', '\u2813' }, + { '9', '\u280A' }, { '0', '\u281A' }, + + // French accented characters + { 'à', '\u282F' }, { 'â', '\u2801' }, { 'ç', '\u280F' }, + { 'é', '\u283F' }, { 'è', '\u282E' }, { 'ê', '\u2811' }, + { 'ë', '\u283B' }, { 'î', '\u280A' }, { 'ï', '\u283B' }, + { 'ô', '\u2815' }, { 'ù', '\u283D' }, { 'û', '\u2825' }, + { 'ü', '\u283B' }, { 'œ', '\u283B' }, + + // Common punctuation + { ' ', BlankCell }, + { ',', '\u2802' }, + { '.', '\u2832' }, + { '?', '\u2826' }, + { '!', '\u2816' }, + { '\'', '\u2804' }, + { '-', '\u2824' }, + { ':', '\u2812' }, + { ';', '\u2822' }, + { '(', '\u2823' }, + { ')', '\u281C' }, + { '\n', '\n' }, + { '\f', '\f' }, + }; + + // German Grade 1 Braille mapping + private static readonly Dictionary GermanGrade1Map = new() + { + // Letters (a-z) - same as English + { 'a', '\u2801' }, { 'b', '\u2803' }, { 'c', '\u2809' }, { 'd', '\u2819' }, + { 'e', '\u2811' }, { 'f', '\u280B' }, { 'g', '\u281B' }, { 'h', '\u2813' }, + { 'i', '\u280A' }, { 'j', '\u281A' }, { 'k', '\u2805' }, { 'l', '\u2807' }, + { 'm', '\u280D' }, { 'n', '\u281D' }, { 'o', '\u2815' }, { 'p', '\u280F' }, + { 'q', '\u281F' }, { 'r', '\u2817' }, { 's', '\u280E' }, { 't', '\u281E' }, + { 'u', '\u2825' }, { 'v', '\u2827' }, { 'w', '\u283A' }, { 'x', '\u282D' }, + { 'y', '\u283D' }, { 'z', '\u2835' }, + + // Capital letters + { 'A', CapitalIndicator }, { 'B', CapitalIndicator }, { 'C', CapitalIndicator }, { 'D', CapitalIndicator }, + { 'E', CapitalIndicator }, { 'F', CapitalIndicator }, { 'G', CapitalIndicator }, { 'H', CapitalIndicator }, + { 'I', CapitalIndicator }, { 'J', CapitalIndicator }, { 'K', CapitalIndicator }, { 'L', CapitalIndicator }, + { 'M', CapitalIndicator }, { 'N', CapitalIndicator }, { 'O', CapitalIndicator }, { 'P', CapitalIndicator }, + { 'Q', CapitalIndicator }, { 'R', CapitalIndicator }, { 'S', CapitalIndicator }, { 'T', CapitalIndicator }, + { 'U', CapitalIndicator }, { 'V', CapitalIndicator }, { 'W', CapitalIndicator }, { 'X', CapitalIndicator }, + { 'Y', CapitalIndicator }, { 'Z', CapitalIndicator }, + + // Numbers + { '1', '\u2801' }, { '2', '\u2803' }, { '3', '\u2809' }, { '4', '\u2819' }, + { '5', '\u2811' }, { '6', '\u280B' }, { '7', '\u281B' }, { '8', '\u2813' }, + { '9', '\u280A' }, { '0', '\u281A' }, + + // German umlauts and special characters + { 'ä', '\u2831' }, { 'Ä', '\u2831' }, + { 'ö', '\u283B' }, { 'Ö', '\u283B' }, + { 'ü', '\u283C' }, { 'Ü', '\u283C' }, + { 'ß', '\u282E' }, + + // Common punctuation + { ' ', BlankCell }, + { ',', '\u2802' }, + { '.', '\u2832' }, + { '?', '\u2826' }, + { '!', '\u2816' }, + { '\'', '\u2804' }, + { '-', '\u2824' }, + { ':', '\u2812' }, + { ';', '\u2822' }, + { '(', '\u2823' }, + { ')', '\u281C' }, + { '\n', '\n' }, + { '\f', '\f' }, + }; + + // Spanish Grade 1 Braille mapping + private static readonly Dictionary SpanishGrade1Map = new() + { + // Letters (a-z) - same as English + { 'a', '\u2801' }, { 'b', '\u2803' }, { 'c', '\u2809' }, { 'd', '\u2819' }, + { 'e', '\u2811' }, { 'f', '\u280B' }, { 'g', '\u281B' }, { 'h', '\u2813' }, + { 'i', '\u280A' }, { 'j', '\u281A' }, { 'k', '\u2805' }, { 'l', '\u2807' }, + { 'm', '\u280D' }, { 'n', '\u281D' }, { 'o', '\u2815' }, { 'p', '\u280F' }, + { 'q', '\u281F' }, { 'r', '\u2817' }, { 's', '\u280E' }, { 't', '\u281E' }, + { 'u', '\u2825' }, { 'v', '\u2827' }, { 'w', '\u283A' }, { 'x', '\u282D' }, + { 'y', '\u283D' }, { 'z', '\u2835' }, + + // Capital letters + { 'A', CapitalIndicator }, { 'B', CapitalIndicator }, { 'C', CapitalIndicator }, { 'D', CapitalIndicator }, + { 'E', CapitalIndicator }, { 'F', CapitalIndicator }, { 'G', CapitalIndicator }, { 'H', CapitalIndicator }, + { 'I', CapitalIndicator }, { 'J', CapitalIndicator }, { 'K', CapitalIndicator }, { 'L', CapitalIndicator }, + { 'M', CapitalIndicator }, { 'N', CapitalIndicator }, { 'O', CapitalIndicator }, { 'P', CapitalIndicator }, + { 'Q', CapitalIndicator }, { 'R', CapitalIndicator }, { 'S', CapitalIndicator }, { 'T', CapitalIndicator }, + { 'U', CapitalIndicator }, { 'V', CapitalIndicator }, { 'W', CapitalIndicator }, { 'X', CapitalIndicator }, + { 'Y', CapitalIndicator }, { 'Z', CapitalIndicator }, + + // Numbers + { '1', '\u2801' }, { '2', '\u2803' }, { '3', '\u2809' }, { '4', '\u2819' }, + { '5', '\u2811' }, { '6', '\u280B' }, { '7', '\u281B' }, { '8', '\u2813' }, + { '9', '\u280A' }, { '0', '\u281A' }, + + // Spanish accented characters + { 'á', '\u2831' }, { 'é', '\u283F' }, { 'í', '\u280C' }, + { 'ó', '\u283B' }, { 'ú', '\u283C' }, + { 'ñ', '\u283B' }, { 'ü', '\u283C' }, + { '¿', '\u2826' }, { '¡', '\u2816' }, + + // Common punctuation + { ' ', BlankCell }, + { ',', '\u2802' }, + { '.', '\u2832' }, + { '?', '\u2826' }, + { '!', '\u2816' }, + { '\'', '\u2804' }, + { '-', '\u2824' }, + { ':', '\u2812' }, + { ';', '\u2822' }, + { '(', '\u2823' }, + { ')', '\u281C' }, + { '\n', '\n' }, + { '\f', '\f' }, + }; + + /// + /// Gets the appropriate translation map for the current language. + /// + private Dictionary GetTranslationMap() + { + return _currentLanguage switch + { + BrailleLanguage.FrenchGrade1 => FrenchGrade1Map, + BrailleLanguage.GermanGrade1 => GermanGrade1Map, + BrailleLanguage.SpanishGrade1 => SpanishGrade1Map, + _ => EnglishGrade1Map + }; + } + + /// + /// Translates plain text to Braille. + /// + /// The text to translate. + /// List of Braille lines. + public List Translate(string text) + { + if (string.IsNullOrEmpty(text)) + return []; + + var translationMap = GetTranslationMap(); + + // Split by newlines but preserve form feeds + var lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.None) + .Where(line => !string.IsNullOrEmpty(line) || line == string.Empty) + .ToList(); + + var brailleLines = new List(); + + foreach (var line in lines) + { + if (line.Contains('\f')) + { + brailleLines.Add("\f"); + continue; + } + + var brailleLine = new System.Text.StringBuilder(); + + foreach (var ch in line) + { + if (char.IsUpper(ch)) + { + // Add capital indicator before the letter + brailleLine.Append(CapitalIndicator); + // Then add the lowercase version + var lower = char.ToLower(ch); + if (translationMap.TryGetValue(lower, out var brailleChar)) + { + brailleLine.Append(brailleChar); + } + else + { + // Unknown character, use blank + brailleLine.Append(BlankCell); + } + } + else if (translationMap.TryGetValue(ch, out var brailleChar)) + { + if (brailleChar != '\n' && brailleChar != '\f') + brailleLine.Append(brailleChar); + } + else + { + // Unknown character, use blank cell + brailleLine.Append(BlankCell); + } + } + + brailleLines.Add(brailleLine.ToString()); + } + + return brailleLines; + } + } +} diff --git a/src/MakerPrompt.UI.Components/Components/AppInitializer.razor b/src/MakerPrompt.UI.Components/Components/AppInitializer.razor new file mode 100644 index 0000000..0627abf --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/AppInitializer.razor @@ -0,0 +1,39 @@ +@* + App-level initializer — ensures PrinterConnectionManager and PrintProjectService + are loaded and auto-connect runs. Place in App.razor or Routes.razor wrapping the Router. +*@ +@inject PrinterConnectionManager ConnectionManager +@inject PrintProjectService ProjectService +@inject FilamentInventoryService FilamentInventoryService +@inject AnalyticsService AnalyticsService +@inject NotificationService NotificationService +@inject ILogger Logger + +@ChildContent + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + + private bool _initialized; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && !_initialized) + { + _initialized = true; + try + { + await FilamentInventoryService.InitializeAsync(); + await AnalyticsService.InitializeAsync(); + await NotificationService.InitializeAsync(); + await ConnectionManager.InitializeAsync(); + await ProjectService.InitializeAsync(); + _ = ConnectionManager.AutoConnectAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to initialize app services"); + } + } + } +} diff --git a/src/MakerPrompt.UI.Components/Components/Calculators/BeltStepsCalculator.razor b/src/MakerPrompt.UI.Components/Components/Calculators/BeltStepsCalculator.razor new file mode 100644 index 0000000..bac3d0e --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/Calculators/BeltStepsCalculator.razor @@ -0,0 +1,80 @@ +@inherits ConnectionComponentBase + +
+
+
+ + +
+ +
+ + +
+
+
+
+ + +
+ +
+ + +
+
+
+
+
+
+

@Localizer[Resources.CalibrationPage_Result]

+
+

@Localizer[Resources.Calculators_Steps]: @BeltStepsPerMm.ToString("0.00")

+

@Localizer[Resources.Calculators_Resolution]: @BeltResolution.ToString("0.000") microns

+
+
+
+

+ @Localizer[Resources.Calculators_GCodeExample]: + + @GCodeString + +

+ +
+
+
+
+
+ +@code { + private MotorStepAngle SelectedStepAngle = MotorStepAngle.Step1_8; + private MicrosteppingMode SelectedMicrostepping = MicrosteppingMode.SixteenthStep; + private decimal BeltPitch = 2; + private int BeltPulleyTeeth = 8; + private decimal BeltStepsPerRev => 360m / SelectedStepAngle.GetStepAngleValue(); + private decimal BeltStepsPerMm => (BeltStepsPerRev * (int)SelectedMicrostepping) / (BeltPitch * BeltPulleyTeeth); + private decimal BeltResolution => 1000m / BeltStepsPerMm; // microns + + private string GCodeString => $"M92 X{BeltStepsPerMm:0.00} Y{BeltStepsPerMm:0.00}"; +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Components/Calculators/ExtruderStepsCalculator.razor b/src/MakerPrompt.UI.Components/Components/Calculators/ExtruderStepsCalculator.razor new file mode 100644 index 0000000..6bf9d71 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/Calculators/ExtruderStepsCalculator.razor @@ -0,0 +1,70 @@ +@inherits ConnectionComponentBase + +
+
+ @howTo +
+
+
+ + +
+ +
+ + +
+ +
+ + + @Localizer[Resources.Calculators_MeasureCaliper] +
+
+
+ +
+
+
+

@Localizer[Resources.CalibrationPage_Result]

+
+

@Localizer[Resources.Calculators_Steps]: @CalculatedESteps.ToString("0.00")

+
+
+
+

+ @Localizer[Resources.Calculators_GCodeExample]: + + @GCodeString + +

+ +
+
+
+
+
+ +@code { + private decimal CurrentESteps = 93.0m; + private decimal RequestedLength = 100m; + private decimal ActualLength = 100m; + + private decimal CalculatedESteps => + (ActualLength / RequestedLength) * CurrentESteps; + + private MarkupString howTo; + private string GCodeString => $"M92 E{CalculatedESteps:0.00}"; + + protected override void OnInitialized() + { + howTo = new MarkupString(Localizer[Resources.Calculators_Esteps_HowTo]); + base.OnInitialized(); + } +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Components/Calculators/LeadScrewCalculator.razor b/src/MakerPrompt.UI.Components/Components/Calculators/LeadScrewCalculator.razor new file mode 100644 index 0000000..a703a2d --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/Calculators/LeadScrewCalculator.razor @@ -0,0 +1,88 @@ +@inherits ConnectionComponentBase + +
+
+
+ + +
+ +
+ + +
+
+
+
+ + +
+ +
+ +
+ + : + +
+
+
+
+
+
+
+

@Localizer[Resources.CalibrationPage_Result]

+
+

@Localizer[Resources.Calculators_Steps]: @ScrewStepsPerMm.ToString("0.00")

+

@Localizer[Resources.Calculators_Resolution]: @ScrewResolution.ToString("0.000") microns

+
+
+
+

+ @Localizer[Resources.Calculators_GCodeExample]: + + @GCodeString + +

+ +
+
+
+
+
+ +@code { + private MotorStepAngle SelectedStepAngle = MotorStepAngle.Step1_8; + private MicrosteppingMode SelectedMicrostepping = MicrosteppingMode.SixteenthStep; + private decimal ScrewPitch = 1.25m; + private int GearRatioA = 1; + private int GearRatioB = 1; + + private decimal ScrewStepsPerRev => 360m / SelectedStepAngle.GetStepAngleValue(); + private decimal ScrewStepsPerMm => (ScrewStepsPerRev * (int)SelectedMicrostepping * GearRatioA) / (ScrewPitch * GearRatioB); + private decimal ScrewResolution => 1000m / ScrewStepsPerMm; // microns + + private string GCodeString => $"M92 Z{ScrewStepsPerMm:0.00}"; +} diff --git a/src/MakerPrompt.UI.Components/Components/Calculators/PrintPriceCalculator.razor b/src/MakerPrompt.UI.Components/Components/Calculators/PrintPriceCalculator.razor new file mode 100644 index 0000000..7343d27 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/Calculators/PrintPriceCalculator.razor @@ -0,0 +1,5 @@ +

PrintPriceCalculator

+ +@code { + +} diff --git a/src/MakerPrompt.UI.Components/Components/Calibration.razor b/src/MakerPrompt.UI.Components/Components/Calibration.razor new file mode 100644 index 0000000..9043083 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/Calibration.razor @@ -0,0 +1,34 @@ +@inject IStringLocalizer localizer + +
+ + + @switch (ActiveTab) + { + default: + case DashboardTab.PID: + + break; + case DashboardTab.Thermal: + + break; + } +
+ +@code{ + private DashboardTab ActiveTab = DashboardTab.PID; + + private enum DashboardTab { PID, Thermal } + + private void ShowTab(DashboardTab tab) + { + ActiveTab = tab; + } +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Components/CommandPrompt.razor b/src/MakerPrompt.UI.Components/Components/CommandPrompt.razor new file mode 100644 index 0000000..7713b19 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/CommandPrompt.razor @@ -0,0 +1,179 @@ +@using System.Collections.Generic +@inherits ConnectionComponentBase + +
+
+
+ + +
+
+ +
+ @foreach (var entry in GetHistorySnapshot()) + { +
+ @if (entry.Type == TerminalEntryType.Sent) + { + >> + @entry.Timestamp.ToString("HH:mm:ss") + @entry.Text + } + else if (entry.Type == TerminalEntryType.Received) + { + << + @entry.Timestamp.ToString("HH:mm:ss") + @entry.Text + } + else + { + @entry.Text + } +
+ } +
+ +
+ + +
+
+@code { + private enum TerminalEntryType { Sent, Received, System } + private class TerminalEntry + { + public string Text { get; set; } = string.Empty; + public TerminalEntryType Type { get; set; } + public DateTime Timestamp { get; set; } = DateTime.Now; + } + + private readonly object _historyLock = new(); + private List history = new(); + private string inputCommand = string.Empty; + private ElementReference historyContainer; + + // Tracks whether the last telemetry update came from a user-initiated command. + private bool _expectTelemetryEcho; + private bool _showTelemetry; + + private async Task SendCommand() + { + if (string.IsNullOrWhiteSpace(inputCommand) || PrinterServiceFactory.Current == null) return; + + var command = inputCommand.Trim(); + AddEntryToHistory(new TerminalEntry { Text = command, Type = TerminalEntryType.Sent, Timestamp = DateTime.Now }); + inputCommand = string.Empty; + + // Next telemetry response is likely the echo of this command. + _expectTelemetryEcho = true; + + try + { + await PrinterServiceFactory.Current.WriteDataAsync(command); + await ScrollToBottom(); + } + catch (Exception ex) + { + AddSystemMessage(string.Format(Localizer[Resources.CommandPrompt_ErrorMessage], ex.Message), true); + } + } + + private void HandleKeyPress(KeyboardEventArgs args) + { + if (args.Key == "Enter") + { + SendCommand().ConfigureAwait(false); + } + } + + protected override void HandleTelemetryUpdated(object? sender, PrinterTelemetry printerTelemetry) + { + var response = printerTelemetry.LastResponse; + if (string.IsNullOrWhiteSpace(response)) + { + return; + } + + // When telemetry echo display is disabled, only show responses that are + // likely tied to a user-initiated command. + if (!_showTelemetry && !_expectTelemetryEcho) + { + return; + } + + _expectTelemetryEcho = false; + + AddEntryToHistory(new TerminalEntry + { + Text = response.Trim(), + Type = TerminalEntryType.Received, + Timestamp = DateTime.Now + }); + InvokeAsync(StateHasChanged); + InvokeAsync(ScrollToBottom); + } + + protected override void HandleConnectionChanged(object? sender, bool connected) + { + base.HandleConnectionChanged(sender, connected); + + var currentName = PrinterServiceFactory.Current?.ConnectionName ?? string.Empty; + var message = connected ? string.Format(Localizer[Resources.CommandPrompt_ConnectedMessage], currentName) + : string.Format(Localizer[Resources.CommandPrompt_DisconnectedMessage], currentName); + AddSystemMessage(message); + InvokeAsync(StateHasChanged); + } + + private void AddSystemMessage(string message, bool isError = false) + { + AddEntryToHistory(new TerminalEntry + { + Text = message, + Type = isError ? TerminalEntryType.System : TerminalEntryType.System, + Timestamp = DateTime.Now + }); + InvokeAsync(StateHasChanged); + InvokeAsync(ScrollToBottom); + } + + private async Task ScrollToBottom() + { + try + { + await JS.ScrollToBottom(historyContainer); + } + catch + { + // Ignore JS interop errors + } + } + + private void AddEntryToHistory(TerminalEntry entry) + { + lock (_historyLock) + { + history.Add(entry); + // Limit history size to prevent memory issues + if (history.Count > 1000) + { + history.RemoveAt(0); + } + } + } + + private List GetHistorySnapshot() + { + lock (_historyLock) + { + return new List(history); + } + } +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Components/ControlPanel.razor b/src/MakerPrompt.UI.Components/Components/ControlPanel.razor new file mode 100644 index 0000000..3f70082 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/ControlPanel.razor @@ -0,0 +1,465 @@ +@inherits ConnectionComponentBase +@inject PrinterStorageProvider PrinterStorage +@inject IAppLocalStorageProvider AppStorage + +@* ── Print Status Banner (only while printing) ── *@ +@if (Telemetry.SDCard.Printing || Telemetry.Status == PrinterStatus.Printing) +{ +
+
+ @* SVG progress ring: r=15.9 → circumference ≈ 100, dasharray == percentage *@ +
+ + + + +
+ @($"{Telemetry.SDCard.Progress:F0}")% +
+
+ @* Job info + bar *@ +
+
+ Printing + @if (!string.IsNullOrWhiteSpace(Telemetry.PrintJobName)) + { + @Telemetry.PrintJobName + } +
+
+
+
+
+ @* Stats strip *@ +
+
+ Speed + @Telemetry.FeedRate% +
+
+ Flow + @Telemetry.FlowRate% +
+ @if (Telemetry.PrintDuration > TimeSpan.Zero) + { +
+ Elapsed + @($"{(int)Telemetry.PrintDuration.TotalHours:D2}:{Telemetry.PrintDuration.Minutes:D2}") +
+ } + @if (Telemetry.FilamentUsed > 0) + { +
+ Filament + @($"{Telemetry.FilamentUsed:F1}")m +
+ } +
+
+
+} + +
+ + @* ── Left: Position + Webcam ── *@ +
+
+
+ + @Localizer[Resources.ControlPanel_Position] +
+
+ @* Position readout *@ +
+
+ X + @Telemetry.Position.X.ToString("F2") +
+
+ Y + @Telemetry.Position.Y.ToString("F2") +
+
+ Z + @Telemetry.Position.Z.ToString("F2") +
+
+ +
+
+ @* Jog speeds *@ +
+
+
+ XY + + mm/min +
+
+
+
+ Z + + mm/min +
+
+
+ @* Axis + jog buttons *@ +
+
+ + + + + +
+
+ @foreach (var length in moveLenghts) + { + + } +
+
+ + +
+
+
+
+
+
+
+ +
+
+ + @* ── Right: Temps + Extrude + Speed/Flow + Calibration ── *@ +
+ + @* Temperatures *@ +
+
+ + @Localizer[Resources.ControlPanel_Heating] +
+
+ @* Preheat presets *@ +
+ @foreach (var (label, hotend, bed) in PreheatProfiles) + { + var h = hotend; var b = bed; + + } +
+ @* Hotend *@ +
+
+ + + @Localizer[Resources.PrinterComponent_Hotend] + + + @($"{Telemetry.HotendTemp:F1}")°C + / @($"{Telemetry.HotendTarget:F0}")°C + +
+
+
60 ? "bg-warning" : "bg-secondary")" + style="width:@($"{Math.Min(Telemetry.HotendTemp / 300.0 * 100, 100):F0}%")">
+
+
+ + °C + +
+
+ @* Bed *@ +
+
+ + + @Localizer[Resources.PrinterComponent_Heatbed] + + + @($"{Telemetry.BedTemp:F1}")°C + / @($"{Telemetry.BedTarget:F0}")°C + +
+
+
+
+
+ + °C + +
+
+ @* Chamber temp (shown when data is available from printer) *@ + @if (Telemetry.ChamberTemp > 0 || Telemetry.ChamberTarget > 0) + { +
+
+ + + Chamber + + + @($"{Telemetry.ChamberTemp:F1}")°C + / @($"{Telemetry.ChamberTarget:F0}")°C + +
+
+
+
+
+ } + @* Fan *@ +
+
+ + + @Localizer[Resources.ControlPanel_FanSpeed] + + @Telemetry.FanSpeed% +
+
+
+
+
+ + % + +
+
+
+
+ + @* Extrude *@ +
+
+ + @Localizer[Resources.ControlPanel_Extrude] +
+
+
+
+
+ @Localizer[Resources.ControlPanel_Length] + + mm +
+
+
+
+ @Localizer[Resources.ControlPanel_Speed] + + mm/min +
+
+
+
+ + +
+
+
+ + @* Speed / Flow *@ +
+
+ + @Localizer[Resources.ControlPanel_Status] +
+
+
+
+ +
+ + % +
+
+
+ +
+ + % +
+
+
+
+
+ + @* Calibration *@ +
+
+ + @Localizer[Resources.GCodeCategory_Calibration] +
+
+ +
+
+
+ + @* ── File storage ── *@ +
+ +
+
+ +@code { + private int hotendTarget = 0; + private int bedTarget = 0; + private int fanTarget = 0; + private int extrudeLength = 5; + private int extrudeSpeed = 100; + private int xySpeed = 1000; + private int zSpeed = 100; + private int printSpeed = 100; + private int printFlow = 100; + + private static readonly (string Label, int Hotend, int Bed)[] PreheatProfiles = + [ + ("PLA", 200, 60), + ("PETG", 230, 70), + ("ABS", 240, 100), + ("ASA", 245, 105), + ("TPU", 220, 40), + ("Cooldown", 0, 0), + ]; + private bool isInverse; + private int[] moveLenghts = { 1, 10, 100, 500 }; + private AxisTab ActiveTab = AxisTab.Y; + private enum AxisTab { X, Y, Z } + + private void MotionReversed(ChangeEventArgs e) + { + isInverse = e.Value as bool? ?? false; + } + + private void SetAxis(AxisTab tab) + { + ActiveTab = tab; + } + + private Task HomeAllAsync() + { + var printer = PrinterServiceFactory.Current; + return printer?.Home() ?? Task.CompletedTask; + } + + private async Task HomeSelectedAxis() + { + var printer = PrinterServiceFactory.Current; + if (printer == null) return; + + switch (ActiveTab) + { + case AxisTab.X: + await printer.Home(true, false, false); + break; + case AxisTab.Y: + await printer.Home(false, true, false); + break; + case AxisTab.Z: + await printer.Home(false, false, true); + break; + } + } + + private async Task MoveSelectedAxis(int length) + { + var printer = PrinterServiceFactory.Current; + if (printer == null) return; + + switch (ActiveTab) + { + case AxisTab.X: + await printer.RelativeMove(xySpeed, length, 0, 0); + break; + case AxisTab.Y: + await printer.RelativeMove(xySpeed, 0, length, 0); + break; + case AxisTab.Z: + await printer.RelativeMove(zSpeed, 0, 0, length); + break; + } + } + + private Task SetHotendTempAsync() + { + var printer = PrinterServiceFactory.Current; + return printer?.SetHotendTemp(hotendTarget) ?? Task.CompletedTask; + } + + private Task SetBedTempAsync() + { + var printer = PrinterServiceFactory.Current; + return printer?.SetBedTemp(bedTarget) ?? Task.CompletedTask; + } + + private Task SetFanSpeedAsync() + { + var printer = PrinterServiceFactory.Current; + return printer?.SetFanSpeed(fanTarget) ?? Task.CompletedTask; + } + + private async Task PreheatAsync(int hotend, int bed) + { + var printer = PrinterServiceFactory.Current; + if (printer == null) return; + hotendTarget = hotend; + bedTarget = bed; + await printer.SetHotendTemp(hotend); + await printer.SetBedTemp(bed); + } + + private Task ExtrudeAsync(int amount) + { + var printer = PrinterServiceFactory.Current; + return printer?.RelativeMove(extrudeSpeed, 0, 0, 0, amount) ?? Task.CompletedTask; + } + + private Task UpdatePrintSpeedAsync() + { + var printer = PrinterServiceFactory.Current; + return printer?.SetPrintSpeed(printSpeed) ?? Task.CompletedTask; + } + + private Task UpdatePrintFlowAsync() + { + var printer = PrinterServiceFactory.Current; + return printer?.SetPrintFlow(printFlow) ?? Task.CompletedTask; + } + + private readonly PrinterTelemetry fallbackTelemetry = new(); + private PrinterTelemetry Telemetry => PrinterServiceFactory.Current?.LastTelemetry ?? fallbackTelemetry; +} diff --git a/src/MakerPrompt.UI.Components/Components/CultureSelector.razor b/src/MakerPrompt.UI.Components/Components/CultureSelector.razor new file mode 100644 index 0000000..097442c --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/CultureSelector.razor @@ -0,0 +1,47 @@ +@using System.Globalization +@using Microsoft.JSInterop +@inject IJSRuntime JS +@inject IAppConfigurationService AppConfigurationService +@inject NavigationManager Navigation + + + +@code +{ + private List supportedCultures = new(); + + private CultureInfo? selectedCulture; + + protected override void OnInitialized() + { + supportedCultures = AppConfigurationService.Configuration.SupportedCultures.Select((s) => new CultureInfo(s)).ToList(); + selectedCulture = CultureInfo.CurrentCulture; + } + + private async Task ApplySelectedCultureAsync(CultureInfo culture) + { + selectedCulture = culture; + if (CultureInfo.CurrentCulture != selectedCulture) + { + await JS.InvokeVoidAsync("blazorCulture.set", selectedCulture!.Name); + AppConfigurationService.Configuration.Language = selectedCulture.Name; + Thread.CurrentThread.CurrentCulture = selectedCulture; + Thread.CurrentThread.CurrentUICulture = selectedCulture; + CultureInfo.DefaultThreadCurrentCulture = selectedCulture; + CultureInfo.DefaultThreadCurrentUICulture = selectedCulture; + await AppConfigurationService.SaveConfigurationAsync(); + Navigation.NavigateTo(Navigation.Uri, forceLoad: true); + } + StateHasChanged(); + } +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Components/FileExplorer.razor b/src/MakerPrompt.UI.Components/Components/FileExplorer.razor new file mode 100644 index 0000000..10c1f00 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/FileExplorer.razor @@ -0,0 +1,215 @@ +@inherits ConnectionComponentBase +@inject IAppLocalStorageProvider AppStorage +@inject PrinterStorageProvider PrinterStorage +@inject GCodeDocumentService GCodeDoc + +
+
+ + + + +
+
+ +
+ + + + + + + + + + @if (IsConnected) + { + @if (_isLoading) + { + + + + } + else + { + @foreach (var item in _files) + { + + + + + + } + } + } + else + { + + + + } + +
@Localizer[Resources.Files_Name]@Localizer[Resources.Files_DateModified]@Localizer[Resources.Files_Size]
+
+ Loading... +
+
@item.FullPath@item.ModifiedDate?.ToString("g")@FormatSize(item.Size)
+ @Localizer[Resources.PrinterStatus_Disconnected] +
+
+ +@code { + private List _files = new(); + private bool _isLoading; + private bool _isOpening; + private bool _isCopying; + private FileEntry? selectedFile; + + protected override async Task OnInitializedAsync() + { + await RefreshFiles(); + } + + protected override void HandleConnectionChanged(object? sender, bool connected) + { + base.HandleConnectionChanged(sender, connected); + if (connected) + { + _ = RefreshFiles(); + } + else + { + _files.Clear(); + selectedFile = null; + StateHasChanged(); + } + } + + private async Task RefreshFiles() + { + _isLoading = true; + StateHasChanged(); + + var printerService = PrinterServiceFactory.Current; + if (printerService != null) + { + await RunAsync(async () => + { + _files = await printerService.GetFilesAsync() ?? new List(); + }, "Failed to load files"); + } + else + { + _files = new List(); + } + + _isLoading = false; + StateHasChanged(); + } + + private string FormatSize(long bytes) + { + string[] sizes = { "B", "KB", "MB", "GB" }; + int order = 0; + double len = bytes; + + while (len >= 1024 && order < sizes.Length - 1) + { + order++; + len /= 1024; + } + + return $"{len:0.##} {sizes[order]}"; + } + + private void SelectFile(FileEntry file) + { + selectedFile = file; + } + + private bool CanStartPrint => selectedFile != null && selectedFile.Size > 0; + + private string GetRowClass(FileEntry file) + { + return selectedFile == file ? "table-active" : string.Empty; + } + + private Task StartPrintAsync() + { + if (selectedFile == null || PrinterServiceFactory.Current == null) + return Task.CompletedTask; + var file = selectedFile; + var printer = PrinterServiceFactory.Current; + return RunAsync(() => printer.StartPrint(file), "Failed to start print"); + } + + private async Task CopySelectedToApp() + { + if (selectedFile == null) return; + var file = selectedFile; + _isCopying = true; + StateHasChanged(); + try + { + await RunAsync(async () => + { + await using var stream = await PrinterStorage.OpenReadAsync(file.FullPath) ?? Stream.Null; + if (stream == Stream.Null) return; + await AppStorage.SaveFileAsync(Path.GetFileName(file.FullPath), stream); + }, "Failed to copy file"); + } + finally + { + _isCopying = false; + StateHasChanged(); + } + } + + private async Task OpenSelectedPrinter() + { + if (selectedFile == null) return; + var file = selectedFile; + _isOpening = true; + StateHasChanged(); + try + { + await RunAsync(async () => + { + await using var stream = await PrinterStorage.OpenReadAsync(file.FullPath) ?? Stream.Null; + if (stream == Stream.Null) return; + using var reader = new StreamReader(stream); + var content = await reader.ReadToEndAsync(); + GCodeDoc.SetGCode(content); + }, "Failed to open file"); + } + finally + { + _isOpening = false; + StateHasChanged(); + } + } +} diff --git a/src/MakerPrompt.UI.Components/Components/GCodeViewer.razor b/src/MakerPrompt.UI.Components/Components/GCodeViewer.razor new file mode 100644 index 0000000..a851826 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/GCodeViewer.razor @@ -0,0 +1,203 @@ +@inject GCodeDocumentService GCodeDoc +@inject IStringLocalizer localizer +@inject IAppLocalStorageProvider AppStorage +@inject PrinterStorageProvider PrinterStorage +@inject ToastService ToastService +@inherits ConnectionComponentBase + +
+
+
+ + + +
+ +
+ + + +
+
@localizer[Resources.GCode_Title]
+
+
+ + + + + +
+
+
+ @if (showVisual) + { + @if (_viewerFailed) + { +
+ + Visual G-code preview is not available on this device. Switch to text view. +
+ } +
+ @if (_viewerLoading) + { +
+
+ Loading viewer… +
+
+ } +
+
+ } + else + { + @if (IsGCodeTruncated) + { +
+ + Large file — showing first @(MaxTextChars / 1024) KB of @($"{GCodeDoc.CurrentGCode!.Length / 1024.0 / 1024.0:0.1}") MB +
+ } + + } +
+
+ +@code { + private ElementReference viewerContainer; + private bool showVisual = false; + // Only reinitialise when content or mode actually changes — prevents re-init on every telemetry poll. + private bool _viewerNeedsReset = false; + private bool _viewerFailed = false; + private bool _viewerLoading = false; + private string? _viewerGCode = null; + // Cap text preview to avoid freezing the browser with very large files. + private const int MaxTextChars = 500_000; + private string? GCodeDisplayText => GCodeDoc.CurrentGCode?.Length > MaxTextChars + ? GCodeDoc.CurrentGCode.Substring(0, MaxTextChars) : GCodeDoc.CurrentGCode; + private bool IsGCodeTruncated => (GCodeDoc.CurrentGCode?.Length ?? 0) > MaxTextChars; + + private bool CanPrint => IsConnected && !string.IsNullOrWhiteSpace(GCodeDoc.CurrentGCode) && PrinterServiceFactory.Current != null; + + protected override void OnInitialized() + { + base.OnInitialized(); + GCodeDoc.Changed += OnDocChanged; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (showVisual && _viewerNeedsReset && !string.IsNullOrWhiteSpace(GCodeDoc.CurrentGCode)) + { + _viewerNeedsReset = false; + _viewerGCode = GCodeDoc.CurrentGCode; + _viewerLoading = true; + StateHasChanged(); + try + { + await JS.InitializeViewerAsync(viewerContainer, GCodeDoc.CurrentGCode!); + } + catch (JSException ex) when (ex.Message.Contains("bgcode")) + { + ToastService.Notify(new(ToastType.Warning, "Binary G-code", "bgcode format cannot be visualised. Use the text view.")); + } + catch (Exception) + { + _viewerFailed = true; + } + finally + { + _viewerLoading = false; + StateHasChanged(); + } + } + } + + private void OnDocChanged() + { + if (GCodeDoc.CurrentGCode != _viewerGCode) + { + _viewerNeedsReset = true; + _viewerFailed = false; + } + StateHasChanged(); + } + + private void ToggleMode(ChangeEventArgs e) + { + var was = showVisual; + showVisual = e.Value is bool b ? b : showVisual; + if (showVisual && !was) + { + _viewerNeedsReset = true; + _viewerFailed = false; + } + StateHasChanged(); + } + + private async Task CopyGCode() + { + try + { + await JS.CopyToClipboard(GCodeDoc.CurrentGCode!); + } + catch (Exception) + { + } + } + + private async Task ExportToApp() + { + var content = GCodeDoc.CurrentGCode; + if (string.IsNullOrWhiteSpace(content)) return; + var name = $"export_{DateTime.Now:yyyyMMdd_HHmmss}.gcode"; + await using var ms = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content)); + await AppStorage.SaveFileAsync(name, ms); + } + + private async Task ExportToPrinter() + { + var content = GCodeDoc.CurrentGCode; + if (string.IsNullOrWhiteSpace(content)) return; + var name = $"printer_{DateTime.Now:yyyyMMdd_HHmmss}.gcode"; + await using var ms = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content)); + await PrinterStorage.SaveFileAsync(name, ms); + } + + private async Task PrintCurrentGCode() + { + if (!CanPrint) return; + var service = PrinterServiceFactory.Current; + if (service == null) return; + + try + { + await service.StartPrint(GCodeDoc.Document); + } + catch + { + // Swallow UI print errors; telemetry and other UI will indicate issues. + } + } + + public new async ValueTask DisposeAsync() + { + GCodeDoc.Changed -= OnDocChanged; + await base.DisposeAsync(); + } +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Components/GlobalErrorBoundary.cs b/src/MakerPrompt.UI.Components/Components/GlobalErrorBoundary.cs new file mode 100644 index 0000000..9be1f10 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/GlobalErrorBoundary.cs @@ -0,0 +1,43 @@ +using BlazorBootstrap; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.UI.Components.Components; + +/// +/// Global error boundary that catches unhandled UI/component exceptions, +/// logs them via ILogger and surfaces a toast notification via ToastService. +/// Overrides BuildRenderTree to always render child content so the default +/// Blazor error UI (red banner) is never shown. +/// +public class GlobalErrorBoundary : ErrorBoundary +{ + [Inject] + private ILogger Logger { get; set; } = null!; + + [Inject] + private ToastService ToastService { get; set; } = null!; + + protected override async Task OnErrorAsync(Exception ex) + { + Logger.LogError(ex, "Unhandled UI exception"); + // Reset error state first so the boundary re-renders child content. + Recover(); + // Yield to let the recovery render batch complete before notifying, + // otherwise the Toasts StateHasChanged gets swallowed in the same batch. + await Task.Yield(); + ToastService.Notify(new ToastMessage( + ToastType.Danger, + "An unexpected error occurred", + ex.Message)); + } + + // Always render child content — never let the base class swap in the red + // error banner, regardless of whether CurrentException is set. + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, ChildContent); + } +} diff --git a/src/MakerPrompt.UI.Components/Components/LocalizedLabel.razor b/src/MakerPrompt.UI.Components/Components/LocalizedLabel.razor new file mode 100644 index 0000000..5321a18 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/LocalizedLabel.razor @@ -0,0 +1,26 @@ +@using System.Linq.Expressions +@using System.ComponentModel +@using System.Reflection + +@Label + +@code { + + [Parameter] public required Expression> For { get; set; } + [Inject] IStringLocalizer Localizer { get; set; } = default!; + + protected override void OnParametersSet() + { + base.OnParametersSet(); + Label = Localizer[GetDisplayName()]; + } + + private string Label { get; set; } = string.Empty; + + private string GetDisplayName() + { + var expression = (MemberExpression)For.Body; + var value = expression.Member.GetCustomAttribute(typeof(DisplayNameAttribute)) as DisplayNameAttribute; + return value?.DisplayName ?? expression.Member.Name ?? ""; + } +} diff --git a/src/MakerPrompt.UI.Components/Components/LocalizedTitle.razor b/src/MakerPrompt.UI.Components/Components/LocalizedTitle.razor new file mode 100644 index 0000000..3120dbf --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/LocalizedTitle.razor @@ -0,0 +1,36 @@ +@using Microsoft.AspNetCore.Components.Web +@inject LocalizedTitleService TitleService + +@TitleService.CurrentTitle + +@code { + [Parameter] + public string TitleKey { get; set; } = string.Empty; + + [Parameter] + public object[] TitleArguments { get; set; } = Array.Empty(); + + protected override void OnInitialized() + { + base.OnInitialized(); + TitleService.OnTitleChanged += HandleTitleChanged; + } + + protected override void OnParametersSet() + { + if (!string.IsNullOrEmpty(TitleKey)) + { + TitleService.SetTitle(TitleKey, TitleArguments); + } + } + + private void HandleTitleChanged() + { + StateHasChanged(); + } + + public void Dispose() + { + TitleService.OnTitleChanged -= HandleTitleChanged; + } +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Components/PidCalibration.razor b/src/MakerPrompt.UI.Components/Components/PidCalibration.razor new file mode 100644 index 0000000..dc7a5ab --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/PidCalibration.razor @@ -0,0 +1,66 @@ +@inherits ConnectionComponentBase + +
+
+ + +
+ +
+ + +
+ +
+ + + Bed = -1, Hotend = 0 +
+ + + + @if (!string.IsNullOrEmpty(calibrationResult)) + { +
+

@Localizer[Resources.CalibrationPage_Result]:

+
@calibrationResult
+
+ } +
+ +@code{ + private CalibrationParameters Calibration { get; set; } = new(); + private int HeaterIndex { get; set; } = 0; + private string calibrationResult = string.Empty; + + private async Task RunPidTuning() + { + if (PrinterServiceFactory.Current == null) return; + var command = GCodeCommands.PidAutotune + .SetParameterValue(GCodeParameters.CalibrationCycle.Label, Calibration.Cycles.ToString()) + .SetParameterValue(GCodeParameters.TargetTemp.Label, Calibration.Temperature.ToString()) + .SetParameterValue(GCodeParameters.PositionE.Label, HeaterIndex.ToString()) + .ToString(); + await PrinterServiceFactory.Current.WriteDataAsync(command); + //StateService.Telemetry.PropertyChanged += HandlePidResult; + } + + protected override void HandleTelemetryUpdated(object? sender, PrinterTelemetry printerTelemetry) + { + // printerTelemetry + // if (e.PropertyName == nameof(PrinterTelemetry.LastResponse)) + // { + // var response = printerTelemety.LastResponse; + // if (response.Contains("Kp") || response.Contains("PID")) + // { + // calibrationResult += response + Environment.NewLine; + // InvokeAsync(StateHasChanged); + // } + // } + } +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Components/PrintQueue.razor b/src/MakerPrompt.UI.Components/Components/PrintQueue.razor new file mode 100644 index 0000000..ba45bd8 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/PrintQueue.razor @@ -0,0 +1,398 @@ +@* ───────────────────────────────────────────────────────────────────── + PrintQueue — Project-based print job manager. + + Upload G-code files, organize them into project folders, and dispatch + individual jobs to any connected idle printer in the fleet. + ───────────────────────────────────────────────────────────────────── *@ +@using MakerPrompt.UI.Components.Infrastructure +@using static MakerPrompt.UI.Components.Utils.Enums +@inject PrintProjectService ProjectService +@inject PrinterConnectionManager ConnectionManager +@inject IStringLocalizer localizer +@inject ToastService ToastService +@inject ILogger Logger +@implements IDisposable + +
+
+
+ + @localizer[Resources.PrintQueue_Title] +
+
+ @if (_showNewProjectInput) + { +
+ + + +
+ } + else + { + + } +
+
+
+ @if (!ProjectService.Projects.Any()) + { +
+ +

@localizer[Resources.PrintQueue_NoProjects]

+
+ } + else + { +
+ @foreach (var project in ProjectService.Projects) + { + var collapseId = $"pq-{project.Id:N}"; + var headerId = $"pqh-{project.Id:N}"; + var isExpanded = _expandedProjectId == project.Id; + var jobCounts = GetJobCounts(project); + +
+

+ +

+
+
+ @* ── Project actions ── *@ +
+ + +
+ + @if (!project.Jobs.Any()) + { +

+ @localizer[Resources.PrintQueue_NoFiles] +

+ } + else + { +
+ @foreach (var job in project.Jobs) + { +
+
+ +
+
+ @job.FileName + @GetJobStatusText(job.Status) +
+
+ @if (job.Size > 0) + { + @FormatFileSize(job.Size) + } + @if (!string.IsNullOrEmpty(job.AssignedPrinterName)) + { + + @job.AssignedPrinterName + + } +
+
+
+ @if (job.Status == PrintJobStatus.Queued && _idlePrinters.Any()) + { +
+ + +
+ } + +
+
+
+ } +
+ } +
+
+
+ } +
+ } +
+
+ +@code { + private List _idlePrinters = new(); + private Guid? _expandedProjectId; + private bool _showNewProjectInput; + private string _newProjectName = string.Empty; + private readonly Dictionary _jobTargetPrinter = new(); + private readonly HashSet _sendingJobs = new(); + + protected override void OnInitialized() + { + ProjectService.ProjectsChanged += HandleProjectsChanged; + ConnectionManager.PrintersChanged += HandlePrintersChanged; + RefreshIdlePrinters(); + InitJobTargets(); + } + + private async void HandleProjectsChanged(object? sender, EventArgs e) + { + InitJobTargets(); + await InvokeAsync(StateHasChanged); + } + + private async void HandlePrintersChanged(object? sender, EventArgs e) + { + RefreshIdlePrinters(); + await InvokeAsync(StateHasChanged); + } + + private void RefreshIdlePrinters() + { + _idlePrinters = ConnectionManager.Printers + .Where(p => p.Service?.IsConnected == true && p.Status == PrinterStatus.Connected) + .ToList(); + } + + private void InitJobTargets() + { + var defaultPrinterId = _idlePrinters.FirstOrDefault()?.Definition.Id ?? Guid.Empty; + foreach (var project in ProjectService.Projects) + { + foreach (var job in project.Jobs) + { + _jobTargetPrinter.TryAdd(job.Id, defaultPrinterId); + } + } + } + + private void ToggleProject(Guid projectId) + { + _expandedProjectId = _expandedProjectId == projectId ? null : projectId; + } + + // ── New project ── + private async Task CreateProjectAsync() + { + if (string.IsNullOrWhiteSpace(_newProjectName)) return; + try + { + await ProjectService.AddProjectAsync(_newProjectName); + _newProjectName = string.Empty; + _showNewProjectInput = false; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to create project"); + ToastService.Notify(new ToastMessage(ToastType.Danger, "Error", "Failed to create project")); + } + } + + private void HandleNewProjectKeyPress(KeyboardEventArgs e) + { + if (e.Key == "Enter") + _ = CreateProjectAsync(); + } + + // ── Delete project ── + private async Task DeleteProjectAsync(Guid projectId) + { + try + { + await ProjectService.DeleteProjectAsync(projectId); + if (_expandedProjectId == projectId) + _expandedProjectId = null; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to delete project"); + ToastService.Notify(new ToastMessage(ToastType.Danger, "Error", "Failed to delete project")); + } + } + + // ── Upload files ── + private async Task UploadFilesAsync(Guid projectId, InputFileChangeEventArgs e) + { + foreach (var file in e.GetMultipleFiles(20)) + { + try + { + await using var stream = file.OpenReadStream(long.MaxValue); + await ProjectService.AddJobAsync(projectId, file.Name, stream); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to upload {File}", file.Name); + ToastService.Notify(new ToastMessage(ToastType.Danger, "Upload failed", file.Name)); + } + } + InitJobTargets(); + } + + // ── Remove job ── + private async Task RemoveJobAsync(Guid projectId, Guid jobId) + { + try + { + await ProjectService.RemoveJobAsync(projectId, jobId); + _jobTargetPrinter.Remove(jobId); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to remove job"); + ToastService.Notify(new ToastMessage(ToastType.Danger, "Error", "Failed to remove job")); + } + } + + // ── Send to printer ── + private async Task SendToPrinterAsync(Guid projectId, PrintJob job) + { + if (!_jobTargetPrinter.TryGetValue(job.Id, out var targetId)) return; + var target = _idlePrinters.FirstOrDefault(p => p.Definition.Id == targetId); + if (target?.Service == null) return; + + _sendingJobs.Add(job.Id); + StateHasChanged(); + + try + { + // Read G-code content from storage + using var stream = await ProjectService.OpenJobFileAsync(projectId, job.Id); + if (stream == null) + { + ToastService.Notify(new ToastMessage(ToastType.Warning, "Warning", "File not found in storage")); + return; + } + + using var reader = new StreamReader(stream); + var gcode = await reader.ReadToEndAsync(); + var doc = new GCodeDoc(gcode); + + // Mark as printing + await ProjectService.AssignJobAsync(projectId, job.Id, target.Definition.Id, target.Definition.Name); + + // Start print + await target.Service.StartPrint(doc); + + ToastService.Notify(new ToastMessage( + ToastType.Success, + localizer[Resources.PrintQueue_PrintStarted], + $"{job.FileName} → {target.Definition.Name}")); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to send print to {Printer}", target.Definition.Name); + ToastService.Notify(new ToastMessage(ToastType.Danger, "Print failed", ex.Message)); + await ProjectService.UpdateJobStatusAsync(projectId, job.Id, PrintJobStatus.Failed); + } + finally + { + _sendingJobs.Remove(job.Id); + StateHasChanged(); + } + } + + // ── Helpers ── + private static (int queued, int printing, int completed, int failed) GetJobCounts(PrintProject project) + { + return ( + project.Jobs.Count(j => j.Status == PrintJobStatus.Queued), + project.Jobs.Count(j => j.Status == PrintJobStatus.Printing), + project.Jobs.Count(j => j.Status == PrintJobStatus.Completed), + project.Jobs.Count(j => j.Status == PrintJobStatus.Failed) + ); + } + + private string GetJobStatusBadge(PrintJobStatus status) + { + return status switch + { + PrintJobStatus.Queued => "bg-secondary", + PrintJobStatus.Printing => "bg-primary", + PrintJobStatus.Completed => "bg-success", + PrintJobStatus.Failed => "bg-danger", + _ => "bg-secondary" + }; + } + + private string GetJobStatusText(PrintJobStatus status) + { + return status switch + { + PrintJobStatus.Queued => localizer[Resources.PrintQueue_Queued], + PrintJobStatus.Printing => localizer[Resources.PrintQueue_Printing], + PrintJobStatus.Completed => localizer[Resources.PrintQueue_Completed], + PrintJobStatus.Failed => localizer[Resources.PrintQueue_Failed], + _ => status.ToString() + }; + } + + private static string FormatFileSize(long bytes) + { + if (bytes < 1024) + return $"{bytes} B"; + if (bytes < 1024 * 1024) + return $"{bytes / 1024.0:F1} KB"; + return $"{bytes / (1024.0 * 1024.0):F1} MB"; + } + + public void Dispose() + { + ProjectService.ProjectsChanged -= HandleProjectsChanged; + ConnectionManager.PrintersChanged -= HandlePrintersChanged; + } +} diff --git a/src/MakerPrompt.UI.Components/Components/PrinterConnectionModal.razor b/src/MakerPrompt.UI.Components/Components/PrinterConnectionModal.razor new file mode 100644 index 0000000..a393c1b --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/PrinterConnectionModal.razor @@ -0,0 +1,318 @@ +@* ───────────────────────────────────────────────────────────────────── + PrinterConnectionModal — Reusable add/edit printer modal. + + Call ShowAddAsync() or ShowEditAsync(definition) to open it. + Connects automatically after saving when _connectAfterSave is set + (default true for new printers in single-printer mode). + ───────────────────────────────────────────────────────────────────── *@ +@using MakerPrompt.UI.Components.Infrastructure +@using static MakerPrompt.UI.Components.Utils.Enums +@inject PrinterConnectionManager ConnectionManager +@inject FilamentInventoryService FilamentInventoryService +@inject ISerialService SerialService +@inject IAppConfigurationService ConfigService +@inject IStringLocalizer localizer +@inject ToastService ToastService +@inject ILogger Logger + + + +
+ + +
+ +
+ + +
+ + @if (_editConnectionType == PrinterConnectionType.Serial) + { +
+
+ Port + + +
+
+
+
+ + bps +
+
+ } + else if (_editConnectionType == PrinterConnectionType.Demo) + { +
+

@localizer[Resources.NavConnection_DemoServiceDescription]

+
+ } + else + { +
+ + +
+ + @if (_editConnectionType == PrinterConnectionType.BambuLab) + { +
+ + +
+
+ + +
+ } + else if (_editConnectionType == PrinterConnectionType.OctoPrint) + { +
+ + +
+ } + else + { +
+ + +
+
+ + +
+ } + } + +
+ + +
+ + @if (!string.IsNullOrEmpty(_connectionError)) + { + + } + + @if (ConfigService.Configuration.EnableFilamentInventory) + { +
+ + +
+ } +
+ + + + +
+ +@code { + [Parameter] public EventCallback OnSaved { get; set; } + + private Modal _modal = default!; + private bool _isEditing; + // In non-farm mode, always attempt an immediate connection after saving a new printer. + // Not exposed as a checkbox — "Auto-connect on startup" is the only visible flag. + private bool _connectAfterSave; + private bool _isSaving; + private string? _connectionError; + + private PrinterConnectionDefinition _editDefinition = new(); + private ApiConnectionSettings _editApiSettings = new(); + private SerialConnectionSettings _editSerialSettings = new(); + private PrinterConnectionType _editConnectionType = PrinterConnectionType.Demo; + private List _availablePorts = new(); + private readonly List BaudRates = [9600, 19200, 38400, 57600, 115200, 250000]; + // PrusaConnect uses the fleet-picker flow (PrusaConnectProvider), not this modal. + private static readonly PrinterConnectionType[] _connectionTypes = + Enum.GetValues() + .Where(t => t != PrinterConnectionType.PrusaConnect) + .ToArray(); + + public async Task ShowAddAsync() + { + _isEditing = false; + _connectAfterSave = !ConfigService.Configuration.FarmModeEnabled; + _connectionError = null; + _editDefinition = new(); + _editApiSettings = new(); + _editSerialSettings = new(); + _editConnectionType = PrinterConnectionType.Demo; + await _modal.ShowAsync(); + } + + public async Task ShowEditAsync(PrinterConnectionDefinition definition) + { + _isEditing = true; + _connectAfterSave = false; + _connectionError = null; + _editDefinition = new PrinterConnectionDefinition + { + Id = definition.Id, + Name = definition.Name, + ConnectionType = definition.ConnectionType, + AutoConnect = definition.AutoConnect, + Color = definition.Color, + Notes = definition.Notes, + CreatedAt = definition.CreatedAt, + LastConnectedAt = definition.LastConnectedAt, + AssignedFilamentSpoolId = definition.AssignedFilamentSpoolId, + }; + _editConnectionType = definition.ConnectionType; + _editApiSettings = definition.Settings.Api is not null + ? new ApiConnectionSettings(definition.Settings.Api.Url, definition.Settings.Api.UserName, definition.Settings.Api.Password) + : new ApiConnectionSettings(); + _editSerialSettings = definition.Settings.Serial is not null + ? new SerialConnectionSettings { PortName = definition.Settings.Serial.PortName, BaudRate = definition.Settings.Serial.BaudRate } + : new SerialConnectionSettings(); + await _modal.ShowAsync(); + } + + private async Task CloseAsync() + { + _connectionError = null; + await _modal.HideAsync(); + } + + private async Task SaveAsync() + { + if (string.IsNullOrWhiteSpace(_editDefinition.Name)) + { + ToastService.Notify(new ToastMessage(ToastType.Warning, localizer[Resources.Fleet_PrinterName] + " is required")); + return; + } + + // Validate URL for API-based backends before attempting anything. + if (_editConnectionType != PrinterConnectionType.Serial && _editConnectionType != PrinterConnectionType.Demo + && string.IsNullOrWhiteSpace(_editApiSettings.Url)) + { + _connectionError = "URL is required. Enter the printer\'s IP address or hostname (e.g. http://192.168.1.100)."; + return; + } + + _editDefinition.ConnectionType = _editConnectionType; + _editDefinition.Settings = _editConnectionType switch + { + PrinterConnectionType.Serial => new PrinterConnectionSettings(_editSerialSettings), + PrinterConnectionType.Demo => new PrinterConnectionSettings(), + _ => new PrinterConnectionSettings(_editApiSettings, _editConnectionType) + }; + + _isSaving = true; + _connectionError = null; + try + { + if (_isEditing) + { + await ConnectionManager.UpdatePrinterAsync(_editDefinition); + ToastService.Notify(new ToastMessage(ToastType.Success, "Printer updated")); + await _modal.HideAsync(); + await OnSaved.InvokeAsync(_editDefinition); + } + else + { + await ConnectionManager.AddPrinterAsync(_editDefinition); + + if (_connectAfterSave) + { + // Look up by Id — the definition already has the Guid assigned before saving. + var saved = ConnectionManager.Printers.FirstOrDefault(p => p.Definition.Id == _editDefinition.Id); + if (saved != null) + { + try + { + await ConnectionManager.ConnectPrinterAsync(saved.Definition.Id); + } + catch (Exception ex) + { + Logger.LogError(ex, "Auto-connect after save failed"); + } + + // ConnectPrinterAsync does not throw on failure — check state after the call. + var result = ConnectionManager.Printers.FirstOrDefault(p => p.Definition.Id == _editDefinition.Id); + if (result?.Status != PrinterStatus.Connected) + { + // Surface the error inline so the user can correct settings without re-opening the modal. + _connectionError = result?.LastError ?? "Could not reach the printer. Check the URL and credentials, then try again."; + Logger.LogWarning("Auto-connect after save failed: {Reason}", _connectionError); + return; // leave the modal open + } + } + + ToastService.Notify(new ToastMessage(ToastType.Success, $"{_editDefinition.Name} connected")); + } + else + { + ToastService.Notify(new ToastMessage(ToastType.Success, "Printer added")); + } + + await _modal.HideAsync(); + await OnSaved.InvokeAsync(_editDefinition); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to save printer definition"); + _connectionError = ex.Message; + } + finally + { + _isSaving = false; + } + } + + private async Task RefreshPortsAsync() + { + try + { + _availablePorts = (await SerialService.GetAvailablePortsAsync()).ToList(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to enumerate serial ports"); + ToastService.Notify(new ToastMessage(ToastType.Warning, "Could not refresh ports", ex.Message)); + } + } +} diff --git a/src/MakerPrompt.UI.Components/Components/ProcessError.razor b/src/MakerPrompt.UI.Components/Components/ProcessError.razor new file mode 100644 index 0000000..d233a58 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/ProcessError.razor @@ -0,0 +1,34 @@ +@using Microsoft.Extensions.Logging +@inject ILogger Logger +@inject ToastService ToastService + + + @ChildContent + + +@code { + /// Child content wrapped by this cascading error reporter. + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Call from any component's catch block to log and surface a toast for a + /// handled exception. + /// + /// + /// [CascadingParameter] ProcessError? ProcessError { get; set; } + /// + /// try { ... } + /// catch (Exception ex) { ProcessError?.Handle(ex); } + /// + /// + /// + public void Handle(Exception ex) + { + Logger.LogError(ex, "Handled UI exception"); + ToastService.Notify(new ToastMessage( + ToastType.Danger, + "An unexpected error occurred", + ex.Message)); + } +} diff --git a/src/MakerPrompt.UI.Components/Components/StorageExplorer.razor b/src/MakerPrompt.UI.Components/Components/StorageExplorer.razor new file mode 100644 index 0000000..1818368 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/StorageExplorer.razor @@ -0,0 +1,233 @@ +@inherits ConnectionComponentBase +@using System.IO +@inject GCodeDocumentService GCodeDoc + +@code { + [Parameter] public string Title { get; set; } = string.Empty; + [Parameter] public IStorageProvider Provider { get; set; } = null!; + [Parameter] public IStorageProvider? TargetProvider { get; set; } + [Parameter] public bool EnableUpload { get; set; } = false; + [Parameter] public bool EnableDelete { get; set; } = true; + [Parameter] public bool EnableOpen { get; set; } = true; + [Parameter] public bool EnableCopy { get; set; } = true; + /// + /// File path prefixes to hide from the listing (e.g. internal config files). + /// + [Parameter] public IReadOnlyList HiddenPrefixes { get; set; } = Array.Empty(); + + private List _files = new(); + private bool _isLoading; + private bool _isOpening; + private bool _isUploading; + private bool _isCopying; + private FileEntry? selectedFile; + + protected override async Task OnInitializedAsync() + { + await RefreshFiles(); + } + + private async Task RefreshFiles() + { + _isLoading = true; + StateHasChanged(); + var all = await Provider.ListFilesAsync(); + _files = HiddenPrefixes.Count > 0 + ? all.Where(f => !HiddenPrefixes.Any(prefix => f.FullPath.Contains(prefix, StringComparison.OrdinalIgnoreCase))).ToList() + : all; + _isLoading = false; + StateHasChanged(); + } + + private async Task OnFileSelected(InputFileChangeEventArgs e) + { + if (!EnableUpload) return; + var file = e.File; + if (file == null) return; + _isUploading = true; + StateHasChanged(); + try + { + await using var stream = file.OpenReadStream(long.MaxValue); + await Provider.SaveFileAsync(file.Name, stream); + } + finally + { + _isUploading = false; + StateHasChanged(); + } + await RefreshFiles(); + } + + private async Task CopySelected() + { + if (!EnableCopy || selectedFile == null || TargetProvider == null) return; + _isCopying = true; + StateHasChanged(); + try + { + await using var stream = await Provider.OpenReadAsync(selectedFile.FullPath) ?? Stream.Null; + if (stream == Stream.Null) return; + await TargetProvider.SaveFileAsync(Path.GetFileName(selectedFile.FullPath), stream); + } + finally + { + _isCopying = false; + StateHasChanged(); + } + } + + private async Task OpenSelected() + { + if (!EnableOpen || selectedFile == null) return; + _isOpening = true; + StateHasChanged(); + try + { + await using var stream = await Provider.OpenReadAsync(selectedFile.FullPath) ?? Stream.Null; + if (stream == Stream.Null) return; + using var reader = new StreamReader(stream); + var content = await reader.ReadToEndAsync(); + GCodeDoc.SetGCode(content); + } + finally + { + _isOpening = false; + StateHasChanged(); + } + } + + private async Task DeleteSelected() + { + if (!EnableDelete || selectedFile == null) return; + await Provider.DeleteFileAsync(selectedFile.FullPath); + selectedFile = null; + await RefreshFiles(); + } + + private string FormatSize(long bytes) + { + string[] sizes = { "B", "KB", "MB", "GB" }; + int order = 0; + double len = bytes; + while (len >= 1024 && order < sizes.Length - 1) + { + order++; + len /= 1024; + } + return $"{len:0.##} {sizes[order]}"; + } + + private void SelectFile(FileEntry file) + { + selectedFile = file; + } + + private string GetRowClass(FileEntry file) + { + return selectedFile == file ? "table-active" : string.Empty; + } +} + +
+

@Title

+
+
+
+ @Provider.DisplayName +
+
+ @if (EnableUpload) + { + var uploadId = $"fileUpload-{Title?.Replace(" ", string.Empty)}"; +
+ + +
+ } +
+ + @if (EnableCopy) + { + + } + @if (EnableOpen) + { + + } + @if (EnableDelete) + { + + } +
+
+
+ +
+ + + + + + + + + + @if (_isLoading) + { + + + + } + else + { + @foreach (var item in _files) + { + + + + + + } + } + +
@Localizer[Resources.Files_Name]@Localizer[Resources.Files_DateModified]@Localizer[Resources.Files_Size]
+
+ @Localizer[Resources.StorageExplorer_Loading] +
+
@Path.GetFileName(item.FullPath)@item.ModifiedDate?.ToString("g")@FormatSize(item.Size)
+
+
+
diff --git a/src/MakerPrompt.UI.Components/Components/TestCommandModal.razor b/src/MakerPrompt.UI.Components/Components/TestCommandModal.razor new file mode 100644 index 0000000..cf1f606 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/TestCommandModal.razor @@ -0,0 +1,50 @@ +@inject IStringLocalizer localizer +@inject ModalService ModalService + + +
+ @if (selectedCommand != null) + { +
+
@localizer[Resources.GCodeCommand_Description]
+

@localizer[selectedCommand.Description]

+
+ + @if (selectedCommand.Parameters.Any()) + { +
+
@localizer[Resources.GCodeCommand_Parameter]
+
+ @foreach (var param in selectedCommand.Parameters) + { +
+ @param.Label +
+
+ @param.Description +
+ } +
+
+ } + +
+
@localizer[Resources.Calculators_GCodeExample]
+ @selectedCommand.GetCommandExample() +
+ } +
+ +@code { + [Parameter] public string? Command { get; set; } + + private GCodeCommand selectedCommand { get; set; } = null!; + + protected override void OnParametersSet() + { + base.OnParametersSet(); + selectedCommand = GCodeCommands.AllCommands().First(g => g.Command.Equals(Command, StringComparison.CurrentCultureIgnoreCase)); + } + + //private async Task Close() => await ModalService.HideAsync(); +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Components/ThemeSelector.razor b/src/MakerPrompt.UI.Components/Components/ThemeSelector.razor new file mode 100644 index 0000000..5eba727 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/ThemeSelector.razor @@ -0,0 +1,33 @@ +@using static MakerPrompt.UI.Components.Utils.Enums +@inject ThemeService ThemeService +@inject IStringLocalizer localizer + + + + +@code { + private Theme selectedTheme; + + protected override async Task OnInitializedAsync() + { + await ThemeService.InitializeAsync(); + selectedTheme = ThemeService.CurrentTheme; + } + + private async Task UpdateTheme(Theme theme) + { + selectedTheme = theme; + await ThemeService.SetThemeAsync(selectedTheme); + StateHasChanged(); + } +} diff --git a/src/MakerPrompt.UI.Components/Components/ThermalModelCalibration.razor b/src/MakerPrompt.UI.Components/Components/ThermalModelCalibration.razor new file mode 100644 index 0000000..1b33a10 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/ThermalModelCalibration.razor @@ -0,0 +1,47 @@ +@inherits ConnectionComponentBase + +
+
+ + +
+ +
+ + +
+ + +
+ +@code { + private CalibrationParameters Calibration { get; set; } = new(); + + private async Task RunThermalModelCalibration() + { + if (PrinterServiceFactory.Current == null) return; + var command = GCodeCommands.ThermalModelCalibration + .SetParameterValue(GCodeParameters.CalibrationCycle.Label, Calibration.Cycles.ToString()) + .SetParameterValue(GCodeParameters.TargetTemp.Label, Calibration.Temperature.ToString()) + .ToString(); + await PrinterServiceFactory.Current.WriteDataAsync(command); + + } + + private void HandleThermalModelResult(object? sender, PropertyChangedEventArgs e) + { + //TODO fix + if (e.PropertyName == nameof(PrinterTelemetry.LastResponse)) + { + + // var response = StateService.Telemetry.LastResponse; + // if (response.Contains("Kp") || response.Contains("PID")) + // { + // calibrationResult += response + Environment.NewLine; + // InvokeAsync(StateHasChanged); + // } + } + } +} diff --git a/src/MakerPrompt.UI.Components/Components/WebcamPanel.razor b/src/MakerPrompt.UI.Components/Components/WebcamPanel.razor new file mode 100644 index 0000000..f61375b --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/WebcamPanel.razor @@ -0,0 +1,54 @@ +@inherits ConnectionComponentBase +@inject IPrinterCameraProvider CameraProvider + +@if (Camera is not null) +{ +
+

Webcam

+
+ +
+
+} + +@code { + private PrinterCamera? Camera; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + if (IsConnected) + { + await LoadCameraAsync(); + } + } + + protected override async void HandleConnectionChanged(object? sender, bool connected) + { + base.HandleConnectionChanged(sender, connected); + + if (connected) + { + await LoadCameraAsync(); + } + else + { + Camera = null; + } + } + + private async Task LoadCameraAsync() + { + try + { + Camera = await CameraProvider.GetPrimaryCameraAsync(); + } + catch + { + Camera = null; + } + + await InvokeAsync(StateHasChanged); + } +} diff --git a/src/MakerPrompt.UI.Components/Components/WebcamViewer.razor b/src/MakerPrompt.UI.Components/Components/WebcamViewer.razor new file mode 100644 index 0000000..02d91ed --- /dev/null +++ b/src/MakerPrompt.UI.Components/Components/WebcamViewer.razor @@ -0,0 +1,147 @@ +@using MakerPrompt.UI.Components.Models +@using MakerPrompt.UI.Components.Services +@inject ICameraProxyService CameraProxy +@implements IAsyncDisposable + +@if (Camera is null) +{ +
No webcam configured.
+} +else if (_hasError) +{ +
Unable to load webcam stream.
+} +else if (!string.IsNullOrWhiteSpace(_currentUrl)) +{ + +} +else +{ +
Webcam not available.
+} + +@code { + [Parameter] + public PrinterCamera? Camera { get; set; } + + [Parameter] + public TimeSpan SnapshotInterval { get; set; } = TimeSpan.FromSeconds(3); + + private string? _currentUrl; + private bool _hasError; + private CancellationTokenSource? _snapshotCts; + + protected override void OnParametersSet() + { + _hasError = false; + + if (Camera is null) + { + StopSnapshotLoop(); + _currentUrl = null; + return; + } + + var hasStream = !string.IsNullOrWhiteSpace(Camera.StreamUrl); + var hasSnapshot = !string.IsNullOrWhiteSpace(Camera.SnapshotUrl); + + // On MAUI the WebView can't load cross-origin img src URLs. + // Fall back to snapshot polling so we can fetch natively via HttpClient. + if (hasStream && !CameraProxy.NativeProxyRequired) + { + StopSnapshotLoop(); + _currentUrl = Camera.StreamUrl; + } + else if (hasSnapshot || (hasStream && CameraProxy.NativeProxyRequired)) + { + StartSnapshotLoop(); + } + else + { + StopSnapshotLoop(); + _currentUrl = null; + } + } + + private void StartSnapshotLoop() + { + if (_snapshotCts != null) + { + return; + } + + _snapshotCts = new CancellationTokenSource(); + _ = RunSnapshotLoopAsync(_snapshotCts.Token); + } + + private void StopSnapshotLoop() + { + if (_snapshotCts == null) + { + return; + } + + _snapshotCts.Cancel(); + _snapshotCts.Dispose(); + _snapshotCts = null; + } + + private async Task RunSnapshotLoopAsync(CancellationToken token) + { + // Prefer SnapshotUrl; fall back to StreamUrl when using native proxy (MAUI) + var rawUrl = !string.IsNullOrWhiteSpace(Camera?.SnapshotUrl) + ? Camera!.SnapshotUrl! + : Camera?.StreamUrl; + + while (!token.IsCancellationRequested && rawUrl != null) + { + if (CameraProxy.NativeProxyRequired) + { + // Fetch via native HttpClient — no CORS issues + var dataUrl = await CameraProxy.FetchSnapshotAsDataUrlAsync(rawUrl, token).ConfigureAwait(false); + if (dataUrl != null) + { + _currentUrl = dataUrl; + _hasError = false; + } + else if (_currentUrl == null) + { + _hasError = true; + } + } + else + { + // Browser handles the request — just update the cache-busting URL + var separator = rawUrl.Contains('?') ? "&" : "?"; + _currentUrl = rawUrl + separator + "t=" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + _hasError = false; + } + + await InvokeAsync(StateHasChanged); + + try + { + await Task.Delay(SnapshotInterval, token); + } + catch (TaskCanceledException) + { + break; + } + } + } + + private void OnImageError() + { + _hasError = true; + StopSnapshotLoop(); + StateHasChanged(); + } + + public ValueTask DisposeAsync() + { + StopSnapshotLoop(); + return ValueTask.CompletedTask; + } +} diff --git a/src/MakerPrompt.UI.Components/Infrastructure/BasePrinterConnectionService.cs b/src/MakerPrompt.UI.Components/Infrastructure/BasePrinterConnectionService.cs new file mode 100644 index 0000000..97f3b64 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Infrastructure/BasePrinterConnectionService.cs @@ -0,0 +1,32 @@ +namespace MakerPrompt.UI.Components.Infrastructure +{ + public abstract class BasePrinterConnectionService : IAsyncDisposable + { + public event EventHandler? ConnectionStateChanged; + public event EventHandler? TelemetryUpdated; + public PrinterTelemetry LastTelemetry { get; set; } = new(); + + public abstract PrinterConnectionType ConnectionType { get; } + + public string ConnectionName { get; set; } = string.Empty; + + public bool IsConnected { get; set; } = false; + + public readonly System.Timers.Timer updateTimer = new(TimeSpan.FromMilliseconds(3000)); + + // True while a print job is actively streaming G-code to the printer. + public bool IsPrinting { get; protected set; } + + public void RaiseConnectionChanged() + { + ConnectionStateChanged?.Invoke(this, IsConnected); + } + + public void RaiseTelemetryUpdated() + { + TelemetryUpdated?.Invoke(this, LastTelemetry); + } + + public abstract ValueTask DisposeAsync(); + } +} diff --git a/src/MakerPrompt.UI.Components/Infrastructure/BaseSerialService.cs b/src/MakerPrompt.UI.Components/Infrastructure/BaseSerialService.cs new file mode 100644 index 0000000..25106a0 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Infrastructure/BaseSerialService.cs @@ -0,0 +1,288 @@ +namespace MakerPrompt.UI.Components.Infrastructure +{ + public abstract class BaseSerialService : BasePrinterConnectionService + { + private readonly Regex _tempRegex = new(@"T:([\d.]+)\s/\s*([\d.]+)\sB:([\d.]+)\s/\s*([\d.]+)"); + private readonly Regex _posRegex = new(@"X:([\d.]+)\sY:([\d.]+)\sZ:([\d.]+)"); + public override Enums.PrinterConnectionType ConnectionType => Enums.PrinterConnectionType.Serial; + + StringBuilder _receiveBuffer = new(); + + // Core write entry point used by higher-level services. Implementations should + // enqueue commands with appropriate metadata where available. + public abstract Task WriteDataAsync(string command); + + // Convenience helpers for tagging commands with their intent. Implementations + // that support a queued sender can use this classification to prioritise work. + public virtual Task WriteUserCommandAsync(string command) => WriteDataAsync(command); + public virtual Task WriteTelemetryCommandAsync(string command) => WriteDataAsync(command); + public virtual Task WritePrintCommandAsync(string command) => WriteDataAsync(command); + + public async Task GetPrinterTelemetryAsync() + { + // Treat telemetry polling distinctly so queueing code can prioritise + // active print commands when necessary. + await WriteTelemetryCommandAsync(GCodeCommands.GetTemperature.ToString()); + await WriteTelemetryCommandAsync(GCodeCommands.GetCurrentPosition.ToString()); + //await WriteDataAsync("M123"); + //await WriteDataAsync(GCodeCommands.SetFeedratePercentage.ToString()); + //await WriteDataAsync(GCodeCommands.SetFlowratePercentage.ToString()); + + await Task.Delay(200); + return LastTelemetry; + } + + public async Task> GetFilesAsync() + { + // await WriteDataAsync("M20 L T"); + // await Task.Delay(500); // Wait for response + + return []; + } + + public async Task SetHotendTemp(int targetTemp = 0) + { + if (!IsConnected || (targetTemp < 0 || targetTemp > 300)) return; + var command = GCodeCommands.SetTemp + .SetParameterValue(GCodeParameters.TargetTemp.Label, targetTemp.ToString()) + .ToString(); + await WriteUserCommandAsync(command); + } + + public async Task SetBedTemp(int targetTemp = 0) + { + if (!IsConnected || (targetTemp < 0 || targetTemp > 120)) return; + var command = GCodeCommands.SetBedTemp + .SetParameterValue(GCodeParameters.TargetTemp.Label, targetTemp.ToString()) + .ToString(); + await WriteUserCommandAsync(command); + } + + public async Task Home(bool x = true, bool y = true, bool z = true) + { + if (!IsConnected) return; + var command = GCodeCommands.Home; + if (!(x && y && z)) + { + if (x) command.SetParameterValue(GCodeParameters.HomeX.Label); + if (y) command.SetParameterValue(GCodeParameters.HomeY.Label); + if (z) command.SetParameterValue(GCodeParameters.HomeZ.Label); + } + + await WriteUserCommandAsync(command.ToString()); + } + + public async Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) + { + if (!IsConnected) return; + var command = GCodeCommands.MoveLinear; + + if (!IsEqual(x, 0.0f)) command.SetParameterValue(GCodeParameters.PositionX.Label, x.ToString("0.0")); + if (!IsEqual(y, 0.0f)) command.SetParameterValue(GCodeParameters.PositionY.Label, y.ToString("0.0")); + if (!IsEqual(z, 0.0f)) command.SetParameterValue(GCodeParameters.PositionZ.Label, z.ToString("0.0")); + if (!IsEqual(e, 0.0f)) command.SetParameterValue(GCodeParameters.PositionE.Label, e.ToString("0.0")); + command.SetParameterValue(GCodeParameters.Feedrate.Label, feedRate.ToString()); + + await WriteUserCommandAsync(GCodeCommands.RelativePositioning.ToString()); + await WriteUserCommandAsync(command.ToString()); + await WriteUserCommandAsync(GCodeCommands.AbsolutePositioning.ToString()); + } + + public async Task SetFanSpeed(int fanSpeedPercentage = 0) + { + if (!IsConnected || (fanSpeedPercentage < 0 || fanSpeedPercentage > 100)) return; + var command = fanSpeedPercentage == 0 ? GCodeCommands.FanOff + :GCodeCommands.SetFanSpeed.SetParameterValue(GCodeParameters.FanSpeed.Label, ((int)(fanSpeedPercentage * 2.55)).ToString()); + await WriteUserCommandAsync(command.ToString()); + } + + public async Task SetPrintSpeed(int speed) + { + if (!IsConnected || (speed < 1 || speed > 200)) return; + var command = GCodeCommands.SetFeedratePercentage.SetParameterValue(GCodeParameters.RatePercentage.Label, speed.ToString()); + await WriteUserCommandAsync(command.ToString()); + } + + public async Task SetPrintFlow(int flow) + { + if (!IsConnected || (flow < 1 || flow > 200)) return; + var command = GCodeCommands.SetFlowratePercentage.SetParameterValue(GCodeParameters.RatePercentage.Label, flow.ToString()); + await WriteUserCommandAsync(command.ToString()); + } + + public async Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) + { + if (!IsConnected) return; + var command = GCodeCommands.MoveLinear; + + if (!IsEqual(x, 0.0f)) command.SetParameterValue(GCodeParameters.PositionX.Label, x.ToString("0.0")); + if (!IsEqual(y, 0.0f)) command.SetParameterValue(GCodeParameters.PositionY.Label, y.ToString("0.0")); + if (!IsEqual(z, 0.0f)) command.SetParameterValue(GCodeParameters.PositionZ.Label, z.ToString("0.0")); + if (!IsEqual(e, 0.0f)) command.SetParameterValue(GCodeParameters.PositionE.Label, e.ToString("0.0")); + await WriteUserCommandAsync(command.ToString()); + } + + public async Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) + { + if (!IsConnected) return; + var command = GCodeCommands.PidAutotune + .SetParameterValue(GCodeParameters.CalibrationCycle.Label, cycles.ToString()) + .SetParameterValue(GCodeParameters.TargetTemp.Label, targetTemp.ToString()) + .SetParameterValue(GCodeParameters.PositionE.Label, extruderIndex.ToString()) + .ToString(); + await WriteUserCommandAsync(command); + } + + public async Task RunThermalModelCalibration(int cycles, int targetTemp) + { + if (!IsConnected) return; + var command = GCodeCommands.ThermalModelCalibration + .SetParameterValue(GCodeParameters.CalibrationCycle.Label, cycles.ToString()) + .SetParameterValue(GCodeParameters.TargetTemp.Label, targetTemp.ToString()) + .ToString(); + await WriteUserCommandAsync(command); + } + + public Task StartPrint(FileEntry file) + { + throw new NotImplementedException(); + } + + public async Task SaveEEPROM() + { + if (!IsConnected) return; + await WriteUserCommandAsync(GCodeCommands.StoreEEPROM.ToString()); + } + + private static bool IsEqual(float a, float b, float tolerance = 0.001f) + { + return Math.Abs(a - b) < tolerance; + } + + public void ProcessReceivedData(string data) + { + _receiveBuffer.Append(data); + + while (true) + { + var bufferStr = _receiveBuffer.ToString(); + var newlineIndex = bufferStr.IndexOf('\n'); + + if (newlineIndex < 0) break; + + var line = bufferStr.Substring(0, newlineIndex + 1) + .Trim('\r', '\n', ' '); + + if (!string.IsNullOrEmpty(line)) + { + ParseResponse(line); + } + + _receiveBuffer = _receiveBuffer.Remove(0, newlineIndex + 1); + } + } + + public PrinterTelemetry ParseResponse(string data) + { + try + { + LastTelemetry.LastResponse = data; + if (data.StartsWith("ok T:")) + { + var match = _tempRegex.Match(data); + if (match.Success) + { + LastTelemetry.HotendTemp = float.Parse(match.Groups[1].Value); + LastTelemetry.HotendTarget = float.Parse(match.Groups[2].Value); + LastTelemetry.BedTemp = float.Parse(match.Groups[3].Value); + LastTelemetry.BedTarget = float.Parse(match.Groups[4].Value); + } + } + else if (data.StartsWith("X:")) + { + var match = _posRegex.Match(data); + if (match.Success) + { + LastTelemetry.Position = new Vector3( + float.Parse(match.Groups[1].Value), + float.Parse(match.Groups[2].Value), + float.Parse(match.Groups[3].Value)); + } + } + else if (data.Contains("SD printing byte")) + { + LastTelemetry.SDCard.Printing = true; + } + + RaiseTelemetryUpdated(); + return LastTelemetry; + } + catch (Exception ex) + { + Console.WriteLine($"Error parsing data: {ex.Message}"); + } + + RaiseTelemetryUpdated(); + return LastTelemetry; + } + + private List ParseM20Response(string response) + { + var files = new List(); + + if (string.IsNullOrEmpty(response)) + return files; + + // Split into lines and skip non-file lines + var lines = response.Split('\n') + .Where(line => line.Contains(".G", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var line in lines) + { + try + { + // Split into parts - format: "filename size timestamp longname" + var parts = line.Split([' '], 4, StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length < 2) // Need at least filename and size + continue; + + var file = new FileEntry + { + FullPath = parts[0] + }; + + // Parse size + if (long.TryParse(parts[1], out var size)) + { + file.Size = size; + } + + // Parse timestamp if available (hex format) + if (parts.Length > 2 && parts[2].StartsWith("0x") && + long.TryParse(parts[2].Substring(2), System.Globalization.NumberStyles.HexNumber, null, out var timestamp)) + { + // Convert Unix hex timestamp to DateTime + file.ModifiedDate = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime; + } + + // Use long filename if available + if (parts.Length > 3) + { + file.FullPath = parts[3].Trim(); + } + + files.Add(file); + } + catch + { + // Skip malformed entries + continue; + } + } + + return files; + } + } +} diff --git a/src/MakerPrompt.UI.Components/Infrastructure/ConnectionComponentBase.cs b/src/MakerPrompt.UI.Components/Infrastructure/ConnectionComponentBase.cs new file mode 100644 index 0000000..51b54d3 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Infrastructure/ConnectionComponentBase.cs @@ -0,0 +1,92 @@ +using BlazorBootstrap; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.UI.Components.Infrastructure +{ + public abstract class ConnectionComponentBase : ComponentBase, IAsyncDisposable + { + [Inject] + public required MakerPromptJsInterop JS { get; set; } + + [Inject] + public required IStringLocalizer Localizer { get; set; } + + [Inject] + public required PrinterCommunicationServiceFactory PrinterServiceFactory { get; set; } + + [Inject] + private ILogger Logger { get; set; } = null!; + + [Inject] + private ToastService ToastService { get; set; } = null!; + + protected bool IsConnected { get; set; } + protected string ConnectionDisabledAttribute => IsConnected ? string.Empty : "disabled"; + + /// + /// Executes a printer command, catches any exception, logs it, and surfaces + /// a toast — so subclasses never need individual try/catch blocks. + /// + protected async Task RunAsync(Func action, string errorTitle = "Command failed") + { + try + { + await action(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Printer command failed: {Title}", errorTitle); + ToastService.Notify(new ToastMessage(ToastType.Danger, errorTitle, ex.Message)); + } + } + + private void OnTelemetryUpdated(object? sender, PrinterTelemetry e) + { + HandleTelemetryUpdated(sender, e); + InvokeAsync(StateHasChanged); + } + + protected override void OnInitialized() + { + IsConnected = PrinterServiceFactory.IsConnected; + PrinterServiceFactory.ConnectionStateChanged += HandleConnectionChanged; + if (PrinterServiceFactory.Current != null) + { + PrinterServiceFactory.Current.TelemetryUpdated += OnTelemetryUpdated; + } + + base.OnInitialized(); + } + + protected virtual void HandleTelemetryUpdated(object? sender, PrinterTelemetry printerTelemetry) { } + + protected virtual void HandleConnectionChanged(object? sender, bool connected) + { + IsConnected = connected; + if (PrinterServiceFactory.Current != null) + { + if (IsConnected) + { + PrinterServiceFactory.Current.TelemetryUpdated += OnTelemetryUpdated; + } + else + { + PrinterServiceFactory.Current.TelemetryUpdated -= OnTelemetryUpdated; + } + } + InvokeAsync(StateHasChanged); + } + + public async ValueTask DisposeAsync() + { + PrinterServiceFactory.ConnectionStateChanged -= HandleConnectionChanged; + if (PrinterServiceFactory.Current != null) + { + PrinterServiceFactory.Current.TelemetryUpdated -= OnTelemetryUpdated; + } + GC.SuppressFinalize(this); + } + } +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Infrastructure/IAppConfigurationService.cs b/src/MakerPrompt.UI.Components/Infrastructure/IAppConfigurationService.cs new file mode 100644 index 0000000..07ff421 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Infrastructure/IAppConfigurationService.cs @@ -0,0 +1,10 @@ +namespace MakerPrompt.UI.Components.Infrastructure +{ + public interface IAppConfigurationService + { + AppConfiguration Configuration { get; } + Task InitializeAsync(); + Task SaveConfigurationAsync(); + Task ResetToDefaultsAsync(); + } +} diff --git a/src/MakerPrompt.UI.Components/Infrastructure/IPrinterCommunicationService.cs b/src/MakerPrompt.UI.Components/Infrastructure/IPrinterCommunicationService.cs new file mode 100644 index 0000000..a9820c3 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Infrastructure/IPrinterCommunicationService.cs @@ -0,0 +1,33 @@ +namespace MakerPrompt.UI.Components.Infrastructure +{ + public interface IPrinterCommunicationService : IAsyncDisposable + { + event EventHandler ConnectionStateChanged; + event EventHandler TelemetryUpdated; + + PrinterConnectionType ConnectionType { get; } + PrinterTelemetry LastTelemetry { get; } + string ConnectionName { get; } + bool IsConnected { get; } + bool IsPrinting { get; } + + Task ConnectAsync(PrinterConnectionSettings connectionSettings); + Task DisconnectAsync(); + Task WriteDataAsync(string command); + Task GetPrinterTelemetryAsync(); + Task> GetFilesAsync(); + Task SetHotendTemp(int targetTemp = 0); + Task SetBedTemp(int targetTemp = 0); + Task Home(bool x = true, bool y = true, bool z = true); + Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f); + Task SetFanSpeed(int fanSpeedPercentage = 0); + Task SetPrintSpeed(int speed); + Task SetPrintFlow(int flow); + Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f); + Task RunPidTuning(int cycles, int targetTemp, int extruderIndex); + Task RunThermalModelCalibration(int cycles, int targetTemp); + Task StartPrint(FileEntry file); + Task StartPrint(GCodeDoc document); + Task SaveEEPROM(); + } +} diff --git a/src/MakerPrompt.UI.Components/Infrastructure/IPrinterProvider.cs b/src/MakerPrompt.UI.Components/Infrastructure/IPrinterProvider.cs new file mode 100644 index 0000000..1445b02 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Infrastructure/IPrinterProvider.cs @@ -0,0 +1,11 @@ +namespace MakerPrompt.UI.Components.Infrastructure; + +/// +/// Abstracts services that manage a fleet of printers under a single account. +/// Call Configure(token) before GetPrintersAsync(). +/// +public interface IPrinterProvider +{ + void Configure(string bearerToken); + Task> GetPrintersAsync(); +} diff --git a/src/MakerPrompt.UI.Components/Infrastructure/ISerialService.cs b/src/MakerPrompt.UI.Components/Infrastructure/ISerialService.cs new file mode 100644 index 0000000..16f38ab --- /dev/null +++ b/src/MakerPrompt.UI.Components/Infrastructure/ISerialService.cs @@ -0,0 +1,15 @@ +namespace MakerPrompt.UI.Components.Infrastructure +{ + public interface ISerialService : IPrinterCommunicationService + { + bool IsSupported { get; } + + Task> GetAvailablePortsAsync(); + + Task CheckSupportedAsync(); + Task RequestPortAsync(); + + //Task OpenPortAsync(string port, int baudRate, int dataBits = 8, int stopBits = 1, + // string parity = "none", string flowControl = "none"); + } +} diff --git a/src/MakerPrompt.UI.Components/Infrastructure/IStorageProvider.cs b/src/MakerPrompt.UI.Components/Infrastructure/IStorageProvider.cs new file mode 100644 index 0000000..5559938 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Infrastructure/IStorageProvider.cs @@ -0,0 +1,19 @@ +namespace MakerPrompt.UI.Components.Infrastructure +{ + using MakerPrompt.UI.Components.Models; + + public interface IStorageProvider + { + string DisplayName { get; } + string Key { get; } + Task> ListFilesAsync(CancellationToken cancellationToken = default); + Task OpenReadAsync(string fullPath, CancellationToken cancellationToken = default); + Task SaveFileAsync(string fullPath, Stream content, CancellationToken cancellationToken = default); + Task DeleteFileAsync(string fullPath, CancellationToken cancellationToken = default); + } + + public interface IAppLocalStorageProvider : IStorageProvider + { + string RootPath { get; } + } +} diff --git a/src/MakerPrompt.UI.Components/Infrastructure/PrinterStorageProvider.cs b/src/MakerPrompt.UI.Components/Infrastructure/PrinterStorageProvider.cs new file mode 100644 index 0000000..db4c7c5 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Infrastructure/PrinterStorageProvider.cs @@ -0,0 +1,52 @@ +namespace MakerPrompt.UI.Components.Infrastructure +{ + using MakerPrompt.UI.Components.Models; + + public sealed class PrinterStorageProvider : IStorageProvider + { + private readonly PrinterCommunicationServiceFactory factory; + public PrinterStorageProvider(PrinterCommunicationServiceFactory factory) + { + this.factory = factory; + } + + public string DisplayName => factory.Current?.ConnectionName ?? "Printer"; + public string Key => "printer"; + + public async Task> ListFilesAsync(CancellationToken cancellationToken = default) + { + var svc = factory.Current; + if (svc == null) return []; + return await svc.GetFilesAsync() ?? []; + } + + public async Task OpenReadAsync(string fullPath, CancellationToken cancellationToken = default) + { + switch (factory.Current) + { + case Services.DemoPrinterService demo: + return await demo.OpenReadAsync(fullPath); + case Services.MoonrakerApiService moonraker: + return await moonraker.OpenReadAsync(fullPath, cancellationToken); + default: + return null; + } + } + + public async Task SaveFileAsync(string fullPath, Stream content, CancellationToken cancellationToken = default) + { + if (factory.Current is Services.DemoPrinterService svc) + { + await svc.SaveFileAsync(fullPath, content); + } + } + + public async Task DeleteFileAsync(string fullPath, CancellationToken cancellationToken = default) + { + if (factory.Current is Services.DemoPrinterService svc) + { + await svc.DeleteFileAsync(fullPath); + } + } + } +} diff --git a/src/MakerPrompt.UI.Components/Layout/MainLayout.razor b/src/MakerPrompt.UI.Components/Layout/MainLayout.razor index 7e2bd8c..7e3276d 100644 --- a/src/MakerPrompt.UI.Components/Layout/MainLayout.razor +++ b/src/MakerPrompt.UI.Components/Layout/MainLayout.razor @@ -1,17 +1,122 @@ -@inherits LayoutComponentBase - -
- - -
-
- About -
- -
- @Body -
-
-
+@inject IStringLocalizer localizer +@inject IAppConfigurationService ConfigService +@inherits LayoutComponentBase +@implements IDisposable +@implements IAsyncDisposable + + + + +
+ +
+
+

@TitleService.CurrentTitle

+
+
+
+ @Body +
+
+
+ @if (ConfigService.Configuration.FarmModeEnabled) + { +
+ +
+
+ +
+ } + else + { +
+ +
+
+ +
+ } +
+
+
+
+
+ + + + +@code { + [Inject] + private LocalizedTitleService TitleService { get; set; } = null!; + + private PrinterConnectionModal? _connectionModal; + private bool _navCollapsed = false; + private bool _isResizing = false; + private double _rightPanelWidth = 600; // Default width in pixels + private double _startX; + private double _startWidth; + + private void ToggleSidebar() => _navCollapsed = !_navCollapsed; + + private void OnMouseDown(MouseEventArgs e) + { + _isResizing = true; + _startX = e.ClientX; + _startWidth = _rightPanelWidth; + } + + private void OnMouseMove(MouseEventArgs e) + { + if (!_isResizing) return; + + // Calculate new width (moving left increases width, moving right decreases width) + var deltaX = _startX - e.ClientX; + var newWidth = _startWidth + deltaX; + + // Constrain width between min and max values + if (newWidth >= 600 && newWidth <= 1600) + { + _rightPanelWidth = newWidth; + } + } + + private void OnMouseUp(MouseEventArgs e) + { + _isResizing = false; + } + + protected override void OnInitialized() + { + base.OnInitialized(); + TitleService.OnTitleChanged += HandleTitleChanged; + } + + private void HandleTitleChanged() + { + StateHasChanged(); + } + + public void Dispose() + { + TitleService.OnTitleChanged -= HandleTitleChanged; + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/MakerPrompt.UI.Components/Layout/NavConnection.razor b/src/MakerPrompt.UI.Components/Layout/NavConnection.razor new file mode 100644 index 0000000..de7ed0d --- /dev/null +++ b/src/MakerPrompt.UI.Components/Layout/NavConnection.razor @@ -0,0 +1,268 @@ +@using MakerPrompt.UI.Components.Infrastructure +@using System.Globalization +@using Microsoft.JSInterop +@using static MakerPrompt.UI.Components.Utils.Enums +@inject IJSRuntime JS +@inject ISerialService SerialService +@inject PrinterCommunicationServiceFactory ServiceFactory; +@inject IStringLocalizer localizer +@inject NavigationManager Navigation +@inject ToastService ToastService +@inject ILogger Logger +@implements IDisposable + + + +@code { + SerialConnectionSettings SerialConnectionSettings { get; set; } = new(); + ApiConnectionSettings ApiConnectionSettings { get; set; } = new(); + private List AvailablePorts { get; set; } = new(); + private bool IsConnected { get; set; } = false; + private bool IsBusy { get; set; } = false; + private bool IsSerialAvailable { get; set; } = false; + private PrinterConnectionType ActiveTab = PrinterConnectionType.Serial; + + private readonly List BaudRates = new() + { + 9600, 19200, 38400, 57600, 115200, 250000 + }; + + private bool HasValidSelection => ActiveTab == PrinterConnectionType.Demo || !string.IsNullOrEmpty(SerialConnectionSettings.PortName) || !string.IsNullOrEmpty(ApiConnectionSettings.Url); + private string ConnectButtonClass => IsConnected ? "btn-danger" : "btn-success"; + private string ConnectButtonText => IsConnected ? localizer[Resources.NavConnection_Disconnect] : localizer[Resources.NavConnection_Connect]; + private string NavButtonText => IsConnected ? ServiceFactory.Current?.ConnectionName ?? localizer[Resources.NavConnection_Connect] : localizer[Resources.NavConnection_Connect]; + + protected override async Task OnInitializedAsync() + { + IsSerialAvailable = await SerialService.CheckSupportedAsync(); + ServiceFactory.ConnectionStateChanged += HandleConnectionChanged; + await base.OnInitializedAsync(); + } + + private async Task OnConnectionDropdownOpenedAsync() + { + if (!IsSerialAvailable) return; + // Only refresh serial ports when serial tab is active and not currently connected. + if (ActiveTab == PrinterConnectionType.Serial && IsSerialAvailable && !IsConnected) + { + await RefreshPortsAsync(); + + // Auto-select first available port if none selected yet. + if (string.IsNullOrEmpty(SerialConnectionSettings.PortName) && AvailablePorts.Count > 0) + { + SerialConnectionSettings.PortName = AvailablePorts[0]; + StateHasChanged(); + } + } + } + + private async Task RefreshPortsAsync() + { + if (!IsSerialAvailable) return; + IsBusy = true; + try + { + AvailablePorts = (await SerialService.GetAvailablePortsAsync()).ToList(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to enumerate serial ports"); + ToastService.Notify(new(ToastType.Warning, "Could not refresh ports", ex.Message)); + } + finally + { + IsBusy = false; + } + } + + private async Task ToggleConnectionAsync() + { + IsBusy = true; + try + { + if (IsConnected) + { + if (ServiceFactory.Current == null) return; + await ServiceFactory.DisconnectAsync(); + } + else + { + if (!IsSerialAvailable && ActiveTab == PrinterConnectionType.Serial) + { + ToastService.Notify(new(ToastType.Danger, localizer[Resources.NavConnection_BrowserNotSupported])); + return; + } + + var connectionSettings = ActiveTab switch + { + PrinterConnectionType.Demo => new PrinterConnectionSettings(), + PrinterConnectionType.Serial => new PrinterConnectionSettings(SerialConnectionSettings), + PrinterConnectionType.Moonraker => new PrinterConnectionSettings(ApiConnectionSettings, ActiveTab), + PrinterConnectionType.PrusaLink => new PrinterConnectionSettings(ApiConnectionSettings, ActiveTab), + _ => new PrinterConnectionSettings() + }; + + await ServiceFactory.ConnectAsync(connectionSettings); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Connection toggle failed"); + ToastService.Notify(new(ToastType.Danger, "Connection failed", ex.Message)); + } + finally + { + IsBusy = false; + } + } + + private void HandleConnectionChanged(object? sender, bool isConnected) + { + IsConnected = isConnected; + StateHasChanged(); + } + + private void ShowTab(PrinterConnectionType tab) + { + ActiveTab = tab; + } + + public void Dispose() + { + ServiceFactory.ConnectionStateChanged -= HandleConnectionChanged; + } +} diff --git a/src/MakerPrompt.UI.Components/Layout/NavMenu.razor b/src/MakerPrompt.UI.Components/Layout/NavMenu.razor index 873c22a..9d3a082 100644 --- a/src/MakerPrompt.UI.Components/Layout/NavMenu.razor +++ b/src/MakerPrompt.UI.Components/Layout/NavMenu.razor @@ -1,32 +1,103 @@ - +@inject IAppConfigurationService ConfigService +@inject IStringLocalizer localizer + + + +@code { + [Parameter] public bool Collapsed { get; set; } + [Parameter] public EventCallback OnToggle { get; set; } + + private string Version = "0.0.0"; + + protected override void OnInitialized() + { + var assembly = Assembly.GetEntryAssembly(); + Version = assembly?.GetName()?.Version?.ToString(3) ?? Version; + } +} diff --git a/src/MakerPrompt.UI.Components/Layout/NavPrinters.razor b/src/MakerPrompt.UI.Components/Layout/NavPrinters.razor new file mode 100644 index 0000000..4e4d39c --- /dev/null +++ b/src/MakerPrompt.UI.Components/Layout/NavPrinters.razor @@ -0,0 +1,496 @@ +@* ───────────────────────────────────────────────────────────────────── + NavPrinters — Multi-printer navbar component. + Replaces the old NavConnection single-dropdown with a toolbar button + that opens a BlazorBootstrap modal showing all saved printers with + Add/Edit/Delete/Connect/Disconnect controls. + + Design inspired by PrintQue multi-printer dashboard UX. + ───────────────────────────────────────────────────────────────────── *@ +@using MakerPrompt.UI.Components.Infrastructure +@using static MakerPrompt.UI.Components.Utils.Enums +@inject PrinterConnectionManager ConnectionManager +@inject ISerialService SerialService +@inject IStringLocalizer localizer +@inject ToastService ToastService +@inject ILogger Logger +@implements IDisposable + + + +@* ── Printers Management Modal ── *@ + + + @if (!_showAddEditForm) + { + @* ── Printer List ── *@ +
+
@localizer[Resources.Fleet_Printers]
+ +
+ + @if (!ConnectionManager.Printers.Any()) + { +
+ @localizer[Resources.Fleet_NoPrinters] +
+ } + else + { +
+ @foreach (var printer in ConnectionManager.Printers) + { +
+
+
+ +
+ @printer.Definition.Name + @if (printer.IsActive) + { + @localizer[Resources.Fleet_Active] + } +
+ @printer.Definition.ConnectionType.GetDisplayName() + @if (printer.Definition.AutoConnect) + { + + } +
+
+
+ @if (!printer.IsActive && printer.Status != PrinterStatus.Disconnected) + { + + } + @if (printer.IsBusy) + { + + } + else if (printer.Status == PrinterStatus.Disconnected || printer.Status == PrinterStatus.Error) + { + + } + else + { + + } + + +
+
+ @if (!string.IsNullOrEmpty(printer.LastError)) + { +
+ @printer.LastError +
+ } + @if (printer.Status != PrinterStatus.Disconnected && printer.Status != PrinterStatus.Error) + { +
+ @($"{printer.Telemetry.HotendTemp:F0}°C / {printer.Telemetry.HotendTarget:F0}°C") + @($"{printer.Telemetry.BedTemp:F0}°C / {printer.Telemetry.BedTarget:F0}°C") + @if (printer.Telemetry.SDCard.Printing) + { + @($"{printer.Telemetry.SDCard.Progress:F0}%") + } +
+ } +
+ } +
+ } + } + else + { + @* ── Add/Edit Form ── *@ +
+ +
+ +
+ + +
+ +
+ + +
+ + @if (_editConnectionType == PrinterConnectionType.Serial) + { +
+
+ @localizer[Resources.NavPrinters_Port] + + +
+
+
+
+ + bps +
+
+ } + else if (_editConnectionType == PrinterConnectionType.Demo) + { +
+

@localizer[Resources.NavConnection_DemoServiceDescription]

+
+ } + else if (_editConnectionType == PrinterConnectionType.PrusaConnect) + { +
+

@localizer[Resources.NavConnect_PrusaConnectDescription]

+
+
+ + +
+
+ + +
+ } + else + { + @* API-based backends: PrusaLink, Moonraker, OctoPrint, BambuLab *@ +
+ + +
+ + @if (_editConnectionType == PrinterConnectionType.BambuLab) + { +
+ + +
+
+ + +
+ } + else if (_editConnectionType == PrinterConnectionType.OctoPrint) + { +
+ + +
+ } + else + { +
+ + +
+
+ + +
+ } + } + +
+ + +
+ } +
+ + @if (_showAddEditForm) + { + + + } + else + { + + } + +
+ +@code { + private Modal _printersModal = default!; + private int _connectedCount; + private bool _showAddEditForm; + private bool _isEditing; + + // Edit state + private PrinterConnectionDefinition _editDefinition = new(); + private ApiConnectionSettings _editApiSettings = new(); + private SerialConnectionSettings _editSerialSettings = new(); + private PrinterConnectionType _editConnectionType = PrinterConnectionType.Demo; + private List _availablePorts = new(); + + private readonly List BaudRates = [9600, 19200, 38400, 57600, 115200, 250000]; + private readonly PrinterConnectionType[] _connectionTypes = Enum.GetValues(); + + private string NavButtonLabel + { + get + { + var active = ConnectionManager.ActivePrinter; + if (active?.Service?.IsConnected == true) + return active.Definition.Name; + return localizer[Resources.Fleet_Printers]; + } + } + + protected override void OnInitialized() + { + ConnectionManager.PrintersChanged += HandlePrintersChanged; + UpdateConnectedCount(); + } + + private void HandlePrintersChanged(object? sender, EventArgs e) + { + UpdateConnectedCount(); + InvokeAsync(StateHasChanged); + } + + private void UpdateConnectedCount() + { + _connectedCount = ConnectionManager.Printers.Count(p => + p.Status != PrinterStatus.Disconnected && p.Status != PrinterStatus.Error); + } + + private async Task OpenPrintersModal() + { + _showAddEditForm = false; + await _printersModal.ShowAsync(); + } + + private async Task ClosePrintersModal() + { + await _printersModal.HideAsync(); + } + + private void ShowAddForm() + { + _isEditing = false; + _editDefinition = new PrinterConnectionDefinition(); + _editApiSettings = new ApiConnectionSettings(); + _editSerialSettings = new SerialConnectionSettings(); + _editConnectionType = PrinterConnectionType.Demo; + _showAddEditForm = true; + } + + private void ShowEditForm(PrinterConnectionDefinition definition) + { + _isEditing = true; + _editDefinition = new PrinterConnectionDefinition + { + Id = definition.Id, + Name = definition.Name, + ConnectionType = definition.ConnectionType, + AutoConnect = definition.AutoConnect, + Color = definition.Color, + Notes = definition.Notes, + CreatedAt = definition.CreatedAt, + LastConnectedAt = definition.LastConnectedAt + }; + _editConnectionType = definition.ConnectionType; + _editApiSettings = definition.Settings.Api is not null + ? new ApiConnectionSettings(definition.Settings.Api.Url, definition.Settings.Api.UserName, definition.Settings.Api.Password) + : new ApiConnectionSettings(); + _editSerialSettings = definition.Settings.Serial is not null + ? new SerialConnectionSettings { PortName = definition.Settings.Serial.PortName, BaudRate = definition.Settings.Serial.BaudRate } + : new SerialConnectionSettings(); + _showAddEditForm = true; + } + + private void HideAddEditForm() + { + _showAddEditForm = false; + } + + private async Task SavePrinterAsync() + { + if (string.IsNullOrWhiteSpace(_editDefinition.Name)) + { + ToastService.Notify(new ToastMessage(ToastType.Warning, localizer[Resources.NavPrinters_PrinterNameRequired])); + return; + } + + _editDefinition.ConnectionType = _editConnectionType; + _editDefinition.Settings = _editConnectionType switch + { + PrinterConnectionType.Serial => new PrinterConnectionSettings(_editSerialSettings), + PrinterConnectionType.Demo => new PrinterConnectionSettings(), + _ => new PrinterConnectionSettings(_editApiSettings, _editConnectionType) + }; + + try + { + if (_isEditing) + { + await ConnectionManager.UpdatePrinterAsync(_editDefinition); + ToastService.Notify(new ToastMessage(ToastType.Success, localizer[Resources.NavPrinters_PrinterUpdated])); + } + else + { + await ConnectionManager.AddPrinterAsync(_editDefinition); + ToastService.Notify(new ToastMessage(ToastType.Success, localizer[Resources.NavPrinters_PrinterAdded])); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to save printer definition"); + ToastService.Notify(new ToastMessage(ToastType.Danger, localizer[Resources.NavPrinters_SaveFailed], ex.Message)); + } + + _showAddEditForm = false; + } + + private async Task ConnectPrinter(Guid id) + { + try + { + await ConnectionManager.ConnectPrinterAsync(id); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to connect printer"); + ToastService.Notify(new ToastMessage(ToastType.Danger, localizer[Resources.NavPrinters_ConnectFailed], ex.Message)); + } + } + + private async Task DisconnectPrinter(Guid id) + { + try + { + await ConnectionManager.DisconnectPrinterAsync(id); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to disconnect printer"); + ToastService.Notify(new ToastMessage(ToastType.Danger, localizer[Resources.NavPrinters_DisconnectFailed], ex.Message)); + } + } + + private void SetActive(Guid id) + { + ConnectionManager.SetActivePrinter(id); + } + + private async Task DeletePrinter(Guid id) + { + try + { + await ConnectionManager.RemovePrinterAsync(id); + ToastService.Notify(new ToastMessage(ToastType.Success, localizer[Resources.NavPrinters_PrinterRemoved])); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to delete printer"); + ToastService.Notify(new ToastMessage(ToastType.Danger, localizer[Resources.NavPrinters_DeleteFailed], ex.Message)); + } + } + + private async Task RefreshPortsAsync() + { + try + { + _availablePorts = (await SerialService.GetAvailablePortsAsync()).ToList(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to enumerate serial ports"); + ToastService.Notify(new ToastMessage(ToastType.Warning, "Could not refresh ports", ex.Message)); + } + } + + private static string GetStatusIcon(ManagedPrinterState printer) + { + return printer.Status switch + { + PrinterStatus.Connected => "bi-check-circle-fill", + PrinterStatus.Printing => "bi-play-circle-fill", + PrinterStatus.Paused => "bi-pause-circle-fill", + PrinterStatus.Error => "bi-exclamation-circle-fill", + _ => "bi-dash-circle" + }; + } + + private static string GetStatusColor(ManagedPrinterState printer) + { + return printer.Status switch + { + PrinterStatus.Connected => "text-success", + PrinterStatus.Printing => "text-primary", + PrinterStatus.Paused => "text-warning", + PrinterStatus.Error => "text-danger", + _ => "text-muted" + }; + } + + public void Dispose() + { + ConnectionManager.PrintersChanged -= HandlePrintersChanged; + } +} diff --git a/src/MakerPrompt.UI.Components/Layout/NavTop.razor b/src/MakerPrompt.UI.Components/Layout/NavTop.razor new file mode 100644 index 0000000..4a41060 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Layout/NavTop.razor @@ -0,0 +1,21 @@ + + diff --git a/src/MakerPrompt.UI.Components/MakerPrompt.UI.Components.csproj b/src/MakerPrompt.UI.Components/MakerPrompt.UI.Components.csproj index d7b32b4..4a25f91 100644 --- a/src/MakerPrompt.UI.Components/MakerPrompt.UI.Components.csproj +++ b/src/MakerPrompt.UI.Components/MakerPrompt.UI.Components.csproj @@ -4,12 +4,18 @@ net10.0 enable enable - false + + + + - + + + + @@ -17,4 +23,20 @@ + + + True + True + Resources.resx + + + + + + Designer + Resources.Designer.cs + PublicResXFileCodeGenerator + + + diff --git a/src/MakerPrompt.UI.Components/Models/CalibrationParameters.cs b/src/MakerPrompt.UI.Components/Models/CalibrationParameters.cs new file mode 100644 index 0000000..25cf8dd --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/CalibrationParameters.cs @@ -0,0 +1,8 @@ +namespace MakerPrompt.UI.Components.Models +{ + internal class CalibrationParameters + { + public int Temperature { get; set; } = 200; + public int Cycles { get; set; } = 5; + } +} diff --git a/src/MakerPrompt.UI.Components/Models/FarmConfiguration.cs b/src/MakerPrompt.UI.Components/Models/FarmConfiguration.cs new file mode 100644 index 0000000..ed23bdc --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/FarmConfiguration.cs @@ -0,0 +1,14 @@ +namespace MakerPrompt.UI.Components.Models +{ + /// + /// Represents a saved farm profile. Each farm bundles a set of printer connections + /// and a display name so users can switch between different physical setups. + /// + public class FarmConfiguration + { + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public List Printers { get; set; } = []; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/src/MakerPrompt.UI.Components/Models/FilamentSpool.cs b/src/MakerPrompt.UI.Components/Models/FilamentSpool.cs new file mode 100644 index 0000000..5aa4a07 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/FilamentSpool.cs @@ -0,0 +1,17 @@ +namespace MakerPrompt.UI.Components.Models +{ + public class FilamentSpool + { + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string Material { get; set; } = string.Empty; + public string Brand { get; set; } = string.Empty; + public string Color { get; set; } = string.Empty; + public double Diameter { get; set; } = 1.75; + public double TotalWeightGrams { get; set; } = 1000; + public double RemainingWeightGrams { get; set; } = 1000; + public decimal Cost { get; set; } + public DateTime PurchaseDate { get; set; } = DateTime.UtcNow; + public bool IsArchived { get; set; } + } +} diff --git a/src/MakerPrompt.UI.Components/Models/FileEntry.cs b/src/MakerPrompt.UI.Components/Models/FileEntry.cs new file mode 100644 index 0000000..1f8521e --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/FileEntry.cs @@ -0,0 +1,10 @@ +namespace MakerPrompt.UI.Components.Models +{ + public class FileEntry + { + public string FullPath { get; set; } = string.Empty; + public long Size { get; set; } + public DateTime? ModifiedDate { get; set; } + public bool IsAvailable { get; set; } = true; + } +} diff --git a/src/MakerPrompt.UI.Components/Models/GCodeCommand.cs b/src/MakerPrompt.UI.Components/Models/GCodeCommand.cs new file mode 100644 index 0000000..d090d36 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/GCodeCommand.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; + +namespace MakerPrompt.UI.Components.Models +{ + public partial class GCodeCommand(string command, string description, List categories) + { + public GCodeCommand(string command, string description, List categories, List parameters) + : this(command, description, categories) + { + Parameters = parameters; + } + + [Display(Name = nameof(Resources.GCodeCommand_Command), ResourceType = typeof(Resources))] + public string Command { get; } = command; + + [Display(Name = nameof(Resources.GCodeCommand_Description), ResourceType = typeof(Resources))] + public string Description { get; } = description; + + [Display(Name = nameof(Resources.GCodeCommand_Category), ResourceType = typeof(Resources))] + public List Categories { get; } = categories; + + [Display(Name = nameof(Resources.GCodeCommand_Parameter), ResourceType = typeof(Resources))] + public List Parameters { get; } = []; + + public override string ToString() + { + if (Parameters == null || Parameters.Count == 0) + return Command; + + var result = $"{Command} {string.Join(" ", Parameters + .Where(p => !string.IsNullOrEmpty(p.Value)) + .Select(p => $"{p.Label}{p.Value}"))}"; + + Parameters.ForEach(p => p.Value = string.Empty); + return result; + } + } +} diff --git a/src/MakerPrompt.UI.Components/Models/GCodeParameter.cs b/src/MakerPrompt.UI.Components/Models/GCodeParameter.cs new file mode 100644 index 0000000..9b00207 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/GCodeParameter.cs @@ -0,0 +1,11 @@ +namespace MakerPrompt.UI.Components.Models +{ + public partial class GCodeParameter(char label, string description) + { + public char Label { get; } = label; + public string Description { get; } = description; + public string Value { get; set; } = string.Empty; + + // public bool ValueRequired { get; set; } = false; + } +} diff --git a/src/MakerPrompt.UI.Components/Models/ManagedPrinterState.cs b/src/MakerPrompt.UI.Components/Models/ManagedPrinterState.cs new file mode 100644 index 0000000..569735d --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/ManagedPrinterState.cs @@ -0,0 +1,54 @@ +namespace MakerPrompt.UI.Components.Models +{ + /// + /// Runtime state for a managed printer — combines the persisted definition with + /// live connection state and latest telemetry snapshot. + /// + public class ManagedPrinterState + { + /// + /// The persisted definition this state corresponds to. + /// + public PrinterConnectionDefinition Definition { get; set; } = new(); + + /// + /// The live backend service instance (null when not connected). + /// + public IPrinterCommunicationService? Service { get; set; } + + /// + /// Current connection status. + /// + public PrinterStatus Status { get; set; } = PrinterStatus.Disconnected; + + /// + /// Latest telemetry snapshot from this printer. + /// + public PrinterTelemetry Telemetry { get; set; } = new(); + + /// + /// Whether a connection/disconnection operation is in progress. + /// + public bool IsBusy { get; set; } + + /// + /// Last error message for this printer, if any (friendly, not a stack trace). + /// + public string? LastError { get; set; } + + /// + /// Whether this printer is currently the "active" printer (selected for single-printer views). + /// + public bool IsActive { get; set; } + + /// + /// Tracks the accumulated E-axis extrusion for the current print job. + /// + public double AccumulatedExtrusion { get; set; } + + /// + /// Tracks the start time of the current print job. + /// + public DateTime? PrintStartTime { get; set; } + } +} diff --git a/src/MakerPrompt.UI.Components/Models/NotificationRecord.cs b/src/MakerPrompt.UI.Components/Models/NotificationRecord.cs new file mode 100644 index 0000000..5c0b7eb --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/NotificationRecord.cs @@ -0,0 +1,22 @@ +namespace MakerPrompt.UI.Components.Models +{ + public enum NotificationLevel + { + Info, + Warning, + Error, + Critical + } + + public class NotificationRecord + { + public Guid Id { get; set; } = Guid.NewGuid(); + public NotificationLevel Level { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + public Guid? PrinterId { get; set; } + public Guid? FilamentSpoolId { get; set; } + public bool IsRead { get; set; } + } +} diff --git a/src/MakerPrompt.UI.Components/Models/PrintJobUsageRecord.cs b/src/MakerPrompt.UI.Components/Models/PrintJobUsageRecord.cs new file mode 100644 index 0000000..719f59f --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/PrintJobUsageRecord.cs @@ -0,0 +1,14 @@ +namespace MakerPrompt.UI.Components.Models +{ + public class PrintJobUsageRecord + { + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PrinterId { get; set; } + public Guid FilamentSpoolId { get; set; } + public string JobName { get; set; } = string.Empty; + public TimeSpan Duration { get; set; } + public double EstimatedFilamentUsedGrams { get; set; } + public double ActualFilamentUsedGrams { get; set; } + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + } +} diff --git a/src/MakerPrompt.UI.Components/Models/PrintProject.cs b/src/MakerPrompt.UI.Components/Models/PrintProject.cs new file mode 100644 index 0000000..38ca249 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/PrintProject.cs @@ -0,0 +1,77 @@ +namespace MakerPrompt.UI.Components.Models +{ + /// + /// A print project groups multiple G-code files under a folder/name. + /// Files are uploaded locally to app storage and can be dispatched to any connected printer. + /// + public class PrintProject + { + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Human-readable project name (also acts as the folder name). + /// + public string Name { get; set; } = string.Empty; + + /// + /// Optional description or notes. + /// + public string? Notes { get; set; } + + /// + /// When the project was created. + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// The individual print jobs in this project. + /// + public List Jobs { get; set; } = []; + } + + /// + /// A single print job within a project — one G-code file plus tracking state. + /// + public class PrintJob + { + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Original uploaded filename (e.g. "benchy.gcode"). + /// + public string FileName { get; set; } = string.Empty; + + /// + /// Storage path within IAppLocalStorageProvider (e.g. "PrintProjects/{projectId}/{filename}"). + /// + public string StoragePath { get; set; } = string.Empty; + + /// + /// File size in bytes. + /// + public long Size { get; set; } + + /// + /// Current status of this job. + /// + public PrintJobStatus Status { get; set; } = PrintJobStatus.Queued; + + /// + /// The printer this job was assigned/sent to (null = unassigned). + /// + public Guid? AssignedPrinterId { get; set; } + + /// + /// Friendly name of the assigned printer (for display when printer is offline). + /// + public string? AssignedPrinterName { get; set; } + } + + public enum PrintJobStatus + { + Queued, + Printing, + Completed, + Failed + } +} diff --git a/src/MakerPrompt.UI.Components/Models/PrinterCamera.cs b/src/MakerPrompt.UI.Components/Models/PrinterCamera.cs new file mode 100644 index 0000000..a9cde3b --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/PrinterCamera.cs @@ -0,0 +1,26 @@ +namespace MakerPrompt.UI.Components.Models +{ + /// + /// Neutral printer camera description consumed by UI components. + /// + public sealed class PrinterCamera + { + public string Id { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + /// + /// Optional MJPEG or similar stream URL. When present, the UI prefers this. + /// + public string? StreamUrl { get; set; } + + /// + /// Optional snapshot URL used for periodic still-image refresh when no stream is available. + /// + public string? SnapshotUrl { get; set; } + + public bool IsEnabled { get; set; } + + public string? Location { get; set; } + } +} diff --git a/src/MakerPrompt.UI.Components/Models/PrinterConnectionDefinition.cs b/src/MakerPrompt.UI.Components/Models/PrinterConnectionDefinition.cs new file mode 100644 index 0000000..7f5c2b4 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/PrinterConnectionDefinition.cs @@ -0,0 +1,59 @@ +namespace MakerPrompt.UI.Components.Models +{ + /// + /// Persistent model for a saved printer connection. Stored via IAppLocalStorageProvider. + /// Inspired by PrintQue multi-printer management and OctoPrint connection profiles. + /// + public class PrinterConnectionDefinition + { + /// + /// Unique identifier for this printer connection. + /// + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// User-friendly display name (e.g. "Workshop Prusa MK4", "BambuLab X1C #2"). + /// + public string Name { get; set; } = string.Empty; + + /// + /// Backend type for this printer connection. + /// + public PrinterConnectionType ConnectionType { get; set; } = PrinterConnectionType.Demo; + + /// + /// Connection details — API settings for HTTP/WS backends, serial settings for USB. + /// + public PrinterConnectionSettings Settings { get; set; } = new(); + + /// + /// Whether MakerPrompt should attempt to auto-connect this printer on startup. + /// + public bool AutoConnect { get; set; } + + /// + /// Optional user-assigned color for the printer card in the Fleet dashboard. + /// + public string? Color { get; set; } + + /// + /// Optional notes for this printer connection. + /// + public string? Notes { get; set; } + + /// + /// Timestamp of when this definition was created. + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Timestamp of the last successful connection. + /// + public DateTime? LastConnectedAt { get; set; } + + /// + /// The ID of the currently assigned filament spool. + /// + public Guid? AssignedFilamentSpoolId { get; set; } + } +} diff --git a/src/MakerPrompt.UI.Components/Models/PrinterConnectionSettings.cs b/src/MakerPrompt.UI.Components/Models/PrinterConnectionSettings.cs new file mode 100644 index 0000000..16ce4a0 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/PrinterConnectionSettings.cs @@ -0,0 +1,54 @@ +namespace MakerPrompt.UI.Components.Models +{ + public class PrinterConnectionSettings + { + public PrinterConnectionType ConnectionType { get; set; } + + public SerialConnectionSettings? Serial { get; set; } + + public ApiConnectionSettings? Api { get; set; } + + public PrinterConnectionSettings() + { + ConnectionType = PrinterConnectionType.Demo; + } + + public PrinterConnectionSettings(SerialConnectionSettings serialConnectionSettings) + { + ConnectionType = PrinterConnectionType.Serial; + Serial = serialConnectionSettings; + } + + public PrinterConnectionSettings(ApiConnectionSettings apiConnectionSettings, PrinterConnectionType connectionType) + { + if (connectionType == PrinterConnectionType.Serial) throw new ArgumentOutOfRangeException(nameof(connectionType)); + ConnectionType = connectionType; + Api = apiConnectionSettings; + } + } + + public record SerialConnectionSettings + { + public string PortName { get; set; } = string.Empty; + + public int BaudRate { get; set; } = 115200; + } + + public class ApiConnectionSettings + { + public ApiConnectionSettings() + { + } + public ApiConnectionSettings(string url, string username, string password) + { + Url = url; + UserName = username; + Password = password; + } + public string Url { get; set; } = string.Empty; + + public string UserName { get; set; } = string.Empty; + + public string Password { get; set; } = string.Empty; + } +} diff --git a/src/MakerPrompt.UI.Components/Models/PrinterTelemetry.cs b/src/MakerPrompt.UI.Components/Models/PrinterTelemetry.cs new file mode 100644 index 0000000..a7e1acf --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/PrinterTelemetry.cs @@ -0,0 +1,156 @@ +using System.ComponentModel; + +namespace MakerPrompt.UI.Components.Models +{ + public class PrinterTelemetry : INotifyPropertyChanged + { + private readonly object _lock = new(); + + private string _lastResponse = ""; + public string LastResponse + { + get => _lastResponse; + set => SetField(ref _lastResponse, value, nameof(LastResponse)); + } + + private string _printerName = "My 3D Printer"; + public string PrinterName + { + get => _printerName; + set => SetField(ref _printerName, value, nameof(PrinterName)); + } + + private DateTime? _connectionTime; + public DateTime? ConnectionTime + { + get => _connectionTime; + set => SetField(ref _connectionTime, value, nameof(ConnectionTime)); + } + + private double _hotendTemp; + public double HotendTemp + { + get => _hotendTemp; + set => SetField(ref _hotendTemp, value, nameof(HotendTemp)); + } + + private double _hotendTarget; + public double HotendTarget + { + get => _hotendTarget; + set => SetField(ref _hotendTarget, value, nameof(HotendTarget)); + } + + private double _bedTemp; + public double BedTemp + { + get => _bedTemp; + set => SetField(ref _bedTemp, value, nameof(BedTemp)); + } + + private double _bedTarget; + public double BedTarget + { + get => _bedTarget; + set => SetField(ref _bedTarget, value, nameof(BedTarget)); + } + + private double _chamberTemp; + public double ChamberTemp + { + get => _chamberTemp; + set => SetField(ref _chamberTemp, value, nameof(ChamberTemp)); + } + + private double _chamberTarget; + public double ChamberTarget + { + get => _chamberTarget; + set => SetField(ref _chamberTarget, value, nameof(ChamberTarget)); + } + + private Vector3 _position = new(); + public Vector3 Position + { + get => _position; + set => SetField(ref _position, value, nameof(Position)); + } + + private PrinterStatus _status = PrinterStatus.Disconnected; + public PrinterStatus Status + { + get => _status; + set => SetField(ref _status, value, nameof(Status)); + } + + private int _feedRate; + public int FeedRate + { + get => _feedRate; + set => SetField(ref _feedRate, value, nameof(FeedRate)); + } + + private int _flowRate; + public int FlowRate + { + get => _flowRate; + set => SetField(ref _flowRate, value, nameof(FlowRate)); + } + + private int _fanSpeed; + public int FanSpeed + { + get => _fanSpeed; + set => SetField(ref _fanSpeed, value, nameof(FanSpeed)); + } + + private string _printJobName = ""; + public string PrintJobName + { + get => _printJobName; + set => SetField(ref _printJobName, value, nameof(PrintJobName)); + } + + private TimeSpan _printDuration; + public TimeSpan PrintDuration + { + get => _printDuration; + set => SetField(ref _printDuration, value, nameof(PrintDuration)); + } + + private double _filamentUsed; + public double FilamentUsed + { + get => _filamentUsed; + set => SetField(ref _filamentUsed, value, nameof(FilamentUsed)); + } + + public SDCardStatus SDCard { get; } = new(); + + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + protected bool SetField(ref T field, T value, string propertyName) + { + lock (_lock) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + OnPropertyChanged(propertyName); + return true; + } + } + } + + public class SDCardStatus + { + public bool Present { get; set; } + public bool Printing { get; set; } + public double Progress { get; set; } // 0-100% + } +} diff --git a/src/MakerPrompt.UI.Components/Models/RemotePrinterInfo.cs b/src/MakerPrompt.UI.Components/Models/RemotePrinterInfo.cs new file mode 100644 index 0000000..0b4ec4e --- /dev/null +++ b/src/MakerPrompt.UI.Components/Models/RemotePrinterInfo.cs @@ -0,0 +1,12 @@ +namespace MakerPrompt.UI.Components.Models; + +/// +/// Printer discovered from a fleet provider (e.g. PrusaConnect account). +/// +public class RemotePrinterInfo +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; +} diff --git a/src/MakerPrompt.UI.Components/Pages/About.razor b/src/MakerPrompt.UI.Components/Pages/About.razor new file mode 100644 index 0000000..d7d42c1 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/About.razor @@ -0,0 +1,17 @@ +@page "/about" +@inject IStringLocalizer localizer + + +
+
+ @aboutContent +
+
+@code { + private MarkupString aboutContent; + + protected override void OnInitialized() + { + aboutContent = new MarkupString(localizer[Resources.AboutPage_Content]); + } +} diff --git a/src/MakerPrompt.UI.Components/Pages/Analytics.razor b/src/MakerPrompt.UI.Components/Pages/Analytics.razor new file mode 100644 index 0000000..108e4ef --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/Analytics.razor @@ -0,0 +1,132 @@ +@page "/analytics" +@using MakerPrompt.UI.Components.Models +@using MakerPrompt.UI.Components.Services +@inject AnalyticsService AnalyticsService +@inject PrinterConnectionManager ConnectionManager +@inject FilamentInventoryService InventoryService +@implements IDisposable + + + +
+

Analytics Dashboard

+ +
+
+
+
+
Total Print Time
+

@Math.Floor(AnalyticsService.GetTotalPrintHours().TotalHours)h @AnalyticsService.GetTotalPrintHours().Minutes m

+
+
+
+
+
+
+
Total Filament Consumed
+

@($"{AnalyticsService.GetTotalFilamentConsumed() / 1000:F2} kg")

+
+
+
+
+
+
+
Total Print Jobs
+

@AnalyticsService.GetRecords().Count

+
+
+
+
+ +
+
+
+
+ Usage by Printer +
+
+
    + @foreach (var printer in ConnectionManager.Printers) + { + var consumed = AnalyticsService.GetFilamentConsumedByPrinter(printer.Definition.Id); +
  • + @printer.Definition.Name + @($"{consumed:F0} g") +
  • + } +
+
+
+
+
+
+
+ Usage by Filament Spool +
+
+
    + @foreach (var spool in InventoryService.GetSpools()) + { + var consumed = AnalyticsService.GetFilamentConsumedBySpool(spool.Id); +
  • + @spool.Name + @($"{consumed:F0} g") +
  • + } +
+
+
+
+
+ +
+
+ Recent Print Jobs +
+
+
+ + + + + + + + + + + + @foreach (var record in AnalyticsService.GetRecords().OrderByDescending(r => r.Timestamp).Take(10)) + { + var printerName = ConnectionManager.Printers.FirstOrDefault(p => p.Definition.Id == record.PrinterId)?.Definition.Name ?? "Unknown"; + + + + + + + + } + +
DateJob NamePrinterDurationFilament Used
@record.Timestamp.ToLocalTime().ToString("g")@record.JobName@printerName@record.Duration.ToString(@"hh\:mm\:ss")@($"{(record.ActualFilamentUsedGrams > 0 ? record.ActualFilamentUsedGrams : record.EstimatedFilamentUsedGrams):F1} g")
+
+
+
+
+ +@code { + protected override void OnInitialized() + { + AnalyticsService.AnalyticsUpdated += OnAnalyticsUpdated; + } + + public void Dispose() + { + AnalyticsService.AnalyticsUpdated -= OnAnalyticsUpdated; + } + + private void OnAnalyticsUpdated(object? sender, EventArgs e) + { + InvokeAsync(StateHasChanged); + } +} diff --git a/src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor b/src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor deleted file mode 100644 index a682580..0000000 --- a/src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor +++ /dev/null @@ -1,159 +0,0 @@ -@page "/analytics" -@inject AnalyticsService Analytics -@inject ILogger Logger -@implements IDisposable - -

Analytics

- -@if (_records is null) -{ -

Loading…

-} -else -{ - -
-
-
-
-

Total Jobs

-

@_records.Count

-
-
-
-
-
-
-

Total Print Time

-

@FormatDuration(_totalPrintTime)

-
-
-
-
-
-
-

Total Filament (g)

-

@_totalFilament.ToString("F0")

-
-
-
-
- -
- -
-
-
Print Time by Printer
-
- @if (!_byPrinter.Any()) - { -

No data recorded yet.

- } - else - { - - - - - - @foreach (var (id, time, grams) in _byPrinter) - { - - - - - - } - -
Printer IDTimeFilament (g)
@TruncateId(id)@FormatDuration(time)@grams.ToString("F1")
- } -
-
-
- - -
-
-
Recent Jobs
-
- @if (!_records.Any()) - { -

No jobs recorded yet.

- } - else - { - - - - - - @foreach (var r in _records.OrderByDescending(r => r.Timestamp).Take(20)) - { - - - - - - } - -
JobDurationDate
@r.JobName@FormatDuration(r.Duration)@r.Timestamp.ToString("MM/dd HH:mm")
- } -
-
-
-
-} - -@code { - private IReadOnlyList? _records; - private TimeSpan _totalPrintTime; - private double _totalFilament; - private List<(Guid Id, TimeSpan Time, double Grams)> _byPrinter = []; - - protected override async Task OnInitializedAsync() - { - Analytics.AnalyticsUpdated += OnAnalyticsUpdated; - await LoadAsync(); - } - - private async Task LoadAsync() - { - try - { - _records = await Analytics.GetRecordsAsync(); - - _totalPrintTime = TimeSpan.FromTicks(_records.Sum(r => r.Duration.Ticks)); - _totalFilament = _records.Sum(r => r.EffectiveFilamentGrams); - - _byPrinter = _records - .GroupBy(r => r.PrinterId) - .Select(g => ( - Id: g.Key, - Time: TimeSpan.FromTicks(g.Sum(r => r.Duration.Ticks)), - Grams: g.Sum(r => r.EffectiveFilamentGrams))) - .OrderByDescending(x => x.Time) - .ToList(); - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to load analytics"); - _records = []; - } - } - - private void OnAnalyticsUpdated(object? sender, EventArgs e) => - InvokeAsync(async () => { await LoadAsync(); StateHasChanged(); }); - - private static string FormatDuration(TimeSpan ts) - => ts.TotalHours >= 1 - ? $"{(int)ts.TotalHours}h {ts.Minutes:D2}m" - : $"{ts.Minutes}m {ts.Seconds:D2}s"; - - private static string TruncateId(Guid id) - { - var s = id.ToString(); - return s.Length > 8 ? s[..8] + "…" : s; - } - - public void Dispose() => Analytics.AnalyticsUpdated -= OnAnalyticsUpdated; -} diff --git a/src/MakerPrompt.UI.Components/Pages/BrailleRAPPage.razor b/src/MakerPrompt.UI.Components/Pages/BrailleRAPPage.razor new file mode 100644 index 0000000..ff22642 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/BrailleRAPPage.razor @@ -0,0 +1,413 @@ +@page "/braillerap" +@inherits ConnectionComponentBase +@using MakerPrompt.UI.Components.BrailleRAP.Models +@using MakerPrompt.UI.Components.BrailleRAP.Services + + + +
+
+
+
+ @Localizer[Resources.BrailleRAP_Description] +
+
+
+ +
+ +
+
+
+
@Localizer[Resources.BrailleRAP_TextInput]
+
+
+
+ + +
+ +
+ + @if (statistics.PageCount > 0) + { + @Localizer[Resources.BrailleRAP_Pages]: @statistics.PageCount | @Localizer[Resources.BrailleRAP_Lines]: @statistics.TotalLines + } + + +
+
+
+
+ + +
+
+
+
@Localizer[Resources.BrailleRAP_Configuration]
+
+
+
+ +
+

+ +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+

+ +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
@Localizer[Resources.BrailleRAP_Preview]
+
+ + +
+
+
+ @if (!string.IsNullOrEmpty(inputText)) + { +
+ + + @foreach (var line in preview) + { + + @foreach (var ch in line) + { + + } + + } + +
@ch
+
+ } + else + { +

@Localizer[Resources.BrailleRAP_NoPreview]

+ } + @if (statistics.PageCount > 1) + { +
+
+ + + +
+
+ } +
+
+
+
+
+ +@code { + [Inject] private GCodeDocumentService GCodeDoc { get; set; } = null!; + [Inject] private ToastService ToastService { get; set; } = null!; + private BrailleRAPService brailleService = new(); + private PageConfig pageConfig = new(); + private MachineConfig machineConfig = new(); + + private string inputText = string.Empty; + private List preview = new(); + private string generatedGCode = string.Empty; + private int currentPage = 0; + private (int PageCount, int TotalLines, int TotalCharacters) statistics = (0, 0, 0); + private int selectedLanguage = 0; + + protected override void OnInitialized() + { + base.OnInitialized(); + brailleService.SetPageConfig(pageConfig); + brailleService.SetMachineConfig(machineConfig); + brailleService.SetLanguage(BrailleLanguage.EnglishGrade1); + UpdatePreview(); + } + + private void UpdatePreview() + { + if (string.IsNullOrEmpty(inputText)) + { + preview = new List(); + statistics = (0, 0, 0); + return; + } + + try + { + // Update language based on selection + var language = (BrailleLanguage)selectedLanguage; + brailleService.SetLanguage(language); + + brailleService.SetPageConfig(pageConfig); + brailleService.SetMachineConfig(machineConfig); + + preview = brailleService.GetBraillePreview(inputText, currentPage); + statistics = brailleService.GetStatistics(inputText); + + if (currentPage >= statistics.PageCount) + { + currentPage = Math.Max(0, statistics.PageCount - 1); + preview = brailleService.GetBraillePreview(inputText, currentPage); + } + } + catch (Exception ex) + { + ShowStatus($"Error updating preview: {ex.Message}", ToastType.Danger); + } + } + + private void OnInputTextChanged(ChangeEventArgs e) + { + inputText = e.Value?.ToString() ?? string.Empty; + UpdatePreview(); + } + + private void OnLanguageChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out int langValue)) + { + selectedLanguage = langValue; + UpdatePreview(); + } + } + + private void OnColumnsChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out int value) && value > 0 && value <= 50) + { + pageConfig.Columns = value; + UpdatePreview(); + } + } + + private void OnRowsChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out int value) && value > 0 && value <= 50) + { + pageConfig.Rows = value; + UpdatePreview(); + } + } + + private void OnLineSpacingChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out int value) && value >= 0 && value <= 3) + { + pageConfig.LineSpacing = value; + UpdatePreview(); + } + } + + private void OnFeedRateChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out int value) && value >= 100) + { + machineConfig.FeedRate = value; + } + } + + private void OnOffsetXChanged(ChangeEventArgs e) + { + if (double.TryParse(e.Value?.ToString(), out double value)) + { + machineConfig.OffsetX = value; + } + } + + private void OnOffsetYChanged(ChangeEventArgs e) + { + if (double.TryParse(e.Value?.ToString(), out double value)) + { + machineConfig.OffsetY = value; + } + } + + private void ClearText() + { + inputText = string.Empty; + generatedGCode = string.Empty; + currentPage = 0; + GCodeDoc.Clear(); + UpdatePreview(); + } + + private void PreviousPage() + { + if (currentPage > 0) + { + currentPage--; + UpdatePreview(); + } + } + + private void NextPage() + { + if (currentPage < statistics.PageCount - 1) + { + currentPage++; + UpdatePreview(); + } + } + + private void GenerateGCodeAction() + { + try + { + generatedGCode = brailleService.GenerateGCode(inputText, currentPage); + GCodeDoc.SetGCode(generatedGCode); + ShowStatus(Localizer[Resources.BrailleRAP_GCodeGenerated], ToastType.Success); + } + catch (Exception ex) + { + ShowStatus($"Error generating G-code: {ex.Message}", ToastType.Danger); + } + } + + private async Task SendToPrinterAction() + { + if (!IsConnected) + { + ShowStatus(Localizer[Resources.BrailleRAP_NotConnected], ToastType.Warning); + return; + } + + if (string.IsNullOrEmpty(generatedGCode)) + { + ShowStatus(Localizer[Resources.BrailleRAP_GenerateFirst], ToastType.Warning); + return; + } + + try + { + if (PrinterServiceFactory.Current != null) + { + await PrinterServiceFactory.Current.WriteDataAsync(generatedGCode); + ShowStatus(Localizer[Resources.BrailleRAP_SentSuccessfully], ToastType.Success); + } + } + catch (Exception ex) + { + ShowStatus($"Error sending to printer: {ex.Message}", ToastType.Danger); + } + } + + private void ShowStatus(string message, ToastType toastType) + { + ToastService.Notify(new(toastType, message)); + } +} diff --git a/src/MakerPrompt.UI.Components/Pages/Calculators.razor b/src/MakerPrompt.UI.Components/Pages/Calculators.razor new file mode 100644 index 0000000..873c196 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/Calculators.razor @@ -0,0 +1,86 @@ +@page "/calculators" +@inherits ConnectionComponentBase + + + +
+

@Localizer[Resources.Calculators_RememberSave]: M500

+ +
+ +
+
+

+ +

+
+
+ +
+
+
+
+

+ +

+
+
+ +
+
+
+
+

+ +

+
+
+ +
+
+
+@*
+

+ +

+
+
+ +
+
+
*@ +
+ +@code { + enum CalculatorTabs { + [Display(Name = nameof(Resources.Calculators_PrintPrice), ResourceType = typeof(Resources))] + PrintPrice, + [Display(Name = nameof(Resources.Calculators_BeltSteps), ResourceType = typeof(Resources))] + BeltSteps, + [Display(Name = nameof(Resources.Calculators_LeadScrewSteps), ResourceType = typeof(Resources))] + LeadScrewSteps, + [Display(Name = nameof(Resources.Calculators_ExtruderSteps), ResourceType = typeof(Resources))] + ExtruderSteps + } + + private CalculatorTabs ActiveTab = CalculatorTabs.BeltSteps; + + + private void ShowTab(CalculatorTabs tab) + { + ActiveTab = tab; + } + + public async Task CopyM500() => await JS.CopyToClipboard("M500"); +} diff --git a/src/MakerPrompt.UI.Components/Pages/CheatSheet.razor b/src/MakerPrompt.UI.Components/Pages/CheatSheet.razor new file mode 100644 index 0000000..e61ce07 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/CheatSheet.razor @@ -0,0 +1,211 @@ +@page "/cheatsheet" +@using System.Reflection +@using System.ComponentModel.DataAnnotations +@inject IStringLocalizer localizer +@inherits ConnectionComponentBase + + + + + +
+
+
+ +
+
+ + + + +
+ + + + + + + + + + + + @foreach (var command in _filteredList) + { + + + + + + + + } + +
@command.Command@localizer[command.Description] + @foreach (var category in command.Categories) + { + + @category.GetLocalizedDisplayName() + + } + + @if (command.Parameters.Any()) + { +
    + @foreach (var param in command.Parameters) + { +
  • + @param.Label + @param.Description +
  • + } +
+ } + else + { + @localizer[Resources.CheatSheetPage_None] + } +
+ +
+
+
+
+ + @if (IsConnected && PrinterServiceFactory.Current?.ConnectionType == MakerPrompt.UI.Components.Utils.Enums.PrinterConnectionType.Moonraker) + { + + + @if (moonrakerHelpError != null) + { +
@moonrakerHelpError
+ } + else if (moonrakerHelp is null) + { +
Loading Moonraker G-code help...
+ } + else if (moonrakerHelp.Count == 0) + { +
No G-code help available from Moonraker.
+ } + else + { +
+ + + + + + + + + @foreach (var entry in moonrakerHelp) + { + + + + + } + +
CommandDescription
@entry.Key@entry.Value
+
+ } +
+
+ } +
+
+ +@code { + private Modal modal = default!; + private string searchTerm = string.Empty; + private Dictionary? moonrakerHelp; + private string? moonrakerHelpError; + + // Cached once via reflection — shared across all instances. + private static readonly List allCommands = GCodeCommands.AllCommands(); + + // Materialized filtered list — only recomputed when searchTerm changes. + private List _filteredList = allCommands; + + // Dirty flag: set true only when content-affecting state changes. + // Prevents the 3-second telemetry tick from re-rendering 43 rows. + private bool _needsRender = true; + + protected override bool ShouldRender() + { + if (_needsRender) { _needsRender = false; return true; } + return false; + } + + private void UpdateFilter() + { + _filteredList = string.IsNullOrWhiteSpace(searchTerm) + ? allCommands + : allCommands.Where(c => + c.Command.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) || + c.Description.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)).ToList(); + _needsRender = true; + } + + // Telemetry updates don't affect this page's content — suppress re-renders. + protected override void HandleTelemetryUpdated(object? sender, PrinterTelemetry printerTelemetry) { } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + await TryLoadMoonrakerHelpAsync(); + } + + protected override void HandleConnectionChanged(object? sender, bool connected) + { + _needsRender = true; + base.HandleConnectionChanged(sender, connected); + if (connected && PrinterServiceFactory.Current?.ConnectionType == MakerPrompt.UI.Components.Utils.Enums.PrinterConnectionType.Moonraker) + { + _ = TryLoadMoonrakerHelpAsync(); + } + } + + private async Task TryLoadMoonrakerHelpAsync() + { + moonrakerHelpError = null; + moonrakerHelp = null; + + if (PrinterServiceFactory.Current is not MoonrakerApiService moonrakerService) + { + _needsRender = true; + await InvokeAsync(StateHasChanged); + return; + } + + try + { + moonrakerHelp = await moonrakerService.GetGcodeHelpAsync(); + } + catch (Exception ex) + { + moonrakerHelpError = ex.Message; + } + + _needsRender = true; + await InvokeAsync(StateHasChanged); + } + + private async Task ShowCommandDetailsAsync(GCodeCommand command) + { + var parameters = new Dictionary + { + { nameof(TestCommandModal.Command), command.Command } + }; + + await modal.ShowAsync(title: command.Command, parameters: parameters); + } +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Pages/Dashboard.razor b/src/MakerPrompt.UI.Components/Pages/Dashboard.razor new file mode 100644 index 0000000..c53de98 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/Dashboard.razor @@ -0,0 +1,49 @@ +@page "/dashboard" +@inject IAppConfigurationService ConfigService +@inject PrinterConnectionManager ConnectionManager +@inject IStringLocalizer localizer +@implements IDisposable + + + +
+ @if (ConnectionManager.Printers.Any(p => p.Service?.IsConnected == true)) + { + + } + else + { +
+
+ +

@localizer[Resources.Dashboard_Welcome]

+

@localizer[Resources.Dashboard_ConnectPrompt]

+ +
+
+ } +
+ +@code { + [CascadingParameter] private PrinterConnectionModal? ConnectionModal { get; set; } + + protected override void OnInitialized() + { + ConnectionManager.PrintersChanged += HandleChange; + } + + private Task OpenAddPrinterModal() => + ConnectionModal?.ShowAddAsync() ?? Task.CompletedTask; + + private async void HandleChange(object? sender, EventArgs e) + { + await InvokeAsync(StateHasChanged); + } + + public void Dispose() + { + ConnectionManager.PrintersChanged -= HandleChange; + } +} diff --git a/src/MakerPrompt.UI.Components/Pages/DashboardPage.razor b/src/MakerPrompt.UI.Components/Pages/DashboardPage.razor deleted file mode 100644 index b51bd93..0000000 --- a/src/MakerPrompt.UI.Components/Pages/DashboardPage.razor +++ /dev/null @@ -1,192 +0,0 @@ -@page "/dashboard" -@inject PrinterFleetService FleetService -@inject ILogger Logger -@implements IDisposable - -

Dashboard

- -@if (!_printerIds.Any()) -{ -
- No printers connected. Visit the Fleet page to add one. -
-} -else -{ -
- - -
- - @if (_selected is not null) - { - var t = _selected.LastTelemetry; -
- -
-
-
-
Status
-

@t.Status

- @if (t.Status == PrinterStatus.Printing && !string.IsNullOrEmpty(t.PrintJobName)) - { -

@t.PrintJobName

- } -
-
-
- - -
-
-
-
Temperature
- - - - - - - - - - - @if (t.ChamberTemp > 0) - { - - - - - } - -
Hotend@t.HotendTemp.ToString("F1") / @t.HotendTarget.ToString("F0") °C
Bed@t.BedTemp.ToString("F1") / @t.BedTarget.ToString("F0") °C
Chamber@t.ChamberTemp.ToString("F1") / @t.ChamberTarget.ToString("F0") °C
-
-
-
- - - @if (t.Status == PrinterStatus.Printing) - { -
-
-
-
Print Progress
-
-
-
-
-

@t.PrintProgress.ToString("F1") %

- - Duration: @FormatDuration(t.PrintDuration) -  |  Filament: @t.FilamentUsed.ToString("F1") mm - -
-
-
- } - - -
-
-
-
Controls
-
- - - -
-
-
-
-
- } -} - -@code { - private IReadOnlyCollection _printerIds = []; - private string? _selectedId; - private IPrinterCommunicationService? _selected; - - protected override void OnInitialized() - { - FleetService.FleetChanged += OnFleetChanged; - Refresh(); - } - - private void Refresh() - { - _printerIds = FleetService.PrinterIds; - if (_selectedId is null || !_printerIds.Contains(_selectedId)) - _selectedId = _printerIds.FirstOrDefault(); - - _selected = _selectedId is not null - ? FleetService.GetConnection(_selectedId) : null; - } - - private void OnFleetChanged(object? sender, EventArgs e) => - InvokeAsync(() => { Refresh(); StateHasChanged(); }); - - private void OnPrinterSelected(ChangeEventArgs e) - { - _selectedId = e.Value?.ToString(); - _selected = _selectedId is not null - ? FleetService.GetConnection(_selectedId) : null; - } - - private async Task HomeAllAxes() - { - if (_selected is null) return; - try { await _selected.HomeAsync(); } - catch (Exception ex) { Logger.LogError(ex, "Home failed"); } - } - - private async Task TurnOffHeaters() - { - if (_selected is null) return; - try - { - await _selected.SetHotendTempAsync(0); - await _selected.SetBedTempAsync(0); - } - catch (Exception ex) { Logger.LogError(ex, "Heaters off failed"); } - } - - private async Task FanOff() - { - if (_selected is null) return; - try { await _selected.SetFanSpeedAsync(0); } - catch (Exception ex) { Logger.LogError(ex, "Fan off failed"); } - } - - private static string FormatDuration(TimeSpan ts) - => ts.TotalHours >= 1 - ? $"{(int)ts.TotalHours}h {ts.Minutes:D2}m" - : $"{ts.Minutes}m {ts.Seconds:D2}s"; - - public void Dispose() => FleetService.FleetChanged -= OnFleetChanged; -} diff --git a/src/MakerPrompt.UI.Components/Pages/FilamentInventory.razor b/src/MakerPrompt.UI.Components/Pages/FilamentInventory.razor new file mode 100644 index 0000000..bf432ee --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/FilamentInventory.razor @@ -0,0 +1,162 @@ +@page "/filament" +@using MakerPrompt.UI.Components.Models +@using MakerPrompt.UI.Components.Services +@inject FilamentInventoryService InventoryService +@inject ToastService ToastService +@implements IDisposable + + + +
+
+

Filament Inventory

+ +
+ +
+ @foreach (var spool in InventoryService.GetSpools()) + { +
+
+
+ @spool.Name +
+ + +
+
+
+
+ @spool.Material + @spool.Color + @spool.Brand +
+
+ Remaining + @($"{spool.RemainingWeightGrams:F0}g / {spool.TotalWeightGrams:F0}g") +
+
+
+
+
+
+
+
+ } +
+
+ + + +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + + + +
+ +@code { + private Modal _modal = default!; + private bool _isEditing; + private FilamentSpool _editSpool = new(); + + protected override void OnInitialized() + { + InventoryService.InventoryChanged += OnInventoryChanged; + } + + public void Dispose() + { + InventoryService.InventoryChanged -= OnInventoryChanged; + } + + private void OnInventoryChanged(object? sender, EventArgs e) + { + InvokeAsync(StateHasChanged); + } + + private async Task OpenAddModal() + { + _isEditing = false; + _editSpool = new FilamentSpool(); + await _modal.ShowAsync(); + } + + private async Task OpenEditModal(FilamentSpool spool) + { + _isEditing = true; + _editSpool = new FilamentSpool + { + Id = spool.Id, + Name = spool.Name, + Material = spool.Material, + Color = spool.Color, + Brand = spool.Brand, + TotalWeightGrams = spool.TotalWeightGrams, + RemainingWeightGrams = spool.RemainingWeightGrams + }; + await _modal.ShowAsync(); + } + + private async Task CloseModal() + { + await _modal.HideAsync(); + } + + private async Task SaveSpoolAsync() + { + if (_isEditing) + { + await InventoryService.UpdateSpoolAsync(_editSpool); + ToastService.Notify(new ToastMessage(ToastType.Success, "Spool Updated", "Filament spool updated successfully.")); + } + else + { + await InventoryService.AddSpoolAsync(_editSpool); + ToastService.Notify(new ToastMessage(ToastType.Success, "Spool Added", "Filament spool added successfully.")); + } + await _modal.HideAsync(); + } + + private async Task DeleteSpool(Guid id) + { + await InventoryService.DeleteSpoolAsync(id); + ToastService.Notify(new ToastMessage(ToastType.Success, "Spool Deleted", "Filament spool deleted successfully.")); + } +} diff --git a/src/MakerPrompt.UI.Components/Pages/FilamentInventoryPage.razor b/src/MakerPrompt.UI.Components/Pages/FilamentInventoryPage.razor deleted file mode 100644 index 3bffb56..0000000 --- a/src/MakerPrompt.UI.Components/Pages/FilamentInventoryPage.razor +++ /dev/null @@ -1,209 +0,0 @@ -@page "/filament" -@inject FilamentInventoryService InventoryService -@inject ILogger Logger -@implements IDisposable - -

Filament Inventory

- -@if (_spools is null) -{ -

Loading…

-} -else -{ -
- -
- - @if (_showForm) - { -
-
@(_editingSpool is null ? "Add Spool" : "Edit Spool")
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
- @if (!string.IsNullOrEmpty(_formError)) - { -
@_formError
- } -
-
- } - - @if (!_spools.Any()) - { -
No spools tracked yet. Add one above.
- } - else - { - - - - - - - - - - - - - @foreach (var spool in _spools) - { - var pct = spool.TotalWeightGrams > 0 - ? (int)(spool.RemainingWeightGrams / spool.TotalWeightGrams * 100) - : 0; - - - - - - - - - } - -
MaterialBrandColorRemainingTotal
@spool.Material@spool.Brand@spool.Color -
-
-
-
-
- @spool.RemainingWeightGrams.ToString("F0") g -
-
@spool.TotalWeightGrams.ToString("F0") g - - -
- } -} - -@code { - private IReadOnlyList? _spools; - private bool _showForm; - private FilamentSpool? _editingSpool; - private string _formMaterial = ""; - private string _formBrand = ""; - private string _formColor = ""; - private double _formTotalWeight = 1000; - private double _formRemainingWeight = 1000; - private string _formError = ""; - - protected override async Task OnInitializedAsync() - { - InventoryService.InventoryChanged += OnInventoryChanged; - await LoadAsync(); - } - - private async Task LoadAsync() - { - try { _spools = await InventoryService.GetSpoolsAsync(); } - catch (Exception ex) { Logger.LogError(ex, "Failed to load spools"); } - } - - private void OnInventoryChanged(object? sender, EventArgs e) => - InvokeAsync(async () => { await LoadAsync(); StateHasChanged(); }); - - private void ShowAddForm() - { - _editingSpool = null; - _formMaterial = ""; - _formBrand = ""; - _formColor = ""; - _formTotalWeight = 1000; - _formRemainingWeight = 1000; - _formError = ""; - _showForm = true; - } - - private void EditSpool(FilamentSpool spool) - { - _editingSpool = spool; - _formMaterial = spool.Material; - _formBrand = spool.Brand; - _formColor = spool.Color; - _formTotalWeight = spool.TotalWeightGrams; - _formRemainingWeight = spool.RemainingWeightGrams; - _formError = ""; - _showForm = true; - } - - private void CancelForm() => _showForm = false; - - private async Task SaveSpoolAsync() - { - _formError = ""; - if (string.IsNullOrWhiteSpace(_formMaterial)) - { - _formError = "Material is required."; - return; - } - - try - { - var spool = _editingSpool ?? new FilamentSpool { Id = Guid.NewGuid() }; - spool.Material = _formMaterial; - spool.Brand = _formBrand; - spool.Color = _formColor; - spool.TotalWeightGrams = _formTotalWeight; - spool.RemainingWeightGrams = _formRemainingWeight; - - if (_editingSpool is null) - await InventoryService.AddSpoolAsync(spool); - else - await InventoryService.UpdateSpoolAsync(spool); - - _showForm = false; - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to save spool"); - _formError = "Save failed. Please try again."; - } - } - - private async Task DeleteSpoolAsync(Guid id) - { - try { await InventoryService.DeleteSpoolAsync(id); } - catch (Exception ex) { Logger.LogError(ex, "Failed to delete spool"); } - } - - public void Dispose() => InventoryService.InventoryChanged -= OnInventoryChanged; -} diff --git a/src/MakerPrompt.UI.Components/Pages/Fleet.razor b/src/MakerPrompt.UI.Components/Pages/Fleet.razor new file mode 100644 index 0000000..99cb605 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/Fleet.razor @@ -0,0 +1,385 @@ +@page "/fleet" +@* ───────────────────────────────────────────────────────────────────── + Fleet Dashboard — Farm mode main landing page. + + Shows all saved printers as cards. Clicking a connected printer opens + the full Dashboard (ControlPanel / PID / Thermal) inline. Disconnected + printers show a connect button. Each card has edit/delete actions that + appear when the card is selected. + ───────────────────────────────────────────────────────────────────── *@ +@using MakerPrompt.UI.Components.Infrastructure +@using static MakerPrompt.UI.Components.Utils.Enums +@inject PrinterConnectionManager ConnectionManager +@inject FilamentInventoryService FilamentInventoryService +@inject IAppConfigurationService ConfigService +@inject NavigationManager Navigation +@inject IPrinterCameraProvider CameraProvider +@inject IStringLocalizer localizer +@inject ToastService ToastService +@inject ILogger Logger +@implements IDisposable + + + +@* ── Selected printer → show Dashboard ── *@ +@if (_selectedPrinter?.Service?.IsConnected == true) +{ +
+
+ +
@_selectedPrinter.Definition.Name
+ @_selectedPrinter.Status.GetLocalizedDisplayName() + +
+ +
+} +else +{ +
+ @* ── Toolbar ── *@ +
+ @if (ConfigService.Configuration.DeploymentMode != AppDeploymentMode.CloudMakerspace) + { + + } +
+ + @if (!ConnectionManager.Printers.Any()) + { +
+ +
+ @localizer[Resources.Fleet_NoPrinters] +
+
+
+ } + else + { +
+ @foreach (var printer in ConnectionManager.Printers) + { + var isSelected = _selectedPrinterId == printer.Definition.Id; +
+
+
+
+ + @printer.Definition.Name +
+ @if (isSelected) + { +
+ @if (printer.Status == PrinterStatus.Disconnected || printer.Status == PrinterStatus.Error) + { + + } + else + { + + } + @if (ConfigService.Configuration.DeploymentMode != AppDeploymentMode.CloudMakerspace) + { + + + } +
+ } +
+
+ @if (printer.Status == PrinterStatus.Disconnected || printer.Status == PrinterStatus.Error) + { +
+ +

@printer.Status.GetLocalizedDisplayName()

+ @if (!string.IsNullOrEmpty(printer.LastError)) + { + @printer.LastError + } +
+ } + else + { + @* ── Temperatures ── *@ +
+
+
+ +
+ @localizer[Resources.PrinterComponent_Hotend] + @($"{printer.Telemetry.HotendTemp:F1}°C") + → @($"{printer.Telemetry.HotendTarget:F0}°C") +
+
+
+
+
+ +
+ @localizer[Resources.PrinterComponent_Heatbed] + @($"{printer.Telemetry.BedTemp:F1}°C") + → @($"{printer.Telemetry.BedTarget:F0}°C") +
+
+
+
+ + @* ── Progress ── *@ + @if (printer.Telemetry.SDCard.Printing || printer.Status == PrinterStatus.Printing) + { +
+
+ @localizer[Resources.Fleet_Progress] + @($"{printer.Telemetry.SDCard.Progress:F0}%") +
+
+
+
+
+
+ } + + @* ── Camera Preview ── *@ + @if (_cameras.TryGetValue(printer.Definition.Id, out var cam) && cam is not null) + { +
+ +
+ } + + @* ── Assigned Filament ── *@ + @if (ConfigService.Configuration.EnableFilamentInventory && printer.Definition.AssignedFilamentSpoolId.HasValue) + { + var spool = FilamentInventoryService.GetSpool(printer.Definition.AssignedFilamentSpoolId.Value); + if (spool != null) + { +
+
+ @spool.Name + @($"{spool.RemainingWeightGrams:F0}g / {spool.TotalWeightGrams:F0}g") +
+
+
+
+
+
+ } + } + } +
+
+
+ } +
+ } +
+} + +@code { + [CascadingParameter] private PrinterConnectionModal? ConnectionModal { get; set; } + + private readonly Dictionary _cameras = new(); + + // Selection state + private Guid? _selectedPrinterId; + private ManagedPrinterState? _selectedPrinter; + + protected override async Task OnInitializedAsync() + { + await ConfigService.InitializeAsync(); + if (!ConfigService.Configuration.FarmModeEnabled) + { + Navigation.NavigateTo("/dashboard", replace: true); + return; + } + ConnectionManager.PrintersChanged += HandlePrintersChanged; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await RefreshCameraUrlsAsync(); + } + } + + private async void HandlePrintersChanged(object? sender, EventArgs e) + { + // Keep selected printer reference fresh + if (_selectedPrinterId.HasValue) + { + _selectedPrinter = ConnectionManager.Printers.ToList().FirstOrDefault(p => p.Definition.Id == _selectedPrinterId); + if (_selectedPrinter == null) + _selectedPrinterId = null; + // When the selected printer just became connected (e.g. user hit Connect + // while its card was selected), sync factory.Current before the re-render + // creates the dashboard — otherwise the StorageExplorer reads stale data. + else if (_selectedPrinter.Service?.IsConnected == true && !_selectedPrinter.IsActive) + ConnectionManager.SetActivePrinter(_selectedPrinter.Definition.Id); + } + + await RefreshCameraUrlsAsync(); + await InvokeAsync(StateHasChanged); + } + + // ── Selection ── + private void SelectPrinter(ManagedPrinterState printer) + { + if (_selectedPrinterId == printer.Definition.Id && printer.Service?.IsConnected != true) + { + // Clicking the already-selected card again deselects it + _selectedPrinterId = null; + _selectedPrinter = null; + return; + } + + _selectedPrinterId = printer.Definition.Id; + _selectedPrinter = printer; + + // If connected, sync to factory so Dashboard/ControlPanel/CommandPrompt work + if (printer.Service?.IsConnected == true) + { + ConnectionManager.SetActivePrinter(printer.Definition.Id); + } + } + + private void DeselectPrinter() + { + _selectedPrinterId = null; + _selectedPrinter = null; + } + + // ── Camera ── + private async Task RefreshCameraUrlsAsync() + { + foreach (var printer in ConnectionManager.Printers.ToList()) + { + if (printer.Service == null || !printer.Service.IsConnected) + { + _cameras.Remove(printer.Definition.Id); + continue; + } + + try + { + IReadOnlyList cameras = printer.Service switch + { + MoonrakerApiService moonraker => await moonraker.GetCamerasAsync(), + OctoPrintApiService octoprint => await octoprint.GetCamerasAsync(), + BambuLabApiService bambu => await bambu.GetCamerasAsync(), + PrusaLinkApiService prusa => await prusa.GetCamerasAsync(), + PrusaConnectApiService prusaConnect => await prusaConnect.GetCamerasAsync(), + _ => Array.Empty() + }; + + _cameras[printer.Definition.Id] = cameras.FirstOrDefault(); + } + catch + { + _cameras.Remove(printer.Definition.Id); + } + } + } + + // ── Connection actions ── + private async Task ConnectAsync(Guid id) + { + try { await ConnectionManager.ConnectPrinterAsync(id); } + catch (Exception ex) + { + Logger.LogError(ex, "Fleet: connect failed"); + ToastService.Notify(new ToastMessage(ToastType.Danger, localizer[Resources.NavPrinters_ConnectFailed], ex.Message)); + } + } + + private async Task DisconnectAsync(Guid id) + { + try { await ConnectionManager.DisconnectPrinterAsync(id); } + catch (Exception ex) + { + Logger.LogError(ex, "Fleet: disconnect failed"); + ToastService.Notify(new ToastMessage(ToastType.Danger, localizer[Resources.NavPrinters_DisconnectFailed], ex.Message)); + } + } + + private async Task DeletePrinter(Guid id) + { + try + { + await ConnectionManager.RemovePrinterAsync(id); + if (_selectedPrinterId == id) + { + _selectedPrinterId = null; + _selectedPrinter = null; + } + ToastService.Notify(new ToastMessage(ToastType.Success, localizer[Resources.NavPrinters_PrinterRemoved])); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to delete printer"); + ToastService.Notify(new ToastMessage(ToastType.Danger, localizer[Resources.NavPrinters_DeleteFailed], ex.Message)); + } + } + + // ── Helpers ── + private static string GetStatusIcon(ManagedPrinterState printer) + { + return printer.Status switch + { + PrinterStatus.Connected => "bi-check-circle-fill", + PrinterStatus.Printing => "bi-play-circle-fill", + PrinterStatus.Paused => "bi-pause-circle-fill", + PrinterStatus.Error => "bi-exclamation-circle-fill", + _ => "bi-dash-circle" + }; + } + + private static string GetStatusColor(ManagedPrinterState printer) + { + return printer.Status switch + { + PrinterStatus.Connected => "text-success", + PrinterStatus.Printing => "text-primary", + PrinterStatus.Paused => "text-warning", + PrinterStatus.Error => "text-danger", + _ => "text-muted" + }; + } + + public void Dispose() + { + ConnectionManager.PrintersChanged -= HandlePrintersChanged; + } +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Pages/FleetPage.razor b/src/MakerPrompt.UI.Components/Pages/FleetPage.razor deleted file mode 100644 index c4ff866..0000000 --- a/src/MakerPrompt.UI.Components/Pages/FleetPage.razor +++ /dev/null @@ -1,125 +0,0 @@ -@page "/fleet" -@inject PrinterFleetService FleetService -@inject ILogger Logger -@implements IDisposable - -

Printer Fleet

- -@if (!_printerIds.Any()) -{ -
- No printers connected. Add a printer to get started. -
-} -else -{ -
- @foreach (var id in _printerIds) - { - var conn = FleetService.GetConnection(id); - if (conn is null) continue; -
-
-
- - - @(string.IsNullOrEmpty(conn.ConnectionName) ? id : conn.ConnectionName) - - @conn.ConnectionType -
-
- @if (conn.IsConnected) - { - var t = conn.LastTelemetry; -
-
Status
-
@t.Status
-
Hotend
-
@t.HotendTemp.ToString("F1") °C / @t.HotendTarget.ToString("F0") °C
-
Bed
-
@t.BedTemp.ToString("F1") °C / @t.BedTarget.ToString("F0") °C
- @if (t.Status == PrinterStatus.Printing) - { -
Progress
-
-
-
-
-
- @t.PrintProgress.ToString("F1") % -
- } -
- } - else - { -

Not connected

- } -
- -
-
- } -
-} - -@code { - private IReadOnlyCollection _printerIds = []; - - protected override void OnInitialized() - { - FleetService.FleetChanged += OnFleetChanged; - Refresh(); - } - - private void Refresh() => - _printerIds = FleetService.PrinterIds; - - private void OnFleetChanged(object? sender, EventArgs e) => - InvokeAsync(() => { Refresh(); StateHasChanged(); }); - - private async Task DisconnectAsync(string printerId) - { - var conn = FleetService.GetConnection(printerId); - if (conn is null) return; - try - { - await conn.DisconnectAsync(); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error disconnecting printer {PrinterId}", printerId); - } - } - - private async Task RemoveAsync(string printerId) - { - try - { - await FleetService.RemoveAsync(printerId); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error removing printer {PrinterId}", printerId); - } - } - - public void Dispose() => FleetService.FleetChanged -= OnFleetChanged; -} diff --git a/src/MakerPrompt.UI.Components/Pages/Home.razor b/src/MakerPrompt.UI.Components/Pages/Home.razor new file mode 100644 index 0000000..f6db6c5 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/Home.razor @@ -0,0 +1,5 @@ +@page "/home" + + + + diff --git a/src/MakerPrompt.UI.Components/Pages/Index.razor b/src/MakerPrompt.UI.Components/Pages/Index.razor new file mode 100644 index 0000000..eb91472 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/Index.razor @@ -0,0 +1,14 @@ +@page "/" +@inject IAppConfigurationService ConfigService +@inject NavigationManager Navigation + +@code { + protected override async Task OnInitializedAsync() + { + await ConfigService.InitializeAsync(); + if (ConfigService.Configuration.FarmModeEnabled) + Navigation.NavigateTo("/fleet", replace: true); + else + Navigation.NavigateTo("/dashboard", replace: true); + } +} diff --git a/src/MakerPrompt.UI.Components/Pages/Settings.razor b/src/MakerPrompt.UI.Components/Pages/Settings.razor new file mode 100644 index 0000000..8a1d7ee --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/Settings.razor @@ -0,0 +1,287 @@ +@page "/settings" +@using MakerPrompt.UI.Components.Infrastructure +@using MakerPrompt.UI.Components.Utils +@inject IAppConfigurationService ConfigService +@inject FarmConfigurationService FarmService +@inject MakerPromptJsInterop JsInterop +@inject IAppLocalStorageProvider AppStorage +@inject ToastService ToastService +@inject NavigationManager Navigation +@inject ILogger Logger +@inject IStringLocalizer localizer +@implements IDisposable + + + +
+
+
+
+
+ @localizer[Resources.Settings_FarmMode] +
+
+ @if (ConfigService.Configuration.DeploymentMode == AppDeploymentMode.CloudMakerspace) + { + + } + else + { +
+ + +
@localizer[Resources.Settings_FarmModeDescription]
+
+ + @if (_config.FarmModeEnabled) + { +
+
@localizer[Resources.Settings_FarmConfigurations]
+
+
+ + + +
+
+
+
+ + +
+
+
+ + +
+ } + } +
+
+ +
+
+ @localizer[Resources.Settings_Features] +
+
+
+ + +
@localizer[Resources.Settings_FilamentInventoryDescription]
+
+ +
+ + +
@localizer[Resources.Settings_PrintAnalyticsDescription]
+
+
+
+ +
+
+ @localizer[Resources.Settings_TelemetryAnalytics] +
+
+
+ + +
@localizer[Resources.Settings_TelemetryDescription]
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+ +@code { + private AppConfiguration _config = new(); + private static readonly string[] _hiddenPrefixes = ["MakerPrompt.PrinterConnections", "MakerPrompt.PrintProjects"]; + private Guid? _selectedFarmId; + private string _newFarmName = string.Empty; + + protected override async Task OnInitializedAsync() + { + _config = new AppConfiguration + { + Theme = ConfigService.Configuration.Theme, + Language = ConfigService.Configuration.Language, + FarmModeEnabled = ConfigService.Configuration.FarmModeEnabled, + ActiveFarmId = ConfigService.Configuration.ActiveFarmId, + AnalyticsEnabled = ConfigService.Configuration.AnalyticsEnabled, + EnableFilamentInventory = ConfigService.Configuration.EnableFilamentInventory, + EnablePrintAnalytics = ConfigService.Configuration.EnablePrintAnalytics, + LastUpdated = ConfigService.Configuration.LastUpdated + }; + + _selectedFarmId = _config.ActiveFarmId; + await FarmService.InitializeAsync(); + } + + private async Task SaveSettingsAsync() + { + var wasInFarmMode = ConfigService.Configuration.FarmModeEnabled; + + ConfigService.Configuration.Theme = _config.Theme; + ConfigService.Configuration.Language = _config.Language; + ConfigService.Configuration.FarmModeEnabled = _config.FarmModeEnabled; + ConfigService.Configuration.ActiveFarmId = _config.ActiveFarmId; + ConfigService.Configuration.AnalyticsEnabled = _config.AnalyticsEnabled; + ConfigService.Configuration.EnableFilamentInventory = _config.EnableFilamentInventory; + ConfigService.Configuration.EnablePrintAnalytics = _config.EnablePrintAnalytics; + ConfigService.Configuration.LastUpdated = DateTime.UtcNow; + + // When disabling farm mode, clear the farm-loaded printers and reset the farm name + if (wasInFarmMode && !_config.FarmModeEnabled) + { + ConfigService.Configuration.FarmName = string.Empty; + ConfigService.Configuration.ActiveFarmId = null; + await FarmService.ClearPrinterConnectionsAsync(); + } + + await ConfigService.SaveConfigurationAsync(); + ToastService.Notify(new ToastMessage(ToastType.Success, localizer[Resources.Settings_Toast_Saved], localizer[Resources.Settings_Toast_SavedMessage])); + + if (!wasInFarmMode && _config.FarmModeEnabled) + Navigation.NavigateTo("/fleet", replace: true); + else if (wasInFarmMode && !_config.FarmModeEnabled) + Navigation.NavigateTo("/dashboard", replace: true); + } + + private async Task CreateFarmAsync() + { + if (string.IsNullOrWhiteSpace(_newFarmName)) return; + try + { + var farm = await FarmService.CreateFarmAsync(_newFarmName.Trim()); + _selectedFarmId = farm.Id; + _newFarmName = string.Empty; + ToastService.Notify(new ToastMessage(ToastType.Success, localizer[Resources.Settings_Toast_FarmCreated], string.Format(localizer[Resources.Settings_Toast_FarmCreatedMessage], farm.Name))); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to create farm"); + ToastService.Notify(new ToastMessage(ToastType.Danger, localizer[Resources.Settings_Toast_Error], localizer[Resources.Settings_Toast_CreateFarmError])); + } + } + + private async Task SwitchFarmAsync() + { + if (_selectedFarmId == null) return; + try + { + await FarmService.SwitchFarmAsync(_selectedFarmId.Value); + _config.FarmName = ConfigService.Configuration.FarmName; + _config.ActiveFarmId = _selectedFarmId; + ToastService.Notify(new ToastMessage(ToastType.Success, localizer[Resources.Settings_Toast_FarmSwitched], string.Format(localizer[Resources.Settings_Toast_FarmSwitchedMessage], FarmService.ActiveFarm?.Name))); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to switch farm"); + ToastService.Notify(new ToastMessage(ToastType.Danger, localizer[Resources.Settings_Toast_Error], localizer[Resources.Settings_Toast_SwitchFarmError])); + } + } + + private async Task DeleteFarmAsync() + { + if (_selectedFarmId == null) return; + try + { + await FarmService.DeleteFarmAsync(_selectedFarmId.Value); + _selectedFarmId = FarmService.Farms.FirstOrDefault()?.Id; + + // If no farms remain, disable farm mode and redirect so MainLayout re-renders + if (!FarmService.Farms.Any()) + { + ConfigService.Configuration.FarmModeEnabled = false; + ConfigService.Configuration.FarmName = string.Empty; + ConfigService.Configuration.ActiveFarmId = null; + await ConfigService.SaveConfigurationAsync(); + ToastService.Notify(new ToastMessage(ToastType.Success, localizer[Resources.Settings_Toast_FarmDeleted], localizer[Resources.Settings_Toast_FarmDeletedDisabled])); + Navigation.NavigateTo("/dashboard", replace: true); + return; + } + + ToastService.Notify(new ToastMessage(ToastType.Success, localizer[Resources.Settings_Toast_FarmDeleted], localizer[Resources.Settings_Toast_FarmDeletedMessage])); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to delete farm"); + ToastService.Notify(new ToastMessage(ToastType.Danger, localizer[Resources.Settings_Toast_Error], localizer[Resources.Settings_Toast_DeleteFarmError])); + } + } + + private async Task ExportFarmAsync() + { + if (_selectedFarmId == null) return; + try + { + var json = FarmService.ExportFarm(_selectedFarmId.Value); + var farmName = FarmService.Farms.FirstOrDefault(f => f.Id == _selectedFarmId)?.Name ?? "farm"; + await JsInterop.DownloadFileAsync($"{farmName}-config.json", json); + ToastService.Notify(new ToastMessage(ToastType.Success, localizer[Resources.Settings_Toast_Exported], localizer[Resources.Settings_Toast_ExportedMessage])); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to export farm"); + ToastService.Notify(new ToastMessage(ToastType.Danger, localizer[Resources.Settings_Toast_Error], localizer[Resources.Settings_Toast_ExportError])); + } + } + + private async Task ImportFarmAsync(InputFileChangeEventArgs e) + { + try + { + using var stream = e.File.OpenReadStream(maxAllowedSize: 5 * 1024 * 1024); + using var reader = new StreamReader(stream); + var json = await reader.ReadToEndAsync(); + var farm = await FarmService.ImportFarmAsync(json); + _selectedFarmId = farm.Id; + ToastService.Notify(new ToastMessage(ToastType.Success, localizer[Resources.Settings_Toast_Imported], string.Format(localizer[Resources.Settings_Toast_ImportedMessage], farm.Name))); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to import farm"); + ToastService.Notify(new ToastMessage(ToastType.Danger, localizer[Resources.Settings_Toast_Error], localizer[Resources.Settings_Toast_ImportError])); + } + } + + public void Dispose() + { + } +} diff --git a/src/MakerPrompt.UI.Components/Pages/SettingsPage.razor b/src/MakerPrompt.UI.Components/Pages/SettingsPage.razor deleted file mode 100644 index 45d2df4..0000000 --- a/src/MakerPrompt.UI.Components/Pages/SettingsPage.razor +++ /dev/null @@ -1,72 +0,0 @@ -@page "/settings" - -

Settings

- -
-
-
-
About MakerPrompt
-
-
-
Version
-
0.5.0
-
Architecture
-
Core / Application / Infrastructure
-
Source
-
- - GitHub - -
-
-
-
-
- -
-
-
Cloud Connection
-
-
- - -
-
- - -
- - @if (_saved) - { - Saved! - } -
-
-
-
- -@code { - private string _cloudUrl = ""; - private string _apiKey = ""; - private bool _saved; - - private void SaveSettings() - { - // Persist via IAppLocalStorageProvider in future phases. - _saved = true; - // Fire-and-forget to clear the saved flag after 2 s; exceptions are logged to console. - Task.Delay(2000) - .ContinueWith( - _ => InvokeAsync(() => { _saved = false; StateHasChanged(); }), - TaskContinuationOptions.OnlyOnRanToCompletion) - .ContinueWith( - t => Console.WriteLine($"[SettingsPage] Error clearing saved flag: {t.Exception?.GetBaseException().Message}"), - TaskContinuationOptions.OnlyOnFaulted); - } -} diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.Designer.cs b/src/MakerPrompt.UI.Components/Properties/Resources.Designer.cs new file mode 100644 index 0000000..d0c9b11 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.Designer.cs @@ -0,0 +1,2388 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace MakerPrompt.UI.Components.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MakerPrompt.UI.Components.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Open-source, cross-platform 3D printer management software powered by Blazor Hybrid. <strong>Still under development—use at your own risk.</strong></br>Special thanks to <a href="https://mrhide.de">MrHide</a> for the digital logo and Paleva (@x-hain) for her invaluable support. The source code is available <a href="https://github.com/akinbender/MakerPrompt">here</a>.. + /// + public static string AboutPage_Content { + get { + return ResourceManager.GetString("AboutPage_Content", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Clear. + /// + public static string BrailleRAP_Clear { + get { + return ResourceManager.GetString("BrailleRAP_Clear", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Columns per line. + /// + public static string BrailleRAP_Columns { + get { + return ResourceManager.GetString("BrailleRAP_Columns", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Configuration. + /// + public static string BrailleRAP_Configuration { + get { + return ResourceManager.GetString("BrailleRAP_Configuration", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Copied to clipboard. + /// + public static string BrailleRAP_CopiedToClipboard { + get { + return ResourceManager.GetString("BrailleRAP_CopiedToClipboard", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Copy. + /// + public static string BrailleRAP_Copy { + get { + return ResourceManager.GetString("BrailleRAP_Copy", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Convert text to Braille and generate G-code for BrailleRAP embossing.. + /// + public static string BrailleRAP_Description { + get { + return ResourceManager.GetString("BrailleRAP_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enter text to convert to Braille. + /// + public static string BrailleRAP_EnterText { + get { + return ResourceManager.GetString("BrailleRAP_EnterText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Feed rate. + /// + public static string BrailleRAP_FeedRate { + get { + return ResourceManager.GetString("BrailleRAP_FeedRate", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to G-code generated successfully. + /// + public static string BrailleRAP_GCodeGenerated { + get { + return ResourceManager.GetString("BrailleRAP_GCodeGenerated", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to G-code. + /// + public static string BrailleRAP_GeneratedGCode { + get { + return ResourceManager.GetString("BrailleRAP_GeneratedGCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please generate G-code first. + /// + public static string BrailleRAP_GenerateFirst { + get { + return ResourceManager.GetString("BrailleRAP_GenerateFirst", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Generate G-code. + /// + public static string BrailleRAP_GenerateGCode { + get { + return ResourceManager.GetString("BrailleRAP_GenerateGCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Translation Language. + /// + public static string BrailleRAP_Language { + get { + return ResourceManager.GetString("BrailleRAP_Language", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Lines. + /// + public static string BrailleRAP_Lines { + get { + return ResourceManager.GetString("BrailleRAP_Lines", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Line spacing. + /// + public static string BrailleRAP_LineSpacing { + get { + return ResourceManager.GetString("BrailleRAP_LineSpacing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Machine Settings. + /// + public static string BrailleRAP_MachineSettings { + get { + return ResourceManager.GetString("BrailleRAP_MachineSettings", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enter text to see Braille preview. + /// + public static string BrailleRAP_NoPreview { + get { + return ResourceManager.GetString("BrailleRAP_NoPreview", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please connect to a printer first. + /// + public static string BrailleRAP_NotConnected { + get { + return ResourceManager.GetString("BrailleRAP_NotConnected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to X offset. + /// + public static string BrailleRAP_OffsetX { + get { + return ResourceManager.GetString("BrailleRAP_OffsetX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Y offset. + /// + public static string BrailleRAP_OffsetY { + get { + return ResourceManager.GetString("BrailleRAP_OffsetY", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Page. + /// + public static string BrailleRAP_Page { + get { + return ResourceManager.GetString("BrailleRAP_Page", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Page navigation. + /// + public static string BrailleRAP_PageNavigation { + get { + return ResourceManager.GetString("BrailleRAP_PageNavigation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pages. + /// + public static string BrailleRAP_Pages { + get { + return ResourceManager.GetString("BrailleRAP_Pages", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Page Settings. + /// + public static string BrailleRAP_PageSettings { + get { + return ResourceManager.GetString("BrailleRAP_PageSettings", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Braille Preview. + /// + public static string BrailleRAP_Preview { + get { + return ResourceManager.GetString("BrailleRAP_Preview", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rows per page. + /// + public static string BrailleRAP_Rows { + get { + return ResourceManager.GetString("BrailleRAP_Rows", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Send to Printer. + /// + public static string BrailleRAP_SendToPrinter { + get { + return ResourceManager.GetString("BrailleRAP_SendToPrinter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to G-code sent to printer successfully. + /// + public static string BrailleRAP_SentSuccessfully { + get { + return ResourceManager.GetString("BrailleRAP_SentSuccessfully", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Text Input. + /// + public static string BrailleRAP_TextInput { + get { + return ResourceManager.GetString("BrailleRAP_TextInput", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Type or paste your text here.... + /// + public static string BrailleRAP_TextPlaceholder { + get { + return ResourceManager.GetString("BrailleRAP_TextPlaceholder", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Actual Extrusion Length (mm). + /// + public static string Calculators_ActualExtrusionLength { + get { + return ResourceManager.GetString("Calculators_ActualExtrusionLength", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Belt Pitch (mm). + /// + public static string Calculators_BeltPitch { + get { + return ResourceManager.GetString("Calculators_BeltPitch", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Belt Steps per Millimeter Calculator. + /// + public static string Calculators_BeltSteps { + get { + return ResourceManager.GetString("Calculators_BeltSteps", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Current E-steps (steps/mm). + /// + public static string Calculators_CurrentEsteps { + get { + return ResourceManager.GetString("Calculators_CurrentEsteps", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to <h5>How to use:</h5> <ol> <li>Mark 120mm from your extruder entrance</li> <li>Heat up your hotend</li> <li>Extrude 100mm (use <code>G1 E100 F100</code>)</li> <li>Measure remaining filament to extruder</li> <li>Enter values above (actual = 120 - remaining)</li> </ol>. + /// + public static string Calculators_Esteps_HowTo { + get { + return ResourceManager.GetString("Calculators_Esteps_HowTo", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to E-Steps Calculator. + /// + public static string Calculators_ExtruderSteps { + get { + return ResourceManager.GetString("Calculators_ExtruderSteps", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to G-code Example. + /// + public static string Calculators_GCodeExample { + get { + return ResourceManager.GetString("Calculators_GCodeExample", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Gear Ratio. + /// + public static string Calculators_GearRatio { + get { + return ResourceManager.GetString("Calculators_GearRatio", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Leadscrew Pitch (mm/revolution). + /// + public static string Calculators_LeadscrewPitch { + get { + return ResourceManager.GetString("Calculators_LeadscrewPitch", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Leadscrew Steps per Millimeter Calculator. + /// + public static string Calculators_LeadScrewSteps { + get { + return ResourceManager.GetString("Calculators_LeadScrewSteps", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Measure with a caliper. + /// + public static string Calculators_MeasureCaliper { + get { + return ResourceManager.GetString("Calculators_MeasureCaliper", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Driver Microsteppin. + /// + public static string Calculators_Microstepping { + get { + return ResourceManager.GetString("Calculators_Microstepping", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Motor Step Angle. + /// + public static string Calculators_MotorAngle { + get { + return ResourceManager.GetString("Calculators_MotorAngle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Print price Calculator. + /// + public static string Calculators_PrintPrice { + get { + return ResourceManager.GetString("Calculators_PrintPrice", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pulley Tooth Count. + /// + public static string Calculators_PulleyToothCount { + get { + return ResourceManager.GetString("Calculators_PulleyToothCount", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Remember to save after testing. + /// + public static string Calculators_RememberSave { + get { + return ResourceManager.GetString("Calculators_RememberSave", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Requested Extrusion Length (mm). + /// + public static string Calculators_RequestedExtrusionLength { + get { + return ResourceManager.GetString("Calculators_RequestedExtrusionLength", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Resolution. + /// + public static string Calculators_Resolution { + get { + return ResourceManager.GetString("Calculators_Resolution", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Steps per mm. + /// + public static string Calculators_Steps { + get { + return ResourceManager.GetString("Calculators_Steps", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Heater Index. + /// + public static string CalibrationPage_HeaterIndex { + get { + return ResourceManager.GetString("CalibrationPage_HeaterIndex", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to PID Tuning. + /// + public static string CalibrationPage_PidTuning { + get { + return ResourceManager.GetString("CalibrationPage_PidTuning", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Result. + /// + public static string CalibrationPage_Result { + get { + return ResourceManager.GetString("CalibrationPage_Result", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Start. + /// + public static string CalibrationPage_Start { + get { + return ResourceManager.GetString("CalibrationPage_Start", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Thermal Model. + /// + public static string CalibrationPage_ThermalModel { + get { + return ResourceManager.GetString("CalibrationPage_ThermalModel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to None. + /// + public static string CheatSheetPage_None { + get { + return ResourceManager.GetString("CheatSheetPage_None", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Search commands... + /// + public static string CheatSheetPage_SearchCommands { + get { + return ResourceManager.GetString("CheatSheetPage_SearchCommands", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Connection established.. + /// + public static string CommandPrompt_ConnectedMessage { + get { + return ResourceManager.GetString("CommandPrompt_ConnectedMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Copy command. + /// + public static string CommandPrompt_CopyCommand { + get { + return ResourceManager.GetString("CommandPrompt_CopyCommand", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Connection terminated.. + /// + public static string CommandPrompt_DisconnectedMessage { + get { + return ResourceManager.GetString("CommandPrompt_DisconnectedMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enter command... + /// + public static string CommandPrompt_EnterCommand { + get { + return ResourceManager.GetString("CommandPrompt_EnterCommand", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error sending command: {0}.. + /// + public static string CommandPrompt_ErrorMessage { + get { + return ResourceManager.GetString("CommandPrompt_ErrorMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Run command. + /// + public static string CommandPrompt_RunCommand { + get { + return ResourceManager.GetString("CommandPrompt_RunCommand", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Send. + /// + public static string CommandPrompt_Send { + get { + return ResourceManager.GetString("CommandPrompt_Send", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show telemetry output. + /// + public static string CommandPrompt_ShowTelemetry { + get { + return ResourceManager.GetString("CommandPrompt_ShowTelemetry", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Current. + /// + public static string ControlPanel_Current { + get { + return ResourceManager.GetString("ControlPanel_Current", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Extrude. + /// + public static string ControlPanel_Extrude { + get { + return ResourceManager.GetString("ControlPanel_Extrude", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Fan speed. + /// + public static string ControlPanel_FanSpeed { + get { + return ResourceManager.GetString("ControlPanel_FanSpeed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Heating. + /// + public static string ControlPanel_Heating { + get { + return ResourceManager.GetString("ControlPanel_Heating", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Home all. + /// + public static string ControlPanel_HomeAll { + get { + return ResourceManager.GetString("ControlPanel_HomeAll", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Home selected axis. + /// + public static string ControlPanel_HomeSelected { + get { + return ResourceManager.GetString("ControlPanel_HomeSelected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Length. + /// + public static string ControlPanel_Length { + get { + return ResourceManager.GetString("ControlPanel_Length", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Motors off. + /// + public static string ControlPanel_MotorsOff { + get { + return ResourceManager.GetString("ControlPanel_MotorsOff", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Position. + /// + public static string ControlPanel_Position { + get { + return ResourceManager.GetString("ControlPanel_Position", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Print flow. + /// + public static string ControlPanel_PrintFlow { + get { + return ResourceManager.GetString("ControlPanel_PrintFlow", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Print speed. + /// + public static string ControlPanel_PrintSpeed { + get { + return ResourceManager.GetString("ControlPanel_PrintSpeed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reverse. + /// + public static string ControlPanel_Reverse { + get { + return ResourceManager.GetString("ControlPanel_Reverse", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Not present. + /// + public static string ControlPanel_SdCard_NotPresent { + get { + return ResourceManager.GetString("ControlPanel_SdCard_NotPresent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Ready. + /// + public static string ControlPanel_SdCard_Ready { + get { + return ResourceManager.GetString("ControlPanel_SdCard_Ready", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set. + /// + public static string ControlPanel_Set { + get { + return ResourceManager.GetString("ControlPanel_Set", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set fan speed. + /// + public static string ControlPanel_Set_FanSpeed { + get { + return ResourceManager.GetString("ControlPanel_Set_FanSpeed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set speed for X & Y axes (mm/min). + /// + public static string ControlPanel_Set_XYSpeed { + get { + return ResourceManager.GetString("ControlPanel_Set_XYSpeed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set speed for Z axis (mm/min). + /// + public static string ControlPanel_Set_ZSpeed { + get { + return ResourceManager.GetString("ControlPanel_Set_ZSpeed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set bed temperature. + /// + public static string ControlPanel_SetBedTemp { + get { + return ResourceManager.GetString("ControlPanel_SetBedTemp", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set hotend temperature. + /// + public static string ControlPanel_SetHotendTemp { + get { + return ResourceManager.GetString("ControlPanel_SetHotendTemp", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Speed. + /// + public static string ControlPanel_Speed { + get { + return ResourceManager.GetString("ControlPanel_Speed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Status. + /// + public static string ControlPanel_Status { + get { + return ResourceManager.GetString("ControlPanel_Status", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Target. + /// + public static string ControlPanel_Target { + get { + return ResourceManager.GetString("ControlPanel_Target", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Control Panel. + /// + public static string Dashboard_ControlPanel { + get { + return ResourceManager.GetString("Dashboard_ControlPanel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Files. + /// + public static string Dashboard_FileExplorer { + get { + return ResourceManager.GetString("Dashboard_FileExplorer", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Date Modified. + /// + public static string Files_DateModified { + get { + return ResourceManager.GetString("Files_DateModified", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Name. + /// + public static string Files_Name { + get { + return ResourceManager.GetString("Files_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Refresh. + /// + public static string Files_Refresh { + get { + return ResourceManager.GetString("Files_Refresh", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Size. + /// + public static string Files_Size { + get { + return ResourceManager.GetString("Files_Size", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to G-code. + /// + public static string GCode_Title { + get { + return ResourceManager.GetString("GCode_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Calibration. + /// + public static string GCodeCategory_Calibration { + get { + return ResourceManager.GetString("GCodeCategory_Calibration", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Fan. + /// + public static string GCodeCategory_Fan { + get { + return ResourceManager.GetString("GCodeCategory_Fan", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Movement. + /// + public static string GCodeCategory_Movement { + get { + return ResourceManager.GetString("GCodeCategory_Movement", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reporting. + /// + public static string GCodeCategory_Reporting { + get { + return ResourceManager.GetString("GCodeCategory_Reporting", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sd Card. + /// + public static string GCodeCategory_SdCard { + get { + return ResourceManager.GetString("GCodeCategory_SdCard", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Settings. + /// + public static string GCodeCategory_Settings { + get { + return ResourceManager.GetString("GCodeCategory_Settings", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Temperature. + /// + public static string GCodeCategory_Temperature { + get { + return ResourceManager.GetString("GCodeCategory_Temperature", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Categories. + /// + public static string GCodeCommand_Category { + get { + return ResourceManager.GetString("GCodeCommand_Category", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Command. + /// + public static string GCodeCommand_Command { + get { + return ResourceManager.GetString("GCodeCommand_Command", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Description. + /// + public static string GCodeCommand_Description { + get { + return ResourceManager.GetString("GCodeCommand_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Parameters. + /// + public static string GCodeCommand_Parameter { + get { + return ResourceManager.GetString("GCodeCommand_Parameter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cycles (3-20). + /// + public static string GCodeDescription_C_Cycle { + get { + return ResourceManager.GetString("GCodeDescription_C_Cycle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Derivative Value. + /// + public static string GCodeDescription_D_Derivative { + get { + return ResourceManager.GetString("GCodeDescription_D_Derivative", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Extrude amount. + /// + public static string GCodeDescription_E_Amount { + get { + return ResourceManager.GetString("GCodeDescription_E_Amount", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to E Position. + /// + public static string GCodeDescription_E_Position { + get { + return ResourceManager.GetString("GCodeDescription_E_Position", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Feedrate. + /// + public static string GCodeDescription_F_Feedrate { + get { + return ResourceManager.GetString("GCodeDescription_F_Feedrate", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to File path. + /// + public static string GCodeDescription_F_File { + get { + return ResourceManager.GetString("GCodeDescription_F_File", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rapid linear movement (non-printing). + /// + public static string GCodeDescription_G0 { + get { + return ResourceManager.GetString("GCodeDescription_G0", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Controlled linear movement (printing). + /// + public static string GCodeDescription_G1 { + get { + return ResourceManager.GetString("GCodeDescription_G1", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Home all or specified axes. + /// + public static string GCodeDescription_G28 { + get { + return ResourceManager.GetString("GCodeDescription_G28", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Auto bed leveling. + /// + public static string GCodeDescription_G29 { + get { + return ResourceManager.GetString("GCodeDescription_G29", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set absolute positioning mode. + /// + public static string GCodeDescription_G90 { + get { + return ResourceManager.GetString("GCodeDescription_G90", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set relative positioning mode. + /// + public static string GCodeDescription_G91 { + get { + return ResourceManager.GetString("GCodeDescription_G91", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set current position (reset coordinates). + /// + public static string GCodeDescription_G92 { + get { + return ResourceManager.GetString("GCodeDescription_G92", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Inline mode (on / off). + /// + public static string GCodeDescription_I_InlineMode { + get { + return ResourceManager.GetString("GCodeDescription_I_InlineMode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Integral Value. + /// + public static string GCodeDescription_I_Integral { + get { + return ResourceManager.GetString("GCodeDescription_I_Integral", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set hotend temperature (non-blocking). + /// + public static string GCodeDescription_M104 { + get { + return ResourceManager.GetString("GCodeDescription_M104", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Report current temperatures. + /// + public static string GCodeDescription_M105 { + get { + return ResourceManager.GetString("GCodeDescription_M105", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set fan speed (S: 0-255). + /// + public static string GCodeDescription_M106 { + get { + return ResourceManager.GetString("GCodeDescription_M106", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Turn fan off. + /// + public static string GCodeDescription_M107 { + get { + return ResourceManager.GetString("GCodeDescription_M107", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set hotend temp & wait (blocking). + /// + public static string GCodeDescription_M109 { + get { + return ResourceManager.GetString("GCodeDescription_M109", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Emergency stop. + /// + public static string GCodeDescription_M112 { + get { + return ResourceManager.GetString("GCodeDescription_M112", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Get current position. + /// + public static string GCodeDescription_M114 { + get { + return ResourceManager.GetString("GCodeDescription_M114", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Get firmware info. + /// + public static string GCodeDescription_M115 { + get { + return ResourceManager.GetString("GCodeDescription_M115", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set LCD Message. + /// + public static string GCodeDescription_M117 { + get { + return ResourceManager.GetString("GCodeDescription_M117", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set bed temperature (non-blocking). + /// + public static string GCodeDescription_M140 { + get { + return ResourceManager.GetString("GCodeDescription_M140", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enable Steppers. + /// + public static string GCodeDescription_M17 { + get { + return ResourceManager.GetString("GCodeDescription_M17", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Disable Steppers. + /// + public static string GCodeDescription_M18 { + get { + return ResourceManager.GetString("GCodeDescription_M18", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set bed temp & wait (blocking). + /// + public static string GCodeDescription_M190 { + get { + return ResourceManager.GetString("GCodeDescription_M190", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to List SD card contents. + /// + public static string GCodeDescription_M20 { + get { + return ResourceManager.GetString("GCodeDescription_M20", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Initialize SD card. + /// + public static string GCodeDescription_M21 { + get { + return ResourceManager.GetString("GCodeDescription_M21", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Release SD card. + /// + public static string GCodeDescription_M22 { + get { + return ResourceManager.GetString("GCodeDescription_M22", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set or report the feed rate percentage for all G-code moves (X, Y, Z, E axes). + /// + public static string GCodeDescription_M220 { + get { + return ResourceManager.GetString("GCodeDescription_M220", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set or report the flow rate percentage for E-axis extrusion moves. + /// + public static string GCodeDescription_M221 { + get { + return ResourceManager.GetString("GCodeDescription_M221", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select SD file. + /// + public static string GCodeDescription_M23 { + get { + return ResourceManager.GetString("GCodeDescription_M23", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Start/resume SD print. + /// + public static string GCodeDescription_M24 { + get { + return ResourceManager.GetString("GCodeDescription_M24", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pause SD print. + /// + public static string GCodeDescription_M25 { + get { + return ResourceManager.GetString("GCodeDescription_M25", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set SD read position. + /// + public static string GCodeDescription_M26 { + get { + return ResourceManager.GetString("GCodeDescription_M26", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Report SD print status. + /// + public static string GCodeDescription_M27 { + get { + return ResourceManager.GetString("GCodeDescription_M27", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Start writing to SD card. + /// + public static string GCodeDescription_M28 { + get { + return ResourceManager.GetString("GCodeDescription_M28", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Stop writing to SD card. + /// + public static string GCodeDescription_M29 { + get { + return ResourceManager.GetString("GCodeDescription_M29", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Spindle / laser on (clockwise). + /// + public static string GCodeDescription_M3 { + get { + return ResourceManager.GetString("GCodeDescription_M3", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Delete SD file. + /// + public static string GCodeDescription_M30 { + get { + return ResourceManager.GetString("GCodeDescription_M30", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set hotend PID. + /// + public static string GCodeDescription_M301 { + get { + return ResourceManager.GetString("GCodeDescription_M301", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to PID autotune. + /// + public static string GCodeDescription_M303 { + get { + return ResourceManager.GetString("GCodeDescription_M303", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set bed PID. + /// + public static string GCodeDescription_M304 { + get { + return ResourceManager.GetString("GCodeDescription_M304", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Thermal model calibration. + /// + public static string GCodeDescription_M306 { + get { + return ResourceManager.GetString("GCodeDescription_M306", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select file and start print. + /// + public static string GCodeDescription_M32 { + get { + return ResourceManager.GetString("GCodeDescription_M32", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Spindle / laser on (counterclockwise). + /// + public static string GCodeDescription_M4 { + get { + return ResourceManager.GetString("GCodeDescription_M4", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Spindle / laser off. + /// + public static string GCodeDescription_M5 { + get { + return ResourceManager.GetString("GCodeDescription_M5", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Store settings in EEPROM. + /// + public static string GCodeDescription_M500 { + get { + return ResourceManager.GetString("GCodeDescription_M500", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Read settings from EEPROM. + /// + public static string GCodeDescription_M501 { + get { + return ResourceManager.GetString("GCodeDescription_M501", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reset all settings in memory to their factory defaults. + /// + public static string GCodeDescription_M502 { + get { + return ResourceManager.GetString("GCodeDescription_M502", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Report current settings from EEPROM. + /// + public static string GCodeDescription_M503 { + get { + return ResourceManager.GetString("GCodeDescription_M503", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Validate the contents of the EEPROM. + /// + public static string GCodeDescription_M504 { + get { + return ResourceManager.GetString("GCodeDescription_M504", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Get or set the steps-per-unit for one or more axes. + /// + public static string GCodeDescription_M92 { + get { + return ResourceManager.GetString("GCodeDescription_M92", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Spindle / laser power (PWM 0-255). + /// + public static string GCodeDescription_O_SpindlePower { + get { + return ResourceManager.GetString("GCodeDescription_O_SpindlePower", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Proportional Value. + /// + public static string GCodeDescription_P_Proportional { + get { + return ResourceManager.GetString("GCodeDescription_P_Proportional", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Spindle / laser speed. + /// + public static string GCodeDescription_S_SpindleSpeed { + get { + return ResourceManager.GetString("GCodeDescription_S_SpindleSpeed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Target temperature. + /// + public static string GCodeDescription_S_TargetTemp { + get { + return ResourceManager.GetString("GCodeDescription_S_TargetTemp", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to X position. + /// + public static string GCodeDescription_X_Position { + get { + return ResourceManager.GetString("GCodeDescription_X_Position", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Y position. + /// + public static string GCodeDescription_Y_Position { + get { + return ResourceManager.GetString("GCodeDescription_Y_Position", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Z position. + /// + public static string GCodeDescription_Z_Position { + get { + return ResourceManager.GetString("GCodeDescription_Z_Position", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Browser not supported.. + /// + public static string NavConnection_BrowserNotSupported { + get { + return ResourceManager.GetString("NavConnection_BrowserNotSupported", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Connect. + /// + public static string NavConnection_Connect { + get { + return ResourceManager.GetString("NavConnection_Connect", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A service that simulates a functioning 3D printer for demonstration purposes.. + /// + public static string NavConnection_DemoServiceDescription { + get { + return ResourceManager.GetString("NavConnection_DemoServiceDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Disconnect. + /// + public static string NavConnection_Disconnect { + get { + return ResourceManager.GetString("NavConnection_Disconnect", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Refresh ports. + /// + public static string NavConnection_RefreshPorts { + get { + return ResourceManager.GetString("NavConnection_RefreshPorts", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select port. + /// + public static string NavConnection_SelectPort { + get { + return ResourceManager.GetString("NavConnection_SelectPort", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to About. + /// + public static string PageTitle_About { + get { + return ResourceManager.GetString("PageTitle_About", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to BrailleRAP. + /// + public static string PageTitle_BrailleRAP { + get { + return ResourceManager.GetString("PageTitle_BrailleRAP", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Calculators. + /// + public static string PageTitle_Calculators { + get { + return ResourceManager.GetString("PageTitle_Calculators", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Terminal. + /// + public static string PageTitle_CommandPrompt { + get { + return ResourceManager.GetString("PageTitle_CommandPrompt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to G-Code Cheat Sheet. + /// + public static string PageTitle_GCodeList { + get { + return ResourceManager.GetString("PageTitle_GCodeList", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to G-Code Viewer. + /// + public static string PageTitle_GCodeViewer { + get { + return ResourceManager.GetString("PageTitle_GCodeViewer", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Dashboard. + /// + public static string PageTitle_Homepage { + get { + return ResourceManager.GetString("PageTitle_Homepage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Heatbed. + /// + public static string PrinterComponent_Heatbed { + get { + return ResourceManager.GetString("PrinterComponent_Heatbed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Hotend. + /// + public static string PrinterComponent_Hotend { + get { + return ResourceManager.GetString("PrinterComponent_Hotend", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Connected. + /// + public static string PrinterStatus_Connected { + get { + return ResourceManager.GetString("PrinterStatus_Connected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Disconnected. + /// + public static string PrinterStatus_Disconnected { + get { + return ResourceManager.GetString("PrinterStatus_Disconnected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error. + /// + public static string PrinterStatus_Error { + get { + return ResourceManager.GetString("PrinterStatus_Error", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Paused. + /// + public static string PrinterStatus_Paused { + get { + return ResourceManager.GetString("PrinterStatus_Paused", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Printing. + /// + public static string PrinterStatus_Printing { + get { + return ResourceManager.GetString("PrinterStatus_Printing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Auto. + /// + public static string Theme_Auto { + get { + return ResourceManager.GetString("Theme_Auto", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Dark. + /// + public static string Theme_Dark { + get { + return ResourceManager.GetString("Theme_Dark", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Light. + /// + public static string Theme_Light { + get { + return ResourceManager.GetString("Theme_Light", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Fleet. + /// + public static string Fleet_Title { + get { + return ResourceManager.GetString("Fleet_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No printers configured. Add one to get started.. + /// + public static string Fleet_NoPrinters { + get { + return ResourceManager.GetString("Fleet_NoPrinters", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add Printer. + /// + public static string Fleet_AddPrinter { + get { + return ResourceManager.GetString("Fleet_AddPrinter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Edit Printer. + /// + public static string Fleet_EditPrinter { + get { + return ResourceManager.GetString("Fleet_EditPrinter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Manage Printers. + /// + public static string Fleet_ManagePrinters { + get { + return ResourceManager.GetString("Fleet_ManagePrinters", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Delete Printer. + /// + public static string Fleet_DeletePrinter { + get { + return ResourceManager.GetString("Fleet_DeletePrinter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Printer Name. + /// + public static string Fleet_PrinterName { + get { + return ResourceManager.GetString("Fleet_PrinterName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Connection Type. + /// + public static string Fleet_ConnectionType { + get { + return ResourceManager.GetString("Fleet_ConnectionType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Auto-connect on startup. + /// + public static string Fleet_AutoConnect { + get { + return ResourceManager.GetString("Fleet_AutoConnect", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Save. + /// + public static string Fleet_Save { + get { + return ResourceManager.GetString("Fleet_Save", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cancel. + /// + public static string Fleet_Cancel { + get { + return ResourceManager.GetString("Fleet_Cancel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Connecting.... + /// + public static string Fleet_Connecting { + get { + return ResourceManager.GetString("Fleet_Connecting", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Disconnecting.... + /// + public static string Fleet_Disconnecting { + get { + return ResourceManager.GetString("Fleet_Disconnecting", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set as active. + /// + public static string Fleet_SetActive { + get { + return ResourceManager.GetString("Fleet_SetActive", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Active. + /// + public static string Fleet_Active { + get { + return ResourceManager.GetString("Fleet_Active", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Printers. + /// + public static string Fleet_Printers { + get { + return ResourceManager.GetString("Fleet_Printers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Are you sure you want to delete this printer?. + /// + public static string Fleet_DeleteConfirm { + get { + return ResourceManager.GetString("Fleet_DeleteConfirm", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Progress. + /// + public static string Fleet_Progress { + get { + return ResourceManager.GetString("Fleet_Progress", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Camera. + /// + public static string Fleet_Camera { + get { + return ResourceManager.GetString("Fleet_Camera", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Fleet. + /// + public static string PageTitle_Fleet { + get { + return ResourceManager.GetString("PageTitle_Fleet", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Print Queue. + /// + public static string PrintQueue_Title { + get { + return ResourceManager.GetString("PrintQueue_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No files available. Connect a printer to browse files.. + /// + public static string PrintQueue_NoFiles { + get { + return ResourceManager.GetString("PrintQueue_NoFiles", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Send to printer. + /// + public static string PrintQueue_SelectPrinter { + get { + return ResourceManager.GetString("PrintQueue_SelectPrinter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Start Print. + /// + public static string PrintQueue_StartPrint { + get { + return ResourceManager.GetString("PrintQueue_StartPrint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No idle printers available. + /// + public static string PrintQueue_NoPrintersAvailable { + get { + return ResourceManager.GetString("PrintQueue_NoPrintersAvailable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Print started. + /// + public static string PrintQueue_PrintStarted { + get { + return ResourceManager.GetString("PrintQueue_PrintStarted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Refresh. + /// + public static string PrintQueue_Refresh { + get { + return ResourceManager.GetString("PrintQueue_Refresh", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No projects yet. Create one to start organizing prints.. + /// + public static string PrintQueue_NoProjects { + get { + return ResourceManager.GetString("PrintQueue_NoProjects", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to New Project. + /// + public static string PrintQueue_NewProject { + get { + return ResourceManager.GetString("PrintQueue_NewProject", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Project Name. + /// + public static string PrintQueue_ProjectName { + get { + return ResourceManager.GetString("PrintQueue_ProjectName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Upload G-code. + /// + public static string PrintQueue_UploadFiles { + get { + return ResourceManager.GetString("PrintQueue_UploadFiles", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Delete Project. + /// + public static string PrintQueue_DeleteProject { + get { + return ResourceManager.GetString("PrintQueue_DeleteProject", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Remove. + /// + public static string PrintQueue_RemoveJob { + get { + return ResourceManager.GetString("PrintQueue_RemoveJob", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Send to Printer. + /// + public static string PrintQueue_SendToPrinter { + get { + return ResourceManager.GetString("PrintQueue_SendToPrinter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Queued. + /// + public static string PrintQueue_Queued { + get { + return ResourceManager.GetString("PrintQueue_Queued", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Printing. + /// + public static string PrintQueue_Printing { + get { + return ResourceManager.GetString("PrintQueue_Printing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Completed. + /// + public static string PrintQueue_Completed { + get { + return ResourceManager.GetString("PrintQueue_Completed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed. + /// + public static string PrintQueue_Failed { + get { + return ResourceManager.GetString("PrintQueue_Failed", resourceCulture); + } + } + + public static string PageTitle_Settings { + get { return ResourceManager.GetString("PageTitle_Settings", resourceCulture); } + } + + public static string PageTitle_Dashboard { + get { return ResourceManager.GetString("PageTitle_Dashboard", resourceCulture); } + } + + public static string PageTitle_FilamentInventory { + get { return ResourceManager.GetString("PageTitle_FilamentInventory", resourceCulture); } + } + + public static string PageTitle_Analytics { + get { return ResourceManager.GetString("PageTitle_Analytics", resourceCulture); } + } + + public static string Dashboard_Welcome { + get { return ResourceManager.GetString("Dashboard_Welcome", resourceCulture); } + } + + public static string Dashboard_ConnectPrompt { + get { return ResourceManager.GetString("Dashboard_ConnectPrompt", resourceCulture); } + } + + public static string Settings_FarmMode { + get { return ResourceManager.GetString("Settings_FarmMode", resourceCulture); } + } + + public static string Settings_Delete { + get { return ResourceManager.GetString("Settings_Delete", resourceCulture); } + } + + public static string Settings_EnableFarmMode { + get { return ResourceManager.GetString("Settings_EnableFarmMode", resourceCulture); } + } + + public static string Settings_FarmModeDescription { + get { return ResourceManager.GetString("Settings_FarmModeDescription", resourceCulture); } + } + + public static string Settings_FarmConfigurations { + get { return ResourceManager.GetString("Settings_FarmConfigurations", resourceCulture); } + } + + public static string Settings_SelectFarm { + get { return ResourceManager.GetString("Settings_SelectFarm", resourceCulture); } + } + + public static string Settings_NewFarmName { + get { return ResourceManager.GetString("Settings_NewFarmName", resourceCulture); } + } + + public static string Settings_Create { + get { return ResourceManager.GetString("Settings_Create", resourceCulture); } + } + + public static string Settings_Export { + get { return ResourceManager.GetString("Settings_Export", resourceCulture); } + } + + public static string Settings_Import { + get { return ResourceManager.GetString("Settings_Import", resourceCulture); } + } + + public static string Settings_Features { + get { return ResourceManager.GetString("Settings_Features", resourceCulture); } + } + + public static string Settings_EnableFilamentInventory { + get { return ResourceManager.GetString("Settings_EnableFilamentInventory", resourceCulture); } + } + + public static string Settings_FilamentInventoryDescription { + get { return ResourceManager.GetString("Settings_FilamentInventoryDescription", resourceCulture); } + } + + public static string Settings_EnablePrintAnalytics { + get { return ResourceManager.GetString("Settings_EnablePrintAnalytics", resourceCulture); } + } + + public static string Settings_PrintAnalyticsDescription { + get { return ResourceManager.GetString("Settings_PrintAnalyticsDescription", resourceCulture); } + } + + public static string Settings_TelemetryAnalytics { + get { return ResourceManager.GetString("Settings_TelemetryAnalytics", resourceCulture); } + } + + public static string Settings_EnableAppTelemetry { + get { return ResourceManager.GetString("Settings_EnableAppTelemetry", resourceCulture); } + } + + public static string Settings_TelemetryDescription { + get { return ResourceManager.GetString("Settings_TelemetryDescription", resourceCulture); } + } + + public static string Settings_AppStorage { + get { return ResourceManager.GetString("Settings_AppStorage", resourceCulture); } + } + + public static string Settings_SaveButton { + get { return ResourceManager.GetString("Settings_SaveButton", resourceCulture); } + } + + public static string Settings_Toast_Saved { + get { return ResourceManager.GetString("Settings_Toast_Saved", resourceCulture); } + } + + public static string Settings_Toast_SavedMessage { + get { return ResourceManager.GetString("Settings_Toast_SavedMessage", resourceCulture); } + } + + public static string Settings_Toast_FarmCreated { + get { return ResourceManager.GetString("Settings_Toast_FarmCreated", resourceCulture); } + } + + public static string Settings_Toast_FarmCreatedMessage { + get { return ResourceManager.GetString("Settings_Toast_FarmCreatedMessage", resourceCulture); } + } + + public static string Settings_Toast_FarmSwitched { + get { return ResourceManager.GetString("Settings_Toast_FarmSwitched", resourceCulture); } + } + + public static string Settings_Toast_FarmSwitchedMessage { + get { return ResourceManager.GetString("Settings_Toast_FarmSwitchedMessage", resourceCulture); } + } + + public static string Settings_Toast_FarmDeleted { + get { return ResourceManager.GetString("Settings_Toast_FarmDeleted", resourceCulture); } + } + + public static string Settings_Toast_FarmDeletedMessage { + get { return ResourceManager.GetString("Settings_Toast_FarmDeletedMessage", resourceCulture); } + } + + public static string Settings_Toast_FarmDeletedDisabled { + get { return ResourceManager.GetString("Settings_Toast_FarmDeletedDisabled", resourceCulture); } + } + + public static string Settings_Toast_Exported { + get { return ResourceManager.GetString("Settings_Toast_Exported", resourceCulture); } + } + + public static string Settings_Toast_ExportedMessage { + get { return ResourceManager.GetString("Settings_Toast_ExportedMessage", resourceCulture); } + } + + public static string Settings_Toast_Imported { + get { return ResourceManager.GetString("Settings_Toast_Imported", resourceCulture); } + } + + public static string Settings_Toast_ImportedMessage { + get { return ResourceManager.GetString("Settings_Toast_ImportedMessage", resourceCulture); } + } + + public static string Settings_Toast_Error { + get { return ResourceManager.GetString("Settings_Toast_Error", resourceCulture); } + } + + public static string Settings_Toast_CreateFarmError { + get { return ResourceManager.GetString("Settings_Toast_CreateFarmError", resourceCulture); } + } + + public static string Settings_Toast_SwitchFarmError { + get { return ResourceManager.GetString("Settings_Toast_SwitchFarmError", resourceCulture); } + } + + public static string Settings_Toast_DeleteFarmError { + get { return ResourceManager.GetString("Settings_Toast_DeleteFarmError", resourceCulture); } + } + + public static string Settings_Toast_ExportError { + get { return ResourceManager.GetString("Settings_Toast_ExportError", resourceCulture); } + } + + public static string Settings_Toast_ImportError { + get { return ResourceManager.GetString("Settings_Toast_ImportError", resourceCulture); } + } + + public static string NavPrinters_Back { + get { return ResourceManager.GetString("NavPrinters_Back", resourceCulture); } + } + + public static string NavPrinters_Close { + get { return ResourceManager.GetString("NavPrinters_Close", resourceCulture); } + } + + public static string NavPrinters_Port { + get { return ResourceManager.GetString("NavPrinters_Port", resourceCulture); } + } + + public static string NavPrinters_Url { + get { return ResourceManager.GetString("NavPrinters_Url", resourceCulture); } + } + + public static string NavPrinters_SerialNumber { + get { return ResourceManager.GetString("NavPrinters_SerialNumber", resourceCulture); } + } + + public static string NavPrinters_AccessCode { + get { return ResourceManager.GetString("NavPrinters_AccessCode", resourceCulture); } + } + + public static string NavPrinters_ApiKey { + get { return ResourceManager.GetString("NavPrinters_ApiKey", resourceCulture); } + } + + public static string NavPrinters_PrinterUuid { + get { return ResourceManager.GetString("NavPrinters_PrinterUuid", resourceCulture); } + } + + public static string NavConnect_PrusaConnectDescription { + get { return ResourceManager.GetString("NavConnect_PrusaConnectDescription", resourceCulture); } + } + + public static string NavPrinters_Username { + get { return ResourceManager.GetString("NavPrinters_Username", resourceCulture); } + } + + public static string NavPrinters_PasswordOrApiKey { + get { return ResourceManager.GetString("NavPrinters_PasswordOrApiKey", resourceCulture); } + } + + public static string NavPrinters_PrinterNameRequired { + get { return ResourceManager.GetString("NavPrinters_PrinterNameRequired", resourceCulture); } + } + + public static string NavPrinters_PrinterUpdated { + get { return ResourceManager.GetString("NavPrinters_PrinterUpdated", resourceCulture); } + } + + public static string NavPrinters_PrinterAdded { + get { return ResourceManager.GetString("NavPrinters_PrinterAdded", resourceCulture); } + } + + public static string NavPrinters_SaveFailed { + get { return ResourceManager.GetString("NavPrinters_SaveFailed", resourceCulture); } + } + + public static string NavPrinters_ConnectFailed { + get { return ResourceManager.GetString("NavPrinters_ConnectFailed", resourceCulture); } + } + + public static string NavPrinters_DisconnectFailed { + get { return ResourceManager.GetString("NavPrinters_DisconnectFailed", resourceCulture); } + } + + public static string NavPrinters_PrinterRemoved { + get { return ResourceManager.GetString("NavPrinters_PrinterRemoved", resourceCulture); } + } + + public static string NavPrinters_DeleteFailed { + get { return ResourceManager.GetString("NavPrinters_DeleteFailed", resourceCulture); } + } + + public static string StorageExplorer_Upload { + get { return ResourceManager.GetString("StorageExplorer_Upload", resourceCulture); } + } + + public static string StorageExplorer_Loading { + get { return ResourceManager.GetString("StorageExplorer_Loading", resourceCulture); } + } + } +} diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.de-DE.resx b/src/MakerPrompt.UI.Components/Properties/Resources.de-DE.resx new file mode 100644 index 0000000..e7f68f5 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.de-DE.resx @@ -0,0 +1,795 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Kalibrierung + + + Lüfter + + + Berichte + + + Einstellungen + + + Temperatur + + + Schnelle lineare Bewegung (nicht druckend) + + + Kontrollierte lineare Bewegung (druckend) + + + Alle oder bestimmte Achsen nullen + + + Automatische Bettnivellierung + + + Absoluten Positionsmodus aktivieren + + + Relativen Positionsmodus aktivieren + + + Aktuelle Position setzen (Koordinaten zurücksetzen) + + + Heizdüsentemperatur setzen und warten (nicht blockierend) + + + Aktuelle Temperaturen melden + + + Lüftergeschwindigkeit setzen (S: 0-255) + + + Lüfter ausschalten + + + Heizdüsentemperatur setzen und warten (blockierend) + + + Not-Aus + + + Spindel / Laser ein (rechtslauf) + + + Spindel / Laser ein (linkslauf) + + + Spindel / Laser aus + + + Aktuelle Position abfragen + + + Firmware-Informationen abrufen + + + Bett-Temperatur setzen (nicht blockierend) + + + Bett-Temperatur setzen und warten (blockierend) + + + G-Code-Referenz + + + Dashboard + + + Bewegung + + + PID-Autokalibrierung + + + Thermische Modellkalibrierung + + + SD-Karte + + + Automatisch + + + Dunkel + + + Hell + + + SD-Karteninhalt auflisten + + + SD-Karte initialisieren + + + SD-Karte freigeben + + + SD-Datei auswählen + + + SD-Druck starten/fortsetzen + + + SD-Druck pausieren + + + SD-Leseposition setzen + + + SD-Druckstatus melden + + + Schreiben auf SD-Karte starten + + + Schreiben auf SD-Karte beenden + + + SD-Datei löschen + + + Datei auswählen und Druck starten + + + Befehl + + + Beschreibung + + + Kategorien + + + Parameter + + + Befehle suchen... + + + Zieltemperatur + + + Inline-Modus (an / aus) + + + Spindel- / Laserleistung (PWM 0-255) + + + Spindel- / Laser-Drehzahl + + + Zyklen (3-20) + + + X-Position + + + Y-Position + + + Z-Position + + + Vorschubgeschwindigkeit + + + Dateipfad + + + Extrusionsmenge + + + Stepper aktivieren + + + Stepper deaktivieren + + + LCD-Meldung einstellen + + + Verbinden + + + Trennen + + + E-Position + + + Ports aktualisieren + + + Keine + + + Port auswählen + + + PID-Einstellung + + + Thermisches Modell + + + Einstellungen im EEPROM speichern + + + Einstellungen aus EEPROM lesen + + + Alle Einstellungen auf Werkseinstellungen zurücksetzen + + + Aktuelle Einstellungen aus EEPROM melden + + + EEPROM-Inhalt validieren + + + Proportionalwert + + + Integralwert + + + Differentialwert + + + Bett-PID setzen + + + Heizdüsen-PID setzen + + + Verbindung hergestellt. + + + Fehler beim Senden des Befehls: {0}. + + + Verbindung getrennt. + + + Senden + + + Befehl eingeben... + + + Starten + + + Hotend + + + Heatbed + + + Steuerkonsole + + + Heizer-Index + + + Ergebnis + + + Position + + + Länge + + + Setzen + + + Extrudieren + + + Rückwärts + + + Materialfluss + + + Aufheizen + + + Status + + + Heizdüsentemperatur einstellen + + + Bett-Temperatur einstellen + + + Motoren ausschalten + + + Bereit + + + Nicht vorhanden + + + Geschwindigkeit + + + Aktuell + + + Ziel + + + Geschwindigkeit für X/Y-Achsen (mm/min) + + + Geschwindigkeit für Z-Achse (mm/min) + + + Druckgeschwindigkeit + + + Vorschubgeschwindigkeits-Prozentsatz für alle G-Code-Bewegungen setzen/melden + + + Materialfluss-Prozentsatz für Extrusionsbewegungen setzen/melden + + + Getrennt + + + Verbunden + + + Druckt + + + Pausiert + + + Fehler + + + Lüftergeschwindigkeit setzen + + + Lüftergeschwindigkeit + + + Alle achsen referenzieren + + + Ausgewählte Achse referenzieren + + + Schritte pro Millimeter (Spindel) + + + E-Schritte Rechner + + + Rechner + + + Motorschritt-Winkel + + + Mikroschrittung + + + Schritte pro Millimeter (Riemen) + + + Druckkosten Rechner + + + Riementeilung (mm) + + + Riemenscheibenzähne + + + Spindelsteigung (mm/Umdrehung) + + + Übersetzungsverhältnis + + + Schritte pro mm + + + Auflösung + + + G-Code Beispiel + + + Angeforderte Extrusionslänge (mm) + + + Aktuelle E-Schritte (Schritte/mm) + + + Tatsächliche Extrusionslänge (mm) + + + Messen mit einem Messschieber + + + <h5>Anleitung:</h5><ol> <li>Messen Sie 120 mm vom Extrudereingang ab und markieren Sie diese Stelle</li> <li>Erhitzen Sie das Hotend auf Betriebstemperatur</li> <li>Extrudieren Sie 100 mm (verwenden Sie <code>G1 E100 F100</code>)</li> <li>Messen Sie das verbleibende Filament bis zur Markierung</li> <li>Tragen Sie die Werte oben ein (tatsächliche Länge = 120 - verbleibende Länge)</li></ol> + + + Schritte pro Einheit für eine oder mehrere Achsen ermitteln oder festlegen + + + Browser nicht unterstützt. + + + Vergessen Sie nicht, nach dem Testen zu speichern + + + Dateien + + + Name + + + letzte Änderung + + + Größe + + + Aktualisieren + + + Über uns + + + Open-Source, plattformübergreifende 3D-Drucker-Verwaltungssoftware, entwickelt mit Blazor Hybrid. <strong></br>Noch in Entwicklung—Nutzung auf eigene Gefahr.</strong> Besonderer Dank geht an <a href="https://mrhide.de">MrHide</a> für das digitale Logo und Paleva (@x-hain) für ihre wertvolle Unterstützung. Der Quellcode ist <a href="https://github.com/akinbender/MakerPrompt">hier</a> verfügbar. + + + Befehl ausführen + + + kopiere den Befehl + + + Ein Dienst, der einen funktionierenden 3D-Drucker zu Demonstrationszwecken simuliert. + + + Konsole + + + G-Code-Viewer + + + BrailleRAP + + + Text in Braille umwandeln und G-Code für BrailleRAP-Prägung generieren. + + + Texteingabe + + + Text zum Umwandeln in Braille eingeben + + + Geben Sie hier Ihren Text ein oder fügen Sie ihn ein... + + + Seiten + + + Zeilen + + + Löschen + + + Konfiguration + + + Seiteneinstellungen + + + Übersetzungssprache + + + Spalten pro Zeile + + + Zeilen pro Seite + + + Zeilenabstand + + + Maschineneinstellungen + + + Vorschubgeschwindigkeit + + + X-Versatz + + + Y-Versatz + + + Braille-Vorschau + + + Seitennavigation + + + Seite + + + Text eingeben, um Braille-Vorschau anzuzeigen + + + G-Code generieren + + + An Drucker senden + + + G-Code + + + Kopieren + + + G-Code erfolgreich generiert + + + Bitte zuerst mit einem Drucker verbinden + + + Bitte zuerst G-Code generieren + + + G-Code erfolgreich an Drucker gesendet + + + In die Zwischenablage kopiert + + + G-Code + + + Telemetrieausgabe anzeigen + + Dashboard + Einstellungen + Filamentbestand + Analysen + Flotte + Willkommen bei MakerPrompt + Richte einen Drucker ein und verbinde ihn, um loszulegen. + Farmmodus + Löschen + Farmmodus aktivieren + Wenn aktiv, ist die Flottenansicht die Standard-Startseite und Farmverwaltungsfunktionen sind verfügbar. Wenn inaktiv, ist das Einzeldrucker-Dashboard die Standardansicht. + Farm-Konfigurationen + — Farm auswählen — + Name der neuen Farm + Erstellen + Exportieren + Importieren + Funktionen + Filamentbestand aktivieren + Filamentspulen und verbleibendes Gewicht verfolgen. + Druckanalysen aktivieren + Druckauftragshistorie, Dauer und Filamentverbrauch aufzeichnen. + Telemetrie und Analysen + App-Telemetrie aktivieren + MakerPrompt erlauben, anonyme Nutzungsdaten zur Verbesserung der App zu erfassen. + App-Speicher + Einstellungen speichern + Einstellungen gespeichert + Deine Einstellungen wurden erfolgreich gespeichert. + Farm erstellt + Farm '{0}' wurde erstellt. + Farm gewechselt + Zu '{0}' gewechselt. + Farm gelöscht + Farm-Konfiguration wurde gelöscht. + Farm-Konfiguration wurde gelöscht. Der Farmmodus wurde deaktiviert. + Exportiert + Farm-Konfiguration exportiert. + Importiert + Farm '{0}' importiert. + Fehler + Farm konnte nicht erstellt werden. + Farm konnte nicht gewechselt werden. + Farm konnte nicht gelöscht werden. + Farm-Konfiguration konnte nicht exportiert werden. + Farm-Konfiguration konnte nicht importiert werden. + Flotte + Keine Drucker konfiguriert. Füge einen hinzu, um loszulegen. + Drucker hinzufügen + Drucker bearbeiten + Drucker verwalten + Drucker löschen + Druckername + Verbindungstyp + Beim Start automatisch verbinden + Speichern + Abbrechen + Verbinde... + Trenne Verbindung... + Als aktiv setzen + Aktiv + Drucker + Möchtest du diesen Drucker wirklich löschen? + Fortschritt + Kamera + Zurück + Schließen + Port + URL + Seriennummer + Zugangscode + API-Schlüssel + Benutzername + Passwort / API-Schlüssel + Druckername ist erforderlich + Drucker aktualisiert + Drucker hinzugefügt + Drucker konnte nicht gespeichert werden + Verbindung fehlgeschlagen + Trennung fehlgeschlagen + Drucker entfernt + Löschen fehlgeschlagen + Druckwarteschlange + Keine Dateien verfügbar. Drucker verbinden, um Dateien anzuzeigen. + An Drucker senden + Druck starten + Keine Drucker verfügbar + Druck gestartet + Aktualisieren + Noch keine Projekte. Erstelle eines, um deine Drucke zu organisieren. + Neues Projekt + Projektname + G-Code hochladen + Projekt löschen + Entfernen + An Drucker senden + In der Warteschlange + Druckt + Abgeschlossen + Fehlgeschlagen + Hochladen + Lädt... + \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.de.resx b/src/MakerPrompt.UI.Components/Properties/Resources.de.resx new file mode 100644 index 0000000..1dd8fc2 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.de.resx @@ -0,0 +1,693 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Kalibrierung + + + Lüfter + + + Berichte + + + Einstellungen + + + Temperatur + + + Schnelle lineare Bewegung (nicht druckend) + + + Kontrollierte lineare Bewegung (druckend) + + + Alle oder bestimmte Achsen nullen + + + Automatische Bettnivellierung + + + Absoluten Positionsmodus aktivieren + + + Relativen Positionsmodus aktivieren + + + Aktuelle Position setzen (Koordinaten zurücksetzen) + + + Heizdüsentemperatur setzen und warten (nicht blockierend) + + + Aktuelle Temperaturen melden + + + Lüftergeschwindigkeit setzen (S: 0-255) + + + Lüfter ausschalten + + + Heizdüsentemperatur setzen und warten (blockierend) + + + Not-Aus + + + Spindel / Laser ein (rechtslauf) + + + Spindel / Laser ein (linkslauf) + + + Spindel / Laser aus + + + Aktuelle Position abfragen + + + Firmware-Informationen abrufen + + + Bett-Temperatur setzen (nicht blockierend) + + + Bett-Temperatur setzen und warten (blockierend) + + + G-Code-Referenz + + + Dashboard + + + Bewegung + + + PID-Autokalibrierung + + + Thermische Modellkalibrierung + + + SD-Karte + + + Automatisch + + + Dunkel + + + Hell + + + SD-Karteninhalt auflisten + + + SD-Karte initialisieren + + + SD-Karte freigeben + + + SD-Datei auswählen + + + SD-Druck starten/fortsetzen + + + SD-Druck pausieren + + + SD-Leseposition setzen + + + SD-Druckstatus melden + + + Schreiben auf SD-Karte starten + + + Schreiben auf SD-Karte beenden + + + SD-Datei löschen + + + Datei auswählen und Druck starten + + + Befehl + + + Beschreibung + + + Kategorien + + + Parameter + + + Befehle suchen... + + + Zieltemperatur + + + Inline-Modus (an / aus) + + + Spindel- / Laserleistung (PWM 0-255) + + + Spindel- / Laser-Drehzahl + + + Zyklen (3-20) + + + X-Position + + + Y-Position + + + Z-Position + + + Vorschubgeschwindigkeit + + + Dateipfad + + + Extrusionsmenge + + + Stepper aktivieren + + + Stepper deaktivieren + + + LCD-Meldung einstellen + + + Verbinden + + + Trennen + + + E-Position + + + Ports aktualisieren + + + Keine + + + Port auswählen + + + PID-Einstellung + + + Thermisches Modell + + + Einstellungen im EEPROM speichern + + + Einstellungen aus EEPROM lesen + + + Alle Einstellungen auf Werkseinstellungen zurücksetzen + + + Aktuelle Einstellungen aus EEPROM melden + + + EEPROM-Inhalt validieren + + + Proportionalwert + + + Integralwert + + + Differentialwert + + + Bett-PID setzen + + + Heizdüsen-PID setzen + + + Verbindung hergestellt. + + + Fehler beim Senden des Befehls: {0}. + + + Verbindung getrennt. + + + Senden + + + Befehl eingeben... + + + Starten + + + Hotend + + + Heatbed + + + Steuerkonsole + + + Heizer-Index + + + Ergebnis + + + Position + + + Länge + + + Setzen + + + Extrudieren + + + Rückwärts + + + Materialfluss + + + Aufheizen + + + Status + + + Heizdüsentemperatur einstellen + + + Bett-Temperatur einstellen + + + Motoren ausschalten + + + Bereit + + + Nicht vorhanden + + + Geschwindigkeit + + + Aktuell + + + Ziel + + + Geschwindigkeit für X/Y-Achsen (mm/min) + + + Geschwindigkeit für Z-Achse (mm/min) + + + Druckgeschwindigkeit + + + Vorschubgeschwindigkeits-Prozentsatz für alle G-Code-Bewegungen setzen/melden + + + Materialfluss-Prozentsatz für Extrusionsbewegungen setzen/melden + + + Getrennt + + + Verbunden + + + Druckt + + + Pausiert + + + Fehler + + + Lüftergeschwindigkeit setzen + + + Lüftergeschwindigkeit + + + Alle achsen referenzieren + + + Ausgewählte Achse referenzieren + + + Schritte pro Millimeter (Spindel) + + + E-Schritte Rechner + + + Rechner + + + Motorschritt-Winkel + + + Mikroschrittung + + + Schritte pro Millimeter (Riemen) + + + Druckkosten Rechner + + + Riementeilung (mm) + + + Riemenscheibenzähne + + + Spindelsteigung (mm/Umdrehung) + + + Übersetzungsverhältnis + + + Schritte pro mm + + + Auflösung + + + G-Code Beispiel + + + Angeforderte Extrusionslänge (mm) + + + Aktuelle E-Schritte (Schritte/mm) + + + Tatsächliche Extrusionslänge (mm) + + + Messen mit einem Messschieber + + + <h5>Anleitung:</h5><ol> <li>Messen Sie 120 mm vom Extrudereingang ab und markieren Sie diese Stelle</li> <li>Erhitzen Sie das Hotend auf Betriebstemperatur</li> <li>Extrudieren Sie 100 mm (verwenden Sie <code>G1 E100 F100</code>)</li> <li>Messen Sie das verbleibende Filament bis zur Markierung</li> <li>Tragen Sie die Werte oben ein (tatsächliche Länge = 120 - verbleibende Länge)</li></ol> + + + Schritte pro Einheit für eine oder mehrere Achsen ermitteln oder festlegen + + + Browser nicht unterstützt. + + + Vergessen Sie nicht, nach dem Testen zu speichern + + + Dateien + + + Name + + + letzte Änderung + + + Größe + + + Aktualisieren + + + Über uns + + + Open-Source, plattformübergreifende 3D-Drucker-Verwaltungssoftware, entwickelt mit Blazor Hybrid. <strong></br>Noch in Entwicklung—Nutzung auf eigene Gefahr.</strong> Besonderer Dank geht an <a href="https://mrhide.de">MrHide</a> für das digitale Logo und Paleva (@x-hain) für ihre wertvolle Unterstützung. Der Quellcode ist <a href="https://github.com/akinbender/MakerPrompt">hier</a> verfügbar. + + + Befehl ausführen + + + kopiere den Befehl + + + Ein Dienst, der einen funktionierenden 3D-Drucker zu Demonstrationszwecken simuliert. + + + Konsole + + + G-Code-Viewer + + + BrailleRAP + + + Text in Braille umwandeln und G-Code für BrailleRAP-Prägung generieren. + + + Texteingabe + + + Text zum Umwandeln in Braille eingeben + + + Geben Sie hier Ihren Text ein oder fügen Sie ihn ein... + + + Seiten + + + Zeilen + + + Löschen + + + Konfiguration + + + Seiteneinstellungen + + + Übersetzungssprache + + + Spalten pro Zeile + + + Zeilen pro Seite + + + Zeilenabstand + + + Maschineneinstellungen + + + Vorschubgeschwindigkeit + + + X-Versatz + + + Y-Versatz + + + Braille-Vorschau + + + Seitennavigation + + + Seite + + + Text eingeben, um Braille-Vorschau anzuzeigen + + + G-Code generieren + + + An Drucker senden + + + G-Code + + + Kopieren + + + G-Code erfolgreich generiert + + + Bitte zuerst mit einem Drucker verbinden + + + Bitte zuerst G-Code generieren + + + G-Code erfolgreich an Drucker gesendet + + + In die Zwischenablage kopiert + + + G-Code + + + Telemetrieausgabe anzeigen + + \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.es-ES.resx b/src/MakerPrompt.UI.Components/Properties/Resources.es-ES.resx new file mode 100644 index 0000000..f56f36f --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.es-ES.resx @@ -0,0 +1,796 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Calibración + + + Ventilador + + + Reporte + + + Ajustes + + + Temperatura + + + Movimiento lineal rápido (sin impresión) + + + Movimiento lineal controlado (impresión) + + + Hogar todos o ejes especificados + + + Autonivelación de cama + + + Establecer modo de posicionamiento absoluto + + + Establecer modo de posicionamiento relativo + + + Establecer posición actual (reiniciar coordenadas) + + + Establecer temperatura del hotend (no bloqueante) + + + Reportar temperaturas actuales + + + Establecer velocidad del ventilador (S: 0-255) + + + Apagar ventilador + + + Establecer temp. del hotend y esperar (bloqueante) + + + Parada de emergencia + + + Husillo / láser encendido (sentido horario) + + + Husillo / láser encendido (sentido antihorario) + + + Husillo / láser apagado + + + Obtener posición actual + + + Obtener información del firmware + + + Establecer temperatura de la cama (no bloqueante) + + + Establecer temp. de la cama y esperar (bloqueante) + + + Guía rápida de G-Code + + + Panel principal + + + Movimiento + + + Autotune PID + + + Calibración del modelo térmico + + + Tarjeta SD + + + Auto + + + Oscuro + + + Claro + + + Listar contenido de la tarjeta SD + + + Inicializar tarjeta SD + + + Liberar tarjeta SD + + + Seleccionar archivo SD + + + Iniciar/reanudar impresión SD + + + Pausar impresión SD + + + Establecer posición de lectura SD + + + Reportar estado de impresión SD + + + Comenzar a escribir en la tarjeta SD + + + Detener escritura en la tarjeta SD + + + Eliminar archivo SD + + + Seleccionar archivo e iniciar impresión + + + Comando + + + Descripción + + + Categorías + + + Parámetros + + + Buscar comandos.. + + + Temperatura objetivo + + + Modo en línea (encendido / apagado) + + + Potencia del husillo / láser (PWM 0-255) + + + Velocidad del husillo / láser + + + Ciclos (3-20) + + + Posición X + + + Posición Y + + + Posición Z + + + Velocidad de avance + + + Ruta del archivo + + + Cantidad de extrusión + + + Habilitar motores + + + Deshabilitar motores + + + Establecer mensaje LCD + + + Conectar + + + Desconectar + + + Posición E + + + Actualizar puertos + + + Ninguno + + + Seleccionar puerto + + + Ajuste PID + + + Modelo térmico + + + Guardar ajustes en EEPROM + + + Leer ajustes desde EEPROM + + + Restablecer todos los ajustes a valores de fábrica + + + Reportar ajustes actuales desde EEPROM + + + Validar el contenido de la EEPROM + + + Valor proporcional + + + Valor integral + + + Valor derivativo + + + Establecer PID de la cama + + + Establecer PID del hotend + + + Conexión establecida. + + + Error al enviar el comando: {0}. + + + Conexión terminada. + + + Enviar + + + Ingrese comando.. + + + Iniciar + + + Hotend + + + Cama caliente + + + Panel de control + + + Índice de calentador + + + Resultado + + + Posición + + + Longitud + + + Establecer + + + Extruir + + + Revertir + + + Flujo de impresión + + + Calentando + + + Estado + + + Establecer temperatura del hotend + + + Establecer temperatura de la cama + + + Motores apagados + + + Listo + + + No presente + + + Velocidad + + + Actual + + + Objetivo + + + Establecer velocidad para ejes X e Y (mm/min) + + + Establecer velocidad para eje Z (mm/min) + + + Velocidad de impresión + + + Establecer o reportar el porcentaje de velocidad de avance para todos los movimientos G-code (ejes X, Y, Z, E) + + + Establecer o reportar el porcentaje de flujo para movimientos de extrusión del eje E + + + Desconectado + + + Conectado + + + Imprimiendo + + + Pausado + + + Error + + + Establecer velocidad del ventilador + + + Velocidad del ventilador + + + Hogar todos los ejes + + + Hogar eje seleccionado + + + Calculadora de pasos por milímetro de husillo + + + Calculadora de E-Steps + + + Calculadoras + + + Ángulo de paso del motor + + + Microstepping del driver + + + Calculadora de pasos por milímetro de correa + + + Calculadora de precio de impresión + + + Distancia entre dientes de correa (mm) + + + Cantidad de dientes de polea + + + Paso del husillo (mm/revolución) + + + Relación de engranajes + + + Pasos por mm + + + Resolución + + + Ejemplo de G-code + + + Longitud de extrusión solicitada (mm) + + + E-steps actuales (pasos/mm) + + + Longitud de extrusión real (mm) + + + Medir con un calibrador + + + <h5>Cómo usar:</h5> <ol> <li>Marca 120mm desde la entrada del extrusor</li> <li>Calienta el hotend</li> <li>Extruye 100mm (usa <code>G1 E100 F100</code>)</li> <li>Mide el filamento restante hasta el extrusor</li> <li>Ingresa los valores arriba (real = 120 - restante)</li> </ol> + + + Obtener o establecer los pasos por unidad para uno o más ejes + + + Navegador no soportado. + + + Recuerde guardar después de probar + + + Archivos + + + Nombre + + + Fecha de modificación + + + Tamaño + + + Actualizar + + + Acerca de + + + Software de gestión de impresoras 3D de código abierto y multiplataforma impulsado por Blazor Hybrid. <strong>Aún en desarrollo—úselo bajo su propio riesgo.</strong></br>Agradecimientos especiales a <a href="https://mrhide.de">MrHide</a> por el logo digital y a Paleva (@x-hain) por su invaluable apoyo. El código fuente está disponible <a href="https://github.com/akinbender/MakerPrompt">aquí</a>. + + + Ejecutar comando + + + Copiar comando + + + Un servicio que simula una impresora 3D funcional con fines de demostración. + + + + Consola + + + Visor de G-Code + + + BrailleRAP + + + Convertir texto a Braille y generar código G para grabado BrailleRAP. + + + Entrada de texto + + + Ingrese el texto para convertir a Braille + + + Escriba o pegue su texto aquí... + + + Páginas + + + Líneas + + + Limpiar + + + Configuración + + + Configuración de página + + + Idioma de traducción + + + Columnas por línea + + + Filas por página + + + Espaciado de líneas + + + Configuración de máquina + + + Velocidad de avance + + + Desplazamiento X + + + Desplazamiento Y + + + Vista previa Braille + + + Navegación de página + + + Página + + + Ingrese texto para ver la vista previa en Braille + + + Generar código G + + + Enviar a impresora + + + Código G + + + Copiar + + + Código G generado exitosamente + + + Por favor, conéctese primero a una impresora + + + Por favor, genere el código G primero + + + Código G enviado a la impresora exitosamente + + + Copiado al portapapeles + + + Código G + + + Mostrar salida de telemetría + + Panel + Configuración + Inventario de Filamento + Analíticas + Flota + Bienvenido a MakerPrompt + Configura y conecta una impresora para comenzar. + Modo Granja + Eliminar + Activar Modo Granja + Cuando está activo, la página de Flota es la página de inicio predeterminada y las funciones de gestión de granjas están disponibles. + Configuraciones de Granja + — seleccionar granja — + Nombre de nueva granja + Crear + Exportar + Importar + Funciones + Activar Inventario de Filamento + Realiza seguimiento de bobinas de filamento y su peso restante. + Activar Analíticas de Impresión + Registra el historial de trabajos de impresión, duración y uso de filamento. + Telemetría y Analíticas + Activar Telemetría de la App + Permite que MakerPrompt recopile datos de uso anónimos para mejorar la aplicación. + Almacenamiento de la App + Guardar Configuración + Configuración Guardada + Tu configuración se ha guardado correctamente. + Granja Creada + Granja '{0}' creada. + Granja Cambiada + Cambiado a '{0}'. + Granja Eliminada + Configuración de granja eliminada. + Configuración de granja eliminada. El modo granja ha sido desactivado. + Exportado + Configuración de granja exportada. + Importado + Granja '{0}' importada. + Error + No se pudo crear la granja. + No se pudo cambiar de granja. + No se pudo eliminar la granja. + No se pudo exportar la configuración de granja. + No se pudo importar la configuración de granja. + Flota + No hay impresoras configuradas. Añade una para comenzar. + Añadir Impresora + Editar Impresora + Gestionar Impresoras + Eliminar Impresora + Nombre de Impresora + Tipo de Conexión + Conectar automáticamente al inicio + Guardar + Cancelar + Conectando... + Desconectando... + Establecer como activa + Activa + Impresoras + ¿Seguro que deseas eliminar esta impresora? + Progreso + Cámara + Atrás + Cerrar + Puerto + URL + Número de Serie + Código de Acceso + Clave API + Usuario + Contraseña / Clave API + El nombre de la impresora es obligatorio + Impresora actualizada + Impresora añadida + No se pudo guardar la impresora + Error de conexión + Error al desconectar + Impresora eliminada + Error al eliminar + Cola de Impresión + No hay archivos disponibles. Conecta una impresora para ver los archivos. + Enviar a impresora + Iniciar impresión + No hay impresoras disponibles + Impresión iniciada + Actualizar + Aún no hay proyectos. Crea uno para organizar tus impresiones. + Nuevo proyecto + Nombre del proyecto + Subir G-Code + Eliminar proyecto + Eliminar + Enviar a impresora + En cola + Imprimiendo + Completado + Fallido + Subir + Cargando... + \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.es.resx b/src/MakerPrompt.UI.Components/Properties/Resources.es.resx new file mode 100644 index 0000000..c672c95 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.es.resx @@ -0,0 +1,694 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Calibración + + + Ventilador + + + Reporte + + + Ajustes + + + Temperatura + + + Movimiento lineal rápido (sin impresión) + + + Movimiento lineal controlado (impresión) + + + Hogar todos o ejes especificados + + + Autonivelación de cama + + + Establecer modo de posicionamiento absoluto + + + Establecer modo de posicionamiento relativo + + + Establecer posición actual (reiniciar coordenadas) + + + Establecer temperatura del hotend (no bloqueante) + + + Reportar temperaturas actuales + + + Establecer velocidad del ventilador (S: 0-255) + + + Apagar ventilador + + + Establecer temp. del hotend y esperar (bloqueante) + + + Parada de emergencia + + + Husillo / láser encendido (sentido horario) + + + Husillo / láser encendido (sentido antihorario) + + + Husillo / láser apagado + + + Obtener posición actual + + + Obtener información del firmware + + + Establecer temperatura de la cama (no bloqueante) + + + Establecer temp. de la cama y esperar (bloqueante) + + + Guía rápida de G-Code + + + Panel principal + + + Movimiento + + + Autotune PID + + + Calibración del modelo térmico + + + Tarjeta SD + + + Auto + + + Oscuro + + + Claro + + + Listar contenido de la tarjeta SD + + + Inicializar tarjeta SD + + + Liberar tarjeta SD + + + Seleccionar archivo SD + + + Iniciar/reanudar impresión SD + + + Pausar impresión SD + + + Establecer posición de lectura SD + + + Reportar estado de impresión SD + + + Comenzar a escribir en la tarjeta SD + + + Detener escritura en la tarjeta SD + + + Eliminar archivo SD + + + Seleccionar archivo e iniciar impresión + + + Comando + + + Descripción + + + Categorías + + + Parámetros + + + Buscar comandos.. + + + Temperatura objetivo + + + Modo en línea (encendido / apagado) + + + Potencia del husillo / láser (PWM 0-255) + + + Velocidad del husillo / láser + + + Ciclos (3-20) + + + Posición X + + + Posición Y + + + Posición Z + + + Velocidad de avance + + + Ruta del archivo + + + Cantidad de extrusión + + + Habilitar motores + + + Deshabilitar motores + + + Establecer mensaje LCD + + + Conectar + + + Desconectar + + + Posición E + + + Actualizar puertos + + + Ninguno + + + Seleccionar puerto + + + Ajuste PID + + + Modelo térmico + + + Guardar ajustes en EEPROM + + + Leer ajustes desde EEPROM + + + Restablecer todos los ajustes a valores de fábrica + + + Reportar ajustes actuales desde EEPROM + + + Validar el contenido de la EEPROM + + + Valor proporcional + + + Valor integral + + + Valor derivativo + + + Establecer PID de la cama + + + Establecer PID del hotend + + + Conexión establecida. + + + Error al enviar el comando: {0}. + + + Conexión terminada. + + + Enviar + + + Ingrese comando.. + + + Iniciar + + + Hotend + + + Cama caliente + + + Panel de control + + + Índice de calentador + + + Resultado + + + Posición + + + Longitud + + + Establecer + + + Extruir + + + Revertir + + + Flujo de impresión + + + Calentando + + + Estado + + + Establecer temperatura del hotend + + + Establecer temperatura de la cama + + + Motores apagados + + + Listo + + + No presente + + + Velocidad + + + Actual + + + Objetivo + + + Establecer velocidad para ejes X e Y (mm/min) + + + Establecer velocidad para eje Z (mm/min) + + + Velocidad de impresión + + + Establecer o reportar el porcentaje de velocidad de avance para todos los movimientos G-code (ejes X, Y, Z, E) + + + Establecer o reportar el porcentaje de flujo para movimientos de extrusión del eje E + + + Desconectado + + + Conectado + + + Imprimiendo + + + Pausado + + + Error + + + Establecer velocidad del ventilador + + + Velocidad del ventilador + + + Hogar todos los ejes + + + Hogar eje seleccionado + + + Calculadora de pasos por milímetro de husillo + + + Calculadora de E-Steps + + + Calculadoras + + + Ángulo de paso del motor + + + Microstepping del driver + + + Calculadora de pasos por milímetro de correa + + + Calculadora de precio de impresión + + + Distancia entre dientes de correa (mm) + + + Cantidad de dientes de polea + + + Paso del husillo (mm/revolución) + + + Relación de engranajes + + + Pasos por mm + + + Resolución + + + Ejemplo de G-code + + + Longitud de extrusión solicitada (mm) + + + E-steps actuales (pasos/mm) + + + Longitud de extrusión real (mm) + + + Medir con un calibrador + + + <h5>Cómo usar:</h5> <ol> <li>Marca 120mm desde la entrada del extrusor</li> <li>Calienta el hotend</li> <li>Extruye 100mm (usa <code>G1 E100 F100</code>)</li> <li>Mide el filamento restante hasta el extrusor</li> <li>Ingresa los valores arriba (real = 120 - restante)</li> </ol> + + + Obtener o establecer los pasos por unidad para uno o más ejes + + + Navegador no soportado. + + + Recuerde guardar después de probar + + + Archivos + + + Nombre + + + Fecha de modificación + + + Tamaño + + + Actualizar + + + Acerca de + + + Software de gestión de impresoras 3D de código abierto y multiplataforma impulsado por Blazor Hybrid. <strong>Aún en desarrollo—úselo bajo su propio riesgo.</strong></br>Agradecimientos especiales a <a href="https://mrhide.de">MrHide</a> por el logo digital y a Paleva (@x-hain) por su invaluable apoyo. El código fuente está disponible <a href="https://github.com/akinbender/MakerPrompt">aquí</a>. + + + Ejecutar comando + + + Copiar comando + + + Un servicio que simula una impresora 3D funcional con fines de demostración. + + + + Consola + + + Visor de G-Code + + + BrailleRAP + + + Convertir texto a Braille y generar código G para grabado BrailleRAP. + + + Entrada de texto + + + Ingrese el texto para convertir a Braille + + + Escriba o pegue su texto aquí... + + + Páginas + + + Líneas + + + Limpiar + + + Configuración + + + Configuración de página + + + Idioma de traducción + + + Columnas por línea + + + Filas por página + + + Espaciado de líneas + + + Configuración de máquina + + + Velocidad de avance + + + Desplazamiento X + + + Desplazamiento Y + + + Vista previa Braille + + + Navegación de página + + + Página + + + Ingrese texto para ver la vista previa en Braille + + + Generar código G + + + Enviar a impresora + + + Código G + + + Copiar + + + Código G generado exitosamente + + + Por favor, conéctese primero a una impresora + + + Por favor, genere el código G primero + + + Código G enviado a la impresora exitosamente + + + Copiado al portapapeles + + + Código G + + + Mostrar salida de telemetría + + \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.fr-FR.resx b/src/MakerPrompt.UI.Components/Properties/Resources.fr-FR.resx new file mode 100644 index 0000000..327d8e3 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.fr-FR.resx @@ -0,0 +1,795 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Calibration + + + Ventilateur + + + Rapport + + + Paramètres + + + Température + + + Mouvement linéaire rapide (hors impression) + + + Mouvement linéaire contrôlé (impression) + + + Faire le home de tous les axes ou axes spécifiés + + + Auto-nivellement du plateau + + + Définir le mode de positionnement absolu + + + Définir le mode de positionnement relatif + + + Définir la position actuelle (réinitialiser les coordonnées) + + + Définir la température de la buse (non bloquant) + + + Rapporter les températures actuelles + + + Définir la vitesse du ventilateur (S: 0-255) + + + Éteindre le ventilateur + + + Définir la température de la buse et attendre (bloquant) + + + Arrêt d'urgence + + + Broche / laser activé (sens horaire) + + + Broche / laser activé (sens antihoraire) + + + Broche / laser désactivé + + + Obtenir la position actuelle + + + Obtenir les informations du firmware + + + Définir la température du plateau (non bloquant) + + + Définir la température du plateau et attendre (bloquant) + + + Aide-mémoire G-Code + + + Tableau de bord + + + Mouvement + + + Auto-étalonnage PID + + + Étalonnage du modèle thermique + + + Carte SD + + + Auto + + + Sombre + + + Clair + + + Lister le contenu de la carte SD + + + Initialiser la carte SD + + + Libérer la carte SD + + + Sélectionner un fichier SD + + + Démarrer/reprendre l'impression SD + + + Mettre en pause l'impression SD + + + Définir la position de lecture SD + + + Rapporter l'état d'impression SD + + + Commencer à écrire sur la carte SD + + + Arrêter l'écriture sur la carte SD + + + Supprimer un fichier SD + + + Sélectionner un fichier et démarrer l'impression + + + Commande + + + Description + + + Catégories + + + Paramètres + + + Rechercher des commandes.. + + + Température cible + + + Mode en ligne (activé / désactivé) + + + Puissance de broche / laser (PWM 0-255) + + + Vitesse de broche / laser + + + Cycles (3-20) + + + Position X + + + Position Y + + + Position Z + + + Vitesse d'avance + + + Chemin du fichier + + + Quantité d'extrusion + + + Activer les moteurs + + + Désactiver les moteurs + + + Définir le message LCD + + + Connecter + + + Déconnecter + + + Position E + + + Actualiser les ports + + + Aucun + + + Sélectionner un port + + + Réglage PID + + + Modèle thermique + + + Enregistrer les paramètres dans l'EEPROM + + + Lire les paramètres depuis l'EEPROM + + + Réinitialiser tous les paramètres par défaut + + + Rapporter les paramètres actuels de l'EEPROM + + + Valider le contenu de l'EEPROM + + + Valeur proportionnelle + + + Valeur intégrale + + + Valeur dérivée + + + Définir le PID du plateau + + + Définir le PID de la buse + + + Connexion établie. + + + Erreur lors de l'envoi de la commande : {0}. + + + Connexion terminée. + + + Envoyer + + + Entrer une commande.. + + + Démarrer + + + Buse + + + Plateau chauffant + + + Panneau de contrôle + + + Index du chauffage + + + Résultat + + + Position + + + Longueur + + + Définir + + + Extruder + + + Inverser + + + Flux d'impression + + + Chauffage + + + Statut + + + Définir la température de la buse + + + Définir la température du plateau + + + Moteurs éteints + + + Prêt + + + Non présent + + + Vitesse + + + Actuel + + + Cible + + + Définir la vitesse pour les axes X et Y (mm/min) + + + Définir la vitesse pour l'axe Z (mm/min) + + + Vitesse d'impression + + + Définir ou rapporter le pourcentage de vitesse d'avance pour tous les mouvements G-code (axes X, Y, Z, E) + + + Définir ou rapporter le pourcentage de flux pour les mouvements d'extrusion de l'axe E + + + Déconnecté + + + Connecté + + + Impression + + + En pause + + + Erreur + + + Définir la vitesse du ventilateur + + + Vitesse du ventilateur + + + Rentrer toutes les axes + + + Rentrer l’axe sélectionné + + + Calculateur de pas par millimètre de vis-mère + + + Calculateur E-Steps + + + Calculateurs + + + Angle de pas du moteur + + + Micro-pas du driver + + + Calculateur de pas par millimètre de courroie + + + Calculateur de prix d'impression + + + Pas de la courroie (mm) + + + Nombre de dents de la poulie + + + Pas de la vis-mère (mm/tour) + + + Rapport de réduction + + + Pas par mm + + + Résolution + + + Exemple de G-code + + + Longueur d'extrusion demandée (mm) + + + E-steps actuels (pas/mm) + + + Longueur d'extrusion réelle (mm) + + + Mesurer avec un pied à coulisse + + + <h5>Comment utiliser :</h5> <ol> <li>Marquez 120mm depuis l'entrée de l'extrudeur</li> <li>Chauffez la buse</li> <li>Extrudez 100mm (utilisez <code>G1 E100 F100</code>)</li> <li>Mesurez le filament restant jusqu'à l'extrudeur</li> <li>Entrez les valeurs ci-dessus (réel = 120 - restant)</li> </ol> + + + Obtenir ou définir les pas par unité pour un ou plusieurs axes + + + Navigateurs non pris en charge. + + + Pensez à sauvegarder après test + + + Fichiers + + + Nom + + + Date de modification + + + Taille + + + Actualiser + + + À propos + + + Logiciel de gestion d'imprimante 3D open-source et multiplateforme propulsé par Blazor Hybrid. <strong>Toujours en développement—utilisez à vos risques.</strong></br>Remerciements à <a href="https://mrhide.de">MrHide</a> pour le logo digital et Paleva (@x-hain) pour son soutien précieux. Le code source est disponible <a href="https://github.com/akinbender/MakerPrompt">ici</a>. + + + Exécuter la commande + + + Copier la commande + + + Un service qui simule une imprimante 3D fonctionnelle à des fins de démonstration. + + + Console + + + Visionneuse G-Code + + + BrailleRAP + + + Convertir du texte en Braille et générer du G-code pour l'embossage BrailleRAP. + + + Saisie de texte + + + Entrez le texte à convertir en Braille + + + Tapez ou collez votre texte ici... + + + Pages + + + Lignes + + + Effacer + + + Configuration + + + Paramètres de page + + + Langue de traduction + + + Colonnes par ligne + + + Lignes par page + + + Espacement des lignes + + + Paramètres de la machine + + + Vitesse d'avance + + + Décalage X + + + Décalage Y + + + Aperçu Braille + + + Navigation de page + + + Page + + + Entrez du texte pour voir l'aperçu Braille + + + Générer le G-code + + + Envoyer à l'imprimante + + + G-code + + + Copier + + + G-code généré avec succès + + + Veuillez d'abord vous connecter à une imprimante + + + Veuillez d'abord générer le G-code + + + G-code envoyé à l'imprimante avec succès + + + Copié dans le presse-papiers + + + G-code + + + Afficher la sortie de télémétrie + + Tableau de bord + Paramètres + Inventaire des filaments + Analytiques + Flotte + Bienvenue sur MakerPrompt + Configurez et connectez une imprimante pour commencer. + Mode Ferme + Supprimer + Activer le mode ferme + Lorsqu'il est activé, la page Flotte est la page d'accueil par défaut et les fonctions de gestion de ferme sont actives. + Configurations de ferme + — sélectionner une ferme — + Nom de la nouvelle ferme + Créer + Exporter + Importer + Fonctionnalités + Activer l'inventaire des filaments + Suivez les bobines de filament et leur poids restant. + Activer les analytiques d'impression + Suivez l'historique des impressions, la durée et la consommation de filament. + Télémétrie et analytiques + Activer la télémétrie de l'application + Autoriser MakerPrompt à collecter des données d'utilisation anonymes pour améliorer l'application. + Stockage de l'application + Enregistrer les paramètres + Paramètres enregistrés + Vos paramètres ont été enregistrés avec succès. + Ferme créée + Ferme '{0}' créée. + Ferme changée + Basculé vers '{0}'. + Ferme supprimée + Configuration de ferme supprimée. + Configuration de ferme supprimée. Le mode ferme a été désactivé. + Exporté + Configuration de ferme exportée. + Importé + Ferme '{0}' importée. + Erreur + Impossible de créer la ferme. + Impossible de changer de ferme. + Impossible de supprimer la ferme. + Impossible d'exporter la configuration de la ferme. + Impossible d'importer la configuration de la ferme. + Flotte + Aucune imprimante configurée. Ajoutez-en une pour commencer. + Ajouter une imprimante + Modifier l'imprimante + Gérer les imprimantes + Supprimer l'imprimante + Nom de l'imprimante + Type de connexion + Connecter automatiquement au démarrage + Enregistrer + Annuler + Connexion en cours... + Déconnexion en cours... + Définir comme active + Active + Imprimantes + Voulez-vous vraiment supprimer cette imprimante ? + Progression + Caméra + Retour + Fermer + Port + URL + Numéro de série + Code d'accès + Clé API + Nom d'utilisateur + Mot de passe / Clé API + Le nom de l'imprimante est obligatoire + Imprimante mise à jour + Imprimante ajoutée + Échec de l'enregistrement de l'imprimante + Échec de la connexion + Échec de la déconnexion + Imprimante supprimée + Échec de la suppression + File d'impression + Aucun fichier disponible. Connectez une imprimante pour afficher les fichiers. + Envoyer à l'imprimante + Démarrer l'impression + Aucune imprimante disponible + Impression démarrée + Actualiser + Pas encore de projets. Créez-en un pour organiser vos impressions. + Nouveau projet + Nom du projet + Télécharger du G-Code + Supprimer le projet + Retirer + Envoyer à l'imprimante + En attente + Impression en cours + Terminé + Échoué + Télécharger + Chargement... + \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.fr.resx b/src/MakerPrompt.UI.Components/Properties/Resources.fr.resx new file mode 100644 index 0000000..88e4953 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.fr.resx @@ -0,0 +1,693 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Calibration + + + Ventilateur + + + Rapport + + + Paramètres + + + Température + + + Mouvement linéaire rapide (hors impression) + + + Mouvement linéaire contrôlé (impression) + + + Faire le home de tous les axes ou axes spécifiés + + + Auto-nivellement du plateau + + + Définir le mode de positionnement absolu + + + Définir le mode de positionnement relatif + + + Définir la position actuelle (réinitialiser les coordonnées) + + + Définir la température de la buse (non bloquant) + + + Rapporter les températures actuelles + + + Définir la vitesse du ventilateur (S: 0-255) + + + Éteindre le ventilateur + + + Définir la température de la buse et attendre (bloquant) + + + Arrêt d'urgence + + + Broche / laser activé (sens horaire) + + + Broche / laser activé (sens antihoraire) + + + Broche / laser désactivé + + + Obtenir la position actuelle + + + Obtenir les informations du firmware + + + Définir la température du plateau (non bloquant) + + + Définir la température du plateau et attendre (bloquant) + + + Aide-mémoire G-Code + + + Tableau de bord + + + Mouvement + + + Auto-étalonnage PID + + + Étalonnage du modèle thermique + + + Carte SD + + + Auto + + + Sombre + + + Clair + + + Lister le contenu de la carte SD + + + Initialiser la carte SD + + + Libérer la carte SD + + + Sélectionner un fichier SD + + + Démarrer/reprendre l'impression SD + + + Mettre en pause l'impression SD + + + Définir la position de lecture SD + + + Rapporter l'état d'impression SD + + + Commencer à écrire sur la carte SD + + + Arrêter l'écriture sur la carte SD + + + Supprimer un fichier SD + + + Sélectionner un fichier et démarrer l'impression + + + Commande + + + Description + + + Catégories + + + Paramètres + + + Rechercher des commandes.. + + + Température cible + + + Mode en ligne (activé / désactivé) + + + Puissance de broche / laser (PWM 0-255) + + + Vitesse de broche / laser + + + Cycles (3-20) + + + Position X + + + Position Y + + + Position Z + + + Vitesse d'avance + + + Chemin du fichier + + + Quantité d'extrusion + + + Activer les moteurs + + + Désactiver les moteurs + + + Définir le message LCD + + + Connecter + + + Déconnecter + + + Position E + + + Actualiser les ports + + + Aucun + + + Sélectionner un port + + + Réglage PID + + + Modèle thermique + + + Enregistrer les paramètres dans l'EEPROM + + + Lire les paramètres depuis l'EEPROM + + + Réinitialiser tous les paramètres par défaut + + + Rapporter les paramètres actuels de l'EEPROM + + + Valider le contenu de l'EEPROM + + + Valeur proportionnelle + + + Valeur intégrale + + + Valeur dérivée + + + Définir le PID du plateau + + + Définir le PID de la buse + + + Connexion établie. + + + Erreur lors de l'envoi de la commande : {0}. + + + Connexion terminée. + + + Envoyer + + + Entrer une commande.. + + + Démarrer + + + Buse + + + Plateau chauffant + + + Panneau de contrôle + + + Index du chauffage + + + Résultat + + + Position + + + Longueur + + + Définir + + + Extruder + + + Inverser + + + Flux d'impression + + + Chauffage + + + Statut + + + Définir la température de la buse + + + Définir la température du plateau + + + Moteurs éteints + + + Prêt + + + Non présent + + + Vitesse + + + Actuel + + + Cible + + + Définir la vitesse pour les axes X et Y (mm/min) + + + Définir la vitesse pour l'axe Z (mm/min) + + + Vitesse d'impression + + + Définir ou rapporter le pourcentage de vitesse d'avance pour tous les mouvements G-code (axes X, Y, Z, E) + + + Définir ou rapporter le pourcentage de flux pour les mouvements d'extrusion de l'axe E + + + Déconnecté + + + Connecté + + + Impression + + + En pause + + + Erreur + + + Définir la vitesse du ventilateur + + + Vitesse du ventilateur + + + Rentrer toutes les axes + + + Rentrer l’axe sélectionné + + + Calculateur de pas par millimètre de vis-mère + + + Calculateur E-Steps + + + Calculateurs + + + Angle de pas du moteur + + + Micro-pas du driver + + + Calculateur de pas par millimètre de courroie + + + Calculateur de prix d'impression + + + Pas de la courroie (mm) + + + Nombre de dents de la poulie + + + Pas de la vis-mère (mm/tour) + + + Rapport de réduction + + + Pas par mm + + + Résolution + + + Exemple de G-code + + + Longueur d'extrusion demandée (mm) + + + E-steps actuels (pas/mm) + + + Longueur d'extrusion réelle (mm) + + + Mesurer avec un pied à coulisse + + + <h5>Comment utiliser :</h5> <ol> <li>Marquez 120mm depuis l'entrée de l'extrudeur</li> <li>Chauffez la buse</li> <li>Extrudez 100mm (utilisez <code>G1 E100 F100</code>)</li> <li>Mesurez le filament restant jusqu'à l'extrudeur</li> <li>Entrez les valeurs ci-dessus (réel = 120 - restant)</li> </ol> + + + Obtenir ou définir les pas par unité pour un ou plusieurs axes + + + Navigateurs non pris en charge. + + + Pensez à sauvegarder après test + + + Fichiers + + + Nom + + + Date de modification + + + Taille + + + Actualiser + + + À propos + + + Logiciel de gestion d'imprimante 3D open-source et multiplateforme propulsé par Blazor Hybrid. <strong>Toujours en développement—utilisez à vos risques.</strong></br>Remerciements à <a href="https://mrhide.de">MrHide</a> pour le logo digital et Paleva (@x-hain) pour son soutien précieux. Le code source est disponible <a href="https://github.com/akinbender/MakerPrompt">ici</a>. + + + Exécuter la commande + + + Copier la commande + + + Un service qui simule une imprimante 3D fonctionnelle à des fins de démonstration. + + + Console + + + Visionneuse G-Code + + + BrailleRAP + + + Convertir du texte en Braille et générer du G-code pour l'embossage BrailleRAP. + + + Saisie de texte + + + Entrez le texte à convertir en Braille + + + Tapez ou collez votre texte ici... + + + Pages + + + Lignes + + + Effacer + + + Configuration + + + Paramètres de page + + + Langue de traduction + + + Colonnes par ligne + + + Lignes par page + + + Espacement des lignes + + + Paramètres de la machine + + + Vitesse d'avance + + + Décalage X + + + Décalage Y + + + Aperçu Braille + + + Navigation de page + + + Page + + + Entrez du texte pour voir l'aperçu Braille + + + Générer le G-code + + + Envoyer à l'imprimante + + + G-code + + + Copier + + + G-code généré avec succès + + + Veuillez d'abord vous connecter à une imprimante + + + Veuillez d'abord générer le G-code + + + G-code envoyé à l'imprimante avec succès + + + Copié dans le presse-papiers + + + G-code + + + Afficher la sortie de télémétrie + + \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.he-IL.resx b/src/MakerPrompt.UI.Components/Properties/Resources.he-IL.resx new file mode 100644 index 0000000..6d7517e --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.he-IL.resx @@ -0,0 +1,353 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + כיול + מאוורר + דיווח + הגדרות + תנועה + כרטיס SD + טמפרטורה + פקודה + תיאור + קטגוריה + פרמטר + תנועה ליניארית מהירה (ללא שחול) + תנועה ליניארית מבוקרת (הדפסה) + כינוס לנקודת מוצא של כל הצירים או חלקם + ישור אוטומטי של המיטה + קביעת מיקום מוחלט + קביעת מיקום יחסי + קביעת מיקום נוכחי (אפס קואורדינטות) + ציר / לייזר פועל (כיוון שעון) + ציר / לייזר פועל (נגד כיוון שעון) + ציר / לייזר כבוי + הפעלת מנועי צעד + כיבוי מנועי צעד + הצגת תוכן כרטיס SD + אתחול כרטיס SD + שחרור כרטיס SD + בחירת קובץ מכרטיס SD + התחלה/המשך הדפסה מכרטיס SD + השהיית הדפסה מכרטיס SD + קביעת מיקום קריאה בכרטיס SD + הצגת סטטוס הדפסת SD + כתיבה לכרטיס SD + סיום כתיבה לכרטיס SD + מחיקת קובץ מכרטיס SD + בחירת קובץ והתחלת הדפסה + קריאה/קביעה של צעדים ליחידה + קביעת טמפרטורת ראש ההדפסה (לא חוסם) + הצגת טמפרטורות נוכחיות + קביעת מהירות מאוורר (S: 0–255) + כיבוי מאוורר + קביעת טמפרטורת ראש ההדפסה והמתנה + עצירת חירום + קבלת מיקום נוכחי + קבלת מידע על הקושחה + קביעת הודעת תצוגת LCD + קביעת טמפרטורת מיטה (לא חוסם) + קביעת טמפרטורת מיטה והמתנה + קביעה/קריאה של קצב הזנה לכל התנועות + קביעה/קריאה של קצב שחול + קביעת PID ראש ההדפסה + כיול PID אוטומטי + קביעת PID מיטה + כיול מודל תרמי + שמירת הגדרות ב-EEPROM + טעינת הגדרות מ-EEPROM + איפוס הגדרות להגדרות יצרן + הצגת הגדרות EEPROM נוכחיות + אימות תוכן EEPROM + מחזורים (3–20) + ערך נגזרת + כמות שחול + מיקום E + קצב הזנה + נתיב קובץ + מצב inline (הפעל / כבה) + ערך אינטגרל + עוצמת ציר / לייזר (PWM 0–255) + ערך פרופורציונלי + מהירות ציר / לייזר + טמפרטורת יעד + מיקום X + מיקום Y + מיקום Z + אורך שחול בפועל (מ"מ) + צעד רצועה (מ"מ) + מחשבון צעדים/מ"מ לרצועה + E-Steps נוכחיים (צעדים/מ"מ) + כיצד לכייל E-Steps + מחשבון E-Steps + דוגמת G-Code + יחס הילוכים + צעד בורג הזזה (מ"מ/סיבוב) + מחשבון צעדים/מ"מ לבורג הזזה + מדוד עם קליפר + מיקרו-צעדים של מנהל התקן + זווית צעד מנוע + מחשבון עלות הדפסה + מספר שיני גלגלת + זכור לשמור לאחר הבדיקות + אורך שחול מבוקש (מ"מ) + רזולוציה + צעדים למ"מ + אינדקס מחמם + כיוונון PID + תוצאה + התחל + מודל תרמי + ללא + חיפוש פקודות... + החיבור נוצר. + העתק פקודה + החיבור נסגר. + הזן פקודה... + שגיאה בשליחת הפקודה: {0}. + הפעל פקודה + שלח + נוכחי + שחול + מהירות מאוורר + חימום + כינוס (כל הצירים) + כינוס ציר נבחר + אורך + כיבוי מנועים + מיקום + זרימת הדפסה + מהירות הדפסה + נסיגה + לא קיים + מוכן + קבע + קביעת מהירות מאוורר + מהירות X ו-Y (מ"מ/דקה) + מהירות ציר Z (מ"מ/דקה) + קביעת טמפרטורת מיטה + קביעת טמפרטורת ראש הדפסה + מהירות + סטטוס + יעד + לוח בקרה + קבצים + תאריך שינוי + שם + רענן + גודל + הדפדפן אינו נתמך. + התחבר + שירות המדמה מדפסת תלת-ממד פועלת למטרות הדגמה. + נתק + רענן יציאות + בחר יציאה + אודות + BrailleRAP + מחשבונים + מסוף + מדריך G-Code + מציג G-Code + לוח מחוונים + מיטה מחוממת + ראש חם + מחובר + מנותק + שגיאה + מושהה + מדפיס + אוטומטי + כהה + בהיר + נקה + עמודות לשורה + תצורה + הועתק ללוח + העתק + המרת טקסט לברייל ויצירת G-Code להבלטה ב-BrailleRAP. + הזן טקסט להמרה לברייל + קצב הזנה + G-Code נוצר בהצלחה + G-Code + יש לייצר G-Code תחילה + יצור G-Code + שפת תרגום + שורות + רווח בין שורות + הגדרות מכונה + הזן טקסט לצפייה בתצוגה מקדימה של ברייל + אנא חבר מדפסת תחילה + היסט X + היסט Y + עמוד + ניווט בעמודים + עמודים + הגדרות עמוד + תצוגה מקדימה של ברייל + שורות לעמוד + שלח למדפסת + G-Code נשלח למדפסת בהצלחה + הזנת טקסט + הקלד או הדבק כאן טקסט... + G-Code + הצג פלט טלמטריה + לוח מחוונים + הגדרות + מלאי פילמנט + ניתוח נתונים + צי מדפסות + ברוך הבא ל-MakerPrompt + הגדר וחבר מדפסת כדי להתחיל. + מצב חווה + מחק + הפעל מצב חווה + כאשר פעיל, דף הצי הוא דף הבית הברירת מחדל ופונקציות ניהול החווה זמינות. + תצורות חווה + — בחר חווה — + שם חווה חדשה + צור + ייצוא + ייבוא + תכונות + הפעל מלאי פילמנט + עקוב אחר סלילי פילמנט ומשקלם הנותר. + הפעל ניתוח הדפסות + הקלט היסטוריית עבודות הדפסה, משך זמן ושימוש בפילמנט. + טלמטריה וניתוח נתונים + הפעל טלמטריה של האפליקציה + אפשר ל-MakerPrompt לאסוף נתוני שימוש אנונימיים לשיפור האפליקציה. + אחסון אפליקציה + שמור הגדרות + ההגדרות נשמרו + ההגדרות שלך נשמרו בהצלחה. + חווה נוצרה + חווה '{0}' נוצרה. + עברת לחווה אחרת + עברת ל-'{0}'. + חווה נמחקה + תצורת החווה נמחקה. + תצורת החווה נמחקה. מצב החווה הושבת. + יוצא + תצורת החווה יוצאה. + יובא + חווה '{0}' יובאה. + שגיאה + לא ניתן ליצור את החווה. + לא ניתן לעבור לחווה. + לא ניתן למחוק את החווה. + לא ניתן לייצא את תצורת החווה. + לא ניתן לייבא את תצורת החווה. + צי מדפסות + אין מדפסות מוגדרות. הוסף אחת כדי להתחיל. + הוסף מדפסת + ערוך מדפסת + נהל מדפסות + מחק מדפסת + שם מדפסת + סוג חיבור + התחבר אוטומטית בהפעלה + שמור + ביטול + מתחבר... + מנתק... + הגדר כפעיל + פעיל + מדפסות + האם אתה בטוח שברצונך למחוק מדפסת זו? + התקדמות + מצלמה + חזרה + סגור + יציאה + כתובת URL + מספר סידורי + קוד גישה + מפתח API + שם משתמש + סיסמה / מפתח API + שם המדפסת הוא שדה חובה + המדפסת עודכנה + המדפסת נוספה + שמירת המדפסת נכשלה + החיבור נכשל + הניתוק נכשל + המדפסת הוסרה + המחיקה נכשלה + תור הדפסה + אין קבצים זמינים. חבר מדפסת כדי לראות קבצים. + שלח למדפסת + התחל הדפסה + אין מדפסות זמינות + ההדפסה התחילה + רענן + אין עדיין פרויקטים. צור אחד לארגון ההדפסות שלך. + פרויקט חדש + שם פרויקט + העלה G-Code + מחק פרויקט + הסר + שלח למדפסת + בתור + מדפיס + הושלם + נכשל + העלה + טוען... + diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.it-IT.resx b/src/MakerPrompt.UI.Components/Properties/Resources.it-IT.resx new file mode 100644 index 0000000..3617807 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.it-IT.resx @@ -0,0 +1,353 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Calibrazione + Ventola + Report + Impostazioni + Movimento + Scheda SD + Temperatura + Comando + Descrizione + Categoria + Parametro + Movimento lineare rapido (senza estrusione) + Movimento lineare controllato (stampa) + Vai all'origine di tutti o alcuni assi + Livellamento automatico del piano + Imposta modalità assoluta + Imposta modalità relativa + Imposta posizione corrente (azzera coordinate) + Mandrino / Laser acceso (senso orario) + Mandrino / Laser acceso (senso antiorario) + Mandrino / Laser spento + Abilita motori passo-passo + Disabilita motori passo-passo + Elenca contenuto scheda SD + Inizializza scheda SD + Rilascia scheda SD + Seleziona file SD + Avvia/riprendi stampa SD + Metti in pausa stampa SD + Imposta posizione lettura SD + Mostra stato stampa SD + Scrivi su scheda SD + Interrompi scrittura su scheda SD + Elimina file SD + Seleziona file e avvia stampa + Leggi/imposta passi per unità + Imposta temperatura hotend (non bloccante) + Mostra temperature correnti + Imposta velocità ventola (S: 0–255) + Spegni ventola + Imposta temperatura hotend e attendi + Arresto di emergenza + Ottieni posizione corrente + Ottieni informazioni firmware + Imposta messaggio display LCD + Imposta temperatura piano (non bloccante) + Imposta temperatura piano e attendi + Imposta/leggi avanzamento per tutti i movimenti + Imposta/leggi flusso estrusione + Imposta PID hotend + Calibrazione automatica PID + Imposta PID piano + Calibrazione modello termico + Salva impostazioni su EEPROM + Carica impostazioni da EEPROM + Ripristina impostazioni di fabbrica + Mostra impostazioni EEPROM correnti + Verifica contenuto EEPROM + Cicli (3–20) + Valore derivativo + Quantità estrusione + Posizione E + Velocità avanzamento + Percorso file + Modalità inline (attiva / disattiva) + Valore integrale + Potenza mandrino / laser (PWM 0–255) + Valore proporzionale + Velocità mandrino / laser + Temperatura obiettivo + Posizione X + Posizione Y + Posizione Z + Lunghezza estrusione effettiva (mm) + Passo cinghia (mm) + Calcolatore passi/mm per cinghia + E-Steps correnti (passi/mm) + Come calibrare gli E-Steps + Calcolatore E-Steps + Esempio G-Code + Rapporto di trasmissione + Passo vite di avanzamento (mm/giro) + Calcolatore passi/mm per vite di avanzamento + Misura con un calibro + Micropassi driver + Angolo di passo motore + Calcolatore costo di stampa + Numero denti puleggia + Ricorda di salvare dopo i test + Lunghezza estrusione richiesta (mm) + Risoluzione + Passi per mm + Indice riscaldatore + Regolazione PID + Risultato + Avvia + Modello termico + Nessuno + Cerca comandi... + Connessione stabilita. + Copia comando + Connessione chiusa. + Inserisci comando... + Errore durante l'invio del comando: {0}. + Esegui comando + Invia + Attuale + Estrudi + Velocità ventola + Riscaldamento + Vai all'origine (tutti) + Vai all'origine asse selezionato + Lunghezza + Motori spenti + Posizione + Flusso di stampa + Velocità di stampa + Ritrai + Non presente + Pronto + Imposta + Imposta velocità ventola + Velocità X e Y (mm/min) + Velocità asse Z (mm/min) + Imposta temperatura piano + Imposta temperatura hotend + Velocità + Stato + Obiettivo + Pannello di controllo + File + Data di modifica + Nome + Aggiorna + Dimensione + Browser non supportato. + Connetti + Un servizio che simula una stampante 3D funzionante a scopo dimostrativo. + Disconnetti + Aggiorna porte + Seleziona porta + Informazioni + BrailleRAP + Calcolatori + Terminale + Guida G-Code + Visualizzatore G-Code + Pannello + Piano riscaldato + Hotend + Connessa + Disconnessa + Errore + In pausa + In stampa + Automatico + Scuro + Chiaro + Cancella + Colonne per riga + Configurazione + Copiato negli appunti + Copia + Converte il testo in Braille e genera G-Code per la goffratura BrailleRAP. + Inserisci il testo da convertire in Braille + Velocità avanzamento + G-Code generato correttamente + G-Code + Genera prima il G-Code + Genera G-Code + Lingua di traduzione + Righe + Interlinea + Impostazioni macchina + Inserisci testo per vedere l'anteprima Braille + Collega prima una stampante + Scostamento X + Scostamento Y + Pagina + Navigazione pagina + Pagine + Impostazioni pagina + Anteprima Braille + Righe per pagina + Invia alla stampante + G-Code inviato correttamente alla stampante + Inserimento testo + Digita o incolla il testo qui... + G-Code + Mostra output telemetria + Pannello + Impostazioni + Inventario filamenti + Analisi + Flotta + Benvenuto su MakerPrompt + Configura e collega una stampante per iniziare. + Modalità fattoria + Elimina + Abilita modalità fattoria + Quando attivo, la pagina Flotta è quella principale predefinita e le funzioni di gestione fattoria sono disponibili. + Configurazioni fattoria + — seleziona fattoria — + Nome nuova fattoria + Crea + Esporta + Importa + Funzionalità + Abilita inventario filamenti + Monitora le bobine di filamento e il peso rimanente. + Abilita analisi di stampa + Registra la cronologia dei lavori di stampa, la durata e l'utilizzo del filamento. + Telemetria e analisi + Abilita telemetria dell'app + Consenti a MakerPrompt di raccogliere dati di utilizzo anonimi per migliorare l'applicazione. + Archiviazione app + Salva impostazioni + Impostazioni salvate + Le impostazioni sono state salvate correttamente. + Fattoria creata + Fattoria '{0}' creata. + Fattoria cambiata + Passato a '{0}'. + Fattoria eliminata + Configurazione fattoria eliminata. + Configurazione fattoria eliminata. La modalità fattoria è stata disattivata. + Esportato + Configurazione fattoria esportata. + Importato + Fattoria '{0}' importata. + Errore + Impossibile creare la fattoria. + Impossibile cambiare fattoria. + Impossibile eliminare la fattoria. + Impossibile esportare la configurazione fattoria. + Impossibile importare la configurazione fattoria. + Flotta + Nessuna stampante configurata. Aggiungine una per iniziare. + Aggiungi stampante + Modifica stampante + Gestisci stampanti + Elimina stampante + Nome stampante + Tipo di connessione + Connetti automaticamente all'avvio + Salva + Annulla + Connessione in corso... + Disconnessione in corso... + Imposta come attiva + Attiva + Stampanti + Sei sicuro di voler eliminare questa stampante? + Avanzamento + Fotocamera + Indietro + Chiudi + Porta + URL + Numero di serie + Codice di accesso + Chiave API + Nome utente + Password / Chiave API + Il nome della stampante è obbligatorio + Stampante aggiornata + Stampante aggiunta + Salvataggio stampante non riuscito + Connessione non riuscita + Disconnessione non riuscita + Stampante rimossa + Eliminazione non riuscita + Coda di stampa + Nessun file disponibile. Collega una stampante per vedere i file. + Invia alla stampante + Avvia stampa + Nessuna stampante disponibile + Stampa avviata + Aggiorna + Nessun progetto ancora. Creane uno per organizzare le stampe. + Nuovo progetto + Nome progetto + Carica G-Code + Elimina progetto + Rimuovi + Invia alla stampante + In coda + In stampa + Completato + Fallito + Carica + Caricamento... + diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.pl-PL.resx b/src/MakerPrompt.UI.Components/Properties/Resources.pl-PL.resx new file mode 100644 index 0000000..8c9be4a --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.pl-PL.resx @@ -0,0 +1,796 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Kalibracja + + + Wentylator + + + Raportowanie + + + Ustawienia + + + Temperatura + + + Szybki ruch liniowy (bez drukowania) + + + Sterowany ruch liniowy (drukowanie) + + + Powrót do pozycji bazowej wszystkich lub wybranych osi + + + Automatyczne poziomowanie stołu + + + Ustaw tryb pozycjonowania absolutnego + + + Ustaw tryb pozycjonowania względnego + + + Ustaw bieżącą pozycję (resetuj współrzędne) + + + Ustaw temperaturę głowicy (nieblokujące) + + + Podaj aktualne temperatury + + + Ustaw prędkość wentylatora (S: 0-255) + + + Wyłącz wentylator + + + Ustaw temp. głowicy i czekaj (blokujące) + + + Zatrzymanie awaryjne + + + Wrzeciono / laser włączony (zgodnie z ruchem wskazówek zegara) + + + Wrzeciono / laser włączony (przeciwnie do ruchu wskazówek zegara) + + + Wrzeciono / laser wyłączony + + + Pobierz bieżącą pozycję + + + Pobierz informacje o firmware + + + Ustaw temperaturę stołu (nieblokujące) + + + Ustaw temp. stołu i czekaj (blokujące) + + + Ściąga G-Code + + + Pulpit + + + Ruch + + + Autokalibracja PID + + + Kalibracja modelu termicznego + + + Karta SD + + + Auto + + + Ciemny + + + Jasny + + + Wyświetl zawartość karty SD + + + Zainicjuj kartę SD + + + Zwolnij kartę SD + + + Wybierz plik SD + + + Rozpocznij/wznów druk z SD + + + Wstrzymaj druk z SD + + + Ustaw pozycję odczytu SD + + + Podaj status druku SD + + + Rozpocznij zapis na karcie SD + + + Zakończ zapis na karcie SD + + + Usuń plik SD + + + Wybierz plik i rozpocznij druk + + + Polecenie + + + Opis + + + Kategorie + + + Parametry + + + Szukaj poleceń.. + + + Temperatura docelowa + + + Tryb inline (włączony / wyłączony) + + + Moc wrzeciona / lasera (PWM 0-255) + + + Prędkość wrzeciona / lasera + + + Cykle (3-20) + + + Pozycja X + + + Pozycja Y + + + Pozycja Z + + + Prędkość posuwu + + + Ścieżka pliku + + + Ilość ekstruzji + + + Włącz silniki + + + Wyłącz silniki + + + Ustaw komunikat LCD + + + Połącz + + + Rozłącz + + + Pozycja E + + + Odśwież porty + + + Brak + + + Wybierz port + + + Strojenie PID + + + Model termiczny + + + Zapisz ustawienia w EEPROM + + + Odczytaj ustawienia z EEPROM + + + Przywróć ustawienia fabryczne + + + Podaj bieżące ustawienia z EEPROM + + + Sprawdź zawartość EEPROM + + + Wartość proporcjonalna + + + Wartość całkowania + + + Wartość różniczkowania + + + Ustaw PID stołu + + + Ustaw PID głowicy + + + Połączenie nawiązane. + + + Błąd podczas wysyłania polecenia: {0}. + + + Połączenie zakończone. + + + Wyślij + + + Wpisz polecenie.. + + + Start + + + Głowica + + + Stół grzewczy + + + Panel sterowania + + + Indeks grzałki + + + Wynik + + + Pozycja + + + Długość + + + Ustaw + + + Ekstruzja + + + Odwróć + + + Przepływ druku + + + Grzanie + + + Status + + + Ustaw temperaturę głowicy + + + Ustaw temperaturę stołu + + + Silniki wyłączone + + + Gotowe + + + Brak + + + Prędkość + + + Bieżący + + + Docelowy + + + Ustaw prędkość dla osi X i Y (mm/min) + + + Ustaw prędkość dla osi Z (mm/min) + + + Prędkość druku + + + Ustaw lub podaj procent prędkości posuwu dla wszystkich ruchów G-code (osie X, Y, Z, E) + + + Ustaw lub podaj procent przepływu dla ruchów ekstruzji osi E + + + Rozłączony + + + Połączony + + + Drukowanie + + + Pauza + + + Błąd + + + Ustaw prędkość wentylatora + + + Prędkość wentylatora + + + Wszystkie osie do pozycji bazowej + + + Wybrana oś do pozycji bazowej + + + Kalkulator kroków na mm śruby pociągowej + + + Kalkulator E-Steps + + + Kalkulatory + + + Kąt kroku silnika + + + Mikrokroki sterownika + + + Kalkulator kroków na mm paska + + + Kalkulator ceny druku + + + Skok paska (mm) + + + Liczba zębów koła pasowego + + + Skok śruby pociągowej (mm/obrót) + + + Przełożenie + + + Kroki na mm + + + Rozdzielczość + + + Przykład G-code + + + Żądana długość ekstruzji (mm) + + + Aktualne E-steps (kroki/mm) + + + Rzeczywista długość ekstruzji (mm) + + + Zmierz suwmiarką + + + <h5>Jak używać:</h5> <ol> <li>Zaznacz 120mm od wejścia ekstrudera</li> <li>Podgrzej głowicę</li> <li>Wyekstruzuj 100mm (użyj <code>G1 E100 F100</code>)</li> <li>Zmierz pozostały filament do ekstrudera</li> <li>Wpisz powyższe wartości (rzeczywista = 120 - pozostała)</li> </ol> + + + Pobierz lub ustaw kroki na jednostkę dla jednej lub więcej osi + + + Przeglądarka nieobsługiwana. + + + Pamiętaj, aby zapisać po teście + + + Pliki + + + Nazwa + + + Data modyfikacji + + + Rozmiar + + + Odśwież + + + O programie + + + Oprogramowanie do zarządzania drukarkami 3D open-source, wieloplatformowe, oparte na Blazor Hybrid. <strong>Wciąż w trakcie rozwoju—używasz na własne ryzyko.</strong></br>Specjalne podziękowania dla <a href="https://mrhide.de">MrHide</a> za logo cyfrowe oraz Paleva (@x-hain) za nieocenione wsparcie. Kod źródłowy dostępny <a href="https://github.com/akinbender/MakerPrompt">tutaj</a>. + + + Wykonaj polecenie + + + Kopiuj polecenie + + + Usługa symulująca działającą drukarkę 3D do celów demonstracyjnych. + + + + Konsola + + + Przeglądarka G-Code + + + BrailleRAP + + + Konwertuj tekst na alfabet Braille'a i generuj G-code do tłoczenia BrailleRAP. + + + Wprowadzanie tekstu + + + Wprowadź tekst do konwersji na Braille'a + + + Wpisz lub wklej swój tekst tutaj... + + + Strony + + + Linie + + + Wyczyść + + + Konfiguracja + + + Ustawienia strony + + + Język tłumaczenia + + + Kolumn na linię + + + Wierszy na stronę + + + Odstęp między liniami + + + Ustawienia maszyny + + + Prędkość posuwu + + + Przesunięcie X + + + Przesunięcie Y + + + Podgląd Braille'a + + + Nawigacja strony + + + Strona + + + Wprowadź tekst, aby zobaczyć podgląd Braille'a + + + Generuj G-code + + + Wyślij do drukarki + + + G-code + + + Kopiuj + + + G-code wygenerowany pomyślnie + + + Najpierw połącz się z drukarką + + + Najpierw wygeneruj G-code + + + G-code wysłany do drukarki pomyślnie + + + Skopiowano do schowka + + + G-code + + + Pokaż wyjście telemetrii + + Panel + Ustawienia + Magazyn filamentów + Analityka + Flota + Witaj w MakerPrompt + Skonfiguruj i połącz drukarkę, aby zacząć. + Tryb farmy + Usuń + Włącz tryb farmy + Gdy włączony, strona Floty jest domyślną stroną główną i aktywne są funkcje zarządzania farmą. + Konfiguracje farmy + — wybierz farmę — + Nazwa nowej farmy + Utwórz + Eksportuj + Importuj + Funkcje + Włącz magazyn filamentów + Śledź szpule filamentu i ich pozostałą wagę. + Włącz analitykę druku + Śledź historię zadań drukowania, czas trwania i zużycie filamentu. + Telemetria i analityka + Włącz telemetrię aplikacji + Zezwól aplikacji MakerPrompt na zbieranie anonimowych danych użytkowania w celu poprawy jakości. + Przechowywanie aplikacji + Zapisz ustawienia + Ustawienia zapisane + Twoje ustawienia zostały pomyślnie zapisane. + Farma utworzona + Farma '{0}' utworzona. + Farma zmieniona + Przełączono na '{0}'. + Farma usunięta + Konfiguracja farmy została usunięta. + Konfiguracja farmy została usunięta. Tryb farmy został wyłączony. + Wyeksportowano + Konfiguracja farmy wyeksportowana. + Zaimportowano + Farma '{0}' zaimportowana. + Błąd + Nie udało się utworzyć farmy. + Nie udało się zmienić farmy. + Nie udało się usunąć farmy. + Nie udało się wyeksportować konfiguracji farmy. + Nie udało się zaimportować konfiguracji farmy. + Flota + Brak skonfigurowanych drukarek. Dodaj jedną, aby zacząć. + Dodaj drukarkę + Edytuj drukarkę + Zarządzaj drukarkami + Usuń drukarkę + Nazwa drukarki + Typ połączenia + Połącz automatycznie przy uruchomieniu + Zapisz + Anuluj + Łączenie... + Rozłączanie... + Ustaw jako aktywną + Aktywna + Drukarki + Czy na pewno chcesz usunąć tę drukarkę? + Postęp + Kamera + Wstecz + Zamknij + Port + URL + Numer seryjny + Kod dostępu + Klucz API + Nazwa użytkownika + Hasło / Klucz API + Nazwa drukarki jest wymagana + Drukarka zaktualizowana + Drukarka dodana + Nie udało się zapisać drukarki + Błąd połączenia + Błąd rozłączania + Drukarka usunięta + Błąd usuwania + Kolejka druku + Brak dostępnych plików. Połącz drukarkę, aby wyświetlić pliki. + Wyślij do drukarki + Rozpocznij druk + Brak dostępnych drukarek + Druk rozpoczęty + Odśwież + Brak projektów. Utwórz jeden, aby organizować wydruki. + Nowy projekt + Nazwa projektu + Prześlij G-Code + Usuń projekt + Usuń + Wyślij do drukarki + W kolejce + Drukowanie + Ukończono + Niepowodzenie + Prześlij + Ładowanie... + \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.pl.resx b/src/MakerPrompt.UI.Components/Properties/Resources.pl.resx new file mode 100644 index 0000000..3cd94be --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.pl.resx @@ -0,0 +1,694 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Kalibracja + + + Wentylator + + + Raportowanie + + + Ustawienia + + + Temperatura + + + Szybki ruch liniowy (bez drukowania) + + + Sterowany ruch liniowy (drukowanie) + + + Powrót do pozycji bazowej wszystkich lub wybranych osi + + + Automatyczne poziomowanie stołu + + + Ustaw tryb pozycjonowania absolutnego + + + Ustaw tryb pozycjonowania względnego + + + Ustaw bieżącą pozycję (resetuj współrzędne) + + + Ustaw temperaturę głowicy (nieblokujące) + + + Podaj aktualne temperatury + + + Ustaw prędkość wentylatora (S: 0-255) + + + Wyłącz wentylator + + + Ustaw temp. głowicy i czekaj (blokujące) + + + Zatrzymanie awaryjne + + + Wrzeciono / laser włączony (zgodnie z ruchem wskazówek zegara) + + + Wrzeciono / laser włączony (przeciwnie do ruchu wskazówek zegara) + + + Wrzeciono / laser wyłączony + + + Pobierz bieżącą pozycję + + + Pobierz informacje o firmware + + + Ustaw temperaturę stołu (nieblokujące) + + + Ustaw temp. stołu i czekaj (blokujące) + + + Ściąga G-Code + + + Pulpit + + + Ruch + + + Autokalibracja PID + + + Kalibracja modelu termicznego + + + Karta SD + + + Auto + + + Ciemny + + + Jasny + + + Wyświetl zawartość karty SD + + + Zainicjuj kartę SD + + + Zwolnij kartę SD + + + Wybierz plik SD + + + Rozpocznij/wznów druk z SD + + + Wstrzymaj druk z SD + + + Ustaw pozycję odczytu SD + + + Podaj status druku SD + + + Rozpocznij zapis na karcie SD + + + Zakończ zapis na karcie SD + + + Usuń plik SD + + + Wybierz plik i rozpocznij druk + + + Polecenie + + + Opis + + + Kategorie + + + Parametry + + + Szukaj poleceń.. + + + Temperatura docelowa + + + Tryb inline (włączony / wyłączony) + + + Moc wrzeciona / lasera (PWM 0-255) + + + Prędkość wrzeciona / lasera + + + Cykle (3-20) + + + Pozycja X + + + Pozycja Y + + + Pozycja Z + + + Prędkość posuwu + + + Ścieżka pliku + + + Ilość ekstruzji + + + Włącz silniki + + + Wyłącz silniki + + + Ustaw komunikat LCD + + + Połącz + + + Rozłącz + + + Pozycja E + + + Odśwież porty + + + Brak + + + Wybierz port + + + Strojenie PID + + + Model termiczny + + + Zapisz ustawienia w EEPROM + + + Odczytaj ustawienia z EEPROM + + + Przywróć ustawienia fabryczne + + + Podaj bieżące ustawienia z EEPROM + + + Sprawdź zawartość EEPROM + + + Wartość proporcjonalna + + + Wartość całkowania + + + Wartość różniczkowania + + + Ustaw PID stołu + + + Ustaw PID głowicy + + + Połączenie nawiązane. + + + Błąd podczas wysyłania polecenia: {0}. + + + Połączenie zakończone. + + + Wyślij + + + Wpisz polecenie.. + + + Start + + + Głowica + + + Stół grzewczy + + + Panel sterowania + + + Indeks grzałki + + + Wynik + + + Pozycja + + + Długość + + + Ustaw + + + Ekstruzja + + + Odwróć + + + Przepływ druku + + + Grzanie + + + Status + + + Ustaw temperaturę głowicy + + + Ustaw temperaturę stołu + + + Silniki wyłączone + + + Gotowe + + + Brak + + + Prędkość + + + Bieżący + + + Docelowy + + + Ustaw prędkość dla osi X i Y (mm/min) + + + Ustaw prędkość dla osi Z (mm/min) + + + Prędkość druku + + + Ustaw lub podaj procent prędkości posuwu dla wszystkich ruchów G-code (osie X, Y, Z, E) + + + Ustaw lub podaj procent przepływu dla ruchów ekstruzji osi E + + + Rozłączony + + + Połączony + + + Drukowanie + + + Pauza + + + Błąd + + + Ustaw prędkość wentylatora + + + Prędkość wentylatora + + + Wszystkie osie do pozycji bazowej + + + Wybrana oś do pozycji bazowej + + + Kalkulator kroków na mm śruby pociągowej + + + Kalkulator E-Steps + + + Kalkulatory + + + Kąt kroku silnika + + + Mikrokroki sterownika + + + Kalkulator kroków na mm paska + + + Kalkulator ceny druku + + + Skok paska (mm) + + + Liczba zębów koła pasowego + + + Skok śruby pociągowej (mm/obrót) + + + Przełożenie + + + Kroki na mm + + + Rozdzielczość + + + Przykład G-code + + + Żądana długość ekstruzji (mm) + + + Aktualne E-steps (kroki/mm) + + + Rzeczywista długość ekstruzji (mm) + + + Zmierz suwmiarką + + + <h5>Jak używać:</h5> <ol> <li>Zaznacz 120mm od wejścia ekstrudera</li> <li>Podgrzej głowicę</li> <li>Wyekstruzuj 100mm (użyj <code>G1 E100 F100</code>)</li> <li>Zmierz pozostały filament do ekstrudera</li> <li>Wpisz powyższe wartości (rzeczywista = 120 - pozostała)</li> </ol> + + + Pobierz lub ustaw kroki na jednostkę dla jednej lub więcej osi + + + Przeglądarka nieobsługiwana. + + + Pamiętaj, aby zapisać po teście + + + Pliki + + + Nazwa + + + Data modyfikacji + + + Rozmiar + + + Odśwież + + + O programie + + + Oprogramowanie do zarządzania drukarkami 3D open-source, wieloplatformowe, oparte na Blazor Hybrid. <strong>Wciąż w trakcie rozwoju—używasz na własne ryzyko.</strong></br>Specjalne podziękowania dla <a href="https://mrhide.de">MrHide</a> za logo cyfrowe oraz Paleva (@x-hain) za nieocenione wsparcie. Kod źródłowy dostępny <a href="https://github.com/akinbender/MakerPrompt">tutaj</a>. + + + Wykonaj polecenie + + + Kopiuj polecenie + + + Usługa symulująca działającą drukarkę 3D do celów demonstracyjnych. + + + + Konsola + + + Przeglądarka G-Code + + + BrailleRAP + + + Konwertuj tekst na alfabet Braille'a i generuj G-code do tłoczenia BrailleRAP. + + + Wprowadzanie tekstu + + + Wprowadź tekst do konwersji na Braille'a + + + Wpisz lub wklej swój tekst tutaj... + + + Strony + + + Linie + + + Wyczyść + + + Konfiguracja + + + Ustawienia strony + + + Język tłumaczenia + + + Kolumn na linię + + + Wierszy na stronę + + + Odstęp między liniami + + + Ustawienia maszyny + + + Prędkość posuwu + + + Przesunięcie X + + + Przesunięcie Y + + + Podgląd Braille'a + + + Nawigacja strony + + + Strona + + + Wprowadź tekst, aby zobaczyć podgląd Braille'a + + + Generuj G-code + + + Wyślij do drukarki + + + G-code + + + Kopiuj + + + G-code wygenerowany pomyślnie + + + Najpierw połącz się z drukarką + + + Najpierw wygeneruj G-code + + + G-code wysłany do drukarki pomyślnie + + + Skopiowano do schowka + + + G-code + + + Pokaż wyjście telemetrii + + \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.resx b/src/MakerPrompt.UI.Components/Properties/Resources.resx new file mode 100644 index 0000000..ae4d6df --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.resx @@ -0,0 +1,1005 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Calibration + + + Fan + + + Reporting + + + Settings + + + Temperature + + + Rapid linear movement (non-printing) + + + Controlled linear movement (printing) + + + Home all or specified axes + + + Auto bed leveling + + + Set absolute positioning mode + + + Set relative positioning mode + + + Set current position (reset coordinates) + + + Set hotend temperature (non-blocking) + + + Report current temperatures + + + Set fan speed (S: 0-255) + + + Turn fan off + + + Set hotend temp & wait (blocking) + + + Emergency stop + + + Spindle / laser on (clockwise) + + + Spindle / laser on (counterclockwise) + + + Spindle / laser off + + + Get current position + + + Get firmware info + + + Set bed temperature (non-blocking) + + + Set bed temp & wait (blocking) + + + G-Code Cheat Sheet + + + Dashboard + + + Movement + + + PID autotune + + + Thermal model calibration + + + Sd Card + + + Auto + + + Dark + + + Light + + + List SD card contents + + + Initialize SD card + + + Release SD card + + + Select SD file + + + Start/resume SD print + + + Pause SD print + + + Set SD read position + + + Report SD print status + + + Start writing to SD card + + + Stop writing to SD card + + + Delete SD file + + + Select file and start print + + + Command + + + Description + + + Categories + + + Parameters + + + Search commands.. + + + Target temperature + + + Inline mode (on / off) + + + Spindle / laser power (PWM 0-255) + + + Spindle / laser speed + + + Cycles (3-20) + + + X position + + + Y position + + + Z position + + + Feedrate + + + File path + + + Extrude amount + + + Enable Steppers + + + Disable Steppers + + + Set LCD Message + + + Connect + + + Disconnect + + + E Position + + + Refresh ports + + + None + + + Select port + + + PID Tuning + + + Thermal Model + + + Store settings in EEPROM + + + Read settings from EEPROM + + + Reset all settings in memory to their factory defaults + + + Report current settings from EEPROM + + + Validate the contents of the EEPROM + + + Proportional Value + + + Integral Value + + + Derivative Value + + + Set bed PID + + + Set hotend PID + + + Connection established. + + + Error sending command: {0}. + + + Connection terminated. + + + Send + + + Enter command.. + + + Start + + + Hotend + + + Heatbed + + + Control Panel + + + Heater Index + + + Result + + + Position + + + Length + + + Set + + + Extrude + + + Reverse + + + Print flow + + + Heating + + + Status + + + Set hotend temperature + + + Set bed temperature + + + Motors off + + + Ready + + + Not present + + + Speed + + + Current + + + Target + + + Set speed for X & Y axes (mm/min) + + + Set speed for Z axis (mm/min) + + + Print speed + + + Set or report the feed rate percentage for all G-code moves (X, Y, Z, E axes) + + + Set or report the flow rate percentage for E-axis extrusion moves + + + Disconnected + + + Connected + + + Printing + + + Paused + + + Error + + + Set fan speed + + + Fan speed + + + Home all + + + Home selected axis + + + Leadscrew Steps per Millimeter Calculator + + + E-Steps Calculator + + + Calculators + + + Motor Step Angle + + + Driver Microsteppin + + + Belt Steps per Millimeter Calculator + + + Print price Calculator + + + Belt Pitch (mm) + + + Pulley Tooth Count + + + Leadscrew Pitch (mm/revolution) + + + Gear Ratio + + + Steps per mm + + + Resolution + + + G-code Example + + + Requested Extrusion Length (mm) + + + Current E-steps (steps/mm) + + + Actual Extrusion Length (mm) + + + Measure with a caliper + + + <h5>How to use:</h5> <ol> <li>Mark 120mm from your extruder entrance</li> <li>Heat up your hotend</li> <li>Extrude 100mm (use <code>G1 E100 F100</code>)</li> <li>Measure remaining filament to extruder</li> <li>Enter values above (actual = 120 - remaining)</li> </ol> + + + Get or set the steps-per-unit for one or more axes + + + Browser not supported. + + + Remember to save after testing + + + Files + + + Name + + + Date Modified + + + Size + + + Refresh + + + About + + + Open-source, cross-platform 3D printer management software powered by Blazor Hybrid. <strong>Still under development—use at your own risk.</strong></br>Special thanks to <a href="https://mrhide.de">MrHide</a> for the digital logo and Paleva (@x-hain) for her invaluable support. The source code is available <a href="https://github.com/akinbender/MakerPrompt">here</a>. + + + Run command + + + Copy command + + + A service that simulates a functioning 3D printer for demonstration purposes. + + + Terminal + + + G-Code Viewer + + + BrailleRAP + + + Convert text to Braille and generate G-code for BrailleRAP embossing. + + + Text Input + + + Enter text to convert to Braille + + + Type or paste your text here... + + + Pages + + + Lines + + + Clear + + + Configuration + + + Page Settings + + + Translation Language + + + Columns per line + + + Rows per page + + + Line spacing + + + Machine Settings + + + Feed rate + + + X offset + + + Y offset + + + Braille Preview + + + Page navigation + + + Page + + + Enter text to see Braille preview + + + Generate G-code + + + Send to Printer + + + G-code + + + Copy + + + G-code generated successfully + + + Please connect to a printer first + + + Please generate G-code first + + + G-code sent to printer successfully + + + Copied to clipboard + + + G-code + + + Show telemetry output + + + Fleet + + + No printers configured. Add one to get started. + + + Add Printer + + + Edit Printer + + + Manage Printers + + + Delete Printer + + + Printer Name + + + Connection Type + + + Auto-connect on startup + + + Save + + + Cancel + + + Connecting... + + + Disconnecting... + + + Set as active + + + Active + + + Printers + + + Are you sure you want to delete this printer? + + + Progress + + + Camera + + + Fleet + + + Print Queue + + + No files available. Connect a printer to browse files. + + + Send to printer + + + Start Print + + + No idle printers available + + + Print started + + + Refresh + + + No projects yet. Create one to start organizing prints. + + + New Project + + + Project Name + + + Upload G-code + + + Delete Project + + + Remove + + + Send to Printer + + + Queued + + + Printing + + + Completed + + + Failed + + + Settings + + + Dashboard + + + Filament Inventory + + + Analytics + + + Welcome to MakerPrompt + + + Configure and connect a printer to get started. + + + Farm Mode + + + Delete + + + Enable Farm Mode + + + When enabled, the Fleet page is the default landing page and farm management features are active. When disabled, a single-printer Dashboard is the default. + + + Farm Configurations + + + — select a farm — + + + New farm name + + + Create + + + Export + + + Import + + + Features + + + Enable Filament Inventory + + + Track filament spools and their remaining weight. + + + Enable Print Analytics + + + Track print job history, duration, and filament usage. + + + Telemetry & Analytics + + + Enable App Telemetry + + + Allow MakerPrompt to collect anonymous usage data to improve the app. + + + App storage + + + Save Settings + + + Settings Saved + + + Your settings have been saved successfully. + + + Farm Created + + + Farm '{0}' created. + + + Farm Switched + + + Switched to '{0}'. + + + Farm Deleted + + + Farm configuration removed. + + + Farm configuration removed. Farm mode has been disabled. + + + Exported + + + Farm configuration exported. + + + Imported + + + Farm '{0}' imported. + + + Error + + + Failed to create farm. + + + Failed to switch farm. + + + Failed to delete farm. + + + Failed to export farm configuration. + + + Failed to import farm configuration. + + + Back + + + Close + + + Port + + + URL + + + Serial Number + + + Access Code + + + API Key + + + Printer UUID + + + Cloud-based printer management via Prusa Connect. Get your Printer UUID and API key from connect.prusa3d.com → Account → API Keys. + + + Username + + + Password / API Key + + + Printer name is required + + + Printer updated + + + Printer added + + + Failed to save printer + + + Connection failed + + + Disconnect failed + + + Printer removed + + + Delete failed + + + Upload + + + Loading... + + \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.tr-TR.resx b/src/MakerPrompt.UI.Components/Properties/Resources.tr-TR.resx new file mode 100644 index 0000000..ebea113 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.tr-TR.resx @@ -0,0 +1,796 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Kalibrasyon + + + Fan + + + Raporlama + + + Ayarlar + + + Sıcaklık + + + Hızlı doğrusal hareket (baskı dışı) + + + Kontrollü doğrusal hareket (baskı) + + + Tüm veya belirli eksenleri sıfırla + + + Otomatik yatak kalibrasyonu + + + Mutlak konum modunu aktif et + + + Göreceli konum modunu aktif et + + + Mevcut konumu ayarla (koordinatları sıfırla) + + + Nozul sıcaklığını ayarla (bekleme yok) + + + Mevcut sıcaklıkları raporla + + + Fan hızını ayarla (S: 0-255) + + + Fanı kapat + + + Nozul sıcaklığını ayarla ve bekle (beklemeli) + + + Acil durdurma + + + Mil / lazer açık (saat yönünde) + + + Mil / lazer açık (saat yönünün tersine) + + + Mil / lazer kapalı + + + Mevcut konumu al + + + Firmware bilgilerini al + + + Yatak sıcaklığını ayarla (bekleme yok) + + + Yatak sıcaklığını ayarla ve bekle (beklemeli) + + + G-Code Kılavuzu + + + Kontrol Paneli + + + Hareket + + + PID otomatik ayarı + + + Termal model kalibrasyonu + + + SD Kart + + + Otomatik + + + Koyu + + + Açık + + + SD kart içeriğini listele + + + SD kartı başlat + + + SD kartı serbest bırak + + + SD dosyasını seç + + + SD baskısını başlat/devam ettir + + + SD baskısını duraklat + + + SD okuma pozisyonunu ayarla + + + SD baskı durumunu raporla + + + SD karta yazmaya başla + + + SD karta yazmayı durdur + + + SD dosyasını sil + + + Dosya seç ve baskıyı başlat + + + Komut + + + Açıklama + + + Kategoriler + + + Parametreler + + + Komut ara... + + + Hedef sıcaklık + + + Satır içi mod (açık / kapalı) + + + Mil / lazer gücü (PWM 0-255) + + + Mil / lazer hızı + + + Döngüler (3-20) + + + X Pozisyonu + + + Y Pozisyonu + + + Z Pozisyonu + + + Besleme hızı + + + Dosya yolu + + + Eksstrüzyon miktarı + + + Stepper'ları etkinleştir + + + Stepper'ları devre dışı bırak + + + LCD Mesajını Ayarla + + + Bağlan + + + Bağlantıyı Kes + + + E Pozisyonu + + + Portları Yenile + + + Yok + + + Port Seç + + + PID Ayarlama + + + Termal Model + + + Ayarları EEPROM'a kaydet + + + EEPROM'dan ayarları oku + + + Tüm ayarları fabrika varsayılanlarına sıfırla + + + EEPROM'dan mevcut ayarları raporla + + + EEPROM içeriğini doğrula + + + Oransal Değer + + + İntegral Değeri + + + Türev Değeri + + + Yatak PID ayarı + + + Nozul PID ayarı + + + Bağlantı kuruldu. + + + Komut gönderilirken hata: {0}. + + + Bağlantı kesildi. + + + Gönder + + + Komut girin... + + + Başlat + + + Sıcak uç + + + Isı Yatağı + + + Kontrol Paneli + + + Isıtıcı İndeksi + + + Sonuç + + + Pozisyon + + + Uzunluk + + + Ayarla + + + Eksstrüde Et + + + Geri Sar + + + Akış Hızı + + + Isınma + + + Durum + + + Nozul Sıcaklığını Ayarla + + + Yatak Sıcaklığını Ayarla + + + Motorları Kapat + + + Hazır + + + Bulunamadı + + + Hız + + + Mevcut + + + Hedef + + + X/Y Eksen Hızı (mm/dak) + + + Z Eksen Hızı (mm/dak) + + + Baskı Hızı + + + Tüm G-code hareketleri için besleme hızı yüzdesini ayarla/raporla + + + Eksstrüzyon hareketleri için akış hızı yüzdesini ayarla/raporla + + + Bağlantı Kesildi + + + Bağlı + + + Baskıda + + + Duraklatıldı + + + Hata + + + Fan Hızını Ayarla + + + Fan Hızı + + + Tüm eksenleri referansa gönder + + + Seçili ekseni referansa gönder + + + Mil Başına Adım Hesaplayıcı (Vida) + + + E-Adım Hesaplayıcı + + + Hesaplayıcılar + + + Motor Adım Açısı + + + Mikro Adımlama + + + Mil Başına Adım Hesaplayıcı (Kayış) + + + Baskı Maliyeti Hesaplayıcı + + + Kayış Diş Aralığı (mm) + + + Kasnak Diş Sayısı + + + Vida Adımı (mm/devir) + + + Dişli Oranı + + + Mil Başına Adım + + + Çözünürlük + + + G-code örneği + + + Talep Edilen Ekstrüzyon Uzunluğu (mm) + + + Mevcut E-adımları (adım/mm) + + + Gerçek Ekstrüzyon Uzunluğu (mm) + + + Bir kumpas ile ölçün + + + <h5>Nasıl Kullanılır:</h5><ol> <li>Ekstruder girişinden 120 mm işaretleyin</li> <li>Hotend'i çalışma sıcaklığına ısıtın</li> <li>100 mm ekstrüde edin (<code>G1 E100 F100</code> komutunu kullanın)</li> <li>Ekstrudere kalan filament uzunluğunu ölçün</li> <li>Yukarıdaki değerleri girin (gerçek uzunluk = 120 - kalan uzunluk)</li></ol> + + + Bir veya daha fazla eksen için birim başına adımları sorma veya ayarlama + + + Tarayıcı desteklenmiyor. + + + Test ettikten sonra kaydetmeyi unutmayın + + + Dosyalar + + + İsim + + + Değiştirildiği Tarih + + + Boyut + + + Yenile + + + Hakkımızda + + + Blazor Hybrid ile geliştirilen, açık kaynaklı ve çapraz platform bir 3D yazıcı yönetim yazılımı. <strong>Hala geliştirme aşamasındadır—kullanım riski size aittir.</strong></br>Dijital logo için <a href="https://mrhide.de">MrHide</a>'a ve değerli desteği için Paleva'ya (@x-hain) özel teşekkürler. Kaynak koduna <a href="https://github.com/akinbender/MakerPrompt">buradan</a> ulaşabilirsiniz. + + + Komutu gönder + + + Komutu kopyala + + + Demonstrasyon amaçlı çalışan bir 3B yazıcıyı simüle eden bir hizmet. + + + + Konsol + + + G-Code Görüntüleyici + + + BrailleRAP + + + Metni Braille'e dönüştürün ve BrailleRAP kabartma için G-kodu oluşturun. + + + Metin Girişi + + + Braille'e dönüştürülecek metni girin + + + Metninizi buraya yazın veya yapıştırın... + + + Sayfalar + + + Satırlar + + + Temizle + + + Yapılandırma + + + Sayfa Ayarları + + + Çeviri Dili + + + Satır başına sütun + + + Sayfa başına satır + + + Satır aralığı + + + Makine Ayarları + + + İlerleme hızı + + + X kayması + + + Y kayması + + + Braille Önizlemesi + + + Sayfa gezinme + + + Sayfa + + + Braille önizlemesini görmek için metin girin + + + G-kodu oluştur + + + Yazıcıya gönder + + + G-kodu + + + Kopyala + + + G-kodu başarıyla oluşturuldu + + + Lütfen önce bir yazıcıya bağlanın + + + Lütfen önce G-kodunu oluşturun + + + G-kodu yazıcıya başarıyla gönderildi + + + Panoya kopyalandı + + + G-kodu + + + Telemetri çıktısını göster + + Gösterge Paneli + Ayarlar + Filament Envanteri + Analitik + Filo + MakerPrompt'a Hoş Geldiniz + Başlamak için bir yazıcı yapılandırın ve bağlayın. + Çiftlik Modu + Sil + Çiftlik Modunu Etkinleştir + Etkin olduğunda, Filo sayfası varsayılan ana sayfa olur ve çiftlik yönetimi özellikleri kullanılabilir. Devre dışı olduğunda, tek yazıcı panosu varsayılan sayfa olur. + Çiftlik Yapılandırmaları + — çiftlik seç — + Yeni çiftlik adı + Oluştur + Dışa Aktar + İçe Aktar + Özellikler + Filament Envanterini Etkinleştir + Filament makaralarını ve kalan ağırlıklarını takip edin. + Baskı Analitiğini Etkinleştir + Baskı işi geçmişini, süresini ve filament kullanımını kaydedin. + Telemetri ve Analitik + Uygulama Telemetrisini Etkinleştir + MakerPrompt'ın uygulamayı geliştirmek için anonim kullanım verileri toplamasına izin verin. + Uygulama Depolama + Ayarları Kaydet + Ayarlar Kaydedildi + Ayarlarınız başarıyla kaydedildi. + Çiftlik Oluşturuldu + '{0}' çiftliği oluşturuldu. + Çiftlik Değiştirildi + '{0}' çiftliğine geçildi. + Çiftlik Silindi + Çiftlik yapılandırması silindi. + Çiftlik yapılandırması silindi. Çiftlik modu devre dışı bırakıldı. + Dışa Aktarıldı + Çiftlik yapılandırması dışa aktarıldı. + İçe Aktarıldı + '{0}' çiftliği içe aktarıldı. + Hata + Çiftlik oluşturulamadı. + Çiftlik değiştirilemedi. + Çiftlik silinemedi. + Çiftlik yapılandırması dışa aktarılamadı. + Çiftlik yapılandırması içe aktarılamadı. + Filo + Yapılandırılmış yazıcı yok. Başlamak için bir tane ekleyin. + Yazıcı Ekle + Yazıcıyı Düzenle + Yazıcıları Yönet + Yazıcıyı Sil + Yazıcı Adı + Bağlantı Türü + Başlangıçta otomatik bağlan + Kaydet + İptal + Bağlanıyor... + Bağlantı kesiliyor... + Aktif olarak ayarla + Aktif + Yazıcılar + Bu yazıcıyı silmek istediğinizden emin misiniz? + İlerleme + Kamera + Geri + Kapat + Port + URL + Seri Numarası + Erişim Kodu + API Anahtarı + Kullanıcı Adı + Şifre / API Anahtarı + Yazıcı adı zorunludur + Yazıcı güncellendi + Yazıcı eklendi + Yazıcı kaydedilemedi + Bağlantı başarısız + Bağlantı kesme başarısız + Yazıcı kaldırıldı + Silme başarısız + Baskı Kuyruğu + Dosya yok. Dosyaları görmek için bir yazıcı bağlayın. + Yazıcıya Gönder + Baskıyı Başlat + Kullanılabilir yazıcı yok + Baskı başlatıldı + Yenile + Henüz proje yok. Baskılarınızı düzenlemek için bir tane oluşturun. + Yeni Proje + Proje Adı + G-Code Yükle + Projeyi Sil + Kaldır + Yazıcıya Gönder + Kuyrukta + Basıyor + Tamamlandı + Başarısız + Yükle + Yükleniyor... + \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.tr.resx b/src/MakerPrompt.UI.Components/Properties/Resources.tr.resx new file mode 100644 index 0000000..09fe3b3 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.tr.resx @@ -0,0 +1,694 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Kalibrasyon + + + Fan + + + Raporlama + + + Ayarlar + + + Sıcaklık + + + Hızlı doğrusal hareket (baskı dışı) + + + Kontrollü doğrusal hareket (baskı) + + + Tüm veya belirli eksenleri sıfırla + + + Otomatik yatak kalibrasyonu + + + Mutlak konum modunu aktif et + + + Göreceli konum modunu aktif et + + + Mevcut konumu ayarla (koordinatları sıfırla) + + + Nozul sıcaklığını ayarla (bekleme yok) + + + Mevcut sıcaklıkları raporla + + + Fan hızını ayarla (S: 0-255) + + + Fanı kapat + + + Nozul sıcaklığını ayarla ve bekle (beklemeli) + + + Acil durdurma + + + Mil / lazer açık (saat yönünde) + + + Mil / lazer açık (saat yönünün tersine) + + + Mil / lazer kapalı + + + Mevcut konumu al + + + Firmware bilgilerini al + + + Yatak sıcaklığını ayarla (bekleme yok) + + + Yatak sıcaklığını ayarla ve bekle (beklemeli) + + + G-Code Kılavuzu + + + Kontrol Paneli + + + Hareket + + + PID otomatik ayarı + + + Termal model kalibrasyonu + + + SD Kart + + + Otomatik + + + Koyu + + + Açık + + + SD kart içeriğini listele + + + SD kartı başlat + + + SD kartı serbest bırak + + + SD dosyasını seç + + + SD baskısını başlat/devam ettir + + + SD baskısını duraklat + + + SD okuma pozisyonunu ayarla + + + SD baskı durumunu raporla + + + SD karta yazmaya başla + + + SD karta yazmayı durdur + + + SD dosyasını sil + + + Dosya seç ve baskıyı başlat + + + Komut + + + Açıklama + + + Kategoriler + + + Parametreler + + + Komut ara... + + + Hedef sıcaklık + + + Satır içi mod (açık / kapalı) + + + Mil / lazer gücü (PWM 0-255) + + + Mil / lazer hızı + + + Döngüler (3-20) + + + X Pozisyonu + + + Y Pozisyonu + + + Z Pozisyonu + + + Besleme hızı + + + Dosya yolu + + + Eksstrüzyon miktarı + + + Stepper'ları etkinleştir + + + Stepper'ları devre dışı bırak + + + LCD Mesajını Ayarla + + + Bağlan + + + Bağlantıyı Kes + + + E Pozisyonu + + + Portları Yenile + + + Yok + + + Port Seç + + + PID Ayarlama + + + Termal Model + + + Ayarları EEPROM'a kaydet + + + EEPROM'dan ayarları oku + + + Tüm ayarları fabrika varsayılanlarına sıfırla + + + EEPROM'dan mevcut ayarları raporla + + + EEPROM içeriğini doğrula + + + Oransal Değer + + + İntegral Değeri + + + Türev Değeri + + + Yatak PID ayarı + + + Nozul PID ayarı + + + Bağlantı kuruldu. + + + Komut gönderilirken hata: {0}. + + + Bağlantı kesildi. + + + Gönder + + + Komut girin... + + + Başlat + + + Sıcak uç + + + Isı Yatağı + + + Kontrol Paneli + + + Isıtıcı İndeksi + + + Sonuç + + + Pozisyon + + + Uzunluk + + + Ayarla + + + Eksstrüde Et + + + Geri Sar + + + Akış Hızı + + + Isınma + + + Durum + + + Nozul Sıcaklığını Ayarla + + + Yatak Sıcaklığını Ayarla + + + Motorları Kapat + + + Hazır + + + Bulunamadı + + + Hız + + + Mevcut + + + Hedef + + + X/Y Eksen Hızı (mm/dak) + + + Z Eksen Hızı (mm/dak) + + + Baskı Hızı + + + Tüm G-code hareketleri için besleme hızı yüzdesini ayarla/raporla + + + Eksstrüzyon hareketleri için akış hızı yüzdesini ayarla/raporla + + + Bağlantı Kesildi + + + Bağlı + + + Baskıda + + + Duraklatıldı + + + Hata + + + Fan Hızını Ayarla + + + Fan Hızı + + + Tüm eksenleri referansa gönder + + + Seçili ekseni referansa gönder + + + Mil Başına Adım Hesaplayıcı (Vida) + + + E-Adım Hesaplayıcı + + + Hesaplayıcılar + + + Motor Adım Açısı + + + Mikro Adımlama + + + Mil Başına Adım Hesaplayıcı (Kayış) + + + Baskı Maliyeti Hesaplayıcı + + + Kayış Diş Aralığı (mm) + + + Kasnak Diş Sayısı + + + Vida Adımı (mm/devir) + + + Dişli Oranı + + + Mil Başına Adım + + + Çözünürlük + + + G-code örneği + + + Talep Edilen Ekstrüzyon Uzunluğu (mm) + + + Mevcut E-adımları (adım/mm) + + + Gerçek Ekstrüzyon Uzunluğu (mm) + + + Bir kumpas ile ölçün + + + <h5>Nasıl Kullanılır:</h5><ol> <li>Ekstruder girişinden 120 mm işaretleyin</li> <li>Hotend'i çalışma sıcaklığına ısıtın</li> <li>100 mm ekstrüde edin (<code>G1 E100 F100</code> komutunu kullanın)</li> <li>Ekstrudere kalan filament uzunluğunu ölçün</li> <li>Yukarıdaki değerleri girin (gerçek uzunluk = 120 - kalan uzunluk)</li></ol> + + + Bir veya daha fazla eksen için birim başına adımları sorma veya ayarlama + + + Tarayıcı desteklenmiyor. + + + Test ettikten sonra kaydetmeyi unutmayın + + + Dosyalar + + + İsim + + + Değiştirildiği Tarih + + + Boyut + + + Yenile + + + Hakkımızda + + + Blazor Hybrid ile geliştirilen, açık kaynaklı ve çapraz platform bir 3D yazıcı yönetim yazılımı. <strong>Hala geliştirme aşamasındadır—kullanım riski size aittir.</strong></br>Dijital logo için <a href="https://mrhide.de">MrHide</a>'a ve değerli desteği için Paleva'ya (@x-hain) özel teşekkürler. Kaynak koduna <a href="https://github.com/akinbender/MakerPrompt">buradan</a> ulaşabilirsiniz. + + + Komutu gönder + + + Komutu kopyala + + + Demonstrasyon amaçlı çalışan bir 3B yazıcıyı simüle eden bir hizmet. + + + + Konsol + + + G-Code Görüntüleyici + + + BrailleRAP + + + Metni Braille'e dönüştürün ve BrailleRAP kabartma için G-kodu oluşturun. + + + Metin Girişi + + + Braille'e dönüştürülecek metni girin + + + Metninizi buraya yazın veya yapıştırın... + + + Sayfalar + + + Satırlar + + + Temizle + + + Yapılandırma + + + Sayfa Ayarları + + + Çeviri Dili + + + Satır başına sütun + + + Sayfa başına satır + + + Satır aralığı + + + Makine Ayarları + + + İlerleme hızı + + + X kayması + + + Y kayması + + + Braille Önizlemesi + + + Sayfa gezinme + + + Sayfa + + + Braille önizlemesini görmek için metin girin + + + G-kodu oluştur + + + Yazıcıya gönder + + + G-kodu + + + Kopyala + + + G-kodu başarıyla oluşturuldu + + + Lütfen önce bir yazıcıya bağlanın + + + Lütfen önce G-kodunu oluşturun + + + G-kodu yazıcıya başarıyla gönderildi + + + Panoya kopyalandı + + + G-kodu + + + Telemetri çıktısını göster + + \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Properties/Resources.zh-CN.resx b/src/MakerPrompt.UI.Components/Properties/Resources.zh-CN.resx new file mode 100644 index 0000000..067ee86 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Properties/Resources.zh-CN.resx @@ -0,0 +1,353 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + 校准 + 风扇 + 报告 + 设置 + 运动 + SD 卡 + 温度 + 命令 + 描述 + 类别 + 参数 + 快速直线运动(不挤料) + 受控直线运动(打印) + 将所有或指定轴归零 + 自动调平热床 + 设置绝对定位模式 + 设置相对定位模式 + 设置当前位置(重置坐标) + 主轴/激光开启(顺时针) + 主轴/激光开启(逆时针) + 主轴/激光关闭 + 启用步进电机 + 禁用步进电机 + 列出 SD 卡内容 + 初始化 SD 卡 + 释放 SD 卡 + 从 SD 卡选择文件 + 开始/继续 SD 卡打印 + 暂停 SD 卡打印 + 设置 SD 卡读取位置 + 显示 SD 打印状态 + 写入 SD 卡 + 停止写入 SD 卡 + 删除 SD 卡文件 + 选择文件并开始打印 + 读取/设置每单位步数 + 设置喷嘴温度(非阻塞) + 显示当前温度 + 设置风扇速度(S: 0–255) + 关闭风扇 + 设置喷嘴温度并等待 + 紧急停止 + 获取当前位置 + 获取固件信息 + 设置 LCD 显示信息 + 设置热床温度(非阻塞) + 设置热床温度并等待 + 读取/设置所有运动的进给倍率 + 读取/设置挤出流量 + 设置喷嘴 PID 参数 + 自动 PID 校准 + 设置热床 PID 参数 + 热模型校准 + 将设置保存到 EEPROM + 从 EEPROM 加载设置 + 重置为出厂设置 + 显示当前 EEPROM 设置 + 验证 EEPROM 内容 + 循环次数(3–20) + 微分值 + 挤出量 + E 轴位置 + 进给速率 + 文件路径 + 内联模式(开/关) + 积分值 + 主轴/激光功率(PWM 0–255) + 比例值 + 主轴/激光速度 + 目标温度 + X 轴位置 + Y 轴位置 + Z 轴位置 + 实际挤出长度(毫米) + 皮带齿距(毫米) + 皮带步进/毫米计算器 + 当前 E 步数(步/毫米) + 如何校准 E 步数 + E 步数计算器 + G-Code 示例 + 传动比 + 丝杠螺距(毫米/转) + 丝杠步进/毫米计算器 + 用卡尺测量 + 驱动器细分 + 电机步进角 + 打印成本计算器 + 皮带轮齿数 + 测试后记得保存 + 请求挤出长度(毫米) + 分辨率 + 步数/毫米 + 加热器编号 + PID 调节 + 结果 + 开始 + 热模型 + + 搜索命令... + 连接已建立。 + 复制命令 + 连接已关闭。 + 输入命令... + 发送命令时出错:{0}。 + 运行命令 + 发送 + 当前 + 挤出 + 风扇速度 + 加热 + 所有轴归零 + 所选轴归零 + 长度 + 关闭电机 + 位置 + 打印流量 + 打印速度 + 回抽 + 未插入 + 就绪 + 设置 + 设置风扇速度 + X 和 Y 速度(毫米/分钟) + Z 轴速度(毫米/分钟) + 设置热床温度 + 设置喷嘴温度 + 速度 + 状态 + 目标 + 控制面板 + 文件 + 修改日期 + 名称 + 刷新 + 大小 + 浏览器不受支持。 + 连接 + 模拟正在运行的 3D 打印机的演示服务。 + 断开 + 刷新端口 + 选择端口 + 关于 + BrailleRAP + 计算器 + 控制台 + G-Code 参考 + G-Code 查看器 + 仪表板 + 热床 + 热端 + 已连接 + 已断开 + 错误 + 已暂停 + 打印中 + 自动 + 深色 + 浅色 + 清除 + 每行列数 + 配置 + 已复制到剪贴板 + 复制 + 将文本转换为盲文并生成用于 BrailleRAP 压印的 G-Code。 + 输入要转换为盲文的文字 + 进给速率 + G-Code 生成成功 + G-Code + 请先生成 G-Code + 生成 G-Code + 翻译语言 + 行数 + 行间距 + 机器设置 + 输入文字以查看盲文预览 + 请先连接打印机 + X 偏移 + Y 偏移 + 页面 + 页面导航 + 页数 + 页面设置 + 盲文预览 + 每页行数 + 发送到打印机 + G-Code 已成功发送到打印机 + 文字输入 + 在此处输入或粘贴文字... + G-Code + 显示遥测输出 + 仪表板 + 设置 + 耗材库存 + 数据分析 + 打印机群 + 欢迎使用 MakerPrompt + 配置并连接打印机以开始使用。 + 农场模式 + 删除 + 启用农场模式 + 启用后,打印机群页面为默认主页,农场管理功能可用。 + 农场配置 + — 选择农场 — + 新农场名称 + 创建 + 导出 + 导入 + 功能 + 启用耗材库存 + 追踪耗材卷轴及剩余重量。 + 启用打印分析 + 记录打印任务历史、时长和耗材用量。 + 遥测与分析 + 启用应用遥测 + 允许 MakerPrompt 收集匿名使用数据以改进应用。 + 应用存储 + 保存设置 + 设置已保存 + 您的设置已成功保存。 + 农场已创建 + 农场"{0}"已创建。 + 已切换农场 + 已切换到"{0}"。 + 农场已删除 + 农场配置已删除。 + 农场配置已删除,农场模式已关闭。 + 已导出 + 农场配置已导出。 + 已导入 + 农场"{0}"已导入。 + 错误 + 无法创建农场。 + 无法切换农场。 + 无法删除农场。 + 无法导出农场配置。 + 无法导入农场配置。 + 打印机群 + 没有已配置的打印机,请添加一台以开始使用。 + 添加打印机 + 编辑打印机 + 管理打印机 + 删除打印机 + 打印机名称 + 连接类型 + 启动时自动连接 + 保存 + 取消 + 连接中... + 断开中... + 设为活动 + 活动中 + 打印机 + 确定要删除此打印机吗? + 进度 + 摄像头 + 返回 + 关闭 + 端口 + URL + 序列号 + 访问码 + API 密钥 + 用户名 + 密码 / API 密钥 + 打印机名称为必填项 + 打印机已更新 + 打印机已添加 + 保存打印机失败 + 连接失败 + 断开失败 + 打印机已移除 + 删除失败 + 打印队列 + 没有可用文件,连接打印机以查看文件。 + 发送到打印机 + 开始打印 + 没有可用的打印机 + 打印已开始 + 刷新 + 暂无项目,创建一个以整理您的打印任务。 + 新建项目 + 项目名称 + 上传 G-Code + 删除项目 + 移除 + 发送到打印机 + 排队中 + 打印中 + 已完成 + 失败 + 上传 + 加载中... + diff --git a/src/MakerPrompt.UI.Components/Services/AnalyticsService.cs b/src/MakerPrompt.UI.Components/Services/AnalyticsService.cs new file mode 100644 index 0000000..037361f --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/AnalyticsService.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.UI.Components.Services +{ + public class AnalyticsService + { + private const string StorageKey = "MakerPrompt.PrintJobUsageRecords.json"; + private readonly IAppLocalStorageProvider _storage; + private readonly ILogger _logger; + private readonly SemaphoreSlim _lock = new(1, 1); + private List _records = []; + + public event EventHandler? AnalyticsUpdated; + + public AnalyticsService(IAppLocalStorageProvider storage, ILogger logger) + { + _storage = storage; + _logger = logger; + } + + public async Task InitializeAsync() + { + await _lock.WaitAsync(); + try + { + var files = await _storage.ListFilesAsync(); + var file = files.FirstOrDefault(f => f.FullPath.Contains(StorageKey)); + if (file != null) + { + using var stream = await _storage.OpenReadAsync(file.FullPath); + if (stream != null) + { + using var reader = new StreamReader(stream); + var json = await reader.ReadToEndAsync(); + var stored = JsonSerializer.Deserialize>(json); + if (stored != null) + { + _records = stored; + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load print job usage records"); + } + finally + { + _lock.Release(); + } + } + + public async Task RecordUsageAsync(PrintJobUsageRecord record) + { + await _lock.WaitAsync(); + try + { + _records.Add(record); + var json = JsonSerializer.Serialize(_records); + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + await _storage.SaveFileAsync(StorageKey, stream); + } + finally + { + _lock.Release(); + } + AnalyticsUpdated?.Invoke(this, EventArgs.Empty); + } + + public IReadOnlyList GetRecords() => _records.AsReadOnly(); + + public TimeSpan GetTotalPrintHours() => TimeSpan.FromTicks(_records.Sum(r => r.Duration.Ticks)); + + public double GetTotalFilamentConsumed() => _records.Sum(r => r.ActualFilamentUsedGrams > 0 ? r.ActualFilamentUsedGrams : r.EstimatedFilamentUsedGrams); + + public double GetFilamentConsumedByPrinter(Guid printerId) => _records.Where(r => r.PrinterId == printerId).Sum(r => r.ActualFilamentUsedGrams > 0 ? r.ActualFilamentUsedGrams : r.EstimatedFilamentUsedGrams); + + public double GetFilamentConsumedBySpool(Guid spoolId) => _records.Where(r => r.FilamentSpoolId == spoolId).Sum(r => r.ActualFilamentUsedGrams > 0 ? r.ActualFilamentUsedGrams : r.EstimatedFilamentUsedGrams); + } +} diff --git a/src/MakerPrompt.UI.Components/Services/BambuLabApiService.cs b/src/MakerPrompt.UI.Components/Services/BambuLabApiService.cs new file mode 100644 index 0000000..7cfb1b4 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/BambuLabApiService.cs @@ -0,0 +1,585 @@ +using System.Net.WebSockets; + +namespace MakerPrompt.UI.Components.Services; + +/// +/// BambuLab printer backend using WebSocket MQTT + HTTP REST API. +/// +/// Improvements over initial implementation (inspired by BambuCAM/BambuFarm patterns): +/// - Automatic WebSocket reconnection with exponential backoff +/// - Enhanced telemetry parsing (progress %, gcode state, print time, layer info) +/// - Camera stream URL extraction from telemetry +/// - Proper CancellationToken propagation for clean shutdown +/// - Connection health monitoring via periodic ping +/// +public sealed class BambuLabApiService : BasePrinterConnectionService, IPrinterCommunicationService +{ + private CancellationTokenSource _cts = new(); + private readonly HttpMessageHandler? _customHandler; + private HttpClient? _httpClient; + private Uri? _httpBaseUri; + + private ClientWebSocket? _ws; + private Task? _receiveLoopTask; + private readonly object _syncRoot = new(); + private bool _disposed; + private bool _telemetryTimerInitialized; + + // Reconnection state (BambuCAM pattern) + private string? _accessCode; + private string? _serial; + private int _reconnectAttempts; + private const int MaxReconnectAttempts = 5; + private const int BaseReconnectDelayMs = 2000; + + // Camera URL extracted from telemetry + private string? _cameraStreamUrl; + + public override PrinterConnectionType ConnectionType { get; } = PrinterConnectionType.BambuLab; + + public BambuLabApiService() + { + } + + public BambuLabApiService(HttpMessageHandler handler) + { + _customHandler = handler; + _httpClient = new HttpClient(handler, false); + } + + private HttpClient Client + { + get + { + _httpClient ??= _customHandler is not null + ? new HttpClient(_customHandler, false) + : new HttpClient(); + + return _httpClient; + } + } + + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + { + if (IsConnected) return true; + if (connectionSettings.ConnectionType != ConnectionType) + { + throw new ArgumentException("BambuLab connection type mismatch.", nameof(connectionSettings)); + } + + if (connectionSettings.Api is null) + { + return false; + } + + _httpBaseUri = new Uri(connectionSettings.Api.Url); + ConfigureClient(connectionSettings.Api); + + _accessCode = connectionSettings.Api.Password; + _serial = connectionSettings.Api.UserName; + + if (string.IsNullOrWhiteSpace(_accessCode) || string.IsNullOrWhiteSpace(_serial)) + { + IsConnected = false; + RaiseConnectionChanged(); + return false; + } + + // Reset CTS if previously cancelled (reconnection scenario) + if (_cts.IsCancellationRequested) + { + _cts.Dispose(); + _cts = new CancellationTokenSource(); + } + + ConnectionName = _httpBaseUri.AbsoluteUri; + _reconnectAttempts = 0; + + var mqttOk = await ConnectMqttAsync(_httpBaseUri, _accessCode, _serial, _cts.Token).ConfigureAwait(false); + + try + { + using var response = await Client.GetAsync("/api/v1/system/status", _cts.Token).ConfigureAwait(false); + IsConnected = response.IsSuccessStatusCode || mqttOk; + } + catch + { + IsConnected = mqttOk; + } + + if (IsConnected && !_telemetryTimerInitialized) + { + updateTimer.Elapsed += async (_, _) => await SafeTelemetryAsync().ConfigureAwait(false); + _telemetryTimerInitialized = true; + updateTimer.Start(); + } + + RaiseConnectionChanged(); + return IsConnected; + } + + private async Task ConnectMqttAsync(Uri baseUri, string accessCode, string serial, CancellationToken cancellationToken) + { + try + { + lock (_syncRoot) + { + _ws?.Dispose(); + _ws = new ClientWebSocket(); + } + + var builder = new UriBuilder(baseUri) + { + Scheme = baseUri.Scheme == Uri.UriSchemeHttps ? "wss" : "ws", + Path = "/ws/mqtt" + }; + + var wsUri = builder.Uri; + + await _ws!.ConnectAsync(wsUri, cancellationToken).ConfigureAwait(false); + + var clientId = $"makerprompt_{serial}"; + var authPayload = JsonSerializer.Serialize(new + { + client_id = clientId, + user = serial, + password = accessCode + }); + + await SendRawAsync(authPayload, cancellationToken).ConfigureAwait(false); + + var subscribePayload = JsonSerializer.Serialize(new + { + type = "subscribe", + topics = new[] + { + $"device/{serial}/report", + $"device/{serial}/history" + } + }); + + await SendRawAsync(subscribePayload, cancellationToken).ConfigureAwait(false); + + _receiveLoopTask = Task.Run(() => ReceiveLoopAsync(_cts.Token), _cts.Token); + + return true; + } + catch + { + return false; + } + } + + private async Task SendRawAsync(string payload, CancellationToken cancellationToken) + { + ClientWebSocket? ws; + lock (_syncRoot) + { + ws = _ws; + } + + if (ws is null || ws.State != WebSocketState.Open) return; + + var bytes = Encoding.UTF8.GetBytes(payload); + await ws.SendAsync(bytes, WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); + } + + private async Task ReceiveLoopAsync(CancellationToken cancellationToken) + { + var buffer = new byte[16 * 1024]; + + while (!cancellationToken.IsCancellationRequested) + { + WebSocketReceiveResult? result = null; + var ms = new MemoryStream(); + + try + { + ClientWebSocket? ws; + lock (_syncRoot) + { + ws = _ws; + } + + if (ws is null || ws.State != WebSocketState.Open) + { + // Attempt reconnection if not intentionally cancelled + if (!cancellationToken.IsCancellationRequested) + { + await AttemptReconnectAsync(cancellationToken).ConfigureAwait(false); + } + break; + } + + do + { + result = await ws.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + break; + } + + ms.Write(buffer, 0, result.Count); + } while (!result.EndOfMessage); + + if (result is null || result.MessageType != WebSocketMessageType.Text) + { + continue; + } + + // Reset reconnect counter on successful message + _reconnectAttempts = 0; + + ms.Position = 0; + using var reader = new StreamReader(ms, Encoding.UTF8, leaveOpen: false); + var json = await reader.ReadToEndAsync().ConfigureAwait(false); + HandleTelemetryMessage(json); + } + catch (OperationCanceledException) + { + break; + } + catch (WebSocketException) + { + // WebSocket error — attempt reconnection + if (!cancellationToken.IsCancellationRequested) + { + await AttemptReconnectAsync(cancellationToken).ConfigureAwait(false); + } + break; + } + catch + { + // swallow other receive loop errors + } + finally + { + ms.Dispose(); + } + } + } + + /// + /// Exponential backoff reconnection — inspired by BambuCAM/BambuFarm resilience patterns. + /// + private async Task AttemptReconnectAsync(CancellationToken cancellationToken) + { + if (_reconnectAttempts >= MaxReconnectAttempts) return; + if (string.IsNullOrWhiteSpace(_accessCode) || string.IsNullOrWhiteSpace(_serial) || _httpBaseUri is null) return; + + _reconnectAttempts++; + var delay = BaseReconnectDelayMs * (int)Math.Pow(2, _reconnectAttempts - 1); + + try + { + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + var reconnected = await ConnectMqttAsync(_httpBaseUri, _accessCode, _serial, cancellationToken).ConfigureAwait(false); + if (!reconnected && _reconnectAttempts >= MaxReconnectAttempts) + { + // Give up — mark as disconnected + IsConnected = false; + RaiseConnectionChanged(); + } + } + catch (OperationCanceledException) + { + // Clean shutdown — don't retry + } + } + + private void HandleTelemetryMessage(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("print", out var print)) + { + if (print.TryGetProperty("stg", out var stateElement) && stateElement.ValueKind == JsonValueKind.String) + { + var s = stateElement.GetString(); + LastTelemetry.Status = s?.ToLowerInvariant() switch + { + "printing" => PrinterStatus.Printing, + "paused" => PrinterStatus.Paused, + "finish" or "idle" => PrinterStatus.Connected, + "error" => PrinterStatus.Error, + _ => LastTelemetry.Status + }; + } + + if (print.TryGetProperty("bed_temp", out var bedTemp)) + { + LastTelemetry.BedTemp = bedTemp.GetDouble(); + } + + if (print.TryGetProperty("bed_target_temp", out var bedTarget)) + { + LastTelemetry.BedTarget = bedTarget.GetDouble(); + } + + if (print.TryGetProperty("nozzle_temp", out var nozzleTemp)) + { + LastTelemetry.HotendTemp = nozzleTemp.GetDouble(); + } + + if (print.TryGetProperty("nozzle_target_temp", out var nozzleTarget)) + { + LastTelemetry.HotendTarget = nozzleTarget.GetDouble(); + } + + if (print.TryGetProperty("fan_speed", out var fanSpeed)) + { + LastTelemetry.FanSpeed = fanSpeed.GetInt32(); + } + + // Progress percentage (BambuFarm pattern) + if (print.TryGetProperty("mc_percent", out var progressEl) && + progressEl.ValueKind == JsonValueKind.Number) + { + LastTelemetry.SDCard.Progress = progressEl.GetDouble(); + LastTelemetry.SDCard.Printing = LastTelemetry.Status == PrinterStatus.Printing; + } + + // Print time remaining (seconds) + if (print.TryGetProperty("mc_remaining_time", out var remainEl) && + remainEl.ValueKind == JsonValueKind.Number) + { + // Store as printTime info in LastResponse for now + } + + // Printer name from telemetry (BambuCAM pattern) + if (print.TryGetProperty("printer_name", out var nameEl) && + nameEl.ValueKind == JsonValueKind.String) + { + var name = nameEl.GetString(); + if (!string.IsNullOrWhiteSpace(name)) + { + LastTelemetry.PrinterName = name; + ConnectionName = name; + } + } + + // Camera URL from device report (BambuCAM pattern) + if (print.TryGetProperty("ipcam", out var ipcam)) + { + if (ipcam.TryGetProperty("rtsp_url", out var rtsp) && + rtsp.ValueKind == JsonValueKind.String) + { + _cameraStreamUrl = rtsp.GetString(); + } + else if (ipcam.TryGetProperty("tutk_server", out var tutk) && + tutk.ValueKind == JsonValueKind.String) + { + _cameraStreamUrl = tutk.GetString(); + } + } + + // IsPrinting flag sync + IsPrinting = LastTelemetry.Status == PrinterStatus.Printing; + } + + LastTelemetry.LastResponse = "BambuLab telemetry update"; + RaiseTelemetryUpdated(); + } + catch + { + // ignore malformed telemetry + } + } + + private async Task SafeTelemetryAsync() + { + try + { + await GetPrinterTelemetryAsync().ConfigureAwait(false); + } + catch + { + } + } + + public async Task DisconnectAsync() + { + updateTimer.Stop(); + _cts.Cancel(); + + lock (_syncRoot) + { + _ws?.Abort(); + _ws?.Dispose(); + _ws = null; + } + + try + { + if (_receiveLoopTask is not null) + { + await _receiveLoopTask.ConfigureAwait(false); + } + } + catch + { + } + + _httpClient?.CancelPendingRequests(); + IsConnected = false; + RaiseConnectionChanged(); + } + + public Task WriteDataAsync(string command) + { + return Task.CompletedTask; + } + + public async Task GetPrinterTelemetryAsync() + { + if (!IsConnected || _httpBaseUri is null) + { + return LastTelemetry; + } + + try + { + using var response = await Client.GetAsync("/api/v1/printer/print", _cts.Token).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + return LastTelemetry; + } + + var json = await response.Content.ReadAsStringAsync(_cts.Token).ConfigureAwait(false); + HandleTelemetryMessage(json); + } + catch + { + } + + return LastTelemetry; + } + + public Task> GetFilesAsync() + { + return Task.FromResult(new List()); + } + + private Task SendCommandAsync(string name, object payload) + { + var envelope = JsonSerializer.Serialize(new + { + cmd = name, + param = payload + }); + + return SendRawAsync(envelope, _cts.Token); + } + + public Task SetHotendTemp(int targetTemp = 0) => + SendCommandAsync("set_nozzle_temp", new { target = targetTemp }); + + public Task SetBedTemp(int targetTemp = 0) => + SendCommandAsync("set_bed_temp", new { target = targetTemp }); + + public Task Home(bool x = true, bool y = true, bool z = true) => + SendCommandAsync("home", new { x, y, z }); + + public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) => + SendCommandAsync("move_relative", new { feedRate, x, y, z, e }); + + public Task SetFanSpeed(int fanSpeedPercentage = 0) => + SendCommandAsync("set_fan_speed", new { speed = fanSpeedPercentage }); + + public Task SetPrintSpeed(int speed) => + SendCommandAsync("set_print_speed", new { speed }); + + public Task SetPrintFlow(int flow) => + SendCommandAsync("set_print_flow", new { flow }); + + public Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) => + SendCommandAsync("set_axis_per_unit", new { x, y, z, e }); + + public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) => + SendCommandAsync("run_pid_tuning", new { cycles, targetTemp, extruderIndex }); + + public Task RunThermalModelCalibration(int cycles, int targetTemp) => + SendCommandAsync("run_thermal_model_calibration", new { cycles, targetTemp }); + + public Task StartPrint(FileEntry file) => + SendCommandAsync("start_print_file", new { path = file.FullPath }); + + public Task StartPrint(GCodeDoc gcodeDoc) + { + if (!IsConnected || string.IsNullOrEmpty(gcodeDoc.Content)) + { + return Task.CompletedTask; + } + + return Task.Run(async () => + { + await foreach (var command in gcodeDoc.EnumerateCommandsAsync(_cts.Token)) + { + if (!IsConnected) break; + await SendCommandAsync("gcode_line", new { command }); + } + }); + } + + public Task SaveEEPROM() => + SendCommandAsync("save_eeprom", new { }); + + /// + /// Returns camera info if a stream URL was discovered via MQTT telemetry. + /// + public Task> GetCamerasAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_cameraStreamUrl)) + return Task.FromResult((IReadOnlyList)Array.Empty()); + + IReadOnlyList cameras = new[] + { + new PrinterCamera + { + Id = $"bambu-{_serial ?? "cam"}", + DisplayName = $"{ConnectionName} Camera", + StreamUrl = _cameraStreamUrl, + IsEnabled = true + } + }; + return Task.FromResult(cameras); + } + + public override ValueTask DisposeAsync() + { + if (_disposed) return ValueTask.CompletedTask; + _disposed = true; + + _cts.Cancel(); + updateTimer.Dispose(); + + lock (_syncRoot) + { + _ws?.Abort(); + _ws?.Dispose(); + _ws = null; + } + + _httpClient?.Dispose(); + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } + + private void ConfigureClient(ApiConnectionSettings settings) + { + var client = Client; + client.BaseAddress = _httpBaseUri; + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (!string.IsNullOrEmpty(settings.Password)) + { + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", settings.Password); + } + } +} diff --git a/src/MakerPrompt.UI.Components/Services/CameraProxyService.cs b/src/MakerPrompt.UI.Components/Services/CameraProxyService.cs new file mode 100644 index 0000000..559b7b1 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/CameraProxyService.cs @@ -0,0 +1,32 @@ +namespace MakerPrompt.UI.Components.Services +{ + /// + /// Abstracts camera frame fetching. + /// Blazor WASM: browser fetches the img src directly (no proxy needed). + /// MAUI: WebView can't load cross-origin http:// img tags — native HttpClient + /// fetches the bytes and returns a base64 data URL instead. + /// + public interface ICameraProxyService + { + /// True on platforms where the WebView blocks direct img src URLs. + bool NativeProxyRequired { get; } + + /// + /// Fetches a camera snapshot and returns a base64 data URL, + /// or null if the proxy is not required / fetch failed. + /// + Task FetchSnapshotAsDataUrlAsync(string url, CancellationToken ct = default); + } + + /// + /// Default (Blazor WASM) implementation — passthrough. + /// The browser handles img src loading natively. + /// + public sealed class PassthroughCameraProxyService : ICameraProxyService + { + public bool NativeProxyRequired => false; + + public Task FetchSnapshotAsDataUrlAsync(string url, CancellationToken ct = default) + => Task.FromResult(null); + } +} diff --git a/src/MakerPrompt.UI.Components/Services/ConnectionEncryptionService.cs b/src/MakerPrompt.UI.Components/Services/ConnectionEncryptionService.cs new file mode 100644 index 0000000..a67e9a0 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/ConnectionEncryptionService.cs @@ -0,0 +1,145 @@ +using System.Runtime.Versioning; +using System.Security.Cryptography; + +namespace MakerPrompt.UI.Components.Services +{ + /// + /// Provides AES-256-GCM encryption for sensitive connection data (API keys, passwords). + /// The key is derived from a device-stable identifier via PBKDF2. + /// + public interface IConnectionEncryptionService + { + string Encrypt(string plainText); + string Decrypt(string cipherText); + } + + /// + /// AES-256-GCM implementation. Uses a salt + nonce prepended to the ciphertext. + /// Format: Base64( salt[16] | nonce[12] | tag[16] | ciphertext ) + /// Only used on native platforms (MAUI). Browser/WASM uses Base64ConnectionEncryptionService. + /// + [UnsupportedOSPlatform("browser")] + public sealed class AesConnectionEncryptionService : IConnectionEncryptionService + { + private const int SaltSize = 16; + private const int NonceSize = 12; // AES-GCM standard + private const int TagSize = 16; // AES-GCM standard + private const int KeySize = 32; // 256-bit + private const int Iterations = 100_000; + + private readonly byte[] _masterKey; + + public AesConnectionEncryptionService(string deviceIdentifier) + { + if (string.IsNullOrWhiteSpace(deviceIdentifier)) + throw new ArgumentException("Device identifier is required for encryption key derivation.", nameof(deviceIdentifier)); + + // Derive a stable master key from the device identifier + // We use a fixed application salt so the same device always produces the same key + var appSalt = "MakerPrompt.ConnectionStore.v1"u8.ToArray(); + _masterKey = Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(deviceIdentifier), + appSalt, + Iterations, + HashAlgorithmName.SHA256, + KeySize); + } + + public string Encrypt(string plainText) + { + if (string.IsNullOrEmpty(plainText)) + return string.Empty; + + var plainBytes = Encoding.UTF8.GetBytes(plainText); + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + + // Derive per-message key from master key + salt + var key = Rfc2898DeriveBytes.Pbkdf2(_masterKey, salt, Iterations, HashAlgorithmName.SHA256, KeySize); + + var cipherText = new byte[plainBytes.Length]; + var tag = new byte[TagSize]; + + using var aes = new AesGcm(key, TagSize); + aes.Encrypt(nonce, plainBytes, cipherText, tag); + + // Pack: salt | nonce | tag | ciphertext + var result = new byte[SaltSize + NonceSize + TagSize + cipherText.Length]; + Buffer.BlockCopy(salt, 0, result, 0, SaltSize); + Buffer.BlockCopy(nonce, 0, result, SaltSize, NonceSize); + Buffer.BlockCopy(tag, 0, result, SaltSize + NonceSize, TagSize); + Buffer.BlockCopy(cipherText, 0, result, SaltSize + NonceSize + TagSize, cipherText.Length); + + return Convert.ToBase64String(result); + } + + public string Decrypt(string cipherText) + { + if (string.IsNullOrEmpty(cipherText)) + return string.Empty; + + byte[] packed; + try + { + packed = Convert.FromBase64String(cipherText); + } + catch (FormatException) + { + // If it's not valid Base64, return as-is (plaintext migration path) + return cipherText; + } + + if (packed.Length < SaltSize + NonceSize + TagSize) + { + // Too short to be encrypted — return as-is for backward compatibility + return cipherText; + } + + var salt = new byte[SaltSize]; + var nonce = new byte[NonceSize]; + var tag = new byte[TagSize]; + var encryptedBytes = new byte[packed.Length - SaltSize - NonceSize - TagSize]; + + Buffer.BlockCopy(packed, 0, salt, 0, SaltSize); + Buffer.BlockCopy(packed, SaltSize, nonce, 0, NonceSize); + Buffer.BlockCopy(packed, SaltSize + NonceSize, tag, 0, TagSize); + Buffer.BlockCopy(packed, SaltSize + NonceSize + TagSize, encryptedBytes, 0, encryptedBytes.Length); + + var key = Rfc2898DeriveBytes.Pbkdf2(_masterKey, salt, Iterations, HashAlgorithmName.SHA256, KeySize); + + var plainBytes = new byte[encryptedBytes.Length]; + using var aes = new AesGcm(key, TagSize); + aes.Decrypt(nonce, encryptedBytes, tag, plainBytes); + + return Encoding.UTF8.GetString(plainBytes); + } + } + + /// + /// No-op encryption for platforms that don't support AES-GCM (e.g. Blazor WASM browser). + /// Uses Base64 encoding only — not secure, but prevents casual reading. + /// + public sealed class Base64ConnectionEncryptionService : IConnectionEncryptionService + { + public string Encrypt(string plainText) + { + if (string.IsNullOrEmpty(plainText)) + return string.Empty; + return Convert.ToBase64String(Encoding.UTF8.GetBytes(plainText)); + } + + public string Decrypt(string cipherText) + { + if (string.IsNullOrEmpty(cipherText)) + return string.Empty; + try + { + return Encoding.UTF8.GetString(Convert.FromBase64String(cipherText)); + } + catch (FormatException) + { + return cipherText; + } + } + } +} diff --git a/src/MakerPrompt.UI.Components/Services/DemoPrinterService.cs b/src/MakerPrompt.UI.Components/Services/DemoPrinterService.cs new file mode 100644 index 0000000..2fb3fbe --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/DemoPrinterService.cs @@ -0,0 +1,285 @@ +namespace MakerPrompt.UI.Components.Services +{ + public class DemoPrinterService : BasePrinterConnectionService, IPrinterCommunicationService + { + private readonly List files = []; + private readonly Dictionary fileContents = []; + private double _hotendTarget = 0; + private double _bedTarget = 0; + private double _hotendTemp = 25; + private double _bedTemp = 25; + private int _fanSpeed = 0; + private int _feedRate = 100; + private int _flowRate = 100; + private Vector3 _position = new(0, 0, 0); + + public override PrinterConnectionType ConnectionType => PrinterConnectionType.Demo; + + public DemoPrinterService() + { + ConnectionName = "Demo 3D Printer"; + updateTimer.Elapsed += (s, e) => SimulateTelemetry(); + } + + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + { + IsConnected = true; + LastTelemetry = new PrinterTelemetry + { + PrinterName = "Demo 3D Printer", + ConnectionTime = DateTime.Now, + HotendTemp = _hotendTemp, + HotendTarget = _hotendTarget, + BedTemp = _bedTemp, + BedTarget = _bedTarget, + Position = _position, + Status = PrinterStatus.Connected, + FeedRate = _feedRate, + FlowRate = _flowRate, + FanSpeed = _fanSpeed + }; + RaiseConnectionChanged(); + updateTimer.Start(); + await Task.Delay(300); + RaiseTelemetryUpdated(); + return true; + } + + public async Task DisconnectAsync() + { + updateTimer.Stop(); + IsConnected = false; + LastTelemetry.Status = PrinterStatus.Disconnected; + RaiseConnectionChanged(); + await Task.Delay(100); + RaiseTelemetryUpdated(); + } + + public async Task WriteDataAsync(string command) + { + LastTelemetry.LastResponse = $"Received command: {command}"; + RaiseTelemetryUpdated(); + await Task.Delay(50); + } + + public async Task GetPrinterTelemetryAsync() + { + await Task.Delay(50); + return LastTelemetry; + } + + public async Task> GetFilesAsync() + { + await Task.Delay(100); + return + [ + new() { FullPath = "/gcodes/DemoCube.gcode", Size = 123456, ModifiedDate = DateTime.Now.AddDays(-1), IsAvailable = true }, + new() { FullPath = "/gcodes/Benchy.gcode", Size = 654321, ModifiedDate = DateTime.Now.AddDays(-2), IsAvailable = true } + ]; + } + + public async Task SetHotendTemp(int targetTemp = 0) + { + _hotendTarget = Math.Clamp(targetTemp, 0, 300); + LastTelemetry.HotendTarget = _hotendTarget; + LastTelemetry.LastResponse = $"Set hotend target to {_hotendTarget}C"; + RaiseTelemetryUpdated(); + await Task.Delay(50); + } + + public async Task SetBedTemp(int targetTemp = 0) + { + _bedTarget = Math.Clamp(targetTemp, 0, 120); + LastTelemetry.BedTarget = _bedTarget; + LastTelemetry.LastResponse = $"Set bed target to {_bedTarget}C"; + RaiseTelemetryUpdated(); + await Task.Delay(50); + } + + public async Task Home(bool x = true, bool y = true, bool z = true) + { + if (x) _position.X = 0; + if (y) _position.Y = 0; + if (z) _position.Z = 0; + LastTelemetry.Position = _position; + LastTelemetry.LastResponse = "Homed axes"; + RaiseTelemetryUpdated(); + await Task.Delay(100); + } + + public async Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) + { + _position += new Vector3(x, y, z); + LastTelemetry.Position = _position; + LastTelemetry.LastResponse = $"Moved to X:{_position.X:0.0} Y:{_position.Y:0.0} Z:{_position.Z:0.0}"; + RaiseTelemetryUpdated(); + await Task.Delay(100); + } + + public async Task SetFanSpeed(int fanSpeedPercentage = 0) + { + _fanSpeed = Math.Clamp(fanSpeedPercentage, 0, 100); + LastTelemetry.FanSpeed = _fanSpeed; + LastTelemetry.LastResponse = $"Set fan speed to {_fanSpeed}%"; + RaiseTelemetryUpdated(); + await Task.Delay(50); + } + + public async Task SetPrintSpeed(int speed) + { + _feedRate = Math.Clamp(speed, 1, 200); + LastTelemetry.FeedRate = _feedRate; + LastTelemetry.LastResponse = $"Set print speed to {_feedRate}%"; + RaiseTelemetryUpdated(); + await Task.Delay(50); + } + + public async Task SetPrintFlow(int flow) + { + _flowRate = Math.Clamp(flow, 1, 200); + LastTelemetry.FlowRate = _flowRate; + LastTelemetry.LastResponse = $"Set print flow to {_flowRate}%"; + RaiseTelemetryUpdated(); + await Task.Delay(50); + } + + public async Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) + { + LastTelemetry.LastResponse = $"Set axis per unit: X={x}, Y={y}, Z={z}, E={e}"; + RaiseTelemetryUpdated(); + await Task.Delay(50); + } + + public async Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) + { + LastTelemetry.LastResponse = $"PID tuning started: cycles={cycles}, target={targetTemp}, extruder={extruderIndex}"; + RaiseTelemetryUpdated(); + await Task.Delay(500); + LastTelemetry.LastResponse = $"PID tuning complete: Kp=22.2 Ki=1.08 Kd=114"; + RaiseTelemetryUpdated(); + } + + public async Task RunThermalModelCalibration(int cycles, int targetTemp) + { + LastTelemetry.LastResponse = $"Thermal model calibration started: cycles={cycles}, target={targetTemp}"; + RaiseTelemetryUpdated(); + await Task.Delay(500); + LastTelemetry.LastResponse = $"Thermal model calibration complete: Model=OK"; + RaiseTelemetryUpdated(); + } + + public async Task SaveEEPROM() + { + LastTelemetry.LastResponse = "EEPROM saved"; + RaiseTelemetryUpdated(); + await Task.Delay(100); + } + + public async Task StartPrint(FileEntry file) + { + // Simulate starting a print job in demo mode + if (file == null) + { + LastTelemetry.LastResponse = "No file selected to print."; + RaiseTelemetryUpdated(); + return; + } + + LastTelemetry.LastResponse = $"Started print job: {file.FullPath}"; + LastTelemetry.Status = PrinterStatus.Printing; + RaiseTelemetryUpdated(); + + // Simulate print duration + await Task.Delay(1000); + + LastTelemetry.LastResponse = $"Print job completed: {file.FullPath}"; + LastTelemetry.Status = PrinterStatus.Connected; + RaiseTelemetryUpdated(); + } + + public Task StartPrint(GCodeDoc gcodeDoc) + { + // For the demo printer, just log that we would print the provided G-code. + LastTelemetry.LastResponse = string.IsNullOrWhiteSpace(gcodeDoc.Content) + ? "No G-code loaded to print." + : "Simulated print from in-memory G-code document started."; + RaiseTelemetryUpdated(); + return Task.CompletedTask; + } + + public Task SaveFileAsync(string fullPath, Stream content) + { + using var ms = new MemoryStream(); + content.CopyTo(ms); + var bytes = ms.ToArray(); + fileContents[fullPath] = bytes; + var entry = files.FirstOrDefault(f => f.FullPath == fullPath); + if (entry == null) + { + files.Add(new FileEntry + { + FullPath = fullPath, + Size = bytes.Length, + ModifiedDate = DateTime.Now, + IsAvailable = true + }); + } + else + { + entry.Size = bytes.Length; + entry.ModifiedDate = DateTime.Now; + } + return Task.CompletedTask; + } + + public Task DeleteFileAsync(string fullPath) + { + files.RemoveAll(f => f.FullPath == fullPath); + fileContents.Remove(fullPath); + return Task.CompletedTask; + } + + public Task OpenReadAsync(string fullPath) + { + if (fileContents.TryGetValue(fullPath, out var bytes)) + { + return Task.FromResult(new MemoryStream(bytes)); + } + return Task.FromResult(null); + } + + public override ValueTask DisposeAsync() + { + updateTimer.Stop(); + files.Clear(); + fileContents.Clear(); + return ValueTask.CompletedTask; + } + + private void SimulateTelemetry() + { + // Simulate hotend heating/cooling + if (Math.Abs(_hotendTemp - _hotendTarget) > 0.1) + { + if (_hotendTemp < _hotendTarget) + _hotendTemp += Math.Min(2.0, _hotendTarget - _hotendTemp); + else + _hotendTemp -= Math.Min(1.0, _hotendTemp - _hotendTarget); + } + + // Simulate bed heating/cooling + if (Math.Abs(_bedTemp - _bedTarget) > 0.1) + { + if (_bedTemp < _bedTarget) + _bedTemp += Math.Min(1.0, _bedTarget - _bedTemp); + else + _bedTemp -= Math.Min(0.5, _bedTemp - _bedTarget); + } + + LastTelemetry.HotendTemp = Math.Round(_hotendTemp, 1); + LastTelemetry.BedTemp = Math.Round(_bedTemp, 1); + LastTelemetry.Status = IsConnected ? PrinterStatus.Connected : PrinterStatus.Disconnected; + RaiseTelemetryUpdated(); + } + } +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/Services/FarmConfigurationService.cs b/src/MakerPrompt.UI.Components/Services/FarmConfigurationService.cs new file mode 100644 index 0000000..879a0c2 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/FarmConfigurationService.cs @@ -0,0 +1,219 @@ +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.UI.Components.Services +{ + /// + /// Manages multiple farm configurations. Supports creating, switching, importing, + /// and exporting farm profiles. Each farm stores a snapshot of printer connection + /// definitions that are loaded into when active. + /// + public sealed class FarmConfigurationService + { + private const string StorageKey = "MakerPrompt.FarmConfigurations"; + private const string PrinterStorageKey = "MakerPrompt.PrinterConnections"; + + private readonly IAppLocalStorageProvider _storage; + private readonly IAppConfigurationService _configService; + private readonly PrinterConnectionManager _connectionManager; + private readonly ILogger _logger; + private List _farms = []; + + public IReadOnlyList Farms => _farms.AsReadOnly(); + + public FarmConfiguration? ActiveFarm => + _farms.FirstOrDefault(f => f.Id == _configService.Configuration.ActiveFarmId); + + public event EventHandler? FarmsChanged; + + public FarmConfigurationService( + IAppLocalStorageProvider storage, + IAppConfigurationService configService, + PrinterConnectionManager connectionManager, + ILogger logger) + { + _storage = storage; + _configService = configService; + _connectionManager = connectionManager; + _logger = logger; + } + + public async Task InitializeAsync() + { + try + { + _farms = await LoadFarmsAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load farm configurations"); + _farms = []; + } + } + + public async Task CreateFarmAsync(string name) + { + var farm = new FarmConfiguration { Name = name }; + _farms.Add(farm); + await SaveFarmsAsync(); + FarmsChanged?.Invoke(this, EventArgs.Empty); + return farm; + } + + public async Task UpdateFarmNameAsync(Guid farmId, string name) + { + var farm = _farms.FirstOrDefault(f => f.Id == farmId); + if (farm == null) return; + farm.Name = name; + await SaveFarmsAsync(); + + if (_configService.Configuration.ActiveFarmId == farmId) + { + _configService.Configuration.FarmName = name; + await _configService.SaveConfigurationAsync(); + } + + FarmsChanged?.Invoke(this, EventArgs.Empty); + } + + public async Task DeleteFarmAsync(Guid farmId) + { + _farms.RemoveAll(f => f.Id == farmId); + + if (_configService.Configuration.ActiveFarmId == farmId) + { + _configService.Configuration.ActiveFarmId = _farms.FirstOrDefault()?.Id; + _configService.Configuration.FarmName = _farms.FirstOrDefault()?.Name ?? string.Empty; + await _configService.SaveConfigurationAsync(); + } + + await SaveFarmsAsync(); + FarmsChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// Switches the active farm. Saves current printers to the outgoing farm, + /// writes the new farm's printers to storage, and reloads the connection manager. + /// + public async Task SwitchFarmAsync(Guid farmId) + { + var newFarm = _farms.FirstOrDefault(f => f.Id == farmId); + if (newFarm == null) return; + + // Save current printers to current farm + var currentFarm = ActiveFarm; + if (currentFarm != null) + { + currentFarm.Printers = _connectionManager.Printers + .Select(p => CloneDefinition(p.Definition)) + .ToList(); + } + + // Update config + _configService.Configuration.ActiveFarmId = farmId; + _configService.Configuration.FarmName = newFarm.Name; + await _configService.SaveConfigurationAsync(); + + // Write new farm's printers to printer connection storage + var json = JsonSerializer.Serialize(newFarm.Printers, new JsonSerializerOptions { WriteIndented = true }); + var bytes = Encoding.UTF8.GetBytes(json); + using var stream = new MemoryStream(bytes); + await _storage.SaveFileAsync(PrinterStorageKey, stream); + + // Reload connection manager with new printers + await _connectionManager.ReloadAsync(); + + await SaveFarmsAsync(); + FarmsChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// Clears all printer connections from storage and reloads the connection manager. + /// Called when farm mode is disabled to remove printers that were loaded from a farm. + /// + public async Task ClearPrinterConnectionsAsync() + { + var bytes = Encoding.UTF8.GetBytes("[]"); + using var stream = new MemoryStream(bytes); + await _storage.SaveFileAsync(PrinterStorageKey, stream); + await _connectionManager.ReloadAsync(); + } + + /// + /// Exports a farm configuration as a JSON string suitable for saving to a file. + /// + public string ExportFarm(Guid farmId) + { + var farm = _farms.FirstOrDefault(f => f.Id == farmId); + if (farm == null) return "{}"; + + // Snapshot current printers if exporting the active farm + if (farm.Id == _configService.Configuration.ActiveFarmId) + { + farm.Printers = _connectionManager.Printers + .Select(p => CloneDefinition(p.Definition)) + .ToList(); + } + + return JsonSerializer.Serialize(farm, new JsonSerializerOptions { WriteIndented = true }); + } + + /// + /// Imports a farm configuration from a JSON string. Assigns a new ID to avoid collisions. + /// + public async Task ImportFarmAsync(string json) + { + var farm = JsonSerializer.Deserialize(json) + ?? throw new InvalidOperationException("Invalid farm configuration data."); + + farm.Id = Guid.NewGuid(); + farm.CreatedAt = DateTime.UtcNow; + _farms.Add(farm); + await SaveFarmsAsync(); + FarmsChanged?.Invoke(this, EventArgs.Empty); + return farm; + } + + private async Task> LoadFarmsAsync() + { + try + { + var files = await _storage.ListFilesAsync(); + var file = files.FirstOrDefault(f => f.FullPath.Contains(StorageKey)); + if (file == null) return []; + + using var stream = await _storage.OpenReadAsync(file.FullPath); + if (stream == null) return []; + + using var reader = new StreamReader(stream); + var json = await reader.ReadToEndAsync(); + return JsonSerializer.Deserialize>(json) ?? []; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load farm configurations"); + return []; + } + } + + private async Task SaveFarmsAsync() + { + try + { + var json = JsonSerializer.Serialize(_farms, new JsonSerializerOptions { WriteIndented = true }); + var bytes = Encoding.UTF8.GetBytes(json); + using var stream = new MemoryStream(bytes); + await _storage.SaveFileAsync(StorageKey, stream); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save farm configurations"); + } + } + + private static PrinterConnectionDefinition CloneDefinition(PrinterConnectionDefinition original) + { + var json = JsonSerializer.Serialize(original); + return JsonSerializer.Deserialize(json)!; + } + } +} diff --git a/src/MakerPrompt.UI.Components/Services/FilamentInventoryService.cs b/src/MakerPrompt.UI.Components/Services/FilamentInventoryService.cs new file mode 100644 index 0000000..8bff42c --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/FilamentInventoryService.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.UI.Components.Services +{ + public class FilamentInventoryService + { + private const string StorageKey = "MakerPrompt.FilamentInventory.json"; + private readonly IAppLocalStorageProvider _storage; + private readonly ILogger _logger; + private readonly SemaphoreSlim _lock = new(1, 1); + private List _spools = []; + + public event EventHandler? InventoryChanged; + + public FilamentInventoryService(IAppLocalStorageProvider storage, ILogger logger) + { + _storage = storage; + _logger = logger; + } + + public async Task InitializeAsync() + { + await _lock.WaitAsync(); + try + { + var files = await _storage.ListFilesAsync(); + var file = files.FirstOrDefault(f => f.FullPath.Contains(StorageKey)); + if (file != null) + { + using var stream = await _storage.OpenReadAsync(file.FullPath); + if (stream != null) + { + using var reader = new StreamReader(stream); + var json = await reader.ReadToEndAsync(); + var stored = JsonSerializer.Deserialize>(json); + if (stored != null) + { + _spools = stored; + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load filament inventory"); + } + finally + { + _lock.Release(); + } + } + + public IReadOnlyList GetSpools() => _spools.AsReadOnly(); + + public FilamentSpool? GetSpool(Guid id) => _spools.FirstOrDefault(s => s.Id == id); + + public async Task AddSpoolAsync(FilamentSpool spool) + { + await _lock.WaitAsync(); + try + { + _spools.Add(spool); + await SaveAsync(); + } + finally + { + _lock.Release(); + } + InventoryChanged?.Invoke(this, EventArgs.Empty); + } + + public async Task UpdateSpoolAsync(FilamentSpool spool) + { + await _lock.WaitAsync(); + try + { + var index = _spools.FindIndex(s => s.Id == spool.Id); + if (index >= 0) + { + _spools[index] = spool; + await SaveAsync(); + } + } + finally + { + _lock.Release(); + } + InventoryChanged?.Invoke(this, EventArgs.Empty); + } + + public async Task DeleteSpoolAsync(Guid id) + { + await _lock.WaitAsync(); + try + { + var index = _spools.FindIndex(s => s.Id == id); + if (index >= 0) + { + _spools.RemoveAt(index); + await SaveAsync(); + } + } + finally + { + _lock.Release(); + } + InventoryChanged?.Invoke(this, EventArgs.Empty); + } + + public async Task DeductFilamentAsync(Guid spoolId, double grams) + { + await _lock.WaitAsync(); + try + { + var spool = _spools.FirstOrDefault(s => s.Id == spoolId); + if (spool != null) + { + spool.RemainingWeightGrams = Math.Max(0, spool.RemainingWeightGrams - grams); + await SaveAsync(); + } + } + finally + { + _lock.Release(); + } + InventoryChanged?.Invoke(this, EventArgs.Empty); + } + + private async Task SaveAsync() + { + var json = JsonSerializer.Serialize(_spools); + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + await _storage.SaveFileAsync(StorageKey, stream); + } + } +} diff --git a/src/MakerPrompt.UI.Components/Services/GCodeDocumentService.cs b/src/MakerPrompt.UI.Components/Services/GCodeDocumentService.cs new file mode 100644 index 0000000..5dc1f49 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/GCodeDocumentService.cs @@ -0,0 +1,50 @@ +using System.Runtime.CompilerServices; + +namespace MakerPrompt.UI.Components.Services +{ + // Simple wrapper around the current G-code text; can be extended later to expose parsed structures + public class GCodeDocumentService + { + private string? _current; + public string? CurrentGCode => _current; + public event Action? Changed; + + // Expose a lightweight document wrapper for higher-level APIs + public GCodeDoc Document => new(_current ?? string.Empty); + + public void SetGCode(string? gcode) + { + _current = gcode ?? string.Empty; + Changed?.Invoke(); + } + + public void Clear() + { + _current = string.Empty; + Changed?.Invoke(); + } + } + + public readonly record struct GCodeDoc(string Content) + { + // Async, streaming enumeration of non-empty, non-comment commands. + public async IAsyncEnumerable EnumerateCommandsAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(Content)) yield break; + + using var reader = new StringReader(Content); + string? line; + + while (!cancellationToken.IsCancellationRequested && + (line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) + { + line = line.Trim(); + if (string.IsNullOrEmpty(line) || line.StartsWith(";", StringComparison.Ordinal)) + continue; + + yield return line; + } + } + } +} diff --git a/src/MakerPrompt.UI.Components/Services/LocalizedTitleService.cs b/src/MakerPrompt.UI.Components/Services/LocalizedTitleService.cs new file mode 100644 index 0000000..d2ce411 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/LocalizedTitleService.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Localization; + +namespace MakerPrompt.UI.Components.Services +{ + public class LocalizedTitleService : IDisposable + { + private readonly IStringLocalizer _localizer; + private string _baseTitleKey = string.Empty; + private object[] _titleArguments = Array.Empty(); + + public event Action? OnTitleChanged; + + public LocalizedTitleService(IStringLocalizer localizer) + { + _localizer = localizer; + } + + public string CurrentTitle => + string.IsNullOrEmpty(_baseTitleKey) + ? string.Empty + : _localizer[_baseTitleKey, _titleArguments]; + + public void SetTitle(string titleKey, params object[] arguments) + { + _baseTitleKey = titleKey; + _titleArguments = arguments; + OnTitleChanged?.Invoke(); + } + + public void Dispose() + { + OnTitleChanged = null; + } + } +} diff --git a/src/MakerPrompt.UI.Components/Services/MakerPromptJsInterop.cs b/src/MakerPrompt.UI.Components/Services/MakerPromptJsInterop.cs new file mode 100644 index 0000000..2c6051e --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/MakerPromptJsInterop.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Components; + +namespace MakerPrompt.UI.Components.Services +{ + public class MakerPromptJsInterop : IAsyncDisposable + { + private readonly IJSRuntime jsRuntime; + private readonly Lazy> moduleTask; + + public MakerPromptJsInterop(IJSRuntime jsRuntime) + { + this.jsRuntime = jsRuntime ?? throw new ArgumentNullException(nameof(jsRuntime)); + moduleTask = new(() => jsRuntime.InvokeAsync( + "import", "./_content/MakerPrompt.UI.Components/js/makerpromptJsInterop.js").AsTask()); + } + + public async ValueTask Prompt(string message) + { + var module = await moduleTask.Value; + return await module.InvokeAsync("showPrompt", message); + } + + public async ValueTask ScrollToBottom(ElementReference container) + { + var module = await moduleTask.Value; + await module.InvokeVoidAsync("scrollToBottom", container); + } + + public async ValueTask CopyToClipboard(string text) + { + await jsRuntime.InvokeVoidAsync("navigator.clipboard.writeText", text); + } + + public async ValueTask DownloadFileAsync(string filename, string content) + { + var module = await moduleTask.Value; + await module.InvokeVoidAsync("downloadFile", filename, content); + } + + public async ValueTask ReadFromClipboard() + { + return await jsRuntime.InvokeAsync("navigator.clipboard.readText"); + } + + // https://github.com/remcoder/gcode-preview — MIT, supports Klipper/Moonraker, PrusaLink, Cura, Marlin G-code + public async ValueTask InitializeViewerAsync(ElementReference container, string gcodeContent) + { + var module = await moduleTask.Value; + await module.InvokeVoidAsync("initializeViewer", container, gcodeContent); + } + + public async ValueTask DisposeViewerAsync(ElementReference container) + { + var module = await moduleTask.Value; + await module.InvokeVoidAsync("disposeViewer", container); + } + + public async ValueTask DisposeAsync() + { + if (moduleTask.IsValueCreated) + { + try + { + var module = await moduleTask.Value; + await module.DisposeAsync(); + } + catch + { + // ignore JS module dispose errors + } + } + } + } +} diff --git a/src/MakerPrompt.UI.Components/Services/MoonrakerApiService.cs b/src/MakerPrompt.UI.Components/Services/MoonrakerApiService.cs new file mode 100644 index 0000000..5a7d90a --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/MoonrakerApiService.cs @@ -0,0 +1,621 @@ +namespace MakerPrompt.UI.Components.Services +{ + public class MoonrakerApiService : BasePrinterConnectionService, IPrinterCommunicationService + { + private CancellationTokenSource _cts = new(); + private readonly HttpMessageHandler? _customHandler; + private HttpClient? _httpClient; + private Uri _baseUri = null!; + private string _jwtToken = string.Empty; + private string _refreshToken = string.Empty; + public override PrinterConnectionType ConnectionType { get; } = PrinterConnectionType.Moonraker; + + public MoonrakerApiService() + { + } + + public MoonrakerApiService(HttpMessageHandler handler) + { + _customHandler = handler; + _httpClient = new HttpClient(handler, false); + } + + private HttpClient Client => _httpClient ??= _customHandler != null + ? new HttpClient(_customHandler, false) + : new HttpClient(); + + private static string? NormalizeUrl(Uri baseUri, string? url) + { + if (string.IsNullOrWhiteSpace(url)) return null; + + // If already absolute HTTP/HTTPS URL, just return it + if (Uri.TryCreate(url, UriKind.Absolute, out var absolute) && + (absolute.Scheme == Uri.UriSchemeHttp || absolute.Scheme == Uri.UriSchemeHttps)) + { + return absolute.AbsoluteUri; + } + + // Only combine when baseUri is HTTP/HTTPS to avoid accidental file:// URLs + if (baseUri.Scheme == Uri.UriSchemeHttp || baseUri.Scheme == Uri.UriSchemeHttps) + { + var combined = new Uri(baseUri, url); + return combined.AbsoluteUri; + } + + // Fallback: return original string when scheme is not web-compatible + return url; + } + + private bool _telemetryTimerInitialized; + + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + { + if (IsConnected) return IsConnected; + + if (connectionSettings.ConnectionType != ConnectionType || connectionSettings.Api == null) throw new ArgumentException(); + + if (_cts.IsCancellationRequested) + { + _cts.Dispose(); + _cts = new CancellationTokenSource(); + } + + _baseUri = new Uri(connectionSettings.Api.Url); + ConfigureClient(connectionSettings.Api); + + if (!string.IsNullOrEmpty(connectionSettings.Api.UserName) && !string.IsNullOrEmpty(connectionSettings.Api.Password)) + { + IsConnected = await AuthenticateAsync(connectionSettings.Api.UserName, connectionSettings.Api.Password); + if (!IsConnected) return IsConnected; + } + + try + { + var response = await Client.GetAsync("/printer/info", _cts.Token); + IsConnected = response.IsSuccessStatusCode; + if (IsConnected) + { + if (!_telemetryTimerInitialized) + { + updateTimer.Elapsed += async (s, e) => await SafeTelemetryAsync(); + _telemetryTimerInitialized = true; + } + updateTimer.Start(); + } + ConnectionName = _baseUri.AbsoluteUri; + } + catch + { + IsConnected = false; + } + + RaiseConnectionChanged(); + return IsConnected; + } + + public async Task DisconnectAsync() + { + updateTimer.Stop(); + _cts.Cancel(); + _httpClient?.CancelPendingRequests(); + IsConnected = false; + RaiseConnectionChanged(); + await Task.CompletedTask; + } + + public async Task WriteDataAsync(string command) + { + if (!IsConnected) return; + + var response = await Client.PostAsync( + $"/printer/gcode/script?script={WebUtility.UrlEncode(command)}", + null, + _cts.Token); + + var content = await response.Content.ReadAsStringAsync(); + LastTelemetry.LastResponse = content; + RaiseTelemetryUpdated(); + } + public async Task GetPrinterTelemetryAsync() + { + if (!IsConnected) return LastTelemetry; + + // Get temperature data + try + { + var tempResponse = await Client.GetAsync("/printer/objects/query?heater_bed&extruder", _cts.Token); + if (tempResponse.IsSuccessStatusCode) + { + var json = await tempResponse.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement.GetProperty("result").GetProperty("status"); + + LastTelemetry.BedTemp = root.GetProperty("heater_bed").GetProperty("temperature").GetDouble(); + LastTelemetry.BedTarget = root.GetProperty("heater_bed").GetProperty("target").GetDouble(); + LastTelemetry.HotendTemp = root.GetProperty("extruder").GetProperty("temperature").GetDouble(); + LastTelemetry.HotendTarget = root.GetProperty("extruder").GetProperty("target").GetDouble(); + } + } + catch { } + + // Get motion and fan data (each object must be a separate query parameter) + try + { + var motionResponse = await Client.GetAsync("/printer/objects/query?gcode_move&fan", _cts.Token); + if (motionResponse.IsSuccessStatusCode) + { + var json = await motionResponse.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var status = doc.RootElement.GetProperty("result").GetProperty("status"); + + // Position data + var position = status.GetProperty("gcode_move").GetProperty("position"); + LastTelemetry.Position = new Vector3( + (float)position[0].GetDecimal(), + (float)position[1].GetDecimal(), + (float)position[2].GetDecimal() + ); + // Speed and flow data + LastTelemetry.FeedRate = (int)status.GetProperty("gcode_move") + .GetProperty("speed").GetDecimal(); + LastTelemetry.FlowRate = (int)(status.GetProperty("gcode_move") + .GetProperty("extrude_factor").GetDecimal() * 100); + + // Fan speed + LastTelemetry.FanSpeed = (int)(status.GetProperty("fan") + .GetProperty("speed").GetDecimal() * 100); + } + } + catch { } + + // Get printer status, print progress, and job info + try + { + var statusResponse = await Client.GetAsync("/printer/objects/query?print_stats&virtual_sdcard", _cts.Token); + if (statusResponse.IsSuccessStatusCode) + { + var json = await statusResponse.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var status = doc.RootElement.GetProperty("result").GetProperty("status"); + + if (status.TryGetProperty("print_stats", out var printStats)) + { + var state = printStats.TryGetProperty("state", out var stateProp) + ? stateProp.GetString() ?? "" + : ""; + LastTelemetry.Status = state switch + { + "printing" => PrinterStatus.Printing, + "paused" => PrinterStatus.Paused, + "error" => PrinterStatus.Error, + "complete" or "cancelled" or "standby" => PrinterStatus.Connected, + _ => PrinterStatus.Connected + }; + LastTelemetry.SDCard.Printing = state == "printing"; + + if (printStats.TryGetProperty("filename", out var filenameProp)) + LastTelemetry.PrintJobName = filenameProp.GetString() ?? ""; + + if (printStats.TryGetProperty("print_duration", out var durationProp)) + LastTelemetry.PrintDuration = TimeSpan.FromSeconds(durationProp.GetDouble()); + + if (printStats.TryGetProperty("filament_used", out var filamentProp)) + LastTelemetry.FilamentUsed = filamentProp.GetDouble(); + } + + if (status.TryGetProperty("virtual_sdcard", out var sdcard)) + { + if (sdcard.TryGetProperty("progress", out var progressProp)) + LastTelemetry.SDCard.Progress = progressProp.GetDouble() * 100; + } + } + } + catch { } + + RaiseTelemetryUpdated(); + return LastTelemetry; + } + + public async Task> GetCamerasAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected || _baseUri is null) + { + return Array.Empty(); + } + + try + { + using var response = await Client.GetAsync("/server/webcams/list", cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + return Array.Empty(); + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var result = await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + var webcams = result?.Result?.Webcams ?? []; + + var cameras = new List(); + foreach (var cam in webcams) + { + if (!cam.Enabled) + { + continue; + } + + var streamUrl = NormalizeUrl(_baseUri, cam.StreamUrl); + var snapshotUrl = NormalizeUrl(_baseUri, cam.SnapshotUrl); + + if (string.IsNullOrWhiteSpace(streamUrl) && string.IsNullOrWhiteSpace(snapshotUrl)) + { + continue; + } + Console.WriteLine($"[Moonraker] baseUri={_baseUri}, raw={cam.StreamUrl}, normalized={streamUrl}"); + + cameras.Add(new PrinterCamera + { + Id = string.IsNullOrWhiteSpace(cam.Uid) ? cam.Name ?? string.Empty : cam.Uid, + DisplayName = string.IsNullOrWhiteSpace(cam.Name) ? "Webcam" : cam.Name!, + StreamUrl = streamUrl, + SnapshotUrl = snapshotUrl, + IsEnabled = cam.Enabled, + Location = cam.Location + }); + } + + return cameras; + } + catch + { + // Discovery failures should not surface to the UI; absence of + // cameras simply means the webcam card is not shown. + return Array.Empty(); + } + } + + public async Task> GetFilesAsync() + { + if (!IsConnected) return []; + + var response = await Client.GetAsync( + $"/server/files/list?root=gcodes", _cts.Token); + response.EnsureSuccessStatusCode(); + var content = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); + var files = content?.Files ?? []; + return files.Select(f => new FileEntry + { + FullPath = f.Path, + Size = f.Size, + ModifiedDate = f.ModifiedDate, + IsAvailable = f.Permissions.Contains("rw"), + }).ToList(); + } + + public async Task OpenReadAsync(string fullPath, CancellationToken cancellationToken = default) + { + if (!IsConnected) return null; + if (string.IsNullOrWhiteSpace(fullPath)) return null; + + try + { + // Moonraker's file API expects: /server/files/{root}/{filename} + // We currently list from the "gcodes" root and store FileEntry.FullPath + // as the path relative to that root. + var relativePath = fullPath.TrimStart('/'); + + // If a root was accidentally included, strip a leading "gcodes/" once + if (relativePath.StartsWith("gcodes/", StringComparison.OrdinalIgnoreCase)) + { + relativePath = relativePath.Substring("gcodes/".Length); + } + + var requestUri = $"/server/files/gcodes/{relativePath}"; + + var response = await Client.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadAsStreamAsync(cancellationToken); + } + catch + { + return null; + } + } + public async Task AuthenticateAsync(string username, string password) + { + try + { + var request = new + { + username, + password, + source = "moonraker" + }; + + var json = JsonSerializer.Serialize(request); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await Client.PostAsync("/access/login", content, _cts.Token); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(); + var authResponse = JsonSerializer.Deserialize(responseJson); + + _jwtToken = authResponse?.Token ?? string.Empty; + _refreshToken = authResponse?.RefreshToken ?? string.Empty; + + if (!string.IsNullOrEmpty(_jwtToken)) + { + Client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", _jwtToken); + } + + return true; + } + catch + { + return false; + } + } + + public void Dispose() + { + updateTimer.Dispose(); + _httpClient?.Dispose(); + _cts.Dispose(); + GC.SuppressFinalize(this); + } + + public override ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + + private void ConfigureClient(ApiConnectionSettings settings) + { + var client = Client; + client.BaseAddress = _baseUri; + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (!string.IsNullOrEmpty(settings.UserName) && !string.IsNullOrEmpty(settings.Password)) + { + var credentialBytes = Encoding.ASCII.GetBytes($"{settings.UserName}:{settings.Password}"); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Basic", Convert.ToBase64String(credentialBytes)); + } + } + + private async Task SafeTelemetryAsync() + { + try + { + await GetPrinterTelemetryAsync(); + } + catch + { + // swallow background telemetry errors + } + } + + private Task SendGcodeAsync(string gcode) => WriteDataAsync(gcode); + + public Task SetHotendTemp(int targetTemp = 0) => + SendGcodeAsync($"M104 S{targetTemp}"); + + public Task SetBedTemp(int targetTemp = 0) => + SendGcodeAsync($"M140 S{targetTemp}"); + + public Task Home(bool x = true, bool y = true, bool z = true) + { + var axes = new StringBuilder(); + if (x) axes.Append(" X"); + if (y) axes.Append(" Y"); + if (z) axes.Append(" Z"); + var command = axes.Length == 0 ? "G28" : $"G28{axes}"; + return SendGcodeAsync(command); + } + + public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) + { + var sb = new StringBuilder(); + sb.Append("G91\nG1"); + if (Math.Abs(x) > 0.0001f) sb.Append($" X{x}"); + if (Math.Abs(y) > 0.0001f) sb.Append($" Y{y}"); + if (Math.Abs(z) > 0.0001f) sb.Append($" Z{z}"); + if (Math.Abs(e) > 0.0001f) sb.Append($" E{e}"); + sb.Append($" F{feedRate}\nG90"); + return SendGcodeAsync(sb.ToString()); + } + + public Task SetFanSpeed(int speed) + { + var clamped = Math.Clamp(speed, 0, 100); + var duty = (int)Math.Round(clamped * 255.0 / 100.0, MidpointRounding.AwayFromZero); + return SendGcodeAsync($"M106 S{duty}"); + } + + public Task SetPrintSpeed(int speed) + { + var clamped = Math.Clamp(speed, 1, 200); + return SendGcodeAsync($"M220 S{clamped}"); + } + + public Task SetPrintFlow(int flow) + { + var clamped = Math.Clamp(flow, 1, 200); + return SendGcodeAsync($"M221 S{clamped}"); + } + + public Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) + { + var sb = new StringBuilder("M92"); + if (x > 0) sb.Append($" X{x}"); + if (y > 0) sb.Append($" Y{y}"); + if (z > 0) sb.Append($" Z{z}"); + if (e > 0) sb.Append($" E{e}"); + return SendGcodeAsync(sb.ToString()); + } + + public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) + { + var heater = extruderIndex == 0 ? "extruder" : $"extruder{extruderIndex}"; + return SendGcodeAsync($"PID_CALIBRATE HEATER={heater} TARGET={targetTemp}"); + } + + public Task RunThermalModelCalibration(int cycles, int targetTemp) + { + // No direct Moonraker endpoint; fall back to PID tuning for the bed as closest available. + return SendGcodeAsync($"PID_CALIBRATE HEATER=heater_bed TARGET={targetTemp}"); + } + + public async Task StartPrint(FileEntry file) + { + var filename = WebUtility.UrlEncode(file.FullPath); + await Client.PostAsync($"/printer/print/start?filename={filename}", null, _cts.Token); + } + + public Task StartPrint(GCodeDoc gcodeDoc) + { + if (!IsConnected || string.IsNullOrEmpty(gcodeDoc.Content)) + { + return Task.CompletedTask; + } + + return Task.Run(async () => + { + await foreach (var command in gcodeDoc.EnumerateCommandsAsync(_cts.Token)) + { + if (!IsConnected) + { + break; + } + + await WriteDataAsync(command); + } + }); + } + + public Task SaveEEPROM() => SendGcodeAsync("SAVE_CONFIG"); + + public async Task> GetGcodeHelpAsync() + { + if (!IsConnected) + return []; + + try + { + var response = await Client.GetAsync("/printer/gcode/help", _cts.Token); + if (!response.IsSuccessStatusCode) + return []; + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("result", out var root) || + root.ValueKind != JsonValueKind.Object) + { + return []; + } + + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var prop in root.EnumerateObject()) + dict[prop.Name] = prop.Value.GetString() ?? string.Empty; + + return dict; + } + catch + { + return []; + } + } + + private record AuthResponse + { + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("token")] + public string Token { get; set; } = string.Empty; + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } = string.Empty; + + [JsonPropertyName("action")] + public string Action { get; set; } = string.Empty; + + [JsonPropertyName("source")] + public string Source { get; set; } = string.Empty; + } + + private record FileListResponse + { + [JsonPropertyName("result")] + public List Files { get; set; } = []; + } + + private record FileItem + { + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + [JsonPropertyName("modified")] + public double ModifiedSeconds { get; set; } + + [JsonIgnore] + public DateTime ModifiedDate => + DateTimeOffset.FromUnixTimeSeconds((long)ModifiedSeconds).DateTime; + + [JsonPropertyName("size")] + public long Size { get; set; } + + [JsonPropertyName("permissions")] + public string Permissions { get; set; } = string.Empty; + + [JsonIgnore] + public bool IsDirectory => Size == 0; + } + + private sealed record WebcamListResponse + { + [JsonPropertyName("result")] + public WebcamResult? Result { get; set; } + } + + private sealed record WebcamResult + { + [JsonPropertyName("webcams")] + public List Webcams { get; set; } = []; + } + + private sealed record WebcamEntry + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("location")] + public string? Location { get; set; } + + [JsonPropertyName("service")] + public string? Service { get; set; } + + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [JsonPropertyName("stream_url")] + public string? StreamUrl { get; set; } + + [JsonPropertyName("snapshot_url")] + public string? SnapshotUrl { get; set; } + + [JsonPropertyName("uid")] + public string? Uid { get; set; } + } + + } + +} diff --git a/src/MakerPrompt.UI.Components/Services/NotificationService.cs b/src/MakerPrompt.UI.Components/Services/NotificationService.cs new file mode 100644 index 0000000..2a1f14e --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/NotificationService.cs @@ -0,0 +1,116 @@ +using Microsoft.Extensions.Logging; +using BlazorBootstrap; + +namespace MakerPrompt.UI.Components.Services +{ + public class NotificationService + { + private const string StorageKey = "MakerPrompt.Notifications.json"; + private readonly IAppLocalStorageProvider _storage; + private readonly ToastService _toastService; + private readonly ILogger _logger; + private readonly SemaphoreSlim _lock = new(1, 1); + private List _notifications = []; + + public event EventHandler? NotificationsChanged; + + public NotificationService(IAppLocalStorageProvider storage, ToastService toastService, ILogger logger) + { + _storage = storage; + _toastService = toastService; + _logger = logger; + } + + public async Task InitializeAsync() + { + await _lock.WaitAsync(); + try + { + var files = await _storage.ListFilesAsync(); + var file = files.FirstOrDefault(f => f.FullPath.Contains(StorageKey)); + if (file != null) + { + using var stream = await _storage.OpenReadAsync(file.FullPath); + if (stream != null) + { + using var reader = new StreamReader(stream); + var json = await reader.ReadToEndAsync(); + var stored = JsonSerializer.Deserialize>(json); + if (stored != null) + { + _notifications = stored; + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load notifications"); + } + finally + { + _lock.Release(); + } + } + + public IReadOnlyList GetNotifications() => _notifications.AsReadOnly(); + + public async Task NotifyAsync(NotificationLevel level, string title, string message, Guid? printerId = null, Guid? filamentSpoolId = null) + { + var record = new NotificationRecord + { + Level = level, + Title = title, + Message = message, + PrinterId = printerId, + FilamentSpoolId = filamentSpoolId + }; + + await _lock.WaitAsync(); + try + { + _notifications.Add(record); + var json = JsonSerializer.Serialize(_notifications); + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + await _storage.SaveFileAsync(StorageKey, stream); + } + finally + { + _lock.Release(); + } + + var toastType = level switch + { + NotificationLevel.Info => ToastType.Info, + NotificationLevel.Warning => ToastType.Warning, + NotificationLevel.Error => ToastType.Danger, + NotificationLevel.Critical => ToastType.Danger, + _ => ToastType.Info + }; + + _toastService.Notify(new ToastMessage(toastType, title, message)); + NotificationsChanged?.Invoke(this, EventArgs.Empty); + } + + public async Task MarkAsReadAsync(Guid id) + { + await _lock.WaitAsync(); + try + { + var notification = _notifications.FirstOrDefault(n => n.Id == id); + if (notification != null) + { + notification.IsRead = true; + var json = JsonSerializer.Serialize(_notifications); + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + await _storage.SaveFileAsync(StorageKey, stream); + } + } + finally + { + _lock.Release(); + } + NotificationsChanged?.Invoke(this, EventArgs.Empty); + } + } +} diff --git a/src/MakerPrompt.UI.Components/Services/OctoPrintApiService.cs b/src/MakerPrompt.UI.Components/Services/OctoPrintApiService.cs new file mode 100644 index 0000000..f37d10a --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/OctoPrintApiService.cs @@ -0,0 +1,523 @@ +namespace MakerPrompt.UI.Components.Services; + +/// +/// OctoPrint REST API backend. +/// +/// Implements printer communication via OctoPrint's documented REST API: +/// https://docs.octoprint.org/en/master/api/ +/// +/// Follows the same HTTP/JSON polling pattern as PrusaLinkApiService. +/// Supports: +/// - Connection management (connect/disconnect) +/// - Printer status & telemetry polling +/// - Job status (progress, filename, time remaining) +/// - G-code command execution +/// - File listing and print start +/// - Webcam streams (via OctoPrint settings) +/// - Temperature control, fan, movement commands via G-code +/// +/// Authentication: X-Api-Key header (standard OctoPrint API key auth). +/// +public sealed class OctoPrintApiService : BasePrinterConnectionService, IPrinterCommunicationService +{ + private readonly CancellationTokenSource _cts = new(); + private readonly HttpMessageHandler? _customHandler; + private HttpClient? _httpClient; + private Uri? _baseUri; + private bool _telemetryTimerInitialized; + private bool _disposed; + + public override PrinterConnectionType ConnectionType => PrinterConnectionType.OctoPrint; + + public OctoPrintApiService() { } + + public OctoPrintApiService(HttpMessageHandler handler) + { + _customHandler = handler; + _httpClient = new HttpClient(handler, false); + } + + private HttpClient Client + { + get + { + _httpClient ??= _customHandler is not null + ? new HttpClient(_customHandler, false) + : new HttpClient(); + return _httpClient; + } + } + + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + { + if (IsConnected) return true; + + if (connectionSettings.Api is null) + throw new ArgumentException("OctoPrint connection requires API settings.", nameof(connectionSettings)); + + _baseUri = new Uri(connectionSettings.Api.Url); + ConfigureClient(connectionSettings.Api); + + try + { + // Verify connectivity using the version endpoint (no auth for basic check, but + // API key is required for full access) + using var response = await Client.GetAsync("/api/version", _cts.Token).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + IsConnected = false; + RaiseConnectionChanged(); + return false; + } + + var versionJson = await response.Content.ReadAsStringAsync(_cts.Token).ConfigureAwait(false); + using var versionDoc = JsonDocument.Parse(versionJson); + if (versionDoc.RootElement.TryGetProperty("text", out var textEl)) + { + ConnectionName = textEl.GetString() ?? _baseUri.AbsoluteUri; + } + else + { + ConnectionName = _baseUri.AbsoluteUri; + } + + // Also get current printer state + using var stateResponse = await Client.GetAsync("/api/connection", _cts.Token).ConfigureAwait(false); + if (stateResponse.IsSuccessStatusCode) + { + var stateJson = await stateResponse.Content.ReadAsStringAsync(_cts.Token).ConfigureAwait(false); + using var stateDoc = JsonDocument.Parse(stateJson); + if (stateDoc.RootElement.TryGetProperty("current", out var current) && + current.TryGetProperty("state", out var stateStr)) + { + var state = stateStr.GetString(); + // If OctoPrint is not connected to the printer, try connecting + if (state == "Closed" || state == "Offline") + { + await SendConnectCommandAsync().ConfigureAwait(false); + } + } + } + + IsConnected = true; + LastTelemetry.ConnectionTime = DateTime.UtcNow; + + if (!_telemetryTimerInitialized) + { + updateTimer.Elapsed += async (_, _) => await SafeTelemetryAsync().ConfigureAwait(false); + _telemetryTimerInitialized = true; + } + updateTimer.Start(); + } + catch + { + IsConnected = false; + } + + RaiseConnectionChanged(); + return IsConnected; + } + + /// + /// Sends a connect command to OctoPrint to connect to the physical printer. + /// + private async Task SendConnectCommandAsync() + { + try + { + var payload = JsonSerializer.Serialize(new { command = "connect" }); + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + await Client.PostAsync("/api/connection", content, _cts.Token).ConfigureAwait(false); + } + catch + { + // Non-critical — telemetry polling will catch errors + } + } + + public async Task DisconnectAsync() + { + updateTimer.Stop(); + _cts.Cancel(); + _httpClient?.CancelPendingRequests(); + IsConnected = false; + RaiseConnectionChanged(); + await Task.CompletedTask; + } + + public async Task WriteDataAsync(string command) + { + if (!IsConnected) return; + + var payload = JsonSerializer.Serialize(new { command }); + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var response = await Client.PostAsync("/api/printer/command", content, _cts.Token).ConfigureAwait(false); + var result = await response.Content.ReadAsStringAsync(_cts.Token).ConfigureAwait(false); + LastTelemetry.LastResponse = result; + RaiseTelemetryUpdated(); + } + + /// + /// Sends one or more G-code commands via the OctoPrint command API. + /// + private async Task SendGcodeAsync(params string[] commands) + { + if (!IsConnected) return; + + var payload = JsonSerializer.Serialize(new { commands }); + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + await Client.PostAsync("/api/printer/command", content, _cts.Token).ConfigureAwait(false); + } + + public async Task GetPrinterTelemetryAsync() + { + if (!IsConnected) return LastTelemetry; + + try + { + // Get printer state (temperatures, flags) + using var printerResponse = await Client.GetAsync("/api/printer", _cts.Token).ConfigureAwait(false); + if (printerResponse.IsSuccessStatusCode) + { + var json = await printerResponse.Content.ReadAsStringAsync(_cts.Token).ConfigureAwait(false); + ParsePrinterState(json); + } + + // Get job status (progress, file, time remaining) + using var jobResponse = await Client.GetAsync("/api/job", _cts.Token).ConfigureAwait(false); + if (jobResponse.IsSuccessStatusCode) + { + var json = await jobResponse.Content.ReadAsStringAsync(_cts.Token).ConfigureAwait(false); + ParseJobState(json); + } + } + catch + { + // Swallow telemetry errors + } + + LastTelemetry.LastResponse = "OctoPrint telemetry update"; + RaiseTelemetryUpdated(); + return LastTelemetry; + } + + private void ParsePrinterState(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Temperature + if (root.TryGetProperty("temperature", out var temp)) + { + if (temp.TryGetProperty("tool0", out var tool0)) + { + if (tool0.TryGetProperty("actual", out var actual)) + LastTelemetry.HotendTemp = actual.GetDouble(); + if (tool0.TryGetProperty("target", out var target)) + LastTelemetry.HotendTarget = target.GetDouble(); + } + + if (temp.TryGetProperty("bed", out var bed)) + { + if (bed.TryGetProperty("actual", out var actual)) + LastTelemetry.BedTemp = actual.GetDouble(); + if (bed.TryGetProperty("target", out var target)) + LastTelemetry.BedTarget = target.GetDouble(); + } + } + + // State flags + if (root.TryGetProperty("state", out var state)) + { + if (state.TryGetProperty("flags", out var flags)) + { + var isPrinting = flags.TryGetProperty("printing", out var p) && p.GetBoolean(); + var isPaused = flags.TryGetProperty("pausing", out var pa) && pa.GetBoolean(); + var isError = flags.TryGetProperty("error", out var e) && e.GetBoolean(); + + if (isError) + LastTelemetry.Status = PrinterStatus.Error; + else if (isPaused) + LastTelemetry.Status = PrinterStatus.Paused; + else if (isPrinting) + LastTelemetry.Status = PrinterStatus.Printing; + else + LastTelemetry.Status = PrinterStatus.Connected; + } + } + } + + private void ParseJobState(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("progress", out var progress)) + { + if (progress.TryGetProperty("completion", out var completion) && + completion.ValueKind == JsonValueKind.Number) + { + LastTelemetry.SDCard.Progress = completion.GetDouble(); + } + + if (progress.TryGetProperty("printTime", out var printTime) && + printTime.ValueKind == JsonValueKind.Number) + { + // printTime is in seconds + } + } + + if (root.TryGetProperty("state", out var state) && state.ValueKind == JsonValueKind.String) + { + var stateStr = state.GetString()?.ToLowerInvariant(); + if (stateStr?.Contains("printing") == true) + { + LastTelemetry.SDCard.Printing = true; + IsPrinting = true; + } + else + { + LastTelemetry.SDCard.Printing = false; + IsPrinting = false; + } + } + } + + public async Task> GetFilesAsync() + { + if (!IsConnected) return []; + + try + { + using var response = await Client.GetAsync("/api/files?recursive=true", _cts.Token).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) return []; + + var json = await response.Content.ReadAsStringAsync(_cts.Token).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("files", out var filesArray)) + return []; + + var files = new List(); + ParseFilesRecursive(filesArray, files); + return files; + } + catch + { + return []; + } + } + + private static void ParseFilesRecursive(JsonElement filesArray, List result) + { + foreach (var file in filesArray.EnumerateArray()) + { + var type = file.TryGetProperty("type", out var t) ? t.GetString() : null; + + if (type == "folder" && file.TryGetProperty("children", out var children)) + { + ParseFilesRecursive(children, result); + continue; + } + + if (type == "machinecode" || type == "model") + { + var path = file.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""; + var size = file.TryGetProperty("size", out var s) && s.ValueKind == JsonValueKind.Number ? s.GetInt64() : 0; + var dateUnix = file.TryGetProperty("date", out var d) && d.ValueKind == JsonValueKind.Number ? d.GetInt64() : 0; + + result.Add(new FileEntry + { + FullPath = path, + Size = size, + ModifiedDate = dateUnix > 0 ? DateTimeOffset.FromUnixTimeSeconds(dateUnix).DateTime : null, + IsAvailable = true + }); + } + } + } + + /// + /// Retrieves available webcam configurations from OctoPrint settings. + /// + public async Task> GetCamerasAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected || _baseUri is null) return Array.Empty(); + + try + { + using var response = await Client.GetAsync("/api/settings", cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) return Array.Empty(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("webcam", out var webcam)) + return Array.Empty(); + + var streamUrl = webcam.TryGetProperty("streamUrl", out var su) ? su.GetString() : null; + var snapshotUrl = webcam.TryGetProperty("snapshotUrl", out var sn) ? sn.GetString() : null; + var webcamEnabled = !webcam.TryGetProperty("webcamEnabled", out var we) || we.GetBoolean(); + + if (!webcamEnabled || (string.IsNullOrWhiteSpace(streamUrl) && string.IsNullOrWhiteSpace(snapshotUrl))) + return Array.Empty(); + + // Resolve relative URLs against base URI + if (streamUrl != null && !Uri.IsWellFormedUriString(streamUrl, UriKind.Absolute)) + streamUrl = new Uri(_baseUri, streamUrl).AbsoluteUri; + if (snapshotUrl != null && !Uri.IsWellFormedUriString(snapshotUrl, UriKind.Absolute)) + snapshotUrl = new Uri(_baseUri, snapshotUrl).AbsoluteUri; + + return new[] + { + new PrinterCamera + { + Id = "octoprint-webcam", + DisplayName = "OctoPrint Webcam", + StreamUrl = streamUrl, + SnapshotUrl = snapshotUrl, + IsEnabled = true + } + }; + } + catch + { + return Array.Empty(); + } + } + + // ── Printer control commands via G-code ──────────────────────────── + + public Task SetHotendTemp(int targetTemp = 0) => + SendGcodeAsync($"M104 S{targetTemp}"); + + public Task SetBedTemp(int targetTemp = 0) => + SendGcodeAsync($"M140 S{targetTemp}"); + + public Task Home(bool x = true, bool y = true, bool z = true) + { + var axes = new StringBuilder("G28"); + if (x) axes.Append(" X"); + if (y) axes.Append(" Y"); + if (z) axes.Append(" Z"); + return SendGcodeAsync(axes.ToString()); + } + + public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) + { + var sb = new StringBuilder(); + sb.Append("G91\nG1"); + if (Math.Abs(x) > 0.0001f) sb.Append($" X{x}"); + if (Math.Abs(y) > 0.0001f) sb.Append($" Y{y}"); + if (Math.Abs(z) > 0.0001f) sb.Append($" Z{z}"); + if (Math.Abs(e) > 0.0001f) sb.Append($" E{e}"); + sb.Append($" F{feedRate}\nG90"); + return SendGcodeAsync(sb.ToString().Split('\n')); + } + + public Task SetFanSpeed(int fanSpeedPercentage = 0) + { + var duty = (int)Math.Round(Math.Clamp(fanSpeedPercentage, 0, 100) * 255.0 / 100.0); + return SendGcodeAsync($"M106 S{duty}"); + } + + public Task SetPrintSpeed(int speed) => + SendGcodeAsync($"M220 S{Math.Clamp(speed, 1, 200)}"); + + public Task SetPrintFlow(int flow) => + SendGcodeAsync($"M221 S{Math.Clamp(flow, 1, 200)}"); + + public Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) + { + var sb = new StringBuilder("M92"); + if (x > 0) sb.Append($" X{x}"); + if (y > 0) sb.Append($" Y{y}"); + if (z > 0) sb.Append($" Z{z}"); + if (e > 0) sb.Append($" E{e}"); + return SendGcodeAsync(sb.ToString()); + } + + public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) => + SendGcodeAsync($"M303 E{extruderIndex} S{targetTemp} C{cycles}"); + + public Task RunThermalModelCalibration(int cycles, int targetTemp) => + SendGcodeAsync($"M303 E-1 S{targetTemp} C{cycles}"); + + public async Task StartPrint(FileEntry file) + { + if (!IsConnected || string.IsNullOrWhiteSpace(file.FullPath)) return; + + var payload = JsonSerializer.Serialize(new + { + command = "select", + print = true + }); + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + + // OctoPrint expects POST to /api/files/{location}/{filename} + var location = "local"; + var filePath = file.FullPath.TrimStart('/'); + await Client.PostAsync($"/api/files/{location}/{filePath}", content, _cts.Token).ConfigureAwait(false); + } + + public Task StartPrint(GCodeDoc gcodeDoc) + { + if (!IsConnected || string.IsNullOrEmpty(gcodeDoc.Content)) + return Task.CompletedTask; + + return Task.Run(async () => + { + await foreach (var command in gcodeDoc.EnumerateCommandsAsync(_cts.Token)) + { + if (!IsConnected) break; + await SendGcodeAsync(command); + } + }); + } + + public Task SaveEEPROM() => SendGcodeAsync("M500"); + + // ── Helpers ────────────────────────────────────────────────────────── + + private async Task SafeTelemetryAsync() + { + try + { + await GetPrinterTelemetryAsync().ConfigureAwait(false); + } + catch + { + // Swallow background polling errors + } + } + + private void ConfigureClient(ApiConnectionSettings settings) + { + var client = Client; + _baseUri = new Uri(settings.Url); + client.BaseAddress = _baseUri; + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // OctoPrint uses X-Api-Key header for authentication + // The API key is stored in Password field + if (!string.IsNullOrEmpty(settings.Password)) + { + client.DefaultRequestHeaders.Remove("X-Api-Key"); + client.DefaultRequestHeaders.Add("X-Api-Key", settings.Password); + } + } + + public override ValueTask DisposeAsync() + { + if (_disposed) return ValueTask.CompletedTask; + _disposed = true; + + _cts.Cancel(); + updateTimer.Dispose(); + _httpClient?.Dispose(); + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } +} diff --git a/src/MakerPrompt.UI.Components/Services/PrintProjectService.cs b/src/MakerPrompt.UI.Components/Services/PrintProjectService.cs new file mode 100644 index 0000000..0b72c29 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/PrintProjectService.cs @@ -0,0 +1,210 @@ +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.UI.Components.Services +{ + /// + /// Manages print projects — CRUD for projects and their jobs, persists to IAppLocalStorageProvider. + /// G-code file contents are stored as separate files; the project manifest is a JSON index. + /// + public sealed class PrintProjectService + { + private const string ManifestKey = "MakerPrompt.PrintProjects"; + + private readonly IAppLocalStorageProvider _storage; + private readonly ILogger _logger; + private List _projects = []; + private bool _initialized; + + public IReadOnlyList Projects => _projects.AsReadOnly(); + + public event EventHandler? ProjectsChanged; + + public PrintProjectService(IAppLocalStorageProvider storage, ILogger logger) + { + _storage = storage; + _logger = logger; + } + + /// + /// Load projects from storage. Call once at startup. + /// + public async Task InitializeAsync() + { + if (_initialized) return; + + try + { + var files = await _storage.ListFilesAsync(); + var manifest = files.FirstOrDefault(f => f.FullPath.Contains(ManifestKey)); + if (manifest != null) + { + using var stream = await _storage.OpenReadAsync(manifest.FullPath); + if (stream != null) + { + using var reader = new StreamReader(stream); + var json = await reader.ReadToEndAsync(); + _projects = JsonSerializer.Deserialize>(json) ?? []; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load print projects"); + } + + _initialized = true; + } + + // ── Project CRUD ── + + public async Task AddProjectAsync(string name, string? notes = null) + { + var project = new PrintProject + { + Name = name.Trim(), + Notes = notes + }; + _projects.Add(project); + await SaveManifestAsync(); + ProjectsChanged?.Invoke(this, EventArgs.Empty); + } + + public async Task RenameProjectAsync(Guid projectId, string newName) + { + var project = _projects.FirstOrDefault(p => p.Id == projectId); + if (project == null) return; + project.Name = newName.Trim(); + await SaveManifestAsync(); + ProjectsChanged?.Invoke(this, EventArgs.Empty); + } + + public async Task DeleteProjectAsync(Guid projectId) + { + var project = _projects.FirstOrDefault(p => p.Id == projectId); + if (project == null) return; + + // Delete all stored files for this project + foreach (var job in project.Jobs) + { + try + { + await _storage.DeleteFileAsync(job.StoragePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete job file {Path}", job.StoragePath); + } + } + + _projects.Remove(project); + await SaveManifestAsync(); + ProjectsChanged?.Invoke(this, EventArgs.Empty); + } + + // ── Job management ── + + /// + /// Upload a G-code file into a project. + /// + public async Task AddJobAsync(Guid projectId, string fileName, Stream fileContent) + { + var project = _projects.FirstOrDefault(p => p.Id == projectId); + if (project == null) throw new InvalidOperationException("Project not found"); + + var storagePath = $"PrintProjects/{projectId}/{fileName}"; + + await _storage.SaveFileAsync(storagePath, fileContent); + + var job = new PrintJob + { + FileName = fileName, + StoragePath = storagePath, + Size = fileContent.CanSeek ? fileContent.Length : 0 + }; + project.Jobs.Add(job); + await SaveManifestAsync(); + ProjectsChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// Remove a job from a project and delete its stored file. + /// + public async Task RemoveJobAsync(Guid projectId, Guid jobId) + { + var project = _projects.FirstOrDefault(p => p.Id == projectId); + var job = project?.Jobs.FirstOrDefault(j => j.Id == jobId); + if (project == null || job == null) return; + + try + { + await _storage.DeleteFileAsync(job.StoragePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete job file {Path}", job.StoragePath); + } + + project.Jobs.Remove(job); + await SaveManifestAsync(); + ProjectsChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// Assign a job to a printer (mark it as printing). + /// + public async Task AssignJobAsync(Guid projectId, Guid jobId, Guid printerId, string printerName) + { + var project = _projects.FirstOrDefault(p => p.Id == projectId); + var job = project?.Jobs.FirstOrDefault(j => j.Id == jobId); + if (job == null) return; + + job.AssignedPrinterId = printerId; + job.AssignedPrinterName = printerName; + job.Status = PrintJobStatus.Printing; + await SaveManifestAsync(); + ProjectsChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// Mark a job as completed or failed. + /// + public async Task UpdateJobStatusAsync(Guid projectId, Guid jobId, PrintJobStatus status) + { + var project = _projects.FirstOrDefault(p => p.Id == projectId); + var job = project?.Jobs.FirstOrDefault(j => j.Id == jobId); + if (job == null) return; + + job.Status = status; + await SaveManifestAsync(); + ProjectsChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// Open a job's G-code file content from storage. + /// + public Task OpenJobFileAsync(Guid projectId, Guid jobId) + { + var project = _projects.FirstOrDefault(p => p.Id == projectId); + var job = project?.Jobs.FirstOrDefault(j => j.Id == jobId); + if (job == null) return Task.FromResult(null); + return _storage.OpenReadAsync(job.StoragePath); + } + + // ── Persistence ── + + private async Task SaveManifestAsync() + { + try + { + var json = JsonSerializer.Serialize(_projects, new JsonSerializerOptions { WriteIndented = true }); + var bytes = Encoding.UTF8.GetBytes(json); + using var stream = new MemoryStream(bytes); + await _storage.SaveFileAsync(ManifestKey, stream); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save print projects manifest"); + } + } + } +} diff --git a/src/MakerPrompt.UI.Components/Services/PrinterCameraProvider.cs b/src/MakerPrompt.UI.Components/Services/PrinterCameraProvider.cs new file mode 100644 index 0000000..4d317b2 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/PrinterCameraProvider.cs @@ -0,0 +1,70 @@ +namespace MakerPrompt.UI.Components.Services +{ + public interface IPrinterCameraProvider + { + Task> GetCamerasAsync(CancellationToken cancellationToken = default); + + Task GetPrimaryCameraAsync(CancellationToken cancellationToken = default); + } + + /// + /// Provides a backend-agnostic view of available printer cameras, delegating to + /// backend-specific services via the existing PrinterCommunicationServiceFactory. + /// + public sealed class PrinterCameraProvider : IPrinterCameraProvider + { + private readonly PrinterCommunicationServiceFactory factory; + + public PrinterCameraProvider(PrinterCommunicationServiceFactory factory) + { + this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + public async Task> GetCamerasAsync(CancellationToken cancellationToken = default) + { + var current = factory.Current; + if (current is null || !current.IsConnected) + { + return Array.Empty(); + } + + try + { + return current.ConnectionType switch + { + PrinterConnectionType.Moonraker when current is MoonrakerApiService moonraker => + await moonraker.GetCamerasAsync(cancellationToken).ConfigureAwait(false), + + PrinterConnectionType.PrusaLink when current is PrusaLinkApiService prusa => + await prusa.GetCamerasAsync(cancellationToken).ConfigureAwait(false), + + PrinterConnectionType.OctoPrint when current is OctoPrintApiService octoprint => + await octoprint.GetCamerasAsync(cancellationToken).ConfigureAwait(false), + + PrinterConnectionType.BambuLab when current is BambuLabApiService bambu => + await bambu.GetCamerasAsync(cancellationToken).ConfigureAwait(false), + + PrinterConnectionType.PrusaConnect when current is PrusaConnectApiService prusaConnect => + await prusaConnect.GetCamerasAsync(cancellationToken).ConfigureAwait(false), + + PrinterConnectionType.PrusaConnect when current is PrusaConnectPrinterService mobilePrusa => + await mobilePrusa.GetCamerasAsync(cancellationToken).ConfigureAwait(false), + + _ => Array.Empty() + }; + } + catch + { + // Any failure to query cameras should be silent; the dashboard simply + // omits the webcam card when discovery is not successful. + return Array.Empty(); + } + } + + public async Task GetPrimaryCameraAsync(CancellationToken cancellationToken = default) + { + var cameras = await GetCamerasAsync(cancellationToken).ConfigureAwait(false); + return cameras.Count > 0 ? cameras[0] : null; + } + } +} diff --git a/src/MakerPrompt.UI.Components/Services/PrinterCommunicationServiceFactory.cs b/src/MakerPrompt.UI.Components/Services/PrinterCommunicationServiceFactory.cs new file mode 100644 index 0000000..d6e6f13 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/PrinterCommunicationServiceFactory.cs @@ -0,0 +1,83 @@ +namespace MakerPrompt.UI.Components.Services +{ + public class PrinterCommunicationServiceFactory( + ISerialService serialService, + PrusaLinkApiService prusaLinkApiService, + PrusaConnectApiService prusaConnectApiService, + PrusaConnectPrinterService prusaConnectPrinterService, + MoonrakerApiService moonrakerApiService, + BambuLabApiService bambuLabApiService, + OctoPrintApiService octoPrintApiService) : IAsyncDisposable + { + public event EventHandler? ConnectionStateChanged; + public bool IsConnected { get; private set; } + public IPrinterCommunicationService? Current { get; private set; } + + private readonly ISerialService serialService = serialService; + private readonly PrusaLinkApiService prusaLinkApiService = prusaLinkApiService; + private readonly PrusaConnectApiService prusaConnectApiService = prusaConnectApiService; + private readonly PrusaConnectPrinterService prusaConnectPrinterService = prusaConnectPrinterService; + private readonly MoonrakerApiService moonrakerApiService = moonrakerApiService; + private readonly BambuLabApiService bambuLabApiService = bambuLabApiService; + private readonly OctoPrintApiService octoPrintApiService = octoPrintApiService; + + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + { + if (Current != null && Current.ConnectionType != connectionSettings.ConnectionType) + { + await Current.DisposeAsync(); + } + + IPrinterCommunicationService service = connectionSettings.ConnectionType switch + { + PrinterConnectionType.Demo => new DemoPrinterService(), + PrinterConnectionType.Serial => serialService, + PrinterConnectionType.PrusaLink => prusaLinkApiService, + PrinterConnectionType.PrusaConnect => prusaConnectPrinterService, + PrinterConnectionType.Moonraker => moonrakerApiService, + PrinterConnectionType.BambuLab => bambuLabApiService, + PrinterConnectionType.OctoPrint => octoPrintApiService, + _ => throw new NotImplementedException(), + }; + + if (await service.ConnectAsync(connectionSettings)) + { + Current = service; + IsConnected = Current.IsConnected; + ConnectionStateChanged?.Invoke(this, IsConnected); + } + } + + public async Task DisconnectAsync() + { + if (Current == null) return; + await Current.DisconnectAsync(); + IsConnected = Current.IsConnected; + ConnectionStateChanged?.Invoke(this, IsConnected); + } + + /// + /// Called by PrinterConnectionManager to keep this factory in sync with the + /// currently active managed printer. Preserves backward compatibility with + /// all existing single-printer UI components that read factory.Current. + /// + public void SetManagedCurrent(IPrinterCommunicationService? service) + { + Current = service; + IsConnected = service?.IsConnected ?? false; + ConnectionStateChanged?.Invoke(this, IsConnected); + } + + public async ValueTask DisposeAsync() + { + await DisconnectAsync(); + await serialService.DisposeAsync(); + await prusaLinkApiService.DisposeAsync(); + await prusaConnectPrinterService.DisposeAsync(); + await moonrakerApiService.DisposeAsync(); + await bambuLabApiService.DisposeAsync(); + await octoPrintApiService.DisposeAsync(); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/MakerPrompt.UI.Components/Services/PrinterConnectionManager.cs b/src/MakerPrompt.UI.Components/Services/PrinterConnectionManager.cs new file mode 100644 index 0000000..99562e7 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/PrinterConnectionManager.cs @@ -0,0 +1,603 @@ +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.UI.Components.Services +{ + /// + /// Manages multiple printer connections. Loads/saves definitions from local storage, + /// creates per-printer backend instances, tracks live state, and exposes events for UI. + /// + /// Design inspired by: + /// - PrintQue multi-printer dashboard patterns + /// - OctoPrint connection profiles + REST/WS integration + /// - BambuCAM/BambuFarm multi-printer telemetry management + /// + /// Works alongside the existing PrinterCommunicationServiceFactory for backward compatibility. + /// The factory's `Current` property is kept in sync with the active printer. + /// + public sealed class PrinterConnectionManager : IAsyncDisposable + { + private const string StorageKey = "MakerPrompt.PrinterConnections"; + + private readonly IAppLocalStorageProvider _storage; + private readonly IConnectionEncryptionService _encryption; + private readonly PrinterCommunicationServiceFactory _factory; + private readonly ISerialService _serialService; + private readonly ILogger _logger; + private readonly FilamentInventoryService _filamentInventoryService; + private readonly AnalyticsService _analyticsService; + private readonly NotificationService _notificationService; + private readonly IAppConfigurationService _configService; + private readonly List _printers = []; + private readonly SemaphoreSlim _lock = new(1, 1); + + /// + /// Fires when any printer's state changes (connected, disconnected, telemetry update, etc.). + /// + public event EventHandler? PrintersChanged; + + /// + /// Fires when the active printer selection changes. + /// + public event EventHandler? ActivePrinterChanged; + + /// + /// Read-only snapshot of all managed printers. + /// + public IReadOnlyList Printers => _printers.AsReadOnly(); + + /// + /// The currently selected "active" printer (used by single-printer views like Dashboard, ControlPanel). + /// + public ManagedPrinterState? ActivePrinter => _printers.FirstOrDefault(p => p.IsActive); + + public PrinterConnectionManager( + IAppLocalStorageProvider storage, + IConnectionEncryptionService encryption, + PrinterCommunicationServiceFactory factory, + ISerialService serialService, + ILogger logger, + FilamentInventoryService filamentInventoryService, + AnalyticsService analyticsService, + NotificationService notificationService, + IAppConfigurationService configService) + { + _storage = storage; + _encryption = encryption; + _factory = factory; + _serialService = serialService; + _logger = logger; + _filamentInventoryService = filamentInventoryService; + _analyticsService = analyticsService; + _notificationService = notificationService; + _configService = configService; + } + + /// + /// Loads saved printer definitions from storage. Called once at app startup. + /// + public async Task InitializeAsync() + { + await _lock.WaitAsync(); + try + { + var definitions = await LoadDefinitionsAsync(); + foreach (var def in definitions) + { + DecryptSensitiveFields(def); + _printers.Add(new ManagedPrinterState + { + Definition = def, + Status = PrinterStatus.Disconnected + }); + } + + // Set first printer as active if any exist + if (_printers.Count > 0) + { + _printers[0].IsActive = true; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load printer definitions from storage"); + } + finally + { + _lock.Release(); + } + + RaisePrintersChanged(); + } + + /// + /// Disconnects all printers, clears state, and re-initializes from storage. + /// Used when switching farm configurations. + /// + public async Task ReloadAsync() + { + await _lock.WaitAsync(); + try + { + foreach (var p in _printers.ToList()) + { + var reloadService = p.Service; + if (reloadService != null) + { + try + { + await reloadService.DisconnectAsync(); + await reloadService.DisposeAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error disconnecting printer {Name} during reload", p.Definition.Name); + } + } + } + _printers.Clear(); + } + finally + { + _lock.Release(); + } + + await InitializeAsync(); + } + + /// + /// Auto-connects all printers that have AutoConnect enabled. + /// Should be called after InitializeAsync. + /// + public async Task AutoConnectAsync() + { + var autoConnectPrinters = _printers + .Where(p => p.Definition.AutoConnect && p.Status == PrinterStatus.Disconnected) + .ToList(); + + // Connect in parallel with per-printer error isolation + var tasks = autoConnectPrinters.Select(p => ConnectPrinterAsync(p.Definition.Id)); + await Task.WhenAll(tasks); + } + + /// + /// Adds a new printer definition and persists it. + /// + public async Task AddPrinterAsync(PrinterConnectionDefinition definition) + { + await _lock.WaitAsync(); + try + { + var state = new ManagedPrinterState + { + Definition = definition, + Status = PrinterStatus.Disconnected + }; + + _printers.Add(state); + + // If this is the first printer, make it active + if (_printers.Count == 1) + { + state.IsActive = true; + } + + await SaveDefinitionsAsync(); + } + finally + { + _lock.Release(); + } + + RaisePrintersChanged(); + return definition; + } + + /// + /// Updates an existing printer definition and persists the change. + /// + public async Task UpdatePrinterAsync(PrinterConnectionDefinition definition) + { + await _lock.WaitAsync(); + try + { + var state = _printers.FirstOrDefault(p => p.Definition.Id == definition.Id); + if (state == null) + { + _logger.LogWarning("Attempted to update non-existent printer {Id}", definition.Id); + return; + } + + state.Definition = definition; + await SaveDefinitionsAsync(); + } + finally + { + _lock.Release(); + } + + RaisePrintersChanged(); + } + + /// + /// Removes a printer definition, disconnects if connected, and persists. + /// + public async Task RemovePrinterAsync(Guid printerId) + { + await _lock.WaitAsync(); + try + { + var state = _printers.FirstOrDefault(p => p.Definition.Id == printerId); + if (state == null) return; + + // Disconnect if connected + var removeService = state.Service; + if (removeService != null) + { + try + { + await removeService.DisconnectAsync(); + await removeService.DisposeAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error disconnecting printer {Name} during removal", state.Definition.Name); + } + } + + bool wasActive = state.IsActive; + _printers.Remove(state); + + // If we removed the active printer, select the first remaining one + if (wasActive && _printers.Count > 0) + { + _printers[0].IsActive = true; + SyncActiveToFactory(_printers[0]); + } + + await SaveDefinitionsAsync(); + } + finally + { + _lock.Release(); + } + + RaisePrintersChanged(); + } + + /// + /// Connects a specific printer by its definition ID. + /// + public async Task ConnectPrinterAsync(Guid printerId) + { + var state = _printers.FirstOrDefault(p => p.Definition.Id == printerId); + if (state == null) return; + if (state.Service?.IsConnected == true) return; + + state.IsBusy = true; + state.LastError = null; + RaisePrintersChanged(); + + try + { + var service = CreateBackendService(state.Definition.ConnectionType); + var connected = await service.ConnectAsync(state.Definition.Settings); + + if (connected) + { + state.Service = service; + state.Status = PrinterStatus.Connected; + state.Definition.LastConnectedAt = DateTime.UtcNow; + + // Subscribe to telemetry + service.TelemetryUpdated += async (_, telemetry) => + { + await HandleTelemetryUpdateAsync(state, telemetry); + }; + + service.ConnectionStateChanged += (_, isConnected) => + { + state.Status = isConnected ? PrinterStatus.Connected : PrinterStatus.Disconnected; + if (!isConnected) + { + state.Service = null; + } + RaisePrintersChanged(); + + // Keep factory in sync + if (state.IsActive) + { + SyncActiveToFactory(state); + } + }; + + // Sync to factory if this is the active printer + if (state.IsActive) + { + SyncActiveToFactory(state); + } + + // Persist last connected timestamp + await SaveDefinitionsAsync(); + } + else + { + state.LastError = "Connection failed"; + state.Status = PrinterStatus.Disconnected; + await service.DisposeAsync(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect printer {Name}", state.Definition.Name); + state.LastError = ex.Message; + state.Status = PrinterStatus.Error; + } + finally + { + state.IsBusy = false; + RaisePrintersChanged(); + } + } + + private async Task HandleTelemetryUpdateAsync(ManagedPrinterState state, PrinterTelemetry telemetry) + { + var previousStatus = state.Status; + state.Telemetry = telemetry; + state.Status = telemetry.Status; + + // Track print job start + if (previousStatus != PrinterStatus.Printing && state.Status == PrinterStatus.Printing) + { + state.PrintStartTime = DateTime.UtcNow; + state.AccumulatedExtrusion = 0; + + if (_configService.Configuration.EnableFilamentInventory && state.Definition.AssignedFilamentSpoolId == null) + { + await _notificationService.NotifyAsync(NotificationLevel.Warning, "Print Started", "Print started without assigned filament.", state.Definition.Id); + } + } + + // Track print job end + if (previousStatus == PrinterStatus.Printing && state.Status != PrinterStatus.Printing) + { + var duration = state.PrintStartTime.HasValue ? DateTime.UtcNow - state.PrintStartTime.Value : TimeSpan.Zero; + var filamentUsed = telemetry.FilamentUsed > 0 ? telemetry.FilamentUsed : state.AccumulatedExtrusion; + + if (state.Status == PrinterStatus.Connected || state.Status == PrinterStatus.Disconnected) + { + await _notificationService.NotifyAsync(NotificationLevel.Info, "Print Completed", $"Print job '{telemetry.PrintJobName}' completed.", state.Definition.Id); + } + else if (state.Status == PrinterStatus.Error) + { + await _notificationService.NotifyAsync(NotificationLevel.Error, "Print Failed", $"Print job '{telemetry.PrintJobName}' failed.", state.Definition.Id); + } + + if (state.Definition.AssignedFilamentSpoolId.HasValue && filamentUsed > 0) + { + var spoolId = state.Definition.AssignedFilamentSpoolId.Value; + + if (_configService.Configuration.EnablePrintAnalytics) + { + var record = new PrintJobUsageRecord + { + PrinterId = state.Definition.Id, + FilamentSpoolId = spoolId, + JobName = telemetry.PrintJobName, + Duration = duration, + EstimatedFilamentUsedGrams = filamentUsed, + ActualFilamentUsedGrams = filamentUsed + }; + await _analyticsService.RecordUsageAsync(record); + } + + if (_configService.Configuration.EnableFilamentInventory) + { + await _filamentInventoryService.DeductFilamentAsync(spoolId, filamentUsed); + + var spool = _filamentInventoryService.GetSpool(spoolId); + if (spool != null) + { + if (spool.RemainingWeightGrams <= 0) + { + await _notificationService.NotifyAsync(NotificationLevel.Critical, "Spool Empty", $"Filament spool '{spool.Name}' is empty.", state.Definition.Id, spoolId); + } + else if (spool.RemainingWeightGrams < spool.TotalWeightGrams * 0.1) // 10% threshold + { + await _notificationService.NotifyAsync(NotificationLevel.Warning, "Low Filament", $"Filament spool '{spool.Name}' is running low.", state.Definition.Id, spoolId); + } + } + } + } + + state.PrintStartTime = null; + state.AccumulatedExtrusion = 0; + } + + RaisePrintersChanged(); + } + + /// + /// Disconnects a specific printer by its definition ID. + /// + public async Task DisconnectPrinterAsync(Guid printerId) + { + var state = _printers.FirstOrDefault(p => p.Definition.Id == printerId); + if (state?.Service == null) return; + + state.IsBusy = true; + RaisePrintersChanged(); + + var disconnectService = state.Service; + try + { + await disconnectService.DisconnectAsync(); + await disconnectService.DisposeAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error disconnecting printer {Name}", state.Definition.Name); + } + finally + { + state.Service = null; + state.Status = PrinterStatus.Disconnected; + state.IsBusy = false; + + // Keep factory in sync + if (state.IsActive) + { + SyncActiveToFactory(state); + } + + RaisePrintersChanged(); + } + } + + /// + /// Sets which printer is the "active" one for single-printer views. + /// Also syncs the legacy factory's Current property. + /// + public void SetActivePrinter(Guid printerId) + { + foreach (var p in _printers) + { + p.IsActive = p.Definition.Id == printerId; + } + + var active = ActivePrinter; + SyncActiveToFactory(active); + ActivePrinterChanged?.Invoke(this, active); + RaisePrintersChanged(); + } + + /// + /// Creates a new backend service instance for the given connection type. + /// Each printer gets its own instance — no singleton sharing. + /// + private IPrinterCommunicationService CreateBackendService(PrinterConnectionType type) + { + return type switch + { + PrinterConnectionType.Demo => new DemoPrinterService(), + PrinterConnectionType.Serial => _serialService, + PrinterConnectionType.PrusaLink => new PrusaLinkApiService(), + PrinterConnectionType.PrusaConnect => new PrusaConnectPrinterService(), + PrinterConnectionType.Moonraker => new MoonrakerApiService(), + PrinterConnectionType.BambuLab => new BambuLabApiService(), + PrinterConnectionType.OctoPrint => new OctoPrintApiService(), + _ => throw new NotSupportedException($"Unsupported connection type: {type}") + }; + } + + /// + /// Keeps the legacy PrinterCommunicationServiceFactory in sync with the active printer. + /// This preserves backward compatibility with all existing single-printer UI components. + /// + private void SyncActiveToFactory(ManagedPrinterState? state) + { + // The factory exposes Current + IsConnected + ConnectionStateChanged. + // We update these to match the active managed printer. + _factory.SetManagedCurrent(state?.Service); + } + + private async Task> LoadDefinitionsAsync() + { + try + { + var files = await _storage.ListFilesAsync(); + var file = files.FirstOrDefault(f => f.FullPath.Contains(StorageKey)); + if (file == null) + return []; + + using var stream = await _storage.OpenReadAsync(file.FullPath); + if (stream == null) + return []; + + using var reader = new StreamReader(stream); + var json = await reader.ReadToEndAsync(); + return JsonSerializer.Deserialize>(json) ?? []; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load printer definitions"); + return []; + } + } + + private async Task SaveDefinitionsAsync() + { + try + { + var defs = _printers.Select(p => + { + // Clone definition and encrypt sensitive fields before saving + var clone = CloneDefinition(p.Definition); + EncryptSensitiveFields(clone); + return clone; + }).ToList(); + + var json = JsonSerializer.Serialize(defs, new JsonSerializerOptions { WriteIndented = true }); + var bytes = Encoding.UTF8.GetBytes(json); + using var stream = new MemoryStream(bytes); + await _storage.SaveFileAsync(StorageKey, stream); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save printer definitions"); + } + } + + private void EncryptSensitiveFields(PrinterConnectionDefinition def) + { + if (def.Settings.Api != null) + { + if (!string.IsNullOrEmpty(def.Settings.Api.Password)) + def.Settings.Api.Password = _encryption.Encrypt(def.Settings.Api.Password); + if (!string.IsNullOrEmpty(def.Settings.Api.UserName)) + def.Settings.Api.UserName = _encryption.Encrypt(def.Settings.Api.UserName); + } + } + + private void DecryptSensitiveFields(PrinterConnectionDefinition def) + { + if (def.Settings.Api != null) + { + if (!string.IsNullOrEmpty(def.Settings.Api.Password)) + def.Settings.Api.Password = _encryption.Decrypt(def.Settings.Api.Password); + if (!string.IsNullOrEmpty(def.Settings.Api.UserName)) + def.Settings.Api.UserName = _encryption.Decrypt(def.Settings.Api.UserName); + } + } + + private static PrinterConnectionDefinition CloneDefinition(PrinterConnectionDefinition original) + { + var json = JsonSerializer.Serialize(original); + return JsonSerializer.Deserialize(json)!; + } + + private void RaisePrintersChanged() + { + PrintersChanged?.Invoke(this, EventArgs.Empty); + } + + public async ValueTask DisposeAsync() + { + foreach (var printer in _printers) + { + if (printer.Service != null) + { + try + { + await printer.Service.DisposeAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error disposing printer {Name}", printer.Definition.Name); + } + } + } + _printers.Clear(); + _lock.Dispose(); + } + } +} diff --git a/src/MakerPrompt.UI.Components/Services/PrusaConnectApiService.cs b/src/MakerPrompt.UI.Components/Services/PrusaConnectApiService.cs new file mode 100644 index 0000000..5ff763d --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/PrusaConnectApiService.cs @@ -0,0 +1,360 @@ +namespace MakerPrompt.UI.Components.Services; + +/// +/// PrusaConnect cloud API backend. +/// +/// Communicates with the Prusa Connect REST API hosted at connect.prusa3d.com. +/// Documented at: https://connect.prusa3d.com/docs/ +/// +/// Authentication: API key sent in the x-api-key header. +/// Obtain it from Prusa Connect → Account → API Keys. +/// +/// Connection settings mapping: +/// Api.UserName — Printer UUID (visible in Prusa Connect printer detail) +/// Api.Password — API key +/// Api.Url — ignored; base URL is always +/// +/// Supports: +/// - Printer state + temperature telemetry polling +/// - Job progress and elapsed time +/// - Camera snapshot retrieval via webcam.connect.prusa3d.com +/// +/// Direct G-code, motion, and temperature control commands are not available +/// through the PrusaConnect cloud API. Use PrusaLink for local direct control. +/// +public sealed class PrusaConnectApiService : BasePrinterConnectionService, IPrinterCommunicationService +{ + private const string BaseUrl = "https://connect.prusa3d.com"; + private const string WebcamBaseUrl = "https://webcam.connect.prusa3d.com"; + + private readonly CancellationTokenSource _cts = new(); + private HttpClient? _httpClient; + private bool _telemetryTimerInitialized; + private string? _printerUuid; + + public override PrinterConnectionType ConnectionType => PrinterConnectionType.PrusaConnect; + + public PrusaConnectApiService() { } + + public PrusaConnectApiService(HttpMessageHandler handler) + { + _httpClient = new HttpClient(handler, false) { BaseAddress = new Uri(BaseUrl) }; + } + + private HttpClient Client => _httpClient ??= new HttpClient { BaseAddress = new Uri(BaseUrl) }; + + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + { + if (connectionSettings.Api is null) + throw new ArgumentException("PrusaConnect connection requires API settings.", nameof(connectionSettings)); + + _printerUuid = connectionSettings.Api.UserName; + ConfigureClient(connectionSettings.Api.Password); + + try + { + var printer = await GetPrinterAsync(_cts.Token); + if (printer == null) + { + IsConnected = false; + RaiseConnectionChanged(); + return false; + } + + ConnectionName = printer.Name ?? $"PrusaConnect ({_printerUuid})"; + LastTelemetry.PrinterName = printer.Name ?? LastTelemetry.PrinterName; + LastTelemetry.ConnectionTime = DateTime.UtcNow; + + if (!_telemetryTimerInitialized) + { + updateTimer.Elapsed += async (_, _) => await SafeTelemetryAsync(); + _telemetryTimerInitialized = true; + } + + updateTimer.Start(); + IsConnected = true; + } + catch + { + IsConnected = false; + } + + RaiseConnectionChanged(); + return IsConnected; + } + + public async Task DisconnectAsync() + { + updateTimer.Stop(); + _cts.Cancel(); + _httpClient?.CancelPendingRequests(); + IsConnected = false; + RaiseConnectionChanged(); + await Task.CompletedTask; + } + + public Task WriteDataAsync(string command) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support direct G-code commands.")); + + public async Task GetPrinterTelemetryAsync() + { + if (string.IsNullOrEmpty(_printerUuid)) return LastTelemetry; + + var printer = await GetPrinterAsync(_cts.Token); + if (printer == null) return LastTelemetry; + + if (printer.Telemetry != null) + { + LastTelemetry.HotendTemp = printer.Telemetry.TempNozzle ?? LastTelemetry.HotendTemp; + LastTelemetry.HotendTarget = printer.Telemetry.TargetNozzle ?? LastTelemetry.HotendTarget; + LastTelemetry.BedTemp = printer.Telemetry.TempBed ?? LastTelemetry.BedTemp; + LastTelemetry.BedTarget = printer.Telemetry.TargetBed ?? LastTelemetry.BedTarget; + LastTelemetry.FeedRate = printer.Telemetry.PrintSpeed ?? LastTelemetry.FeedRate; + + if (printer.Telemetry.ZHeight.HasValue) + LastTelemetry.Position = LastTelemetry.Position with { Z = printer.Telemetry.ZHeight.Value }; + } + + LastTelemetry.Status = MapState(printer.State); + + if (printer.JobInfo != null) + { + LastTelemetry.SDCard.Progress = printer.JobInfo.Progress ?? LastTelemetry.SDCard.Progress; + LastTelemetry.SDCard.Printing = LastTelemetry.Status == PrinterStatus.Printing; + IsPrinting = LastTelemetry.SDCard.Printing; + + if (printer.JobInfo.TimePrinting.HasValue) + LastTelemetry.PrintDuration = TimeSpan.FromSeconds(printer.JobInfo.TimePrinting.Value); + } + + LastTelemetry.LastResponse = "PrusaConnect telemetry update"; + RaiseTelemetryUpdated(); + return LastTelemetry; + } + + public Task> GetFilesAsync() => + Task.FromResult(new List()); + + /// + /// Retrieves cameras registered for this printer in Prusa Connect. + /// Snapshot URLs are served by webcam.connect.prusa3d.com and are + /// authenticated via the per-camera token embedded in the query string. + /// Ref: https://connect.prusa3d.com/docs/camera/ + /// + public async Task> GetCamerasAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected || string.IsNullOrEmpty(_printerUuid)) + return Array.Empty(); + + try + { + using var response = await Client.GetAsync( + $"/app/printers/{_printerUuid}/cameras", cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) return Array.Empty(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + // Response is either a root array or { "cameras": [...] } + var root = doc.RootElement; + JsonElement cameraArray; + if (root.ValueKind == JsonValueKind.Array) + cameraArray = root; + else if (root.TryGetProperty("cameras", out var nested)) + cameraArray = nested; + else + return Array.Empty(); + + var cameras = new List(); + foreach (var cam in cameraArray.EnumerateArray()) + { + var fingerprint = cam.TryGetProperty("camera_fingerprint", out var fp) ? fp.GetString() : null; + var token = cam.TryGetProperty("token", out var tk) ? tk.GetString() : null; + var name = cam.TryGetProperty("name", out var nm) ? nm.GetString() : null; + var registered = !cam.TryGetProperty("registered", out var reg) || reg.GetBoolean(); + + if (!registered || string.IsNullOrEmpty(token) || string.IsNullOrEmpty(fingerprint)) + continue; + + cameras.Add(new PrinterCamera + { + Id = fingerprint, + DisplayName = name ?? "Camera", + SnapshotUrl = $"{WebcamBaseUrl}/c/snapshot?fingerprint={fingerprint}&token={token}", + IsEnabled = true + }); + } + + return cameras; + } + catch + { + return Array.Empty(); + } + } + + // ── Unsupported operations (cloud API — no direct printer control) ──── + + public Task SetHotendTemp(int targetTemp = 0) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support temperature control.")); + + public Task SetBedTemp(int targetTemp = 0) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support temperature control.")); + + public Task Home(bool x = true, bool y = true, bool z = true) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support homing commands.")); + + public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support move commands.")); + + public Task SetFanSpeed(int fanSpeedPercentage = 0) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support fan control.")); + + public Task SetPrintSpeed(int speed) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support print speed control.")); + + public Task SetPrintFlow(int flow) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support flow control.")); + + public Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support steps-per-unit control.")); + + public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support PID tuning.")); + + public Task RunThermalModelCalibration(int cycles, int targetTemp) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support thermal model calibration.")); + + public Task StartPrint(FileEntry file) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support starting prints remotely.")); + + public Task StartPrint(GCodeDoc gcodeDoc) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support direct G-code printing.")); + + public Task SaveEEPROM() => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support EEPROM commands.")); + + public override ValueTask DisposeAsync() + { + _cts.Cancel(); + _httpClient?.Dispose(); + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } + + // ── Private helpers ─────────────────────────────────────────────────── + + private void ConfigureClient(string? apiKey) + { + var client = Client; + client.BaseAddress ??= new Uri(BaseUrl); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (!string.IsNullOrEmpty(apiKey)) + { + client.DefaultRequestHeaders.Remove("x-api-key"); + client.DefaultRequestHeaders.Add("x-api-key", apiKey); + } + } + + private async Task GetPrinterAsync(CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(_printerUuid)) return null; + + using var response = await Client.GetAsync($"/app/printers/{_printerUuid}", cancellationToken) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) return null; + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync( + stream, s_jsonOptions, cancellationToken); + } + + private async Task SafeTelemetryAsync() + { + try + { + await GetPrinterTelemetryAsync(); + } + catch + { + // swallow background polling errors + } + } + + private static PrinterStatus MapState(string? state) => state?.ToUpperInvariant() switch + { + "PRINTING" => PrinterStatus.Printing, + "PAUSED" => PrinterStatus.Paused, + "ERROR" or "ATTENTION" => PrinterStatus.Error, + "IDLE" or "READY" or "FINISHED" or "STOPPED" => PrinterStatus.Connected, + _ => PrinterStatus.Disconnected + }; + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; +} + +// ── Response models ─────────────────────────────────────────────────────── + +public sealed class PrusaConnectPrinterResponse +{ + [JsonPropertyName("uuid")] + public string? Uuid { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("printer_type")] + public string? PrinterType { get; set; } + + [JsonPropertyName("state")] + public string? State { get; set; } + + [JsonPropertyName("telemetry")] + public PrusaConnectTelemetry? Telemetry { get; set; } + + [JsonPropertyName("job_info")] + public PrusaConnectJobInfo? JobInfo { get; set; } +} + +public sealed class PrusaConnectTelemetry +{ + [JsonPropertyName("temp_nozzle")] + public double? TempNozzle { get; set; } + + [JsonPropertyName("target_nozzle")] + public double? TargetNozzle { get; set; } + + [JsonPropertyName("temp_bed")] + public double? TempBed { get; set; } + + [JsonPropertyName("target_bed")] + public double? TargetBed { get; set; } + + [JsonPropertyName("print_speed")] + public int? PrintSpeed { get; set; } + + [JsonPropertyName("z_height")] + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public float? ZHeight { get; set; } +} + +public sealed class PrusaConnectJobInfo +{ + [JsonPropertyName("progress")] + public double? Progress { get; set; } + + [JsonPropertyName("time_remaining")] + public int? TimeRemaining { get; set; } + + [JsonPropertyName("time_printing")] + public int? TimePrinting { get; set; } +} diff --git a/src/MakerPrompt.UI.Components/Services/PrusaConnectPrinterService.cs b/src/MakerPrompt.UI.Components/Services/PrusaConnectPrinterService.cs new file mode 100644 index 0000000..78382c6 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/PrusaConnectPrinterService.cs @@ -0,0 +1,423 @@ +namespace MakerPrompt.UI.Components.Services; + +/// +/// PrusaConnect printer backend using the mobile API. +/// +/// Base URL: https://connect-mobile-api.prusa3d.com +/// Auth: Authorization: Bearer {token} +/// +/// Connection settings mapping: +/// Api.UserName — Printer UUID (from PrusaConnectProvider or printer detail page) +/// Api.Password — Bearer token (from account login) +/// Api.Url — ignored; base URL is always +/// +/// Supports: state + temperature telemetry polling, job progress, G-code commands. +/// Direct motion/temperature/tuning commands are not available via the cloud API. +/// +/// Endpoints used: +/// GET /api/v1/printers/{uuid} +/// GET /api/v1/printers/{uuid}/telemetry +/// GET /api/v1/printers/{uuid}/files +/// GET /api/v1/printers/{uuid}/cameras +/// POST /api/v1/printers/{uuid}/command +/// +public sealed class PrusaConnectPrinterService : BasePrinterConnectionService, IPrinterCommunicationService +{ + private const string BaseUrl = "https://connect-mobile-api.prusa3d.com"; + + private readonly CancellationTokenSource _cts = new(); + private HttpClient? _httpClient; + private bool _timerInitialized; + private string? _printerUuid; + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; + + public override PrinterConnectionType ConnectionType => PrinterConnectionType.PrusaConnect; + + public PrusaConnectPrinterService() { } + + public PrusaConnectPrinterService(HttpMessageHandler handler) + { + _httpClient = new HttpClient(handler, false) { BaseAddress = new Uri(BaseUrl) }; + } + + private HttpClient Client => _httpClient ??= new HttpClient { BaseAddress = new Uri(BaseUrl) }; + + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + { + if (connectionSettings.Api is null) + throw new ArgumentException("PrusaConnect requires API settings.", nameof(connectionSettings)); + + _printerUuid = connectionSettings.Api.UserName; + ConfigureClient(connectionSettings.Api.Password); + + try + { + var printer = await FetchPrinterAsync(_cts.Token); + if (printer is null) + { + IsConnected = false; + RaiseConnectionChanged(); + return false; + } + + ConnectionName = printer.Name ?? $"PrusaConnect ({_printerUuid})"; + LastTelemetry.PrinterName = ConnectionName; + LastTelemetry.ConnectionTime = DateTime.UtcNow; + + if (!_timerInitialized) + { + updateTimer.Elapsed += async (_, _) => await SafePollAsync(); + _timerInitialized = true; + } + + updateTimer.Start(); + IsConnected = true; + } + catch + { + IsConnected = false; + } + + RaiseConnectionChanged(); + return IsConnected; + } + + public async Task DisconnectAsync() + { + updateTimer.Stop(); + _cts.Cancel(); + _httpClient?.CancelPendingRequests(); + IsConnected = false; + RaiseConnectionChanged(); + await Task.CompletedTask; + } + + /// + /// Sends a raw G-code command via POST /app/printers/{uuid}/command. + /// + public async Task WriteDataAsync(string command) + { + if (string.IsNullOrEmpty(_printerUuid)) return; + + var body = JsonSerializer.Serialize(new { command }, s_jsonOptions); + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + using var response = await Client + .PostAsync($"/api/v1/printers/{_printerUuid}/command", content, _cts.Token); + response.EnsureSuccessStatusCode(); + } + + public async Task GetPrinterTelemetryAsync() + { + if (string.IsNullOrEmpty(_printerUuid)) return LastTelemetry; + + try + { + var telemetry = await FetchTelemetryAsync(_cts.Token); + if (telemetry is not null) + ApplyTelemetry(telemetry); + + var printer = await FetchPrinterAsync(_cts.Token); + if (printer is not null) + ApplyPrinterState(printer); + } + catch + { + // swallow background polling errors + } + + RaiseTelemetryUpdated(); + return LastTelemetry; + } + + public async Task> GetFilesAsync() + { + if (string.IsNullOrEmpty(_printerUuid)) return []; + + try + { + using var response = await Client.GetAsync($"/api/v1/printers/{_printerUuid}/files", _cts.Token); + if (!response.IsSuccessStatusCode) return []; + + await using var stream = await response.Content.ReadAsStreamAsync(_cts.Token); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: _cts.Token); + + var root = doc.RootElement; + JsonElement fileArray; + if (root.ValueKind == JsonValueKind.Array) + fileArray = root; + else if (root.TryGetProperty("files", out var nested)) + fileArray = nested; + else + return []; + + var result = new List(); + foreach (var item in fileArray.EnumerateArray()) + { + var path = item.TryGetProperty("path", out var p) ? p.GetString() : null; + if (string.IsNullOrEmpty(path)) continue; + + var size = item.TryGetProperty("size", out var s) && s.TryGetInt64(out var sv) ? sv : 0L; + DateTime? modified = null; + if (item.TryGetProperty("date", out var d) && d.TryGetInt64(out var dv)) + modified = DateTimeOffset.FromUnixTimeSeconds(dv).DateTime; + + result.Add(new FileEntry { FullPath = path, Size = size, ModifiedDate = modified }); + } + + return result; + } + catch + { + return []; + } + } + + public async Task> GetCamerasAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected || string.IsNullOrEmpty(_printerUuid)) return []; + + try + { + using var response = await Client.GetAsync( + $"/api/v1/printers/{_printerUuid}/cameras", cancellationToken); + + if (!response.IsSuccessStatusCode) return []; + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + var root = doc.RootElement; + JsonElement cameraArray; + if (root.ValueKind == JsonValueKind.Array) + cameraArray = root; + else if (root.TryGetProperty("cameras", out var nested)) + cameraArray = nested; + else + return []; + + var cameras = new List(); + foreach (var cam in cameraArray.EnumerateArray()) + { + if (cam.TryGetProperty("registered", out var reg) && !reg.GetBoolean()) continue; + + var token = cam.TryGetProperty("token", out var tk) ? tk.GetString() : null; + if (string.IsNullOrEmpty(token)) continue; + + // fingerprint is in config.camera_id per the OpenAPI spec + string? fingerprint = null; + if (cam.TryGetProperty("config", out var cfg) && + cfg.TryGetProperty("camera_id", out var fp)) + fingerprint = fp.GetString(); + + if (string.IsNullOrEmpty(fingerprint)) continue; + + var name = cam.TryGetProperty("name", out var nm) ? nm.GetString() : null; + + cameras.Add(new PrinterCamera + { + Id = fingerprint, + DisplayName = name ?? "Camera", + SnapshotUrl = $"https://webcam.connect.prusa3d.com/c/snapshot?fingerprint={fingerprint}&token={token}", + IsEnabled = true, + }); + } + + return cameras; + } + catch + { + return []; + } + } + + // ── Unsupported cloud operations ────────────────────────────────────── + + public Task SetHotendTemp(int targetTemp = 0) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support temperature control.")); + + public Task SetBedTemp(int targetTemp = 0) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support temperature control.")); + + public Task Home(bool x = true, bool y = true, bool z = true) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support homing commands.")); + + public Task RelativeMove(int feedRate, float x = 0, float y = 0, float z = 0, float e = 0) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support move commands.")); + + public Task SetFanSpeed(int fanSpeedPercentage = 0) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support fan control.")); + + public Task SetPrintSpeed(int speed) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support print speed control.")); + + public Task SetPrintFlow(int flow) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support flow control.")); + + public Task SetAxisPerUnit(float x = 0, float y = 0, float z = 0, float e = 0) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support steps-per-unit control.")); + + public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support PID tuning.")); + + public Task RunThermalModelCalibration(int cycles, int targetTemp) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support thermal model calibration.")); + + public Task StartPrint(FileEntry file) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support starting prints remotely.")); + + public Task StartPrint(GCodeDoc gcodeDoc) => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support direct G-code printing.")); + + public Task SaveEEPROM() => + Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support EEPROM commands.")); + + public override ValueTask DisposeAsync() + { + _cts.Cancel(); + _httpClient?.Dispose(); + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } + + // ── Private helpers ─────────────────────────────────────────────────── + + private void ConfigureClient(string? bearerToken) + { + var client = Client; + client.BaseAddress ??= new Uri(BaseUrl); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (!string.IsNullOrEmpty(bearerToken)) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + } + + private async Task FetchPrinterAsync(CancellationToken ct) + { + using var response = await Client.GetAsync($"/api/v1/printers/{_printerUuid}", ct); + if (!response.IsSuccessStatusCode) return null; + + await using var stream = await response.Content.ReadAsStreamAsync(ct); + return await JsonSerializer.DeserializeAsync(stream, s_jsonOptions, ct); + } + + private async Task FetchTelemetryAsync(CancellationToken ct) + { + using var response = await Client.GetAsync($"/api/v1/printers/{_printerUuid}/telemetry", ct); + if (!response.IsSuccessStatusCode) return null; + + await using var stream = await response.Content.ReadAsStreamAsync(ct); + return await JsonSerializer.DeserializeAsync(stream, s_jsonOptions, ct); + } + + private void ApplyTelemetry(PrusaConnectMobileTelemetryResponse t) + { + LastTelemetry.HotendTemp = t.TempNozzle ?? LastTelemetry.HotendTemp; + LastTelemetry.HotendTarget = t.TargetNozzle ?? LastTelemetry.HotendTarget; + LastTelemetry.BedTemp = t.TempBed ?? LastTelemetry.BedTemp; + LastTelemetry.BedTarget = t.TargetBed ?? LastTelemetry.BedTarget; + LastTelemetry.FeedRate = t.PrintSpeed ?? LastTelemetry.FeedRate; + + if (t.ZHeight.HasValue) + LastTelemetry.Position = LastTelemetry.Position with { Z = t.ZHeight.Value }; + } + + private void ApplyPrinterState(PrusaConnectMobilePrinterResponse p) + { + LastTelemetry.Status = MapState(p.State); + + if (p.Telemetry is not null) + ApplyTelemetry(p.Telemetry); + + if (p.JobInfo is not null) + { + LastTelemetry.SDCard.Progress = p.JobInfo.Progress ?? LastTelemetry.SDCard.Progress; + LastTelemetry.SDCard.Printing = LastTelemetry.Status == PrinterStatus.Printing; + IsPrinting = LastTelemetry.SDCard.Printing; + + if (p.JobInfo.TimePrinting.HasValue) + LastTelemetry.PrintDuration = TimeSpan.FromSeconds(p.JobInfo.TimePrinting.Value); + } + + LastTelemetry.LastResponse = "PrusaConnect telemetry update"; + } + + private async Task SafePollAsync() + { + try { await GetPrinterTelemetryAsync(); } + catch { } + } + + private static PrinterStatus MapState(string? state) => state?.ToUpperInvariant() switch + { + "PRINTING" => PrinterStatus.Printing, + "PAUSED" => PrinterStatus.Paused, + "ERROR" or "ATTENTION" => PrinterStatus.Error, + "IDLE" or "READY" or "FINISHED" + or "STOPPED" => PrinterStatus.Connected, + _ => PrinterStatus.Disconnected, + }; +} + +// ── Response models ─────────────────────────────────────────────────────── + +public sealed class PrusaConnectMobilePrinterResponse +{ + [JsonPropertyName("uuid")] + public string? Uuid { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("printer_type")] + public string? PrinterType { get; set; } + + [JsonPropertyName("state")] + public string? State { get; set; } + + [JsonPropertyName("telemetry")] + public PrusaConnectMobileTelemetryResponse? Telemetry { get; set; } + + [JsonPropertyName("job_info")] + public PrusaConnectMobileJobInfo? JobInfo { get; set; } +} + +public sealed class PrusaConnectMobileTelemetryResponse +{ + [JsonPropertyName("temp_nozzle")] + public double? TempNozzle { get; set; } + + [JsonPropertyName("target_nozzle")] + public double? TargetNozzle { get; set; } + + [JsonPropertyName("temp_bed")] + public double? TempBed { get; set; } + + [JsonPropertyName("target_bed")] + public double? TargetBed { get; set; } + + [JsonPropertyName("print_speed")] + public int? PrintSpeed { get; set; } + + [JsonPropertyName("z_height")] + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public float? ZHeight { get; set; } +} + +public sealed class PrusaConnectMobileJobInfo +{ + [JsonPropertyName("progress")] + public double? Progress { get; set; } + + [JsonPropertyName("time_remaining")] + public int? TimeRemaining { get; set; } + + [JsonPropertyName("time_printing")] + public int? TimePrinting { get; set; } +} diff --git a/src/MakerPrompt.UI.Components/Services/PrusaConnectProvider.cs b/src/MakerPrompt.UI.Components/Services/PrusaConnectProvider.cs new file mode 100644 index 0000000..2a4ceca --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/PrusaConnectProvider.cs @@ -0,0 +1,86 @@ +namespace MakerPrompt.UI.Components.Services; + +/// +/// Discovers printers registered to a PrusaConnect account via the mobile API. +/// +/// Base URL: https://connect-mobile-api.prusa3d.com +/// Auth: Authorization: Bearer {token} +/// +/// Usage: +/// provider.Configure(bearerToken); +/// var printers = await provider.GetPrintersAsync(); // GET /api/v1/printers +/// +public sealed class PrusaConnectProvider : IPrinterProvider +{ + private const string BaseUrl = "https://connect-mobile-api.prusa3d.com"; + + private readonly HttpClient _httpClient; + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, + }; + + public PrusaConnectProvider() + { + _httpClient = new HttpClient { BaseAddress = new Uri(BaseUrl) }; + } + + // Constructor for testing with a custom handler. + public PrusaConnectProvider(HttpMessageHandler handler) + { + _httpClient = new HttpClient(handler, false) { BaseAddress = new Uri(BaseUrl) }; + } + + /// Sets the Bearer token used for all subsequent requests. + public void Configure(string bearerToken) + { + _httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", bearerToken); + } + + /// + /// Returns all printers associated with the configured account. + /// Returns an empty list on auth failure or network error. + /// + public async Task> GetPrintersAsync() + { + try + { + using var response = await _httpClient.GetAsync("/api/v1/printers?page=1&itemsPerPage=100"); + if (!response.IsSuccessStatusCode) return []; + + await using var stream = await response.Content.ReadAsStreamAsync(); + using var doc = await JsonDocument.ParseAsync(stream); + + var root = doc.RootElement; + JsonElement printerArray; + if (root.ValueKind == JsonValueKind.Array) + printerArray = root; + else if (root.TryGetProperty("printers", out var nested)) + printerArray = nested; + else + return []; + + var result = new List(); + foreach (var item in printerArray.EnumerateArray()) + { + var id = item.TryGetProperty("uuid", out var p1) ? p1.GetString() ?? string.Empty : string.Empty; + var name = item.TryGetProperty("name", out var p2) ? p2.GetString() ?? string.Empty : string.Empty; + var model = item.TryGetProperty("printer_type", out var p3) ? p3.GetString() ?? string.Empty : string.Empty; + var status = item.TryGetProperty("state", out var p4) ? p4.GetString() ?? string.Empty : string.Empty; + + if (string.IsNullOrEmpty(id)) continue; + + result.Add(new RemotePrinterInfo { Id = id, Name = name, Model = model, Status = status }); + } + + return result; + } + catch + { + return []; + } + } +} diff --git a/src/MakerPrompt.UI.Components/Services/PrusaLinkApiService.cs b/src/MakerPrompt.UI.Components/Services/PrusaLinkApiService.cs new file mode 100644 index 0000000..5bc8502 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/PrusaLinkApiService.cs @@ -0,0 +1,542 @@ +namespace MakerPrompt.UI.Components.Services; + +public class PrusaLinkApiService : BasePrinterConnectionService, IPrinterCommunicationService +{ + private readonly CancellationTokenSource _cts = new(); + private readonly HttpMessageHandler? _customHandler; + private HttpClient? _httpClient; + private bool _ownsClient; + private ApiConnectionSettings? _connectionSettings; + private Uri? _baseUri; + + public override PrinterConnectionType ConnectionType => PrinterConnectionType.PrusaLink; + + public PrusaLinkApiService() + { + } + + public PrusaLinkApiService(HttpMessageHandler handler) + { + _customHandler = handler; + _httpClient = new HttpClient(handler, false); + _ownsClient = true; + } + + private HttpClient Client + { + get + { + _httpClient ??= new HttpClient(); + return _httpClient; + } + } + + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + { + if (connectionSettings.Api is null) + { + throw new ArgumentException("PrusaLink connection requires API settings.", nameof(connectionSettings)); + } + + _connectionSettings = connectionSettings.Api; + ConfigureClient(_connectionSettings); + + try + { + var version = await GetVersionAsync(_cts.Token); + if (version == null) + { + IsConnected = false; + RaiseConnectionChanged(); + return IsConnected; + } + + var info = await GetInfoAsync(_cts.Token); + ConnectionName = _baseUri?.AbsoluteUri ?? _connectionSettings.Url; + LastTelemetry.PrinterName = info?.Name ?? LastTelemetry.PrinterName; + LastTelemetry.ConnectionTime ??= DateTime.UtcNow; + + updateTimer.Elapsed += async (_, _) => await SafeTelemetryAsync(); + updateTimer.Start(); + + IsConnected = true; + } + catch + { + IsConnected = false; + } + + RaiseConnectionChanged(); + return IsConnected; + } + + private async Task SafeTelemetryAsync() + { + try + { + await GetPrinterTelemetryAsync(); + } + catch + { + // swallow background polling errors to avoid breaking UI + } + } + + public async Task DisconnectAsync() + { + updateTimer.Stop(); + _cts.Cancel(); + _httpClient?.CancelPendingRequests(); + IsConnected = false; + RaiseConnectionChanged(); + await Task.CompletedTask; + } + + public Task WriteDataAsync(string command) + { + throw new NotSupportedException("Direct G-code injection is not supported by the PrusaLink API."); + } + + public async Task GetPrinterTelemetryAsync() + { + var status = await GetStatusAsync(_cts.Token); + if (status?.Printer is null) + { + return LastTelemetry; + } + + LastTelemetry.HotendTemp = status.Printer.TempNozzle ?? LastTelemetry.HotendTemp; + LastTelemetry.HotendTarget = status.Printer.TargetNozzle ?? LastTelemetry.HotendTarget; + LastTelemetry.BedTemp = status.Printer.TempBed ?? LastTelemetry.BedTemp; + LastTelemetry.BedTarget = status.Printer.TargetBed ?? LastTelemetry.BedTarget; + LastTelemetry.Position = new Vector3( + status.Printer.AxisX ?? LastTelemetry.Position.X, + status.Printer.AxisY ?? LastTelemetry.Position.Y, + status.Printer.AxisZ ?? LastTelemetry.Position.Z); + LastTelemetry.Status = MapPrusaStatus(status.Printer.State); + LastTelemetry.FanSpeed = status.Printer.FanPrint ?? LastTelemetry.FanSpeed; + LastTelemetry.FeedRate = status.Printer.Speed ?? LastTelemetry.FeedRate; + LastTelemetry.FlowRate = status.Printer.Flow ?? LastTelemetry.FlowRate; + + if (status.Job != null) + { + LastTelemetry.SDCard.Printing = status.Job.State?.Equals("PRINTING", StringComparison.OrdinalIgnoreCase) == true; + LastTelemetry.SDCard.Progress = status.Job.Progress ?? LastTelemetry.SDCard.Progress; + + if (status.Job.TimePrinting.HasValue) + LastTelemetry.PrintDuration = TimeSpan.FromSeconds(status.Job.TimePrinting.Value); + } + + if (status.Storage != null) + { + LastTelemetry.SDCard.Present = !status.Storage.ReadOnly; + } + + LastTelemetry.LastResponse = "PrusaLink status update"; + RaiseTelemetryUpdated(); + return LastTelemetry; + } + + public async Task> GetFilesAsync() + { + var storages = await GetStorageAsync(_cts.Token); + var firstStorage = storages?.StorageList?.FirstOrDefault(); + if (firstStorage == null) + { + return []; + } + + var storageKey = firstStorage.Path.Trim('/'); + var folder = await GetFolderAsync(storageKey, "/", _cts.Token); + if (folder?.Children == null) + { + return []; + } + + var storagePath = firstStorage.Path.TrimEnd('/'); + var result = new List(); + CollectFiles(folder.Children, storagePath, result); + return result; + } + + private static void CollectFiles(List entries, string parentPath, List result) + { + foreach (var child in entries) + { + if (string.Equals(child.Type, "FOLDER", StringComparison.OrdinalIgnoreCase)) + { + if (child.Children != null) + { + var folderPath = $"{parentPath}/{child.DisplayName ?? child.Name}"; + CollectFiles(child.Children, folderPath, result); + } + continue; + } + + result.Add(new FileEntry + { + FullPath = $"{parentPath}/{child.DisplayName ?? child.Name}", + Size = child.Size ?? 0, + ModifiedDate = child.Timestamp.HasValue + ? DateTimeOffset.FromUnixTimeSeconds(child.Timestamp.Value).DateTime + : null, + IsAvailable = !child.ReadOnly + }); + } + } + + public Task SetHotendTemp(int targetTemp = 0) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose hotend temperature control.")); + + public Task SetBedTemp(int targetTemp = 0) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose bed temperature control.")); + + public Task Home(bool x = true, bool y = true, bool z = true) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose homing commands.")); + + public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose move commands.")); + + public Task SetFanSpeed(int fanSpeedPercentage = 0) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose fan control.")); + + public Task SetPrintSpeed(int speed) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose print speed control.")); + + public Task SetPrintFlow(int flow) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose flow control.")); + + public Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose steps-per-unit control.")); + + public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose PID tuning commands.")); + + public Task RunThermalModelCalibration(int cycles, int targetTemp) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose thermal model calibration.")); + + public Task StartPrint(FileEntry file) => + Task.FromException(new NotSupportedException("Starting prints requires uploading with Print-After-Upload per PrusaLink spec.")); + + public Task StartPrint(GCodeDoc gcodeDoc) => + Task.FromException(new NotSupportedException("Direct G-code printing is not supported by the PrusaLink API.")); + + public Task SaveEEPROM() => + Task.FromException(new NotSupportedException("PrusaLink API does not expose EEPROM save commands.")); + + public override ValueTask DisposeAsync() + { + _cts.Cancel(); + if (_ownsClient) + { + _httpClient?.Dispose(); + } + + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } + + /// + /// PrusaLink / Prusa Connect cameras require additional registration and authentication + /// not currently configured in MakerPrompt. For now this returns an empty set so that + /// webcam UI remains disabled for this backend until full camera support is wired up. + /// + public Task> GetCamerasAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult((IReadOnlyList)Array.Empty()); + } + + private void ConfigureClient(ApiConnectionSettings settings) + { + _baseUri = new Uri(settings.Url); + // In Blazor WebAssembly (browser) we cannot use HttpClientHandler.Credentials or + // most handler-specific features. Instead, rely on the platform HttpClient and + // send Basic auth via headers when credentials are supplied. + if (OperatingSystem.IsBrowser()) + { + if (_customHandler != null) + { + _httpClient ??= new HttpClient(_customHandler, false); + _ownsClient = false; + } + else + { + _httpClient ??= new HttpClient(); + _ownsClient = true; + } + } + else + { + if (_customHandler != null) + { + _httpClient?.Dispose(); + _httpClient = new HttpClient(_customHandler, false); + _ownsClient = false; + } + else + { + _httpClient?.Dispose(); + var handler = new HttpClientHandler(); + if (!string.IsNullOrEmpty(settings.UserName) || !string.IsNullOrEmpty(settings.Password)) + { +#pragma warning disable CA1416 + handler.Credentials = new NetworkCredential(settings.UserName, settings.Password); + handler.PreAuthenticate = true; +#pragma warning restore CA1416 + } + + _httpClient = new HttpClient(handler); + _ownsClient = true; + } + } + + Client.BaseAddress = _baseUri; + Client.Timeout = TimeSpan.FromSeconds(30); + Client.DefaultRequestHeaders.Accept.Clear(); + Client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (!string.IsNullOrEmpty(settings.UserName) || !string.IsNullOrEmpty(settings.Password)) + { + var credentialBytes = Encoding.ASCII.GetBytes($"{settings.UserName}:{settings.Password}"); + Client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Basic", Convert.ToBase64String(credentialBytes)); + } + } + + private async Task GetVersionAsync(CancellationToken cancellationToken) => + await GetAsync("/api/version", cancellationToken); + + private async Task GetInfoAsync(CancellationToken cancellationToken) => + await GetAsync("/api/v1/info", cancellationToken); + + private async Task GetStatusAsync(CancellationToken cancellationToken) => + await GetAsync("/api/v1/status", cancellationToken); + + private async Task GetStorageAsync(CancellationToken cancellationToken) => + await GetAsync("/api/v1/storage", cancellationToken); + + private async Task GetFolderAsync(string storage, string path, CancellationToken cancellationToken) + { + var sanitizedStorage = storage.Trim('/'); + var encodedPath = Uri.EscapeDataString(path.TrimStart('/')); + var requestPath = $"/api/v1/files/{sanitizedStorage}/{encodedPath}"; + return await GetAsync(requestPath, cancellationToken); + } + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; + + private async Task GetAsync(string path, CancellationToken cancellationToken) + { + var request = new HttpRequestMessage(HttpMethod.Get, path); + var response = await SendWithRetryAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return default; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync(stream, s_jsonOptions, cancellationToken); + } + + private async Task SendWithRetryAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + const int maxAttempts = 3; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + var response = await Client.SendAsync(request, cancellationToken); + if (response.IsSuccessStatusCode || attempt == maxAttempts || (int)response.StatusCode < 500) + { + return response; + } + } + catch when (attempt < maxAttempts) + { + // transient failure, retry below + } + + await Task.Delay(TimeSpan.FromMilliseconds(200 * attempt), cancellationToken); + } + + return await Client.SendAsync(request, cancellationToken); + } + + private static PrinterStatus MapPrusaStatus(string? status) => status?.ToUpperInvariant() switch + { + "PRINTING" => PrinterStatus.Printing, + "PAUSED" => PrinterStatus.Paused, + "ERROR" => PrinterStatus.Error, + "READY" or "IDLE" => PrinterStatus.Connected, + _ => PrinterStatus.Disconnected + }; +} + +public sealed class PrusaVersionResponse +{ + [JsonPropertyName("api")] + public string Api { get; set; } = string.Empty; + + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + [JsonPropertyName("printer")] + public string Printer { get; set; } = string.Empty; + + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; + + [JsonPropertyName("firmware")] + public string Firmware { get; set; } = string.Empty; +} + +public sealed class PrusaInfoResponse +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("hostname")] + public string? Hostname { get; set; } +} + +public sealed class PrusaStatusResponse +{ + [JsonPropertyName("printer")] + public PrusaStatusPrinter? Printer { get; set; } + + [JsonPropertyName("job")] + public PrusaStatusJob? Job { get; set; } + + [JsonPropertyName("storage")] + public PrusaStatusStorage? Storage { get; set; } +} + +public sealed class PrusaStatusPrinter +{ + [JsonPropertyName("state")] + public string State { get; set; } = string.Empty; + + [JsonPropertyName("temp_nozzle")] + public double? TempNozzle { get; set; } + + [JsonPropertyName("target_nozzle")] + public double? TargetNozzle { get; set; } + + [JsonPropertyName("temp_bed")] + public double? TempBed { get; set; } + + [JsonPropertyName("target_bed")] + public double? TargetBed { get; set; } + + [JsonPropertyName("axis_x")] + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public float? AxisX { get; set; } + + [JsonPropertyName("axis_y")] + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public float? AxisY { get; set; } + + [JsonPropertyName("axis_z")] + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public float? AxisZ { get; set; } + + [JsonPropertyName("flow")] + public int? Flow { get; set; } + + [JsonPropertyName("speed")] + public int? Speed { get; set; } + + [JsonPropertyName("fan_print")] + public int? FanPrint { get; set; } +} + +public sealed class PrusaStatusJob +{ + [JsonPropertyName("id")] + public int? Id { get; set; } + + [JsonPropertyName("state")] + public string? State { get; set; } + + [JsonPropertyName("progress")] + public double? Progress { get; set; } + + [JsonPropertyName("time_remaining")] + public int? TimeRemaining { get; set; } + + [JsonPropertyName("time_printing")] + public int? TimePrinting { get; set; } +} + +public sealed class PrusaStatusStorage +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + [JsonPropertyName("read_only")] + public bool ReadOnly { get; set; } + + [JsonPropertyName("free_space")] + public long? FreeSpace { get; set; } +} + +public sealed class PrusaStorageListResponse +{ + [JsonPropertyName("storage_list")] + public List? StorageList { get; set; } +} + +public sealed class PrusaStorageItem +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; +} + +public sealed class PrusaFileSystemEntry +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("display_name")] + public string? DisplayName { get; set; } + + [JsonPropertyName("path")] + public string? Path { get; set; } + + // Some firmware versions use "ro", others use "read_only". + // PropertyNameCaseInsensitive + both properties covers all variants. + [JsonPropertyName("ro")] + public bool? Ro { get; set; } + + [JsonPropertyName("read_only")] + public bool? ReadOnlyFlag { get; set; } + + [JsonIgnore] + public bool ReadOnly => Ro ?? ReadOnlyFlag ?? false; + + [JsonPropertyName("size")] + public long? Size { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("m_timestamp")] + public long? Timestamp { get; set; } + + [JsonPropertyName("children")] + public List? Children { get; set; } +} diff --git a/src/MakerPrompt.UI.Components/Services/ThemeService.cs b/src/MakerPrompt.UI.Components/Services/ThemeService.cs new file mode 100644 index 0000000..3dd4290 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Services/ThemeService.cs @@ -0,0 +1,119 @@ +namespace MakerPrompt.UI.Components.Services +{ + public sealed class ThemeService : IAsyncDisposable + { + private readonly Lazy> _moduleTask; + private readonly IAppConfigurationService _configService; + private DotNetObjectReference _dotNetRef; + private IJSObjectReference? _jsModule; + + public Theme CurrentTheme { get; private set; } + public event Action? OnThemeChanged; + + public ThemeService(IJSRuntime jsRuntime, IAppConfigurationService configService) + { + _configService = configService; + _moduleTask = new(() => jsRuntime.InvokeAsync( + "import", "./_content/MakerPrompt.UI.Components/js/themeJsInterop.js").AsTask()); + + _dotNetRef = DotNetObjectReference.Create(this); + } + + public async Task InitializeAsync() + { + await _configService.InitializeAsync(); + CurrentTheme = _configService.Configuration.Theme; + + _jsModule = await _moduleTask.Value; + await _jsModule.InvokeVoidAsync("watchSystemTheme", _dotNetRef); + + await ApplyTheme(); + } + + public async Task SetThemeAsync(Theme theme) + { + CurrentTheme = theme; + _configService.Configuration.Theme = theme; + await _configService.SaveConfigurationAsync(); + + await ApplyTheme(); + OnThemeChanged?.Invoke(); + } + + private async Task ApplyTheme() + { + var effectiveTheme = CurrentTheme == Theme.Auto + ? await GetSystemTheme() + : CurrentTheme; + + await SetTheme(effectiveTheme); + UpdateNativeTheme(effectiveTheme); + } + + private async Task GetSystemTheme() + { + var isDark = await _jsModule!.InvokeAsync("isSystemDark"); + return isDark ? Theme.Dark : Theme.Light; + } + + private async Task SetTheme(Theme theme) + { + await _jsModule!.InvokeVoidAsync("setTheme", theme.ToString().ToLower()); + } + + private void UpdateNativeTheme(Theme theme) + { + //TODO + //try + //{ + // if (Application.Current != null) + // { + // Application.Current.Dispatcher.Dispatch(() => + // { + // Application.Current.UserAppTheme = theme switch + // { + // Theme.Light => AppTheme.Light, + // Theme.Dark => AppTheme.Dark, + // _ => Application.Current.RequestedTheme + // }; + // }); + // } + //} + //catch + //{ + // // Web platform - ignore native theme updates + //} + } + + [JSInvokable] + public async Task HandleSystemThemeChange(bool isDark) + { + if (CurrentTheme == Theme.Auto) + { + await ApplyTheme(); + OnThemeChanged?.Invoke(); + } + } + + public async ValueTask DisposeAsync() + { + if (_moduleTask.IsValueCreated) + { + if (_jsModule != null) + { + try + { + await _jsModule.InvokeVoidAsync("dispose"); + } + catch (JSDisconnectedException) { } + catch (JSException) { } + + await _jsModule.DisposeAsync(); + } + } + + _dotNetRef?.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/MakerPrompt.UI.Components/Utils/AppConfiguration.cs b/src/MakerPrompt.UI.Components/Utils/AppConfiguration.cs new file mode 100644 index 0000000..3271c03 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Utils/AppConfiguration.cs @@ -0,0 +1,31 @@ +namespace MakerPrompt.UI.Components.Utils +{ + public class AppConfiguration + { + public Theme Theme { get; set; } = Theme.Auto; + public string[] SupportedCultures { get; } = new string[] { "en-US", "de-DE", "tr-TR", "es-ES", "fr-FR", "it-IT", "pl-PL", "he-IL", "zh-CN" }; + public string Language { get; set; } = "en-US"; + public string FarmName { get; set; } = string.Empty; + public bool FarmModeEnabled { get; set; } = false; + public Guid? ActiveFarmId { get; set; } + public bool AnalyticsEnabled { get; set; } = true; + public bool EnableFilamentInventory { get; set; } = false; + public bool EnablePrintAnalytics { get; set; } = false; + public DateTime? LastUpdated { get; set; } + + // ── Deployment mode ───────────────────────────────────────────────── + // Sourced from appsettings.json (MakerPrompt:DeploymentMode) at startup. + // NOT persisted to localStorage — this is a deployment-time decision. + // Default: Standalone (no auth, direct printer connections). + [System.Text.Json.Serialization.JsonIgnore] + public AppDeploymentMode DeploymentMode { get; set; } = AppDeploymentMode.Standalone; + + /// + /// Base URL of the MakerPrompt Cloud API. + /// Required when DeploymentMode == CloudMakerspace. + /// Sourced from appsettings.json (MakerPrompt:CloudApiBaseUrl). + /// + [System.Text.Json.Serialization.JsonIgnore] + public string CloudApiBaseUrl { get; set; } = string.Empty; + } +} diff --git a/src/MakerPrompt.UI.Components/Utils/AppDeploymentMode.cs b/src/MakerPrompt.UI.Components/Utils/AppDeploymentMode.cs new file mode 100644 index 0000000..71fbea0 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Utils/AppDeploymentMode.cs @@ -0,0 +1,24 @@ +namespace MakerPrompt.UI.Components.Utils +{ + /// + /// Controls which deployment mode the app is running in. + /// The value is read from appsettings.json (MakerPrompt:DeploymentMode) at startup + /// and cannot be changed at runtime — it is a deployment-time decision. + /// + public enum AppDeploymentMode + { + /// + /// Default single-user / local mode. + /// Direct printer connections, local fleet management, no authentication required. + /// All existing backends (Moonraker, PrusaLink, Bambu, WebSerial, etc.) are available. + /// + Standalone = 0, + + /// + /// Makerspace / cloud-hosted mode. + /// OIDC authentication is required. Fleet configuration is served from the cloud API + /// and cannot be modified by the user. Farm mode is automatically enabled. + /// + CloudMakerspace = 1 + } +} diff --git a/src/MakerPrompt.UI.Components/Utils/Enums.cs b/src/MakerPrompt.UI.Components/Utils/Enums.cs new file mode 100644 index 0000000..58c7025 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Utils/Enums.cs @@ -0,0 +1,147 @@ +using System.ComponentModel.DataAnnotations; +using System.Resources; + +namespace MakerPrompt.UI.Components.Utils +{ + public class Enums + { + public enum MicrosteppingMode + { + [Display(Name = "1/1 (Full step)")] + FullStep = 1, + [Display(Name = "1/2 (Half step)")] + HalfStep = 2, + [Display(Name = "1/4")] + QuarterStep = 4, + [Display(Name = "1/8")] + EighthStep = 8, + [Display(Name = "1/16")] + SixteenthStep = 16, + [Display(Name = "1/32")] + ThirtySecondStep = 32, + [Display(Name = "1/64")] + SixtyFourthStep = 64, + [Display(Name = "1/128")] + OneTwentyEighthStep = 128 + } + + public enum MotorStepAngle + { + [Display(Name = "1.8° (200 steps/rev)")] + Step1_8 = 180, // 1.8° in tenths of degrees for precision + [Display(Name = "0.9° (400 steps/rev)")] + Step0_9 = 90, + [Display(Name = "7.5° (48 steps/rev)")] + Step7_5 = 75, + [Display(Name = "15° (24 steps/rev)")] + Step15 = 150 + } + + public enum PrinterConnectionType + { + [Display(Name = "Demo")] + Demo, + [Display(Name = "Serial")] + Serial, + [Display(Name = "Moonraker")] + Moonraker, + [Display(Name = "PrusaLink")] + PrusaLink, + [Display(Name = "PrusaConnect")] + PrusaConnect, + [Display(Name = "BambuLab")] + BambuLab, + [Display(Name = "OctoPrint")] + OctoPrint + } + + public enum PrinterStatus + { + [Display(Name = nameof(Resources.PrinterStatus_Disconnected), ResourceType = typeof(Resources))] + Disconnected, + [Display(Name = nameof(Resources.PrinterStatus_Connected), ResourceType = typeof(Resources))] + Connected, + [Display(Name = nameof(Resources.PrinterStatus_Printing), ResourceType = typeof(Resources))] + Printing, + [Display(Name = nameof(Resources.PrinterStatus_Paused), ResourceType = typeof(Resources))] + Paused, + [Display(Name = nameof(Resources.PrinterStatus_Error), ResourceType = typeof(Resources))] + Error + } + + public enum Theme + { + [Display(Name = nameof(Resources.Theme_Auto), ResourceType = typeof(Resources))] + Auto, + [Display(Name = nameof(Resources.Theme_Light), ResourceType = typeof(Resources))] + Light, + [Display(Name = nameof(Resources.Theme_Dark), ResourceType = typeof(Resources))] + Dark, + [Display(Name = "MakerPrompt")] + MpDark + } + + public enum GCodeCategory + { + [Display(Name = nameof(Resources.GCodeCategory_Temperature), ResourceType = typeof(Resources))] + Temperature, + + [Display(Name = nameof(Resources.GCodeCategory_Movement), ResourceType = typeof(Resources))] + Movement, + + [Display(Name = nameof(Resources.GCodeCategory_Fan), ResourceType = typeof(Resources))] + FanControl, + + [Display(Name = nameof(Resources.GCodeCategory_Settings), ResourceType = typeof(Resources))] + Settings, + + [Display(Name = nameof(Resources.GCodeCategory_Reporting), ResourceType = typeof(Resources))] + Reporting, + + [Display(Name = nameof(Resources.GCodeCategory_Calibration), ResourceType = typeof(Resources))] + Calibration, + + [Display(Name = nameof(Resources.GCodeCategory_SdCard), ResourceType = typeof(Resources))] + SDCard + } + } + + public static class EnumExtensions + { + public static IEnumerable GetAllValues() where T : Enum + { + return Enum.GetValues(typeof(T)).Cast(); + } + + public static List> GetMotorStepAngleOptions() + { + return [.. GetAllValues().Select(e => new KeyValuePair(e, e.GetDisplayName()))]; + } + + public static List> GetMicrosteppingOptions() + { + return [.. GetAllValues().Select(e => new KeyValuePair(e, e.GetDisplayName()))]; + } + + public static decimal GetStepAngleValue(this MotorStepAngle angle) + { + return (decimal)angle / 100m; + } + + public static string GetDisplayName(this Enum value) + { + var field = value.GetType().GetField(value.ToString()); + var attribute = field?.GetCustomAttribute(); + return attribute?.Name?? value.ToString(); + } + + public static string GetLocalizedDisplayName(this Enum value) + { + var rm = new ResourceManager(typeof(Resources)); + var name = value.GetDisplayName(); + var resourceDisplayName = rm.GetString(name); + + return string.IsNullOrWhiteSpace(resourceDisplayName) ? name : resourceDisplayName; + } + } +} diff --git a/src/MakerPrompt.UI.Components/Utils/GCodeCommands.cs b/src/MakerPrompt.UI.Components/Utils/GCodeCommands.cs new file mode 100644 index 0000000..30f5a57 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Utils/GCodeCommands.cs @@ -0,0 +1,274 @@ +namespace MakerPrompt.UI.Components.Utils +{ + internal class GCodeParameters + { + public static GCodeParameter TargetTemp = new('S', Resources.GCodeDescription_S_TargetTemp); + + public static GCodeParameter FanSpeed = new('S', "Speed (0-255)"); + + public static GCodeParameter RatePercentage = new('S', "Percentage"); + + public static GCodeParameter CalibrationCycle = new('C', Resources.GCodeDescription_C_Cycle); + + public static GCodeParameter HomeX = new('X', Resources.GCodeDescription_X_Position); + + public static GCodeParameter HomeY = new('Y', Resources.GCodeDescription_Y_Position); + + public static GCodeParameter HomeZ = new('Z', Resources.GCodeDescription_Z_Position); + + public static GCodeParameter PositionX = new('X', Resources.GCodeDescription_X_Position); + + public static GCodeParameter PositionY = new('Y', Resources.GCodeDescription_Y_Position); + + public static GCodeParameter PositionZ = new('Z', Resources.GCodeDescription_Z_Position); + + public static GCodeParameter PositionE = new('E', Resources.GCodeDescription_E_Position); + + public static GCodeParameter Feedrate = new('F', Resources.GCodeDescription_F_Feedrate); + + public static GCodeParameter FilePath = new('F', Resources.GCodeDescription_F_File); + + public static GCodeParameter SpindleInlineMode = new('I', Resources.GCodeDescription_I_InlineMode); + + public static GCodeParameter SpindlePwmPower = new('O', Resources.GCodeDescription_O_SpindlePower); + + public static GCodeParameter SpindleSpeed = new('S', Resources.GCodeDescription_S_SpindleSpeed); + + public static GCodeParameter Proportional = new('P', Resources.GCodeDescription_P_Proportional); + + public static GCodeParameter Integral = new('I', Resources.GCodeDescription_I_Integral); + + public static GCodeParameter Derivative = new('D', Resources.GCodeDescription_D_Derivative); + + } + internal static class GCodeCommands + { + public static GCodeCommand SetParameterValue(this GCodeCommand command, char label) + { + command.Parameters.First(p => p.Label.Equals(label)).Value = " "; + return command; + } + + public static GCodeCommand SetParameterValue(this GCodeCommand command, char label, string value) + { + command.Parameters.First(p => p.Label.Equals(label)).Value = value; + return command; + } + + public static List AllCommands() => typeof(GCodeCommands) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.FieldType == typeof(GCodeCommand)) + .Select(f => f.GetValue(null)) + .OfType() + .ToList(); + + // Movement Commands + public static GCodeCommand MoveLinearRapid = + new("G0", Resources.GCodeDescription_G0, [GCodeCategory.Movement], + [ GCodeParameters.PositionX, + GCodeParameters.PositionY, + GCodeParameters.PositionZ, + GCodeParameters.PositionE, + GCodeParameters.Feedrate]); + + public static GCodeCommand MoveLinear = + new("G1", Resources.GCodeDescription_G1, [GCodeCategory.Movement], + [ GCodeParameters.PositionX, + GCodeParameters.PositionY, + GCodeParameters.PositionZ, + GCodeParameters.PositionE, + GCodeParameters.Feedrate]); + + public static GCodeCommand Home = + new("G28", Resources.GCodeDescription_G28, [GCodeCategory.Movement], + [ GCodeParameters.HomeX, + GCodeParameters.HomeY, + GCodeParameters.HomeZ]); + + + public static GCodeCommand AbsolutePositioning = + new("G90", Resources.GCodeDescription_G90, [GCodeCategory.Movement]); + + public static GCodeCommand RelativePositioning = + new("G91", Resources.GCodeDescription_G91, [GCodeCategory.Movement]); + + public static GCodeCommand SpindleOnClockwise = + new("M3", Resources.GCodeDescription_M3, [GCodeCategory.Movement], + [ GCodeParameters.SpindleInlineMode, + GCodeParameters.SpindlePwmPower, + GCodeParameters.SpindleSpeed ]); + + public static GCodeCommand SpindleOnCounterClockwise = + new("M4", Resources.GCodeDescription_M4, [GCodeCategory.Movement], + [ GCodeParameters.SpindleInlineMode, + GCodeParameters.SpindlePwmPower, + GCodeParameters.SpindleSpeed ]); + + public static GCodeCommand SpindleOff = + new("M5", Resources.GCodeDescription_M5, [GCodeCategory.Movement]); + + public static GCodeCommand EnableSteppers = + new("M17", Resources.GCodeDescription_M17, [GCodeCategory.Movement]); + + public static GCodeCommand DisableSteppers = + new("M18", Resources.GCodeDescription_M18, [GCodeCategory.Movement]); + + // SD Card + public static GCodeCommand ListSDCard = + new("M20", Resources.GCodeDescription_M20, [GCodeCategory.SDCard], + [ + new('L', "Long format listing (optional)"), + new('T', "Timestamp (optional)") + ]); + + public static GCodeCommand InitSDCard = + new("M21", Resources.GCodeDescription_M21, [GCodeCategory.SDCard]); + + public static GCodeCommand ReleaseSDCard = + new("M22", Resources.GCodeDescription_M22, [GCodeCategory.SDCard]); + + public static GCodeCommand SelectSDFile = + new("M23", Resources.GCodeDescription_M23, [GCodeCategory.SDCard], + [ + // Note: Actual parameter is filename without letter prefix + new('F', "File path (e.g., 'model.gcode')") + ]); + + public static GCodeCommand StartSDPrint = + new("M24", Resources.GCodeDescription_M24, [GCodeCategory.SDCard], + [ + new('S', "Start position (bytes, optional)"), + new('T', "Start time (seconds, optional)") + ]); + + public static GCodeCommand PauseSDPrint = new("M25", Resources.GCodeDescription_M25, [GCodeCategory.SDCard]); + + public static GCodeCommand SetSDCardPosition = new("M26", Resources.GCodeDescription_M26, [GCodeCategory.SDCard], + [ + new('S', "Position in bytes") + ]); + + public static GCodeCommand ReportSDStatus = new("M27", Resources.GCodeDescription_M27, [GCodeCategory.SDCard], + [ + new('C', "Continuous reporting mode"), + new('S', "Interval in seconds") + ]); + + public static GCodeCommand WriteToSDCard = + new("M28", Resources.GCodeDescription_M28, [GCodeCategory.SDCard], [ GCodeParameters.FilePath ]); + + public static GCodeCommand EndSDWrite = + new("M29", Resources.GCodeDescription_M29, [GCodeCategory.SDCard]); + + public static GCodeCommand DeleteSDFile = + new("M30", Resources.GCodeDescription_M30, [GCodeCategory.SDCard], [GCodeParameters.FilePath]); + + public static GCodeCommand SelectAndStartPrint = + new("M32", Resources.GCodeDescription_M32, [GCodeCategory.SDCard], [GCodeParameters.FilePath]); + + public static GCodeCommand SetAxisSteps = + new("M92", Resources.GCodeDescription_M92, [GCodeCategory.Movement, GCodeCategory.Settings], + [ GCodeParameters.PositionX, + GCodeParameters.PositionY, + GCodeParameters.PositionZ, + GCodeParameters.PositionE ]); + + public static GCodeCommand SetTemp = + new("M104", Resources.GCodeDescription_M104, [GCodeCategory.Temperature], + [GCodeParameters.TargetTemp]); + + public static GCodeCommand GetTemperature = + new("M105", Resources.GCodeDescription_M105, [GCodeCategory.Temperature, GCodeCategory.Reporting]); + + public static GCodeCommand SetFanSpeed = + new("M106", Resources.GCodeDescription_M106, [GCodeCategory.Temperature, GCodeCategory.FanControl], + [GCodeParameters.FanSpeed ]); + + public static GCodeCommand FanOff = + new("M107", Resources.GCodeDescription_M107, [GCodeCategory.Temperature, GCodeCategory.FanControl]); + + public static GCodeCommand SetAndWaitTemp = + new("M109", Resources.GCodeDescription_M109, [GCodeCategory.Temperature], + [GCodeParameters.TargetTemp]); + + public static GCodeCommand GetCurrentPosition = + new("M114", Resources.GCodeDescription_M114, [GCodeCategory.Movement, GCodeCategory.Reporting]); + + public static GCodeCommand SetLcdMessage = + new("M117", Resources.GCodeDescription_M117, [GCodeCategory.Reporting], + [ new GCodeParameter('A', "Message")]); //fix + + public static GCodeCommand SetBedTemp = + new("M140", Resources.GCodeDescription_M140, [GCodeCategory.Temperature], + [GCodeParameters.TargetTemp]); + + public static GCodeCommand SetAndWaitBedTemp = + new("M190", Resources.GCodeDescription_M190, [GCodeCategory.Temperature], + [GCodeParameters.TargetTemp]); + + public static GCodeCommand SetFeedratePercentage = + new("M220", Resources.GCodeDescription_M220, [GCodeCategory.Movement, GCodeCategory.Settings], + [GCodeParameters.RatePercentage]); + + public static GCodeCommand SetFlowratePercentage = + new("M221", Resources.GCodeDescription_M221, [GCodeCategory.Movement, GCodeCategory.Settings], + [GCodeParameters.RatePercentage]); + + // Calibration Commands + public static readonly GCodeCommand SetHotendPid = + new("M301", Resources.GCodeDescription_M301, [GCodeCategory.Calibration, GCodeCategory.Settings], + [ GCodeParameters.Proportional, + GCodeParameters.Integral, + GCodeParameters.Derivative]); + + public static readonly GCodeCommand PidAutotune = new( + "M303", Resources.GCodeDescription_M303, [GCodeCategory.Calibration], + [ + new('E', "Extruder index"), + GCodeParameters.TargetTemp, + GCodeParameters.CalibrationCycle + ]); + + public static readonly GCodeCommand SetBedPid = + new("M304", Resources.GCodeDescription_M304, [GCodeCategory.Calibration, GCodeCategory.Settings], + [ GCodeParameters.Proportional, + GCodeParameters.Integral, + GCodeParameters.Derivative]); + + public static readonly GCodeCommand ThermalModelCalibration = new( + "M306", Resources.GCodeDescription_M306, [GCodeCategory.Calibration], + [ + GCodeParameters.TargetTemp, + GCodeParameters.CalibrationCycle + ]); + + public static GCodeCommand StoreEEPROM = + new ("M500", Resources.GCodeDescription_M500, [GCodeCategory.Settings]); + + public static GCodeCommand RestoreEEPROM = + new("M501", Resources.GCodeDescription_M501, [GCodeCategory.Settings]); + + public static GCodeCommand ResetEEPROM = + new("M502", Resources.GCodeDescription_M502, [GCodeCategory.Settings]); + + public static GCodeCommand ReportEEPROM = + new("M503", Resources.GCodeDescription_M503, [GCodeCategory.Settings]); + + public static GCodeCommand ValidateEEPROM = + new("M504", Resources.GCodeDescription_M504, [GCodeCategory.Settings]); + } + + internal static class GCodeCommandExtensions + { + internal static string GetCommandExample(this GCodeCommand command) + { + var example = command.Command; + if (command.Parameters.Count != 0) + { + example += " " + string.Join(" ", command.Parameters + .Select(p => $"{p.Label}123")); + } + return example; + } + } +} diff --git a/src/MakerPrompt.UI.Components/Utils/SerialException.cs b/src/MakerPrompt.UI.Components/Utils/SerialException.cs new file mode 100644 index 0000000..2b53bf0 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Utils/SerialException.cs @@ -0,0 +1,6 @@ +namespace MakerPrompt.UI.Components.Utils +{ + public class SerialException(string message, Exception inner) : Exception(message, inner) + { + } +} diff --git a/src/MakerPrompt.UI.Components/Utils/ServiceCollectionExtensions.cs b/src/MakerPrompt.UI.Components/Utils/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..26ae489 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Utils/ServiceCollectionExtensions.cs @@ -0,0 +1,48 @@ +using System.Globalization; +using Microsoft.Extensions.DependencyInjection; + +namespace MakerPrompt.UI.Components.Utils +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection RegisterMakerPromptSharedServices(this IServiceCollection services) + where P : class, IAppConfigurationService + where L : class, ISerialService + { + CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US"); + CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo("en-US"); + + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddLocalization(options => + { + options.ResourcesPath = "Resources"; + }); + services.AddHttpClient(); + services.AddBlazorBootstrap(); + + return services; + } + } +} diff --git a/src/MakerPrompt.UI.Components/_Imports.razor b/src/MakerPrompt.UI.Components/_Imports.razor index 643a970..8b0ffbe 100644 --- a/src/MakerPrompt.UI.Components/_Imports.razor +++ b/src/MakerPrompt.UI.Components/_Imports.razor @@ -1,12 +1,17 @@ -@using System.Net.Http -@using System.Net.Http.Json -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using Microsoft.AspNetCore.Components.Web.Virtualization -@using Microsoft.Extensions.Logging -@using MakerPrompt.Core.Abstractions -@using MakerPrompt.Core.Models -@using MakerPrompt.Application.Services -@using MakerPrompt.UI.Components.Layout -@using MakerPrompt.UI.Components.Pages +@using System.ComponentModel +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.Extensions.Localization +@using Microsoft.Extensions.Logging +@using MakerPrompt.UI.Components.Models +@using MakerPrompt.UI.Components.Infrastructure +@using MakerPrompt.UI.Components.Utils +@using MakerPrompt.UI.Components.Components +@using MakerPrompt.UI.Components.Components.Calculators +@using MakerPrompt.UI.Components.Layout +@using MakerPrompt.UI.Components.Properties +@using MakerPrompt.UI.Components.Services +@using BlazorBootstrap +@using EnumExtensions = MakerPrompt.UI.Components.Utils.EnumExtensions \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/libman.json b/src/MakerPrompt.UI.Components/libman.json new file mode 100644 index 0000000..6f96d4b --- /dev/null +++ b/src/MakerPrompt.UI.Components/libman.json @@ -0,0 +1,37 @@ +{ + "version": "3.0", + "defaultProvider": "cdnjs", + "libraries": [ + { + "provider": "cdnjs", + "library": "bootstrap@5.3.3", + "destination": "wwwroot/lib/bootstrap/", + "files": [ + "js/bootstrap.min.js", + "js/bootstrap.bundle.min.js", + "js/bootstrap.bundle.js.map", + "css/bootstrap.min.css" + ] + }, + { + "provider": "cdnjs", + "library": "bootstrap-icons@1.11.3", + "destination": "wwwroot/lib/bootstrap-icons/", + "files": [ + "font/fonts/bootstrap-icons.woff", + "font/fonts/bootstrap-icons.woff2", + "font/bootstrap-icons.json", + "font/bootstrap-icons.min.css" + ] + }, + { + "provider": "unpkg", + "library": "gcode-viewer@0.7.1", + "destination": "wwwroot/lib/gcode-viewer/", + "files": [ + "_bundles/gcode-viewer.min.js", + "_bundles/gcode-viewer.min.js.map" + ] + } + ] +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/usings.cs b/src/MakerPrompt.UI.Components/usings.cs new file mode 100644 index 0000000..e202659 --- /dev/null +++ b/src/MakerPrompt.UI.Components/usings.cs @@ -0,0 +1,15 @@ +global using static MakerPrompt.UI.Components.Utils.Enums; +global using System.Text; +global using System.Text.Json; +global using System.Text.RegularExpressions; +global using System.Text.Json.Serialization; +global using System.Reflection; +global using System.Numerics; +global using System.Net; +global using System.Net.Http.Headers; +global using MakerPrompt.UI.Components.Infrastructure; +global using MakerPrompt.UI.Components.Properties; +global using MakerPrompt.UI.Components.Services; +global using MakerPrompt.UI.Components.Models; +global using MakerPrompt.UI.Components.Utils; +global using Microsoft.JSInterop; \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/wwwroot/css/app.css b/src/MakerPrompt.UI.Components/wwwroot/css/app.css new file mode 100644 index 0000000..1d5d78e --- /dev/null +++ b/src/MakerPrompt.UI.Components/wwwroot/css/app.css @@ -0,0 +1,402 @@ +html, body { + margin: 0; + height: 100%; +} + +body { + overflow: hidden; /* avoid scrollbars from the oversized logical box */ + /* Global 80 % zoom applied on so that BOTH the #app content + AND Bootstrap's modal-backdrop (which JS appends to ) share + the same coordinate system. Using `zoom` (not `transform`) keeps + position:fixed elements working correctly. */ + zoom: 0.8; +} + +#app { + width: 100%; + height: 100%; + + display: flex; + flex-direction: column; +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.loading-progress { + position: relative; + display: block; + width: 8rem; + height: 8rem; + margin: 20vh auto 1rem auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +/* ─── Layout ───────────────────────────────────────────────── */ + +.layout-body { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.layout-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +.layout-content { + flex: 1; + display: flex; + min-height: 0; + overflow: hidden; +} + +.layout-content.resizing { + user-select: none; + -webkit-user-select: none; +} + +.layout-page { + flex: 1; + min-width: 0; + overflow-y: auto; + padding: 0 1.5rem; +} + +.layout-right { + width: var(--layout-right-width, 38%); + min-width: 600px; + max-width: 1600px; + display: flex; + flex-direction: column; + overflow: hidden; + border-left: 1px solid var(--bs-border-color); +} + +.layout-resizer { + width: 5px; + cursor: col-resize; + background-color: transparent; + transition: background-color 0.2s; + z-index: 10; +} + +.layout-resizer:hover, +.layout-resizer.active { + background-color: var(--bs-primary); +} + +@media (max-width: 991.98px) { + .layout-right { display: none; } +} + +.layout-right-top, +.layout-right-bottom { + flex: 1; + min-height: 0; + overflow: hidden; + padding: 0.5rem; +} + +/* ─── Sidebar ───────────────────────────────────────────────── */ + +.sidebar { + width: 220px; + min-width: 220px; + background: var(--bs-secondary-bg); + border-right: 1px solid var(--bs-border-color); + transition: width 0.25s ease, min-width 0.25s ease; + overflow: hidden; + flex-shrink: 0; +} + +/* Collapsed = icon-only strip (48px) so the toggle button stays reachable */ +.sidebar.sidebar--collapsed { + width: 48px; + min-width: 48px; +} + +/* Brand in header tracks sidebar width */ +.navbar-brand { + width: 220px; + min-width: 220px; + transition: width 0.25s ease, min-width 0.25s ease; + overflow: hidden; + white-space: nowrap; + padding-top: .75rem; + padding-bottom: .75rem; + box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25); +} + +.navbar-brand.brand--collapsed { + width: 48px; + min-width: 48px; +} + +.brand-label { + opacity: 1; + transition: opacity 0.2s ease; +} + +.brand--collapsed .brand-label { + opacity: 0; +} + +.sidebar-icon { + flex-shrink: 0; + width: 1.1rem; + text-align: center; +} + +/* Labels fade+clip when collapsed */ +.sidebar-label { + margin-left: 0.6rem; + opacity: 1; + transition: opacity 0.2s ease; + white-space: nowrap; + overflow: hidden; +} + +.sidebar--collapsed .sidebar-label { + opacity: 0; + pointer-events: none; +} + +.sidebar-link, +.sidebar-toggle-btn { + display: flex; + align-items: center; + white-space: nowrap; + transition: background 0.15s ease; + color: var(--bs-body-color) !important; + text-decoration: none !important; +} + +.sidebar-link:hover, +.sidebar-toggle-btn:hover { + background: rgba(var(--bs-emphasis-color-rgb, 0,0,0), 0.06); +} + +.sidebar .nav-link.active { + color: var(--bs-body-text-emphasis); + background: rgba(var(--bs-emphasis-color-rgb, 0,0,0), 0.09); + font-weight: 500; +} + +/* ─── Misc ───────────────────────────────────────────────────── */ + +.action-button { + opacity: 0; + transition: opacity 0.2s ease-in-out; +} + +tr:hover .action-button { + opacity: 1; +} + +.alert:hover .action-button { + opacity: 1; +} + +/* + * Navbar + */ + +.navbar .navbar-toggler { + top: .25rem; + right: 1rem; +} + + [data-bs-theme=mpdark] { + color-scheme: dark; + --bs-body-color: #EBF8FF; + --bs-body-color-rgb: 235, 248, 255; + --bs-body-bg: #030027; + --bs-body-bg-rgb: 3, 0, 39; + --bs-emphasis-color: #fff; + --bs-emphasis-color-rgb: 255, 255, 255; + --bs-secondary-color: rgba(235, 248, 255, 0.75); + --bs-secondary-color-rgb: 235, 248, 255; + --bs-secondary-bg: #151E3F; + --bs-secondary-bg-rgb: 21, 30, 63; + --bs-tertiary-color: rgba(235, 248, 255, 0.5); + --bs-tertiary-color-rgb: 235, 248, 255; + --bs-tertiary-bg: #0D1026; + --bs-tertiary-bg-rgb: 13, 16, 38; + + /* Text emphasis colors */ + --bs-primary-text-emphasis: #85D4FF; + --bs-secondary-text-emphasis: #7A9090; + --bs-success-text-emphasis: #CEFF1F; + --bs-info-text-emphasis: #85D4FF; + --bs-warning-text-emphasis: #F2F3D9; + --bs-danger-text-emphasis: #EE4266; + --bs-light-text-emphasis: #EBF8FF; + --bs-dark-text-emphasis: #232C2F; + + /* Background subtle colors */ + --bs-primary-bg-subtle: #001C2E; + --bs-secondary-bg-subtle: #0A0D1F; + --bs-success-bg-subtle: #2B3A00; + --bs-info-bg-subtle: #002A3D; + --bs-warning-bg-subtle: #3D3E2F; + --bs-danger-bg-subtle: #3D000D; + --bs-light-bg-subtle: #151E3F; + --bs-dark-bg-subtle: #0D1113; + + /* Border subtle colors */ + --bs-primary-border-subtle: #0066A3; + --bs-secondary-border-subtle: #3D4B6B; + --bs-success-border-subtle: #A3CC00; + --bs-info-border-subtle: #0088C2; + --bs-warning-border-subtle: #B8B99D; + --bs-danger-border-subtle: #C2001A; + --bs-light-border-subtle: #3D4B6B; + --bs-dark-border-subtle: #3D4548; + + /* Other variables */ + --bs-heading-color: inherit; + --bs-link-color: #85D4FF; + --bs-link-hover-color: #A8DFFF; + --bs-link-color-rgb: 133, 212, 255; + --bs-link-hover-color-rgb: 168, 223, 255; + --bs-code-color: #EE4266; + --bs-highlight-color: #F2F3D9; + --bs-highlight-bg: #3D3E2F; + --bs-border-color: #151E3F; + --bs-border-color-translucent: rgba(122, 144, 144, 0.15); + --bs-form-valid-color: #CEFF1F; + --bs-form-valid-border-color: #CEFF1F; + --bs-form-invalid-color: #EE4266; + --bs-form-invalid-border-color: #EE4266; + } +} + +/* ─── Dashboard ──────────────────────────────────────────────── */ + +.dash-print-banner { + border: 1px solid var(--bs-success-border-subtle); + background: linear-gradient(135deg, var(--bs-secondary-bg), var(--bs-body-bg)); +} + +/* SVG progress ring */ +.dash-ring-wrap { + position: relative; + width: 72px; + height: 72px; + flex-shrink: 0; +} + +.dash-ring-label { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + font-size: 0.8rem; + line-height: 1.1; + pointer-events: none; +} + +.dash-ring-label strong { + font-size: 1rem; + line-height: 1; +} + +/* Stat boxes in the print banner */ +.dash-stat-box { + text-align: center; + min-width: 52px; +} + +.dash-stat-box small { + display: block; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.dash-stat-box strong { + font-size: 1rem; + display: block; + line-height: 1.2; +} + +/* Position readout boxes */ +.dash-pos-box { + background: var(--bs-secondary-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.375rem; + padding: 0.3rem 0.4rem; +} + +.dash-pos-box span { + font-size: 0.95rem; +} + +/* Temperature rows with divider */ +.dash-temp-row { + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--bs-border-color-translucent); +} + +.dash-temp-row:last-child { + padding-bottom: 0; + border-bottom: none; +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/wwwroot/favicon.png b/src/MakerPrompt.UI.Components/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..0883a23a809b28430dd649c30089e11704af51d8 GIT binary patch literal 32501 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 literal 0 HcmV?d00001 diff --git a/src/MakerPrompt.UI.Components/wwwroot/js/makerpromptJsInterop.js b/src/MakerPrompt.UI.Components/wwwroot/js/makerpromptJsInterop.js new file mode 100644 index 0000000..7c880bd --- /dev/null +++ b/src/MakerPrompt.UI.Components/wwwroot/js/makerpromptJsInterop.js @@ -0,0 +1,93 @@ +// This is a JavaScript module that is loaded on demand. It can export any number of +// functions, and may import other JavaScript modules if required. + +export function showPrompt(message) { + return prompt(message, 'Type anything here'); +} + +// Track a viewer instance per container to avoid leaking a global renderer +// Uses gcode-preview (https://github.com/remcoder/gcode-preview) — MIT license +const viewers = new WeakMap(); + +export async function initializeViewer(container, gcodeContent) { + // Dispose any existing instance first + disposeViewer(container); + + // Binary bgcode files start with the magic bytes "GCDE" — cannot be visualised + if (gcodeContent.startsWith('GCDE')) { + throw new Error('Binary G-code (bgcode) cannot be visualised. Use the text view.'); + } + + // Wait for the browser to compute layout so getBoundingClientRect returns real dimensions. + // This is necessary when the container was just added to the DOM by a conditional render. + await new Promise(resolve => requestAnimationFrame(resolve)); + + const rect = container.getBoundingClientRect(); + if (!document.body.contains(container)) return; // container unmounted during await + + // Create a canvas inside the container div + const canvas = document.createElement('canvas'); + canvas.width = Math.max(rect.width || 800, 100); + canvas.height = Math.max(rect.height || 600, 100); + canvas.style.display = 'block'; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + container.appendChild(canvas); + + // Verify WebGL availability using a SEPARATE temporary canvas. + // The main canvas must not have getContext() called before GCodePreview.init(): + // a canvas can only hold one rendering context, so pre-acquiring it here would + // prevent Three.js from creating its own context, silently breaking the renderer. + const testCanvas = document.createElement('canvas'); + const gl = testCanvas.getContext('webgl2') || testCanvas.getContext('webgl') || testCanvas.getContext('experimental-webgl'); + if (!gl) { + container.innerHTML = ''; + throw new Error('WebGL is not available on this device. Switch to text view.'); + } + + const preview = GCodePreview.init({ + canvas, + buildVolume: { x: 220, y: 220, z: 250 }, + lineWidth: 2, + }); + + preview.processGCode(gcodeContent); + viewers.set(container, preview); +} + +export function disposeViewer(container) { + const preview = viewers.get(container); + if (!preview) return; + + viewers.delete(container); + try { if (typeof preview.dispose === 'function') preview.dispose(); } catch { /* ignore */ } + container.innerHTML = ''; +} + +// Scroll a container to the bottom; used by the command prompt history. +export function scrollToBottom(element) { + if (!element) { + return; + } + + // Support both plain elements and Blazor's ElementReference wrappers. + const target = element instanceof HTMLElement ? element : element.firstElementChild || element; + if (!target) { + return; + } + + target.scrollTop = target.scrollHeight; +} + +// Triggers a browser file download from an in-memory string. +export function downloadFile(filename, content) { + const blob = new Blob([content], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/wwwroot/js/themeJsInterop.js b/src/MakerPrompt.UI.Components/wwwroot/js/themeJsInterop.js new file mode 100644 index 0000000..e787aca --- /dev/null +++ b/src/MakerPrompt.UI.Components/wwwroot/js/themeJsInterop.js @@ -0,0 +1,24 @@ +export function watchSystemTheme(dotNetHelper) { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handler = (e) => { + dotNetHelper.invokeMethodAsync('HandleSystemThemeChange', e.matches); + }; + + mediaQuery.addEventListener('change', handler); + return { + dispose: () => mediaQuery.removeEventListener('change', handler) + }; +} + +export function setTheme(theme) { + document.documentElement.setAttribute('data-bs-theme', theme); + // color-scheme only accepts 'light', 'dark', or 'auto' — custom + // theme names like 'mpdark' are invalid and break WebView2 rendering. + const scheme = theme === 'light' ? 'light' : 'dark'; + document.documentElement.style.colorScheme = scheme; +} + +export function isSystemDark() { + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap-icons/font/bootstrap-icons.json b/src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap-icons/font/bootstrap-icons.json new file mode 100644 index 0000000..56247e5 --- /dev/null +++ b/src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap-icons/font/bootstrap-icons.json @@ -0,0 +1,2052 @@ +{ + "123": 63103, + "alarm-fill": 61697, + "alarm": 61698, + "align-bottom": 61699, + "align-center": 61700, + "align-end": 61701, + "align-middle": 61702, + "align-start": 61703, + "align-top": 61704, + "alt": 61705, + "app-indicator": 61706, + "app": 61707, + "archive-fill": 61708, + "archive": 61709, + "arrow-90deg-down": 61710, + "arrow-90deg-left": 61711, + "arrow-90deg-right": 61712, + "arrow-90deg-up": 61713, + "arrow-bar-down": 61714, + "arrow-bar-left": 61715, + "arrow-bar-right": 61716, + "arrow-bar-up": 61717, + "arrow-clockwise": 61718, + "arrow-counterclockwise": 61719, + "arrow-down-circle-fill": 61720, + "arrow-down-circle": 61721, + "arrow-down-left-circle-fill": 61722, + "arrow-down-left-circle": 61723, + "arrow-down-left-square-fill": 61724, + "arrow-down-left-square": 61725, + "arrow-down-left": 61726, + "arrow-down-right-circle-fill": 61727, + "arrow-down-right-circle": 61728, + "arrow-down-right-square-fill": 61729, + "arrow-down-right-square": 61730, + "arrow-down-right": 61731, + "arrow-down-short": 61732, + "arrow-down-square-fill": 61733, + "arrow-down-square": 61734, + "arrow-down-up": 61735, + "arrow-down": 61736, + "arrow-left-circle-fill": 61737, + "arrow-left-circle": 61738, + "arrow-left-right": 61739, + "arrow-left-short": 61740, + "arrow-left-square-fill": 61741, + "arrow-left-square": 61742, + "arrow-left": 61743, + "arrow-repeat": 61744, + "arrow-return-left": 61745, + "arrow-return-right": 61746, + "arrow-right-circle-fill": 61747, + "arrow-right-circle": 61748, + "arrow-right-short": 61749, + "arrow-right-square-fill": 61750, + "arrow-right-square": 61751, + "arrow-right": 61752, + "arrow-up-circle-fill": 61753, + "arrow-up-circle": 61754, + "arrow-up-left-circle-fill": 61755, + "arrow-up-left-circle": 61756, + "arrow-up-left-square-fill": 61757, + "arrow-up-left-square": 61758, + "arrow-up-left": 61759, + "arrow-up-right-circle-fill": 61760, + "arrow-up-right-circle": 61761, + "arrow-up-right-square-fill": 61762, + "arrow-up-right-square": 61763, + "arrow-up-right": 61764, + "arrow-up-short": 61765, + "arrow-up-square-fill": 61766, + "arrow-up-square": 61767, + "arrow-up": 61768, + "arrows-angle-contract": 61769, + "arrows-angle-expand": 61770, + "arrows-collapse": 61771, + "arrows-expand": 61772, + "arrows-fullscreen": 61773, + "arrows-move": 61774, + "aspect-ratio-fill": 61775, + "aspect-ratio": 61776, + "asterisk": 61777, + "at": 61778, + "award-fill": 61779, + "award": 61780, + "back": 61781, + "backspace-fill": 61782, + "backspace-reverse-fill": 61783, + "backspace-reverse": 61784, + "backspace": 61785, + "badge-3d-fill": 61786, + "badge-3d": 61787, + "badge-4k-fill": 61788, + "badge-4k": 61789, + "badge-8k-fill": 61790, + "badge-8k": 61791, + "badge-ad-fill": 61792, + "badge-ad": 61793, + "badge-ar-fill": 61794, + "badge-ar": 61795, + "badge-cc-fill": 61796, + "badge-cc": 61797, + "badge-hd-fill": 61798, + "badge-hd": 61799, + "badge-tm-fill": 61800, + "badge-tm": 61801, + "badge-vo-fill": 61802, + "badge-vo": 61803, + "badge-vr-fill": 61804, + "badge-vr": 61805, + "badge-wc-fill": 61806, + "badge-wc": 61807, + "bag-check-fill": 61808, + "bag-check": 61809, + "bag-dash-fill": 61810, + "bag-dash": 61811, + "bag-fill": 61812, + "bag-plus-fill": 61813, + "bag-plus": 61814, + "bag-x-fill": 61815, + "bag-x": 61816, + "bag": 61817, + "bar-chart-fill": 61818, + "bar-chart-line-fill": 61819, + "bar-chart-line": 61820, + "bar-chart-steps": 61821, + "bar-chart": 61822, + "basket-fill": 61823, + "basket": 61824, + "basket2-fill": 61825, + "basket2": 61826, + "basket3-fill": 61827, + "basket3": 61828, + "battery-charging": 61829, + "battery-full": 61830, + "battery-half": 61831, + "battery": 61832, + "bell-fill": 61833, + "bell": 61834, + "bezier": 61835, + "bezier2": 61836, + "bicycle": 61837, + "binoculars-fill": 61838, + "binoculars": 61839, + "blockquote-left": 61840, + "blockquote-right": 61841, + "book-fill": 61842, + "book-half": 61843, + "book": 61844, + "bookmark-check-fill": 61845, + "bookmark-check": 61846, + "bookmark-dash-fill": 61847, + "bookmark-dash": 61848, + "bookmark-fill": 61849, + "bookmark-heart-fill": 61850, + "bookmark-heart": 61851, + "bookmark-plus-fill": 61852, + "bookmark-plus": 61853, + "bookmark-star-fill": 61854, + "bookmark-star": 61855, + "bookmark-x-fill": 61856, + "bookmark-x": 61857, + "bookmark": 61858, + "bookmarks-fill": 61859, + "bookmarks": 61860, + "bookshelf": 61861, + "bootstrap-fill": 61862, + "bootstrap-reboot": 61863, + "bootstrap": 61864, + "border-all": 61865, + "border-bottom": 61866, + "border-center": 61867, + "border-inner": 61868, + "border-left": 61869, + "border-middle": 61870, + "border-outer": 61871, + "border-right": 61872, + "border-style": 61873, + "border-top": 61874, + "border-width": 61875, + "border": 61876, + "bounding-box-circles": 61877, + "bounding-box": 61878, + "box-arrow-down-left": 61879, + "box-arrow-down-right": 61880, + "box-arrow-down": 61881, + "box-arrow-in-down-left": 61882, + "box-arrow-in-down-right": 61883, + "box-arrow-in-down": 61884, + "box-arrow-in-left": 61885, + "box-arrow-in-right": 61886, + "box-arrow-in-up-left": 61887, + "box-arrow-in-up-right": 61888, + "box-arrow-in-up": 61889, + "box-arrow-left": 61890, + "box-arrow-right": 61891, + "box-arrow-up-left": 61892, + "box-arrow-up-right": 61893, + "box-arrow-up": 61894, + "box-seam": 61895, + "box": 61896, + "braces": 61897, + "bricks": 61898, + "briefcase-fill": 61899, + "briefcase": 61900, + "brightness-alt-high-fill": 61901, + "brightness-alt-high": 61902, + "brightness-alt-low-fill": 61903, + "brightness-alt-low": 61904, + "brightness-high-fill": 61905, + "brightness-high": 61906, + "brightness-low-fill": 61907, + "brightness-low": 61908, + "broadcast-pin": 61909, + "broadcast": 61910, + "brush-fill": 61911, + "brush": 61912, + "bucket-fill": 61913, + "bucket": 61914, + "bug-fill": 61915, + "bug": 61916, + "building": 61917, + "bullseye": 61918, + "calculator-fill": 61919, + "calculator": 61920, + "calendar-check-fill": 61921, + "calendar-check": 61922, + "calendar-date-fill": 61923, + "calendar-date": 61924, + "calendar-day-fill": 61925, + "calendar-day": 61926, + "calendar-event-fill": 61927, + "calendar-event": 61928, + "calendar-fill": 61929, + "calendar-minus-fill": 61930, + "calendar-minus": 61931, + "calendar-month-fill": 61932, + "calendar-month": 61933, + "calendar-plus-fill": 61934, + "calendar-plus": 61935, + "calendar-range-fill": 61936, + "calendar-range": 61937, + "calendar-week-fill": 61938, + "calendar-week": 61939, + "calendar-x-fill": 61940, + "calendar-x": 61941, + "calendar": 61942, + "calendar2-check-fill": 61943, + "calendar2-check": 61944, + "calendar2-date-fill": 61945, + "calendar2-date": 61946, + "calendar2-day-fill": 61947, + "calendar2-day": 61948, + "calendar2-event-fill": 61949, + "calendar2-event": 61950, + "calendar2-fill": 61951, + "calendar2-minus-fill": 61952, + "calendar2-minus": 61953, + "calendar2-month-fill": 61954, + "calendar2-month": 61955, + "calendar2-plus-fill": 61956, + "calendar2-plus": 61957, + "calendar2-range-fill": 61958, + "calendar2-range": 61959, + "calendar2-week-fill": 61960, + "calendar2-week": 61961, + "calendar2-x-fill": 61962, + "calendar2-x": 61963, + "calendar2": 61964, + "calendar3-event-fill": 61965, + "calendar3-event": 61966, + "calendar3-fill": 61967, + "calendar3-range-fill": 61968, + "calendar3-range": 61969, + "calendar3-week-fill": 61970, + "calendar3-week": 61971, + "calendar3": 61972, + "calendar4-event": 61973, + "calendar4-range": 61974, + "calendar4-week": 61975, + "calendar4": 61976, + "camera-fill": 61977, + "camera-reels-fill": 61978, + "camera-reels": 61979, + "camera-video-fill": 61980, + "camera-video-off-fill": 61981, + "camera-video-off": 61982, + "camera-video": 61983, + "camera": 61984, + "camera2": 61985, + "capslock-fill": 61986, + "capslock": 61987, + "card-checklist": 61988, + "card-heading": 61989, + "card-image": 61990, + "card-list": 61991, + "card-text": 61992, + "caret-down-fill": 61993, + "caret-down-square-fill": 61994, + "caret-down-square": 61995, + "caret-down": 61996, + "caret-left-fill": 61997, + "caret-left-square-fill": 61998, + "caret-left-square": 61999, + "caret-left": 62000, + "caret-right-fill": 62001, + "caret-right-square-fill": 62002, + "caret-right-square": 62003, + "caret-right": 62004, + "caret-up-fill": 62005, + "caret-up-square-fill": 62006, + "caret-up-square": 62007, + "caret-up": 62008, + "cart-check-fill": 62009, + "cart-check": 62010, + "cart-dash-fill": 62011, + "cart-dash": 62012, + "cart-fill": 62013, + "cart-plus-fill": 62014, + "cart-plus": 62015, + "cart-x-fill": 62016, + "cart-x": 62017, + "cart": 62018, + "cart2": 62019, + "cart3": 62020, + "cart4": 62021, + "cash-stack": 62022, + "cash": 62023, + "cast": 62024, + "chat-dots-fill": 62025, + "chat-dots": 62026, + "chat-fill": 62027, + "chat-left-dots-fill": 62028, + "chat-left-dots": 62029, + "chat-left-fill": 62030, + "chat-left-quote-fill": 62031, + "chat-left-quote": 62032, + "chat-left-text-fill": 62033, + "chat-left-text": 62034, + "chat-left": 62035, + "chat-quote-fill": 62036, + "chat-quote": 62037, + "chat-right-dots-fill": 62038, + "chat-right-dots": 62039, + "chat-right-fill": 62040, + "chat-right-quote-fill": 62041, + "chat-right-quote": 62042, + "chat-right-text-fill": 62043, + "chat-right-text": 62044, + "chat-right": 62045, + "chat-square-dots-fill": 62046, + "chat-square-dots": 62047, + "chat-square-fill": 62048, + "chat-square-quote-fill": 62049, + "chat-square-quote": 62050, + "chat-square-text-fill": 62051, + "chat-square-text": 62052, + "chat-square": 62053, + "chat-text-fill": 62054, + "chat-text": 62055, + "chat": 62056, + "check-all": 62057, + "check-circle-fill": 62058, + "check-circle": 62059, + "check-square-fill": 62060, + "check-square": 62061, + "check": 62062, + "check2-all": 62063, + "check2-circle": 62064, + "check2-square": 62065, + "check2": 62066, + "chevron-bar-contract": 62067, + "chevron-bar-down": 62068, + "chevron-bar-expand": 62069, + "chevron-bar-left": 62070, + "chevron-bar-right": 62071, + "chevron-bar-up": 62072, + "chevron-compact-down": 62073, + "chevron-compact-left": 62074, + "chevron-compact-right": 62075, + "chevron-compact-up": 62076, + "chevron-contract": 62077, + "chevron-double-down": 62078, + "chevron-double-left": 62079, + "chevron-double-right": 62080, + "chevron-double-up": 62081, + "chevron-down": 62082, + "chevron-expand": 62083, + "chevron-left": 62084, + "chevron-right": 62085, + "chevron-up": 62086, + "circle-fill": 62087, + "circle-half": 62088, + "circle-square": 62089, + "circle": 62090, + "clipboard-check": 62091, + "clipboard-data": 62092, + "clipboard-minus": 62093, + "clipboard-plus": 62094, + "clipboard-x": 62095, + "clipboard": 62096, + "clock-fill": 62097, + "clock-history": 62098, + "clock": 62099, + "cloud-arrow-down-fill": 62100, + "cloud-arrow-down": 62101, + "cloud-arrow-up-fill": 62102, + "cloud-arrow-up": 62103, + "cloud-check-fill": 62104, + "cloud-check": 62105, + "cloud-download-fill": 62106, + "cloud-download": 62107, + "cloud-drizzle-fill": 62108, + "cloud-drizzle": 62109, + "cloud-fill": 62110, + "cloud-fog-fill": 62111, + "cloud-fog": 62112, + "cloud-fog2-fill": 62113, + "cloud-fog2": 62114, + "cloud-hail-fill": 62115, + "cloud-hail": 62116, + "cloud-haze-fill": 62118, + "cloud-haze": 62119, + "cloud-haze2-fill": 62120, + "cloud-lightning-fill": 62121, + "cloud-lightning-rain-fill": 62122, + "cloud-lightning-rain": 62123, + "cloud-lightning": 62124, + "cloud-minus-fill": 62125, + "cloud-minus": 62126, + "cloud-moon-fill": 62127, + "cloud-moon": 62128, + "cloud-plus-fill": 62129, + "cloud-plus": 62130, + "cloud-rain-fill": 62131, + "cloud-rain-heavy-fill": 62132, + "cloud-rain-heavy": 62133, + "cloud-rain": 62134, + "cloud-slash-fill": 62135, + "cloud-slash": 62136, + "cloud-sleet-fill": 62137, + "cloud-sleet": 62138, + "cloud-snow-fill": 62139, + "cloud-snow": 62140, + "cloud-sun-fill": 62141, + "cloud-sun": 62142, + "cloud-upload-fill": 62143, + "cloud-upload": 62144, + "cloud": 62145, + "clouds-fill": 62146, + "clouds": 62147, + "cloudy-fill": 62148, + "cloudy": 62149, + "code-slash": 62150, + "code-square": 62151, + "code": 62152, + "collection-fill": 62153, + "collection-play-fill": 62154, + "collection-play": 62155, + "collection": 62156, + "columns-gap": 62157, + "columns": 62158, + "command": 62159, + "compass-fill": 62160, + "compass": 62161, + "cone-striped": 62162, + "cone": 62163, + "controller": 62164, + "cpu-fill": 62165, + "cpu": 62166, + "credit-card-2-back-fill": 62167, + "credit-card-2-back": 62168, + "credit-card-2-front-fill": 62169, + "credit-card-2-front": 62170, + "credit-card-fill": 62171, + "credit-card": 62172, + "crop": 62173, + "cup-fill": 62174, + "cup-straw": 62175, + "cup": 62176, + "cursor-fill": 62177, + "cursor-text": 62178, + "cursor": 62179, + "dash-circle-dotted": 62180, + "dash-circle-fill": 62181, + "dash-circle": 62182, + "dash-square-dotted": 62183, + "dash-square-fill": 62184, + "dash-square": 62185, + "dash": 62186, + "diagram-2-fill": 62187, + "diagram-2": 62188, + "diagram-3-fill": 62189, + "diagram-3": 62190, + "diamond-fill": 62191, + "diamond-half": 62192, + "diamond": 62193, + "dice-1-fill": 62194, + "dice-1": 62195, + "dice-2-fill": 62196, + "dice-2": 62197, + "dice-3-fill": 62198, + "dice-3": 62199, + "dice-4-fill": 62200, + "dice-4": 62201, + "dice-5-fill": 62202, + "dice-5": 62203, + "dice-6-fill": 62204, + "dice-6": 62205, + "disc-fill": 62206, + "disc": 62207, + "discord": 62208, + "display-fill": 62209, + "display": 62210, + "distribute-horizontal": 62211, + "distribute-vertical": 62212, + "door-closed-fill": 62213, + "door-closed": 62214, + "door-open-fill": 62215, + "door-open": 62216, + "dot": 62217, + "download": 62218, + "droplet-fill": 62219, + "droplet-half": 62220, + "droplet": 62221, + "earbuds": 62222, + "easel-fill": 62223, + "easel": 62224, + "egg-fill": 62225, + "egg-fried": 62226, + "egg": 62227, + "eject-fill": 62228, + "eject": 62229, + "emoji-angry-fill": 62230, + "emoji-angry": 62231, + "emoji-dizzy-fill": 62232, + "emoji-dizzy": 62233, + "emoji-expressionless-fill": 62234, + "emoji-expressionless": 62235, + "emoji-frown-fill": 62236, + "emoji-frown": 62237, + "emoji-heart-eyes-fill": 62238, + "emoji-heart-eyes": 62239, + "emoji-laughing-fill": 62240, + "emoji-laughing": 62241, + "emoji-neutral-fill": 62242, + "emoji-neutral": 62243, + "emoji-smile-fill": 62244, + "emoji-smile-upside-down-fill": 62245, + "emoji-smile-upside-down": 62246, + "emoji-smile": 62247, + "emoji-sunglasses-fill": 62248, + "emoji-sunglasses": 62249, + "emoji-wink-fill": 62250, + "emoji-wink": 62251, + "envelope-fill": 62252, + "envelope-open-fill": 62253, + "envelope-open": 62254, + "envelope": 62255, + "eraser-fill": 62256, + "eraser": 62257, + "exclamation-circle-fill": 62258, + "exclamation-circle": 62259, + "exclamation-diamond-fill": 62260, + "exclamation-diamond": 62261, + "exclamation-octagon-fill": 62262, + "exclamation-octagon": 62263, + "exclamation-square-fill": 62264, + "exclamation-square": 62265, + "exclamation-triangle-fill": 62266, + "exclamation-triangle": 62267, + "exclamation": 62268, + "exclude": 62269, + "eye-fill": 62270, + "eye-slash-fill": 62271, + "eye-slash": 62272, + "eye": 62273, + "eyedropper": 62274, + "eyeglasses": 62275, + "facebook": 62276, + "file-arrow-down-fill": 62277, + "file-arrow-down": 62278, + "file-arrow-up-fill": 62279, + "file-arrow-up": 62280, + "file-bar-graph-fill": 62281, + "file-bar-graph": 62282, + "file-binary-fill": 62283, + "file-binary": 62284, + "file-break-fill": 62285, + "file-break": 62286, + "file-check-fill": 62287, + "file-check": 62288, + "file-code-fill": 62289, + "file-code": 62290, + "file-diff-fill": 62291, + "file-diff": 62292, + "file-earmark-arrow-down-fill": 62293, + "file-earmark-arrow-down": 62294, + "file-earmark-arrow-up-fill": 62295, + "file-earmark-arrow-up": 62296, + "file-earmark-bar-graph-fill": 62297, + "file-earmark-bar-graph": 62298, + "file-earmark-binary-fill": 62299, + "file-earmark-binary": 62300, + "file-earmark-break-fill": 62301, + "file-earmark-break": 62302, + "file-earmark-check-fill": 62303, + "file-earmark-check": 62304, + "file-earmark-code-fill": 62305, + "file-earmark-code": 62306, + "file-earmark-diff-fill": 62307, + "file-earmark-diff": 62308, + "file-earmark-easel-fill": 62309, + "file-earmark-easel": 62310, + "file-earmark-excel-fill": 62311, + "file-earmark-excel": 62312, + "file-earmark-fill": 62313, + "file-earmark-font-fill": 62314, + "file-earmark-font": 62315, + "file-earmark-image-fill": 62316, + "file-earmark-image": 62317, + "file-earmark-lock-fill": 62318, + "file-earmark-lock": 62319, + "file-earmark-lock2-fill": 62320, + "file-earmark-lock2": 62321, + "file-earmark-medical-fill": 62322, + "file-earmark-medical": 62323, + "file-earmark-minus-fill": 62324, + "file-earmark-minus": 62325, + "file-earmark-music-fill": 62326, + "file-earmark-music": 62327, + "file-earmark-person-fill": 62328, + "file-earmark-person": 62329, + "file-earmark-play-fill": 62330, + "file-earmark-play": 62331, + "file-earmark-plus-fill": 62332, + "file-earmark-plus": 62333, + "file-earmark-post-fill": 62334, + "file-earmark-post": 62335, + "file-earmark-ppt-fill": 62336, + "file-earmark-ppt": 62337, + "file-earmark-richtext-fill": 62338, + "file-earmark-richtext": 62339, + "file-earmark-ruled-fill": 62340, + "file-earmark-ruled": 62341, + "file-earmark-slides-fill": 62342, + "file-earmark-slides": 62343, + "file-earmark-spreadsheet-fill": 62344, + "file-earmark-spreadsheet": 62345, + "file-earmark-text-fill": 62346, + "file-earmark-text": 62347, + "file-earmark-word-fill": 62348, + "file-earmark-word": 62349, + "file-earmark-x-fill": 62350, + "file-earmark-x": 62351, + "file-earmark-zip-fill": 62352, + "file-earmark-zip": 62353, + "file-earmark": 62354, + "file-easel-fill": 62355, + "file-easel": 62356, + "file-excel-fill": 62357, + "file-excel": 62358, + "file-fill": 62359, + "file-font-fill": 62360, + "file-font": 62361, + "file-image-fill": 62362, + "file-image": 62363, + "file-lock-fill": 62364, + "file-lock": 62365, + "file-lock2-fill": 62366, + "file-lock2": 62367, + "file-medical-fill": 62368, + "file-medical": 62369, + "file-minus-fill": 62370, + "file-minus": 62371, + "file-music-fill": 62372, + "file-music": 62373, + "file-person-fill": 62374, + "file-person": 62375, + "file-play-fill": 62376, + "file-play": 62377, + "file-plus-fill": 62378, + "file-plus": 62379, + "file-post-fill": 62380, + "file-post": 62381, + "file-ppt-fill": 62382, + "file-ppt": 62383, + "file-richtext-fill": 62384, + "file-richtext": 62385, + "file-ruled-fill": 62386, + "file-ruled": 62387, + "file-slides-fill": 62388, + "file-slides": 62389, + "file-spreadsheet-fill": 62390, + "file-spreadsheet": 62391, + "file-text-fill": 62392, + "file-text": 62393, + "file-word-fill": 62394, + "file-word": 62395, + "file-x-fill": 62396, + "file-x": 62397, + "file-zip-fill": 62398, + "file-zip": 62399, + "file": 62400, + "files-alt": 62401, + "files": 62402, + "film": 62403, + "filter-circle-fill": 62404, + "filter-circle": 62405, + "filter-left": 62406, + "filter-right": 62407, + "filter-square-fill": 62408, + "filter-square": 62409, + "filter": 62410, + "flag-fill": 62411, + "flag": 62412, + "flower1": 62413, + "flower2": 62414, + "flower3": 62415, + "folder-check": 62416, + "folder-fill": 62417, + "folder-minus": 62418, + "folder-plus": 62419, + "folder-symlink-fill": 62420, + "folder-symlink": 62421, + "folder-x": 62422, + "folder": 62423, + "folder2-open": 62424, + "folder2": 62425, + "fonts": 62426, + "forward-fill": 62427, + "forward": 62428, + "front": 62429, + "fullscreen-exit": 62430, + "fullscreen": 62431, + "funnel-fill": 62432, + "funnel": 62433, + "gear-fill": 62434, + "gear-wide-connected": 62435, + "gear-wide": 62436, + "gear": 62437, + "gem": 62438, + "geo-alt-fill": 62439, + "geo-alt": 62440, + "geo-fill": 62441, + "geo": 62442, + "gift-fill": 62443, + "gift": 62444, + "github": 62445, + "globe": 62446, + "globe2": 62447, + "google": 62448, + "graph-down": 62449, + "graph-up": 62450, + "grid-1x2-fill": 62451, + "grid-1x2": 62452, + "grid-3x2-gap-fill": 62453, + "grid-3x2-gap": 62454, + "grid-3x2": 62455, + "grid-3x3-gap-fill": 62456, + "grid-3x3-gap": 62457, + "grid-3x3": 62458, + "grid-fill": 62459, + "grid": 62460, + "grip-horizontal": 62461, + "grip-vertical": 62462, + "hammer": 62463, + "hand-index-fill": 62464, + "hand-index-thumb-fill": 62465, + "hand-index-thumb": 62466, + "hand-index": 62467, + "hand-thumbs-down-fill": 62468, + "hand-thumbs-down": 62469, + "hand-thumbs-up-fill": 62470, + "hand-thumbs-up": 62471, + "handbag-fill": 62472, + "handbag": 62473, + "hash": 62474, + "hdd-fill": 62475, + "hdd-network-fill": 62476, + "hdd-network": 62477, + "hdd-rack-fill": 62478, + "hdd-rack": 62479, + "hdd-stack-fill": 62480, + "hdd-stack": 62481, + "hdd": 62482, + "headphones": 62483, + "headset": 62484, + "heart-fill": 62485, + "heart-half": 62486, + "heart": 62487, + "heptagon-fill": 62488, + "heptagon-half": 62489, + "heptagon": 62490, + "hexagon-fill": 62491, + "hexagon-half": 62492, + "hexagon": 62493, + "hourglass-bottom": 62494, + "hourglass-split": 62495, + "hourglass-top": 62496, + "hourglass": 62497, + "house-door-fill": 62498, + "house-door": 62499, + "house-fill": 62500, + "house": 62501, + "hr": 62502, + "hurricane": 62503, + "image-alt": 62504, + "image-fill": 62505, + "image": 62506, + "images": 62507, + "inbox-fill": 62508, + "inbox": 62509, + "inboxes-fill": 62510, + "inboxes": 62511, + "info-circle-fill": 62512, + "info-circle": 62513, + "info-square-fill": 62514, + "info-square": 62515, + "info": 62516, + "input-cursor-text": 62517, + "input-cursor": 62518, + "instagram": 62519, + "intersect": 62520, + "journal-album": 62521, + "journal-arrow-down": 62522, + "journal-arrow-up": 62523, + "journal-bookmark-fill": 62524, + "journal-bookmark": 62525, + "journal-check": 62526, + "journal-code": 62527, + "journal-medical": 62528, + "journal-minus": 62529, + "journal-plus": 62530, + "journal-richtext": 62531, + "journal-text": 62532, + "journal-x": 62533, + "journal": 62534, + "journals": 62535, + "joystick": 62536, + "justify-left": 62537, + "justify-right": 62538, + "justify": 62539, + "kanban-fill": 62540, + "kanban": 62541, + "key-fill": 62542, + "key": 62543, + "keyboard-fill": 62544, + "keyboard": 62545, + "ladder": 62546, + "lamp-fill": 62547, + "lamp": 62548, + "laptop-fill": 62549, + "laptop": 62550, + "layer-backward": 62551, + "layer-forward": 62552, + "layers-fill": 62553, + "layers-half": 62554, + "layers": 62555, + "layout-sidebar-inset-reverse": 62556, + "layout-sidebar-inset": 62557, + "layout-sidebar-reverse": 62558, + "layout-sidebar": 62559, + "layout-split": 62560, + "layout-text-sidebar-reverse": 62561, + "layout-text-sidebar": 62562, + "layout-text-window-reverse": 62563, + "layout-text-window": 62564, + "layout-three-columns": 62565, + "layout-wtf": 62566, + "life-preserver": 62567, + "lightbulb-fill": 62568, + "lightbulb-off-fill": 62569, + "lightbulb-off": 62570, + "lightbulb": 62571, + "lightning-charge-fill": 62572, + "lightning-charge": 62573, + "lightning-fill": 62574, + "lightning": 62575, + "link-45deg": 62576, + "link": 62577, + "linkedin": 62578, + "list-check": 62579, + "list-nested": 62580, + "list-ol": 62581, + "list-stars": 62582, + "list-task": 62583, + "list-ul": 62584, + "list": 62585, + "lock-fill": 62586, + "lock": 62587, + "mailbox": 62588, + "mailbox2": 62589, + "map-fill": 62590, + "map": 62591, + "markdown-fill": 62592, + "markdown": 62593, + "mask": 62594, + "megaphone-fill": 62595, + "megaphone": 62596, + "menu-app-fill": 62597, + "menu-app": 62598, + "menu-button-fill": 62599, + "menu-button-wide-fill": 62600, + "menu-button-wide": 62601, + "menu-button": 62602, + "menu-down": 62603, + "menu-up": 62604, + "mic-fill": 62605, + "mic-mute-fill": 62606, + "mic-mute": 62607, + "mic": 62608, + "minecart-loaded": 62609, + "minecart": 62610, + "moisture": 62611, + "moon-fill": 62612, + "moon-stars-fill": 62613, + "moon-stars": 62614, + "moon": 62615, + "mouse-fill": 62616, + "mouse": 62617, + "mouse2-fill": 62618, + "mouse2": 62619, + "mouse3-fill": 62620, + "mouse3": 62621, + "music-note-beamed": 62622, + "music-note-list": 62623, + "music-note": 62624, + "music-player-fill": 62625, + "music-player": 62626, + "newspaper": 62627, + "node-minus-fill": 62628, + "node-minus": 62629, + "node-plus-fill": 62630, + "node-plus": 62631, + "nut-fill": 62632, + "nut": 62633, + "octagon-fill": 62634, + "octagon-half": 62635, + "octagon": 62636, + "option": 62637, + "outlet": 62638, + "paint-bucket": 62639, + "palette-fill": 62640, + "palette": 62641, + "palette2": 62642, + "paperclip": 62643, + "paragraph": 62644, + "patch-check-fill": 62645, + "patch-check": 62646, + "patch-exclamation-fill": 62647, + "patch-exclamation": 62648, + "patch-minus-fill": 62649, + "patch-minus": 62650, + "patch-plus-fill": 62651, + "patch-plus": 62652, + "patch-question-fill": 62653, + "patch-question": 62654, + "pause-btn-fill": 62655, + "pause-btn": 62656, + "pause-circle-fill": 62657, + "pause-circle": 62658, + "pause-fill": 62659, + "pause": 62660, + "peace-fill": 62661, + "peace": 62662, + "pen-fill": 62663, + "pen": 62664, + "pencil-fill": 62665, + "pencil-square": 62666, + "pencil": 62667, + "pentagon-fill": 62668, + "pentagon-half": 62669, + "pentagon": 62670, + "people-fill": 62671, + "people": 62672, + "percent": 62673, + "person-badge-fill": 62674, + "person-badge": 62675, + "person-bounding-box": 62676, + "person-check-fill": 62677, + "person-check": 62678, + "person-circle": 62679, + "person-dash-fill": 62680, + "person-dash": 62681, + "person-fill": 62682, + "person-lines-fill": 62683, + "person-plus-fill": 62684, + "person-plus": 62685, + "person-square": 62686, + "person-x-fill": 62687, + "person-x": 62688, + "person": 62689, + "phone-fill": 62690, + "phone-landscape-fill": 62691, + "phone-landscape": 62692, + "phone-vibrate-fill": 62693, + "phone-vibrate": 62694, + "phone": 62695, + "pie-chart-fill": 62696, + "pie-chart": 62697, + "pin-angle-fill": 62698, + "pin-angle": 62699, + "pin-fill": 62700, + "pin": 62701, + "pip-fill": 62702, + "pip": 62703, + "play-btn-fill": 62704, + "play-btn": 62705, + "play-circle-fill": 62706, + "play-circle": 62707, + "play-fill": 62708, + "play": 62709, + "plug-fill": 62710, + "plug": 62711, + "plus-circle-dotted": 62712, + "plus-circle-fill": 62713, + "plus-circle": 62714, + "plus-square-dotted": 62715, + "plus-square-fill": 62716, + "plus-square": 62717, + "plus": 62718, + "power": 62719, + "printer-fill": 62720, + "printer": 62721, + "puzzle-fill": 62722, + "puzzle": 62723, + "question-circle-fill": 62724, + "question-circle": 62725, + "question-diamond-fill": 62726, + "question-diamond": 62727, + "question-octagon-fill": 62728, + "question-octagon": 62729, + "question-square-fill": 62730, + "question-square": 62731, + "question": 62732, + "rainbow": 62733, + "receipt-cutoff": 62734, + "receipt": 62735, + "reception-0": 62736, + "reception-1": 62737, + "reception-2": 62738, + "reception-3": 62739, + "reception-4": 62740, + "record-btn-fill": 62741, + "record-btn": 62742, + "record-circle-fill": 62743, + "record-circle": 62744, + "record-fill": 62745, + "record": 62746, + "record2-fill": 62747, + "record2": 62748, + "reply-all-fill": 62749, + "reply-all": 62750, + "reply-fill": 62751, + "reply": 62752, + "rss-fill": 62753, + "rss": 62754, + "rulers": 62755, + "save-fill": 62756, + "save": 62757, + "save2-fill": 62758, + "save2": 62759, + "scissors": 62760, + "screwdriver": 62761, + "search": 62762, + "segmented-nav": 62763, + "server": 62764, + "share-fill": 62765, + "share": 62766, + "shield-check": 62767, + "shield-exclamation": 62768, + "shield-fill-check": 62769, + "shield-fill-exclamation": 62770, + "shield-fill-minus": 62771, + "shield-fill-plus": 62772, + "shield-fill-x": 62773, + "shield-fill": 62774, + "shield-lock-fill": 62775, + "shield-lock": 62776, + "shield-minus": 62777, + "shield-plus": 62778, + "shield-shaded": 62779, + "shield-slash-fill": 62780, + "shield-slash": 62781, + "shield-x": 62782, + "shield": 62783, + "shift-fill": 62784, + "shift": 62785, + "shop-window": 62786, + "shop": 62787, + "shuffle": 62788, + "signpost-2-fill": 62789, + "signpost-2": 62790, + "signpost-fill": 62791, + "signpost-split-fill": 62792, + "signpost-split": 62793, + "signpost": 62794, + "sim-fill": 62795, + "sim": 62796, + "skip-backward-btn-fill": 62797, + "skip-backward-btn": 62798, + "skip-backward-circle-fill": 62799, + "skip-backward-circle": 62800, + "skip-backward-fill": 62801, + "skip-backward": 62802, + "skip-end-btn-fill": 62803, + "skip-end-btn": 62804, + "skip-end-circle-fill": 62805, + "skip-end-circle": 62806, + "skip-end-fill": 62807, + "skip-end": 62808, + "skip-forward-btn-fill": 62809, + "skip-forward-btn": 62810, + "skip-forward-circle-fill": 62811, + "skip-forward-circle": 62812, + "skip-forward-fill": 62813, + "skip-forward": 62814, + "skip-start-btn-fill": 62815, + "skip-start-btn": 62816, + "skip-start-circle-fill": 62817, + "skip-start-circle": 62818, + "skip-start-fill": 62819, + "skip-start": 62820, + "slack": 62821, + "slash-circle-fill": 62822, + "slash-circle": 62823, + "slash-square-fill": 62824, + "slash-square": 62825, + "slash": 62826, + "sliders": 62827, + "smartwatch": 62828, + "snow": 62829, + "snow2": 62830, + "snow3": 62831, + "sort-alpha-down-alt": 62832, + "sort-alpha-down": 62833, + "sort-alpha-up-alt": 62834, + "sort-alpha-up": 62835, + "sort-down-alt": 62836, + "sort-down": 62837, + "sort-numeric-down-alt": 62838, + "sort-numeric-down": 62839, + "sort-numeric-up-alt": 62840, + "sort-numeric-up": 62841, + "sort-up-alt": 62842, + "sort-up": 62843, + "soundwave": 62844, + "speaker-fill": 62845, + "speaker": 62846, + "speedometer": 62847, + "speedometer2": 62848, + "spellcheck": 62849, + "square-fill": 62850, + "square-half": 62851, + "square": 62852, + "stack": 62853, + "star-fill": 62854, + "star-half": 62855, + "star": 62856, + "stars": 62857, + "stickies-fill": 62858, + "stickies": 62859, + "sticky-fill": 62860, + "sticky": 62861, + "stop-btn-fill": 62862, + "stop-btn": 62863, + "stop-circle-fill": 62864, + "stop-circle": 62865, + "stop-fill": 62866, + "stop": 62867, + "stoplights-fill": 62868, + "stoplights": 62869, + "stopwatch-fill": 62870, + "stopwatch": 62871, + "subtract": 62872, + "suit-club-fill": 62873, + "suit-club": 62874, + "suit-diamond-fill": 62875, + "suit-diamond": 62876, + "suit-heart-fill": 62877, + "suit-heart": 62878, + "suit-spade-fill": 62879, + "suit-spade": 62880, + "sun-fill": 62881, + "sun": 62882, + "sunglasses": 62883, + "sunrise-fill": 62884, + "sunrise": 62885, + "sunset-fill": 62886, + "sunset": 62887, + "symmetry-horizontal": 62888, + "symmetry-vertical": 62889, + "table": 62890, + "tablet-fill": 62891, + "tablet-landscape-fill": 62892, + "tablet-landscape": 62893, + "tablet": 62894, + "tag-fill": 62895, + "tag": 62896, + "tags-fill": 62897, + "tags": 62898, + "telegram": 62899, + "telephone-fill": 62900, + "telephone-forward-fill": 62901, + "telephone-forward": 62902, + "telephone-inbound-fill": 62903, + "telephone-inbound": 62904, + "telephone-minus-fill": 62905, + "telephone-minus": 62906, + "telephone-outbound-fill": 62907, + "telephone-outbound": 62908, + "telephone-plus-fill": 62909, + "telephone-plus": 62910, + "telephone-x-fill": 62911, + "telephone-x": 62912, + "telephone": 62913, + "terminal-fill": 62914, + "terminal": 62915, + "text-center": 62916, + "text-indent-left": 62917, + "text-indent-right": 62918, + "text-left": 62919, + "text-paragraph": 62920, + "text-right": 62921, + "textarea-resize": 62922, + "textarea-t": 62923, + "textarea": 62924, + "thermometer-half": 62925, + "thermometer-high": 62926, + "thermometer-low": 62927, + "thermometer-snow": 62928, + "thermometer-sun": 62929, + "thermometer": 62930, + "three-dots-vertical": 62931, + "three-dots": 62932, + "toggle-off": 62933, + "toggle-on": 62934, + "toggle2-off": 62935, + "toggle2-on": 62936, + "toggles": 62937, + "toggles2": 62938, + "tools": 62939, + "tornado": 62940, + "trash-fill": 62941, + "trash": 62942, + "trash2-fill": 62943, + "trash2": 62944, + "tree-fill": 62945, + "tree": 62946, + "triangle-fill": 62947, + "triangle-half": 62948, + "triangle": 62949, + "trophy-fill": 62950, + "trophy": 62951, + "tropical-storm": 62952, + "truck-flatbed": 62953, + "truck": 62954, + "tsunami": 62955, + "tv-fill": 62956, + "tv": 62957, + "twitch": 62958, + "twitter": 62959, + "type-bold": 62960, + "type-h1": 62961, + "type-h2": 62962, + "type-h3": 62963, + "type-italic": 62964, + "type-strikethrough": 62965, + "type-underline": 62966, + "type": 62967, + "ui-checks-grid": 62968, + "ui-checks": 62969, + "ui-radios-grid": 62970, + "ui-radios": 62971, + "umbrella-fill": 62972, + "umbrella": 62973, + "union": 62974, + "unlock-fill": 62975, + "unlock": 62976, + "upc-scan": 62977, + "upc": 62978, + "upload": 62979, + "vector-pen": 62980, + "view-list": 62981, + "view-stacked": 62982, + "vinyl-fill": 62983, + "vinyl": 62984, + "voicemail": 62985, + "volume-down-fill": 62986, + "volume-down": 62987, + "volume-mute-fill": 62988, + "volume-mute": 62989, + "volume-off-fill": 62990, + "volume-off": 62991, + "volume-up-fill": 62992, + "volume-up": 62993, + "vr": 62994, + "wallet-fill": 62995, + "wallet": 62996, + "wallet2": 62997, + "watch": 62998, + "water": 62999, + "whatsapp": 63000, + "wifi-1": 63001, + "wifi-2": 63002, + "wifi-off": 63003, + "wifi": 63004, + "wind": 63005, + "window-dock": 63006, + "window-sidebar": 63007, + "window": 63008, + "wrench": 63009, + "x-circle-fill": 63010, + "x-circle": 63011, + "x-diamond-fill": 63012, + "x-diamond": 63013, + "x-octagon-fill": 63014, + "x-octagon": 63015, + "x-square-fill": 63016, + "x-square": 63017, + "x": 63018, + "youtube": 63019, + "zoom-in": 63020, + "zoom-out": 63021, + "bank": 63022, + "bank2": 63023, + "bell-slash-fill": 63024, + "bell-slash": 63025, + "cash-coin": 63026, + "check-lg": 63027, + "coin": 63028, + "currency-bitcoin": 63029, + "currency-dollar": 63030, + "currency-euro": 63031, + "currency-exchange": 63032, + "currency-pound": 63033, + "currency-yen": 63034, + "dash-lg": 63035, + "exclamation-lg": 63036, + "file-earmark-pdf-fill": 63037, + "file-earmark-pdf": 63038, + "file-pdf-fill": 63039, + "file-pdf": 63040, + "gender-ambiguous": 63041, + "gender-female": 63042, + "gender-male": 63043, + "gender-trans": 63044, + "headset-vr": 63045, + "info-lg": 63046, + "mastodon": 63047, + "messenger": 63048, + "piggy-bank-fill": 63049, + "piggy-bank": 63050, + "pin-map-fill": 63051, + "pin-map": 63052, + "plus-lg": 63053, + "question-lg": 63054, + "recycle": 63055, + "reddit": 63056, + "safe-fill": 63057, + "safe2-fill": 63058, + "safe2": 63059, + "sd-card-fill": 63060, + "sd-card": 63061, + "skype": 63062, + "slash-lg": 63063, + "translate": 63064, + "x-lg": 63065, + "safe": 63066, + "apple": 63067, + "microsoft": 63069, + "windows": 63070, + "behance": 63068, + "dribbble": 63071, + "line": 63072, + "medium": 63073, + "paypal": 63074, + "pinterest": 63075, + "signal": 63076, + "snapchat": 63077, + "spotify": 63078, + "stack-overflow": 63079, + "strava": 63080, + "wordpress": 63081, + "vimeo": 63082, + "activity": 63083, + "easel2-fill": 63084, + "easel2": 63085, + "easel3-fill": 63086, + "easel3": 63087, + "fan": 63088, + "fingerprint": 63089, + "graph-down-arrow": 63090, + "graph-up-arrow": 63091, + "hypnotize": 63092, + "magic": 63093, + "person-rolodex": 63094, + "person-video": 63095, + "person-video2": 63096, + "person-video3": 63097, + "person-workspace": 63098, + "radioactive": 63099, + "webcam-fill": 63100, + "webcam": 63101, + "yin-yang": 63102, + "bandaid-fill": 63104, + "bandaid": 63105, + "bluetooth": 63106, + "body-text": 63107, + "boombox": 63108, + "boxes": 63109, + "dpad-fill": 63110, + "dpad": 63111, + "ear-fill": 63112, + "ear": 63113, + "envelope-check-fill": 63115, + "envelope-check": 63116, + "envelope-dash-fill": 63118, + "envelope-dash": 63119, + "envelope-exclamation-fill": 63121, + "envelope-exclamation": 63122, + "envelope-plus-fill": 63123, + "envelope-plus": 63124, + "envelope-slash-fill": 63126, + "envelope-slash": 63127, + "envelope-x-fill": 63129, + "envelope-x": 63130, + "explicit-fill": 63131, + "explicit": 63132, + "git": 63133, + "infinity": 63134, + "list-columns-reverse": 63135, + "list-columns": 63136, + "meta": 63137, + "nintendo-switch": 63140, + "pc-display-horizontal": 63141, + "pc-display": 63142, + "pc-horizontal": 63143, + "pc": 63144, + "playstation": 63145, + "plus-slash-minus": 63146, + "projector-fill": 63147, + "projector": 63148, + "qr-code-scan": 63149, + "qr-code": 63150, + "quora": 63151, + "quote": 63152, + "robot": 63153, + "send-check-fill": 63154, + "send-check": 63155, + "send-dash-fill": 63156, + "send-dash": 63157, + "send-exclamation-fill": 63159, + "send-exclamation": 63160, + "send-fill": 63161, + "send-plus-fill": 63162, + "send-plus": 63163, + "send-slash-fill": 63164, + "send-slash": 63165, + "send-x-fill": 63166, + "send-x": 63167, + "send": 63168, + "steam": 63169, + "terminal-dash": 63171, + "terminal-plus": 63172, + "terminal-split": 63173, + "ticket-detailed-fill": 63174, + "ticket-detailed": 63175, + "ticket-fill": 63176, + "ticket-perforated-fill": 63177, + "ticket-perforated": 63178, + "ticket": 63179, + "tiktok": 63180, + "window-dash": 63181, + "window-desktop": 63182, + "window-fullscreen": 63183, + "window-plus": 63184, + "window-split": 63185, + "window-stack": 63186, + "window-x": 63187, + "xbox": 63188, + "ethernet": 63189, + "hdmi-fill": 63190, + "hdmi": 63191, + "usb-c-fill": 63192, + "usb-c": 63193, + "usb-fill": 63194, + "usb-plug-fill": 63195, + "usb-plug": 63196, + "usb-symbol": 63197, + "usb": 63198, + "boombox-fill": 63199, + "displayport": 63201, + "gpu-card": 63202, + "memory": 63203, + "modem-fill": 63204, + "modem": 63205, + "motherboard-fill": 63206, + "motherboard": 63207, + "optical-audio-fill": 63208, + "optical-audio": 63209, + "pci-card": 63210, + "router-fill": 63211, + "router": 63212, + "thunderbolt-fill": 63215, + "thunderbolt": 63216, + "usb-drive-fill": 63217, + "usb-drive": 63218, + "usb-micro-fill": 63219, + "usb-micro": 63220, + "usb-mini-fill": 63221, + "usb-mini": 63222, + "cloud-haze2": 63223, + "device-hdd-fill": 63224, + "device-hdd": 63225, + "device-ssd-fill": 63226, + "device-ssd": 63227, + "displayport-fill": 63228, + "mortarboard-fill": 63229, + "mortarboard": 63230, + "terminal-x": 63231, + "arrow-through-heart-fill": 63232, + "arrow-through-heart": 63233, + "badge-sd-fill": 63234, + "badge-sd": 63235, + "bag-heart-fill": 63236, + "bag-heart": 63237, + "balloon-fill": 63238, + "balloon-heart-fill": 63239, + "balloon-heart": 63240, + "balloon": 63241, + "box2-fill": 63242, + "box2-heart-fill": 63243, + "box2-heart": 63244, + "box2": 63245, + "braces-asterisk": 63246, + "calendar-heart-fill": 63247, + "calendar-heart": 63248, + "calendar2-heart-fill": 63249, + "calendar2-heart": 63250, + "chat-heart-fill": 63251, + "chat-heart": 63252, + "chat-left-heart-fill": 63253, + "chat-left-heart": 63254, + "chat-right-heart-fill": 63255, + "chat-right-heart": 63256, + "chat-square-heart-fill": 63257, + "chat-square-heart": 63258, + "clipboard-check-fill": 63259, + "clipboard-data-fill": 63260, + "clipboard-fill": 63261, + "clipboard-heart-fill": 63262, + "clipboard-heart": 63263, + "clipboard-minus-fill": 63264, + "clipboard-plus-fill": 63265, + "clipboard-pulse": 63266, + "clipboard-x-fill": 63267, + "clipboard2-check-fill": 63268, + "clipboard2-check": 63269, + "clipboard2-data-fill": 63270, + "clipboard2-data": 63271, + "clipboard2-fill": 63272, + "clipboard2-heart-fill": 63273, + "clipboard2-heart": 63274, + "clipboard2-minus-fill": 63275, + "clipboard2-minus": 63276, + "clipboard2-plus-fill": 63277, + "clipboard2-plus": 63278, + "clipboard2-pulse-fill": 63279, + "clipboard2-pulse": 63280, + "clipboard2-x-fill": 63281, + "clipboard2-x": 63282, + "clipboard2": 63283, + "emoji-kiss-fill": 63284, + "emoji-kiss": 63285, + "envelope-heart-fill": 63286, + "envelope-heart": 63287, + "envelope-open-heart-fill": 63288, + "envelope-open-heart": 63289, + "envelope-paper-fill": 63290, + "envelope-paper-heart-fill": 63291, + "envelope-paper-heart": 63292, + "envelope-paper": 63293, + "filetype-aac": 63294, + "filetype-ai": 63295, + "filetype-bmp": 63296, + "filetype-cs": 63297, + "filetype-css": 63298, + "filetype-csv": 63299, + "filetype-doc": 63300, + "filetype-docx": 63301, + "filetype-exe": 63302, + "filetype-gif": 63303, + "filetype-heic": 63304, + "filetype-html": 63305, + "filetype-java": 63306, + "filetype-jpg": 63307, + "filetype-js": 63308, + "filetype-jsx": 63309, + "filetype-key": 63310, + "filetype-m4p": 63311, + "filetype-md": 63312, + "filetype-mdx": 63313, + "filetype-mov": 63314, + "filetype-mp3": 63315, + "filetype-mp4": 63316, + "filetype-otf": 63317, + "filetype-pdf": 63318, + "filetype-php": 63319, + "filetype-png": 63320, + "filetype-ppt": 63322, + "filetype-psd": 63323, + "filetype-py": 63324, + "filetype-raw": 63325, + "filetype-rb": 63326, + "filetype-sass": 63327, + "filetype-scss": 63328, + "filetype-sh": 63329, + "filetype-svg": 63330, + "filetype-tiff": 63331, + "filetype-tsx": 63332, + "filetype-ttf": 63333, + "filetype-txt": 63334, + "filetype-wav": 63335, + "filetype-woff": 63336, + "filetype-xls": 63338, + "filetype-xml": 63339, + "filetype-yml": 63340, + "heart-arrow": 63341, + "heart-pulse-fill": 63342, + "heart-pulse": 63343, + "heartbreak-fill": 63344, + "heartbreak": 63345, + "hearts": 63346, + "hospital-fill": 63347, + "hospital": 63348, + "house-heart-fill": 63349, + "house-heart": 63350, + "incognito": 63351, + "magnet-fill": 63352, + "magnet": 63353, + "person-heart": 63354, + "person-hearts": 63355, + "phone-flip": 63356, + "plugin": 63357, + "postage-fill": 63358, + "postage-heart-fill": 63359, + "postage-heart": 63360, + "postage": 63361, + "postcard-fill": 63362, + "postcard-heart-fill": 63363, + "postcard-heart": 63364, + "postcard": 63365, + "search-heart-fill": 63366, + "search-heart": 63367, + "sliders2-vertical": 63368, + "sliders2": 63369, + "trash3-fill": 63370, + "trash3": 63371, + "valentine": 63372, + "valentine2": 63373, + "wrench-adjustable-circle-fill": 63374, + "wrench-adjustable-circle": 63375, + "wrench-adjustable": 63376, + "filetype-json": 63377, + "filetype-pptx": 63378, + "filetype-xlsx": 63379, + "1-circle-fill": 63382, + "1-circle": 63383, + "1-square-fill": 63384, + "1-square": 63385, + "2-circle-fill": 63388, + "2-circle": 63389, + "2-square-fill": 63390, + "2-square": 63391, + "3-circle-fill": 63394, + "3-circle": 63395, + "3-square-fill": 63396, + "3-square": 63397, + "4-circle-fill": 63400, + "4-circle": 63401, + "4-square-fill": 63402, + "4-square": 63403, + "5-circle-fill": 63406, + "5-circle": 63407, + "5-square-fill": 63408, + "5-square": 63409, + "6-circle-fill": 63412, + "6-circle": 63413, + "6-square-fill": 63414, + "6-square": 63415, + "7-circle-fill": 63418, + "7-circle": 63419, + "7-square-fill": 63420, + "7-square": 63421, + "8-circle-fill": 63424, + "8-circle": 63425, + "8-square-fill": 63426, + "8-square": 63427, + "9-circle-fill": 63430, + "9-circle": 63431, + "9-square-fill": 63432, + "9-square": 63433, + "airplane-engines-fill": 63434, + "airplane-engines": 63435, + "airplane-fill": 63436, + "airplane": 63437, + "alexa": 63438, + "alipay": 63439, + "android": 63440, + "android2": 63441, + "box-fill": 63442, + "box-seam-fill": 63443, + "browser-chrome": 63444, + "browser-edge": 63445, + "browser-firefox": 63446, + "browser-safari": 63447, + "c-circle-fill": 63450, + "c-circle": 63451, + "c-square-fill": 63452, + "c-square": 63453, + "capsule-pill": 63454, + "capsule": 63455, + "car-front-fill": 63456, + "car-front": 63457, + "cassette-fill": 63458, + "cassette": 63459, + "cc-circle-fill": 63462, + "cc-circle": 63463, + "cc-square-fill": 63464, + "cc-square": 63465, + "cup-hot-fill": 63466, + "cup-hot": 63467, + "currency-rupee": 63468, + "dropbox": 63469, + "escape": 63470, + "fast-forward-btn-fill": 63471, + "fast-forward-btn": 63472, + "fast-forward-circle-fill": 63473, + "fast-forward-circle": 63474, + "fast-forward-fill": 63475, + "fast-forward": 63476, + "filetype-sql": 63477, + "fire": 63478, + "google-play": 63479, + "h-circle-fill": 63482, + "h-circle": 63483, + "h-square-fill": 63484, + "h-square": 63485, + "indent": 63486, + "lungs-fill": 63487, + "lungs": 63488, + "microsoft-teams": 63489, + "p-circle-fill": 63492, + "p-circle": 63493, + "p-square-fill": 63494, + "p-square": 63495, + "pass-fill": 63496, + "pass": 63497, + "prescription": 63498, + "prescription2": 63499, + "r-circle-fill": 63502, + "r-circle": 63503, + "r-square-fill": 63504, + "r-square": 63505, + "repeat-1": 63506, + "repeat": 63507, + "rewind-btn-fill": 63508, + "rewind-btn": 63509, + "rewind-circle-fill": 63510, + "rewind-circle": 63511, + "rewind-fill": 63512, + "rewind": 63513, + "train-freight-front-fill": 63514, + "train-freight-front": 63515, + "train-front-fill": 63516, + "train-front": 63517, + "train-lightrail-front-fill": 63518, + "train-lightrail-front": 63519, + "truck-front-fill": 63520, + "truck-front": 63521, + "ubuntu": 63522, + "unindent": 63523, + "unity": 63524, + "universal-access-circle": 63525, + "universal-access": 63526, + "virus": 63527, + "virus2": 63528, + "wechat": 63529, + "yelp": 63530, + "sign-stop-fill": 63531, + "sign-stop-lights-fill": 63532, + "sign-stop-lights": 63533, + "sign-stop": 63534, + "sign-turn-left-fill": 63535, + "sign-turn-left": 63536, + "sign-turn-right-fill": 63537, + "sign-turn-right": 63538, + "sign-turn-slight-left-fill": 63539, + "sign-turn-slight-left": 63540, + "sign-turn-slight-right-fill": 63541, + "sign-turn-slight-right": 63542, + "sign-yield-fill": 63543, + "sign-yield": 63544, + "ev-station-fill": 63545, + "ev-station": 63546, + "fuel-pump-diesel-fill": 63547, + "fuel-pump-diesel": 63548, + "fuel-pump-fill": 63549, + "fuel-pump": 63550, + "0-circle-fill": 63551, + "0-circle": 63552, + "0-square-fill": 63553, + "0-square": 63554, + "rocket-fill": 63555, + "rocket-takeoff-fill": 63556, + "rocket-takeoff": 63557, + "rocket": 63558, + "stripe": 63559, + "subscript": 63560, + "superscript": 63561, + "trello": 63562, + "envelope-at-fill": 63563, + "envelope-at": 63564, + "regex": 63565, + "text-wrap": 63566, + "sign-dead-end-fill": 63567, + "sign-dead-end": 63568, + "sign-do-not-enter-fill": 63569, + "sign-do-not-enter": 63570, + "sign-intersection-fill": 63571, + "sign-intersection-side-fill": 63572, + "sign-intersection-side": 63573, + "sign-intersection-t-fill": 63574, + "sign-intersection-t": 63575, + "sign-intersection-y-fill": 63576, + "sign-intersection-y": 63577, + "sign-intersection": 63578, + "sign-merge-left-fill": 63579, + "sign-merge-left": 63580, + "sign-merge-right-fill": 63581, + "sign-merge-right": 63582, + "sign-no-left-turn-fill": 63583, + "sign-no-left-turn": 63584, + "sign-no-parking-fill": 63585, + "sign-no-parking": 63586, + "sign-no-right-turn-fill": 63587, + "sign-no-right-turn": 63588, + "sign-railroad-fill": 63589, + "sign-railroad": 63590, + "building-add": 63591, + "building-check": 63592, + "building-dash": 63593, + "building-down": 63594, + "building-exclamation": 63595, + "building-fill-add": 63596, + "building-fill-check": 63597, + "building-fill-dash": 63598, + "building-fill-down": 63599, + "building-fill-exclamation": 63600, + "building-fill-gear": 63601, + "building-fill-lock": 63602, + "building-fill-slash": 63603, + "building-fill-up": 63604, + "building-fill-x": 63605, + "building-fill": 63606, + "building-gear": 63607, + "building-lock": 63608, + "building-slash": 63609, + "building-up": 63610, + "building-x": 63611, + "buildings-fill": 63612, + "buildings": 63613, + "bus-front-fill": 63614, + "bus-front": 63615, + "ev-front-fill": 63616, + "ev-front": 63617, + "globe-americas": 63618, + "globe-asia-australia": 63619, + "globe-central-south-asia": 63620, + "globe-europe-africa": 63621, + "house-add-fill": 63622, + "house-add": 63623, + "house-check-fill": 63624, + "house-check": 63625, + "house-dash-fill": 63626, + "house-dash": 63627, + "house-down-fill": 63628, + "house-down": 63629, + "house-exclamation-fill": 63630, + "house-exclamation": 63631, + "house-gear-fill": 63632, + "house-gear": 63633, + "house-lock-fill": 63634, + "house-lock": 63635, + "house-slash-fill": 63636, + "house-slash": 63637, + "house-up-fill": 63638, + "house-up": 63639, + "house-x-fill": 63640, + "house-x": 63641, + "person-add": 63642, + "person-down": 63643, + "person-exclamation": 63644, + "person-fill-add": 63645, + "person-fill-check": 63646, + "person-fill-dash": 63647, + "person-fill-down": 63648, + "person-fill-exclamation": 63649, + "person-fill-gear": 63650, + "person-fill-lock": 63651, + "person-fill-slash": 63652, + "person-fill-up": 63653, + "person-fill-x": 63654, + "person-gear": 63655, + "person-lock": 63656, + "person-slash": 63657, + "person-up": 63658, + "scooter": 63659, + "taxi-front-fill": 63660, + "taxi-front": 63661, + "amd": 63662, + "database-add": 63663, + "database-check": 63664, + "database-dash": 63665, + "database-down": 63666, + "database-exclamation": 63667, + "database-fill-add": 63668, + "database-fill-check": 63669, + "database-fill-dash": 63670, + "database-fill-down": 63671, + "database-fill-exclamation": 63672, + "database-fill-gear": 63673, + "database-fill-lock": 63674, + "database-fill-slash": 63675, + "database-fill-up": 63676, + "database-fill-x": 63677, + "database-fill": 63678, + "database-gear": 63679, + "database-lock": 63680, + "database-slash": 63681, + "database-up": 63682, + "database-x": 63683, + "database": 63684, + "houses-fill": 63685, + "houses": 63686, + "nvidia": 63687, + "person-vcard-fill": 63688, + "person-vcard": 63689, + "sina-weibo": 63690, + "tencent-qq": 63691, + "wikipedia": 63692, + "alphabet-uppercase": 62117, + "alphabet": 63114, + "amazon": 63117, + "arrows-collapse-vertical": 63120, + "arrows-expand-vertical": 63125, + "arrows-vertical": 63128, + "arrows": 63138, + "ban-fill": 63139, + "ban": 63158, + "bing": 63170, + "cake": 63200, + "cake2": 63213, + "cookie": 63214, + "copy": 63321, + "crosshair": 63337, + "crosshair2": 63380, + "emoji-astonished-fill": 63381, + "emoji-astonished": 63386, + "emoji-grimace-fill": 63387, + "emoji-grimace": 63392, + "emoji-grin-fill": 63393, + "emoji-grin": 63398, + "emoji-surprise-fill": 63399, + "emoji-surprise": 63404, + "emoji-tear-fill": 63405, + "emoji-tear": 63410, + "envelope-arrow-down-fill": 63411, + "envelope-arrow-down": 63416, + "envelope-arrow-up-fill": 63417, + "envelope-arrow-up": 63422, + "feather": 63423, + "feather2": 63428, + "floppy-fill": 63429, + "floppy": 63448, + "floppy2-fill": 63449, + "floppy2": 63460, + "gitlab": 63461, + "highlighter": 63480, + "marker-tip": 63490, + "nvme-fill": 63491, + "nvme": 63500, + "opencollective": 63501, + "pci-card-network": 63693, + "pci-card-sound": 63694, + "radar": 63695, + "send-arrow-down-fill": 63696, + "send-arrow-down": 63697, + "send-arrow-up-fill": 63698, + "send-arrow-up": 63699, + "sim-slash-fill": 63700, + "sim-slash": 63701, + "sourceforge": 63702, + "substack": 63703, + "threads-fill": 63704, + "threads": 63705, + "transparency": 63706, + "twitter-x": 63707, + "type-h4": 63708, + "type-h5": 63709, + "type-h6": 63710, + "backpack-fill": 63711, + "backpack": 63712, + "backpack2-fill": 63713, + "backpack2": 63714, + "backpack3-fill": 63715, + "backpack3": 63716, + "backpack4-fill": 63717, + "backpack4": 63718, + "brilliance": 63719, + "cake-fill": 63720, + "cake2-fill": 63721, + "duffle-fill": 63722, + "duffle": 63723, + "exposure": 63724, + "gender-neuter": 63725, + "highlights": 63726, + "luggage-fill": 63727, + "luggage": 63728, + "mailbox-flag": 63729, + "mailbox2-flag": 63730, + "noise-reduction": 63731, + "passport-fill": 63732, + "passport": 63733, + "person-arms-up": 63734, + "person-raised-hand": 63735, + "person-standing-dress": 63736, + "person-standing": 63737, + "person-walking": 63738, + "person-wheelchair": 63739, + "shadows": 63740, + "suitcase-fill": 63741, + "suitcase-lg-fill": 63742, + "suitcase-lg": 63743, + "suitcase": 63744, + "suitcase2-fill": 63745, + "suitcase2": 63746, + "vignette": 63747 +} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap-icons/font/bootstrap-icons.min.css b/src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap-icons/font/bootstrap-icons.min.css new file mode 100644 index 0000000..dadd6dc --- /dev/null +++ b/src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap-icons/font/bootstrap-icons.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap Icons v1.11.3 (https://icons.getbootstrap.com/) + * Copyright 2019-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) + */@font-face{font-display:block;font-family:bootstrap-icons;src:url("fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"),url("fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff")}.bi::before,[class*=" bi-"]::before,[class^=bi-]::before{display:inline-block;font-family:bootstrap-icons!important;font-style:normal;font-weight:400!important;font-variant:normal;text-transform:none;line-height:1;vertical-align:-.125em;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.bi-123::before{content:"\f67f"}.bi-alarm-fill::before{content:"\f101"}.bi-alarm::before{content:"\f102"}.bi-align-bottom::before{content:"\f103"}.bi-align-center::before{content:"\f104"}.bi-align-end::before{content:"\f105"}.bi-align-middle::before{content:"\f106"}.bi-align-start::before{content:"\f107"}.bi-align-top::before{content:"\f108"}.bi-alt::before{content:"\f109"}.bi-app-indicator::before{content:"\f10a"}.bi-app::before{content:"\f10b"}.bi-archive-fill::before{content:"\f10c"}.bi-archive::before{content:"\f10d"}.bi-arrow-90deg-down::before{content:"\f10e"}.bi-arrow-90deg-left::before{content:"\f10f"}.bi-arrow-90deg-right::before{content:"\f110"}.bi-arrow-90deg-up::before{content:"\f111"}.bi-arrow-bar-down::before{content:"\f112"}.bi-arrow-bar-left::before{content:"\f113"}.bi-arrow-bar-right::before{content:"\f114"}.bi-arrow-bar-up::before{content:"\f115"}.bi-arrow-clockwise::before{content:"\f116"}.bi-arrow-counterclockwise::before{content:"\f117"}.bi-arrow-down-circle-fill::before{content:"\f118"}.bi-arrow-down-circle::before{content:"\f119"}.bi-arrow-down-left-circle-fill::before{content:"\f11a"}.bi-arrow-down-left-circle::before{content:"\f11b"}.bi-arrow-down-left-square-fill::before{content:"\f11c"}.bi-arrow-down-left-square::before{content:"\f11d"}.bi-arrow-down-left::before{content:"\f11e"}.bi-arrow-down-right-circle-fill::before{content:"\f11f"}.bi-arrow-down-right-circle::before{content:"\f120"}.bi-arrow-down-right-square-fill::before{content:"\f121"}.bi-arrow-down-right-square::before{content:"\f122"}.bi-arrow-down-right::before{content:"\f123"}.bi-arrow-down-short::before{content:"\f124"}.bi-arrow-down-square-fill::before{content:"\f125"}.bi-arrow-down-square::before{content:"\f126"}.bi-arrow-down-up::before{content:"\f127"}.bi-arrow-down::before{content:"\f128"}.bi-arrow-left-circle-fill::before{content:"\f129"}.bi-arrow-left-circle::before{content:"\f12a"}.bi-arrow-left-right::before{content:"\f12b"}.bi-arrow-left-short::before{content:"\f12c"}.bi-arrow-left-square-fill::before{content:"\f12d"}.bi-arrow-left-square::before{content:"\f12e"}.bi-arrow-left::before{content:"\f12f"}.bi-arrow-repeat::before{content:"\f130"}.bi-arrow-return-left::before{content:"\f131"}.bi-arrow-return-right::before{content:"\f132"}.bi-arrow-right-circle-fill::before{content:"\f133"}.bi-arrow-right-circle::before{content:"\f134"}.bi-arrow-right-short::before{content:"\f135"}.bi-arrow-right-square-fill::before{content:"\f136"}.bi-arrow-right-square::before{content:"\f137"}.bi-arrow-right::before{content:"\f138"}.bi-arrow-up-circle-fill::before{content:"\f139"}.bi-arrow-up-circle::before{content:"\f13a"}.bi-arrow-up-left-circle-fill::before{content:"\f13b"}.bi-arrow-up-left-circle::before{content:"\f13c"}.bi-arrow-up-left-square-fill::before{content:"\f13d"}.bi-arrow-up-left-square::before{content:"\f13e"}.bi-arrow-up-left::before{content:"\f13f"}.bi-arrow-up-right-circle-fill::before{content:"\f140"}.bi-arrow-up-right-circle::before{content:"\f141"}.bi-arrow-up-right-square-fill::before{content:"\f142"}.bi-arrow-up-right-square::before{content:"\f143"}.bi-arrow-up-right::before{content:"\f144"}.bi-arrow-up-short::before{content:"\f145"}.bi-arrow-up-square-fill::before{content:"\f146"}.bi-arrow-up-square::before{content:"\f147"}.bi-arrow-up::before{content:"\f148"}.bi-arrows-angle-contract::before{content:"\f149"}.bi-arrows-angle-expand::before{content:"\f14a"}.bi-arrows-collapse::before{content:"\f14b"}.bi-arrows-expand::before{content:"\f14c"}.bi-arrows-fullscreen::before{content:"\f14d"}.bi-arrows-move::before{content:"\f14e"}.bi-aspect-ratio-fill::before{content:"\f14f"}.bi-aspect-ratio::before{content:"\f150"}.bi-asterisk::before{content:"\f151"}.bi-at::before{content:"\f152"}.bi-award-fill::before{content:"\f153"}.bi-award::before{content:"\f154"}.bi-back::before{content:"\f155"}.bi-backspace-fill::before{content:"\f156"}.bi-backspace-reverse-fill::before{content:"\f157"}.bi-backspace-reverse::before{content:"\f158"}.bi-backspace::before{content:"\f159"}.bi-badge-3d-fill::before{content:"\f15a"}.bi-badge-3d::before{content:"\f15b"}.bi-badge-4k-fill::before{content:"\f15c"}.bi-badge-4k::before{content:"\f15d"}.bi-badge-8k-fill::before{content:"\f15e"}.bi-badge-8k::before{content:"\f15f"}.bi-badge-ad-fill::before{content:"\f160"}.bi-badge-ad::before{content:"\f161"}.bi-badge-ar-fill::before{content:"\f162"}.bi-badge-ar::before{content:"\f163"}.bi-badge-cc-fill::before{content:"\f164"}.bi-badge-cc::before{content:"\f165"}.bi-badge-hd-fill::before{content:"\f166"}.bi-badge-hd::before{content:"\f167"}.bi-badge-tm-fill::before{content:"\f168"}.bi-badge-tm::before{content:"\f169"}.bi-badge-vo-fill::before{content:"\f16a"}.bi-badge-vo::before{content:"\f16b"}.bi-badge-vr-fill::before{content:"\f16c"}.bi-badge-vr::before{content:"\f16d"}.bi-badge-wc-fill::before{content:"\f16e"}.bi-badge-wc::before{content:"\f16f"}.bi-bag-check-fill::before{content:"\f170"}.bi-bag-check::before{content:"\f171"}.bi-bag-dash-fill::before{content:"\f172"}.bi-bag-dash::before{content:"\f173"}.bi-bag-fill::before{content:"\f174"}.bi-bag-plus-fill::before{content:"\f175"}.bi-bag-plus::before{content:"\f176"}.bi-bag-x-fill::before{content:"\f177"}.bi-bag-x::before{content:"\f178"}.bi-bag::before{content:"\f179"}.bi-bar-chart-fill::before{content:"\f17a"}.bi-bar-chart-line-fill::before{content:"\f17b"}.bi-bar-chart-line::before{content:"\f17c"}.bi-bar-chart-steps::before{content:"\f17d"}.bi-bar-chart::before{content:"\f17e"}.bi-basket-fill::before{content:"\f17f"}.bi-basket::before{content:"\f180"}.bi-basket2-fill::before{content:"\f181"}.bi-basket2::before{content:"\f182"}.bi-basket3-fill::before{content:"\f183"}.bi-basket3::before{content:"\f184"}.bi-battery-charging::before{content:"\f185"}.bi-battery-full::before{content:"\f186"}.bi-battery-half::before{content:"\f187"}.bi-battery::before{content:"\f188"}.bi-bell-fill::before{content:"\f189"}.bi-bell::before{content:"\f18a"}.bi-bezier::before{content:"\f18b"}.bi-bezier2::before{content:"\f18c"}.bi-bicycle::before{content:"\f18d"}.bi-binoculars-fill::before{content:"\f18e"}.bi-binoculars::before{content:"\f18f"}.bi-blockquote-left::before{content:"\f190"}.bi-blockquote-right::before{content:"\f191"}.bi-book-fill::before{content:"\f192"}.bi-book-half::before{content:"\f193"}.bi-book::before{content:"\f194"}.bi-bookmark-check-fill::before{content:"\f195"}.bi-bookmark-check::before{content:"\f196"}.bi-bookmark-dash-fill::before{content:"\f197"}.bi-bookmark-dash::before{content:"\f198"}.bi-bookmark-fill::before{content:"\f199"}.bi-bookmark-heart-fill::before{content:"\f19a"}.bi-bookmark-heart::before{content:"\f19b"}.bi-bookmark-plus-fill::before{content:"\f19c"}.bi-bookmark-plus::before{content:"\f19d"}.bi-bookmark-star-fill::before{content:"\f19e"}.bi-bookmark-star::before{content:"\f19f"}.bi-bookmark-x-fill::before{content:"\f1a0"}.bi-bookmark-x::before{content:"\f1a1"}.bi-bookmark::before{content:"\f1a2"}.bi-bookmarks-fill::before{content:"\f1a3"}.bi-bookmarks::before{content:"\f1a4"}.bi-bookshelf::before{content:"\f1a5"}.bi-bootstrap-fill::before{content:"\f1a6"}.bi-bootstrap-reboot::before{content:"\f1a7"}.bi-bootstrap::before{content:"\f1a8"}.bi-border-all::before{content:"\f1a9"}.bi-border-bottom::before{content:"\f1aa"}.bi-border-center::before{content:"\f1ab"}.bi-border-inner::before{content:"\f1ac"}.bi-border-left::before{content:"\f1ad"}.bi-border-middle::before{content:"\f1ae"}.bi-border-outer::before{content:"\f1af"}.bi-border-right::before{content:"\f1b0"}.bi-border-style::before{content:"\f1b1"}.bi-border-top::before{content:"\f1b2"}.bi-border-width::before{content:"\f1b3"}.bi-border::before{content:"\f1b4"}.bi-bounding-box-circles::before{content:"\f1b5"}.bi-bounding-box::before{content:"\f1b6"}.bi-box-arrow-down-left::before{content:"\f1b7"}.bi-box-arrow-down-right::before{content:"\f1b8"}.bi-box-arrow-down::before{content:"\f1b9"}.bi-box-arrow-in-down-left::before{content:"\f1ba"}.bi-box-arrow-in-down-right::before{content:"\f1bb"}.bi-box-arrow-in-down::before{content:"\f1bc"}.bi-box-arrow-in-left::before{content:"\f1bd"}.bi-box-arrow-in-right::before{content:"\f1be"}.bi-box-arrow-in-up-left::before{content:"\f1bf"}.bi-box-arrow-in-up-right::before{content:"\f1c0"}.bi-box-arrow-in-up::before{content:"\f1c1"}.bi-box-arrow-left::before{content:"\f1c2"}.bi-box-arrow-right::before{content:"\f1c3"}.bi-box-arrow-up-left::before{content:"\f1c4"}.bi-box-arrow-up-right::before{content:"\f1c5"}.bi-box-arrow-up::before{content:"\f1c6"}.bi-box-seam::before{content:"\f1c7"}.bi-box::before{content:"\f1c8"}.bi-braces::before{content:"\f1c9"}.bi-bricks::before{content:"\f1ca"}.bi-briefcase-fill::before{content:"\f1cb"}.bi-briefcase::before{content:"\f1cc"}.bi-brightness-alt-high-fill::before{content:"\f1cd"}.bi-brightness-alt-high::before{content:"\f1ce"}.bi-brightness-alt-low-fill::before{content:"\f1cf"}.bi-brightness-alt-low::before{content:"\f1d0"}.bi-brightness-high-fill::before{content:"\f1d1"}.bi-brightness-high::before{content:"\f1d2"}.bi-brightness-low-fill::before{content:"\f1d3"}.bi-brightness-low::before{content:"\f1d4"}.bi-broadcast-pin::before{content:"\f1d5"}.bi-broadcast::before{content:"\f1d6"}.bi-brush-fill::before{content:"\f1d7"}.bi-brush::before{content:"\f1d8"}.bi-bucket-fill::before{content:"\f1d9"}.bi-bucket::before{content:"\f1da"}.bi-bug-fill::before{content:"\f1db"}.bi-bug::before{content:"\f1dc"}.bi-building::before{content:"\f1dd"}.bi-bullseye::before{content:"\f1de"}.bi-calculator-fill::before{content:"\f1df"}.bi-calculator::before{content:"\f1e0"}.bi-calendar-check-fill::before{content:"\f1e1"}.bi-calendar-check::before{content:"\f1e2"}.bi-calendar-date-fill::before{content:"\f1e3"}.bi-calendar-date::before{content:"\f1e4"}.bi-calendar-day-fill::before{content:"\f1e5"}.bi-calendar-day::before{content:"\f1e6"}.bi-calendar-event-fill::before{content:"\f1e7"}.bi-calendar-event::before{content:"\f1e8"}.bi-calendar-fill::before{content:"\f1e9"}.bi-calendar-minus-fill::before{content:"\f1ea"}.bi-calendar-minus::before{content:"\f1eb"}.bi-calendar-month-fill::before{content:"\f1ec"}.bi-calendar-month::before{content:"\f1ed"}.bi-calendar-plus-fill::before{content:"\f1ee"}.bi-calendar-plus::before{content:"\f1ef"}.bi-calendar-range-fill::before{content:"\f1f0"}.bi-calendar-range::before{content:"\f1f1"}.bi-calendar-week-fill::before{content:"\f1f2"}.bi-calendar-week::before{content:"\f1f3"}.bi-calendar-x-fill::before{content:"\f1f4"}.bi-calendar-x::before{content:"\f1f5"}.bi-calendar::before{content:"\f1f6"}.bi-calendar2-check-fill::before{content:"\f1f7"}.bi-calendar2-check::before{content:"\f1f8"}.bi-calendar2-date-fill::before{content:"\f1f9"}.bi-calendar2-date::before{content:"\f1fa"}.bi-calendar2-day-fill::before{content:"\f1fb"}.bi-calendar2-day::before{content:"\f1fc"}.bi-calendar2-event-fill::before{content:"\f1fd"}.bi-calendar2-event::before{content:"\f1fe"}.bi-calendar2-fill::before{content:"\f1ff"}.bi-calendar2-minus-fill::before{content:"\f200"}.bi-calendar2-minus::before{content:"\f201"}.bi-calendar2-month-fill::before{content:"\f202"}.bi-calendar2-month::before{content:"\f203"}.bi-calendar2-plus-fill::before{content:"\f204"}.bi-calendar2-plus::before{content:"\f205"}.bi-calendar2-range-fill::before{content:"\f206"}.bi-calendar2-range::before{content:"\f207"}.bi-calendar2-week-fill::before{content:"\f208"}.bi-calendar2-week::before{content:"\f209"}.bi-calendar2-x-fill::before{content:"\f20a"}.bi-calendar2-x::before{content:"\f20b"}.bi-calendar2::before{content:"\f20c"}.bi-calendar3-event-fill::before{content:"\f20d"}.bi-calendar3-event::before{content:"\f20e"}.bi-calendar3-fill::before{content:"\f20f"}.bi-calendar3-range-fill::before{content:"\f210"}.bi-calendar3-range::before{content:"\f211"}.bi-calendar3-week-fill::before{content:"\f212"}.bi-calendar3-week::before{content:"\f213"}.bi-calendar3::before{content:"\f214"}.bi-calendar4-event::before{content:"\f215"}.bi-calendar4-range::before{content:"\f216"}.bi-calendar4-week::before{content:"\f217"}.bi-calendar4::before{content:"\f218"}.bi-camera-fill::before{content:"\f219"}.bi-camera-reels-fill::before{content:"\f21a"}.bi-camera-reels::before{content:"\f21b"}.bi-camera-video-fill::before{content:"\f21c"}.bi-camera-video-off-fill::before{content:"\f21d"}.bi-camera-video-off::before{content:"\f21e"}.bi-camera-video::before{content:"\f21f"}.bi-camera::before{content:"\f220"}.bi-camera2::before{content:"\f221"}.bi-capslock-fill::before{content:"\f222"}.bi-capslock::before{content:"\f223"}.bi-card-checklist::before{content:"\f224"}.bi-card-heading::before{content:"\f225"}.bi-card-image::before{content:"\f226"}.bi-card-list::before{content:"\f227"}.bi-card-text::before{content:"\f228"}.bi-caret-down-fill::before{content:"\f229"}.bi-caret-down-square-fill::before{content:"\f22a"}.bi-caret-down-square::before{content:"\f22b"}.bi-caret-down::before{content:"\f22c"}.bi-caret-left-fill::before{content:"\f22d"}.bi-caret-left-square-fill::before{content:"\f22e"}.bi-caret-left-square::before{content:"\f22f"}.bi-caret-left::before{content:"\f230"}.bi-caret-right-fill::before{content:"\f231"}.bi-caret-right-square-fill::before{content:"\f232"}.bi-caret-right-square::before{content:"\f233"}.bi-caret-right::before{content:"\f234"}.bi-caret-up-fill::before{content:"\f235"}.bi-caret-up-square-fill::before{content:"\f236"}.bi-caret-up-square::before{content:"\f237"}.bi-caret-up::before{content:"\f238"}.bi-cart-check-fill::before{content:"\f239"}.bi-cart-check::before{content:"\f23a"}.bi-cart-dash-fill::before{content:"\f23b"}.bi-cart-dash::before{content:"\f23c"}.bi-cart-fill::before{content:"\f23d"}.bi-cart-plus-fill::before{content:"\f23e"}.bi-cart-plus::before{content:"\f23f"}.bi-cart-x-fill::before{content:"\f240"}.bi-cart-x::before{content:"\f241"}.bi-cart::before{content:"\f242"}.bi-cart2::before{content:"\f243"}.bi-cart3::before{content:"\f244"}.bi-cart4::before{content:"\f245"}.bi-cash-stack::before{content:"\f246"}.bi-cash::before{content:"\f247"}.bi-cast::before{content:"\f248"}.bi-chat-dots-fill::before{content:"\f249"}.bi-chat-dots::before{content:"\f24a"}.bi-chat-fill::before{content:"\f24b"}.bi-chat-left-dots-fill::before{content:"\f24c"}.bi-chat-left-dots::before{content:"\f24d"}.bi-chat-left-fill::before{content:"\f24e"}.bi-chat-left-quote-fill::before{content:"\f24f"}.bi-chat-left-quote::before{content:"\f250"}.bi-chat-left-text-fill::before{content:"\f251"}.bi-chat-left-text::before{content:"\f252"}.bi-chat-left::before{content:"\f253"}.bi-chat-quote-fill::before{content:"\f254"}.bi-chat-quote::before{content:"\f255"}.bi-chat-right-dots-fill::before{content:"\f256"}.bi-chat-right-dots::before{content:"\f257"}.bi-chat-right-fill::before{content:"\f258"}.bi-chat-right-quote-fill::before{content:"\f259"}.bi-chat-right-quote::before{content:"\f25a"}.bi-chat-right-text-fill::before{content:"\f25b"}.bi-chat-right-text::before{content:"\f25c"}.bi-chat-right::before{content:"\f25d"}.bi-chat-square-dots-fill::before{content:"\f25e"}.bi-chat-square-dots::before{content:"\f25f"}.bi-chat-square-fill::before{content:"\f260"}.bi-chat-square-quote-fill::before{content:"\f261"}.bi-chat-square-quote::before{content:"\f262"}.bi-chat-square-text-fill::before{content:"\f263"}.bi-chat-square-text::before{content:"\f264"}.bi-chat-square::before{content:"\f265"}.bi-chat-text-fill::before{content:"\f266"}.bi-chat-text::before{content:"\f267"}.bi-chat::before{content:"\f268"}.bi-check-all::before{content:"\f269"}.bi-check-circle-fill::before{content:"\f26a"}.bi-check-circle::before{content:"\f26b"}.bi-check-square-fill::before{content:"\f26c"}.bi-check-square::before{content:"\f26d"}.bi-check::before{content:"\f26e"}.bi-check2-all::before{content:"\f26f"}.bi-check2-circle::before{content:"\f270"}.bi-check2-square::before{content:"\f271"}.bi-check2::before{content:"\f272"}.bi-chevron-bar-contract::before{content:"\f273"}.bi-chevron-bar-down::before{content:"\f274"}.bi-chevron-bar-expand::before{content:"\f275"}.bi-chevron-bar-left::before{content:"\f276"}.bi-chevron-bar-right::before{content:"\f277"}.bi-chevron-bar-up::before{content:"\f278"}.bi-chevron-compact-down::before{content:"\f279"}.bi-chevron-compact-left::before{content:"\f27a"}.bi-chevron-compact-right::before{content:"\f27b"}.bi-chevron-compact-up::before{content:"\f27c"}.bi-chevron-contract::before{content:"\f27d"}.bi-chevron-double-down::before{content:"\f27e"}.bi-chevron-double-left::before{content:"\f27f"}.bi-chevron-double-right::before{content:"\f280"}.bi-chevron-double-up::before{content:"\f281"}.bi-chevron-down::before{content:"\f282"}.bi-chevron-expand::before{content:"\f283"}.bi-chevron-left::before{content:"\f284"}.bi-chevron-right::before{content:"\f285"}.bi-chevron-up::before{content:"\f286"}.bi-circle-fill::before{content:"\f287"}.bi-circle-half::before{content:"\f288"}.bi-circle-square::before{content:"\f289"}.bi-circle::before{content:"\f28a"}.bi-clipboard-check::before{content:"\f28b"}.bi-clipboard-data::before{content:"\f28c"}.bi-clipboard-minus::before{content:"\f28d"}.bi-clipboard-plus::before{content:"\f28e"}.bi-clipboard-x::before{content:"\f28f"}.bi-clipboard::before{content:"\f290"}.bi-clock-fill::before{content:"\f291"}.bi-clock-history::before{content:"\f292"}.bi-clock::before{content:"\f293"}.bi-cloud-arrow-down-fill::before{content:"\f294"}.bi-cloud-arrow-down::before{content:"\f295"}.bi-cloud-arrow-up-fill::before{content:"\f296"}.bi-cloud-arrow-up::before{content:"\f297"}.bi-cloud-check-fill::before{content:"\f298"}.bi-cloud-check::before{content:"\f299"}.bi-cloud-download-fill::before{content:"\f29a"}.bi-cloud-download::before{content:"\f29b"}.bi-cloud-drizzle-fill::before{content:"\f29c"}.bi-cloud-drizzle::before{content:"\f29d"}.bi-cloud-fill::before{content:"\f29e"}.bi-cloud-fog-fill::before{content:"\f29f"}.bi-cloud-fog::before{content:"\f2a0"}.bi-cloud-fog2-fill::before{content:"\f2a1"}.bi-cloud-fog2::before{content:"\f2a2"}.bi-cloud-hail-fill::before{content:"\f2a3"}.bi-cloud-hail::before{content:"\f2a4"}.bi-cloud-haze-fill::before{content:"\f2a6"}.bi-cloud-haze::before{content:"\f2a7"}.bi-cloud-haze2-fill::before{content:"\f2a8"}.bi-cloud-lightning-fill::before{content:"\f2a9"}.bi-cloud-lightning-rain-fill::before{content:"\f2aa"}.bi-cloud-lightning-rain::before{content:"\f2ab"}.bi-cloud-lightning::before{content:"\f2ac"}.bi-cloud-minus-fill::before{content:"\f2ad"}.bi-cloud-minus::before{content:"\f2ae"}.bi-cloud-moon-fill::before{content:"\f2af"}.bi-cloud-moon::before{content:"\f2b0"}.bi-cloud-plus-fill::before{content:"\f2b1"}.bi-cloud-plus::before{content:"\f2b2"}.bi-cloud-rain-fill::before{content:"\f2b3"}.bi-cloud-rain-heavy-fill::before{content:"\f2b4"}.bi-cloud-rain-heavy::before{content:"\f2b5"}.bi-cloud-rain::before{content:"\f2b6"}.bi-cloud-slash-fill::before{content:"\f2b7"}.bi-cloud-slash::before{content:"\f2b8"}.bi-cloud-sleet-fill::before{content:"\f2b9"}.bi-cloud-sleet::before{content:"\f2ba"}.bi-cloud-snow-fill::before{content:"\f2bb"}.bi-cloud-snow::before{content:"\f2bc"}.bi-cloud-sun-fill::before{content:"\f2bd"}.bi-cloud-sun::before{content:"\f2be"}.bi-cloud-upload-fill::before{content:"\f2bf"}.bi-cloud-upload::before{content:"\f2c0"}.bi-cloud::before{content:"\f2c1"}.bi-clouds-fill::before{content:"\f2c2"}.bi-clouds::before{content:"\f2c3"}.bi-cloudy-fill::before{content:"\f2c4"}.bi-cloudy::before{content:"\f2c5"}.bi-code-slash::before{content:"\f2c6"}.bi-code-square::before{content:"\f2c7"}.bi-code::before{content:"\f2c8"}.bi-collection-fill::before{content:"\f2c9"}.bi-collection-play-fill::before{content:"\f2ca"}.bi-collection-play::before{content:"\f2cb"}.bi-collection::before{content:"\f2cc"}.bi-columns-gap::before{content:"\f2cd"}.bi-columns::before{content:"\f2ce"}.bi-command::before{content:"\f2cf"}.bi-compass-fill::before{content:"\f2d0"}.bi-compass::before{content:"\f2d1"}.bi-cone-striped::before{content:"\f2d2"}.bi-cone::before{content:"\f2d3"}.bi-controller::before{content:"\f2d4"}.bi-cpu-fill::before{content:"\f2d5"}.bi-cpu::before{content:"\f2d6"}.bi-credit-card-2-back-fill::before{content:"\f2d7"}.bi-credit-card-2-back::before{content:"\f2d8"}.bi-credit-card-2-front-fill::before{content:"\f2d9"}.bi-credit-card-2-front::before{content:"\f2da"}.bi-credit-card-fill::before{content:"\f2db"}.bi-credit-card::before{content:"\f2dc"}.bi-crop::before{content:"\f2dd"}.bi-cup-fill::before{content:"\f2de"}.bi-cup-straw::before{content:"\f2df"}.bi-cup::before{content:"\f2e0"}.bi-cursor-fill::before{content:"\f2e1"}.bi-cursor-text::before{content:"\f2e2"}.bi-cursor::before{content:"\f2e3"}.bi-dash-circle-dotted::before{content:"\f2e4"}.bi-dash-circle-fill::before{content:"\f2e5"}.bi-dash-circle::before{content:"\f2e6"}.bi-dash-square-dotted::before{content:"\f2e7"}.bi-dash-square-fill::before{content:"\f2e8"}.bi-dash-square::before{content:"\f2e9"}.bi-dash::before{content:"\f2ea"}.bi-diagram-2-fill::before{content:"\f2eb"}.bi-diagram-2::before{content:"\f2ec"}.bi-diagram-3-fill::before{content:"\f2ed"}.bi-diagram-3::before{content:"\f2ee"}.bi-diamond-fill::before{content:"\f2ef"}.bi-diamond-half::before{content:"\f2f0"}.bi-diamond::before{content:"\f2f1"}.bi-dice-1-fill::before{content:"\f2f2"}.bi-dice-1::before{content:"\f2f3"}.bi-dice-2-fill::before{content:"\f2f4"}.bi-dice-2::before{content:"\f2f5"}.bi-dice-3-fill::before{content:"\f2f6"}.bi-dice-3::before{content:"\f2f7"}.bi-dice-4-fill::before{content:"\f2f8"}.bi-dice-4::before{content:"\f2f9"}.bi-dice-5-fill::before{content:"\f2fa"}.bi-dice-5::before{content:"\f2fb"}.bi-dice-6-fill::before{content:"\f2fc"}.bi-dice-6::before{content:"\f2fd"}.bi-disc-fill::before{content:"\f2fe"}.bi-disc::before{content:"\f2ff"}.bi-discord::before{content:"\f300"}.bi-display-fill::before{content:"\f301"}.bi-display::before{content:"\f302"}.bi-distribute-horizontal::before{content:"\f303"}.bi-distribute-vertical::before{content:"\f304"}.bi-door-closed-fill::before{content:"\f305"}.bi-door-closed::before{content:"\f306"}.bi-door-open-fill::before{content:"\f307"}.bi-door-open::before{content:"\f308"}.bi-dot::before{content:"\f309"}.bi-download::before{content:"\f30a"}.bi-droplet-fill::before{content:"\f30b"}.bi-droplet-half::before{content:"\f30c"}.bi-droplet::before{content:"\f30d"}.bi-earbuds::before{content:"\f30e"}.bi-easel-fill::before{content:"\f30f"}.bi-easel::before{content:"\f310"}.bi-egg-fill::before{content:"\f311"}.bi-egg-fried::before{content:"\f312"}.bi-egg::before{content:"\f313"}.bi-eject-fill::before{content:"\f314"}.bi-eject::before{content:"\f315"}.bi-emoji-angry-fill::before{content:"\f316"}.bi-emoji-angry::before{content:"\f317"}.bi-emoji-dizzy-fill::before{content:"\f318"}.bi-emoji-dizzy::before{content:"\f319"}.bi-emoji-expressionless-fill::before{content:"\f31a"}.bi-emoji-expressionless::before{content:"\f31b"}.bi-emoji-frown-fill::before{content:"\f31c"}.bi-emoji-frown::before{content:"\f31d"}.bi-emoji-heart-eyes-fill::before{content:"\f31e"}.bi-emoji-heart-eyes::before{content:"\f31f"}.bi-emoji-laughing-fill::before{content:"\f320"}.bi-emoji-laughing::before{content:"\f321"}.bi-emoji-neutral-fill::before{content:"\f322"}.bi-emoji-neutral::before{content:"\f323"}.bi-emoji-smile-fill::before{content:"\f324"}.bi-emoji-smile-upside-down-fill::before{content:"\f325"}.bi-emoji-smile-upside-down::before{content:"\f326"}.bi-emoji-smile::before{content:"\f327"}.bi-emoji-sunglasses-fill::before{content:"\f328"}.bi-emoji-sunglasses::before{content:"\f329"}.bi-emoji-wink-fill::before{content:"\f32a"}.bi-emoji-wink::before{content:"\f32b"}.bi-envelope-fill::before{content:"\f32c"}.bi-envelope-open-fill::before{content:"\f32d"}.bi-envelope-open::before{content:"\f32e"}.bi-envelope::before{content:"\f32f"}.bi-eraser-fill::before{content:"\f330"}.bi-eraser::before{content:"\f331"}.bi-exclamation-circle-fill::before{content:"\f332"}.bi-exclamation-circle::before{content:"\f333"}.bi-exclamation-diamond-fill::before{content:"\f334"}.bi-exclamation-diamond::before{content:"\f335"}.bi-exclamation-octagon-fill::before{content:"\f336"}.bi-exclamation-octagon::before{content:"\f337"}.bi-exclamation-square-fill::before{content:"\f338"}.bi-exclamation-square::before{content:"\f339"}.bi-exclamation-triangle-fill::before{content:"\f33a"}.bi-exclamation-triangle::before{content:"\f33b"}.bi-exclamation::before{content:"\f33c"}.bi-exclude::before{content:"\f33d"}.bi-eye-fill::before{content:"\f33e"}.bi-eye-slash-fill::before{content:"\f33f"}.bi-eye-slash::before{content:"\f340"}.bi-eye::before{content:"\f341"}.bi-eyedropper::before{content:"\f342"}.bi-eyeglasses::before{content:"\f343"}.bi-facebook::before{content:"\f344"}.bi-file-arrow-down-fill::before{content:"\f345"}.bi-file-arrow-down::before{content:"\f346"}.bi-file-arrow-up-fill::before{content:"\f347"}.bi-file-arrow-up::before{content:"\f348"}.bi-file-bar-graph-fill::before{content:"\f349"}.bi-file-bar-graph::before{content:"\f34a"}.bi-file-binary-fill::before{content:"\f34b"}.bi-file-binary::before{content:"\f34c"}.bi-file-break-fill::before{content:"\f34d"}.bi-file-break::before{content:"\f34e"}.bi-file-check-fill::before{content:"\f34f"}.bi-file-check::before{content:"\f350"}.bi-file-code-fill::before{content:"\f351"}.bi-file-code::before{content:"\f352"}.bi-file-diff-fill::before{content:"\f353"}.bi-file-diff::before{content:"\f354"}.bi-file-earmark-arrow-down-fill::before{content:"\f355"}.bi-file-earmark-arrow-down::before{content:"\f356"}.bi-file-earmark-arrow-up-fill::before{content:"\f357"}.bi-file-earmark-arrow-up::before{content:"\f358"}.bi-file-earmark-bar-graph-fill::before{content:"\f359"}.bi-file-earmark-bar-graph::before{content:"\f35a"}.bi-file-earmark-binary-fill::before{content:"\f35b"}.bi-file-earmark-binary::before{content:"\f35c"}.bi-file-earmark-break-fill::before{content:"\f35d"}.bi-file-earmark-break::before{content:"\f35e"}.bi-file-earmark-check-fill::before{content:"\f35f"}.bi-file-earmark-check::before{content:"\f360"}.bi-file-earmark-code-fill::before{content:"\f361"}.bi-file-earmark-code::before{content:"\f362"}.bi-file-earmark-diff-fill::before{content:"\f363"}.bi-file-earmark-diff::before{content:"\f364"}.bi-file-earmark-easel-fill::before{content:"\f365"}.bi-file-earmark-easel::before{content:"\f366"}.bi-file-earmark-excel-fill::before{content:"\f367"}.bi-file-earmark-excel::before{content:"\f368"}.bi-file-earmark-fill::before{content:"\f369"}.bi-file-earmark-font-fill::before{content:"\f36a"}.bi-file-earmark-font::before{content:"\f36b"}.bi-file-earmark-image-fill::before{content:"\f36c"}.bi-file-earmark-image::before{content:"\f36d"}.bi-file-earmark-lock-fill::before{content:"\f36e"}.bi-file-earmark-lock::before{content:"\f36f"}.bi-file-earmark-lock2-fill::before{content:"\f370"}.bi-file-earmark-lock2::before{content:"\f371"}.bi-file-earmark-medical-fill::before{content:"\f372"}.bi-file-earmark-medical::before{content:"\f373"}.bi-file-earmark-minus-fill::before{content:"\f374"}.bi-file-earmark-minus::before{content:"\f375"}.bi-file-earmark-music-fill::before{content:"\f376"}.bi-file-earmark-music::before{content:"\f377"}.bi-file-earmark-person-fill::before{content:"\f378"}.bi-file-earmark-person::before{content:"\f379"}.bi-file-earmark-play-fill::before{content:"\f37a"}.bi-file-earmark-play::before{content:"\f37b"}.bi-file-earmark-plus-fill::before{content:"\f37c"}.bi-file-earmark-plus::before{content:"\f37d"}.bi-file-earmark-post-fill::before{content:"\f37e"}.bi-file-earmark-post::before{content:"\f37f"}.bi-file-earmark-ppt-fill::before{content:"\f380"}.bi-file-earmark-ppt::before{content:"\f381"}.bi-file-earmark-richtext-fill::before{content:"\f382"}.bi-file-earmark-richtext::before{content:"\f383"}.bi-file-earmark-ruled-fill::before{content:"\f384"}.bi-file-earmark-ruled::before{content:"\f385"}.bi-file-earmark-slides-fill::before{content:"\f386"}.bi-file-earmark-slides::before{content:"\f387"}.bi-file-earmark-spreadsheet-fill::before{content:"\f388"}.bi-file-earmark-spreadsheet::before{content:"\f389"}.bi-file-earmark-text-fill::before{content:"\f38a"}.bi-file-earmark-text::before{content:"\f38b"}.bi-file-earmark-word-fill::before{content:"\f38c"}.bi-file-earmark-word::before{content:"\f38d"}.bi-file-earmark-x-fill::before{content:"\f38e"}.bi-file-earmark-x::before{content:"\f38f"}.bi-file-earmark-zip-fill::before{content:"\f390"}.bi-file-earmark-zip::before{content:"\f391"}.bi-file-earmark::before{content:"\f392"}.bi-file-easel-fill::before{content:"\f393"}.bi-file-easel::before{content:"\f394"}.bi-file-excel-fill::before{content:"\f395"}.bi-file-excel::before{content:"\f396"}.bi-file-fill::before{content:"\f397"}.bi-file-font-fill::before{content:"\f398"}.bi-file-font::before{content:"\f399"}.bi-file-image-fill::before{content:"\f39a"}.bi-file-image::before{content:"\f39b"}.bi-file-lock-fill::before{content:"\f39c"}.bi-file-lock::before{content:"\f39d"}.bi-file-lock2-fill::before{content:"\f39e"}.bi-file-lock2::before{content:"\f39f"}.bi-file-medical-fill::before{content:"\f3a0"}.bi-file-medical::before{content:"\f3a1"}.bi-file-minus-fill::before{content:"\f3a2"}.bi-file-minus::before{content:"\f3a3"}.bi-file-music-fill::before{content:"\f3a4"}.bi-file-music::before{content:"\f3a5"}.bi-file-person-fill::before{content:"\f3a6"}.bi-file-person::before{content:"\f3a7"}.bi-file-play-fill::before{content:"\f3a8"}.bi-file-play::before{content:"\f3a9"}.bi-file-plus-fill::before{content:"\f3aa"}.bi-file-plus::before{content:"\f3ab"}.bi-file-post-fill::before{content:"\f3ac"}.bi-file-post::before{content:"\f3ad"}.bi-file-ppt-fill::before{content:"\f3ae"}.bi-file-ppt::before{content:"\f3af"}.bi-file-richtext-fill::before{content:"\f3b0"}.bi-file-richtext::before{content:"\f3b1"}.bi-file-ruled-fill::before{content:"\f3b2"}.bi-file-ruled::before{content:"\f3b3"}.bi-file-slides-fill::before{content:"\f3b4"}.bi-file-slides::before{content:"\f3b5"}.bi-file-spreadsheet-fill::before{content:"\f3b6"}.bi-file-spreadsheet::before{content:"\f3b7"}.bi-file-text-fill::before{content:"\f3b8"}.bi-file-text::before{content:"\f3b9"}.bi-file-word-fill::before{content:"\f3ba"}.bi-file-word::before{content:"\f3bb"}.bi-file-x-fill::before{content:"\f3bc"}.bi-file-x::before{content:"\f3bd"}.bi-file-zip-fill::before{content:"\f3be"}.bi-file-zip::before{content:"\f3bf"}.bi-file::before{content:"\f3c0"}.bi-files-alt::before{content:"\f3c1"}.bi-files::before{content:"\f3c2"}.bi-film::before{content:"\f3c3"}.bi-filter-circle-fill::before{content:"\f3c4"}.bi-filter-circle::before{content:"\f3c5"}.bi-filter-left::before{content:"\f3c6"}.bi-filter-right::before{content:"\f3c7"}.bi-filter-square-fill::before{content:"\f3c8"}.bi-filter-square::before{content:"\f3c9"}.bi-filter::before{content:"\f3ca"}.bi-flag-fill::before{content:"\f3cb"}.bi-flag::before{content:"\f3cc"}.bi-flower1::before{content:"\f3cd"}.bi-flower2::before{content:"\f3ce"}.bi-flower3::before{content:"\f3cf"}.bi-folder-check::before{content:"\f3d0"}.bi-folder-fill::before{content:"\f3d1"}.bi-folder-minus::before{content:"\f3d2"}.bi-folder-plus::before{content:"\f3d3"}.bi-folder-symlink-fill::before{content:"\f3d4"}.bi-folder-symlink::before{content:"\f3d5"}.bi-folder-x::before{content:"\f3d6"}.bi-folder::before{content:"\f3d7"}.bi-folder2-open::before{content:"\f3d8"}.bi-folder2::before{content:"\f3d9"}.bi-fonts::before{content:"\f3da"}.bi-forward-fill::before{content:"\f3db"}.bi-forward::before{content:"\f3dc"}.bi-front::before{content:"\f3dd"}.bi-fullscreen-exit::before{content:"\f3de"}.bi-fullscreen::before{content:"\f3df"}.bi-funnel-fill::before{content:"\f3e0"}.bi-funnel::before{content:"\f3e1"}.bi-gear-fill::before{content:"\f3e2"}.bi-gear-wide-connected::before{content:"\f3e3"}.bi-gear-wide::before{content:"\f3e4"}.bi-gear::before{content:"\f3e5"}.bi-gem::before{content:"\f3e6"}.bi-geo-alt-fill::before{content:"\f3e7"}.bi-geo-alt::before{content:"\f3e8"}.bi-geo-fill::before{content:"\f3e9"}.bi-geo::before{content:"\f3ea"}.bi-gift-fill::before{content:"\f3eb"}.bi-gift::before{content:"\f3ec"}.bi-github::before{content:"\f3ed"}.bi-globe::before{content:"\f3ee"}.bi-globe2::before{content:"\f3ef"}.bi-google::before{content:"\f3f0"}.bi-graph-down::before{content:"\f3f1"}.bi-graph-up::before{content:"\f3f2"}.bi-grid-1x2-fill::before{content:"\f3f3"}.bi-grid-1x2::before{content:"\f3f4"}.bi-grid-3x2-gap-fill::before{content:"\f3f5"}.bi-grid-3x2-gap::before{content:"\f3f6"}.bi-grid-3x2::before{content:"\f3f7"}.bi-grid-3x3-gap-fill::before{content:"\f3f8"}.bi-grid-3x3-gap::before{content:"\f3f9"}.bi-grid-3x3::before{content:"\f3fa"}.bi-grid-fill::before{content:"\f3fb"}.bi-grid::before{content:"\f3fc"}.bi-grip-horizontal::before{content:"\f3fd"}.bi-grip-vertical::before{content:"\f3fe"}.bi-hammer::before{content:"\f3ff"}.bi-hand-index-fill::before{content:"\f400"}.bi-hand-index-thumb-fill::before{content:"\f401"}.bi-hand-index-thumb::before{content:"\f402"}.bi-hand-index::before{content:"\f403"}.bi-hand-thumbs-down-fill::before{content:"\f404"}.bi-hand-thumbs-down::before{content:"\f405"}.bi-hand-thumbs-up-fill::before{content:"\f406"}.bi-hand-thumbs-up::before{content:"\f407"}.bi-handbag-fill::before{content:"\f408"}.bi-handbag::before{content:"\f409"}.bi-hash::before{content:"\f40a"}.bi-hdd-fill::before{content:"\f40b"}.bi-hdd-network-fill::before{content:"\f40c"}.bi-hdd-network::before{content:"\f40d"}.bi-hdd-rack-fill::before{content:"\f40e"}.bi-hdd-rack::before{content:"\f40f"}.bi-hdd-stack-fill::before{content:"\f410"}.bi-hdd-stack::before{content:"\f411"}.bi-hdd::before{content:"\f412"}.bi-headphones::before{content:"\f413"}.bi-headset::before{content:"\f414"}.bi-heart-fill::before{content:"\f415"}.bi-heart-half::before{content:"\f416"}.bi-heart::before{content:"\f417"}.bi-heptagon-fill::before{content:"\f418"}.bi-heptagon-half::before{content:"\f419"}.bi-heptagon::before{content:"\f41a"}.bi-hexagon-fill::before{content:"\f41b"}.bi-hexagon-half::before{content:"\f41c"}.bi-hexagon::before{content:"\f41d"}.bi-hourglass-bottom::before{content:"\f41e"}.bi-hourglass-split::before{content:"\f41f"}.bi-hourglass-top::before{content:"\f420"}.bi-hourglass::before{content:"\f421"}.bi-house-door-fill::before{content:"\f422"}.bi-house-door::before{content:"\f423"}.bi-house-fill::before{content:"\f424"}.bi-house::before{content:"\f425"}.bi-hr::before{content:"\f426"}.bi-hurricane::before{content:"\f427"}.bi-image-alt::before{content:"\f428"}.bi-image-fill::before{content:"\f429"}.bi-image::before{content:"\f42a"}.bi-images::before{content:"\f42b"}.bi-inbox-fill::before{content:"\f42c"}.bi-inbox::before{content:"\f42d"}.bi-inboxes-fill::before{content:"\f42e"}.bi-inboxes::before{content:"\f42f"}.bi-info-circle-fill::before{content:"\f430"}.bi-info-circle::before{content:"\f431"}.bi-info-square-fill::before{content:"\f432"}.bi-info-square::before{content:"\f433"}.bi-info::before{content:"\f434"}.bi-input-cursor-text::before{content:"\f435"}.bi-input-cursor::before{content:"\f436"}.bi-instagram::before{content:"\f437"}.bi-intersect::before{content:"\f438"}.bi-journal-album::before{content:"\f439"}.bi-journal-arrow-down::before{content:"\f43a"}.bi-journal-arrow-up::before{content:"\f43b"}.bi-journal-bookmark-fill::before{content:"\f43c"}.bi-journal-bookmark::before{content:"\f43d"}.bi-journal-check::before{content:"\f43e"}.bi-journal-code::before{content:"\f43f"}.bi-journal-medical::before{content:"\f440"}.bi-journal-minus::before{content:"\f441"}.bi-journal-plus::before{content:"\f442"}.bi-journal-richtext::before{content:"\f443"}.bi-journal-text::before{content:"\f444"}.bi-journal-x::before{content:"\f445"}.bi-journal::before{content:"\f446"}.bi-journals::before{content:"\f447"}.bi-joystick::before{content:"\f448"}.bi-justify-left::before{content:"\f449"}.bi-justify-right::before{content:"\f44a"}.bi-justify::before{content:"\f44b"}.bi-kanban-fill::before{content:"\f44c"}.bi-kanban::before{content:"\f44d"}.bi-key-fill::before{content:"\f44e"}.bi-key::before{content:"\f44f"}.bi-keyboard-fill::before{content:"\f450"}.bi-keyboard::before{content:"\f451"}.bi-ladder::before{content:"\f452"}.bi-lamp-fill::before{content:"\f453"}.bi-lamp::before{content:"\f454"}.bi-laptop-fill::before{content:"\f455"}.bi-laptop::before{content:"\f456"}.bi-layer-backward::before{content:"\f457"}.bi-layer-forward::before{content:"\f458"}.bi-layers-fill::before{content:"\f459"}.bi-layers-half::before{content:"\f45a"}.bi-layers::before{content:"\f45b"}.bi-layout-sidebar-inset-reverse::before{content:"\f45c"}.bi-layout-sidebar-inset::before{content:"\f45d"}.bi-layout-sidebar-reverse::before{content:"\f45e"}.bi-layout-sidebar::before{content:"\f45f"}.bi-layout-split::before{content:"\f460"}.bi-layout-text-sidebar-reverse::before{content:"\f461"}.bi-layout-text-sidebar::before{content:"\f462"}.bi-layout-text-window-reverse::before{content:"\f463"}.bi-layout-text-window::before{content:"\f464"}.bi-layout-three-columns::before{content:"\f465"}.bi-layout-wtf::before{content:"\f466"}.bi-life-preserver::before{content:"\f467"}.bi-lightbulb-fill::before{content:"\f468"}.bi-lightbulb-off-fill::before{content:"\f469"}.bi-lightbulb-off::before{content:"\f46a"}.bi-lightbulb::before{content:"\f46b"}.bi-lightning-charge-fill::before{content:"\f46c"}.bi-lightning-charge::before{content:"\f46d"}.bi-lightning-fill::before{content:"\f46e"}.bi-lightning::before{content:"\f46f"}.bi-link-45deg::before{content:"\f470"}.bi-link::before{content:"\f471"}.bi-linkedin::before{content:"\f472"}.bi-list-check::before{content:"\f473"}.bi-list-nested::before{content:"\f474"}.bi-list-ol::before{content:"\f475"}.bi-list-stars::before{content:"\f476"}.bi-list-task::before{content:"\f477"}.bi-list-ul::before{content:"\f478"}.bi-list::before{content:"\f479"}.bi-lock-fill::before{content:"\f47a"}.bi-lock::before{content:"\f47b"}.bi-mailbox::before{content:"\f47c"}.bi-mailbox2::before{content:"\f47d"}.bi-map-fill::before{content:"\f47e"}.bi-map::before{content:"\f47f"}.bi-markdown-fill::before{content:"\f480"}.bi-markdown::before{content:"\f481"}.bi-mask::before{content:"\f482"}.bi-megaphone-fill::before{content:"\f483"}.bi-megaphone::before{content:"\f484"}.bi-menu-app-fill::before{content:"\f485"}.bi-menu-app::before{content:"\f486"}.bi-menu-button-fill::before{content:"\f487"}.bi-menu-button-wide-fill::before{content:"\f488"}.bi-menu-button-wide::before{content:"\f489"}.bi-menu-button::before{content:"\f48a"}.bi-menu-down::before{content:"\f48b"}.bi-menu-up::before{content:"\f48c"}.bi-mic-fill::before{content:"\f48d"}.bi-mic-mute-fill::before{content:"\f48e"}.bi-mic-mute::before{content:"\f48f"}.bi-mic::before{content:"\f490"}.bi-minecart-loaded::before{content:"\f491"}.bi-minecart::before{content:"\f492"}.bi-moisture::before{content:"\f493"}.bi-moon-fill::before{content:"\f494"}.bi-moon-stars-fill::before{content:"\f495"}.bi-moon-stars::before{content:"\f496"}.bi-moon::before{content:"\f497"}.bi-mouse-fill::before{content:"\f498"}.bi-mouse::before{content:"\f499"}.bi-mouse2-fill::before{content:"\f49a"}.bi-mouse2::before{content:"\f49b"}.bi-mouse3-fill::before{content:"\f49c"}.bi-mouse3::before{content:"\f49d"}.bi-music-note-beamed::before{content:"\f49e"}.bi-music-note-list::before{content:"\f49f"}.bi-music-note::before{content:"\f4a0"}.bi-music-player-fill::before{content:"\f4a1"}.bi-music-player::before{content:"\f4a2"}.bi-newspaper::before{content:"\f4a3"}.bi-node-minus-fill::before{content:"\f4a4"}.bi-node-minus::before{content:"\f4a5"}.bi-node-plus-fill::before{content:"\f4a6"}.bi-node-plus::before{content:"\f4a7"}.bi-nut-fill::before{content:"\f4a8"}.bi-nut::before{content:"\f4a9"}.bi-octagon-fill::before{content:"\f4aa"}.bi-octagon-half::before{content:"\f4ab"}.bi-octagon::before{content:"\f4ac"}.bi-option::before{content:"\f4ad"}.bi-outlet::before{content:"\f4ae"}.bi-paint-bucket::before{content:"\f4af"}.bi-palette-fill::before{content:"\f4b0"}.bi-palette::before{content:"\f4b1"}.bi-palette2::before{content:"\f4b2"}.bi-paperclip::before{content:"\f4b3"}.bi-paragraph::before{content:"\f4b4"}.bi-patch-check-fill::before{content:"\f4b5"}.bi-patch-check::before{content:"\f4b6"}.bi-patch-exclamation-fill::before{content:"\f4b7"}.bi-patch-exclamation::before{content:"\f4b8"}.bi-patch-minus-fill::before{content:"\f4b9"}.bi-patch-minus::before{content:"\f4ba"}.bi-patch-plus-fill::before{content:"\f4bb"}.bi-patch-plus::before{content:"\f4bc"}.bi-patch-question-fill::before{content:"\f4bd"}.bi-patch-question::before{content:"\f4be"}.bi-pause-btn-fill::before{content:"\f4bf"}.bi-pause-btn::before{content:"\f4c0"}.bi-pause-circle-fill::before{content:"\f4c1"}.bi-pause-circle::before{content:"\f4c2"}.bi-pause-fill::before{content:"\f4c3"}.bi-pause::before{content:"\f4c4"}.bi-peace-fill::before{content:"\f4c5"}.bi-peace::before{content:"\f4c6"}.bi-pen-fill::before{content:"\f4c7"}.bi-pen::before{content:"\f4c8"}.bi-pencil-fill::before{content:"\f4c9"}.bi-pencil-square::before{content:"\f4ca"}.bi-pencil::before{content:"\f4cb"}.bi-pentagon-fill::before{content:"\f4cc"}.bi-pentagon-half::before{content:"\f4cd"}.bi-pentagon::before{content:"\f4ce"}.bi-people-fill::before{content:"\f4cf"}.bi-people::before{content:"\f4d0"}.bi-percent::before{content:"\f4d1"}.bi-person-badge-fill::before{content:"\f4d2"}.bi-person-badge::before{content:"\f4d3"}.bi-person-bounding-box::before{content:"\f4d4"}.bi-person-check-fill::before{content:"\f4d5"}.bi-person-check::before{content:"\f4d6"}.bi-person-circle::before{content:"\f4d7"}.bi-person-dash-fill::before{content:"\f4d8"}.bi-person-dash::before{content:"\f4d9"}.bi-person-fill::before{content:"\f4da"}.bi-person-lines-fill::before{content:"\f4db"}.bi-person-plus-fill::before{content:"\f4dc"}.bi-person-plus::before{content:"\f4dd"}.bi-person-square::before{content:"\f4de"}.bi-person-x-fill::before{content:"\f4df"}.bi-person-x::before{content:"\f4e0"}.bi-person::before{content:"\f4e1"}.bi-phone-fill::before{content:"\f4e2"}.bi-phone-landscape-fill::before{content:"\f4e3"}.bi-phone-landscape::before{content:"\f4e4"}.bi-phone-vibrate-fill::before{content:"\f4e5"}.bi-phone-vibrate::before{content:"\f4e6"}.bi-phone::before{content:"\f4e7"}.bi-pie-chart-fill::before{content:"\f4e8"}.bi-pie-chart::before{content:"\f4e9"}.bi-pin-angle-fill::before{content:"\f4ea"}.bi-pin-angle::before{content:"\f4eb"}.bi-pin-fill::before{content:"\f4ec"}.bi-pin::before{content:"\f4ed"}.bi-pip-fill::before{content:"\f4ee"}.bi-pip::before{content:"\f4ef"}.bi-play-btn-fill::before{content:"\f4f0"}.bi-play-btn::before{content:"\f4f1"}.bi-play-circle-fill::before{content:"\f4f2"}.bi-play-circle::before{content:"\f4f3"}.bi-play-fill::before{content:"\f4f4"}.bi-play::before{content:"\f4f5"}.bi-plug-fill::before{content:"\f4f6"}.bi-plug::before{content:"\f4f7"}.bi-plus-circle-dotted::before{content:"\f4f8"}.bi-plus-circle-fill::before{content:"\f4f9"}.bi-plus-circle::before{content:"\f4fa"}.bi-plus-square-dotted::before{content:"\f4fb"}.bi-plus-square-fill::before{content:"\f4fc"}.bi-plus-square::before{content:"\f4fd"}.bi-plus::before{content:"\f4fe"}.bi-power::before{content:"\f4ff"}.bi-printer-fill::before{content:"\f500"}.bi-printer::before{content:"\f501"}.bi-puzzle-fill::before{content:"\f502"}.bi-puzzle::before{content:"\f503"}.bi-question-circle-fill::before{content:"\f504"}.bi-question-circle::before{content:"\f505"}.bi-question-diamond-fill::before{content:"\f506"}.bi-question-diamond::before{content:"\f507"}.bi-question-octagon-fill::before{content:"\f508"}.bi-question-octagon::before{content:"\f509"}.bi-question-square-fill::before{content:"\f50a"}.bi-question-square::before{content:"\f50b"}.bi-question::before{content:"\f50c"}.bi-rainbow::before{content:"\f50d"}.bi-receipt-cutoff::before{content:"\f50e"}.bi-receipt::before{content:"\f50f"}.bi-reception-0::before{content:"\f510"}.bi-reception-1::before{content:"\f511"}.bi-reception-2::before{content:"\f512"}.bi-reception-3::before{content:"\f513"}.bi-reception-4::before{content:"\f514"}.bi-record-btn-fill::before{content:"\f515"}.bi-record-btn::before{content:"\f516"}.bi-record-circle-fill::before{content:"\f517"}.bi-record-circle::before{content:"\f518"}.bi-record-fill::before{content:"\f519"}.bi-record::before{content:"\f51a"}.bi-record2-fill::before{content:"\f51b"}.bi-record2::before{content:"\f51c"}.bi-reply-all-fill::before{content:"\f51d"}.bi-reply-all::before{content:"\f51e"}.bi-reply-fill::before{content:"\f51f"}.bi-reply::before{content:"\f520"}.bi-rss-fill::before{content:"\f521"}.bi-rss::before{content:"\f522"}.bi-rulers::before{content:"\f523"}.bi-save-fill::before{content:"\f524"}.bi-save::before{content:"\f525"}.bi-save2-fill::before{content:"\f526"}.bi-save2::before{content:"\f527"}.bi-scissors::before{content:"\f528"}.bi-screwdriver::before{content:"\f529"}.bi-search::before{content:"\f52a"}.bi-segmented-nav::before{content:"\f52b"}.bi-server::before{content:"\f52c"}.bi-share-fill::before{content:"\f52d"}.bi-share::before{content:"\f52e"}.bi-shield-check::before{content:"\f52f"}.bi-shield-exclamation::before{content:"\f530"}.bi-shield-fill-check::before{content:"\f531"}.bi-shield-fill-exclamation::before{content:"\f532"}.bi-shield-fill-minus::before{content:"\f533"}.bi-shield-fill-plus::before{content:"\f534"}.bi-shield-fill-x::before{content:"\f535"}.bi-shield-fill::before{content:"\f536"}.bi-shield-lock-fill::before{content:"\f537"}.bi-shield-lock::before{content:"\f538"}.bi-shield-minus::before{content:"\f539"}.bi-shield-plus::before{content:"\f53a"}.bi-shield-shaded::before{content:"\f53b"}.bi-shield-slash-fill::before{content:"\f53c"}.bi-shield-slash::before{content:"\f53d"}.bi-shield-x::before{content:"\f53e"}.bi-shield::before{content:"\f53f"}.bi-shift-fill::before{content:"\f540"}.bi-shift::before{content:"\f541"}.bi-shop-window::before{content:"\f542"}.bi-shop::before{content:"\f543"}.bi-shuffle::before{content:"\f544"}.bi-signpost-2-fill::before{content:"\f545"}.bi-signpost-2::before{content:"\f546"}.bi-signpost-fill::before{content:"\f547"}.bi-signpost-split-fill::before{content:"\f548"}.bi-signpost-split::before{content:"\f549"}.bi-signpost::before{content:"\f54a"}.bi-sim-fill::before{content:"\f54b"}.bi-sim::before{content:"\f54c"}.bi-skip-backward-btn-fill::before{content:"\f54d"}.bi-skip-backward-btn::before{content:"\f54e"}.bi-skip-backward-circle-fill::before{content:"\f54f"}.bi-skip-backward-circle::before{content:"\f550"}.bi-skip-backward-fill::before{content:"\f551"}.bi-skip-backward::before{content:"\f552"}.bi-skip-end-btn-fill::before{content:"\f553"}.bi-skip-end-btn::before{content:"\f554"}.bi-skip-end-circle-fill::before{content:"\f555"}.bi-skip-end-circle::before{content:"\f556"}.bi-skip-end-fill::before{content:"\f557"}.bi-skip-end::before{content:"\f558"}.bi-skip-forward-btn-fill::before{content:"\f559"}.bi-skip-forward-btn::before{content:"\f55a"}.bi-skip-forward-circle-fill::before{content:"\f55b"}.bi-skip-forward-circle::before{content:"\f55c"}.bi-skip-forward-fill::before{content:"\f55d"}.bi-skip-forward::before{content:"\f55e"}.bi-skip-start-btn-fill::before{content:"\f55f"}.bi-skip-start-btn::before{content:"\f560"}.bi-skip-start-circle-fill::before{content:"\f561"}.bi-skip-start-circle::before{content:"\f562"}.bi-skip-start-fill::before{content:"\f563"}.bi-skip-start::before{content:"\f564"}.bi-slack::before{content:"\f565"}.bi-slash-circle-fill::before{content:"\f566"}.bi-slash-circle::before{content:"\f567"}.bi-slash-square-fill::before{content:"\f568"}.bi-slash-square::before{content:"\f569"}.bi-slash::before{content:"\f56a"}.bi-sliders::before{content:"\f56b"}.bi-smartwatch::before{content:"\f56c"}.bi-snow::before{content:"\f56d"}.bi-snow2::before{content:"\f56e"}.bi-snow3::before{content:"\f56f"}.bi-sort-alpha-down-alt::before{content:"\f570"}.bi-sort-alpha-down::before{content:"\f571"}.bi-sort-alpha-up-alt::before{content:"\f572"}.bi-sort-alpha-up::before{content:"\f573"}.bi-sort-down-alt::before{content:"\f574"}.bi-sort-down::before{content:"\f575"}.bi-sort-numeric-down-alt::before{content:"\f576"}.bi-sort-numeric-down::before{content:"\f577"}.bi-sort-numeric-up-alt::before{content:"\f578"}.bi-sort-numeric-up::before{content:"\f579"}.bi-sort-up-alt::before{content:"\f57a"}.bi-sort-up::before{content:"\f57b"}.bi-soundwave::before{content:"\f57c"}.bi-speaker-fill::before{content:"\f57d"}.bi-speaker::before{content:"\f57e"}.bi-speedometer::before{content:"\f57f"}.bi-speedometer2::before{content:"\f580"}.bi-spellcheck::before{content:"\f581"}.bi-square-fill::before{content:"\f582"}.bi-square-half::before{content:"\f583"}.bi-square::before{content:"\f584"}.bi-stack::before{content:"\f585"}.bi-star-fill::before{content:"\f586"}.bi-star-half::before{content:"\f587"}.bi-star::before{content:"\f588"}.bi-stars::before{content:"\f589"}.bi-stickies-fill::before{content:"\f58a"}.bi-stickies::before{content:"\f58b"}.bi-sticky-fill::before{content:"\f58c"}.bi-sticky::before{content:"\f58d"}.bi-stop-btn-fill::before{content:"\f58e"}.bi-stop-btn::before{content:"\f58f"}.bi-stop-circle-fill::before{content:"\f590"}.bi-stop-circle::before{content:"\f591"}.bi-stop-fill::before{content:"\f592"}.bi-stop::before{content:"\f593"}.bi-stoplights-fill::before{content:"\f594"}.bi-stoplights::before{content:"\f595"}.bi-stopwatch-fill::before{content:"\f596"}.bi-stopwatch::before{content:"\f597"}.bi-subtract::before{content:"\f598"}.bi-suit-club-fill::before{content:"\f599"}.bi-suit-club::before{content:"\f59a"}.bi-suit-diamond-fill::before{content:"\f59b"}.bi-suit-diamond::before{content:"\f59c"}.bi-suit-heart-fill::before{content:"\f59d"}.bi-suit-heart::before{content:"\f59e"}.bi-suit-spade-fill::before{content:"\f59f"}.bi-suit-spade::before{content:"\f5a0"}.bi-sun-fill::before{content:"\f5a1"}.bi-sun::before{content:"\f5a2"}.bi-sunglasses::before{content:"\f5a3"}.bi-sunrise-fill::before{content:"\f5a4"}.bi-sunrise::before{content:"\f5a5"}.bi-sunset-fill::before{content:"\f5a6"}.bi-sunset::before{content:"\f5a7"}.bi-symmetry-horizontal::before{content:"\f5a8"}.bi-symmetry-vertical::before{content:"\f5a9"}.bi-table::before{content:"\f5aa"}.bi-tablet-fill::before{content:"\f5ab"}.bi-tablet-landscape-fill::before{content:"\f5ac"}.bi-tablet-landscape::before{content:"\f5ad"}.bi-tablet::before{content:"\f5ae"}.bi-tag-fill::before{content:"\f5af"}.bi-tag::before{content:"\f5b0"}.bi-tags-fill::before{content:"\f5b1"}.bi-tags::before{content:"\f5b2"}.bi-telegram::before{content:"\f5b3"}.bi-telephone-fill::before{content:"\f5b4"}.bi-telephone-forward-fill::before{content:"\f5b5"}.bi-telephone-forward::before{content:"\f5b6"}.bi-telephone-inbound-fill::before{content:"\f5b7"}.bi-telephone-inbound::before{content:"\f5b8"}.bi-telephone-minus-fill::before{content:"\f5b9"}.bi-telephone-minus::before{content:"\f5ba"}.bi-telephone-outbound-fill::before{content:"\f5bb"}.bi-telephone-outbound::before{content:"\f5bc"}.bi-telephone-plus-fill::before{content:"\f5bd"}.bi-telephone-plus::before{content:"\f5be"}.bi-telephone-x-fill::before{content:"\f5bf"}.bi-telephone-x::before{content:"\f5c0"}.bi-telephone::before{content:"\f5c1"}.bi-terminal-fill::before{content:"\f5c2"}.bi-terminal::before{content:"\f5c3"}.bi-text-center::before{content:"\f5c4"}.bi-text-indent-left::before{content:"\f5c5"}.bi-text-indent-right::before{content:"\f5c6"}.bi-text-left::before{content:"\f5c7"}.bi-text-paragraph::before{content:"\f5c8"}.bi-text-right::before{content:"\f5c9"}.bi-textarea-resize::before{content:"\f5ca"}.bi-textarea-t::before{content:"\f5cb"}.bi-textarea::before{content:"\f5cc"}.bi-thermometer-half::before{content:"\f5cd"}.bi-thermometer-high::before{content:"\f5ce"}.bi-thermometer-low::before{content:"\f5cf"}.bi-thermometer-snow::before{content:"\f5d0"}.bi-thermometer-sun::before{content:"\f5d1"}.bi-thermometer::before{content:"\f5d2"}.bi-three-dots-vertical::before{content:"\f5d3"}.bi-three-dots::before{content:"\f5d4"}.bi-toggle-off::before{content:"\f5d5"}.bi-toggle-on::before{content:"\f5d6"}.bi-toggle2-off::before{content:"\f5d7"}.bi-toggle2-on::before{content:"\f5d8"}.bi-toggles::before{content:"\f5d9"}.bi-toggles2::before{content:"\f5da"}.bi-tools::before{content:"\f5db"}.bi-tornado::before{content:"\f5dc"}.bi-trash-fill::before{content:"\f5dd"}.bi-trash::before{content:"\f5de"}.bi-trash2-fill::before{content:"\f5df"}.bi-trash2::before{content:"\f5e0"}.bi-tree-fill::before{content:"\f5e1"}.bi-tree::before{content:"\f5e2"}.bi-triangle-fill::before{content:"\f5e3"}.bi-triangle-half::before{content:"\f5e4"}.bi-triangle::before{content:"\f5e5"}.bi-trophy-fill::before{content:"\f5e6"}.bi-trophy::before{content:"\f5e7"}.bi-tropical-storm::before{content:"\f5e8"}.bi-truck-flatbed::before{content:"\f5e9"}.bi-truck::before{content:"\f5ea"}.bi-tsunami::before{content:"\f5eb"}.bi-tv-fill::before{content:"\f5ec"}.bi-tv::before{content:"\f5ed"}.bi-twitch::before{content:"\f5ee"}.bi-twitter::before{content:"\f5ef"}.bi-type-bold::before{content:"\f5f0"}.bi-type-h1::before{content:"\f5f1"}.bi-type-h2::before{content:"\f5f2"}.bi-type-h3::before{content:"\f5f3"}.bi-type-italic::before{content:"\f5f4"}.bi-type-strikethrough::before{content:"\f5f5"}.bi-type-underline::before{content:"\f5f6"}.bi-type::before{content:"\f5f7"}.bi-ui-checks-grid::before{content:"\f5f8"}.bi-ui-checks::before{content:"\f5f9"}.bi-ui-radios-grid::before{content:"\f5fa"}.bi-ui-radios::before{content:"\f5fb"}.bi-umbrella-fill::before{content:"\f5fc"}.bi-umbrella::before{content:"\f5fd"}.bi-union::before{content:"\f5fe"}.bi-unlock-fill::before{content:"\f5ff"}.bi-unlock::before{content:"\f600"}.bi-upc-scan::before{content:"\f601"}.bi-upc::before{content:"\f602"}.bi-upload::before{content:"\f603"}.bi-vector-pen::before{content:"\f604"}.bi-view-list::before{content:"\f605"}.bi-view-stacked::before{content:"\f606"}.bi-vinyl-fill::before{content:"\f607"}.bi-vinyl::before{content:"\f608"}.bi-voicemail::before{content:"\f609"}.bi-volume-down-fill::before{content:"\f60a"}.bi-volume-down::before{content:"\f60b"}.bi-volume-mute-fill::before{content:"\f60c"}.bi-volume-mute::before{content:"\f60d"}.bi-volume-off-fill::before{content:"\f60e"}.bi-volume-off::before{content:"\f60f"}.bi-volume-up-fill::before{content:"\f610"}.bi-volume-up::before{content:"\f611"}.bi-vr::before{content:"\f612"}.bi-wallet-fill::before{content:"\f613"}.bi-wallet::before{content:"\f614"}.bi-wallet2::before{content:"\f615"}.bi-watch::before{content:"\f616"}.bi-water::before{content:"\f617"}.bi-whatsapp::before{content:"\f618"}.bi-wifi-1::before{content:"\f619"}.bi-wifi-2::before{content:"\f61a"}.bi-wifi-off::before{content:"\f61b"}.bi-wifi::before{content:"\f61c"}.bi-wind::before{content:"\f61d"}.bi-window-dock::before{content:"\f61e"}.bi-window-sidebar::before{content:"\f61f"}.bi-window::before{content:"\f620"}.bi-wrench::before{content:"\f621"}.bi-x-circle-fill::before{content:"\f622"}.bi-x-circle::before{content:"\f623"}.bi-x-diamond-fill::before{content:"\f624"}.bi-x-diamond::before{content:"\f625"}.bi-x-octagon-fill::before{content:"\f626"}.bi-x-octagon::before{content:"\f627"}.bi-x-square-fill::before{content:"\f628"}.bi-x-square::before{content:"\f629"}.bi-x::before{content:"\f62a"}.bi-youtube::before{content:"\f62b"}.bi-zoom-in::before{content:"\f62c"}.bi-zoom-out::before{content:"\f62d"}.bi-bank::before{content:"\f62e"}.bi-bank2::before{content:"\f62f"}.bi-bell-slash-fill::before{content:"\f630"}.bi-bell-slash::before{content:"\f631"}.bi-cash-coin::before{content:"\f632"}.bi-check-lg::before{content:"\f633"}.bi-coin::before{content:"\f634"}.bi-currency-bitcoin::before{content:"\f635"}.bi-currency-dollar::before{content:"\f636"}.bi-currency-euro::before{content:"\f637"}.bi-currency-exchange::before{content:"\f638"}.bi-currency-pound::before{content:"\f639"}.bi-currency-yen::before{content:"\f63a"}.bi-dash-lg::before{content:"\f63b"}.bi-exclamation-lg::before{content:"\f63c"}.bi-file-earmark-pdf-fill::before{content:"\f63d"}.bi-file-earmark-pdf::before{content:"\f63e"}.bi-file-pdf-fill::before{content:"\f63f"}.bi-file-pdf::before{content:"\f640"}.bi-gender-ambiguous::before{content:"\f641"}.bi-gender-female::before{content:"\f642"}.bi-gender-male::before{content:"\f643"}.bi-gender-trans::before{content:"\f644"}.bi-headset-vr::before{content:"\f645"}.bi-info-lg::before{content:"\f646"}.bi-mastodon::before{content:"\f647"}.bi-messenger::before{content:"\f648"}.bi-piggy-bank-fill::before{content:"\f649"}.bi-piggy-bank::before{content:"\f64a"}.bi-pin-map-fill::before{content:"\f64b"}.bi-pin-map::before{content:"\f64c"}.bi-plus-lg::before{content:"\f64d"}.bi-question-lg::before{content:"\f64e"}.bi-recycle::before{content:"\f64f"}.bi-reddit::before{content:"\f650"}.bi-safe-fill::before{content:"\f651"}.bi-safe2-fill::before{content:"\f652"}.bi-safe2::before{content:"\f653"}.bi-sd-card-fill::before{content:"\f654"}.bi-sd-card::before{content:"\f655"}.bi-skype::before{content:"\f656"}.bi-slash-lg::before{content:"\f657"}.bi-translate::before{content:"\f658"}.bi-x-lg::before{content:"\f659"}.bi-safe::before{content:"\f65a"}.bi-apple::before{content:"\f65b"}.bi-microsoft::before{content:"\f65d"}.bi-windows::before{content:"\f65e"}.bi-behance::before{content:"\f65c"}.bi-dribbble::before{content:"\f65f"}.bi-line::before{content:"\f660"}.bi-medium::before{content:"\f661"}.bi-paypal::before{content:"\f662"}.bi-pinterest::before{content:"\f663"}.bi-signal::before{content:"\f664"}.bi-snapchat::before{content:"\f665"}.bi-spotify::before{content:"\f666"}.bi-stack-overflow::before{content:"\f667"}.bi-strava::before{content:"\f668"}.bi-wordpress::before{content:"\f669"}.bi-vimeo::before{content:"\f66a"}.bi-activity::before{content:"\f66b"}.bi-easel2-fill::before{content:"\f66c"}.bi-easel2::before{content:"\f66d"}.bi-easel3-fill::before{content:"\f66e"}.bi-easel3::before{content:"\f66f"}.bi-fan::before{content:"\f670"}.bi-fingerprint::before{content:"\f671"}.bi-graph-down-arrow::before{content:"\f672"}.bi-graph-up-arrow::before{content:"\f673"}.bi-hypnotize::before{content:"\f674"}.bi-magic::before{content:"\f675"}.bi-person-rolodex::before{content:"\f676"}.bi-person-video::before{content:"\f677"}.bi-person-video2::before{content:"\f678"}.bi-person-video3::before{content:"\f679"}.bi-person-workspace::before{content:"\f67a"}.bi-radioactive::before{content:"\f67b"}.bi-webcam-fill::before{content:"\f67c"}.bi-webcam::before{content:"\f67d"}.bi-yin-yang::before{content:"\f67e"}.bi-bandaid-fill::before{content:"\f680"}.bi-bandaid::before{content:"\f681"}.bi-bluetooth::before{content:"\f682"}.bi-body-text::before{content:"\f683"}.bi-boombox::before{content:"\f684"}.bi-boxes::before{content:"\f685"}.bi-dpad-fill::before{content:"\f686"}.bi-dpad::before{content:"\f687"}.bi-ear-fill::before{content:"\f688"}.bi-ear::before{content:"\f689"}.bi-envelope-check-fill::before{content:"\f68b"}.bi-envelope-check::before{content:"\f68c"}.bi-envelope-dash-fill::before{content:"\f68e"}.bi-envelope-dash::before{content:"\f68f"}.bi-envelope-exclamation-fill::before{content:"\f691"}.bi-envelope-exclamation::before{content:"\f692"}.bi-envelope-plus-fill::before{content:"\f693"}.bi-envelope-plus::before{content:"\f694"}.bi-envelope-slash-fill::before{content:"\f696"}.bi-envelope-slash::before{content:"\f697"}.bi-envelope-x-fill::before{content:"\f699"}.bi-envelope-x::before{content:"\f69a"}.bi-explicit-fill::before{content:"\f69b"}.bi-explicit::before{content:"\f69c"}.bi-git::before{content:"\f69d"}.bi-infinity::before{content:"\f69e"}.bi-list-columns-reverse::before{content:"\f69f"}.bi-list-columns::before{content:"\f6a0"}.bi-meta::before{content:"\f6a1"}.bi-nintendo-switch::before{content:"\f6a4"}.bi-pc-display-horizontal::before{content:"\f6a5"}.bi-pc-display::before{content:"\f6a6"}.bi-pc-horizontal::before{content:"\f6a7"}.bi-pc::before{content:"\f6a8"}.bi-playstation::before{content:"\f6a9"}.bi-plus-slash-minus::before{content:"\f6aa"}.bi-projector-fill::before{content:"\f6ab"}.bi-projector::before{content:"\f6ac"}.bi-qr-code-scan::before{content:"\f6ad"}.bi-qr-code::before{content:"\f6ae"}.bi-quora::before{content:"\f6af"}.bi-quote::before{content:"\f6b0"}.bi-robot::before{content:"\f6b1"}.bi-send-check-fill::before{content:"\f6b2"}.bi-send-check::before{content:"\f6b3"}.bi-send-dash-fill::before{content:"\f6b4"}.bi-send-dash::before{content:"\f6b5"}.bi-send-exclamation-fill::before{content:"\f6b7"}.bi-send-exclamation::before{content:"\f6b8"}.bi-send-fill::before{content:"\f6b9"}.bi-send-plus-fill::before{content:"\f6ba"}.bi-send-plus::before{content:"\f6bb"}.bi-send-slash-fill::before{content:"\f6bc"}.bi-send-slash::before{content:"\f6bd"}.bi-send-x-fill::before{content:"\f6be"}.bi-send-x::before{content:"\f6bf"}.bi-send::before{content:"\f6c0"}.bi-steam::before{content:"\f6c1"}.bi-terminal-dash::before{content:"\f6c3"}.bi-terminal-plus::before{content:"\f6c4"}.bi-terminal-split::before{content:"\f6c5"}.bi-ticket-detailed-fill::before{content:"\f6c6"}.bi-ticket-detailed::before{content:"\f6c7"}.bi-ticket-fill::before{content:"\f6c8"}.bi-ticket-perforated-fill::before{content:"\f6c9"}.bi-ticket-perforated::before{content:"\f6ca"}.bi-ticket::before{content:"\f6cb"}.bi-tiktok::before{content:"\f6cc"}.bi-window-dash::before{content:"\f6cd"}.bi-window-desktop::before{content:"\f6ce"}.bi-window-fullscreen::before{content:"\f6cf"}.bi-window-plus::before{content:"\f6d0"}.bi-window-split::before{content:"\f6d1"}.bi-window-stack::before{content:"\f6d2"}.bi-window-x::before{content:"\f6d3"}.bi-xbox::before{content:"\f6d4"}.bi-ethernet::before{content:"\f6d5"}.bi-hdmi-fill::before{content:"\f6d6"}.bi-hdmi::before{content:"\f6d7"}.bi-usb-c-fill::before{content:"\f6d8"}.bi-usb-c::before{content:"\f6d9"}.bi-usb-fill::before{content:"\f6da"}.bi-usb-plug-fill::before{content:"\f6db"}.bi-usb-plug::before{content:"\f6dc"}.bi-usb-symbol::before{content:"\f6dd"}.bi-usb::before{content:"\f6de"}.bi-boombox-fill::before{content:"\f6df"}.bi-displayport::before{content:"\f6e1"}.bi-gpu-card::before{content:"\f6e2"}.bi-memory::before{content:"\f6e3"}.bi-modem-fill::before{content:"\f6e4"}.bi-modem::before{content:"\f6e5"}.bi-motherboard-fill::before{content:"\f6e6"}.bi-motherboard::before{content:"\f6e7"}.bi-optical-audio-fill::before{content:"\f6e8"}.bi-optical-audio::before{content:"\f6e9"}.bi-pci-card::before{content:"\f6ea"}.bi-router-fill::before{content:"\f6eb"}.bi-router::before{content:"\f6ec"}.bi-thunderbolt-fill::before{content:"\f6ef"}.bi-thunderbolt::before{content:"\f6f0"}.bi-usb-drive-fill::before{content:"\f6f1"}.bi-usb-drive::before{content:"\f6f2"}.bi-usb-micro-fill::before{content:"\f6f3"}.bi-usb-micro::before{content:"\f6f4"}.bi-usb-mini-fill::before{content:"\f6f5"}.bi-usb-mini::before{content:"\f6f6"}.bi-cloud-haze2::before{content:"\f6f7"}.bi-device-hdd-fill::before{content:"\f6f8"}.bi-device-hdd::before{content:"\f6f9"}.bi-device-ssd-fill::before{content:"\f6fa"}.bi-device-ssd::before{content:"\f6fb"}.bi-displayport-fill::before{content:"\f6fc"}.bi-mortarboard-fill::before{content:"\f6fd"}.bi-mortarboard::before{content:"\f6fe"}.bi-terminal-x::before{content:"\f6ff"}.bi-arrow-through-heart-fill::before{content:"\f700"}.bi-arrow-through-heart::before{content:"\f701"}.bi-badge-sd-fill::before{content:"\f702"}.bi-badge-sd::before{content:"\f703"}.bi-bag-heart-fill::before{content:"\f704"}.bi-bag-heart::before{content:"\f705"}.bi-balloon-fill::before{content:"\f706"}.bi-balloon-heart-fill::before{content:"\f707"}.bi-balloon-heart::before{content:"\f708"}.bi-balloon::before{content:"\f709"}.bi-box2-fill::before{content:"\f70a"}.bi-box2-heart-fill::before{content:"\f70b"}.bi-box2-heart::before{content:"\f70c"}.bi-box2::before{content:"\f70d"}.bi-braces-asterisk::before{content:"\f70e"}.bi-calendar-heart-fill::before{content:"\f70f"}.bi-calendar-heart::before{content:"\f710"}.bi-calendar2-heart-fill::before{content:"\f711"}.bi-calendar2-heart::before{content:"\f712"}.bi-chat-heart-fill::before{content:"\f713"}.bi-chat-heart::before{content:"\f714"}.bi-chat-left-heart-fill::before{content:"\f715"}.bi-chat-left-heart::before{content:"\f716"}.bi-chat-right-heart-fill::before{content:"\f717"}.bi-chat-right-heart::before{content:"\f718"}.bi-chat-square-heart-fill::before{content:"\f719"}.bi-chat-square-heart::before{content:"\f71a"}.bi-clipboard-check-fill::before{content:"\f71b"}.bi-clipboard-data-fill::before{content:"\f71c"}.bi-clipboard-fill::before{content:"\f71d"}.bi-clipboard-heart-fill::before{content:"\f71e"}.bi-clipboard-heart::before{content:"\f71f"}.bi-clipboard-minus-fill::before{content:"\f720"}.bi-clipboard-plus-fill::before{content:"\f721"}.bi-clipboard-pulse::before{content:"\f722"}.bi-clipboard-x-fill::before{content:"\f723"}.bi-clipboard2-check-fill::before{content:"\f724"}.bi-clipboard2-check::before{content:"\f725"}.bi-clipboard2-data-fill::before{content:"\f726"}.bi-clipboard2-data::before{content:"\f727"}.bi-clipboard2-fill::before{content:"\f728"}.bi-clipboard2-heart-fill::before{content:"\f729"}.bi-clipboard2-heart::before{content:"\f72a"}.bi-clipboard2-minus-fill::before{content:"\f72b"}.bi-clipboard2-minus::before{content:"\f72c"}.bi-clipboard2-plus-fill::before{content:"\f72d"}.bi-clipboard2-plus::before{content:"\f72e"}.bi-clipboard2-pulse-fill::before{content:"\f72f"}.bi-clipboard2-pulse::before{content:"\f730"}.bi-clipboard2-x-fill::before{content:"\f731"}.bi-clipboard2-x::before{content:"\f732"}.bi-clipboard2::before{content:"\f733"}.bi-emoji-kiss-fill::before{content:"\f734"}.bi-emoji-kiss::before{content:"\f735"}.bi-envelope-heart-fill::before{content:"\f736"}.bi-envelope-heart::before{content:"\f737"}.bi-envelope-open-heart-fill::before{content:"\f738"}.bi-envelope-open-heart::before{content:"\f739"}.bi-envelope-paper-fill::before{content:"\f73a"}.bi-envelope-paper-heart-fill::before{content:"\f73b"}.bi-envelope-paper-heart::before{content:"\f73c"}.bi-envelope-paper::before{content:"\f73d"}.bi-filetype-aac::before{content:"\f73e"}.bi-filetype-ai::before{content:"\f73f"}.bi-filetype-bmp::before{content:"\f740"}.bi-filetype-cs::before{content:"\f741"}.bi-filetype-css::before{content:"\f742"}.bi-filetype-csv::before{content:"\f743"}.bi-filetype-doc::before{content:"\f744"}.bi-filetype-docx::before{content:"\f745"}.bi-filetype-exe::before{content:"\f746"}.bi-filetype-gif::before{content:"\f747"}.bi-filetype-heic::before{content:"\f748"}.bi-filetype-html::before{content:"\f749"}.bi-filetype-java::before{content:"\f74a"}.bi-filetype-jpg::before{content:"\f74b"}.bi-filetype-js::before{content:"\f74c"}.bi-filetype-jsx::before{content:"\f74d"}.bi-filetype-key::before{content:"\f74e"}.bi-filetype-m4p::before{content:"\f74f"}.bi-filetype-md::before{content:"\f750"}.bi-filetype-mdx::before{content:"\f751"}.bi-filetype-mov::before{content:"\f752"}.bi-filetype-mp3::before{content:"\f753"}.bi-filetype-mp4::before{content:"\f754"}.bi-filetype-otf::before{content:"\f755"}.bi-filetype-pdf::before{content:"\f756"}.bi-filetype-php::before{content:"\f757"}.bi-filetype-png::before{content:"\f758"}.bi-filetype-ppt::before{content:"\f75a"}.bi-filetype-psd::before{content:"\f75b"}.bi-filetype-py::before{content:"\f75c"}.bi-filetype-raw::before{content:"\f75d"}.bi-filetype-rb::before{content:"\f75e"}.bi-filetype-sass::before{content:"\f75f"}.bi-filetype-scss::before{content:"\f760"}.bi-filetype-sh::before{content:"\f761"}.bi-filetype-svg::before{content:"\f762"}.bi-filetype-tiff::before{content:"\f763"}.bi-filetype-tsx::before{content:"\f764"}.bi-filetype-ttf::before{content:"\f765"}.bi-filetype-txt::before{content:"\f766"}.bi-filetype-wav::before{content:"\f767"}.bi-filetype-woff::before{content:"\f768"}.bi-filetype-xls::before{content:"\f76a"}.bi-filetype-xml::before{content:"\f76b"}.bi-filetype-yml::before{content:"\f76c"}.bi-heart-arrow::before{content:"\f76d"}.bi-heart-pulse-fill::before{content:"\f76e"}.bi-heart-pulse::before{content:"\f76f"}.bi-heartbreak-fill::before{content:"\f770"}.bi-heartbreak::before{content:"\f771"}.bi-hearts::before{content:"\f772"}.bi-hospital-fill::before{content:"\f773"}.bi-hospital::before{content:"\f774"}.bi-house-heart-fill::before{content:"\f775"}.bi-house-heart::before{content:"\f776"}.bi-incognito::before{content:"\f777"}.bi-magnet-fill::before{content:"\f778"}.bi-magnet::before{content:"\f779"}.bi-person-heart::before{content:"\f77a"}.bi-person-hearts::before{content:"\f77b"}.bi-phone-flip::before{content:"\f77c"}.bi-plugin::before{content:"\f77d"}.bi-postage-fill::before{content:"\f77e"}.bi-postage-heart-fill::before{content:"\f77f"}.bi-postage-heart::before{content:"\f780"}.bi-postage::before{content:"\f781"}.bi-postcard-fill::before{content:"\f782"}.bi-postcard-heart-fill::before{content:"\f783"}.bi-postcard-heart::before{content:"\f784"}.bi-postcard::before{content:"\f785"}.bi-search-heart-fill::before{content:"\f786"}.bi-search-heart::before{content:"\f787"}.bi-sliders2-vertical::before{content:"\f788"}.bi-sliders2::before{content:"\f789"}.bi-trash3-fill::before{content:"\f78a"}.bi-trash3::before{content:"\f78b"}.bi-valentine::before{content:"\f78c"}.bi-valentine2::before{content:"\f78d"}.bi-wrench-adjustable-circle-fill::before{content:"\f78e"}.bi-wrench-adjustable-circle::before{content:"\f78f"}.bi-wrench-adjustable::before{content:"\f790"}.bi-filetype-json::before{content:"\f791"}.bi-filetype-pptx::before{content:"\f792"}.bi-filetype-xlsx::before{content:"\f793"}.bi-1-circle-fill::before{content:"\f796"}.bi-1-circle::before{content:"\f797"}.bi-1-square-fill::before{content:"\f798"}.bi-1-square::before{content:"\f799"}.bi-2-circle-fill::before{content:"\f79c"}.bi-2-circle::before{content:"\f79d"}.bi-2-square-fill::before{content:"\f79e"}.bi-2-square::before{content:"\f79f"}.bi-3-circle-fill::before{content:"\f7a2"}.bi-3-circle::before{content:"\f7a3"}.bi-3-square-fill::before{content:"\f7a4"}.bi-3-square::before{content:"\f7a5"}.bi-4-circle-fill::before{content:"\f7a8"}.bi-4-circle::before{content:"\f7a9"}.bi-4-square-fill::before{content:"\f7aa"}.bi-4-square::before{content:"\f7ab"}.bi-5-circle-fill::before{content:"\f7ae"}.bi-5-circle::before{content:"\f7af"}.bi-5-square-fill::before{content:"\f7b0"}.bi-5-square::before{content:"\f7b1"}.bi-6-circle-fill::before{content:"\f7b4"}.bi-6-circle::before{content:"\f7b5"}.bi-6-square-fill::before{content:"\f7b6"}.bi-6-square::before{content:"\f7b7"}.bi-7-circle-fill::before{content:"\f7ba"}.bi-7-circle::before{content:"\f7bb"}.bi-7-square-fill::before{content:"\f7bc"}.bi-7-square::before{content:"\f7bd"}.bi-8-circle-fill::before{content:"\f7c0"}.bi-8-circle::before{content:"\f7c1"}.bi-8-square-fill::before{content:"\f7c2"}.bi-8-square::before{content:"\f7c3"}.bi-9-circle-fill::before{content:"\f7c6"}.bi-9-circle::before{content:"\f7c7"}.bi-9-square-fill::before{content:"\f7c8"}.bi-9-square::before{content:"\f7c9"}.bi-airplane-engines-fill::before{content:"\f7ca"}.bi-airplane-engines::before{content:"\f7cb"}.bi-airplane-fill::before{content:"\f7cc"}.bi-airplane::before{content:"\f7cd"}.bi-alexa::before{content:"\f7ce"}.bi-alipay::before{content:"\f7cf"}.bi-android::before{content:"\f7d0"}.bi-android2::before{content:"\f7d1"}.bi-box-fill::before{content:"\f7d2"}.bi-box-seam-fill::before{content:"\f7d3"}.bi-browser-chrome::before{content:"\f7d4"}.bi-browser-edge::before{content:"\f7d5"}.bi-browser-firefox::before{content:"\f7d6"}.bi-browser-safari::before{content:"\f7d7"}.bi-c-circle-fill::before{content:"\f7da"}.bi-c-circle::before{content:"\f7db"}.bi-c-square-fill::before{content:"\f7dc"}.bi-c-square::before{content:"\f7dd"}.bi-capsule-pill::before{content:"\f7de"}.bi-capsule::before{content:"\f7df"}.bi-car-front-fill::before{content:"\f7e0"}.bi-car-front::before{content:"\f7e1"}.bi-cassette-fill::before{content:"\f7e2"}.bi-cassette::before{content:"\f7e3"}.bi-cc-circle-fill::before{content:"\f7e6"}.bi-cc-circle::before{content:"\f7e7"}.bi-cc-square-fill::before{content:"\f7e8"}.bi-cc-square::before{content:"\f7e9"}.bi-cup-hot-fill::before{content:"\f7ea"}.bi-cup-hot::before{content:"\f7eb"}.bi-currency-rupee::before{content:"\f7ec"}.bi-dropbox::before{content:"\f7ed"}.bi-escape::before{content:"\f7ee"}.bi-fast-forward-btn-fill::before{content:"\f7ef"}.bi-fast-forward-btn::before{content:"\f7f0"}.bi-fast-forward-circle-fill::before{content:"\f7f1"}.bi-fast-forward-circle::before{content:"\f7f2"}.bi-fast-forward-fill::before{content:"\f7f3"}.bi-fast-forward::before{content:"\f7f4"}.bi-filetype-sql::before{content:"\f7f5"}.bi-fire::before{content:"\f7f6"}.bi-google-play::before{content:"\f7f7"}.bi-h-circle-fill::before{content:"\f7fa"}.bi-h-circle::before{content:"\f7fb"}.bi-h-square-fill::before{content:"\f7fc"}.bi-h-square::before{content:"\f7fd"}.bi-indent::before{content:"\f7fe"}.bi-lungs-fill::before{content:"\f7ff"}.bi-lungs::before{content:"\f800"}.bi-microsoft-teams::before{content:"\f801"}.bi-p-circle-fill::before{content:"\f804"}.bi-p-circle::before{content:"\f805"}.bi-p-square-fill::before{content:"\f806"}.bi-p-square::before{content:"\f807"}.bi-pass-fill::before{content:"\f808"}.bi-pass::before{content:"\f809"}.bi-prescription::before{content:"\f80a"}.bi-prescription2::before{content:"\f80b"}.bi-r-circle-fill::before{content:"\f80e"}.bi-r-circle::before{content:"\f80f"}.bi-r-square-fill::before{content:"\f810"}.bi-r-square::before{content:"\f811"}.bi-repeat-1::before{content:"\f812"}.bi-repeat::before{content:"\f813"}.bi-rewind-btn-fill::before{content:"\f814"}.bi-rewind-btn::before{content:"\f815"}.bi-rewind-circle-fill::before{content:"\f816"}.bi-rewind-circle::before{content:"\f817"}.bi-rewind-fill::before{content:"\f818"}.bi-rewind::before{content:"\f819"}.bi-train-freight-front-fill::before{content:"\f81a"}.bi-train-freight-front::before{content:"\f81b"}.bi-train-front-fill::before{content:"\f81c"}.bi-train-front::before{content:"\f81d"}.bi-train-lightrail-front-fill::before{content:"\f81e"}.bi-train-lightrail-front::before{content:"\f81f"}.bi-truck-front-fill::before{content:"\f820"}.bi-truck-front::before{content:"\f821"}.bi-ubuntu::before{content:"\f822"}.bi-unindent::before{content:"\f823"}.bi-unity::before{content:"\f824"}.bi-universal-access-circle::before{content:"\f825"}.bi-universal-access::before{content:"\f826"}.bi-virus::before{content:"\f827"}.bi-virus2::before{content:"\f828"}.bi-wechat::before{content:"\f829"}.bi-yelp::before{content:"\f82a"}.bi-sign-stop-fill::before{content:"\f82b"}.bi-sign-stop-lights-fill::before{content:"\f82c"}.bi-sign-stop-lights::before{content:"\f82d"}.bi-sign-stop::before{content:"\f82e"}.bi-sign-turn-left-fill::before{content:"\f82f"}.bi-sign-turn-left::before{content:"\f830"}.bi-sign-turn-right-fill::before{content:"\f831"}.bi-sign-turn-right::before{content:"\f832"}.bi-sign-turn-slight-left-fill::before{content:"\f833"}.bi-sign-turn-slight-left::before{content:"\f834"}.bi-sign-turn-slight-right-fill::before{content:"\f835"}.bi-sign-turn-slight-right::before{content:"\f836"}.bi-sign-yield-fill::before{content:"\f837"}.bi-sign-yield::before{content:"\f838"}.bi-ev-station-fill::before{content:"\f839"}.bi-ev-station::before{content:"\f83a"}.bi-fuel-pump-diesel-fill::before{content:"\f83b"}.bi-fuel-pump-diesel::before{content:"\f83c"}.bi-fuel-pump-fill::before{content:"\f83d"}.bi-fuel-pump::before{content:"\f83e"}.bi-0-circle-fill::before{content:"\f83f"}.bi-0-circle::before{content:"\f840"}.bi-0-square-fill::before{content:"\f841"}.bi-0-square::before{content:"\f842"}.bi-rocket-fill::before{content:"\f843"}.bi-rocket-takeoff-fill::before{content:"\f844"}.bi-rocket-takeoff::before{content:"\f845"}.bi-rocket::before{content:"\f846"}.bi-stripe::before{content:"\f847"}.bi-subscript::before{content:"\f848"}.bi-superscript::before{content:"\f849"}.bi-trello::before{content:"\f84a"}.bi-envelope-at-fill::before{content:"\f84b"}.bi-envelope-at::before{content:"\f84c"}.bi-regex::before{content:"\f84d"}.bi-text-wrap::before{content:"\f84e"}.bi-sign-dead-end-fill::before{content:"\f84f"}.bi-sign-dead-end::before{content:"\f850"}.bi-sign-do-not-enter-fill::before{content:"\f851"}.bi-sign-do-not-enter::before{content:"\f852"}.bi-sign-intersection-fill::before{content:"\f853"}.bi-sign-intersection-side-fill::before{content:"\f854"}.bi-sign-intersection-side::before{content:"\f855"}.bi-sign-intersection-t-fill::before{content:"\f856"}.bi-sign-intersection-t::before{content:"\f857"}.bi-sign-intersection-y-fill::before{content:"\f858"}.bi-sign-intersection-y::before{content:"\f859"}.bi-sign-intersection::before{content:"\f85a"}.bi-sign-merge-left-fill::before{content:"\f85b"}.bi-sign-merge-left::before{content:"\f85c"}.bi-sign-merge-right-fill::before{content:"\f85d"}.bi-sign-merge-right::before{content:"\f85e"}.bi-sign-no-left-turn-fill::before{content:"\f85f"}.bi-sign-no-left-turn::before{content:"\f860"}.bi-sign-no-parking-fill::before{content:"\f861"}.bi-sign-no-parking::before{content:"\f862"}.bi-sign-no-right-turn-fill::before{content:"\f863"}.bi-sign-no-right-turn::before{content:"\f864"}.bi-sign-railroad-fill::before{content:"\f865"}.bi-sign-railroad::before{content:"\f866"}.bi-building-add::before{content:"\f867"}.bi-building-check::before{content:"\f868"}.bi-building-dash::before{content:"\f869"}.bi-building-down::before{content:"\f86a"}.bi-building-exclamation::before{content:"\f86b"}.bi-building-fill-add::before{content:"\f86c"}.bi-building-fill-check::before{content:"\f86d"}.bi-building-fill-dash::before{content:"\f86e"}.bi-building-fill-down::before{content:"\f86f"}.bi-building-fill-exclamation::before{content:"\f870"}.bi-building-fill-gear::before{content:"\f871"}.bi-building-fill-lock::before{content:"\f872"}.bi-building-fill-slash::before{content:"\f873"}.bi-building-fill-up::before{content:"\f874"}.bi-building-fill-x::before{content:"\f875"}.bi-building-fill::before{content:"\f876"}.bi-building-gear::before{content:"\f877"}.bi-building-lock::before{content:"\f878"}.bi-building-slash::before{content:"\f879"}.bi-building-up::before{content:"\f87a"}.bi-building-x::before{content:"\f87b"}.bi-buildings-fill::before{content:"\f87c"}.bi-buildings::before{content:"\f87d"}.bi-bus-front-fill::before{content:"\f87e"}.bi-bus-front::before{content:"\f87f"}.bi-ev-front-fill::before{content:"\f880"}.bi-ev-front::before{content:"\f881"}.bi-globe-americas::before{content:"\f882"}.bi-globe-asia-australia::before{content:"\f883"}.bi-globe-central-south-asia::before{content:"\f884"}.bi-globe-europe-africa::before{content:"\f885"}.bi-house-add-fill::before{content:"\f886"}.bi-house-add::before{content:"\f887"}.bi-house-check-fill::before{content:"\f888"}.bi-house-check::before{content:"\f889"}.bi-house-dash-fill::before{content:"\f88a"}.bi-house-dash::before{content:"\f88b"}.bi-house-down-fill::before{content:"\f88c"}.bi-house-down::before{content:"\f88d"}.bi-house-exclamation-fill::before{content:"\f88e"}.bi-house-exclamation::before{content:"\f88f"}.bi-house-gear-fill::before{content:"\f890"}.bi-house-gear::before{content:"\f891"}.bi-house-lock-fill::before{content:"\f892"}.bi-house-lock::before{content:"\f893"}.bi-house-slash-fill::before{content:"\f894"}.bi-house-slash::before{content:"\f895"}.bi-house-up-fill::before{content:"\f896"}.bi-house-up::before{content:"\f897"}.bi-house-x-fill::before{content:"\f898"}.bi-house-x::before{content:"\f899"}.bi-person-add::before{content:"\f89a"}.bi-person-down::before{content:"\f89b"}.bi-person-exclamation::before{content:"\f89c"}.bi-person-fill-add::before{content:"\f89d"}.bi-person-fill-check::before{content:"\f89e"}.bi-person-fill-dash::before{content:"\f89f"}.bi-person-fill-down::before{content:"\f8a0"}.bi-person-fill-exclamation::before{content:"\f8a1"}.bi-person-fill-gear::before{content:"\f8a2"}.bi-person-fill-lock::before{content:"\f8a3"}.bi-person-fill-slash::before{content:"\f8a4"}.bi-person-fill-up::before{content:"\f8a5"}.bi-person-fill-x::before{content:"\f8a6"}.bi-person-gear::before{content:"\f8a7"}.bi-person-lock::before{content:"\f8a8"}.bi-person-slash::before{content:"\f8a9"}.bi-person-up::before{content:"\f8aa"}.bi-scooter::before{content:"\f8ab"}.bi-taxi-front-fill::before{content:"\f8ac"}.bi-taxi-front::before{content:"\f8ad"}.bi-amd::before{content:"\f8ae"}.bi-database-add::before{content:"\f8af"}.bi-database-check::before{content:"\f8b0"}.bi-database-dash::before{content:"\f8b1"}.bi-database-down::before{content:"\f8b2"}.bi-database-exclamation::before{content:"\f8b3"}.bi-database-fill-add::before{content:"\f8b4"}.bi-database-fill-check::before{content:"\f8b5"}.bi-database-fill-dash::before{content:"\f8b6"}.bi-database-fill-down::before{content:"\f8b7"}.bi-database-fill-exclamation::before{content:"\f8b8"}.bi-database-fill-gear::before{content:"\f8b9"}.bi-database-fill-lock::before{content:"\f8ba"}.bi-database-fill-slash::before{content:"\f8bb"}.bi-database-fill-up::before{content:"\f8bc"}.bi-database-fill-x::before{content:"\f8bd"}.bi-database-fill::before{content:"\f8be"}.bi-database-gear::before{content:"\f8bf"}.bi-database-lock::before{content:"\f8c0"}.bi-database-slash::before{content:"\f8c1"}.bi-database-up::before{content:"\f8c2"}.bi-database-x::before{content:"\f8c3"}.bi-database::before{content:"\f8c4"}.bi-houses-fill::before{content:"\f8c5"}.bi-houses::before{content:"\f8c6"}.bi-nvidia::before{content:"\f8c7"}.bi-person-vcard-fill::before{content:"\f8c8"}.bi-person-vcard::before{content:"\f8c9"}.bi-sina-weibo::before{content:"\f8ca"}.bi-tencent-qq::before{content:"\f8cb"}.bi-wikipedia::before{content:"\f8cc"}.bi-alphabet-uppercase::before{content:"\f2a5"}.bi-alphabet::before{content:"\f68a"}.bi-amazon::before{content:"\f68d"}.bi-arrows-collapse-vertical::before{content:"\f690"}.bi-arrows-expand-vertical::before{content:"\f695"}.bi-arrows-vertical::before{content:"\f698"}.bi-arrows::before{content:"\f6a2"}.bi-ban-fill::before{content:"\f6a3"}.bi-ban::before{content:"\f6b6"}.bi-bing::before{content:"\f6c2"}.bi-cake::before{content:"\f6e0"}.bi-cake2::before{content:"\f6ed"}.bi-cookie::before{content:"\f6ee"}.bi-copy::before{content:"\f759"}.bi-crosshair::before{content:"\f769"}.bi-crosshair2::before{content:"\f794"}.bi-emoji-astonished-fill::before{content:"\f795"}.bi-emoji-astonished::before{content:"\f79a"}.bi-emoji-grimace-fill::before{content:"\f79b"}.bi-emoji-grimace::before{content:"\f7a0"}.bi-emoji-grin-fill::before{content:"\f7a1"}.bi-emoji-grin::before{content:"\f7a6"}.bi-emoji-surprise-fill::before{content:"\f7a7"}.bi-emoji-surprise::before{content:"\f7ac"}.bi-emoji-tear-fill::before{content:"\f7ad"}.bi-emoji-tear::before{content:"\f7b2"}.bi-envelope-arrow-down-fill::before{content:"\f7b3"}.bi-envelope-arrow-down::before{content:"\f7b8"}.bi-envelope-arrow-up-fill::before{content:"\f7b9"}.bi-envelope-arrow-up::before{content:"\f7be"}.bi-feather::before{content:"\f7bf"}.bi-feather2::before{content:"\f7c4"}.bi-floppy-fill::before{content:"\f7c5"}.bi-floppy::before{content:"\f7d8"}.bi-floppy2-fill::before{content:"\f7d9"}.bi-floppy2::before{content:"\f7e4"}.bi-gitlab::before{content:"\f7e5"}.bi-highlighter::before{content:"\f7f8"}.bi-marker-tip::before{content:"\f802"}.bi-nvme-fill::before{content:"\f803"}.bi-nvme::before{content:"\f80c"}.bi-opencollective::before{content:"\f80d"}.bi-pci-card-network::before{content:"\f8cd"}.bi-pci-card-sound::before{content:"\f8ce"}.bi-radar::before{content:"\f8cf"}.bi-send-arrow-down-fill::before{content:"\f8d0"}.bi-send-arrow-down::before{content:"\f8d1"}.bi-send-arrow-up-fill::before{content:"\f8d2"}.bi-send-arrow-up::before{content:"\f8d3"}.bi-sim-slash-fill::before{content:"\f8d4"}.bi-sim-slash::before{content:"\f8d5"}.bi-sourceforge::before{content:"\f8d6"}.bi-substack::before{content:"\f8d7"}.bi-threads-fill::before{content:"\f8d8"}.bi-threads::before{content:"\f8d9"}.bi-transparency::before{content:"\f8da"}.bi-twitter-x::before{content:"\f8db"}.bi-type-h4::before{content:"\f8dc"}.bi-type-h5::before{content:"\f8dd"}.bi-type-h6::before{content:"\f8de"}.bi-backpack-fill::before{content:"\f8df"}.bi-backpack::before{content:"\f8e0"}.bi-backpack2-fill::before{content:"\f8e1"}.bi-backpack2::before{content:"\f8e2"}.bi-backpack3-fill::before{content:"\f8e3"}.bi-backpack3::before{content:"\f8e4"}.bi-backpack4-fill::before{content:"\f8e5"}.bi-backpack4::before{content:"\f8e6"}.bi-brilliance::before{content:"\f8e7"}.bi-cake-fill::before{content:"\f8e8"}.bi-cake2-fill::before{content:"\f8e9"}.bi-duffle-fill::before{content:"\f8ea"}.bi-duffle::before{content:"\f8eb"}.bi-exposure::before{content:"\f8ec"}.bi-gender-neuter::before{content:"\f8ed"}.bi-highlights::before{content:"\f8ee"}.bi-luggage-fill::before{content:"\f8ef"}.bi-luggage::before{content:"\f8f0"}.bi-mailbox-flag::before{content:"\f8f1"}.bi-mailbox2-flag::before{content:"\f8f2"}.bi-noise-reduction::before{content:"\f8f3"}.bi-passport-fill::before{content:"\f8f4"}.bi-passport::before{content:"\f8f5"}.bi-person-arms-up::before{content:"\f8f6"}.bi-person-raised-hand::before{content:"\f8f7"}.bi-person-standing-dress::before{content:"\f8f8"}.bi-person-standing::before{content:"\f8f9"}.bi-person-walking::before{content:"\f8fa"}.bi-person-wheelchair::before{content:"\f8fb"}.bi-shadows::before{content:"\f8fc"}.bi-suitcase-fill::before{content:"\f8fd"}.bi-suitcase-lg-fill::before{content:"\f8fe"}.bi-suitcase-lg::before{content:"\f8ff"}.bi-suitcase::before{content:"\f900"}.bi-suitcase2-fill::before{content:"\f901"}.bi-suitcase2::before{content:"\f902"}.bi-vignette::before{content:"\f903"} \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap-icons/font/fonts/bootstrap-icons.woff b/src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap-icons/font/fonts/bootstrap-icons.woff new file mode 100644 index 0000000000000000000000000000000000000000..51204d27de92c7bb0f8bed6165b9dc888f38ff38 GIT binary patch literal 176032 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 literal 0 HcmV?d00001 diff --git a/src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap-icons/font/fonts/bootstrap-icons.woff2 b/src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap-icons/font/fonts/bootstrap-icons.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..92c4830216044ba21db9f4294b887312e80da38e GIT binary patch literal 130396 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Ã=hYmg-7dH4?&*NqqtUL18qtx> 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 z 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:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-emphasis-color);--bs-table-striped-bg:rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color:var(--bs-emphasis-color);--bs-table-active-bg:rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color:var(--bs-emphasis-color);--bs-table-hover-bg:rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#a6b5cc;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#b5b6b7;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#a7b9b1;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#a6c3ca;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#ccc2a4;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#c6acae;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#c6c7c8;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#4d5154;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;-webkit-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;-moz-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:not(:-moz-placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control-plaintext~label::after,.form-floating>.form-control:focus~label::after,.form-floating>.form-control:not(:placeholder-shown)~label::after,.form-floating>.form-select~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control:-webkit-autofill~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#6c757d}.form-floating>.form-control:disabled~label::after,.form-floating>:disabled~label::after{background-color:var(--bs-secondary-bg)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(var(--bs-border-width) * -1);border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:var(--bs-box-shadow);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(var(--bs-border-width) * -1)}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(var(--bs-border-width) * -1)}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}.accordion-flush>.accordion-item>.accordion-collapse{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;--bs-btn-close-white-filter:invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{filter:var(--bs-btn-close-white-filter)}[data-bs-theme=dark] .btn-close{filter:var(--bs-btn-close-white-filter)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:var(--bs-box-shadow-sm);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:var(--bs-box-shadow);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}[data-bs-theme=dark] .carousel .carousel-control-next-icon,[data-bs-theme=dark] .carousel .carousel-control-prev-icon,[data-bs-theme=dark].carousel .carousel-control-next-icon,[data-bs-theme=dark].carousel .carousel-control-prev-icon{filter:invert(1) grayscale(100)}[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target],[data-bs-theme=dark].carousel .carousel-indicators [data-bs-target]{background-color:#000}[data-bs-theme=dark] .carousel .carousel-caption,[data-bs-theme=dark].carousel .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:var(--bs-box-shadow-sm);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin:calc(-.5 * var(--bs-offcanvas-padding-y)) calc(-.5 * var(--bs-offcanvas-padding-x)) calc(-.5 * var(--bs-offcanvas-padding-y)) auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap/js/bootstrap.bundle.js.map b/src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap/js/bootstrap.bundle.js.map new file mode 100644 index 0000000..681f25c --- /dev/null +++ b/src/MakerPrompt.UI.Components/wwwroot/lib/bootstrap/js/bootstrap.bundle.js.map @@ -0,0 +1 @@ +{"version":3,"file":"bootstrap.bundle.js","sources":["../../js/src/dom/data.js","../../js/src/util/index.js","../../js/src/dom/event-handler.js","../../js/src/dom/manipulator.js","../../js/src/util/config.js","../../js/src/base-component.js","../../js/src/dom/selector-engine.js","../../js/src/util/component-functions.js","../../js/src/alert.js","../../js/src/button.js","../../js/src/util/swipe.js","../../js/src/carousel.js","../../js/src/collapse.js","../../node_modules/@popperjs/core/lib/enums.js","../../node_modules/@popperjs/core/lib/dom-utils/getNodeName.js","../../node_modules/@popperjs/core/lib/dom-utils/getWindow.js","../../node_modules/@popperjs/core/lib/dom-utils/instanceOf.js","../../node_modules/@popperjs/core/lib/modifiers/applyStyles.js","../../node_modules/@popperjs/core/lib/utils/getBasePlacement.js","../../node_modules/@popperjs/core/lib/utils/math.js","../../node_modules/@popperjs/core/lib/utils/userAgent.js","../../node_modules/@popperjs/core/lib/dom-utils/isLayoutViewport.js","../../node_modules/@popperjs/core/lib/dom-utils/getBoundingClientRect.js","../../node_modules/@popperjs/core/lib/dom-utils/getLayoutRect.js","../../node_modules/@popperjs/core/lib/dom-utils/contains.js","../../node_modules/@popperjs/core/lib/dom-utils/getComputedStyle.js","../../node_modules/@popperjs/core/lib/dom-utils/isTableElement.js","../../node_modules/@popperjs/core/lib/dom-utils/getDocumentElement.js","../../node_modules/@popperjs/core/lib/dom-utils/getParentNode.js","../../node_modules/@popperjs/core/lib/dom-utils/getOffsetParent.js","../../node_modules/@popperjs/core/lib/utils/getMainAxisFromPlacement.js","../../node_modules/@popperjs/core/lib/utils/within.js","../../node_modules/@popperjs/core/lib/utils/getFreshSideObject.js","../../node_modules/@popperjs/core/lib/utils/mergePaddingObject.js","../../node_modules/@popperjs/core/lib/utils/expandToHashMap.js","../../node_modules/@popperjs/core/lib/modifiers/arrow.js","../../node_modules/@popperjs/core/lib/utils/getVariation.js","../../node_modules/@popperjs/core/lib/modifiers/computeStyles.js","../../node_modules/@popperjs/core/lib/modifiers/eventListeners.js","../../node_modules/@popperjs/core/lib/utils/getOppositePlacement.js","../../node_modules/@popperjs/core/lib/utils/getOppositeVariationPlacement.js","../../node_modules/@popperjs/core/lib/dom-utils/getWindowScroll.js","../../node_modules/@popperjs/core/lib/dom-utils/getWindowScrollBarX.js","../../node_modules/@popperjs/core/lib/dom-utils/getViewportRect.js","../../node_modules/@popperjs/core/lib/dom-utils/getDocumentRect.js","../../node_modules/@popperjs/core/lib/dom-utils/isScrollParent.js","../../node_modules/@popperjs/core/lib/dom-utils/getScrollParent.js","../../node_modules/@popperjs/core/lib/dom-utils/listScrollParents.js","../../node_modules/@popperjs/core/lib/utils/rectToClientRect.js","../../node_modules/@popperjs/core/lib/dom-utils/getClippingRect.js","../../node_modules/@popperjs/core/lib/utils/computeOffsets.js","../../node_modules/@popperjs/core/lib/utils/detectOverflow.js","../../node_modules/@popperjs/core/lib/utils/computeAutoPlacement.js","../../node_modules/@popperjs/core/lib/modifiers/flip.js","../../node_modules/@popperjs/core/lib/modifiers/hide.js","../../node_modules/@popperjs/core/lib/modifiers/offset.js","../../node_modules/@popperjs/core/lib/modifiers/popperOffsets.js","../../node_modules/@popperjs/core/lib/utils/getAltAxis.js","../../node_modules/@popperjs/core/lib/modifiers/preventOverflow.js","../../node_modules/@popperjs/core/lib/dom-utils/getHTMLElementScroll.js","../../node_modules/@popperjs/core/lib/dom-utils/getNodeScroll.js","../../node_modules/@popperjs/core/lib/dom-utils/getCompositeRect.js","../../node_modules/@popperjs/core/lib/utils/orderModifiers.js","../../node_modules/@popperjs/core/lib/utils/debounce.js","../../node_modules/@popperjs/core/lib/utils/mergeByName.js","../../node_modules/@popperjs/core/lib/createPopper.js","../../node_modules/@popperjs/core/lib/popper-lite.js","../../node_modules/@popperjs/core/lib/popper.js","../../js/src/dropdown.js","../../js/src/util/backdrop.js","../../js/src/util/focustrap.js","../../js/src/util/scrollbar.js","../../js/src/modal.js","../../js/src/offcanvas.js","../../js/src/util/sanitizer.js","../../js/src/util/template-factory.js","../../js/src/tooltip.js","../../js/src/popover.js","../../js/src/scrollspy.js","../../js/src/tab.js","../../js/src/toast.js","../../js/index.umd.js"],"sourcesContent":["/**\n * --------------------------------------------------------------------------\n * Bootstrap dom/data.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\n/**\n * Constants\n */\n\nconst elementMap = new Map()\n\nexport default {\n set(element, key, instance) {\n if (!elementMap.has(element)) {\n elementMap.set(element, new Map())\n }\n\n const instanceMap = elementMap.get(element)\n\n // make it clear we only want one instance per element\n // can be removed later when multiple key/instances are fine to be used\n if (!instanceMap.has(key) && instanceMap.size !== 0) {\n // eslint-disable-next-line no-console\n console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(instanceMap.keys())[0]}.`)\n return\n }\n\n instanceMap.set(key, instance)\n },\n\n get(element, key) {\n if (elementMap.has(element)) {\n return elementMap.get(element).get(key) || null\n }\n\n return null\n },\n\n remove(element, key) {\n if (!elementMap.has(element)) {\n return\n }\n\n const instanceMap = elementMap.get(element)\n\n instanceMap.delete(key)\n\n // free up element references if there are no instances left for an element\n if (instanceMap.size === 0) {\n elementMap.delete(element)\n }\n }\n}\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap util/index.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nconst MAX_UID = 1_000_000\nconst MILLISECONDS_MULTIPLIER = 1000\nconst TRANSITION_END = 'transitionend'\n\n/**\n * Properly escape IDs selectors to handle weird IDs\n * @param {string} selector\n * @returns {string}\n */\nconst parseSelector = selector => {\n if (selector && window.CSS && window.CSS.escape) {\n // document.querySelector needs escaping to handle IDs (html5+) containing for instance /\n selector = selector.replace(/#([^\\s\"#']+)/g, (match, id) => `#${CSS.escape(id)}`)\n }\n\n return selector\n}\n\n// Shout-out Angus Croll (https://goo.gl/pxwQGp)\nconst toType = object => {\n if (object === null || object === undefined) {\n return `${object}`\n }\n\n return Object.prototype.toString.call(object).match(/\\s([a-z]+)/i)[1].toLowerCase()\n}\n\n/**\n * Public Util API\n */\n\nconst getUID = prefix => {\n do {\n prefix += Math.floor(Math.random() * MAX_UID)\n } while (document.getElementById(prefix))\n\n return prefix\n}\n\nconst getTransitionDurationFromElement = element => {\n if (!element) {\n return 0\n }\n\n // Get transition-duration of the element\n let { transitionDuration, transitionDelay } = window.getComputedStyle(element)\n\n const floatTransitionDuration = Number.parseFloat(transitionDuration)\n const floatTransitionDelay = Number.parseFloat(transitionDelay)\n\n // Return 0 if element or transition duration is not found\n if (!floatTransitionDuration && !floatTransitionDelay) {\n return 0\n }\n\n // If multiple durations are defined, take the first\n transitionDuration = transitionDuration.split(',')[0]\n transitionDelay = transitionDelay.split(',')[0]\n\n return (Number.parseFloat(transitionDuration) + Number.parseFloat(transitionDelay)) * MILLISECONDS_MULTIPLIER\n}\n\nconst triggerTransitionEnd = element => {\n element.dispatchEvent(new Event(TRANSITION_END))\n}\n\nconst isElement = object => {\n if (!object || typeof object !== 'object') {\n return false\n }\n\n if (typeof object.jquery !== 'undefined') {\n object = object[0]\n }\n\n return typeof object.nodeType !== 'undefined'\n}\n\nconst getElement = object => {\n // it's a jQuery object or a node element\n if (isElement(object)) {\n return object.jquery ? object[0] : object\n }\n\n if (typeof object === 'string' && object.length > 0) {\n return document.querySelector(parseSelector(object))\n }\n\n return null\n}\n\nconst isVisible = element => {\n if (!isElement(element) || element.getClientRects().length === 0) {\n return false\n }\n\n const elementIsVisible = getComputedStyle(element).getPropertyValue('visibility') === 'visible'\n // Handle `details` element as its content may falsie appear visible when it is closed\n const closedDetails = element.closest('details:not([open])')\n\n if (!closedDetails) {\n return elementIsVisible\n }\n\n if (closedDetails !== element) {\n const summary = element.closest('summary')\n if (summary && summary.parentNode !== closedDetails) {\n return false\n }\n\n if (summary === null) {\n return false\n }\n }\n\n return elementIsVisible\n}\n\nconst isDisabled = element => {\n if (!element || element.nodeType !== Node.ELEMENT_NODE) {\n return true\n }\n\n if (element.classList.contains('disabled')) {\n return true\n }\n\n if (typeof element.disabled !== 'undefined') {\n return element.disabled\n }\n\n return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false'\n}\n\nconst findShadowRoot = element => {\n if (!document.documentElement.attachShadow) {\n return null\n }\n\n // Can find the shadow root otherwise it'll return the document\n if (typeof element.getRootNode === 'function') {\n const root = element.getRootNode()\n return root instanceof ShadowRoot ? root : null\n }\n\n if (element instanceof ShadowRoot) {\n return element\n }\n\n // when we don't find a shadow root\n if (!element.parentNode) {\n return null\n }\n\n return findShadowRoot(element.parentNode)\n}\n\nconst noop = () => {}\n\n/**\n * Trick to restart an element's animation\n *\n * @param {HTMLElement} element\n * @return void\n *\n * @see https://www.charistheo.io/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation\n */\nconst reflow = element => {\n element.offsetHeight // eslint-disable-line no-unused-expressions\n}\n\nconst getjQuery = () => {\n if (window.jQuery && !document.body.hasAttribute('data-bs-no-jquery')) {\n return window.jQuery\n }\n\n return null\n}\n\nconst DOMContentLoadedCallbacks = []\n\nconst onDOMContentLoaded = callback => {\n if (document.readyState === 'loading') {\n // add listener on the first call when the document is in loading state\n if (!DOMContentLoadedCallbacks.length) {\n document.addEventListener('DOMContentLoaded', () => {\n for (const callback of DOMContentLoadedCallbacks) {\n callback()\n }\n })\n }\n\n DOMContentLoadedCallbacks.push(callback)\n } else {\n callback()\n }\n}\n\nconst isRTL = () => document.documentElement.dir === 'rtl'\n\nconst defineJQueryPlugin = plugin => {\n onDOMContentLoaded(() => {\n const $ = getjQuery()\n /* istanbul ignore if */\n if ($) {\n const name = plugin.NAME\n const JQUERY_NO_CONFLICT = $.fn[name]\n $.fn[name] = plugin.jQueryInterface\n $.fn[name].Constructor = plugin\n $.fn[name].noConflict = () => {\n $.fn[name] = JQUERY_NO_CONFLICT\n return plugin.jQueryInterface\n }\n }\n })\n}\n\nconst execute = (possibleCallback, args = [], defaultValue = possibleCallback) => {\n return typeof possibleCallback === 'function' ? possibleCallback(...args) : defaultValue\n}\n\nconst executeAfterTransition = (callback, transitionElement, waitForTransition = true) => {\n if (!waitForTransition) {\n execute(callback)\n return\n }\n\n const durationPadding = 5\n const emulatedDuration = getTransitionDurationFromElement(transitionElement) + durationPadding\n\n let called = false\n\n const handler = ({ target }) => {\n if (target !== transitionElement) {\n return\n }\n\n called = true\n transitionElement.removeEventListener(TRANSITION_END, handler)\n execute(callback)\n }\n\n transitionElement.addEventListener(TRANSITION_END, handler)\n setTimeout(() => {\n if (!called) {\n triggerTransitionEnd(transitionElement)\n }\n }, emulatedDuration)\n}\n\n/**\n * Return the previous/next element of a list.\n *\n * @param {array} list The list of elements\n * @param activeElement The active element\n * @param shouldGetNext Choose to get next or previous element\n * @param isCycleAllowed\n * @return {Element|elem} The proper element\n */\nconst getNextActiveElement = (list, activeElement, shouldGetNext, isCycleAllowed) => {\n const listLength = list.length\n let index = list.indexOf(activeElement)\n\n // if the element does not exist in the list return an element\n // depending on the direction and if cycle is allowed\n if (index === -1) {\n return !shouldGetNext && isCycleAllowed ? list[listLength - 1] : list[0]\n }\n\n index += shouldGetNext ? 1 : -1\n\n if (isCycleAllowed) {\n index = (index + listLength) % listLength\n }\n\n return list[Math.max(0, Math.min(index, listLength - 1))]\n}\n\nexport {\n defineJQueryPlugin,\n execute,\n executeAfterTransition,\n findShadowRoot,\n getElement,\n getjQuery,\n getNextActiveElement,\n getTransitionDurationFromElement,\n getUID,\n isDisabled,\n isElement,\n isRTL,\n isVisible,\n noop,\n onDOMContentLoaded,\n parseSelector,\n reflow,\n triggerTransitionEnd,\n toType\n}\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap dom/event-handler.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport { getjQuery } from '../util/index.js'\n\n/**\n * Constants\n */\n\nconst namespaceRegex = /[^.]*(?=\\..*)\\.|.*/\nconst stripNameRegex = /\\..*/\nconst stripUidRegex = /::\\d+$/\nconst eventRegistry = {} // Events storage\nlet uidEvent = 1\nconst customEvents = {\n mouseenter: 'mouseover',\n mouseleave: 'mouseout'\n}\n\nconst nativeEvents = new Set([\n 'click',\n 'dblclick',\n 'mouseup',\n 'mousedown',\n 'contextmenu',\n 'mousewheel',\n 'DOMMouseScroll',\n 'mouseover',\n 'mouseout',\n 'mousemove',\n 'selectstart',\n 'selectend',\n 'keydown',\n 'keypress',\n 'keyup',\n 'orientationchange',\n 'touchstart',\n 'touchmove',\n 'touchend',\n 'touchcancel',\n 'pointerdown',\n 'pointermove',\n 'pointerup',\n 'pointerleave',\n 'pointercancel',\n 'gesturestart',\n 'gesturechange',\n 'gestureend',\n 'focus',\n 'blur',\n 'change',\n 'reset',\n 'select',\n 'submit',\n 'focusin',\n 'focusout',\n 'load',\n 'unload',\n 'beforeunload',\n 'resize',\n 'move',\n 'DOMContentLoaded',\n 'readystatechange',\n 'error',\n 'abort',\n 'scroll'\n])\n\n/**\n * Private methods\n */\n\nfunction makeEventUid(element, uid) {\n return (uid && `${uid}::${uidEvent++}`) || element.uidEvent || uidEvent++\n}\n\nfunction getElementEvents(element) {\n const uid = makeEventUid(element)\n\n element.uidEvent = uid\n eventRegistry[uid] = eventRegistry[uid] || {}\n\n return eventRegistry[uid]\n}\n\nfunction bootstrapHandler(element, fn) {\n return function handler(event) {\n hydrateObj(event, { delegateTarget: element })\n\n if (handler.oneOff) {\n EventHandler.off(element, event.type, fn)\n }\n\n return fn.apply(element, [event])\n }\n}\n\nfunction bootstrapDelegationHandler(element, selector, fn) {\n return function handler(event) {\n const domElements = element.querySelectorAll(selector)\n\n for (let { target } = event; target && target !== this; target = target.parentNode) {\n for (const domElement of domElements) {\n if (domElement !== target) {\n continue\n }\n\n hydrateObj(event, { delegateTarget: target })\n\n if (handler.oneOff) {\n EventHandler.off(element, event.type, selector, fn)\n }\n\n return fn.apply(target, [event])\n }\n }\n }\n}\n\nfunction findHandler(events, callable, delegationSelector = null) {\n return Object.values(events)\n .find(event => event.callable === callable && event.delegationSelector === delegationSelector)\n}\n\nfunction normalizeParameters(originalTypeEvent, handler, delegationFunction) {\n const isDelegated = typeof handler === 'string'\n // TODO: tooltip passes `false` instead of selector, so we need to check\n const callable = isDelegated ? delegationFunction : (handler || delegationFunction)\n let typeEvent = getTypeEvent(originalTypeEvent)\n\n if (!nativeEvents.has(typeEvent)) {\n typeEvent = originalTypeEvent\n }\n\n return [isDelegated, callable, typeEvent]\n}\n\nfunction addHandler(element, originalTypeEvent, handler, delegationFunction, oneOff) {\n if (typeof originalTypeEvent !== 'string' || !element) {\n return\n }\n\n let [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction)\n\n // in case of mouseenter or mouseleave wrap the handler within a function that checks for its DOM position\n // this prevents the handler from being dispatched the same way as mouseover or mouseout does\n if (originalTypeEvent in customEvents) {\n const wrapFunction = fn => {\n return function (event) {\n if (!event.relatedTarget || (event.relatedTarget !== event.delegateTarget && !event.delegateTarget.contains(event.relatedTarget))) {\n return fn.call(this, event)\n }\n }\n }\n\n callable = wrapFunction(callable)\n }\n\n const events = getElementEvents(element)\n const handlers = events[typeEvent] || (events[typeEvent] = {})\n const previousFunction = findHandler(handlers, callable, isDelegated ? handler : null)\n\n if (previousFunction) {\n previousFunction.oneOff = previousFunction.oneOff && oneOff\n\n return\n }\n\n const uid = makeEventUid(callable, originalTypeEvent.replace(namespaceRegex, ''))\n const fn = isDelegated ?\n bootstrapDelegationHandler(element, handler, callable) :\n bootstrapHandler(element, callable)\n\n fn.delegationSelector = isDelegated ? handler : null\n fn.callable = callable\n fn.oneOff = oneOff\n fn.uidEvent = uid\n handlers[uid] = fn\n\n element.addEventListener(typeEvent, fn, isDelegated)\n}\n\nfunction removeHandler(element, events, typeEvent, handler, delegationSelector) {\n const fn = findHandler(events[typeEvent], handler, delegationSelector)\n\n if (!fn) {\n return\n }\n\n element.removeEventListener(typeEvent, fn, Boolean(delegationSelector))\n delete events[typeEvent][fn.uidEvent]\n}\n\nfunction removeNamespacedHandlers(element, events, typeEvent, namespace) {\n const storeElementEvent = events[typeEvent] || {}\n\n for (const [handlerKey, event] of Object.entries(storeElementEvent)) {\n if (handlerKey.includes(namespace)) {\n removeHandler(element, events, typeEvent, event.callable, event.delegationSelector)\n }\n }\n}\n\nfunction getTypeEvent(event) {\n // allow to get the native events from namespaced events ('click.bs.button' --> 'click')\n event = event.replace(stripNameRegex, '')\n return customEvents[event] || event\n}\n\nconst EventHandler = {\n on(element, event, handler, delegationFunction) {\n addHandler(element, event, handler, delegationFunction, false)\n },\n\n one(element, event, handler, delegationFunction) {\n addHandler(element, event, handler, delegationFunction, true)\n },\n\n off(element, originalTypeEvent, handler, delegationFunction) {\n if (typeof originalTypeEvent !== 'string' || !element) {\n return\n }\n\n const [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction)\n const inNamespace = typeEvent !== originalTypeEvent\n const events = getElementEvents(element)\n const storeElementEvent = events[typeEvent] || {}\n const isNamespace = originalTypeEvent.startsWith('.')\n\n if (typeof callable !== 'undefined') {\n // Simplest case: handler is passed, remove that listener ONLY.\n if (!Object.keys(storeElementEvent).length) {\n return\n }\n\n removeHandler(element, events, typeEvent, callable, isDelegated ? handler : null)\n return\n }\n\n if (isNamespace) {\n for (const elementEvent of Object.keys(events)) {\n removeNamespacedHandlers(element, events, elementEvent, originalTypeEvent.slice(1))\n }\n }\n\n for (const [keyHandlers, event] of Object.entries(storeElementEvent)) {\n const handlerKey = keyHandlers.replace(stripUidRegex, '')\n\n if (!inNamespace || originalTypeEvent.includes(handlerKey)) {\n removeHandler(element, events, typeEvent, event.callable, event.delegationSelector)\n }\n }\n },\n\n trigger(element, event, args) {\n if (typeof event !== 'string' || !element) {\n return null\n }\n\n const $ = getjQuery()\n const typeEvent = getTypeEvent(event)\n const inNamespace = event !== typeEvent\n\n let jQueryEvent = null\n let bubbles = true\n let nativeDispatch = true\n let defaultPrevented = false\n\n if (inNamespace && $) {\n jQueryEvent = $.Event(event, args)\n\n $(element).trigger(jQueryEvent)\n bubbles = !jQueryEvent.isPropagationStopped()\n nativeDispatch = !jQueryEvent.isImmediatePropagationStopped()\n defaultPrevented = jQueryEvent.isDefaultPrevented()\n }\n\n const evt = hydrateObj(new Event(event, { bubbles, cancelable: true }), args)\n\n if (defaultPrevented) {\n evt.preventDefault()\n }\n\n if (nativeDispatch) {\n element.dispatchEvent(evt)\n }\n\n if (evt.defaultPrevented && jQueryEvent) {\n jQueryEvent.preventDefault()\n }\n\n return evt\n }\n}\n\nfunction hydrateObj(obj, meta = {}) {\n for (const [key, value] of Object.entries(meta)) {\n try {\n obj[key] = value\n } catch {\n Object.defineProperty(obj, key, {\n configurable: true,\n get() {\n return value\n }\n })\n }\n }\n\n return obj\n}\n\nexport default EventHandler\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap dom/manipulator.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nfunction normalizeData(value) {\n if (value === 'true') {\n return true\n }\n\n if (value === 'false') {\n return false\n }\n\n if (value === Number(value).toString()) {\n return Number(value)\n }\n\n if (value === '' || value === 'null') {\n return null\n }\n\n if (typeof value !== 'string') {\n return value\n }\n\n try {\n return JSON.parse(decodeURIComponent(value))\n } catch {\n return value\n }\n}\n\nfunction normalizeDataKey(key) {\n return key.replace(/[A-Z]/g, chr => `-${chr.toLowerCase()}`)\n}\n\nconst Manipulator = {\n setDataAttribute(element, key, value) {\n element.setAttribute(`data-bs-${normalizeDataKey(key)}`, value)\n },\n\n removeDataAttribute(element, key) {\n element.removeAttribute(`data-bs-${normalizeDataKey(key)}`)\n },\n\n getDataAttributes(element) {\n if (!element) {\n return {}\n }\n\n const attributes = {}\n const bsKeys = Object.keys(element.dataset).filter(key => key.startsWith('bs') && !key.startsWith('bsConfig'))\n\n for (const key of bsKeys) {\n let pureKey = key.replace(/^bs/, '')\n pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1, pureKey.length)\n attributes[pureKey] = normalizeData(element.dataset[key])\n }\n\n return attributes\n },\n\n getDataAttribute(element, key) {\n return normalizeData(element.getAttribute(`data-bs-${normalizeDataKey(key)}`))\n }\n}\n\nexport default Manipulator\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap util/config.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport Manipulator from '../dom/manipulator.js'\nimport { isElement, toType } from './index.js'\n\n/**\n * Class definition\n */\n\nclass Config {\n // Getters\n static get Default() {\n return {}\n }\n\n static get DefaultType() {\n return {}\n }\n\n static get NAME() {\n throw new Error('You have to implement the static method \"NAME\", for each component!')\n }\n\n _getConfig(config) {\n config = this._mergeConfigObj(config)\n config = this._configAfterMerge(config)\n this._typeCheckConfig(config)\n return config\n }\n\n _configAfterMerge(config) {\n return config\n }\n\n _mergeConfigObj(config, element) {\n const jsonConfig = isElement(element) ? Manipulator.getDataAttribute(element, 'config') : {} // try to parse\n\n return {\n ...this.constructor.Default,\n ...(typeof jsonConfig === 'object' ? jsonConfig : {}),\n ...(isElement(element) ? Manipulator.getDataAttributes(element) : {}),\n ...(typeof config === 'object' ? config : {})\n }\n }\n\n _typeCheckConfig(config, configTypes = this.constructor.DefaultType) {\n for (const [property, expectedTypes] of Object.entries(configTypes)) {\n const value = config[property]\n const valueType = isElement(value) ? 'element' : toType(value)\n\n if (!new RegExp(expectedTypes).test(valueType)) {\n throw new TypeError(\n `${this.constructor.NAME.toUpperCase()}: Option \"${property}\" provided type \"${valueType}\" but expected type \"${expectedTypes}\".`\n )\n }\n }\n }\n}\n\nexport default Config\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap base-component.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport Data from './dom/data.js'\nimport EventHandler from './dom/event-handler.js'\nimport Config from './util/config.js'\nimport { executeAfterTransition, getElement } from './util/index.js'\n\n/**\n * Constants\n */\n\nconst VERSION = '5.3.3'\n\n/**\n * Class definition\n */\n\nclass BaseComponent extends Config {\n constructor(element, config) {\n super()\n\n element = getElement(element)\n if (!element) {\n return\n }\n\n this._element = element\n this._config = this._getConfig(config)\n\n Data.set(this._element, this.constructor.DATA_KEY, this)\n }\n\n // Public\n dispose() {\n Data.remove(this._element, this.constructor.DATA_KEY)\n EventHandler.off(this._element, this.constructor.EVENT_KEY)\n\n for (const propertyName of Object.getOwnPropertyNames(this)) {\n this[propertyName] = null\n }\n }\n\n _queueCallback(callback, element, isAnimated = true) {\n executeAfterTransition(callback, element, isAnimated)\n }\n\n _getConfig(config) {\n config = this._mergeConfigObj(config, this._element)\n config = this._configAfterMerge(config)\n this._typeCheckConfig(config)\n return config\n }\n\n // Static\n static getInstance(element) {\n return Data.get(getElement(element), this.DATA_KEY)\n }\n\n static getOrCreateInstance(element, config = {}) {\n return this.getInstance(element) || new this(element, typeof config === 'object' ? config : null)\n }\n\n static get VERSION() {\n return VERSION\n }\n\n static get DATA_KEY() {\n return `bs.${this.NAME}`\n }\n\n static get EVENT_KEY() {\n return `.${this.DATA_KEY}`\n }\n\n static eventName(name) {\n return `${name}${this.EVENT_KEY}`\n }\n}\n\nexport default BaseComponent\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap dom/selector-engine.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport { isDisabled, isVisible, parseSelector } from '../util/index.js'\n\nconst getSelector = element => {\n let selector = element.getAttribute('data-bs-target')\n\n if (!selector || selector === '#') {\n let hrefAttribute = element.getAttribute('href')\n\n // The only valid content that could double as a selector are IDs or classes,\n // so everything starting with `#` or `.`. If a \"real\" URL is used as the selector,\n // `document.querySelector` will rightfully complain it is invalid.\n // See https://github.com/twbs/bootstrap/issues/32273\n if (!hrefAttribute || (!hrefAttribute.includes('#') && !hrefAttribute.startsWith('.'))) {\n return null\n }\n\n // Just in case some CMS puts out a full URL with the anchor appended\n if (hrefAttribute.includes('#') && !hrefAttribute.startsWith('#')) {\n hrefAttribute = `#${hrefAttribute.split('#')[1]}`\n }\n\n selector = hrefAttribute && hrefAttribute !== '#' ? hrefAttribute.trim() : null\n }\n\n return selector ? selector.split(',').map(sel => parseSelector(sel)).join(',') : null\n}\n\nconst SelectorEngine = {\n find(selector, element = document.documentElement) {\n return [].concat(...Element.prototype.querySelectorAll.call(element, selector))\n },\n\n findOne(selector, element = document.documentElement) {\n return Element.prototype.querySelector.call(element, selector)\n },\n\n children(element, selector) {\n return [].concat(...element.children).filter(child => child.matches(selector))\n },\n\n parents(element, selector) {\n const parents = []\n let ancestor = element.parentNode.closest(selector)\n\n while (ancestor) {\n parents.push(ancestor)\n ancestor = ancestor.parentNode.closest(selector)\n }\n\n return parents\n },\n\n prev(element, selector) {\n let previous = element.previousElementSibling\n\n while (previous) {\n if (previous.matches(selector)) {\n return [previous]\n }\n\n previous = previous.previousElementSibling\n }\n\n return []\n },\n // TODO: this is now unused; remove later along with prev()\n next(element, selector) {\n let next = element.nextElementSibling\n\n while (next) {\n if (next.matches(selector)) {\n return [next]\n }\n\n next = next.nextElementSibling\n }\n\n return []\n },\n\n focusableChildren(element) {\n const focusables = [\n 'a',\n 'button',\n 'input',\n 'textarea',\n 'select',\n 'details',\n '[tabindex]',\n '[contenteditable=\"true\"]'\n ].map(selector => `${selector}:not([tabindex^=\"-\"])`).join(',')\n\n return this.find(focusables, element).filter(el => !isDisabled(el) && isVisible(el))\n },\n\n getSelectorFromElement(element) {\n const selector = getSelector(element)\n\n if (selector) {\n return SelectorEngine.findOne(selector) ? selector : null\n }\n\n return null\n },\n\n getElementFromSelector(element) {\n const selector = getSelector(element)\n\n return selector ? SelectorEngine.findOne(selector) : null\n },\n\n getMultipleElementsFromSelector(element) {\n const selector = getSelector(element)\n\n return selector ? SelectorEngine.find(selector) : []\n }\n}\n\nexport default SelectorEngine\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap util/component-functions.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport EventHandler from '../dom/event-handler.js'\nimport SelectorEngine from '../dom/selector-engine.js'\nimport { isDisabled } from './index.js'\n\nconst enableDismissTrigger = (component, method = 'hide') => {\n const clickEvent = `click.dismiss${component.EVENT_KEY}`\n const name = component.NAME\n\n EventHandler.on(document, clickEvent, `[data-bs-dismiss=\"${name}\"]`, function (event) {\n if (['A', 'AREA'].includes(this.tagName)) {\n event.preventDefault()\n }\n\n if (isDisabled(this)) {\n return\n }\n\n const target = SelectorEngine.getElementFromSelector(this) || this.closest(`.${name}`)\n const instance = component.getOrCreateInstance(target)\n\n // Method argument is left, for Alert and only, as it doesn't implement the 'hide' method\n instance[method]()\n })\n}\n\nexport {\n enableDismissTrigger\n}\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap alert.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport BaseComponent from './base-component.js'\nimport EventHandler from './dom/event-handler.js'\nimport { enableDismissTrigger } from './util/component-functions.js'\nimport { defineJQueryPlugin } from './util/index.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'alert'\nconst DATA_KEY = 'bs.alert'\nconst EVENT_KEY = `.${DATA_KEY}`\n\nconst EVENT_CLOSE = `close${EVENT_KEY}`\nconst EVENT_CLOSED = `closed${EVENT_KEY}`\nconst CLASS_NAME_FADE = 'fade'\nconst CLASS_NAME_SHOW = 'show'\n\n/**\n * Class definition\n */\n\nclass Alert extends BaseComponent {\n // Getters\n static get NAME() {\n return NAME\n }\n\n // Public\n close() {\n const closeEvent = EventHandler.trigger(this._element, EVENT_CLOSE)\n\n if (closeEvent.defaultPrevented) {\n return\n }\n\n this._element.classList.remove(CLASS_NAME_SHOW)\n\n const isAnimated = this._element.classList.contains(CLASS_NAME_FADE)\n this._queueCallback(() => this._destroyElement(), this._element, isAnimated)\n }\n\n // Private\n _destroyElement() {\n this._element.remove()\n EventHandler.trigger(this._element, EVENT_CLOSED)\n this.dispose()\n }\n\n // Static\n static jQueryInterface(config) {\n return this.each(function () {\n const data = Alert.getOrCreateInstance(this)\n\n if (typeof config !== 'string') {\n return\n }\n\n if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {\n throw new TypeError(`No method named \"${config}\"`)\n }\n\n data[config](this)\n })\n }\n}\n\n/**\n * Data API implementation\n */\n\nenableDismissTrigger(Alert, 'close')\n\n/**\n * jQuery\n */\n\ndefineJQueryPlugin(Alert)\n\nexport default Alert\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap button.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport BaseComponent from './base-component.js'\nimport EventHandler from './dom/event-handler.js'\nimport { defineJQueryPlugin } from './util/index.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'button'\nconst DATA_KEY = 'bs.button'\nconst EVENT_KEY = `.${DATA_KEY}`\nconst DATA_API_KEY = '.data-api'\n\nconst CLASS_NAME_ACTIVE = 'active'\nconst SELECTOR_DATA_TOGGLE = '[data-bs-toggle=\"button\"]'\nconst EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`\n\n/**\n * Class definition\n */\n\nclass Button extends BaseComponent {\n // Getters\n static get NAME() {\n return NAME\n }\n\n // Public\n toggle() {\n // Toggle class and sync the `aria-pressed` attribute with the return value of the `.toggle()` method\n this._element.setAttribute('aria-pressed', this._element.classList.toggle(CLASS_NAME_ACTIVE))\n }\n\n // Static\n static jQueryInterface(config) {\n return this.each(function () {\n const data = Button.getOrCreateInstance(this)\n\n if (config === 'toggle') {\n data[config]()\n }\n })\n }\n}\n\n/**\n * Data API implementation\n */\n\nEventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, event => {\n event.preventDefault()\n\n const button = event.target.closest(SELECTOR_DATA_TOGGLE)\n const data = Button.getOrCreateInstance(button)\n\n data.toggle()\n})\n\n/**\n * jQuery\n */\n\ndefineJQueryPlugin(Button)\n\nexport default Button\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap util/swipe.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport EventHandler from '../dom/event-handler.js'\nimport Config from './config.js'\nimport { execute } from './index.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'swipe'\nconst EVENT_KEY = '.bs.swipe'\nconst EVENT_TOUCHSTART = `touchstart${EVENT_KEY}`\nconst EVENT_TOUCHMOVE = `touchmove${EVENT_KEY}`\nconst EVENT_TOUCHEND = `touchend${EVENT_KEY}`\nconst EVENT_POINTERDOWN = `pointerdown${EVENT_KEY}`\nconst EVENT_POINTERUP = `pointerup${EVENT_KEY}`\nconst POINTER_TYPE_TOUCH = 'touch'\nconst POINTER_TYPE_PEN = 'pen'\nconst CLASS_NAME_POINTER_EVENT = 'pointer-event'\nconst SWIPE_THRESHOLD = 40\n\nconst Default = {\n endCallback: null,\n leftCallback: null,\n rightCallback: null\n}\n\nconst DefaultType = {\n endCallback: '(function|null)',\n leftCallback: '(function|null)',\n rightCallback: '(function|null)'\n}\n\n/**\n * Class definition\n */\n\nclass Swipe extends Config {\n constructor(element, config) {\n super()\n this._element = element\n\n if (!element || !Swipe.isSupported()) {\n return\n }\n\n this._config = this._getConfig(config)\n this._deltaX = 0\n this._supportPointerEvents = Boolean(window.PointerEvent)\n this._initEvents()\n }\n\n // Getters\n static get Default() {\n return Default\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n static get NAME() {\n return NAME\n }\n\n // Public\n dispose() {\n EventHandler.off(this._element, EVENT_KEY)\n }\n\n // Private\n _start(event) {\n if (!this._supportPointerEvents) {\n this._deltaX = event.touches[0].clientX\n\n return\n }\n\n if (this._eventIsPointerPenTouch(event)) {\n this._deltaX = event.clientX\n }\n }\n\n _end(event) {\n if (this._eventIsPointerPenTouch(event)) {\n this._deltaX = event.clientX - this._deltaX\n }\n\n this._handleSwipe()\n execute(this._config.endCallback)\n }\n\n _move(event) {\n this._deltaX = event.touches && event.touches.length > 1 ?\n 0 :\n event.touches[0].clientX - this._deltaX\n }\n\n _handleSwipe() {\n const absDeltaX = Math.abs(this._deltaX)\n\n if (absDeltaX <= SWIPE_THRESHOLD) {\n return\n }\n\n const direction = absDeltaX / this._deltaX\n\n this._deltaX = 0\n\n if (!direction) {\n return\n }\n\n execute(direction > 0 ? this._config.rightCallback : this._config.leftCallback)\n }\n\n _initEvents() {\n if (this._supportPointerEvents) {\n EventHandler.on(this._element, EVENT_POINTERDOWN, event => this._start(event))\n EventHandler.on(this._element, EVENT_POINTERUP, event => this._end(event))\n\n this._element.classList.add(CLASS_NAME_POINTER_EVENT)\n } else {\n EventHandler.on(this._element, EVENT_TOUCHSTART, event => this._start(event))\n EventHandler.on(this._element, EVENT_TOUCHMOVE, event => this._move(event))\n EventHandler.on(this._element, EVENT_TOUCHEND, event => this._end(event))\n }\n }\n\n _eventIsPointerPenTouch(event) {\n return this._supportPointerEvents && (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH)\n }\n\n // Static\n static isSupported() {\n return 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0\n }\n}\n\nexport default Swipe\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap carousel.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport BaseComponent from './base-component.js'\nimport EventHandler from './dom/event-handler.js'\nimport Manipulator from './dom/manipulator.js'\nimport SelectorEngine from './dom/selector-engine.js'\nimport {\n defineJQueryPlugin,\n getNextActiveElement,\n isRTL,\n isVisible,\n reflow,\n triggerTransitionEnd\n} from './util/index.js'\nimport Swipe from './util/swipe.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'carousel'\nconst DATA_KEY = 'bs.carousel'\nconst EVENT_KEY = `.${DATA_KEY}`\nconst DATA_API_KEY = '.data-api'\n\nconst ARROW_LEFT_KEY = 'ArrowLeft'\nconst ARROW_RIGHT_KEY = 'ArrowRight'\nconst TOUCHEVENT_COMPAT_WAIT = 500 // Time for mouse compat events to fire after touch\n\nconst ORDER_NEXT = 'next'\nconst ORDER_PREV = 'prev'\nconst DIRECTION_LEFT = 'left'\nconst DIRECTION_RIGHT = 'right'\n\nconst EVENT_SLIDE = `slide${EVENT_KEY}`\nconst EVENT_SLID = `slid${EVENT_KEY}`\nconst EVENT_KEYDOWN = `keydown${EVENT_KEY}`\nconst EVENT_MOUSEENTER = `mouseenter${EVENT_KEY}`\nconst EVENT_MOUSELEAVE = `mouseleave${EVENT_KEY}`\nconst EVENT_DRAG_START = `dragstart${EVENT_KEY}`\nconst EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`\nconst EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`\n\nconst CLASS_NAME_CAROUSEL = 'carousel'\nconst CLASS_NAME_ACTIVE = 'active'\nconst CLASS_NAME_SLIDE = 'slide'\nconst CLASS_NAME_END = 'carousel-item-end'\nconst CLASS_NAME_START = 'carousel-item-start'\nconst CLASS_NAME_NEXT = 'carousel-item-next'\nconst CLASS_NAME_PREV = 'carousel-item-prev'\n\nconst SELECTOR_ACTIVE = '.active'\nconst SELECTOR_ITEM = '.carousel-item'\nconst SELECTOR_ACTIVE_ITEM = SELECTOR_ACTIVE + SELECTOR_ITEM\nconst SELECTOR_ITEM_IMG = '.carousel-item img'\nconst SELECTOR_INDICATORS = '.carousel-indicators'\nconst SELECTOR_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to]'\nconst SELECTOR_DATA_RIDE = '[data-bs-ride=\"carousel\"]'\n\nconst KEY_TO_DIRECTION = {\n [ARROW_LEFT_KEY]: DIRECTION_RIGHT,\n [ARROW_RIGHT_KEY]: DIRECTION_LEFT\n}\n\nconst Default = {\n interval: 5000,\n keyboard: true,\n pause: 'hover',\n ride: false,\n touch: true,\n wrap: true\n}\n\nconst DefaultType = {\n interval: '(number|boolean)', // TODO:v6 remove boolean support\n keyboard: 'boolean',\n pause: '(string|boolean)',\n ride: '(boolean|string)',\n touch: 'boolean',\n wrap: 'boolean'\n}\n\n/**\n * Class definition\n */\n\nclass Carousel extends BaseComponent {\n constructor(element, config) {\n super(element, config)\n\n this._interval = null\n this._activeElement = null\n this._isSliding = false\n this.touchTimeout = null\n this._swipeHelper = null\n\n this._indicatorsElement = SelectorEngine.findOne(SELECTOR_INDICATORS, this._element)\n this._addEventListeners()\n\n if (this._config.ride === CLASS_NAME_CAROUSEL) {\n this.cycle()\n }\n }\n\n // Getters\n static get Default() {\n return Default\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n static get NAME() {\n return NAME\n }\n\n // Public\n next() {\n this._slide(ORDER_NEXT)\n }\n\n nextWhenVisible() {\n // FIXME TODO use `document.visibilityState`\n // Don't call next when the page isn't visible\n // or the carousel or its parent isn't visible\n if (!document.hidden && isVisible(this._element)) {\n this.next()\n }\n }\n\n prev() {\n this._slide(ORDER_PREV)\n }\n\n pause() {\n if (this._isSliding) {\n triggerTransitionEnd(this._element)\n }\n\n this._clearInterval()\n }\n\n cycle() {\n this._clearInterval()\n this._updateInterval()\n\n this._interval = setInterval(() => this.nextWhenVisible(), this._config.interval)\n }\n\n _maybeEnableCycle() {\n if (!this._config.ride) {\n return\n }\n\n if (this._isSliding) {\n EventHandler.one(this._element, EVENT_SLID, () => this.cycle())\n return\n }\n\n this.cycle()\n }\n\n to(index) {\n const items = this._getItems()\n if (index > items.length - 1 || index < 0) {\n return\n }\n\n if (this._isSliding) {\n EventHandler.one(this._element, EVENT_SLID, () => this.to(index))\n return\n }\n\n const activeIndex = this._getItemIndex(this._getActive())\n if (activeIndex === index) {\n return\n }\n\n const order = index > activeIndex ? ORDER_NEXT : ORDER_PREV\n\n this._slide(order, items[index])\n }\n\n dispose() {\n if (this._swipeHelper) {\n this._swipeHelper.dispose()\n }\n\n super.dispose()\n }\n\n // Private\n _configAfterMerge(config) {\n config.defaultInterval = config.interval\n return config\n }\n\n _addEventListeners() {\n if (this._config.keyboard) {\n EventHandler.on(this._element, EVENT_KEYDOWN, event => this._keydown(event))\n }\n\n if (this._config.pause === 'hover') {\n EventHandler.on(this._element, EVENT_MOUSEENTER, () => this.pause())\n EventHandler.on(this._element, EVENT_MOUSELEAVE, () => this._maybeEnableCycle())\n }\n\n if (this._config.touch && Swipe.isSupported()) {\n this._addTouchEventListeners()\n }\n }\n\n _addTouchEventListeners() {\n for (const img of SelectorEngine.find(SELECTOR_ITEM_IMG, this._element)) {\n EventHandler.on(img, EVENT_DRAG_START, event => event.preventDefault())\n }\n\n const endCallBack = () => {\n if (this._config.pause !== 'hover') {\n return\n }\n\n // If it's a touch-enabled device, mouseenter/leave are fired as\n // part of the mouse compatibility events on first tap - the carousel\n // would stop cycling until user tapped out of it;\n // here, we listen for touchend, explicitly pause the carousel\n // (as if it's the second time we tap on it, mouseenter compat event\n // is NOT fired) and after a timeout (to allow for mouse compatibility\n // events to fire) we explicitly restart cycling\n\n this.pause()\n if (this.touchTimeout) {\n clearTimeout(this.touchTimeout)\n }\n\n this.touchTimeout = setTimeout(() => this._maybeEnableCycle(), TOUCHEVENT_COMPAT_WAIT + this._config.interval)\n }\n\n const swipeConfig = {\n leftCallback: () => this._slide(this._directionToOrder(DIRECTION_LEFT)),\n rightCallback: () => this._slide(this._directionToOrder(DIRECTION_RIGHT)),\n endCallback: endCallBack\n }\n\n this._swipeHelper = new Swipe(this._element, swipeConfig)\n }\n\n _keydown(event) {\n if (/input|textarea/i.test(event.target.tagName)) {\n return\n }\n\n const direction = KEY_TO_DIRECTION[event.key]\n if (direction) {\n event.preventDefault()\n this._slide(this._directionToOrder(direction))\n }\n }\n\n _getItemIndex(element) {\n return this._getItems().indexOf(element)\n }\n\n _setActiveIndicatorElement(index) {\n if (!this._indicatorsElement) {\n return\n }\n\n const activeIndicator = SelectorEngine.findOne(SELECTOR_ACTIVE, this._indicatorsElement)\n\n activeIndicator.classList.remove(CLASS_NAME_ACTIVE)\n activeIndicator.removeAttribute('aria-current')\n\n const newActiveIndicator = SelectorEngine.findOne(`[data-bs-slide-to=\"${index}\"]`, this._indicatorsElement)\n\n if (newActiveIndicator) {\n newActiveIndicator.classList.add(CLASS_NAME_ACTIVE)\n newActiveIndicator.setAttribute('aria-current', 'true')\n }\n }\n\n _updateInterval() {\n const element = this._activeElement || this._getActive()\n\n if (!element) {\n return\n }\n\n const elementInterval = Number.parseInt(element.getAttribute('data-bs-interval'), 10)\n\n this._config.interval = elementInterval || this._config.defaultInterval\n }\n\n _slide(order, element = null) {\n if (this._isSliding) {\n return\n }\n\n const activeElement = this._getActive()\n const isNext = order === ORDER_NEXT\n const nextElement = element || getNextActiveElement(this._getItems(), activeElement, isNext, this._config.wrap)\n\n if (nextElement === activeElement) {\n return\n }\n\n const nextElementIndex = this._getItemIndex(nextElement)\n\n const triggerEvent = eventName => {\n return EventHandler.trigger(this._element, eventName, {\n relatedTarget: nextElement,\n direction: this._orderToDirection(order),\n from: this._getItemIndex(activeElement),\n to: nextElementIndex\n })\n }\n\n const slideEvent = triggerEvent(EVENT_SLIDE)\n\n if (slideEvent.defaultPrevented) {\n return\n }\n\n if (!activeElement || !nextElement) {\n // Some weirdness is happening, so we bail\n // TODO: change tests that use empty divs to avoid this check\n return\n }\n\n const isCycling = Boolean(this._interval)\n this.pause()\n\n this._isSliding = true\n\n this._setActiveIndicatorElement(nextElementIndex)\n this._activeElement = nextElement\n\n const directionalClassName = isNext ? CLASS_NAME_START : CLASS_NAME_END\n const orderClassName = isNext ? CLASS_NAME_NEXT : CLASS_NAME_PREV\n\n nextElement.classList.add(orderClassName)\n\n reflow(nextElement)\n\n activeElement.classList.add(directionalClassName)\n nextElement.classList.add(directionalClassName)\n\n const completeCallBack = () => {\n nextElement.classList.remove(directionalClassName, orderClassName)\n nextElement.classList.add(CLASS_NAME_ACTIVE)\n\n activeElement.classList.remove(CLASS_NAME_ACTIVE, orderClassName, directionalClassName)\n\n this._isSliding = false\n\n triggerEvent(EVENT_SLID)\n }\n\n this._queueCallback(completeCallBack, activeElement, this._isAnimated())\n\n if (isCycling) {\n this.cycle()\n }\n }\n\n _isAnimated() {\n return this._element.classList.contains(CLASS_NAME_SLIDE)\n }\n\n _getActive() {\n return SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element)\n }\n\n _getItems() {\n return SelectorEngine.find(SELECTOR_ITEM, this._element)\n }\n\n _clearInterval() {\n if (this._interval) {\n clearInterval(this._interval)\n this._interval = null\n }\n }\n\n _directionToOrder(direction) {\n if (isRTL()) {\n return direction === DIRECTION_LEFT ? ORDER_PREV : ORDER_NEXT\n }\n\n return direction === DIRECTION_LEFT ? ORDER_NEXT : ORDER_PREV\n }\n\n _orderToDirection(order) {\n if (isRTL()) {\n return order === ORDER_PREV ? DIRECTION_LEFT : DIRECTION_RIGHT\n }\n\n return order === ORDER_PREV ? DIRECTION_RIGHT : DIRECTION_LEFT\n }\n\n // Static\n static jQueryInterface(config) {\n return this.each(function () {\n const data = Carousel.getOrCreateInstance(this, config)\n\n if (typeof config === 'number') {\n data.to(config)\n return\n }\n\n if (typeof config === 'string') {\n if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {\n throw new TypeError(`No method named \"${config}\"`)\n }\n\n data[config]()\n }\n })\n }\n}\n\n/**\n * Data API implementation\n */\n\nEventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_SLIDE, function (event) {\n const target = SelectorEngine.getElementFromSelector(this)\n\n if (!target || !target.classList.contains(CLASS_NAME_CAROUSEL)) {\n return\n }\n\n event.preventDefault()\n\n const carousel = Carousel.getOrCreateInstance(target)\n const slideIndex = this.getAttribute('data-bs-slide-to')\n\n if (slideIndex) {\n carousel.to(slideIndex)\n carousel._maybeEnableCycle()\n return\n }\n\n if (Manipulator.getDataAttribute(this, 'slide') === 'next') {\n carousel.next()\n carousel._maybeEnableCycle()\n return\n }\n\n carousel.prev()\n carousel._maybeEnableCycle()\n})\n\nEventHandler.on(window, EVENT_LOAD_DATA_API, () => {\n const carousels = SelectorEngine.find(SELECTOR_DATA_RIDE)\n\n for (const carousel of carousels) {\n Carousel.getOrCreateInstance(carousel)\n }\n})\n\n/**\n * jQuery\n */\n\ndefineJQueryPlugin(Carousel)\n\nexport default Carousel\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap collapse.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport BaseComponent from './base-component.js'\nimport EventHandler from './dom/event-handler.js'\nimport SelectorEngine from './dom/selector-engine.js'\nimport {\n defineJQueryPlugin,\n getElement,\n reflow\n} from './util/index.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'collapse'\nconst DATA_KEY = 'bs.collapse'\nconst EVENT_KEY = `.${DATA_KEY}`\nconst DATA_API_KEY = '.data-api'\n\nconst EVENT_SHOW = `show${EVENT_KEY}`\nconst EVENT_SHOWN = `shown${EVENT_KEY}`\nconst EVENT_HIDE = `hide${EVENT_KEY}`\nconst EVENT_HIDDEN = `hidden${EVENT_KEY}`\nconst EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`\n\nconst CLASS_NAME_SHOW = 'show'\nconst CLASS_NAME_COLLAPSE = 'collapse'\nconst CLASS_NAME_COLLAPSING = 'collapsing'\nconst CLASS_NAME_COLLAPSED = 'collapsed'\nconst CLASS_NAME_DEEPER_CHILDREN = `:scope .${CLASS_NAME_COLLAPSE} .${CLASS_NAME_COLLAPSE}`\nconst CLASS_NAME_HORIZONTAL = 'collapse-horizontal'\n\nconst WIDTH = 'width'\nconst HEIGHT = 'height'\n\nconst SELECTOR_ACTIVES = '.collapse.show, .collapse.collapsing'\nconst SELECTOR_DATA_TOGGLE = '[data-bs-toggle=\"collapse\"]'\n\nconst Default = {\n parent: null,\n toggle: true\n}\n\nconst DefaultType = {\n parent: '(null|element)',\n toggle: 'boolean'\n}\n\n/**\n * Class definition\n */\n\nclass Collapse extends BaseComponent {\n constructor(element, config) {\n super(element, config)\n\n this._isTransitioning = false\n this._triggerArray = []\n\n const toggleList = SelectorEngine.find(SELECTOR_DATA_TOGGLE)\n\n for (const elem of toggleList) {\n const selector = SelectorEngine.getSelectorFromElement(elem)\n const filterElement = SelectorEngine.find(selector)\n .filter(foundElement => foundElement === this._element)\n\n if (selector !== null && filterElement.length) {\n this._triggerArray.push(elem)\n }\n }\n\n this._initializeChildren()\n\n if (!this._config.parent) {\n this._addAriaAndCollapsedClass(this._triggerArray, this._isShown())\n }\n\n if (this._config.toggle) {\n this.toggle()\n }\n }\n\n // Getters\n static get Default() {\n return Default\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n static get NAME() {\n return NAME\n }\n\n // Public\n toggle() {\n if (this._isShown()) {\n this.hide()\n } else {\n this.show()\n }\n }\n\n show() {\n if (this._isTransitioning || this._isShown()) {\n return\n }\n\n let activeChildren = []\n\n // find active children\n if (this._config.parent) {\n activeChildren = this._getFirstLevelChildren(SELECTOR_ACTIVES)\n .filter(element => element !== this._element)\n .map(element => Collapse.getOrCreateInstance(element, { toggle: false }))\n }\n\n if (activeChildren.length && activeChildren[0]._isTransitioning) {\n return\n }\n\n const startEvent = EventHandler.trigger(this._element, EVENT_SHOW)\n if (startEvent.defaultPrevented) {\n return\n }\n\n for (const activeInstance of activeChildren) {\n activeInstance.hide()\n }\n\n const dimension = this._getDimension()\n\n this._element.classList.remove(CLASS_NAME_COLLAPSE)\n this._element.classList.add(CLASS_NAME_COLLAPSING)\n\n this._element.style[dimension] = 0\n\n this._addAriaAndCollapsedClass(this._triggerArray, true)\n this._isTransitioning = true\n\n const complete = () => {\n this._isTransitioning = false\n\n this._element.classList.remove(CLASS_NAME_COLLAPSING)\n this._element.classList.add(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW)\n\n this._element.style[dimension] = ''\n\n EventHandler.trigger(this._element, EVENT_SHOWN)\n }\n\n const capitalizedDimension = dimension[0].toUpperCase() + dimension.slice(1)\n const scrollSize = `scroll${capitalizedDimension}`\n\n this._queueCallback(complete, this._element, true)\n this._element.style[dimension] = `${this._element[scrollSize]}px`\n }\n\n hide() {\n if (this._isTransitioning || !this._isShown()) {\n return\n }\n\n const startEvent = EventHandler.trigger(this._element, EVENT_HIDE)\n if (startEvent.defaultPrevented) {\n return\n }\n\n const dimension = this._getDimension()\n\n this._element.style[dimension] = `${this._element.getBoundingClientRect()[dimension]}px`\n\n reflow(this._element)\n\n this._element.classList.add(CLASS_NAME_COLLAPSING)\n this._element.classList.remove(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW)\n\n for (const trigger of this._triggerArray) {\n const element = SelectorEngine.getElementFromSelector(trigger)\n\n if (element && !this._isShown(element)) {\n this._addAriaAndCollapsedClass([trigger], false)\n }\n }\n\n this._isTransitioning = true\n\n const complete = () => {\n this._isTransitioning = false\n this._element.classList.remove(CLASS_NAME_COLLAPSING)\n this._element.classList.add(CLASS_NAME_COLLAPSE)\n EventHandler.trigger(this._element, EVENT_HIDDEN)\n }\n\n this._element.style[dimension] = ''\n\n this._queueCallback(complete, this._element, true)\n }\n\n _isShown(element = this._element) {\n return element.classList.contains(CLASS_NAME_SHOW)\n }\n\n // Private\n _configAfterMerge(config) {\n config.toggle = Boolean(config.toggle) // Coerce string values\n config.parent = getElement(config.parent)\n return config\n }\n\n _getDimension() {\n return this._element.classList.contains(CLASS_NAME_HORIZONTAL) ? WIDTH : HEIGHT\n }\n\n _initializeChildren() {\n if (!this._config.parent) {\n return\n }\n\n const children = this._getFirstLevelChildren(SELECTOR_DATA_TOGGLE)\n\n for (const element of children) {\n const selected = SelectorEngine.getElementFromSelector(element)\n\n if (selected) {\n this._addAriaAndCollapsedClass([element], this._isShown(selected))\n }\n }\n }\n\n _getFirstLevelChildren(selector) {\n const children = SelectorEngine.find(CLASS_NAME_DEEPER_CHILDREN, this._config.parent)\n // remove children if greater depth\n return SelectorEngine.find(selector, this._config.parent).filter(element => !children.includes(element))\n }\n\n _addAriaAndCollapsedClass(triggerArray, isOpen) {\n if (!triggerArray.length) {\n return\n }\n\n for (const element of triggerArray) {\n element.classList.toggle(CLASS_NAME_COLLAPSED, !isOpen)\n element.setAttribute('aria-expanded', isOpen)\n }\n }\n\n // Static\n static jQueryInterface(config) {\n const _config = {}\n if (typeof config === 'string' && /show|hide/.test(config)) {\n _config.toggle = false\n }\n\n return this.each(function () {\n const data = Collapse.getOrCreateInstance(this, _config)\n\n if (typeof config === 'string') {\n if (typeof data[config] === 'undefined') {\n throw new TypeError(`No method named \"${config}\"`)\n }\n\n data[config]()\n }\n })\n }\n}\n\n/**\n * Data API implementation\n */\n\nEventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {\n // preventDefault only for elements (which change the URL) not inside the collapsible element\n if (event.target.tagName === 'A' || (event.delegateTarget && event.delegateTarget.tagName === 'A')) {\n event.preventDefault()\n }\n\n for (const element of SelectorEngine.getMultipleElementsFromSelector(this)) {\n Collapse.getOrCreateInstance(element, { toggle: false }).toggle()\n }\n})\n\n/**\n * jQuery\n */\n\ndefineJQueryPlugin(Collapse)\n\nexport default Collapse\n","export var top = 'top';\nexport var bottom = 'bottom';\nexport var right = 'right';\nexport var left = 'left';\nexport var auto = 'auto';\nexport var basePlacements = [top, bottom, right, left];\nexport var start = 'start';\nexport var end = 'end';\nexport var clippingParents = 'clippingParents';\nexport var viewport = 'viewport';\nexport var popper = 'popper';\nexport var reference = 'reference';\nexport var variationPlacements = /*#__PURE__*/basePlacements.reduce(function (acc, placement) {\n return acc.concat([placement + \"-\" + start, placement + \"-\" + end]);\n}, []);\nexport var placements = /*#__PURE__*/[].concat(basePlacements, [auto]).reduce(function (acc, placement) {\n return acc.concat([placement, placement + \"-\" + start, placement + \"-\" + end]);\n}, []); // modifiers that need to read the DOM\n\nexport var beforeRead = 'beforeRead';\nexport var read = 'read';\nexport var afterRead = 'afterRead'; // pure-logic modifiers\n\nexport var beforeMain = 'beforeMain';\nexport var main = 'main';\nexport var afterMain = 'afterMain'; // modifier with the purpose to write to the DOM (or write into a framework state)\n\nexport var beforeWrite = 'beforeWrite';\nexport var write = 'write';\nexport var afterWrite = 'afterWrite';\nexport var modifierPhases = [beforeRead, read, afterRead, beforeMain, main, afterMain, beforeWrite, write, afterWrite];","export default function getNodeName(element) {\n return element ? (element.nodeName || '').toLowerCase() : null;\n}","export default function getWindow(node) {\n if (node == null) {\n return window;\n }\n\n if (node.toString() !== '[object Window]') {\n var ownerDocument = node.ownerDocument;\n return ownerDocument ? ownerDocument.defaultView || window : window;\n }\n\n return node;\n}","import getWindow from \"./getWindow.js\";\n\nfunction isElement(node) {\n var OwnElement = getWindow(node).Element;\n return node instanceof OwnElement || node instanceof Element;\n}\n\nfunction isHTMLElement(node) {\n var OwnElement = getWindow(node).HTMLElement;\n return node instanceof OwnElement || node instanceof HTMLElement;\n}\n\nfunction isShadowRoot(node) {\n // IE 11 has no ShadowRoot\n if (typeof ShadowRoot === 'undefined') {\n return false;\n }\n\n var OwnElement = getWindow(node).ShadowRoot;\n return node instanceof OwnElement || node instanceof ShadowRoot;\n}\n\nexport { isElement, isHTMLElement, isShadowRoot };","import getNodeName from \"../dom-utils/getNodeName.js\";\nimport { isHTMLElement } from \"../dom-utils/instanceOf.js\"; // This modifier takes the styles prepared by the `computeStyles` modifier\n// and applies them to the HTMLElements such as popper and arrow\n\nfunction applyStyles(_ref) {\n var state = _ref.state;\n Object.keys(state.elements).forEach(function (name) {\n var style = state.styles[name] || {};\n var attributes = state.attributes[name] || {};\n var element = state.elements[name]; // arrow is optional + virtual elements\n\n if (!isHTMLElement(element) || !getNodeName(element)) {\n return;\n } // Flow doesn't support to extend this property, but it's the most\n // effective way to apply styles to an HTMLElement\n // $FlowFixMe[cannot-write]\n\n\n Object.assign(element.style, style);\n Object.keys(attributes).forEach(function (name) {\n var value = attributes[name];\n\n if (value === false) {\n element.removeAttribute(name);\n } else {\n element.setAttribute(name, value === true ? '' : value);\n }\n });\n });\n}\n\nfunction effect(_ref2) {\n var state = _ref2.state;\n var initialStyles = {\n popper: {\n position: state.options.strategy,\n left: '0',\n top: '0',\n margin: '0'\n },\n arrow: {\n position: 'absolute'\n },\n reference: {}\n };\n Object.assign(state.elements.popper.style, initialStyles.popper);\n state.styles = initialStyles;\n\n if (state.elements.arrow) {\n Object.assign(state.elements.arrow.style, initialStyles.arrow);\n }\n\n return function () {\n Object.keys(state.elements).forEach(function (name) {\n var element = state.elements[name];\n var attributes = state.attributes[name] || {};\n var styleProperties = Object.keys(state.styles.hasOwnProperty(name) ? state.styles[name] : initialStyles[name]); // Set all values to an empty string to unset them\n\n var style = styleProperties.reduce(function (style, property) {\n style[property] = '';\n return style;\n }, {}); // arrow is optional + virtual elements\n\n if (!isHTMLElement(element) || !getNodeName(element)) {\n return;\n }\n\n Object.assign(element.style, style);\n Object.keys(attributes).forEach(function (attribute) {\n element.removeAttribute(attribute);\n });\n });\n };\n} // eslint-disable-next-line import/no-unused-modules\n\n\nexport default {\n name: 'applyStyles',\n enabled: true,\n phase: 'write',\n fn: applyStyles,\n effect: effect,\n requires: ['computeStyles']\n};","import { auto } from \"../enums.js\";\nexport default function getBasePlacement(placement) {\n return placement.split('-')[0];\n}","export var max = Math.max;\nexport var min = Math.min;\nexport var round = Math.round;","export default function getUAString() {\n var uaData = navigator.userAgentData;\n\n if (uaData != null && uaData.brands && Array.isArray(uaData.brands)) {\n return uaData.brands.map(function (item) {\n return item.brand + \"/\" + item.version;\n }).join(' ');\n }\n\n return navigator.userAgent;\n}","import getUAString from \"../utils/userAgent.js\";\nexport default function isLayoutViewport() {\n return !/^((?!chrome|android).)*safari/i.test(getUAString());\n}","import { isElement, isHTMLElement } from \"./instanceOf.js\";\nimport { round } from \"../utils/math.js\";\nimport getWindow from \"./getWindow.js\";\nimport isLayoutViewport from \"./isLayoutViewport.js\";\nexport default function getBoundingClientRect(element, includeScale, isFixedStrategy) {\n if (includeScale === void 0) {\n includeScale = false;\n }\n\n if (isFixedStrategy === void 0) {\n isFixedStrategy = false;\n }\n\n var clientRect = element.getBoundingClientRect();\n var scaleX = 1;\n var scaleY = 1;\n\n if (includeScale && isHTMLElement(element)) {\n scaleX = element.offsetWidth > 0 ? round(clientRect.width) / element.offsetWidth || 1 : 1;\n scaleY = element.offsetHeight > 0 ? round(clientRect.height) / element.offsetHeight || 1 : 1;\n }\n\n var _ref = isElement(element) ? getWindow(element) : window,\n visualViewport = _ref.visualViewport;\n\n var addVisualOffsets = !isLayoutViewport() && isFixedStrategy;\n var x = (clientRect.left + (addVisualOffsets && visualViewport ? visualViewport.offsetLeft : 0)) / scaleX;\n var y = (clientRect.top + (addVisualOffsets && visualViewport ? visualViewport.offsetTop : 0)) / scaleY;\n var width = clientRect.width / scaleX;\n var height = clientRect.height / scaleY;\n return {\n width: width,\n height: height,\n top: y,\n right: x + width,\n bottom: y + height,\n left: x,\n x: x,\n y: y\n };\n}","import getBoundingClientRect from \"./getBoundingClientRect.js\"; // Returns the layout rect of an element relative to its offsetParent. Layout\n// means it doesn't take into account transforms.\n\nexport default function getLayoutRect(element) {\n var clientRect = getBoundingClientRect(element); // Use the clientRect sizes if it's not been transformed.\n // Fixes https://github.com/popperjs/popper-core/issues/1223\n\n var width = element.offsetWidth;\n var height = element.offsetHeight;\n\n if (Math.abs(clientRect.width - width) <= 1) {\n width = clientRect.width;\n }\n\n if (Math.abs(clientRect.height - height) <= 1) {\n height = clientRect.height;\n }\n\n return {\n x: element.offsetLeft,\n y: element.offsetTop,\n width: width,\n height: height\n };\n}","import { isShadowRoot } from \"./instanceOf.js\";\nexport default function contains(parent, child) {\n var rootNode = child.getRootNode && child.getRootNode(); // First, attempt with faster native method\n\n if (parent.contains(child)) {\n return true;\n } // then fallback to custom implementation with Shadow DOM support\n else if (rootNode && isShadowRoot(rootNode)) {\n var next = child;\n\n do {\n if (next && parent.isSameNode(next)) {\n return true;\n } // $FlowFixMe[prop-missing]: need a better way to handle this...\n\n\n next = next.parentNode || next.host;\n } while (next);\n } // Give up, the result is false\n\n\n return false;\n}","import getWindow from \"./getWindow.js\";\nexport default function getComputedStyle(element) {\n return getWindow(element).getComputedStyle(element);\n}","import getNodeName from \"./getNodeName.js\";\nexport default function isTableElement(element) {\n return ['table', 'td', 'th'].indexOf(getNodeName(element)) >= 0;\n}","import { isElement } from \"./instanceOf.js\";\nexport default function getDocumentElement(element) {\n // $FlowFixMe[incompatible-return]: assume body is always available\n return ((isElement(element) ? element.ownerDocument : // $FlowFixMe[prop-missing]\n element.document) || window.document).documentElement;\n}","import getNodeName from \"./getNodeName.js\";\nimport getDocumentElement from \"./getDocumentElement.js\";\nimport { isShadowRoot } from \"./instanceOf.js\";\nexport default function getParentNode(element) {\n if (getNodeName(element) === 'html') {\n return element;\n }\n\n return (// this is a quicker (but less type safe) way to save quite some bytes from the bundle\n // $FlowFixMe[incompatible-return]\n // $FlowFixMe[prop-missing]\n element.assignedSlot || // step into the shadow DOM of the parent of a slotted node\n element.parentNode || ( // DOM Element detected\n isShadowRoot(element) ? element.host : null) || // ShadowRoot detected\n // $FlowFixMe[incompatible-call]: HTMLElement is a Node\n getDocumentElement(element) // fallback\n\n );\n}","import getWindow from \"./getWindow.js\";\nimport getNodeName from \"./getNodeName.js\";\nimport getComputedStyle from \"./getComputedStyle.js\";\nimport { isHTMLElement, isShadowRoot } from \"./instanceOf.js\";\nimport isTableElement from \"./isTableElement.js\";\nimport getParentNode from \"./getParentNode.js\";\nimport getUAString from \"../utils/userAgent.js\";\n\nfunction getTrueOffsetParent(element) {\n if (!isHTMLElement(element) || // https://github.com/popperjs/popper-core/issues/837\n getComputedStyle(element).position === 'fixed') {\n return null;\n }\n\n return element.offsetParent;\n} // `.offsetParent` reports `null` for fixed elements, while absolute elements\n// return the containing block\n\n\nfunction getContainingBlock(element) {\n var isFirefox = /firefox/i.test(getUAString());\n var isIE = /Trident/i.test(getUAString());\n\n if (isIE && isHTMLElement(element)) {\n // In IE 9, 10 and 11 fixed elements containing block is always established by the viewport\n var elementCss = getComputedStyle(element);\n\n if (elementCss.position === 'fixed') {\n return null;\n }\n }\n\n var currentNode = getParentNode(element);\n\n if (isShadowRoot(currentNode)) {\n currentNode = currentNode.host;\n }\n\n while (isHTMLElement(currentNode) && ['html', 'body'].indexOf(getNodeName(currentNode)) < 0) {\n var css = getComputedStyle(currentNode); // This is non-exhaustive but covers the most common CSS properties that\n // create a containing block.\n // https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block\n\n if (css.transform !== 'none' || css.perspective !== 'none' || css.contain === 'paint' || ['transform', 'perspective'].indexOf(css.willChange) !== -1 || isFirefox && css.willChange === 'filter' || isFirefox && css.filter && css.filter !== 'none') {\n return currentNode;\n } else {\n currentNode = currentNode.parentNode;\n }\n }\n\n return null;\n} // Gets the closest ancestor positioned element. Handles some edge cases,\n// such as table ancestors and cross browser bugs.\n\n\nexport default function getOffsetParent(element) {\n var window = getWindow(element);\n var offsetParent = getTrueOffsetParent(element);\n\n while (offsetParent && isTableElement(offsetParent) && getComputedStyle(offsetParent).position === 'static') {\n offsetParent = getTrueOffsetParent(offsetParent);\n }\n\n if (offsetParent && (getNodeName(offsetParent) === 'html' || getNodeName(offsetParent) === 'body' && getComputedStyle(offsetParent).position === 'static')) {\n return window;\n }\n\n return offsetParent || getContainingBlock(element) || window;\n}","export default function getMainAxisFromPlacement(placement) {\n return ['top', 'bottom'].indexOf(placement) >= 0 ? 'x' : 'y';\n}","import { max as mathMax, min as mathMin } from \"./math.js\";\nexport function within(min, value, max) {\n return mathMax(min, mathMin(value, max));\n}\nexport function withinMaxClamp(min, value, max) {\n var v = within(min, value, max);\n return v > max ? max : v;\n}","export default function getFreshSideObject() {\n return {\n top: 0,\n right: 0,\n bottom: 0,\n left: 0\n };\n}","import getFreshSideObject from \"./getFreshSideObject.js\";\nexport default function mergePaddingObject(paddingObject) {\n return Object.assign({}, getFreshSideObject(), paddingObject);\n}","export default function expandToHashMap(value, keys) {\n return keys.reduce(function (hashMap, key) {\n hashMap[key] = value;\n return hashMap;\n }, {});\n}","import getBasePlacement from \"../utils/getBasePlacement.js\";\nimport getLayoutRect from \"../dom-utils/getLayoutRect.js\";\nimport contains from \"../dom-utils/contains.js\";\nimport getOffsetParent from \"../dom-utils/getOffsetParent.js\";\nimport getMainAxisFromPlacement from \"../utils/getMainAxisFromPlacement.js\";\nimport { within } from \"../utils/within.js\";\nimport mergePaddingObject from \"../utils/mergePaddingObject.js\";\nimport expandToHashMap from \"../utils/expandToHashMap.js\";\nimport { left, right, basePlacements, top, bottom } from \"../enums.js\"; // eslint-disable-next-line import/no-unused-modules\n\nvar toPaddingObject = function toPaddingObject(padding, state) {\n padding = typeof padding === 'function' ? padding(Object.assign({}, state.rects, {\n placement: state.placement\n })) : padding;\n return mergePaddingObject(typeof padding !== 'number' ? padding : expandToHashMap(padding, basePlacements));\n};\n\nfunction arrow(_ref) {\n var _state$modifiersData$;\n\n var state = _ref.state,\n name = _ref.name,\n options = _ref.options;\n var arrowElement = state.elements.arrow;\n var popperOffsets = state.modifiersData.popperOffsets;\n var basePlacement = getBasePlacement(state.placement);\n var axis = getMainAxisFromPlacement(basePlacement);\n var isVertical = [left, right].indexOf(basePlacement) >= 0;\n var len = isVertical ? 'height' : 'width';\n\n if (!arrowElement || !popperOffsets) {\n return;\n }\n\n var paddingObject = toPaddingObject(options.padding, state);\n var arrowRect = getLayoutRect(arrowElement);\n var minProp = axis === 'y' ? top : left;\n var maxProp = axis === 'y' ? bottom : right;\n var endDiff = state.rects.reference[len] + state.rects.reference[axis] - popperOffsets[axis] - state.rects.popper[len];\n var startDiff = popperOffsets[axis] - state.rects.reference[axis];\n var arrowOffsetParent = getOffsetParent(arrowElement);\n var clientSize = arrowOffsetParent ? axis === 'y' ? arrowOffsetParent.clientHeight || 0 : arrowOffsetParent.clientWidth || 0 : 0;\n var centerToReference = endDiff / 2 - startDiff / 2; // Make sure the arrow doesn't overflow the popper if the center point is\n // outside of the popper bounds\n\n var min = paddingObject[minProp];\n var max = clientSize - arrowRect[len] - paddingObject[maxProp];\n var center = clientSize / 2 - arrowRect[len] / 2 + centerToReference;\n var offset = within(min, center, max); // Prevents breaking syntax highlighting...\n\n var axisProp = axis;\n state.modifiersData[name] = (_state$modifiersData$ = {}, _state$modifiersData$[axisProp] = offset, _state$modifiersData$.centerOffset = offset - center, _state$modifiersData$);\n}\n\nfunction effect(_ref2) {\n var state = _ref2.state,\n options = _ref2.options;\n var _options$element = options.element,\n arrowElement = _options$element === void 0 ? '[data-popper-arrow]' : _options$element;\n\n if (arrowElement == null) {\n return;\n } // CSS selector\n\n\n if (typeof arrowElement === 'string') {\n arrowElement = state.elements.popper.querySelector(arrowElement);\n\n if (!arrowElement) {\n return;\n }\n }\n\n if (!contains(state.elements.popper, arrowElement)) {\n return;\n }\n\n state.elements.arrow = arrowElement;\n} // eslint-disable-next-line import/no-unused-modules\n\n\nexport default {\n name: 'arrow',\n enabled: true,\n phase: 'main',\n fn: arrow,\n effect: effect,\n requires: ['popperOffsets'],\n requiresIfExists: ['preventOverflow']\n};","export default function getVariation(placement) {\n return placement.split('-')[1];\n}","import { top, left, right, bottom, end } from \"../enums.js\";\nimport getOffsetParent from \"../dom-utils/getOffsetParent.js\";\nimport getWindow from \"../dom-utils/getWindow.js\";\nimport getDocumentElement from \"../dom-utils/getDocumentElement.js\";\nimport getComputedStyle from \"../dom-utils/getComputedStyle.js\";\nimport getBasePlacement from \"../utils/getBasePlacement.js\";\nimport getVariation from \"../utils/getVariation.js\";\nimport { round } from \"../utils/math.js\"; // eslint-disable-next-line import/no-unused-modules\n\nvar unsetSides = {\n top: 'auto',\n right: 'auto',\n bottom: 'auto',\n left: 'auto'\n}; // Round the offsets to the nearest suitable subpixel based on the DPR.\n// Zooming can change the DPR, but it seems to report a value that will\n// cleanly divide the values into the appropriate subpixels.\n\nfunction roundOffsetsByDPR(_ref, win) {\n var x = _ref.x,\n y = _ref.y;\n var dpr = win.devicePixelRatio || 1;\n return {\n x: round(x * dpr) / dpr || 0,\n y: round(y * dpr) / dpr || 0\n };\n}\n\nexport function mapToStyles(_ref2) {\n var _Object$assign2;\n\n var popper = _ref2.popper,\n popperRect = _ref2.popperRect,\n placement = _ref2.placement,\n variation = _ref2.variation,\n offsets = _ref2.offsets,\n position = _ref2.position,\n gpuAcceleration = _ref2.gpuAcceleration,\n adaptive = _ref2.adaptive,\n roundOffsets = _ref2.roundOffsets,\n isFixed = _ref2.isFixed;\n var _offsets$x = offsets.x,\n x = _offsets$x === void 0 ? 0 : _offsets$x,\n _offsets$y = offsets.y,\n y = _offsets$y === void 0 ? 0 : _offsets$y;\n\n var _ref3 = typeof roundOffsets === 'function' ? roundOffsets({\n x: x,\n y: y\n }) : {\n x: x,\n y: y\n };\n\n x = _ref3.x;\n y = _ref3.y;\n var hasX = offsets.hasOwnProperty('x');\n var hasY = offsets.hasOwnProperty('y');\n var sideX = left;\n var sideY = top;\n var win = window;\n\n if (adaptive) {\n var offsetParent = getOffsetParent(popper);\n var heightProp = 'clientHeight';\n var widthProp = 'clientWidth';\n\n if (offsetParent === getWindow(popper)) {\n offsetParent = getDocumentElement(popper);\n\n if (getComputedStyle(offsetParent).position !== 'static' && position === 'absolute') {\n heightProp = 'scrollHeight';\n widthProp = 'scrollWidth';\n }\n } // $FlowFixMe[incompatible-cast]: force type refinement, we compare offsetParent with window above, but Flow doesn't detect it\n\n\n offsetParent = offsetParent;\n\n if (placement === top || (placement === left || placement === right) && variation === end) {\n sideY = bottom;\n var offsetY = isFixed && offsetParent === win && win.visualViewport ? win.visualViewport.height : // $FlowFixMe[prop-missing]\n offsetParent[heightProp];\n y -= offsetY - popperRect.height;\n y *= gpuAcceleration ? 1 : -1;\n }\n\n if (placement === left || (placement === top || placement === bottom) && variation === end) {\n sideX = right;\n var offsetX = isFixed && offsetParent === win && win.visualViewport ? win.visualViewport.width : // $FlowFixMe[prop-missing]\n offsetParent[widthProp];\n x -= offsetX - popperRect.width;\n x *= gpuAcceleration ? 1 : -1;\n }\n }\n\n var commonStyles = Object.assign({\n position: position\n }, adaptive && unsetSides);\n\n var _ref4 = roundOffsets === true ? roundOffsetsByDPR({\n x: x,\n y: y\n }, getWindow(popper)) : {\n x: x,\n y: y\n };\n\n x = _ref4.x;\n y = _ref4.y;\n\n if (gpuAcceleration) {\n var _Object$assign;\n\n return Object.assign({}, commonStyles, (_Object$assign = {}, _Object$assign[sideY] = hasY ? '0' : '', _Object$assign[sideX] = hasX ? '0' : '', _Object$assign.transform = (win.devicePixelRatio || 1) <= 1 ? \"translate(\" + x + \"px, \" + y + \"px)\" : \"translate3d(\" + x + \"px, \" + y + \"px, 0)\", _Object$assign));\n }\n\n return Object.assign({}, commonStyles, (_Object$assign2 = {}, _Object$assign2[sideY] = hasY ? y + \"px\" : '', _Object$assign2[sideX] = hasX ? x + \"px\" : '', _Object$assign2.transform = '', _Object$assign2));\n}\n\nfunction computeStyles(_ref5) {\n var state = _ref5.state,\n options = _ref5.options;\n var _options$gpuAccelerat = options.gpuAcceleration,\n gpuAcceleration = _options$gpuAccelerat === void 0 ? true : _options$gpuAccelerat,\n _options$adaptive = options.adaptive,\n adaptive = _options$adaptive === void 0 ? true : _options$adaptive,\n _options$roundOffsets = options.roundOffsets,\n roundOffsets = _options$roundOffsets === void 0 ? true : _options$roundOffsets;\n var commonStyles = {\n placement: getBasePlacement(state.placement),\n variation: getVariation(state.placement),\n popper: state.elements.popper,\n popperRect: state.rects.popper,\n gpuAcceleration: gpuAcceleration,\n isFixed: state.options.strategy === 'fixed'\n };\n\n if (state.modifiersData.popperOffsets != null) {\n state.styles.popper = Object.assign({}, state.styles.popper, mapToStyles(Object.assign({}, commonStyles, {\n offsets: state.modifiersData.popperOffsets,\n position: state.options.strategy,\n adaptive: adaptive,\n roundOffsets: roundOffsets\n })));\n }\n\n if (state.modifiersData.arrow != null) {\n state.styles.arrow = Object.assign({}, state.styles.arrow, mapToStyles(Object.assign({}, commonStyles, {\n offsets: state.modifiersData.arrow,\n position: 'absolute',\n adaptive: false,\n roundOffsets: roundOffsets\n })));\n }\n\n state.attributes.popper = Object.assign({}, state.attributes.popper, {\n 'data-popper-placement': state.placement\n });\n} // eslint-disable-next-line import/no-unused-modules\n\n\nexport default {\n name: 'computeStyles',\n enabled: true,\n phase: 'beforeWrite',\n fn: computeStyles,\n data: {}\n};","import getWindow from \"../dom-utils/getWindow.js\"; // eslint-disable-next-line import/no-unused-modules\n\nvar passive = {\n passive: true\n};\n\nfunction effect(_ref) {\n var state = _ref.state,\n instance = _ref.instance,\n options = _ref.options;\n var _options$scroll = options.scroll,\n scroll = _options$scroll === void 0 ? true : _options$scroll,\n _options$resize = options.resize,\n resize = _options$resize === void 0 ? true : _options$resize;\n var window = getWindow(state.elements.popper);\n var scrollParents = [].concat(state.scrollParents.reference, state.scrollParents.popper);\n\n if (scroll) {\n scrollParents.forEach(function (scrollParent) {\n scrollParent.addEventListener('scroll', instance.update, passive);\n });\n }\n\n if (resize) {\n window.addEventListener('resize', instance.update, passive);\n }\n\n return function () {\n if (scroll) {\n scrollParents.forEach(function (scrollParent) {\n scrollParent.removeEventListener('scroll', instance.update, passive);\n });\n }\n\n if (resize) {\n window.removeEventListener('resize', instance.update, passive);\n }\n };\n} // eslint-disable-next-line import/no-unused-modules\n\n\nexport default {\n name: 'eventListeners',\n enabled: true,\n phase: 'write',\n fn: function fn() {},\n effect: effect,\n data: {}\n};","var hash = {\n left: 'right',\n right: 'left',\n bottom: 'top',\n top: 'bottom'\n};\nexport default function getOppositePlacement(placement) {\n return placement.replace(/left|right|bottom|top/g, function (matched) {\n return hash[matched];\n });\n}","var hash = {\n start: 'end',\n end: 'start'\n};\nexport default function getOppositeVariationPlacement(placement) {\n return placement.replace(/start|end/g, function (matched) {\n return hash[matched];\n });\n}","import getWindow from \"./getWindow.js\";\nexport default function getWindowScroll(node) {\n var win = getWindow(node);\n var scrollLeft = win.pageXOffset;\n var scrollTop = win.pageYOffset;\n return {\n scrollLeft: scrollLeft,\n scrollTop: scrollTop\n };\n}","import getBoundingClientRect from \"./getBoundingClientRect.js\";\nimport getDocumentElement from \"./getDocumentElement.js\";\nimport getWindowScroll from \"./getWindowScroll.js\";\nexport default function getWindowScrollBarX(element) {\n // If has a CSS width greater than the viewport, then this will be\n // incorrect for RTL.\n // Popper 1 is broken in this case and never had a bug report so let's assume\n // it's not an issue. I don't think anyone ever specifies width on \n // anyway.\n // Browsers where the left scrollbar doesn't cause an issue report `0` for\n // this (e.g. Edge 2019, IE11, Safari)\n return getBoundingClientRect(getDocumentElement(element)).left + getWindowScroll(element).scrollLeft;\n}","import getWindow from \"./getWindow.js\";\nimport getDocumentElement from \"./getDocumentElement.js\";\nimport getWindowScrollBarX from \"./getWindowScrollBarX.js\";\nimport isLayoutViewport from \"./isLayoutViewport.js\";\nexport default function getViewportRect(element, strategy) {\n var win = getWindow(element);\n var html = getDocumentElement(element);\n var visualViewport = win.visualViewport;\n var width = html.clientWidth;\n var height = html.clientHeight;\n var x = 0;\n var y = 0;\n\n if (visualViewport) {\n width = visualViewport.width;\n height = visualViewport.height;\n var layoutViewport = isLayoutViewport();\n\n if (layoutViewport || !layoutViewport && strategy === 'fixed') {\n x = visualViewport.offsetLeft;\n y = visualViewport.offsetTop;\n }\n }\n\n return {\n width: width,\n height: height,\n x: x + getWindowScrollBarX(element),\n y: y\n };\n}","import getDocumentElement from \"./getDocumentElement.js\";\nimport getComputedStyle from \"./getComputedStyle.js\";\nimport getWindowScrollBarX from \"./getWindowScrollBarX.js\";\nimport getWindowScroll from \"./getWindowScroll.js\";\nimport { max } from \"../utils/math.js\"; // Gets the entire size of the scrollable document area, even extending outside\n// of the `` and `` rect bounds if horizontally scrollable\n\nexport default function getDocumentRect(element) {\n var _element$ownerDocumen;\n\n var html = getDocumentElement(element);\n var winScroll = getWindowScroll(element);\n var body = (_element$ownerDocumen = element.ownerDocument) == null ? void 0 : _element$ownerDocumen.body;\n var width = max(html.scrollWidth, html.clientWidth, body ? body.scrollWidth : 0, body ? body.clientWidth : 0);\n var height = max(html.scrollHeight, html.clientHeight, body ? body.scrollHeight : 0, body ? body.clientHeight : 0);\n var x = -winScroll.scrollLeft + getWindowScrollBarX(element);\n var y = -winScroll.scrollTop;\n\n if (getComputedStyle(body || html).direction === 'rtl') {\n x += max(html.clientWidth, body ? body.clientWidth : 0) - width;\n }\n\n return {\n width: width,\n height: height,\n x: x,\n y: y\n };\n}","import getComputedStyle from \"./getComputedStyle.js\";\nexport default function isScrollParent(element) {\n // Firefox wants us to check `-x` and `-y` variations as well\n var _getComputedStyle = getComputedStyle(element),\n overflow = _getComputedStyle.overflow,\n overflowX = _getComputedStyle.overflowX,\n overflowY = _getComputedStyle.overflowY;\n\n return /auto|scroll|overlay|hidden/.test(overflow + overflowY + overflowX);\n}","import getParentNode from \"./getParentNode.js\";\nimport isScrollParent from \"./isScrollParent.js\";\nimport getNodeName from \"./getNodeName.js\";\nimport { isHTMLElement } from \"./instanceOf.js\";\nexport default function getScrollParent(node) {\n if (['html', 'body', '#document'].indexOf(getNodeName(node)) >= 0) {\n // $FlowFixMe[incompatible-return]: assume body is always available\n return node.ownerDocument.body;\n }\n\n if (isHTMLElement(node) && isScrollParent(node)) {\n return node;\n }\n\n return getScrollParent(getParentNode(node));\n}","import getScrollParent from \"./getScrollParent.js\";\nimport getParentNode from \"./getParentNode.js\";\nimport getWindow from \"./getWindow.js\";\nimport isScrollParent from \"./isScrollParent.js\";\n/*\ngiven a DOM element, return the list of all scroll parents, up the list of ancesors\nuntil we get to the top window object. This list is what we attach scroll listeners\nto, because if any of these parent elements scroll, we'll need to re-calculate the\nreference element's position.\n*/\n\nexport default function listScrollParents(element, list) {\n var _element$ownerDocumen;\n\n if (list === void 0) {\n list = [];\n }\n\n var scrollParent = getScrollParent(element);\n var isBody = scrollParent === ((_element$ownerDocumen = element.ownerDocument) == null ? void 0 : _element$ownerDocumen.body);\n var win = getWindow(scrollParent);\n var target = isBody ? [win].concat(win.visualViewport || [], isScrollParent(scrollParent) ? scrollParent : []) : scrollParent;\n var updatedList = list.concat(target);\n return isBody ? updatedList : // $FlowFixMe[incompatible-call]: isBody tells us target will be an HTMLElement here\n updatedList.concat(listScrollParents(getParentNode(target)));\n}","export default function rectToClientRect(rect) {\n return Object.assign({}, rect, {\n left: rect.x,\n top: rect.y,\n right: rect.x + rect.width,\n bottom: rect.y + rect.height\n });\n}","import { viewport } from \"../enums.js\";\nimport getViewportRect from \"./getViewportRect.js\";\nimport getDocumentRect from \"./getDocumentRect.js\";\nimport listScrollParents from \"./listScrollParents.js\";\nimport getOffsetParent from \"./getOffsetParent.js\";\nimport getDocumentElement from \"./getDocumentElement.js\";\nimport getComputedStyle from \"./getComputedStyle.js\";\nimport { isElement, isHTMLElement } from \"./instanceOf.js\";\nimport getBoundingClientRect from \"./getBoundingClientRect.js\";\nimport getParentNode from \"./getParentNode.js\";\nimport contains from \"./contains.js\";\nimport getNodeName from \"./getNodeName.js\";\nimport rectToClientRect from \"../utils/rectToClientRect.js\";\nimport { max, min } from \"../utils/math.js\";\n\nfunction getInnerBoundingClientRect(element, strategy) {\n var rect = getBoundingClientRect(element, false, strategy === 'fixed');\n rect.top = rect.top + element.clientTop;\n rect.left = rect.left + element.clientLeft;\n rect.bottom = rect.top + element.clientHeight;\n rect.right = rect.left + element.clientWidth;\n rect.width = element.clientWidth;\n rect.height = element.clientHeight;\n rect.x = rect.left;\n rect.y = rect.top;\n return rect;\n}\n\nfunction getClientRectFromMixedType(element, clippingParent, strategy) {\n return clippingParent === viewport ? rectToClientRect(getViewportRect(element, strategy)) : isElement(clippingParent) ? getInnerBoundingClientRect(clippingParent, strategy) : rectToClientRect(getDocumentRect(getDocumentElement(element)));\n} // A \"clipping parent\" is an overflowable container with the characteristic of\n// clipping (or hiding) overflowing elements with a position different from\n// `initial`\n\n\nfunction getClippingParents(element) {\n var clippingParents = listScrollParents(getParentNode(element));\n var canEscapeClipping = ['absolute', 'fixed'].indexOf(getComputedStyle(element).position) >= 0;\n var clipperElement = canEscapeClipping && isHTMLElement(element) ? getOffsetParent(element) : element;\n\n if (!isElement(clipperElement)) {\n return [];\n } // $FlowFixMe[incompatible-return]: https://github.com/facebook/flow/issues/1414\n\n\n return clippingParents.filter(function (clippingParent) {\n return isElement(clippingParent) && contains(clippingParent, clipperElement) && getNodeName(clippingParent) !== 'body';\n });\n} // Gets the maximum area that the element is visible in due to any number of\n// clipping parents\n\n\nexport default function getClippingRect(element, boundary, rootBoundary, strategy) {\n var mainClippingParents = boundary === 'clippingParents' ? getClippingParents(element) : [].concat(boundary);\n var clippingParents = [].concat(mainClippingParents, [rootBoundary]);\n var firstClippingParent = clippingParents[0];\n var clippingRect = clippingParents.reduce(function (accRect, clippingParent) {\n var rect = getClientRectFromMixedType(element, clippingParent, strategy);\n accRect.top = max(rect.top, accRect.top);\n accRect.right = min(rect.right, accRect.right);\n accRect.bottom = min(rect.bottom, accRect.bottom);\n accRect.left = max(rect.left, accRect.left);\n return accRect;\n }, getClientRectFromMixedType(element, firstClippingParent, strategy));\n clippingRect.width = clippingRect.right - clippingRect.left;\n clippingRect.height = clippingRect.bottom - clippingRect.top;\n clippingRect.x = clippingRect.left;\n clippingRect.y = clippingRect.top;\n return clippingRect;\n}","import getBasePlacement from \"./getBasePlacement.js\";\nimport getVariation from \"./getVariation.js\";\nimport getMainAxisFromPlacement from \"./getMainAxisFromPlacement.js\";\nimport { top, right, bottom, left, start, end } from \"../enums.js\";\nexport default function computeOffsets(_ref) {\n var reference = _ref.reference,\n element = _ref.element,\n placement = _ref.placement;\n var basePlacement = placement ? getBasePlacement(placement) : null;\n var variation = placement ? getVariation(placement) : null;\n var commonX = reference.x + reference.width / 2 - element.width / 2;\n var commonY = reference.y + reference.height / 2 - element.height / 2;\n var offsets;\n\n switch (basePlacement) {\n case top:\n offsets = {\n x: commonX,\n y: reference.y - element.height\n };\n break;\n\n case bottom:\n offsets = {\n x: commonX,\n y: reference.y + reference.height\n };\n break;\n\n case right:\n offsets = {\n x: reference.x + reference.width,\n y: commonY\n };\n break;\n\n case left:\n offsets = {\n x: reference.x - element.width,\n y: commonY\n };\n break;\n\n default:\n offsets = {\n x: reference.x,\n y: reference.y\n };\n }\n\n var mainAxis = basePlacement ? getMainAxisFromPlacement(basePlacement) : null;\n\n if (mainAxis != null) {\n var len = mainAxis === 'y' ? 'height' : 'width';\n\n switch (variation) {\n case start:\n offsets[mainAxis] = offsets[mainAxis] - (reference[len] / 2 - element[len] / 2);\n break;\n\n case end:\n offsets[mainAxis] = offsets[mainAxis] + (reference[len] / 2 - element[len] / 2);\n break;\n\n default:\n }\n }\n\n return offsets;\n}","import getClippingRect from \"../dom-utils/getClippingRect.js\";\nimport getDocumentElement from \"../dom-utils/getDocumentElement.js\";\nimport getBoundingClientRect from \"../dom-utils/getBoundingClientRect.js\";\nimport computeOffsets from \"./computeOffsets.js\";\nimport rectToClientRect from \"./rectToClientRect.js\";\nimport { clippingParents, reference, popper, bottom, top, right, basePlacements, viewport } from \"../enums.js\";\nimport { isElement } from \"../dom-utils/instanceOf.js\";\nimport mergePaddingObject from \"./mergePaddingObject.js\";\nimport expandToHashMap from \"./expandToHashMap.js\"; // eslint-disable-next-line import/no-unused-modules\n\nexport default function detectOverflow(state, options) {\n if (options === void 0) {\n options = {};\n }\n\n var _options = options,\n _options$placement = _options.placement,\n placement = _options$placement === void 0 ? state.placement : _options$placement,\n _options$strategy = _options.strategy,\n strategy = _options$strategy === void 0 ? state.strategy : _options$strategy,\n _options$boundary = _options.boundary,\n boundary = _options$boundary === void 0 ? clippingParents : _options$boundary,\n _options$rootBoundary = _options.rootBoundary,\n rootBoundary = _options$rootBoundary === void 0 ? viewport : _options$rootBoundary,\n _options$elementConte = _options.elementContext,\n elementContext = _options$elementConte === void 0 ? popper : _options$elementConte,\n _options$altBoundary = _options.altBoundary,\n altBoundary = _options$altBoundary === void 0 ? false : _options$altBoundary,\n _options$padding = _options.padding,\n padding = _options$padding === void 0 ? 0 : _options$padding;\n var paddingObject = mergePaddingObject(typeof padding !== 'number' ? padding : expandToHashMap(padding, basePlacements));\n var altContext = elementContext === popper ? reference : popper;\n var popperRect = state.rects.popper;\n var element = state.elements[altBoundary ? altContext : elementContext];\n var clippingClientRect = getClippingRect(isElement(element) ? element : element.contextElement || getDocumentElement(state.elements.popper), boundary, rootBoundary, strategy);\n var referenceClientRect = getBoundingClientRect(state.elements.reference);\n var popperOffsets = computeOffsets({\n reference: referenceClientRect,\n element: popperRect,\n strategy: 'absolute',\n placement: placement\n });\n var popperClientRect = rectToClientRect(Object.assign({}, popperRect, popperOffsets));\n var elementClientRect = elementContext === popper ? popperClientRect : referenceClientRect; // positive = overflowing the clipping rect\n // 0 or negative = within the clipping rect\n\n var overflowOffsets = {\n top: clippingClientRect.top - elementClientRect.top + paddingObject.top,\n bottom: elementClientRect.bottom - clippingClientRect.bottom + paddingObject.bottom,\n left: clippingClientRect.left - elementClientRect.left + paddingObject.left,\n right: elementClientRect.right - clippingClientRect.right + paddingObject.right\n };\n var offsetData = state.modifiersData.offset; // Offsets can be applied only to the popper element\n\n if (elementContext === popper && offsetData) {\n var offset = offsetData[placement];\n Object.keys(overflowOffsets).forEach(function (key) {\n var multiply = [right, bottom].indexOf(key) >= 0 ? 1 : -1;\n var axis = [top, bottom].indexOf(key) >= 0 ? 'y' : 'x';\n overflowOffsets[key] += offset[axis] * multiply;\n });\n }\n\n return overflowOffsets;\n}","import getVariation from \"./getVariation.js\";\nimport { variationPlacements, basePlacements, placements as allPlacements } from \"../enums.js\";\nimport detectOverflow from \"./detectOverflow.js\";\nimport getBasePlacement from \"./getBasePlacement.js\";\nexport default function computeAutoPlacement(state, options) {\n if (options === void 0) {\n options = {};\n }\n\n var _options = options,\n placement = _options.placement,\n boundary = _options.boundary,\n rootBoundary = _options.rootBoundary,\n padding = _options.padding,\n flipVariations = _options.flipVariations,\n _options$allowedAutoP = _options.allowedAutoPlacements,\n allowedAutoPlacements = _options$allowedAutoP === void 0 ? allPlacements : _options$allowedAutoP;\n var variation = getVariation(placement);\n var placements = variation ? flipVariations ? variationPlacements : variationPlacements.filter(function (placement) {\n return getVariation(placement) === variation;\n }) : basePlacements;\n var allowedPlacements = placements.filter(function (placement) {\n return allowedAutoPlacements.indexOf(placement) >= 0;\n });\n\n if (allowedPlacements.length === 0) {\n allowedPlacements = placements;\n } // $FlowFixMe[incompatible-type]: Flow seems to have problems with two array unions...\n\n\n var overflows = allowedPlacements.reduce(function (acc, placement) {\n acc[placement] = detectOverflow(state, {\n placement: placement,\n boundary: boundary,\n rootBoundary: rootBoundary,\n padding: padding\n })[getBasePlacement(placement)];\n return acc;\n }, {});\n return Object.keys(overflows).sort(function (a, b) {\n return overflows[a] - overflows[b];\n });\n}","import getOppositePlacement from \"../utils/getOppositePlacement.js\";\nimport getBasePlacement from \"../utils/getBasePlacement.js\";\nimport getOppositeVariationPlacement from \"../utils/getOppositeVariationPlacement.js\";\nimport detectOverflow from \"../utils/detectOverflow.js\";\nimport computeAutoPlacement from \"../utils/computeAutoPlacement.js\";\nimport { bottom, top, start, right, left, auto } from \"../enums.js\";\nimport getVariation from \"../utils/getVariation.js\"; // eslint-disable-next-line import/no-unused-modules\n\nfunction getExpandedFallbackPlacements(placement) {\n if (getBasePlacement(placement) === auto) {\n return [];\n }\n\n var oppositePlacement = getOppositePlacement(placement);\n return [getOppositeVariationPlacement(placement), oppositePlacement, getOppositeVariationPlacement(oppositePlacement)];\n}\n\nfunction flip(_ref) {\n var state = _ref.state,\n options = _ref.options,\n name = _ref.name;\n\n if (state.modifiersData[name]._skip) {\n return;\n }\n\n var _options$mainAxis = options.mainAxis,\n checkMainAxis = _options$mainAxis === void 0 ? true : _options$mainAxis,\n _options$altAxis = options.altAxis,\n checkAltAxis = _options$altAxis === void 0 ? true : _options$altAxis,\n specifiedFallbackPlacements = options.fallbackPlacements,\n padding = options.padding,\n boundary = options.boundary,\n rootBoundary = options.rootBoundary,\n altBoundary = options.altBoundary,\n _options$flipVariatio = options.flipVariations,\n flipVariations = _options$flipVariatio === void 0 ? true : _options$flipVariatio,\n allowedAutoPlacements = options.allowedAutoPlacements;\n var preferredPlacement = state.options.placement;\n var basePlacement = getBasePlacement(preferredPlacement);\n var isBasePlacement = basePlacement === preferredPlacement;\n var fallbackPlacements = specifiedFallbackPlacements || (isBasePlacement || !flipVariations ? [getOppositePlacement(preferredPlacement)] : getExpandedFallbackPlacements(preferredPlacement));\n var placements = [preferredPlacement].concat(fallbackPlacements).reduce(function (acc, placement) {\n return acc.concat(getBasePlacement(placement) === auto ? computeAutoPlacement(state, {\n placement: placement,\n boundary: boundary,\n rootBoundary: rootBoundary,\n padding: padding,\n flipVariations: flipVariations,\n allowedAutoPlacements: allowedAutoPlacements\n }) : placement);\n }, []);\n var referenceRect = state.rects.reference;\n var popperRect = state.rects.popper;\n var checksMap = new Map();\n var makeFallbackChecks = true;\n var firstFittingPlacement = placements[0];\n\n for (var i = 0; i < placements.length; i++) {\n var placement = placements[i];\n\n var _basePlacement = getBasePlacement(placement);\n\n var isStartVariation = getVariation(placement) === start;\n var isVertical = [top, bottom].indexOf(_basePlacement) >= 0;\n var len = isVertical ? 'width' : 'height';\n var overflow = detectOverflow(state, {\n placement: placement,\n boundary: boundary,\n rootBoundary: rootBoundary,\n altBoundary: altBoundary,\n padding: padding\n });\n var mainVariationSide = isVertical ? isStartVariation ? right : left : isStartVariation ? bottom : top;\n\n if (referenceRect[len] > popperRect[len]) {\n mainVariationSide = getOppositePlacement(mainVariationSide);\n }\n\n var altVariationSide = getOppositePlacement(mainVariationSide);\n var checks = [];\n\n if (checkMainAxis) {\n checks.push(overflow[_basePlacement] <= 0);\n }\n\n if (checkAltAxis) {\n checks.push(overflow[mainVariationSide] <= 0, overflow[altVariationSide] <= 0);\n }\n\n if (checks.every(function (check) {\n return check;\n })) {\n firstFittingPlacement = placement;\n makeFallbackChecks = false;\n break;\n }\n\n checksMap.set(placement, checks);\n }\n\n if (makeFallbackChecks) {\n // `2` may be desired in some cases – research later\n var numberOfChecks = flipVariations ? 3 : 1;\n\n var _loop = function _loop(_i) {\n var fittingPlacement = placements.find(function (placement) {\n var checks = checksMap.get(placement);\n\n if (checks) {\n return checks.slice(0, _i).every(function (check) {\n return check;\n });\n }\n });\n\n if (fittingPlacement) {\n firstFittingPlacement = fittingPlacement;\n return \"break\";\n }\n };\n\n for (var _i = numberOfChecks; _i > 0; _i--) {\n var _ret = _loop(_i);\n\n if (_ret === \"break\") break;\n }\n }\n\n if (state.placement !== firstFittingPlacement) {\n state.modifiersData[name]._skip = true;\n state.placement = firstFittingPlacement;\n state.reset = true;\n }\n} // eslint-disable-next-line import/no-unused-modules\n\n\nexport default {\n name: 'flip',\n enabled: true,\n phase: 'main',\n fn: flip,\n requiresIfExists: ['offset'],\n data: {\n _skip: false\n }\n};","import { top, bottom, left, right } from \"../enums.js\";\nimport detectOverflow from \"../utils/detectOverflow.js\";\n\nfunction getSideOffsets(overflow, rect, preventedOffsets) {\n if (preventedOffsets === void 0) {\n preventedOffsets = {\n x: 0,\n y: 0\n };\n }\n\n return {\n top: overflow.top - rect.height - preventedOffsets.y,\n right: overflow.right - rect.width + preventedOffsets.x,\n bottom: overflow.bottom - rect.height + preventedOffsets.y,\n left: overflow.left - rect.width - preventedOffsets.x\n };\n}\n\nfunction isAnySideFullyClipped(overflow) {\n return [top, right, bottom, left].some(function (side) {\n return overflow[side] >= 0;\n });\n}\n\nfunction hide(_ref) {\n var state = _ref.state,\n name = _ref.name;\n var referenceRect = state.rects.reference;\n var popperRect = state.rects.popper;\n var preventedOffsets = state.modifiersData.preventOverflow;\n var referenceOverflow = detectOverflow(state, {\n elementContext: 'reference'\n });\n var popperAltOverflow = detectOverflow(state, {\n altBoundary: true\n });\n var referenceClippingOffsets = getSideOffsets(referenceOverflow, referenceRect);\n var popperEscapeOffsets = getSideOffsets(popperAltOverflow, popperRect, preventedOffsets);\n var isReferenceHidden = isAnySideFullyClipped(referenceClippingOffsets);\n var hasPopperEscaped = isAnySideFullyClipped(popperEscapeOffsets);\n state.modifiersData[name] = {\n referenceClippingOffsets: referenceClippingOffsets,\n popperEscapeOffsets: popperEscapeOffsets,\n isReferenceHidden: isReferenceHidden,\n hasPopperEscaped: hasPopperEscaped\n };\n state.attributes.popper = Object.assign({}, state.attributes.popper, {\n 'data-popper-reference-hidden': isReferenceHidden,\n 'data-popper-escaped': hasPopperEscaped\n });\n} // eslint-disable-next-line import/no-unused-modules\n\n\nexport default {\n name: 'hide',\n enabled: true,\n phase: 'main',\n requiresIfExists: ['preventOverflow'],\n fn: hide\n};","import getBasePlacement from \"../utils/getBasePlacement.js\";\nimport { top, left, right, placements } from \"../enums.js\"; // eslint-disable-next-line import/no-unused-modules\n\nexport function distanceAndSkiddingToXY(placement, rects, offset) {\n var basePlacement = getBasePlacement(placement);\n var invertDistance = [left, top].indexOf(basePlacement) >= 0 ? -1 : 1;\n\n var _ref = typeof offset === 'function' ? offset(Object.assign({}, rects, {\n placement: placement\n })) : offset,\n skidding = _ref[0],\n distance = _ref[1];\n\n skidding = skidding || 0;\n distance = (distance || 0) * invertDistance;\n return [left, right].indexOf(basePlacement) >= 0 ? {\n x: distance,\n y: skidding\n } : {\n x: skidding,\n y: distance\n };\n}\n\nfunction offset(_ref2) {\n var state = _ref2.state,\n options = _ref2.options,\n name = _ref2.name;\n var _options$offset = options.offset,\n offset = _options$offset === void 0 ? [0, 0] : _options$offset;\n var data = placements.reduce(function (acc, placement) {\n acc[placement] = distanceAndSkiddingToXY(placement, state.rects, offset);\n return acc;\n }, {});\n var _data$state$placement = data[state.placement],\n x = _data$state$placement.x,\n y = _data$state$placement.y;\n\n if (state.modifiersData.popperOffsets != null) {\n state.modifiersData.popperOffsets.x += x;\n state.modifiersData.popperOffsets.y += y;\n }\n\n state.modifiersData[name] = data;\n} // eslint-disable-next-line import/no-unused-modules\n\n\nexport default {\n name: 'offset',\n enabled: true,\n phase: 'main',\n requires: ['popperOffsets'],\n fn: offset\n};","import computeOffsets from \"../utils/computeOffsets.js\";\n\nfunction popperOffsets(_ref) {\n var state = _ref.state,\n name = _ref.name;\n // Offsets are the actual position the popper needs to have to be\n // properly positioned near its reference element\n // This is the most basic placement, and will be adjusted by\n // the modifiers in the next step\n state.modifiersData[name] = computeOffsets({\n reference: state.rects.reference,\n element: state.rects.popper,\n strategy: 'absolute',\n placement: state.placement\n });\n} // eslint-disable-next-line import/no-unused-modules\n\n\nexport default {\n name: 'popperOffsets',\n enabled: true,\n phase: 'read',\n fn: popperOffsets,\n data: {}\n};","export default function getAltAxis(axis) {\n return axis === 'x' ? 'y' : 'x';\n}","import { top, left, right, bottom, start } from \"../enums.js\";\nimport getBasePlacement from \"../utils/getBasePlacement.js\";\nimport getMainAxisFromPlacement from \"../utils/getMainAxisFromPlacement.js\";\nimport getAltAxis from \"../utils/getAltAxis.js\";\nimport { within, withinMaxClamp } from \"../utils/within.js\";\nimport getLayoutRect from \"../dom-utils/getLayoutRect.js\";\nimport getOffsetParent from \"../dom-utils/getOffsetParent.js\";\nimport detectOverflow from \"../utils/detectOverflow.js\";\nimport getVariation from \"../utils/getVariation.js\";\nimport getFreshSideObject from \"../utils/getFreshSideObject.js\";\nimport { min as mathMin, max as mathMax } from \"../utils/math.js\";\n\nfunction preventOverflow(_ref) {\n var state = _ref.state,\n options = _ref.options,\n name = _ref.name;\n var _options$mainAxis = options.mainAxis,\n checkMainAxis = _options$mainAxis === void 0 ? true : _options$mainAxis,\n _options$altAxis = options.altAxis,\n checkAltAxis = _options$altAxis === void 0 ? false : _options$altAxis,\n boundary = options.boundary,\n rootBoundary = options.rootBoundary,\n altBoundary = options.altBoundary,\n padding = options.padding,\n _options$tether = options.tether,\n tether = _options$tether === void 0 ? true : _options$tether,\n _options$tetherOffset = options.tetherOffset,\n tetherOffset = _options$tetherOffset === void 0 ? 0 : _options$tetherOffset;\n var overflow = detectOverflow(state, {\n boundary: boundary,\n rootBoundary: rootBoundary,\n padding: padding,\n altBoundary: altBoundary\n });\n var basePlacement = getBasePlacement(state.placement);\n var variation = getVariation(state.placement);\n var isBasePlacement = !variation;\n var mainAxis = getMainAxisFromPlacement(basePlacement);\n var altAxis = getAltAxis(mainAxis);\n var popperOffsets = state.modifiersData.popperOffsets;\n var referenceRect = state.rects.reference;\n var popperRect = state.rects.popper;\n var tetherOffsetValue = typeof tetherOffset === 'function' ? tetherOffset(Object.assign({}, state.rects, {\n placement: state.placement\n })) : tetherOffset;\n var normalizedTetherOffsetValue = typeof tetherOffsetValue === 'number' ? {\n mainAxis: tetherOffsetValue,\n altAxis: tetherOffsetValue\n } : Object.assign({\n mainAxis: 0,\n altAxis: 0\n }, tetherOffsetValue);\n var offsetModifierState = state.modifiersData.offset ? state.modifiersData.offset[state.placement] : null;\n var data = {\n x: 0,\n y: 0\n };\n\n if (!popperOffsets) {\n return;\n }\n\n if (checkMainAxis) {\n var _offsetModifierState$;\n\n var mainSide = mainAxis === 'y' ? top : left;\n var altSide = mainAxis === 'y' ? bottom : right;\n var len = mainAxis === 'y' ? 'height' : 'width';\n var offset = popperOffsets[mainAxis];\n var min = offset + overflow[mainSide];\n var max = offset - overflow[altSide];\n var additive = tether ? -popperRect[len] / 2 : 0;\n var minLen = variation === start ? referenceRect[len] : popperRect[len];\n var maxLen = variation === start ? -popperRect[len] : -referenceRect[len]; // We need to include the arrow in the calculation so the arrow doesn't go\n // outside the reference bounds\n\n var arrowElement = state.elements.arrow;\n var arrowRect = tether && arrowElement ? getLayoutRect(arrowElement) : {\n width: 0,\n height: 0\n };\n var arrowPaddingObject = state.modifiersData['arrow#persistent'] ? state.modifiersData['arrow#persistent'].padding : getFreshSideObject();\n var arrowPaddingMin = arrowPaddingObject[mainSide];\n var arrowPaddingMax = arrowPaddingObject[altSide]; // If the reference length is smaller than the arrow length, we don't want\n // to include its full size in the calculation. If the reference is small\n // and near the edge of a boundary, the popper can overflow even if the\n // reference is not overflowing as well (e.g. virtual elements with no\n // width or height)\n\n var arrowLen = within(0, referenceRect[len], arrowRect[len]);\n var minOffset = isBasePlacement ? referenceRect[len] / 2 - additive - arrowLen - arrowPaddingMin - normalizedTetherOffsetValue.mainAxis : minLen - arrowLen - arrowPaddingMin - normalizedTetherOffsetValue.mainAxis;\n var maxOffset = isBasePlacement ? -referenceRect[len] / 2 + additive + arrowLen + arrowPaddingMax + normalizedTetherOffsetValue.mainAxis : maxLen + arrowLen + arrowPaddingMax + normalizedTetherOffsetValue.mainAxis;\n var arrowOffsetParent = state.elements.arrow && getOffsetParent(state.elements.arrow);\n var clientOffset = arrowOffsetParent ? mainAxis === 'y' ? arrowOffsetParent.clientTop || 0 : arrowOffsetParent.clientLeft || 0 : 0;\n var offsetModifierValue = (_offsetModifierState$ = offsetModifierState == null ? void 0 : offsetModifierState[mainAxis]) != null ? _offsetModifierState$ : 0;\n var tetherMin = offset + minOffset - offsetModifierValue - clientOffset;\n var tetherMax = offset + maxOffset - offsetModifierValue;\n var preventedOffset = within(tether ? mathMin(min, tetherMin) : min, offset, tether ? mathMax(max, tetherMax) : max);\n popperOffsets[mainAxis] = preventedOffset;\n data[mainAxis] = preventedOffset - offset;\n }\n\n if (checkAltAxis) {\n var _offsetModifierState$2;\n\n var _mainSide = mainAxis === 'x' ? top : left;\n\n var _altSide = mainAxis === 'x' ? bottom : right;\n\n var _offset = popperOffsets[altAxis];\n\n var _len = altAxis === 'y' ? 'height' : 'width';\n\n var _min = _offset + overflow[_mainSide];\n\n var _max = _offset - overflow[_altSide];\n\n var isOriginSide = [top, left].indexOf(basePlacement) !== -1;\n\n var _offsetModifierValue = (_offsetModifierState$2 = offsetModifierState == null ? void 0 : offsetModifierState[altAxis]) != null ? _offsetModifierState$2 : 0;\n\n var _tetherMin = isOriginSide ? _min : _offset - referenceRect[_len] - popperRect[_len] - _offsetModifierValue + normalizedTetherOffsetValue.altAxis;\n\n var _tetherMax = isOriginSide ? _offset + referenceRect[_len] + popperRect[_len] - _offsetModifierValue - normalizedTetherOffsetValue.altAxis : _max;\n\n var _preventedOffset = tether && isOriginSide ? withinMaxClamp(_tetherMin, _offset, _tetherMax) : within(tether ? _tetherMin : _min, _offset, tether ? _tetherMax : _max);\n\n popperOffsets[altAxis] = _preventedOffset;\n data[altAxis] = _preventedOffset - _offset;\n }\n\n state.modifiersData[name] = data;\n} // eslint-disable-next-line import/no-unused-modules\n\n\nexport default {\n name: 'preventOverflow',\n enabled: true,\n phase: 'main',\n fn: preventOverflow,\n requiresIfExists: ['offset']\n};","export default function getHTMLElementScroll(element) {\n return {\n scrollLeft: element.scrollLeft,\n scrollTop: element.scrollTop\n };\n}","import getWindowScroll from \"./getWindowScroll.js\";\nimport getWindow from \"./getWindow.js\";\nimport { isHTMLElement } from \"./instanceOf.js\";\nimport getHTMLElementScroll from \"./getHTMLElementScroll.js\";\nexport default function getNodeScroll(node) {\n if (node === getWindow(node) || !isHTMLElement(node)) {\n return getWindowScroll(node);\n } else {\n return getHTMLElementScroll(node);\n }\n}","import getBoundingClientRect from \"./getBoundingClientRect.js\";\nimport getNodeScroll from \"./getNodeScroll.js\";\nimport getNodeName from \"./getNodeName.js\";\nimport { isHTMLElement } from \"./instanceOf.js\";\nimport getWindowScrollBarX from \"./getWindowScrollBarX.js\";\nimport getDocumentElement from \"./getDocumentElement.js\";\nimport isScrollParent from \"./isScrollParent.js\";\nimport { round } from \"../utils/math.js\";\n\nfunction isElementScaled(element) {\n var rect = element.getBoundingClientRect();\n var scaleX = round(rect.width) / element.offsetWidth || 1;\n var scaleY = round(rect.height) / element.offsetHeight || 1;\n return scaleX !== 1 || scaleY !== 1;\n} // Returns the composite rect of an element relative to its offsetParent.\n// Composite means it takes into account transforms as well as layout.\n\n\nexport default function getCompositeRect(elementOrVirtualElement, offsetParent, isFixed) {\n if (isFixed === void 0) {\n isFixed = false;\n }\n\n var isOffsetParentAnElement = isHTMLElement(offsetParent);\n var offsetParentIsScaled = isHTMLElement(offsetParent) && isElementScaled(offsetParent);\n var documentElement = getDocumentElement(offsetParent);\n var rect = getBoundingClientRect(elementOrVirtualElement, offsetParentIsScaled, isFixed);\n var scroll = {\n scrollLeft: 0,\n scrollTop: 0\n };\n var offsets = {\n x: 0,\n y: 0\n };\n\n if (isOffsetParentAnElement || !isOffsetParentAnElement && !isFixed) {\n if (getNodeName(offsetParent) !== 'body' || // https://github.com/popperjs/popper-core/issues/1078\n isScrollParent(documentElement)) {\n scroll = getNodeScroll(offsetParent);\n }\n\n if (isHTMLElement(offsetParent)) {\n offsets = getBoundingClientRect(offsetParent, true);\n offsets.x += offsetParent.clientLeft;\n offsets.y += offsetParent.clientTop;\n } else if (documentElement) {\n offsets.x = getWindowScrollBarX(documentElement);\n }\n }\n\n return {\n x: rect.left + scroll.scrollLeft - offsets.x,\n y: rect.top + scroll.scrollTop - offsets.y,\n width: rect.width,\n height: rect.height\n };\n}","import { modifierPhases } from \"../enums.js\"; // source: https://stackoverflow.com/questions/49875255\n\nfunction order(modifiers) {\n var map = new Map();\n var visited = new Set();\n var result = [];\n modifiers.forEach(function (modifier) {\n map.set(modifier.name, modifier);\n }); // On visiting object, check for its dependencies and visit them recursively\n\n function sort(modifier) {\n visited.add(modifier.name);\n var requires = [].concat(modifier.requires || [], modifier.requiresIfExists || []);\n requires.forEach(function (dep) {\n if (!visited.has(dep)) {\n var depModifier = map.get(dep);\n\n if (depModifier) {\n sort(depModifier);\n }\n }\n });\n result.push(modifier);\n }\n\n modifiers.forEach(function (modifier) {\n if (!visited.has(modifier.name)) {\n // check for visited object\n sort(modifier);\n }\n });\n return result;\n}\n\nexport default function orderModifiers(modifiers) {\n // order based on dependencies\n var orderedModifiers = order(modifiers); // order based on phase\n\n return modifierPhases.reduce(function (acc, phase) {\n return acc.concat(orderedModifiers.filter(function (modifier) {\n return modifier.phase === phase;\n }));\n }, []);\n}","export default function debounce(fn) {\n var pending;\n return function () {\n if (!pending) {\n pending = new Promise(function (resolve) {\n Promise.resolve().then(function () {\n pending = undefined;\n resolve(fn());\n });\n });\n }\n\n return pending;\n };\n}","export default function mergeByName(modifiers) {\n var merged = modifiers.reduce(function (merged, current) {\n var existing = merged[current.name];\n merged[current.name] = existing ? Object.assign({}, existing, current, {\n options: Object.assign({}, existing.options, current.options),\n data: Object.assign({}, existing.data, current.data)\n }) : current;\n return merged;\n }, {}); // IE11 does not support Object.values\n\n return Object.keys(merged).map(function (key) {\n return merged[key];\n });\n}","import getCompositeRect from \"./dom-utils/getCompositeRect.js\";\nimport getLayoutRect from \"./dom-utils/getLayoutRect.js\";\nimport listScrollParents from \"./dom-utils/listScrollParents.js\";\nimport getOffsetParent from \"./dom-utils/getOffsetParent.js\";\nimport orderModifiers from \"./utils/orderModifiers.js\";\nimport debounce from \"./utils/debounce.js\";\nimport mergeByName from \"./utils/mergeByName.js\";\nimport detectOverflow from \"./utils/detectOverflow.js\";\nimport { isElement } from \"./dom-utils/instanceOf.js\";\nvar DEFAULT_OPTIONS = {\n placement: 'bottom',\n modifiers: [],\n strategy: 'absolute'\n};\n\nfunction areValidElements() {\n for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {\n args[_key] = arguments[_key];\n }\n\n return !args.some(function (element) {\n return !(element && typeof element.getBoundingClientRect === 'function');\n });\n}\n\nexport function popperGenerator(generatorOptions) {\n if (generatorOptions === void 0) {\n generatorOptions = {};\n }\n\n var _generatorOptions = generatorOptions,\n _generatorOptions$def = _generatorOptions.defaultModifiers,\n defaultModifiers = _generatorOptions$def === void 0 ? [] : _generatorOptions$def,\n _generatorOptions$def2 = _generatorOptions.defaultOptions,\n defaultOptions = _generatorOptions$def2 === void 0 ? DEFAULT_OPTIONS : _generatorOptions$def2;\n return function createPopper(reference, popper, options) {\n if (options === void 0) {\n options = defaultOptions;\n }\n\n var state = {\n placement: 'bottom',\n orderedModifiers: [],\n options: Object.assign({}, DEFAULT_OPTIONS, defaultOptions),\n modifiersData: {},\n elements: {\n reference: reference,\n popper: popper\n },\n attributes: {},\n styles: {}\n };\n var effectCleanupFns = [];\n var isDestroyed = false;\n var instance = {\n state: state,\n setOptions: function setOptions(setOptionsAction) {\n var options = typeof setOptionsAction === 'function' ? setOptionsAction(state.options) : setOptionsAction;\n cleanupModifierEffects();\n state.options = Object.assign({}, defaultOptions, state.options, options);\n state.scrollParents = {\n reference: isElement(reference) ? listScrollParents(reference) : reference.contextElement ? listScrollParents(reference.contextElement) : [],\n popper: listScrollParents(popper)\n }; // Orders the modifiers based on their dependencies and `phase`\n // properties\n\n var orderedModifiers = orderModifiers(mergeByName([].concat(defaultModifiers, state.options.modifiers))); // Strip out disabled modifiers\n\n state.orderedModifiers = orderedModifiers.filter(function (m) {\n return m.enabled;\n });\n runModifierEffects();\n return instance.update();\n },\n // Sync update – it will always be executed, even if not necessary. This\n // is useful for low frequency updates where sync behavior simplifies the\n // logic.\n // For high frequency updates (e.g. `resize` and `scroll` events), always\n // prefer the async Popper#update method\n forceUpdate: function forceUpdate() {\n if (isDestroyed) {\n return;\n }\n\n var _state$elements = state.elements,\n reference = _state$elements.reference,\n popper = _state$elements.popper; // Don't proceed if `reference` or `popper` are not valid elements\n // anymore\n\n if (!areValidElements(reference, popper)) {\n return;\n } // Store the reference and popper rects to be read by modifiers\n\n\n state.rects = {\n reference: getCompositeRect(reference, getOffsetParent(popper), state.options.strategy === 'fixed'),\n popper: getLayoutRect(popper)\n }; // Modifiers have the ability to reset the current update cycle. The\n // most common use case for this is the `flip` modifier changing the\n // placement, which then needs to re-run all the modifiers, because the\n // logic was previously ran for the previous placement and is therefore\n // stale/incorrect\n\n state.reset = false;\n state.placement = state.options.placement; // On each update cycle, the `modifiersData` property for each modifier\n // is filled with the initial data specified by the modifier. This means\n // it doesn't persist and is fresh on each update.\n // To ensure persistent data, use `${name}#persistent`\n\n state.orderedModifiers.forEach(function (modifier) {\n return state.modifiersData[modifier.name] = Object.assign({}, modifier.data);\n });\n\n for (var index = 0; index < state.orderedModifiers.length; index++) {\n if (state.reset === true) {\n state.reset = false;\n index = -1;\n continue;\n }\n\n var _state$orderedModifie = state.orderedModifiers[index],\n fn = _state$orderedModifie.fn,\n _state$orderedModifie2 = _state$orderedModifie.options,\n _options = _state$orderedModifie2 === void 0 ? {} : _state$orderedModifie2,\n name = _state$orderedModifie.name;\n\n if (typeof fn === 'function') {\n state = fn({\n state: state,\n options: _options,\n name: name,\n instance: instance\n }) || state;\n }\n }\n },\n // Async and optimistically optimized update – it will not be executed if\n // not necessary (debounced to run at most once-per-tick)\n update: debounce(function () {\n return new Promise(function (resolve) {\n instance.forceUpdate();\n resolve(state);\n });\n }),\n destroy: function destroy() {\n cleanupModifierEffects();\n isDestroyed = true;\n }\n };\n\n if (!areValidElements(reference, popper)) {\n return instance;\n }\n\n instance.setOptions(options).then(function (state) {\n if (!isDestroyed && options.onFirstUpdate) {\n options.onFirstUpdate(state);\n }\n }); // Modifiers have the ability to execute arbitrary code before the first\n // update cycle runs. They will be executed in the same order as the update\n // cycle. This is useful when a modifier adds some persistent data that\n // other modifiers need to use, but the modifier is run after the dependent\n // one.\n\n function runModifierEffects() {\n state.orderedModifiers.forEach(function (_ref) {\n var name = _ref.name,\n _ref$options = _ref.options,\n options = _ref$options === void 0 ? {} : _ref$options,\n effect = _ref.effect;\n\n if (typeof effect === 'function') {\n var cleanupFn = effect({\n state: state,\n name: name,\n instance: instance,\n options: options\n });\n\n var noopFn = function noopFn() {};\n\n effectCleanupFns.push(cleanupFn || noopFn);\n }\n });\n }\n\n function cleanupModifierEffects() {\n effectCleanupFns.forEach(function (fn) {\n return fn();\n });\n effectCleanupFns = [];\n }\n\n return instance;\n };\n}\nexport var createPopper = /*#__PURE__*/popperGenerator(); // eslint-disable-next-line import/no-unused-modules\n\nexport { detectOverflow };","import { popperGenerator, detectOverflow } from \"./createPopper.js\";\nimport eventListeners from \"./modifiers/eventListeners.js\";\nimport popperOffsets from \"./modifiers/popperOffsets.js\";\nimport computeStyles from \"./modifiers/computeStyles.js\";\nimport applyStyles from \"./modifiers/applyStyles.js\";\nvar defaultModifiers = [eventListeners, popperOffsets, computeStyles, applyStyles];\nvar createPopper = /*#__PURE__*/popperGenerator({\n defaultModifiers: defaultModifiers\n}); // eslint-disable-next-line import/no-unused-modules\n\nexport { createPopper, popperGenerator, defaultModifiers, detectOverflow };","import { popperGenerator, detectOverflow } from \"./createPopper.js\";\nimport eventListeners from \"./modifiers/eventListeners.js\";\nimport popperOffsets from \"./modifiers/popperOffsets.js\";\nimport computeStyles from \"./modifiers/computeStyles.js\";\nimport applyStyles from \"./modifiers/applyStyles.js\";\nimport offset from \"./modifiers/offset.js\";\nimport flip from \"./modifiers/flip.js\";\nimport preventOverflow from \"./modifiers/preventOverflow.js\";\nimport arrow from \"./modifiers/arrow.js\";\nimport hide from \"./modifiers/hide.js\";\nvar defaultModifiers = [eventListeners, popperOffsets, computeStyles, applyStyles, offset, flip, preventOverflow, arrow, hide];\nvar createPopper = /*#__PURE__*/popperGenerator({\n defaultModifiers: defaultModifiers\n}); // eslint-disable-next-line import/no-unused-modules\n\nexport { createPopper, popperGenerator, defaultModifiers, detectOverflow }; // eslint-disable-next-line import/no-unused-modules\n\nexport { createPopper as createPopperLite } from \"./popper-lite.js\"; // eslint-disable-next-line import/no-unused-modules\n\nexport * from \"./modifiers/index.js\";","/**\n * --------------------------------------------------------------------------\n * Bootstrap dropdown.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport * as Popper from '@popperjs/core'\nimport BaseComponent from './base-component.js'\nimport EventHandler from './dom/event-handler.js'\nimport Manipulator from './dom/manipulator.js'\nimport SelectorEngine from './dom/selector-engine.js'\nimport {\n defineJQueryPlugin,\n execute,\n getElement,\n getNextActiveElement,\n isDisabled,\n isElement,\n isRTL,\n isVisible,\n noop\n} from './util/index.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'dropdown'\nconst DATA_KEY = 'bs.dropdown'\nconst EVENT_KEY = `.${DATA_KEY}`\nconst DATA_API_KEY = '.data-api'\n\nconst ESCAPE_KEY = 'Escape'\nconst TAB_KEY = 'Tab'\nconst ARROW_UP_KEY = 'ArrowUp'\nconst ARROW_DOWN_KEY = 'ArrowDown'\nconst RIGHT_MOUSE_BUTTON = 2 // MouseEvent.button value for the secondary button, usually the right button\n\nconst EVENT_HIDE = `hide${EVENT_KEY}`\nconst EVENT_HIDDEN = `hidden${EVENT_KEY}`\nconst EVENT_SHOW = `show${EVENT_KEY}`\nconst EVENT_SHOWN = `shown${EVENT_KEY}`\nconst EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`\nconst EVENT_KEYDOWN_DATA_API = `keydown${EVENT_KEY}${DATA_API_KEY}`\nconst EVENT_KEYUP_DATA_API = `keyup${EVENT_KEY}${DATA_API_KEY}`\n\nconst CLASS_NAME_SHOW = 'show'\nconst CLASS_NAME_DROPUP = 'dropup'\nconst CLASS_NAME_DROPEND = 'dropend'\nconst CLASS_NAME_DROPSTART = 'dropstart'\nconst CLASS_NAME_DROPUP_CENTER = 'dropup-center'\nconst CLASS_NAME_DROPDOWN_CENTER = 'dropdown-center'\n\nconst SELECTOR_DATA_TOGGLE = '[data-bs-toggle=\"dropdown\"]:not(.disabled):not(:disabled)'\nconst SELECTOR_DATA_TOGGLE_SHOWN = `${SELECTOR_DATA_TOGGLE}.${CLASS_NAME_SHOW}`\nconst SELECTOR_MENU = '.dropdown-menu'\nconst SELECTOR_NAVBAR = '.navbar'\nconst SELECTOR_NAVBAR_NAV = '.navbar-nav'\nconst SELECTOR_VISIBLE_ITEMS = '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)'\n\nconst PLACEMENT_TOP = isRTL() ? 'top-end' : 'top-start'\nconst PLACEMENT_TOPEND = isRTL() ? 'top-start' : 'top-end'\nconst PLACEMENT_BOTTOM = isRTL() ? 'bottom-end' : 'bottom-start'\nconst PLACEMENT_BOTTOMEND = isRTL() ? 'bottom-start' : 'bottom-end'\nconst PLACEMENT_RIGHT = isRTL() ? 'left-start' : 'right-start'\nconst PLACEMENT_LEFT = isRTL() ? 'right-start' : 'left-start'\nconst PLACEMENT_TOPCENTER = 'top'\nconst PLACEMENT_BOTTOMCENTER = 'bottom'\n\nconst Default = {\n autoClose: true,\n boundary: 'clippingParents',\n display: 'dynamic',\n offset: [0, 2],\n popperConfig: null,\n reference: 'toggle'\n}\n\nconst DefaultType = {\n autoClose: '(boolean|string)',\n boundary: '(string|element)',\n display: 'string',\n offset: '(array|string|function)',\n popperConfig: '(null|object|function)',\n reference: '(string|element|object)'\n}\n\n/**\n * Class definition\n */\n\nclass Dropdown extends BaseComponent {\n constructor(element, config) {\n super(element, config)\n\n this._popper = null\n this._parent = this._element.parentNode // dropdown wrapper\n // TODO: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.3/forms/input-group/\n this._menu = SelectorEngine.next(this._element, SELECTOR_MENU)[0] ||\n SelectorEngine.prev(this._element, SELECTOR_MENU)[0] ||\n SelectorEngine.findOne(SELECTOR_MENU, this._parent)\n this._inNavbar = this._detectNavbar()\n }\n\n // Getters\n static get Default() {\n return Default\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n static get NAME() {\n return NAME\n }\n\n // Public\n toggle() {\n return this._isShown() ? this.hide() : this.show()\n }\n\n show() {\n if (isDisabled(this._element) || this._isShown()) {\n return\n }\n\n const relatedTarget = {\n relatedTarget: this._element\n }\n\n const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, relatedTarget)\n\n if (showEvent.defaultPrevented) {\n return\n }\n\n this._createPopper()\n\n // If this is a touch-enabled device we add extra\n // empty mouseover listeners to the body's immediate children;\n // only needed because of broken event delegation on iOS\n // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html\n if ('ontouchstart' in document.documentElement && !this._parent.closest(SELECTOR_NAVBAR_NAV)) {\n for (const element of [].concat(...document.body.children)) {\n EventHandler.on(element, 'mouseover', noop)\n }\n }\n\n this._element.focus()\n this._element.setAttribute('aria-expanded', true)\n\n this._menu.classList.add(CLASS_NAME_SHOW)\n this._element.classList.add(CLASS_NAME_SHOW)\n EventHandler.trigger(this._element, EVENT_SHOWN, relatedTarget)\n }\n\n hide() {\n if (isDisabled(this._element) || !this._isShown()) {\n return\n }\n\n const relatedTarget = {\n relatedTarget: this._element\n }\n\n this._completeHide(relatedTarget)\n }\n\n dispose() {\n if (this._popper) {\n this._popper.destroy()\n }\n\n super.dispose()\n }\n\n update() {\n this._inNavbar = this._detectNavbar()\n if (this._popper) {\n this._popper.update()\n }\n }\n\n // Private\n _completeHide(relatedTarget) {\n const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE, relatedTarget)\n if (hideEvent.defaultPrevented) {\n return\n }\n\n // If this is a touch-enabled device we remove the extra\n // empty mouseover listeners we added for iOS support\n if ('ontouchstart' in document.documentElement) {\n for (const element of [].concat(...document.body.children)) {\n EventHandler.off(element, 'mouseover', noop)\n }\n }\n\n if (this._popper) {\n this._popper.destroy()\n }\n\n this._menu.classList.remove(CLASS_NAME_SHOW)\n this._element.classList.remove(CLASS_NAME_SHOW)\n this._element.setAttribute('aria-expanded', 'false')\n Manipulator.removeDataAttribute(this._menu, 'popper')\n EventHandler.trigger(this._element, EVENT_HIDDEN, relatedTarget)\n }\n\n _getConfig(config) {\n config = super._getConfig(config)\n\n if (typeof config.reference === 'object' && !isElement(config.reference) &&\n typeof config.reference.getBoundingClientRect !== 'function'\n ) {\n // Popper virtual elements require a getBoundingClientRect method\n throw new TypeError(`${NAME.toUpperCase()}: Option \"reference\" provided type \"object\" without a required \"getBoundingClientRect\" method.`)\n }\n\n return config\n }\n\n _createPopper() {\n if (typeof Popper === 'undefined') {\n throw new TypeError('Bootstrap\\'s dropdowns require Popper (https://popper.js.org)')\n }\n\n let referenceElement = this._element\n\n if (this._config.reference === 'parent') {\n referenceElement = this._parent\n } else if (isElement(this._config.reference)) {\n referenceElement = getElement(this._config.reference)\n } else if (typeof this._config.reference === 'object') {\n referenceElement = this._config.reference\n }\n\n const popperConfig = this._getPopperConfig()\n this._popper = Popper.createPopper(referenceElement, this._menu, popperConfig)\n }\n\n _isShown() {\n return this._menu.classList.contains(CLASS_NAME_SHOW)\n }\n\n _getPlacement() {\n const parentDropdown = this._parent\n\n if (parentDropdown.classList.contains(CLASS_NAME_DROPEND)) {\n return PLACEMENT_RIGHT\n }\n\n if (parentDropdown.classList.contains(CLASS_NAME_DROPSTART)) {\n return PLACEMENT_LEFT\n }\n\n if (parentDropdown.classList.contains(CLASS_NAME_DROPUP_CENTER)) {\n return PLACEMENT_TOPCENTER\n }\n\n if (parentDropdown.classList.contains(CLASS_NAME_DROPDOWN_CENTER)) {\n return PLACEMENT_BOTTOMCENTER\n }\n\n // We need to trim the value because custom properties can also include spaces\n const isEnd = getComputedStyle(this._menu).getPropertyValue('--bs-position').trim() === 'end'\n\n if (parentDropdown.classList.contains(CLASS_NAME_DROPUP)) {\n return isEnd ? PLACEMENT_TOPEND : PLACEMENT_TOP\n }\n\n return isEnd ? PLACEMENT_BOTTOMEND : PLACEMENT_BOTTOM\n }\n\n _detectNavbar() {\n return this._element.closest(SELECTOR_NAVBAR) !== null\n }\n\n _getOffset() {\n const { offset } = this._config\n\n if (typeof offset === 'string') {\n return offset.split(',').map(value => Number.parseInt(value, 10))\n }\n\n if (typeof offset === 'function') {\n return popperData => offset(popperData, this._element)\n }\n\n return offset\n }\n\n _getPopperConfig() {\n const defaultBsPopperConfig = {\n placement: this._getPlacement(),\n modifiers: [{\n name: 'preventOverflow',\n options: {\n boundary: this._config.boundary\n }\n },\n {\n name: 'offset',\n options: {\n offset: this._getOffset()\n }\n }]\n }\n\n // Disable Popper if we have a static display or Dropdown is in Navbar\n if (this._inNavbar || this._config.display === 'static') {\n Manipulator.setDataAttribute(this._menu, 'popper', 'static') // TODO: v6 remove\n defaultBsPopperConfig.modifiers = [{\n name: 'applyStyles',\n enabled: false\n }]\n }\n\n return {\n ...defaultBsPopperConfig,\n ...execute(this._config.popperConfig, [defaultBsPopperConfig])\n }\n }\n\n _selectMenuItem({ key, target }) {\n const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(element => isVisible(element))\n\n if (!items.length) {\n return\n }\n\n // if target isn't included in items (e.g. when expanding the dropdown)\n // allow cycling to get the last item in case key equals ARROW_UP_KEY\n getNextActiveElement(items, target, key === ARROW_DOWN_KEY, !items.includes(target)).focus()\n }\n\n // Static\n static jQueryInterface(config) {\n return this.each(function () {\n const data = Dropdown.getOrCreateInstance(this, config)\n\n if (typeof config !== 'string') {\n return\n }\n\n if (typeof data[config] === 'undefined') {\n throw new TypeError(`No method named \"${config}\"`)\n }\n\n data[config]()\n })\n }\n\n static clearMenus(event) {\n if (event.button === RIGHT_MOUSE_BUTTON || (event.type === 'keyup' && event.key !== TAB_KEY)) {\n return\n }\n\n const openToggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE_SHOWN)\n\n for (const toggle of openToggles) {\n const context = Dropdown.getInstance(toggle)\n if (!context || context._config.autoClose === false) {\n continue\n }\n\n const composedPath = event.composedPath()\n const isMenuTarget = composedPath.includes(context._menu)\n if (\n composedPath.includes(context._element) ||\n (context._config.autoClose === 'inside' && !isMenuTarget) ||\n (context._config.autoClose === 'outside' && isMenuTarget)\n ) {\n continue\n }\n\n // Tab navigation through the dropdown menu or events from contained inputs shouldn't close the menu\n if (context._menu.contains(event.target) && ((event.type === 'keyup' && event.key === TAB_KEY) || /input|select|option|textarea|form/i.test(event.target.tagName))) {\n continue\n }\n\n const relatedTarget = { relatedTarget: context._element }\n\n if (event.type === 'click') {\n relatedTarget.clickEvent = event\n }\n\n context._completeHide(relatedTarget)\n }\n }\n\n static dataApiKeydownHandler(event) {\n // If not an UP | DOWN | ESCAPE key => not a dropdown command\n // If input/textarea && if key is other than ESCAPE => not a dropdown command\n\n const isInput = /input|textarea/i.test(event.target.tagName)\n const isEscapeEvent = event.key === ESCAPE_KEY\n const isUpOrDownEvent = [ARROW_UP_KEY, ARROW_DOWN_KEY].includes(event.key)\n\n if (!isUpOrDownEvent && !isEscapeEvent) {\n return\n }\n\n if (isInput && !isEscapeEvent) {\n return\n }\n\n event.preventDefault()\n\n // TODO: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.3/forms/input-group/\n const getToggleButton = this.matches(SELECTOR_DATA_TOGGLE) ?\n this :\n (SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] ||\n SelectorEngine.next(this, SELECTOR_DATA_TOGGLE)[0] ||\n SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, event.delegateTarget.parentNode))\n\n const instance = Dropdown.getOrCreateInstance(getToggleButton)\n\n if (isUpOrDownEvent) {\n event.stopPropagation()\n instance.show()\n instance._selectMenuItem(event)\n return\n }\n\n if (instance._isShown()) { // else is escape and we check if it is shown\n event.stopPropagation()\n instance.hide()\n getToggleButton.focus()\n }\n }\n}\n\n/**\n * Data API implementation\n */\n\nEventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_DATA_TOGGLE, Dropdown.dataApiKeydownHandler)\nEventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_MENU, Dropdown.dataApiKeydownHandler)\nEventHandler.on(document, EVENT_CLICK_DATA_API, Dropdown.clearMenus)\nEventHandler.on(document, EVENT_KEYUP_DATA_API, Dropdown.clearMenus)\nEventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {\n event.preventDefault()\n Dropdown.getOrCreateInstance(this).toggle()\n})\n\n/**\n * jQuery\n */\n\ndefineJQueryPlugin(Dropdown)\n\nexport default Dropdown\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap util/backdrop.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport EventHandler from '../dom/event-handler.js'\nimport Config from './config.js'\nimport {\n execute, executeAfterTransition, getElement, reflow\n} from './index.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'backdrop'\nconst CLASS_NAME_FADE = 'fade'\nconst CLASS_NAME_SHOW = 'show'\nconst EVENT_MOUSEDOWN = `mousedown.bs.${NAME}`\n\nconst Default = {\n className: 'modal-backdrop',\n clickCallback: null,\n isAnimated: false,\n isVisible: true, // if false, we use the backdrop helper without adding any element to the dom\n rootElement: 'body' // give the choice to place backdrop under different elements\n}\n\nconst DefaultType = {\n className: 'string',\n clickCallback: '(function|null)',\n isAnimated: 'boolean',\n isVisible: 'boolean',\n rootElement: '(element|string)'\n}\n\n/**\n * Class definition\n */\n\nclass Backdrop extends Config {\n constructor(config) {\n super()\n this._config = this._getConfig(config)\n this._isAppended = false\n this._element = null\n }\n\n // Getters\n static get Default() {\n return Default\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n static get NAME() {\n return NAME\n }\n\n // Public\n show(callback) {\n if (!this._config.isVisible) {\n execute(callback)\n return\n }\n\n this._append()\n\n const element = this._getElement()\n if (this._config.isAnimated) {\n reflow(element)\n }\n\n element.classList.add(CLASS_NAME_SHOW)\n\n this._emulateAnimation(() => {\n execute(callback)\n })\n }\n\n hide(callback) {\n if (!this._config.isVisible) {\n execute(callback)\n return\n }\n\n this._getElement().classList.remove(CLASS_NAME_SHOW)\n\n this._emulateAnimation(() => {\n this.dispose()\n execute(callback)\n })\n }\n\n dispose() {\n if (!this._isAppended) {\n return\n }\n\n EventHandler.off(this._element, EVENT_MOUSEDOWN)\n\n this._element.remove()\n this._isAppended = false\n }\n\n // Private\n _getElement() {\n if (!this._element) {\n const backdrop = document.createElement('div')\n backdrop.className = this._config.className\n if (this._config.isAnimated) {\n backdrop.classList.add(CLASS_NAME_FADE)\n }\n\n this._element = backdrop\n }\n\n return this._element\n }\n\n _configAfterMerge(config) {\n // use getElement() with the default \"body\" to get a fresh Element on each instantiation\n config.rootElement = getElement(config.rootElement)\n return config\n }\n\n _append() {\n if (this._isAppended) {\n return\n }\n\n const element = this._getElement()\n this._config.rootElement.append(element)\n\n EventHandler.on(element, EVENT_MOUSEDOWN, () => {\n execute(this._config.clickCallback)\n })\n\n this._isAppended = true\n }\n\n _emulateAnimation(callback) {\n executeAfterTransition(callback, this._getElement(), this._config.isAnimated)\n }\n}\n\nexport default Backdrop\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap util/focustrap.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport EventHandler from '../dom/event-handler.js'\nimport SelectorEngine from '../dom/selector-engine.js'\nimport Config from './config.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'focustrap'\nconst DATA_KEY = 'bs.focustrap'\nconst EVENT_KEY = `.${DATA_KEY}`\nconst EVENT_FOCUSIN = `focusin${EVENT_KEY}`\nconst EVENT_KEYDOWN_TAB = `keydown.tab${EVENT_KEY}`\n\nconst TAB_KEY = 'Tab'\nconst TAB_NAV_FORWARD = 'forward'\nconst TAB_NAV_BACKWARD = 'backward'\n\nconst Default = {\n autofocus: true,\n trapElement: null // The element to trap focus inside of\n}\n\nconst DefaultType = {\n autofocus: 'boolean',\n trapElement: 'element'\n}\n\n/**\n * Class definition\n */\n\nclass FocusTrap extends Config {\n constructor(config) {\n super()\n this._config = this._getConfig(config)\n this._isActive = false\n this._lastTabNavDirection = null\n }\n\n // Getters\n static get Default() {\n return Default\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n static get NAME() {\n return NAME\n }\n\n // Public\n activate() {\n if (this._isActive) {\n return\n }\n\n if (this._config.autofocus) {\n this._config.trapElement.focus()\n }\n\n EventHandler.off(document, EVENT_KEY) // guard against infinite focus loop\n EventHandler.on(document, EVENT_FOCUSIN, event => this._handleFocusin(event))\n EventHandler.on(document, EVENT_KEYDOWN_TAB, event => this._handleKeydown(event))\n\n this._isActive = true\n }\n\n deactivate() {\n if (!this._isActive) {\n return\n }\n\n this._isActive = false\n EventHandler.off(document, EVENT_KEY)\n }\n\n // Private\n _handleFocusin(event) {\n const { trapElement } = this._config\n\n if (event.target === document || event.target === trapElement || trapElement.contains(event.target)) {\n return\n }\n\n const elements = SelectorEngine.focusableChildren(trapElement)\n\n if (elements.length === 0) {\n trapElement.focus()\n } else if (this._lastTabNavDirection === TAB_NAV_BACKWARD) {\n elements[elements.length - 1].focus()\n } else {\n elements[0].focus()\n }\n }\n\n _handleKeydown(event) {\n if (event.key !== TAB_KEY) {\n return\n }\n\n this._lastTabNavDirection = event.shiftKey ? TAB_NAV_BACKWARD : TAB_NAV_FORWARD\n }\n}\n\nexport default FocusTrap\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap util/scrollBar.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport Manipulator from '../dom/manipulator.js'\nimport SelectorEngine from '../dom/selector-engine.js'\nimport { isElement } from './index.js'\n\n/**\n * Constants\n */\n\nconst SELECTOR_FIXED_CONTENT = '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top'\nconst SELECTOR_STICKY_CONTENT = '.sticky-top'\nconst PROPERTY_PADDING = 'padding-right'\nconst PROPERTY_MARGIN = 'margin-right'\n\n/**\n * Class definition\n */\n\nclass ScrollBarHelper {\n constructor() {\n this._element = document.body\n }\n\n // Public\n getWidth() {\n // https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes\n const documentWidth = document.documentElement.clientWidth\n return Math.abs(window.innerWidth - documentWidth)\n }\n\n hide() {\n const width = this.getWidth()\n this._disableOverFlow()\n // give padding to element to balance the hidden scrollbar width\n this._setElementAttributes(this._element, PROPERTY_PADDING, calculatedValue => calculatedValue + width)\n // trick: We adjust positive paddingRight and negative marginRight to sticky-top elements to keep showing fullwidth\n this._setElementAttributes(SELECTOR_FIXED_CONTENT, PROPERTY_PADDING, calculatedValue => calculatedValue + width)\n this._setElementAttributes(SELECTOR_STICKY_CONTENT, PROPERTY_MARGIN, calculatedValue => calculatedValue - width)\n }\n\n reset() {\n this._resetElementAttributes(this._element, 'overflow')\n this._resetElementAttributes(this._element, PROPERTY_PADDING)\n this._resetElementAttributes(SELECTOR_FIXED_CONTENT, PROPERTY_PADDING)\n this._resetElementAttributes(SELECTOR_STICKY_CONTENT, PROPERTY_MARGIN)\n }\n\n isOverflowing() {\n return this.getWidth() > 0\n }\n\n // Private\n _disableOverFlow() {\n this._saveInitialAttribute(this._element, 'overflow')\n this._element.style.overflow = 'hidden'\n }\n\n _setElementAttributes(selector, styleProperty, callback) {\n const scrollbarWidth = this.getWidth()\n const manipulationCallBack = element => {\n if (element !== this._element && window.innerWidth > element.clientWidth + scrollbarWidth) {\n return\n }\n\n this._saveInitialAttribute(element, styleProperty)\n const calculatedValue = window.getComputedStyle(element).getPropertyValue(styleProperty)\n element.style.setProperty(styleProperty, `${callback(Number.parseFloat(calculatedValue))}px`)\n }\n\n this._applyManipulationCallback(selector, manipulationCallBack)\n }\n\n _saveInitialAttribute(element, styleProperty) {\n const actualValue = element.style.getPropertyValue(styleProperty)\n if (actualValue) {\n Manipulator.setDataAttribute(element, styleProperty, actualValue)\n }\n }\n\n _resetElementAttributes(selector, styleProperty) {\n const manipulationCallBack = element => {\n const value = Manipulator.getDataAttribute(element, styleProperty)\n // We only want to remove the property if the value is `null`; the value can also be zero\n if (value === null) {\n element.style.removeProperty(styleProperty)\n return\n }\n\n Manipulator.removeDataAttribute(element, styleProperty)\n element.style.setProperty(styleProperty, value)\n }\n\n this._applyManipulationCallback(selector, manipulationCallBack)\n }\n\n _applyManipulationCallback(selector, callBack) {\n if (isElement(selector)) {\n callBack(selector)\n return\n }\n\n for (const sel of SelectorEngine.find(selector, this._element)) {\n callBack(sel)\n }\n }\n}\n\nexport default ScrollBarHelper\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap modal.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport BaseComponent from './base-component.js'\nimport EventHandler from './dom/event-handler.js'\nimport SelectorEngine from './dom/selector-engine.js'\nimport Backdrop from './util/backdrop.js'\nimport { enableDismissTrigger } from './util/component-functions.js'\nimport FocusTrap from './util/focustrap.js'\nimport {\n defineJQueryPlugin, isRTL, isVisible, reflow\n} from './util/index.js'\nimport ScrollBarHelper from './util/scrollbar.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'modal'\nconst DATA_KEY = 'bs.modal'\nconst EVENT_KEY = `.${DATA_KEY}`\nconst DATA_API_KEY = '.data-api'\nconst ESCAPE_KEY = 'Escape'\n\nconst EVENT_HIDE = `hide${EVENT_KEY}`\nconst EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}`\nconst EVENT_HIDDEN = `hidden${EVENT_KEY}`\nconst EVENT_SHOW = `show${EVENT_KEY}`\nconst EVENT_SHOWN = `shown${EVENT_KEY}`\nconst EVENT_RESIZE = `resize${EVENT_KEY}`\nconst EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}`\nconst EVENT_MOUSEDOWN_DISMISS = `mousedown.dismiss${EVENT_KEY}`\nconst EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}`\nconst EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`\n\nconst CLASS_NAME_OPEN = 'modal-open'\nconst CLASS_NAME_FADE = 'fade'\nconst CLASS_NAME_SHOW = 'show'\nconst CLASS_NAME_STATIC = 'modal-static'\n\nconst OPEN_SELECTOR = '.modal.show'\nconst SELECTOR_DIALOG = '.modal-dialog'\nconst SELECTOR_MODAL_BODY = '.modal-body'\nconst SELECTOR_DATA_TOGGLE = '[data-bs-toggle=\"modal\"]'\n\nconst Default = {\n backdrop: true,\n focus: true,\n keyboard: true\n}\n\nconst DefaultType = {\n backdrop: '(boolean|string)',\n focus: 'boolean',\n keyboard: 'boolean'\n}\n\n/**\n * Class definition\n */\n\nclass Modal extends BaseComponent {\n constructor(element, config) {\n super(element, config)\n\n this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element)\n this._backdrop = this._initializeBackDrop()\n this._focustrap = this._initializeFocusTrap()\n this._isShown = false\n this._isTransitioning = false\n this._scrollBar = new ScrollBarHelper()\n\n this._addEventListeners()\n }\n\n // Getters\n static get Default() {\n return Default\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n static get NAME() {\n return NAME\n }\n\n // Public\n toggle(relatedTarget) {\n return this._isShown ? this.hide() : this.show(relatedTarget)\n }\n\n show(relatedTarget) {\n if (this._isShown || this._isTransitioning) {\n return\n }\n\n const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, {\n relatedTarget\n })\n\n if (showEvent.defaultPrevented) {\n return\n }\n\n this._isShown = true\n this._isTransitioning = true\n\n this._scrollBar.hide()\n\n document.body.classList.add(CLASS_NAME_OPEN)\n\n this._adjustDialog()\n\n this._backdrop.show(() => this._showElement(relatedTarget))\n }\n\n hide() {\n if (!this._isShown || this._isTransitioning) {\n return\n }\n\n const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)\n\n if (hideEvent.defaultPrevented) {\n return\n }\n\n this._isShown = false\n this._isTransitioning = true\n this._focustrap.deactivate()\n\n this._element.classList.remove(CLASS_NAME_SHOW)\n\n this._queueCallback(() => this._hideModal(), this._element, this._isAnimated())\n }\n\n dispose() {\n EventHandler.off(window, EVENT_KEY)\n EventHandler.off(this._dialog, EVENT_KEY)\n\n this._backdrop.dispose()\n this._focustrap.deactivate()\n\n super.dispose()\n }\n\n handleUpdate() {\n this._adjustDialog()\n }\n\n // Private\n _initializeBackDrop() {\n return new Backdrop({\n isVisible: Boolean(this._config.backdrop), // 'static' option will be translated to true, and booleans will keep their value,\n isAnimated: this._isAnimated()\n })\n }\n\n _initializeFocusTrap() {\n return new FocusTrap({\n trapElement: this._element\n })\n }\n\n _showElement(relatedTarget) {\n // try to append dynamic modal\n if (!document.body.contains(this._element)) {\n document.body.append(this._element)\n }\n\n this._element.style.display = 'block'\n this._element.removeAttribute('aria-hidden')\n this._element.setAttribute('aria-modal', true)\n this._element.setAttribute('role', 'dialog')\n this._element.scrollTop = 0\n\n const modalBody = SelectorEngine.findOne(SELECTOR_MODAL_BODY, this._dialog)\n if (modalBody) {\n modalBody.scrollTop = 0\n }\n\n reflow(this._element)\n\n this._element.classList.add(CLASS_NAME_SHOW)\n\n const transitionComplete = () => {\n if (this._config.focus) {\n this._focustrap.activate()\n }\n\n this._isTransitioning = false\n EventHandler.trigger(this._element, EVENT_SHOWN, {\n relatedTarget\n })\n }\n\n this._queueCallback(transitionComplete, this._dialog, this._isAnimated())\n }\n\n _addEventListeners() {\n EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => {\n if (event.key !== ESCAPE_KEY) {\n return\n }\n\n if (this._config.keyboard) {\n this.hide()\n return\n }\n\n this._triggerBackdropTransition()\n })\n\n EventHandler.on(window, EVENT_RESIZE, () => {\n if (this._isShown && !this._isTransitioning) {\n this._adjustDialog()\n }\n })\n\n EventHandler.on(this._element, EVENT_MOUSEDOWN_DISMISS, event => {\n // a bad trick to segregate clicks that may start inside dialog but end outside, and avoid listen to scrollbar clicks\n EventHandler.one(this._element, EVENT_CLICK_DISMISS, event2 => {\n if (this._element !== event.target || this._element !== event2.target) {\n return\n }\n\n if (this._config.backdrop === 'static') {\n this._triggerBackdropTransition()\n return\n }\n\n if (this._config.backdrop) {\n this.hide()\n }\n })\n })\n }\n\n _hideModal() {\n this._element.style.display = 'none'\n this._element.setAttribute('aria-hidden', true)\n this._element.removeAttribute('aria-modal')\n this._element.removeAttribute('role')\n this._isTransitioning = false\n\n this._backdrop.hide(() => {\n document.body.classList.remove(CLASS_NAME_OPEN)\n this._resetAdjustments()\n this._scrollBar.reset()\n EventHandler.trigger(this._element, EVENT_HIDDEN)\n })\n }\n\n _isAnimated() {\n return this._element.classList.contains(CLASS_NAME_FADE)\n }\n\n _triggerBackdropTransition() {\n const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)\n if (hideEvent.defaultPrevented) {\n return\n }\n\n const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight\n const initialOverflowY = this._element.style.overflowY\n // return if the following background transition hasn't yet completed\n if (initialOverflowY === 'hidden' || this._element.classList.contains(CLASS_NAME_STATIC)) {\n return\n }\n\n if (!isModalOverflowing) {\n this._element.style.overflowY = 'hidden'\n }\n\n this._element.classList.add(CLASS_NAME_STATIC)\n this._queueCallback(() => {\n this._element.classList.remove(CLASS_NAME_STATIC)\n this._queueCallback(() => {\n this._element.style.overflowY = initialOverflowY\n }, this._dialog)\n }, this._dialog)\n\n this._element.focus()\n }\n\n /**\n * The following methods are used to handle overflowing modals\n */\n\n _adjustDialog() {\n const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight\n const scrollbarWidth = this._scrollBar.getWidth()\n const isBodyOverflowing = scrollbarWidth > 0\n\n if (isBodyOverflowing && !isModalOverflowing) {\n const property = isRTL() ? 'paddingLeft' : 'paddingRight'\n this._element.style[property] = `${scrollbarWidth}px`\n }\n\n if (!isBodyOverflowing && isModalOverflowing) {\n const property = isRTL() ? 'paddingRight' : 'paddingLeft'\n this._element.style[property] = `${scrollbarWidth}px`\n }\n }\n\n _resetAdjustments() {\n this._element.style.paddingLeft = ''\n this._element.style.paddingRight = ''\n }\n\n // Static\n static jQueryInterface(config, relatedTarget) {\n return this.each(function () {\n const data = Modal.getOrCreateInstance(this, config)\n\n if (typeof config !== 'string') {\n return\n }\n\n if (typeof data[config] === 'undefined') {\n throw new TypeError(`No method named \"${config}\"`)\n }\n\n data[config](relatedTarget)\n })\n }\n}\n\n/**\n * Data API implementation\n */\n\nEventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {\n const target = SelectorEngine.getElementFromSelector(this)\n\n if (['A', 'AREA'].includes(this.tagName)) {\n event.preventDefault()\n }\n\n EventHandler.one(target, EVENT_SHOW, showEvent => {\n if (showEvent.defaultPrevented) {\n // only register focus restorer if modal will actually get shown\n return\n }\n\n EventHandler.one(target, EVENT_HIDDEN, () => {\n if (isVisible(this)) {\n this.focus()\n }\n })\n })\n\n // avoid conflict when clicking modal toggler while another one is open\n const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR)\n if (alreadyOpen) {\n Modal.getInstance(alreadyOpen).hide()\n }\n\n const data = Modal.getOrCreateInstance(target)\n\n data.toggle(this)\n})\n\nenableDismissTrigger(Modal)\n\n/**\n * jQuery\n */\n\ndefineJQueryPlugin(Modal)\n\nexport default Modal\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap offcanvas.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport BaseComponent from './base-component.js'\nimport EventHandler from './dom/event-handler.js'\nimport SelectorEngine from './dom/selector-engine.js'\nimport Backdrop from './util/backdrop.js'\nimport { enableDismissTrigger } from './util/component-functions.js'\nimport FocusTrap from './util/focustrap.js'\nimport {\n defineJQueryPlugin,\n isDisabled,\n isVisible\n} from './util/index.js'\nimport ScrollBarHelper from './util/scrollbar.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'offcanvas'\nconst DATA_KEY = 'bs.offcanvas'\nconst EVENT_KEY = `.${DATA_KEY}`\nconst DATA_API_KEY = '.data-api'\nconst EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`\nconst ESCAPE_KEY = 'Escape'\n\nconst CLASS_NAME_SHOW = 'show'\nconst CLASS_NAME_SHOWING = 'showing'\nconst CLASS_NAME_HIDING = 'hiding'\nconst CLASS_NAME_BACKDROP = 'offcanvas-backdrop'\nconst OPEN_SELECTOR = '.offcanvas.show'\n\nconst EVENT_SHOW = `show${EVENT_KEY}`\nconst EVENT_SHOWN = `shown${EVENT_KEY}`\nconst EVENT_HIDE = `hide${EVENT_KEY}`\nconst EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}`\nconst EVENT_HIDDEN = `hidden${EVENT_KEY}`\nconst EVENT_RESIZE = `resize${EVENT_KEY}`\nconst EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`\nconst EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}`\n\nconst SELECTOR_DATA_TOGGLE = '[data-bs-toggle=\"offcanvas\"]'\n\nconst Default = {\n backdrop: true,\n keyboard: true,\n scroll: false\n}\n\nconst DefaultType = {\n backdrop: '(boolean|string)',\n keyboard: 'boolean',\n scroll: 'boolean'\n}\n\n/**\n * Class definition\n */\n\nclass Offcanvas extends BaseComponent {\n constructor(element, config) {\n super(element, config)\n\n this._isShown = false\n this._backdrop = this._initializeBackDrop()\n this._focustrap = this._initializeFocusTrap()\n this._addEventListeners()\n }\n\n // Getters\n static get Default() {\n return Default\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n static get NAME() {\n return NAME\n }\n\n // Public\n toggle(relatedTarget) {\n return this._isShown ? this.hide() : this.show(relatedTarget)\n }\n\n show(relatedTarget) {\n if (this._isShown) {\n return\n }\n\n const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, { relatedTarget })\n\n if (showEvent.defaultPrevented) {\n return\n }\n\n this._isShown = true\n this._backdrop.show()\n\n if (!this._config.scroll) {\n new ScrollBarHelper().hide()\n }\n\n this._element.setAttribute('aria-modal', true)\n this._element.setAttribute('role', 'dialog')\n this._element.classList.add(CLASS_NAME_SHOWING)\n\n const completeCallBack = () => {\n if (!this._config.scroll || this._config.backdrop) {\n this._focustrap.activate()\n }\n\n this._element.classList.add(CLASS_NAME_SHOW)\n this._element.classList.remove(CLASS_NAME_SHOWING)\n EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget })\n }\n\n this._queueCallback(completeCallBack, this._element, true)\n }\n\n hide() {\n if (!this._isShown) {\n return\n }\n\n const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)\n\n if (hideEvent.defaultPrevented) {\n return\n }\n\n this._focustrap.deactivate()\n this._element.blur()\n this._isShown = false\n this._element.classList.add(CLASS_NAME_HIDING)\n this._backdrop.hide()\n\n const completeCallback = () => {\n this._element.classList.remove(CLASS_NAME_SHOW, CLASS_NAME_HIDING)\n this._element.removeAttribute('aria-modal')\n this._element.removeAttribute('role')\n\n if (!this._config.scroll) {\n new ScrollBarHelper().reset()\n }\n\n EventHandler.trigger(this._element, EVENT_HIDDEN)\n }\n\n this._queueCallback(completeCallback, this._element, true)\n }\n\n dispose() {\n this._backdrop.dispose()\n this._focustrap.deactivate()\n super.dispose()\n }\n\n // Private\n _initializeBackDrop() {\n const clickCallback = () => {\n if (this._config.backdrop === 'static') {\n EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)\n return\n }\n\n this.hide()\n }\n\n // 'static' option will be translated to true, and booleans will keep their value\n const isVisible = Boolean(this._config.backdrop)\n\n return new Backdrop({\n className: CLASS_NAME_BACKDROP,\n isVisible,\n isAnimated: true,\n rootElement: this._element.parentNode,\n clickCallback: isVisible ? clickCallback : null\n })\n }\n\n _initializeFocusTrap() {\n return new FocusTrap({\n trapElement: this._element\n })\n }\n\n _addEventListeners() {\n EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => {\n if (event.key !== ESCAPE_KEY) {\n return\n }\n\n if (this._config.keyboard) {\n this.hide()\n return\n }\n\n EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)\n })\n }\n\n // Static\n static jQueryInterface(config) {\n return this.each(function () {\n const data = Offcanvas.getOrCreateInstance(this, config)\n\n if (typeof config !== 'string') {\n return\n }\n\n if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {\n throw new TypeError(`No method named \"${config}\"`)\n }\n\n data[config](this)\n })\n }\n}\n\n/**\n * Data API implementation\n */\n\nEventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {\n const target = SelectorEngine.getElementFromSelector(this)\n\n if (['A', 'AREA'].includes(this.tagName)) {\n event.preventDefault()\n }\n\n if (isDisabled(this)) {\n return\n }\n\n EventHandler.one(target, EVENT_HIDDEN, () => {\n // focus on trigger when it is closed\n if (isVisible(this)) {\n this.focus()\n }\n })\n\n // avoid conflict when clicking a toggler of an offcanvas, while another is open\n const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR)\n if (alreadyOpen && alreadyOpen !== target) {\n Offcanvas.getInstance(alreadyOpen).hide()\n }\n\n const data = Offcanvas.getOrCreateInstance(target)\n data.toggle(this)\n})\n\nEventHandler.on(window, EVENT_LOAD_DATA_API, () => {\n for (const selector of SelectorEngine.find(OPEN_SELECTOR)) {\n Offcanvas.getOrCreateInstance(selector).show()\n }\n})\n\nEventHandler.on(window, EVENT_RESIZE, () => {\n for (const element of SelectorEngine.find('[aria-modal][class*=show][class*=offcanvas-]')) {\n if (getComputedStyle(element).position !== 'fixed') {\n Offcanvas.getOrCreateInstance(element).hide()\n }\n }\n})\n\nenableDismissTrigger(Offcanvas)\n\n/**\n * jQuery\n */\n\ndefineJQueryPlugin(Offcanvas)\n\nexport default Offcanvas\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap util/sanitizer.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\n// js-docs-start allow-list\nconst ARIA_ATTRIBUTE_PATTERN = /^aria-[\\w-]*$/i\n\nexport const DefaultAllowlist = {\n // Global attributes allowed on any supplied element below.\n '*': ['class', 'dir', 'id', 'lang', 'role', ARIA_ATTRIBUTE_PATTERN],\n a: ['target', 'href', 'title', 'rel'],\n area: [],\n b: [],\n br: [],\n col: [],\n code: [],\n dd: [],\n div: [],\n dl: [],\n dt: [],\n em: [],\n hr: [],\n h1: [],\n h2: [],\n h3: [],\n h4: [],\n h5: [],\n h6: [],\n i: [],\n img: ['src', 'srcset', 'alt', 'title', 'width', 'height'],\n li: [],\n ol: [],\n p: [],\n pre: [],\n s: [],\n small: [],\n span: [],\n sub: [],\n sup: [],\n strong: [],\n u: [],\n ul: []\n}\n// js-docs-end allow-list\n\nconst uriAttributes = new Set([\n 'background',\n 'cite',\n 'href',\n 'itemtype',\n 'longdesc',\n 'poster',\n 'src',\n 'xlink:href'\n])\n\n/**\n * A pattern that recognizes URLs that are safe wrt. XSS in URL navigation\n * contexts.\n *\n * Shout-out to Angular https://github.com/angular/angular/blob/15.2.8/packages/core/src/sanitization/url_sanitizer.ts#L38\n */\n// eslint-disable-next-line unicorn/better-regex\nconst SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i\n\nconst allowedAttribute = (attribute, allowedAttributeList) => {\n const attributeName = attribute.nodeName.toLowerCase()\n\n if (allowedAttributeList.includes(attributeName)) {\n if (uriAttributes.has(attributeName)) {\n return Boolean(SAFE_URL_PATTERN.test(attribute.nodeValue))\n }\n\n return true\n }\n\n // Check if a regular expression validates the attribute.\n return allowedAttributeList.filter(attributeRegex => attributeRegex instanceof RegExp)\n .some(regex => regex.test(attributeName))\n}\n\nexport function sanitizeHtml(unsafeHtml, allowList, sanitizeFunction) {\n if (!unsafeHtml.length) {\n return unsafeHtml\n }\n\n if (sanitizeFunction && typeof sanitizeFunction === 'function') {\n return sanitizeFunction(unsafeHtml)\n }\n\n const domParser = new window.DOMParser()\n const createdDocument = domParser.parseFromString(unsafeHtml, 'text/html')\n const elements = [].concat(...createdDocument.body.querySelectorAll('*'))\n\n for (const element of elements) {\n const elementName = element.nodeName.toLowerCase()\n\n if (!Object.keys(allowList).includes(elementName)) {\n element.remove()\n continue\n }\n\n const attributeList = [].concat(...element.attributes)\n const allowedAttributes = [].concat(allowList['*'] || [], allowList[elementName] || [])\n\n for (const attribute of attributeList) {\n if (!allowedAttribute(attribute, allowedAttributes)) {\n element.removeAttribute(attribute.nodeName)\n }\n }\n }\n\n return createdDocument.body.innerHTML\n}\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap util/template-factory.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport SelectorEngine from '../dom/selector-engine.js'\nimport Config from './config.js'\nimport { DefaultAllowlist, sanitizeHtml } from './sanitizer.js'\nimport { execute, getElement, isElement } from './index.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'TemplateFactory'\n\nconst Default = {\n allowList: DefaultAllowlist,\n content: {}, // { selector : text , selector2 : text2 , }\n extraClass: '',\n html: false,\n sanitize: true,\n sanitizeFn: null,\n template: '
'\n}\n\nconst DefaultType = {\n allowList: 'object',\n content: 'object',\n extraClass: '(string|function)',\n html: 'boolean',\n sanitize: 'boolean',\n sanitizeFn: '(null|function)',\n template: 'string'\n}\n\nconst DefaultContentType = {\n entry: '(string|element|function|null)',\n selector: '(string|element)'\n}\n\n/**\n * Class definition\n */\n\nclass TemplateFactory extends Config {\n constructor(config) {\n super()\n this._config = this._getConfig(config)\n }\n\n // Getters\n static get Default() {\n return Default\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n static get NAME() {\n return NAME\n }\n\n // Public\n getContent() {\n return Object.values(this._config.content)\n .map(config => this._resolvePossibleFunction(config))\n .filter(Boolean)\n }\n\n hasContent() {\n return this.getContent().length > 0\n }\n\n changeContent(content) {\n this._checkContent(content)\n this._config.content = { ...this._config.content, ...content }\n return this\n }\n\n toHtml() {\n const templateWrapper = document.createElement('div')\n templateWrapper.innerHTML = this._maybeSanitize(this._config.template)\n\n for (const [selector, text] of Object.entries(this._config.content)) {\n this._setContent(templateWrapper, text, selector)\n }\n\n const template = templateWrapper.children[0]\n const extraClass = this._resolvePossibleFunction(this._config.extraClass)\n\n if (extraClass) {\n template.classList.add(...extraClass.split(' '))\n }\n\n return template\n }\n\n // Private\n _typeCheckConfig(config) {\n super._typeCheckConfig(config)\n this._checkContent(config.content)\n }\n\n _checkContent(arg) {\n for (const [selector, content] of Object.entries(arg)) {\n super._typeCheckConfig({ selector, entry: content }, DefaultContentType)\n }\n }\n\n _setContent(template, content, selector) {\n const templateElement = SelectorEngine.findOne(selector, template)\n\n if (!templateElement) {\n return\n }\n\n content = this._resolvePossibleFunction(content)\n\n if (!content) {\n templateElement.remove()\n return\n }\n\n if (isElement(content)) {\n this._putElementInTemplate(getElement(content), templateElement)\n return\n }\n\n if (this._config.html) {\n templateElement.innerHTML = this._maybeSanitize(content)\n return\n }\n\n templateElement.textContent = content\n }\n\n _maybeSanitize(arg) {\n return this._config.sanitize ? sanitizeHtml(arg, this._config.allowList, this._config.sanitizeFn) : arg\n }\n\n _resolvePossibleFunction(arg) {\n return execute(arg, [this])\n }\n\n _putElementInTemplate(element, templateElement) {\n if (this._config.html) {\n templateElement.innerHTML = ''\n templateElement.append(element)\n return\n }\n\n templateElement.textContent = element.textContent\n }\n}\n\nexport default TemplateFactory\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap tooltip.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport * as Popper from '@popperjs/core'\nimport BaseComponent from './base-component.js'\nimport EventHandler from './dom/event-handler.js'\nimport Manipulator from './dom/manipulator.js'\nimport {\n defineJQueryPlugin, execute, findShadowRoot, getElement, getUID, isRTL, noop\n} from './util/index.js'\nimport { DefaultAllowlist } from './util/sanitizer.js'\nimport TemplateFactory from './util/template-factory.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'tooltip'\nconst DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn'])\n\nconst CLASS_NAME_FADE = 'fade'\nconst CLASS_NAME_MODAL = 'modal'\nconst CLASS_NAME_SHOW = 'show'\n\nconst SELECTOR_TOOLTIP_INNER = '.tooltip-inner'\nconst SELECTOR_MODAL = `.${CLASS_NAME_MODAL}`\n\nconst EVENT_MODAL_HIDE = 'hide.bs.modal'\n\nconst TRIGGER_HOVER = 'hover'\nconst TRIGGER_FOCUS = 'focus'\nconst TRIGGER_CLICK = 'click'\nconst TRIGGER_MANUAL = 'manual'\n\nconst EVENT_HIDE = 'hide'\nconst EVENT_HIDDEN = 'hidden'\nconst EVENT_SHOW = 'show'\nconst EVENT_SHOWN = 'shown'\nconst EVENT_INSERTED = 'inserted'\nconst EVENT_CLICK = 'click'\nconst EVENT_FOCUSIN = 'focusin'\nconst EVENT_FOCUSOUT = 'focusout'\nconst EVENT_MOUSEENTER = 'mouseenter'\nconst EVENT_MOUSELEAVE = 'mouseleave'\n\nconst AttachmentMap = {\n AUTO: 'auto',\n TOP: 'top',\n RIGHT: isRTL() ? 'left' : 'right',\n BOTTOM: 'bottom',\n LEFT: isRTL() ? 'right' : 'left'\n}\n\nconst Default = {\n allowList: DefaultAllowlist,\n animation: true,\n boundary: 'clippingParents',\n container: false,\n customClass: '',\n delay: 0,\n fallbackPlacements: ['top', 'right', 'bottom', 'left'],\n html: false,\n offset: [0, 6],\n placement: 'top',\n popperConfig: null,\n sanitize: true,\n sanitizeFn: null,\n selector: false,\n template: '
' +\n '
' +\n '
' +\n '
',\n title: '',\n trigger: 'hover focus'\n}\n\nconst DefaultType = {\n allowList: 'object',\n animation: 'boolean',\n boundary: '(string|element)',\n container: '(string|element|boolean)',\n customClass: '(string|function)',\n delay: '(number|object)',\n fallbackPlacements: 'array',\n html: 'boolean',\n offset: '(array|string|function)',\n placement: '(string|function)',\n popperConfig: '(null|object|function)',\n sanitize: 'boolean',\n sanitizeFn: '(null|function)',\n selector: '(string|boolean)',\n template: 'string',\n title: '(string|element|function)',\n trigger: 'string'\n}\n\n/**\n * Class definition\n */\n\nclass Tooltip extends BaseComponent {\n constructor(element, config) {\n if (typeof Popper === 'undefined') {\n throw new TypeError('Bootstrap\\'s tooltips require Popper (https://popper.js.org)')\n }\n\n super(element, config)\n\n // Private\n this._isEnabled = true\n this._timeout = 0\n this._isHovered = null\n this._activeTrigger = {}\n this._popper = null\n this._templateFactory = null\n this._newContent = null\n\n // Protected\n this.tip = null\n\n this._setListeners()\n\n if (!this._config.selector) {\n this._fixTitle()\n }\n }\n\n // Getters\n static get Default() {\n return Default\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n static get NAME() {\n return NAME\n }\n\n // Public\n enable() {\n this._isEnabled = true\n }\n\n disable() {\n this._isEnabled = false\n }\n\n toggleEnabled() {\n this._isEnabled = !this._isEnabled\n }\n\n toggle() {\n if (!this._isEnabled) {\n return\n }\n\n this._activeTrigger.click = !this._activeTrigger.click\n if (this._isShown()) {\n this._leave()\n return\n }\n\n this._enter()\n }\n\n dispose() {\n clearTimeout(this._timeout)\n\n EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)\n\n if (this._element.getAttribute('data-bs-original-title')) {\n this._element.setAttribute('title', this._element.getAttribute('data-bs-original-title'))\n }\n\n this._disposePopper()\n super.dispose()\n }\n\n show() {\n if (this._element.style.display === 'none') {\n throw new Error('Please use show on visible elements')\n }\n\n if (!(this._isWithContent() && this._isEnabled)) {\n return\n }\n\n const showEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOW))\n const shadowRoot = findShadowRoot(this._element)\n const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element)\n\n if (showEvent.defaultPrevented || !isInTheDom) {\n return\n }\n\n // TODO: v6 remove this or make it optional\n this._disposePopper()\n\n const tip = this._getTipElement()\n\n this._element.setAttribute('aria-describedby', tip.getAttribute('id'))\n\n const { container } = this._config\n\n if (!this._element.ownerDocument.documentElement.contains(this.tip)) {\n container.append(tip)\n EventHandler.trigger(this._element, this.constructor.eventName(EVENT_INSERTED))\n }\n\n this._popper = this._createPopper(tip)\n\n tip.classList.add(CLASS_NAME_SHOW)\n\n // If this is a touch-enabled device we add extra\n // empty mouseover listeners to the body's immediate children;\n // only needed because of broken event delegation on iOS\n // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html\n if ('ontouchstart' in document.documentElement) {\n for (const element of [].concat(...document.body.children)) {\n EventHandler.on(element, 'mouseover', noop)\n }\n }\n\n const complete = () => {\n EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOWN))\n\n if (this._isHovered === false) {\n this._leave()\n }\n\n this._isHovered = false\n }\n\n this._queueCallback(complete, this.tip, this._isAnimated())\n }\n\n hide() {\n if (!this._isShown()) {\n return\n }\n\n const hideEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDE))\n if (hideEvent.defaultPrevented) {\n return\n }\n\n const tip = this._getTipElement()\n tip.classList.remove(CLASS_NAME_SHOW)\n\n // If this is a touch-enabled device we remove the extra\n // empty mouseover listeners we added for iOS support\n if ('ontouchstart' in document.documentElement) {\n for (const element of [].concat(...document.body.children)) {\n EventHandler.off(element, 'mouseover', noop)\n }\n }\n\n this._activeTrigger[TRIGGER_CLICK] = false\n this._activeTrigger[TRIGGER_FOCUS] = false\n this._activeTrigger[TRIGGER_HOVER] = false\n this._isHovered = null // it is a trick to support manual triggering\n\n const complete = () => {\n if (this._isWithActiveTrigger()) {\n return\n }\n\n if (!this._isHovered) {\n this._disposePopper()\n }\n\n this._element.removeAttribute('aria-describedby')\n EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDDEN))\n }\n\n this._queueCallback(complete, this.tip, this._isAnimated())\n }\n\n update() {\n if (this._popper) {\n this._popper.update()\n }\n }\n\n // Protected\n _isWithContent() {\n return Boolean(this._getTitle())\n }\n\n _getTipElement() {\n if (!this.tip) {\n this.tip = this._createTipElement(this._newContent || this._getContentForTemplate())\n }\n\n return this.tip\n }\n\n _createTipElement(content) {\n const tip = this._getTemplateFactory(content).toHtml()\n\n // TODO: remove this check in v6\n if (!tip) {\n return null\n }\n\n tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW)\n // TODO: v6 the following can be achieved with CSS only\n tip.classList.add(`bs-${this.constructor.NAME}-auto`)\n\n const tipId = getUID(this.constructor.NAME).toString()\n\n tip.setAttribute('id', tipId)\n\n if (this._isAnimated()) {\n tip.classList.add(CLASS_NAME_FADE)\n }\n\n return tip\n }\n\n setContent(content) {\n this._newContent = content\n if (this._isShown()) {\n this._disposePopper()\n this.show()\n }\n }\n\n _getTemplateFactory(content) {\n if (this._templateFactory) {\n this._templateFactory.changeContent(content)\n } else {\n this._templateFactory = new TemplateFactory({\n ...this._config,\n // the `content` var has to be after `this._config`\n // to override config.content in case of popover\n content,\n extraClass: this._resolvePossibleFunction(this._config.customClass)\n })\n }\n\n return this._templateFactory\n }\n\n _getContentForTemplate() {\n return {\n [SELECTOR_TOOLTIP_INNER]: this._getTitle()\n }\n }\n\n _getTitle() {\n return this._resolvePossibleFunction(this._config.title) || this._element.getAttribute('data-bs-original-title')\n }\n\n // Private\n _initializeOnDelegatedTarget(event) {\n return this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig())\n }\n\n _isAnimated() {\n return this._config.animation || (this.tip && this.tip.classList.contains(CLASS_NAME_FADE))\n }\n\n _isShown() {\n return this.tip && this.tip.classList.contains(CLASS_NAME_SHOW)\n }\n\n _createPopper(tip) {\n const placement = execute(this._config.placement, [this, tip, this._element])\n const attachment = AttachmentMap[placement.toUpperCase()]\n return Popper.createPopper(this._element, tip, this._getPopperConfig(attachment))\n }\n\n _getOffset() {\n const { offset } = this._config\n\n if (typeof offset === 'string') {\n return offset.split(',').map(value => Number.parseInt(value, 10))\n }\n\n if (typeof offset === 'function') {\n return popperData => offset(popperData, this._element)\n }\n\n return offset\n }\n\n _resolvePossibleFunction(arg) {\n return execute(arg, [this._element])\n }\n\n _getPopperConfig(attachment) {\n const defaultBsPopperConfig = {\n placement: attachment,\n modifiers: [\n {\n name: 'flip',\n options: {\n fallbackPlacements: this._config.fallbackPlacements\n }\n },\n {\n name: 'offset',\n options: {\n offset: this._getOffset()\n }\n },\n {\n name: 'preventOverflow',\n options: {\n boundary: this._config.boundary\n }\n },\n {\n name: 'arrow',\n options: {\n element: `.${this.constructor.NAME}-arrow`\n }\n },\n {\n name: 'preSetPlacement',\n enabled: true,\n phase: 'beforeMain',\n fn: data => {\n // Pre-set Popper's placement attribute in order to read the arrow sizes properly.\n // Otherwise, Popper mixes up the width and height dimensions since the initial arrow style is for top placement\n this._getTipElement().setAttribute('data-popper-placement', data.state.placement)\n }\n }\n ]\n }\n\n return {\n ...defaultBsPopperConfig,\n ...execute(this._config.popperConfig, [defaultBsPopperConfig])\n }\n }\n\n _setListeners() {\n const triggers = this._config.trigger.split(' ')\n\n for (const trigger of triggers) {\n if (trigger === 'click') {\n EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK), this._config.selector, event => {\n const context = this._initializeOnDelegatedTarget(event)\n context.toggle()\n })\n } else if (trigger !== TRIGGER_MANUAL) {\n const eventIn = trigger === TRIGGER_HOVER ?\n this.constructor.eventName(EVENT_MOUSEENTER) :\n this.constructor.eventName(EVENT_FOCUSIN)\n const eventOut = trigger === TRIGGER_HOVER ?\n this.constructor.eventName(EVENT_MOUSELEAVE) :\n this.constructor.eventName(EVENT_FOCUSOUT)\n\n EventHandler.on(this._element, eventIn, this._config.selector, event => {\n const context = this._initializeOnDelegatedTarget(event)\n context._activeTrigger[event.type === 'focusin' ? TRIGGER_FOCUS : TRIGGER_HOVER] = true\n context._enter()\n })\n EventHandler.on(this._element, eventOut, this._config.selector, event => {\n const context = this._initializeOnDelegatedTarget(event)\n context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] =\n context._element.contains(event.relatedTarget)\n\n context._leave()\n })\n }\n }\n\n this._hideModalHandler = () => {\n if (this._element) {\n this.hide()\n }\n }\n\n EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)\n }\n\n _fixTitle() {\n const title = this._element.getAttribute('title')\n\n if (!title) {\n return\n }\n\n if (!this._element.getAttribute('aria-label') && !this._element.textContent.trim()) {\n this._element.setAttribute('aria-label', title)\n }\n\n this._element.setAttribute('data-bs-original-title', title) // DO NOT USE IT. Is only for backwards compatibility\n this._element.removeAttribute('title')\n }\n\n _enter() {\n if (this._isShown() || this._isHovered) {\n this._isHovered = true\n return\n }\n\n this._isHovered = true\n\n this._setTimeout(() => {\n if (this._isHovered) {\n this.show()\n }\n }, this._config.delay.show)\n }\n\n _leave() {\n if (this._isWithActiveTrigger()) {\n return\n }\n\n this._isHovered = false\n\n this._setTimeout(() => {\n if (!this._isHovered) {\n this.hide()\n }\n }, this._config.delay.hide)\n }\n\n _setTimeout(handler, timeout) {\n clearTimeout(this._timeout)\n this._timeout = setTimeout(handler, timeout)\n }\n\n _isWithActiveTrigger() {\n return Object.values(this._activeTrigger).includes(true)\n }\n\n _getConfig(config) {\n const dataAttributes = Manipulator.getDataAttributes(this._element)\n\n for (const dataAttribute of Object.keys(dataAttributes)) {\n if (DISALLOWED_ATTRIBUTES.has(dataAttribute)) {\n delete dataAttributes[dataAttribute]\n }\n }\n\n config = {\n ...dataAttributes,\n ...(typeof config === 'object' && config ? config : {})\n }\n config = this._mergeConfigObj(config)\n config = this._configAfterMerge(config)\n this._typeCheckConfig(config)\n return config\n }\n\n _configAfterMerge(config) {\n config.container = config.container === false ? document.body : getElement(config.container)\n\n if (typeof config.delay === 'number') {\n config.delay = {\n show: config.delay,\n hide: config.delay\n }\n }\n\n if (typeof config.title === 'number') {\n config.title = config.title.toString()\n }\n\n if (typeof config.content === 'number') {\n config.content = config.content.toString()\n }\n\n return config\n }\n\n _getDelegateConfig() {\n const config = {}\n\n for (const [key, value] of Object.entries(this._config)) {\n if (this.constructor.Default[key] !== value) {\n config[key] = value\n }\n }\n\n config.selector = false\n config.trigger = 'manual'\n\n // In the future can be replaced with:\n // const keysWithDifferentValues = Object.entries(this._config).filter(entry => this.constructor.Default[entry[0]] !== this._config[entry[0]])\n // `Object.fromEntries(keysWithDifferentValues)`\n return config\n }\n\n _disposePopper() {\n if (this._popper) {\n this._popper.destroy()\n this._popper = null\n }\n\n if (this.tip) {\n this.tip.remove()\n this.tip = null\n }\n }\n\n // Static\n static jQueryInterface(config) {\n return this.each(function () {\n const data = Tooltip.getOrCreateInstance(this, config)\n\n if (typeof config !== 'string') {\n return\n }\n\n if (typeof data[config] === 'undefined') {\n throw new TypeError(`No method named \"${config}\"`)\n }\n\n data[config]()\n })\n }\n}\n\n/**\n * jQuery\n */\n\ndefineJQueryPlugin(Tooltip)\n\nexport default Tooltip\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap popover.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport Tooltip from './tooltip.js'\nimport { defineJQueryPlugin } from './util/index.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'popover'\n\nconst SELECTOR_TITLE = '.popover-header'\nconst SELECTOR_CONTENT = '.popover-body'\n\nconst Default = {\n ...Tooltip.Default,\n content: '',\n offset: [0, 8],\n placement: 'right',\n template: '
' +\n '
' +\n '

' +\n '
' +\n '
',\n trigger: 'click'\n}\n\nconst DefaultType = {\n ...Tooltip.DefaultType,\n content: '(null|string|element|function)'\n}\n\n/**\n * Class definition\n */\n\nclass Popover extends Tooltip {\n // Getters\n static get Default() {\n return Default\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n static get NAME() {\n return NAME\n }\n\n // Overrides\n _isWithContent() {\n return this._getTitle() || this._getContent()\n }\n\n // Private\n _getContentForTemplate() {\n return {\n [SELECTOR_TITLE]: this._getTitle(),\n [SELECTOR_CONTENT]: this._getContent()\n }\n }\n\n _getContent() {\n return this._resolvePossibleFunction(this._config.content)\n }\n\n // Static\n static jQueryInterface(config) {\n return this.each(function () {\n const data = Popover.getOrCreateInstance(this, config)\n\n if (typeof config !== 'string') {\n return\n }\n\n if (typeof data[config] === 'undefined') {\n throw new TypeError(`No method named \"${config}\"`)\n }\n\n data[config]()\n })\n }\n}\n\n/**\n * jQuery\n */\n\ndefineJQueryPlugin(Popover)\n\nexport default Popover\n","/**\n * --------------------------------------------------------------------------\n * Bootstrap scrollspy.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport BaseComponent from './base-component.js'\nimport EventHandler from './dom/event-handler.js'\nimport SelectorEngine from './dom/selector-engine.js'\nimport {\n defineJQueryPlugin, getElement, isDisabled, isVisible\n} from './util/index.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'scrollspy'\nconst DATA_KEY = 'bs.scrollspy'\nconst EVENT_KEY = `.${DATA_KEY}`\nconst DATA_API_KEY = '.data-api'\n\nconst EVENT_ACTIVATE = `activate${EVENT_KEY}`\nconst EVENT_CLICK = `click${EVENT_KEY}`\nconst EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`\n\nconst CLASS_NAME_DROPDOWN_ITEM = 'dropdown-item'\nconst CLASS_NAME_ACTIVE = 'active'\n\nconst SELECTOR_DATA_SPY = '[data-bs-spy=\"scroll\"]'\nconst SELECTOR_TARGET_LINKS = '[href]'\nconst SELECTOR_NAV_LIST_GROUP = '.nav, .list-group'\nconst SELECTOR_NAV_LINKS = '.nav-link'\nconst SELECTOR_NAV_ITEMS = '.nav-item'\nconst SELECTOR_LIST_ITEMS = '.list-group-item'\nconst SELECTOR_LINK_ITEMS = `${SELECTOR_NAV_LINKS}, ${SELECTOR_NAV_ITEMS} > ${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}`\nconst SELECTOR_DROPDOWN = '.dropdown'\nconst SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle'\n\nconst Default = {\n offset: null, // TODO: v6 @deprecated, keep it for backwards compatibility reasons\n rootMargin: '0px 0px -25%',\n smoothScroll: false,\n target: null,\n threshold: [0.1, 0.5, 1]\n}\n\nconst DefaultType = {\n offset: '(number|null)', // TODO v6 @deprecated, keep it for backwards compatibility reasons\n rootMargin: 'string',\n smoothScroll: 'boolean',\n target: 'element',\n threshold: 'array'\n}\n\n/**\n * Class definition\n */\n\nclass ScrollSpy extends BaseComponent {\n constructor(element, config) {\n super(element, config)\n\n // this._element is the observablesContainer and config.target the menu links wrapper\n this._targetLinks = new Map()\n this._observableSections = new Map()\n this._rootElement = getComputedStyle(this._element).overflowY === 'visible' ? null : this._element\n this._activeTarget = null\n this._observer = null\n this._previousScrollData = {\n visibleEntryTop: 0,\n parentScrollTop: 0\n }\n this.refresh() // initialize\n }\n\n // Getters\n static get Default() {\n return Default\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n static get NAME() {\n return NAME\n }\n\n // Public\n refresh() {\n this._initializeTargetsAndObservables()\n this._maybeEnableSmoothScroll()\n\n if (this._observer) {\n this._observer.disconnect()\n } else {\n this._observer = this._getNewObserver()\n }\n\n for (const section of this._observableSections.values()) {\n this._observer.observe(section)\n }\n }\n\n dispose() {\n this._observer.disconnect()\n super.dispose()\n }\n\n // Private\n _configAfterMerge(config) {\n // TODO: on v6 target should be given explicitly & remove the {target: 'ss-target'} case\n config.target = getElement(config.target) || document.body\n\n // TODO: v6 Only for backwards compatibility reasons. Use rootMargin only\n config.rootMargin = config.offset ? `${config.offset}px 0px -30%` : config.rootMargin\n\n if (typeof config.threshold === 'string') {\n config.threshold = config.threshold.split(',').map(value => Number.parseFloat(value))\n }\n\n return config\n }\n\n _maybeEnableSmoothScroll() {\n if (!this._config.smoothScroll) {\n return\n }\n\n // unregister any previous listeners\n EventHandler.off(this._config.target, EVENT_CLICK)\n\n EventHandler.on(this._config.target, EVENT_CLICK, SELECTOR_TARGET_LINKS, event => {\n const observableSection = this._observableSections.get(event.target.hash)\n if (observableSection) {\n event.preventDefault()\n const root = this._rootElement || window\n const height = observableSection.offsetTop - this._element.offsetTop\n if (root.scrollTo) {\n root.scrollTo({ top: height, behavior: 'smooth' })\n return\n }\n\n // Chrome 60 doesn't support `scrollTo`\n root.scrollTop = height\n }\n })\n }\n\n _getNewObserver() {\n const options = {\n root: this._rootElement,\n threshold: this._config.threshold,\n rootMargin: this._config.rootMargin\n }\n\n return new IntersectionObserver(entries => this._observerCallback(entries), options)\n }\n\n // The logic of selection\n _observerCallback(entries) {\n const targetElement = entry => this._targetLinks.get(`#${entry.target.id}`)\n const activate = entry => {\n this._previousScrollData.visibleEntryTop = entry.target.offsetTop\n this._process(targetElement(entry))\n }\n\n const parentScrollTop = (this._rootElement || document.documentElement).scrollTop\n const userScrollsDown = parentScrollTop >= this._previousScrollData.parentScrollTop\n this._previousScrollData.parentScrollTop = parentScrollTop\n\n for (const entry of entries) {\n if (!entry.isIntersecting) {\n this._activeTarget = null\n this._clearActiveClass(targetElement(entry))\n\n continue\n }\n\n const entryIsLowerThanPrevious = entry.target.offsetTop >= this._previousScrollData.visibleEntryTop\n // if we are scrolling down, pick the bigger offsetTop\n if (userScrollsDown && entryIsLowerThanPrevious) {\n activate(entry)\n // if parent isn't scrolled, let's keep the first visible item, breaking the iteration\n if (!parentScrollTop) {\n return\n }\n\n continue\n }\n\n // if we are scrolling up, pick the smallest offsetTop\n if (!userScrollsDown && !entryIsLowerThanPrevious) {\n activate(entry)\n }\n }\n }\n\n _initializeTargetsAndObservables() {\n this._targetLinks = new Map()\n this._observableSections = new Map()\n\n const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target)\n\n for (const anchor of targetLinks) {\n // ensure that the anchor has an id and is not disabled\n if (!anchor.hash || isDisabled(anchor)) {\n continue\n }\n\n const observableSection = SelectorEngine.findOne(decodeURI(anchor.hash), this._element)\n\n // ensure that the observableSection exists & is visible\n if (isVisible(observableSection)) {\n this._targetLinks.set(decodeURI(anchor.hash), anchor)\n this._observableSections.set(anchor.hash, observableSection)\n }\n }\n }\n\n _process(target) {\n if (this._activeTarget === target) {\n return\n }\n\n this._clearActiveClass(this._config.target)\n this._activeTarget = target\n target.classList.add(CLASS_NAME_ACTIVE)\n this._activateParents(target)\n\n EventHandler.trigger(this._element, EVENT_ACTIVATE, { relatedTarget: target })\n }\n\n _activateParents(target) {\n // Activate dropdown parents\n if (target.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) {\n SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, target.closest(SELECTOR_DROPDOWN))\n .classList.add(CLASS_NAME_ACTIVE)\n return\n }\n\n for (const listGroup of SelectorEngine.parents(target, SELECTOR_NAV_LIST_GROUP)) {\n // Set triggered links parents as active\n // With both