diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 7cded831..011eafb6 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 b21fd995..00000000 --- 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 00000000..961f340a --- /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 00000000..385a2033 --- /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 00000000..499b1d38 --- /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 787474bc..db8b931b 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 9d962c98..f47b0661 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/helpers/moderation.ts b/apps/frontend/src/helpers/moderation.ts new file mode 100644 index 00000000..0e842fef --- /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 308b7e3a..0a118099 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 38e72448..3a5bdd37 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 0f3ff082..78fdaf29 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 35eb72a2..7b5327ed 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 7f88fa51..a8cb5664 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 4dbb509d..3de15993 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 aa4ff5fa..c344d7cb 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 d485333d..00000000 --- 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 af15186e..00000000 --- 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 00000000..8ec2c4d6 --- /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 00000000..4d375ffe --- /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 0613982d..00000000 --- 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 00000000..f18897fc --- /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 00000000..40f28fec --- /dev/null +++ b/apps/frontend/src/pages/moderation/technical-review.vue @@ -0,0 +1,3 @@ + diff --git a/apps/frontend/src/store/moderation.ts b/apps/frontend/src/store/moderation.ts new file mode 100644 index 00000000..d89a68fc --- /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/.sqlx/query-32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55.json b/apps/labrinth/.sqlx/query-010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e.json similarity index 71% rename from apps/labrinth/.sqlx/query-32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55.json rename to apps/labrinth/.sqlx/query-010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e.json index 8d844ddf..082a6e4e 100644 --- a/apps/labrinth/.sqlx/query-32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55.json +++ b/apps/labrinth/.sqlx/query-010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21\n )\n ", + "query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21, $22\n )\n ", "describe": { "columns": [], "parameters": { @@ -25,10 +25,11 @@ "Text", "Text", "Text", + "Bool", "Bool" ] }, "nullable": [] }, - "hash": "32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55" + "hash": "010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e" } diff --git a/apps/labrinth/.sqlx/query-b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0.json b/apps/labrinth/.sqlx/query-5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4.json similarity index 88% rename from apps/labrinth/.sqlx/query-b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0.json rename to apps/labrinth/.sqlx/query-5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4.json index 988ec11a..0c33202b 100644 --- a/apps/labrinth/.sqlx/query-b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0.json +++ b/apps/labrinth/.sqlx/query-5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", + "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", "describe": { "columns": [ { @@ -122,6 +122,11 @@ "ordinal": 23, "name": "allow_friend_requests", "type_info": "Bool" + }, + { + "ordinal": 24, + "name": "is_subscribed_to_newsletter", + "type_info": "Bool" } ], "parameters": { @@ -154,8 +159,9 @@ true, true, true, + false, false ] }, - "hash": "b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0" + "hash": "5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4" } diff --git a/apps/labrinth/.sqlx/query-c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623.json b/apps/labrinth/.sqlx/query-c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623.json new file mode 100644 index 00000000..975dc151 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET is_subscribed_to_newsletter = TRUE\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623" +} diff --git a/apps/labrinth/migrations/20250727184120_user-newsletter-subscription-column.sql b/apps/labrinth/migrations/20250727184120_user-newsletter-subscription-column.sql new file mode 100644 index 00000000..5a475b68 --- /dev/null +++ b/apps/labrinth/migrations/20250727184120_user-newsletter-subscription-column.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN is_subscribed_to_newsletter BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/apps/labrinth/src/auth/validate.rs b/apps/labrinth/src/auth/validate.rs index 806eaa12..325adc3d 100644 --- a/apps/labrinth/src/auth/validate.rs +++ b/apps/labrinth/src/auth/validate.rs @@ -1,6 +1,6 @@ use super::AuthProvider; use crate::auth::AuthenticationError; -use crate::database::models::user_item; +use crate::database::models::{DBUser, user_item}; use crate::database::redis::RedisPool; use crate::models::pats::Scopes; use crate::models::users::User; @@ -44,17 +44,16 @@ where Ok(Some((scopes, User::from_full(db_user)))) } -pub async fn get_user_from_headers<'a, E>( +pub async fn get_full_user_from_headers<'a, E>( req: &HttpRequest, executor: E, redis: &RedisPool, session_queue: &AuthQueue, required_scopes: Scopes, -) -> Result<(Scopes, User), AuthenticationError> +) -> Result<(Scopes, DBUser), AuthenticationError> where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, { - // Fetch DB user record and minos user from headers let (scopes, db_user) = get_user_record_from_bearer_token( req, None, @@ -65,13 +64,33 @@ where .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; - let user = User::from_full(db_user); - if !scopes.contains(required_scopes) { return Err(AuthenticationError::InvalidCredentials); } - Ok((scopes, user)) + Ok((scopes, db_user)) +} + +pub async fn get_user_from_headers<'a, E>( + req: &HttpRequest, + executor: E, + redis: &RedisPool, + session_queue: &AuthQueue, + required_scopes: Scopes, +) -> Result<(Scopes, User), AuthenticationError> +where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, +{ + let (scopes, db_user) = get_full_user_from_headers( + req, + executor, + redis, + session_queue, + required_scopes, + ) + .await?; + + Ok((scopes, User::from_full(db_user))) } pub async fn get_user_record_from_bearer_token<'a, 'b, E>( diff --git a/apps/labrinth/src/database/models/user_item.rs b/apps/labrinth/src/database/models/user_item.rs index 6a2e4aba..4d447702 100644 --- a/apps/labrinth/src/database/models/user_item.rs +++ b/apps/labrinth/src/database/models/user_item.rs @@ -49,6 +49,8 @@ pub struct DBUser { pub badges: Badges, pub allow_friend_requests: bool, + + pub is_subscribed_to_newsletter: bool, } impl DBUser { @@ -63,13 +65,13 @@ impl DBUser { avatar_url, raw_avatar_url, bio, created, github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, email_verified, password, paypal_id, paypal_country, paypal_email, - venmo_handle, stripe_customer_id, allow_friend_requests + venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, - $14, $15, $16, $17, $18, $19, $20, $21 + $14, $15, $16, $17, $18, $19, $20, $21, $22 ) ", self.id as DBUserId, @@ -93,6 +95,7 @@ impl DBUser { self.venmo_handle, self.stripe_customer_id, self.allow_friend_requests, + self.is_subscribed_to_newsletter, ) .execute(&mut **transaction) .await?; @@ -178,7 +181,7 @@ impl DBUser { created, role, badges, github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email, - venmo_handle, stripe_customer_id, allow_friend_requests + venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter FROM users WHERE id = ANY($1) OR LOWER(username) = ANY($2) ", @@ -212,6 +215,7 @@ impl DBUser { stripe_customer_id: u.stripe_customer_id, totp_secret: u.totp_secret, allow_friend_requests: u.allow_friend_requests, + is_subscribed_to_newsletter: u.is_subscribed_to_newsletter, }; acc.insert(u.id, (Some(u.username), user)); diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 281f85be..cba2de5d 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -1,5 +1,7 @@ use crate::auth::email::send_email; -use crate::auth::validate::get_user_record_from_bearer_token; +use crate::auth::validate::{ + get_full_user_from_headers, get_user_record_from_bearer_token, +}; use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers}; use crate::database::models::DBUser; use crate::database::models::flow_item::DBFlow; @@ -232,6 +234,7 @@ impl TempUser { role: Role::Developer.to_string(), badges: Badges::default(), allow_friend_requests: true, + is_subscribed_to_newsletter: false, } .insert(transaction) .await?; @@ -1291,37 +1294,6 @@ pub async fn delete_auth_provider( Ok(HttpResponse::NoContent().finish()) } -pub async fn sign_up_sendy(email: &str) -> Result<(), AuthenticationError> { - let url = dotenvy::var("SENDY_URL")?; - let id = dotenvy::var("SENDY_LIST_ID")?; - let api_key = dotenvy::var("SENDY_API_KEY")?; - let site_url = dotenvy::var("SITE_URL")?; - - if url.is_empty() || url == "none" { - tracing::info!("Sendy URL not set, skipping signup"); - return Ok(()); - } - - let mut form = HashMap::new(); - - form.insert("api_key", &*api_key); - form.insert("email", email); - form.insert("list", &*id); - form.insert("referrer", &*site_url); - - let client = reqwest::Client::new(); - client - .post(format!("{url}/subscribe")) - .form(&form) - .send() - .await? - .error_for_status()? - .text() - .await?; - - Ok(()) -} - pub async fn check_sendy_subscription( email: &str, ) -> Result { @@ -1456,6 +1428,9 @@ pub async fn create_account_with_password( role: Role::Developer.to_string(), badges: Badges::default(), allow_friend_requests: true, + is_subscribed_to_newsletter: new_account + .sign_up_newsletter + .unwrap_or(false), } .insert(&mut transaction) .await?; @@ -1476,10 +1451,6 @@ pub async fn create_account_with_password( &format!("Welcome to Modrinth, {}!", new_account.username), )?; - if new_account.sign_up_newsletter.unwrap_or(false) { - sign_up_sendy(&new_account.email).await?; - } - transaction.commit().await?; Ok(HttpResponse::Ok().json(res)) @@ -2420,15 +2391,24 @@ pub async fn subscribe_newsletter( .await? .1; - if let Some(email) = user.email { - sign_up_sendy(&email).await?; + sqlx::query!( + " + UPDATE users + SET is_subscribed_to_newsletter = TRUE + WHERE id = $1 + ", + user.id.0 as i64, + ) + .execute(&**pool) + .await?; - Ok(HttpResponse::NoContent().finish()) - } else { - Err(ApiError::InvalidInput( - "User does not have an email.".to_string(), - )) - } + crate::database::models::DBUser::clear_caches( + &[(user.id.into(), None)], + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().finish()) } #[get("email/subscribe")] @@ -2438,7 +2418,7 @@ pub async fn get_newsletter_subscription_status( redis: Data, session_queue: Data, ) -> Result { - let user = get_user_from_headers( + let user = get_full_user_from_headers( &req, &**pool, &redis, @@ -2448,16 +2428,16 @@ pub async fn get_newsletter_subscription_status( .await? .1; - if let Some(email) = user.email { - let is_subscribed = check_sendy_subscription(&email).await?; - Ok(HttpResponse::Ok().json(serde_json::json!({ - "subscribed": is_subscribed - }))) - } else { - Ok(HttpResponse::Ok().json(serde_json::json!({ - "subscribed": false - }))) - } + let is_subscribed = user.is_subscribed_to_newsletter + || if let Some(email) = user.email { + check_sendy_subscription(&email).await? + } else { + false + }; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "subscribed": is_subscribed + }))) } fn send_email_verify( diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index 249a53b6..f0104415 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 00000000..49723c69 --- /dev/null +++ b/packages/assets/icons/collapse.svg @@ -0,0 +1,8 @@ + + + + + + \ 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 00000000..ebe30683 --- /dev/null +++ b/packages/assets/icons/ellipsis-vertical.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/moderation/data/messages/reports/antivirus.md b/packages/moderation/data/messages/reports/antivirus.md new file mode 100644 index 00000000..338dd8d6 --- /dev/null +++ b/packages/moderation/data/messages/reports/antivirus.md @@ -0,0 +1,3 @@ +Unfortunately, anti-virus software has consistently been found to be an unreliable tool for Minecraft mods. + +If you have evidence of malicious activity concerning a specific mod, or of malicious code decompiled from a mod on Modrinth, please create a new Report and provide the required details, thank you. diff --git a/packages/moderation/data/messages/reports/confirmed-malware.md b/packages/moderation/data/messages/reports/confirmed-malware.md new file mode 100644 index 00000000..5f7cc2a5 --- /dev/null +++ b/packages/moderation/data/messages/reports/confirmed-malware.md @@ -0,0 +1,3 @@ +Thank you for your report. + +This project was confirmed to be malicious after a detailed investigation. Luckily, thanks to your report and quick action from our team, we have reason to believe this did not impact a significant amount of users and we have taken precautions to prevent this malicious code from appearing on Modrinth again. diff --git a/packages/moderation/data/messages/reports/gameplay-issue.md b/packages/moderation/data/messages/reports/gameplay-issue.md new file mode 100644 index 00000000..ee0c9dd9 --- /dev/null +++ b/packages/moderation/data/messages/reports/gameplay-issue.md @@ -0,0 +1,6 @@ +Unfortunately, the Moderation team is unable to assist with your issue. + +The reporting system is exclusively for reporting issues to Moderation staff; only violations of [Modrinth's Content Rules](https://modrinth.com/legal/rules) should be reported. The members of the project you're reporting do not see that you have submitted a report. + +If you are having issues with crashes, please check out [our FAQ section](https://support.modrinth.com/aen/articles/8792916) to learn how to diagnose and fix crashes. +For other project-specific issues consider asking the project's own community, check for a Discord or Issues link on the project page. diff --git a/packages/moderation/data/messages/reports/platform-issue.md b/packages/moderation/data/messages/reports/platform-issue.md new file mode 100644 index 00000000..a6bcd943 --- /dev/null +++ b/packages/moderation/data/messages/reports/platform-issue.md @@ -0,0 +1,5 @@ +Unfortunately, the Moderation team is unable to assist with your issue. + +The reporting system is exclusively for reporting issues to Moderation staff; only violations of [Modrinth's Content Rules](https://modrinth.com/legal/rules) should be reported. + +Please reach out to the [Modrinth Help Center](https://support.modrinth.com/) so we can better assist you and bring up your concerns with our platform tean, diff --git a/packages/moderation/data/messages/reports/spam.md b/packages/moderation/data/messages/reports/spam.md new file mode 100644 index 00000000..876dda2b --- /dev/null +++ b/packages/moderation/data/messages/reports/spam.md @@ -0,0 +1,3 @@ +The reporting system is exclusively for reporting issues to Modrinth staff; only violations of [Modrinth's Content Rules](https://modrinth.com/legal/rules) should be reported. The members of the project you're reporting do not see that you have submitted a report. + +Please ensure you are using the Reports system appropriately, repeated misuse may result in account suspension. diff --git a/packages/moderation/data/messages/reports/stale.md b/packages/moderation/data/messages/reports/stale.md new file mode 100644 index 00000000..cc21f5f1 --- /dev/null +++ b/packages/moderation/data/messages/reports/stale.md @@ -0,0 +1,3 @@ +We haven't received a response in some time, so we're closing this report thread. + +If you have additional information to share we ask that you create a new report. diff --git a/packages/moderation/data/report-quick-replies.ts b/packages/moderation/data/report-quick-replies.ts new file mode 100644 index 00000000..db1fd232 --- /dev/null +++ b/packages/moderation/data/report-quick-replies.ts @@ -0,0 +1,34 @@ +import type { ReportQuickReply } from '../types/reports' + +export default [ + { + label: 'Antivirus', + message: async () => (await import('./messages/reports/antivirus.md?raw')).default, + private: false, + }, + { + label: 'Spam', + message: async () => (await import('./messages/reports/spam.md?raw')).default, + private: false, + }, + { + label: 'Gameplay Issue', + message: async () => (await import('./messages/reports/gameplay-issue.md?raw')).default, + private: false, + }, + { + label: 'Platform Issue', + message: async () => (await import('./messages/reports/platform-issue.md?raw')).default, + private: false, + }, + { + label: 'Stale', + message: async () => (await import('./messages/reports/stale.md?raw')).default, + private: false, + }, + { + label: 'Confirmed Malware', + message: async () => (await import('./messages/reports/confirmed-malware.md?raw')).default, + private: false, + }, +] as ReadonlyArray diff --git a/packages/moderation/data/stages/versions.ts b/packages/moderation/data/stages/versions.ts index 9976fa96..7f5ca33a 100644 --- a/packages/moderation/data/stages/versions.ts +++ b/packages/moderation/data/stages/versions.ts @@ -68,7 +68,7 @@ const versions: Stage = { message: async () => '', enablesActions: [ { - id: 'versions_incorrect_project_type_options', + id: 'versions_alternate_versions_options', type: 'dropdown', label: 'How are the alternate versions distributed?', options: [ diff --git a/packages/moderation/index.ts b/packages/moderation/index.ts index a7cebdcd..0ee0afdf 100644 --- a/packages/moderation/index.ts +++ b/packages/moderation/index.ts @@ -2,8 +2,10 @@ export * from './types/actions' export * from './types/messages' export * from './types/stage' export * from './types/keybinds' +export * from './types/reports' export * from './utils' -export { finalPermissionMessages } from './data/modpack-permissions-stage' +export { finalPermissionMessages } from './data/modpack-permissions-stage' export { default as checklist } from './data/checklist' export { default as keybinds } from './data/keybinds' +export { default as reportQuickReplies } from './data/report-quick-replies' diff --git a/packages/moderation/types/reports.ts b/packages/moderation/types/reports.ts new file mode 100644 index 00000000..c05dc348 --- /dev/null +++ b/packages/moderation/types/reports.ts @@ -0,0 +1,28 @@ +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) => Promise | string) + shouldShow?: (report: ExtendedReport) => boolean + private?: boolean +} diff --git a/packages/ui/src/components/base/CollapsibleRegion.vue b/packages/ui/src/components/base/CollapsibleRegion.vue new file mode 100644 index 00000000..14d06c83 --- /dev/null +++ b/packages/ui/src/components/base/CollapsibleRegion.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/packages/ui/src/components/base/DropdownSelect.vue b/packages/ui/src/components/base/DropdownSelect.vue index c87ef340..9843eb15 100644 --- a/packages/ui/src/components/base/DropdownSelect.vue +++ b/packages/ui/src/components/base/DropdownSelect.vue @@ -163,7 +163,6 @@ const onFocus = () => { } const onBlur = (event) => { - console.log(event) if (!isChildOfDropdown(event.relatedTarget)) { dropdownVisible.value = false } diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index e1217fc5..85b7f2da 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/types.ts b/packages/utils/types.ts index a56bc3c5..61f553a2 100644 --- a/packages/utils/types.ts +++ b/packages/utils/types.ts @@ -18,7 +18,7 @@ export type DonationPlatform = | { short: 'ko-fi'; name: 'Ko-fi' } | { short: 'other'; name: 'Other' } -export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' +export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'plugin' | 'datapack' export type MonetizationStatus = 'monetized' | 'demonetized' | 'force-demonetized' export type GameVersion = string @@ -65,7 +65,8 @@ export interface Project { client_side: Environment server_side: Environment - team: ModrinthId + team?: ModrinthId + team_id: ModrinthId thread_id: ModrinthId organization: ModrinthId @@ -76,6 +77,7 @@ export interface Project { donation_urls: DonationLink[] published: string + created?: string updated: string approved: string queued: string @@ -295,6 +297,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 | null + 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: @@ -379,3 +435,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 631f4c46..968cea07 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.14.1592(magicast@0.3.5)(rollup@4.28.1))(@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 @@ -3941,6 +3947,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'} @@ -3990,6 +3999,9 @@ packages: destr@2.0.3: resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -6253,6 +6265,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: @@ -8089,6 +8115,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'} @@ -12277,6 +12309,8 @@ snapshots: deep-is@0.1.4: {} + deep-pick-omit@1.2.1: {} + deepmerge@4.3.1: {} default-browser-id@5.0.0: {} @@ -12314,6 +12348,8 @@ snapshots: destr@2.0.3: {} + destr@2.0.5: {} + destroy@1.2.0: {} detect-libc@1.0.3: {} @@ -15509,6 +15545,16 @@ snapshots: pify@4.0.1: {} + pinia-plugin-persistedstate@4.4.1(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.28.1))(@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.14.1592(magicast@0.3.5)(rollup@4.28.1) + '@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 @@ -17508,6 +17554,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)