feat(phase-6): event browsing UI — pre-match/live lists, detail page, +26 bUnit tests
Replaces PreMatch/Live placeholder pages with a shared EventListShell
(filter chips, date range, sortable virtualized-friendly table, debounced
search, live auto-refresh with odds-movement indicators) and adds a new
/events/{eventCode} detail page (asymmetric header lockup, dynamic
Match/Period tabs, Plotly.Blazor odds-over-time chart with accessible
data-table fallback, snapshot history, Excel export modal).
New primitives matching Phase 5's editorial-quant system:
- SportIcon: inline SVGs per sport (basketball=6, football=11,
tennis=22723, hockey=43658, generic fallback)
- OddsCell: tabular mono with ▲/▼/— delta + flash on change
(prefers-reduced-motion honored)
- OddsTimeline: Plotly.Blazor wrapper with theme-aware colors and
<details>/<summary> data-table screen-reader fallback
- ExportDialog: From/To pickers + ExportKind radio + Esc/Enter
keyboard, surfaces use-case errors inline
- EventListShell: shared section shell for PreMatch/Live cadence
State + service split keeps the RCL host-agnostic:
- IEventBrowsingService / EventBrowsingService — wraps repos, returns
view-model records (EventListItem, EventDetail, EventScopeBoard,
BetRow, OddsTimelinePoint, SnapshotHistoryEntry); pages never see
EF or domain entities directly.
- EventBrowsingState — singleton (per-circuit in BlazorWebView) holding
immutable PageFilter records for PreMatch and Live.
Plotly.Blazor 5.4.1 added (latest .NET 8 line; 7.x has breaking changes).
+59 RU/EN localization keys following the Phase 5 dot-segmented convention.
Tests: +26 bUnit tests (PreMatch/Live/Detail pages, OddsCell/SportIcon/
ExportDialog components, EventBrowsingState). Total 228/228 passing
(Domain 96 + Application 15 + Infrastructure 80 + UI 37; baseline 202).
Build clean (0/0).
PLAN.md: P2/P3/P5 top-level checkboxes ticked; P6 row marked Done.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
@*
|
||||
ExportDialog — modal that collects a DateRange + ExportKind, then calls
|
||||
ExportToExcelUseCase. Reports back via DialogResult so the caller can
|
||||
show a snackbar with the file path. Keyboard: Esc to cancel, Enter to
|
||||
submit.
|
||||
*@
|
||||
|
||||
@using Marathon.Application.UseCases
|
||||
@using AppDateRange = Marathon.Application.Storage.DateRange
|
||||
@using ExportKind = Marathon.Application.Storage.ExportKind
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject ExportToExcelUseCase ExportUseCase
|
||||
@inject ILogger<ExportDialog> Logger
|
||||
|
||||
<MudDialog @key="@(_kind.ToString())" Class="m-export-dialog">
|
||||
<TitleContent>
|
||||
<span class="m-kicker">@L["Export.Kicker"]</span>
|
||||
<h2 style="font-family: var(--m-font-display); font-weight: 400; font-size: 1.5rem; margin: var(--m-space-2) 0 0;">
|
||||
@L["Export.Title"]
|
||||
</h2>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<div style="display: grid; gap: var(--m-space-4); padding: var(--m-space-2) 0;" @onkeydown="HandleKeyDown">
|
||||
<div class="m-field-row">
|
||||
<div>
|
||||
<label style="font-weight: 500;">@L["Export.DateRange.From"]</label>
|
||||
</div>
|
||||
<div>
|
||||
<MudDatePicker
|
||||
@bind-Date="_from"
|
||||
Label="@L["Export.DateRange.From"]"
|
||||
DateFormat="yyyy-MM-dd"
|
||||
Variant="Variant.Outlined" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-field-row">
|
||||
<div>
|
||||
<label style="font-weight: 500;">@L["Export.DateRange.To"]</label>
|
||||
</div>
|
||||
<div>
|
||||
<MudDatePicker
|
||||
@bind-Date="_to"
|
||||
Label="@L["Export.DateRange.To"]"
|
||||
DateFormat="yyyy-MM-dd"
|
||||
Variant="Variant.Outlined" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-field-row">
|
||||
<div>
|
||||
<label style="font-weight: 500;">@L["Export.Kind.Label"]</label>
|
||||
</div>
|
||||
<div>
|
||||
<MudRadioGroup T="ExportKind" @bind-Value="_kind">
|
||||
<MudRadio Value="@(ExportKind.PreMatch)" Color="Color.Primary">@L["Export.Kind.PreMatch"]</MudRadio>
|
||||
<MudRadio Value="@(ExportKind.Live)" Color="Color.Primary">@L["Export.Kind.Live"]</MudRadio>
|
||||
<MudRadio Value="@(ExportKind.Combined)" Color="Color.Primary">@L["Export.Kind.Combined"]</MudRadio>
|
||||
</MudRadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_error is not null)
|
||||
{
|
||||
<div role="alert" class="m-export-dialog__error">
|
||||
@_error
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel" Variant="Variant.Text">@L["Export.Cancel"]</MudButton>
|
||||
<MudButton OnClick="Submit"
|
||||
Color="Color.Primary"
|
||||
Variant="Variant.Filled"
|
||||
Disabled="@_busy">
|
||||
@if (_busy)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Inherit" />
|
||||
<span style="margin-left: 8px;">@L["Common.Loading"]</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@L["Export.Submit"]
|
||||
}
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
<style>
|
||||
.m-export-dialog__error {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
border-left: 3px solid var(--m-c-anomaly);
|
||||
padding: var(--m-space-3);
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--m-c-anomaly);
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private MudDialogInstance Dialog { get; set; } = default!;
|
||||
|
||||
[Parameter] public DateTime? InitialFrom { get; set; }
|
||||
[Parameter] public DateTime? InitialTo { get; set; }
|
||||
[Parameter] public ExportKind InitialKind { get; set; } = ExportKind.Combined;
|
||||
|
||||
private DateTime? _from;
|
||||
private DateTime? _to;
|
||||
private ExportKind _kind;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var moscow = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3));
|
||||
_from = InitialFrom ?? moscow.AddDays(-1).Date;
|
||||
_to = InitialTo ?? moscow.AddDays(1).Date;
|
||||
_kind = InitialKind;
|
||||
}
|
||||
|
||||
private void Cancel() => Dialog.Cancel();
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
if (_from is null || _to is null)
|
||||
{
|
||||
_error = L["Export.Error.MissingDates"];
|
||||
return;
|
||||
}
|
||||
if (_from > _to)
|
||||
{
|
||||
_error = L["Export.Error.InvalidRange"];
|
||||
return;
|
||||
}
|
||||
|
||||
_error = null;
|
||||
_busy = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
// Use Moscow offset to match domain ScheduledAt invariant.
|
||||
var moscow = TimeSpan.FromHours(3);
|
||||
var range = new AppDateRange(
|
||||
new DateTimeOffset(_from.Value.Date, moscow),
|
||||
new DateTimeOffset(_to.Value.Date.AddDays(1).AddSeconds(-1), moscow));
|
||||
|
||||
var path = await ExportUseCase.ExecuteAsync(range, _kind, CancellationToken.None);
|
||||
Dialog.Close(DialogResult.Ok(path));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Export failed");
|
||||
_error = L["Export.Error.Failed"].Value + " — " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleKeyDown(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter" && !_busy)
|
||||
{
|
||||
await Submit();
|
||||
}
|
||||
else if (e.Key == "Escape")
|
||||
{
|
||||
Cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
@*
|
||||
OddsCell — tabular mono rendering of a decimal odds rate, with an inline
|
||||
direction marker (▲ amber rising, ▼ red falling, em-dash unchanged) when
|
||||
the value differs from a previous render. Drives the visual indicator
|
||||
that Phase 6 promises for live-list odds movement.
|
||||
|
||||
Rate is the current value. Previous (optional) is the prior value the
|
||||
caller wants compared against. Caller decides what "previous" means
|
||||
(last refresh / last snapshot) and tracks it in its own state.
|
||||
*@
|
||||
|
||||
@{
|
||||
var direction = Direction;
|
||||
var directionGlyph = direction switch
|
||||
{
|
||||
Trend.Up => "▲", // ▲
|
||||
Trend.Down => "▼", // ▼
|
||||
_ => "—", // em-dash
|
||||
};
|
||||
var directionClass = direction switch
|
||||
{
|
||||
Trend.Up => "is-up",
|
||||
Trend.Down => "is-down",
|
||||
_ => "is-flat",
|
||||
};
|
||||
var ariaTrend = direction switch
|
||||
{
|
||||
Trend.Up => "rising",
|
||||
Trend.Down => "falling",
|
||||
_ => "unchanged",
|
||||
};
|
||||
}
|
||||
|
||||
<span class="m-odds @directionClass" aria-label="@AriaPrefix @Formatted (@ariaTrend)" data-trend="@ariaTrend">
|
||||
<span class="m-odds__value m-mono" data-numeric>@Formatted</span>
|
||||
@if (ShowTrend)
|
||||
{
|
||||
<span class="m-odds__delta" aria-hidden="true">@directionGlyph</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.m-odds {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 56px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m-odds__value {
|
||||
font-feature-settings: "tnum" 1, "lnum" 1;
|
||||
font-weight: 500;
|
||||
color: var(--m-c-ink);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.m-odds__delta {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1;
|
||||
color: var(--m-c-ink-soft);
|
||||
transition: color 220ms ease;
|
||||
}
|
||||
.m-odds.is-up .m-odds__delta { color: var(--m-c-accent); }
|
||||
.m-odds.is-down .m-odds__delta { color: var(--m-c-anomaly); }
|
||||
.m-odds.is-flat .m-odds__delta { color: var(--m-c-ink-soft); }
|
||||
|
||||
.m-odds.is-up .m-odds__value,
|
||||
.m-odds.is-down .m-odds__value {
|
||||
animation: m-odds-flash 1200ms ease-out 1;
|
||||
}
|
||||
|
||||
@@keyframes m-odds-flash {
|
||||
0% { background: color-mix(in srgb, currentColor 14%, transparent); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
|
||||
@@media (prefers-reduced-motion: reduce) {
|
||||
.m-odds.is-up .m-odds__value,
|
||||
.m-odds.is-down .m-odds__value { animation: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter] public decimal? Rate { get; set; }
|
||||
[Parameter] public decimal? Previous { get; set; }
|
||||
[Parameter] public bool ShowTrend { get; set; } = true;
|
||||
[Parameter] public string AriaPrefix { get; set; } = "Odds";
|
||||
[Parameter] public string EmptyPlaceholder { get; set; } = "—";
|
||||
|
||||
private string Formatted => Rate is { } r ? r.ToString("0.00") : EmptyPlaceholder;
|
||||
|
||||
private Trend Direction
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Rate is not { } r || Previous is not { } p) return Trend.Flat;
|
||||
if (r > p) return Trend.Up;
|
||||
if (r < p) return Trend.Down;
|
||||
return Trend.Flat;
|
||||
}
|
||||
}
|
||||
|
||||
private enum Trend { Flat, Up, Down }
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
@*
|
||||
OddsTimeline — wraps Plotly.Blazor's PlotlyChart with three traces
|
||||
(Win-1 / Draw / Win-2) and a hidden parallel data table for screen
|
||||
readers. Matches the editorial-quant theme: parchment paper-fill on
|
||||
light / ink-near-black on dark, single amber accent for the highlight
|
||||
rate, mono number formatting on hover labels.
|
||||
|
||||
Data is memoized — we only rebuild the trace lists when Points actually
|
||||
changes (the calling page may re-render frequently during live polling).
|
||||
*@
|
||||
|
||||
@using Plotly.Blazor
|
||||
@using Plotly.Blazor.LayoutLib
|
||||
@using Plotly.Blazor.Traces
|
||||
@using Plotly.Blazor.Traces.ScatterLib
|
||||
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<div class="m-timeline">
|
||||
@if (HasData)
|
||||
{
|
||||
<PlotlyChart @ref="_chart" Config="_config" Layout="_layout" Data="_data" />
|
||||
|
||||
<details class="m-timeline__a11y">
|
||||
<summary>@L["Detail.Chart.AccessibleSummary"]</summary>
|
||||
<table class="m-timeline__table">
|
||||
<caption class="visually-hidden">@L["Detail.Chart.Title"]</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@L["Detail.Chart.Time"]</th>
|
||||
<th scope="col">@L["Detail.Chart.Win1"]</th>
|
||||
<th scope="col">@L["Detail.Chart.Draw"]</th>
|
||||
<th scope="col">@L["Detail.Chart.Win2"]</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var p in Points)
|
||||
{
|
||||
<tr>
|
||||
<td class="m-mono">@p.At.ToString("HH:mm:ss") </td>
|
||||
<td class="m-mono">@FormatRate(p.Win1Rate)</td>
|
||||
<td class="m-mono">@FormatRate(p.DrawRate)</td>
|
||||
<td class="m-mono">@FormatRate(p.Win2Rate)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="m-timeline__empty">
|
||||
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
|
||||
@L["Detail.Chart.Title"]
|
||||
</span>
|
||||
<p style="margin-top: var(--m-space-3); color: var(--m-c-ink-soft);">
|
||||
@L["Detail.Chart.Empty"]
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.m-timeline {
|
||||
display: block;
|
||||
position: relative;
|
||||
min-height: 320px;
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
padding: var(--m-space-4);
|
||||
}
|
||||
.m-timeline__empty {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: var(--m-space-2);
|
||||
min-height: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Hidden but accessible — the data-table fallback for screen readers. */
|
||||
.m-timeline__a11y {
|
||||
margin-top: var(--m-space-3);
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-timeline__a11y[open] { padding: var(--m-space-3) 0; }
|
||||
.m-timeline__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: var(--m-space-2);
|
||||
}
|
||||
.m-timeline__table th,
|
||||
.m-timeline__table td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--m-c-rule);
|
||||
text-align: left;
|
||||
}
|
||||
.m-timeline__table th { color: var(--m-c-ink); }
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
margin: -1px; padding: 0; overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public IReadOnlyList<OddsTimelinePoint> Points { get; set; } = Array.Empty<OddsTimelinePoint>();
|
||||
[Parameter] public bool DarkMode { get; set; }
|
||||
|
||||
private PlotlyChart? _chart;
|
||||
private Config _config = new() { Responsive = true, DisplayLogo = false };
|
||||
private Layout _layout = new();
|
||||
private IList<ITrace> _data = new List<ITrace>();
|
||||
private int _signature = -1;
|
||||
|
||||
private bool HasData => Points.Count > 0;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Memoize: only rebuild traces/layout when the input identity changes.
|
||||
var sig = ComputeSignature(Points);
|
||||
if (sig == _signature) return;
|
||||
|
||||
_signature = sig;
|
||||
_layout = BuildLayout(DarkMode);
|
||||
_data = BuildTraces(Points);
|
||||
}
|
||||
|
||||
private static int ComputeSignature(IReadOnlyList<OddsTimelinePoint> points)
|
||||
{
|
||||
// Cheap stable signature: count + first/last timestamps + first/last
|
||||
// rates. Sufficient to invalidate the memo on any meaningful change.
|
||||
if (points.Count == 0) return 0;
|
||||
var first = points[0];
|
||||
var last = points[^1];
|
||||
return HashCode.Combine(
|
||||
points.Count,
|
||||
first.At.Ticks,
|
||||
last.At.Ticks,
|
||||
first.Win1Rate,
|
||||
last.Win1Rate,
|
||||
first.DrawRate,
|
||||
last.DrawRate,
|
||||
first.Win2Rate);
|
||||
}
|
||||
|
||||
private static IList<ITrace> BuildTraces(IReadOnlyList<OddsTimelinePoint> points)
|
||||
{
|
||||
var times = points.Select(p => (object)p.At.UtcDateTime).ToList();
|
||||
return new List<ITrace>
|
||||
{
|
||||
BuildSeries("Win 1", "#0f172a", points.Select(p => (object?)p.Win1Rate).ToList(), times),
|
||||
BuildSeries("Draw", "#d97706", points.Select(p => (object?)p.DrawRate).ToList(), times),
|
||||
BuildSeries("Win 2", "#dc2626", points.Select(p => (object?)p.Win2Rate).ToList(), times),
|
||||
};
|
||||
}
|
||||
|
||||
private static Scatter BuildSeries(string name, string color, IList<object?> y, IList<object> x)
|
||||
{
|
||||
// Plotly's IList<object> doesn't accept nulls well — cast nulls to DBNull.
|
||||
var ys = y.Select(v => v ?? (object)DBNull.Value).ToList();
|
||||
return new Scatter
|
||||
{
|
||||
Name = name,
|
||||
Mode = ModeFlag.Lines | ModeFlag.Markers,
|
||||
X = x,
|
||||
Y = ys,
|
||||
Line = new Plotly.Blazor.Traces.ScatterLib.Line { Color = color, Width = 1.6m, Shape = Plotly.Blazor.Traces.ScatterLib.LineLib.ShapeEnum.Linear },
|
||||
Marker = new Plotly.Blazor.Traces.ScatterLib.Marker { Size = 5, Color = color },
|
||||
ConnectGaps = false,
|
||||
};
|
||||
}
|
||||
|
||||
private static Layout BuildLayout(bool dark) => new()
|
||||
{
|
||||
AutoSize = true,
|
||||
Margin = new Plotly.Blazor.LayoutLib.Margin { L = 56, R = 24, T = 24, B = 48 },
|
||||
PaperBgColor = dark ? "#1c1917" : "#fafaf7",
|
||||
PlotBgColor = dark ? "#0c0a09" : "#fafaf7",
|
||||
Font = new Plotly.Blazor.LayoutLib.Font
|
||||
{
|
||||
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
|
||||
Size = 11,
|
||||
Color = dark ? "#f5f5f4" : "#0f172a",
|
||||
},
|
||||
XAxis = new List<XAxis>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Title = new Plotly.Blazor.LayoutLib.XAxisLib.Title { Text = string.Empty },
|
||||
ShowGrid = true,
|
||||
GridColor = dark ? "#292524" : "#e7e5e4",
|
||||
ZeroLine = false,
|
||||
LineColor = dark ? "#292524" : "#e7e5e4",
|
||||
TickFont = new Plotly.Blazor.LayoutLib.XAxisLib.TickFont
|
||||
{
|
||||
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
|
||||
Size = 10,
|
||||
Color = dark ? "#a8a29e" : "#475569",
|
||||
},
|
||||
},
|
||||
},
|
||||
YAxis = new List<YAxis>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = string.Empty },
|
||||
ShowGrid = true,
|
||||
GridColor = dark ? "#292524" : "#e7e5e4",
|
||||
ZeroLine = false,
|
||||
LineColor = dark ? "#292524" : "#e7e5e4",
|
||||
TickFont = new Plotly.Blazor.LayoutLib.YAxisLib.TickFont
|
||||
{
|
||||
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
|
||||
Size = 10,
|
||||
Color = dark ? "#a8a29e" : "#475569",
|
||||
},
|
||||
},
|
||||
},
|
||||
ShowLegend = true,
|
||||
Legend = new List<Plotly.Blazor.LayoutLib.Legend>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Orientation = Plotly.Blazor.LayoutLib.LegendLib.OrientationEnum.H,
|
||||
X = 0m,
|
||||
Y = 1.12m,
|
||||
Font = new Plotly.Blazor.LayoutLib.LegendLib.Font
|
||||
{
|
||||
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
|
||||
Size = 11,
|
||||
Color = dark ? "#e7e5e4" : "#1e293b",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
private static string FormatRate(decimal? r) => r is { } v ? v.ToString("0.00") : "—";
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@*
|
||||
SportIcon — minimal, distinctive inline-SVG icons per sport. Inline (not
|
||||
icon library) so the wordmark stays editorial-quant: thin strokes, sharp
|
||||
corners, no shadowing. Coverage matches Phase 0 spike findings:
|
||||
Basketball=6, Football=11, Tennis=22723, Hockey=43658.
|
||||
*@
|
||||
|
||||
<span class="m-sport @ClassName" role="img" aria-label="@Label" title="@Label" data-sport="@Code">
|
||||
@SvgContent
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.m-sport {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--m-sport-size, 18px);
|
||||
height: var(--m-sport-size, 18px);
|
||||
color: var(--m-sport-color, var(--m-c-ink-soft));
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.m-sport svg { width: 100%; height: 100%; display: block; }
|
||||
.m-sport[data-sport="6"] { color: #d97706; }
|
||||
.m-sport[data-sport="11"] { color: #15803d; }
|
||||
.m-sport[data-sport="22723"] { color: #0369a1; }
|
||||
.m-sport[data-sport="43658"] { color: #6d28d9; }
|
||||
[data-theme="dark"] .m-sport { filter: brightness(1.1); }
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public int Code { get; set; }
|
||||
[Parameter] public string? Label { get; set; }
|
||||
[Parameter] public string? ClassName { get; set; }
|
||||
|
||||
private MarkupString SvgContent => new(GetSvg(Code));
|
||||
|
||||
private static string GetSvg(int code) => code switch
|
||||
{
|
||||
6 => Basketball,
|
||||
11 => Football,
|
||||
22723 => Tennis,
|
||||
43658 => Hockey,
|
||||
_ => Generic,
|
||||
};
|
||||
|
||||
// SVG glyphs — single-quote attributes so the entire literal can stay on one line.
|
||||
private const string Basketball =
|
||||
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round'>" +
|
||||
"<circle cx='12' cy='12' r='9' />" +
|
||||
"<path d='M3 12h18 M12 3v18 M5 5c4 4 10 4 14 0 M5 19c4-4 10-4 14 0' />" +
|
||||
"</svg>";
|
||||
|
||||
private const string Football =
|
||||
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linejoin='round'>" +
|
||||
"<circle cx='12' cy='12' r='9' />" +
|
||||
"<polygon points='12,7 16.5,10 14.5,15 9.5,15 7.5,10' />" +
|
||||
"</svg>";
|
||||
|
||||
private const string Tennis =
|
||||
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round'>" +
|
||||
"<circle cx='12' cy='12' r='9' />" +
|
||||
"<path d='M3.5 9 C 9 9 15 15 20.5 15' />" +
|
||||
"<path d='M3.5 15 C 9 15 15 9 20.5 9' />" +
|
||||
"</svg>";
|
||||
|
||||
private const string Hockey =
|
||||
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round'>" +
|
||||
"<ellipse cx='12' cy='14' rx='8' ry='3' />" +
|
||||
"<path d='M4 14 L4 11 M20 14 L20 11' />" +
|
||||
"<path d='M6 7 L18 4' />" +
|
||||
"</svg>";
|
||||
|
||||
private const string Generic =
|
||||
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6'>" +
|
||||
"<circle cx='12' cy='12' r='9' />" +
|
||||
"<circle cx='12' cy='12' r='3' />" +
|
||||
"</svg>";
|
||||
}
|
||||
Reference in New Issue
Block a user