diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 7cded8314..011eafb64 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -59,10 +59,12 @@ "markdown-it": "14.1.0", "pathe": "^1.1.2", "pinia": "^2.1.7", + "pinia-plugin-persistedstate": "^4.4.1", "prettier": "^3.6.2", "qrcode.vue": "^3.4.0", "semver": "^7.5.4", "three": "^0.172.0", + "vue-confetti-explosion": "^1.0.2", "vue-multiselect": "3.0.0-alpha.2", "vue-typed-virtual-list": "^1.0.10", "vue3-ace-editor": "^2.2.4", diff --git a/apps/frontend/src/components/ui/ModerationChecklist.vue b/apps/frontend/src/components/ui/ModerationChecklist.vue deleted file mode 100644 index b21fd9954..000000000 --- a/apps/frontend/src/components/ui/ModerationChecklist.vue +++ /dev/null @@ -1,1133 +0,0 @@ - - - - - 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 000000000..961f340a3 --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/ModerationDelphiReportCard.vue @@ -0,0 +1,179 @@ + + + + + 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 000000000..385a2033b --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/ModerationQueueCard.vue @@ -0,0 +1,204 @@ + + + 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 000000000..499b1d381 --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue @@ -0,0 +1,275 @@ + + + + diff --git a/apps/frontend/src/components/ui/moderation/ChecklistKeybindsModal.vue b/apps/frontend/src/components/ui/moderation/checklist/ChecklistKeybindsModal.vue similarity index 96% rename from apps/frontend/src/components/ui/moderation/ChecklistKeybindsModal.vue rename to apps/frontend/src/components/ui/moderation/checklist/ChecklistKeybindsModal.vue index 787474bc5..db8b931ba 100644 --- a/apps/frontend/src/components/ui/moderation/ChecklistKeybindsModal.vue +++ b/apps/frontend/src/components/ui/moderation/checklist/ChecklistKeybindsModal.vue @@ -29,7 +29,7 @@ diff --git a/apps/frontend/src/components/ui/thread/ThreadMessage.vue b/apps/frontend/src/components/ui/thread/ThreadMessage.vue index 9d962c98e..f47b06613 100644 --- a/apps/frontend/src/components/ui/thread/ThreadMessage.vue +++ b/apps/frontend/src/components/ui/thread/ThreadMessage.vue @@ -36,7 +36,7 @@ v-tooltip="'Modrinth Team'" /> diff --git a/apps/frontend/src/composables/servers/servers-fetch.ts b/apps/frontend/src/composables/servers/servers-fetch.ts index 45bd48c68..29a63f7cc 100644 --- a/apps/frontend/src/composables/servers/servers-fetch.ts +++ b/apps/frontend/src/composables/servers/servers-fetch.ts @@ -147,7 +147,7 @@ export async function useServersFetch( 404: "Not Found", 405: "Method Not Allowed", 408: "Request Timeout", - 429: "Too Many Requests", + 429: "You're making requests too quickly. Please wait a moment and try again.", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable", @@ -167,11 +167,17 @@ export async function useServersFetch( console.error("Fetch error:", error); const fetchError = new ModrinthServersFetchError( - `[Modrinth Servers] ${message}`, + `[Modrinth Servers] ${error.message}`, statusCode, error, ); - throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error); + throw new ModrinthServerError( + `[Modrinth Servers] ${message}`, + statusCode, + fetchError, + module, + v1Error, + ); } const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000; diff --git a/apps/frontend/src/helpers/moderation.ts b/apps/frontend/src/helpers/moderation.ts new file mode 100644 index 000000000..0e842fef8 --- /dev/null +++ b/apps/frontend/src/helpers/moderation.ts @@ -0,0 +1,236 @@ +import type { ExtendedReport, OwnershipTarget } from "@modrinth/moderation"; +import type { + Thread, + Version, + User, + Project, + TeamMember, + Organization, + Report, +} from "@modrinth/utils"; + +export const useModerationCache = () => ({ + threads: useState>("moderation-report-cache-threads", () => new Map()), + users: useState>("moderation-report-cache-users", () => new Map()), + projects: useState>("moderation-report-cache-projects", () => new Map()), + versions: useState>("moderation-report-cache-versions", () => new Map()), + teams: useState>("moderation-report-cache-teams", () => new Map()), + orgs: useState>("moderation-report-cache-orgs", () => new Map()), +}); + +// TODO: @AlexTMjugador - backend should do all of these functions. +export async function enrichReportBatch(reports: Report[]): Promise { + if (reports.length === 0) return []; + + const cache = useModerationCache(); + + const threadIDs = reports + .map((r) => r.thread_id) + .filter(Boolean) + .filter((id) => !cache.threads.value.has(id)); + const userIDs = [ + ...reports.filter((r) => r.item_type === "user").map((r) => r.item_id), + ...reports.map((r) => r.reporter), + ].filter((id) => !cache.users.value.has(id)); + const versionIDs = reports + .filter((r) => r.item_type === "version") + .map((r) => r.item_id) + .filter((id) => !cache.versions.value.has(id)); + const projectIDs = reports + .filter((r) => r.item_type === "project") + .map((r) => r.item_id) + .filter((id) => !cache.projects.value.has(id)); + + const [newThreads, newVersions, newUsers] = await Promise.all([ + threadIDs.length > 0 + ? (fetchSegmented(threadIDs, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`) as Promise< + Thread[] + >) + : Promise.resolve([]), + versionIDs.length > 0 + ? (fetchSegmented(versionIDs, (ids) => `versions?ids=${asEncodedJsonArray(ids)}`) as Promise< + Version[] + >) + : Promise.resolve([]), + [...new Set(userIDs)].length > 0 + ? (fetchSegmented( + [...new Set(userIDs)], + (ids) => `users?ids=${asEncodedJsonArray(ids)}`, + ) as Promise) + : Promise.resolve([]), + ]); + + newThreads.forEach((t) => cache.threads.value.set(t.id, t)); + newVersions.forEach((v) => cache.versions.value.set(v.id, v)); + newUsers.forEach((u) => cache.users.value.set(u.id, u)); + + const allVersions = [...newVersions, ...Array.from(cache.versions.value.values())]; + const fullProjectIds = new Set([ + ...projectIDs, + ...allVersions + .filter((v) => versionIDs.includes(v.id)) + .map((v) => v.project_id) + .filter(Boolean), + ]); + + const uncachedProjectIds = Array.from(fullProjectIds).filter( + (id) => !cache.projects.value.has(id), + ); + const newProjects = + uncachedProjectIds.length > 0 + ? ((await fetchSegmented( + uncachedProjectIds, + (ids) => `projects?ids=${asEncodedJsonArray(ids)}`, + )) as Project[]) + : []; + + newProjects.forEach((p) => cache.projects.value.set(p.id, p)); + + const allProjects = [...newProjects, ...Array.from(cache.projects.value.values())]; + const teamIds = [...new Set(allProjects.map((p) => p.team).filter(Boolean))].filter( + (id) => !cache.teams.value.has(id || "invalid team id"), + ); + const orgIds = [...new Set(allProjects.map((p) => p.organization).filter(Boolean))].filter( + (id) => !cache.orgs.value.has(id), + ); + + const [newTeams, newOrgs] = await Promise.all([ + teamIds.length > 0 + ? (fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`) as Promise< + TeamMember[][] + >) + : Promise.resolve([]), + orgIds.length > 0 + ? (fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, { + apiVersion: 3, + }) as Promise) + : Promise.resolve([]), + ]); + + newTeams.forEach((team) => { + if (team.length > 0) cache.teams.value.set(team[0].team_id, team); + }); + newOrgs.forEach((org) => cache.orgs.value.set(org.id, org)); + + return reports.map((report) => { + const thread = cache.threads.value.get(report.thread_id) || ({} as Thread); + const version = + report.item_type === "version" ? cache.versions.value.get(report.item_id) : undefined; + + const project = + report.item_type === "project" + ? cache.projects.value.get(report.item_id) + : report.item_type === "version" && version + ? cache.projects.value.get(version.project_id) + : undefined; + + let target: OwnershipTarget | undefined; + + if (report.item_type === "user") { + const targetUser = cache.users.value.get(report.item_id); + if (targetUser) { + target = { + name: targetUser.username, + slug: targetUser.username, + avatar_url: targetUser.avatar_url, + type: "user", + }; + } + } else if (project) { + let owner: TeamMember | null = null; + let org: Organization | null = null; + + if (project.team) { + const teamMembers = cache.teams.value.get(project.team); + if (teamMembers) { + owner = teamMembers.find((member) => member.role === "Owner") || null; + } + } + + if (project.organization) { + org = cache.orgs.value.get(project.organization) || null; + } + + if (org) { + target = { + name: org.name, + avatar_url: org.icon_url, + type: "organization", + slug: org.slug, + }; + } else if (owner) { + target = { + name: owner.user.username, + avatar_url: owner.user.avatar_url, + type: "user", + slug: owner.user.username, + }; + } + } + + return { + ...report, + thread, + reporter_user: cache.users.value.get(report.reporter) || ({} as User), + project, + user: report.item_type === "user" ? cache.users.value.get(report.item_id) : undefined, + version, + target, + }; + }); +} + +// Doesn't need to be in @modrinth/moderation because it is specific to the frontend. +export interface ModerationProject { + project: any; + owner: TeamMember | null; + org: Organization | null; +} + +export async function enrichProjectBatch(projects: any[]): Promise { + const teamIds = [...new Set(projects.map((p) => p.team_id).filter(Boolean))]; + const orgIds = [...new Set(projects.map((p) => p.organization).filter(Boolean))]; + + const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([ + teamIds.length > 0 + ? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`) + : Promise.resolve([]), + orgIds.length > 0 + ? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, { + apiVersion: 3, + }) + : Promise.resolve([]), + ]); + + const cache = useModerationCache(); + + teamsData.forEach((team) => { + if (team.length > 0) cache.teams.value.set(team[0].team_id, team); + }); + + orgsData.forEach((org: Organization) => { + cache.orgs.value.set(org.id, org); + }); + + return projects.map((project) => { + let owner: TeamMember | null = null; + let org: Organization | null = null; + + if (project.team_id) { + const teamMembers = cache.teams.value.get(project.team_id); + if (teamMembers) { + owner = teamMembers.find((member) => member.role === "Owner") || null; + } + } + + if (project.organization) { + org = cache.orgs.value.get(project.organization) || null; + } + + return { + project, + owner, + org, + } as ModerationProject; + }); +} diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index 308b7e3a9..0a118099c 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -295,7 +295,7 @@ { id: 'review-projects', color: 'orange', - link: '/moderation/review', + link: '/moderation/', }, { id: 'review-reports', @@ -981,23 +981,6 @@ const userMenuOptions = computed(() => { }, ]; - if ( - (auth.value && auth.value.user && auth.value.user.role === "moderator") || - auth.value.user.role === "admin" - ) { - options = [ - ...options, - { - divider: true, - }, - { - id: "moderation", - color: "orange", - link: "/moderation/review", - }, - ]; - } - options = [ ...options, { diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 38e72448e..3a5bdd371 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -476,6 +476,30 @@ "layout.nav.search": { "message": "Search" }, + "moderation.filter.by": { + "message": "Filter by" + }, + "moderation.moderate": { + "message": "Moderate" + }, + "moderation.page.projects": { + "message": "Projects" + }, + "moderation.page.reports": { + "message": "Reports" + }, + "moderation.page.technicalReview": { + "message": "Technical Review" + }, + "moderation.search.placeholder": { + "message": "Search..." + }, + "moderation.sort.by": { + "message": "Sort by" + }, + "moderation.technical.search.placeholder": { + "message": "Search tech reviews..." + }, "profile.button.billing": { "message": "Manage user billing" }, diff --git a/apps/frontend/src/pages/[type]/[id].vue b/apps/frontend/src/pages/[type]/[id].vue index 2bb35162f..1dc0cfd6b 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -689,7 +689,10 @@ }, { id: 'moderation-checklist', - action: () => (showModerationChecklist = true), + action: () => { + moderationStore.setSingleProject(project.id); + showModerationChecklist = true; + }, color: 'orange', hoverOnly: true, shown: @@ -870,19 +873,6 @@ @delete-version="deleteVersion" /> - -
- - -
@@ -890,9 +880,8 @@ v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist" class="moderation-checklist" > - { - console.log("Future project IDs updated:", newValue); -}); - watch( showModerationChecklist, (newValue) => { diff --git a/apps/frontend/src/pages/[type]/[id]/gallery.vue b/apps/frontend/src/pages/[type]/[id]/gallery.vue index 35eb72a2c..7b5327ed3 100644 --- a/apps/frontend/src/pages/[type]/[id]/gallery.vue +++ b/apps/frontend/src/pages/[type]/[id]/gallery.vue @@ -365,8 +365,10 @@ export default defineNuxtComponent({ if (e.key === "Escape") { this.expandedGalleryItem = null; } else if (e.key === "ArrowLeft") { + e.stopPropagation(); this.previousImage(); } else if (e.key === "ArrowRight") { + e.stopPropagation(); this.nextImage(); } } diff --git a/apps/frontend/src/pages/auth/sign-up.vue b/apps/frontend/src/pages/auth/sign-up.vue index 7f88fa512..a8cb5664c 100644 --- a/apps/frontend/src/pages/auth/sign-up.vue +++ b/apps/frontend/src/pages/auth/sign-up.vue @@ -218,7 +218,7 @@ const username = ref(""); const password = ref(""); const confirmPassword = ref(""); const token = ref(""); -const subscribe = ref(true); +const subscribe = ref(false); async function createAccount() { startLoading(); diff --git a/apps/frontend/src/pages/moderation.vue b/apps/frontend/src/pages/moderation.vue index 4dbb509d6..3de15993c 100644 --- a/apps/frontend/src/pages/moderation.vue +++ b/apps/frontend/src/pages/moderation.vue @@ -1,33 +1,84 @@ - diff --git a/apps/frontend/src/pages/moderation/index.vue b/apps/frontend/src/pages/moderation/index.vue index aa4ff5fa7..c344d7cbf 100644 --- a/apps/frontend/src/pages/moderation/index.vue +++ b/apps/frontend/src/pages/moderation/index.vue @@ -1,42 +1,339 @@ - diff --git a/apps/frontend/src/pages/moderation/report/[id].vue b/apps/frontend/src/pages/moderation/report/[id].vue deleted file mode 100644 index d485333d2..000000000 --- a/apps/frontend/src/pages/moderation/report/[id].vue +++ /dev/null @@ -1,17 +0,0 @@ - - diff --git a/apps/frontend/src/pages/moderation/reports.vue b/apps/frontend/src/pages/moderation/reports.vue deleted file mode 100644 index af15186e1..000000000 --- a/apps/frontend/src/pages/moderation/reports.vue +++ /dev/null @@ -1,16 +0,0 @@ - - diff --git a/apps/frontend/src/pages/moderation/reports/[id].vue b/apps/frontend/src/pages/moderation/reports/[id].vue new file mode 100644 index 000000000..8ec2c4d67 --- /dev/null +++ b/apps/frontend/src/pages/moderation/reports/[id].vue @@ -0,0 +1,28 @@ + + + diff --git a/apps/frontend/src/pages/moderation/reports/index.vue b/apps/frontend/src/pages/moderation/reports/index.vue new file mode 100644 index 000000000..4d375ffeb --- /dev/null +++ b/apps/frontend/src/pages/moderation/reports/index.vue @@ -0,0 +1,290 @@ + + + diff --git a/apps/frontend/src/pages/moderation/review.vue b/apps/frontend/src/pages/moderation/review.vue deleted file mode 100644 index 0613982d4..000000000 --- a/apps/frontend/src/pages/moderation/review.vue +++ /dev/null @@ -1,304 +0,0 @@ - - - - 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 000000000..f18897fc6 --- /dev/null +++ b/apps/frontend/src/pages/moderation/technical-review-mockup.vue @@ -0,0 +1,386 @@ + + + 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 000000000..40f28feca --- /dev/null +++ b/apps/frontend/src/pages/moderation/technical-review.vue @@ -0,0 +1,3 @@ + diff --git a/apps/frontend/src/pages/servers/manage/[id]/options/startup.vue b/apps/frontend/src/pages/servers/manage/[id]/options/startup.vue index bd9a7edd4..7080f0e1c 100644 --- a/apps/frontend/src/pages/servers/manage/[id]/options/startup.vue +++ b/apps/frontend/src/pages/servers/manage/[id]/options/startup.vue @@ -42,7 +42,7 @@