feat(dashboard): add redirects page

This commit is contained in:
sam 2024-10-19 20:47:55 +02:00
parent 32ddb9fae2
commit cb425fe3cd
Signed by: sam
GPG key ID: 5F3C3C1B3166639D
7 changed files with 400 additions and 62 deletions

View file

@ -0,0 +1,92 @@
// Copyright (C) 2021-present sam (starshines.gay)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using Catalogger.Backend.Api.Middleware;
using Catalogger.Backend.Database.Queries;
using Microsoft.AspNetCore.Mvc;
using Remora.Discord.API.Abstractions.Objects;
namespace Catalogger.Backend.Api;
public partial class GuildsController
{
[HttpPost("redirects")]
public async Task<IActionResult> CreateRedirectAsync(
string id,
[FromBody] CreateRedirectRequest req
)
{
var (guildId, _) = await ParseGuildAsync(id);
var guildChannels = channelCache.GuildChannels(guildId).ToList();
var guildConfig = await db.GetGuildAsync(guildId.Value);
Console.WriteLine($"Source: {req.Source}, target: {req.Target}");
var source = guildChannels.FirstOrDefault(c =>
c.ID.Value == req.Source
&& c.Type
is ChannelType.GuildText
or ChannelType.GuildCategory
or ChannelType.GuildAnnouncement
or ChannelType.GuildForum
or ChannelType.GuildMedia
or ChannelType.GuildVoice
);
if (source == null)
throw new ApiError(
HttpStatusCode.BadRequest,
ErrorCode.BadRequest,
"Unknown source channel ID or it's not a valid source"
);
var target = guildChannels.FirstOrDefault(c =>
c.ID.Value == req.Target && c.Type is ChannelType.GuildText
);
if (target == null)
throw new ApiError(
HttpStatusCode.BadRequest,
ErrorCode.BadRequest,
"Unknown target channel ID or it's not a valid target"
);
guildConfig.Channels.Redirects[source.ID.Value] = target.ID.Value;
db.Update(guildConfig);
await db.SaveChangesAsync();
return NoContent();
}
[HttpDelete("redirects/{channelId}")]
public async Task<IActionResult> DeleteRedirectAsync(string id, ulong channelId)
{
var (guildId, _) = await ParseGuildAsync(id);
var guildConfig = await db.GetGuildAsync(guildId.Value);
if (!guildConfig.Channels.Redirects.ContainsKey(channelId))
throw new ApiError(
HttpStatusCode.BadRequest,
ErrorCode.BadRequest,
"That channel is already not being redirected"
);
guildConfig.Channels.Redirects.Remove(channelId, out _);
db.Update(guildConfig);
await db.SaveChangesAsync();
return NoContent();
}
public record CreateRedirectRequest(ulong Source, ulong Target);
}

View file

@ -26,7 +26,7 @@ using Remora.Rest.Core;
namespace Catalogger.Backend.Api;
[Route("/api/guilds/{id}")]
public class GuildsController(
public partial class GuildsController(
Config config,
DatabaseContext db,
ChannelCache channelCache,

View file

@ -1,11 +1,40 @@
export const TOKEN_KEY = "catalogger-token";
export default async function apiFetch<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
export type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";
export async function fastFetch(
method: HttpMethod,
path: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: any = null,
) {
): Promise<void> {
const token = localStorage.getItem(TOKEN_KEY);
const headers = {
...(body != null
? { "Content-Type": "application/json; charset=utf-8" }
: {}),
...(token ? { Authorization: token } : {}),
};
const reqBody = body ? JSON.stringify(body) : undefined;
console.debug("Sending", method, "request to", path, "with body", reqBody);
const resp = await fetch(path, {
method,
body: body ? JSON.stringify(body) : undefined,
headers,
});
if (resp.status < 200 || resp.status > 299)
throw (await resp.json()) as ApiError;
}
export default async function apiFetch<T>(
method: HttpMethod,
path: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: any = null,
): Promise<T> {
const token = localStorage.getItem(TOKEN_KEY);
const headers = {
...(body != null

View file

@ -8,7 +8,32 @@ export const load = async ({ params }) => {
`/api/guilds/${params.guildId}`,
);
return { guild };
const options = [];
const channelsWithoutCategory = guild.channels_without_category.filter(
(c) => c.can_log_to,
);
if (channelsWithoutCategory.length > 0)
options.push({
label: "(no category)",
options: channelsWithoutCategory.map((c) => ({
value: c.id,
label: `#${c.name}`,
})),
});
options.push(
...guild.categories
.map((cat) => ({
label: cat.name,
options: cat.channels
.filter((c) => c.can_log_to)
.map((c) => ({ value: c.id, label: `#${c.name}` })),
}))
.filter((c) => c.options.length > 0),
);
return { guild, options };
} catch (e) {
const err = e as ApiError;
console.log("Fetching guild", params.guildId, ":", e);

View file

@ -1,89 +1,85 @@
<script lang="ts">
import { Label } from "@sveltestrap/sveltestrap";
import type { FullGuild, GuildChannelConfig } from "$lib/api";
import type { GuildChannelConfig } from "$lib/api";
import type { PageData } from "./$types";
import ChannelSelect from "./ChannelSelect.svelte";
export let data: PageData;
$: channels = data.guild.config as GuildChannelConfig;
const logChannelOptions = (g: FullGuild) => {
let options = [];
const channelsWithoutCategory = g.channels_without_category.filter(
(c) => c.can_log_to,
);
if (channelsWithoutCategory.length > 0)
options.push({
label: "(no category)",
options: channelsWithoutCategory.map((c) => ({
value: c.id,
label: `#${c.name}`,
})),
});
options.push(
...data.guild.categories
.map((cat) => ({
label: cat.name,
options: cat.channels
.filter((c) => c.can_log_to)
.map((c) => ({ value: c.id, label: `#${c.name}` })),
}))
.filter((c) => c.options.length > 0),
);
return options;
};
$: options = logChannelOptions(data.guild);
</script>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3">
<div class="p-2">
<Label><strong>Server changes</strong></Label>
<ChannelSelect bind:value={channels.guild_update} {options} />
<ChannelSelect bind:value={channels.guild_update} options={data.options} />
</div>
<div class="p-2">
<Label><strong>Changes to emotes</strong></Label>
<ChannelSelect bind:value={channels.guild_emojis_update} {options} />
<ChannelSelect
bind:value={channels.guild_emojis_update}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>New roles</strong></Label>
<ChannelSelect bind:value={channels.guild_role_create} {options} />
<ChannelSelect
bind:value={channels.guild_role_create}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>Changes to roles</strong></Label>
<ChannelSelect bind:value={channels.guild_role_update} {options} />
<ChannelSelect
bind:value={channels.guild_role_update}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>Deleted roles</strong></Label>
<ChannelSelect bind:value={channels.guild_role_delete} {options} />
<ChannelSelect
bind:value={channels.guild_role_delete}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>New channels</strong></Label>
<ChannelSelect bind:value={channels.channel_create} {options} />
<ChannelSelect
bind:value={channels.channel_create}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>Changes to channels</strong></Label>
<ChannelSelect bind:value={channels.channel_update} {options} />
<ChannelSelect
bind:value={channels.channel_update}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>Deleted channels</strong></Label>
<ChannelSelect bind:value={channels.channel_delete} {options} />
<ChannelSelect
bind:value={channels.channel_delete}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>Users joining</strong></Label>
<ChannelSelect bind:value={channels.guild_member_add} {options} />
<ChannelSelect
bind:value={channels.guild_member_add}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>Users leaving</strong></Label>
<ChannelSelect bind:value={channels.guild_member_remove} {options} />
<ChannelSelect
bind:value={channels.guild_member_remove}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>Changes to key roles</strong></Label>
<ChannelSelect bind:value={channels.guild_update} {options} />
<ChannelSelect bind:value={channels.guild_update} options={data.options} />
<small>
Key roles can be designated with the <code>/key-roles</code> command or the
"Key roles" tab above.
@ -91,48 +87,72 @@
</div>
<div class="p-2">
<Label><strong>Member name changes</strong></Label>
<ChannelSelect bind:value={channels.guild_member_nick_update} {options} />
<ChannelSelect
bind:value={channels.guild_member_nick_update}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>Member icon changes</strong></Label>
<ChannelSelect bind:value={channels.guild_member_avatar_update} {options} />
<ChannelSelect
bind:value={channels.guild_member_avatar_update}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>Timeouts</strong></Label>
<ChannelSelect bind:value={channels.guild_member_timeout} {options} />
<ChannelSelect
bind:value={channels.guild_member_timeout}
options={data.options}
/>
<small> Note that this will not log timeouts naturally expiring. </small>
</div>
<div class="p-2">
<Label><strong>Kicked users</strong></Label>
<ChannelSelect bind:value={channels.guild_member_kick} {options} />
<ChannelSelect
bind:value={channels.guild_member_kick}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>Banned users</strong></Label>
<ChannelSelect bind:value={channels.guild_ban_add} {options} />
<ChannelSelect bind:value={channels.guild_ban_add} options={data.options} />
</div>
<div class="p-2">
<Label><strong>Unbanned users</strong></Label>
<ChannelSelect bind:value={channels.guild_ban_remove} {options} />
<ChannelSelect
bind:value={channels.guild_ban_remove}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>New invites</strong></Label>
<ChannelSelect bind:value={channels.invite_create} {options} />
<ChannelSelect bind:value={channels.invite_create} options={data.options} />
</div>
<div class="p-2">
<Label><strong>Deleted invites</strong></Label>
<ChannelSelect bind:value={channels.invite_delete} {options} />
<ChannelSelect bind:value={channels.invite_delete} options={data.options} />
</div>
<div class="p-2">
<Label><strong>Edited messages</strong></Label>
<ChannelSelect bind:value={channels.message_update} {options} />
<ChannelSelect
bind:value={channels.message_update}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>Deleted messages</strong></Label>
<ChannelSelect bind:value={channels.message_delete} {options} />
<ChannelSelect
bind:value={channels.message_delete}
options={data.options}
/>
</div>
<div class="p-2">
<Label><strong>Bulk deleted messages</strong></Label>
<ChannelSelect bind:value={channels.message_delete_bulk} {options} />
<ChannelSelect
bind:value={channels.message_delete_bulk}
options={data.options}
/>
<small> This will only be triggered by bots, not normal users. </small>
</div>
</div>

View file

@ -4,9 +4,9 @@
type Group = { label: string; options: Option[] };
type Option = { label: string; value: string };
export let options: Group[];
export let options: Array<Group | Option>;
export let placeholder: string = "Select a channel";
export let value: string;
export let value: string | null;
</script>
<Svelecte

View file

@ -1 +1,173 @@
<h1>aaaaaaaaa</h1>
<script lang="ts">
import type { ApiError, FullGuild } from "$lib/api";
import {
Button,
Label,
ListGroup,
ListGroupItem,
} from "@sveltestrap/sveltestrap";
import type { PageData } from "./$types";
import ChannelSelect from "../ChannelSelect.svelte";
import { fastFetch } from "$lib/api";
import { addToast } from "$lib/toast";
export let data: PageData;
$: redirects = data.guild.config.redirects;
const makeSourceOptions = (
guild: FullGuild,
redirects: Record<string, string>,
) => {
const options = [];
// We shouldn't list channels that are already being redirected.
const redirectedChannels = Object.keys(redirects);
options.push(
...guild.categories
.filter((cat) => !redirectedChannels.some((k) => k === cat.id))
.map((cat) => ({
label: `${cat.name} (category)`,
value: cat.id,
})),
);
// Filter these channels
const channelsWithoutCategory = guild.channels_without_category.filter(
(c) => c.can_redirect_from && !redirectedChannels.some((k) => k === c.id),
);
if (channelsWithoutCategory.length > 0)
options.push({
label: "(no category)",
options: channelsWithoutCategory.map((c) => ({
value: c.id,
label: `#${c.name}`,
})),
});
options.push(
...guild.categories
.map((cat) => ({
label: cat.name,
options: cat.channels
.filter(
(c) =>
c.can_redirect_from &&
!redirectedChannels.some((k) => k === c.id),
)
.map((c) => ({ value: c.id, label: `#${c.name}` })),
}))
.filter((c) => c.options.length > 0),
);
return options;
};
$: allChannels = [
...data.guild.channels_without_category.map((c) => ({
id: c.id,
name: `#${c.name}`,
})),
...data.guild.categories.map((cat) => ({ id: cat.id, name: cat.name })),
...data.guild.categories.flatMap((cat) =>
cat.channels.map((c) => ({ id: c.id, name: `#${c.name}` })),
),
];
const channelName = (id: string) =>
allChannels.find((c) => c.id === id)?.name || `(unknown channel ${id})`;
$: sourceOptions = makeSourceOptions(data.guild, redirects);
$: targetOptions = data.options;
let source: string | null = null;
let target: string | null = null;
const addRedirect = async () => {
if (!source || !target) {
addToast({
header: "Can't add redirect",
body: "You haven't selected a source and target channel.",
});
return;
}
try {
await fastFetch("POST", `/api/guilds/${data.guild.id}/redirects`, {
source,
target,
});
data.guild.config.redirects[source] = target;
addToast({ body: "Successfully added redirect." });
} catch (e) {
addToast({
header: "Error adding redirect",
body:
(e as ApiError).message || "Unknown error. Please try again later.",
});
}
};
const removeRedirect = async (id: string) => {
try {
await fastFetch("DELETE", `/api/guilds/${data.guild.id}/redirects/${id}`);
delete data.guild.config.redirects[id];
data.guild.config.redirects = data.guild.config.redirects;
addToast({ body: "Successfully removed redirect." });
} catch (e) {
addToast({
header: "Error removing redirect",
body:
(e as ApiError).message || "Unknown error. Please try again later.",
});
}
};
</script>
<h3>Add new redirect</h3>
<div class="row">
<div class="col-sm">
<Label><strong>Source</strong></Label>
<ChannelSelect bind:value={source} options={sourceOptions} />
</div>
<div class="col-sm">
<Label><strong>Target</strong></Label>
<ChannelSelect bind:value={target} options={targetOptions} />
</div>
</div>
<div>
<Button
color="primary"
disabled={!source || !target}
on:click={() => addRedirect()}>Add redirect</Button
>
</div>
{#if Object.keys(redirects).length > 0}
<h3>Existing redirects</h3>
<ListGroup>
{#each Object.keys(redirects) as redirectSource}
<ListGroupItem class="d-flex justify-content-between align-items-center">
<span
>{channelName(redirectSource)}{channelName(
redirects[redirectSource],
)}</span
>
<Button color="link" on:click={() => removeRedirect(redirectSource)}>
Remove redirect
</Button>
</ListGroupItem>
{/each}
</ListGroup>
{:else}
<p>There are no redirects configured.</p>
{/if}