Compare commits
No commits in common. "53006ea313a449c893e407f7804278143165b587" and "fe1cf7ce8affa26f23cef47618736bf786540a74" have entirely different histories.
53006ea313
...
fe1cf7ce8a
25 changed files with 24 additions and 462 deletions
|
@ -20,5 +20,4 @@
|
||||||
**/secrets.dev.yaml
|
**/secrets.dev.yaml
|
||||||
**/values.dev.yaml
|
**/values.dev.yaml
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
static-pages/*
|
|
|
@ -12,7 +12,6 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using Foxnouns.Backend.Dto;
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
@ -20,7 +19,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||||
namespace Foxnouns.Backend.Controllers;
|
namespace Foxnouns.Backend.Controllers;
|
||||||
|
|
||||||
[Route("/api/v2/meta")]
|
[Route("/api/v2/meta")]
|
||||||
public partial class MetaController : ApiControllerBase
|
public class MetaController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc";
|
private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc";
|
||||||
|
|
||||||
|
@ -49,23 +48,7 @@ public partial class MetaController : ApiControllerBase
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
[HttpGet("page/{page}")]
|
|
||||||
public async Task<IActionResult> GetStaticPageAsync(string page, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (!PageRegex().IsMatch(page))
|
|
||||||
{
|
|
||||||
throw new ApiError.BadRequest("Invalid page name");
|
|
||||||
}
|
|
||||||
|
|
||||||
string path = Path.Join(Directory.GetCurrentDirectory(), "static-pages", $"{page}.md");
|
|
||||||
string text = await System.IO.File.ReadAllTextAsync(path, ct);
|
|
||||||
return Ok(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("/api/v2/coffee")]
|
[HttpGet("/api/v2/coffee")]
|
||||||
public IActionResult BrewCoffee() =>
|
public IActionResult BrewCoffee() =>
|
||||||
Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
|
Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
|
||||||
|
|
||||||
[GeneratedRegex(@"^[a-z\-_]+$")]
|
|
||||||
private static partial Regex PageRegex();
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,9 +30,7 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo
|
||||||
public async Task<IActionResult> GetAuditLogAsync(
|
public async Task<IActionResult> GetAuditLogAsync(
|
||||||
[FromQuery] AuditLogEntryType? type = null,
|
[FromQuery] AuditLogEntryType? type = null,
|
||||||
[FromQuery] int? limit = null,
|
[FromQuery] int? limit = null,
|
||||||
[FromQuery] Snowflake? before = null,
|
[FromQuery] Snowflake? before = null
|
||||||
[FromQuery] Snowflake? after = null,
|
|
||||||
[FromQuery(Name = "by-moderator")] Snowflake? byModerator = null
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
limit = limit switch
|
limit = limit switch
|
||||||
|
@ -47,30 +45,11 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo
|
||||||
|
|
||||||
if (before != null)
|
if (before != null)
|
||||||
query = query.Where(e => e.Id < before.Value);
|
query = query.Where(e => e.Id < before.Value);
|
||||||
else if (after != null)
|
|
||||||
query = query.Where(e => e.Id > after.Value);
|
|
||||||
|
|
||||||
if (type != null)
|
if (type != null)
|
||||||
query = query.Where(e => e.Type == type);
|
query = query.Where(e => e.Type == type);
|
||||||
if (byModerator != null)
|
|
||||||
query = query.Where(e => e.ModeratorId == byModerator.Value);
|
|
||||||
|
|
||||||
List<AuditLogEntry> entries = await query.Take(limit!.Value).ToListAsync();
|
List<AuditLogEntry> entries = await query.Take(limit!.Value).ToListAsync();
|
||||||
|
|
||||||
return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry));
|
return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("moderators")]
|
|
||||||
public async Task<IActionResult> GetModeratorsAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var moderators = await db
|
|
||||||
.Users.Where(u =>
|
|
||||||
!u.Deleted && (u.Role == UserRole.Admin || u.Role == UserRole.Moderator)
|
|
||||||
)
|
|
||||||
.Select(u => new { u.Id, u.Username })
|
|
||||||
.OrderBy(u => u.Id)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
return Ok(moderators);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,7 +29,6 @@ public record ReportResponse(
|
||||||
PartialMember? TargetMember,
|
PartialMember? TargetMember,
|
||||||
ReportStatus Status,
|
ReportStatus Status,
|
||||||
ReportReason Reason,
|
ReportReason Reason,
|
||||||
string? Context,
|
|
||||||
ReportTargetType TargetType,
|
ReportTargetType TargetType,
|
||||||
JObject? Snapshot
|
JObject? Snapshot
|
||||||
);
|
);
|
||||||
|
|
|
@ -36,7 +36,6 @@ public class ModerationRendererService(
|
||||||
: null,
|
: null,
|
||||||
report.Status,
|
report.Status,
|
||||||
report.Reason,
|
report.Reason,
|
||||||
report.Context,
|
|
||||||
report.TargetType,
|
report.TargetType,
|
||||||
report.TargetSnapshot != null
|
report.TargetSnapshot != null
|
||||||
? JsonConvert.DeserializeObject<JObject>(report.TargetSnapshot)
|
? JsonConvert.DeserializeObject<JObject>(report.TargetSnapshot)
|
||||||
|
|
2
Foxnouns.Backend/static-pages/.gitignore
vendored
2
Foxnouns.Backend/static-pages/.gitignore
vendored
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
|
@ -1,6 +1,3 @@
|
||||||
import type { Member } from "./member";
|
|
||||||
import type { PartialMember, PartialUser, User } from "./user";
|
|
||||||
|
|
||||||
export type CreateReportRequest = {
|
export type CreateReportRequest = {
|
||||||
reason: ReportReason;
|
reason: ReportReason;
|
||||||
context: string | null;
|
context: string | null;
|
||||||
|
@ -27,35 +24,3 @@ export enum ReportReason {
|
||||||
Advertisement = "ADVERTISEMENT",
|
Advertisement = "ADVERTISEMENT",
|
||||||
CopyrightViolation = "COPYRIGHT_VIOLATION",
|
CopyrightViolation = "COPYRIGHT_VIOLATION",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Report = {
|
|
||||||
id: string;
|
|
||||||
reporter: PartialUser;
|
|
||||||
target_user: PartialUser;
|
|
||||||
target_member?: PartialMember;
|
|
||||||
status: "OPEN" | "CLOSED";
|
|
||||||
reason: ReportReason;
|
|
||||||
context: string | null;
|
|
||||||
target_type: "USER" | "MEMBER";
|
|
||||||
snapshot: User | Member | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AuditLogEntry = {
|
|
||||||
id: string;
|
|
||||||
moderator: AuditLogEntity;
|
|
||||||
target_user?: AuditLogEntity;
|
|
||||||
target_member?: AuditLogEntity;
|
|
||||||
report_id?: string;
|
|
||||||
type: AuditLogEntryType;
|
|
||||||
reason: string | null;
|
|
||||||
cleared_fields?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AuditLogEntity = { id: string; username: string };
|
|
||||||
|
|
||||||
export enum AuditLogEntryType {
|
|
||||||
IgnoreReport = "IGNORE_REPORT",
|
|
||||||
WarnUser = "WARN_USER",
|
|
||||||
WarnUserAndClearProfile = "WARN_USER_AND_CLEAR_PROFILE",
|
|
||||||
SuspendUser = "SUSPEND_USER",
|
|
||||||
}
|
|
||||||
|
|
|
@ -58,13 +58,6 @@
|
||||||
@{user.username}
|
@{user.username}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
{#if user.role === "ADMIN" || user.role === "MODERATOR"}
|
|
||||||
<NavItem>
|
|
||||||
<NavLink href="/admin" active={page.url.pathname.startsWith(`/admin`)}>
|
|
||||||
Administration
|
|
||||||
</NavLink>
|
|
||||||
</NavItem>
|
|
||||||
{/if}
|
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink href="/settings" active={page.url.pathname.startsWith("/settings")}>
|
<NavLink href="/settings" active={page.url.pathname.startsWith("/settings")}>
|
||||||
{$t("nav.settings")}
|
{$t("nav.settings")}
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { AuditLogEntity } from "$api/models/moderation";
|
|
||||||
|
|
||||||
type Props = { entity: AuditLogEntity };
|
|
||||||
let { entity }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<strong>{entity.username}</strong> <span class="text-secondary">({entity.id})</span>
|
|
|
@ -1,50 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { AuditLogEntry } from "$api/models/moderation";
|
|
||||||
import { idTimestamp } from "$lib";
|
|
||||||
import { renderMarkdown } from "$lib/markdown";
|
|
||||||
import { DateTime } from "luxon";
|
|
||||||
import AuditLogEntity from "./AuditLogEntity.svelte";
|
|
||||||
|
|
||||||
type Props = { entry: AuditLogEntry };
|
|
||||||
let { entry }: Props = $props();
|
|
||||||
|
|
||||||
let reason = $derived(renderMarkdown(entry.reason));
|
|
||||||
let date = $derived(idTimestamp(entry.id).toLocaleString(DateTime.DATETIME_MED));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Audit log</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="card my-1 p-2">
|
|
||||||
<h6 class="d-flex">
|
|
||||||
<span class="flex-grow-1">
|
|
||||||
<AuditLogEntity entity={entry.moderator} />
|
|
||||||
{#if entry.type === "IGNORE_REPORT"}
|
|
||||||
ignored a report
|
|
||||||
{:else if entry.type === "WARN_USER" || entry.type === "WARN_USER_AND_CLEAR_PROFILE"}
|
|
||||||
warned
|
|
||||||
{:else if entry.type === "SUSPEND_USER"}
|
|
||||||
suspended
|
|
||||||
{:else}
|
|
||||||
(unknown action <code>{entry.type}</code>)
|
|
||||||
{/if}
|
|
||||||
{#if entry.target_user}
|
|
||||||
<AuditLogEntity entity={entry.target_user} />
|
|
||||||
{/if}
|
|
||||||
{#if entry.target_member}
|
|
||||||
for member <AuditLogEntity entity={entry.target_member} />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<small class="text-secondary">{date}</small>
|
|
||||||
</h6>
|
|
||||||
{#if reason}
|
|
||||||
<details>
|
|
||||||
<summary>Reason</summary>
|
|
||||||
{@html reason}
|
|
||||||
</details>
|
|
||||||
{:else}
|
|
||||||
<em>(no reason given)</em>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
|
@ -1,17 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
|
|
||||||
type Props = { title: string; onlyNumber?: boolean; children: Snippet };
|
|
||||||
let { title, onlyNumber = true, children }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="col-md">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">{title}</h5>
|
|
||||||
<p class="card-text text-center" class:fs-1={onlyNumber}>
|
|
||||||
{@render children()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -7,7 +7,11 @@ const md = new MarkdownIt({
|
||||||
linkify: true,
|
linkify: true,
|
||||||
}).disable(["heading", "lheading", "link", "table", "blockquote"]);
|
}).disable(["heading", "lheading", "link", "table", "blockquote"]);
|
||||||
|
|
||||||
const unsafeMd = new MarkdownIt();
|
const unsafeMd = new MarkdownIt({
|
||||||
|
html: false,
|
||||||
|
breaks: true,
|
||||||
|
linkify: true,
|
||||||
|
});
|
||||||
|
|
||||||
export const renderMarkdown = (src: string | null) => (src ? sanitize(md.render(src)) : null);
|
export const renderMarkdown = (src: string | null) => (src ? sanitize(md.render(src)) : null);
|
||||||
|
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
import { page } from "$app/state";
|
|
||||||
|
|
||||||
export const isActive = (path: string | string[], prefix: boolean = false) =>
|
|
||||||
typeof path === "string"
|
|
||||||
? prefix
|
|
||||||
? page.url.pathname.startsWith(path)
|
|
||||||
: page.url.pathname === path
|
|
||||||
: prefix
|
|
||||||
? path.some((p) => page.url.pathname.startsWith(p))
|
|
||||||
: path.some((p) => page.url.pathname === p);
|
|
|
@ -1,30 +0,0 @@
|
||||||
import { apiRequest } from "$api";
|
|
||||||
import ApiError, { ErrorCode } from "$api/error";
|
|
||||||
import type { Report } from "$api/models/moderation";
|
|
||||||
import { idTimestamp } from "$lib";
|
|
||||||
import { redirect } from "@sveltejs/kit";
|
|
||||||
|
|
||||||
export const load = async ({ parent, fetch, cookies }) => {
|
|
||||||
const { meUser } = await parent();
|
|
||||||
if (!meUser) redirect(303, "/");
|
|
||||||
|
|
||||||
if (meUser.role !== "ADMIN" && meUser.role !== "MODERATOR") {
|
|
||||||
throw new ApiError({
|
|
||||||
status: 403,
|
|
||||||
code: ErrorCode.Forbidden,
|
|
||||||
message: "Only admins and moderators can use this page.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const reports = await apiRequest<Report[]>("GET", "/moderation/reports", { fetch, cookies });
|
|
||||||
const staleReportCount = reports.filter(
|
|
||||||
(r) => idTimestamp(r.id).diffNow(["days"]).days >= 7,
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
user: meUser,
|
|
||||||
isAdmin: meUser.role === "ADMIN",
|
|
||||||
reportCount: reports.length,
|
|
||||||
staleReportCount,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,50 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
import type { LayoutData } from "./$types";
|
|
||||||
import { isActive } from "$lib/pageUtils.svelte";
|
|
||||||
|
|
||||||
type Props = { data: LayoutData; children: Snippet };
|
|
||||||
let { data, children }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3 mt-1 mb-3">
|
|
||||||
<div class="list-group">
|
|
||||||
<a
|
|
||||||
href="/admin"
|
|
||||||
class="list-group-item list-group-item-action"
|
|
||||||
class:active={isActive("/admin")}
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/admin/reports"
|
|
||||||
class="list-group-item list-group-item-action"
|
|
||||||
class:active={isActive("/admin/reports", true)}
|
|
||||||
>
|
|
||||||
Reports
|
|
||||||
{#if data.reportCount}
|
|
||||||
<span
|
|
||||||
class="badge"
|
|
||||||
class:text-bg-danger={data.reportCount >= 10}
|
|
||||||
class:text-bg-secondary={data.reportCount < 10}
|
|
||||||
>
|
|
||||||
{data.reportCount >= 100 ? "99+" : data.reportCount.toString()}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/admin/audit-log"
|
|
||||||
class="list-group-item list-group-item-action"
|
|
||||||
class:active={isActive("/admin/audit-log", true)}
|
|
||||||
>
|
|
||||||
Audit log
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-9">
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,23 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import DashboardCard from "$components/admin/DashboardCard.svelte";
|
|
||||||
import type { PageData } from "./$types";
|
|
||||||
|
|
||||||
type Props = { data: PageData };
|
|
||||||
let { data }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h1>Dashboard</h1>
|
|
||||||
|
|
||||||
<div class="row gx-3 gy-3">
|
|
||||||
<DashboardCard title="Users" onlyNumber={false}>
|
|
||||||
<span class="fs-1">{data.meta.users.total.toLocaleString("en")}</span>
|
|
||||||
<br />
|
|
||||||
<small>({data.meta.users.active_month.toLocaleString("en")} active in the last month)</small>
|
|
||||||
</DashboardCard>
|
|
||||||
<DashboardCard title="Members">{data.meta.members.toLocaleString("en")}</DashboardCard>
|
|
||||||
<DashboardCard title="Open reports" onlyNumber={false}>
|
|
||||||
<span class="fs-1">{data.reportCount.toLocaleString("en")}</span>
|
|
||||||
<br />
|
|
||||||
<small>({data.staleReportCount} older than 1 week)</small>
|
|
||||||
</DashboardCard>
|
|
||||||
</div>
|
|
|
@ -1,38 +0,0 @@
|
||||||
import { apiRequest } from "$api";
|
|
||||||
import { type AuditLogEntity, type AuditLogEntry } from "$api/models/moderation.js";
|
|
||||||
|
|
||||||
export const load = async ({ url, fetch, cookies }) => {
|
|
||||||
const type = url.searchParams.get("type");
|
|
||||||
const before = url.searchParams.get("before");
|
|
||||||
const after = url.searchParams.get("after");
|
|
||||||
const byModerator = url.searchParams.get("by-moderator");
|
|
||||||
let limit: number = 100;
|
|
||||||
if (url.searchParams.has("limit")) limit = parseInt(url.searchParams.get("limit")!);
|
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set("limit", limit.toString());
|
|
||||||
if (type) params.set("type", type);
|
|
||||||
if (before) params.set("before", before);
|
|
||||||
if (after) params.set("after", after);
|
|
||||||
if (byModerator) params.set("by-moderator", byModerator);
|
|
||||||
|
|
||||||
const entries = await apiRequest<AuditLogEntry[]>(
|
|
||||||
"GET",
|
|
||||||
`/moderation/audit-log?${params.toString()}`,
|
|
||||||
{
|
|
||||||
fetch,
|
|
||||||
cookies,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const moderators = await apiRequest<AuditLogEntity[]>("GET", "/moderation/audit-log/moderators", {
|
|
||||||
fetch,
|
|
||||||
cookies,
|
|
||||||
});
|
|
||||||
|
|
||||||
let modFilter: AuditLogEntity | null = null;
|
|
||||||
if (byModerator)
|
|
||||||
modFilter = entries.find((e) => e.moderator.id === byModerator)?.moderator || null;
|
|
||||||
|
|
||||||
return { entries, type, before, after, modFilter, url: url.toString(), moderators };
|
|
||||||
};
|
|
|
@ -1,105 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { AuditLogEntity } from "$api/models/moderation";
|
|
||||||
import AuditLogEntryCard from "$components/admin/AuditLogEntryCard.svelte";
|
|
||||||
import {
|
|
||||||
ButtonDropdown,
|
|
||||||
DropdownItem,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownToggle,
|
|
||||||
} from "@sveltestrap/sveltestrap";
|
|
||||||
import type { PageData } from "./$types";
|
|
||||||
|
|
||||||
type Props = { data: PageData };
|
|
||||||
let { data }: Props = $props();
|
|
||||||
|
|
||||||
const addTypeFilter = (type: string | null) => {
|
|
||||||
const url = new URL(data.url);
|
|
||||||
if (type) url.searchParams.set("type", type);
|
|
||||||
else url.searchParams.delete("type");
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const addModerator = (mod: AuditLogEntity | null) => {
|
|
||||||
const url = new URL(data.url);
|
|
||||||
if (mod) url.searchParams.set("by-moderator", mod.id);
|
|
||||||
else url.searchParams.delete("by-moderator");
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const addBefore = (id: string) => {
|
|
||||||
const url = new URL(data.url);
|
|
||||||
url.searchParams.delete("after");
|
|
||||||
url.searchParams.set("before", id);
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const addAfter = (id: string) => {
|
|
||||||
const url = new URL(data.url);
|
|
||||||
url.searchParams.delete("before");
|
|
||||||
url.searchParams.set("after", id);
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h1>Audit log</h1>
|
|
||||||
|
|
||||||
<div class="btn-group">
|
|
||||||
<ButtonDropdown>
|
|
||||||
<DropdownToggle color="secondary" outline caret active={!!data.type}>
|
|
||||||
Filter by type
|
|
||||||
</DropdownToggle>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownItem href={addTypeFilter("IgnoreReport")} active={data.type === "IgnoreReport"}>
|
|
||||||
Ignore report
|
|
||||||
</DropdownItem>
|
|
||||||
<DropdownItem href={addTypeFilter("WarnUser")} active={data.type === "WarnUser"}>
|
|
||||||
Warn user
|
|
||||||
</DropdownItem>
|
|
||||||
<DropdownItem
|
|
||||||
href={addTypeFilter("WarnUserAndClearProfile")}
|
|
||||||
active={data.type === "WarnUserAndClearProfile"}
|
|
||||||
>
|
|
||||||
Warn user and clear profile
|
|
||||||
</DropdownItem>
|
|
||||||
<DropdownItem href={addTypeFilter("SuspendUser")} active={data.type === "SuspendUser"}>
|
|
||||||
Suspend user
|
|
||||||
</DropdownItem>
|
|
||||||
{#if data.type}
|
|
||||||
<DropdownItem href={addTypeFilter(null)}>Remove filter</DropdownItem>
|
|
||||||
{/if}
|
|
||||||
</DropdownMenu>
|
|
||||||
</ButtonDropdown>
|
|
||||||
<ButtonDropdown>
|
|
||||||
<DropdownToggle color="secondary" outline caret active={!!data.modFilter}>
|
|
||||||
Filter by moderator
|
|
||||||
</DropdownToggle>
|
|
||||||
<DropdownMenu>
|
|
||||||
{#each data.moderators as mod (mod.id)}
|
|
||||||
<DropdownItem href={addModerator(mod)} active={data.modFilter?.id === mod.id}>
|
|
||||||
{mod.username}
|
|
||||||
</DropdownItem>
|
|
||||||
{/each}
|
|
||||||
{#if data.modFilter}
|
|
||||||
<DropdownItem href={addModerator(null)}>Remove filter</DropdownItem>
|
|
||||||
{/if}
|
|
||||||
</DropdownMenu>
|
|
||||||
</ButtonDropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if data.before}
|
|
||||||
<a href={addAfter(data.before)}>Show newer entries</a>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each data.entries as entry (entry.id)}
|
|
||||||
<AuditLogEntryCard {entry} />
|
|
||||||
{:else}
|
|
||||||
<p class="text-secondary m-3">There are no entries matching your filter</p>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if data.entries.length === 100}
|
|
||||||
<a href={addBefore(data.entries[data.entries.length - 1].id)}>Show older entries</a>
|
|
||||||
{/if}
|
|
|
@ -1,14 +0,0 @@
|
||||||
import { baseRequest } from "$api";
|
|
||||||
import ApiError from "$api/error";
|
|
||||||
|
|
||||||
export const load = async ({ fetch, params }) => {
|
|
||||||
const resp = await baseRequest("GET", `/meta/page/${params.page}`, { fetch });
|
|
||||||
if (resp.status < 200 || resp.status > 299) {
|
|
||||||
const err = await resp.json();
|
|
||||||
if ("code" in err) throw new ApiError(err);
|
|
||||||
else throw new ApiError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageText = await resp.text();
|
|
||||||
return { page: params.page, text: pageText };
|
|
||||||
};
|
|
|
@ -1,22 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { renderUnsafeMarkdown } from "$lib/markdown";
|
|
||||||
import type { PageData } from "./$types";
|
|
||||||
|
|
||||||
type Props = { data: PageData };
|
|
||||||
let { data }: Props = $props();
|
|
||||||
|
|
||||||
let md = $derived(renderUnsafeMarkdown(data.text));
|
|
||||||
let title = $derived.by(() => {
|
|
||||||
let title = data.text.split("\n")[0];
|
|
||||||
if (title.startsWith("# ")) title = title.substring("# ".length);
|
|
||||||
return title;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{title} • pronouns.cc</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
{@html md}
|
|
||||||
</div>
|
|
|
@ -1,11 +1,20 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import { Nav, NavLink } from "@sveltestrap/sveltestrap";
|
import { Nav, NavLink } from "@sveltestrap/sveltestrap";
|
||||||
import { isActive } from "$lib/pageUtils.svelte";
|
|
||||||
|
|
||||||
type Props = { children: Snippet };
|
type Props = { children: Snippet };
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
const isActive = (path: string | string[], prefix: boolean = false) =>
|
||||||
|
typeof path === "string"
|
||||||
|
? prefix
|
||||||
|
? page.url.pathname.startsWith(path)
|
||||||
|
: page.url.pathname === path
|
||||||
|
: prefix
|
||||||
|
? path.some((p) => page.url.pathname.startsWith(p))
|
||||||
|
: path.some((p) => page.url.pathname === p);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import type { LayoutData } from "./$types";
|
import type { LayoutData } from "./$types";
|
||||||
import { isActive } from "$lib/pageUtils.svelte";
|
|
||||||
|
|
||||||
type Props = { data: LayoutData; children: Snippet };
|
type Props = { data: LayoutData; children: Snippet };
|
||||||
let { data, children }: Props = $props();
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
|
const isActive = (path: string) => page.url.pathname === path;
|
||||||
|
|
||||||
let name = $derived(
|
let name = $derived(
|
||||||
data.member.display_name === data.member.name
|
data.member.display_name === data.member.name
|
||||||
? data.member.name
|
? data.member.name
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import type { LayoutData } from "./$types";
|
import type { LayoutData } from "./$types";
|
||||||
import { isActive } from "$lib/pageUtils.svelte";
|
|
||||||
|
|
||||||
type Props = { data: LayoutData; children: Snippet };
|
type Props = { data: LayoutData; children: Snippet };
|
||||||
let { data, children }: Props = $props();
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
|
const isActive = (path: string) => page.url.pathname === path;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
|
@ -16,7 +16,6 @@ services:
|
||||||
- "5007:5001"
|
- "5007:5001"
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/config.ini:/app/config.ini
|
- ./docker/config.ini:/app/config.ini
|
||||||
- ./docker/static-pages:/app/static-pages
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: frontend
|
image: frontend
|
||||||
|
|
2
docker/static-pages/.gitignore
vendored
2
docker/static-pages/.gitignore
vendored
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
Loading…
Add table
Add a link
Reference in a new issue