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

+399
View File
@@ -0,0 +1,399 @@
@inherits LocalizableComponentBase
@using System.Globalization
@* What the server says of itself (/clientapi/admin/settings): its name, its short description and its Markdown
description, its thumbnail, who answers for it (an address and a persona of this server, picked by searching the
local accounts or from the administrator's own personas), its privacy policy and terms of service, and whether its
weekly activity is published. The thumbnail is saved as soon as it is picked, streamed from the file. Saving starts
from the settings PrivaPub has now (InstanceAdminService.SaveSettings), so what Rules or Media limits saved stays.
Registrations are the configuration's and only shown. *@
<div class="flex flex-col gap-4 w-full" data-instance>
@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 (settings is null)
{
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm text-error">@Localizer["The server's settings could not be read."]</p>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
</div>
}
else
{
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold">@Localizer["Thumbnail"]</span>
<img src="@settings.ThumbnailUrl" alt="@Localizer["Thumbnail"]" class="w-full max-w-sm aspect-[40/21] object-cover rounded-lg neo-inset neo-2xs"
data-instance-thumbnail-image="@(settings.ThumbnailIsDefault ? "default" : "uploaded")" />
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="PickThumbnail" disabled="@(IsDefaultDisabled || isUploading)"
aria-label="@Localizer["Thumbnail"]" data-instance-thumbnail />
@if (isUploading)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
@if (!settings.ThumbnailIsDefault)
{
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isUploading" OnClick="RemoveThumbnail" data-instance-thumbnail-remove>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Back to the default picture"]</span>
</DButton>
}
</div>
<p class="text-xs opacity-70">@Localizer["Saved as soon as it is picked: at most {0}, fitted within 1200×630 and stripped of its metadata.", MaximumThumbnail.GetFileSize(Localizer)]</p>
<ErrorViewer AdditionalErrors="thumbnailErrors" />
</div>
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4">
<DataAnnotationsValidator />
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Server name"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.Title" Immediate="true" autocomplete="off" maxlength="64" data-instance-title />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Short description"]</span>
<DTextarea TValue="string" class="w-full" @bind-Value="form.ShortDescription" Immediate="true" rows="2" autocomplete="off" maxlength="500"
data-instance-short-description />
<span class="text-xs opacity-70">@Localizer["Plain text, as the instance API, NodeInfo and the server's own actor give it."]</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Description (Markdown)"]</span>
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.Description" Immediate="true" rows="8" autocomplete="off"
maxlength="@ServerSettingsForm.MaxText" data-instance-description />
@if (settings.DescriptionUpdatedAt is { } described)
{
<span class="text-xs opacity-70" title="@described.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Changed {0} ago", described.GetPassedTime(Localizer)]</span>
}
</label>
<fieldset class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
<legend class="text-sm font-semibold px-1">@Localizer["Contact"]</legend>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Contact address"]</span>
<DInput TValue="string" Type="email" class="w-full" @bind-Value="form.ContactEmail" Immediate="true" autocomplete="off" maxlength="254"
data-instance-email>
<Prefix><i class="ion-md-mail" aria-hidden="true"></i></Prefix>
</DInput>
</label>
<span class="text-sm">@Localizer["Contact persona"]</span>
@if (form.ContactAccountId is { Length: > 0 })
{
<div class="flex flex-wrap items-center gap-2 p-2 rounded-lg neo-raised neo-2xs" data-instance-contact="@form.ContactAccountId">
<i class="ion-md-person text-lg" aria-hidden="true"></i>
<span class="flex flex-col min-w-0 flex-1">
<b class="truncate">@(contactName ?? form.ContactAccountId)</b>
@if (contactHandle is { Length: > 0 })
{
<span class="text-xs opacity-70 truncate">@contactHandle</span>
}
</span>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="RemoveContact" data-instance-contact-remove>
<i class="ion-md-close" aria-hidden="true"></i><span>@Localizer["No contact persona"]</span>
</DButton>
</div>
}
else
{
<p class="text-xs opacity-70">@Localizer["None: the server names nobody."]</p>
}
@* its form attribute names no form, so Enter in the search never saves the settings *@
<DInput TValue="string" class="w-full" Value="contactQuery" ValueChanged="SearchContact" ValueExpression="() => contactQuery" Immediate="true"
autocomplete="off" form="instance-contact-search" placeholder="@Localizer["Search this server's personas"]"
aria-label="@Localizer["Search this server's personas"]" data-instance-contact-search>
<Prefix><i class="ion-md-search" aria-hidden="true"></i></Prefix>
</DInput>
<div class="flex flex-wrap gap-2" data-instance-contact-options>
@if (contactQuery is { Length: > 0 })
{
@foreach (var account in contactMatches)
{
<DButton Size="DSize.Sm" class="max-w-full" Disabled="IsDefaultDisabled || isSaving" OnClick="() => PickContact(account.Id, account.Name, account.Acct)"
data-instance-contact-option="@account.Id">
<span class="truncate">@account.Name</span><span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</DButton>
}
@if (contactMatches.Count == 0 && !isSearching)
{
<span class="text-xs opacity-70">@Localizer["No persona of this server matches."]</span>
}
}
else
{
@foreach (var persona in CascadingState.Personas)
{
<DButton Size="DSize.Sm" class="max-w-full" Disabled="IsDefaultDisabled || isSaving"
OnClick="() => PickContact(persona.Id, persona.Name ?? persona.UserName, persona.Handle ?? persona.UserName)" data-instance-contact-option="@persona.Id">
<span class="truncate">@(persona.Name ?? persona.UserName)</span>
</DButton>
}
}
</div>
<p class="text-xs opacity-70">@Localizer["The contact persona is shown to everyone as the one who runs this server."]</p>
</fieldset>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Privacy policy (Markdown)"]</span>
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.PrivacyPolicy" Immediate="true" rows="8" autocomplete="off"
maxlength="@ServerSettingsForm.MaxText" data-instance-privacy />
@if (settings.PrivacyPolicyUpdatedAt is { } policyAt)
{
<span class="text-xs opacity-70" title="@policyAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Changed {0} ago", policyAt.GetPassedTime(Localizer)]</span>
}
</label>
<div class="flex flex-col gap-1">
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Terms of service (Markdown)"]</span>
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.TermsOfService" Immediate="true" rows="8" autocomplete="off"
maxlength="@ServerSettingsForm.MaxText" placeholder="@Localizer["None"]" data-instance-terms />
</label>
<label class="flex flex-wrap items-center gap-2">
<span class="text-sm">@Localizer["In effect from"]</span>
<DInput TValue="string" Type="date" class="w-auto" @bind-Value="termsDate" autocomplete="off" data-instance-terms-date />
</label>
<span class="text-xs opacity-70">@Localizer["Empty, the server has no terms. Without a date, new terms take effect today."]</span>
</div>
<div class="flex flex-col gap-2">
<DToggle LabelText="@Localizer["Publish the weekly activity"]" @bind-Value="form.PublishActivity" data-instance-publish-activity />
<p class="text-xs opacity-70">@Localizer["Twelve weeks of local posts and new logins, for anyone to read. On a small server those counts tell who joined and when people write: it is off by default."]</p>
</div>
<p class="text-sm" data-instance-registrations="@settings.RegistrationMode">
<b>@Localizer["Sign-ups"]</b>
@(settings.RegistrationMode == "Open" ? Localizer["open to anyone"] : Localizer["by invitation only"])
<span class="text-xs opacity-70">@Localizer["(set in the server's configuration, Registrations:Mode)"]</span>
</p>
<ErrorViewer />
<div class="flex flex-wrap items-center justify-end gap-2 pointer-coarse:gap-2">
@if (settings.UpdatedAt is { } savedAt)
{
<span class="me-auto text-xs opacity-70" title="@savedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)" data-instance-saved>
@Localizer["Saved {0} ago by {1}", savedAt.GetPassedTime(Localizer), SavedBy]
</span>
}
<DButton Disabled="IsDefaultDisabled || isSaving" OnClick="Reset" data-instance-reset>@Localizer["Discard changes"]</DButton>
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" data-instance-save>
@if (isSaving)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Save"]</span>
</DButton>
</div>
</EditForm>
}
</div>
@code {
[Inject] InstanceAdminService InstanceAdminService { get; set; }
[Inject] AccountService AccountService { get; set; }
[Inject] MediaService MediaService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
ViewServerSettings settings;
ServerSettingsForm form = new();
// the terms' date as the date input holds it (yyyy-MM-dd), empty for none
string termsDate;
// the contact persona as shown: what the settings name, or the one just picked
string contactName;
string contactHandle;
string contactQuery;
List<Account> contactMatches = [];
int searching;
bool isSearching;
bool isSaving;
bool isUploading;
List<string> pictureTypes = [];
List<string> thumbnailErrors = [];
string PictureTypes => string.Join(',', pictureTypes);
// what PrivaPub takes for a picture now, within its hard cap
long MaximumThumbnail => settings?.Media.EffectiveMaxImageBytes is long effective and > 0
? Math.Min(effective, ServerMediaLimitsForm.HardMaxImageBytes)
: ServerMediaLimitsForm.HardMaxImageBytes;
string SavedBy => settings?.UpdatedBy switch
{
null => Localizer["someone"],
"cli" => Localizer["the command line"],
var name => name
};
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/", StringComparison.Ordinal))];
settings = await InstanceAdminService.Settings();
Reset();
}
async Task Retry()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
void Reset()
{
if (settings is null)
return;
form = InstanceAdminService.FormOf(settings);
termsDate = settings.TermsEffectiveAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
contactName = settings.ContactAccount?.Name ?? settings.ContactAccount?.UserName;
contactHandle = settings.ContactAccount?.Handle;
contactQuery = null;
contactMatches = [];
}
async Task SearchContact(string query)
{
contactQuery = query;
var lookup = ++searching;
if (string.IsNullOrWhiteSpace(query))
{
contactMatches = [];
isSearching = false;
return;
}
isSearching = true;
await Task.Delay(300);
if (lookup != searching)
return;
var found = await AccountService.Suggest(query.Trim(), false);
if (lookup != searching)
return;
// the server's own personas only: a local account's acct has no host
contactMatches = [.. found.Accounts.Where(a => !a.Group && a.Acct is { Length: > 0 } acct && !acct.Contains('@'))];
isSearching = false;
}
void PickContact(string accountId, string name, string handle)
{
form.ContactAccountId = accountId;
contactName = name;
contactHandle = handle;
contactQuery = null;
contactMatches = [];
}
void RemoveContact()
{
form.ContactAccountId = null;
contactName = null;
contactHandle = null;
}
async Task PickThumbnail(InputFileChangeEventArgs eventArgs)
{
thumbnailErrors = [];
var file = eventArgs.File;
if (!pictureTypes.Contains(file.ContentType))
{
thumbnailErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
return;
}
if (file.Size > MaximumThumbnail)
{
thumbnailErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name, file.Size.GetFileSize(Localizer),
MaximumThumbnail.GetFileSize(Localizer)]);
return;
}
isUploading = true;
var result = await InstanceAdminService.SaveThumbnail(file, MaximumThumbnail);
isUploading = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
// the form keeps what is being written; only the picture and the dates come from the answer
settings = (ViewServerSettings)result.Data;
Toast.AddResponseSuccess(Localizer["Thumbnail saved."]);
}
async Task RemoveThumbnail()
{
if (!await Confirm.Ask(Localizer["Remove the thumbnail?"], Localizer["The picture uploaded is deleted, and the server shows its default picture again."],
Localizer["Remove"], true))
return;
isUploading = true;
var result = await InstanceAdminService.RemoveThumbnail();
isUploading = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
settings = (ViewServerSettings)result.Data;
Toast.AddResponseSuccess(Localizer["Thumbnail removed."]);
}
// the value written here when it differs from the one shown, else the one PrivaPub has now
static string Changed(string written, string shown, string latest) => written == shown ? latest : written;
async Task Save()
{
var effective = default(DateTime?);
if (termsDate is { Length: > 0 })
{
if (!DateTime.TryParseExact(termsDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var on))
{
Toast.AddResponseError(Localizer["\"{0}\" is not a date.", termsDate]);
return;
}
effective = on;
}
// only what was changed here: what another administrator saved meanwhile to the rest stays
var shown = InstanceAdminService.FormOf(settings);
var dateChanged = termsDate != settings.TermsEffectiveAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
isSaving = true;
var result = await InstanceAdminService.SaveSettings(latest =>
{
latest.Title = Changed(form.Title?.Trim(), shown.Title, latest.Title);
latest.ShortDescription = Changed(form.ShortDescription, shown.ShortDescription, latest.ShortDescription);
latest.Description = Changed(form.Description, shown.Description, latest.Description);
latest.ContactEmail = Changed(form.ContactEmail?.Trim(), shown.ContactEmail, latest.ContactEmail);
latest.ContactAccountId = Changed(form.ContactAccountId, shown.ContactAccountId, latest.ContactAccountId);
latest.PrivacyPolicy = Changed(form.PrivacyPolicy, shown.PrivacyPolicy, latest.PrivacyPolicy);
latest.TermsOfService = Changed(form.TermsOfService, shown.TermsOfService, latest.TermsOfService);
latest.TermsEffectiveAt = dateChanged ? effective : latest.TermsEffectiveAt;
latest.PublishActivity = form.PublishActivity != shown.PublishActivity ? form.PublishActivity : latest.PublishActivity;
});
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
settings = (ViewServerSettings)result.Data;
Reset();
Toast.AddResponseSuccess(Localizer["The server's settings are saved."]);
}
}