245 lines
9.1 KiB
Plaintext
245 lines
9.1 KiB
Plaintext
@inherits LocalizableComponentBase
|
|||
|
|
@using System.Globalization
|
||
|
|
|
||
|
|
@* The administrators' announcements (/clientapi/admin/announcements): written in Markdown, with when they run and
|
||
|
|
whether they are all day, kept as drafts until published; shown to every persona above its feed while published and
|
||
|
|
not ended. Each says how many personas dismissed it and how they reacted. Deleting one takes its reads and reactions
|
||
|
|
with it, and every persona's stream hears it go. *@
|
||
|
|
<div class="flex flex-col gap-3 w-full" data-announcements>
|
||
|
|
<div class="flex flex-wrap items-center gap-2">
|
||
|
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled" OnClick="New" data-announcement-new>
|
||
|
|
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Write an announcement"]</span>
|
||
|
|
</DButton>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
@if (IsLoading)
|
||
|
|
{
|
||
|
|
<p class="flex w-full items-center justify-center gap-2">
|
||
|
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||
|
|
@Localizer["loading"]
|
||
|
|
</p>
|
||
|
|
}
|
||
|
|
else if (announcements.Count == 0)
|
||
|
|
{
|
||
|
|
<p class="text-sm opacity-70" data-announcements-empty>@Localizer["No announcement yet."]</p>
|
||
|
|
}
|
||
|
|
|
||
|
|
@foreach (var announcement in announcements)
|
||
|
|
{
|
||
|
|
<article class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-announcement="@announcement.Id" data-announcement-state="@StateOf(announcement)">
|
||
|
|
<div class="flex flex-wrap items-center gap-2">
|
||
|
|
@switch (StateOf(announcement))
|
||
|
|
{
|
||
|
|
case "shown":
|
||
|
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success">@Localizer["Shown now"]</DBadge>
|
||
|
|
break;
|
||
|
|
case "ended":
|
||
|
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Ended"]</DBadge>
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Draft"]</DBadge>
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
@if (announcement.AllDay)
|
||
|
|
{
|
||
|
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline">@Localizer["All day"]</DBadge>
|
||
|
|
}
|
||
|
|
<span class="text-xs opacity-70" title="@announcement.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
|
||
|
|
@Localizer["Written {0} ago", announcement.CreatedAt.GetPassedTime(Localizer)]
|
||
|
|
</span>
|
||
|
|
<span class="flex-1"></span>
|
||
|
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(announcement.Id)" OnClick="() => Edit(announcement)" data-announcement-edit>
|
||
|
|
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Edit"]</span>
|
||
|
|
</DButton>
|
||
|
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(announcement.Id)" OnClick="() => Delete(announcement)" data-announcement-delete>
|
||
|
|
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Delete"]</span>
|
||
|
|
</DButton>
|
||
|
|
</div>
|
||
|
|
@if (announcement.StartsAt is not null || announcement.EndsAt is not null)
|
||
|
|
{
|
||
|
|
<p class="text-xs opacity-70">@When(announcement)</p>
|
||
|
|
}
|
||
|
|
<div class="post-content doc-content text-sm break-words">@((MarkupString)announcement.Html)</div>
|
||
|
|
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||
|
|
<span class="opacity-70" data-announcement-reads="@announcement.Reads">@Localizer["Dismissed by {0}", announcement.Reads]</span>
|
||
|
|
@foreach (var reaction in announcement.Reactions)
|
||
|
|
{
|
||
|
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" data-announcement-reaction="@reaction.Name">
|
||
|
|
@reaction.Name <span class="tabular-nums">@reaction.Count</span>
|
||
|
|
</DBadge>
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
</article>
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
@if (editing is not null)
|
||
|
|
{
|
||
|
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
|
||
|
|
data-announcement-dialog>
|
||
|
|
<Heading>@(editing.Id is null ? Localizer["New announcement"] : Localizer["Edit the announcement"])</Heading>
|
||
|
|
<ChildContent>
|
||
|
|
<EditForm Model="editing" OnValidSubmit="Save" class="flex flex-col gap-3 mt-3">
|
||
|
|
<DataAnnotationsValidator />
|
||
|
|
<label class="flex flex-col gap-1">
|
||
|
|
<span class="text-sm">@Localizer["What it says (Markdown)"]</span>
|
||
|
|
<DTextarea TValue="string" class="w-full" @bind-Value="editing.Text" Immediate="true" rows="6" autocomplete="off" maxlength="5000"
|
||
|
|
data-announcement-text />
|
||
|
|
</label>
|
||
|
|
<div class="flex flex-wrap gap-3 pointer-coarse:gap-2">
|
||
|
|
<label class="flex flex-col gap-1">
|
||
|
|
<span class="text-sm">@Localizer["From"]</span>
|
||
|
|
<DInput TValue="string" Type="datetime-local" class="w-auto" @bind-Value="startsAt" autocomplete="off" data-announcement-starts />
|
||
|
|
</label>
|
||
|
|
<label class="flex flex-col gap-1">
|
||
|
|
<span class="text-sm">@Localizer["Until"]</span>
|
||
|
|
<DInput TValue="string" Type="datetime-local" class="w-auto" @bind-Value="endsAt" autocomplete="off" data-announcement-ends />
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
<p class="text-xs opacity-70">@Localizer["Both are optional: once its end is past, it is no longer shown."]</p>
|
||
|
|
<DToggle LabelText="@Localizer["All day"]" @bind-Value="editing.AllDay" data-announcement-all-day />
|
||
|
|
<DToggle LabelText="@Localizer["Published: shown to every persona"]" @bind-Value="editing.IsPublished" data-announcement-published />
|
||
|
|
<ErrorViewer AdditionalError="@error" />
|
||
|
|
<div class="flex justify-end gap-2">
|
||
|
|
<DButton OnClick="() => editing = null" data-announcement-cancel>@Localizer["Cancel"]</DButton>
|
||
|
|
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" data-announcement-save>
|
||
|
|
@if (isSaving)
|
||
|
|
{
|
||
|
|
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||
|
|
}
|
||
|
|
<span>@Localizer["Save"]</span>
|
||
|
|
</DButton>
|
||
|
|
</div>
|
||
|
|
</EditForm>
|
||
|
|
</ChildContent>
|
||
|
|
</DModal>
|
||
|
|
}
|
||
|
|
|
||
|
|
@code {
|
||
|
|
const string LocalInput = "yyyy-MM-ddTHH:mm";
|
||
|
|
|
||
|
|
[Inject] InstanceAdminService InstanceAdminService { get; set; }
|
||
|
|
[Inject] ConfirmService Confirm { get; set; }
|
||
|
|
|
||
|
|
List<ViewAnnouncement> announcements = [];
|
||
|
|
// the announcement being written or changed, with its times as the datetime-local inputs hold them (the reader's own)
|
||
|
|
AnnouncementForm editing;
|
||
|
|
string startsAt;
|
||
|
|
string endsAt;
|
||
|
|
string error;
|
||
|
|
bool isSaving;
|
||
|
|
HashSet<string> busy = [];
|
||
|
|
|
||
|
|
protected override async Task OnInitializedAsync()
|
||
|
|
{
|
||
|
|
if (CascadingState.IsOffline)
|
||
|
|
{
|
||
|
|
WhenOnline(LoadData);
|
||
|
|
StartOnlinePolling();
|
||
|
|
IsLoading = false;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
await LoadData();
|
||
|
|
IsLoading = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
async Task LoadData() => announcements = await InstanceAdminService.Announcements();
|
||
|
|
|
||
|
|
static string StateOf(ViewAnnouncement announcement) => announcement.IsShown ? "shown" : announcement.IsPublished ? "ended" : "draft";
|
||
|
|
|
||
|
|
string When(ViewAnnouncement announcement)
|
||
|
|
{
|
||
|
|
var format = announcement.AllDay ? SUtility.dateTimeFormat : SUtility.dateTimeFormatExt;
|
||
|
|
var starts = announcement.StartsAt?.ToLocalTime().ToString(format);
|
||
|
|
var ends = announcement.EndsAt?.ToLocalTime().ToString(format);
|
||
|
|
return (starts, ends) switch
|
||
|
|
{
|
||
|
|
({ } from, { } to) => Localizer["From {0} to {1}", from, to],
|
||
|
|
({ } from, null) => Localizer["From {0}", from],
|
||
|
|
_ => Localizer["until {0}", ends]
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
void Closed(bool open)
|
||
|
|
{
|
||
|
|
if (!open)
|
||
|
|
editing = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
void New()
|
||
|
|
{
|
||
|
|
error = null;
|
||
|
|
editing = new AnnouncementForm();
|
||
|
|
startsAt = null;
|
||
|
|
endsAt = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
void Edit(ViewAnnouncement announcement)
|
||
|
|
{
|
||
|
|
error = null;
|
||
|
|
editing = new AnnouncementForm
|
||
|
|
{
|
||
|
|
Id = announcement.Id,
|
||
|
|
Text = announcement.Text,
|
||
|
|
StartsAt = announcement.StartsAt,
|
||
|
|
EndsAt = announcement.EndsAt,
|
||
|
|
AllDay = announcement.AllDay,
|
||
|
|
IsPublished = announcement.IsPublished
|
||
|
|
};
|
||
|
|
startsAt = announcement.StartsAt?.ToLocalTime().ToString(LocalInput, CultureInfo.InvariantCulture);
|
||
|
|
endsAt = announcement.EndsAt?.ToLocalTime().ToString(LocalInput, CultureInfo.InvariantCulture);
|
||
|
|
}
|
||
|
|
|
||
|
|
// a datetime-local value, the reader's own time, in UTC; null when empty or not a time
|
||
|
|
static DateTime? Utc(string local) =>
|
||
|
|
DateTime.TryParse(local, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var at) ? at.ToUniversalTime() : null;
|
||
|
|
|
||
|
|
async Task Save()
|
||
|
|
{
|
||
|
|
error = null;
|
||
|
|
editing.StartsAt = Utc(startsAt);
|
||
|
|
editing.EndsAt = Utc(endsAt);
|
||
|
|
if (editing.StartsAt is { } from && editing.EndsAt is { } to && to < from)
|
||
|
|
{
|
||
|
|
error = Localizer["An announcement can't end before it starts."];
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
isSaving = true;
|
||
|
|
var result = await InstanceAdminService.SaveAnnouncement(editing);
|
||
|
|
isSaving = false;
|
||
|
|
if (result.IsInvalid)
|
||
|
|
{
|
||
|
|
error = result.ErrorMessage;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var saved = (ViewAnnouncement)result.Data;
|
||
|
|
var at = announcements.FindIndex(a => a.Id == saved.Id);
|
||
|
|
if (at < 0)
|
||
|
|
announcements.Insert(0, saved);
|
||
|
|
else
|
||
|
|
announcements[at] = saved;
|
||
|
|
editing = null;
|
||
|
|
Toast.AddResponseSuccess(Localizer["The announcement is saved."]);
|
||
|
|
}
|
||
|
|
|
||
|
|
async Task Delete(ViewAnnouncement announcement)
|
||
|
|
{
|
||
|
|
if (!await Confirm.Ask(Localizer["Delete the announcement?"], Localizer["It disappears for everyone, with its reactions."], Localizer["Delete"], true))
|
||
|
|
return;
|
||
|
|
|
||
|
|
busy.Add(announcement.Id);
|
||
|
|
var result = await InstanceAdminService.DeleteAnnouncement(announcement.Id);
|
||
|
|
busy.Remove(announcement.Id);
|
||
|
|
if (result.IsInvalid)
|
||
|
|
{
|
||
|
|
Toast.AddResponseError(result);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
announcements.Remove(announcement);
|
||
|
|
Toast.AddResponseSuccess(Localizer["The announcement is deleted."]);
|
||
|
|
}
|
||
|
|
}
|