Refactor FightTable and GroupTable components to use numeric group identifiers; enhance event handling in FightEdit and EventFightList; add new Pages management UI with editor tabs; improve event data handling and display logic; update event types to include hasFinished status; optimize announcement page rendering and structure.
Some checks failed
SteamWarCI Build failed
Some checks failed
SteamWarCI Build failed
This commit is contained in:
@@ -28,12 +28,14 @@
|
||||
import Events from "@components/moderator/pages/events/Events.svelte";
|
||||
import Dashboard from "@components/moderator/pages/dashboard/Dashboard.svelte";
|
||||
import Event from "@components/moderator/pages/event/Event.svelte";
|
||||
import Pages from "@components/moderator/pages/pages/Pages.svelte";
|
||||
|
||||
const routes: RouteDefinition = {
|
||||
"/": Dashboard,
|
||||
"/events": Events,
|
||||
"/players": Players,
|
||||
"/event/:id": Event,
|
||||
"/pages": Pages,
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -48,7 +50,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<main class="flex flex-col">
|
||||
<Router {routes} />
|
||||
</main>
|
||||
<Router {routes} />
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
let fightMap = $state(fight?.map);
|
||||
let fightBlueTeam = $state(fight?.blueTeam);
|
||||
let fightRedTeam = $state(fight?.redTeam);
|
||||
let fightStart = $state(fight?.start ? fromAbsolute(fight.start, "Europe/Berlin") : now("Europe/Berlin"));
|
||||
let fightStart = $state(fight?.start ? fromAbsolute(fight.start, "Europe/Berlin") : fromAbsolute(event.start, "Europe/Berlin"));
|
||||
let fightErgebnis = $state(fight?.ergebnis ?? 0);
|
||||
let fightSpectatePort = $state(fight?.spectatePort?.toString() ?? null);
|
||||
let fightGroup = $state(fight?.group?.id ?? null);
|
||||
@@ -139,7 +139,7 @@
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search Teams..." />
|
||||
<CommandInput placeholder="Search Maps..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No map found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
import GroupEditDialog from "./GroupEditDialog.svelte";
|
||||
import GroupResultsDialog from "./GroupResultsDialog.svelte";
|
||||
import type { ResponseGroups } from "@type/event";
|
||||
import { EditIcon, GroupIcon, LinkIcon } from "lucide-svelte";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@components/ui/dropdown-menu";
|
||||
|
||||
let { data = $bindable() }: { data: ExtendedEvent } = $props();
|
||||
|
||||
@@ -209,7 +211,7 @@
|
||||
<TableBody>
|
||||
{#each table.getRowModel().rows as groupRow (groupRow.id)}
|
||||
{#if groupRow.getIsGrouped()}
|
||||
{@const group = groups.find((g) => g.id === groupRow.getValue("group"))}
|
||||
{@const group = groups.find((g) => g.id == groupRow.getValue("group"))}
|
||||
<TableRow class="font-bold">
|
||||
<TableCell colspan={columns.length - 1}>
|
||||
<Checkbox
|
||||
@@ -220,7 +222,31 @@
|
||||
/>
|
||||
{group?.name ?? "Keine Gruppe"}
|
||||
</TableCell>
|
||||
<TableCell class="text-right"></TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button variant="ghost" size="icon" onclick={() => openGroupEditDialog(group)}>
|
||||
<EditIcon />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onclick={() => openGroupResultsDialog(group)}>
|
||||
<GroupIcon />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="ghost" size="icon">
|
||||
<LinkIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onclick={() => navigator.clipboard.writeText(`<group-table data-event="${data.event.id}"${group ? ` data-group="${group?.id}"` : ""}> </group-table>`)}
|
||||
>Punkte Tabelle</DropdownMenuItem
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onclick={() => navigator.clipboard.writeText(`<fight-table data-event="${data.event.id}"${group ? ` data-group="${group?.id}"` : ""}> </group-table>`)}
|
||||
>Kampf Tabelle</DropdownMenuItem
|
||||
>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{#each groupRow.subRows as row (row.id)}
|
||||
<TableRow data-state={row.getIsSelected() && "selected"}>
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
<TableBody>
|
||||
{#each Object.entries(group.points).toSorted((a, b) => b[1] - a[1]) as [teamIdString, points] (teamIdString)}
|
||||
{@const teamId = Number(teamIdString)}
|
||||
{@const team = teams.find((t) => t.id === teamId) as ResponseTeam}
|
||||
{@const playedGames = fights.filter((f) => f.start > Date.now() && f.group?.id === group.id && (f.blueTeam.id === teamId || f.redTeam.id === teamId)).length}
|
||||
{@const team = teams.find((t) => t.id === teamId) as ResponseTeam | undefined}
|
||||
{@const playedGames = fights.filter((f) => f.hasFinished && f.group?.id === group.id && (f.blueTeam.id === teamId || f.redTeam.id === teamId)).length}
|
||||
<TableRow>
|
||||
<TableCell>{team.name} ({team.kuerzel})</TableCell>
|
||||
<TableCell>{team?.name ?? "?"} ({team?.kuerzel ?? "?"})</TableCell>
|
||||
<TableCell class="text-right">{playedGames}</TableCell>
|
||||
<TableCell class="text-right font-bold">{points}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -90,7 +90,7 @@ export const columns: ColumnDef<EventFight> = [
|
||||
accessorKey: "ergebnis",
|
||||
cell: ({ row }) => {
|
||||
const fight = row.original;
|
||||
if (fight.ergebnis === 0 && fight.start > Date.now()) {
|
||||
if (!fight.hasFinished) {
|
||||
return "Noch nicht gespielt";
|
||||
} else if (fight.ergebnis === 1) {
|
||||
return fight.blueTeam.name + " hat gewonnen";
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="p-4 min-h-screen">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-semibold">Events</h1>
|
||||
<Dialog bind:open={createOpen}>
|
||||
|
||||
99
src/components/moderator/pages/pages/EditorWithTabs.svelte
Normal file
99
src/components/moderator/pages/pages/EditorWithTabs.svelte
Normal file
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from "@components/ui/separator";
|
||||
import { manager, OpenEditPage } from "./page.svelte";
|
||||
import { File, X } from "lucide-svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { basicSetup } from "codemirror";
|
||||
import EasyMDE from "easymde";
|
||||
import "easymde/dist/easymde.min.css";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { materialDark } from "@ddietr/codemirror-themes/theme/material-dark";
|
||||
|
||||
let codemirrorParent: HTMLElement | undefined = $state();
|
||||
let easyMdeParent: HTMLElement | undefined = $state();
|
||||
let easyMdeWrapper: HTMLElement | undefined = $state();
|
||||
|
||||
let easyMde: EasyMDE | null = $state(null);
|
||||
let view: EditorView | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
switch (manager.selectedPage?.fileType) {
|
||||
case "md":
|
||||
case "mdx":
|
||||
easyMdeWrapper?.classList.remove("hidden");
|
||||
codemirrorParent?.classList.add("hidden");
|
||||
break;
|
||||
case "json":
|
||||
easyMdeWrapper?.classList.add("hidden");
|
||||
codemirrorParent?.classList.remove("hidden");
|
||||
break;
|
||||
default:
|
||||
easyMdeWrapper?.classList.add("hidden");
|
||||
codemirrorParent?.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
function updatePage(page: OpenEditPage | undefined) {
|
||||
if (page) {
|
||||
easyMde?.value(page.content || "");
|
||||
view?.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: page.content || "" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => updatePage(manager.selectedPage));
|
||||
|
||||
onMount(() => {
|
||||
view = new EditorView({
|
||||
doc: manager.selectedPage?.content || "",
|
||||
parent: codemirrorParent,
|
||||
extensions: [basicSetup, json(), materialDark],
|
||||
});
|
||||
easyMde = new EasyMDE({
|
||||
element: easyMdeParent,
|
||||
spellChecker: false,
|
||||
initialValue: manager.selectedPage?.content || "",
|
||||
});
|
||||
|
||||
easyMde.codemirror.on("change", () => {
|
||||
if (manager.selectedPage?.content !== easyMde?.value()) {
|
||||
manager.selectedPage!.dirty = true;
|
||||
}
|
||||
|
||||
manager.selectedPage!.content = easyMde?.value() || "";
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full w-full">
|
||||
<div class="h-8 flex">
|
||||
{#each manager.pages as tab, index}
|
||||
{@const isActive = manager.openPageIndex === index}
|
||||
<button
|
||||
class="flex pl-4 border-r group items-center hover:bg-neutral-800 transition-colors cursor-pointer h-full {isActive
|
||||
? 'text-primary bg-neutral-900'
|
||||
: 'text-muted-foreground'} {tab.dirty ? 'italic' : ''}"
|
||||
onclick={() => (manager.openPageIndex = index)}
|
||||
>
|
||||
<File class="h-4 w-4 mr-2" />
|
||||
{tab.pageTitle}
|
||||
<span
|
||||
class="mx-4 hover:bg-neutral-700 transition-all rounded {isActive ? '' : 'opacity-0'} group-hover:opacity-100 cursor-pointer"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
manager.closePage(index);
|
||||
}}><X /></span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<div bind:this={codemirrorParent} class="hidden"></div>
|
||||
<div bind:this={easyMdeWrapper} class="hidden">
|
||||
<textarea bind:this={easyMdeParent}></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
27
src/components/moderator/pages/pages/Pages.svelte
Normal file
27
src/components/moderator/pages/pages/Pages.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { ResizablePane, ResizablePaneGroup } from "@components/ui/resizable";
|
||||
import { manager } from "./page.svelte";
|
||||
import ResizableHandle from "@components/ui/resizable/resizable-handle.svelte";
|
||||
import PagesList from "./PagesList.svelte";
|
||||
import EditorWithTabs from "./EditorWithTabs.svelte";
|
||||
</script>
|
||||
|
||||
<div class="flex-grow flex flex-col">
|
||||
<ResizablePaneGroup direction="horizontal" class="flex-grow">
|
||||
<ResizablePane defaultSize={20}>
|
||||
<div class="overflow-y-scroll">
|
||||
{#await manager.pagesLoad}
|
||||
<p>Loading pages...</p>
|
||||
{:then pages}
|
||||
{#each Object.values(pages.dirs) as page}
|
||||
<PagesList {page} path={page.name + "/"} />
|
||||
{/each}
|
||||
{/await}
|
||||
</div>
|
||||
</ResizablePane>
|
||||
<ResizableHandle />
|
||||
<ResizablePane defaultSize={80}>
|
||||
<EditorWithTabs />
|
||||
</ResizablePane>
|
||||
</ResizablePaneGroup>
|
||||
</div>
|
||||
116
src/components/moderator/pages/pages/PagesList.svelte
Normal file
116
src/components/moderator/pages/pages/PagesList.svelte
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight, Folder, FolderPlus, FileJson, FileText, File, FilePlus } from "lucide-svelte";
|
||||
import type { DirTree } from "./page.svelte";
|
||||
import PagesList from "./PagesList.svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import Button from "@components/ui/button/button.svelte";
|
||||
import { manager } from "./page.svelte";
|
||||
|
||||
const { page, depth = 0, path }: { page: DirTree; depth?: number; path: string } = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let newPage = $state(false);
|
||||
let newPageName = $state("");
|
||||
|
||||
let newPageInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
function startNewPageCreate(e: Event) {
|
||||
e.stopPropagation();
|
||||
newPage = true;
|
||||
newPageName = "";
|
||||
open = true;
|
||||
|
||||
setTimeout(() => {
|
||||
newPageInput?.focus();
|
||||
}, 1);
|
||||
}
|
||||
|
||||
function createNewPage(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (newPageName.trim() === "") {
|
||||
alert("Page name cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newPageName.match(/^[a-zA-Z0-9_\-\.]+$/)) {
|
||||
alert("Invalid page name. Only alphanumeric characters, underscores, dashes, and dots are allowed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newPageName.endsWith(".json") && !newPageName.endsWith(".md") && !newPageName.endsWith(".mdx")) {
|
||||
newPageName += ".md";
|
||||
}
|
||||
|
||||
manager
|
||||
.createPage(path + newPageName, newPageName)
|
||||
.then(() => {
|
||||
newPage = false;
|
||||
newPageName = "";
|
||||
})
|
||||
.catch((error) => {
|
||||
alert("Error creating page: " + error.message);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class={`group flex flex-row justify-between h-full w-full hover:bg-neutral-700 pl-${4 * depth}`} onclick={() => (open = !open)}>
|
||||
<div class="flex flex-row items-center">
|
||||
{#if open}
|
||||
<ChevronDown class="w-6 h-6" />
|
||||
{:else}
|
||||
<ChevronRight class="w-6 h-6" />
|
||||
{/if}
|
||||
<Folder class="mr-2 w-4 h-4" />
|
||||
{page.name}/
|
||||
</div>
|
||||
<div class="flex-row items-center hidden group-hover:flex">
|
||||
<Button variant="ghost" size="sm" class="p-0 m-0 h-6 w-6" onclick={startNewPageCreate}>
|
||||
<FilePlus class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div transition:slide={{ duration: 200, axis: "y" }}>
|
||||
<div>
|
||||
{#if newPage}
|
||||
<button class={`flex flex-row items-center h-full py-1 w-full hover:bg-neutral-700 pl-${4 * (depth + 1)}`}>
|
||||
{#if newPageName.endsWith(".json")}
|
||||
<FileJson class="mr-2 w-4 h-4" />
|
||||
{:else if newPageName.endsWith(".md") || newPageName.endsWith(".mdx")}
|
||||
<FileText class="mr-2 w-4 h-4" />
|
||||
{:else}
|
||||
<File class="mr-2 w-4 h-4" />
|
||||
{/if}
|
||||
<form onsubmit={createNewPage}>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newPageName}
|
||||
bind:this={newPageInput}
|
||||
onblur={() => (newPage = false)}
|
||||
placeholder="New page name"
|
||||
class="flex-grow bg-transparent border-none outline-none text-white"
|
||||
/>
|
||||
</form>
|
||||
</button>
|
||||
{/if}
|
||||
{#each Object.values(page.dirs) as subPage (subPage.name)}
|
||||
<PagesList page={subPage} depth={depth + 1} path={path + subPage.name + "/"} />
|
||||
{/each}
|
||||
{#each Object.values(page.files) as file (file.id)}
|
||||
<button class={`flex flex-row items-center h-full py-1 w-full hover:bg-neutral-700 pl-${4 * (depth + 1)}`} onclick={() => manager.openPage(file.id)}>
|
||||
{#if file.name.endsWith(".json")}
|
||||
<FileJson class="mr-2 w-4 h-4" />
|
||||
{:else if file.name.endsWith(".md") || file.name.endsWith(".mdx")}
|
||||
<FileText class="mr-2 w-4 h-4" />
|
||||
{:else}
|
||||
<File class="mr-2 w-4 h-4" />
|
||||
{/if}
|
||||
{file.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
163
src/components/moderator/pages/pages/page.svelte.ts
Normal file
163
src/components/moderator/pages/pages/page.svelte.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { base64ToBytes } from "@components/admin/util";
|
||||
import { pageRepo } from "@components/repo/page";
|
||||
import type { ListPage, PageList } from "@components/types/page";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export class OpenEditPage {
|
||||
public content: string = "";
|
||||
public frontmatter: { [key: string]: string } = $state({});
|
||||
public dirty: boolean = $state(false);
|
||||
|
||||
public readonly fileType: string;
|
||||
|
||||
public constructor(
|
||||
private manager: PageManager,
|
||||
public readonly pageId: number,
|
||||
public readonly pageTitle: string,
|
||||
public readonly sha: string,
|
||||
public readonly originalContent: string,
|
||||
private readonly path: string
|
||||
) {
|
||||
this.fileType = this.path.split(".").pop() || "md";
|
||||
|
||||
this.content = this.removeFrontmatter(originalContent);
|
||||
this.frontmatter = this.parseFrontmatter(originalContent);
|
||||
}
|
||||
|
||||
public focus(): boolean {
|
||||
let index = this.manager.pages.indexOf(this);
|
||||
|
||||
if (index === this.manager.openPageIndex) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.manager.openPageIndex = this.manager.pages.indexOf(this);
|
||||
return false;
|
||||
}
|
||||
|
||||
private parseFrontmatter(content: string): { [key: string]: string } {
|
||||
const frontmatter: { [key: string]: string } = {};
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*([a-zA-Z0-9_-]+):\s*(.*)$/);
|
||||
if (match) {
|
||||
frontmatter[match[1]] = match[2].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
private removeFrontmatter(content: string): string {
|
||||
const lines = content.split("\n");
|
||||
let inFrontmatter = false;
|
||||
const result: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === "---") {
|
||||
inFrontmatter = !inFrontmatter;
|
||||
continue;
|
||||
}
|
||||
if (!inFrontmatter) {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join("\n").trim();
|
||||
}
|
||||
}
|
||||
|
||||
export interface DirTree {
|
||||
name: string;
|
||||
dirs: { [key: string]: DirTree };
|
||||
files: { [key: string]: ListPage };
|
||||
}
|
||||
|
||||
export class PageManager {
|
||||
public branch: string = $state("master");
|
||||
public pages: OpenEditPage[] = $state([]);
|
||||
|
||||
public openPageIndex: number = $state(-1);
|
||||
public pagesLoad = $derived(get(pageRepo).listPages(this.branch).then(this.convertToTree));
|
||||
|
||||
public selectedPage = $derived(this.openPageIndex >= 0 ? this.pages[this.openPageIndex] : undefined);
|
||||
|
||||
private convertToTree(pages: PageList): DirTree {
|
||||
const tree: DirTree = { dirs: {}, files: {}, name: "/" };
|
||||
|
||||
pages.forEach((page) => {
|
||||
const pathParts = page.path.split("/").filter((part) => part !== "");
|
||||
let current = tree;
|
||||
|
||||
// Navigate/create directory structure
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const dir = pathParts[i];
|
||||
if (!current.dirs[dir]) {
|
||||
current.dirs[dir] = { dirs: {}, files: {}, name: dir };
|
||||
}
|
||||
current = current.dirs[dir];
|
||||
}
|
||||
|
||||
// Add file to the final directory
|
||||
const fileName = pathParts[pathParts.length - 1];
|
||||
current.files[fileName] = page;
|
||||
});
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
public async openPage(pageId: number) {
|
||||
const existingPage = this.existingPage(pageId);
|
||||
if (existingPage) {
|
||||
existingPage.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
let r = await get(pageRepo).getPage(pageId, this.branch);
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPage = new OpenEditPage(this, pageId, r.name, r.sha, new TextDecoder().decode(base64ToBytes(r.content)), r.path);
|
||||
this.pages.push(newPage);
|
||||
newPage.focus();
|
||||
}
|
||||
|
||||
public existingPage(pageId: number): OpenEditPage | undefined {
|
||||
return this.pages.find((page) => page.pageId === pageId);
|
||||
}
|
||||
|
||||
public closePage(index: number) {
|
||||
if (index < 0 || index >= this.pages.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = this.pages[index];
|
||||
if (page.dirty) {
|
||||
if (!confirm(`The page "${page.pageTitle}" has unsaved changes. Are you sure you want to close it?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.pages.splice(index, 1);
|
||||
if (this.openPageIndex >= index) {
|
||||
this.openPageIndex = Math.max(0, this.openPageIndex - 1);
|
||||
}
|
||||
|
||||
if (this.openPageIndex < 0 && this.pages.length > 0) {
|
||||
this.openPageIndex = 0;
|
||||
}
|
||||
|
||||
if (this.pages.length === 0) {
|
||||
this.openPageIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
public async createPage(path: string, newPageName: string): Promise<void> {
|
||||
await get(pageRepo).createFile(path, this.branch, newPageName, newPageName);
|
||||
this.branch = this.branch;
|
||||
}
|
||||
}
|
||||
|
||||
export const manager = $state(new PageManager());
|
||||
Reference in New Issue
Block a user