R5 the server's own settings in decePub: instance, rules, announcements, relays, media limits, the staff's log, About
Build / Build (push) Successful in 1m5s

Administration's Pleroma placeholders give way to real sections: Instance (title, descriptions, contact, thumbnail,
privacy policy, terms, the weekly activity), Rules, Announcements, Relays, Media limits and the Moderation log, next to
Reports, blocked servers (now published or obfuscated), statistics and backups; moderators see Reports and the log. Every
save sends the whole settings form built from the latest read, since PrivaPub replaces what it carries. /about shows the
server's documents to anyone. The report dialog picks the rules a violation breaks, and reports show them. Announcements
show above the feed tabs, dismissed and reacted to, live from the user stream. Login names the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 04:32:57 +02:00
1 parent b8d7858acb
commit 4c88980374
33 files changed
+3743 -239

No files matched your search

+245
View File
@@ -0,0 +1,245 @@
@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."]);
}
}