-
{{ getFormattedMessage(messages.publishingChecklist) }}
+
-
-
-
-
+
+
+
+
- {{ getFormattedMessage(nag.title) }}
-
- {{ getNagDescription(nag) }}
+
{{ nag.title }}
+ {{ nag.description }}
- {{ getFormattedMessage(nag.link.title) }}
+ {{ nag.link.title }}
-
-
-
+
+ {{ nag.action.title }}
+
-
-const emit = defineEmits<{
- toggleCollapsed: [];
- updateMembers: [];
- setProcessing: [processing: boolean];
-}>();
+
diff --git a/apps/frontend/src/components/ui/moderation/ModerationDelphiReportCard.vue b/apps/frontend/src/components/ui/moderation/ModerationDelphiReportCard.vue
new file mode 100644
index 0000000000..961f340a34
--- /dev/null
+++ b/apps/frontend/src/components/ui/moderation/ModerationDelphiReportCard.vue
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
{{ report.project.title }}
+
+
+
+
+
+
+ {{ report.target.name }}
+
+
+
+
+
+ Score: {{ report.priority_score }}
+
+
+ {{ report.status.charAt(0).toUpperCase() + report.status.slice(1) }}
+
+
+ {{
+ report.version.files.find((file) => file.primary)?.filename ||
+ "Unknown primary file"
+ }}
+
+
+
+
+
+
+
+
+ {{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Copy ID
+
+
+
+ Copy link
+
+
+
+
+
+
+
+
+
+ {{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/moderation/ModerationQueueCard.vue b/apps/frontend/src/components/ui/moderation/ModerationQueueCard.vue
new file mode 100644
index 0000000000..8dcb0f8f5d
--- /dev/null
+++ b/apps/frontend/src/components/ui/moderation/ModerationQueueCard.vue
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+ {{ project.title }}
+
+
+
+ {{ enrichedProject.owner.user.username }}
+
+
+
+ {{ enrichedProject.org.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatProjectType(project.project_type) }}
+ {{
+ formatProjectType(project.project_type).substring(0, 3)
+ }}
+
+
+
•
+
+
+ Requesting
+
+
+
+
•
+
+
+ {{ getSubmittedTime(project) }}
+ {{ getSubmittedTime(project).replace("Submitted ", "") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue b/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue
new file mode 100644
index 0000000000..0e006db50e
--- /dev/null
+++ b/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue
@@ -0,0 +1,267 @@
+
+
+
+
+
+ Reported for
+
+ {{ formattedReportType }}
+
+
+
+ By
+ Reporter:
+
+
+ {{ report.reporter_user.username }}
+
+
+
+
+
+ {{
+ formatRelativeTime(report.created)
+ }}
+
+
+ Quick Reply
+ Quick Reply
+
+
+
+
+
+
+
+
+
+
+ Copy ID
+
+
+
+ Copy link
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ reportItemTitle }}
+
+
+
+
+
+ {{ report.target.name || "Unknown User" }}
+
+
+
+
+
+ {{ formattedItemType }}
+
+
+ {{
+ report.version.files.find((file) => file.primary)?.filename || "Unknown Version"
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/moderation/ChecklistKeybindsModal.vue b/apps/frontend/src/components/ui/moderation/checklist/ChecklistKeybindsModal.vue
similarity index 100%
rename from apps/frontend/src/components/ui/moderation/ChecklistKeybindsModal.vue
rename to apps/frontend/src/components/ui/moderation/checklist/ChecklistKeybindsModal.vue
diff --git a/apps/frontend/src/components/ui/moderation/NewModerationChecklist.vue b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue
similarity index 87%
rename from apps/frontend/src/components/ui/moderation/NewModerationChecklist.vue
rename to apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue
index 2126091ad6..44f928dbdf 100644
--- a/apps/frontend/src/components/ui/moderation/NewModerationChecklist.vue
+++ b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue
@@ -42,9 +42,9 @@
You are done moderating this project!
-
+
There are
- {{ futureProjectCount }} left.
+ {{ moderationStore.queueLength }} left.
@@ -215,49 +215,31 @@
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
>
-
-
-
-
-
- Next Project
-
-
-
-
-
- Done
+
+
+
+
+ Next Project ({{ moderationStore.queueLength }} left)
+
+
+
+ All Done!
+
-
-
-
-
-
-
-
-
-
- {{ opt.text }}
-
-
@@ -368,44 +350,41 @@ import {
DropdownSelect,
MarkdownEditor,
} from "@modrinth/ui";
-import { type Project, renderHighlightedString, type ModerationJudgements } from "@modrinth/utils";
+import {
+ type Project,
+ renderHighlightedString,
+ type ModerationJudgements,
+ type ModerationModpackItem,
+} from "@modrinth/utils";
import { computedAsync, useLocalStorage } from "@vueuse/core";
-import type {
- Action,
- MultiSelectChipsAction,
- DropdownAction,
- ButtonAction,
- ToggleAction,
- ConditionalButtonAction,
- Stage,
+import {
+ type Action,
+ type MultiSelectChipsAction,
+ type DropdownAction,
+ type ButtonAction,
+ type ToggleAction,
+ type ConditionalButtonAction,
+ type Stage,
+ finalPermissionMessages,
} from "@modrinth/moderation";
+import * as prettier from "prettier";
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
import KeybindsModal from "./ChecklistKeybindsModal.vue";
-import { finalPermissionMessages } from "@modrinth/moderation/data/modpack-permissions-stage";
-import prettier from "prettier";
+import { useModerationStore } from "~/store/moderation.ts";
const keybindsModal = ref>();
-const props = withDefaults(
- defineProps<{
- project: Project;
- futureProjectIds?: string[];
- collapsed: boolean;
- }>(),
- {
- futureProjectIds: () => [] as string[],
- },
-);
+const props = defineProps<{
+ project: Project;
+ collapsed: boolean;
+}>();
+
+const moderationStore = useModerationStore();
const variables = computed(() => {
return flattenProjectVariables(props.project);
});
-const futureProjectCount = computed(() => {
- const ids = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
- return ids.length;
-});
-
const modpackPermissionsComplete = ref(false);
const modpackJudgements = ref({});
const isModpackPermissionsStage = computed(() => {
@@ -419,7 +398,6 @@ const done = ref(false);
function handleModpackPermissionsComplete() {
modpackPermissionsComplete.value = true;
- nextStage();
}
const emit = defineEmits<{
@@ -530,7 +508,7 @@ function handleKeybinds(event: KeyboardEvent) {
isLoadingMessage: loadingMessage.value,
isModpackPermissionsStage: isModpackPermissionsStage.value,
- futureProjectCount: futureProjectCount.value,
+ futureProjectCount: moderationStore.queueLength,
visibleActionsCount: visibleActions.value.length,
focusedActionIndex: focusedActionIndex.value,
@@ -543,7 +521,7 @@ function handleKeybinds(event: KeyboardEvent) {
tryGoNext: nextStage,
tryGoBack: previousStage,
tryGenerateMessage: generateMessage,
- trySkipProject: goToNextProject,
+ trySkipProject: skipCurrentProject,
tryToggleCollapse: () => emit("toggleCollapsed"),
tryResetProgress: resetProgress,
@@ -823,6 +801,31 @@ const isAnyVisibleInputs = computed(() => {
});
});
+function getModpackFilesFromStorage(): {
+ interactive: ModerationModpackItem[];
+ permanentNo: ModerationModpackItem[];
+} {
+ try {
+ const sessionData = sessionStorage.getItem(`modpack-permissions-data-${props.project.id}`);
+ const interactive = sessionData ? (JSON.parse(sessionData) as ModerationModpackItem[]) : [];
+
+ const permanentNoData = sessionStorage.getItem(
+ `modpack-permissions-permanent-no-${props.project.id}`,
+ );
+ const permanentNo = permanentNoData
+ ? (JSON.parse(permanentNoData) as ModerationModpackItem[])
+ : [];
+
+ return {
+ interactive: interactive || [],
+ permanentNo: permanentNo || [],
+ };
+ } catch (error) {
+ console.warn("Failed to parse session storage modpack data:", error);
+ return { interactive: [], permanentNo: [] };
+ }
+}
+
async function assembleFullMessage() {
const messageParts: MessagePart[] = [];
@@ -1045,7 +1048,7 @@ function nextStage() {
if (isModpackPermissionsStage.value && !modpackPermissionsComplete.value) {
addNotification({
title: "Modpack permissions stage unfinished",
- message: "Please complete the modpack permissions stage before proceeding.",
+ text: "Please complete the modpack permissions stage before proceeding.",
type: "error",
});
@@ -1092,13 +1095,14 @@ async function generateMessage() {
const baseMessage = await assembleFullMessage();
let fullMessage = baseMessage;
- if (
- props.project.project_type === "modpack" &&
- Object.keys(modpackJudgements.value).length > 0
- ) {
- const modpackMessage = generateModpackMessage(modpackJudgements.value);
- if (modpackMessage) {
- fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage;
+ if (props.project.project_type === "modpack") {
+ const modpackFilesData = getModpackFilesFromStorage();
+
+ if (modpackFilesData.interactive.length > 0 || modpackFilesData.permanentNo.length > 0) {
+ const modpackMessage = generateModpackMessage(modpackFilesData);
+ if (modpackMessage) {
+ fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage;
+ }
}
}
@@ -1121,7 +1125,7 @@ async function generateMessage() {
console.error("Error generating message:", error);
addNotification({
title: "Error generating message",
- message: "Failed to generate moderation message. Please try again.",
+ text: "Failed to generate moderation message. Please try again.",
type: "error",
});
} finally {
@@ -1129,25 +1133,32 @@ async function generateMessage() {
}
}
-function generateModpackMessage(judgements: ModerationJudgements) {
+function generateModpackMessage(allFiles: {
+ interactive: ModerationModpackItem[];
+ permanentNo: ModerationModpackItem[];
+}) {
const issues = [];
- const attributeMods = [];
- const noMods = [];
- const permanentNoMods = [];
- const unidentifiedMods = [];
-
- for (const [, judgement] of Object.entries(judgements)) {
- if (judgement.status === "with-attribution") {
- attributeMods.push(judgement.file_name);
- } else if (judgement.status === "no") {
- noMods.push(judgement.file_name);
- } else if (judgement.status === "permanent-no") {
- permanentNoMods.push(judgement.file_name);
- } else if (judgement.status === "unidentified") {
- unidentifiedMods.push(judgement.file_name);
+ const attributeMods: string[] = [];
+ const noMods: string[] = [];
+ const permanentNoMods: string[] = [];
+ const unidentifiedMods: string[] = [];
+
+ allFiles.interactive.forEach((file) => {
+ if (file.status === "unidentified") {
+ if (file.approved === "no") {
+ unidentifiedMods.push(file.file_name);
+ }
+ } else if (file.status === "with-attribution" && file.approved === "no") {
+ attributeMods.push(file.file_name);
+ } else if (file.status === "no" && file.approved === "no") {
+ noMods.push(file.file_name);
}
- }
+ });
+
+ allFiles.permanentNo.forEach((file) => {
+ permanentNoMods.push(file.file_name);
+ });
if (
attributeMods.length > 0 ||
@@ -1157,6 +1168,12 @@ function generateModpackMessage(judgements: ModerationJudgements) {
) {
issues.push("## Copyrighted content");
+ if (unidentifiedMods.length > 0) {
+ issues.push(
+ `${finalPermissionMessages.unidentified}\n${unidentifiedMods.map((mod) => `- ${mod}`).join("\n")}`,
+ );
+ }
+
if (attributeMods.length > 0) {
issues.push(
`${finalPermissionMessages["with-attribution"]}\n${attributeMods.map((mod) => `- ${mod}`).join("\n")}`,
@@ -1172,17 +1189,12 @@ function generateModpackMessage(judgements: ModerationJudgements) {
`${finalPermissionMessages["permanent-no"]}\n${permanentNoMods.map((mod) => `- ${mod}`).join("\n")}`,
);
}
-
- if (unidentifiedMods.length > 0) {
- issues.push(
- `${finalPermissionMessages["unidentified"]}\n${unidentifiedMods.map((mod) => `- ${mod}`).join("\n")}`,
- );
- }
}
return issues.join("\n\n");
}
+const hasNextProject = ref(false);
async function sendMessage(status: "approved" | "rejected" | "withheld") {
try {
await useBaseFetch(`project/${props.project.id}`, {
@@ -1217,55 +1229,73 @@ async function sendMessage(status: "approved" | "rejected" | "withheld") {
done.value = true;
- // Clear local storage for future reviews
- localStorage.removeItem(`modpack-permissions-${props.project.id}`);
- localStorage.removeItem(`modpack-permissions-index-${props.project.id}`);
- localStorage.removeItem(`moderation-actions-${props.project.slug}`);
- localStorage.removeItem(`moderation-inputs-${props.project.slug}`);
- actionStates.value = {};
-
- addNotification({
- title: "Moderation submitted",
- message: `Project ${status} successfully.`,
- type: "success",
- });
+ hasNextProject.value = await moderationStore.completeCurrentProject(
+ props.project.id,
+ "completed",
+ );
} catch (error) {
console.error("Error submitting moderation:", error);
addNotification({
title: "Error submitting moderation",
- message: "Failed to submit moderation decision. Please try again.",
+ text: "Failed to submit moderation decision. Please try again.",
type: "error",
});
}
}
-async function goToNextProject() {
- const currentIds = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
+async function endChecklist(status?: string) {
+ clearProjectLocalStorage();
- if (currentIds.length === 0) {
- await navigateTo("/moderation/review");
- return;
- }
+ if (!hasNextProject.value) {
+ await navigateTo({
+ name: "moderation",
+ state: {
+ confetti: true,
+ },
+ });
- const nextProjectId = currentIds[0];
- const remainingIds = currentIds.slice(1);
+ await nextTick();
- localStorage.setItem("moderation-future-projects", JSON.stringify(remainingIds));
+ if (moderationStore.currentQueue.total > 1) {
+ addNotification({
+ title: "Moderation completed",
+ text: `You have completed the moderation queue.`,
+ type: "success",
+ });
+ } else {
+ addNotification({
+ title: "Moderation submitted",
+ text: `Project ${status ?? "completed successfully"}.`,
+ type: "success",
+ });
+ }
+ } else {
+ navigateTo({
+ name: "type-id",
+ params: {
+ type: "project",
+ id: moderationStore.getCurrentProjectId(),
+ },
+ state: {
+ showChecklist: true,
+ },
+ });
+ }
+}
- await router.push({
- name: "type-id",
- params: {
- type: "project",
- id: nextProjectId,
- },
- state: {
- showChecklist: true,
- },
- });
+async function skipCurrentProject() {
+ hasNextProject.value = await moderationStore.completeCurrentProject(props.project.id, "skipped");
+
+ await endChecklist("skipped");
}
-async function exitModeration() {
- await navigateTo("/moderation/review");
+function clearProjectLocalStorage() {
+ localStorage.removeItem(`modpack-permissions-${props.project.id}`);
+ localStorage.removeItem(`modpack-permissions-index-${props.project.id}`);
+ localStorage.removeItem(`moderation-actions-${props.project.slug}`);
+ localStorage.removeItem(`moderation-inputs-${props.project.slug}`);
+ localStorage.removeItem(`moderation-stage-${props.project.slug}`);
+ actionStates.value = {};
}
const isLastVisibleStage = computed(() => {
diff --git a/apps/frontend/src/components/ui/moderation/ModpackPermissionsFlow.vue b/apps/frontend/src/components/ui/moderation/checklist/ModpackPermissionsFlow.vue
similarity index 83%
rename from apps/frontend/src/components/ui/moderation/ModpackPermissionsFlow.vue
rename to apps/frontend/src/components/ui/moderation/checklist/ModpackPermissionsFlow.vue
index 124a49336f..cdb97d7c06 100644
--- a/apps/frontend/src/components/ui/moderation/ModpackPermissionsFlow.vue
+++ b/apps/frontend/src/components/ui/moderation/checklist/ModpackPermissionsFlow.vue
@@ -8,7 +8,7 @@
Loading data...
-
All permissions obtained. You may skip this step!
+
All permissions already obtained.
@@ -157,7 +157,7 @@ import type {
} from "@modrinth/utils";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted } from "vue";
-import { useLocalStorage } from "@vueuse/core";
+import { useLocalStorage, useSessionStorage } from "@vueuse/core";
const props = defineProps<{
projectId: string;
@@ -182,7 +182,26 @@ const persistedModPackData = useLocalStorage
(
const persistedIndex = useLocalStorage(`modpack-permissions-index-${props.projectId}`, 0);
-const modPackData = ref(null);
+const modPackData = useSessionStorage(
+ `modpack-permissions-data-${props.projectId}`,
+ null,
+ {
+ serializer: {
+ read: (v: any) => (v ? JSON.parse(v) : null),
+ write: (v: any) => JSON.stringify(v),
+ },
+ },
+);
+const permanentNoFiles = useSessionStorage(
+ `modpack-permissions-permanent-no-${props.projectId}`,
+ [],
+ {
+ serializer: {
+ read: (v: any) => (v ? JSON.parse(v) : []),
+ write: (v: any) => JSON.stringify(v),
+ },
+ },
+);
const currentIndex = ref(0);
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
@@ -251,7 +270,45 @@ async function fetchModPackData(): Promise {
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
internal: true,
})) as ModerationModpackResponse;
+
+ const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
+ .filter(([_, file]) => file.status === "permanent-no")
+ .map(
+ ([sha1, file]): ModerationModpackItem => ({
+ sha1,
+ file_name: file.file_name,
+ type: "identified",
+ status: file.status,
+ approved: null,
+ }),
+ )
+ .sort((a, b) => a.file_name.localeCompare(b.file_name));
+
+ permanentNoFiles.value = permanentNoItems;
+
const sortedData: ModerationModpackItem[] = [
+ ...Object.entries(data.identified || {})
+ .filter(
+ ([_, file]) =>
+ file.status !== "yes" &&
+ file.status !== "with-attribution-and-source" &&
+ file.status !== "permanent-no",
+ )
+ .map(
+ ([sha1, file]): ModerationModpackItem => ({
+ sha1,
+ file_name: file.file_name,
+ type: "identified",
+ status: file.status,
+ approved: null,
+ ...(file.status === "unidentified" && {
+ proof: "",
+ url: "",
+ title: "",
+ }),
+ }),
+ )
+ .sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.unknown_files || {})
.map(
([sha1, fileName]): ModerationUnknownModpackItem => ({
@@ -310,6 +367,7 @@ async function fetchModPackData(): Promise {
} catch (error) {
console.error("Failed to fetch modpack data:", error);
modPackData.value = [];
+ permanentNoFiles.value = [];
persistAll();
}
}
@@ -321,6 +379,14 @@ function goToPrevious(): void {
}
}
+watch(
+ modPackData,
+ (newValue) => {
+ persistedModPackData.value = newValue;
+ },
+ { deep: true },
+);
+
function goToNext(): void {
if (modPackData.value && currentIndex.value < modPackData.value.length) {
currentIndex.value++;
@@ -396,6 +462,17 @@ onMounted(() => {
}
});
+watch(
+ modPackData,
+ (newValue) => {
+ if (newValue && newValue.length === 0) {
+ emit("complete");
+ clearPersistedData();
+ }
+ },
+ { immediate: true },
+);
+
watch(
() => props.projectId,
() => {
@@ -406,6 +483,20 @@ watch(
}
},
);
+
+function getModpackFiles(): {
+ interactive: ModerationModpackItem[];
+ permanentNo: ModerationModpackItem[];
+} {
+ return {
+ interactive: modPackData.value || [],
+ permanentNo: permanentNoFiles.value,
+ };
+}
+
+defineExpose({
+ getModpackFiles,
+});
diff --git a/apps/frontend/src/pages/moderation/technical-review-mockup.vue b/apps/frontend/src/pages/moderation/technical-review-mockup.vue
new file mode 100644
index 0000000000..f18897fc6f
--- /dev/null
+++ b/apps/frontend/src/pages/moderation/technical-review-mockup.vue
@@ -0,0 +1,386 @@
+
+
+
+
+
+
+ (query = '')">
+
+
+
+
+
+
+
+
+
+
+ {{ selected }} ({{ filteredReports.length }})
+
+
+
+
+
+
+
+ {{ selected }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/pages/moderation/technical-review.vue b/apps/frontend/src/pages/moderation/technical-review.vue
new file mode 100644
index 0000000000..40f28feca6
--- /dev/null
+++ b/apps/frontend/src/pages/moderation/technical-review.vue
@@ -0,0 +1,3 @@
+
+ Not yet implemented.
+
diff --git a/apps/frontend/src/store/moderation.ts b/apps/frontend/src/store/moderation.ts
new file mode 100644
index 0000000000..d89a68fcd7
--- /dev/null
+++ b/apps/frontend/src/store/moderation.ts
@@ -0,0 +1,98 @@
+import { defineStore, createPinia } from "pinia";
+import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
+
+export interface ModerationQueue {
+ items: string[];
+ total: number;
+ completed: number;
+ skipped: number;
+ lastUpdated: Date;
+}
+
+const EMPTY_QUEUE: Partial = {
+ items: [],
+
+ // TODO: Consider some form of displaying this in the checklist, maybe at the end
+ total: 0,
+ completed: 0,
+ skipped: 0,
+};
+
+function createEmptyQueue(): ModerationQueue {
+ return { ...EMPTY_QUEUE, lastUpdated: new Date() } as ModerationQueue;
+}
+
+const pinia = createPinia();
+pinia.use(piniaPluginPersistedstate);
+
+export const useModerationStore = defineStore("moderation", {
+ state: () => ({
+ currentQueue: createEmptyQueue(),
+ }),
+
+ getters: {
+ queueLength: (state) => state.currentQueue.items.length,
+ hasItems: (state) => state.currentQueue.items.length > 0,
+ progress: (state) => {
+ if (state.currentQueue.total === 0) return 0;
+ return (state.currentQueue.completed + state.currentQueue.skipped) / state.currentQueue.total;
+ },
+ },
+
+ actions: {
+ setQueue(projectIDs: string[]) {
+ this.currentQueue = {
+ items: [...projectIDs],
+ total: projectIDs.length,
+ completed: 0,
+ skipped: 0,
+ lastUpdated: new Date(),
+ };
+ },
+
+ setSingleProject(projectId: string) {
+ this.currentQueue = {
+ items: [projectId],
+ total: 1,
+ completed: 0,
+ skipped: 0,
+ lastUpdated: new Date(),
+ };
+ },
+
+ completeCurrentProject(projectId: string, status: "completed" | "skipped" = "completed") {
+ if (status === "completed") {
+ this.currentQueue.completed++;
+ } else {
+ this.currentQueue.skipped++;
+ }
+
+ this.currentQueue.items = this.currentQueue.items.filter((id: string) => id !== projectId);
+ this.currentQueue.lastUpdated = new Date();
+
+ return this.currentQueue.items.length > 0;
+ },
+
+ getCurrentProjectId(): string | null {
+ return this.currentQueue.items[0] || null;
+ },
+
+ resetQueue() {
+ this.currentQueue = createEmptyQueue();
+ },
+ },
+
+ persist: {
+ key: "moderation-store",
+ serializer: {
+ serialize: JSON.stringify,
+ deserialize: (value: string) => {
+ const parsed = JSON.parse(value);
+ if (parsed.currentQueue?.lastUpdated) {
+ parsed.currentQueue.lastUpdated = new Date(parsed.currentQueue.lastUpdated);
+ }
+ return parsed;
+ },
+ },
+ },
+});
diff --git a/apps/labrinth/Dockerfile b/apps/labrinth/Dockerfile
index f0677efd50..f8ff754d95 100644
--- a/apps/labrinth/Dockerfile
+++ b/apps/labrinth/Dockerfile
@@ -1,8 +1,21 @@
+# syntax=docker/dockerfile:1
+
FROM rust:1.88.0 AS build
WORKDIR /usr/src/labrinth
COPY . .
-RUN SQLX_OFFLINE=true cargo build --release --package labrinth
+RUN --mount=type=cache,target=/usr/src/labrinth/target \
+ --mount=type=cache,target=/usr/local/cargo/git/db \
+ --mount=type=cache,target=/usr/local/cargo/registry \
+ SQLX_OFFLINE=true cargo build --release --package labrinth
+
+FROM build AS artifacts
+
+RUN --mount=type=cache,target=/usr/src/labrinth/target \
+ mkdir /labrinth \
+ && cp /usr/src/labrinth/target/release/labrinth /labrinth/labrinth \
+ && cp -r /usr/src/labrinth/apps/labrinth/migrations /labrinth \
+ && cp -r /usr/src/labrinth/apps/labrinth/assets /labrinth
FROM debian:bookworm-slim
@@ -14,10 +27,8 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
&& rm -rf /var/lib/apt/lists/*
-COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth
-COPY --from=build /usr/src/labrinth/apps/labrinth/migrations/* /labrinth/migrations/
-COPY --from=build /usr/src/labrinth/apps/labrinth/assets /labrinth/assets
-WORKDIR /labrinth
+COPY --from=artifacts /labrinth /labrinth
+WORKDIR /labrinth
ENTRYPOINT ["dumb-init", "--"]
CMD ["/labrinth/labrinth"]
diff --git a/packages/app-lib/.env.local b/packages/app-lib/.env.local
index e648f5b565..171b37b9e6 100644
--- a/packages/app-lib/.env.local
+++ b/packages/app-lib/.env.local
@@ -1,2 +1,10 @@
-# SQLite database file location
-DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
+MODRINTH_URL=http://localhost:3000/
+MODRINTH_API_URL=http://127.0.0.1:8000/v2/
+MODRINTH_API_URL_V3=http://127.0.0.1:8000/v3/
+MODRINTH_SOCKET_URL=ws://127.0.0.1:8000/
+MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
+
+# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
+# in your system and run `cargo sqlx database setup` to generate an empty database that
+# can be used for developing the app DB schema
+#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
diff --git a/packages/app-lib/.env.prod b/packages/app-lib/.env.prod
new file mode 100644
index 0000000000..f721d50783
--- /dev/null
+++ b/packages/app-lib/.env.prod
@@ -0,0 +1,10 @@
+MODRINTH_URL=https://modrinth.com/
+MODRINTH_API_URL=https://api.modrinth.com/v2/
+MODRINTH_API_URL_V3=https://api.modrinth.com/v3/
+MODRINTH_SOCKET_URL=wss://api.modrinth.com/
+MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
+
+# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
+# in your system and run `cargo sqlx database setup` to generate an empty database that
+# can be used for developing the app DB schema
+#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
diff --git a/packages/app-lib/.env.staging b/packages/app-lib/.env.staging
new file mode 100644
index 0000000000..be0daeb9b2
--- /dev/null
+++ b/packages/app-lib/.env.staging
@@ -0,0 +1,10 @@
+MODRINTH_URL=https://staging.modrinth.com/
+MODRINTH_API_URL=https://staging-api.modrinth.com/v2/
+MODRINTH_API_URL_V3=https://staging-api.modrinth.com/v3/
+MODRINTH_SOCKET_URL=wss://staging-api.modrinth.com/
+MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
+
+# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
+# in your system and run `cargo sqlx database setup` to generate an empty database that
+# can be used for developing the app DB schema
+#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml
index 85200eb258..127edd852c 100644
--- a/packages/app-lib/Cargo.toml
+++ b/packages/app-lib/Cargo.toml
@@ -82,6 +82,7 @@ ariadne.workspace = true
winreg.workspace = true
[build-dependencies]
+dotenvy.workspace = true
dunce.workspace = true
[features]
diff --git a/packages/app-lib/build.rs b/packages/app-lib/build.rs
index 251c4e8480..10ed29b99f 100644
--- a/packages/app-lib/build.rs
+++ b/packages/app-lib/build.rs
@@ -4,12 +4,31 @@ use std::process::{Command, exit};
use std::{env, fs};
fn main() {
+ println!("cargo::rerun-if-changed=.env");
println!("cargo::rerun-if-changed=java/gradle");
println!("cargo::rerun-if-changed=java/src");
println!("cargo::rerun-if-changed=java/build.gradle.kts");
println!("cargo::rerun-if-changed=java/settings.gradle.kts");
println!("cargo::rerun-if-changed=java/gradle.properties");
+ set_env();
+ build_java_jars();
+}
+
+fn set_env() {
+ for (var_name, var_value) in
+ dotenvy::dotenv_iter().into_iter().flatten().flatten()
+ {
+ if var_name == "DATABASE_URL" {
+ // The sqlx database URL is a build-time detail that should not be exposed to the crate
+ continue;
+ }
+
+ println!("cargo::rustc-env={var_name}={var_value}");
+ }
+}
+
+fn build_java_jars() {
let out_dir =
dunce::canonicalize(PathBuf::from(env::var_os("OUT_DIR").unwrap()))
.unwrap();
@@ -37,6 +56,7 @@ fn main() {
.current_dir(dunce::canonicalize("java").unwrap())
.status()
.expect("Failed to wait on Gradle build");
+
if !exit_status.success() {
println!("cargo::error=Gradle build failed with {exit_status}");
exit(exit_status.code().unwrap_or(1));
diff --git a/packages/app-lib/src/api/mr_auth.rs b/packages/app-lib/src/api/mr_auth.rs
index 3c99d1f5b5..42ce41fe5e 100644
--- a/packages/app-lib/src/api/mr_auth.rs
+++ b/packages/app-lib/src/api/mr_auth.rs
@@ -1,7 +1,7 @@
use crate::state::ModrinthCredentials;
#[tracing::instrument]
-pub fn authenticate_begin_flow() -> String {
+pub fn authenticate_begin_flow() -> &'static str {
crate::state::get_login_url()
}
diff --git a/packages/app-lib/src/config.rs b/packages/app-lib/src/config.rs
deleted file mode 100644
index bf7e47e1e3..0000000000
--- a/packages/app-lib/src/config.rs
+++ /dev/null
@@ -1,13 +0,0 @@
-//! Configuration structs
-
-// pub const MODRINTH_URL: &str = "https://staging.modrinth.com/";
-// pub const MODRINTH_API_URL: &str = "https://staging-api.modrinth.com/v2/";
-// pub const MODRINTH_API_URL_V3: &str = "https://staging-api.modrinth.com/v3/";
-
-pub const MODRINTH_URL: &str = "https://modrinth.com/";
-pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2/";
-pub const MODRINTH_API_URL_V3: &str = "https://api.modrinth.com/v3/";
-
-pub const MODRINTH_SOCKET_URL: &str = "wss://api.modrinth.com/";
-
-pub const META_URL: &str = "https://launcher-meta.modrinth.com/";
diff --git a/packages/app-lib/src/lib.rs b/packages/app-lib/src/lib.rs
index 55f4459acd..258e72423a 100644
--- a/packages/app-lib/src/lib.rs
+++ b/packages/app-lib/src/lib.rs
@@ -11,7 +11,6 @@ and launching Modrinth mod packs
mod util;
mod api;
-mod config;
mod error;
mod event;
mod launcher;
diff --git a/packages/app-lib/src/state/cache.rs b/packages/app-lib/src/state/cache.rs
index cd6ee1df2b..98f1bb79ce 100644
--- a/packages/app-lib/src/state/cache.rs
+++ b/packages/app-lib/src/state/cache.rs
@@ -1,4 +1,3 @@
-use crate::config::{META_URL, MODRINTH_API_URL, MODRINTH_API_URL_V3};
use crate::state::ProjectType;
use crate::util::fetch::{FetchSemaphore, fetch_json, sha1_async};
use chrono::{DateTime, Utc};
@@ -8,6 +7,7 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::collections::HashMap;
+use std::env;
use std::fmt::Display;
use std::hash::Hash;
use std::path::{Path, PathBuf};
@@ -945,7 +945,7 @@ impl CachedEntry {
CacheValueType::Project => {
fetch_original_values!(
Project,
- MODRINTH_API_URL,
+ env!("MODRINTH_API_URL"),
"projects",
CacheValue::Project
)
@@ -953,7 +953,7 @@ impl CachedEntry {
CacheValueType::Version => {
fetch_original_values!(
Version,
- MODRINTH_API_URL,
+ env!("MODRINTH_API_URL"),
"versions",
CacheValue::Version
)
@@ -961,7 +961,7 @@ impl CachedEntry {
CacheValueType::User => {
fetch_original_values!(
User,
- MODRINTH_API_URL,
+ env!("MODRINTH_API_URL"),
"users",
CacheValue::User
)
@@ -969,7 +969,7 @@ impl CachedEntry {
CacheValueType::Team => {
let mut teams = fetch_many_batched::>(
Method::GET,
- MODRINTH_API_URL_V3,
+ env!("MODRINTH_API_URL_V3"),
"teams?ids=",
&keys,
fetch_semaphore,
@@ -1008,7 +1008,7 @@ impl CachedEntry {
CacheValueType::Organization => {
let mut orgs = fetch_many_batched::(
Method::GET,
- MODRINTH_API_URL_V3,
+ env!("MODRINTH_API_URL_V3"),
"organizations?ids=",
&keys,
fetch_semaphore,
@@ -1063,7 +1063,7 @@ impl CachedEntry {
CacheValueType::File => {
let mut versions = fetch_json::>(
Method::POST,
- &format!("{MODRINTH_API_URL}version_files"),
+ concat!(env!("MODRINTH_API_URL"), "version_files"),
None,
Some(serde_json::json!({
"algorithm": "sha1",
@@ -1119,7 +1119,11 @@ impl CachedEntry {
.map(|x| {
(
x.key().to_string(),
- format!("{META_URL}{}/v0/manifest.json", x.key()),
+ format!(
+ "{}{}/v0/manifest.json",
+ env!("MODRINTH_LAUNCHER_META_URL"),
+ x.key()
+ ),
)
})
.collect::>();
@@ -1154,7 +1158,7 @@ impl CachedEntry {
CacheValueType::MinecraftManifest => {
fetch_original_value!(
MinecraftManifest,
- META_URL,
+ env!("MODRINTH_LAUNCHER_META_URL"),
format!(
"minecraft/v{}/manifest.json",
daedalus::minecraft::CURRENT_FORMAT_VERSION
@@ -1165,7 +1169,7 @@ impl CachedEntry {
CacheValueType::Categories => {
fetch_original_value!(
Categories,
- MODRINTH_API_URL,
+ env!("MODRINTH_API_URL"),
"tag/category",
CacheValue::Categories
)
@@ -1173,7 +1177,7 @@ impl CachedEntry {
CacheValueType::ReportTypes => {
fetch_original_value!(
ReportTypes,
- MODRINTH_API_URL,
+ env!("MODRINTH_API_URL"),
"tag/report_type",
CacheValue::ReportTypes
)
@@ -1181,7 +1185,7 @@ impl CachedEntry {
CacheValueType::Loaders => {
fetch_original_value!(
Loaders,
- MODRINTH_API_URL,
+ env!("MODRINTH_API_URL"),
"tag/loader",
CacheValue::Loaders
)
@@ -1189,7 +1193,7 @@ impl CachedEntry {
CacheValueType::GameVersions => {
fetch_original_value!(
GameVersions,
- MODRINTH_API_URL,
+ env!("MODRINTH_API_URL"),
"tag/game_version",
CacheValue::GameVersions
)
@@ -1197,7 +1201,7 @@ impl CachedEntry {
CacheValueType::DonationPlatforms => {
fetch_original_value!(
DonationPlatforms,
- MODRINTH_API_URL,
+ env!("MODRINTH_API_URL"),
"tag/donation_platform",
CacheValue::DonationPlatforms
)
@@ -1297,14 +1301,12 @@ impl CachedEntry {
}
});
- let version_update_url =
- format!("{MODRINTH_API_URL}version_files/update");
let variations =
futures::future::try_join_all(filtered_keys.iter().map(
|((loaders_key, game_version), hashes)| {
fetch_json::>(
Method::POST,
- &version_update_url,
+ concat!(env!("MODRINTH_API_URL"), "version_files/update"),
None,
Some(serde_json::json!({
"algorithm": "sha1",
@@ -1368,7 +1370,11 @@ impl CachedEntry {
.map(|x| {
(
x.key().to_string(),
- format!("{MODRINTH_API_URL}search{}", x.key()),
+ format!(
+ "{}search{}",
+ env!("MODRINTH_API_URL"),
+ x.key()
+ ),
)
})
.collect::>();
diff --git a/packages/app-lib/src/state/friends.rs b/packages/app-lib/src/state/friends.rs
index 008660d9fc..0079daa40b 100644
--- a/packages/app-lib/src/state/friends.rs
+++ b/packages/app-lib/src/state/friends.rs
@@ -1,4 +1,3 @@
-use crate::config::{MODRINTH_API_URL_V3, MODRINTH_SOCKET_URL};
use crate::data::ModrinthCredentials;
use crate::event::FriendPayload;
use crate::event::emit::emit_friend;
@@ -77,7 +76,8 @@ impl FriendsSocket {
if let Some(credentials) = credentials {
let mut request = format!(
- "{MODRINTH_SOCKET_URL}_internal/launcher_socket?code={}",
+ "{}_internal/launcher_socket?code={}",
+ env!("MODRINTH_SOCKET_URL"),
credentials.session
)
.into_client_request()?;
@@ -303,7 +303,7 @@ impl FriendsSocket {
) -> crate::Result> {
fetch_json(
Method::GET,
- &format!("{MODRINTH_API_URL_V3}friends"),
+ concat!(env!("MODRINTH_API_URL_V3"), "friends"),
None,
None,
semaphore,
@@ -328,7 +328,7 @@ impl FriendsSocket {
) -> crate::Result<()> {
fetch_advanced(
Method::POST,
- &format!("{MODRINTH_API_URL_V3}friend/{user_id}"),
+ &format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")),
None,
None,
None,
@@ -349,7 +349,7 @@ impl FriendsSocket {
) -> crate::Result<()> {
fetch_advanced(
Method::DELETE,
- &format!("{MODRINTH_API_URL_V3}friend/{user_id}"),
+ &format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")),
None,
None,
None,
diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
index febfd67da7..fb9129989a 100644
--- a/packages/app-lib/src/state/minecraft_auth.rs
+++ b/packages/app-lib/src/state/minecraft_auth.rs
@@ -85,21 +85,18 @@ pub struct MinecraftLoginFlow {
pub verifier: String,
pub challenge: String,
pub session_id: String,
- pub redirect_uri: String,
+ pub auth_request_uri: String,
}
#[tracing::instrument]
pub async fn login_begin(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result {
- let (pair, current_date, valid_date) =
- DeviceTokenPair::refresh_and_get_device_token(Utc::now(), false, exec)
- .await?;
+ let (pair, current_date) =
+ DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
let verifier = generate_oauth_challenge();
- let mut hasher = sha2::Sha256::new();
- hasher.update(&verifier);
- let result = hasher.finalize();
+ let result = sha2::Sha256::digest(&verifier);
let challenge = BASE64_URL_SAFE_NO_PAD.encode(result);
match sisu_authenticate(
@@ -110,46 +107,15 @@ pub async fn login_begin(
)
.await
{
- Ok((session_id, redirect_uri)) => Ok(MinecraftLoginFlow {
- verifier,
- challenge,
- session_id,
- redirect_uri: redirect_uri.value.msa_oauth_redirect,
- }),
- Err(err) => {
- if !valid_date {
- let (pair, current_date, _) =
- DeviceTokenPair::refresh_and_get_device_token(
- Utc::now(),
- false,
- exec,
- )
- .await?;
-
- let verifier = generate_oauth_challenge();
- let mut hasher = sha2::Sha256::new();
- hasher.update(&verifier);
- let result = hasher.finalize();
- let challenge = BASE64_URL_SAFE_NO_PAD.encode(result);
-
- let (session_id, redirect_uri) = sisu_authenticate(
- &pair.token.token,
- &challenge,
- &pair.key,
- current_date,
- )
- .await?;
-
- Ok(MinecraftLoginFlow {
- verifier,
- challenge,
- session_id,
- redirect_uri: redirect_uri.value.msa_oauth_redirect,
- })
- } else {
- Err(crate::ErrorKind::from(err).into())
- }
+ Ok((session_id, redirect_uri)) => {
+ return Ok(MinecraftLoginFlow {
+ verifier,
+ challenge,
+ session_id,
+ auth_request_uri: redirect_uri.value.msa_oauth_redirect,
+ });
}
+ Err(err) => return Err(crate::ErrorKind::from(err).into()),
}
}
@@ -159,9 +125,8 @@ pub async fn login_finish(
flow: MinecraftLoginFlow,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result {
- let (pair, _, _) =
- DeviceTokenPair::refresh_and_get_device_token(Utc::now(), false, exec)
- .await?;
+ let (pair, _) =
+ DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
let oauth_token = oauth_token(code, &flow.verifier).await?;
let sisu_authorize = sisu_authorize(
@@ -267,10 +232,9 @@ impl Credentials {
}
let oauth_token = oauth_refresh(&self.refresh_token).await?;
- let (pair, current_date, _) =
+ let (pair, current_date) =
DeviceTokenPair::refresh_and_get_device_token(
oauth_token.date,
- false,
exec,
)
.await?;
@@ -633,21 +597,20 @@ impl DeviceTokenPair {
#[tracing::instrument(skip(exec))]
async fn refresh_and_get_device_token(
current_date: DateTime,
- force_generate: bool,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
- ) -> crate::Result<(Self, DateTime, bool)> {
+ ) -> crate::Result<(Self, DateTime)> {
let pair = Self::get(exec).await?;
if let Some(mut pair) = pair {
- if pair.token.not_after > Utc::now() && !force_generate {
- Ok((pair, current_date, false))
+ if pair.token.not_after > current_date {
+ Ok((pair, current_date))
} else {
let res = device_token(&pair.key, current_date).await?;
pair.token = res.value;
pair.upsert(exec).await?;
- Ok((pair, res.date, true))
+ Ok((pair, res.date))
}
} else {
let key = generate_key()?;
@@ -660,7 +623,7 @@ impl DeviceTokenPair {
pair.upsert(exec).await?;
- Ok((pair, res.date, true))
+ Ok((pair, res.date))
}
}
@@ -758,8 +721,8 @@ impl DeviceTokenPair {
}
const MICROSOFT_CLIENT_ID: &str = "00000000402b5328";
-const REDIRECT_URL: &str = "https://login.live.com/oauth20_desktop.srf";
-const REQUESTED_SCOPES: &str = "service::user.auth.xboxlive.com::MBI_SSL";
+const AUTH_REPLY_URL: &str = "https://login.live.com/oauth20_desktop.srf";
+const REQUESTED_SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
struct RequestWithDate {
pub date: DateTime,
@@ -838,7 +801,7 @@ async fn sisu_authenticate(
"AppId": MICROSOFT_CLIENT_ID,
"DeviceToken": token,
"Offers": [
- REQUESTED_SCOPES
+ REQUESTED_SCOPE
],
"Query": {
"code_challenge": challenge,
@@ -846,7 +809,7 @@ async fn sisu_authenticate(
"state": generate_oauth_challenge(),
"prompt": "select_account"
},
- "RedirectUri": REDIRECT_URL,
+ "RedirectUri": AUTH_REPLY_URL,
"Sandbox": "RETAIL",
"TokenType": "code",
"TitleId": "1794566092",
@@ -890,12 +853,12 @@ async fn oauth_token(
verifier: &str,
) -> Result, MinecraftAuthenticationError> {
let mut query = HashMap::new();
- query.insert("client_id", "00000000402b5328");
+ query.insert("client_id", MICROSOFT_CLIENT_ID);
query.insert("code", code);
query.insert("code_verifier", verifier);
query.insert("grant_type", "authorization_code");
- query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf");
- query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL");
+ query.insert("redirect_uri", AUTH_REPLY_URL);
+ query.insert("scope", REQUESTED_SCOPE);
let res = auth_retry(|| {
REQWEST_CLIENT
@@ -939,11 +902,11 @@ async fn oauth_refresh(
refresh_token: &str,
) -> Result, MinecraftAuthenticationError> {
let mut query = HashMap::new();
- query.insert("client_id", "00000000402b5328");
+ query.insert("client_id", MICROSOFT_CLIENT_ID);
query.insert("refresh_token", refresh_token);
query.insert("grant_type", "refresh_token");
- query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf");
- query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL");
+ query.insert("redirect_uri", AUTH_REPLY_URL);
+ query.insert("scope", REQUESTED_SCOPE);
let res = auth_retry(|| {
REQWEST_CLIENT
@@ -1007,7 +970,7 @@ async fn sisu_authorize(
"/authorize",
json!({
"AccessToken": format!("t={access_token}"),
- "AppId": "00000000402b5328",
+ "AppId": MICROSOFT_CLIENT_ID,
"DeviceToken": device_token,
"ProofKey": {
"kty": "EC",
diff --git a/packages/app-lib/src/state/mr_auth.rs b/packages/app-lib/src/state/mr_auth.rs
index 8aab2a37de..d46de6e53d 100644
--- a/packages/app-lib/src/state/mr_auth.rs
+++ b/packages/app-lib/src/state/mr_auth.rs
@@ -1,4 +1,3 @@
-use crate::config::{MODRINTH_API_URL, MODRINTH_URL};
use crate::state::{CacheBehaviour, CachedEntry};
use crate::util::fetch::{FetchSemaphore, fetch_advanced};
use chrono::{DateTime, Duration, TimeZone, Utc};
@@ -31,7 +30,7 @@ impl ModrinthCredentials {
let resp = fetch_advanced(
Method::POST,
- &format!("{MODRINTH_API_URL}session/refresh"),
+ concat!(env!("MODRINTH_API_URL"), "session/refresh"),
None,
None,
Some(("Authorization", &*creds.session)),
@@ -190,8 +189,8 @@ impl ModrinthCredentials {
}
}
-pub fn get_login_url() -> String {
- format!("{MODRINTH_URL}auth/sign-in?launcher=true")
+pub const fn get_login_url() -> &'static str {
+ concat!(env!("MODRINTH_URL"), "auth/sign-in")
}
pub async fn finish_login_flow(
@@ -199,6 +198,12 @@ pub async fn finish_login_flow(
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result {
+ // The authorization code actually is the access token, since Labrinth doesn't
+ // issue separate authorization codes. Therefore, this is equivalent to an
+ // implicit OAuth grant flow, and no additional exchanging or finalization is
+ // needed. TODO not do this for the reasons outlined at
+ // https://oauth.net/2/grant-types/implicit/
+
let info = fetch_info(code, semaphore, exec).await?;
Ok(ModrinthCredentials {
@@ -216,7 +221,7 @@ async fn fetch_info(
) -> crate::Result {
let result = fetch_advanced(
Method::GET,
- &format!("{MODRINTH_API_URL}user"),
+ concat!(env!("MODRINTH_API_URL"), "user"),
None,
None,
Some(("Authorization", token)),
diff --git a/packages/app-lib/src/util/fetch.rs b/packages/app-lib/src/util/fetch.rs
index fb62386aac..9b2e872086 100644
--- a/packages/app-lib/src/util/fetch.rs
+++ b/packages/app-lib/src/util/fetch.rs
@@ -1,6 +1,5 @@
//! Functions for fetching information from the Internet
use super::io::{self, IOError};
-use crate::config::{MODRINTH_API_URL, MODRINTH_API_URL_V3};
use crate::event::LoadingBarId;
use crate::event::emit::emit_loading;
use bytes::Bytes;
@@ -84,8 +83,8 @@ pub async fn fetch_advanced(
.as_ref()
.is_none_or(|x| &*x.0.to_lowercase() != "authorization")
&& (url.starts_with("https://cdn.modrinth.com")
- || url.starts_with(MODRINTH_API_URL)
- || url.starts_with(MODRINTH_API_URL_V3))
+ || url.starts_with(env!("MODRINTH_API_URL"))
+ || url.starts_with(env!("MODRINTH_API_URL_V3")))
{
crate::state::ModrinthCredentials::get_active(exec).await?
} else {
diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts
index 249a53b604..f010441554 100644
--- a/packages/assets/generated-icons.ts
+++ b/packages/assets/generated-icons.ts
@@ -38,6 +38,7 @@ import _CodeIcon from './icons/code.svg?component'
import _CoffeeIcon from './icons/coffee.svg?component'
import _CogIcon from './icons/cog.svg?component'
import _CoinsIcon from './icons/coins.svg?component'
+import _CollapseIcon from './icons/collapse.svg?component'
import _CollectionIcon from './icons/collection.svg?component'
import _CompassIcon from './icons/compass.svg?component'
import _ContractIcon from './icons/contract.svg?component'
@@ -52,6 +53,7 @@ import _DatabaseIcon from './icons/database.svg?component'
import _DownloadIcon from './icons/download.svg?component'
import _DropdownIcon from './icons/dropdown.svg?component'
import _EditIcon from './icons/edit.svg?component'
+import _EllipsisVerticalIcon from './icons/ellipsis-vertical.svg?component'
import _ExpandIcon from './icons/expand.svg?component'
import _ExternalIcon from './icons/external.svg?component'
import _EyeOffIcon from './icons/eye-off.svg?component'
@@ -229,6 +231,7 @@ export const CodeIcon = _CodeIcon
export const CoffeeIcon = _CoffeeIcon
export const CogIcon = _CogIcon
export const CoinsIcon = _CoinsIcon
+export const CollapseIcon = _CollapseIcon
export const CollectionIcon = _CollectionIcon
export const CompassIcon = _CompassIcon
export const ContractIcon = _ContractIcon
@@ -243,6 +246,7 @@ export const DatabaseIcon = _DatabaseIcon
export const DownloadIcon = _DownloadIcon
export const DropdownIcon = _DropdownIcon
export const EditIcon = _EditIcon
+export const EllipsisVerticalIcon = _EllipsisVerticalIcon
export const ExpandIcon = _ExpandIcon
export const ExternalIcon = _ExternalIcon
export const EyeOffIcon = _EyeOffIcon
diff --git a/packages/assets/icons/collapse.svg b/packages/assets/icons/collapse.svg
new file mode 100644
index 0000000000..9ca3e53c22
--- /dev/null
+++ b/packages/assets/icons/collapse.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/assets/icons/ellipsis-vertical.svg b/packages/assets/icons/ellipsis-vertical.svg
new file mode 100644
index 0000000000..ebe30683a5
--- /dev/null
+++ b/packages/assets/icons/ellipsis-vertical.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/moderation/README.md b/packages/moderation/README.md
index b15700e2c2..adb1e34945 100644
--- a/packages/moderation/README.md
+++ b/packages/moderation/README.md
@@ -1,8 +1,6 @@
-
-
# @modrinth/moderation
-This package contains both the moderation checklist system used by moderators for reviewing projects on Modrinth, and the publishing checklist (nag system) that provides automated feedback to project authors during the submission process.
+This package contains the moderation checklist system used for reviewing projects on Modrinth. It provides a structured and transparent way to define moderation stages, actions, and messages that are displayed to moderators during the review process.
## Structure
@@ -11,31 +9,22 @@ The package is organized as follows:
```
/packages/moderation/
โโโ data/
-โ โโโ checklist.ts # Main moderation checklist definition - imports and exports all stages
-โ โโโ messages/ # Markdown files containing message templates for moderation
+โ โโโ checklist.ts # Main checklist definition - imports and exports all stages
+โ โโโ messages/ # Markdown files containing message templates
โ โ โโโ title/ # Messages for the title stage
โ โ โโโ description/ # Messages for the description stage
โ โ โโโ ... # One directory per stage
-โ โโโ stages/ # Moderation stage definition files
-โ โ โโโ title.ts # Title stage definition
-โ โ โโโ description.ts # Description stage definition
-โ โ โโโ ... # One file per stage
-โ โโโ nags/ # Publishing checklist (nag system) files
-โ โโโ core.ts # Core nags (required fields, basic validation)
-โ โโโ core.i18n.ts # Internationalization messages for core nags
-โ โโโ ...
+โ โโโ stages/ # Stage definition files
+โ โโโ title.ts # Title stage definition
+โ โโโ description.ts # Description stage definition
+โ โโโ ... # One file per stage
โโโ types/ # Type definitions
- โโโ actions.ts # Action-related types (moderation)
- โโโ messages.ts # Message-related types (moderation)
- โโโ stage.ts # Stage-related types (moderation)
- โโโ nags.ts # Nag-related types (publishing checklist)
+ โโโ actions.ts # Action-related types
+ โโโ messages.ts # Message-related types
+ โโโ stage.ts # Stage-related types
```
-## Moderation Checklist System
-
-The moderation checklist provides a structured and transparent way to define moderation stages, actions, and messages that are displayed to moderators during the review process.
-
-### Stages
+## Stages
A stage represents a discrete step in the moderation process, like checking a project's title, description, or links. Each stage has:
@@ -46,7 +35,7 @@ A stage represents a discrete step in the moderation process, like checking a pr
Stages are defined in individual files in the `data/stages` directory and are assembled into the complete checklist in `data/checklist.ts`.
-### Actions
+## Actions
Actions represent decisions moderators can make for each stage. They can be buttons, dropdowns, toggles, etc. Actions can have:
@@ -58,11 +47,11 @@ Actions represent decisions moderators can make for each stage. They can be butt
Each action requires a unique `id` field that is used for conditional logic and action relationships. The `suggestedStatus` and `severity` fields help determine the overall moderation outcome.
-### Messages
+## Messages
Messages are the actual text that will be included in communications to project authors. To promote maintainability and reuse, messages are stored as Markdown files in the `data/messages` directory, organized by stage.
-#### Variable replacement
+### Variable replacement
You can use variables in your messages that will be replaced with user input:
@@ -92,11 +81,11 @@ More text after the variable.
The `%MESSAGE%` placeholder will be replaced with the text entered by the moderator.
-### Conditional logic
+## Conditional logic
The moderation system supports conditional behavior that changes based on the selection of other actions.
-#### Conditional messages
+### Conditional messages
You can define different messages for an action based on other selected actions:
@@ -119,7 +108,7 @@ You can define different messages for an action based on other selected actions:
}
```
-#### Enabling and disabling actions
+### Enabling and disabling actions
Actions can enable or disable other actions when selected:
@@ -142,7 +131,7 @@ Actions can enable or disable other actions when selected:
}
```
-#### Conditional text inputs
+### Conditional text inputs
Text inputs can be conditionally shown based on selected actions:
@@ -158,101 +147,3 @@ relevantExtraInput: [
},
]
```
-
-## Publishing Checklist (Nag System)
-
-The nag system provides automated feedback to project authors during the submission process, helping them improve their projects before they reach moderation. It analyzes project data and provides suggestions, warnings, and requirements.
-
-### Nags
-
-A nag represents a specific issue or suggestion for improvement. Each nag has:
-
-- A unique `id` for identification
-- A `title` and `description` displayed to the user
-- A `status` indicating severity: `'required'`, `'warning'`, or `'suggestion'`
-- A `shouldShow` function that determines when the nag should be displayed
-- An optional `link` to help users address the issue
-
-### Internationalization
-
-Each nag category has a corresponding `.i18n.ts` file containing message definitions:
-
-```typescript
-// Example from core.i18n.ts
-export default defineMessages({
- addDescriptionTitle: {
- id: 'nags.add-description.title',
- defaultMessage: 'Add a description',
- },
- addDescriptionDescription: {
- id: 'nags.add-description.description',
- defaultMessage:
- "A description that clearly describes the project's purpose and function is required.",
- },
-})
-```
-
-If you want to use context in the messages, you can do so like this:
-
-```typescript
-description: (context: NagContext) => {
- const { formatMessage } = useVIntl()
-
- return formatMessage(messages.descriptionTooShortDescription, {
- length: context.project.body?.length || 0,
- minChars: MIN_DESCRIPTION_CHARS,
- })
-}
-```
-
-### Nag Context
-
-The `NagContext` type provides access to:
-
-- `project`: Current project data
-- `versions`: Project versions
-- `tags`: Frontend "tags" (generated state)
-- `currentRoute`: Current page route
-- and other data...
-
-### Adding New Nags
-
-To add a new nag:
-
-1. Add the nag definition to the appropriate category file (or make a new category file and add it to `data/nags.ts`)
-2. Add corresponding i18n messages to the `.i18n.ts` file
-3. Implement the `shouldShow` logic based on project state
-4. Add appropriate links to help users resolve the issue
-5. Run `pnpm run fix` to fix lint issues & generate the root locale index.json file.
-
-Example:
-
-```typescript
-// In description.ts
-{
- id: 'new-nag',
- title: messages.newNagTitle,
- description: messages.newNagDescription,
- status: 'warning',
- shouldShow: (context: NagContext) => {
- // Your validation logic here
- return someCondition
- },
- link: {
- path: 'settings/description',
- title: messages.editDescriptionTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
- },
-}
-```
-
-```typescript
-// In description.i18n.ts
-newNagTitle: {
- id: 'nags.new-nag.title',
- defaultMessage: 'New Nag Title',
-},
-newNagDescription: {
- id: 'nags.new-nag.description',
- defaultMessage: 'Description of the new nag issue.',
-```
diff --git a/packages/moderation/data/nags.ts b/packages/moderation/data/nags.ts
deleted file mode 100644
index 852321bd3f..0000000000
--- a/packages/moderation/data/nags.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { Nag } from '../types/nags'
-import { coreNags } from './nags/core'
-import { descriptionNags } from './nags/description'
-import { linksNags } from './nags/links'
-import { tagsNags } from './nags/tags'
-
-export default [...coreNags, ...linksNags, ...descriptionNags, ...tagsNags] as Nag[]
diff --git a/packages/moderation/data/nags/core.i18n.ts b/packages/moderation/data/nags/core.i18n.ts
deleted file mode 100644
index d3c89f2483..0000000000
--- a/packages/moderation/data/nags/core.i18n.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { defineMessages } from '@vintl/vintl'
-
-export default defineMessages({
- moderatorFeedbackTitle: {
- id: 'nags.moderator-feedback.title',
- defaultMessage: 'Review moderator feedback',
- },
- moderatorFeedbackDescription: {
- id: 'nags.moderator-feedback.description',
- defaultMessage:
- 'Review any feedback from moderators regarding your project before resubmitting.',
- },
- moderationTitle: {
- id: 'nags.moderation.title',
- defaultMessage: 'Visit moderation thread',
- },
- uploadVersionTitle: {
- id: 'nags.upload-version.title',
- defaultMessage: 'Upload a version',
- },
- uploadVersionDescription: {
- id: 'nags.upload-version.description',
- defaultMessage: 'At least one version is required for a project to be submitted for review.',
- },
- versionsTitle: {
- id: 'nags.versions.title',
- defaultMessage: 'Visit versions page',
- },
- addDescriptionTitle: {
- id: 'nags.add-description.title',
- defaultMessage: 'Add a description',
- },
- addDescriptionDescription: {
- id: 'nags.add-description.description',
- defaultMessage:
- "A description that clearly describes the project's purpose and function is required.",
- },
- settingsDescriptionTitle: {
- id: 'nags.settings.description.title',
- defaultMessage: 'Visit description settings',
- },
- addIconTitle: {
- id: 'nags.add-icon.title',
- defaultMessage: 'Add an icon',
- },
- addIconDescription: {
- id: 'nags.add-icon.description',
- defaultMessage:
- 'Your project should have a nice-looking icon to uniquely identify your project at a glance.',
- },
- settingsTitle: {
- id: 'nags.settings.title',
- defaultMessage: 'Visit general settings',
- },
- featureGalleryImageTitle: {
- id: 'nags.feature-gallery-image.title',
- defaultMessage: 'Feature a gallery image',
- },
- featureGalleryImageDescription: {
- id: 'nags.feature-gallery-image.description',
- defaultMessage: 'Featured gallery images may be the first impression of many users.',
- },
- galleryTitle: {
- id: 'nags.gallery.title',
- defaultMessage: 'Visit gallery page',
- },
- selectTagsTitle: {
- id: 'nags.select-tags.title',
- defaultMessage: 'Select tags',
- },
- selectTagsDescription: {
- id: 'nags.select-tags.description',
- defaultMessage: 'Select all tags that apply to your project.',
- },
- settingsTagsTitle: {
- id: 'nags.settings.tags.title',
- defaultMessage: 'Visit tag settings',
- },
- addLinksTitle: {
- id: 'nags.add-links.title',
- defaultMessage: 'Add external links',
- },
- addLinksDescription: {
- id: 'nags.add-links.description',
- defaultMessage:
- 'Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.',
- },
- settingsLinksTitle: {
- id: 'nags.settings.links.title',
- defaultMessage: 'Visit links settings',
- },
- selectEnvironmentsTitle: {
- id: 'nags.select-environments.title',
- defaultMessage: 'Select supported environments',
- },
- selectEnvironmentsDescription: {
- id: 'nags.select-environments.description',
- defaultMessage: `Select if the {projectType} functions on the client-side and/or server-side.`,
- },
- settingsEnvironmentsTitle: {
- id: 'nags.settings.environments.title',
- defaultMessage: 'Visit general settings',
- },
- selectLicenseTitle: {
- id: 'nags.select-license.title',
- defaultMessage: 'Select license',
- },
- selectLicenseDescription: {
- id: 'nags.select-license.description',
- defaultMessage: 'Select the license your {projectType} is distributed under.',
- },
- settingsLicenseTitle: {
- id: 'nags.settings.license.title',
- defaultMessage: 'Visit license settings',
- },
-})
diff --git a/packages/moderation/data/nags/core.ts b/packages/moderation/data/nags/core.ts
deleted file mode 100644
index dff2b45015..0000000000
--- a/packages/moderation/data/nags/core.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import type { Nag, NagContext } from '../../types/nags'
-import { formatProjectType } from '@modrinth/utils'
-import { useVIntl } from '@vintl/vintl'
-
-import messages from './core.i18n'
-
-export const coreNags: Nag[] = [
- {
- id: 'moderator-feedback',
- title: messages.moderatorFeedbackTitle,
- description: messages.moderatorFeedbackDescription,
- status: 'suggestion',
- shouldShow: (context: NagContext) =>
- context.tags.rejectedStatuses.includes(context.project.status),
- link: {
- path: 'moderation',
- title: messages.moderationTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-moderation',
- },
- },
- {
- id: 'upload-version',
- title: messages.uploadVersionTitle,
- description: messages.uploadVersionDescription,
- status: 'required',
- shouldShow: (context: NagContext) => context.versions.length < 1,
- link: {
- path: 'versions',
- title: messages.versionsTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-versions',
- },
- },
- {
- id: 'add-description',
- title: messages.addDescriptionTitle,
- description: messages.addDescriptionDescription,
- status: 'required',
- shouldShow: (context: NagContext) =>
- context.project.body === '' || context.project.body.startsWith('# Placeholder description'),
- link: {
- path: 'settings/description',
- title: messages.settingsDescriptionTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
- },
- },
- {
- id: 'add-icon',
- title: messages.addIconTitle,
- description: messages.addIconDescription,
- status: 'suggestion',
- shouldShow: (context: NagContext) => !context.project.icon_url,
- link: {
- path: 'settings',
- title: messages.settingsTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
- },
- },
- {
- id: 'feature-gallery-image',
- title: messages.featureGalleryImageTitle,
- description: messages.featureGalleryImageDescription,
- status: 'suggestion',
- shouldShow: (context: NagContext) => {
- const featuredGalleryImage = context.project.gallery?.find((img) => img.featured)
- return context.project?.gallery?.length === 0 || !featuredGalleryImage
- },
- link: {
- path: 'gallery',
- title: messages.galleryTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-gallery',
- },
- },
- {
- id: 'select-tags',
- title: messages.selectTagsTitle,
- description: messages.selectTagsDescription,
- status: 'suggestion',
- shouldShow: (context: NagContext) =>
- context.project.versions.length > 0 && context.project.categories.length < 1,
- link: {
- path: 'settings/tags',
- title: messages.settingsTagsTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
- },
- },
- {
- id: 'add-links',
- title: messages.addLinksTitle,
- description: messages.addLinksDescription,
- status: 'suggestion',
- shouldShow: (context: NagContext) =>
- !(
- context.project.issues_url ||
- context.project.source_url ||
- context.project.wiki_url ||
- context.project.discord_url ||
- context.project.donation_urls.length > 0
- ),
- link: {
- path: 'settings/links',
- title: messages.settingsLinksTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
- },
- },
- {
- id: 'select-environments',
- title: messages.selectEnvironmentsTitle,
- description: (context: NagContext) => {
- const { formatMessage } = useVIntl()
-
- return formatMessage(messages.selectEnvironmentsDescription, {
- projectType: formatProjectType(context.project.project_type).toLowerCase(),
- })
- },
- status: 'required',
- shouldShow: (context: NagContext) => {
- const excludedTypes = ['resourcepack', 'plugin', 'shader', 'datapack']
- return (
- context.project.versions.length > 0 &&
- !excludedTypes.includes(context.project.project_type) &&
- (context.project.client_side === 'unknown' ||
- context.project.server_side === 'unknown' ||
- (context.project.client_side === 'unsupported' &&
- context.project.server_side === 'unsupported'))
- )
- },
- link: {
- path: 'settings',
- title: messages.settingsEnvironmentsTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
- },
- },
- {
- id: 'select-license',
- title: messages.selectLicenseTitle,
- description: (context: NagContext) => {
- const { formatMessage } = useVIntl()
-
- return formatMessage(messages.selectLicenseDescription, {
- projectType: formatProjectType(context.project.project_type).toLowerCase(),
- })
- },
- status: 'required',
- shouldShow: (context: NagContext) => context.project.license.id === 'LicenseRef-Unknown',
- link: {
- path: 'settings/license',
- title: messages.settingsLicenseTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-license',
- },
- },
-]
diff --git a/packages/moderation/data/nags/description.i18n.ts b/packages/moderation/data/nags/description.i18n.ts
deleted file mode 100644
index 4fa37414a6..0000000000
--- a/packages/moderation/data/nags/description.i18n.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { defineMessages } from '@vintl/vintl'
-
-export default defineMessages({
- descriptionTooShortTitle: {
- id: 'nags.description-too-short.title',
- defaultMessage: 'Description may be insufficient',
- },
- descriptionTooShortDescription: {
- id: 'nags.description-too-short.description',
- defaultMessage:
- "Your description is {length} characters. It's recommended to have at least {minChars} characters to provide users with enough information about your project.",
- },
- longHeadersTitle: {
- id: 'nags.long-headers.title',
- defaultMessage: 'Headers are too long',
- },
- longHeadersDescription: {
- id: 'nags.long-headers.description',
- defaultMessage:
- '{count, plural, one {# header} other {# headers}} in your description {count, plural, one {is} other {are}} too long. Headers should be concise and act as section titles, not full sentences.',
- },
- summaryTooShortTitle: {
- id: 'nags.summary-too-short.title',
- defaultMessage: 'Summary may be insufficient',
- },
- summaryTooShortDescription: {
- id: 'nags.summary-too-short.description',
- defaultMessage:
- "Your summary is {length} characters. It's recommended to have at least {minChars} characters to provide users with enough information about your project.",
- },
- minecraftTitleClauseTitle: {
- id: 'nags.minecraft-title-clause.title',
- defaultMessage: 'Title contains "Minecraft"',
- },
- minecraftTitleClauseDescription: {
- id: 'nags.minecraft-title-clause.description',
- defaultMessage:
- 'Please remove "Minecraft" from your title. You cannot use "Minecraft" in your title for legal reasons.',
- },
- titleContainsTechnicalInfoTitle: {
- id: 'nags.title-contains-technical-info.title',
- defaultMessage: 'Title contains loader or version info',
- },
- titleContainsTechnicalInfoDescription: {
- id: 'nags.title-contains-technical-info.description',
- defaultMessage:
- 'Removing these helps keep titles clean and makes your project easier to find. Version and loader information is automatically displayed alongside your project.',
- },
- summarySameAsTitleTitle: {
- id: 'nags.summary-same-as-title.title',
- defaultMessage: 'Summary is project name',
- },
- summarySameAsTitleDescription: {
- id: 'nags.summary-same-as-title.description',
- defaultMessage:
- "Your summary is the same as your project name. Please change it. It's recommended to have a unique summary to provide more context about your project.",
- },
- imageHeavyDescriptionTitle: {
- id: 'nags.image-heavy-description.title',
- defaultMessage: 'Description is mostly images',
- },
- imageHeavyDescriptionDescription: {
- id: 'nags.image-heavy-description.description',
- defaultMessage:
- 'Please add more descriptive text to help users understand your project, especially those using screen readers or with slow internet connections.',
- },
- missingAltTextTitle: {
- id: 'nags.missing-alt-text.title',
- defaultMessage: 'Images missing alt text',
- },
- missingAltTextDescription: {
- id: 'nags.missing-alt-text.description',
- defaultMessage:
- 'Some of your images are missing alt text, which is important for accessibility, especially for visually impaired users.',
- },
- editDescriptionTitle: {
- id: 'nags.edit-description.title',
- defaultMessage: 'Edit description',
- },
- editSummaryTitle: {
- id: 'nags.edit-summary.title',
- defaultMessage: 'Edit summary',
- },
- editTitleTitle: {
- id: 'nags.edit-title.title',
- defaultMessage: 'Edit title',
- },
-})
diff --git a/packages/moderation/data/nags/description.ts b/packages/moderation/data/nags/description.ts
deleted file mode 100644
index abd53914dd..0000000000
--- a/packages/moderation/data/nags/description.ts
+++ /dev/null
@@ -1,226 +0,0 @@
-import type { Nag, NagContext } from '../../types/nags'
-import { useVIntl } from '@vintl/vintl'
-
-import messages from './description.i18n'
-
-export const MIN_DESCRIPTION_CHARS = 500
-export const MAX_HEADER_LENGTH = 100
-export const MIN_SUMMARY_CHARS = 125
-
-function analyzeHeaderLength(markdown: string): { hasLongHeaders: boolean; longHeaders: string[] } {
- if (!markdown) return { hasLongHeaders: false, longHeaders: [] }
-
- const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
-
- const headerRegex = /^(#{1,3})\s+(.+)$/gm
- const headers = [...withoutCodeBlocks.matchAll(headerRegex)]
-
- const longHeaders: string[] = []
-
- headers.forEach((match) => {
- const headerText = match[2].trim()
- const sentenceEnders = /[.!?]+/g
- const sentences = headerText.split(sentenceEnders).filter((s) => s.trim().length > 0)
-
- const hasSentenceEnders = sentenceEnders.test(headerText)
- const isVeryLong = headerText.length > MAX_HEADER_LENGTH
- const hasMultipleSentences = sentences.length > 1
-
- if (hasSentenceEnders || isVeryLong || hasMultipleSentences) {
- longHeaders.push(headerText)
- }
- })
-
- return {
- hasLongHeaders: longHeaders.length > 0,
- longHeaders,
- }
-}
-
-function analyzeImageContent(markdown: string): { imageHeavy: boolean; hasEmptyAltText: boolean } {
- if (!markdown) return { imageHeavy: false, hasEmptyAltText: false }
-
- const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
-
- const imageRegex = /!\[([^\]]*)\]\([^)]+\)/g
- const images = [...withoutCodeBlocks.matchAll(imageRegex)]
-
- const htmlImageRegex = /
]*>/gi
- const htmlImages = [...withoutCodeBlocks.matchAll(htmlImageRegex)]
-
- const totalImages = images.length + htmlImages.length
- if (totalImages === 0) return { imageHeavy: false, hasEmptyAltText: false }
-
- const textWithoutImages = withoutCodeBlocks
- .replace(/!\[([^\]]*)\]\([^)]+\)/g, '')
- .replace(/
]*>/gi, '')
- .replace(/\s+/g, ' ')
- .trim()
-
- const textLength = textWithoutImages.length
- const imageHeavy = textLength < 100 || (totalImages >= 3 && textLength < 200)
-
- const hasEmptyAltText =
- images.some((match) => !match[1]?.trim()) ||
- htmlImages.some((match) => {
- const altMatch = match[0].match(/alt\s*=\s*["']([^"']*)["']/i)
- return !altMatch || !altMatch[1]?.trim()
- })
-
- return { imageHeavy, hasEmptyAltText }
-}
-
-export const descriptionNags: Nag[] = [
- {
- id: 'description-too-short',
- title: messages.descriptionTooShortTitle,
- description: (context: NagContext) => {
- const { formatMessage } = useVIntl()
-
- return formatMessage(messages.descriptionTooShortDescription, {
- length: context.project.body?.length || 0,
- minChars: MIN_DESCRIPTION_CHARS,
- })
- },
- status: 'warning',
- shouldShow: (context: NagContext) => {
- const bodyLength = context.project.body?.trim()?.length || 0
- return bodyLength < MIN_DESCRIPTION_CHARS && bodyLength !== 0
- },
- link: {
- path: 'settings/description',
- title: messages.editDescriptionTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
- },
- },
- {
- id: 'long-headers',
- title: messages.longHeadersTitle,
- description: (context: NagContext) => {
- const { formatMessage } = useVIntl()
- const { longHeaders } = analyzeHeaderLength(context.project.body || '')
- const count = longHeaders.length
-
- return formatMessage(messages.longHeadersDescription, {
- count,
- })
- },
- status: 'warning',
- shouldShow: (context: NagContext) => {
- const { hasLongHeaders } = analyzeHeaderLength(context.project.body || '')
- return hasLongHeaders
- },
- link: {
- path: 'settings/description',
- title: messages.editDescriptionTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
- },
- },
- {
- id: 'summary-too-short',
- title: messages.summaryTooShortTitle,
- description: (context: NagContext) => {
- const { formatMessage } = useVIntl()
-
- return formatMessage(messages.summaryTooShortDescription, {
- length: context.project.description?.length || 0,
- minChars: MIN_SUMMARY_CHARS,
- })
- },
- status: 'warning',
- shouldShow: (context: NagContext) => {
- const summaryLength = context.project.description?.trim()?.length || 0
- return summaryLength < MIN_SUMMARY_CHARS && summaryLength !== 0
- },
- link: {
- path: 'settings',
- title: messages.editSummaryTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
- },
- },
- {
- id: 'minecraft-title-clause',
- title: messages.minecraftTitleClauseTitle,
- description: messages.minecraftTitleClauseDescription,
- status: 'required',
- shouldShow: (context: NagContext) => {
- const title = context.project.title?.toLowerCase() || ''
- const wordsInTitle = title.split(' ').filter((word) => word.length > 0)
- return title.includes('minecraft') && title.length > 0 && wordsInTitle.length <= 3
- },
- link: {
- path: 'settings',
- title: messages.editTitleTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
- },
- },
- {
- id: 'title-contains-technical-info',
- title: messages.titleContainsTechnicalInfoTitle,
- description: messages.titleContainsTechnicalInfoDescription,
- status: 'warning',
- shouldShow: (context: NagContext) => {
- const title = context.project.title?.toLowerCase() || ''
- if (!title) return false
-
- const loaderNames =
- context.tags.loaders?.map((loader: { name: string }) => loader.name?.toLowerCase()) || []
- const hasLoader = loaderNames.some((loader) => loader && title.includes(loader.toLowerCase()))
- const versionPatterns = [/\b1\.\d+(\.\d+)?\b/]
- const hasVersionPattern = versionPatterns.some((pattern) => pattern.test(title))
-
- return hasLoader || hasVersionPattern
- },
- link: {
- path: 'settings',
- title: messages.editTitleTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
- },
- },
- {
- id: 'summary-same-as-title',
- title: messages.summarySameAsTitleTitle,
- description: messages.summarySameAsTitleDescription,
- status: 'required',
- shouldShow: (context: NagContext) => {
- const title = context.project.title?.trim() || ''
- const summary = context.project.description?.trim() || ''
- return title === summary && title.length > 0 && summary.length > 0
- },
- link: {
- path: 'settings',
- title: messages.editSummaryTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
- },
- },
- {
- id: 'image-heavy-description',
- title: messages.imageHeavyDescriptionTitle,
- description: messages.imageHeavyDescriptionDescription,
- status: 'warning',
- shouldShow: (context: NagContext) => {
- const { imageHeavy } = analyzeImageContent(context.project.body || '')
- return imageHeavy
- },
- link: {
- path: 'settings/description',
- title: messages.editDescriptionTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
- },
- },
- {
- id: 'missing-alt-text',
- title: messages.missingAltTextTitle,
- description: messages.missingAltTextDescription,
- status: 'warning',
- shouldShow: (context: NagContext) => {
- const { hasEmptyAltText } = analyzeImageContent(context.project.body || '')
- return hasEmptyAltText
- },
- link: {
- path: 'settings/description',
- title: messages.editDescriptionTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
- },
- },
-]
diff --git a/packages/moderation/data/nags/index.ts b/packages/moderation/data/nags/index.ts
deleted file mode 100644
index d606d58964..0000000000
--- a/packages/moderation/data/nags/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export * from './core'
-export * from './links'
-export * from './description'
-export * from './tags'
diff --git a/packages/moderation/data/nags/links.i18n.ts b/packages/moderation/data/nags/links.i18n.ts
deleted file mode 100644
index facaec25a7..0000000000
--- a/packages/moderation/data/nags/links.i18n.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { defineMessages } from '@vintl/vintl'
-
-export default defineMessages({
- verifyExternalLinksTitle: {
- id: 'nags.verify-external-links.title',
- defaultMessage: 'Verify external links',
- },
- verifyExternalLinksDescription: {
- id: 'nags.verify-external-links.description',
- defaultMessage:
- "Some of your external links may be using domains that aren't recognized as common for their link type.",
- },
- invalidLicenseUrlTitle: {
- id: 'nags.invalid-license-url.title',
- defaultMessage: 'Invalid license URL',
- },
- invalidLicenseUrlDescriptionDefault: {
- id: 'nags.invalid-license-url.description.default',
- defaultMessage: 'License URL is invalid.',
- },
- invalidLicenseUrlDescriptionDomain: {
- id: 'nags.invalid-license-url.description.domain',
- defaultMessage:
- 'Your license URL points to {domain}, which is not appropriate for license information. License URLs should link to the actual license text or legal documentation, not social media, gaming platforms etc.',
- },
- invalidLicenseUrlDescriptionMalformed: {
- id: 'nags.invalid-license-url.description.malformed',
- defaultMessage:
- 'Your license URL appears to be malformed. Please provide a valid URL to your license text.',
- },
- gplLicenseSourceRequiredTitle: {
- id: 'nags.gpl-license-source-required.title',
- defaultMessage: 'GPL license requires source',
- },
- gplLicenseSourceRequiredDescription: {
- id: 'nags.gpl-license-source-required.description',
- defaultMessage:
- 'Your {projectType} uses a GPL license which requires source code to be available. Please provide a source code link or consider using a different license.',
- },
- visitLinksSettingsTitle: {
- id: 'nags.visit-links-settings.title',
- defaultMessage: 'Visit links settings',
- },
- editLicenseTitle: {
- id: 'nags.edit-license.title',
- defaultMessage: 'Edit license',
- },
-})
diff --git a/packages/moderation/data/nags/links.ts b/packages/moderation/data/nags/links.ts
deleted file mode 100644
index 5690977869..0000000000
--- a/packages/moderation/data/nags/links.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import type { Nag, NagContext } from '../../types/nags'
-import { formatProjectType } from '@modrinth/utils'
-import { useVIntl } from '@vintl/vintl'
-
-import messages from './links.i18n'
-
-export const commonLinkDomains = {
- source: ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org', 'git.sr.ht'],
- issues: ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org'],
- discord: ['discord.gg', 'discord.com'],
- licenseBlocklist: [
- 'youtube.com',
- 'youtu.be',
- 'modrinth.com',
- 'curseforge.com',
- 'twitter.com',
- 'x.com',
- 'discord.gg',
- 'discord.com',
- 'instagram.com',
- 'facebook.com',
- 'tiktok.com',
- 'reddit.com',
- 'twitch.tv',
- 'patreon.com',
- 'ko-fi.com',
- 'paypal.com',
- 'buymeacoffee.com',
- ],
-}
-
-export function isCommonUrl(url: string | undefined, commonDomains: string[]): boolean {
- if (!url) return false
- try {
- const domain = new URL(url).hostname.toLowerCase()
- return commonDomains.some((allowed) => domain.includes(allowed))
- } catch {
- return false
- }
-}
-
-export function isUncommonLicenseUrl(url: string | undefined, domains: string[]): boolean {
- if (!url) return false
- try {
- const domain = new URL(url).hostname.toLowerCase()
- return domains.some((uncommonDomain) => domain.includes(uncommonDomain))
- } catch {
- return false
- }
-}
-
-export const linksNags: Nag[] = [
- {
- id: 'verify-external-links',
- title: messages.verifyExternalLinksTitle,
- description: messages.verifyExternalLinksDescription,
- status: 'warning',
- shouldShow: (context: NagContext) => {
- return (
- !isCommonUrl(context.project.source_url, commonLinkDomains.source) ||
- !isCommonUrl(context.project.issues_url, commonLinkDomains.issues) ||
- !isCommonUrl(context.project.discord_url, commonLinkDomains.discord)
- )
- },
- link: {
- path: 'settings/links',
- title: messages.visitLinksSettingsTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
- },
- },
- {
- id: 'invalid-license-url',
- title: messages.invalidLicenseUrlTitle,
- description: (context: NagContext) => {
- const { formatMessage } = useVIntl()
- const licenseUrl = context.project.license.url
-
- if (!licenseUrl) {
- return formatMessage(messages.invalidLicenseUrlDescriptionDefault)
- }
-
- try {
- const domain = new URL(licenseUrl).hostname.toLowerCase()
- return formatMessage(messages.invalidLicenseUrlDescriptionDomain, { domain })
- } catch {
- return formatMessage(messages.invalidLicenseUrlDescriptionMalformed)
- }
- },
- status: 'required',
- shouldShow: (context: NagContext) => {
- const licenseUrl = context.project.license.url
- if (!licenseUrl) return false
-
- const isBlocklisted = isUncommonLicenseUrl(licenseUrl, commonLinkDomains.licenseBlocklist)
-
- try {
- new URL(licenseUrl)
- return isBlocklisted
- } catch {
- return true
- }
- },
- link: {
- path: 'settings',
- title: messages.editLicenseTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
- },
- },
- {
- id: 'gpl-license-source-required',
- title: messages.gplLicenseSourceRequiredTitle,
- description: (context: NagContext) => {
- const { formatMessage } = useVIntl()
-
- return formatMessage(messages.gplLicenseSourceRequiredDescription, {
- projectType: formatProjectType(context.project.project_type).toLowerCase(),
- })
- },
- status: 'required',
- shouldShow: (context: NagContext) => {
- const gplLicenses = [
- 'GPL-2.0',
- 'GPL-2.0+',
- 'GPL-2.0-only',
- 'GPL-2.0-or-later',
- 'GPL-3.0',
- 'GPL-3.0+',
- 'GPL-3.0-only',
- 'GPL-3.0-or-later',
- 'LGPL-2.1',
- 'LGPL-2.1+',
- 'LGPL-2.1-only',
- 'LGPL-2.1-or-later',
- 'LGPL-3.0',
- 'LGPL-3.0+',
- 'LGPL-3.0-only',
- 'LGPL-3.0-or-later',
- 'AGPL-3.0',
- 'AGPL-3.0+',
- 'AGPL-3.0-only',
- 'AGPL-3.0-or-later',
- ]
-
- const isGplLicense = gplLicenses.includes(context.project.license.id)
- const hasSourceUrl = !!context.project.source_url
-
- return isGplLicense && !hasSourceUrl
- },
- link: {
- path: 'settings/links',
- title: messages.visitLinksSettingsTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
- },
- },
-]
diff --git a/packages/moderation/data/nags/tags.i18n.ts b/packages/moderation/data/nags/tags.i18n.ts
deleted file mode 100644
index cd1940ec39..0000000000
--- a/packages/moderation/data/nags/tags.i18n.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { defineMessages } from '@vintl/vintl'
-
-export default defineMessages({
- tooManyTagsTitle: {
- id: 'nags.too-many-tags.title',
- defaultMessage: 'Too many tags selected',
- },
- tooManyTagsDescription: {
- id: 'nags.too-many-tags.description',
- defaultMessage:
- "You've selected {tagCount} tags. Consider reducing to 5 or fewer to keep your project focused and easier to discover.",
- },
- multipleResolutionTagsTitle: {
- id: 'nags.multiple-resolution-tags.title',
- defaultMessage: 'Multiple resolution tags selected',
- },
- multipleResolutionTagsDescription: {
- id: 'nags.multiple-resolution-tags.description',
- defaultMessage:
- "You've selected {count} resolution tags ({tags}). Resource packs should typically only have one resolution tag that matches their primary resolution.",
- },
- allTagsSelectedTitle: {
- id: 'nags.all-tags-selected.title',
- defaultMessage: 'All tags selected',
- },
- allTagsSelectedDescription: {
- id: 'nags.all-tags-selected.description',
- defaultMessage:
- "You've selected all {totalAvailableTags} available tags. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that truly apply to your project.",
- },
- editTagsTitle: {
- id: 'nags.edit-tags.title',
- defaultMessage: 'Edit tags',
- },
-})
diff --git a/packages/moderation/data/nags/tags.ts b/packages/moderation/data/nags/tags.ts
deleted file mode 100644
index 2ea30392b5..0000000000
--- a/packages/moderation/data/nags/tags.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import type { Project } from '@modrinth/utils'
-import type { Nag, NagContext } from '../../types/nags'
-import { useVIntl } from '@vintl/vintl'
-
-import messages from './tags.i18n'
-
-function getCategories(
- project: Project & { actualProjectType: string },
- tags: {
- categories?: {
- project_type: string
- }[]
- },
-) {
- return (
- tags.categories?.filter(
- (category: { project_type: string }) => category.project_type === project.actualProjectType,
- ) ?? []
- )
-}
-
-export const tagsNags: Nag[] = [
- {
- id: 'too-many-tags',
- title: messages.tooManyTagsTitle,
- description: (context: NagContext) => {
- const { formatMessage } = useVIntl()
- const tagCount =
- context.project.categories.length + (context.project.additional_categories?.length || 0)
-
- return formatMessage(messages.tooManyTagsDescription, {
- tagCount,
- })
- },
- status: 'warning',
- shouldShow: (context: NagContext) => {
- const tagCount =
- context.project.categories.length + (context.project.additional_categories?.length || 0)
- return tagCount > 5
- },
- link: {
- path: 'settings/tags',
- title: messages.editTagsTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
- },
- },
- {
- id: 'multiple-resolution-tags',
- title: messages.multipleResolutionTagsTitle,
- description: (context: NagContext) => {
- const { formatMessage } = useVIntl()
- const resolutionTags = context.project.categories.filter((tag: string) =>
- ['16x', '32x', '48x', '64x', '128x', '256x', '512x', '1024x'].includes(tag),
- )
-
- return formatMessage(messages.multipleResolutionTagsDescription, {
- count: resolutionTags.length,
- tags: resolutionTags.join(', '),
- })
- },
- status: 'warning',
- shouldShow: (context: NagContext) => {
- if (context.project.project_type !== 'resourcepack') return false
-
- const resolutionTags = context.project.categories.filter((tag: string) =>
- ['16x', '32x', '48x', '64x', '128x', '256x', '512x', '1024x'].includes(tag),
- )
- return resolutionTags.length > 1
- },
- link: {
- path: 'settings/tags',
- title: messages.editTagsTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
- },
- },
- {
- id: 'all-tags-selected',
- title: messages.allTagsSelectedTitle,
- description: (context: NagContext) => {
- const { formatMessage } = useVIntl()
- const categoriesForProjectType = getCategories(
- context.project as Project & { actualProjectType: string },
- context.tags,
- )
- const totalAvailableTags = categoriesForProjectType.length
-
- return formatMessage(messages.allTagsSelectedDescription, {
- totalAvailableTags,
- })
- },
- status: 'required',
- shouldShow: (context: NagContext) => {
- const categoriesForProjectType = getCategories(
- context.project as Project & { actualProjectType: string },
- context.tags,
- )
- const totalSelectedTags =
- context.project.categories.length + (context.project.additional_categories?.length || 0)
- return totalSelectedTags === categoriesForProjectType.length
- },
- link: {
- path: 'settings/tags',
- title: messages.editTagsTitle,
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
- },
- },
-]
diff --git a/packages/moderation/data/report-quick-replies.ts b/packages/moderation/data/report-quick-replies.ts
new file mode 100644
index 0000000000..6d47366fe7
--- /dev/null
+++ b/packages/moderation/data/report-quick-replies.ts
@@ -0,0 +1,3 @@
+import type { ReportQuickReply } from '../types/reports'
+
+export default [] as ReadonlyArray
diff --git a/packages/moderation/index.ts b/packages/moderation/index.ts
index de7868df10..5135b21b18 100644
--- a/packages/moderation/index.ts
+++ b/packages/moderation/index.ts
@@ -2,10 +2,9 @@ export * from './types/actions'
export * from './types/messages'
export * from './types/stage'
export * from './types/keybinds'
-export * from './types/nags'
+export * from './types/reports'
export * from './utils'
-export * from './data/nags/index'
+export { finalPermissionMessages } from './data/modpack-permissions-stage'
export { default as checklist } from './data/checklist'
export { default as keybinds } from './data/keybinds'
-export { default as nags } from './data/nags'
diff --git a/packages/moderation/locales/en-US/index.json b/packages/moderation/locales/en-US/index.json
deleted file mode 100644
index 185443eae8..0000000000
--- a/packages/moderation/locales/en-US/index.json
+++ /dev/null
@@ -1,191 +0,0 @@
-{
- "nags.add-description.description": {
- "defaultMessage": "A description that clearly describes the project's purpose and function is required."
- },
- "nags.add-description.title": {
- "defaultMessage": "Add a description"
- },
- "nags.add-icon.description": {
- "defaultMessage": "Your project should have a nice-looking icon to uniquely identify your project at a glance."
- },
- "nags.add-icon.title": {
- "defaultMessage": "Add an icon"
- },
- "nags.add-links.description": {
- "defaultMessage": "Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite."
- },
- "nags.add-links.title": {
- "defaultMessage": "Add external links"
- },
- "nags.all-tags-selected.description": {
- "defaultMessage": "You've selected all {totalAvailableTags} available tags. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that truly apply to your project."
- },
- "nags.all-tags-selected.title": {
- "defaultMessage": "All tags selected"
- },
- "nags.description-too-short.description": {
- "defaultMessage": "Your description is {length} characters. It's recommended to have at least {minChars} characters to provide users with enough information about your project."
- },
- "nags.description-too-short.title": {
- "defaultMessage": "Description may be insufficient"
- },
- "nags.edit-description.title": {
- "defaultMessage": "Edit description"
- },
- "nags.edit-license.title": {
- "defaultMessage": "Edit license"
- },
- "nags.edit-summary.title": {
- "defaultMessage": "Edit summary"
- },
- "nags.edit-tags.title": {
- "defaultMessage": "Edit tags"
- },
- "nags.edit-title.title": {
- "defaultMessage": "Edit title"
- },
- "nags.feature-gallery-image.description": {
- "defaultMessage": "Featured gallery images may be the first impression of many users."
- },
- "nags.feature-gallery-image.title": {
- "defaultMessage": "Feature a gallery image"
- },
- "nags.gallery.title": {
- "defaultMessage": "Visit gallery page"
- },
- "nags.gpl-license-source-required.description": {
- "defaultMessage": "Your {projectType} uses a GPL license which requires source code to be available. Please provide a source code link or consider using a different license."
- },
- "nags.gpl-license-source-required.title": {
- "defaultMessage": "GPL license requires source"
- },
- "nags.image-heavy-description.description": {
- "defaultMessage": "Please add more descriptive text to help users understand your project, especially those using screen readers or with slow internet connections."
- },
- "nags.image-heavy-description.title": {
- "defaultMessage": "Description is mostly images"
- },
- "nags.invalid-license-url.description.default": {
- "defaultMessage": "License URL is invalid."
- },
- "nags.invalid-license-url.description.domain": {
- "defaultMessage": "Your license URL points to {domain}, which is not appropriate for license information. License URLs should link to the actual license text or legal documentation, not social media, gaming platforms etc."
- },
- "nags.invalid-license-url.description.malformed": {
- "defaultMessage": "Your license URL appears to be malformed. Please provide a valid URL to your license text."
- },
- "nags.invalid-license-url.title": {
- "defaultMessage": "Invalid license URL"
- },
- "nags.long-headers.description": {
- "defaultMessage": "{count, plural, one {# header} other {# headers}} in your description {count, plural, one {is} other {are}} too long. Headers should be concise and act as section titles, not full sentences."
- },
- "nags.long-headers.title": {
- "defaultMessage": "Headers are too long"
- },
- "nags.minecraft-title-clause.description": {
- "defaultMessage": "Please remove \"Minecraft\" from your title. You cannot use \"Minecraft\" in your title for legal reasons."
- },
- "nags.minecraft-title-clause.title": {
- "defaultMessage": "Title contains \"Minecraft\""
- },
- "nags.missing-alt-text.description": {
- "defaultMessage": "Some of your images are missing alt text, which is important for accessibility, especially for visually impaired users."
- },
- "nags.missing-alt-text.title": {
- "defaultMessage": "Images missing alt text"
- },
- "nags.moderation.title": {
- "defaultMessage": "Visit moderation thread"
- },
- "nags.moderator-feedback.description": {
- "defaultMessage": "Review any feedback from moderators regarding your project before resubmitting."
- },
- "nags.moderator-feedback.title": {
- "defaultMessage": "Review moderator feedback"
- },
- "nags.multiple-resolution-tags.description": {
- "defaultMessage": "You've selected {count} resolution tags ({tags}). Resource packs should typically only have one resolution tag that matches their primary resolution."
- },
- "nags.multiple-resolution-tags.title": {
- "defaultMessage": "Multiple resolution tags selected"
- },
- "nags.select-environments.description": {
- "defaultMessage": "Select if the {projectType} functions on the client-side and/or server-side."
- },
- "nags.select-environments.title": {
- "defaultMessage": "Select supported environments"
- },
- "nags.select-license.description": {
- "defaultMessage": "Select the license your {projectType} is distributed under."
- },
- "nags.select-license.title": {
- "defaultMessage": "Select license"
- },
- "nags.select-tags.description": {
- "defaultMessage": "Select all tags that apply to your project."
- },
- "nags.select-tags.title": {
- "defaultMessage": "Select tags"
- },
- "nags.settings.description.title": {
- "defaultMessage": "Visit description settings"
- },
- "nags.settings.environments.title": {
- "defaultMessage": "Visit general settings"
- },
- "nags.settings.license.title": {
- "defaultMessage": "Visit license settings"
- },
- "nags.settings.links.title": {
- "defaultMessage": "Visit links settings"
- },
- "nags.settings.tags.title": {
- "defaultMessage": "Visit tag settings"
- },
- "nags.settings.title": {
- "defaultMessage": "Visit general settings"
- },
- "nags.summary-same-as-title.description": {
- "defaultMessage": "Your summary is the same as your project name. Please change it. It's recommended to have a unique summary to provide more context about your project."
- },
- "nags.summary-same-as-title.title": {
- "defaultMessage": "Summary is project name"
- },
- "nags.summary-too-short.description": {
- "defaultMessage": "Your summary is {length} characters. It's recommended to have at least {minChars} characters to provide users with enough information about your project."
- },
- "nags.summary-too-short.title": {
- "defaultMessage": "Summary may be insufficient"
- },
- "nags.title-contains-technical-info.description": {
- "defaultMessage": "Removing these helps keep titles clean and makes your project easier to find. Version and loader information is automatically displayed alongside your project."
- },
- "nags.title-contains-technical-info.title": {
- "defaultMessage": "Title contains loader or version info"
- },
- "nags.too-many-tags.description": {
- "defaultMessage": "You've selected {tagCount} tags. Consider reducing to 5 or fewer to keep your project focused and easier to discover."
- },
- "nags.too-many-tags.title": {
- "defaultMessage": "Too many tags selected"
- },
- "nags.upload-version.description": {
- "defaultMessage": "At least one version is required for a project to be submitted for review."
- },
- "nags.upload-version.title": {
- "defaultMessage": "Upload a version"
- },
- "nags.verify-external-links.description": {
- "defaultMessage": "Some of your external links may be using domains that aren't recognized as common for their link type."
- },
- "nags.verify-external-links.title": {
- "defaultMessage": "Verify external links"
- },
- "nags.versions.title": {
- "defaultMessage": "Visit versions page"
- },
- "nags.visit-links-settings.title": {
- "defaultMessage": "Visit links settings"
- }
-}
diff --git a/packages/moderation/package.json b/packages/moderation/package.json
index 2698676859..39a4a49e47 100644
--- a/packages/moderation/package.json
+++ b/packages/moderation/package.json
@@ -6,17 +6,14 @@
"types": "./index.d.ts",
"scripts": {
"lint": "eslint . && prettier --check .",
- "fix": "eslint . --fix && prettier --write . && pnpm run intl:extract",
- "intl:extract": "formatjs extract \"**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore \"node_modules/**/*\" --out-file locales/en-US/index.json --preserve-whitespace"
+ "fix": "eslint . --fix && prettier --write ."
},
"dependencies": {
- "@modrinth/assets": "workspace:*",
"@modrinth/utils": "workspace:*",
+ "@modrinth/assets": "workspace:*",
"vue": "^3.5.13"
},
"devDependencies": {
- "@formatjs/cli": "^6.2.12",
- "@vintl/vintl": "^4.4.1",
"eslint": "^8.57.0",
"eslint-config-custom": "workspace:*",
"tsconfig": "workspace:*"
diff --git a/packages/moderation/types/nags.ts b/packages/moderation/types/nags.ts
deleted file mode 100644
index 7e002ccee1..0000000000
--- a/packages/moderation/types/nags.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import type { Project, User, Version } from '@modrinth/utils'
-import type { MessageDescriptor } from '@vintl/vintl'
-import type { FunctionalComponent, SVGAttributes } from 'vue'
-
-/**
- * Type which represents the status type of a nag.
- *
- * - `required` indicates that the nag must be addressed.
- * - `warning` indicates that the nag is important but not critical, and can be ignored. It is often used for issues that should be resolved but do not block project submission.
- * - `suggestion` indicates that the nag is a recommendation and can be ignored.
- */
-export type NagStatus = 'required' | 'warning' | 'suggestion' | 'special-submit-action'
-
-/**
- * Interface representing the context in which a nag is displayed.
- * It includes the project, versions, current member, all members, and the current route.
- * This context is used to determine whether a nag or it's link should be shown and how it should be presented.
- */
-export interface NagContext {
- /**
- * The project associated with the nag.
- */
- project: Project
- /**
- * The versions associated with the project.
- */
- versions: Version[]
- /**
- * The current project member viewing the nag.
- */
- currentMember: User
- /**
- * The current route in the application.
- */
- currentRoute: string
- /* eslint-disable @typescript-eslint/no-explicit-any */
- tags: any
- submitProject: (...any: any) => any
- /* eslint-enable @typescript-eslint/no-explicit-any */
-}
-
-/**
- * Interface representing a nag's link.
- */
-export interface NagLink {
- /**
- * A relative path to the nag's link, e.g. '/settings'.
- */
- path: string
- /**
- * The text to display for the nag's link.
- */
- title: MessageDescriptor | string
- /**
- * The status of the nag, which can be 'required', 'warning', or 'suggestion'.
- */
- shouldShow?: (context: NagContext) => boolean
-}
-
-/**
- * Interface representing a nag.
- */
-export interface Nag {
- /**
- * A unique identifier for the nag.
- */
- id: string
- /**
- * The title of the nag.
- */
- title: MessageDescriptor | string
- /**
- * A function that returns the description of the nag.
- * It can accept a context to provide dynamic descriptions.
- */
- description: MessageDescriptor | ((context: NagContext) => string)
- /**
- * The status of the nag, which can be 'required', 'warning', or 'suggestion'.
- */
- status: NagStatus
- /**
- * An optional icon for the nag, usually from `@modrinth/assets`.
- * If not specified it will use the default icon associated with the nag status.
- */
- icon?: FunctionalComponent
-
- /**
- * A function that determines whether the nag should be shown based on the context.
- */
- shouldShow: (context: NagContext) => boolean
- /**
- * An optional link associated with the nag.
- * If provided, it should be displayed alongside the nag.
- */
- link?: NagLink
-}
diff --git a/packages/moderation/types/reports.ts b/packages/moderation/types/reports.ts
new file mode 100644
index 0000000000..aec0b08637
--- /dev/null
+++ b/packages/moderation/types/reports.ts
@@ -0,0 +1,27 @@
+import type { Project, Report, Thread, User, Version, DelphiReport } from '@modrinth/utils'
+
+export interface OwnershipTarget {
+ name: string
+ slug: string
+ avatar_url?: string
+ type: 'user' | 'organization'
+}
+
+export interface ExtendedReport extends Report {
+ thread: Thread
+ reporter_user: User
+ project?: Project
+ user?: User
+ version?: Version
+ target?: OwnershipTarget
+}
+
+export interface ExtendedDelphiReport extends DelphiReport {
+ target?: OwnershipTarget
+}
+
+export interface ReportQuickReply {
+ label: string
+ message: string | ((report: ExtendedReport) => string)
+ shouldShow?: (report: ExtendedReport) => boolean
+}
diff --git a/packages/ui/src/components/base/CollapsibleRegion.vue b/packages/ui/src/components/base/CollapsibleRegion.vue
new file mode 100644
index 0000000000..80a7728799
--- /dev/null
+++ b/packages/ui/src/components/base/CollapsibleRegion.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ isCollapsed ? expandText : collapseText }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index e1217fc590..85b7f2da0b 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -10,6 +10,7 @@ export { default as Card } from './base/Card.vue'
export { default as Checkbox } from './base/Checkbox.vue'
export { default as Chips } from './base/Chips.vue'
export { default as Collapsible } from './base/Collapsible.vue'
+export { default as CollapsibleRegion } from './base/CollapsibleRegion.vue'
export { default as ContentPageHeader } from './base/ContentPageHeader.vue'
export { default as CopyCode } from './base/CopyCode.vue'
export { default as DoubleIcon } from './base/DoubleIcon.vue'
diff --git a/packages/utils/changelog.ts b/packages/utils/changelog.ts
index 0383db4643..d6e7d983d7 100644
--- a/packages/utils/changelog.ts
+++ b/packages/utils/changelog.ts
@@ -10,6 +10,13 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
+ {
+ date: `2025-07-19T15:20:00-07:00`,
+ product: 'web',
+ body: `### Improvements
+- Removed Tumblr icon from footer as we no longer use it.
+- Reverted changes to publishing checklist since they need more work.`,
+ },
{
date: `2025-07-16T12:40:00-07:00`,
product: 'web',
diff --git a/packages/utils/types.ts b/packages/utils/types.ts
index 437fda2d68..9fb371feef 100644
--- a/packages/utils/types.ts
+++ b/packages/utils/types.ts
@@ -295,6 +295,60 @@ export type Report = {
body: string
}
+// Threads
+export interface Thread {
+ id: string
+ type: ThreadType
+ project_id: string | null
+ report_id: string | null
+ messages: ThreadMessage[]
+ members: User[]
+}
+
+export type ThreadType = 'project' | 'report' | 'direct_message'
+
+export interface ThreadMessage {
+ id: string
+ author_id: string | null
+ body: MessageBody
+ created: string
+ hide_identity: boolean
+}
+
+export type MessageBody =
+ | TextMessageBody
+ | StatusChangeMessageBody
+ | ThreadClosureMessageBody
+ | ThreadReopenMessageBody
+ | DeletedMessageBody
+
+export interface TextMessageBody {
+ type: 'text'
+ body: string
+ private: boolean
+ replying_to: string | null
+ associated_images: string[]
+}
+
+export interface StatusChangeMessageBody {
+ type: 'status_change'
+ new_status: ProjectStatus
+ old_status: ProjectStatus
+}
+
+export interface ThreadClosureMessageBody {
+ type: 'thread_closure'
+}
+
+export interface ThreadReopenMessageBody {
+ type: 'thread_reopen'
+}
+
+export interface DeletedMessageBody {
+ type: 'deleted'
+ private: boolean
+}
+
// Moderation
export interface ModerationModpackPermissionApprovalType {
id:
@@ -315,7 +369,7 @@ export interface ModerationPermissionType {
export interface ModerationBaseModpackItem {
sha1: string
file_name: string
- type: 'unknown' | 'flame'
+ type: 'unknown' | 'flame' | 'identified'
status: ModerationModpackPermissionApprovalType['id'] | null
approved: ModerationPermissionType['id'] | null
}
@@ -334,9 +388,26 @@ export interface ModerationFlameModpackItem extends ModerationBaseModpackItem {
url: string
}
-export type ModerationModpackItem = ModerationUnknownModpackItem | ModerationFlameModpackItem
+export interface ModerationIdentifiedModpackItem extends ModerationBaseModpackItem {
+ type: 'identified'
+ proof?: string
+ url?: string
+ title?: string
+}
+
+export type ModerationModpackItem =
+ | ModerationUnknownModpackItem
+ | ModerationFlameModpackItem
+ | ModerationIdentifiedModpackItem
export interface ModerationModpackResponse {
+ identified?: Record<
+ string,
+ {
+ file_name: string
+ status: ModerationModpackPermissionApprovalType['id']
+ }
+ >
unknown_files?: Record
flame_files?: Record<
string,
@@ -350,8 +421,8 @@ export interface ModerationModpackResponse {
}
export interface ModerationJudgement {
- type: 'flame' | 'unknown'
- status: string
+ type: 'flame' | 'unknown' | 'identified'
+ status: string | null
id?: string
link?: string
title?: string
@@ -362,3 +433,38 @@ export interface ModerationJudgement {
export interface ModerationJudgements {
[sha1: string]: ModerationJudgement
}
+
+// Delphi
+export interface DelphiReport {
+ id: string
+ project: Project
+ version: Version
+ priority_score: number
+ detected_at: string
+ trace_type:
+ | 'reflection_indirection'
+ | 'xor_obfuscation'
+ | 'included_libraries'
+ | 'suspicious_binaries'
+ | 'corrupt_classes'
+ | 'suspicious_classes'
+ | 'url_usage'
+ | 'classloader_usage'
+ | 'processbuilder_usage'
+ | 'runtime_exec_usage'
+ | 'jni_usage'
+ | 'main_method'
+ | 'native_loading'
+ | 'malformed_jar'
+ | 'nested_jar_too_deep'
+ | 'failed_decompilation'
+ | 'analysis_failure'
+ | 'malware_easyforme'
+ | 'malware_simplyloader'
+ file_path: string
+ // pending = not reviewed yet.
+ // approved = approved as malicious, removed from modrinth
+ // rejected = not approved as malicious, remains on modrinth?
+ status: 'pending' | 'approved' | 'rejected'
+ content?: string
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f337b1ab27..6d09218c13 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -284,6 +284,9 @@ importers:
pinia:
specifier: ^2.1.7
version: 2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
+ pinia-plugin-persistedstate:
+ specifier: ^4.4.1
+ version: 4.4.1(@nuxt/kit@3.17.5(magicast@0.3.5))(@pinia/nuxt@0.5.1(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(pinia@2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))
prettier:
specifier: ^3.6.2
version: 3.6.2
@@ -296,6 +299,9 @@ importers:
three:
specifier: ^0.172.0
version: 0.172.0
+ vue-confetti-explosion:
+ specifier: ^1.0.2
+ version: 1.0.2(vue@3.5.13(typescript@5.5.4))
vue-multiselect:
specifier: 3.0.0-alpha.2
version: 3.0.0-alpha.2
@@ -473,12 +479,6 @@ importers:
specifier: ^3.5.13
version: 3.5.13(typescript@5.8.3)
devDependencies:
- '@formatjs/cli':
- specifier: ^6.2.12
- version: 6.2.12(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.8.3))
- '@vintl/vintl':
- specifier: ^4.4.1
- version: 4.4.1(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))
eslint:
specifier: ^8.57.0
version: 8.57.0
@@ -587,7 +587,7 @@ importers:
version: 7.3.1
'@vintl/unplugin':
specifier: ^1.5.1
- version: 1.5.2(@vue/compiler-core@3.5.13)(rollup@3.29.4)(vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.43.1))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)
+ version: 1.5.2(@vue/compiler-core@3.5.13)(rollup@3.29.4)(vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)
'@vintl/vintl':
specifier: ^4.4.1
version: 4.4.1(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
@@ -1850,9 +1850,6 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
- '@jridgewell/gen-mapping@0.3.12':
- resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
-
'@jridgewell/gen-mapping@0.3.5':
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
engines: {node: '>=6.0.0'}
@@ -1865,24 +1862,15 @@ packages:
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
- '@jridgewell/source-map@0.3.10':
- resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==}
-
'@jridgewell/source-map@0.3.6':
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
- '@jridgewell/sourcemap-codec@1.5.4':
- resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
-
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
- '@jridgewell/trace-mapping@0.3.29':
- resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
-
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
@@ -3518,8 +3506,8 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
- browserslist@4.25.1:
- resolution: {integrity: sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==}
+ browserslist@4.25.0:
+ resolution: {integrity: sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
@@ -3552,8 +3540,8 @@ packages:
magicast:
optional: true
- c12@3.1.0:
- resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==}
+ c12@3.0.4:
+ resolution: {integrity: sha512-t5FaZTYbbCtvxuZq9xxIruYydrAGsJ+8UdP0pZzMiK2xl/gNiSOy0OxhLzHUEEb0m1QXYqfzfvyIFEmz/g9lqg==}
peerDependencies:
magicast: ^0.3.5
peerDependenciesMeta:
@@ -3605,8 +3593,8 @@ packages:
caniuse-lite@1.0.30001687:
resolution: {integrity: sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ==}
- caniuse-lite@1.0.30001727:
- resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==}
+ caniuse-lite@1.0.30001723:
+ resolution: {integrity: sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -4009,6 +3997,9 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+ deep-pick-omit@1.2.1:
+ resolution: {integrity: sha512-2J6Kc/m3irCeqVG42T+SaUMesaK7oGWaedGnQQK/+O0gYc+2SP5bKh/KKTE7d7SJ+GCA9UUE1GRzh6oDe0EnGw==}
+
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
@@ -4169,8 +4160,8 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
- electron-to-chromium@1.5.182:
- resolution: {integrity: sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==}
+ electron-to-chromium@1.5.167:
+ resolution: {integrity: sha512-LxcRvnYO5ez2bMOFpbuuVuAI5QNeY1ncVytE/KXaL6ZNfzX1yPlAO0nSOyIHx2fVAuUprMqPs/TdVhUFZy7SIQ==}
electron-to-chromium@1.5.71:
resolution: {integrity: sha512-dB68l59BI75W1BUGVTAEJy45CEVuEGy9qPVVQ8pnHyHMn36PLPPoE1mjLH+lo9rKulO3HC2OhbACI/8tCqJBcA==}
@@ -4205,8 +4196,8 @@ packages:
resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==}
engines: {node: '>=10.13.0'}
- enhanced-resolve@5.18.2:
- resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==}
+ enhanced-resolve@5.18.1:
+ resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
engines: {node: '>=10.13.0'}
entities@2.2.0:
@@ -6380,6 +6371,20 @@ packages:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'}
+ pinia-plugin-persistedstate@4.4.1:
+ resolution: {integrity: sha512-lmuMPpXla2zJKjxEq34e1E9P9jxkWEhcVwwioCCE0izG45kkTOvQfCzvwhW3i38cvnaWC7T1eRdkd15Re59ldw==}
+ peerDependencies:
+ '@nuxt/kit': '>=3.0.0'
+ '@pinia/nuxt': '>=0.10.0'
+ pinia: '>=3.0.0'
+ peerDependenciesMeta:
+ '@nuxt/kit':
+ optional: true
+ '@pinia/nuxt':
+ optional: true
+ pinia:
+ optional: true
+
pinia@2.1.7:
resolution: {integrity: sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==}
peerDependencies:
@@ -6406,8 +6411,8 @@ packages:
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
- pkg-types@2.2.0:
- resolution: {integrity: sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==}
+ pkg-types@2.1.1:
+ resolution: {integrity: sha512-eY0QFb6eSwc9+0d/5D2lFFUq+A3n3QNGSy/X2Nvp+6MfzGw2u6EbA7S80actgjY1lkvvI0pqB+a4hioMh443Ew==}
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
@@ -6624,8 +6629,8 @@ packages:
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
engines: {node: ^10 || ^12 || >=14}
- postcss@8.5.6:
- resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+ postcss@8.5.5:
+ resolution: {integrity: sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==}
engines: {node: ^10 || ^12 || >=14}
posthog-js@1.158.2:
@@ -7509,11 +7514,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
- terser@5.43.1:
- resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==}
- engines: {node: '>=10'}
- hasBin: true
-
text-decoder@1.1.0:
resolution: {integrity: sha512-TmLJNj6UgX8xcUZo4UDStGQtDiTzF7BzWlzn9g7UWrjkpHr5uJTK1ld16wZ3LXb2vb6jH8qU89dW5whuMdXYdw==}
@@ -8271,6 +8271,12 @@ packages:
vue-bundle-renderer@2.1.1:
resolution: {integrity: sha512-+qALLI5cQncuetYOXp4yScwYvqh8c6SMXee3B+M7oTZxOgtESP0l4j/fXdEJoZ+EdMxkGWIj+aSEyjXkOdmd7g==}
+ vue-confetti-explosion@1.0.2:
+ resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ vue: ^3.0.5
+
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@@ -8376,8 +8382,8 @@ packages:
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
- webpack-sources@3.3.3:
- resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==}
+ webpack-sources@3.3.2:
+ resolution: {integrity: sha512-ykKKus8lqlgXX/1WjudpIEjqsafjOTcOJqxnAbMLAu/KCsDCJ6GBtvscewvTkrn24HsnvFwrSCbenFrhtcCsAA==}
engines: {node: '>=10.13.0'}
webpack-virtual-modules@0.6.2:
@@ -8932,10 +8938,6 @@ snapshots:
dependencies:
vue: 3.5.13(typescript@5.5.4)
- '@braw/async-computed@5.0.2(vue@3.5.13(typescript@5.8.3))':
- dependencies:
- vue: 3.5.13(typescript@5.8.3)
-
'@cloudflare/kv-asset-handler@0.3.4':
dependencies:
mime: 3.0.0
@@ -9366,7 +9368,7 @@ snapshots:
'@eslint/eslintrc@2.1.4':
dependencies:
ajv: 6.12.6
- debug: 4.4.0
+ debug: 4.4.0(supports-color@9.4.0)
espree: 9.6.1
globals: 13.24.0
ignore: 5.3.1
@@ -9463,11 +9465,6 @@ snapshots:
'@vue/compiler-core': 3.5.13
vue: 3.5.13(typescript@5.5.4)
- '@formatjs/cli@6.2.12(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.8.3))':
- optionalDependencies:
- '@vue/compiler-core': 3.5.13
- vue: 3.5.13(typescript@5.8.3)
-
'@formatjs/ecma402-abstract@1.18.3':
dependencies:
'@formatjs/intl-localematcher': 0.5.4
@@ -9525,18 +9522,6 @@ snapshots:
optionalDependencies:
typescript: 5.5.4
- '@formatjs/intl@2.10.4(typescript@5.8.3)':
- dependencies:
- '@formatjs/ecma402-abstract': 2.0.0
- '@formatjs/fast-memoize': 2.2.0
- '@formatjs/icu-messageformat-parser': 2.7.8
- '@formatjs/intl-displaynames': 6.6.8
- '@formatjs/intl-listformat': 7.5.7
- intl-messageformat: 10.5.14
- tslib: 2.6.3
- optionalDependencies:
- typescript: 5.8.3
-
'@formatjs/ts-transformer@3.13.14':
dependencies:
'@formatjs/icu-messageformat-parser': 2.7.8
@@ -9559,7 +9544,7 @@ snapshots:
'@humanwhocodes/config-array@0.11.14':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
- debug: 4.4.0
+ debug: 4.4.0(supports-color@9.4.0)
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -9660,12 +9645,6 @@ snapshots:
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
- '@jridgewell/gen-mapping@0.3.12':
- dependencies:
- '@jridgewell/sourcemap-codec': 1.5.4
- '@jridgewell/trace-mapping': 0.3.29
- optional: true
-
'@jridgewell/gen-mapping@0.3.5':
dependencies:
'@jridgewell/set-array': 1.2.1
@@ -9676,12 +9655,6 @@ snapshots:
'@jridgewell/set-array@1.2.1': {}
- '@jridgewell/source-map@0.3.10':
- dependencies:
- '@jridgewell/gen-mapping': 0.3.12
- '@jridgewell/trace-mapping': 0.3.29
- optional: true
-
'@jridgewell/source-map@0.3.6':
dependencies:
'@jridgewell/gen-mapping': 0.3.5
@@ -9689,20 +9662,11 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.0': {}
- '@jridgewell/sourcemap-codec@1.5.4':
- optional: true
-
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
- '@jridgewell/trace-mapping@0.3.29':
- dependencies:
- '@jridgewell/resolve-uri': 3.1.2
- '@jridgewell/sourcemap-codec': 1.5.4
- optional: true
-
'@jsdevtools/ono@7.1.3': {}
'@kwsites/file-exists@1.1.1':
@@ -9950,7 +9914,7 @@ snapshots:
'@nuxt/kit@3.17.5(magicast@0.3.5)':
dependencies:
- c12: 3.1.0(magicast@0.3.5)
+ c12: 3.0.4(magicast@0.3.5)
consola: 3.4.2
defu: 6.1.4
destr: 2.0.5
@@ -9963,7 +9927,7 @@ snapshots:
mlly: 1.7.4
ohash: 2.0.11
pathe: 2.0.3
- pkg-types: 2.2.0
+ pkg-types: 2.1.1
scule: 1.3.0
semver: 7.7.2
std-env: 3.9.0
@@ -11165,7 +11129,7 @@ snapshots:
- vue
- webpack
- '@vintl/unplugin@1.5.2(@vue/compiler-core@3.5.13)(rollup@3.29.4)(vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.43.1))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)':
+ '@vintl/unplugin@1.5.2(@vue/compiler-core@3.5.13)(rollup@3.29.4)(vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)':
dependencies:
'@formatjs/cli-lib': 6.4.2(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.5.4))
'@formatjs/icu-messageformat-parser': 2.7.8
@@ -11176,7 +11140,7 @@ snapshots:
unplugin: 1.16.0
optionalDependencies:
rollup: 3.29.4
- vite: 4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.43.1)
+ vite: 4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0)
webpack: 5.92.1
transitivePeerDependencies:
- '@glimmer/env'
@@ -11224,17 +11188,6 @@ snapshots:
transitivePeerDependencies:
- typescript
- '@vintl/vintl@4.4.1(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))':
- dependencies:
- '@braw/async-computed': 5.0.2(vue@3.5.13(typescript@5.8.3))
- '@formatjs/icu-messageformat-parser': 2.7.8
- '@formatjs/intl': 2.10.4(typescript@5.8.3)
- '@formatjs/intl-localematcher': 0.4.2
- intl-messageformat: 10.5.14
- vue: 3.5.13(typescript@5.8.3)
- transitivePeerDependencies:
- - typescript
-
'@vitejs/plugin-vue-jsx@4.1.1(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))':
dependencies:
'@babel/core': 7.26.0
@@ -12037,12 +11990,12 @@ snapshots:
node-releases: 2.0.18
update-browserslist-db: 1.1.1(browserslist@4.24.2)
- browserslist@4.25.1:
+ browserslist@4.25.0:
dependencies:
- caniuse-lite: 1.0.30001727
- electron-to-chromium: 1.5.182
+ caniuse-lite: 1.0.30001723
+ electron-to-chromium: 1.5.167
node-releases: 2.0.19
- update-browserslist-db: 1.1.3(browserslist@4.25.1)
+ update-browserslist-db: 1.1.3(browserslist@4.25.0)
optional: true
buffer-crc32@1.0.0: {}
@@ -12081,7 +12034,7 @@ snapshots:
optionalDependencies:
magicast: 0.3.5
- c12@3.1.0(magicast@0.3.5):
+ c12@3.0.4(magicast@0.3.5):
dependencies:
chokidar: 4.0.3
confbox: 0.2.2
@@ -12093,7 +12046,7 @@ snapshots:
ohash: 2.0.11
pathe: 2.0.3
perfect-debounce: 1.0.0
- pkg-types: 2.2.0
+ pkg-types: 2.1.1
rc9: 2.1.2
optionalDependencies:
magicast: 0.3.5
@@ -12145,7 +12098,7 @@ snapshots:
caniuse-lite@1.0.30001687: {}
- caniuse-lite@1.0.30001727:
+ caniuse-lite@1.0.30001723:
optional: true
ccount@2.0.1: {}
@@ -12475,10 +12428,6 @@ snapshots:
dependencies:
ms: 2.1.3
- debug@4.4.0:
- dependencies:
- ms: 2.1.3
-
debug@4.4.0(supports-color@9.4.0):
dependencies:
ms: 2.1.3
@@ -12491,6 +12440,8 @@ snapshots:
deep-is@0.1.4: {}
+ deep-pick-omit@1.2.1: {}
+
deepmerge@4.3.1: {}
default-browser-id@5.0.0: {}
@@ -12528,8 +12479,7 @@ snapshots:
destr@2.0.3: {}
- destr@2.0.5:
- optional: true
+ destr@2.0.5: {}
destroy@1.2.0: {}
@@ -12621,7 +12571,7 @@ snapshots:
ee-first@1.1.1: {}
- electron-to-chromium@1.5.182:
+ electron-to-chromium@1.5.167:
optional: true
electron-to-chromium@1.5.71: {}
@@ -12652,7 +12602,7 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.2.1
- enhanced-resolve@5.18.2:
+ enhanced-resolve@5.18.1:
dependencies:
graceful-fs: 4.2.11
tapable: 2.2.2
@@ -13241,7 +13191,7 @@ snapshots:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.3
- debug: 4.4.0
+ debug: 4.4.0(supports-color@9.4.0)
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@@ -13801,7 +13751,7 @@ snapshots:
cookie-es: 1.2.2
crossws: 0.3.4
defu: 6.1.4
- destr: 2.0.3
+ destr: 2.0.5
iron-webcrypto: 1.2.1
node-mock-http: 1.0.0
radix3: 1.1.2
@@ -14351,7 +14301,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
- '@types/node': 22.4.1
+ '@types/node': 20.14.11
merge-stream: 2.0.0
supports-color: 8.1.1
optional: true
@@ -14524,7 +14474,7 @@ snapshots:
local-pkg@1.1.1:
dependencies:
mlly: 1.7.4
- pkg-types: 2.2.0
+ pkg-types: 2.1.1
quansync: 0.2.10
optional: true
@@ -15521,7 +15471,7 @@ snapshots:
citty: 0.1.6
consola: 3.4.2
pathe: 2.0.3
- pkg-types: 2.2.0
+ pkg-types: 2.1.1
tinyexec: 0.3.2
optional: true
@@ -15782,6 +15732,16 @@ snapshots:
pify@4.0.1: {}
+ pinia-plugin-persistedstate@4.4.1(@nuxt/kit@3.17.5(magicast@0.3.5))(@pinia/nuxt@0.5.1(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(pinia@2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))):
+ dependencies:
+ deep-pick-omit: 1.2.1
+ defu: 6.1.4
+ destr: 2.0.5
+ optionalDependencies:
+ '@nuxt/kit': 3.17.5(magicast@0.3.5)
+ '@pinia/nuxt': 0.5.1(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
+ pinia: 2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
+
pinia@2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)):
dependencies:
'@vue/devtools-api': 6.6.4
@@ -15809,7 +15769,7 @@ snapshots:
pathe: 2.0.3
optional: true
- pkg-types@2.2.0:
+ pkg-types@2.1.1:
dependencies:
confbox: 0.2.2
exsolve: 1.0.7
@@ -16012,7 +15972,7 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
- postcss@8.5.6:
+ postcss@8.5.5:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
@@ -17067,11 +17027,11 @@ snapshots:
terser-webpack-plugin@5.3.14(webpack@5.92.1):
dependencies:
- '@jridgewell/trace-mapping': 0.3.29
+ '@jridgewell/trace-mapping': 0.3.25
jest-worker: 27.5.1
schema-utils: 4.3.2
serialize-javascript: 6.0.2
- terser: 5.43.1
+ terser: 5.42.0
webpack: 5.92.1
optional: true
@@ -17082,14 +17042,6 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
- terser@5.43.1:
- dependencies:
- '@jridgewell/source-map': 0.3.10
- acorn: 8.15.0
- commander: 2.20.3
- source-map-support: 0.5.21
- optional: true
-
text-decoder@1.1.0:
dependencies:
b4a: 1.6.6
@@ -17365,7 +17317,7 @@ snapshots:
mlly: 1.7.4
pathe: 2.0.3
picomatch: 4.0.2
- pkg-types: 2.2.0
+ pkg-types: 2.1.1
scule: 1.3.0
strip-literal: 3.0.0
tinyglobby: 0.2.14
@@ -17485,7 +17437,7 @@ snapshots:
dependencies:
anymatch: 3.1.3
chokidar: 4.0.3
- destr: 2.0.3
+ destr: 2.0.5
h3: 1.15.1
lru-cache: 10.4.3
node-fetch-native: 1.6.6
@@ -17536,9 +17488,9 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
- update-browserslist-db@1.1.3(browserslist@4.25.1):
+ update-browserslist-db@1.1.3(browserslist@4.25.0):
dependencies:
- browserslist: 4.25.1
+ browserslist: 4.25.0
escalade: 3.2.0
picocolors: 1.1.1
optional: true
@@ -17660,16 +17612,16 @@ snapshots:
svgo: 3.3.2
vue: 3.5.13(typescript@5.5.4)
- vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.43.1):
+ vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0):
dependencies:
esbuild: 0.18.20
- postcss: 8.5.6
+ postcss: 8.5.5
rollup: 3.29.4
optionalDependencies:
'@types/node': 22.4.1
fsevents: 2.3.3
sass: 1.77.6
- terser: 5.43.1
+ terser: 5.42.0
optional: true
vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0):
@@ -17833,6 +17785,10 @@ snapshots:
dependencies:
ufo: 1.5.4
+ vue-confetti-explosion@1.0.2(vue@3.5.13(typescript@5.5.4)):
+ dependencies:
+ vue: 3.5.13(typescript@5.5.4)
+
vue-demi@0.14.10(vue@3.5.13(typescript@5.5.4)):
dependencies:
vue: 3.5.13(typescript@5.5.4)
@@ -17945,7 +17901,7 @@ snapshots:
webidl-conversions@3.0.1: {}
- webpack-sources@3.3.3:
+ webpack-sources@3.3.2:
optional: true
webpack-virtual-modules@0.6.2: {}
@@ -17959,9 +17915,9 @@ snapshots:
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.15.0
acorn-import-attributes: 1.9.5(acorn@8.15.0)
- browserslist: 4.25.1
+ browserslist: 4.25.0
chrome-trace-event: 1.0.4
- enhanced-resolve: 5.18.2
+ enhanced-resolve: 5.18.1
es-module-lexer: 1.7.0
eslint-scope: 5.1.1
events: 3.3.0
@@ -17975,7 +17931,7 @@ snapshots:
tapable: 2.2.2
terser-webpack-plugin: 5.3.14(webpack@5.92.1)
watchpack: 2.4.4
- webpack-sources: 3.3.3
+ webpack-sources: 3.3.2
transitivePeerDependencies:
- '@swc/core'
- esbuild