Iceshrimp.NET/Iceshrimp.Frontend/Pages/Moderation/CustomEmojis.razor

291 lines
11 KiB
Text

@page "/mod/emojis"
@using Iceshrimp.Frontend.Core.Services
@using Iceshrimp.Frontend.Localization
@using Microsoft.AspNetCore.Authorization
@using Microsoft.Extensions.Localization
@using Microsoft.AspNetCore.Components.Sections
@using Iceshrimp.Assets.PhosphorIcons
@using Iceshrimp.Frontend.Core.Miscellaneous
@using Iceshrimp.Shared.Schemas.Web
@using Iceshrimp.Frontend.Components
@attribute [Authorize(Roles = "moderator")]
@layout ModerationLayout
@inject ApiService Api;
@inject GlobalComponentSvc Global;
@inject IJSRuntime Js;
@inject IStringLocalizer<Localization> Loc;
@inject ILogger<CustomEmojis> Logger;
<HeadTitle Text="@Loc["Custom Emojis"]"/>
<SectionContent SectionName="top-bar">
<Icon Name="Icons.Smiley"></Icon>
@Loc["Custom Emojis"]
@if (State is State.Empty or State.Loaded && Source == "local")
{
<span class="action btn" @onclick="OpenUpload" title="@Loc["Upload emoji"]">
<Icon Name="Icons.Upload"/>
</span>
<span class="action btn" @onclick="OpenImport" title="@Loc["Import emoji pack"]">
<Icon Name="Icons.FileArrowUp"/>
</span>
}
</SectionContent>
<div class="body">
@if (State is State.Empty or State.Error or State.Loaded)
{
<div class="emoji-search">
<input @bind="EmojiFilter" @bind:event="oninput" @bind:after="FilterEmojis" class="search" type="text"
placeholder="Search" aria-label="search"/>
<select class="search-from" @bind="Source" @bind:after="GetEmojis">
<option value="local">@Loc["Local"]</option>
<option value="remote">@Loc["Remote"]</option>
</select>
<select class="display-type" @bind=_displayType @bind:after="GetEmojis">
<option value="@DisplayType.All">@Loc["List"]</option>
<option value="@DisplayType.Categories">@Loc["Categories"]</option>
</select>
</div>
}
@if (State is State.Loaded)
{
@if (_displayType is DisplayType.All )
{
<div class="emoji-list">
@foreach (var emoji in DisplayedEmojis)
{
<EmojiManagementEntry Emoji="@emoji" Source="@Source" GetEmojis="GetEmojis"/>
}
</div>
@if (PaginationData is { Next: not null } && Source == "remote")
{
<ScrollEnd ManualLoad="FetchMore" IntersectionChange="FetchMore"></ScrollEnd>
}
}
@if (_displayType is DisplayType.Categories && Source == "local")
{
foreach (var el in LocalEmojiCategories)
{
<span class="category-name">@el.Key</span>
<div class="emoji-list">
@foreach (var emoji in el.Value)
{
<EmojiManagementEntry Emoji="emoji" Source="remote"/>
}
</div>
}
}
@if (_displayType is DisplayType.Categories && Source == "remote")
{
@foreach (var category in DisplayedCategories)
{
<HostEmojiEntry Host="@category"/>
}
<ScrollEnd IntersectionChange="FetchHosts" ManualLoad="FetchHosts"/>
}
}
@if (State is State.Empty)
{
<i>This instance has no emojis</i>
}
@if (State is State.Loading)
{
<div class="loading">
<LoadingSpinner Scale="2"/>
</div>
}
<div class="file-input">
<InputFile @ref="UploadInput" OnChange="Upload" accept="image/*">Upload</InputFile>
<InputFile @ref="ImportInput" OnChange="Import" accept=".zip">Import</InputFile>
</div>
</div>
@code {
private List<EmojiResponse> DisplayedEmojis { get; set; } = [];
private List<EmojiResponse> StoredLocalEmojis { get; set; } = [];
private List<EmojiResponse> StoredRemoteEmojis { get; set; } = [];
private Dictionary<string, List<EmojiResponse>> LocalEmojiCategories { get; set; } = new();
private HashSet<string> Categories { get; set; } = [];
private HashSet<string> DisplayedCategories { get; set; } = [];
private string EmojiFilter { get; set; } = "";
private string Source { get; set; } = "local";
private State State { get; set; }
private InputFile UploadInput { get; set; } = null!;
private IBrowserFile UploadFile { get; set; } = null!;
private InputFile ImportInput { get; set; } = null!;
private IBrowserFile ImportFile { get; set; } = null!;
private PaginationData? PaginationData { get; set; }
private IJSObjectReference _module = null!;
private DisplayType _displayType = DisplayType.All;
private void FilterEmojis()
{
if (Source == "remote" && _displayType == DisplayType.All || Source == "local")
{
DisplayedEmojis = StoredRemoteEmojis.Where(p => p.Name.Contains(EmojiFilter.Trim()) || p.Tags.Count(a => a.Contains(EmojiFilter.Trim())) > 0).ToList();
}
if (Source == "remote" && _displayType == DisplayType.Categories)
{
DisplayedCategories = Categories.Where(p => p.Contains(EmojiFilter.Trim())).ToHashSet();
}
if (Source == "local" && _displayType == DisplayType.Categories)
{
LocalEmojiCategories = StoredLocalEmojis
.Where(p => p.Name.Contains(EmojiFilter.Trim()) || p.Tags.Count(a => a.Contains(EmojiFilter.Trim())) != 0)
.OrderBy(p => p.Name)
.ThenBy(p => p.Id)
.GroupBy(p => p.Category)
.OrderBy(p => string.IsNullOrEmpty(p.Key))
.ThenBy(p => p.Key)
.ToDictionary(p => p.Key ?? "Other", p => p.ToList());
}
}
private enum DisplayType
{
Categories,
All
}
private async Task GetEmojis()
{
if (Source == "local")
{
State = State.Loading;
StoredLocalEmojis = await Api.Emoji.GetAllEmojiAsync();
FilterEmojis();
State = StoredLocalEmojis.Count == 0 ? State.Empty : State.Loaded;
}
if (Source == "remote")
{
if (_displayType is DisplayType.Categories)
{
await FetchHosts();
}
else
{
var res = await Api.Emoji.GetRemoteEmojiAsync(new PaginationQuery());
PaginationData = res.Links;
StoredRemoteEmojis = res.Data;
}
}
if (EmojiFilter.Length == 0)
{
switch (Source)
{
case "remote":
DisplayedEmojis = StoredRemoteEmojis;
break;
case "local":
DisplayedEmojis = StoredLocalEmojis;
break;
}
}
}
private async Task FetchHosts()
{
// Due to query weirdness, MinID and MaxID are reversed here
var pq = new PaginationQuery { MinId = Categories.LastOrDefault(), Limit = 250 };
var res = await Api.Emoji.GetRemoteEmojiHostsAsync(pq);
foreach (var el in res)
{
var op = Categories.Add(el.Entity);
if (!op) Logger.LogWarning($"Duplicate host entry: {el.Entity}");
}
if (EmojiFilter.Length == 0) DisplayedCategories = Categories;
}
private async Task FetchMore()
{
if (PaginationData?.Next == null) return;
var pq = new PaginationQuery { MaxId = PaginationData.Next?.Split('=')[1], Limit = PaginationData.Limit };
var res = await Api.Emoji.GetRemoteEmojiAsync(pq);
PaginationData = res.Links;
StoredRemoteEmojis.AddRange(res.Data);
if (EmojiFilter.Length == 0)
{
DisplayedEmojis = StoredRemoteEmojis;
}
StateHasChanged();
}
// The <InputFile> Component is hidden, and triggered by a sepperate button.
// That way we get it's functionality, without the styling limitations of the InputFile component
private async Task OpenUpload()
{
await _module.InvokeVoidAsync("openUpload", UploadInput.Element);
}
private async Task OpenImport()
{
await _module.InvokeVoidAsync("openUpload", ImportInput.Element);
}
private async Task Upload(InputFileChangeEventArgs e)
{
if (!e.File.ContentType.StartsWith("image/")) return;
UploadFile = e.File;
await Global.PromptDialog?.Prompt(new EventCallback<string?>(this, UploadCallback), Loc["Set emoji name"], "", e.File.Name.Split(".")[0], buttonText: Loc["Upload"])!;
}
private async Task UploadCallback(string? name)
{
if (name == null) return;
try
{
await Api.Emoji.UploadEmojiAsync(UploadFile, name);
await Global.NoticeDialog?.Display(Loc["Successfully uploaded {0}", name])!;
await GetEmojis();
}
catch (ApiException e)
{
await Global.NoticeDialog?.Display(e.Response.Message ?? Loc["An unknown error occurred"], NoticeDialog.NoticeType.Error)!;
}
}
private async Task Import(InputFileChangeEventArgs e)
{
if (e.File.ContentType != "application/zip") return;
ImportFile = e.File;
await Global.ConfirmDialog?.Confirm(new EventCallback<bool>(this, ImportCallback), Loc["Import {0}?", e.File.Name], Icons.FileArrowUp, Loc["Import"])!;
}
private async Task ImportCallback(bool import)
{
if (!import) return;
try
{
await Api.Emoji.ImportEmojiAsync(ImportFile);
await Global.NoticeDialog?.Display(Loc["Successfully imported emoji pack"])!;
await GetEmojis();
}
catch (ApiException e)
{
await Global.NoticeDialog?.Display(e.Response.Message ?? Loc["An unknown error occurred"], NoticeDialog.NoticeType.Error)!;
}
}
protected override async Task OnInitializedAsync()
{
_module = await Js.InvokeAsync<IJSObjectReference>("import",
"./Pages/Moderation/CustomEmojis.razor.js");
await GetEmojis();
}
}