WIP(initial-implementation): parallel batch P2/P3/P5 — code complete, unreviewed
Snapshot of the parallel batch (Phases 2 + 3 + 5) at session pause. Solution does
NOT build cleanly yet — known cross-phase compile issues remain to be resolved
before review. See plans/initial-implementation/PLAN.md "Resume Notes" section
for the exact tomorrow-morning action list.
Phase 2 (Storage):
- Repository interfaces in Marathon.Application/Abstractions
- DateRange, ExportKind, StorageOptions in Marathon.Application/Storage
- EF Core 8 + SQLite (WAL) persistence: 7 entities + configurations + 4 repos
- Hand-written InitialCreate migration (dotnet ef blocked by parallel work)
- ClosedXML ExcelExporter with exact customer-spec wide columns
- PersistenceModule.AddMarathonPersistence DI extension
- Round-trip + export tests (cannot run yet — see cross-phase issues)
Phase 3 (Scraping):
- IOddsScraper, IBetPlacer in Marathon.Application/Abstractions
- ScrapingOptions in Marathon.Infrastructure/Configuration
- MarathonbetScraper with 4 parsers (Upcoming, Live, EventOdds, Results)
- Helpers: ServerTimeProvider, PeriodScopeMapper, OutcomeCodeMapper, MoscowDateParser
- UserAgentRotatorHandler + Polly v8 resilience pipeline
- ScrapingModule.AddMarathonScraping DI extension
- GlobalUsings.cs aliases for EventId / Configuration disambiguation
- Parser tests with trimmed HTML fixtures
- ScrapeResultsAsync interim no-op (Phase 8 will replace via watch-list polling)
Phase 5 (UI shell — killed mid-final-verify, assumed ~95%):
- Marathon.UI populated: MainLayout, App.razor, Pages (Home, Settings),
Components, Theme (MarathonTheme.cs + Tokens.cs + app.css), Resources
(SharedResource.{cs,ru.resx,en.resx}), Services (ISettingsWriter), wwwroot
- WPF host: App.xaml(.cs), MainWindow.xaml(.cs), Marathon.Hosts.WpfBlazor.csproj
with Microsoft.AspNetCore.Components.WebView.Wpf + MudBlazor + Serilog
- appsettings.json + appsettings.Development.json with all sections wired
- bUnit tests: MainLayoutTests, LocaleSwitcherTests, ThemeToggleTests,
JsonSettingsWriterTests + Support helpers
Cross-phase issues to resolve at next session:
1. Phase 2 repository classes are 'internal' — Phase 3's tests can't reference
them. Fix: add InternalsVisibleTo to Marathon.Infrastructure.csproj.
2. Phase 5: LocalizationOptions namespace ambiguity (AspNetCore vs Extensions).
3. Phase 5: WpfBlazor Serilog API mismatch.
Reviewer has NOT run on this batch. Move to Phase 4 only after build is green
and a combined parallel-batch reviewer passes.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
@*
|
||||
Top-level Blazor router. Mounted at #app inside index.html via the host's
|
||||
BlazorWebView RootComponents collection.
|
||||
*@
|
||||
<Router AppAssembly="@typeof(App).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
|
||||
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="@typeof(MainLayout)">
|
||||
<div class="m-shell">
|
||||
<p class="m-kicker">404</p>
|
||||
<h1 class="m-display" style="font-size: 2.5rem;">Страница не найдена</h1>
|
||||
<p>Запрошенный маршрут не существует.</p>
|
||||
</div>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
@@ -0,0 +1,10 @@
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<a href="/" class="m-brand @Class" aria-label="@L["App.Title"]">
|
||||
<span class="m-brand__mark">@L["App.BrandMark"]</span>
|
||||
<span class="m-brand__dateline">@L["App.Dateline"]</span>
|
||||
</a>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Class { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<div class="m-field-row">
|
||||
<div>
|
||||
<label style="font-weight: 500; font-size: 0.9375rem;">@Label</label>
|
||||
@if (!string.IsNullOrEmpty(Hint))
|
||||
{
|
||||
<div class="m-field-row__hint">@Hint</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
@ChildContent
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
|
||||
[Parameter] public string? Hint { get; set; }
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
@using LocalizationOptions = Marathon.UI.Services.LocalizationOptions
|
||||
@inject LocaleState LocaleState
|
||||
@inject ISettingsWriter SettingsWriter
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject ILogger<LocaleSwitcher> Logger
|
||||
|
||||
<div class="m-segmented" role="group" aria-label="@L["Locale.Tooltip.Switch"]">
|
||||
<button type="button"
|
||||
class="m-segmented__btn @(IsActive(LocaleState.Russian) ? "is-active" : null)"
|
||||
aria-pressed="@IsActive(LocaleState.Russian).ToString().ToLowerInvariant()"
|
||||
@onclick="@(() => SwitchAsync(LocaleState.Russian))">
|
||||
@L["Locale.Russian"]
|
||||
</button>
|
||||
<button type="button"
|
||||
class="m-segmented__btn @(IsActive(LocaleState.English) ? "is-active" : null)"
|
||||
aria-pressed="@IsActive(LocaleState.English).ToString().ToLowerInvariant()"
|
||||
@onclick="@(() => SwitchAsync(LocaleState.English))">
|
||||
@L["Locale.English"]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool IsActive(string culture) =>
|
||||
string.Equals(LocaleState.Culture.Name, culture, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task SwitchAsync(string culture)
|
||||
{
|
||||
if (IsActive(culture))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
LocaleState.Set(culture);
|
||||
await SettingsWriter.SaveSectionAsync(
|
||||
LocalizationOptions.SectionName,
|
||||
new LocalizationOptions { DefaultCulture = culture });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to persist locale {Culture}", culture);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<nav class="m-nav" aria-label="primary">
|
||||
<div style="padding: var(--m-space-5) var(--m-space-4) var(--m-space-3); border-bottom: 1px solid rgba(231,229,228,0.10);">
|
||||
<div style="font-family: var(--m-font-display); font-size: 1.25rem; color: #fafaf7;">
|
||||
<span style="color: var(--m-c-accent);">M</span>arathon
|
||||
</div>
|
||||
<div style="font-family: var(--m-font-mono); font-size: 0.6875rem; letter-spacing: 0.18em; text-transform: uppercase; color: rgba(231,229,228,0.55); margin-top: 4px;">
|
||||
Odds Lab · v0.1
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-nav__group">@L["Nav.Section.Analysis"]</div>
|
||||
<NavLink class="m-nav__link" href="" Match="NavLinkMatch.All">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.GridView" Size="Size.Small" />
|
||||
<span>@L["Nav.Dashboard"]</span>
|
||||
</NavLink>
|
||||
<NavLink class="m-nav__link" href="prematch">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Schedule" Size="Size.Small" />
|
||||
<span>@L["Nav.PreMatch"]</span>
|
||||
</NavLink>
|
||||
<NavLink class="m-nav__link" href="live">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Bolt" Size="Size.Small" />
|
||||
<span>@L["Nav.Live"]</span>
|
||||
</NavLink>
|
||||
<NavLink class="m-nav__link" href="anomalies">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Warning" Size="Size.Small" />
|
||||
<span>@L["Nav.Anomalies"]</span>
|
||||
</NavLink>
|
||||
<NavLink class="m-nav__link" href="results">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Done" Size="Size.Small" />
|
||||
<span>@L["Nav.Results"]</span>
|
||||
</NavLink>
|
||||
|
||||
<div class="m-nav__group" style="margin-top: var(--m-space-5);">@L["Nav.Section.System"]</div>
|
||||
<NavLink class="m-nav__link" href="settings">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Tune" Size="Size.Small" />
|
||||
<span>@L["Nav.Settings"]</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
@@ -0,0 +1,30 @@
|
||||
<li style="display: grid; grid-template-columns: 36px 1fr auto; gap: var(--m-space-3); align-items: center; padding: var(--m-space-2) 0;">
|
||||
<span class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); letter-spacing: 0.12em;">@Index</span>
|
||||
<span style="font-size: 0.9375rem;">@Label</span>
|
||||
<span style="display: inline-flex; align-items: center; gap: 6px; font-family: var(--m-font-mono); font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.14em; color: @StatusColor;">
|
||||
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: @StatusColor;"></span>
|
||||
@StatusLabel
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public string Index { get; set; } = string.Empty;
|
||||
[Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
|
||||
[Parameter] public string Status { get; set; } = "idle";
|
||||
|
||||
private string StatusColor => Status switch
|
||||
{
|
||||
"ok" => "var(--m-c-positive)",
|
||||
"warn" => "var(--m-c-accent)",
|
||||
"error" => "var(--m-c-anomaly)",
|
||||
_ => "var(--m-c-ink-soft)",
|
||||
};
|
||||
|
||||
private string StatusLabel => Status switch
|
||||
{
|
||||
"ok" => "OK",
|
||||
"warn" => "WAIT",
|
||||
"error" => "FAIL",
|
||||
_ => "IDLE",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; gap: var(--m-space-3); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule); margin-top: var(--m-space-2);">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OnSave">
|
||||
@L["Settings.Action.Save"]
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public EventCallback OnSave { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<div class="m-card @(Anomaly ? "m-card--anomaly" : null)" style="display: flex; flex-direction: column; gap: var(--m-space-2);">
|
||||
<span class="m-stat__label">@Label</span>
|
||||
<span class="m-stat__value">@Value</span>
|
||||
@if (!string.IsNullOrEmpty(Delta))
|
||||
{
|
||||
<span class="m-stat__delta @(Anomaly ? "m-stat__delta--down" : null)" style="color: @(Anomaly ? "var(--m-c-anomaly)" : "var(--m-c-ink-soft)");">
|
||||
@Delta
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
|
||||
[Parameter, EditorRequired] public string Value { get; set; } = string.Empty;
|
||||
[Parameter] public string? Delta { get; set; }
|
||||
[Parameter] public bool Anomaly { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@inject ThemeState ThemeState
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<MudTooltip Text="@(ThemeState.IsDark ? L["Theme.Toggle.Light"] : L["Theme.Toggle.Dark"])">
|
||||
<MudIconButton
|
||||
Icon="@(ThemeState.IsDark ? Icons.Material.Outlined.LightMode : Icons.Material.Outlined.DarkMode)"
|
||||
Color="Color.Inherit"
|
||||
OnClick="OnToggle"
|
||||
aria-label="@(ThemeState.IsDark ? L["Theme.Toggle.Light"] : L["Theme.Toggle.Dark"])" />
|
||||
</MudTooltip>
|
||||
|
||||
@code {
|
||||
private void OnToggle() => ThemeState.Toggle();
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inject ThemeState ThemeState
|
||||
@inject LocaleState LocaleState
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<MudThemeProvider IsDarkMode="@ThemeState.IsDark" Theme="@_theme" />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider FullWidth="true" MaxWidth="MaxWidth.Small" CloseOnEscapeKey="true" />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<div class="m-app-frame @(_drawerOpen ? "is-drawer-open" : null)" data-theme="@(ThemeState.IsDark ? "dark" : "light")">
|
||||
|
||||
<header class="m-appbar">
|
||||
<MudIconButton
|
||||
Icon="@Icons.Material.Outlined.Menu"
|
||||
Color="Color.Inherit"
|
||||
Edge="Edge.Start"
|
||||
OnClick="ToggleDrawer"
|
||||
aria-label="@L["Nav.Section.Analysis"]" />
|
||||
|
||||
<AppBrand Class="m-rise m-rise-1" />
|
||||
|
||||
<div class="m-appbar__spacer"></div>
|
||||
|
||||
<div class="m-appbar__tools m-rise m-rise-2">
|
||||
<LocaleSwitcher />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MudDrawer
|
||||
@bind-Open="_drawerOpen"
|
||||
Anchor="Anchor.Left"
|
||||
Variant="DrawerVariant.Responsive"
|
||||
ClipMode="DrawerClipMode.Always"
|
||||
Elevation="0"
|
||||
Width="248px"
|
||||
Color="Color.Dark">
|
||||
<NavBody />
|
||||
</MudDrawer>
|
||||
|
||||
<main class="m-main">
|
||||
<CascadingValue Value="ThemeState">
|
||||
<CascadingValue Value="LocaleState">
|
||||
@Body
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
</main>
|
||||
|
||||
<footer class="m-footer">
|
||||
<span class="m-kicker">Marathon Odds Lab</span>
|
||||
<span style="font-family: var(--m-font-mono); font-size: 0.6875rem; color: var(--m-c-ink-soft); letter-spacing: 0.16em; text-transform: uppercase;">
|
||||
Phase 5 · Editorial-Quant · v0.1
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.m-app-frame {
|
||||
display: grid;
|
||||
grid-template-rows: 60px 1fr 36px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.m-appbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--m-space-3);
|
||||
padding: 0 clamp(var(--m-space-3), 2vw, var(--m-space-5));
|
||||
border-bottom: 1px solid var(--m-c-rule);
|
||||
background: var(--m-c-paper);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.m-appbar__spacer { flex: 1; }
|
||||
.m-appbar__tools { display: inline-flex; gap: var(--m-space-3); align-items: center; }
|
||||
|
||||
.m-main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 clamp(var(--m-space-3), 2vw, var(--m-space-5));
|
||||
border-top: 1px solid var(--m-c-rule);
|
||||
background: var(--m-c-paper);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .m-appbar,
|
||||
[data-theme="dark"] .m-footer {
|
||||
background: var(--m-c-paper-2);
|
||||
border-color: var(--m-c-rule);
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private bool _drawerOpen = true;
|
||||
private MudBlazor.MudTheme _theme = Theme.MarathonTheme.Build();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
ThemeState.OnChange += StateHasChanged;
|
||||
LocaleState.OnChange += StateHasChanged;
|
||||
}
|
||||
|
||||
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ThemeState.OnChange -= StateHasChanged;
|
||||
LocaleState.OnChange -= StateHasChanged;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>Marathon.UI</RootNamespace>
|
||||
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -10,6 +12,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" />
|
||||
<PackageReference Include="MudBlazor" />
|
||||
<PackageReference Include="Microsoft.Extensions.Localization" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -17,4 +27,13 @@
|
||||
<ProjectReference Include="..\Marathon.Application\Marathon.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Resources\SharedResource.ru.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Update="Resources\SharedResource.en.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@page "/anomalies"
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.Anomalies"]</PageTitle>
|
||||
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Anomalies"]" />
|
||||
@@ -0,0 +1,78 @@
|
||||
@page "/"
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.Dashboard"]</PageTitle>
|
||||
|
||||
<section class="m-shell">
|
||||
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
|
||||
<span class="m-kicker">@L["Home.Kicker"]</span>
|
||||
<h1 class="m-display" style="font-size: clamp(2.5rem, 5vw, 4rem);">@L["Home.Title"]</h1>
|
||||
<p style="font-size: 1.0625rem; line-height: 1.5; color: var(--m-c-ink-soft); max-width: 60ch;">
|
||||
@L["Home.Lede"]
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
<div class="m-grid--three m-rise m-rise-2">
|
||||
<StatCard Label="@L["Home.Stat.Events"]" Value="@_eventsTracked.ToString("N0")" Delta="+12%" />
|
||||
<StatCard Label="@L["Home.Stat.Snapshots"]" Value="@_snapshotsToday.ToString("N0")" Delta="+318" />
|
||||
<StatCard Label="@L["Home.Stat.Anomalies"]" Value="@_anomalies.ToString()" Delta="3 NEW" Anomaly="true" />
|
||||
<StatCard Label="@L["Home.Stat.Coverage"]" Value="4" Delta="BSK · FBL · TNS · HKY" />
|
||||
</div>
|
||||
|
||||
<div class="m-grid--asym m-rise m-rise-3" style="margin-top: var(--m-space-6);">
|
||||
<div class="m-card">
|
||||
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
|
||||
@L["Home.Section.Latest"]
|
||||
</span>
|
||||
<h2 style="font-family: var(--m-font-display); font-weight: 400; font-size: 1.625rem; margin: var(--m-space-3) 0 var(--m-space-5);">
|
||||
@L["Anomaly.Kind.SuspensionFlip"]
|
||||
</h2>
|
||||
|
||||
<div style="display: grid; gap: var(--m-space-4);">
|
||||
@foreach (var item in _placeholderFeed)
|
||||
{
|
||||
<article style="display: grid; grid-template-columns: 80px 1fr auto; gap: var(--m-space-4); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule);">
|
||||
<div class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.1em;">
|
||||
@item.Time
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight: 500;">@item.Match</div>
|
||||
<div style="color: var(--m-c-ink-soft); font-size: 0.8125rem;">@item.Detail</div>
|
||||
</div>
|
||||
<span class="m-anomaly">
|
||||
<span class="m-anomaly__pulse"></span>
|
||||
@($"{item.Score:0.00}")
|
||||
</span>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div style="margin-top: var(--m-space-5); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule); color: var(--m-c-ink-soft); font-size: 0.8125rem;">
|
||||
@L["Home.Empty"]
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="m-card m-card--accented">
|
||||
<span class="m-kicker">@L["Home.Section.Pipeline"]</span>
|
||||
<ol style="list-style: none; padding: 0; margin: var(--m-space-4) 0 0; display: grid; gap: var(--m-space-3); counter-reset: m-step;">
|
||||
<PipelineStep Index="01" Label="@L["Home.Pipeline.Step1"]" Status="ok" />
|
||||
<PipelineStep Index="02" Label="@L["Home.Pipeline.Step2"]" Status="ok" />
|
||||
<PipelineStep Index="03" Label="@L["Home.Pipeline.Step3"]" Status="warn" />
|
||||
<PipelineStep Index="04" Label="@L["Home.Pipeline.Step4"]" Status="idle" />
|
||||
</ol>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
// Mock data — Phase 6+ will replace with live queries.
|
||||
private readonly int _eventsTracked = 0;
|
||||
private readonly int _snapshotsToday = 0;
|
||||
private readonly int _anomalies = 0;
|
||||
|
||||
private record FeedItem(string Time, string Match, string Detail, decimal Score);
|
||||
|
||||
private readonly List<FeedItem> _placeholderFeed = new();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@page "/live"
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.Live"]</PageTitle>
|
||||
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Live"]" />
|
||||
@@ -0,0 +1,19 @@
|
||||
@*
|
||||
Lightweight placeholders for routes that Phase 6/7/8 will replace. Keeping
|
||||
them here means the navigation drawer is fully wired today; later phases
|
||||
just convert each @page block into a real component file.
|
||||
*@
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<section class="m-shell">
|
||||
<span class="m-kicker">@Surface</span>
|
||||
<h1 class="m-display" style="font-size: clamp(1.75rem, 3vw, 2.5rem);">@Title</h1>
|
||||
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">
|
||||
Coming in a later phase. The visual language defined in Phase 5 will carry through unchanged.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Surface { get; set; } = string.Empty;
|
||||
[Parameter] public string Title { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@page "/prematch"
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.PreMatch"]</PageTitle>
|
||||
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.PreMatch"]" />
|
||||
@@ -0,0 +1,5 @@
|
||||
@page "/results"
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.Results"]</PageTitle>
|
||||
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Results"]" />
|
||||
@@ -0,0 +1,272 @@
|
||||
@page "/settings"
|
||||
@using Marathon.Application.Storage
|
||||
@using LocalizationOptions = Marathon.UI.Services.LocalizationOptions
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject IOptionsMonitor<ScrapingSettingsForm> ScrapingOpts
|
||||
@inject IOptionsMonitor<WorkerOptions> WorkerOpts
|
||||
@inject IOptionsMonitor<StorageOptions> StorageOpts
|
||||
@inject IOptionsMonitor<AnomalyOptions> AnomalyOpts
|
||||
@inject IOptionsMonitor<Marathon.UI.Services.LocalizationOptions> LocaleOpts
|
||||
@inject ISettingsWriter Writer
|
||||
@inject IDialogService Dialogs
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<Settings> Logger
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Settings.Title"]</PageTitle>
|
||||
|
||||
<section class="m-shell">
|
||||
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
|
||||
<span class="m-kicker">@L["Settings.Kicker"]</span>
|
||||
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Settings.Title"]</h1>
|
||||
<p style="color: var(--m-c-ink-soft); max-width: 70ch;">@L["Settings.Lede"]</p>
|
||||
</header>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
@* SCRAPING *@
|
||||
<article class="m-section m-rise m-rise-2">
|
||||
<header class="m-section__head">
|
||||
<h2>@L["Settings.Section.Scraping"]</h2>
|
||||
<MudButton Variant="Variant.Text"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => ResetSectionAsync(ScrapingSettingsForm.SectionName))">
|
||||
@L["Settings.Action.Reset"]
|
||||
</MudButton>
|
||||
</header>
|
||||
<div class="m-section__body">
|
||||
<Field Label="@L["Settings.Scraping.PollingIntervalSeconds"]" Hint="@L["Settings.Scraping.PollingIntervalSeconds.Hint"]">
|
||||
<MudNumericField T="int" @bind-Value="_scraping.PollingIntervalSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Scraping.MaxConcurrentRequests"]" Hint="@L["Settings.Scraping.MaxConcurrentRequests.Hint"]">
|
||||
<MudNumericField T="int" @bind-Value="_scraping.MaxConcurrentRequests" Min="1" Max="16" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Scraping.RateLimitRps"]" Hint="@L["Settings.Scraping.RateLimitRps.Hint"]">
|
||||
<MudNumericField T="int" @bind-Value="_scraping.RateLimit.RequestsPerSecond" Min="1" Max="20" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Scraping.RetryMaxAttempts"]">
|
||||
<MudNumericField T="int" @bind-Value="_scraping.RetryPolicy.MaxAttempts" Min="0" Max="10" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Scraping.RetryBaseDelayMs"]">
|
||||
<MudNumericField T="int" @bind-Value="_scraping.RetryPolicy.BaseDelayMs" Min="100" Max="60000" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Scraping.BaseUrl"]">
|
||||
<MudTextField T="string" @bind-Value="_scraping.BaseUrl" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Scraping.RequestTimeoutSeconds"]">
|
||||
<MudNumericField T="int" @bind-Value="_scraping.RequestTimeoutSeconds" Min="5" Max="600" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Scraping.UserAgents"]" Hint="@L["Settings.Scraping.UserAgents.Hint"]">
|
||||
<MudTextField T="string"
|
||||
Value="@_userAgentsRaw"
|
||||
ValueChanged="@OnUserAgentsChanged"
|
||||
Lines="4"
|
||||
Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Scraping.UsePlaywright"]">
|
||||
<MudSwitch T="bool" @bind-Value="_scraping.UsePlaywright" Color="Color.Primary" />
|
||||
</Field>
|
||||
|
||||
<SectionFooter OnSave="@(() => SaveSectionAsync(ScrapingSettingsForm.SectionName, _scraping))" />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@* WORKERS *@
|
||||
<article class="m-section m-rise m-rise-3">
|
||||
<header class="m-section__head">
|
||||
<h2>@L["Settings.Section.Workers"]</h2>
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small"
|
||||
OnClick="@(() => ResetSectionAsync(WorkerOptions.SectionName))">
|
||||
@L["Settings.Action.Reset"]
|
||||
</MudButton>
|
||||
</header>
|
||||
<div class="m-section__body">
|
||||
<Field Label="@L["Settings.Workers.UpcomingScheduleCron"]" Hint="@L["Settings.Workers.UpcomingScheduleCron.Hint"]">
|
||||
<MudTextField T="string" @bind-Value="_workers.UpcomingScheduleCron" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Workers.UpcomingPollerEnabled"]">
|
||||
<MudSwitch T="bool" @bind-Value="_workers.UpcomingPollerEnabled" Color="Color.Primary" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Workers.LivePollerEnabled"]">
|
||||
<MudSwitch T="bool" @bind-Value="_workers.LivePollerEnabled" Color="Color.Primary" />
|
||||
</Field>
|
||||
|
||||
<SectionFooter OnSave="@(() => SaveSectionAsync(WorkerOptions.SectionName, _workers))" />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@* STORAGE *@
|
||||
<article class="m-section m-rise m-rise-4">
|
||||
<header class="m-section__head">
|
||||
<h2>@L["Settings.Section.Storage"]</h2>
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small"
|
||||
OnClick="@(() => ResetSectionAsync(StorageOptions.SectionName))">
|
||||
@L["Settings.Action.Reset"]
|
||||
</MudButton>
|
||||
</header>
|
||||
<div class="m-section__body">
|
||||
<Field Label="@L["Settings.Storage.DatabasePath"]">
|
||||
<MudTextField T="string" @bind-Value="_storage.DatabasePath" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Storage.ExportDirectory"]">
|
||||
<MudTextField T="string" @bind-Value="_storage.ExportDirectory" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Storage.SnapshotRetentionDays"]">
|
||||
<MudNumericField T="int" @bind-Value="_storage.SnapshotRetentionDays" Min="1" Max="3650" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
|
||||
<SectionFooter OnSave="@(() => SaveSectionAsync(StorageOptions.SectionName, _storage))" />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@* ANOMALY *@
|
||||
<article class="m-section m-rise m-rise-5">
|
||||
<header class="m-section__head">
|
||||
<h2>@L["Settings.Section.Anomaly"]</h2>
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small"
|
||||
OnClick="@(() => ResetSectionAsync(AnomalyOptions.SectionName))">
|
||||
@L["Settings.Action.Reset"]
|
||||
</MudButton>
|
||||
</header>
|
||||
<div class="m-section__body">
|
||||
<Field Label="@L["Settings.Anomaly.SuspensionGapSeconds"]">
|
||||
<MudNumericField T="int" @bind-Value="_anomaly.SuspensionGapSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Anomaly.OddsFlipThreshold"]">
|
||||
<MudNumericField T="decimal" @bind-Value="_anomaly.OddsFlipThreshold" Min="0.01m" Max="1m" Step="0.01m" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Anomaly.MinSnapshotCount"]">
|
||||
<MudNumericField T="int" @bind-Value="_anomaly.MinSnapshotCount" Min="2" Max="100" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Anomaly.DetectionIntervalSeconds"]">
|
||||
<MudNumericField T="int" @bind-Value="_anomaly.DetectionIntervalSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
|
||||
<SectionFooter OnSave="@(() => SaveSectionAsync(AnomalyOptions.SectionName, _anomaly))" />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@* LOCALIZATION *@
|
||||
<article class="m-section m-rise m-rise-5">
|
||||
<header class="m-section__head">
|
||||
<h2>@L["Settings.Section.Localization"]</h2>
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small"
|
||||
OnClick="@(() => ResetSectionAsync(LocalizationOptions.SectionName))">
|
||||
@L["Settings.Action.Reset"]
|
||||
</MudButton>
|
||||
</header>
|
||||
<div class="m-section__body">
|
||||
<Field Label="@L["Settings.Localization.DefaultCulture"]">
|
||||
<MudSelect T="string" @bind-Value="_locale.DefaultCulture" Variant="Variant.Outlined">
|
||||
<MudSelectItem T="string" Value="@LocaleState.Russian">@L["Locale.Russian"] · ru-RU</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@LocaleState.English">@L["Locale.English"] · en-US</MudSelectItem>
|
||||
</MudSelect>
|
||||
</Field>
|
||||
|
||||
<SectionFooter OnSave="@(() => SaveSectionAsync(LocalizationOptions.SectionName, _locale))" />
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private ScrapingSettingsForm _scraping = new();
|
||||
private WorkerOptions _workers = new();
|
||||
private StorageOptions _storage = new();
|
||||
private AnomalyOptions _anomaly = new();
|
||||
private LocalizationOptions _locale = new();
|
||||
private string _userAgentsRaw = string.Empty;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_scraping = ScrapingOpts.CurrentValue.Clone();
|
||||
_userAgentsRaw = string.Join('\n', _scraping.UserAgents ?? Array.Empty<string>());
|
||||
|
||||
_workers = new WorkerOptions
|
||||
{
|
||||
UpcomingScheduleCron = WorkerOpts.CurrentValue.UpcomingScheduleCron,
|
||||
LivePollerEnabled = WorkerOpts.CurrentValue.LivePollerEnabled,
|
||||
UpcomingPollerEnabled = WorkerOpts.CurrentValue.UpcomingPollerEnabled,
|
||||
};
|
||||
|
||||
_storage = new StorageOptions
|
||||
{
|
||||
DatabasePath = StorageOpts.CurrentValue.DatabasePath,
|
||||
ExportDirectory = StorageOpts.CurrentValue.ExportDirectory,
|
||||
SnapshotRetentionDays = StorageOpts.CurrentValue.SnapshotRetentionDays,
|
||||
};
|
||||
|
||||
_anomaly = new AnomalyOptions
|
||||
{
|
||||
SuspensionGapSeconds = AnomalyOpts.CurrentValue.SuspensionGapSeconds,
|
||||
OddsFlipThreshold = AnomalyOpts.CurrentValue.OddsFlipThreshold,
|
||||
MinSnapshotCount = AnomalyOpts.CurrentValue.MinSnapshotCount,
|
||||
DetectionIntervalSeconds = AnomalyOpts.CurrentValue.DetectionIntervalSeconds,
|
||||
};
|
||||
|
||||
_locale = new LocalizationOptions { DefaultCulture = LocaleOpts.CurrentValue.DefaultCulture };
|
||||
}
|
||||
|
||||
private void OnUserAgentsChanged(string raw)
|
||||
{
|
||||
_userAgentsRaw = raw;
|
||||
_scraping.UserAgents = (raw ?? string.Empty)
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private async Task SaveSectionAsync<T>(string section, T payload) where T : class
|
||||
{
|
||||
var confirmed = await ConfirmAsync();
|
||||
if (!confirmed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Writer.SaveSectionAsync(section, payload);
|
||||
Snackbar.Add(L["Settings.Saved"], Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to save section {Section}", section);
|
||||
Snackbar.Add(L["Settings.SaveFailed"], Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ResetSectionAsync(string section)
|
||||
{
|
||||
var confirmed = await ConfirmAsync();
|
||||
if (!confirmed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Writer.ResetSectionAsync(section);
|
||||
Snackbar.Add(L["Settings.Saved"], Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to reset section {Section}", section);
|
||||
Snackbar.Add(L["Settings.SaveFailed"], Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ConfirmAsync()
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["ContentText"] = L["Settings.Confirm.Body"].Value,
|
||||
["ButtonText"] = L["Settings.Action.Save"].Value,
|
||||
["CancelText"] = L["Common.Cancel"].Value,
|
||||
};
|
||||
|
||||
var result = await Dialogs.ShowMessageBox(
|
||||
title: L["Settings.Confirm.Title"],
|
||||
message: L["Settings.Confirm.Body"],
|
||||
yesText: L["Settings.Action.Save"],
|
||||
cancelText: L["Common.Cancel"]);
|
||||
|
||||
return result == true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Marathon.UI.Resources;
|
||||
|
||||
/// <summary>
|
||||
/// Marker class for <see cref="Microsoft.Extensions.Localization.IStringLocalizer{T}"/>.
|
||||
/// Routes all <c>IStringLocalizer<SharedResource></c> lookups to the
|
||||
/// <c>SharedResource.{culture}.resx</c> files in this folder.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Key naming convention</b>: dot-segmented <c><Surface>.<Element></c>.</para>
|
||||
/// <para>Surfaces:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>App.*</c> — application chrome (title, brand, tagline)</item>
|
||||
/// <item><c>Nav.*</c> — main navigation labels</item>
|
||||
/// <item><c>Home.*</c> — dashboard page</item>
|
||||
/// <item><c>Settings.*</c> — settings page (further nested by section: <c>Settings.Scraping.*</c>)</item>
|
||||
/// <item><c>Locale.*</c> — locale switcher labels</item>
|
||||
/// <item><c>Theme.*</c> — theme toggle labels</item>
|
||||
/// <item><c>Common.*</c> — shared verbs/nouns (Save, Cancel, Reset)</item>
|
||||
/// <item><c>Anomaly.*</c> — anomaly feed (Phase 7 placeholder)</item>
|
||||
/// </list>
|
||||
/// <para>Add new keys to BOTH <c>SharedResource.ru.resx</c> AND <c>SharedResource.en.resx</c>.</para>
|
||||
/// </remarks>
|
||||
public sealed class SharedResource
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
|
||||
<resheader name="version"><value>2.0</value></resheader>
|
||||
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
|
||||
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
|
||||
|
||||
<data name="App.Title"><value>Marathon Odds Lab</value></data>
|
||||
<data name="App.Tagline"><value>Odds analytics for marathonbet.by</value></data>
|
||||
<data name="App.BrandMark"><value>Marathon</value></data>
|
||||
<data name="App.Dateline"><value>Odds Laboratory</value></data>
|
||||
|
||||
<data name="Nav.Section.Analysis"><value>Analysis</value></data>
|
||||
<data name="Nav.Section.System"><value>System</value></data>
|
||||
<data name="Nav.Dashboard"><value>Dashboard</value></data>
|
||||
<data name="Nav.PreMatch"><value>Pre-match</value></data>
|
||||
<data name="Nav.Live"><value>Live</value></data>
|
||||
<data name="Nav.Anomalies"><value>Anomalies</value></data>
|
||||
<data name="Nav.Results"><value>Results</value></data>
|
||||
<data name="Nav.Settings"><value>Settings</value></data>
|
||||
|
||||
<data name="Home.Kicker"><value>Briefing</value></data>
|
||||
<data name="Home.Title"><value>Hunting odds-flip anomalies</value></data>
|
||||
<data name="Home.Lede"><value>We snapshot marathonbet.by lines on a schedule, watch for favorite-underdog reversals, and keep evidence for every anomaly.</value></data>
|
||||
<data name="Home.Stat.Events"><value>Events tracked</value></data>
|
||||
<data name="Home.Stat.Snapshots"><value>Snapshots today</value></data>
|
||||
<data name="Home.Stat.Anomalies"><value>Anomalies flagged</value></data>
|
||||
<data name="Home.Stat.Coverage"><value>Sports covered</value></data>
|
||||
<data name="Home.Section.Latest"><value>Latest signals</value></data>
|
||||
<data name="Home.Section.Pipeline"><value>Capture pipeline</value></data>
|
||||
<data name="Home.Pipeline.Step1"><value>Schedule capture (`/su`)</value></data>
|
||||
<data name="Home.Pipeline.Step2"><value>Odds snapshot</value></data>
|
||||
<data name="Home.Pipeline.Step3"><value>Flip detector</value></data>
|
||||
<data name="Home.Pipeline.Step4"><value>XLSX export</value></data>
|
||||
<data name="Home.Empty"><value>No data yet. Enable the background pollers in Settings to start the feed.</value></data>
|
||||
|
||||
<data name="Settings.Kicker"><value>Configuration</value></data>
|
||||
<data name="Settings.Title"><value>Settings</value></data>
|
||||
<data name="Settings.Lede"><value>Every scraper, storage, detector, and locale parameter. Changes are written to appsettings.Local.json and applied live.</value></data>
|
||||
<data name="Settings.Section.Scraping"><value>Scraping</value></data>
|
||||
<data name="Settings.Section.Workers"><value>Background workers</value></data>
|
||||
<data name="Settings.Section.Storage"><value>Storage</value></data>
|
||||
<data name="Settings.Section.Anomaly"><value>Anomaly detector</value></data>
|
||||
<data name="Settings.Section.Localization"><value>Localization</value></data>
|
||||
<data name="Settings.Action.Reset"><value>Reset section</value></data>
|
||||
<data name="Settings.Action.Save"><value>Save</value></data>
|
||||
<data name="Settings.Action.SaveAll"><value>Save all</value></data>
|
||||
<data name="Settings.Confirm.Title"><value>Confirm changes</value></data>
|
||||
<data name="Settings.Confirm.Body"><value>Settings will be written to appsettings.Local.json and re-read by services. Continue?</value></data>
|
||||
<data name="Settings.Saved"><value>Settings saved.</value></data>
|
||||
<data name="Settings.SaveFailed"><value>Failed to save settings.</value></data>
|
||||
|
||||
<data name="Settings.Scraping.PollingIntervalSeconds"><value>Polling interval (sec)</value></data>
|
||||
<data name="Settings.Scraping.PollingIntervalSeconds.Hint"><value>How often to refresh the schedule. Minimum 5 seconds.</value></data>
|
||||
<data name="Settings.Scraping.MaxConcurrentRequests"><value>Concurrent requests</value></data>
|
||||
<data name="Settings.Scraping.MaxConcurrentRequests.Hint"><value>Cap at 8 to avoid throttling.</value></data>
|
||||
<data name="Settings.Scraping.UserAgents"><value>User-Agent pool</value></data>
|
||||
<data name="Settings.Scraping.UserAgents.Hint"><value>One UA per line. Rotated per request.</value></data>
|
||||
<data name="Settings.Scraping.RetryMaxAttempts"><value>Retry attempts</value></data>
|
||||
<data name="Settings.Scraping.RetryBaseDelayMs"><value>Base delay (ms)</value></data>
|
||||
<data name="Settings.Scraping.RateLimitRps"><value>Rate limit (RPS)</value></data>
|
||||
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Requests per second. 1 is recommended.</value></data>
|
||||
<data name="Settings.Scraping.BaseUrl"><value>Base URL</value></data>
|
||||
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Request timeout (sec)</value></data>
|
||||
<data name="Settings.Scraping.UsePlaywright"><value>Use Playwright</value></data>
|
||||
|
||||
<data name="Settings.Workers.UpcomingScheduleCron"><value>Schedule cron (UPCOMING)</value></data>
|
||||
<data name="Settings.Workers.UpcomingScheduleCron.Hint"><value>Standard cron. Defaults to every 5 minutes.</value></data>
|
||||
<data name="Settings.Workers.LivePollerEnabled"><value>Live poller enabled</value></data>
|
||||
<data name="Settings.Workers.UpcomingPollerEnabled"><value>Schedule poller enabled</value></data>
|
||||
|
||||
<data name="Settings.Storage.DatabasePath"><value>SQLite path</value></data>
|
||||
<data name="Settings.Storage.ExportDirectory"><value>Export directory</value></data>
|
||||
<data name="Settings.Storage.SnapshotRetentionDays"><value>Snapshot retention (days)</value></data>
|
||||
|
||||
<data name="Settings.Anomaly.SuspensionGapSeconds"><value>Suspension window (sec)</value></data>
|
||||
<data name="Settings.Anomaly.OddsFlipThreshold"><value>Flip threshold (Δ probability)</value></data>
|
||||
<data name="Settings.Anomaly.MinSnapshotCount"><value>Min snapshot count</value></data>
|
||||
<data name="Settings.Anomaly.DetectionIntervalSeconds"><value>Detection interval (sec)</value></data>
|
||||
|
||||
<data name="Settings.Localization.DefaultCulture"><value>Default UI language</value></data>
|
||||
|
||||
<data name="Locale.Russian"><value>RU</value></data>
|
||||
<data name="Locale.English"><value>EN</value></data>
|
||||
<data name="Locale.Tooltip.Switch"><value>Switch language</value></data>
|
||||
|
||||
<data name="Theme.Toggle.Light"><value>Light theme</value></data>
|
||||
<data name="Theme.Toggle.Dark"><value>Dark theme</value></data>
|
||||
|
||||
<data name="Common.Save"><value>Save</value></data>
|
||||
<data name="Common.Cancel"><value>Cancel</value></data>
|
||||
<data name="Common.Reset"><value>Reset</value></data>
|
||||
<data name="Common.Loading"><value>Loading…</value></data>
|
||||
<data name="Common.Empty"><value>No data</value></data>
|
||||
<data name="Common.Yes"><value>Yes</value></data>
|
||||
<data name="Common.No"><value>No</value></data>
|
||||
|
||||
<data name="Anomaly.Live"><value>Anomaly</value></data>
|
||||
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
|
||||
<data name="Anomaly.Score"><value>Confidence</value></data>
|
||||
</root>
|
||||
@@ -0,0 +1,160 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
|
||||
<resheader name="version"><value>2.0</value></resheader>
|
||||
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
|
||||
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
|
||||
|
||||
<!-- App chrome -->
|
||||
<data name="App.Title"><value>Marathon Odds Lab</value></data>
|
||||
<data name="App.Tagline"><value>Аналитика коэффициентов marathonbet.by</value></data>
|
||||
<data name="App.BrandMark"><value>Marathon</value></data>
|
||||
<data name="App.Dateline"><value>Лаборатория коэффициентов</value></data>
|
||||
|
||||
<!-- Navigation -->
|
||||
<data name="Nav.Section.Analysis"><value>Анализ</value></data>
|
||||
<data name="Nav.Section.System"><value>Система</value></data>
|
||||
<data name="Nav.Dashboard"><value>Сводка</value></data>
|
||||
<data name="Nav.PreMatch"><value>До матча</value></data>
|
||||
<data name="Nav.Live"><value>Лайв</value></data>
|
||||
<data name="Nav.Anomalies"><value>Аномалии</value></data>
|
||||
<data name="Nav.Results"><value>Результаты</value></data>
|
||||
<data name="Nav.Settings"><value>Настройки</value></data>
|
||||
|
||||
<!-- Home / Dashboard -->
|
||||
<data name="Home.Kicker"><value>Сводка</value></data>
|
||||
<data name="Home.Title"><value>Поиск аномалий в коэффициентах</value></data>
|
||||
<data name="Home.Lede"><value>Снимаем линии marathonbet.by по расписанию, ищем разворот фаворита и удерживаем доказательства каждой аномалии.</value></data>
|
||||
<data name="Home.Stat.Events"><value>Событий в работе</value></data>
|
||||
<data name="Home.Stat.Snapshots"><value>Снимков сегодня</value></data>
|
||||
<data name="Home.Stat.Anomalies"><value>Аномалий найдено</value></data>
|
||||
<data name="Home.Stat.Coverage"><value>Видов спорта</value></data>
|
||||
<data name="Home.Section.Latest"><value>Свежий поток</value></data>
|
||||
<data name="Home.Section.Pipeline"><value>Конвейер сбора</value></data>
|
||||
<data name="Home.Pipeline.Step1"><value>Сбор расписания (`/su`)</value></data>
|
||||
<data name="Home.Pipeline.Step2"><value>Снимок коэффициентов</value></data>
|
||||
<data name="Home.Pipeline.Step3"><value>Детектор разворота</value></data>
|
||||
<data name="Home.Pipeline.Step4"><value>Экспорт XLSX</value></data>
|
||||
<data name="Home.Empty"><value>Пока пусто. Запустите фоновые сборщики на странице «Настройки», чтобы пошёл поток данных.</value></data>
|
||||
|
||||
<!-- Settings — sections -->
|
||||
<data name="Settings.Kicker"><value>Конфигурация</value></data>
|
||||
<data name="Settings.Title"><value>Настройки</value></data>
|
||||
<data name="Settings.Lede"><value>Каждый параметр сборщика, хранилища, детектора и локализации. Изменения сохраняются в appsettings.Local.json и применяются на лету.</value></data>
|
||||
<data name="Settings.Section.Scraping"><value>Сбор</value></data>
|
||||
<data name="Settings.Section.Workers"><value>Фоновые задачи</value></data>
|
||||
<data name="Settings.Section.Storage"><value>Хранилище</value></data>
|
||||
<data name="Settings.Section.Anomaly"><value>Детектор аномалий</value></data>
|
||||
<data name="Settings.Section.Localization"><value>Локализация</value></data>
|
||||
<data name="Settings.Action.Reset"><value>Сбросить раздел</value></data>
|
||||
<data name="Settings.Action.Save"><value>Сохранить</value></data>
|
||||
<data name="Settings.Action.SaveAll"><value>Сохранить все</value></data>
|
||||
<data name="Settings.Confirm.Title"><value>Подтвердите изменения</value></data>
|
||||
<data name="Settings.Confirm.Body"><value>Параметры будут записаны в appsettings.Local.json и перечитаны службами. Продолжить?</value></data>
|
||||
<data name="Settings.Saved"><value>Настройки сохранены.</value></data>
|
||||
<data name="Settings.SaveFailed"><value>Не удалось сохранить настройки.</value></data>
|
||||
|
||||
<!-- Settings — Scraping -->
|
||||
<data name="Settings.Scraping.PollingIntervalSeconds"><value>Интервал опроса (сек)</value></data>
|
||||
<data name="Settings.Scraping.PollingIntervalSeconds.Hint"><value>Как часто перечитывать список матчей. Минимум 5 секунд.</value></data>
|
||||
<data name="Settings.Scraping.MaxConcurrentRequests"><value>Параллельных запросов</value></data>
|
||||
<data name="Settings.Scraping.MaxConcurrentRequests.Hint"><value>Не более 8 — иначе увидим 429.</value></data>
|
||||
<data name="Settings.Scraping.UserAgents"><value>Пул User-Agent</value></data>
|
||||
<data name="Settings.Scraping.UserAgents.Hint"><value>По одному значению на строку. Ротируется на запрос.</value></data>
|
||||
<data name="Settings.Scraping.RetryMaxAttempts"><value>Повторы при сбое</value></data>
|
||||
<data name="Settings.Scraping.RetryBaseDelayMs"><value>Базовая задержка (мс)</value></data>
|
||||
<data name="Settings.Scraping.RateLimitRps"><value>Лимит RPS</value></data>
|
||||
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Запросов в секунду. Рекомендовано 1.</value></data>
|
||||
<data name="Settings.Scraping.BaseUrl"><value>Базовый URL</value></data>
|
||||
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Тайм-аут запроса (сек)</value></data>
|
||||
<data name="Settings.Scraping.UsePlaywright"><value>Использовать Playwright</value></data>
|
||||
|
||||
<!-- Settings — Workers -->
|
||||
<data name="Settings.Workers.UpcomingScheduleCron"><value>Cron расписания (UPCOMING)</value></data>
|
||||
<data name="Settings.Workers.UpcomingScheduleCron.Hint"><value>Стандартный cron. По умолчанию каждые 5 минут.</value></data>
|
||||
<data name="Settings.Workers.LivePollerEnabled"><value>Лайв-сборщик включён</value></data>
|
||||
<data name="Settings.Workers.UpcomingPollerEnabled"><value>Сборщик расписания включён</value></data>
|
||||
|
||||
<!-- Settings — Storage -->
|
||||
<data name="Settings.Storage.DatabasePath"><value>Путь к SQLite</value></data>
|
||||
<data name="Settings.Storage.ExportDirectory"><value>Каталог экспорта</value></data>
|
||||
<data name="Settings.Storage.SnapshotRetentionDays"><value>Хранить снимки (дней)</value></data>
|
||||
|
||||
<!-- Settings — Anomaly -->
|
||||
<data name="Settings.Anomaly.SuspensionGapSeconds"><value>Окно «заморозки» (сек)</value></data>
|
||||
<data name="Settings.Anomaly.OddsFlipThreshold"><value>Порог флипа (Δ вероятности)</value></data>
|
||||
<data name="Settings.Anomaly.MinSnapshotCount"><value>Мин. число снимков</value></data>
|
||||
<data name="Settings.Anomaly.DetectionIntervalSeconds"><value>Интервал детектора (сек)</value></data>
|
||||
|
||||
<!-- Settings — Localization -->
|
||||
<data name="Settings.Localization.DefaultCulture"><value>Язык интерфейса по умолчанию</value></data>
|
||||
|
||||
<!-- Locale switcher -->
|
||||
<data name="Locale.Russian"><value>RU</value></data>
|
||||
<data name="Locale.English"><value>EN</value></data>
|
||||
<data name="Locale.Tooltip.Switch"><value>Сменить язык</value></data>
|
||||
|
||||
<!-- Theme toggle -->
|
||||
<data name="Theme.Toggle.Light"><value>Светлая тема</value></data>
|
||||
<data name="Theme.Toggle.Dark"><value>Тёмная тема</value></data>
|
||||
|
||||
<!-- Common -->
|
||||
<data name="Common.Save"><value>Сохранить</value></data>
|
||||
<data name="Common.Cancel"><value>Отмена</value></data>
|
||||
<data name="Common.Reset"><value>Сбросить</value></data>
|
||||
<data name="Common.Loading"><value>Загрузка…</value></data>
|
||||
<data name="Common.Empty"><value>Нет данных</value></data>
|
||||
<data name="Common.Yes"><value>Да</value></data>
|
||||
<data name="Common.No"><value>Нет</value></data>
|
||||
|
||||
<!-- Anomaly (Phase 7 placeholders) -->
|
||||
<data name="Anomaly.Live"><value>Аномалия</value></data>
|
||||
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
|
||||
<data name="Anomaly.Score"><value>Уверенность</value></data>
|
||||
</root>
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Options bound to the <c>Anomaly</c> section of <c>appsettings.json</c>.
|
||||
/// </summary>
|
||||
public sealed class AnomalyOptions
|
||||
{
|
||||
public const string SectionName = "Anomaly";
|
||||
|
||||
/// <summary>Suspension window after which a flip is treated as suspicious.</summary>
|
||||
public int SuspensionGapSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>Implied-probability delta that qualifies as a flip.</summary>
|
||||
public decimal OddsFlipThreshold { get; set; } = 0.30m;
|
||||
|
||||
/// <summary>Minimum snapshot count before the detector runs.</summary>
|
||||
public int MinSnapshotCount { get; set; } = 3;
|
||||
|
||||
/// <summary>How often the detector executes, in seconds.</summary>
|
||||
public int DetectionIntervalSeconds { get; set; } = 60;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists user-edited settings to <c>appsettings.Local.json</c> (gitignored).
|
||||
/// </summary>
|
||||
public interface ISettingsWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists a single configuration section under its canonical name
|
||||
/// (e.g. <c>"Scraping"</c>, <c>"Storage"</c>) to <c>appsettings.Local.json</c>.
|
||||
/// Other sections in that file are preserved.
|
||||
/// </summary>
|
||||
Task SaveSectionAsync<T>(string sectionName, T values, CancellationToken cancellationToken = default)
|
||||
where T : class;
|
||||
|
||||
/// <summary>
|
||||
/// Removes the specified section from <c>appsettings.Local.json</c>, restoring the
|
||||
/// value defined in <c>appsettings.json</c> on next configuration reload.
|
||||
/// </summary>
|
||||
Task ResetSectionAsync(string sectionName, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// File-backed implementation of <see cref="ISettingsWriter"/> that maintains
|
||||
/// <c>appsettings.Local.json</c> next to the host's <c>appsettings.json</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The host registers this with a known file path (resolved from the host's
|
||||
/// <c>ContentRootPath</c>). The file is created on first write and is gitignored
|
||||
/// by the repository's <c>.gitignore</c>.
|
||||
/// </remarks>
|
||||
public sealed class JsonSettingsWriter : ISettingsWriter
|
||||
{
|
||||
private static readonly JsonSerializerOptions ReadOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions WriteOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
private readonly string _filePath;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
public JsonSettingsWriter(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
throw new ArgumentException("Settings file path is required.", nameof(filePath));
|
||||
}
|
||||
|
||||
_filePath = filePath;
|
||||
}
|
||||
|
||||
public async Task SaveSectionAsync<T>(string sectionName, T values, CancellationToken cancellationToken = default)
|
||||
where T : class
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sectionName))
|
||||
{
|
||||
throw new ArgumentException("Section name is required.", nameof(sectionName));
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var root = await ReadRootAsync(cancellationToken).ConfigureAwait(false);
|
||||
var json = JsonSerializer.SerializeToNode(values, WriteOptions);
|
||||
root[sectionName] = json;
|
||||
await WriteRootAsync(root, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ResetSectionAsync(string sectionName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sectionName))
|
||||
{
|
||||
throw new ArgumentException("Section name is required.", nameof(sectionName));
|
||||
}
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var root = await ReadRootAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (root.ContainsKey(sectionName))
|
||||
{
|
||||
root.Remove(sectionName);
|
||||
await WriteRootAsync(root, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JsonObject> ReadRootAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
{
|
||||
return new JsonObject();
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(_filePath);
|
||||
var node = await JsonNode.ParseAsync(stream, documentOptions: new JsonDocumentOptions
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
}, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return node as JsonObject ?? new JsonObject();
|
||||
}
|
||||
|
||||
private async Task WriteRootAsync(JsonObject root, CancellationToken cancellationToken)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var tempPath = _filePath + ".tmp";
|
||||
await using (var stream = File.Create(tempPath))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, root, WriteOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Atomic rename — survives crashes mid-write.
|
||||
File.Move(tempPath, _filePath, overwrite: true);
|
||||
}
|
||||
|
||||
/// <summary>For tests: reads the persisted JSON object back.</summary>
|
||||
public async Task<JsonObject> ReadAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return await ReadRootAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Observable culture state. Components subscribe to <see cref="OnChange"/> to
|
||||
/// re-render when the user toggles the locale. Setting the value also flips
|
||||
/// <see cref="CultureInfo.DefaultThreadCurrentUICulture"/> so newly created
|
||||
/// localizers pick up the right resource.
|
||||
/// </summary>
|
||||
public sealed class LocaleState
|
||||
{
|
||||
public const string Russian = "ru-RU";
|
||||
public const string English = "en-US";
|
||||
|
||||
public static readonly IReadOnlyList<string> Supported = new[] { Russian, English };
|
||||
|
||||
private CultureInfo _culture = CultureInfo.GetCultureInfo(Russian);
|
||||
|
||||
public CultureInfo Culture
|
||||
{
|
||||
get => _culture;
|
||||
private set
|
||||
{
|
||||
if (string.Equals(_culture.Name, value.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_culture = value;
|
||||
CultureInfo.DefaultThreadCurrentCulture = value;
|
||||
CultureInfo.DefaultThreadCurrentUICulture = value;
|
||||
CultureInfo.CurrentCulture = value;
|
||||
CultureInfo.CurrentUICulture = value;
|
||||
OnChange?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public event Action? OnChange;
|
||||
|
||||
public void Set(string cultureName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cultureName))
|
||||
{
|
||||
throw new ArgumentException("Culture name is required.", nameof(cultureName));
|
||||
}
|
||||
|
||||
Culture = CultureInfo.GetCultureInfo(cultureName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Options bound to the <c>Localization</c> section of <c>appsettings.json</c>.
|
||||
/// </summary>
|
||||
public sealed class LocalizationOptions
|
||||
{
|
||||
public const string SectionName = "Localization";
|
||||
|
||||
/// <summary>The default UI culture; either <c>ru-RU</c> or <c>en-US</c>.</summary>
|
||||
public string DefaultCulture { get; set; } = "ru-RU";
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// UI-side form model that mirrors <c>Marathon.Infrastructure.Configuration.ScrapingOptions</c>.
|
||||
/// Kept here (not in Infrastructure) so the Razor Class Library remains
|
||||
/// host-agnostic — i.e. usable from a future ASP.NET Core host that doesn't
|
||||
/// reference the Infrastructure project directly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Property names match the JSON shape exactly so binding works either way.
|
||||
/// </remarks>
|
||||
public sealed class ScrapingSettingsForm
|
||||
{
|
||||
public const string SectionName = "Scraping";
|
||||
|
||||
public int PollingIntervalSeconds { get; set; } = 30;
|
||||
public int MaxConcurrentRequests { get; set; } = 4;
|
||||
public string[] UserAgents { get; set; } = Array.Empty<string>();
|
||||
public RetryPolicyForm RetryPolicy { get; set; } = new();
|
||||
public RateLimitForm RateLimit { get; set; } = new();
|
||||
public bool UsePlaywright { get; set; }
|
||||
public string BaseUrl { get; set; } = "https://www.marathonbet.by";
|
||||
public int RequestTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public ScrapingSettingsForm Clone() => new()
|
||||
{
|
||||
PollingIntervalSeconds = PollingIntervalSeconds,
|
||||
MaxConcurrentRequests = MaxConcurrentRequests,
|
||||
UserAgents = (string[])UserAgents.Clone(),
|
||||
RetryPolicy = new RetryPolicyForm
|
||||
{
|
||||
MaxAttempts = RetryPolicy.MaxAttempts,
|
||||
BaseDelayMs = RetryPolicy.BaseDelayMs,
|
||||
},
|
||||
RateLimit = new RateLimitForm { RequestsPerSecond = RateLimit.RequestsPerSecond },
|
||||
UsePlaywright = UsePlaywright,
|
||||
BaseUrl = BaseUrl,
|
||||
RequestTimeoutSeconds = RequestTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class RetryPolicyForm
|
||||
{
|
||||
public int MaxAttempts { get; set; } = 3;
|
||||
public int BaseDelayMs { get; set; } = 500;
|
||||
}
|
||||
|
||||
public sealed class RateLimitForm
|
||||
{
|
||||
public int RequestsPerSecond { get; set; } = 1;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory observable holding the current light/dark preference. Persisted
|
||||
/// (best-effort) by the host via <see cref="ISettingsWriter"/> on change.
|
||||
/// </summary>
|
||||
public sealed class ThemeState
|
||||
{
|
||||
private bool _isDark;
|
||||
|
||||
public bool IsDark
|
||||
{
|
||||
get => _isDark;
|
||||
private set
|
||||
{
|
||||
if (_isDark == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDark = value;
|
||||
OnChange?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public event Action? OnChange;
|
||||
|
||||
public void Set(bool isDark) => IsDark = isDark;
|
||||
|
||||
public void Toggle() => IsDark = !IsDark;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Marathon.Application.Storage;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MudBlazor.Services;
|
||||
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// DI registration helpers for the Marathon.UI Razor Class Library.
|
||||
/// Hosts call <see cref="AddMarathonUi(IServiceCollection, IConfiguration, string)"/>
|
||||
/// during startup.
|
||||
/// </summary>
|
||||
public static class UiServicesExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers MudBlazor services, localization, the theme/locale observable
|
||||
/// state objects, the file-backed settings writer, and binds all
|
||||
/// configuration sections that the Settings page surfaces.
|
||||
/// </summary>
|
||||
/// <param name="services">DI container.</param>
|
||||
/// <param name="configuration">Host configuration root.</param>
|
||||
/// <param name="settingsLocalPath">
|
||||
/// Absolute path to <c>appsettings.Local.json</c>, used by the writer.
|
||||
/// </param>
|
||||
public static IServiceCollection AddMarathonUi(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string settingsLocalPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentException.ThrowIfNullOrEmpty(settingsLocalPath);
|
||||
|
||||
services.AddMudServices();
|
||||
|
||||
services.AddLocalization(options => options.ResourcesPath = "Resources");
|
||||
|
||||
// Strongly typed options bound to appsettings.json sections.
|
||||
services.Configure<LocalizationOptions>(configuration.GetSection(LocalizationOptions.SectionName));
|
||||
services.Configure<WorkerOptions>(configuration.GetSection(WorkerOptions.SectionName));
|
||||
services.Configure<AnomalyOptions>(configuration.GetSection(AnomalyOptions.SectionName));
|
||||
services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
|
||||
services.Configure<ScrapingSettingsForm>(configuration.GetSection(ScrapingSettingsForm.SectionName));
|
||||
|
||||
// Singletons that drive UI chrome state.
|
||||
services.AddSingleton<ThemeState>();
|
||||
services.AddSingleton<LocaleState>();
|
||||
|
||||
// Settings writer — file path is host-resolved.
|
||||
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Options bound to the <c>Workers</c> section of <c>appsettings.json</c>.
|
||||
/// Phase 4 will read these to configure the background pollers.
|
||||
/// </summary>
|
||||
public sealed class WorkerOptions
|
||||
{
|
||||
public const string SectionName = "Workers";
|
||||
|
||||
/// <summary>Cron expression that drives the upcoming-schedule poller.</summary>
|
||||
public string UpcomingScheduleCron { get; set; } = "0 */5 * * * *";
|
||||
|
||||
/// <summary>Whether the live odds poller should run at startup.</summary>
|
||||
public bool LivePollerEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>Whether the upcoming/pre-match poller should run at startup.</summary>
|
||||
public bool UpcomingPollerEnabled { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using MudBlazor;
|
||||
using MudBlazor.Utilities;
|
||||
|
||||
namespace Marathon.UI.Theme;
|
||||
|
||||
/// <summary>
|
||||
/// The Marathon design system, expressed as a MudBlazor theme.
|
||||
///
|
||||
/// Aesthetic direction: editorial-quant. Inspired by Bloomberg terminals,
|
||||
/// FT.com long-reads, and Quartz dashboards. Confident, information-dense,
|
||||
/// reveals patterns. Pairs IBM Plex Sans (Cyrillic-capable display + body)
|
||||
/// with JetBrains Mono for tabular numerals. Anomaly accent is a load-bearing
|
||||
/// signal red so Phase 7 can hang the entire anomaly visual language off
|
||||
/// <c>palette.Error</c> without coupling to a hard-coded hex.
|
||||
/// </summary>
|
||||
public static class MarathonTheme
|
||||
{
|
||||
/// <summary>The full theme — both light and dark palettes plus typography.</summary>
|
||||
public static MudTheme Build() => new()
|
||||
{
|
||||
PaletteLight = LightPalette,
|
||||
PaletteDark = DarkPalette,
|
||||
Typography = MarathonTypography,
|
||||
LayoutProperties = LayoutProps,
|
||||
Shadows = MarathonShadows,
|
||||
ZIndex = new ZIndex(),
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Palettes — single accent (amber #d97706), single signal (red #ef4444)
|
||||
// on a deep navy/parchment chassis. No purple gradients, no cliche.
|
||||
// ------------------------------------------------------------------
|
||||
private static readonly PaletteLight LightPalette = new()
|
||||
{
|
||||
Primary = "#0f172a", // deep navy / ink
|
||||
PrimaryContrastText = "#fafaf7",
|
||||
Secondary = "#334155", // slate
|
||||
SecondaryContrastText = "#fafaf7",
|
||||
Tertiary = "#d97706", // amber accent
|
||||
TertiaryContrastText = "#1c1917",
|
||||
Info = "#0369a1",
|
||||
Success = "#15803d",
|
||||
Warning = "#b45309",
|
||||
Error = "#dc2626", // anomaly signal
|
||||
ErrorContrastText = "#fff7ed",
|
||||
|
||||
Black = "#1c1917",
|
||||
White = "#fafaf7",
|
||||
Surface = "#fafaf7", // warm parchment
|
||||
Background = "#f5f4ef", // a half-step warmer than surface
|
||||
BackgroundGray = "#ebe9e1",
|
||||
DrawerBackground = "#0f172a", // dark drawer on light app — editorial contrast
|
||||
DrawerText = "#e7e5e4",
|
||||
DrawerIcon = "#d6d3d1",
|
||||
AppbarBackground = "#fafaf7",
|
||||
AppbarText = "#0f172a",
|
||||
|
||||
TextPrimary = "#0f172a",
|
||||
TextSecondary = "#475569",
|
||||
TextDisabled = "#94a3b8",
|
||||
ActionDefault = "#334155",
|
||||
ActionDisabled = "#cbd5e1",
|
||||
ActionDisabledBackground = "#e2e8f0",
|
||||
|
||||
LinesDefault = "#e7e5e4",
|
||||
LinesInputs = "#cbd5e1",
|
||||
TableLines = "#e7e5e4",
|
||||
TableStriped = "#f5f4ef",
|
||||
TableHover = "#ebe9e1",
|
||||
Divider = "#e7e5e4",
|
||||
DividerLight = "#f1f5f9",
|
||||
|
||||
OverlayDark = new MudColor("#0f172a99").Value,
|
||||
OverlayLight = new MudColor("#fafaf7cc").Value,
|
||||
};
|
||||
|
||||
private static readonly PaletteDark DarkPalette = new()
|
||||
{
|
||||
Primary = "#fbbf24", // amber, promoted in dark mode
|
||||
PrimaryContrastText = "#0c0a09",
|
||||
Secondary = "#94a3b8",
|
||||
SecondaryContrastText = "#0c0a09",
|
||||
Tertiary = "#fbbf24",
|
||||
TertiaryContrastText = "#0c0a09",
|
||||
Info = "#38bdf8",
|
||||
Success = "#4ade80",
|
||||
Warning = "#fbbf24",
|
||||
Error = "#f87171", // anomaly signal — softened for dark
|
||||
ErrorContrastText = "#0c0a09",
|
||||
|
||||
Black = "#0c0a09",
|
||||
White = "#fafaf7",
|
||||
Surface = "#1c1917", // ink-stained paper
|
||||
Background = "#0c0a09", // near-black
|
||||
BackgroundGray = "#1c1917",
|
||||
DrawerBackground = "#0c0a09",
|
||||
DrawerText = "#e7e5e4",
|
||||
DrawerIcon = "#a8a29e",
|
||||
AppbarBackground = "#0c0a09",
|
||||
AppbarText = "#fafaf7",
|
||||
|
||||
TextPrimary = "#f5f5f4",
|
||||
TextSecondary = "#a8a29e",
|
||||
TextDisabled = "#57534e",
|
||||
ActionDefault = "#a8a29e",
|
||||
ActionDisabled = "#44403c",
|
||||
ActionDisabledBackground = "#1c1917",
|
||||
|
||||
LinesDefault = "#292524",
|
||||
LinesInputs = "#44403c",
|
||||
TableLines = "#292524",
|
||||
TableStriped = "#1c1917",
|
||||
TableHover = "#292524",
|
||||
Divider = "#292524",
|
||||
DividerLight = "#1c1917",
|
||||
|
||||
OverlayDark = new MudColor("#0c0a09cc").Value,
|
||||
OverlayLight = new MudColor("#fafaf722").Value,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Typography — IBM Plex Sans / JetBrains Mono / IBM Plex Serif (display)
|
||||
// All have full Cyrillic coverage. Numerals are tabular.
|
||||
// ------------------------------------------------------------------
|
||||
private static readonly string[] DisplayStack = { "IBM Plex Serif", "PT Serif", "Georgia", "serif" };
|
||||
private static readonly string[] BodyStack = { "IBM Plex Sans", "PT Sans", "system-ui", "sans-serif" };
|
||||
private static readonly string[] MonoStack = { "JetBrains Mono", "IBM Plex Mono", "Fira Code", "Consolas", "monospace" };
|
||||
|
||||
private static readonly Typography MarathonTypography = new()
|
||||
{
|
||||
Default = new Default
|
||||
{
|
||||
FontFamily = BodyStack,
|
||||
FontWeight = 400,
|
||||
FontSize = "0.9375rem", // 15px — denser than MUD default 16
|
||||
LineHeight = 1.55,
|
||||
LetterSpacing = "0",
|
||||
},
|
||||
H1 = new H1
|
||||
{
|
||||
FontFamily = DisplayStack,
|
||||
FontWeight = 300,
|
||||
FontSize = "clamp(2.25rem, 4vw, 3.5rem)",
|
||||
LineHeight = 1.05,
|
||||
LetterSpacing = "-0.022em",
|
||||
},
|
||||
H2 = new H2
|
||||
{
|
||||
FontFamily = DisplayStack,
|
||||
FontWeight = 400,
|
||||
FontSize = "clamp(1.75rem, 2.5vw, 2.25rem)",
|
||||
LineHeight = 1.15,
|
||||
LetterSpacing = "-0.018em",
|
||||
},
|
||||
H3 = new H3
|
||||
{
|
||||
FontFamily = DisplayStack,
|
||||
FontWeight = 500,
|
||||
FontSize = "1.5rem",
|
||||
LineHeight = 1.25,
|
||||
LetterSpacing = "-0.012em",
|
||||
},
|
||||
H4 = new H4
|
||||
{
|
||||
FontFamily = BodyStack,
|
||||
FontWeight = 600,
|
||||
FontSize = "1.25rem",
|
||||
LineHeight = 1.3,
|
||||
LetterSpacing = "-0.005em",
|
||||
},
|
||||
H5 = new H5
|
||||
{
|
||||
FontFamily = BodyStack,
|
||||
FontWeight = 600,
|
||||
FontSize = "1.0625rem",
|
||||
LineHeight = 1.35,
|
||||
},
|
||||
H6 = new H6
|
||||
{
|
||||
FontFamily = BodyStack,
|
||||
FontWeight = 600,
|
||||
FontSize = "0.9375rem",
|
||||
LineHeight = 1.4,
|
||||
LetterSpacing = "0.02em",
|
||||
},
|
||||
Subtitle1 = new Subtitle1
|
||||
{
|
||||
FontFamily = BodyStack,
|
||||
FontWeight = 500,
|
||||
FontSize = "0.9375rem",
|
||||
LineHeight = 1.5,
|
||||
},
|
||||
Subtitle2 = new Subtitle2
|
||||
{
|
||||
FontFamily = BodyStack,
|
||||
FontWeight = 500,
|
||||
FontSize = "0.8125rem",
|
||||
LineHeight = 1.5,
|
||||
LetterSpacing = "0.01em",
|
||||
},
|
||||
Body1 = new Body1
|
||||
{
|
||||
FontFamily = BodyStack,
|
||||
FontWeight = 400,
|
||||
FontSize = "0.9375rem",
|
||||
LineHeight = 1.55,
|
||||
},
|
||||
Body2 = new Body2
|
||||
{
|
||||
FontFamily = BodyStack,
|
||||
FontWeight = 400,
|
||||
FontSize = "0.8125rem",
|
||||
LineHeight = 1.5,
|
||||
},
|
||||
Button = new Button
|
||||
{
|
||||
FontFamily = BodyStack,
|
||||
FontWeight = 500,
|
||||
FontSize = "0.8125rem",
|
||||
LineHeight = 1.4,
|
||||
LetterSpacing = "0.06em",
|
||||
TextTransform = "uppercase",
|
||||
},
|
||||
Caption = new Caption
|
||||
{
|
||||
FontFamily = MonoStack,
|
||||
FontWeight = 400,
|
||||
FontSize = "0.75rem",
|
||||
LineHeight = 1.4,
|
||||
LetterSpacing = "0.04em",
|
||||
TextTransform = "uppercase",
|
||||
},
|
||||
Overline = new Overline
|
||||
{
|
||||
FontFamily = MonoStack,
|
||||
FontWeight = 500,
|
||||
FontSize = "0.6875rem",
|
||||
LineHeight = 1.4,
|
||||
LetterSpacing = "0.18em",
|
||||
TextTransform = "uppercase",
|
||||
},
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Layout — sharp corners, narrow drawer. The aesthetic earns its
|
||||
// authority through restraint.
|
||||
// ------------------------------------------------------------------
|
||||
private static readonly LayoutProperties LayoutProps = new()
|
||||
{
|
||||
DefaultBorderRadius = "2px",
|
||||
AppbarHeight = "60px",
|
||||
DrawerWidthLeft = "248px",
|
||||
DrawerWidthRight = "248px",
|
||||
DrawerMiniWidthLeft = "60px",
|
||||
DrawerMiniWidthRight = "60px",
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Shadows — flat by default, one accent shadow for floating panels.
|
||||
// Override only the slots Mud actually uses; keep first/last as-is.
|
||||
// ------------------------------------------------------------------
|
||||
private static readonly Shadow MarathonShadows = new()
|
||||
{
|
||||
Elevation = new[]
|
||||
{
|
||||
"none",
|
||||
"0 1px 0 0 rgba(15,23,42,0.06)",
|
||||
"0 1px 2px 0 rgba(15,23,42,0.08)",
|
||||
"0 2px 4px -1px rgba(15,23,42,0.10)",
|
||||
"0 4px 8px -2px rgba(15,23,42,0.12)",
|
||||
"0 6px 14px -4px rgba(15,23,42,0.14)",
|
||||
"0 8px 18px -6px rgba(15,23,42,0.16)",
|
||||
"0 10px 22px -8px rgba(15,23,42,0.18)",
|
||||
"0 12px 28px -10px rgba(15,23,42,0.20)",
|
||||
"0 14px 32px -12px rgba(15,23,42,0.22)",
|
||||
"0 16px 36px -14px rgba(15,23,42,0.24)",
|
||||
"0 18px 40px -16px rgba(15,23,42,0.26)",
|
||||
"0 20px 44px -18px rgba(15,23,42,0.28)",
|
||||
"0 22px 48px -20px rgba(15,23,42,0.30)",
|
||||
"0 24px 52px -22px rgba(15,23,42,0.32)",
|
||||
"0 26px 56px -24px rgba(15,23,42,0.34)",
|
||||
"0 28px 60px -26px rgba(15,23,42,0.36)",
|
||||
"0 30px 64px -28px rgba(15,23,42,0.38)",
|
||||
"0 32px 68px -30px rgba(15,23,42,0.40)",
|
||||
"0 34px 72px -32px rgba(15,23,42,0.42)",
|
||||
"0 36px 76px -34px rgba(15,23,42,0.44)",
|
||||
"0 38px 80px -36px rgba(15,23,42,0.46)",
|
||||
"0 40px 84px -38px rgba(15,23,42,0.48)",
|
||||
"0 42px 88px -40px rgba(15,23,42,0.50)",
|
||||
"0 44px 92px -42px rgba(15,23,42,0.52)",
|
||||
"0 46px 96px -44px rgba(15,23,42,0.54)",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Marathon.UI.Theme;
|
||||
|
||||
/// <summary>
|
||||
/// Design tokens exposed to C# code (e.g. for chart colors, custom shapes,
|
||||
/// Razor components that need to reach beyond the MudTheme palette).
|
||||
/// Mirrors the values declared as CSS variables in <c>wwwroot/app.css</c>.
|
||||
/// </summary>
|
||||
public static class Tokens
|
||||
{
|
||||
public static class Colors
|
||||
{
|
||||
public const string AnomalySignal = "#dc2626";
|
||||
public const string AnomalySignalDark = "#f87171";
|
||||
public const string Accent = "#d97706";
|
||||
public const string AccentDark = "#fbbf24";
|
||||
public const string InkPrimary = "#0f172a";
|
||||
public const string Parchment = "#fafaf7";
|
||||
public const string ParchmentDeep = "#f5f4ef";
|
||||
public const string InkDeep = "#0c0a09";
|
||||
}
|
||||
|
||||
public static class Spacing
|
||||
{
|
||||
public const string Xs = "4px";
|
||||
public const string Sm = "8px";
|
||||
public const string Md = "16px";
|
||||
public const string Lg = "24px";
|
||||
public const string Xl = "40px";
|
||||
public const string Xxl = "64px";
|
||||
}
|
||||
|
||||
public static class Typography
|
||||
{
|
||||
public const string DisplayStack = "\"IBM Plex Serif\", \"PT Serif\", Georgia, serif";
|
||||
public const string BodyStack = "\"IBM Plex Sans\", \"PT Sans\", system-ui, sans-serif";
|
||||
public const string MonoStack = "\"JetBrains Mono\", \"IBM Plex Mono\", \"Fira Code\", Consolas, monospace";
|
||||
}
|
||||
}
|
||||
@@ -1 +1,23 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using System
|
||||
@using System.Collections.Generic
|
||||
@using System.Globalization
|
||||
@using System.Linq
|
||||
@using System.Threading
|
||||
@using System.Threading.Tasks
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using Microsoft.Extensions.Options
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using MudBlazor
|
||||
@using Marathon.Domain.Entities
|
||||
@using Marathon.Domain.Enums
|
||||
@using Marathon.Domain.ValueObjects
|
||||
@using Marathon.UI
|
||||
@using Marathon.UI.Components
|
||||
@using Marathon.UI.Pages
|
||||
@using Marathon.UI.Resources
|
||||
@using Marathon.UI.Services
|
||||
@using Marathon.UI.Theme
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
/* ===================================================================
|
||||
Marathon — Editorial-Quant design system
|
||||
------------------------------------------------------------------
|
||||
Inspiration: long-form data journalism (FT, Quartz), terminal
|
||||
instruments (Bloomberg), and Belarusian / Soviet print typography.
|
||||
The aesthetic is confident, dense, and serif-led on display surfaces.
|
||||
=================================================================== */
|
||||
|
||||
:root {
|
||||
/* ----- Spacing scale (4-pt base, doubled at 16+) ----- */
|
||||
--m-space-1: 4px;
|
||||
--m-space-2: 8px;
|
||||
--m-space-3: 12px;
|
||||
--m-space-4: 16px;
|
||||
--m-space-5: 24px;
|
||||
--m-space-6: 32px;
|
||||
--m-space-7: 48px;
|
||||
--m-space-8: 64px;
|
||||
--m-space-9: 96px;
|
||||
|
||||
/* ----- Radius — sharp by default, soft variants for inputs ----- */
|
||||
--m-radius-sharp: 0;
|
||||
--m-radius-xs: 2px;
|
||||
--m-radius-sm: 4px;
|
||||
--m-radius-md: 6px;
|
||||
--m-radius-lg: 10px;
|
||||
|
||||
/* ----- Typography ----- */
|
||||
--m-font-display: "IBM Plex Serif", "PT Serif", Georgia, serif;
|
||||
--m-font-body: "IBM Plex Sans", "PT Sans", system-ui, sans-serif;
|
||||
--m-font-mono: "JetBrains Mono", "IBM Plex Mono", "Fira Code", Consolas, monospace;
|
||||
|
||||
/* ----- Colors — light (parchment) chassis ----- */
|
||||
--m-c-ink: #0f172a;
|
||||
--m-c-ink-2: #1e293b;
|
||||
--m-c-ink-soft: #475569;
|
||||
--m-c-paper: #fafaf7;
|
||||
--m-c-paper-2: #f5f4ef;
|
||||
--m-c-paper-3: #ebe9e1;
|
||||
--m-c-rule: #e7e5e4;
|
||||
--m-c-accent: #d97706;
|
||||
--m-c-accent-soft: #f59e0b;
|
||||
--m-c-anomaly: #dc2626;
|
||||
--m-c-positive: #15803d;
|
||||
--m-c-info: #0369a1;
|
||||
|
||||
/* Tabular numerals for everywhere odds/scores appear */
|
||||
--m-num-feature: "tnum" 1, "lnum" 1, "ss01" 1;
|
||||
}
|
||||
|
||||
/* Dark theme overrides (applied via class on <html> or via MudThemeProvider) */
|
||||
.mud-theme-dark, [data-theme="dark"] {
|
||||
--m-c-ink: #f5f5f4;
|
||||
--m-c-ink-2: #e7e5e4;
|
||||
--m-c-ink-soft: #a8a29e;
|
||||
--m-c-paper: #1c1917;
|
||||
--m-c-paper-2: #0c0a09;
|
||||
--m-c-paper-3: #292524;
|
||||
--m-c-rule: #292524;
|
||||
--m-c-accent: #fbbf24;
|
||||
--m-c-accent-soft: #fcd34d;
|
||||
--m-c-anomaly: #f87171;
|
||||
--m-c-positive: #4ade80;
|
||||
--m-c-info: #38bdf8;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Base
|
||||
=================================================================== */
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--m-c-paper-2);
|
||||
color: var(--m-c-ink);
|
||||
font-family: var(--m-font-body);
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
/* Subtle paper grain — 1px mottled noise, rendered cheaply via SVG. */
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 12%, rgba(217, 119, 6, 0.035), transparent 45%),
|
||||
radial-gradient(circle at 88% 78%, rgba(15, 23, 42, 0.040), transparent 50%),
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.045 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.mud-theme-dark body, [data-theme="dark"] body {
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 12%, rgba(251, 191, 36, 0.045), transparent 45%),
|
||||
radial-gradient(circle at 88% 78%, rgba(56, 189, 248, 0.030), transparent 50%),
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.025 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Numerals — always tabular for odds tables and score readouts
|
||||
=================================================================== */
|
||||
.m-num,
|
||||
.mud-table tbody td,
|
||||
.mud-data-grid tbody td,
|
||||
[data-numeric] {
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
|
||||
.m-mono {
|
||||
font-family: var(--m-font-mono);
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Editorial markers — kicker label + serif display lockup
|
||||
=================================================================== */
|
||||
.m-kicker {
|
||||
font-family: var(--m-font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--m-c-accent);
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
padding-bottom: var(--m-space-1);
|
||||
border-bottom: 1px solid var(--m-c-accent);
|
||||
}
|
||||
|
||||
.m-display {
|
||||
font-family: var(--m-font-display);
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.022em;
|
||||
line-height: 1.05;
|
||||
color: var(--m-c-ink);
|
||||
}
|
||||
|
||||
.m-rule {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--m-c-rule);
|
||||
margin: var(--m-space-5) 0;
|
||||
}
|
||||
|
||||
.m-rule--double {
|
||||
border: 0;
|
||||
border-top: 3px double var(--m-c-rule);
|
||||
margin: var(--m-space-5) 0;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Cards — paper-like, borders not shadows
|
||||
=================================================================== */
|
||||
.m-card {
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
border-radius: var(--m-radius-xs);
|
||||
padding: var(--m-space-5);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.m-card--accented {
|
||||
border-left: 3px solid var(--m-c-accent);
|
||||
}
|
||||
|
||||
.m-card--anomaly {
|
||||
border-left: 3px solid var(--m-c-anomaly);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Stat block — large number, mono, kicker on top
|
||||
=================================================================== */
|
||||
.m-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--m-space-2);
|
||||
}
|
||||
|
||||
.m-stat__value {
|
||||
font-family: var(--m-font-mono);
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--m-c-ink);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.m-stat__label {
|
||||
font-family: var(--m-font-body);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--m-c-ink-soft);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.m-stat__delta {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--m-c-positive);
|
||||
}
|
||||
|
||||
.m-stat__delta--down { color: var(--m-c-anomaly); }
|
||||
|
||||
/* ===================================================================
|
||||
Page-load reveal — one orchestrated entrance, respects motion prefs
|
||||
=================================================================== */
|
||||
@keyframes m-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.m-rise {
|
||||
animation: m-rise 480ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.m-rise-1 { animation-delay: 40ms; }
|
||||
.m-rise-2 { animation-delay: 100ms; }
|
||||
.m-rise-3 { animation-delay: 180ms; }
|
||||
.m-rise-4 { animation-delay: 260ms; }
|
||||
.m-rise-5 { animation-delay: 340ms; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.m-rise, .m-rise-1, .m-rise-2, .m-rise-3, .m-rise-4, .m-rise-5 {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Focus rings — deliberate, accent, never invisible
|
||||
=================================================================== */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--m-c-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mud-button:focus-visible,
|
||||
.mud-icon-button:focus-visible {
|
||||
outline: 2px solid var(--m-c-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Layout primitives — asymmetric content grid
|
||||
=================================================================== */
|
||||
.m-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--m-space-5);
|
||||
padding: var(--m-space-5) clamp(var(--m-space-4), 4vw, var(--m-space-7));
|
||||
max-width: 1480px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.m-grid--asym {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
|
||||
gap: var(--m-space-5);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.m-grid--asym { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.m-grid--three {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: var(--m-space-4);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
AppBar wordmark + dateline
|
||||
=================================================================== */
|
||||
.m-brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--m-space-3);
|
||||
}
|
||||
|
||||
.m-brand__mark {
|
||||
font-family: var(--m-font-display);
|
||||
font-weight: 500;
|
||||
font-size: 1.375rem;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.m-brand__mark::first-letter {
|
||||
color: var(--m-c-accent);
|
||||
}
|
||||
|
||||
.m-brand__dateline {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--m-c-ink-soft);
|
||||
border-left: 1px solid var(--m-c-rule);
|
||||
padding-left: var(--m-space-3);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Drawer — narrow, dark, mono labels
|
||||
=================================================================== */
|
||||
.m-nav__group {
|
||||
padding: var(--m-space-3) var(--m-space-4);
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: rgba(231, 229, 228, 0.55);
|
||||
}
|
||||
|
||||
.m-nav__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--m-space-3);
|
||||
padding: var(--m-space-3) var(--m-space-4);
|
||||
color: rgba(231, 229, 228, 0.85);
|
||||
text-decoration: none;
|
||||
font-size: 0.9375rem;
|
||||
border-left: 2px solid transparent;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.m-nav__link:hover {
|
||||
background: rgba(217, 119, 6, 0.10);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.m-nav__link.active {
|
||||
color: #ffffff;
|
||||
background: rgba(217, 119, 6, 0.14);
|
||||
border-left-color: var(--m-c-accent);
|
||||
}
|
||||
|
||||
.m-nav__link .mud-icon-root { font-size: 1.1rem; }
|
||||
|
||||
/* ===================================================================
|
||||
Locale switcher — segmented control
|
||||
=================================================================== */
|
||||
.m-segmented {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--m-c-rule);
|
||||
border-radius: var(--m-radius-xs);
|
||||
overflow: hidden;
|
||||
background: var(--m-c-paper);
|
||||
}
|
||||
|
||||
.m-segmented__btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 6px 12px;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--m-c-ink-soft);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.m-segmented__btn + .m-segmented__btn {
|
||||
border-left: 1px solid var(--m-c-rule);
|
||||
}
|
||||
|
||||
.m-segmented__btn:hover {
|
||||
color: var(--m-c-ink);
|
||||
}
|
||||
|
||||
.m-segmented__btn.is-active {
|
||||
background: var(--m-c-ink);
|
||||
color: var(--m-c-paper);
|
||||
}
|
||||
|
||||
.mud-theme-dark .m-segmented__btn.is-active,
|
||||
[data-theme="dark"] .m-segmented__btn.is-active {
|
||||
background: var(--m-c-accent);
|
||||
color: var(--m-c-paper-2);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Settings page — section ledger
|
||||
=================================================================== */
|
||||
.m-section {
|
||||
border: 1px solid var(--m-c-rule);
|
||||
background: var(--m-c-paper);
|
||||
margin-bottom: var(--m-space-5);
|
||||
}
|
||||
|
||||
.m-section__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: var(--m-space-4) var(--m-space-5);
|
||||
border-bottom: 1px solid var(--m-c-rule);
|
||||
background: var(--m-c-paper-2);
|
||||
}
|
||||
|
||||
.m-section__head h2 {
|
||||
margin: 0;
|
||||
font-family: var(--m-font-display);
|
||||
font-weight: 400;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.012em;
|
||||
}
|
||||
|
||||
.m-section__body {
|
||||
padding: var(--m-space-5);
|
||||
display: grid;
|
||||
gap: var(--m-space-4);
|
||||
}
|
||||
|
||||
.m-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--m-space-4);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.m-field-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.m-field-row__hint {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--m-c-ink-soft);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Anomaly badge — load-bearing for Phase 7
|
||||
=================================================================== */
|
||||
.m-anomaly {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--m-space-2);
|
||||
padding: 2px 8px;
|
||||
background: rgba(220, 38, 38, 0.10);
|
||||
color: var(--m-c-anomaly);
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--m-radius-xs);
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.m-anomaly__pulse {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: m-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes m-pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.m-anomaly__pulse { animation: none; opacity: 1; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Marathon — Odds Lab</title>
|
||||
<base href="/" />
|
||||
|
||||
<!-- Prevent flash of unthemed content -->
|
||||
<style>
|
||||
html, body { background: #f5f4ef; margin: 0; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body { background: #0c0a09; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Fonts: IBM Plex Sans / Serif / Mono + JetBrains Mono. Full Cyrillic coverage. -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Serif:wght@300;400;500;600&family=IBM+Plex+Mono:wght@400;500&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet" />
|
||||
|
||||
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||
<link href="_content/Marathon.UI/app.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div style="padding: 64px; font-family: 'IBM Plex Serif', Georgia, serif; color: #475569;">
|
||||
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #d97706;">Booting</span>
|
||||
<div style="font-size: 32px; font-weight: 300; margin-top: 8px;">Marathon Odds Lab</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" style="display:none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; padding: 12px 24px; background: #dc2626; color: #fafaf7; font-family: 'IBM Plex Sans', sans-serif;">
|
||||
<span>An unhandled error has occurred.</span>
|
||||
<a href="" class="reload" style="color: #fff; text-decoration: underline; margin-left: 12px;">Reload</a>
|
||||
<a class="dismiss" style="float: right; cursor: pointer; padding: 0 8px;">×</a>
|
||||
</div>
|
||||
|
||||
<script src="_framework/blazor.webview.js" autostart="false"></script>
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user