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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/FluentCMS.Admin/Components/Forms/FieldWidget.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
@using FluentCMS.Admin.Forms
@using FluentCMS.Core.Schema
@inject IStringLocalizer<FieldWidget> L

@* One field of a generated form (FRM-01): the widget its type takes, inside a FormField with the field's description and error. *@
<FormField Label="@Label" Description="@Description" Error="@Error" Class="@(Wide ? "sm:col-span-2" : null)">
@switch (Field.Type)
{
case "boolean":
<div class="flex items-center gap-2">
<Checkbox name="@Name" value="true" checked="@(Value == "true")" data-field="@Field.Name" />
</div>
break;
case "richText":
<Textarea name="@Name" rows="6" value="@Value" required="@Field.Required" maxlength="@MaxLength" data-field="@Field.Name" />
break;
case "list":
case "json":
<Textarea name="@Name" rows="5" value="@Value" required="@Field.Required" dir="ltr" Class="font-mono text-xs" data-field="@Field.Name" />
break;
case "number":
<Input Type="number" step="any" name="@Name" value="@Value" required="@Field.Required" dir="ltr" data-field="@Field.Name" />
break;
case "dateTime":
<Input Type="@(FieldValues.IsDateOnly(Field) ? "date" : "datetime-local")" name="@Name" value="@Value" required="@Field.Required" dir="ltr" data-field="@Field.Name" />
break;
case "enum":
<Select name="@Name" required="@Field.Required" data-field="@Field.Name">
<option value="">@L["Choose…"]</option>
@foreach (var choice in FieldValues.Strings(Field, "values"))
{
<option value="@choice" selected="@(choice == Value)">@choice</option>
}
</Select>
break;
case "reference":
case "media":
<Select name="@Name" required="@Field.Required" data-field="@Field.Name">
<option value="">@L["Choose…"]</option>
@foreach (var option in Options)
{
<option value="@option.Value" selected="@(option.Value == Value)">@option.Label</option>
}
</Select>
break;
default:
<Input name="@Name" value="@Value" required="@Field.Required" maxlength="@MaxLength" data-field="@Field.Name" />
break;
}
</FormField>

@code {
[Parameter, EditorRequired] public FieldDefinition Field { get; set; } = null!;
[Parameter] public string? Value { get; set; }
[Parameter] public string? Error { get; set; }
[Parameter] public IReadOnlyList<FieldOption> Options { get; set; } = [];

private string Name => $"Form.Values[{Field.Name}]";

private string Label => Field.Required ? L["{0} (required)", Field.Name] : Field.Name;

private string? Description => Field.Type switch
{
"dateTime" when !FieldValues.IsDateOnly(Field) => Join(Field.Description, L["In UTC."]),
"list" or "json" => Join(Field.Description, L["As JSON."]),
_ => Field.Description,
};

private bool Wide => Field.Type is "richText" or "list" or "json";

private int? MaxLength => Field.Settings.ValueKind == System.Text.Json.JsonValueKind.Object
&& Field.Settings.TryGetProperty("maxLength", out var max)
&& max.TryGetInt32(out var length)
? length
: null;

private static string? Join(string? description, string hint) => description is null ? hint : $"{description} {hint}";
}
1 change: 1 addition & 0 deletions src/FluentCMS.Admin/Components/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
@if (Initiator.Current is UserInitiator user)
{
<NotificationLinks />
<a href="content" class="text-sm text-muted-foreground hover:text-foreground">@L["Content"]</a>
<a href="media" class="text-sm text-muted-foreground hover:text-foreground">@L["Media"]</a>
<a href="audit" class="text-sm text-muted-foreground hover:text-foreground">@L["Audit log"]</a>
<a href="users" class="text-sm text-muted-foreground hover:text-foreground">@L["Users"]</a>
Expand Down
1 change: 1 addition & 0 deletions src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@
{
"approved" => L["The previous set was approved."].Value,
"rejected" => L["The previous set was rejected."].Value,
"saved" => L["Saved to this new change set. Submit it when it holds every change you want reviewed together."].Value,
_ => null,
};

Expand Down
49 changes: 49 additions & 0 deletions src/FluentCMS.Admin/Components/Pages/ContentTypesView.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
@page "/content"
@using FluentCMS.Core.Schema
@inject IContentTypeDirectory ContentTypes
@inject IStringLocalizer<ContentTypesView> L

<PageTitle>@L["Content"]</PageTitle>

<h1 id="content-heading" class="text-2xl font-semibold tracking-tight">@L["Content"]</h1>
<p class="mt-1 text-sm text-muted-foreground">@L["Create and edit entries by hand. Each save opens a change set that is checked and reviewed like an agent's."]</p>

@if (_types.Count == 0)
{
<p class="mt-6 rounded-md border px-3 py-6 text-center text-sm text-muted-foreground" data-testid="content-empty">@L["No content type is defined yet."]</p>
}
else
{
<div class="mt-6 rounded-md border">
<Table aria-labelledby="content-heading">
<TableHeader>
<TableRow>
<TableHead>@L["Content type"]</TableHead>
<TableHead>@L["Slug"]</TableHead>
<TableHead>@L["Version"]</TableHead>
</TableRow>
</TableHeader>
<TableBody data-ui="rove">
@foreach (var (type, index) in _types.Select((type, index) => (type, index)))
{
<TableRow data-type="@type.Slug">
<TableCell>
<a href="@($"content/{type.Slug}")" data-ui="rove-item" tabindex="@(index == 0 ? null : "-1")" class="font-medium underline-offset-4 hover:underline"><bdi>@type.Name</bdi></a>
</TableCell>
<TableCell><bdi dir="ltr" class="font-mono text-xs">@type.Slug</bdi></TableCell>
<TableCell>@type.Version</TableCell>
</TableRow>
}
</TableBody>
</Table>
</div>
}

@code {
private IReadOnlyList<ContentTypeSummary> _types = [];

protected override async Task OnInitializedAsync()
{
_types = [.. (await ContentTypes.ListAsync()).OrderBy(type => type.Name, StringComparer.CurrentCulture)];
}
}
102 changes: 102 additions & 0 deletions src/FluentCMS.Admin/Components/Pages/EntriesView.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
@page "/content/{Slug}"
@using FluentCMS.Core.Content
@using FluentCMS.Core.Schema
@inject IContentTypeDirectory ContentTypes
@inject IEntryDirectory Entries
@inject NavigationManager Navigation
@inject IStringLocalizer<EntriesView> L

<PageTitle>@L["Content"]</PageTitle>

@if (_type is { } type)
{
<nav class="mb-4" aria-label="@L["Content navigation"]">
<a href="content" aria-keyshortcuts="B" class="@ButtonVariants.Classes("outline", "sm")">@L["Back to content types"]</a>
</nav>
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 id="entries-heading" class="text-2xl font-semibold tracking-tight"><bdi>@type.Name</bdi></h1>
<p class="mt-1 text-sm text-muted-foreground">
@(type.Definition.IsEntity ? L["An entity type: each field is a fact with its source, owner and validity."] : L["Entries of this content type."])
</p>
</div>
@if (!type.Definition.IsEntity)
{
<a href="@($"content/{type.Slug}/new")" aria-keyshortcuts="N" class="@ButtonVariants.Classes()" data-testid="new-entry">@L["New entry"]</a>
}
</div>
@if (type.Definition.IsEntity)
{
<p class="mt-4 rounded-md border px-3 py-2 text-sm text-muted-foreground" data-testid="entity-form-note">@L["Entity forms, with a source, owner and validity on every fact, come with the next forms slice. Agents write entities with set_entity_fact until then."]</p>
}

@if (_entries.Count == 0)
{
<p class="mt-6 rounded-md border px-3 py-6 text-center text-sm text-muted-foreground" data-testid="entries-empty">@L["No entry yet."]</p>
}
else
{
<div class="mt-6 rounded-md border">
<Table aria-labelledby="entries-heading">
<TableHeader>
<TableRow>
<TableHead>@L["Entry"]</TableHead>
<TableHead>@L["State"]</TableHead>
<TableHead>@L["Latest version"]</TableHead>
<TableHead>@L["Published version"]</TableHead>
</TableRow>
</TableHeader>
<TableBody data-ui="rove">
@foreach (var (entry, index) in _entries.Select((entry, index) => (entry, index)))
{
<TableRow data-entry="@entry.Id.ToString("N")">
<TableCell>
@if (type.Definition.IsEntity)
{
<bdi dir="ltr" class="font-mono text-xs">@entry.Id</bdi>
}
else
{
<a href="@($"content/{type.Slug}/{entry.Id}")" data-ui="rove-item" tabindex="@(index == 0 ? null : "-1")" class="font-mono text-xs underline-offset-4 hover:underline" dir="ltr">@entry.Id</a>
}
</TableCell>
<TableCell><Badge Variant="outline">@StateLabel(entry.State)</Badge></TableCell>
<TableCell>@entry.LatestVersion</TableCell>
<TableCell>@(entry.PublishedVersion?.ToString(System.Globalization.CultureInfo.CurrentCulture) ?? "–")</TableCell>
</TableRow>
}
</TableBody>
</Table>
</div>
}
}

@code {
private ContentType? _type;
private IReadOnlyList<EntrySummary> _entries = [];

[Parameter] public string Slug { get; set; } = string.Empty;

protected override async Task OnInitializedAsync()
{
var type = await ContentTypes.GetAsync(Slug);
if (!type.IsSuccess)
{
Navigation.NotFound();
return;
}

_type = type.Value;
var entries = await Entries.ListAsync(Slug);
_entries = entries.IsSuccess ? [.. entries.Value.Reverse()] : [];
}

private string StateLabel(EntryState state) => state switch
{
EntryState.Draft => L["Draft"],
EntryState.InReview => L["In review"],
EntryState.Published => L["Published"],
EntryState.Reverted => L["Reverted"],
_ => L["Archived"],
};
}
Loading
Loading