rewrite website
55
src/actions/admin.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineAction } from 'astro:actions';
|
||||
import { db } from '@db/database.ts';
|
||||
import { z } from 'astro:schema';
|
||||
import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
|
||||
export const admin = {
|
||||
addAdmin: defineAction({
|
||||
input: z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
permissions: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||
const { id } = await db.addAdmin(input);
|
||||
|
||||
return {
|
||||
id: id
|
||||
};
|
||||
}
|
||||
}),
|
||||
editAdmin: defineAction({
|
||||
input: z.object({
|
||||
id: z.number(),
|
||||
username: z.string(),
|
||||
password: z.string().nullable(),
|
||||
permissions: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||
|
||||
await db.editAdmin(input);
|
||||
}
|
||||
}),
|
||||
deleteAdmin: defineAction({
|
||||
input: z.object({
|
||||
id: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||
|
||||
await db.deleteAdmin(input);
|
||||
}
|
||||
}),
|
||||
admins: defineAction({
|
||||
handler: async (_, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||
|
||||
return {
|
||||
admins: await db.getAdmins({})
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
49
src/actions/feedback.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { defineAction } from 'astro:actions';
|
||||
import { db } from '@db/database.ts';
|
||||
import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import { z } from 'astro:schema';
|
||||
|
||||
export const feedback = {
|
||||
addWebsiteFeedback: defineAction({
|
||||
input: z.object({
|
||||
content: z.string()
|
||||
}),
|
||||
handler: async (input) => {
|
||||
await db.addFeedback({
|
||||
event: 'website-feedback',
|
||||
content: input.content
|
||||
});
|
||||
}
|
||||
}),
|
||||
addWebsiteContact: defineAction({
|
||||
input: z.object({
|
||||
content: z.string(),
|
||||
email: z.string().email()
|
||||
}),
|
||||
handler: async (input) => {
|
||||
await db.addFeedback({
|
||||
event: 'website-contact',
|
||||
content: `${input.content}\n\nEmail: ${input.email}`
|
||||
});
|
||||
}
|
||||
}),
|
||||
submitFeedback: defineAction({
|
||||
input: z.object({
|
||||
urlHash: z.string(),
|
||||
content: z.string()
|
||||
}),
|
||||
handler: async (input) => {
|
||||
await db.submitFeedback(input);
|
||||
}
|
||||
}),
|
||||
feedbacks: defineAction({
|
||||
handler: async (_, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Feedback);
|
||||
|
||||
return {
|
||||
feedbacks: await db.getFeedbacks({})
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
19
src/actions/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { session } from './session.ts';
|
||||
import { signup } from './signup.ts';
|
||||
import { user } from './user.ts';
|
||||
import { admin } from './admin.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,
|
||||
session,
|
||||
signup,
|
||||
user,
|
||||
report,
|
||||
feedback,
|
||||
settings,
|
||||
tools
|
||||
};
|
||||
300
src/actions/report.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { ActionError, defineAction } from 'astro:actions';
|
||||
import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import { db } from '@db/database.ts';
|
||||
import { z } from 'astro:schema';
|
||||
import { MAX_UPLOAD_BYTES, UPLOAD_PATH } from 'astro:env/server';
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { sendWebhook, WebhookAction } from '@util/webhook.ts';
|
||||
import { allowedImageTypes, allowedVideoTypes } from '@util/media.ts';
|
||||
|
||||
export const report = {
|
||||
submitReport: defineAction({
|
||||
input: z.object({
|
||||
urlHash: z.string(),
|
||||
reported: z.string().nullish(),
|
||||
reason: z.string(),
|
||||
body: z.string(),
|
||||
files: z
|
||||
.array(
|
||||
z
|
||||
.instanceof(File)
|
||||
.refine((f) => [...allowedImageTypes, ...allowedVideoTypes].findIndex((v) => v === f.type) !== -1)
|
||||
)
|
||||
.nullable()
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const fileSize = input.files?.reduce((prev, curr) => prev + curr.size, 0);
|
||||
if (fileSize && fileSize > MAX_UPLOAD_BYTES) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Die Anhänge sind zu groß'
|
||||
});
|
||||
}
|
||||
|
||||
const report = await db.getReportByUrlHash({ urlHash: input.urlHash });
|
||||
if (!report) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND'
|
||||
});
|
||||
}
|
||||
|
||||
let reportedId = report.reported?.id ?? null;
|
||||
if (input.reported != report.reported?.username) {
|
||||
if (input.reported == null) reportedId = null;
|
||||
else {
|
||||
const reportedUser = await db.getUserByUsername({ username: input.reported });
|
||||
if (!reportedUser)
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND'
|
||||
});
|
||||
reportedId = reportedUser.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!UPLOAD_PATH) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Es dürfen keine Anhänge hochgeladen werden'
|
||||
});
|
||||
}
|
||||
|
||||
const filePaths = [] as string[];
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
for (const file of input.files ?? []) {
|
||||
const uuid = crypto.randomUUID();
|
||||
const tmpFilePath = path.join(UPLOAD_PATH!, uuid);
|
||||
const tmpFileStream = fs.createWriteStream(tmpFilePath);
|
||||
|
||||
filePaths.push(tmpFilePath);
|
||||
|
||||
const md5Hash = crypto.createHash('md5');
|
||||
|
||||
for await (const chunk of file.stream()) {
|
||||
md5Hash.update(chunk);
|
||||
tmpFileStream.write(chunk);
|
||||
}
|
||||
|
||||
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: type,
|
||||
hash: hash,
|
||||
reportId: report.id
|
||||
});
|
||||
|
||||
fs.renameSync(tmpFilePath, filePath);
|
||||
filePaths.pop();
|
||||
filePaths.push(filePath);
|
||||
}
|
||||
|
||||
await tx.submitReport({
|
||||
urlHash: input.urlHash,
|
||||
reportedId: reportedId,
|
||||
reason: input.reason,
|
||||
body: input.body
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
for (const filePath of filePaths) {
|
||||
fs.rmSync(filePath);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
sendWebhook(WebhookAction.Report, {
|
||||
reporter: report.reporter.username,
|
||||
reported: report.reported?.username ?? null,
|
||||
reason: input.reason
|
||||
});
|
||||
},
|
||||
accept: 'form'
|
||||
}),
|
||||
addReport: defineAction({
|
||||
input: z.object({
|
||||
reason: z.string(),
|
||||
body: z.string().nullable(),
|
||||
createdAt: z.string().datetime().nullable(),
|
||||
reporter: z.number(),
|
||||
reported: z.number().nullable()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
|
||||
|
||||
const { id } = await db.addReport({
|
||||
reason: input.reason,
|
||||
body: input.body,
|
||||
createdAt: input.createdAt ? new Date(input.createdAt) : null,
|
||||
reporterId: input.reporter,
|
||||
reportedId: input.reported
|
||||
});
|
||||
|
||||
return {
|
||||
id: id
|
||||
};
|
||||
}
|
||||
}),
|
||||
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,
|
||||
reportedId: input.reported
|
||||
});
|
||||
}
|
||||
}),
|
||||
reportStatus: defineAction({
|
||||
input: z.object({
|
||||
reportId: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
|
||||
|
||||
return {
|
||||
reportStatus: await db.getReportStatus(input)
|
||||
};
|
||||
}
|
||||
}),
|
||||
editReportStatus: defineAction({
|
||||
input: z.object({
|
||||
reportId: z.number(),
|
||||
status: z.enum(['open', 'closed']).nullable(),
|
||||
notice: z.string().nullable(),
|
||||
statement: z.string().nullable(),
|
||||
strikeReasonId: z.number().nullable()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
|
||||
|
||||
let preReportStrike;
|
||||
if (input.status === 'closed') preReportStrike = await db.getStrikeByReportId({ reportId: input.reportId });
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.editReportStatus(input);
|
||||
|
||||
if (input.strikeReasonId) {
|
||||
await db.editStrike({
|
||||
reportId: input.reportId,
|
||||
strikeReasonId: input.strikeReasonId
|
||||
});
|
||||
} else {
|
||||
await db.deleteStrike({ reportId: input.reportId });
|
||||
}
|
||||
});
|
||||
|
||||
if (input.status === 'closed' && preReportStrike?.strikeReason?.id != input.strikeReasonId) {
|
||||
const report = await db.getReportById({ id: input.reportId });
|
||||
if (report.reported) {
|
||||
const user = await db.getUserById({ id: report.reported.id });
|
||||
|
||||
// send webhook in background
|
||||
sendWebhook(WebhookAction.Strike, {
|
||||
user: user!.uuid!
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
reports: defineAction({
|
||||
input: z.object({
|
||||
reporter: z.string().nullish(),
|
||||
reported: z.string().nullish(),
|
||||
includeDrafts: z.boolean().nullish()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
|
||||
|
||||
return {
|
||||
reports: await db.getReports(input)
|
||||
};
|
||||
}
|
||||
}),
|
||||
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(),
|
||||
weight: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||
|
||||
return await db.addStrikeReason(input);
|
||||
}
|
||||
}),
|
||||
editStrikeReason: defineAction({
|
||||
input: z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
weight: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||
|
||||
await db.editStrikeReason(input);
|
||||
}
|
||||
}),
|
||||
deleteStrikeReason: defineAction({
|
||||
input: z.object({
|
||||
id: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||
|
||||
await db.deleteStrikeReason(input);
|
||||
}
|
||||
}),
|
||||
strikeReasons: defineAction({
|
||||
handler: async (_, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
|
||||
|
||||
return {
|
||||
strikeReasons: await db.getStrikeReasons({})
|
||||
};
|
||||
}
|
||||
}),
|
||||
usernames: defineAction({
|
||||
input: z.object({
|
||||
username: z.string()
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const users = await db.getUsers({ username: input.username, limit: 5 });
|
||||
|
||||
return {
|
||||
usernames: users.map((u) => u.username)
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
49
src/actions/session.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ActionError, defineAction } from 'astro:actions';
|
||||
import { z } from 'astro:schema';
|
||||
import { db } from '@db/database.ts';
|
||||
import { ADMIN_USER, ADMIN_PASSWORD } from 'astro:env/server';
|
||||
import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
|
||||
export const session = {
|
||||
login: defineAction({
|
||||
input: z.object({
|
||||
username: z.string(),
|
||||
password: z.string()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
let admin;
|
||||
if (input.username === ADMIN_USER && input.password === ADMIN_PASSWORD) {
|
||||
admin = {
|
||||
id: -1,
|
||||
username: ADMIN_USER,
|
||||
permissions: new Permissions(Permissions.allPermissions())
|
||||
};
|
||||
} else {
|
||||
admin = await db.existsAdmin(input);
|
||||
}
|
||||
|
||||
if (!admin) {
|
||||
throw new ActionError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Nutzername und Passwort stimmen nicht überein'
|
||||
});
|
||||
}
|
||||
|
||||
Session.newSession(admin.id, admin.permissions, context.cookies);
|
||||
|
||||
return {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
permissions: admin.permissions.value
|
||||
};
|
||||
}
|
||||
}),
|
||||
logout: defineAction({
|
||||
handler: async (_, context) => {
|
||||
const session = Session.actionSessionFromCookies(context.cookies);
|
||||
|
||||
session.invalidate(context.cookies);
|
||||
}
|
||||
})
|
||||
};
|
||||
23
src/actions/settings.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineAction } from 'astro:actions';
|
||||
import { z } from 'astro:schema';
|
||||
import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import { db } from '@db/database.ts';
|
||||
|
||||
export const settings = {
|
||||
setSettings: defineAction({
|
||||
input: z.object({
|
||||
settings: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string().nullable()
|
||||
})
|
||||
)
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Settings);
|
||||
|
||||
await db.setSettings(input);
|
||||
}
|
||||
})
|
||||
};
|
||||
80
src/actions/signup.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ActionError, defineAction } from 'astro:actions';
|
||||
import { z } from 'astro:schema';
|
||||
import { db } from '@db/database.ts';
|
||||
import { getJavaUuid } from '@util/minecraft.ts';
|
||||
import { getSetting, SettingKey } from '@util/settings.ts';
|
||||
import { sendWebhook, WebhookAction } from '@util/webhook.ts';
|
||||
|
||||
export const signup = {
|
||||
signup: defineAction({
|
||||
input: z.object({
|
||||
firstname: z.string().trim().min(2),
|
||||
lastname: z.string().trim().min(2),
|
||||
birthday: z
|
||||
.string()
|
||||
.date()
|
||||
// this will be inaccurate as it is evaluated only once
|
||||
.max(Date.now() - 1000 * 60 * 60 * 24 * 365 * 6),
|
||||
phone: z.string().trim().nullable(),
|
||||
username: z.string().trim(),
|
||||
edition: z.enum(['java', 'bedrock'])
|
||||
}),
|
||||
handler: async (input) => {
|
||||
// check if signup is allowed
|
||||
if (!(await getSetting(db, SettingKey.SignupEnabled))) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Die Anmeldung ist derzeit deaktiviert'
|
||||
});
|
||||
}
|
||||
|
||||
// check if the user were already signed up
|
||||
if (await db.getUserByUsername({ username: input.username })) {
|
||||
throw new ActionError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Du hast dich bereits registriert'
|
||||
});
|
||||
}
|
||||
|
||||
let uuid;
|
||||
try {
|
||||
uuid = await getJavaUuid(input.username);
|
||||
} catch (_) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: `Es wurde kein Minecraft Java Account mit dem Username ${input.username} gefunden`
|
||||
});
|
||||
}
|
||||
|
||||
// check if user is blocked
|
||||
if (uuid) {
|
||||
const blockedUser = await db.getBlockedUserByUuid({ uuid: uuid });
|
||||
if (blockedUser) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Du bist für die Registrierung gesperrt'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db.addUser({
|
||||
firstname: input.firstname,
|
||||
lastname: input.lastname,
|
||||
birthday: input.birthday,
|
||||
telephone: input.phone,
|
||||
username: input.username,
|
||||
edition: input.edition,
|
||||
uuid: uuid
|
||||
});
|
||||
|
||||
sendWebhook(WebhookAction.Signup, {
|
||||
firstname: input.firstname,
|
||||
lastname: input.lastname,
|
||||
birthday: new Date(input.birthday).toISOString().slice(0, 10),
|
||||
telephone: input.phone,
|
||||
username: input.username,
|
||||
edition: input.edition
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
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
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
148
src/actions/user.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { ActionError, defineAction } from 'astro:actions';
|
||||
import { z } from 'astro:schema';
|
||||
import { db } from '@db/database.ts';
|
||||
import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
|
||||
export const user = {
|
||||
addUser: defineAction({
|
||||
input: z.object({
|
||||
firstname: z.string(),
|
||||
lastname: z.string(),
|
||||
birthday: z.string().date(),
|
||||
telephone: z.string().nullable(),
|
||||
username: z.string(),
|
||||
edition: z.enum(['java', 'bedrock']),
|
||||
uuid: z.string().nullable()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
if (await db.existsUser({ username: input.username })) {
|
||||
throw new ActionError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Der Benutzername ist bereits registriert'
|
||||
});
|
||||
}
|
||||
|
||||
const { id } = await db.addUser({
|
||||
firstname: input.firstname,
|
||||
lastname: input.lastname,
|
||||
birthday: input.birthday,
|
||||
telephone: input.telephone,
|
||||
username: input.username,
|
||||
edition: input.edition,
|
||||
uuid: input.uuid
|
||||
});
|
||||
|
||||
return {
|
||||
id: id
|
||||
};
|
||||
}
|
||||
}),
|
||||
editUser: defineAction({
|
||||
input: z.object({
|
||||
id: z.number(),
|
||||
firstname: z.string(),
|
||||
lastname: z.string(),
|
||||
birthday: z.string().date(),
|
||||
telephone: z.string().nullable(),
|
||||
username: z.string(),
|
||||
edition: z.enum(['java', 'bedrock']),
|
||||
uuid: z.string().nullable()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
const user = await db.existsUser({ username: input.username });
|
||||
if (user && user.id !== input.id) {
|
||||
throw new ActionError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Ein Spieler mit dem Benutzernamen existiert bereits'
|
||||
});
|
||||
}
|
||||
|
||||
await db.editUser({
|
||||
id: input.id,
|
||||
firstname: input.firstname,
|
||||
lastname: input.lastname,
|
||||
birthday: input.birthday,
|
||||
telephone: input.telephone,
|
||||
username: input.username,
|
||||
edition: input.edition,
|
||||
uuid: input.uuid
|
||||
});
|
||||
}
|
||||
}),
|
||||
deleteUser: defineAction({
|
||||
input: z.object({
|
||||
id: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
await db.deleteUser(input);
|
||||
}
|
||||
}),
|
||||
users: defineAction({
|
||||
input: z.object({
|
||||
username: z.string().nullish(),
|
||||
limit: z.number().optional()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
const users = await db.getUsers(input);
|
||||
|
||||
return {
|
||||
users: users
|
||||
};
|
||||
}
|
||||
}),
|
||||
addBlocked: defineAction({
|
||||
input: z.object({
|
||||
uuid: z.string(),
|
||||
comment: z.string().nullable()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
const { id } = await db.addBlockedUser(input);
|
||||
|
||||
return {
|
||||
id: id
|
||||
};
|
||||
}
|
||||
}),
|
||||
editBlocked: defineAction({
|
||||
input: z.object({
|
||||
id: z.number(),
|
||||
uuid: z.string(),
|
||||
comment: z.string().nullable()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
await db.editBlockedUser(input);
|
||||
}
|
||||
}),
|
||||
deleteBlocked: defineAction({
|
||||
input: z.object({
|
||||
id: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
await db.deleteBlockedUser(input);
|
||||
}
|
||||
}),
|
||||
blocked: defineAction({
|
||||
handler: async (_, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
return {
|
||||
blocked: await db.getBlockedUsers({})
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
36
src/app.css
@@ -1,36 +0,0 @@
|
||||
@import '@fontsource/nunito';
|
||||
@import '@fontsource/roboto';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
@font-face {
|
||||
font-family: 'Minecraft';
|
||||
src: url('/fonts/MinecraftRegular.otf') format('opentype');
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-roboto scroll-smooth;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-minecraft;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.pixelated {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.bg-horizontal-sprite {
|
||||
background-size: auto 100%;
|
||||
}
|
||||
}
|
||||
12
src/app.d.ts
vendored
@@ -1,12 +0,0 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
13
src/app.html
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
63
src/app/admin/admins/Admins.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import BitBadge from '@components/input/BitBadge.svelte';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import { type Admin, admins, deleteAdmin, editAdmin } from '@app/admin/admins/admins.ts';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
|
||||
// state
|
||||
let editPopupAdmin = $state(null);
|
||||
let editPopupOpen = $derived(!!editPopupAdmin);
|
||||
|
||||
// callback
|
||||
function onAdminDelete(admin: Admin) {
|
||||
$confirmPopupState = {
|
||||
title: 'Admin löschen',
|
||||
message: 'Soll der Admin wirklich gelöscht werden?',
|
||||
onConfirm: () => deleteAdmin(admin)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet permissionsBadge(permissions: number)}
|
||||
<BitBadge available={Permissions.asOptions()} value={permissions} readonly />
|
||||
{/snippet}
|
||||
|
||||
<DataTable
|
||||
data={admins}
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'username', label: 'Username', width: 30 },
|
||||
{ key: 'permissions', label: 'Berechtigungen', width: 60, transform: permissionsBadge }
|
||||
]}
|
||||
onEdit={(admin) => (editPopupAdmin = admin)}
|
||||
onDelete={onAdminDelete}
|
||||
/>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Admin bearbeiten',
|
||||
submitButtonTitle: 'Speichern',
|
||||
confirmPopupTitle: 'Änderungen speichern',
|
||||
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
|
||||
}}
|
||||
target={editPopupAdmin}
|
||||
keys={[
|
||||
[
|
||||
{ key: 'username', type: 'text', label: 'Username', options: { required: true } },
|
||||
{ key: 'password', type: 'password', label: 'Passwort', default: null, options: { convert: (v) => v || null } }
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'permissions',
|
||||
type: 'bit-badge',
|
||||
label: 'Berechtigungen',
|
||||
default: 0,
|
||||
options: { available: Permissions.asOptions() }
|
||||
}
|
||||
]
|
||||
]}
|
||||
onSubmit={editAdmin}
|
||||
bind:open={editPopupOpen}
|
||||
/>
|
||||
48
src/app/admin/admins/SidebarActions.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import { addAdmin, fetchAdmins } from '@app/admin/admins/admins.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
|
||||
// state
|
||||
let createPopupOpen = $state(false);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
fetchAdmins();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||
<Icon icon="heroicons:plus-16-solid" />
|
||||
<span>Neuer Admin</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Admin erstellen',
|
||||
submitButtonTitle: 'Erstellen',
|
||||
confirmPopupTitle: 'Admin erstellen?',
|
||||
confirmPopupMessage: 'Soll der Admin erstellt werden?'
|
||||
}}
|
||||
target={null}
|
||||
keys={[
|
||||
[
|
||||
{ key: 'username', type: 'text', label: 'Username', options: { required: true } },
|
||||
{ key: 'password', type: 'password', label: 'Passwort', options: { required: true } }
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'permissions',
|
||||
type: 'bit-badge',
|
||||
label: 'Berechtigungen',
|
||||
default: 0,
|
||||
options: { available: Permissions.asOptions() }
|
||||
}
|
||||
]
|
||||
]}
|
||||
onSubmit={addAdmin}
|
||||
bind:open={createPopupOpen}
|
||||
/>
|
||||
52
src/app/admin/admins/admins.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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 Admins = Exclude<ActionReturnType<typeof actions.admin.admins>['data'], undefined>['admins'];
|
||||
export type Admin = Admins[0];
|
||||
|
||||
// state
|
||||
export const admins = writable<Admin[]>([]);
|
||||
|
||||
// actions
|
||||
export async function fetchAdmins() {
|
||||
const { data, error } = await actions.admin.admins();
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
admins.set(data.admins);
|
||||
}
|
||||
|
||||
export async function addAdmin(admin: Admin & { password: string }) {
|
||||
const { data, error } = await actions.admin.addAdmin(admin);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
addToWritableArray(admins, Object.assign(admin, { id: data.id }));
|
||||
}
|
||||
|
||||
export async function editAdmin(admin: Admin & { password: string }) {
|
||||
const { error } = await actions.admin.editAdmin(admin);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
updateWritableArray(admins, admin, (t) => t.id == admin.id);
|
||||
}
|
||||
|
||||
export async function deleteAdmin(admin: Admin) {
|
||||
const { error } = await actions.admin.deleteAdmin(admin);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteFromWritableArray(admins, (t) => t.id == admin.id);
|
||||
}
|
||||
56
src/app/admin/blockedUsers/BlockedUsers.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type BlockedUser,
|
||||
blockedUsers,
|
||||
deleteBlockedUser,
|
||||
editBlockedUser
|
||||
} from '@app/admin/blockedUsers/blockedUsers.ts';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
|
||||
// state
|
||||
let blockedUserEditPopupBlockedUser = $state(null);
|
||||
let blockedUserEditPopupOpen = $derived(!!blockedUserEditPopupBlockedUser);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
if (!blockedUserEditPopupOpen) blockedUserEditPopupBlockedUser = null;
|
||||
});
|
||||
|
||||
// callback
|
||||
function onBlockedUserDelete(blockedUser: BlockedUser) {
|
||||
$confirmPopupState = {
|
||||
title: 'Nutzer entblockieren?',
|
||||
message: 'Soll der Nutzer wirklich entblockiert werden?\nDieser kann sich danach wieder registrieren.',
|
||||
onConfirm: () => deleteBlockedUser(blockedUser)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<DataTable
|
||||
data={blockedUsers}
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'uuid', label: 'UUID', width: 20, sortable: true },
|
||||
{ key: 'comment', label: 'Kommentar', width: 70 }
|
||||
]}
|
||||
onEdit={(blockedUser) => (blockedUserEditPopupBlockedUser = blockedUser)}
|
||||
onDelete={onBlockedUserDelete}
|
||||
/>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Blockierten Nutzer bearbeiten',
|
||||
submitButtonTitle: 'Speichern',
|
||||
confirmPopupTitle: 'Änderungen speichern',
|
||||
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
|
||||
}}
|
||||
target={blockedUserEditPopupBlockedUser}
|
||||
keys={[
|
||||
[{ key: 'uuid', type: 'text', label: 'UUID', options: { required: true, dynamicWidth: true } }],
|
||||
[{ key: 'comment', type: 'textarea', label: 'Kommentar', options: { dynamicWidth: true } }]
|
||||
]}
|
||||
onSubmit={editBlockedUser}
|
||||
bind:open={blockedUserEditPopupOpen}
|
||||
/>
|
||||
37
src/app/admin/blockedUsers/SidebarActions.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import { fetchBlockedUsers, addBlockedUser } from '@app/admin/blockedUsers/blockedUsers.ts';
|
||||
|
||||
// states
|
||||
let createPopupOpen = $state(false);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
fetchBlockedUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||
<Icon icon="heroicons:plus-16-solid" />
|
||||
<span>Neuer blockierter Nutzer</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Blockierten Nutzer erstellen',
|
||||
submitButtonTitle: 'Erstellen',
|
||||
confirmPopupTitle: 'Nutzer blockieren',
|
||||
confirmPopupMessage:
|
||||
'Bist du sicher, dass der Nutzer blockiert werden soll?\nEin blockierter Nutzer kann sich nicht mehr registrieren.'
|
||||
}}
|
||||
target={null}
|
||||
keys={[
|
||||
[{ key: 'uuid', type: 'text', label: 'UUID', options: { required: true, dynamicWidth: true } }],
|
||||
[{ key: 'comment', type: 'textarea', label: 'Kommentar', default: null, options: { dynamicWidth: true } }]
|
||||
]}
|
||||
onSubmit={addBlockedUser}
|
||||
bind:open={createPopupOpen}
|
||||
/>
|
||||
52
src/app/admin/blockedUsers/blockedUsers.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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 BlockedUsers = Exclude<ActionReturnType<typeof actions.user.blocked>['data'], undefined>['blocked'];
|
||||
export type BlockedUser = BlockedUsers[0];
|
||||
|
||||
// state
|
||||
export const blockedUsers = writable<BlockedUsers>([]);
|
||||
|
||||
// actions
|
||||
export async function fetchBlockedUsers() {
|
||||
const { data, error } = await actions.user.blocked();
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
blockedUsers.set(data.blocked);
|
||||
}
|
||||
|
||||
export async function addBlockedUser(blockedUser: BlockedUser) {
|
||||
const { data, error } = await actions.user.addBlocked(blockedUser);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
addToWritableArray(blockedUsers, Object.assign(blockedUser, { id: data.id }));
|
||||
}
|
||||
|
||||
export async function editBlockedUser(blockedUser: BlockedUser) {
|
||||
const { error } = await actions.user.editBlocked(blockedUser);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
updateWritableArray(blockedUsers, blockedUser, (t) => t.id == blockedUser.id);
|
||||
}
|
||||
|
||||
export async function deleteBlockedUser(blockedUser: BlockedUser) {
|
||||
const { error } = await actions.user.deleteBlocked(blockedUser);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteFromWritableArray(blockedUsers, (t) => t.id == blockedUser.id);
|
||||
}
|
||||
29
src/app/admin/feedback/BottomBar.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import Textarea from '@components/input/Textarea.svelte';
|
||||
import type { Feedback } from '@app/admin/feedback/feedback.ts';
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
feedback: Feedback | null;
|
||||
}
|
||||
|
||||
// inputs
|
||||
let { feedback }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="absolute bottom-2 bg-base-200 rounded-lg w-[calc(100%-1rem)] mx-2 flex px-6 py-4 gap-10"
|
||||
hidden={feedback === null}
|
||||
>
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => (feedback = null)}>✕</button>
|
||||
<div class="w-96">
|
||||
<Input value={feedback?.event} label="Event" readonly />
|
||||
<Input value={feedback?.title} label="Titel" readonly />
|
||||
<Input value={feedback?.username} label="Nutzer" readonly />
|
||||
</div>
|
||||
<div class="divider divider-horizontal"></div>
|
||||
<div class="w-full">
|
||||
<Textarea value={feedback?.content} label="Inhalt" rows={9} readonly dynamicWidth />
|
||||
</div>
|
||||
</div>
|
||||
41
src/app/admin/feedback/Feedback.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import BottomBar from './BottomBar.svelte';
|
||||
import { feedbacks, fetchFeedbacks, type Feedback } from '@app/admin/feedback/feedback.ts';
|
||||
import { onMount } from 'svelte';
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
|
||||
// consts
|
||||
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
// states
|
||||
let activeFeedback = $state<Feedback | null>(null);
|
||||
|
||||
// lifecycle
|
||||
onMount(() => {
|
||||
fetchFeedbacks();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet date(value: string)}
|
||||
{dateFormat.format(new Date(value))}
|
||||
{/snippet}
|
||||
|
||||
<DataTable
|
||||
data={feedbacks}
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'event', label: 'Event', width: 10, sortable: true },
|
||||
{ key: 'username', label: 'Nutzer', width: 10, sortable: true },
|
||||
{ key: 'lastChanged', label: 'Datum', width: 10, sortable: true, transform: date },
|
||||
{ key: 'content', label: 'Inhalt', width: 10 }
|
||||
]}
|
||||
onClick={(feedback) => (activeFeedback = feedback)}
|
||||
/>
|
||||
|
||||
<BottomBar feedback={activeFeedback} />
|
||||
21
src/app/admin/feedback/feedback.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
import { writable } from 'svelte/store';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
|
||||
// types
|
||||
export type Feedbacks = Exclude<ActionReturnType<typeof actions.feedback.feedbacks>['data'], undefined>['feedbacks'];
|
||||
export type Feedback = Feedbacks[0];
|
||||
|
||||
// state
|
||||
export const feedbacks = writable<Feedbacks>([]);
|
||||
|
||||
// actions
|
||||
export async function fetchFeedbacks(reporter?: string | null, reported?: string | null) {
|
||||
const { data, error } = await actions.feedback.feedbacks({ reporter: reporter, reported: reported });
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
feedbacks.set(data.feedbacks);
|
||||
}
|
||||
170
src/app/admin/reports/BottomBar.svelte
Normal file
@@ -0,0 +1,170 @@
|
||||
<script lang="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 { editReportStatus, getReportStatus } from '@app/admin/reports/reports.ts';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
import Icon from '@iconify/svelte';
|
||||
import UserSearch from '@components/admin/search/UserSearch.svelte';
|
||||
|
||||
// html bindings
|
||||
let previewDialogElem: HTMLDialogElement;
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
strikeReasons: StrikeReasons;
|
||||
report: Report | null;
|
||||
}
|
||||
|
||||
// inputs
|
||||
let { strikeReasons, report }: Props = $props();
|
||||
|
||||
// states
|
||||
let reportedUser = $state<{ id: number; username: 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})` }),
|
||||
{ [null]: 'Kein Vergehen' }
|
||||
);
|
||||
|
||||
// lifetime
|
||||
$effect(() => {
|
||||
if (!report) return;
|
||||
|
||||
getReportStatus(report).then((reportStatus) => {
|
||||
if (!reportStatus) return;
|
||||
|
||||
status = reportStatus.status;
|
||||
notice = reportStatus.notice;
|
||||
statement = reportStatus.statement;
|
||||
});
|
||||
|
||||
getReportAttachments(report).then((value) => {
|
||||
if (value) reportAttachments = value;
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (previewReportAttachment) previewDialogElem.show();
|
||||
});
|
||||
|
||||
// callbacks
|
||||
async function onSaveButtonClick() {
|
||||
$confirmPopupState = {
|
||||
title: 'Änderungen speichern?',
|
||||
message: 'Sollen die Änderungen am Report gespeichert werden?',
|
||||
onConfirm: async () => {
|
||||
if (reportedUser?.id != report?.reported?.id) {
|
||||
report!.reported = reportedUser;
|
||||
await editReport(report!);
|
||||
}
|
||||
await editReportStatus(report!, {
|
||||
status: status,
|
||||
notice: notice,
|
||||
statement: statement,
|
||||
strikeReasonId: Number(strikeReason)
|
||||
} 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}
|
||||
>
|
||||
<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]">
|
||||
<UserSearch value={report?.reporter.username} label="Report Ersteller" readonly mustMatch />
|
||||
<UserSearch
|
||||
value={report?.reported?.username}
|
||||
label="Reporteter Spieler"
|
||||
onSubmit={(user) => (reportedUser = user)}
|
||||
/>
|
||||
<Textarea bind:value={notice} label="Interne Notizen" rows={10} />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<Input value={report?.reason} label="Grund" readonly dynamicWidth />
|
||||
<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={7} />
|
||||
<Select
|
||||
bind:value={status}
|
||||
values={{ open: 'In Bearbeitung', closed: 'Bearbeitet' }}
|
||||
defaultValue="Unbearbeitet"
|
||||
label="Bearbeitungsstatus"
|
||||
dynamicWidth
|
||||
/>
|
||||
<Select bind:value={strikeReason} values={strikeReasonValues} label="Vergehen" dynamicWidth></Select>
|
||||
<div class="divider mt-0 mb-2"></div>
|
||||
<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>
|
||||
55
src/app/admin/reports/Reports.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import BottomBar from '@app/admin/reports/BottomBar.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
import { type StrikeReasons, getStrikeReasons, reports } from '@app/admin/reports/reports.ts';
|
||||
|
||||
// consts
|
||||
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
// states
|
||||
let strikeReasons = $state<StrikeReasons>([]);
|
||||
let activeReport = $state<Report | null>(null);
|
||||
|
||||
// lifecycle
|
||||
onMount(() => {
|
||||
getStrikeReasons().then((data) => (strikeReasons = data ?? []));
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet date(value: string)}
|
||||
{value ? 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>
|
||||
{:else if value === null}
|
||||
<p>Unbearbeitet</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<DataTable
|
||||
data={reports}
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'reason', label: 'Grund' },
|
||||
{ key: 'reporter.username', label: 'Report Ersteller' },
|
||||
{ key: 'reported.username', label: 'Reporteter Spieler' },
|
||||
{ key: 'createdAt', label: 'Datum', transform: date },
|
||||
{ key: 'status.status', label: 'Bearbeitungsstatus', transform: status }
|
||||
]}
|
||||
onClick={(report) => (activeReport = report)}
|
||||
/>
|
||||
|
||||
{#key activeReport}
|
||||
<BottomBar {strikeReasons} report={activeReport} />
|
||||
{/key}
|
||||
80
src/app/admin/reports/SidebarActions.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import { addReport, fetchReports } from '@app/admin/reports/reports.ts';
|
||||
import Checkbox from '@components/input/Checkbox.svelte';
|
||||
|
||||
// states
|
||||
let showDrafts = $state(false);
|
||||
let reporterUsernameFilter = $state<string | null>(null);
|
||||
let reportedUsernameFilter = $state<string | null>(null);
|
||||
|
||||
let createPopupOpen = $state(false);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
fetchReports(reporterUsernameFilter, reportedUsernameFilter, showDrafts);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<fieldset class="fieldset border border-base-content/50 rounded-box p-2">
|
||||
<legend class="fieldset-legend">Filter</legend>
|
||||
<Checkbox bind:checked={showDrafts} label="Entwürfe zeigen" />
|
||||
<Input bind:value={reporterUsernameFilter} label="Reporter Ersteller" />
|
||||
<Input bind:value={reportedUsernameFilter} label="Reporteter Spieler" />
|
||||
</fieldset>
|
||||
<div class="divider my-1"></div>
|
||||
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||
<Icon icon="heroicons:plus-16-solid" />
|
||||
<span>Neuer Report</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Report erstellen',
|
||||
submitButtonTitle: 'Erstellen',
|
||||
confirmPopupTitle: 'Report erstellen?',
|
||||
confirmPopupMessage: 'Soll der Report erstellt werden?'
|
||||
}}
|
||||
target={null}
|
||||
keys={[
|
||||
[
|
||||
{
|
||||
key: 'reporter',
|
||||
type: 'user-search',
|
||||
label: 'Report Ersteller',
|
||||
default: { id: null, username: null },
|
||||
options: { required: true, mustMatch: true, validate: (user) => user?.id != null }
|
||||
},
|
||||
{
|
||||
key: 'reported',
|
||||
type: 'user-search',
|
||||
label: 'Reporteter Spieler',
|
||||
options: { mustMatch: true, validate: (user) => user?.id != null }
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'reason',
|
||||
type: 'text',
|
||||
label: 'Grund',
|
||||
options: { required: true, dynamicWidth: true, validate: (reason) => reason }
|
||||
}
|
||||
],
|
||||
[{ key: 'body', type: 'textarea', label: 'Inhalt', default: null, options: { rows: 5, dynamicWidth: true } }],
|
||||
[
|
||||
{
|
||||
key: 'createdAt',
|
||||
type: 'checkbox',
|
||||
label: 'Report kann bearbeitet werden',
|
||||
default: true,
|
||||
options: { convert: (v) => (v ? null : new Date().toISOString()) }
|
||||
}
|
||||
]
|
||||
]}
|
||||
onSubmit={addReport}
|
||||
bind:open={createPopupOpen}
|
||||
/>
|
||||
115
src/app/admin/reports/reports.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
import { writable } from 'svelte/store';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
|
||||
// types
|
||||
export type Reports = Exclude<ActionReturnType<typeof actions.report.reports>['data'], undefined>['reports'];
|
||||
export type Report = Reports[0];
|
||||
|
||||
export type ReportStatus = Exclude<
|
||||
Exclude<ActionReturnType<typeof actions.report.reportStatus>['data'], undefined>['reportStatus'],
|
||||
null
|
||||
> & { strikeReasonId: number | null };
|
||||
|
||||
export type StrikeReasons = Exclude<
|
||||
ActionReturnType<typeof actions.report.strikeReasons>['data'],
|
||||
undefined
|
||||
>['strikeReasons'];
|
||||
|
||||
// state
|
||||
export const reports = writable<Reports>([]);
|
||||
|
||||
// actions
|
||||
export async function fetchReports(
|
||||
reporterUsername: string | null,
|
||||
reportedUsername: string | null,
|
||||
includeDrafts: boolean
|
||||
) {
|
||||
const { data, error } = await actions.report.reports({
|
||||
reporter: reporterUsername,
|
||||
reported: reportedUsername,
|
||||
includeDrafts: includeDrafts
|
||||
});
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
reports.set(data.reports);
|
||||
}
|
||||
|
||||
export async function addReport(report: Report) {
|
||||
const { data, error } = await actions.report.addReport({
|
||||
reason: report.reason,
|
||||
body: report.body,
|
||||
createdAt: report.createdAt as unknown as string,
|
||||
reporter: report.reporter.id,
|
||||
reported: report.reported?.id ?? null
|
||||
});
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
reports.update((old) => {
|
||||
old.push(Object.assign(report, { id: data.id, status: null }));
|
||||
return old;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getReportStatus(report: Report) {
|
||||
const { data, error } = await actions.report.reportStatus({ reportId: report.id });
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
status: reportStatus.status,
|
||||
notice: reportStatus.notice,
|
||||
statement: reportStatus.statement,
|
||||
strikeReasonId: reportStatus.strikeReasonId
|
||||
});
|
||||
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
return data.strikeReasons;
|
||||
}
|
||||
145
src/app/admin/settings/Settings.svelte
Normal file
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import { DynamicSettings } from './dynamicSettings.ts';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
import { updateSettings } from './actions.ts';
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
settings: { name: string; value: string }[];
|
||||
}
|
||||
|
||||
type SettingsInput = {
|
||||
name: string;
|
||||
entries: (
|
||||
| {
|
||||
name: string;
|
||||
type: 'checkbox';
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
type: 'text';
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
type: 'textarea';
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
)[];
|
||||
}[];
|
||||
|
||||
// inputs
|
||||
const { settings }: Props = $props();
|
||||
|
||||
const dynamicSettings = new DynamicSettings(
|
||||
settings.reduce((prev, curr) => Object.assign(prev, { [curr.name]: curr.value }), {})
|
||||
);
|
||||
|
||||
let changes = $state<{ [k: string]: string | null }>(dynamicSettings.getChanges());
|
||||
|
||||
// consts
|
||||
const settingsInput: SettingsInput = [
|
||||
{
|
||||
name: 'Anmeldung',
|
||||
entries: [
|
||||
{
|
||||
name: 'Aktiviert',
|
||||
type: 'checkbox',
|
||||
value: dynamicSettings.signupEnabled(),
|
||||
onChange: dynamicSettings.signupSetEnabled
|
||||
},
|
||||
{
|
||||
name: 'Text unter dem Anmelde Button',
|
||||
type: 'textarea',
|
||||
value: dynamicSettings.signupInfoText(),
|
||||
onChange: dynamicSettings.signupSetInfoText
|
||||
},
|
||||
{
|
||||
name: 'Text, wenn die Anmeldung deaktiviert ist',
|
||||
type: 'textarea',
|
||||
value: dynamicSettings.signupDisabledText(),
|
||||
onChange: dynamicSettings.signupSetDisabledText
|
||||
},
|
||||
{
|
||||
name: 'Subtext, wenn die Anmeldung deaktiviert ist',
|
||||
type: 'textarea',
|
||||
value: dynamicSettings.signupDisabledSubtext(),
|
||||
onChange: dynamicSettings.signupSetDisabledSubtext
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// callbacks
|
||||
function onSaveSettingsClick() {
|
||||
$confirmPopupState = {
|
||||
title: 'Änderungen speichern?',
|
||||
message: 'Sollen die Änderungen gespeichert werden?',
|
||||
onConfirm: async () => {
|
||||
if (!(await updateSettings(changes))) return;
|
||||
dynamicSettings.setChanges();
|
||||
changes = {};
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col items-center justify-between">
|
||||
<div class="grid grid-cols-2 w-full">
|
||||
{#each settingsInput as setting (setting.name)}
|
||||
<div class="mx-12">
|
||||
<div class="divider">{setting.name}</div>
|
||||
<div class="flex flex-col gap-5">
|
||||
{#each setting.entries as entry (entry.name)}
|
||||
<label class="flex justify-between">
|
||||
<span class="mt-[.125rem] text-sm w-1/2">{entry.name}</span>
|
||||
<div class="w-1/2">
|
||||
{#if entry.type === 'checkbox'}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
onchange={(e) => {
|
||||
entry.onChange(e.currentTarget.checked);
|
||||
changes = dynamicSettings.getChanges();
|
||||
}}
|
||||
checked={entry.value}
|
||||
/>
|
||||
{:else if entry.type === 'text'}
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
onchange={(e) => {
|
||||
entry.onChange(e.currentTarget.value);
|
||||
changes = dynamicSettings.getChanges();
|
||||
}}
|
||||
value={entry.value}
|
||||
/>
|
||||
{:else if entry.type === 'textarea'}
|
||||
<textarea
|
||||
class="textarea"
|
||||
value={entry.value}
|
||||
onchange={(e) => {
|
||||
entry.onChange(e.currentTarget.value);
|
||||
changes = dynamicSettings.getChanges();
|
||||
}}
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-success mt-auto mb-8"
|
||||
class:btn-disabled={Object.keys(changes).length === 0}
|
||||
onclick={onSaveSettingsClick}>Speichern</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
19
src/app/admin/settings/actions.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { actions } from 'astro:actions';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
|
||||
export async function updateSettings(changes: { [k: string]: string | null }) {
|
||||
const { error } = await actions.settings.setSettings({
|
||||
settings: Object.entries(changes).reduce(
|
||||
(prev, curr) => {
|
||||
prev.push({ name: curr[0], value: curr[1] });
|
||||
return prev;
|
||||
},
|
||||
[] as { name: string; value: string | null }[]
|
||||
)
|
||||
});
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
48
src/app/admin/settings/dynamicSettings.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { SettingKey } from '@util/settings.ts';
|
||||
|
||||
export class DynamicSettings {
|
||||
private settings: { [k: string]: string | null };
|
||||
private changedSettings: { [k: string]: string | null } = {};
|
||||
|
||||
constructor(settings: typeof this.settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
private get<V extends string | boolean>(key: string, defaultValue: V): V {
|
||||
const setting = this.changedSettings[key] ?? this.settings[key];
|
||||
return setting != null ? JSON.parse(setting) : defaultValue;
|
||||
}
|
||||
|
||||
private set<V extends string | boolean>(key: string, value: V | null) {
|
||||
if (this.settings[key] == value) {
|
||||
delete this.changedSettings[key];
|
||||
} else {
|
||||
this.changedSettings[key] = value != null ? JSON.stringify(value) : null;
|
||||
}
|
||||
}
|
||||
|
||||
getChanges() {
|
||||
return this.changedSettings;
|
||||
}
|
||||
|
||||
setChanges() {
|
||||
this.settings = Object.assign(this.settings, this.changedSettings);
|
||||
this.changedSettings = {};
|
||||
}
|
||||
|
||||
/* signup enabled */
|
||||
signupEnabled = () => this.get(SettingKey.SignupEnabled, false);
|
||||
signupSetEnabled = (active: boolean) => this.set(SettingKey.SignupEnabled, active);
|
||||
|
||||
/* signup info text */
|
||||
signupInfoText = () => this.get(SettingKey.SignupInfoMessage, '');
|
||||
signupSetInfoText = (text: string) => this.set(SettingKey.SignupInfoMessage, text);
|
||||
|
||||
/* signup disabled text */
|
||||
signupDisabledText = () => this.get(SettingKey.SignupDisabledMessage, '');
|
||||
signupSetDisabledText = (text: string) => this.set(SettingKey.SignupDisabledMessage, text);
|
||||
|
||||
/* signup disabled subtext */
|
||||
signupDisabledSubtext = () => this.get(SettingKey.SignupDisabledSubMessage, '');
|
||||
signupSetDisabledSubtext = (text: string) => this.set(SettingKey.SignupDisabledSubMessage, text);
|
||||
}
|
||||
36
src/app/admin/strikeReasons/SidebarActions.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script>
|
||||
import Icon from '@iconify/svelte';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import { addStrikeReason, fetchStrikeReasons } from '@app/admin/strikeReasons/strikeReasons.js';
|
||||
|
||||
// states
|
||||
let createPopupOpen = $state(false);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
fetchStrikeReasons();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||
<Icon icon="heroicons:plus-16-solid" />
|
||||
<span>Neuer Strikegrund</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Strikegrund erstellen',
|
||||
submitButtonTitle: 'Erstellen',
|
||||
confirmPopupTitle: 'Strikegrund erstellen?',
|
||||
confirmPopupMessage: 'Soll der Strikegrund erstellt werden?'
|
||||
}}
|
||||
target={null}
|
||||
keys={[
|
||||
[{ key: 'name', type: 'text', label: 'Name', options: { required: true, dynamicWidth: true } }],
|
||||
[{ key: 'weight', type: 'number', label: 'Gewichtung', options: { required: true, dynamicWidth: true } }]
|
||||
]}
|
||||
onSubmit={addStrikeReason}
|
||||
bind:open={createPopupOpen}
|
||||
/>
|
||||
57
src/app/admin/strikeReasons/StrikeReasons.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
import {
|
||||
deleteStrikeReason,
|
||||
editStrikeReason,
|
||||
type StrikeReason,
|
||||
strikeReasons
|
||||
} from '@app/admin/strikeReasons/strikeReasons.ts';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
|
||||
// state
|
||||
let editPopupStrikeReason = $state(null);
|
||||
let editPopupOpen = $derived(!!editPopupStrikeReason);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
if (!editPopupOpen) editPopupStrikeReason = null;
|
||||
});
|
||||
|
||||
// callback
|
||||
function onBlockedUserDelete(strikeReason: StrikeReason) {
|
||||
$confirmPopupState = {
|
||||
title: 'Nutzer entblockieren?',
|
||||
message: 'Soll der Nutzer wirklich entblockiert werden?\nDieser kann sich danach wieder registrieren.',
|
||||
onConfirm: () => deleteStrikeReason(strikeReason)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<DataTable
|
||||
data={strikeReasons}
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'name', label: 'Name', width: 20 },
|
||||
{ key: 'weight', label: 'Gewichtung', width: 50, sortable: true },
|
||||
{ key: 'id', label: 'Id', width: 20 }
|
||||
]}
|
||||
onDelete={onBlockedUserDelete}
|
||||
onEdit={(strikeReason) => (editPopupStrikeReason = strikeReason)}
|
||||
/>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Strikegrund bearbeiten',
|
||||
submitButtonTitle: 'Speichern',
|
||||
confirmPopupTitle: 'Änderungen speichern',
|
||||
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
|
||||
}}
|
||||
target={editPopupStrikeReason}
|
||||
keys={[
|
||||
[{ key: 'name', type: 'text', label: 'Name', options: { required: true, dynamicWidth: true } }],
|
||||
[{ key: 'weight', type: 'number', label: 'Gewichtung', options: { required: true, dynamicWidth: true } }]
|
||||
]}
|
||||
onSubmit={editStrikeReason}
|
||||
bind:open={editPopupOpen}
|
||||
/>
|
||||
55
src/app/admin/strikeReasons/strikeReasons.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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 StrikeReasons = Exclude<
|
||||
ActionReturnType<typeof actions.report.strikeReasons>['data'],
|
||||
undefined
|
||||
>['strikeReasons'];
|
||||
export type StrikeReason = StrikeReasons[0];
|
||||
|
||||
// state
|
||||
export const strikeReasons = writable<StrikeReasons>([]);
|
||||
|
||||
// actions
|
||||
export async function fetchStrikeReasons() {
|
||||
const { data, error } = await actions.report.strikeReasons();
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
strikeReasons.set(data.strikeReasons);
|
||||
}
|
||||
|
||||
export async function addStrikeReason(strikeReason: StrikeReason) {
|
||||
const { data, error } = await actions.report.addStrikeReason(strikeReason);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
addToWritableArray(strikeReasons, Object.assign(strikeReason, { id: data.id }));
|
||||
}
|
||||
|
||||
export async function editStrikeReason(strikeReason: StrikeReason) {
|
||||
const { error } = await actions.report.editStrikeReason(strikeReason);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
updateWritableArray(strikeReasons, strikeReason, (t) => t.id == strikeReason.id);
|
||||
}
|
||||
|
||||
export async function deleteStrikeReason(strikeReason: StrikeReason) {
|
||||
const { error } = await actions.report.deleteStrikeReason(strikeReason);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteFromWritableArray(strikeReasons, (t) => t.id == strikeReason.id);
|
||||
}
|
||||
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
@@ -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
@@ -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;
|
||||
}
|
||||
54
src/app/admin/users/SidebarActions.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import { addUser, fetchUsers } from '@app/admin/users/users.ts';
|
||||
|
||||
// states
|
||||
let usernameFilter = $state<string | null>(null);
|
||||
|
||||
let createPopupOpen = $state(false);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
fetchUsers({ username: usernameFilter });
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<fieldset class="fieldset border border-base-content/50 rounded-box p-2">
|
||||
<legend class="fieldset-legend">Filter</legend>
|
||||
<Input bind:value={usernameFilter} label="Username" />
|
||||
</fieldset>
|
||||
<div class="divider my-1"></div>
|
||||
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||
<Icon icon="heroicons:plus-16-solid" />
|
||||
<span>Neuer Nutzer</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Nutzer erstellen',
|
||||
submitButtonTitle: 'Erstellen',
|
||||
confirmPopupTitle: 'Nutzer erstellen?',
|
||||
confirmPopupMessage: 'Sollen der neue Nutzer erstellt werden?'
|
||||
}}
|
||||
target={null}
|
||||
keys={[
|
||||
[
|
||||
{ key: 'firstname', type: 'text', label: 'Vorname', options: { required: true } },
|
||||
{ key: 'lastname', type: 'text', label: 'Nachname', options: { required: true } }
|
||||
],
|
||||
[
|
||||
{ key: 'birthday', type: 'date', label: 'Geburtstag', options: { required: true } },
|
||||
{ key: 'telephone', type: 'tel', label: 'Telefonnummer', default: null }
|
||||
],
|
||||
[
|
||||
{ key: 'username', type: 'text', label: 'Spielername', options: { required: true } },
|
||||
{ key: 'uuid', type: 'text', label: 'UUID', default: null }
|
||||
]
|
||||
]}
|
||||
onSubmit={addUser}
|
||||
bind:open={createPopupOpen}
|
||||
/>
|
||||
65
src/app/admin/users/Users.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
import { deleteUser, editUser, type User, users } from '@app/admin/users/users.ts';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
|
||||
// state
|
||||
let editPopupUser = $state(null);
|
||||
let editPopupOpen = $derived(!!editPopupUser);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
if (!editPopupOpen) editPopupUser = null;
|
||||
});
|
||||
|
||||
// callback
|
||||
function onUserDelete(user: User) {
|
||||
$confirmPopupState = {
|
||||
title: 'Nutzer löschen?',
|
||||
message: 'Soll der Nutzer wirklich gelöscht werden?',
|
||||
onConfirm: () => deleteUser(user)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<DataTable
|
||||
data={users}
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'firstname', label: 'Vorname', width: 15, sortable: true },
|
||||
{ key: 'lastname', label: 'Nachname', width: 15, sortable: true },
|
||||
{ key: 'birthday', label: 'Geburtstag', width: 5, sortable: true },
|
||||
{ key: 'telephone', label: 'Telefon', width: 12, sortable: true },
|
||||
{ key: 'username', label: 'Username', width: 20, sortable: true },
|
||||
{ key: 'uuid', label: 'UUID', width: 23 }
|
||||
]}
|
||||
onEdit={(user) => (editPopupUser = user)}
|
||||
onDelete={onUserDelete}
|
||||
/>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Nutzer bearbeiten',
|
||||
submitButtonTitle: 'Speichern',
|
||||
confirmPopupTitle: 'Änderungen speichern?',
|
||||
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
|
||||
}}
|
||||
target={editPopupUser}
|
||||
keys={[
|
||||
[
|
||||
{ key: 'firstname', type: 'text', label: 'Vorname', options: { required: true } },
|
||||
{ key: 'lastname', type: 'text', label: 'Nachname', options: { required: true } }
|
||||
],
|
||||
[
|
||||
{ key: 'birthday', type: 'date', label: 'Geburtstag', options: { required: true } },
|
||||
{ key: 'telephone', type: 'tel', label: 'Telefonnummer' }
|
||||
],
|
||||
[
|
||||
{ key: 'username', type: 'text', label: 'Spielername', options: { required: true } },
|
||||
{ key: 'uuid', type: 'text', label: 'UUID' }
|
||||
]
|
||||
]}
|
||||
onSubmit={editUser}
|
||||
bind:open={editPopupOpen}
|
||||
/>
|
||||
69
src/app/admin/users/users.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
import { addToWritableArray, deleteFromWritableArray, updateWritableArray } from '@util/state.ts';
|
||||
|
||||
// types
|
||||
export type Users = Exclude<ActionReturnType<typeof actions.user.users>['data'], undefined>['users'];
|
||||
export type User = Users[0];
|
||||
|
||||
// state
|
||||
export const users = writable<Users>([]);
|
||||
|
||||
// actions
|
||||
export async function fetchUsers(options?: { username?: string | null }) {
|
||||
const { data, error } = await actions.user.users({ username: options?.username });
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
users.set(data.users);
|
||||
}
|
||||
|
||||
export async function addUser(user: User) {
|
||||
const { data, error } = await actions.user.addUser({
|
||||
firstname: user.firstname,
|
||||
lastname: user.lastname,
|
||||
birthday: user.birthday,
|
||||
telephone: user.telephone,
|
||||
username: user.username,
|
||||
edition: user.edition,
|
||||
uuid: user.uuid
|
||||
});
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
addToWritableArray(users, Object.assign(user, { id: data.id }));
|
||||
}
|
||||
|
||||
export async function editUser(user: User) {
|
||||
const { error } = await actions.user.editUser({
|
||||
id: user.id,
|
||||
firstname: user.firstname,
|
||||
lastname: user.lastname,
|
||||
birthday: user.birthday,
|
||||
telephone: user.telephone,
|
||||
username: user.username,
|
||||
edition: user.edition,
|
||||
uuid: user.uuid
|
||||
});
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
updateWritableArray(users, user, (t) => t.id == user.id);
|
||||
}
|
||||
|
||||
export async function deleteUser(user: User) {
|
||||
const { error } = await actions.user.deleteUser({ id: user.id });
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteFromWritableArray(users, (t) => t.id == user.id);
|
||||
}
|
||||
186
src/app/layout/Menu.svelte
Normal file
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import MenuHome from '@assets/img/menu-home.webp';
|
||||
import MenuSignup from '@assets/img/menu-signup.webp';
|
||||
import MenuRules from '@assets/img/menu-rules.webp';
|
||||
import MenuFaq from '@assets/img/menu-faq.webp';
|
||||
import MenuFeedback from '@assets/img/menu-feedback.webp';
|
||||
import MenuAdmins from '@assets/img/menu-admins.webp';
|
||||
import MenuButton from '@assets/img/menu-button.webp';
|
||||
import MenuInventoryBar from '@assets/img/menu-inventory-bar.webp';
|
||||
import MenuSelectedFrame from '@assets/img/menu-selected-frame.webp';
|
||||
import { isBrowser } from '@antfu/utils';
|
||||
import { navigate } from 'astro:transitions/client';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// html bindings
|
||||
let navElem: HTMLDivElement;
|
||||
|
||||
// states
|
||||
let navPaths = $state([
|
||||
{
|
||||
name: 'Startseite',
|
||||
sprite: MenuHome.src,
|
||||
href: '',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
name: 'Registrieren',
|
||||
sprite: MenuSignup.src,
|
||||
href: 'signup',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
name: 'Regeln',
|
||||
sprite: MenuRules.src,
|
||||
href: 'rules',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
name: 'FAQ',
|
||||
sprite: MenuFaq.src,
|
||||
href: 'faq',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
name: 'Feedback & Kontakt',
|
||||
sprite: MenuFeedback.src,
|
||||
href: 'feedback',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
name: 'Admins',
|
||||
sprite: MenuAdmins.src,
|
||||
href: 'admins',
|
||||
active: false
|
||||
}
|
||||
]);
|
||||
|
||||
let showMenuPermanent = $state(isBrowser ? localStorage.getItem('showMenuPermanent') === 'true' : false);
|
||||
let isTouch = $state(false);
|
||||
let isOpen = $state(false);
|
||||
let windowHeight = $state(0);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
localStorage.setItem('showMenuPermanent', `${showMenuPermanent}`);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
updateActiveNavPath();
|
||||
new MutationObserver(updateActiveNavPath).observe(document.head, { childList: true });
|
||||
});
|
||||
|
||||
// functions
|
||||
function updateActiveNavPath() {
|
||||
for (let i = 0; i < navPaths.length; i++) {
|
||||
navPaths[i].active = new URL(document.baseURI).pathname + navPaths[i].href === window.location.pathname;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight={windowHeight} />
|
||||
<svelte:body
|
||||
ontouchend={(e) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
if (isTouch && !navElem.contains(e.target)) showMenuPermanent = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="fixed bottom-4 right-4 sm:left-4 sm:right-[initial] group/menu-bar flex flex-col-reverse justify-center items-center z-50 main-menu"
|
||||
bind:this={navElem}
|
||||
>
|
||||
<button
|
||||
class={isTouch ? 'btn btn-square relative w-16 h-16' : 'btn btn-square group/menu-button relative w-16 h-16'}
|
||||
onclick={() => {
|
||||
if (!isTouch) {
|
||||
let activePath = navPaths.find((path) => path.active);
|
||||
if (activePath !== undefined) {
|
||||
navigate(activePath.href);
|
||||
}
|
||||
showMenuPermanent = !showMenuPermanent;
|
||||
}
|
||||
}}
|
||||
ontouchend={() => {
|
||||
isTouch = true;
|
||||
showMenuPermanent = !showMenuPermanent;
|
||||
}}
|
||||
>
|
||||
<img class="absolute w-full h-full p-1 pixelated" src={MenuButton.src} alt="menu" />
|
||||
<img
|
||||
class="opacity-0 transition-opacity delay-50 group-hover/menu-button:opacity-100 absolute w-full h-full p-[3px] pixelated"
|
||||
class:opacity-100={isOpen || (isTouch && showMenuPermanent)}
|
||||
src={MenuSelectedFrame.src}
|
||||
alt="menu hover"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class:hidden={!(isOpen || showMenuPermanent)}
|
||||
class={isTouch ? 'pb-3' : 'group-hover/menu-bar:block pb-3'}
|
||||
onmouseenter={() => (isOpen = true)}
|
||||
onmouseleave={() => (isOpen = false)}
|
||||
role="tooltip"
|
||||
>
|
||||
<ul class="bg-base-200 rounded">
|
||||
{#each navPaths as navPath, i (navPath.href)}
|
||||
<li
|
||||
class="flex justify-center tooltip"
|
||||
class:tooltip-left={windowHeight > 450}
|
||||
class:sm:tooltip-right={windowHeight > 450}
|
||||
class:tooltip-top={windowHeight <= 450}
|
||||
class:tooltip-open={isTouch || windowHeight <= 450}
|
||||
data-tip={navPath.name}
|
||||
>
|
||||
<a
|
||||
class="btn btn-square border-none group/menu-item relative w-[3.5rem] h-[3.5rem] flex justify-center items-center"
|
||||
href={navPath.href}
|
||||
onclick={() => navigate(navPath.href)}
|
||||
>
|
||||
<div
|
||||
style="background-image: url({MenuInventoryBar.src}); background-position: -{i * 3.5}rem 0;"
|
||||
class="block w-full h-full bg-no-repeat bg-horizontal-sprite pixelated"
|
||||
></div>
|
||||
<div class="absolute flex justify-center items-center w-full h-full">
|
||||
<img class="w-1/2 h-1/2 pixelated" src={navPath.sprite} alt={navPath.name} />
|
||||
</div>
|
||||
<img
|
||||
class="transition-opacity delay-50 group-hover/menu-item:opacity-100 absolute w-full h-full pixelated scale-110 z-10"
|
||||
class:opacity-0={!navPath.active}
|
||||
src={MenuSelectedFrame.src}
|
||||
alt="menu hover"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media (max-height: 450px) {
|
||||
.main-menu {
|
||||
flex-direction: row;
|
||||
}
|
||||
.main-menu > div {
|
||||
padding: 0.25rem 0 0 0.5rem;
|
||||
}
|
||||
.main-menu li {
|
||||
display: inline-block;
|
||||
|
||||
&::before {
|
||||
transform-origin: 0;
|
||||
transform: rotate(-90deg);
|
||||
margin-bottom: -0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pixelated {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.bg-horizontal-sprite {
|
||||
background-size: auto 100%;
|
||||
}
|
||||
</style>
|
||||
69
src/app/website/index/Countdown.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let { start, end }: { start?: number; end: number } = $props();
|
||||
|
||||
let title = `Spielstart ist am ${new Date(import.meta.env.PUBLIC_START_DATE).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})} Uhr`;
|
||||
|
||||
function getUntil(): [number, number, number, number] {
|
||||
let diff = (end - (start || Date.now())) / 1000;
|
||||
|
||||
return [
|
||||
Math.floor(diff / (60 * 60 * 24)),
|
||||
Math.floor((diff % (60 * 60 * 24)) / (60 * 60)),
|
||||
Math.floor((diff % (60 * 60)) / 60),
|
||||
Math.floor(diff % 60)
|
||||
];
|
||||
}
|
||||
|
||||
let [days, hours, minutes, seconds] = $state(getUntil());
|
||||
let intervalId = setInterval(() => {
|
||||
[days, hours, minutes, seconds] = getUntil();
|
||||
if (start) start += 1000;
|
||||
}, 1000);
|
||||
|
||||
onDestroy(() => clearInterval(intervalId));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class:hidden={days + hours + minutes + seconds < 0}
|
||||
class="grid grid-flow-col gap-5 text-center auto-cols-max text-white"
|
||||
>
|
||||
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
|
||||
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||
<span class="m-auto" style="--value:{days};"></span>
|
||||
</span>
|
||||
Tage
|
||||
</div>
|
||||
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
|
||||
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||
<span class="m-auto" style="--value:{hours};"></span>
|
||||
</span>
|
||||
Stunden
|
||||
</div>
|
||||
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
|
||||
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||
<span class="m-auto" style="--value:{minutes};"></span>
|
||||
</span>
|
||||
Minuten
|
||||
</div>
|
||||
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
|
||||
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||
<span class="m-auto" style="--value:{seconds};"></span>
|
||||
</span>
|
||||
Sekunden
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Set a custom content for the countdown before selector as it only supports numbers up to 99 */
|
||||
.countdown > ::before {
|
||||
content: '00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A 100\A 101\A 102\A 103\A 104\A 105\A 106\A 107\A 108\A 109\A 110\A 111\A 112\A 113\A 114\A 115\A 116\A 117\A 118\A 119\A 120\A 121\A 122\A 123\A 124\A 125\A 126\A 127\A 128\A 129\A 130\A 131\A 132\A 133\A 134\A 135\A 136\A 137\A 138\A 139\A 140\A 141\A 142\A 143\A 144\A 145\A 146\A 147\A 148\A 149\A 150\A 151\A 152\A 153\A 154\A 155\A 156\A 157\A 158\A 159\A 160\A 161\A 162\A 163\A 164\A 165\A 166\A 167\A 168\A 169\A 170\A 171\A 172\A 173\A 174\A 175\A 176\A 177\A 178\A 179\A 180\A 181\A 182\A 183\A 184\A 185\A 186\A 187\A 188\A 189\A 190\A 191\A 192\A 193\A 194\A 195\A 196\A 197\A 198\A 199\A 200\A 201\A 202\A 203\A 204\A 205\A 206\A 207\A 208\A 209\A 210\A 211\A 212\A 213\A 214\A 215\A 216\A 217\A 218\A 219\A 220\A 221\A 222\A 223\A 224\A 225\A 226\A 227\A 228\A 229\A 230\A 231\A 232\A 233\A 234\A 235\A 236\A 237\A 238\A 239\A 240\A 241\A 242\A 243\A 244\A 245\A 246\A 247\A 248\A 249\A 250\A 251\A 252\A 253\A 254\A 255\A 256\A 257\A 258\A 259\A 260\A 261\A 262\A 263\A 264\A 265\A 266\A 267\A 268\A 269\A 270\A 271\A 272\A 273\A 274\A 275\A 276\A 277\A 278\A 279\A 280\A 281\A 282\A 283\A 284\A 285\A 286\A 287\A 288\A 289\A 290\A 291\A 292\A 293\A 294\A 295\A 296\A 297\A 298\A 299\A 300\A 301\A 302\A 303\A 304\A 305\A 306\A 307\A 308\A 309\A 310\A 311\A 312\A 313\A 314\A 315\A 316\A 317\A 318\A 319\A 320\A 321\A 322\A 323\A 324\A 325\A 326\A 327\A 328\A 329\A 330\A 331\A 332\A 333\A 334\A 335\A 336\A 337\A 338\A 339\A 340\A 341\A 342\A 343\A 344\A 345\A 346\A 347\A 348\A 349\A 350\A 351\A 352\A 353\A 354\A 355\A 356\A 357\A 358\A 359\A 360\A 361\A 362\A 363\A 364\A';
|
||||
}
|
||||
</style>
|
||||
70
src/app/website/report/AdversarySearch.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import Search from '@components/admin/search/Search.svelte';
|
||||
import { actions } from 'astro:actions';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
import Select from '@components/input/Select.svelte';
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
adversary: string | null;
|
||||
}
|
||||
|
||||
// input
|
||||
const { adversary }: Props = $props();
|
||||
|
||||
// states
|
||||
let reportTarget = $state<'player' | 'unknown'>('unknown');
|
||||
let adversaryUsername = $state(adversary);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
dispatchAdversaryInput(adversaryUsername);
|
||||
});
|
||||
|
||||
// functions
|
||||
async function getSuggestions(query: string, _limit: number) {
|
||||
const { data, error } = await actions.report.usernames({ username: query });
|
||||
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.usernames.map((u) => ({ name: u, value: u }));
|
||||
}
|
||||
|
||||
function dispatchAdversaryInput(username: string | null) {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('adversaryInput', {
|
||||
detail: {
|
||||
adversaryUsername: username
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center gap-4">
|
||||
<Select
|
||||
bind:value={
|
||||
() => reportTarget,
|
||||
(v) => {
|
||||
dispatchAdversaryInput(v === 'player' ? adversaryUsername : null);
|
||||
reportTarget = v;
|
||||
}
|
||||
}
|
||||
values={{
|
||||
player: 'Ich möchte einen bestimmten Spieler reporten',
|
||||
unknown: 'Ich möchte einen unbekannten Spieler reporten'
|
||||
}}
|
||||
dynamicWidth
|
||||
/>
|
||||
|
||||
{#if reportTarget === 'player'}
|
||||
<Search
|
||||
value={adversaryUsername}
|
||||
requestSuggestions={getSuggestions}
|
||||
onSubmit={(value) => (adversaryUsername = value != null ? value.value : null)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
178
src/app/website/report/Dropzone.svelte
Normal file
@@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import { popupState } from '@components/popup/Popup.ts';
|
||||
import { allowedImageTypes, allowedVideoTypes } from '@util/media.ts';
|
||||
|
||||
// bindings
|
||||
let hiddenFileInputElem: HTMLInputElement;
|
||||
let previewDialogElem: HTMLDialogElement;
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
maxFilesBytes: number;
|
||||
}
|
||||
|
||||
interface UploadFile {
|
||||
dataUrl: string;
|
||||
name: string;
|
||||
type: 'image' | 'video';
|
||||
size: number;
|
||||
file: File;
|
||||
}
|
||||
|
||||
// inputs
|
||||
const { maxFilesBytes }: Props = $props();
|
||||
|
||||
// states
|
||||
let uploadFiles = $state<UploadFile[]>([]);
|
||||
let previewUploadFile = $state<UploadFile | null>(null);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('dropzoneInput', {
|
||||
detail: {
|
||||
files: uploadFiles.map((uf) => uf.file)
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (previewUploadFile) previewDialogElem.show();
|
||||
});
|
||||
|
||||
// functions
|
||||
function addFiles(files: FileList) {
|
||||
for (const file of files) {
|
||||
if (uploadFiles.find((uf) => uf.name === file.name && uf.size === file.size) !== undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let type: 'image' | 'video';
|
||||
if (allowedImageTypes.find((mime) => mime === file.type) !== undefined) {
|
||||
type = 'image';
|
||||
} else if (allowedVideoTypes.find((mime) => mime === file.type) !== undefined) {
|
||||
type = 'video';
|
||||
} else {
|
||||
$popupState = {
|
||||
type: 'error',
|
||||
title: 'Ungültige Datei',
|
||||
message:
|
||||
'Das Dateiformat wird nicht unterstützt. Nur Bilder (.png, .jpg, .jpeg, .webp, .avif) und Videos (.mp4, .webm) sind gültig'
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (uploadFiles.reduce((prev, curr) => prev + curr.size, 0) + file.size > maxFilesBytes) {
|
||||
$popupState = {
|
||||
type: 'error',
|
||||
title: 'Datei zu groß',
|
||||
message: `Die Dateien dürfen insgesamt nur ${bytesToHumanReadable(maxFilesBytes)} groß sein. Fall deine Anhänge größer sind, lade sie bitte auf einem externen Filehoster hoch (z.B. file.io, Google Drive, ...) und füge den Link zum teilen der Datei(en) zu den Report Details hinzu`
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
uploadFiles.push({
|
||||
dataUrl: reader.result as string,
|
||||
name: file.name,
|
||||
type: type,
|
||||
size: file.size,
|
||||
file: file
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(file: UploadFile) {
|
||||
const index = uploadFiles.findIndex((uf) => uf.size === file.size && uf.name === file.name);
|
||||
const uploadFile = uploadFiles.splice(index, 1).pop()!;
|
||||
URL.revokeObjectURL(uploadFile.dataUrl);
|
||||
}
|
||||
|
||||
function bytesToHumanReadable(bytes: number) {
|
||||
const sizes = ['B', 'KB', 'MB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const size = parseFloat((bytes / Math.pow(1024, i)).toFixed(2));
|
||||
|
||||
return `${size} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
// callbacks
|
||||
function onAddFiles(e: Event & { currentTarget: EventTarget & HTMLInputElement }) {
|
||||
e.preventDefault();
|
||||
if ((e.target as typeof e.currentTarget).files) addFiles((e.target as typeof e.currentTarget).files!);
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) addFiles(e.dataTransfer.files);
|
||||
}
|
||||
|
||||
function onFileRemove(file: UploadFile) {
|
||||
removeFile(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Anhänge</legend>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="h-12 rounded border border-dashed flex cursor-pointer"
|
||||
class:h-26={uploadFiles.length > 0}
|
||||
dropzone="copy"
|
||||
onclick={() => hiddenFileInputElem.click()}
|
||||
ondrop={onDrop}
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
>
|
||||
{#if uploadFiles.length === 0}
|
||||
<div class="flex justify-center items-center w-full h-full">
|
||||
<p>Hier Dateien droppen oder klicken um sie hochzuladen</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#each uploadFiles as uploadFile (uploadFile.name)}
|
||||
<div
|
||||
class="relative flex flex-col items-center w-22 h-22 m-1 cursor-default"
|
||||
onclick={(e) => e.stopImmediatePropagation()}
|
||||
>
|
||||
<div class="cursor-zoom-in" onclick={() => (previewUploadFile = uploadFile)}>
|
||||
{#if uploadFile.type === 'image'}
|
||||
<img src={uploadFile.dataUrl} alt={uploadFile.name} class="w-16 h-16" />
|
||||
{:else if uploadFile.type === 'video'}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={uploadFile.dataUrl} class="w-16 h-16"></video>
|
||||
{/if}
|
||||
</div>
|
||||
<span>{bytesToHumanReadable(uploadFile.size)}</span>
|
||||
<button class="cursor-pointer" onclick={() => onFileRemove(uploadFile)}>Datei entfernen</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
<input
|
||||
bind:this={hiddenFileInputElem}
|
||||
type="file"
|
||||
multiple
|
||||
accept={[...allowedImageTypes, ...allowedVideoTypes].join(', ')}
|
||||
class="hidden absolute top-0 left-0 h-0 w-0"
|
||||
onchange={onAddFiles}
|
||||
/>
|
||||
|
||||
<dialog class="modal" bind:this={previewDialogElem} onclose={() => setTimeout(() => (previewUploadFile = null), 300)}>
|
||||
<div class="modal-box">
|
||||
{#if previewUploadFile?.type === 'image'}
|
||||
<img src={previewUploadFile.dataUrl} alt={previewUploadFile.name} />
|
||||
{:else if previewUploadFile?.type === 'video'}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={previewUploadFile.dataUrl} 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>
|
||||
96
src/app/website/signup/RegisteredPopup.svelte
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { registeredPopupState } from '@app/website/signup/RegisteredPopup.ts';
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
discordLink: string;
|
||||
paypalLink: string;
|
||||
teamspeakLink: string;
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
let { discordLink, paypalLink, teamspeakLink, startDate }: Props = $props();
|
||||
|
||||
let skin: string | null = $state(null);
|
||||
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
const cancel = registeredPopupState.subscribe(async (value) => {
|
||||
if (!value) return;
|
||||
|
||||
modal.show();
|
||||
|
||||
const skinview3d = await import('skinview3d');
|
||||
const skinViewer = new skinview3d.SkinViewer({
|
||||
width: 200,
|
||||
height: 300,
|
||||
renderPaused: true
|
||||
});
|
||||
|
||||
skinViewer.camera.rotation.x = -0.62;
|
||||
skinViewer.camera.rotation.y = 0.534;
|
||||
skinViewer.camera.rotation.z = 0.348;
|
||||
skinViewer.camera.position.x = 30.5;
|
||||
skinViewer.camera.position.y = 22.0;
|
||||
skinViewer.camera.position.z = 42.0;
|
||||
|
||||
await skinViewer.loadSkin(`https://mc-heads.net/skin/${value.username}`);
|
||||
skinViewer.render();
|
||||
skin = skinViewer.canvas.toDataURL();
|
||||
|
||||
skinViewer.dispose();
|
||||
});
|
||||
|
||||
onDestroy(cancel);
|
||||
</script>
|
||||
|
||||
<dialog class="modal" bind:this={modal} onclose={() => ($registeredPopupState = null)}>
|
||||
<form method="dialog" class="modal-box xl:w-5/12 max-w-10/12 z-10">
|
||||
<h1 class="text-center text-xl sm:text-3xl mb-8">Registrierung erfolgreich</h1>
|
||||
<p>
|
||||
<b>Du hast Dich erfolgreich für Craftattack 8 registriert</b>. Spielstart ist am
|
||||
<span class="underline"
|
||||
>{new Date(startDate).toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })}</span
|
||||
>
|
||||
um
|
||||
<span class="underline"
|
||||
>{new Date(startDate).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr</span
|
||||
>.
|
||||
</p>
|
||||
<p class="text-center">Alle weiteren Informationen werden in der Whatsapp-Gruppe bekannt gegeben.</p>
|
||||
<p class="mt-2">
|
||||
Falls du uns unterstützen möchtest, kannst du dies ganz einfach über
|
||||
<a class="link" href={paypalLink} target="_blank">PayPal</a>
|
||||
tun. Antworten auf häufig gestellte Fragen findest du in unserer
|
||||
<a class="link" href="faq" target="_blank">FAQ</a>. Außerdem freuen wir uns, dich auf unserem
|
||||
<a class="link" href={teamspeakLink} target="_blank">TeamSpeak</a>
|
||||
oder in unserem
|
||||
<a class="link" href={discordLink} target="_blank">Discord</a>
|
||||
begrüßen zu dürfen!
|
||||
</p>
|
||||
<div class="divider"></div>
|
||||
<div class="flex justify-around mt-2 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 w-full sm:w-fit gap-x-4 gap-y-2">
|
||||
<Input type="text" value={$registeredPopupState?.firstname} label="Vorname" disabled />
|
||||
<Input type="text" value={$registeredPopupState?.lastname} label="Nachname" disabled />
|
||||
<Input type="date" value={$registeredPopupState?.birthday} label="Geburtstag" disabled />
|
||||
<Input type="tel" value={$registeredPopupState?.phone} label="Telefonnummer" disabled />
|
||||
<Input type="text" value={$registeredPopupState?.username} label="Spielername" disabled />
|
||||
<Input type="text" value={$registeredPopupState?.edition} label="Edition" disabled />
|
||||
</div>
|
||||
<div class="relative hidden md:flex justify-center w-[200px] my-4">
|
||||
{#if skin}
|
||||
<img class="absolute" src={skin} alt="" />
|
||||
{:else}
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="flex justify-center gap-8">
|
||||
<button class="btn">Weitere Person anmelden</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="absolute w-full h-full bg-black/50"></div>
|
||||
</dialog>
|
||||
10
src/app/website/signup/RegisteredPopup.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const registeredPopupState = atom<{
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
birthday: string;
|
||||
phone: string;
|
||||
username: string;
|
||||
edition: string;
|
||||
} | null>(null);
|
||||
98
src/app/website/signup/RulesPopup.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { rulesPopupState, rulesPopupRead } from './RulesPopup.ts';
|
||||
import { rules } from '../../../rules.ts';
|
||||
import { popupState } from '@components/popup/Popup.ts';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
const modalTimeoutSeconds = 30;
|
||||
|
||||
let modalElem: HTMLDialogElement;
|
||||
|
||||
let modalTimer = $state<ReturnType<typeof setInterval> | null>(null);
|
||||
let modalSecondsOpen = $state(import.meta.env.PROD ? 0 : modalTimeoutSeconds);
|
||||
|
||||
const cancel = rulesPopupState.subscribe((value) => {
|
||||
if (value == 'open') {
|
||||
modalElem.show();
|
||||
modalTimer = setInterval(() => modalSecondsOpen++, 1000);
|
||||
} else if (value == 'closed') {
|
||||
clearInterval(modalTimer!);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(cancel);
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
class="modal"
|
||||
onclose={() => {
|
||||
if ($rulesPopupState !== 'accepted') $rulesPopupState = 'closed';
|
||||
}}
|
||||
bind:this={modalElem}
|
||||
>
|
||||
<form method="dialog" class="modal-box flex flex-col max-h-[90%] max-w-[95%] md:max-w-[90%] lg:max-w-[75%]">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
<div class="overflow-auto mt-5">
|
||||
<div class="mb-4">
|
||||
<div class="collapse collapse-arrow">
|
||||
<input type="checkbox" autocomplete="off" checked />
|
||||
<div class="collapse-title">
|
||||
<p>0. Vorwort</p>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<p>{rules.header}</p>
|
||||
<p class="mt-1 text-[.75rem]">{rules.footer}</p>
|
||||
</div>
|
||||
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600"></span>
|
||||
</div>
|
||||
{#each rules.sections as section, i (section.title)}
|
||||
<div class="collapse collapse-arrow">
|
||||
<input type="checkbox" autocomplete="off" />
|
||||
<div class="collapse-title">
|
||||
<p>{i + 1}. {section.title}</p>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html section.content}
|
||||
</div>
|
||||
</div>
|
||||
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600"></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative w-min"
|
||||
title={modalSecondsOpen < modalTimeoutSeconds
|
||||
? `Die Regeln können in ${Math.max(modalTimeoutSeconds - modalSecondsOpen, 0)} Sekunden akzeptiert werden`
|
||||
: ''}
|
||||
>
|
||||
<div class="absolute top-0 left-0 h-full w-full overflow-hidden rounded-lg">
|
||||
<div
|
||||
style="width: {Math.min((modalSecondsOpen / modalTimeoutSeconds) * 100, 100)}%"
|
||||
class="h-full bg-neutral"
|
||||
></div>
|
||||
</div>
|
||||
<button
|
||||
class="btn bg-transparent z-[1] relative"
|
||||
class:btn-active={modalSecondsOpen < modalTimeoutSeconds}
|
||||
class:cursor-default={modalSecondsOpen < modalTimeoutSeconds}
|
||||
onclick={(e) => {
|
||||
if (modalSecondsOpen < modalTimeoutSeconds) {
|
||||
e.preventDefault();
|
||||
$popupState = {
|
||||
type: 'info',
|
||||
title: 'Regeln',
|
||||
message: 'Bitte lies die Regeln aufmerksam durch. Du kannst erst in einigen Sekunden fortfahren.'
|
||||
};
|
||||
return;
|
||||
}
|
||||
$rulesPopupRead = true;
|
||||
$rulesPopupState = 'accepted';
|
||||
}}>Akzeptieren</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||
<button class="!cursor-default">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
4
src/app/website/signup/RulesPopup.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const rulesPopupState = atom<'open' | 'closed' | 'accepted'>('closed');
|
||||
export const rulesPopupRead = atom(false);
|
||||
32
src/assets/admin_layout.css
Normal file
@@ -0,0 +1,32 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin 'daisyui' {
|
||||
themes:
|
||||
dark --default,
|
||||
lofi;
|
||||
logs: false;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Geist;
|
||||
src: url('fonts/Geist.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: GeistMono;
|
||||
src: url('fonts/GeistMono.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Minecraft;
|
||||
src: url('./fonts/MinecraftRegular.otf') format('opentype');
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-geist: 'Geist', sans-serif;
|
||||
--font-geist-mono: 'GeistMono', monospace;
|
||||
--font-minecraft: 'Minecraft', sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-geist-mono;
|
||||
}
|
||||
BIN
src/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src/assets/fonts/Geist.ttf
Normal file
BIN
src/assets/fonts/GeistMono.ttf
Normal file
BIN
src/assets/fonts/MinecraftRegular.otf
Normal file
BIN
src/assets/img/background.webp
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
src/assets/img/craftattack.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
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 |
BIN
src/assets/img/logo-512.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src/assets/img/menu-admins.webp
Normal file
|
After Width: | Height: | Size: 156 B |
BIN
src/assets/img/menu-button.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/img/menu-faq.webp
Normal file
|
After Width: | Height: | Size: 252 B |
BIN
src/assets/img/menu-feedback.webp
Normal file
|
After Width: | Height: | Size: 128 B |
BIN
src/assets/img/menu-home.webp
Normal file
|
After Width: | Height: | Size: 180 B |
BIN
src/assets/img/menu-inventory-bar.webp
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src/assets/img/menu-rules.webp
Normal file
|
After Width: | Height: | Size: 198 B |
BIN
src/assets/img/menu-selected-frame.webp
Normal file
|
After Width: | Height: | Size: 322 B |
BIN
src/assets/img/menu-signup.webp
Normal file
|
After Width: | Height: | Size: 216 B |
BIN
src/assets/img/skeleton.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/img/steve.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
41
src/assets/website_layout.css
Normal file
@@ -0,0 +1,41 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin 'daisyui' {
|
||||
themes:
|
||||
dark --default,
|
||||
lofi;
|
||||
logs: false;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Geist;
|
||||
src: url('fonts/Geist.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: GeistMono;
|
||||
src: url('fonts/GeistMono.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Minecraft;
|
||||
src: url('./fonts/MinecraftRegular.otf') format('opentype');
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-geist: 'Geist', sans-serif;
|
||||
--font-geist-mono: 'GeistMono', monospace;
|
||||
--font-minecraft: 'Minecraft', sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-geist;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-minecraft;
|
||||
}
|
||||
254
src/components/admin/popup/CrudPopup.svelte
Normal file
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
import Textarea from '@components/input/Textarea.svelte';
|
||||
import Password from '@components/input/Password.svelte';
|
||||
import BitBadge from '@components/input/BitBadge.svelte';
|
||||
import UserSearch from '@components/admin/search/UserSearch.svelte';
|
||||
import Checkbox from '@components/input/Checkbox.svelte';
|
||||
import type { user } from '@db/schema/user.ts';
|
||||
|
||||
// html bindings
|
||||
let modal: HTMLDialogElement;
|
||||
let modalForm: HTMLFormElement;
|
||||
|
||||
// types
|
||||
interface Props<T> {
|
||||
texts: {
|
||||
title: string;
|
||||
submitButtonTitle: string;
|
||||
confirmPopupTitle: string;
|
||||
confirmPopupMessage: string;
|
||||
};
|
||||
|
||||
target: T | null;
|
||||
keys: Key<any>[][];
|
||||
|
||||
onSubmit: (target: T) => void;
|
||||
onClose?: () => void;
|
||||
|
||||
open: boolean;
|
||||
}
|
||||
type Key<T extends KeyInputType> = {
|
||||
key: string;
|
||||
type: T | null;
|
||||
label: string;
|
||||
default?: any;
|
||||
options?: KeyInputTypeOptions<T>;
|
||||
};
|
||||
type KeyInputType =
|
||||
| 'bit-badge'
|
||||
| 'checkbox'
|
||||
| 'color'
|
||||
| 'date'
|
||||
| 'datetime-local'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'tel'
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'user-search';
|
||||
type KeyInputTypeOptionsConvert<T extends KeyInputType> = {
|
||||
['bit-badge']: string[];
|
||||
['checkbox']: boolean;
|
||||
['color']: string;
|
||||
['date']: string;
|
||||
['datetime-local']: string;
|
||||
['number']: number;
|
||||
['password']: string;
|
||||
['tel']: string;
|
||||
['text']: string;
|
||||
['textarea']: string;
|
||||
['user-search']: typeof user.$inferSelect;
|
||||
}[T];
|
||||
type KeyInputTypeOptions<T extends KeyInputType, V = KeyInputTypeOptionsConvert<T>> = {
|
||||
['bit-badge']: {
|
||||
available: Record<number, string>;
|
||||
};
|
||||
['checkbox']: {};
|
||||
['color']: {};
|
||||
['date']: {};
|
||||
['datetime-local']: {};
|
||||
['number']: {};
|
||||
['password']: {};
|
||||
['tel']: {};
|
||||
['text']: {};
|
||||
['textarea']: {
|
||||
rows?: boolean;
|
||||
};
|
||||
['user-search']: {
|
||||
mustMatch?: boolean;
|
||||
};
|
||||
}[T] & {
|
||||
convert?: (value: KeyInputTypeOptionsConvert<T>) => V;
|
||||
validate?: (value: V) => boolean;
|
||||
required?: boolean;
|
||||
dynamicWidth?: boolean;
|
||||
};
|
||||
|
||||
// input
|
||||
let { texts, target, keys, onSubmit, onClose, open = $bindable() }: Props<any> = $props();
|
||||
|
||||
onInit();
|
||||
|
||||
// state
|
||||
let submitEnabled = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
onInit();
|
||||
modal.show();
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
if (target == null) onInit();
|
||||
updateSubmitEnabled();
|
||||
});
|
||||
|
||||
// functions
|
||||
function onInit() {
|
||||
if (target == null) target = {};
|
||||
|
||||
for (const key of keys) {
|
||||
for (const k of key) {
|
||||
if (k.default !== undefined && target[k.key] === undefined)
|
||||
target[k.key] = k.options?.convert ? k.options.convert(k.default) : k.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateSubmitEnabled() {
|
||||
submitEnabled = false;
|
||||
for (const key of keys) {
|
||||
for (const k of key) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
submitEnabled = true;
|
||||
}
|
||||
|
||||
// callbacks
|
||||
function onBindChange(key: string, value: any, options?: KeyInputTypeOptions<any>) {
|
||||
target[key] = options?.convert ? options.convert(value) : value;
|
||||
|
||||
updateSubmitEnabled();
|
||||
}
|
||||
|
||||
async function onSaveButtonClick(e: Event) {
|
||||
e.preventDefault();
|
||||
$confirmPopupState = {
|
||||
title: texts.confirmPopupTitle,
|
||||
message: texts.confirmPopupMessage,
|
||||
onConfirm: () => {
|
||||
modalForm.submit();
|
||||
onSubmit(target);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function onCancelButtonClick(e: Event) {
|
||||
e.preventDefault();
|
||||
modalForm.submit();
|
||||
}
|
||||
|
||||
function onModalClose() {
|
||||
setTimeout(() => {
|
||||
open = false;
|
||||
target = null;
|
||||
modalForm.reset();
|
||||
onClose?.();
|
||||
}, 300);
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog class="modal" bind:this={modal} onclose={onModalClose}>
|
||||
<form method="dialog" class="modal-box overflow-visible" bind:this={modalForm}>
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onCancelButtonClick}>✕</button>
|
||||
<div class="space-y-5">
|
||||
<h3 class="text-xl font-geist font-bold">{texts.title}</h3>
|
||||
<div class="w-full flex flex-col">
|
||||
{#each keys as key (key)}
|
||||
<div
|
||||
class="grid grid-flow-col gap-4"
|
||||
class:grid-cols-1={key.length === 1}
|
||||
class:grid-cols-2={key.length === 2}
|
||||
>
|
||||
{#each key as k (k)}
|
||||
{#if k.type === 'color' || k.type === 'date' || k.type === 'datetime-local' || k.type === 'number' || k.type === 'tel' || k.type === 'text'}
|
||||
<Input
|
||||
type={k.type}
|
||||
bind:value={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
dynamicWidth={k.options?.dynamicWidth}
|
||||
/>
|
||||
{:else if k.type === 'textarea'}
|
||||
<Textarea
|
||||
bind:value={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
rows={k.options?.rows}
|
||||
dynamicWidth={k.options?.dynamicWidth}
|
||||
/>
|
||||
{:else if k.type === 'checkbox'}
|
||||
<Checkbox
|
||||
bind:checked={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
/>
|
||||
{:else if k.type === 'user-search'}
|
||||
<UserSearch
|
||||
bind:value={
|
||||
() =>
|
||||
target[k.key] ? (target[k.key]['username'] ?? null) : k.default ? k.default['username'] : null,
|
||||
(v) =>
|
||||
k.options.mustMatch
|
||||
? onBindChange(k.key, { id: null, username: null }, k.options)
|
||||
: onBindChange(k.key, { id: null, username: v }, k.options)
|
||||
}
|
||||
onSubmit={(user) => onBindChange(k.key, user, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
mustMatch={k.options?.mustMatch}
|
||||
/>
|
||||
{:else if k.type === 'password'}
|
||||
<Password
|
||||
bind:value={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
/>
|
||||
{:else if k.type === 'bit-badge'}
|
||||
<BitBadge
|
||||
available={k.options?.available}
|
||||
bind:value={
|
||||
() => (target[k.key] ? (target[k.key]['name'] ?? target[k.key]) : k.default),
|
||||
(v) => onBindChange(k.key, v, k.options)
|
||||
}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-success"
|
||||
class:disabled={!submitEnabled}
|
||||
disabled={!submitEnabled}
|
||||
onclick={onSaveButtonClick}>{texts.submitButtonTitle}</button
|
||||
>
|
||||
<button class="btn btn-error" type="button" onclick={onCancelButtonClick}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||
<button class="!cursor-default">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
99
src/components/admin/search/Search.svelte
Normal file
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
// types
|
||||
interface Props {
|
||||
id?: string;
|
||||
value?: string | null;
|
||||
label?: string;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
mustMatch?: boolean;
|
||||
|
||||
requestSuggestions: (query: string, limit: number) => Promise<{ name: string; value: string }[]>;
|
||||
|
||||
onSubmit?: (value: { name: string; value: string } | null) => void;
|
||||
}
|
||||
|
||||
// html bindings
|
||||
let container: HTMLDivElement;
|
||||
|
||||
// inputs
|
||||
let { id, value = $bindable(), label, readonly, required, mustMatch, requestSuggestions, onSubmit }: Props = $props();
|
||||
|
||||
// states
|
||||
let inputValue = $derived(value);
|
||||
let suggestions = $state<{ name: string; value: string }[]>([]);
|
||||
let matched = $state(false);
|
||||
|
||||
// callbacks
|
||||
async function onBodyMouseDown(e: MouseEvent) {
|
||||
if (!container.contains(e.target as Node)) suggestions = [];
|
||||
}
|
||||
|
||||
async function onSearchInput() {
|
||||
if (readonly) return;
|
||||
|
||||
suggestions = await requestSuggestions(inputValue ?? '', 5);
|
||||
|
||||
let suggestion = suggestions.find((s) => s.name === inputValue);
|
||||
if (suggestion != null) {
|
||||
inputValue = value = suggestion.name;
|
||||
matched = true;
|
||||
onSubmit?.(suggestion);
|
||||
} else if (!mustMatch) {
|
||||
value = inputValue;
|
||||
matched = false;
|
||||
} else {
|
||||
value = null;
|
||||
matched = false;
|
||||
onSubmit?.(null);
|
||||
}
|
||||
}
|
||||
|
||||
function onSuggestionClick(name: string) {
|
||||
const suggestion = suggestions.find((s) => s.name === name)!;
|
||||
|
||||
inputValue = value = suggestion.name;
|
||||
suggestions = [];
|
||||
onSubmit?.(suggestion);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:body onmousedown={onBodyMouseDown} />
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">
|
||||
<span>
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-red-700">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
</legend>
|
||||
<div class="relative" bind:this={container}>
|
||||
<input
|
||||
{id}
|
||||
{readonly}
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
class="input"
|
||||
bind:value={inputValue}
|
||||
oninput={() => onSearchInput()}
|
||||
onfocusin={() => onSearchInput()}
|
||||
pattern={mustMatch && matched ? `^(${suggestions.map((s) => s.name).join('|')})$` : undefined}
|
||||
/>
|
||||
{#if suggestions.length > 0}
|
||||
<ul class="absolute bg-base-200 w-full z-20 menu menu-sm rounded-box">
|
||||
{#each suggestions as suggestion (suggestion)}
|
||||
<li class="w-full text-left">
|
||||
<button
|
||||
class="block w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
title={suggestion.name}
|
||||
onclick={() => onSuggestionClick(suggestion.name)}>{suggestion.name}</button
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="fieldset-label"></p>
|
||||
</fieldset>
|
||||
48
src/components/admin/search/UserSearch.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
import Search from '@components/admin/search/Search.svelte';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
|
||||
// types
|
||||
type Users = Exclude<ActionReturnType<typeof actions.user.users>['data'], undefined>['users'];
|
||||
type User = Users[0];
|
||||
interface Props {
|
||||
id?: string;
|
||||
value?: string | null;
|
||||
label?: string;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
mustMatch?: boolean;
|
||||
|
||||
onSubmit?: (user: User | null) => void;
|
||||
}
|
||||
|
||||
// inputs
|
||||
let { id, value = $bindable(), label, readonly, required, mustMatch, onSubmit }: Props = $props();
|
||||
|
||||
// functions
|
||||
async function getSuggestions(query: string, limit: number) {
|
||||
const { data, error } = await actions.user.users({
|
||||
username: query,
|
||||
limit: limit
|
||||
});
|
||||
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.users.map((user) => ({ name: user.username, value: user }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Search
|
||||
{id}
|
||||
bind:value
|
||||
{label}
|
||||
{readonly}
|
||||
{required}
|
||||
{mustMatch}
|
||||
requestSuggestions={async (username) => getSuggestions(username, 5)}
|
||||
onSubmit={async (suggestion) => onSubmit?.(suggestion != null ? suggestion.value : null)}
|
||||
/>
|
||||
81
src/components/admin/table/DataTable.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from 'svelte/store';
|
||||
import SortableTr from '@components/admin/table/SortableTr.svelte';
|
||||
import SortableTh from '@components/admin/table/SortableTh.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getObjectEntryByKey } from '@util/objects.ts';
|
||||
|
||||
// types
|
||||
interface Props<T> {
|
||||
data: Writable<T[]>;
|
||||
|
||||
count?: boolean;
|
||||
keys: {
|
||||
key: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
sortable?: boolean;
|
||||
transform?: Snippet<[T]>;
|
||||
}[];
|
||||
|
||||
onClick?: (t: T) => void;
|
||||
onEdit?: (t: T) => void;
|
||||
onDelete?: (t: T) => void;
|
||||
}
|
||||
|
||||
// input
|
||||
let { data, count, keys, onClick, onEdit, onDelete }: Props<any> = $props();
|
||||
</script>
|
||||
|
||||
<div class="h-screen overflow-x-auto">
|
||||
<table class="table table-pin-rows">
|
||||
<thead>
|
||||
<SortableTr {data}>
|
||||
{#if count}
|
||||
<SortableTh style="width: 5%">#</SortableTh>
|
||||
{/if}
|
||||
{#each keys as key (key.key)}
|
||||
<SortableTh style={key.width ? `width: ${key.width}%` : undefined} key={key.sortable ? key.key : undefined}
|
||||
>{key.label}</SortableTh
|
||||
>
|
||||
{/each}
|
||||
{#if onEdit || onDelete}
|
||||
<SortableTh style="width: 5%"></SortableTh>
|
||||
{/if}
|
||||
</SortableTr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $data as d, i (d)}
|
||||
<tr class="hover:bg-base-200" onclick={() => onClick?.(d)}>
|
||||
{#if count}
|
||||
<td>{i + 1}</td>
|
||||
{/if}
|
||||
{#each keys as key (key.key)}
|
||||
<td>
|
||||
{#if key.transform}
|
||||
{@render key.transform(getObjectEntryByKey(key.key, d))}
|
||||
{:else}
|
||||
{getObjectEntryByKey(key.key, d)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
{#if onEdit || onDelete}
|
||||
<td>
|
||||
{#if onEdit}
|
||||
<button class="cursor-pointer" onclick={() => onEdit(d)}>
|
||||
<Icon icon="heroicons:pencil-square" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if onDelete}
|
||||
<button class="cursor-pointer" onclick={() => onDelete(d)}>
|
||||
<Icon icon="heroicons:trash" />
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
48
src/components/admin/table/SortableTh.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { getContext, type Snippet } from 'svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
key?: string;
|
||||
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
interface SortableHeaderContext {
|
||||
headerKey: Writable<string>;
|
||||
onSort: (key: string, order: 'asc' | 'desc') => void;
|
||||
}
|
||||
|
||||
// inputs
|
||||
const { key, children, ...restProps }: Props & Record<string, any> = $props();
|
||||
|
||||
let { headerKey, onSort }: SortableHeaderContext = getContext('sortableHeader');
|
||||
|
||||
let asc = $state(false);
|
||||
|
||||
// callbacks
|
||||
function onButtonClick() {
|
||||
if (key == undefined) return;
|
||||
|
||||
$headerKey = key;
|
||||
asc = !asc;
|
||||
onSort(key, asc ? 'asc' : 'desc');
|
||||
}
|
||||
</script>
|
||||
|
||||
<th {...restProps}>
|
||||
{#if key}
|
||||
<button class="flex items-center gap-1" onclick={() => onButtonClick()}>
|
||||
<span>{@render children?.()}</span>
|
||||
{#if $headerKey === key && asc}
|
||||
<Icon icon="heroicons:chevron-up-16-solid" />
|
||||
{:else}
|
||||
<Icon icon="heroicons:chevron-down-16-solid" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</th>
|
||||
48
src/components/admin/table/SortableTr.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { setContext, type Snippet } from 'svelte';
|
||||
import { type Writable, writable } from 'svelte/store';
|
||||
import { getObjectEntryByKey } from '@util/objects.ts';
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
data: Writable<{ [key: string]: any }[]>;
|
||||
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
// inputs
|
||||
const { data, children, ...restProps }: Props & Record<string, any> = $props();
|
||||
|
||||
setContext('sortableHeader', {
|
||||
headerKey: writable(null),
|
||||
onSort: onSort
|
||||
});
|
||||
|
||||
// functions
|
||||
function onSort(key: string, order: 'asc' | 'desc') {
|
||||
data.update((old) => {
|
||||
old.sort((a, b) => {
|
||||
let entryA = getObjectEntryByKey(key, a);
|
||||
let entryB = getObjectEntryByKey(key, b);
|
||||
|
||||
if (entryA === undefined || entryB === undefined) return 0;
|
||||
|
||||
if (typeof entryA === 'string') entryA = entryA.toLowerCase();
|
||||
if (typeof entryB === 'string') entryB = entryB.toLowerCase();
|
||||
|
||||
if (order === 'asc') {
|
||||
return entryA < entryB ? -1 : 1;
|
||||
} else if (order === 'desc') {
|
||||
return entryA > entryB ? -1 : 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return old;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<tr {...restProps}>
|
||||
{@render children()}
|
||||
</tr>
|
||||
49
src/components/input/BitBadge.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
available: Record<number, string>;
|
||||
value: number;
|
||||
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
// inputs
|
||||
let { available, value = $bindable(), readonly }: Props = $props();
|
||||
|
||||
// callbacks
|
||||
function onOptionSelect(e: Event) {
|
||||
const selected = Number((e.target as HTMLSelectElement).value);
|
||||
|
||||
value |= selected;
|
||||
|
||||
(e.target as HTMLSelectElement).value = '-';
|
||||
}
|
||||
|
||||
function onBadgeRemove(flag: number) {
|
||||
value &= ~flag;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if !readonly}
|
||||
<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={(value & Number(flag)) !== 0}>{badge}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<div class="flex flow flex-wrap gap-2">
|
||||
{#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>
|
||||
51
src/components/input/Checkbox.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
checked?: boolean | null;
|
||||
required?: boolean;
|
||||
validation?: {
|
||||
hint: string;
|
||||
};
|
||||
disabled?: boolean;
|
||||
|
||||
label?: Snippet | string;
|
||||
notice?: Snippet;
|
||||
}
|
||||
|
||||
let { id, checked = $bindable(), required, validation, disabled, label, notice }: Props = $props();
|
||||
</script>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
{id}
|
||||
name={id}
|
||||
bind:checked
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
class:validator={required || validation}
|
||||
required={required ? true : null}
|
||||
disabled={disabled ? true : null}
|
||||
/>
|
||||
<span class="ml-1">
|
||||
{#if typeof label === 'string'}
|
||||
<span>{label}</span>
|
||||
{:else if label}
|
||||
{@render label()}
|
||||
{/if}
|
||||
{#if required}
|
||||
<span class="text-red-700">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<p class="fieldset-label">
|
||||
{#if notice}
|
||||
{@render notice()}
|
||||
{/if}
|
||||
</p>
|
||||
{#if validation}
|
||||
<p class="validator-hint mt-0">{validation.hint}</p>
|
||||
{/if}
|
||||
</fieldset>
|
||||
75
src/components/input/Input.svelte
Normal file
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
type?: 'color' | 'date' | 'datetime-local' | 'number' | 'tel' | 'text' | 'email';
|
||||
value?: string | null;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
validation?: {
|
||||
min?: string;
|
||||
max?: string;
|
||||
pattern?: string;
|
||||
hint: string;
|
||||
};
|
||||
hidden?: boolean;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
|
||||
size?: 'sm';
|
||||
dynamicWidth?: boolean;
|
||||
|
||||
notice?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
type,
|
||||
value = $bindable(),
|
||||
label,
|
||||
required,
|
||||
validation,
|
||||
hidden,
|
||||
readonly,
|
||||
disabled,
|
||||
size,
|
||||
dynamicWidth,
|
||||
notice
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<fieldset class="fieldset" {hidden}>
|
||||
<legend class="fieldset-legend">
|
||||
<span>
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-red-700">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
</legend>
|
||||
<input
|
||||
{id}
|
||||
name={id}
|
||||
bind:value
|
||||
class="input"
|
||||
class:input-sm={size === 'sm'}
|
||||
class:validator={required || validation}
|
||||
class:w-full={dynamicWidth}
|
||||
type={type || 'text'}
|
||||
min={validation?.min}
|
||||
max={validation?.max}
|
||||
required={required ? true : null}
|
||||
pattern={validation?.pattern}
|
||||
readonly={readonly ? true : null}
|
||||
disabled={disabled ? true : null}
|
||||
/>
|
||||
<p class="fieldset-label">
|
||||
{#if notice}
|
||||
{@render notice()}
|
||||
{/if}
|
||||
</p>
|
||||
{#if validation}
|
||||
<p class="validator-hint mt-0">{validation.hint}</p>
|
||||
{/if}
|
||||
</fieldset>
|
||||
70
src/components/input/Password.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
value?: string | null;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
validation?: {
|
||||
pattern?: string;
|
||||
hint: string;
|
||||
};
|
||||
disabled?: boolean;
|
||||
|
||||
notice?: Snippet;
|
||||
}
|
||||
|
||||
let { id, value = $bindable(), label, required, validation, disabled, notice }: Props = $props();
|
||||
|
||||
let visible = $state(false);
|
||||
</script>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">
|
||||
<span>
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-red-700">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
</legend>
|
||||
<div class="relative flex items-center">
|
||||
<input
|
||||
{id}
|
||||
bind:value
|
||||
class="input pr-9"
|
||||
class:validator={required || validation}
|
||||
type={visible ? 'text' : 'password'}
|
||||
required={required ? true : null}
|
||||
pattern={validation?.pattern}
|
||||
disabled={disabled ? true : null}
|
||||
data-input-visible="false"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 cursor-pointer z-10"
|
||||
class:hidden={!visible}
|
||||
onclick={() => (visible = !visible)}
|
||||
>
|
||||
<Icon icon="heroicons:eye-16-solid" width={22} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 cursor-pointer z-10"
|
||||
class:hidden={visible}
|
||||
onclick={() => (visible = !visible)}
|
||||
>
|
||||
<Icon icon="heroicons:eye-slash-16-solid" width={22} />
|
||||
</button>
|
||||
</div>
|
||||
<p class="fieldset-label">
|
||||
{#if notice}
|
||||
{@render notice()}
|
||||
{/if}
|
||||
</p>
|
||||
{#if validation}
|
||||
<p class="validator-hint mt-0">{validation.hint}</p>
|
||||
{/if}
|
||||
</fieldset>
|
||||
73
src/components/input/Select.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
id?: string;
|
||||
value?: string | null;
|
||||
values: { [value: string]: string };
|
||||
defaultValue?: string;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
validation?: {
|
||||
hint: string;
|
||||
};
|
||||
disabled?: boolean;
|
||||
|
||||
size?: 'sm';
|
||||
dynamicWidth?: boolean;
|
||||
|
||||
notice?: Snippet;
|
||||
}
|
||||
|
||||
// inputs
|
||||
let {
|
||||
id,
|
||||
value = $bindable(),
|
||||
values,
|
||||
defaultValue,
|
||||
label,
|
||||
required,
|
||||
validation,
|
||||
disabled,
|
||||
size,
|
||||
dynamicWidth,
|
||||
notice
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">
|
||||
<span>
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-red-700">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
</legend>
|
||||
<select
|
||||
{id}
|
||||
bind:value
|
||||
class="select"
|
||||
class:select-sm={size === 'sm'}
|
||||
class:w-full={dynamicWidth}
|
||||
class:validator={required || validation}
|
||||
required={required ? true : null}
|
||||
disabled={disabled ? true : null}
|
||||
>
|
||||
{#if defaultValue != null}
|
||||
<option disabled selected>{defaultValue}</option>
|
||||
{/if}
|
||||
{#each Object.entries(values) as [v, label] (v)}
|
||||
<option value={v} selected={v === value}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="fieldset-label">
|
||||
{#if notice}
|
||||
{@render notice()}
|
||||
{/if}
|
||||
</p>
|
||||
{#if validation}
|
||||
<p class="validator-hint mt-0">{validation.hint}</p>
|
||||
{/if}
|
||||
</fieldset>
|
||||
36
src/components/input/Textarea.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
id?: string;
|
||||
value?: string | null;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
|
||||
size?: 'sm';
|
||||
dynamicWidth?: boolean;
|
||||
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
let { id, value = $bindable(), label, required, readonly, size, dynamicWidth, rows }: Props = $props();
|
||||
</script>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-red-700">*</span>
|
||||
{/if}
|
||||
</legend>
|
||||
<textarea
|
||||
{id}
|
||||
class="textarea"
|
||||
class:textarea-sm={size === 'sm'}
|
||||
class:w-full={dynamicWidth}
|
||||
class:validator={required}
|
||||
bind:value
|
||||
{required}
|
||||
{rows}
|
||||
{readonly}
|
||||
></textarea>
|
||||
</fieldset>
|
||||
34
src/components/popup/ConfirmPopup.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
// html bindings
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
// lifecycle
|
||||
const cancel = confirmPopupState.subscribe((value) => {
|
||||
if (value) modal.show();
|
||||
});
|
||||
|
||||
onDestroy(cancel);
|
||||
|
||||
// callbacks
|
||||
function onModalClose() {
|
||||
setTimeout(() => ($confirmPopupState = null), 300);
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog class="modal" bind:this={modal} onclose={onModalClose}>
|
||||
<form method="dialog" class="modal-box">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
<div>
|
||||
<h3 class="text-lg font-geist">{$confirmPopupState?.title}</h3>
|
||||
<p class="py-4 whitespace-pre-line">{$confirmPopupState?.message}</p>
|
||||
<button class="btn" onclick={() => $confirmPopupState?.onConfirm()}>Ok</button>
|
||||
<button class="btn">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||
<button class="!cursor-default">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
3
src/components/popup/ConfirmPopup.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const confirmPopupState = atom<{ title: string; message: string; onConfirm: () => void } | null>(null);
|
||||
34
src/components/popup/Popup.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { popupState } from '@components/popup/Popup.ts';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
// html bindings
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
// lifecycle
|
||||
const cancel = popupState.subscribe((value) => {
|
||||
if (value) modal.show();
|
||||
});
|
||||
|
||||
onDestroy(cancel);
|
||||
|
||||
// callbacks
|
||||
function onModalClose() {
|
||||
$popupState?.onClose?.();
|
||||
setTimeout(() => ($popupState = null), 300);
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog class="modal" bind:this={modal} onclose={onModalClose}>
|
||||
<form method="dialog" class="modal-box">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
<div>
|
||||
<h3 class="text-lg font-geist">{$popupState?.title}</h3>
|
||||
<p class="py-4 whitespace-pre-line">{$popupState?.message}</p>
|
||||
<button class="btn" class:btn-error={$popupState?.type === 'error'}>Ok</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||
<button class="!cursor-default">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
5
src/components/popup/Popup.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const popupState = atom<{ type: 'info' | 'error'; title: string; message: string; onClose?: () => void } | null>(
|
||||
null
|
||||
);
|
||||
93
src/db/database.sql
Normal file
@@ -0,0 +1,93 @@
|
||||
-- admins
|
||||
CREATE TABLE IF NOT EXISTS admin (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
permissions INT NOT NULL
|
||||
);
|
||||
|
||||
-- user
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
firstname VARCHAR(255) NOT NULL,
|
||||
lastname VARCHAR(255) NOT NULL,
|
||||
birthday DATE NOT NULL,
|
||||
telephone VARCHAR(255),
|
||||
username VARCHAR(255) NOT NULL,
|
||||
edition ENUM('java', 'bedrock'),
|
||||
uuid VARCHAR(36)
|
||||
);
|
||||
|
||||
-- blocked user
|
||||
CREATE TABLE IF NOT EXISTS blocked_user (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
uuid VARCHAR(255) UNIQUE NOT NULL,
|
||||
comment TINYTEXT
|
||||
);
|
||||
|
||||
-- report
|
||||
CREATE TABLE IF NOT EXISTS report (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
reason VARCHAR(255) NOT NULL,
|
||||
body TEXT,
|
||||
url_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMP,
|
||||
reporter_id INT,
|
||||
reported_id INT,
|
||||
FOREIGN KEY (reporter_id) REFERENCES user(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (reported_id) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- report attachment
|
||||
CREATE TABLE IF NOT EXISTS report_attachment (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
type ENUM('image', 'video') NOT NULL,
|
||||
hash CHAR(32) NOT NULL,
|
||||
report_id INT NOT NULL,
|
||||
FOREIGN KEY (report_id) REFERENCES report(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- report status
|
||||
CREATE TABLE IF NOT EXISTS report_status (
|
||||
status ENUM('open', 'closed'),
|
||||
notice TEXT,
|
||||
statement TEXT,
|
||||
report_id INT NOT NULL UNIQUE,
|
||||
reviewer_id INT,
|
||||
FOREIGN KEY (report_id) REFERENCES report(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (reviewer_id) REFERENCES admin(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- strike reason
|
||||
CREATE TABLE IF NOT EXISTS strike_reason (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
weight TINYINT NOT NULL
|
||||
);
|
||||
|
||||
-- strike
|
||||
CREATE TABLE IF NOT EXISTS strike (
|
||||
at TIMESTAMP NOT NULL,
|
||||
report_id INT NOT NULL UNIQUE,
|
||||
strike_reason_id INT NOT NULL,
|
||||
FOREIGN KEY (report_id) REFERENCES report(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (strike_reason_id) REFERENCES strike_reason(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- feedback
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
event VARCHAR(255) NOT NULL,
|
||||
title VARCHAR(255),
|
||||
content TEXT,
|
||||
url_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
last_changed TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
user_id INT,
|
||||
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- settings
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
235
src/db/database.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { drizzle, type MySql2Database } from 'drizzle-orm/mysql2';
|
||||
import mysql from 'mysql2/promise';
|
||||
import {
|
||||
existsAdmin,
|
||||
admin,
|
||||
type ExistsAdminReq,
|
||||
type GetAdminReq,
|
||||
getAdmins,
|
||||
type AddAdminReq,
|
||||
addAdmin,
|
||||
type EditAdminReq,
|
||||
editAdmin,
|
||||
type DeleteAdminReq,
|
||||
deleteAdmin
|
||||
} from './schema/admin';
|
||||
import {
|
||||
type AddUserReq,
|
||||
type EditUserReq,
|
||||
type DeleteUserReq,
|
||||
type GetUsersReq,
|
||||
type GetUserByUsernameReq,
|
||||
user,
|
||||
addUser,
|
||||
editUser,
|
||||
deleteUser,
|
||||
getUsers,
|
||||
getUserByUsername,
|
||||
existsUser,
|
||||
type ExistsUserReq,
|
||||
type GetUserByUuidReq,
|
||||
getUserByUuid,
|
||||
type GetUserByIdReq,
|
||||
getUserById
|
||||
} from './schema/user';
|
||||
import {
|
||||
type GetSettingReq,
|
||||
settings,
|
||||
getSetting,
|
||||
type GetSettingsReq,
|
||||
getSettings,
|
||||
type SetSettingsReq,
|
||||
setSettings
|
||||
} from './schema/settings';
|
||||
import {
|
||||
addFeedback,
|
||||
type AddFeedbackReq,
|
||||
addUserFeedbacks,
|
||||
type AddUserFeedbacksReq,
|
||||
feedback,
|
||||
getFeedbackByUrlHash,
|
||||
type GetFeedbackByUrlHash,
|
||||
getFeedbacks,
|
||||
type GetFeedbacksReq,
|
||||
submitFeedback,
|
||||
type SubmitFeedbackReq
|
||||
} from './schema/feedback.ts';
|
||||
import {
|
||||
addReport,
|
||||
type AddReportReq,
|
||||
editReport,
|
||||
type EditReportReq,
|
||||
getReportById,
|
||||
type GetReportById,
|
||||
getReportByUrlHash,
|
||||
type GetReportByUrlHash,
|
||||
getReports,
|
||||
type GetReportsReq,
|
||||
report,
|
||||
submitReport,
|
||||
type SubmitReportReq
|
||||
} from './schema/report.ts';
|
||||
import { DATABASE_URI } from 'astro:env/server';
|
||||
import {
|
||||
type GetStrikeReasonsReq,
|
||||
getStrikeReasons,
|
||||
strikeReason,
|
||||
type AddStrikeReasonReq,
|
||||
addStrikeReason,
|
||||
type EditStrikeReasonReq,
|
||||
editStrikeReason,
|
||||
type DeleteStrikeReasonReq,
|
||||
deleteStrikeReason
|
||||
} from '@db/schema/strikeReason.ts';
|
||||
import {
|
||||
deleteStrike,
|
||||
type DeleteStrikeReq,
|
||||
editStrike,
|
||||
type EditStrikeReq,
|
||||
getStrikeByReportId,
|
||||
type GetStrikeByReportIdReq,
|
||||
getStrikesByUserId,
|
||||
type GetStrikesByUserIdReq,
|
||||
strike
|
||||
} from '@db/schema/strike.ts';
|
||||
import {
|
||||
editReportStatus,
|
||||
type EditReportStatusReq,
|
||||
getReportStatus,
|
||||
type GetReportStatusReq,
|
||||
reportStatus
|
||||
} from '@db/schema/reportStatus.ts';
|
||||
import {
|
||||
addBlockedUser,
|
||||
type AddBlockedUserReq,
|
||||
getBlockedUsers,
|
||||
type GetBlockedUsersReq,
|
||||
blockedUser,
|
||||
type GetBlockedUserByUuidReq,
|
||||
getBlockedUserByUuid,
|
||||
type EditBlockedUserReq,
|
||||
editBlockedUser,
|
||||
type DeleteBlockedUserReq,
|
||||
deleteBlockedUser
|
||||
} from '@db/schema/blockedUser.ts';
|
||||
import {
|
||||
addReportAttachment,
|
||||
type AddReportAttachmentReq,
|
||||
getReportAttachments,
|
||||
type GetReportAttachmentsReq,
|
||||
reportAttachment
|
||||
} from '@db/schema/reportAttachment.ts';
|
||||
|
||||
export class Database {
|
||||
protected readonly db: MySql2Database<{
|
||||
admin: typeof admin;
|
||||
user: typeof user;
|
||||
blockedUser: typeof blockedUser;
|
||||
report: typeof report;
|
||||
reportAttachment: typeof reportAttachment;
|
||||
reportStatus: typeof reportStatus;
|
||||
strike: typeof strike;
|
||||
strikeReason: typeof strikeReason;
|
||||
feedback: typeof feedback;
|
||||
settings: typeof settings;
|
||||
}>;
|
||||
|
||||
private constructor(db: typeof this.db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
static async init(databaseUri: string) {
|
||||
const connectionPool = mysql.createPool({
|
||||
uri: databaseUri
|
||||
});
|
||||
|
||||
const db = drizzle({
|
||||
client: connectionPool,
|
||||
schema: {
|
||||
admin,
|
||||
user,
|
||||
blockedUser,
|
||||
report,
|
||||
reportAttachment,
|
||||
reportStatus,
|
||||
strike,
|
||||
strikeReason,
|
||||
feedback,
|
||||
settings
|
||||
},
|
||||
mode: 'default'
|
||||
});
|
||||
|
||||
return new Database(db);
|
||||
}
|
||||
|
||||
async transaction<T>(fn: (tx: Database & { rollback: () => never }) => Promise<T>): Promise<T> {
|
||||
return this.db.transaction((tx) => fn(new Database(tx) as Database & { rollback: () => never }));
|
||||
}
|
||||
|
||||
/* admins */
|
||||
addAdmin = (values: AddAdminReq) => addAdmin(this.db, values);
|
||||
editAdmin = (values: EditAdminReq) => editAdmin(this.db, values);
|
||||
deleteAdmin = (values: DeleteAdminReq) => deleteAdmin(this.db, values);
|
||||
getAdmins = (values: GetAdminReq) => getAdmins(this.db, values);
|
||||
existsAdmin = (values: ExistsAdminReq) => existsAdmin(this.db, values);
|
||||
|
||||
/* user */
|
||||
addUser = (values: AddUserReq) => addUser(this.db, values);
|
||||
editUser = (values: EditUserReq) => editUser(this.db, values);
|
||||
deleteUser = (values: DeleteUserReq) => deleteUser(this.db, values);
|
||||
existsUser = (values: ExistsUserReq) => existsUser(this.db, values);
|
||||
getUsers = (values: GetUsersReq) => getUsers(this.db, values);
|
||||
getUserById = (values: GetUserByIdReq) => getUserById(this.db, values);
|
||||
getUserByUsername = (values: GetUserByUsernameReq) => getUserByUsername(this.db, values);
|
||||
getUserByUuid = (values: GetUserByUuidReq) => getUserByUuid(this.db, values);
|
||||
|
||||
/* user blocks */
|
||||
addBlockedUser = (values: AddBlockedUserReq) => addBlockedUser(this.db, values);
|
||||
editBlockedUser = (values: EditBlockedUserReq) => editBlockedUser(this.db, values);
|
||||
deleteBlockedUser = (values: DeleteBlockedUserReq) => deleteBlockedUser(this.db, values);
|
||||
getBlockedUserByUuid = (values: GetBlockedUserByUuidReq) => getBlockedUserByUuid(this.db, values);
|
||||
getBlockedUsers = (values: GetBlockedUsersReq) => getBlockedUsers(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);
|
||||
getReportByUrlHash = (values: GetReportByUrlHash) => getReportByUrlHash(this.db, values);
|
||||
|
||||
/* 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);
|
||||
editReportStatus = (values: EditReportStatusReq) => editReportStatus(this.db, values);
|
||||
|
||||
/* strike reason */
|
||||
addStrikeReason = (values: AddStrikeReasonReq) => addStrikeReason(this.db, values);
|
||||
editStrikeReason = (values: EditStrikeReasonReq) => editStrikeReason(this.db, values);
|
||||
deleteStrikeReason = (values: DeleteStrikeReasonReq) => deleteStrikeReason(this.db, values);
|
||||
getStrikeReasons = (values: GetStrikeReasonsReq) => getStrikeReasons(this.db, values);
|
||||
|
||||
/* strikes */
|
||||
editStrike = (values: EditStrikeReq) => editStrike(this.db, values);
|
||||
deleteStrike = (values: DeleteStrikeReq) => deleteStrike(this.db, values);
|
||||
getStrikeByReportId = (values: GetStrikeByReportIdReq) => getStrikeByReportId(this.db, values);
|
||||
getStrikesByUserId = (values: GetStrikesByUserIdReq) => getStrikesByUserId(this.db, values);
|
||||
|
||||
/* feedback */
|
||||
addFeedback = (values: AddFeedbackReq) => addFeedback(this.db, values);
|
||||
addUserFeedbacks = (values: AddUserFeedbacksReq) => addUserFeedbacks(this.db, values);
|
||||
submitFeedback = (values: SubmitFeedbackReq) => submitFeedback(this.db, values);
|
||||
getFeedbacks = (values: GetFeedbacksReq) => getFeedbacks(this.db, values);
|
||||
getFeedbackByUrlHash = (values: GetFeedbackByUrlHash) => getFeedbackByUrlHash(this.db, values);
|
||||
|
||||
/* settings */
|
||||
getSettings = (values: GetSettingsReq) => getSettings(this.db, values);
|
||||
setSettings = (values: SetSettingsReq) => setSettings(this.db, values);
|
||||
getSetting = (values: GetSettingReq) => getSetting(this.db, values);
|
||||
}
|
||||
|
||||
export const db = await Database.init(DATABASE_URI);
|
||||
82
src/db/schema/admin.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { int, mysqlTable, varchar } from 'drizzle-orm/mysql-core';
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
type Database = MySql2Database<{ admin: typeof admin }>;
|
||||
|
||||
export const admin = mysqlTable('admin', {
|
||||
id: int('id').primaryKey().autoincrement(),
|
||||
username: varchar('username', { length: 255 }).notNull(),
|
||||
password: varchar('password', { length: 255 }).notNull(),
|
||||
permissions: int('permissions').notNull()
|
||||
});
|
||||
|
||||
export type AddAdminReq = Omit<typeof admin.$inferInsert, 'id'>;
|
||||
|
||||
export type EditAdminReq = {
|
||||
id: number;
|
||||
username: string;
|
||||
password: string | null;
|
||||
permissions: number;
|
||||
};
|
||||
|
||||
export type DeleteAdminReq = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type GetAdminReq = {};
|
||||
export type GetAdminRes = Omit<typeof admin.$inferSelect, 'password'>[];
|
||||
|
||||
export type ExistsAdminReq = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
export type ExistsAdminRes = {
|
||||
id: number;
|
||||
username: string;
|
||||
permissions: Permissions;
|
||||
} | null;
|
||||
|
||||
export async function addAdmin(db: Database, values: AddAdminReq) {
|
||||
values.password = bcrypt.hashSync(values.password, 10);
|
||||
|
||||
const adminIds = await db.insert(admin).values(values).$returningId();
|
||||
|
||||
return adminIds[0];
|
||||
}
|
||||
|
||||
export async function editAdmin(db: Database, values: EditAdminReq) {
|
||||
return db
|
||||
.update(admin)
|
||||
.set({
|
||||
id: values.id,
|
||||
username: values.username,
|
||||
password: values.password != null ? bcrypt.hashSync(values.password, 10) : undefined,
|
||||
permissions: values.permissions
|
||||
})
|
||||
.where(eq(admin.id, values.id));
|
||||
}
|
||||
|
||||
export async function deleteAdmin(db: Database, values: DeleteAdminReq) {
|
||||
return db.delete(admin).where(eq(admin.id, values.id));
|
||||
}
|
||||
|
||||
export async function getAdmins(db: Database, _values: GetAdminReq): Promise<GetAdminRes> {
|
||||
return db.select({ id: admin.id, username: admin.username, permissions: admin.permissions }).from(admin);
|
||||
}
|
||||
|
||||
export async function existsAdmin(db: Database, values: ExistsAdminReq): Promise<ExistsAdminRes> {
|
||||
const a = await db.query.admin.findFirst({
|
||||
where: eq(admin.username, values.username)
|
||||
});
|
||||
|
||||
if (!a || !bcrypt.compareSync(values.password, a.password)) return null;
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
username: a.username,
|
||||
permissions: new Permissions(a.permissions)
|
||||
};
|
||||
}
|
||||
58
src/db/schema/blockedUser.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { int, mysqlTable, varchar } from 'drizzle-orm/mysql-core';
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type Database = MySql2Database<{ blockedUser: typeof blockedUser }>;
|
||||
|
||||
export const blockedUser = mysqlTable('blocked_user', {
|
||||
id: int('id').primaryKey().autoincrement(),
|
||||
uuid: varchar('uuid', { length: 255 }).unique().notNull(),
|
||||
comment: varchar('comment', { length: 255 })
|
||||
});
|
||||
|
||||
export type AddBlockedUserReq = {
|
||||
uuid: string;
|
||||
comment?: string | null;
|
||||
};
|
||||
|
||||
export type EditBlockedUserReq = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
comment?: string | null;
|
||||
};
|
||||
|
||||
export type DeleteBlockedUserReq = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type GetBlockedUserByUuidReq = {
|
||||
uuid: string;
|
||||
};
|
||||
|
||||
export type GetBlockedUsersReq = {};
|
||||
|
||||
export async function addBlockedUser(db: Database, values: AddBlockedUserReq) {
|
||||
const bu = await db.insert(blockedUser).values(values).$returningId();
|
||||
|
||||
return bu[0];
|
||||
}
|
||||
|
||||
export async function editBlockedUser(db: Database, values: EditBlockedUserReq) {
|
||||
await db.update(blockedUser).set(values).where(eq(blockedUser.id, values.id));
|
||||
}
|
||||
|
||||
export async function deleteBlockedUser(db: Database, values: DeleteBlockedUserReq) {
|
||||
return db.delete(blockedUser).where(eq(blockedUser.id, values.id));
|
||||
}
|
||||
|
||||
export async function getBlockedUserByUuid(db: Database, values: GetBlockedUserByUuidReq) {
|
||||
const bu = await db.query.blockedUser.findFirst({
|
||||
where: eq(blockedUser.uuid, values.uuid)
|
||||
});
|
||||
|
||||
return bu ?? null;
|
||||
}
|
||||
|
||||
export async function getBlockedUsers(db: Database, _values: GetBlockedUsersReq) {
|
||||
return db.select().from(blockedUser);
|
||||
}
|
||||
98
src/db/schema/feedback.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { int, mysqlTable, text, timestamp, varchar } from 'drizzle-orm/mysql-core';
|
||||
import { user } from './user.ts';
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { generateRandomString } from '@util/random.ts';
|
||||
|
||||
type Database = MySql2Database<{ feedback: typeof feedback }>;
|
||||
|
||||
export const feedback = mysqlTable('feedback', {
|
||||
id: int('id').primaryKey().autoincrement(),
|
||||
event: varchar('event', { length: 255 }).notNull(),
|
||||
title: varchar('title', { length: 255 }),
|
||||
content: text('content'),
|
||||
urlHash: varchar('url_hash', { length: 255 }).unique().notNull(),
|
||||
lastChanged: timestamp('last_changed', { mode: 'date' }).notNull().defaultNow().onUpdateNow(),
|
||||
userId: int('user_id').references(() => user.id)
|
||||
});
|
||||
|
||||
export type AddFeedbackReq = {
|
||||
event: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type AddUserFeedbacksReq = {
|
||||
event: string;
|
||||
title: string;
|
||||
uuids: string[];
|
||||
};
|
||||
|
||||
export type SubmitFeedbackReq = {
|
||||
urlHash: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type GetFeedbacksReq = {};
|
||||
|
||||
export type GetFeedbackByUrlHash = {
|
||||
urlHash: string;
|
||||
};
|
||||
|
||||
export async function addFeedback(db: Database, values: AddFeedbackReq) {
|
||||
return db.insert(feedback).values({
|
||||
event: values.event,
|
||||
content: values.content,
|
||||
urlHash: generateRandomString(16)
|
||||
});
|
||||
}
|
||||
|
||||
export async function addUserFeedbacks(db: Database, values: AddUserFeedbacksReq) {
|
||||
const users = await db.select({ id: user.id, uuid: user.uuid }).from(user).where(inArray(user.uuid, values.uuids));
|
||||
|
||||
const userFeedbacks = users.map((user) => ({
|
||||
id: user.id,
|
||||
uuid: user.uuid!,
|
||||
urlHash: generateRandomString(16)
|
||||
}));
|
||||
|
||||
await db.insert(feedback).values(
|
||||
userFeedbacks.map((feedback) => ({
|
||||
event: values.event,
|
||||
title: values.title,
|
||||
urlHash: feedback.urlHash,
|
||||
userId: feedback.id
|
||||
}))
|
||||
);
|
||||
|
||||
return userFeedbacks;
|
||||
}
|
||||
|
||||
export async function submitFeedback(db: Database, values: SubmitFeedbackReq) {
|
||||
return db
|
||||
.update(feedback)
|
||||
.set({
|
||||
content: values.content
|
||||
})
|
||||
.where(eq(feedback.urlHash, values.urlHash));
|
||||
}
|
||||
|
||||
export async function getFeedbacks(db: Database, _values: GetFeedbacksReq) {
|
||||
return db
|
||||
.select({
|
||||
id: feedback.id,
|
||||
event: feedback.event,
|
||||
title: feedback.title,
|
||||
content: feedback.content,
|
||||
urlHash: feedback.urlHash,
|
||||
lastChanged: feedback.lastChanged,
|
||||
username: user.username
|
||||
})
|
||||
.from(feedback)
|
||||
.leftJoin(user, eq(feedback.userId, user.id));
|
||||
}
|
||||
|
||||
export async function getFeedbackByUrlHash(db: Database, values: GetFeedbackByUrlHash) {
|
||||
return db.query.feedback.findFirst({
|
||||
where: eq(feedback.urlHash, values.urlHash)
|
||||
});
|
||||
}
|
||||
218
src/db/schema/report.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { alias, int, mysqlTable, text, timestamp, varchar } from 'drizzle-orm/mysql-core';
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||
import { and, eq, isNotNull } from 'drizzle-orm';
|
||||
import { reportStatus } from './reportStatus.ts';
|
||||
import { generateRandomString } from '@util/random.ts';
|
||||
import { BASE_PATH } from 'astro:env/server';
|
||||
import { strikeReason } from '@db/schema/strikeReason.ts';
|
||||
import { strike } from '@db/schema/strike.ts';
|
||||
import { user } from '@db/schema/user.ts';
|
||||
|
||||
type Database = MySql2Database<{ report: typeof report }>;
|
||||
|
||||
export const report = mysqlTable('report', {
|
||||
id: int('id').primaryKey().autoincrement(),
|
||||
reason: varchar('reason', { length: 255 }).notNull(),
|
||||
body: text('body'),
|
||||
urlHash: varchar('url_hash', { length: 255 }).notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'date' }),
|
||||
reporterId: int('reporter_id').references(() => user.id),
|
||||
reportedId: int('reported_id').references(() => user.id)
|
||||
});
|
||||
|
||||
export type AddReportReq = {
|
||||
reason: string;
|
||||
body: string | null;
|
||||
createdAt?: Date | null;
|
||||
reporterId?: number;
|
||||
reportedId?: number | null;
|
||||
};
|
||||
|
||||
export type EditReportReq = {
|
||||
id: number;
|
||||
reportedId: number | null;
|
||||
};
|
||||
|
||||
export type SubmitReportReq = {
|
||||
urlHash: string;
|
||||
reportedId: number | null;
|
||||
reason: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type GetReportsReq = {
|
||||
reporter?: string | null;
|
||||
reported?: string | null;
|
||||
includeDrafts?: boolean | null;
|
||||
};
|
||||
|
||||
export type GetReportById = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type GetReportByUrlHash = {
|
||||
urlHash: string;
|
||||
};
|
||||
|
||||
export async function addReport(db: Database, values: AddReportReq) {
|
||||
const urlHash = generateRandomString(16);
|
||||
|
||||
const r = await db
|
||||
.insert(report)
|
||||
.values({
|
||||
reason: values.reason,
|
||||
body: values.body,
|
||||
urlHash: urlHash,
|
||||
createdAt: values.createdAt,
|
||||
reporterId: values.reporterId,
|
||||
reportedId: values.reportedId
|
||||
})
|
||||
.$returningId();
|
||||
|
||||
return Object.assign(r[0], { url: `${BASE_PATH}/report/${urlHash}` });
|
||||
}
|
||||
|
||||
export async function editReport(db: Database, values: EditReportReq) {
|
||||
return db.update(report).set({
|
||||
reportedId: values.reportedId
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitReport(db: Database, values: SubmitReportReq) {
|
||||
return db
|
||||
.update(report)
|
||||
.set({
|
||||
reportedId: values.reportedId,
|
||||
reason: values.reason,
|
||||
body: values.body,
|
||||
createdAt: new Date()
|
||||
})
|
||||
.where(eq(report.urlHash, values.urlHash));
|
||||
}
|
||||
|
||||
export async function getReports(db: Database, values: GetReportsReq) {
|
||||
const reporter = alias(user, 'reporter');
|
||||
const reported = alias(user, 'reported');
|
||||
|
||||
let reporterIdSubquery;
|
||||
if (values.reporter != null) {
|
||||
reporterIdSubquery = db
|
||||
.select({ id: reporter.id })
|
||||
.from(reporter)
|
||||
.where(eq(reporter.username, values.reporter))
|
||||
.as('reporter_id_subquery');
|
||||
}
|
||||
let reportedIdSubquery;
|
||||
if (values.reported != null) {
|
||||
reportedIdSubquery = db
|
||||
.select({ id: reported.id })
|
||||
.from(reported)
|
||||
.where(eq(reported.username, values.reported))
|
||||
.as('reported_id_subquery');
|
||||
}
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: report.id,
|
||||
reason: report.reason,
|
||||
body: report.body,
|
||||
urlHash: report.urlHash,
|
||||
createdAt: report.createdAt,
|
||||
reporter: {
|
||||
id: reporter.id,
|
||||
username: reporter.username
|
||||
},
|
||||
reported: {
|
||||
id: reported.id,
|
||||
username: reported.username
|
||||
},
|
||||
status: {
|
||||
status: reportStatus.status,
|
||||
notice: reportStatus.notice,
|
||||
statement: reportStatus.statement
|
||||
},
|
||||
strike: {
|
||||
strikeReasonId: strikeReason.id
|
||||
}
|
||||
})
|
||||
.from(report)
|
||||
.innerJoin(reporter, eq(report.reporterId, reporter.id))
|
||||
.leftJoin(reported, eq(report.reportedId, reported.id))
|
||||
.leftJoin(reportStatus, eq(report.id, reportStatus.reportId))
|
||||
.leftJoin(strike, eq(report.id, strike.reportId))
|
||||
.leftJoin(strikeReason, eq(strike.strikeReasonId, strikeReason.id))
|
||||
.where(
|
||||
and(
|
||||
values.reporter != null ? eq(report.reporterId, reporterIdSubquery!.id) : undefined,
|
||||
values.reported != null ? eq(report.reportedId, reportedIdSubquery!.id) : undefined,
|
||||
values.includeDrafts == false ? isNotNull(report.createdAt) : undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getReportById(db: Database, values: GetReportById) {
|
||||
const reporter = alias(user, 'reporter');
|
||||
const reported = alias(user, 'reported');
|
||||
|
||||
const reports = await db
|
||||
.select({
|
||||
id: report.id,
|
||||
reason: report.reason,
|
||||
body: report.body,
|
||||
createdAt: report.createdAt,
|
||||
reporter: {
|
||||
id: reporter.id,
|
||||
username: reporter.username
|
||||
},
|
||||
reported: {
|
||||
id: reported.id,
|
||||
username: reported.username
|
||||
},
|
||||
status: {
|
||||
status: reportStatus.status,
|
||||
notice: reportStatus.notice,
|
||||
statement: reportStatus.statement
|
||||
}
|
||||
})
|
||||
.from(report)
|
||||
.innerJoin(reporter, eq(report.reporterId, reporter.id))
|
||||
.leftJoin(reported, eq(report.reportedId, reported.id))
|
||||
.leftJoin(reportStatus, eq(report.id, reportStatus.reportId))
|
||||
.where(eq(report.id, values.id));
|
||||
|
||||
return reports[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getReportByUrlHash(db: Database, values: GetReportByUrlHash) {
|
||||
const reporter = alias(user, 'reporter');
|
||||
const reported = alias(user, 'reported');
|
||||
|
||||
const reports = await db
|
||||
.select({
|
||||
id: report.id,
|
||||
reason: report.reason,
|
||||
body: report.body,
|
||||
createdAt: report.createdAt,
|
||||
urlHash: report.urlHash,
|
||||
reporter: {
|
||||
id: reporter.id,
|
||||
username: reporter.username
|
||||
},
|
||||
reported: {
|
||||
id: reported.id,
|
||||
username: reported.username
|
||||
},
|
||||
status: {
|
||||
status: reportStatus.status,
|
||||
notice: reportStatus.notice,
|
||||
statement: reportStatus.statement
|
||||
}
|
||||
})
|
||||
.from(report)
|
||||
.innerJoin(reporter, eq(report.reporterId, reporter.id))
|
||||
.leftJoin(reported, eq(report.reportedId, reported.id))
|
||||
.leftJoin(reportStatus, eq(report.id, reportStatus.reportId))
|
||||
.where(eq(report.urlHash, values.urlHash));
|
||||
|
||||
return reports[0] ?? null;
|
||||
}
|
||||
38
src/db/schema/reportAttachment.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { char, int, mysqlEnum, mysqlTable } from 'drizzle-orm/mysql-core';
|
||||
import { report } from '@db/schema/report.ts';
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type Database = MySql2Database<{ reportAttachment: typeof reportAttachment }>;
|
||||
|
||||
export const reportAttachment = mysqlTable('report_attachment', {
|
||||
type: mysqlEnum('type', ['image', 'video']),
|
||||
hash: char('hash', { length: 32 }),
|
||||
reportId: int('report_id')
|
||||
.notNull()
|
||||
.references(() => report.id)
|
||||
});
|
||||
|
||||
export type AddReportAttachmentReq = {
|
||||
type: 'image' | 'video';
|
||||
hash: string;
|
||||
reportId: number;
|
||||
};
|
||||
|
||||
export type GetReportAttachmentsReq = {
|
||||
reportId: number;
|
||||
};
|
||||
|
||||
export async function addReportAttachment(db: Database, values: AddReportAttachmentReq) {
|
||||
await db.insert(reportAttachment).values(values);
|
||||
}
|
||||
|
||||
export async function getReportAttachments(db: Database, values: GetReportAttachmentsReq) {
|
||||
return db
|
||||
.select({
|
||||
type: reportAttachment.type,
|
||||
hash: reportAttachment.hash
|
||||
})
|
||||
.from(reportAttachment)
|
||||
.where(eq(reportAttachment.reportId, values.reportId));
|
||||
}
|
||||
50
src/db/schema/reportStatus.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { int, mysqlEnum, mysqlTable, text } from 'drizzle-orm/mysql-core';
|
||||
import { admin } from './admin.ts';
|
||||
import { report } from './report.ts';
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type Database = MySql2Database<{ reportStatus: typeof reportStatus }>;
|
||||
|
||||
export const reportStatus = mysqlTable('report_status', {
|
||||
status: mysqlEnum('status', ['open', 'closed']),
|
||||
notice: text('notice'),
|
||||
statement: text('statement'),
|
||||
reportId: int('report_id')
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => report.id),
|
||||
reviewerId: int('reviewer_id').references(() => admin.id)
|
||||
});
|
||||
|
||||
export type GetReportStatusReq = {
|
||||
reportId: number;
|
||||
};
|
||||
|
||||
export type EditReportStatusReq = {
|
||||
reportId: number;
|
||||
status: 'open' | 'closed' | null;
|
||||
notice: string | null;
|
||||
statement: string | null;
|
||||
};
|
||||
|
||||
export async function getReportStatus(db: Database, values: GetReportStatusReq) {
|
||||
const rs = await db.query.reportStatus.findFirst({
|
||||
where: eq(reportStatus.reportId, values.reportId)
|
||||
});
|
||||
|
||||
return rs ?? null;
|
||||
}
|
||||
|
||||
export async function editReportStatus(db: Database, values: EditReportStatusReq) {
|
||||
return db
|
||||
.insert(reportStatus)
|
||||
.values(values)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
status: values.status,
|
||||
notice: values.notice,
|
||||
statement: values.statement
|
||||
}
|
||||
});
|
||||
}
|
||||
52
src/db/schema/settings.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { mysqlTable, text, varchar } from 'drizzle-orm/mysql-core';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||
|
||||
type Database = MySql2Database<{ settings: typeof settings }>;
|
||||
|
||||
export const settings = mysqlTable('settings', {
|
||||
name: varchar('name', { length: 255 }).unique().notNull(),
|
||||
value: text()
|
||||
});
|
||||
|
||||
export type GetSettingsReq = {
|
||||
names?: string[];
|
||||
};
|
||||
|
||||
export type SetSettingsReq = {
|
||||
settings: {
|
||||
name: string;
|
||||
value: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type GetSettingReq = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export async function getSettings(db: Database, values: GetSettingsReq) {
|
||||
return db
|
||||
.select({ name: settings.name, value: settings.value })
|
||||
.from(settings)
|
||||
.where(values.names ? inArray(settings.name, values.names) : undefined);
|
||||
}
|
||||
|
||||
export async function setSettings(db: Database, values: SetSettingsReq) {
|
||||
return db.transaction(async (tx) => {
|
||||
for (const setting of values.settings) {
|
||||
await tx
|
||||
.insert(settings)
|
||||
.values(setting)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
value: setting.value
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSetting(db: Database, values: GetSettingReq): Promise<string | null> {
|
||||
const value = await db.select({ value: settings.value }).from(settings).where(eq(settings.name, values.name));
|
||||
return value.length > 0 ? value[0]?.value : null;
|
||||
}
|
||||
81
src/db/schema/strike.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { int, mysqlTable, timestamp } from 'drizzle-orm/mysql-core';
|
||||
import { strikeReason } from '@db/schema/strikeReason.ts';
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { report } from '@db/schema/report.ts';
|
||||
|
||||
type Database = MySql2Database<{ strike: typeof strike }>;
|
||||
|
||||
export const strike = mysqlTable('strike', {
|
||||
at: timestamp('at', { mode: 'date' }).notNull(),
|
||||
reportId: int('report_id')
|
||||
.notNull()
|
||||
.references(() => report.id),
|
||||
strikeReasonId: int('strike_reason_id')
|
||||
.notNull()
|
||||
.references(() => strikeReason.id)
|
||||
});
|
||||
|
||||
export type EditStrikeReq = {
|
||||
reportId: number;
|
||||
at?: Date;
|
||||
strikeReasonId: number;
|
||||
};
|
||||
|
||||
export type DeleteStrikeReq = {
|
||||
reportId: number;
|
||||
};
|
||||
|
||||
export type GetStrikeByReportIdReq = {
|
||||
reportId: number;
|
||||
};
|
||||
|
||||
export type GetStrikesByUserIdReq = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export async function editStrike(db: Database, values: EditStrikeReq) {
|
||||
return db
|
||||
.insert(strike)
|
||||
.values({
|
||||
at: values.at ?? new Date(),
|
||||
reportId: values.reportId,
|
||||
strikeReasonId: values.strikeReasonId
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
at: values.at ?? new Date(),
|
||||
strikeReasonId: values.strikeReasonId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteStrike(db: Database, values: DeleteStrikeReq) {
|
||||
return db.delete(strike).where(eq(strike.reportId, values.reportId)).limit(1);
|
||||
}
|
||||
|
||||
export async function getStrikeByReportId(db: Database, values: GetStrikeByReportIdReq) {
|
||||
const strikes = await db
|
||||
.select({
|
||||
strike,
|
||||
strikeReason
|
||||
})
|
||||
.from(strike)
|
||||
.where(eq(strike.reportId, values.reportId))
|
||||
.leftJoin(strikeReason, eq(strike.strikeReasonId, strikeReason.id));
|
||||
|
||||
return strikes[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getStrikesByUserId(db: Database, values: GetStrikesByUserIdReq) {
|
||||
return db
|
||||
.select({
|
||||
at: strike.at,
|
||||
report: report,
|
||||
reason: strikeReason
|
||||
})
|
||||
.from(strike)
|
||||
.innerJoin(strikeReason, eq(strike.strikeReasonId, strikeReason.id))
|
||||
.innerJoin(report, eq(strike.reportId, report.id))
|
||||
.where(eq(report.reportedId, values.userId));
|
||||
}
|
||||
42
src/db/schema/strikeReason.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { int, mysqlTable, tinyint, varchar } from 'drizzle-orm/mysql-core';
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
|
||||
type Database = MySql2Database<{ strikeReason: typeof strikeReason }>;
|
||||
|
||||
export const strikeReason = mysqlTable('strike_reason', {
|
||||
id: int('id').primaryKey().autoincrement(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
weight: tinyint('weight').notNull()
|
||||
});
|
||||
|
||||
export type AddStrikeReasonReq = {
|
||||
name: string;
|
||||
weight: number;
|
||||
};
|
||||
|
||||
export type EditStrikeReasonReq = typeof strikeReason.$inferSelect;
|
||||
|
||||
export type DeleteStrikeReasonReq = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type GetStrikeReasonsReq = {};
|
||||
|
||||
export async function addStrikeReason(db: Database, values: AddStrikeReasonReq) {
|
||||
const sr = await db.insert(strikeReason).values(values).$returningId();
|
||||
|
||||
return sr[0];
|
||||
}
|
||||
|
||||
export async function editStrikeReason(db: Database, values: EditStrikeReasonReq) {
|
||||
await db.update(strikeReason).set(values).where(eq(strikeReason.id, values.id));
|
||||
}
|
||||
|
||||
export async function deleteStrikeReason(db: Database, values: DeleteStrikeReasonReq) {
|
||||
await db.delete(strikeReason).where(eq(strikeReason.id, values.id));
|
||||
}
|
||||
|
||||
export async function getStrikeReasons(db: Database, _values: GetStrikeReasonsReq) {
|
||||
return db.select().from(strikeReason).orderBy(asc(strikeReason.weight));
|
||||
}
|
||||
106
src/db/schema/user.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { date, int, mysqlTable, varchar } from 'drizzle-orm/mysql-core';
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||
import { eq, like, or } from 'drizzle-orm';
|
||||
import { mysqlEnum } from 'drizzle-orm/mysql-core/columns/enum';
|
||||
|
||||
type Database = MySql2Database<{ user: typeof user }>;
|
||||
|
||||
export const user = mysqlTable('user', {
|
||||
id: int('id').primaryKey().autoincrement(),
|
||||
firstname: varchar('firstname', { length: 255 }).notNull(),
|
||||
lastname: varchar('lastname', { length: 255 }).notNull(),
|
||||
birthday: date('birthday', { mode: 'string' }).notNull(),
|
||||
telephone: varchar('telephone', { length: 255 }),
|
||||
username: varchar('username', { length: 255 }).notNull(),
|
||||
edition: mysqlEnum(['java', 'bedrock']).notNull(),
|
||||
uuid: varchar('uuid', { length: 36 })
|
||||
});
|
||||
|
||||
export type AddUserReq = Omit<typeof user.$inferInsert, 'id'>;
|
||||
|
||||
export type EditUserReq = Partial<Omit<typeof user.$inferInsert, 'id'>> & { id: number };
|
||||
|
||||
export type DeleteUserReq = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type ExistsUserReq = {
|
||||
username?: string;
|
||||
uuid?: string;
|
||||
};
|
||||
|
||||
export type GetUsersReq = {
|
||||
username?: string | null;
|
||||
limit?: number;
|
||||
};
|
||||
export type GetUsersRes = (typeof user.$inferSelect)[];
|
||||
|
||||
export type GetUserByIdReq = {
|
||||
id: number;
|
||||
};
|
||||
export type GetUserByIdRes = typeof user.$inferSelect | null;
|
||||
|
||||
export type GetUserByUsernameReq = {
|
||||
username: string;
|
||||
};
|
||||
export type GetUserByUsernameRes = typeof user.$inferSelect | null;
|
||||
|
||||
export type GetUserByUuidReq = {
|
||||
uuid: string;
|
||||
};
|
||||
|
||||
export async function addUser(db: Database, values: AddUserReq) {
|
||||
const userIds = await db.insert(user).values(values).$returningId();
|
||||
|
||||
return userIds[0];
|
||||
}
|
||||
|
||||
export async function editUser(db: Database, values: EditUserReq) {
|
||||
await db.update(user).set(values).where(eq(user.id, values.id));
|
||||
}
|
||||
|
||||
export async function deleteUser(db: Database, values: DeleteUserReq) {
|
||||
await db.delete(user).where(eq(user.id, values.id));
|
||||
}
|
||||
|
||||
export async function existsUser(db: Database, values: ExistsUserReq) {
|
||||
const u = db.query.user.findFirst({
|
||||
where: or(
|
||||
values.username != null ? eq(user.username, values.username) : undefined,
|
||||
values.uuid != null ? eq(user.uuid, values.uuid) : undefined
|
||||
)
|
||||
});
|
||||
|
||||
return u ?? null;
|
||||
}
|
||||
|
||||
export async function getUsers(db: Database, values: GetUsersReq): Promise<GetUsersRes> {
|
||||
return db.query.user.findMany({
|
||||
where: values.username != null ? like(user.username, `%${values.username}%`) : undefined,
|
||||
limit: values.limit
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserById(db: Database, values: GetUserByIdReq): Promise<GetUserByIdRes> {
|
||||
const u = await db.query.user.findFirst({
|
||||
where: eq(user.id, values.id)
|
||||
});
|
||||
|
||||
return u ?? null;
|
||||
}
|
||||
|
||||
export async function getUserByUsername(db: Database, values: GetUserByUsernameReq): Promise<GetUserByUsernameRes> {
|
||||
const u = await db.query.user.findFirst({
|
||||
where: eq(user.username, values.username)
|
||||
});
|
||||
|
||||
return u ?? null;
|
||||
}
|
||||
|
||||
export async function getUserByUuid(db: Database, values: GetUserByUuidReq) {
|
||||
const u = await db.query.user.findFirst({
|
||||
where: eq(user.uuid, values.uuid)
|
||||
});
|
||||
|
||||
return u ?? null;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { sequelize } from '$lib/server/database';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { getSession, sessionCookieName } from '$lib/server/session';
|
||||
|
||||
// make sure that the database and tables exist
|
||||
await sequelize.sync();
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
if (
|
||||
event.url.pathname.startsWith(`${env.PUBLIC_BASE_PATH}/admin`) &&
|
||||
event.url.pathname != `${env.PUBLIC_BASE_PATH}/admin/login`
|
||||
) {
|
||||
if (getSession(event.cookies.get(sessionCookieName) || '') == null) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: `${env.PUBLIC_BASE_PATH}/admin/login`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
43
src/layouts/BaseLayout.astro
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
import { ClientRouter } from 'astro:transitions';
|
||||
import { BASE_PATH } from 'astro:env/server';
|
||||
import logo512 from '@assets/img/logo-512.webp';
|
||||
import favicon from '@assets/favicon.png';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
openGraph?: boolean;
|
||||
viewTransition?: boolean;
|
||||
}
|
||||
|
||||
const { title, description, keywords, openGraph, viewTransition } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en" transition:name="root" transition:animate="none">
|
||||
<head>
|
||||
{viewTransition && <ClientRouter />}
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/png" href={favicon.src} />
|
||||
<title>{title}</title>
|
||||
{
|
||||
openGraph && (
|
||||
<>
|
||||
<meta name="og:title" content={title} />
|
||||
<meta property="og:url" content={Astro.url.pathname} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content={logo512.src} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
<meta name="description" content={description} />
|
||||
{keywords && <meta name="keywords" content={keywords.join(', ')} />}
|
||||
<base href=`${BASE_PATH}/` />
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
152
src/layouts/admin/AdminLayout.astro
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
import '@assets/admin_layout.css';
|
||||
import BaseLayout from '../BaseLayout.astro';
|
||||
import { ClientRouter } from 'astro:transitions';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import Popup from '@components/popup/Popup.svelte';
|
||||
import ConfirmPopup from '@components/popup/ConfirmPopup.svelte';
|
||||
import { Session } from '@util/session.ts';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { title } = Astro.props;
|
||||
|
||||
const session = Session.sessionFromCookies(Astro.cookies);
|
||||
|
||||
const preTabs = [
|
||||
{
|
||||
href: '',
|
||||
name: 'Startseite',
|
||||
icon: 'heroicons:computer-desktop-20-solid'
|
||||
}
|
||||
];
|
||||
const adminTabs = [
|
||||
{
|
||||
href: 'admin/users',
|
||||
name: 'Registrierte Nutzer',
|
||||
icon: 'heroicons:user',
|
||||
subTabs: [
|
||||
{
|
||||
href: 'admin/users/blocked',
|
||||
name: 'Blockierte Nutzer',
|
||||
icon: 'heroicons:user-minus'
|
||||
}
|
||||
],
|
||||
enabled: session?.permissions.users
|
||||
},
|
||||
{
|
||||
href: 'admin/reports',
|
||||
name: 'Reports',
|
||||
icon: 'heroicons:flag',
|
||||
enabled: session?.permissions.reports
|
||||
},
|
||||
{
|
||||
href: 'admin/feedback',
|
||||
name: 'Feedback',
|
||||
icon: 'heroicons:book-open',
|
||||
enabled: session?.permissions.feedback
|
||||
},
|
||||
{
|
||||
href: 'admin/admins',
|
||||
name: 'Website Admins',
|
||||
icon: 'heroicons:code-bracket-16-solid',
|
||||
subTabs: [
|
||||
{
|
||||
href: 'admin/admins/strike_reasons',
|
||||
name: 'Strikegründe',
|
||||
icon: 'heroicons:shield-exclamation'
|
||||
}
|
||||
],
|
||||
enabled: session?.permissions.admin
|
||||
},
|
||||
{
|
||||
href: 'admin/settings',
|
||||
name: 'Einstellungen',
|
||||
icon: 'heroicons:adjustments-horizontal',
|
||||
enabled: session?.permissions.settings
|
||||
},
|
||||
{
|
||||
href: 'admin/tools',
|
||||
name: 'Tools',
|
||||
icon: 'heroicons:wrench-screwdriver',
|
||||
enabled: session?.permissions.tools
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout title={title}>
|
||||
<ClientRouter />
|
||||
<div class="flex flex-row max-h-[100vh]">
|
||||
<ul class="menu bg-base-200 w-68 h-[100vh] flex">
|
||||
{
|
||||
preTabs.map((tab) => (
|
||||
<li>
|
||||
<a href={tab.href}>
|
||||
<Icon name={tab.icon} />
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
<div class="divider mx-1 my-1"></div>
|
||||
{
|
||||
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') && (
|
||||
<fieldset class="fieldset bg-base-300 border-base-100 rounded-box border p-2 -ml-1.5 -mr-1.5 mt-auto">
|
||||
<slot name="actions" />
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
<li class:list={[Astro.slots.has('actions') ? null : 'mt-auto']}>
|
||||
<button id="logout">
|
||||
<Icon name="heroicons:arrow-left-end-on-rectangle" />
|
||||
<span>Ausloggen</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="w-full overflow-scroll relative">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<Popup client:idle />
|
||||
<ConfirmPopup client:idle />
|
||||
|
||||
<script>
|
||||
import { actions } from 'astro:actions';
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const logout = document.getElementById('logout') as HTMLButtonElement;
|
||||
logout.addEventListener('click', async () => {
|
||||
await actions.session.logout();
|
||||
window.location.href = `${document.baseURI}admin/login`;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
16
src/layouts/admin/AdminLoginLayout.astro
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import '@assets/admin_layout.css';
|
||||
import BaseLayout from '../BaseLayout.astro';
|
||||
import { ClientRouter } from 'astro:transitions';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout title={title}>
|
||||
<ClientRouter />
|
||||
<slot />
|
||||
</BaseLayout>
|
||||
43
src/layouts/website/WebsiteLayout.astro
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
import '@assets/website_layout.css';
|
||||
import BaseLayout from '../BaseLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import Menu from '@app/layout/Menu.svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
|
||||
footer?: boolean;
|
||||
}
|
||||
|
||||
const { title, description, footer = true } = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description} openGraph={true} viewTransition>
|
||||
<main class="min-h-[calc(100vh-3.5rem)] h-full w-full relative overflow-y-hidden">
|
||||
<slot />
|
||||
</main>
|
||||
<nav>
|
||||
<Menu client:load transition:persist />
|
||||
</nav>
|
||||
<footer class="flex justify-around items-center h-14 w-full bg-base-300 relative" hidden={!footer}>
|
||||
<div class="hidden sm:block">
|
||||
<p>© {new Date().getFullYear()} mhsl.eu</p>
|
||||
</div>
|
||||
<div class="flex gap-12">
|
||||
<div class="flex gap-4">
|
||||
<a class="link" href="https://mhsl.eu/id.html" target="_blank">Impressum</a>
|
||||
<a class="link" href="https://mhsl.eu/datenschutz.html" target="_blank">Datenschutz</a>
|
||||
</div>
|
||||
<div class="hidden sm:flex gap-2 items-center">
|
||||
<a href="ts3server://mhsl.eu?port=9987" target="_blank">
|
||||
<Icon name="fa-brands:teamspeak" />
|
||||
</a>
|
||||
<a href="https://discord.gg/EBGefWPc2K" target="_blank">
|
||||
<Icon name="fa-brands:discord" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</BaseLayout>
|
||||
@@ -1,70 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
let { start, end }: { start?: number; end: number } = $props();
|
||||
|
||||
let title = `Spielstart ist am ${new Date(env.PUBLIC_START_DATE).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})} Uhr`;
|
||||
|
||||
function getUntil(): [number, number, number, number] {
|
||||
let diff = (end - (start || Date.now())) / 1000;
|
||||
|
||||
return [
|
||||
Math.floor(diff / (60 * 60 * 24)),
|
||||
Math.floor((diff % (60 * 60 * 24)) / (60 * 60)),
|
||||
Math.floor((diff % (60 * 60)) / 60),
|
||||
Math.floor(diff % 60)
|
||||
];
|
||||
}
|
||||
|
||||
let [days, hours, minutes, seconds] = $state(getUntil());
|
||||
let intervalId = setInterval(() => {
|
||||
[days, hours, minutes, seconds] = getUntil();
|
||||
if (start) start += 1000;
|
||||
}, 1000);
|
||||
|
||||
onDestroy(() => clearInterval(intervalId));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class:hidden={days + hours + minutes + seconds < 0}
|
||||
class="grid grid-flow-col gap-5 text-center auto-cols-max text-white"
|
||||
>
|
||||
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}>
|
||||
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||
<span class="m-auto" style="--value:{days};"></span>
|
||||
</span>
|
||||
Tage
|
||||
</div>
|
||||
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}>
|
||||
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||
<span class="m-auto" style="--value:{hours};"></span>
|
||||
</span>
|
||||
Stunden
|
||||
</div>
|
||||
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}>
|
||||
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||
<span class="m-auto" style="--value:{minutes};"></span>
|
||||
</span>
|
||||
Minuten
|
||||
</div>
|
||||
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}>
|
||||
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||
<span class="m-auto" style="--value:{seconds};"></span>
|
||||
</span>
|
||||
Sekunden
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Set a custom content for the countdown before selector as it only supports numbers up to 99 */
|
||||
.countdown > ::before {
|
||||
content: '00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A 100\A 101\A 102\A 103\A 104\A 105\A 106\A 107\A 108\A 109\A 110\A 111\A 112\A 113\A 114\A 115\A 116\A 117\A 118\A 119\A 120\A 121\A 122\A 123\A 124\A 125\A 126\A 127\A 128\A 129\A 130\A 131\A 132\A 133\A 134\A 135\A 136\A 137\A 138\A 139\A 140\A 141\A 142\A 143\A 144\A 145\A 146\A 147\A 148\A 149\A 150\A 151\A 152\A 153\A 154\A 155\A 156\A 157\A 158\A 159\A 160\A 161\A 162\A 163\A 164\A 165\A 166\A 167\A 168\A 169\A 170\A 171\A 172\A 173\A 174\A 175\A 176\A 177\A 178\A 179\A 180\A 181\A 182\A 183\A 184\A 185\A 186\A 187\A 188\A 189\A 190\A 191\A 192\A 193\A 194\A 195\A 196\A 197\A 198\A 199\A 200\A 201\A 202\A 203\A 204\A 205\A 206\A 207\A 208\A 209\A 210\A 211\A 212\A 213\A 214\A 215\A 216\A 217\A 218\A 219\A 220\A 221\A 222\A 223\A 224\A 225\A 226\A 227\A 228\A 229\A 230\A 231\A 232\A 233\A 234\A 235\A 236\A 237\A 238\A 239\A 240\A 241\A 242\A 243\A 244\A 245\A 246\A 247\A 248\A 249\A 250\A 251\A 252\A 253\A 254\A 255\A 256\A 257\A 258\A 259\A 260\A 261\A 262\A 263\A 264\A 265\A 266\A 267\A 268\A 269\A 270\A 271\A 272\A 273\A 274\A 275\A 276\A 277\A 278\A 279\A 280\A 281\A 282\A 283\A 284\A 285\A 286\A 287\A 288\A 289\A 290\A 291\A 292\A 293\A 294\A 295\A 296\A 297\A 298\A 299\A 300\A 301\A 302\A 303\A 304\A 305\A 306\A 307\A 308\A 309\A 310\A 311\A 312\A 313\A 314\A 315\A 316\A 317\A 318\A 319\A 320\A 321\A 322\A 323\A 324\A 325\A 326\A 327\A 328\A 329\A 330\A 331\A 332\A 333\A 334\A 335\A 336\A 337\A 338\A 339\A 340\A 341\A 342\A 343\A 344\A 345\A 346\A 347\A 348\A 349\A 350\A 351\A 352\A 353\A 354\A 355\A 356\A 357\A 358\A 359\A 360\A 361\A 362\A 363\A 364\A';
|
||||
}
|
||||
</style>
|
||||