Compare commits
6 commits
cbe88c141b
...
42cbcbac3c
| Author | SHA1 | Date | |
|---|---|---|---|
| 42cbcbac3c | |||
| 0dd9673690 | |||
| fdf6da5c5b | |||
| 6fe7dd62bd | |||
| dcaa8c9bbe | |||
| 1b552b3a6d |
16 changed files with 24812 additions and 115 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.25",
|
"version": "0.2.26",
|
||||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,15 @@ export async function uploadSideOpModlist(
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
return { success: false, error: "Mod list file must be 10 MB or smaller." };
|
return { success: false, error: "Mod list file must be 10 MB or smaller." };
|
||||||
}
|
}
|
||||||
|
// Only HTML mod lists exported from the Arma 3 launcher are supported. Some
|
||||||
|
// browsers report an empty file.type, so also accept by extension.
|
||||||
|
const isHtml = file.type === "text/html" || /\.html?$/i.test(file.name);
|
||||||
|
if (!isHtml) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Mod list must be an HTML file exported from the Arma 3 launcher.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const created = await payload.create({
|
const created = await payload.create({
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import config from "@payload-config";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
import { broadcastToUser } from "@/lib/realtime/bus";
|
|
||||||
import { notifyUser } from "@/lib/notifications";
|
|
||||||
import hasRoles from "@/utils/access-control/hasRoles";
|
import hasRoles from "@/utils/access-control/hasRoles";
|
||||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
import { transcodeToMp3 } from "@/lib/voiceover/transcode";
|
import { transcodeToMp3 } from "@/lib/voiceover/transcode";
|
||||||
|
|
@ -245,34 +243,6 @@ export async function reviewVoiceSubmission(
|
||||||
return { success: false, error: "The script for this submission no longer exists." };
|
return { success: false, error: "The script for this submission no longer exists." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitterId = typeof submission.user === "number" ? submission.user : submission.user.id;
|
|
||||||
|
|
||||||
// Resolve the profile before mutating anything so a missing profile fails
|
|
||||||
// the review cleanly instead of leaving the submission accepted without XP.
|
|
||||||
let profileId: number | undefined;
|
|
||||||
let progression: {
|
|
||||||
xp?: unknown;
|
|
||||||
voiceover?: { xpEarned?: number };
|
|
||||||
} = {};
|
|
||||||
if (decision === "accepted") {
|
|
||||||
const profileRes = await payload.find({
|
|
||||||
collection: "profiles",
|
|
||||||
where: { user: { equals: submitterId } },
|
|
||||||
limit: 1,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
const profile = profileRes.docs[0];
|
|
||||||
if (!profile) {
|
|
||||||
return { success: false, error: "No profile found for the submitter." };
|
|
||||||
}
|
|
||||||
profileId = profile.id;
|
|
||||||
progression = (profile.progression ?? {}) as {
|
|
||||||
xp?: unknown;
|
|
||||||
voiceover?: { xpEarned?: number };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await payload.update({
|
await payload.update({
|
||||||
collection: "voice-submissions",
|
collection: "voice-submissions",
|
||||||
id: submissionId,
|
id: submissionId,
|
||||||
|
|
@ -284,65 +254,6 @@ export async function reviewVoiceSubmission(
|
||||||
depth: 0,
|
depth: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (decision === "accepted" && profileId !== undefined) {
|
|
||||||
const voiceover = progression.voiceover ?? {};
|
|
||||||
const xpEarned = (voiceover.xpEarned ?? 0) + script.xpAmount;
|
|
||||||
// Strip the virtual `xp` field before writing (computed, not stored).
|
|
||||||
const { xp: _xp, ...progressionRest } = progression;
|
|
||||||
|
|
||||||
await payload.update({
|
|
||||||
collection: "profiles",
|
|
||||||
id: profileId,
|
|
||||||
data: {
|
|
||||||
progression: {
|
|
||||||
...progressionRest,
|
|
||||||
voiceover: {
|
|
||||||
...voiceover,
|
|
||||||
xpEarned,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
broadcastToUser("profile-update", submitterId, {
|
|
||||||
reason: "xp",
|
|
||||||
xpGained: script.xpAmount,
|
|
||||||
profileId,
|
|
||||||
});
|
|
||||||
await notifyUser(payload, {
|
|
||||||
userId: submitterId,
|
|
||||||
type: "voiceover:accepted",
|
|
||||||
title: "Voiceover accepted",
|
|
||||||
message: `Your recording for "${script.title}" was accepted (+${script.xpAmount} XP).`,
|
|
||||||
link: "/voiceover",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await notifyUser(payload, {
|
|
||||||
userId: submitterId,
|
|
||||||
type: "voiceover:rejected",
|
|
||||||
title: "Voiceover rejected",
|
|
||||||
message: `Your recording for "${script.title}" was not accepted this time.`,
|
|
||||||
link: "/voiceover",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: decision === "accepted" ? EventTypes.VoiceoverAccepted : EventTypes.VoiceoverRejected,
|
|
||||||
message: `${user.displayName ?? user.username} ${decision} a voiceover submission for "${script.title}"`,
|
|
||||||
actor: user.id,
|
|
||||||
targetCollection: "voice-submissions",
|
|
||||||
targetId: submissionId,
|
|
||||||
data: {
|
|
||||||
scriptId: script.id,
|
|
||||||
scriptTitle: script.title,
|
|
||||||
submitterId,
|
|
||||||
decision,
|
|
||||||
reviewNote: reviewNote ?? null,
|
|
||||||
xpAwarded: decision === "accepted" ? script.xpAmount : 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, data: { id: submissionId, status: decision } };
|
return { success: true, data: { id: submissionId, status: decision } };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { default as default_2f2033acccb3be245f1a85f7b4271dac } from '@/component
|
||||||
import { default as default_4b88a624e9f0b376cffcc6814d459adb } from '@/components/admin/assignments/AssignmentMembersField'
|
import { default as default_4b88a624e9f0b376cffcc6814d459adb } from '@/components/admin/assignments/AssignmentMembersField'
|
||||||
import { default as default_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription'
|
import { default as default_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription'
|
||||||
import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage'
|
import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage'
|
||||||
|
import { AudioPreviewField as AudioPreviewField_6bccef9bf4ea41ed573ab9f36fa579b2 } from '@/components/admin/voiceover/AudioPreviewField'
|
||||||
import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor'
|
import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor'
|
||||||
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
|
@ -58,6 +59,7 @@ export const importMap = {
|
||||||
"@/components/admin/assignments/AssignmentMembersField#default": default_4b88a624e9f0b376cffcc6814d459adb,
|
"@/components/admin/assignments/AssignmentMembersField#default": default_4b88a624e9f0b376cffcc6814d459adb,
|
||||||
"@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360,
|
"@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360,
|
||||||
"@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825,
|
"@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825,
|
||||||
|
"@/components/admin/voiceover/AudioPreviewField#AudioPreviewField": AudioPreviewField_6bccef9bf4ea41ed573ab9f36fa579b2,
|
||||||
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,
|
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,
|
||||||
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
|
|
||||||
|
|
@ -63,13 +63,15 @@ export const VoiceScripts: CollectionConfig = {
|
||||||
name: "status",
|
name: "status",
|
||||||
type: "select",
|
type: "select",
|
||||||
required: true,
|
required: true,
|
||||||
defaultValue: "open",
|
defaultValue: "draft",
|
||||||
options: [
|
options: [
|
||||||
|
{ label: "Draft", value: "draft" },
|
||||||
{ label: "Open", value: "open" },
|
{ label: "Open", value: "open" },
|
||||||
{ label: "Closed", value: "closed" },
|
{ label: "Closed", value: "closed" },
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
description: "Closed scripts reject new submissions",
|
description:
|
||||||
|
"Draft scripts are hidden from the frontend. Open scripts accept submissions; closed scripts reject new ones.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,186 @@
|
||||||
import { CollectionConfig } from "payload";
|
import {
|
||||||
import type { User } from "@/payload-types";
|
CollectionConfig,
|
||||||
|
type CollectionAfterChangeHook,
|
||||||
|
type CollectionBeforeChangeHook,
|
||||||
|
} from "payload";
|
||||||
|
import type { User, VoiceScript, VoiceSubmission } from "@/payload-types";
|
||||||
import hasRoles from "@/utils/access-control/hasRoles";
|
import hasRoles from "@/utils/access-control/hasRoles";
|
||||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
|
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||||
|
import { notifyUser } from "@/lib/notifications";
|
||||||
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
|
||||||
|
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
|
async function fetchScript(payload: PayloadType, id: number): Promise<VoiceScript | null> {
|
||||||
|
try {
|
||||||
|
return await payload.findByID({
|
||||||
|
collection: "voice-scripts",
|
||||||
|
id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptIdOf = (script: VoiceSubmission["script"]): number =>
|
||||||
|
typeof script === "object" && script !== null ? script.id : script;
|
||||||
|
|
||||||
|
const submitterIdOf = (user: VoiceSubmission["user"]): number =>
|
||||||
|
typeof user === "object" && user !== null ? user.id : user;
|
||||||
|
|
||||||
|
const reviewerName = (reqUser: unknown): string => {
|
||||||
|
const user = reqUser as Pick<User, "displayName" | "username"> | null | undefined;
|
||||||
|
return user?.displayName ?? user?.username ?? "A reviewer";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XP and notifications for review decisions live in hooks so both the
|
||||||
|
* frontend action and direct admin-panel edits take the same path. XP is
|
||||||
|
* awarded only on a pending-to-accepted transition, so ordinary re-saves
|
||||||
|
* never double-award.
|
||||||
|
*/
|
||||||
|
const guardReviewTransition: CollectionBeforeChangeHook = async ({
|
||||||
|
operation,
|
||||||
|
data,
|
||||||
|
originalDoc,
|
||||||
|
req,
|
||||||
|
}) => {
|
||||||
|
if (operation !== "update") return data;
|
||||||
|
const nextStatus = data?.status;
|
||||||
|
if (!nextStatus || nextStatus === originalDoc?.status) return data;
|
||||||
|
if (nextStatus !== "accepted" && nextStatus !== "rejected") return data;
|
||||||
|
|
||||||
|
const script = await fetchScript(req.payload, scriptIdOf(originalDoc.script));
|
||||||
|
if (!script) {
|
||||||
|
throw new Error("The script for this submission no longer exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStatus === "accepted") {
|
||||||
|
const profileRes = await req.payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: submitterIdOf(originalDoc.user) } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
if (!profileRes.docs[0]) {
|
||||||
|
throw new Error("No profile found for the submitter.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyReviewSideEffects: CollectionAfterChangeHook = async ({
|
||||||
|
operation,
|
||||||
|
doc,
|
||||||
|
previousDoc,
|
||||||
|
req,
|
||||||
|
}) => {
|
||||||
|
if (operation !== "update") return doc;
|
||||||
|
if (previousDoc?.status !== "pending") return doc;
|
||||||
|
if (doc.status !== "accepted" && doc.status !== "rejected") return doc;
|
||||||
|
|
||||||
|
const submitterId = submitterIdOf(doc.user);
|
||||||
|
const script = await fetchScript(req.payload, scriptIdOf(doc.script));
|
||||||
|
|
||||||
|
if (doc.status === "accepted") {
|
||||||
|
const profileRes = await req.payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: submitterId } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const profile = profileRes.docs[0];
|
||||||
|
if (!profile) {
|
||||||
|
req.payload.logger.error(
|
||||||
|
`Voiceover submission ${doc.id} accepted but submitter ${submitterId} has no profile; XP not awarded.`,
|
||||||
|
);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progression = (profile.progression ?? {}) as {
|
||||||
|
xp?: unknown;
|
||||||
|
voiceover?: { xpEarned?: number };
|
||||||
|
};
|
||||||
|
const voiceover = progression.voiceover ?? {};
|
||||||
|
const xpGained = script?.xpAmount ?? 0;
|
||||||
|
const { xp: _xp, ...progressionRest } = progression;
|
||||||
|
|
||||||
|
await req.payload.update({
|
||||||
|
collection: "profiles",
|
||||||
|
id: profile.id,
|
||||||
|
data: {
|
||||||
|
progression: {
|
||||||
|
...progressionRest,
|
||||||
|
voiceover: {
|
||||||
|
...voiceover,
|
||||||
|
xpEarned: (voiceover.xpEarned ?? 0) + xpGained,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
broadcastToUser("profile-update", submitterId, {
|
||||||
|
reason: "xp",
|
||||||
|
xpGained,
|
||||||
|
profileId: profile.id,
|
||||||
|
});
|
||||||
|
await notifyUser(req.payload, {
|
||||||
|
userId: submitterId,
|
||||||
|
type: "voiceover:accepted",
|
||||||
|
title: "Voiceover accepted",
|
||||||
|
message: `Your recording for "${script?.title ?? "a script"}" was accepted (+${xpGained} XP).`,
|
||||||
|
link: "/voiceover",
|
||||||
|
});
|
||||||
|
await emitGameEvent(req.payload, {
|
||||||
|
type: EventTypes.VoiceoverAccepted,
|
||||||
|
message: `${reviewerName(req.user)} accepted a voiceover submission for "${script?.title ?? "a script"}"`,
|
||||||
|
actor: typeof req.user === "object" && req.user !== null ? req.user.id : undefined,
|
||||||
|
targetCollection: "voice-submissions",
|
||||||
|
targetId: doc.id,
|
||||||
|
data: {
|
||||||
|
scriptId: scriptIdOf(doc.script),
|
||||||
|
scriptTitle: script?.title ?? null,
|
||||||
|
submitterId,
|
||||||
|
decision: "accepted",
|
||||||
|
reviewNote: doc.reviewNote ?? null,
|
||||||
|
xpAwarded: xpGained,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
await notifyUser(req.payload, {
|
||||||
|
userId: submitterId,
|
||||||
|
type: "voiceover:rejected",
|
||||||
|
title: "Voiceover rejected",
|
||||||
|
message: `Your recording for "${script?.title ?? "a script"}" was not accepted this time.`,
|
||||||
|
link: "/voiceover",
|
||||||
|
});
|
||||||
|
await emitGameEvent(req.payload, {
|
||||||
|
type: EventTypes.VoiceoverRejected,
|
||||||
|
message: `${reviewerName(req.user)} rejected a voiceover submission for "${script?.title ?? "a script"}"`,
|
||||||
|
actor: typeof req.user === "object" && req.user !== null ? req.user.id : undefined,
|
||||||
|
targetCollection: "voice-submissions",
|
||||||
|
targetId: doc.id,
|
||||||
|
data: {
|
||||||
|
scriptId: scriptIdOf(doc.script),
|
||||||
|
scriptTitle: script?.title ?? null,
|
||||||
|
submitterId,
|
||||||
|
decision: "rejected",
|
||||||
|
reviewNote: doc.reviewNote ?? null,
|
||||||
|
xpAwarded: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return doc;
|
||||||
|
};
|
||||||
|
|
||||||
export const VoiceSubmissions: CollectionConfig = {
|
export const VoiceSubmissions: CollectionConfig = {
|
||||||
slug: "voice-submissions",
|
slug: "voice-submissions",
|
||||||
|
|
@ -35,6 +214,16 @@ export const VoiceSubmissions: CollectionConfig = {
|
||||||
delete: ({ req }) => hasRoles(["developer"], req.user),
|
delete: ({ req }) => hasRoles(["developer"], req.user),
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
|
{
|
||||||
|
name: "audioPreview",
|
||||||
|
type: "ui",
|
||||||
|
label: "Take audio",
|
||||||
|
admin: {
|
||||||
|
components: {
|
||||||
|
Field: "@/components/admin/voiceover/AudioPreviewField#AudioPreviewField",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "script",
|
name: "script",
|
||||||
type: "relationship",
|
type: "relationship",
|
||||||
|
|
@ -86,4 +275,8 @@ export const VoiceSubmissions: CollectionConfig = {
|
||||||
// scripts, …) stay blocked by Payload's built-in checks.
|
// scripts, …) stay blocked by Payload's built-in checks.
|
||||||
upload: true,
|
upload: true,
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
|
hooks: {
|
||||||
|
beforeChange: [guardReviewTransition],
|
||||||
|
afterChange: [applyReviewSideEffects],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
64
src/components/admin/voiceover/AudioPreviewField.tsx
Normal file
64
src/components/admin/voiceover/AudioPreviewField.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useDocumentInfo } from "@payloadcms/ui";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type UploadDocData = {
|
||||||
|
filename?: string | null;
|
||||||
|
filesize?: number | null;
|
||||||
|
mimeType?: string | null;
|
||||||
|
url?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number | null | undefined): string => {
|
||||||
|
if (!bytes || bytes <= 0) return "";
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline audio player for upload collections. Registered on voice-submissions as
|
||||||
|
* a `ui` field so reviewers can listen to a take directly in the admin panel.
|
||||||
|
* Reads the document's file URL from document info (no form state involved).
|
||||||
|
*/
|
||||||
|
export const AudioPreviewField = () => {
|
||||||
|
const { data } = useDocumentInfo();
|
||||||
|
const doc = (data ?? {}) as UploadDocData;
|
||||||
|
const url = doc.url ?? (doc.filename ? `/api/media/file/${doc.filename}` : "");
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "var(--theme-elevation-500)",
|
||||||
|
fontStyle: "italic",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No audio file on this submission yet.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = [doc.filename, doc.mimeType, formatBytes(doc.filesize)].filter(Boolean).join(" | ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<audio controls preload="metadata" src={url} style={{ width: "100%" }}>
|
||||||
|
Your browser does not support audio playback.
|
||||||
|
</audio>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "var(--theme-elevation-500)",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginTop: "4px",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudioPreviewField;
|
||||||
|
|
@ -34,7 +34,6 @@ export function EodArena({
|
||||||
const clearedSectors = [
|
const clearedSectors = [
|
||||||
initialStats.bestTrainingTime,
|
initialStats.bestTrainingTime,
|
||||||
initialStats.bestPerimeterTime,
|
initialStats.bestPerimeterTime,
|
||||||
initialStats.bestSupplyRoadTime,
|
|
||||||
].filter((time) => time > 0).length;
|
].filter((time) => time > 0).length;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -55,8 +54,8 @@ export function EodArena({
|
||||||
{!customUnlocked && (
|
{!customUnlocked && (
|
||||||
<UnlockNotice
|
<UnlockNotice
|
||||||
title="custom minefields"
|
title="custom minefields"
|
||||||
requirement="you have cleared Training Pad, FOB Perimeter, and Main Supply Road"
|
requirement="you have cleared Training Pad and FOB Perimeter"
|
||||||
progress={`${clearedSectors} of 3 sectors cleared`}
|
progress={`${clearedSectors} of 2 sectors cleared`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MinesweeperBoard
|
<MinesweeperBoard
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
||||||
import { useSessionExpired } from "@/hooks/useSessionExpired";
|
import { useSessionExpired } from "@/hooks/useSessionExpired";
|
||||||
import { LoginLink } from "@/components/frontend/auth/LoginLink";
|
import { LoginLink } from "@/components/frontend/auth/LoginLink";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { isEodUnlocked } from "@/lib/minigames/custom/eod";
|
||||||
import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds";
|
import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds";
|
||||||
|
|
||||||
type Phase = "ready" | "playing" | "over";
|
type Phase = "ready" | "playing" | "over";
|
||||||
|
|
@ -227,8 +228,11 @@ export function MinesweeperBoard({
|
||||||
const longPressTimerRef = useRef<number | null>(null);
|
const longPressTimerRef = useRef<number | null>(null);
|
||||||
const baseConfig: GameConfig = difficulty === "custom" ? customConfig : DIFFICULTIES[difficulty];
|
const baseConfig: GameConfig = difficulty === "custom" ? customConfig : DIFFICULTIES[difficulty];
|
||||||
const config = isMobile ? getMobileConfig(baseConfig, isLandscape) : baseConfig;
|
const config = isMobile ? getMobileConfig(baseConfig, isLandscape) : baseConfig;
|
||||||
const customUnlocked =
|
const customUnlocked = isEodUnlocked({
|
||||||
stats.bestTrainingTime > 0 && stats.bestPerimeterTime > 0 && stats.bestSupplyRoadTime > 0;
|
bestTrainingTime: stats.bestTrainingTime,
|
||||||
|
bestPerimeterTime: stats.bestPerimeterTime,
|
||||||
|
bestSupplyRoadTime: stats.bestSupplyRoadTime,
|
||||||
|
});
|
||||||
const isLargeCustomGame =
|
const isLargeCustomGame =
|
||||||
difficulty === "custom" && config.rows * config.cols > CUSTOM_FULLSCREEN_CELL_THRESHOLD;
|
difficulty === "custom" && config.rows * config.cols > CUSTOM_FULLSCREEN_CELL_THRESHOLD;
|
||||||
const flaggedCount = board.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0);
|
const flaggedCount = board.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0);
|
||||||
|
|
@ -812,7 +816,7 @@ export function MinesweeperBoard({
|
||||||
</div>
|
</div>
|
||||||
) : !customUnlocked ? (
|
) : !customUnlocked ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Clear all three standard locations to unlock custom minefields.
|
Clear the Training Pad and FOB Perimeter to unlock custom minefields.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,9 @@ export interface EodUnlockStats {
|
||||||
bestSupplyRoadTime: number;
|
bestSupplyRoadTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A player unlocks the custom minefield editor once they cleared all three standard difficulties. */
|
/** A player unlocks the custom minefield editor once they cleared Training Pad and FOB Perimeter. */
|
||||||
export function isEodUnlocked(stats: EodUnlockStats): boolean {
|
export function isEodUnlocked(stats: EodUnlockStats): boolean {
|
||||||
return (
|
return stats.bestTrainingTime > 0 && stats.bestPerimeterTime > 0;
|
||||||
stats.bestTrainingTime > 0 &&
|
|
||||||
stats.bestPerimeterTime > 0 &&
|
|
||||||
stats.bestSupplyRoadTime > 0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateMinefieldName(name: unknown): { error?: string; name: string } {
|
export function validateMinefieldName(name: unknown): { error?: string; name: string } {
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ export async function listScriptsForUser(
|
||||||
const [scriptsRes, submissionsRes] = await Promise.all([
|
const [scriptsRes, submissionsRes] = await Promise.all([
|
||||||
payload.find({
|
payload.find({
|
||||||
collection: "voice-scripts",
|
collection: "voice-scripts",
|
||||||
|
where: { status: { not_equals: "draft" } },
|
||||||
sort: "-createdAt",
|
sort: "-createdAt",
|
||||||
limit: 0,
|
limit: 0,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
|
|
@ -121,7 +122,7 @@ export async function getScriptForUser(
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
})
|
})
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
if (!script) return null;
|
if (!script || script.status === "draft") return null;
|
||||||
|
|
||||||
const submissionsRes = await payload.find({
|
const submissionsRes = await payload.find({
|
||||||
collection: "voice-submissions",
|
collection: "voice-submissions",
|
||||||
|
|
|
||||||
24488
src/migrations/20260909_223955_add_voice_script_draft_status.json
Normal file
24488
src/migrations/20260909_223955_add_voice_script_draft_status.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TYPE "public"."enum_voice_scripts_status" ADD VALUE IF NOT EXISTS 'draft' BEFORE 'open';`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "voice_scripts" ALTER COLUMN "status" SET DATA TYPE text;
|
||||||
|
ALTER TABLE "voice_scripts" ALTER COLUMN "status" SET DEFAULT 'open'::text;
|
||||||
|
DROP TYPE "public"."enum_voice_scripts_status";
|
||||||
|
CREATE TYPE "public"."enum_voice_scripts_status" AS ENUM('open', 'closed');
|
||||||
|
ALTER TABLE "voice_scripts" ALTER COLUMN "status" SET DEFAULT 'open'::"public"."enum_voice_scripts_status";
|
||||||
|
ALTER TABLE "voice_scripts" ALTER COLUMN "status" SET DATA TYPE "public"."enum_voice_scripts_status" USING "status"::"public"."enum_voice_scripts_status";`);
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,7 @@ import * as migration_20260908_172241_add_custom_minigame_collections from './20
|
||||||
import * as migration_20260908_193107_add_creator_xp_bank_field from './20260908_193107_add_creator_xp_bank_field';
|
import * as migration_20260908_193107_add_creator_xp_bank_field from './20260908_193107_add_creator_xp_bank_field';
|
||||||
import * as migration_20260908_194435_add_expose_edge_mines_field from './20260908_194435_add_expose_edge_mines_field';
|
import * as migration_20260908_194435_add_expose_edge_mines_field from './20260908_194435_add_expose_edge_mines_field';
|
||||||
import * as migration_20260909_153427_add_opord_recall_best_times from './20260909_153427_add_opord_recall_best_times';
|
import * as migration_20260909_153427_add_opord_recall_best_times from './20260909_153427_add_opord_recall_best_times';
|
||||||
|
import * as migration_20260909_223955_add_voice_script_draft_status from './20260909_223955_add_voice_script_draft_status';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -304,6 +305,11 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260909_153427_add_opord_recall_best_times.up,
|
up: migration_20260909_153427_add_opord_recall_best_times.up,
|
||||||
down: migration_20260909_153427_add_opord_recall_best_times.down,
|
down: migration_20260909_153427_add_opord_recall_best_times.down,
|
||||||
name: '20260909_153427_add_opord_recall_best_times'
|
name: '20260909_153427_add_opord_recall_best_times',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260909_223955_add_voice_script_draft_status.up,
|
||||||
|
down: migration_20260909_223955_add_voice_script_draft_status.down,
|
||||||
|
name: '20260909_223955_add_voice_script_draft_status'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -3157,9 +3157,9 @@ export interface VoiceScript {
|
||||||
*/
|
*/
|
||||||
spoiler?: boolean | null;
|
spoiler?: boolean | null;
|
||||||
/**
|
/**
|
||||||
* Closed scripts reject new submissions
|
* Draft scripts are hidden from the frontend. Open scripts accept submissions; closed scripts reject new ones.
|
||||||
*/
|
*/
|
||||||
status: 'open' | 'closed';
|
status: 'draft' | 'open' | 'closed';
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -465,7 +465,7 @@ describe("Custom minefields", () => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("Unlock gating", () => {
|
describe("Unlock gating", () => {
|
||||||
it("locks custom minefields until a win is recorded on all three difficulties", () => {
|
it("locks custom minefields until Training Pad and FOB Perimeter are cleared", () => {
|
||||||
expect(
|
expect(
|
||||||
isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }),
|
isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
|
@ -473,18 +473,24 @@ describe("Custom minefields", () => {
|
||||||
isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }),
|
isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 60, bestSupplyRoadTime: 0 }),
|
isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 60, bestSupplyRoadTime: 0 }),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unlocks once every difficulty has a best time above zero", () => {
|
it("unlocks once Training Pad and FOB Perimeter have best times above zero", () => {
|
||||||
expect(
|
expect(
|
||||||
isEodUnlocked({ bestTrainingTime: 1, bestPerimeterTime: 1, bestSupplyRoadTime: 1 }),
|
isEodUnlocked({ bestTrainingTime: 1, bestPerimeterTime: 1, bestSupplyRoadTime: 0 }),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 60, bestSupplyRoadTime: 120 }),
|
isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 60, bestSupplyRoadTime: 0 }),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not let a Main Supply Road win substitute for the required sectors", () => {
|
||||||
|
expect(
|
||||||
|
isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 0, bestSupplyRoadTime: 120 }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue