feat(ui): Facts page deposits a raw source under the signed-in user #11

Merged
k.renner merged 1 commits from feat/facts-page into main 2026-09-25 06:26:41 +00:00
4 changed files with 179 additions and 0 deletions
@@ -14,6 +14,7 @@
<MudNavMenu Color="Color.Primary" Bordered="false">
<MudNavLink Href="" Match="NavLinkMatch.All" Icon="@Icons.Material.Outlined.Search">Search</MudNavLink>
<MudNavLink Href="answers" Icon="@Icons.Material.Outlined.QuestionAnswer">Answers</MudNavLink>
<MudNavLink Href="facts" Icon="@Icons.Material.Outlined.NoteAdd">Facts</MudNavLink>
<MudNavLink Href="browse" Icon="@Icons.Material.Outlined.Label">Browse</MudNavLink>
<MudNavLink Href="maintenance" Icon="@Icons.Material.Outlined.Build">Maintenance</MudNavLink>
<MudNavLink Href="lab" Icon="@Icons.Material.Outlined.Science">Lab</MudNavLink>
+165
View File
@@ -0,0 +1,165 @@
@page "/facts"
@rendermode InteractiveServer
@using Microsoft.AspNetCore.Components.Authorization
@using Wiki.Application.Facts
@inject DepositFactUseCase Deposit
@inject NoteQueries Notes
@inject TimeProvider Clock
@inject ISnackbar Snackbar
<PageTitle>Facts</PageTitle>
<MudText Typo="Typo.h4" GutterBottom="true">Facts</MudText>
<MudText Typo="Typo.body2" Class="mb-4" Color="Color.Secondary">
A fact is one verified observation with its provenance. It lands verbatim in <code>raw/</code> under your name and the nightly distill turns it into wiki notes. Raw holds observations only, never hypotheses.
</MudText>
@if (_user is null)
{
<MudAlert Severity="Severity.Warning" Class="mb-4">Not signed in. Facts are attributed to the person depositing them, so this page needs the Authentik sign-in at wiki.renner.uno.</MudAlert>
}
else
{
<MudPaper Class="pa-4 mb-6">
<MudForm @ref="_form" @bind-IsValid="_valid">
<MudGrid>
<MudItem xs="12" md="8">
<MudTextField T="string" @bind-Value="_title" Label="Title" Variant="Variant.Outlined" Required="true" RequiredError="title is required" Immediate="true" Disabled="@_running" />
</MudItem>
<MudItem xs="12" md="4">
<MudDatePicker Label="Observed on" @bind-Date="_observedDate" Variant="Variant.Outlined" Disabled="@_running" />
</MudItem>
<MudItem xs="12" md="8">
<MudTextField T="string" @bind-Value="_source" Label="Source" HelperText="where the fact was observed or quoted from: a command, a URL, a document" Variant="Variant.Outlined" Required="true" RequiredError="source is required" Immediate="true" Disabled="@_running" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField T="string" @bind-Value="_tags" Label="Tags" HelperText="comma separated" Variant="Variant.Outlined" Immediate="true" Disabled="@_running" />
</MudItem>
<MudItem xs="12">
<MudTextField T="string" @bind-Value="_content" Label="Observation" Lines="8" Variant="Variant.Outlined" Required="true" Immediate="true" Disabled="@_running"
Validation="@((string s) => s is not null && s.Trim().Length >= RawSource.MinimumContentLength ? null : $"at least {RawSource.MinimumContentLength} characters of observation")"
Counter="0" HelperText="@($"{_content?.Trim().Length ?? 0} characters; markdown is fine")" />
</MudItem>
</MudGrid>
</MudForm>
<MudStack Row Justify="Justify.FlexEnd" AlignItems="AlignItems.Center" Spacing="2" Class="mt-2">
<MudText Typo="Typo.caption" Color="Color.Secondary">captured_by: @_user</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="DepositAsync" Disabled="@(_running || !_valid)">Deposit</MudButton>
</MudStack>
</MudPaper>
}
@if (_running)
{
<MudProgressLinear Indeterminate="true" Class="mb-4" />
}
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" Class="mb-4">@_error</MudAlert>
}
@if (_receipt is not null)
{
<MudAlert Severity="Severity.Success" Class="mb-4">
Stored as <MudLink Href="@($"raw/{Slug(_receipt.Path)}")">@_receipt.Path</MudLink> (commit @_receipt.Commit[..Math.Min(8, _receipt.Commit.Length)]).
</MudAlert>
}
<MudText Typo="Typo.subtitle1" Class="mb-2">Raw sources awaiting distillation</MudText>
@if (_raw is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (_raw.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">Nothing queued.</MudText>
}
else
{
<MudSimpleTable Dense="true" Hover="true">
<thead>
<tr><th>Captured</th><th>Title</th><th>Source</th></tr>
</thead>
<tbody>
@foreach (var r in _raw.OrderByDescending(r => r.Captured).ThenByDescending(r => r.Slug).Take(30))
{
<tr>
<td>@(r.Captured?.ToString("yyyy-MM-dd") ?? "")</td>
<td><MudLink Href="@($"raw/{r.Slug}")">@r.Title</MudLink></td>
<td><MudText Typo="Typo.caption" Color="Color.Secondary">@r.Source</MudText></td>
</tr>
}
</tbody>
</MudSimpleTable>
}
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private MudForm? _form;
private bool _valid;
private bool _running;
private string? _user;
private string? _title;
private string? _source;
private string? _tags;
private string? _content;
private DateTime? _observedDate;
private string? _error;
private FactReceipt? _receipt;
private IReadOnlyList<RawQueueEntry>? _raw;
protected override async Task OnInitializedAsync()
{
_observedDate = Clock.GetUtcNow().Date;
if (AuthState is not null)
{
var state = await AuthState;
_user = state.User.Identity?.IsAuthenticated == true ? state.User.Identity.Name : null;
}
await LoadRawAsync();
}
private async Task LoadRawAsync() => _raw = await Notes.RawQueueAsync(CancellationToken.None);
private async Task DepositAsync()
{
if (_running || _user is null) return;
_running = true;
_error = null;
_receipt = null;
try
{
var observed = _observedDate is { } d ? new DateTimeOffset(DateTime.SpecifyKind(d, DateTimeKind.Utc)) : Clock.GetUtcNow();
var fact = new RawSource(
_title?.Trim() ?? string.Empty,
_source?.Trim() ?? string.Empty,
_content ?? string.Empty,
observed,
(_tags ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
var errors = fact.Validate();
if (errors.Count > 0)
{
_error = string.Join("; ", errors);
return;
}
_receipt = await Deposit.DepositAsync(fact, new Identity(_user, IdentityKind.Human), CancellationToken.None);
Snackbar.Add($"stored {_receipt.Path}", Severity.Success);
_title = _source = _tags = _content = null;
await LoadRawAsync();
}
catch (HttpRequestException e)
{
_error = $"The store did not accept the fact: {e.Message}";
}
finally
{
_running = false;
}
}
private static string Slug(string path)
{
var file = path[(path.LastIndexOf('/') + 1)..];
return file.EndsWith(".md", StringComparison.Ordinal) ? file[..^3] : file;
}
}
+1
View File
@@ -132,6 +132,7 @@ builder.Services.AddAuthorization(options =>
// ---- UI ----------------------------------------------------------------------------------
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddMudServices();
var app = builder.Build();
+12
View File
@@ -310,6 +310,18 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture<WikiHostFact
private sealed record EvalRow(string Slug, int GoldCount);
private sealed record EvalResultDto(string ModelId, int QueriesEvaluated, int QueriesSkipped, List<EvalRow> Rows);
[Fact]
public async Task FactsPageRendersAndNamesTheSignInWhenAnonymous()
{
// The test server has no peer address, so the proxy identity header is never trusted here.
var page = await Client.GetAsync("/facts");
Assert.Equal(HttpStatusCode.OK, page.StatusCode);
var html = await page.Content.ReadAsStringAsync();
Assert.Contains("<title>Facts</title>", html);
Assert.Contains("Not signed in", html);
Assert.Contains("Raw sources awaiting distillation", html);
}
[Fact]
public async Task StatusListsAdaptersAndIndexes()
{