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