refactor: move org context to new DI setup

This commit is contained in:
IMB11 2025-07-31 12:33:16 +01:00
parent 1602aa9556
commit 718a35737a
8 changed files with 260 additions and 125 deletions

View File

@ -1,18 +1,20 @@
import { ModrinthServerError } from "@modrinth/utils";
import type { JWTAuth, ModuleError, ModuleName } from "@modrinth/utils"; import type { JWTAuth, ModuleError, ModuleName } from "@modrinth/utils";
import { ModrinthServerError } from "@modrinth/utils";
import { injectNotificationManager } from "@modrinth/ui";
import { useServersFetch } from "./servers-fetch.ts"; import { useServersFetch } from "./servers-fetch.ts";
import { import {
GeneralModule,
ContentModule,
BackupsModule, BackupsModule,
ContentModule,
FSModule,
GeneralModule,
NetworkModule, NetworkModule,
StartupModule, StartupModule,
WSModule, WSModule,
FSModule,
} from "./modules/index.ts"; } from "./modules/index.ts";
export function handleError(err: any) { export function handleError(err: any) {
const { addNotification } = injectNotificationManager();
if (err instanceof ModrinthServerError && err.v1Error) { if (err instanceof ModrinthServerError && err.v1Error) {
addNotification({ addNotification({
title: err.v1Error?.context ?? `An error occurred`, title: err.v1Error?.context ?? `An error occurred`,

View File

@ -28,7 +28,7 @@
</nuxt-link> </nuxt-link>
</h2> </h2>
<span> <span>
{{ $formatNumber(acceptedMembers?.length || 0) }} {{ formatNumber(acceptedMembers?.length || 0) }}
member<template v-if="acceptedMembers?.length !== 1">s</template> member<template v-if="acceptedMembers?.length !== 1">s</template>
</span> </span>
</div> </div>
@ -120,11 +120,11 @@
{ {
id: 'manage-projects', id: 'manage-projects',
action: () => action: () =>
navigateTo('/organization/' + organization.slug + '/settings/projects'), router.push('/organization/' + organization?.slug + '/settings/projects'),
hoverOnly: true, hoverFilledOnly: true,
shown: auth.user && currentMember, shown: !!(auth.user && currentMember),
}, },
{ divider: true, shown: auth.user && currentMember }, { divider: true, shown: !!(auth?.user && currentMember) },
{ id: 'copy-id', action: () => copyId() }, { id: 'copy-id', action: () => copyId() },
{ id: 'copy-permalink', action: () => copyPermalink() }, { id: 'copy-permalink', action: () => copyPermalink() },
]" ]"
@ -157,20 +157,20 @@
<template v-for="member in acceptedMembers" :key="member.user.id"> <template v-for="member in acceptedMembers" :key="member.user.id">
<nuxt-link <nuxt-link
class="details-list__item details-list__item--type-large" class="details-list__item details-list__item--type-large"
:to="`/user/${member.user.username}`" :to="`/user/${member?.user?.username}`"
> >
<Avatar :src="member.user.avatar_url" circle /> <Avatar :src="member?.user.avatar_url" circle />
<div class="rows"> <div class="rows">
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
{{ member.user.username }} {{ member?.user?.username }}
<CrownIcon <CrownIcon
v-if="member.is_owner" v-if="member?.is_owner"
v-tooltip="'Organization owner'" v-tooltip="'Organization owner'"
class="text-brand-orange" class="text-brand-orange"
/> />
</span> </span>
<span class="details-list__item__text--style-secondary"> <span class="details-list__item__text--style-secondary">
{{ member.role ? member.role : "Member" }} {{ member?.role ? member.role : "Member" }}
</span> </span>
</div> </div>
</nuxt-link> </nuxt-link>
@ -196,16 +196,21 @@
<div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto"> <div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto">
<NavTabs :links="navLinks" /> <NavTabs :links="navLinks" />
</div> </div>
<template v-if="projects?.length > 0"> <template v-if="projects && projects.length > 0">
<div class="project-list display-mode--list"> <div class="project-list display-mode--list">
<ProjectCard <ProjectCard
v-for="project in (route.params.projectType !== undefined v-for="project in (route.params.projectType !== undefined
? projects.filter((x) => ? (projects ?? []).filter((x) =>
x.project_types.includes( x.project_types.includes(
route.params.projectType.substr(0, route.params.projectType.length - 1), typeof route.params.projectType === 'string'
? route.params.projectType.slice(0, route.params.projectType.length - 1)
: route.params.projectType[0]?.slice(
0,
route.params.projectType[0].length - 1,
) || '',
), ),
) )
: projects : (projects ?? [])
) )
.slice() .slice()
.sort((a, b) => b.downloads - a.downloads)" .sort((a, b) => b.downloads - a.downloads)"
@ -225,9 +230,10 @@
:client-side="project.client_side" :client-side="project.client_side"
:server-side="project.server_side" :server-side="project.server_side"
:status=" :status="
auth.user && (auth.user.id === user.id || tags.staffRoles.includes(auth.user.role)) auth.user &&
? project.status (auth.user.id! === (user as any).id || tags.staffRoles.includes(auth.user.role))
: null ? (project.status as ProjectStatus)
: undefined
" "
:type="project.project_types[0] ?? 'project'" :type="project.project_types[0] ?? 'project'"
:color="project.color" :color="project.color"
@ -240,9 +246,9 @@
<br /> <br />
<span class="preserve-lines text"> <span class="preserve-lines text">
This organization doesn't have any projects yet. This organization doesn't have any projects yet.
<template v-if="isPermission(currentMember?.organization_permissions, 1 << 4)"> <template v-if="isPermission(currentMember?.permissions, 1 << 4)">
Would you like to Would you like to
<a class="link" @click="$refs.modal_creation.show()">create one</a>? <a class="link" @click="($refs as any).modal_creation?.show()">create one</a>?
</template> </template>
</span> </span>
</div> </div>
@ -251,50 +257,58 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { import {
BoxIcon, BoxIcon,
MoreVerticalIcon,
UsersIcon,
SettingsIcon,
ChartIcon, ChartIcon,
CheckIcon, CheckIcon,
XIcon,
ClipboardCopyIcon, ClipboardCopyIcon,
OrganizationIcon,
DownloadIcon,
CrownIcon, CrownIcon,
DownloadIcon,
MoreVerticalIcon,
OrganizationIcon,
SettingsIcon,
UsersIcon,
XIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { import {
Avatar, Avatar,
ButtonStyled,
Breadcrumbs, Breadcrumbs,
ButtonStyled,
commonMessages,
ContentPageHeader, ContentPageHeader,
OverflowMenu, OverflowMenu,
commonMessages,
} from "@modrinth/ui"; } from "@modrinth/ui";
import type { Organization, ProjectStatus, ProjectType, ProjectV3 } from "@modrinth/utils";
import { formatNumber } from "@modrinth/utils";
import UpToDate from "~/assets/images/illustrations/up_to_date.svg?component";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
import ModalCreation from "~/components/ui/ModalCreation.vue";
import NavStack from "~/components/ui/NavStack.vue"; import NavStack from "~/components/ui/NavStack.vue";
import NavStackItem from "~/components/ui/NavStackItem.vue"; import NavStackItem from "~/components/ui/NavStackItem.vue";
import ModalCreation from "~/components/ui/ModalCreation.vue";
import UpToDate from "~/assets/images/illustrations/up_to_date.svg?component";
import ProjectCard from "~/components/ui/ProjectCard.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
import { acceptTeamInvite, removeTeamMember } from "~/helpers/teams.js";
import NavTabs from "~/components/ui/NavTabs.vue"; import NavTabs from "~/components/ui/NavTabs.vue";
import ProjectCard from "~/components/ui/ProjectCard.vue";
import { acceptTeamInvite, removeTeamMember } from "~/helpers/teams.js";
import {
OrganizationContext,
provideOrganizationContext,
} from "~/providers/organization-context.ts";
import { isPermission } from "~/utils/permissions.ts";
const vintl = useVIntl(); const vintl = useVIntl();
const { formatMessage } = vintl; const { formatMessage } = vintl;
const formatCompactNumber = useCompactNumber(true); const formatCompactNumber = useCompactNumber(true);
const auth = await useAuth(); const auth: { user: any } & any = await useAuth();
const user = await useUser(); const user = await useUser();
const cosmetics = useCosmetics(); const cosmetics = useCosmetics();
const route = useNativeRoute(); const route = useNativeRoute();
const router = useRouter();
const tags = useTags(); const tags = useTags();
const config = useRuntimeConfig(); const config = useRuntimeConfig();
let orgId = useRouteId(); const orgId = useRouteId();
// hacky way to show the edit button on the corner of the card. // hacky way to show the edit button on the corner of the card.
const routeHasSettings = computed(() => route.path.includes("settings")); const routeHasSettings = computed(() => route.path.includes("settings"));
@ -303,12 +317,13 @@ const [
{ data: organization, refresh: refreshOrganization }, { data: organization, refresh: refreshOrganization },
{ data: projects, refresh: refreshProjects }, { data: projects, refresh: refreshProjects },
] = await Promise.all([ ] = await Promise.all([
useAsyncData(`organization/${orgId}`, () => useAsyncData(
useBaseFetch(`organization/${orgId}`, { apiVersion: 3 }), `organization/${orgId}`,
() => useBaseFetch(`organization/${orgId}`, { apiVersion: 3 }) as Promise<Organization>,
), ),
useAsyncData( useAsyncData(
`organization/${orgId}/projects`, `organization/${orgId}/projects`,
() => useBaseFetch(`organization/${orgId}/projects`, { apiVersion: 3 }), () => useBaseFetch(`organization/${orgId}/projects`, { apiVersion: 3 }) as Promise<ProjectV3[]>,
{ {
transform: (projects) => { transform: (projects) => {
for (const project of projects) { for (const project of projects) {
@ -359,7 +374,7 @@ if (!organization.value) {
// Filter accepted, sort by role, then by name and Owner role always goes first // Filter accepted, sort by role, then by name and Owner role always goes first
const acceptedMembers = computed(() => { const acceptedMembers = computed(() => {
const acceptedMembers = organization.value.members?.filter((x) => x.accepted); const acceptedMembers = organization.value?.members?.filter((x) => x.accepted) ?? [];
const owner = acceptedMembers.find((x) => x.is_owner); const owner = acceptedMembers.find((x) => x.is_owner);
const rest = acceptedMembers.filter((x) => !x.is_owner) || []; const rest = acceptedMembers.filter((x) => !x.is_owner) || [];
@ -374,43 +389,14 @@ const acceptedMembers = computed(() => {
return [owner, ...rest]; return [owner, ...rest];
}); });
const currentMember = computed(() => {
if (auth.value.user && organization.value) {
const member = organization.value.members.find((x) => x.user.id === auth.value.user.id);
if (member) {
return member;
}
if (tags.value.staffRoles.includes(auth.value.user.role)) {
return {
user: auth.value.user,
role: auth.value.user.role,
permissions: auth.value.user.role === "admin" ? 1023 : 12,
accepted: true,
payouts_split: 0,
avatar_url: auth.value.user.avatar_url,
name: auth.value.user.username,
};
}
}
return null;
});
const hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2;
return currentMember.value && (currentMember.value.permissions & EDIT_DETAILS) === EDIT_DETAILS;
});
const isInvited = computed(() => { const isInvited = computed(() => {
return currentMember.value?.accepted === false; return currentMember.value?.accepted === false;
}); });
const projectTypes = computed(() => { const projectTypes = computed(() => {
const obj = {}; const obj: Record<string, boolean> = {};
for (const project of projects.value) { for (const project of projects.value ?? []) {
obj[project.project_types[0] ?? "project"] = true; obj[project.project_types[0] ?? "project"] = true;
} }
@ -421,62 +407,27 @@ const projectTypes = computed(() => {
const sumDownloads = computed(() => { const sumDownloads = computed(() => {
let sum = 0; let sum = 0;
for (const project of projects.value) { for (const project of projects.value ?? []) {
sum += project.downloads; sum += project.downloads;
} }
return sum; return sum;
}); });
const patchIcon = async (icon) => {
const ext = icon.name.split(".").pop();
await useBaseFetch(`organization/${organization.value.id}/icon`, {
method: "PATCH",
body: icon,
query: { ext },
apiVersion: 3,
});
};
const deleteIcon = async () => {
await useBaseFetch(`organization/${organization.value.id}/icon`, {
method: "DELETE",
apiVersion: 3,
});
};
const patchOrganization = async (id, newData) => {
await useBaseFetch(`organization/${id}`, {
method: "PATCH",
body: newData,
apiVersion: 3,
});
if (newData.slug) {
orgId = newData.slug;
}
};
const onAcceptInvite = useClientTry(async () => { const onAcceptInvite = useClientTry(async () => {
await acceptTeamInvite(organization.value.team_id); await acceptTeamInvite(organization.value?.team_id);
await refreshOrganization(); await refreshOrganization();
}); });
const onDeclineInvite = useClientTry(async () => { const onDeclineInvite = useClientTry(async () => {
await removeTeamMember(organization.value.team_id, auth.value?.user.id); await removeTeamMember(organization.value?.team_id, auth.value?.user?.id);
await refreshOrganization(); await refreshOrganization();
}); });
provide("organizationContext", { const organizationContext = new OrganizationContext(organization, projects, auth, tags, refresh);
organization, const { currentMember } = organizationContext;
projects,
refresh, provideOrganizationContext(organizationContext);
currentMember,
hasPermission,
patchIcon,
deleteIcon,
patchOrganization,
});
const title = `${organization.value.name} - Organization`; const title = `${organization.value.name} - Organization`;
const description = `${organization.value.description} - View the organization ${organization.value.name} on Modrinth`; const description = `${organization.value.description} - View the organization ${organization.value.name} on Modrinth`;
@ -492,13 +443,13 @@ useSeoMeta({
const navLinks = computed(() => [ const navLinks = computed(() => [
{ {
label: formatMessage(commonMessages.allProjectType), label: formatMessage(commonMessages.allProjectType),
href: `/organization/${organization.value.slug}`, href: `/organization/${organization.value?.slug}`,
}, },
...projectTypes.value ...projectTypes.value
.map((x) => { .map((x) => {
return { return {
label: formatMessage(getProjectTypeMessage(x, true)), label: formatMessage(getProjectTypeMessage(x as ProjectType, true)),
href: `/organization/${organization.value.slug}/${x}s`, href: `/organization/${organization.value?.slug}/${x}s`,
}; };
}) })
.slice() .slice()
@ -506,12 +457,12 @@ const navLinks = computed(() => [
]); ]);
async function copyId() { async function copyId() {
await navigator.clipboard.writeText(organization.value.id); await navigator.clipboard.writeText(organization.value?.id ?? "");
} }
async function copyPermalink() { async function copyPermalink() {
await navigator.clipboard.writeText( await navigator.clipboard.writeText(
`${config.public.siteUrl}/organization/${organization.value.id}`, `${config.public.siteUrl}/organization/${organization.value?.id}`,
); );
} }
</script> </script>

View File

@ -16,8 +16,9 @@
<script setup> <script setup>
import ChartDisplay from "~/components/ui/charts/ChartDisplay.vue"; import ChartDisplay from "~/components/ui/charts/ChartDisplay.vue";
import { injectOrganizationContext } from "~/providers/organization-context.ts";
const { projects } = inject("organizationContext"); const { projects } = injectOrganizationContext();
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@ -1,6 +1,7 @@
<script setup> <script setup>
import { SaveIcon, TrashIcon, UploadIcon } from "@modrinth/assets"; import { SaveIcon, TrashIcon, UploadIcon } from "@modrinth/assets";
import { Avatar, Button, ConfirmModal, FileInput, injectNotificationManager } from "@modrinth/ui"; import { Avatar, Button, ConfirmModal, FileInput, injectNotificationManager } from "@modrinth/ui";
import { injectOrganizationContext } from "~/providers/organization-context.ts";
const { addNotification } = injectNotificationManager(); const { addNotification } = injectNotificationManager();
const { const {
@ -10,7 +11,7 @@ const {
deleteIcon, deleteIcon,
patchIcon, patchIcon,
patchOrganization, patchOrganization,
} = inject("organizationContext"); } = injectOrganizationContext();
const icon = ref(null); const icon = ref(null);
const deletedIcon = ref(false); const deletedIcon = ref(false);

View File

@ -230,10 +230,11 @@ import {
import { Avatar, Badge, Button, Checkbox, injectNotificationManager } from "@modrinth/ui"; import { Avatar, Badge, Button, Checkbox, injectNotificationManager } from "@modrinth/ui";
import { ref } from "vue"; import { ref } from "vue";
import { removeTeamMember } from "~/helpers/teams.js"; import { removeTeamMember } from "~/helpers/teams.js";
import { injectOrganizationContext } from "~/providers/organization-context.ts";
import { isPermission } from "~/utils/permissions.ts"; import { isPermission } from "~/utils/permissions.ts";
const { addNotification } = injectNotificationManager(); const { addNotification } = injectNotificationManager();
const { organization, refresh: refreshOrganization, currentMember } = inject("organizationContext"); const { organization, refresh: refreshOrganization, currentMember } = injectOrganizationContext();
const auth = await useAuth(); const auth = await useAuth();

View File

@ -324,11 +324,12 @@ import { formatProjectType } from "@modrinth/utils";
import { Multiselect } from "vue-multiselect"; import { Multiselect } from "vue-multiselect";
import ModalCreation from "~/components/ui/ModalCreation.vue"; import ModalCreation from "~/components/ui/ModalCreation.vue";
import OrganizationProjectTransferModal from "~/components/ui/OrganizationProjectTransferModal.vue"; import OrganizationProjectTransferModal from "~/components/ui/OrganizationProjectTransferModal.vue";
import { injectOrganizationContext } from "~/providers/organization-context.ts";
const { addNotification } = injectNotificationManager(); const { addNotification } = injectNotificationManager();
const { formatMessage } = useVIntl(); const { formatMessage } = useVIntl();
const { organization, projects, refresh } = inject("organizationContext"); const { organization, projects, refresh } = injectOrganizationContext();
const auth = await useAuth(); const auth = await useAuth();

View File

@ -0,0 +1,108 @@
import { createContext } from "@modrinth/ui";
import { type Organization, type OrganizationMember, type ProjectV3 } from "@modrinth/utils";
export class OrganizationContext {
public readonly organization: Ref<Organization | null>;
public readonly projects: Ref<ProjectV3[] | null>;
private readonly auth: Ref<any>;
private readonly tags: Ref<any>;
private readonly refreshFunction: () => Promise<void>;
public constructor(
organization: Ref<Organization | null>,
projects: Ref<ProjectV3[] | null>,
auth: Ref<any>,
tags: Ref<any>,
refreshFunction: () => Promise<void>,
) {
this.organization = organization;
this.projects = projects;
this.auth = auth;
this.tags = tags;
this.refreshFunction = refreshFunction;
}
public refresh = async () => {
if (this.organization.value === null) {
throw new Error("Organization is not set.");
}
await this.refreshFunction();
};
public currentMember = computed<Partial<OrganizationMember> | null>(() => {
if (this.auth.value.user && this.organization.value) {
const member = this.organization.value.members.find(
(x) => x.user.id === this.auth.value.user.id,
);
if (member) {
return member;
}
if (this.tags.value.staffRoles.includes(this.auth.value.user.role)) {
return {
user: this.auth.value.user,
role: this.auth.value.user.role,
permissions: this.auth.value.user.role === "admin" ? 1023 : 12,
accepted: true,
payouts_split: 0,
avatar_url: this.auth.value.user.avatar_url,
name: this.auth.value.user.username,
} as Partial<OrganizationMember>;
}
}
return null;
});
public hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2;
return (
this.currentMember.value &&
(this.currentMember.value.permissions & EDIT_DETAILS) === EDIT_DETAILS
);
});
public patchIcon = async (icon: { name: string }) => {
if (this.organization.value === null) {
throw new Error("Organization is not set.");
}
const ext = icon.name.split(".").pop();
await useBaseFetch(`organization/${this.organization.value.id}/icon`, {
method: "PATCH",
body: icon,
query: { ext },
apiVersion: 3,
});
};
public deleteIcon = async () => {
if (this.organization.value === null) {
throw new Error("Organization is not set.");
}
await useBaseFetch(`organization/${this.organization.value.id}/icon`, {
method: "DELETE",
apiVersion: 3,
});
};
public patchOrganization = async (newData: { slug: any }) => {
if (this.organization.value === null) {
throw new Error("Organization is not set.");
}
await useBaseFetch(`organization/${this.organization.value.id}`, {
method: "PATCH",
body: newData,
apiVersion: 3,
});
await this.refreshFunction();
};
}
export const [injectOrganizationContext, provideOrganizationContext] =
createContext<OrganizationContext>("[id].vue", "organizationContext");

View File

@ -42,6 +42,76 @@ export interface GalleryImage {
description?: string description?: string
} }
export interface ProjectV3 {
id: ModrinthId
slug?: string
project_types: string[]
games: string[]
team_id: ModrinthId
organization?: ModrinthId
name: string
summary: string
description: string
published: string
updated: string
approved?: string
queued?: string
status: ProjectStatus
requested_status?: ProjectStatus
/** @deprecated moved to threads system */
moderator_message?: {
message: string
body?: string
}
license: {
id: string
name: string
url?: string
}
downloads: number
followers: number
categories: string[]
additional_categories: string[]
loaders: string[]
versions: ModrinthId[]
icon_url?: string
link_urls: Record<
string,
{
platform: string
donation: boolean
url: string
}
>
gallery: {
url: string
raw_url: string
featured: boolean
name?: string
description?: string
created: string
ordering: number
}[]
color?: number
thread_id: ModrinthId
monetization_status: MonetizationStatus
side_types_migration_review_status: 'reviewed' | 'pending'
[key: string]: any
}
export type SideTypesMigrationReviewStatus = 'reviewed' | 'pending'
export interface Project { export interface Project {
id: ModrinthId id: ModrinthId
project_type: ProjectType project_type: ProjectType