Compare commits
28 Commits
bff1a4bda6
...
main
Author | SHA1 | Date | |
---|---|---|---|
2f6b3521cd | |||
6789a65285 | |||
7a0db65f78 | |||
94aa6ea377 | |||
a06cc34085 | |||
9041578252 | |||
deafb65c75 | |||
2eb9891b3c | |||
36fe39845f | |||
d82ac4f275 | |||
136e0b808c | |||
d29e761efb | |||
018e239c35 | |||
1f96e3babe | |||
e5f253ebc1 | |||
6e4a7f0ac9 | |||
55f5f25e5a | |||
03ee87d7cf | |||
9a6e44b2d5 | |||
1a81b5fb06 | |||
d7b05deff2 | |||
e9e44f67a2 | |||
eeeca4ed4e | |||
7b5557bd76 | |||
29a80935ff | |||
023fd67004 | |||
daa1de302b | |||
e0c91483fb |
@ -6,6 +6,7 @@ import { team } from './team.ts';
|
||||
import { settings } from './settings.ts';
|
||||
import { feedback } from './feedback.ts';
|
||||
import { report } from './report.ts';
|
||||
import { tools } from './tools.ts';
|
||||
|
||||
export const server = {
|
||||
admin,
|
||||
@ -15,5 +16,6 @@ export const server = {
|
||||
user,
|
||||
report,
|
||||
feedback,
|
||||
settings
|
||||
settings,
|
||||
tools
|
||||
};
|
||||
|
@ -66,8 +66,20 @@ export const report = {
|
||||
const hash = md5Hash.digest('hex');
|
||||
const filePath = path.join(UPLOAD_PATH!, hash);
|
||||
|
||||
let type: 'image' | 'video';
|
||||
if (allowedImageTypes.includes(file.type)) {
|
||||
type = 'image';
|
||||
} else if (allowedVideoTypes.includes(file.type)) {
|
||||
type = 'video';
|
||||
} else {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Invalid file type'
|
||||
});
|
||||
}
|
||||
|
||||
await tx.addReportAttachment({
|
||||
type: 'video',
|
||||
type: type,
|
||||
hash: hash,
|
||||
reportId: report.id
|
||||
});
|
||||
@ -117,6 +129,20 @@ export const report = {
|
||||
};
|
||||
}
|
||||
}),
|
||||
editReport: defineAction({
|
||||
input: z.object({
|
||||
reportId: z.number(),
|
||||
reported: z.number().nullable()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
|
||||
|
||||
await db.editReport({
|
||||
id: input.reportId,
|
||||
reportedTeamId: input.reported
|
||||
});
|
||||
}
|
||||
}),
|
||||
reportStatus: defineAction({
|
||||
input: z.object({
|
||||
reportId: z.number()
|
||||
@ -184,6 +210,18 @@ export const report = {
|
||||
};
|
||||
}
|
||||
}),
|
||||
reportAttachments: defineAction({
|
||||
input: z.object({
|
||||
reportId: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
|
||||
|
||||
return {
|
||||
reportAttachments: (await db.getReportAttachments(input)) ?? []
|
||||
};
|
||||
}
|
||||
}),
|
||||
addStrikeReason: defineAction({
|
||||
input: z.object({
|
||||
name: z.string(),
|
||||
|
@ -128,5 +128,53 @@ export const team = {
|
||||
teams: await db.getTeams(input)
|
||||
};
|
||||
}
|
||||
}),
|
||||
addDeath: defineAction({
|
||||
input: z.object({
|
||||
deadUserId: z.number(),
|
||||
killerUserId: z.number().nullish(),
|
||||
message: z.string()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
const { id } = await db.addDeath(input);
|
||||
|
||||
return {
|
||||
id: id
|
||||
};
|
||||
}
|
||||
}),
|
||||
editDeath: defineAction({
|
||||
input: z.object({
|
||||
id: z.number(),
|
||||
deadUserId: z.number(),
|
||||
killerUserId: z.number().nullish(),
|
||||
message: z.string()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
await db.editDeath(input);
|
||||
}
|
||||
}),
|
||||
deleteDeath: defineAction({
|
||||
input: z.object({
|
||||
id: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
await db.deleteDeath(input);
|
||||
}
|
||||
}),
|
||||
deaths: defineAction({
|
||||
handler: async (_, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
return {
|
||||
deaths: await db.getDeaths({})
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
|
57
src/actions/tools.ts
Normal file
57
src/actions/tools.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { ActionError, defineAction } from 'astro:actions';
|
||||
import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import { z } from 'astro:schema';
|
||||
import { getBedrockUuid, getJavaUuid } from '@util/minecraft.ts';
|
||||
|
||||
export const tools = {
|
||||
uuidFromUsername: defineAction({
|
||||
input: z.object({
|
||||
edition: z.enum(['java', 'bedrock']),
|
||||
username: z.string()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Tools);
|
||||
|
||||
let uuid = null;
|
||||
switch (input.edition) {
|
||||
case 'java':
|
||||
try {
|
||||
uuid = await getJavaUuid(input.username);
|
||||
} catch (_) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: `Der Username ${input.username} existiert nicht`
|
||||
});
|
||||
}
|
||||
if (uuid == null) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Während der Anfrage zur Mojang API ist ein Fehler aufgetreten`
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'bedrock':
|
||||
try {
|
||||
uuid = await getBedrockUuid(input.username);
|
||||
} catch (_) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: `Der Username ${input.username} existiert nicht`
|
||||
});
|
||||
}
|
||||
if (uuid == null) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Während der Anfrage zum Username Resolver ist ein Fehler aufgetreten`
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
uuid: uuid
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
@ -1,11 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Report, ReportStatus, StrikeReasons } from './reports.ts';
|
||||
import { editReport, getReportAttachments, type Report, type ReportStatus, type StrikeReasons } from './reports.ts';
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import Textarea from '@components/input/Textarea.svelte';
|
||||
import Select from '@components/input/Select.svelte';
|
||||
import TeamSearch from '@components/admin/search/TeamSearch.svelte';
|
||||
import { editReportStatus, getReportStatus } from '@app/admin/reports/reports.ts';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
// html bindings
|
||||
let previewDialogElem: HTMLDialogElement;
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
@ -17,11 +21,16 @@
|
||||
let { strikeReasons, report }: Props = $props();
|
||||
|
||||
// states
|
||||
let reportedTeam = $state<{ id: number; name: string } | null>(report?.reported ?? null);
|
||||
|
||||
let status = $state<'open' | 'closed' | null>(null);
|
||||
let notice = $state<string | null>(null);
|
||||
let statement = $state<string | null>(null);
|
||||
let strikeReason = $state<string | null>(String(report?.strike?.strikeReasonId ?? null));
|
||||
|
||||
let reportAttachments = $state<{ type: 'image' | 'video'; hash: string }[]>([]);
|
||||
let previewReportAttachment = $state<{ type: 'image' | 'video'; hash: string } | null>(null);
|
||||
|
||||
// consts
|
||||
const strikeReasonValues = strikeReasons.reduce(
|
||||
(prev, curr) => Object.assign(prev, { [curr.id]: `${curr.name} (${curr.weight})` }),
|
||||
@ -39,6 +48,14 @@
|
||||
notice = reportStatus.notice;
|
||||
statement = reportStatus.statement;
|
||||
});
|
||||
|
||||
getReportAttachments(report).then((value) => {
|
||||
if (value) reportAttachments = value;
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (previewReportAttachment) previewDialogElem.show();
|
||||
});
|
||||
|
||||
// callbacks
|
||||
@ -46,35 +63,76 @@
|
||||
$confirmPopupState = {
|
||||
title: 'Änderungen speichern?',
|
||||
message: 'Sollen die Änderungen am Report gespeichert werden?',
|
||||
onConfirm: async () =>
|
||||
editReportStatus(report!, {
|
||||
onConfirm: async () => {
|
||||
if (reportedTeam?.id != report?.reported?.id) {
|
||||
report!.reported = reportedTeam;
|
||||
await editReport(report!);
|
||||
}
|
||||
await editReportStatus(report!, {
|
||||
status: status,
|
||||
notice: notice,
|
||||
statement: statement,
|
||||
strikeReasonId: Number(strikeReason)
|
||||
} as ReportStatus)
|
||||
} as ReportStatus);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function onCopyPublicLink(urlHash: string) {
|
||||
navigator.clipboard.writeText(`${document.baseURI}report/${urlHash}`);
|
||||
document.activeElement?.blur();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="absolute bottom-2 bg-base-200 rounded-lg w-[calc(100%-1rem)] mx-2 flex px-6 py-4 gap-2"
|
||||
hidden={report === null}
|
||||
>
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => (report = null)}>✕</button>
|
||||
<div class="absolute right-2 top-2">
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-sm btn-circle btn-ghost"><Icon icon="heroicons:share" /></div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<ul tabindex="0" class="menu dropdown-content bg-base-100 rounded-box z-1 p-2 shadow-sm w-max">
|
||||
<li><button onclick={() => onCopyPublicLink(report?.urlHash)}>Öffentlichen Report Link kopieren</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-circle btn-ghost" onclick={() => (report = null)}>✕</button>
|
||||
</div>
|
||||
<div class="w-[34rem]">
|
||||
<TeamSearch value={report?.reporter.name} label="Report Team" readonly mustMatch />
|
||||
<TeamSearch value={report?.reported?.name} label="Reportetes Team" />
|
||||
<Textarea bind:value={notice} label="Interne Notizen" rows={8} />
|
||||
<TeamSearch value={report?.reported?.name} label="Reportetes Team" onSubmit={(team) => (reportedTeam = team)} />
|
||||
<Textarea bind:value={notice} label="Interne Notizen" rows={10} />
|
||||
</div>
|
||||
<div class="divider divider-horizontal"></div>
|
||||
<div class="w-full">
|
||||
<Input value={report?.reason} label="Grund" readonly dynamicWidth />
|
||||
<Textarea value={report?.body} label="Inhalt" readonly dynamicWidth rows={12} />
|
||||
<Textarea value={report?.body} label="Inhalt" readonly dynamicWidth rows={9} />
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Anhänge</legend>
|
||||
<div class="h-16.5 rounded border border-dashed flex">
|
||||
{#each reportAttachments as reportAttachment (reportAttachment.hash)}
|
||||
<div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="cursor-zoom-in" onclick={() => (previewReportAttachment = reportAttachment)}>
|
||||
{#if reportAttachment.type === 'image'}
|
||||
<img
|
||||
src={location.pathname + '/attachment/' + reportAttachment.hash}
|
||||
alt={reportAttachment.hash}
|
||||
class="w-16 h-16"
|
||||
/>
|
||||
{:else if reportAttachment.type === 'video'}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={location.pathname + '/attachment/' + reportAttachment.hash} class="w-16 h-16"></video>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="divider divider-horizontal"></div>
|
||||
<div class="flex flex-col w-[42rem]">
|
||||
<Textarea bind:value={statement} label="Öffentliche Report Antwort" dynamicWidth rows={5} />
|
||||
<Textarea bind:value={statement} label="Öffentliche Report Antwort" dynamicWidth rows={7} />
|
||||
<Select
|
||||
bind:value={status}
|
||||
values={{ open: 'In Bearbeitung', closed: 'Bearbeitet' }}
|
||||
@ -87,3 +145,22 @@
|
||||
<button class="btn mt-auto" onclick={onSaveButtonClick}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog
|
||||
class="modal"
|
||||
bind:this={previewDialogElem}
|
||||
onclose={() => setTimeout(() => (previewReportAttachment = null), 300)}
|
||||
>
|
||||
<div class="modal-box">
|
||||
{#if previewReportAttachment?.type === 'image'}
|
||||
<img src={location.pathname + '/attachment/' + previewReportAttachment.hash} alt={previewReportAttachment.hash} />
|
||||
{:else if previewReportAttachment?.type === 'video'}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={location.pathname + '/attachment/' + previewReportAttachment.hash} controls></video>
|
||||
{/if}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||
<button class="absolute top-3 right-3 btn btn-circle">✕</button>
|
||||
<button class="!cursor-default">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
@ -27,15 +27,23 @@
|
||||
{dateFormat.format(new Date(value))}
|
||||
{/snippet}
|
||||
|
||||
{#snippet status(value: null | 'open' | 'closed')}
|
||||
{#if value === 'open'}
|
||||
<p>In Bearbeitung</p>
|
||||
{:else if value === 'closed'}
|
||||
<p>Bearbeitet</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<DataTable
|
||||
data={reports}
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'reason', label: 'Grund' },
|
||||
{ key: 'reporter.name', label: 'Report Team' },
|
||||
{ key: 'reported?.name', label: 'Reportetes Team' },
|
||||
{ key: 'reported.name', label: 'Reportetes Team' },
|
||||
{ key: 'createdAt', label: 'Datum', transform: date },
|
||||
{ key: 'report.status?.status', label: 'Bearbeitungsstatus' }
|
||||
{ key: 'status.status', label: 'Bearbeitungsstatus', transform: status }
|
||||
]}
|
||||
onClick={(report) => (activeReport = report)}
|
||||
/>
|
||||
|
@ -61,7 +61,7 @@
|
||||
key: 'createdAt',
|
||||
type: 'checkbox',
|
||||
label: 'Report kann bearbeitet werden',
|
||||
options: { convert: (v) => (v ? new Date().toISOString() : null) }
|
||||
options: { convert: (v) => (v ? null : new Date().toISOString()) }
|
||||
}
|
||||
]
|
||||
]}
|
||||
|
@ -59,6 +59,17 @@ export async function getReportStatus(report: Report) {
|
||||
return data.reportStatus;
|
||||
}
|
||||
|
||||
export async function editReport(report: Report) {
|
||||
const { error } = await actions.report.editReport({
|
||||
reportId: report.id,
|
||||
reported: report.reported?.id ?? null
|
||||
});
|
||||
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function editReportStatus(report: Report, reportStatus: ReportStatus) {
|
||||
const { error } = await actions.report.editReportStatus({
|
||||
reportId: report.id,
|
||||
@ -73,6 +84,18 @@ export async function editReportStatus(report: Report, reportStatus: ReportStatu
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReportAttachments(report: Report) {
|
||||
const { data, error } = await actions.report.reportAttachments({
|
||||
reportId: report.id
|
||||
});
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
return data.reportAttachments;
|
||||
}
|
||||
|
||||
export async function getStrikeReasons() {
|
||||
const { data, error } = await actions.report.strikeReasons();
|
||||
if (error) {
|
||||
|
@ -33,7 +33,8 @@
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'name', label: 'Name', width: 20 },
|
||||
{ key: 'weight', label: 'Gewichtung', width: 70, sortable: true }
|
||||
{ key: 'weight', label: 'Gewichtung', width: 50, sortable: true },
|
||||
{ key: 'id', label: 'Id', width: 20 }
|
||||
]}
|
||||
onDelete={onBlockedUserDelete}
|
||||
onEdit={(strikeReason) => (editPopupStrikeReason = strikeReason)}
|
||||
|
49
src/app/admin/teamDeaths/SidebarActions.svelte
Normal file
49
src/app/admin/teamDeaths/SidebarActions.svelte
Normal file
@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { addDeath, fetchDeaths } from '@app/admin/teamDeaths/teamDeaths.ts';
|
||||
import Icon from '@iconify/svelte';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
|
||||
// states
|
||||
let createPopupOpen = $state(false);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
fetchDeaths();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||
<Icon icon="heroicons:plus-16-solid" />
|
||||
<span>Neuer Spielertod</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Spielertod erstellen',
|
||||
submitButtonTitle: 'Erstellen',
|
||||
confirmPopupTitle: 'Spielertod erstellen?',
|
||||
confirmPopupMessage: 'Soll der neue Spielertod erstellt werden?'
|
||||
}}
|
||||
target={null}
|
||||
keys={[
|
||||
[
|
||||
{
|
||||
key: 'killed',
|
||||
type: 'user-search',
|
||||
label: 'Getöteter Spieler',
|
||||
options: { required: true, validate: (user) => !!user?.id }
|
||||
},
|
||||
{
|
||||
key: 'killer',
|
||||
type: 'user-search',
|
||||
label: 'Killer',
|
||||
options: { validate: (user) => (user?.username ? !!user?.id : true) }
|
||||
}
|
||||
],
|
||||
[{ key: 'message', type: 'textarea', label: 'Todesnachricht', options: { required: true, dynamicWidth: true } }]
|
||||
]}
|
||||
onSubmit={addDeath}
|
||||
bind:open={createPopupOpen}
|
||||
/>
|
69
src/app/admin/teamDeaths/TeamDeaths.svelte
Normal file
69
src/app/admin/teamDeaths/TeamDeaths.svelte
Normal file
@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
// state
|
||||
import { type Death, deaths, deleteDeath, editDeath } from '@app/admin/teamDeaths/teamDeaths.ts';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
|
||||
let editPopupDeath = $state(null);
|
||||
let editPopupOpen = $derived(!!editPopupDeath);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
if (!editPopupOpen) editPopupDeath = null;
|
||||
});
|
||||
|
||||
// callbacks
|
||||
function onDeathDelete(death: Death) {
|
||||
$confirmPopupState = {
|
||||
title: 'Tod löschen?',
|
||||
message: 'Soll der Tod wirklich gelöscht werden?',
|
||||
onConfirm: () => deleteDeath(death)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet username(user?: { id: number; username: string })}
|
||||
{user?.username}
|
||||
{/snippet}
|
||||
|
||||
<DataTable
|
||||
data={deaths}
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'killed', label: 'Getöteter Spieler', width: 20, transform: username },
|
||||
{ key: 'killer', label: 'Killer', width: 20, transform: username },
|
||||
{ key: 'message', label: 'Todesnachricht', width: 50 }
|
||||
]}
|
||||
onEdit={(death) => (editPopupDeath = death)}
|
||||
onDelete={onDeathDelete}
|
||||
/>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Tod bearbeiten',
|
||||
submitButtonTitle: 'Speichern',
|
||||
confirmPopupTitle: 'Änderungen speichern?',
|
||||
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
|
||||
}}
|
||||
target={editPopupDeath}
|
||||
keys={[
|
||||
[
|
||||
{
|
||||
key: 'killed',
|
||||
type: 'user-search',
|
||||
label: 'Getöteter Spieler',
|
||||
options: { required: true, validate: (user) => !!user?.id }
|
||||
},
|
||||
{
|
||||
key: 'killer',
|
||||
type: 'user-search',
|
||||
label: 'Killer',
|
||||
options: { validate: (user) => (user?.username ? !!user?.id : true) }
|
||||
}
|
||||
],
|
||||
[{ key: 'message', type: 'textarea', label: 'Todesnachricht', options: { required: true, dynamicWidth: true } }]
|
||||
]}
|
||||
onSubmit={editDeath}
|
||||
bind:open={editPopupDeath}
|
||||
/>
|
61
src/app/admin/teamDeaths/teamDeaths.ts
Normal file
61
src/app/admin/teamDeaths/teamDeaths.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
import { writable } from 'svelte/store';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
import { addToWritableArray, deleteFromWritableArray, updateWritableArray } from '@util/state.ts';
|
||||
|
||||
// types
|
||||
export type Deaths = Exclude<ActionReturnType<typeof actions.team.deaths>['data'], undefined>['deaths'];
|
||||
export type Death = Deaths[0];
|
||||
|
||||
// state
|
||||
export const deaths = writable<Deaths>([]);
|
||||
|
||||
// actions
|
||||
export async function fetchDeaths() {
|
||||
const { data, error } = await actions.team.deaths();
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
deaths.set(data.deaths);
|
||||
}
|
||||
|
||||
export async function addDeath(death: Death) {
|
||||
const { data, error } = await actions.team.addDeath({
|
||||
deadUserId: death.killed.id,
|
||||
killerUserId: death.killer?.id,
|
||||
message: death.message
|
||||
});
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
addToWritableArray(deaths, Object.assign(death, { id: data.id }));
|
||||
}
|
||||
|
||||
export async function editDeath(death: Death) {
|
||||
const { error } = await actions.team.editDeath({
|
||||
id: death.id,
|
||||
deadUserId: death.killed.id,
|
||||
killerUserId: death.killer?.id,
|
||||
message: death.message
|
||||
});
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
updateWritableArray(deaths, death, (d) => d.id == death.id);
|
||||
}
|
||||
|
||||
export async function deleteDeath(death: Death) {
|
||||
const { error } = await actions.team.deleteDeath({ id: death.id });
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteFromWritableArray(deaths, (d) => d.id == death.id);
|
||||
}
|
29
src/app/admin/tools/AccountUuidFinder.svelte
Normal file
29
src/app/admin/tools/AccountUuidFinder.svelte
Normal file
@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import Select from '@components/input/Select.svelte';
|
||||
import { uuidFromUsername } from '@app/admin/tools/tools.ts';
|
||||
|
||||
// states
|
||||
let edition = $state<'java' | 'bedrock'>('java');
|
||||
let username = $state('');
|
||||
let uuid = $state<null | string>(null);
|
||||
|
||||
// callbacks
|
||||
async function onSubmit() {
|
||||
uuid = await uuidFromUsername(edition, username);
|
||||
}
|
||||
</script>
|
||||
|
||||
<fieldset class="fieldset border border-base-200 rounded-box px-4">
|
||||
<legend class="fieldset-legend">Account UUID finder</legend>
|
||||
<div>
|
||||
<div class="flex gap-3">
|
||||
<Input bind:value={username} />
|
||||
<Select bind:value={edition} values={{ java: 'Java', bedrock: 'Bedrock' }} />
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<button class="btn w-4/6" class:disabled={!username} onclick={onSubmit}>UUID finden</button>
|
||||
</div>
|
||||
<Input bind:value={uuid} readonly />
|
||||
</div>
|
||||
</fieldset>
|
7
src/app/admin/tools/Tools.svelte
Normal file
7
src/app/admin/tools/Tools.svelte
Normal file
@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import AccountUuidFinder from '@app/admin/tools/AccountUuidFinder.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center mt-2">
|
||||
<AccountUuidFinder />
|
||||
</div>
|
12
src/app/admin/tools/tools.ts
Normal file
12
src/app/admin/tools/tools.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { actions } from 'astro:actions';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
|
||||
export async function uuidFromUsername(edition: 'java' | 'bedrock', username: string) {
|
||||
const { data, error } = await actions.tools.uuidFromUsername({ edition: edition, username: username });
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.uuid;
|
||||
}
|
@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import Skeleton from '@assets/img/steve.png';
|
||||
import type { GetDeathsRes } from '@db/schema/death.ts';
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
import type { db } from '@db/database.ts';
|
||||
import crown from '@assets/img/crown.svg';
|
||||
|
||||
interface Props {
|
||||
teams: Exclude<ActionReturnType<typeof actions.team.teams>['data'], undefined>['teams'];
|
||||
deaths: GetDeathsRes;
|
||||
deaths: Awaited<ReturnType<typeof db.getDeaths>>;
|
||||
}
|
||||
|
||||
const { teams, deaths }: Props = $props();
|
||||
@ -13,24 +13,37 @@
|
||||
const entries = teams.map((team) => ({
|
||||
...team,
|
||||
memberOne: Object.assign(team.memberOne, {
|
||||
dead: team.memberOne.id != null ? deaths.findIndex((d) => d.deadUserId === team.memberOne.id) !== -1 : null
|
||||
kills: deaths.filter((d) => d.killer?.id === team.memberOne.id) ?? [],
|
||||
dead: deaths.find((d) => d.killed.id === team.memberOne.id) ?? null
|
||||
}),
|
||||
memberTwo: Object.assign(team.memberTwo, {
|
||||
dead: team.memberTwo.id != null ? deaths.findIndex((d) => d.deadUserId === team.memberTwo.id) !== -1 : null
|
||||
}),
|
||||
kills: deaths.filter((d) => d.killerUserId === team.memberOne.id || d.killerUserId === team.memberTwo.id).length
|
||||
kills: deaths.filter((d) => d.killer?.id === team.memberTwo.id) ?? [],
|
||||
dead: deaths.find((d) => d.killed.id === team.memberTwo.id) ?? null
|
||||
})
|
||||
}));
|
||||
entries.sort((a, b) => {
|
||||
const aAllowed = !!a.memberOne.id && !!a.memberTwo.id && !(a.memberOne.dead && a.memberTwo.dead);
|
||||
const bAllowed = !!b.memberOne.id && !!b.memberTwo.id && !(b.memberOne.dead && b.memberTwo.dead);
|
||||
if (!aAllowed && !bAllowed) {
|
||||
return 0;
|
||||
} else if (!aAllowed || !bAllowed) {
|
||||
return (bAllowed as unknown as number) - (aAllowed as unknown as number);
|
||||
const aBothSignedUp = a.memberOne.id != null && a.memberTwo.id != null;
|
||||
const aBothKills = a.memberOne.kills.length + a.memberTwo.kills.length;
|
||||
const aBothDead = a.memberOne.dead && a.memberTwo.dead;
|
||||
|
||||
const bBothSignedUp = b.memberOne.id != null && b.memberTwo.id != null;
|
||||
const bBothKills = b.memberOne.kills.length + b.memberTwo.kills.length;
|
||||
const bBothDead = b.memberOne.dead && b.memberTwo.dead;
|
||||
|
||||
if (!aBothSignedUp || !bBothSignedUp) {
|
||||
return Number(bBothSignedUp) - Number(aBothSignedUp);
|
||||
} else if ((aBothDead && !bBothDead) || (!aBothDead && bBothDead)) {
|
||||
return Number(!!aBothDead) - Number(!!bBothDead);
|
||||
}
|
||||
|
||||
return b.kills - a.kills;
|
||||
return bBothKills - aBothKills;
|
||||
});
|
||||
|
||||
const aliveTeams = entries.reduce(
|
||||
(prev, curr) =>
|
||||
prev + Number(curr.memberOne.id && curr.memberTwo.id && (!curr.memberOne.dead || !curr.memberTwo.dead)),
|
||||
0
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-300 shadow-sm w-full md:w-5/7 xl:w-4/7 sm:p-5 md:p-10">
|
||||
@ -45,30 +58,48 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each entries as team (team.id)}
|
||||
{@const teamSignedUp = !!team.memberOne.id && !!team.memberTwo.id}
|
||||
{@const teamDead = !!team.memberOne.dead && !!team.memberTwo.dead}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-x-2">
|
||||
<div class="rounded-sm min-w-3 w-3 min-h-3 h-3" style="background-color: {team.color}"></div>
|
||||
<h3
|
||||
class="text-xs sm:text-xl break-all"
|
||||
class:line-through={team.memberOne.dead && team.memberTwo.dead}
|
||||
class:text-red-200={!team.memberOne}
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="rounded-sm min-w-3 w-3 min-h-3 h-3" style="background-color: {team.color}"></div>
|
||||
{#if aliveTeams === 1 && teamSignedUp && !teamDead}
|
||||
<div class="absolute h-3.5 w-3.5 -top-2.25 -right-0.25">
|
||||
<img class="h-full w-full" src={crown.src} alt="" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<h3 class="text-xs sm:text-xl break-all" class:line-through={teamDead}>
|
||||
{team.name}
|
||||
</h3>
|
||||
</div>
|
||||
{#if !team.memberOne.id || !team.memberTwo.id}
|
||||
{#if !teamSignedUp}
|
||||
<span>Team unvollständig</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="max-w-9 overflow-ellipsis">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<div class="flex items-center gap-x-2 w-max tooltip">
|
||||
{#if team.memberOne.kills.length > 0 || team.memberOne.dead}
|
||||
<div class="tooltip-content text-left space-y-1">
|
||||
{#each team.memberOne.kills as kill (kill.killed.id)}
|
||||
<p>🔪 {kill.killed.username}</p>
|
||||
{/each}
|
||||
{#if team.memberOne.dead}
|
||||
<p class="mt-2 first:mt-0">{team.memberOne.dead.message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if team.memberOne.id != null}
|
||||
<img
|
||||
class="h-4 pixelated"
|
||||
src={team.memberOne.dead ? Skeleton.src : `https://mc-heads.net/head/${team.memberOne.username}/8`}
|
||||
alt="head"
|
||||
/>
|
||||
<div class="relative">
|
||||
<img class="h-4 pixelated" src="https://mc-heads.net/head/{team.memberOne.username}/8" alt="head" />
|
||||
{#if aliveTeams === 1 && teamSignedUp && !teamDead}
|
||||
<div class="absolute -top-1.25 -right-1.25">
|
||||
<img class="h-3 w-3 rotate-30" src={crown.src} alt="" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span
|
||||
class="text-xs sm:text-md break-all"
|
||||
@ -78,13 +109,26 @@
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-x-2">
|
||||
<div class="flex items-center gap-x-2 w-max tooltip">
|
||||
{#if team.memberTwo.kills.length > 0 || team.memberTwo.dead}
|
||||
<div class="tooltip-content text-left space-y-1">
|
||||
{#each team.memberTwo.kills as kill (kill.killed.id)}
|
||||
<p>🔪 {kill.killed.username}</p>
|
||||
{/each}
|
||||
{#if team.memberTwo.dead}
|
||||
<p class="mt-2 first:mt-0">{team.memberTwo.dead.message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if team.memberTwo.id != null}
|
||||
<img
|
||||
class="h-4 pixelated"
|
||||
src={team.memberTwo.dead ? Skeleton.src : `https://mc-heads.net/head/${team.memberTwo.username}/8`}
|
||||
alt="head"
|
||||
/>
|
||||
<div class="relative">
|
||||
<img class="h-4 pixelated" src="https://mc-heads.net/head/{team.memberTwo.username}/8" alt="head" />
|
||||
{#if aliveTeams === 1 && teamSignedUp && !teamDead}
|
||||
<div class="absolute -top-1.25 -right-1.25">
|
||||
<img class="h-3 w-3 rotate-30" src={crown.src} alt="" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span
|
||||
class="text-xs sm:text-md break-all"
|
||||
@ -94,7 +138,9 @@
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-xs sm:text-md">0</span>
|
||||
<span class="text-xs sm:text-md">
|
||||
{team.memberOne.kills.length + team.memberTwo.kills.length}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
@ -3,7 +3,6 @@
|
||||
import { allowedImageTypes, allowedVideoTypes } from '@util/media.ts';
|
||||
|
||||
// bindings
|
||||
let containerElem: HTMLDivElement;
|
||||
let hiddenFileInputElem: HTMLInputElement;
|
||||
let previewDialogElem: HTMLDialogElement;
|
||||
|
||||
@ -123,7 +122,6 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={containerElem}
|
||||
class="h-12 rounded border border-dashed flex cursor-pointer"
|
||||
class:h-26={uploadFiles.length > 0}
|
||||
dropzone="copy"
|
||||
|
1
src/assets/img/crown.svg
Normal file
1
src/assets/img/crown.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><g fill="#f79329"><path d="m91.56 50.38l14.35 44.94l-36.36-4.71z"/><path d="M105.91 96.5c-.05 0-.1 0-.15-.01l-36.37-4.71c-.39-.05-.72-.29-.9-.64s-.17-.76.01-1.1l22.02-40.23c.23-.41.69-.65 1.15-.61c.47.04.87.36 1.01.81l14.24 44.62c.14.19.22.43.22.68c0 .65-.53 1.18-1.18 1.18c0 .01-.03.01-.05.01M71.4 89.66l32.82 4.25l-12.94-40.55zM40.19 34.91a5.46 5.46 0 0 1-5.46 5.46c-3.01 0-5.46-2.45-5.46-5.46c0-3.02 2.44-5.46 5.46-5.46s5.46 2.44 5.46 5.46"/><path d="M34.73 41.54a6.65 6.65 0 0 1-6.64-6.64a6.65 6.65 0 0 1 6.64-6.64a6.65 6.65 0 0 1 6.64 6.64a6.65 6.65 0 0 1-6.64 6.64m0-10.91c-2.36 0-4.28 1.92-4.28 4.28s1.92 4.28 4.28 4.28s4.29-1.92 4.29-4.28s-1.93-4.28-4.29-4.28m58.85-1.18c3.01.18 5.31 2.77 5.13 5.78c-.17 3.01-2.77 5.3-5.77 5.13a5.45 5.45 0 0 1-5.13-5.77c.18-3.02 2.76-5.32 5.77-5.14"/><path d="m93.26 41.54l-.39-.01c-1.77-.1-3.4-.89-4.57-2.21a6.62 6.62 0 0 1-1.67-4.8a6.647 6.647 0 0 1 6.63-6.25l.39.01c3.66.22 6.46 3.38 6.24 7.03a6.64 6.64 0 0 1-6.63 6.23m.23-10.92c-2.5 0-4.37 1.77-4.5 4.03c-.07 1.14.31 2.24 1.07 3.1s1.8 1.36 2.95 1.43l.25.01c2.26 0 4.14-1.77 4.27-4.03c.14-2.36-1.67-4.39-4.03-4.54zM36.43 50.38L22.09 95.32l36.36-4.71z"/><path d="M22.09 96.5c-.34 0-.68-.15-.91-.42c-.26-.31-.34-.73-.22-1.11L35.3 50.03c.14-.45.54-.77 1.01-.81c.51-.05.92.19 1.15.61l22.02 40.23c.18.34.19.75.01 1.1c-.17.35-.51.58-.9.64l-36.36 4.71c-.04-.01-.09-.01-.14-.01m14.63-43.14L23.77 93.92l32.82-4.25z"/></g><use href="#notoV1Crown1"/><use href="#notoV1Crown1"/><defs><path id="notoV1Crown0" d="M119.5 53.43a1.18 1.18 0 0 0-1.29.22L87.25 82.71L65.16 49.72c-.22-.33-.58-.52-.98-.52c-.39 0-.76.19-.98.51l-22.19 33l-30.95-29.07a1.18 1.18 0 0 0-1.29-.22c-.43.19-.71.63-.69 1.1l1.27 47.52c0 10.33 24.06 18.43 54.78 18.43s54.78-8.1 54.78-18.4l1.27-47.55c.02-.46-.25-.9-.68-1.09"/><path id="notoV1Crown1" fill="#fcc21b" d="M72.17 28.76c0 4.51-3.66 8.17-8.17 8.17s-8.18-3.66-8.18-8.17c0-4.52 3.66-8.17 8.18-8.17s8.17 3.65 8.17 8.17m-58.72 6.15c0 3.58-2.9 6.48-6.49 6.48c-3.58 0-6.48-2.9-6.48-6.48c0-3.59 2.9-6.49 6.48-6.49c3.59 0 6.49 2.9 6.49 6.49m101.09 0c0 3.58 2.9 6.48 6.49 6.48c3.58 0 6.49-2.9 6.49-6.48a6.49 6.49 0 0 0-6.49-6.49a6.49 6.49 0 0 0-6.49 6.49"/></defs><use fill="#fcc21b" href="#notoV1Crown0"/><clipPath id="notoV1Crown2"><use href="#notoV1Crown0"/></clipPath><path fill="#d7598b" d="m119.91 78.06l.01.01l-.59 18.85h-.01c-4.2-.13-7.46-4.45-7.3-9.66c.16-5.22 3.69-9.33 7.89-9.2m-111.54 0l-.01.01l.58 18.85h.02c4.19-.13 7.46-4.45 7.29-9.66c-.16-5.22-3.69-9.33-7.88-9.2" clip-path="url(#notoV1Crown2)"/><path fill="#d7598b" d="M72.8 96.55c0 5.58-3.88 10.11-8.67 10.11c-4.78 0-8.66-4.53-8.66-10.11c0-5.59 3.88-10.11 8.66-10.11c4.79-.01 8.67 4.52 8.67 10.11"/><g fill="#ed6c30"><path d="M89.9 102.14c-.13 2.7-2.12 4.79-4.44 4.68c-2.31-.11-4.08-2.4-3.94-5.09c.14-2.71 2.13-4.8 4.44-4.68c2.31.1 4.07 2.39 3.94 5.09"/><ellipse cx="103.04" cy="98.95" rx="4.89" ry="4.2" transform="rotate(-87.013 103.044 98.958)"/></g><g fill="#ed6c30"><path d="M38.37 102.14c.13 2.7 2.12 4.79 4.44 4.68c2.31-.11 4.08-2.4 3.94-5.09c-.13-2.71-2.12-4.8-4.43-4.68c-2.32.1-4.09 2.39-3.95 5.09"/><ellipse cx="25.23" cy="98.95" rx="4.19" ry="4.89" transform="rotate(-2.987 25.234 98.957)"/></g></svg>
|
After Width: | Height: | Size: 3.2 KiB |
@ -111,11 +111,11 @@
|
||||
submitEnabled = false;
|
||||
for (const key of keys) {
|
||||
for (const k of key) {
|
||||
if (k.options?.required) {
|
||||
if (k.options?.validate) {
|
||||
if (k.options?.validate) {
|
||||
if (k.options?.required && !target[k.key]) {
|
||||
return;
|
||||
} else if (k.options?.required || target[k.key]) {
|
||||
if (!k.options.validate(target[k.key])) return;
|
||||
} else {
|
||||
if (!target[k.key]) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,20 +9,17 @@
|
||||
// inputs
|
||||
let { available, value = $bindable(), readonly }: Props = $props();
|
||||
|
||||
// idk why, but this is needed to trigger loop reactivity
|
||||
let reactiveValue = $derived(value);
|
||||
|
||||
// callbacks
|
||||
function onOptionSelect(e: Event) {
|
||||
const selected = Number((e.target as HTMLSelectElement).value);
|
||||
|
||||
reactiveValue |= selected;
|
||||
value |= selected;
|
||||
|
||||
(e.target as HTMLSelectElement).value = '-';
|
||||
}
|
||||
|
||||
function onBadgeRemove(flag: number) {
|
||||
reactiveValue &= ~flag;
|
||||
value &= ~flag;
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -31,20 +28,22 @@
|
||||
<select class="select select-xs w-min" onchange={onOptionSelect}>
|
||||
<option selected hidden>-</option>
|
||||
{#each Object.entries(available) as [flag, badge] (flag)}
|
||||
<option value={flag} hidden={(reactiveValue & Number(flag)) !== 0}>{badge}</option>
|
||||
<option value={flag} hidden={(value & Number(flag)) !== 0}>{badge}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<div class="flex flow flex-wrap gap-2">
|
||||
{#each Object.entries(available) as [flag, badge] (flag)}
|
||||
{#if (reactiveValue & Number(flag)) !== 0}
|
||||
<div class="badge badge-outline gap-1">
|
||||
{#if !readonly}
|
||||
<button class="cursor-pointer" type="button" onclick={() => onBadgeRemove(Number(flag))}>✕</button>
|
||||
{/if}
|
||||
<span>{badge}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#key value}
|
||||
{#each Object.entries(available) as [flag, badge] (flag)}
|
||||
{#if (value & Number(flag)) !== 0}
|
||||
<div class="badge badge-outline gap-1">
|
||||
{#if !readonly}
|
||||
<button class="cursor-pointer" type="button" onclick={() => onBadgeRemove(Number(flag))}>✕</button>
|
||||
{/if}
|
||||
<span>{badge}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -61,8 +61,9 @@ CREATE TABLE IF NOT EXISTS team_draft (
|
||||
|
||||
-- death
|
||||
CREATE TABLE IF NOT EXISTS death (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
message VARCHAR(1024) NOT NULL,
|
||||
dead_user_id INT NOT NULL,
|
||||
dead_user_id INT NOT NULL UNIQUE,
|
||||
killer_user_id INT,
|
||||
FOREIGN KEY (dead_user_id) REFERENCES user(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (killer_user_id) REFERENCES user(id) ON DELETE CASCADE
|
||||
|
@ -83,9 +83,13 @@ import {
|
||||
setSettings
|
||||
} from './schema/settings';
|
||||
import {
|
||||
addDeath,
|
||||
type AddDeathReq,
|
||||
addDeath,
|
||||
death,
|
||||
deleteDeath,
|
||||
type DeleteDeathReq,
|
||||
editDeath,
|
||||
type EditDeathReq,
|
||||
getDeathByUserId,
|
||||
type GetDeathByUserIdReq,
|
||||
getDeaths,
|
||||
@ -107,6 +111,8 @@ import {
|
||||
import {
|
||||
addReport,
|
||||
type AddReportReq,
|
||||
editReport,
|
||||
type EditReportReq,
|
||||
getReportById,
|
||||
type GetReportById,
|
||||
getReportByUrlHash,
|
||||
@ -160,7 +166,13 @@ import {
|
||||
type DeleteBlockedUserReq,
|
||||
deleteBlockedUser
|
||||
} from '@db/schema/blockedUser.ts';
|
||||
import { addReportAttachment, type AddReportAttachmentReq, reportAttachment } from '@db/schema/reportAttachment.ts';
|
||||
import {
|
||||
addReportAttachment,
|
||||
type AddReportAttachmentReq,
|
||||
getReportAttachments,
|
||||
type GetReportAttachmentsReq,
|
||||
reportAttachment
|
||||
} from '@db/schema/reportAttachment.ts';
|
||||
|
||||
export class Database {
|
||||
protected readonly db: MySql2Database<{
|
||||
@ -265,11 +277,14 @@ export class Database {
|
||||
|
||||
/* death */
|
||||
addDeath = (values: AddDeathReq) => addDeath(this.db, values);
|
||||
editDeath = (values: EditDeathReq) => editDeath(this.db, values);
|
||||
deleteDeath = (values: DeleteDeathReq) => deleteDeath(this.db, values);
|
||||
getDeathByUserId = (values: GetDeathByUserIdReq) => getDeathByUserId(this.db, values);
|
||||
getDeaths = (values: GetDeathsReq) => getDeaths(this.db, values);
|
||||
|
||||
/* report */
|
||||
addReport = (values: AddReportReq) => addReport(this.db, values);
|
||||
editReport = (values: EditReportReq) => editReport(this.db, values);
|
||||
submitReport = (values: SubmitReportReq) => submitReport(this.db, values);
|
||||
getReports = (values: GetReportsReq) => getReports(this.db, values);
|
||||
getReportById = (values: GetReportById) => getReportById(this.db, values);
|
||||
@ -277,6 +292,7 @@ export class Database {
|
||||
|
||||
/* report attachment */
|
||||
addReportAttachment = (values: AddReportAttachmentReq) => addReportAttachment(this.db, values);
|
||||
getReportAttachments = (values: GetReportAttachmentsReq) => getReportAttachments(this.db, values);
|
||||
|
||||
/* report status */
|
||||
getReportStatus = (values: GetReportStatusReq) => getReportStatus(this.db, values);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { int, mysqlTable, varchar } from 'drizzle-orm/mysql-core';
|
||||
import { alias, int, mysqlTable, varchar } from 'drizzle-orm/mysql-core';
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||
import { user } from './user.ts';
|
||||
import { eq } from 'drizzle-orm';
|
||||
@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
|
||||
type Database = MySql2Database<{ death: typeof death }>;
|
||||
|
||||
export const death = mysqlTable('death', {
|
||||
id: int('id').primaryKey().autoincrement(),
|
||||
message: varchar('message', { length: 1024 }).notNull(),
|
||||
deadUserId: int('dead_user_id')
|
||||
.notNull()
|
||||
@ -15,19 +16,46 @@ export const death = mysqlTable('death', {
|
||||
|
||||
export type AddDeathReq = {
|
||||
message: string;
|
||||
killerUserId?: number;
|
||||
killerUserId?: number | null;
|
||||
deadUserId: number;
|
||||
};
|
||||
|
||||
export type EditDeathReq = {
|
||||
id: number;
|
||||
message: string;
|
||||
killerUserId?: number | null;
|
||||
deadUserId: number;
|
||||
};
|
||||
|
||||
export type DeleteDeathReq = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type GetDeathByUserIdReq = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export type GetDeathsReq = {};
|
||||
export type GetDeathsRes = (typeof death.$inferSelect)[];
|
||||
|
||||
export async function addDeath(db: Database, values: AddDeathReq) {
|
||||
await db.insert(death).values(values);
|
||||
const ids = await db.insert(death).values(values).$returningId();
|
||||
|
||||
return ids[0];
|
||||
}
|
||||
|
||||
export async function editDeath(db: Database, values: EditDeathReq) {
|
||||
await db
|
||||
.update(death)
|
||||
.set({
|
||||
message: values.message,
|
||||
killerUserId: values.killerUserId,
|
||||
deadUserId: values.deadUserId
|
||||
})
|
||||
.where(eq(death.id, values.id));
|
||||
}
|
||||
|
||||
export async function deleteDeath(db: Database, values: DeleteDeathReq) {
|
||||
await db.delete(death).where(eq(death.id, values.id));
|
||||
}
|
||||
|
||||
export async function getDeathByUserId(db: Database, values: GetDeathByUserIdReq) {
|
||||
@ -36,7 +64,24 @@ export async function getDeathByUserId(db: Database, values: GetDeathByUserIdReq
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function getDeaths(db: Database, values: GetDeathsReq): Promise<GetDeathsReq> {
|
||||
return db.query.death.findMany();
|
||||
export async function getDeaths(db: Database, _values: GetDeathsReq) {
|
||||
const killed = alias(user, 'killed');
|
||||
const killer = alias(user, 'killer');
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: death.id,
|
||||
message: death.message,
|
||||
killed: {
|
||||
id: killed.id,
|
||||
username: killed.username
|
||||
},
|
||||
killer: {
|
||||
id: killer.id,
|
||||
username: killer.username
|
||||
}
|
||||
})
|
||||
.from(death)
|
||||
.innerJoin(killed, eq(death.deadUserId, killed.id))
|
||||
.leftJoin(killer, eq(death.killerUserId, killer.id));
|
||||
}
|
||||
|
@ -28,6 +28,11 @@ export type AddReportReq = {
|
||||
reportedTeamId?: number | null;
|
||||
};
|
||||
|
||||
export type EditReportReq = {
|
||||
id: number;
|
||||
reportedTeamId: number | null;
|
||||
};
|
||||
|
||||
export type SubmitReportReq = {
|
||||
urlHash: string;
|
||||
reason: string;
|
||||
@ -65,6 +70,12 @@ export async function addReport(db: Database, values: AddReportReq) {
|
||||
return Object.assign(r[0], { url: `${BASE_PATH}/report/${urlHash}` });
|
||||
}
|
||||
|
||||
export async function editReport(db: Database, values: EditReportReq) {
|
||||
return db.update(report).set({
|
||||
reportedTeamId: values.reportedTeamId
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitReport(db: Database, values: SubmitReportReq) {
|
||||
return db
|
||||
.update(report)
|
||||
@ -102,6 +113,7 @@ export async function getReports(db: Database, values: GetReportsReq) {
|
||||
id: report.id,
|
||||
reason: report.reason,
|
||||
body: report.body,
|
||||
urlHash: report.urlHash,
|
||||
createdAt: report.createdAt,
|
||||
reporter: {
|
||||
id: reporterTeam.id,
|
||||
|
@ -21,7 +21,6 @@ export type AddReportAttachmentReq = {
|
||||
|
||||
export type GetReportAttachmentsReq = {
|
||||
reportId: number;
|
||||
fileIds: number[];
|
||||
};
|
||||
|
||||
export async function addReportAttachment(db: Database, values: AddReportAttachmentReq) {
|
||||
|
@ -40,6 +40,13 @@ const adminTabs = [
|
||||
href: 'admin/teams',
|
||||
name: 'Teams',
|
||||
icon: 'heroicons:users',
|
||||
subTabs: [
|
||||
{
|
||||
href: 'admin/teams/dead',
|
||||
name: 'Tote Spieler',
|
||||
icon: 'heroicons:x-mark'
|
||||
}
|
||||
],
|
||||
enabled: session?.permissions.users
|
||||
},
|
||||
{
|
||||
@ -72,6 +79,12 @@ const adminTabs = [
|
||||
name: 'Einstellungen',
|
||||
icon: 'heroicons:adjustments-horizontal',
|
||||
enabled: session?.permissions.settings
|
||||
},
|
||||
{
|
||||
href: 'admin/tools',
|
||||
name: 'Tools',
|
||||
icon: 'heroicons:wrench-screwdriver',
|
||||
enabled: session?.permissions.tools
|
||||
}
|
||||
];
|
||||
---
|
||||
@ -92,26 +105,29 @@ const adminTabs = [
|
||||
}
|
||||
<div class="divider mx-1 my-1"></div>
|
||||
{
|
||||
adminTabs.map((tab) => (
|
||||
<li>
|
||||
<a href={tab.href}>
|
||||
<Icon name={tab.icon} />
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
{tab.subTabs && (
|
||||
<ul>
|
||||
{tab.subTabs.map((subTab) => (
|
||||
<li>
|
||||
<a href={subTab.href}>
|
||||
<Icon name={subTab.icon} />
|
||||
<span>{subTab.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
adminTabs.map(
|
||||
(tab) =>
|
||||
tab.enabled && (
|
||||
<li>
|
||||
<a href={tab.href}>
|
||||
<Icon name={tab.icon} />
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
{tab.subTabs && (
|
||||
<ul>
|
||||
{tab.subTabs.map((subTab) => (
|
||||
<li>
|
||||
<a href={subTab.href}>
|
||||
<Icon name={subTab.icon} />
|
||||
<span>{subTab.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
)
|
||||
}
|
||||
{
|
||||
Astro.slots.has('actions') && (
|
||||
|
27
src/pages/admin/reports/attachment/[fileHash].ts
Normal file
27
src/pages/admin/reports/attachment/[fileHash].ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { UPLOAD_PATH } from 'astro:env/server';
|
||||
|
||||
export const GET: APIRoute = async ({ params, cookies }) => {
|
||||
Session.actionSessionFromCookies(cookies, Permissions.Reports);
|
||||
|
||||
if (!UPLOAD_PATH) return new Response(null, { status: 404 });
|
||||
|
||||
const fileHash = params.fileHash as string;
|
||||
const filePath = path.join(UPLOAD_PATH, fileHash);
|
||||
|
||||
if (!fs.existsSync(filePath)) return new Response(null, { status: 404 });
|
||||
|
||||
const fileStat = fs.statSync(filePath);
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
|
||||
return new Response(fileStream as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Length': fileStat.size.toString()
|
||||
}
|
||||
});
|
||||
};
|
16
src/pages/admin/teams/dead.astro
Normal file
16
src/pages/admin/teams/dead.astro
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
import AdminLayout from '@layouts/admin/AdminLayout.astro';
|
||||
import SidebarActions from '@app/admin/teamDeaths/SidebarActions.svelte';
|
||||
import TeamDeaths from '@app/admin/teamDeaths/TeamDeaths.svelte';
|
||||
import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import { BASE_PATH } from 'astro:env/server';
|
||||
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Users);
|
||||
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
|
||||
---
|
||||
|
||||
<AdminLayout title="Tote Spieler">
|
||||
<SidebarActions slot="actions" client:load />
|
||||
<TeamDeaths client:load />
|
||||
</AdminLayout>
|
@ -6,7 +6,7 @@ import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import { BASE_PATH } from 'astro:env/server';
|
||||
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Admin);
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Users);
|
||||
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
|
||||
---
|
||||
|
14
src/pages/admin/tools/index.astro
Normal file
14
src/pages/admin/tools/index.astro
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
import { Session } from '@util/session';
|
||||
import { Permissions } from '@util/permissions';
|
||||
import { BASE_PATH } from 'astro:env/server';
|
||||
import AdminLayout from '@layouts/admin/AdminLayout.astro';
|
||||
import Tools from '@app/admin/tools/Tools.svelte';
|
||||
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Tools);
|
||||
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
|
||||
---
|
||||
|
||||
<AdminLayout title="Reports">
|
||||
<Tools client:load />
|
||||
</AdminLayout>
|
@ -6,7 +6,7 @@ import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import { BASE_PATH } from 'astro:env/server';
|
||||
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Admin);
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Users);
|
||||
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
|
||||
---
|
||||
|
||||
|
@ -6,7 +6,7 @@ import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import { BASE_PATH } from 'astro:env/server';
|
||||
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Admin);
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Users);
|
||||
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
|
||||
---
|
||||
|
||||
|
@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
|
||||
import { z } from 'astro:schema';
|
||||
import { API_SECRET } from 'astro:env/server';
|
||||
import { db } from '@db/database.ts';
|
||||
import { sendWebhook, WebhookAction } from '@util/webhook.ts';
|
||||
|
||||
const postSchema = z.object({
|
||||
reporter: z.string(),
|
||||
@ -75,6 +76,7 @@ export const PUT: APIRoute = async ({ request }) => {
|
||||
const report = await tx.addReport({
|
||||
reporterTeamId: reporterTeam?.team.id,
|
||||
reportedTeamId: reportedTeam.team.id,
|
||||
createdAt: new Date(),
|
||||
reason: parsed.reason,
|
||||
body: parsed.body
|
||||
});
|
||||
@ -92,5 +94,14 @@ export const PUT: APIRoute = async ({ request }) => {
|
||||
});
|
||||
});
|
||||
|
||||
const strikes = await db.getStrikesByTeamId({ teamId: reportedTeam.team.id });
|
||||
const teamMembers = await db.getTeamMembersByTeamId({ teamId: reportedTeam.team.id });
|
||||
|
||||
// send webhook in background
|
||||
sendWebhook(WebhookAction.Strike, {
|
||||
users: teamMembers.map((tm) => tm.user.uuid!),
|
||||
totalWeight: strikes.map((strike) => strike.reason.weight).reduce((a, b) => a + b, 0)
|
||||
});
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
};
|
||||
|
@ -12,3 +12,46 @@ export async function getJavaUuid(username: string) {
|
||||
// prettier-ignore
|
||||
return `${id.substring(0, 8)}-${id.substring(8, 12)}-${id.substring(12, 16)}-${id.substring(16, 20)}-${id.substring(20)}`;
|
||||
}
|
||||
|
||||
// https://github.com/carlop3333/XUIDGrabber/blob/main/grabber.js
|
||||
export async function getBedrockUuid(username: string): Promise<string> {
|
||||
const initialPageResponse = await fetch('https://cxkes.me/xbox/xuid');
|
||||
const initialPageContent = await initialPageResponse.text();
|
||||
const token = /name="_token"\svalue="(?<token>\w+)"/.exec(initialPageContent)?.groups?.token;
|
||||
|
||||
const cookies = initialPageResponse.headers.get('set-cookie')?.split(' ');
|
||||
if (token === undefined || cookies === undefined || cookies.length < 11) return null;
|
||||
|
||||
const requestBody = new URLSearchParams();
|
||||
requestBody.set('_token', token);
|
||||
requestBody.set('gamertag', username);
|
||||
|
||||
const resultPageResponse = await fetch('https://cxkes.me/xbox/xuid', {
|
||||
method: 'post',
|
||||
body: requestBody,
|
||||
// prettier-ignore
|
||||
headers: {
|
||||
'Host': 'www.cxkes.me',
|
||||
'Accept-Encoding': 'gzip, deflate,br',
|
||||
'Content-Length': Buffer.byteLength(requestBody.toString()).toString(),
|
||||
'Origin': 'https://www.cxkes.me',
|
||||
'DNT': '1',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': 'https://www.cxkes.me/xbox/xuid',
|
||||
'Cookie': `${cookies[0]} ${cookies[10].slice(0, cookies[10].length - 1)}`,
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fectch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,es;q=0.8,en-US;q=0.5,en;q=0.3',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
const resultPageContent = await resultPageResponse.text();
|
||||
let xuid: string | undefined;
|
||||
if ((xuid = /id="xuidHex">(?<xuid>\w+)</.exec(resultPageContent)?.groups?.xuid) === undefined) throw new Error();
|
||||
return `00000000-0000-0000-${xuid.substring(0, 4)}-${xuid.substring(4)}`;
|
||||
}
|
||||
|
@ -1,9 +1,8 @@
|
||||
export function getObjectEntryByKey(key: string, data: { [key: string]: any }): any | undefined {
|
||||
let entry = data;
|
||||
for (const part of key.split('.')) {
|
||||
if ((entry = entry[part]) === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
entry = entry[part];
|
||||
if (entry === null || typeof entry !== 'object') return entry;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
@ -53,10 +53,10 @@ export class Permissions {
|
||||
return (this.value & Permissions.Reports.value) != 0;
|
||||
}
|
||||
get feedback() {
|
||||
return (this.value & Permissions.Reports.value) != 0;
|
||||
return (this.value & Permissions.Feedback.value) != 0;
|
||||
}
|
||||
get settings() {
|
||||
return (this.value & Permissions.Reports.value) != 0;
|
||||
return (this.value & Permissions.Settings.value) != 0;
|
||||
}
|
||||
get tools() {
|
||||
return (this.value & Permissions.Tools.value) != 0;
|
||||
|
@ -18,11 +18,12 @@ export async function sendWebhook<T extends WebhookAction>(action: T, data: Webh
|
||||
while (true) {
|
||||
try {
|
||||
const response = await fetch(WEBHOOK_ENDPOINT, {
|
||||
body: JSON.stringify(data),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-webhook-action': action
|
||||
},
|
||||
keepalive: false
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.status === 200) return;
|
||||
|
Reference in New Issue
Block a user