diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 0000000000..d8768703eb --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,23 @@ +merge_queue: + mode: parallel +scopes: + source: + files: + frontend: + include: + - apps/frontend/**/* + - packages/ui/**/* + - packages/utils/**/* + - packages/assets/**/* + - "**/wrangler.jsonc" + - "**/pnpm-*.yaml" + backend: + include: + - apps/labrinth/** + - Cargo.toml + app: + include: + - apps/app/** + - apps/app-frontend/** + - packages/app-lib/** + - packages/app-macros/** diff --git a/Cargo.lock b/Cargo.lock index b3eefef689..db6f7b0ae2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5515,6 +5515,7 @@ dependencies = [ "scalar_api_reference", "sentry", "serde", + "serde-binhum", "serde_json", "serde_with", "sha1 0.10.6", @@ -9379,6 +9380,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-binhum" +version = "0.1.0" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "serde-untagged" version = "0.1.9" @@ -13443,12 +13454,13 @@ dependencies = [ "chrono", "dashmap", "deadpool-redis", + "eyre", "futures", "lz4_flex", + "postcard", "prometheus", "redis", "serde", - "serde_json", "thiserror 2.0.17", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 1529ce8910..01c9a1af38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "packages/modrinth-util", "packages/neverbounce", "packages/path-util", + "packages/serde-binhum", "packages/xredis", ] @@ -183,6 +184,7 @@ sentry = { version = "0.45.0", default-features = false, features = [ "rustls", ] } serde = "1.0.228" +serde-binhum = { path = "packages/serde-binhum" } serde_bytes = "0.11.19" serde_cbor = "0.11.2" serde_ini = "0.2.0" diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index e2f78fe965..35e6692fbb 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -29,24 +29,25 @@ import { import { Admonition, Avatar, - ButtonStyled, + ButtonLink, commonMessages, ContentInstallModal, ContentUpdaterModal, CreationFlowModal, defineMessages, I18nDebugPanel, + IconButton, IntlFormatted, LoadingBar, NewsArticleCard, NotificationPanel, - OverflowMenu, PopupNotificationPanel, provideModalBehavior, provideModrinthClient, provideNotificationManager, providePageContext, providePopupNotificationManager, + TeleportOverflowMenu, TextLogo, useDebugLogger, useFormatBytes, @@ -120,6 +121,7 @@ import { } from '@/helpers/utils.js' import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts' import i18n from '@/i18n.config' +import { instanceKeys } from '@/pages/instance/query-options' import { appUpdateState, downloadAvailableAppUpdate, @@ -242,7 +244,7 @@ const { data: authenticatedModrinthUser } = useQuery({ retry: false, }) useQuery({ - queryKey: computed(() => ['shared-instance-eligibility', credentials.value?.user?.id]), + queryKey: computed(() => instanceKeys.sharedEligibility(credentials.value?.user?.id)), queryFn: can_current_user_use_shared_instances, enabled: () => !!credentials.value?.session && !!credentials.value?.user?.id, retry: false, @@ -1545,8 +1547,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload) @@ -1570,7 +1574,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload) v-tooltip.right="formatMessage(messages.modrinthHosting)" to="/hosting/manage" :is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'" - :is-subpage="(r) => r.path.startsWith('/hosting/manage/') && r.path !== '/hosting/manage/'" + :is-subpage=" + (r) => + (r.path.startsWith('/hosting/manage/') && r.path !== '/hosting/manage/') || + ((r.path.startsWith('/browse') || r.path.startsWith('/project')) && r.query.sid) + " > @@ -1591,22 +1599,29 @@ provideAppUpdateDownloadProgress(appUpdateDownload) > - - +
- - - - - - + + + + + +
- - - + +
@@ -1793,12 +1803,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload) :key="`news-${index}`" :article="item" /> - - - - {{ formatMessage(messages.viewAllNews) }} - - + + + {{ formatMessage(messages.viewAllNews) }} +
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue index 9c0611ef63..766f234026 100644 --- a/apps/app-frontend/src/components/ui/AccountsCard.vue +++ b/apps/app-frontend/src/components/ui/AccountsCard.vue @@ -4,13 +4,11 @@ class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2" > {{ formatMessage(messages.notSignedIn) }} - - - + - - - + + +
- - - +
@@ -95,8 +97,9 @@ import { import { Accordion, Avatar, - ButtonStyled, + Button, defineMessages, + IconButton, injectNotificationManager, useVIntl, } from '@modrinth/ui' diff --git a/apps/app-frontend/src/components/ui/AddContentButton.vue b/apps/app-frontend/src/components/ui/AddContentButton.vue index cdea116e87..65f6397a9e 100644 --- a/apps/app-frontend/src/components/ui/AddContentButton.vue +++ b/apps/app-frontend/src/components/ui/AddContentButton.vue @@ -1,6 +1,6 @@ @@ -123,21 +108,21 @@ :description="formatMessage(messages.noFriendsInvitedDescription)" > @@ -147,7 +132,7 @@ import { LogInIcon, SpinnerIcon, UserPlusIcon } from '@modrinth/assets' import { Avatar, - ButtonStyled, + Button, ConfirmUnlinkModal, defineMessages, injectAuth, @@ -156,8 +141,8 @@ import { type InvitePlayersUser, useVIntl, } from '@modrinth/ui' -import { useQuery, useQueryClient } from '@tanstack/vue-query' -import { computed, ref, toRef, watch } from 'vue' +import { useQueryClient } from '@tanstack/vue-query' +import { computed, ref, watch } from 'vue' import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue' import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue' @@ -166,16 +151,16 @@ import { isSharedInstancesApiError, isSharedInstanceUnavailableError, } from '@/helpers/install' -import { can_current_user_use_shared_instances, edit } from '@/helpers/instance' +import { edit } from '@/helpers/instance' import type { ModrinthAuthFlow } from '@/helpers/mr_auth.ts' import { sharedInstanceErrorMessages, useSharedInstanceErrors, } from '@/helpers/shared-instance-errors' -import type { GameInstance } from '@/helpers/types' -import { provideInstanceBackup } from '@/providers/instance-backup' -import { injectSharedInstanceState } from '../use-shared-instance-state' +import { injectInstancePage } from '../instance-context' +import { injectSharedInstance } from '../shared-instance-context' +import { provideSharedInstanceManagement } from './shared-instance-management-context' import SharedInstanceMembersTable from './shared-instance-members-table.vue' import SharedInstanceRemoveMemberModal from './shared-instance-remove-member-modal.vue' import SharedInstanceShareEmptyState from './shared-instance-share-empty-state.vue' @@ -184,10 +169,7 @@ import { useSharedInstanceInviteCandidates } from './use-shared-instance-invite- import { useSharedInstanceInviteLink } from './use-shared-instance-invite-link' import { useSharedInstanceMembers } from './use-shared-instance-members' -const props = defineProps<{ - instance: GameInstance - offline?: boolean -}>() +const instancePage = injectInstancePage() const auth = injectAuth() const queryClient = useQueryClient() const { formatMessage } = useVIntl() @@ -196,8 +178,9 @@ const { notifySharedInstanceError, notifySharedInstanceUnavailable, } = useSharedInstanceErrors() -const sharedInstanceState = injectSharedInstanceState() -const instance = toRef(props, 'instance') +const sharedInstanceState = injectSharedInstance() +const instance = computed(() => instancePage.instance.value!) +const offline = instancePage.offline const actionsLocked = sharedInstanceState.shareActionsLocked const sharedInstanceActionsLocked = actionsLocked const currentUserId = computed(() => auth.user.value?.id ?? null) @@ -224,16 +207,7 @@ function notifyOperationError(error: unknown) { } } -const eligibilityQuery = useQuery({ - queryKey: computed(() => ['shared-instance-eligibility', currentUserId.value]), - queryFn: can_current_user_use_shared_instances, - enabled: () => isSignedIn.value && !!currentUserId.value, - retry: false, - staleTime: Infinity, - refetchOnMount: 'always', - refetchOnWindowFocus: false, - refetchOnReconnect: false, -}) +const eligibilityQuery = sharedInstanceState.eligibilityQuery const members = useSharedInstanceMembers({ instance, @@ -257,7 +231,7 @@ const { actionsLocked, }) const inviteLink = useSharedInstanceInviteLink( - computed(() => props.instance.id), + computed(() => instance.value.id), remainingUserSlots, notifyOperationError, ) @@ -286,7 +260,7 @@ const unableToConnect = computed( const membersTableLoading = computed( () => members.rows.value.length === 0 && - !!props.instance.shared_instance && + !!instance.value.shared_instance && (members.query.data.value === undefined || members.query.isFetching.value) && !sharedInstanceUnavailable.value && !sharedInstanceActionsLocked.value, @@ -294,7 +268,7 @@ const membersTableLoading = computed( const showMembersTable = computed( () => members.rows.value.length > 0 || - (!!props.instance.shared_instance && + (!!instance.value.shared_instance && members.query.data.value !== undefined && !members.query.isFetching.value && !sharedInstanceUnavailable.value && @@ -302,13 +276,13 @@ const showMembersTable = computed( ) const requiresUnlink = computed( () => - props.instance.link?.type === 'imported_modpack' && - !props.instance.shared_instance && + instance.value.link?.type === 'imported_modpack' && + !instance.value.shared_instance && !importedModpackUnlinked.value, ) const importedModpackBackupTip = computed(() => - props.instance.link?.type === 'imported_modpack' - ? (props.instance.link.name ?? props.instance.link.filename ?? undefined) + instance.value.link?.type === 'imported_modpack' + ? (instance.value.link.name ?? instance.value.link.filename ?? undefined) : undefined, ) @@ -395,9 +369,9 @@ async function showInvitePlayers(event?: MouseEvent) { } async function unlinkImportedModpack() { try { - await edit(props.instance.id, { link: null as unknown as undefined }) + await edit(instance.value.id, { link: null as unknown as undefined }) importedModpackUnlinked.value = true - await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', props.instance.id] }) + await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] }) if (await inviteLink.ensure()) invitePlayersModal.value?.show() } catch (error) { notifyOperationError(error) @@ -419,7 +393,7 @@ function userProfileLink(username: string) { return !username || username.includes('@') ? undefined : `/user/${encodeURIComponent(username)}` } async function requestAuth(flow: ModrinthAuthFlow) { - await auth.requestSignIn(`/instance/${encodeURIComponent(props.instance.id)}/share`, flow, { + await auth.requestSignIn(`/instance/${encodeURIComponent(instance.value.id)}/share`, flow, { showModal: false, }) return !!auth.session_token.value @@ -428,6 +402,23 @@ function signInToShare(event?: MouseEvent) { void accountRequiredModal.value?.show(event) } +provideSharedInstanceManagement({ + rows: members.rows, + actionsLocked: sharedInstanceActionsLocked, + inviteDisabled: computed(() => !hasRemainingUserSlots.value), + invitePending: inviteLink.pending, + pushUpdateDisabled: computed( + () => + instance.value.install_stage !== 'installed' || + publishState.value !== 'idle' || + offline.value, + ), + pushUpdatePending: computed(() => publishState.value !== 'idle'), + invite: (event) => void showInvitePlayers(event), + remove: showRemoveMemberModal, + pushUpdate: reviewUpdate, +}) + watch( [eligibilityQuery.error, members.query.error], (errors) => { @@ -443,7 +434,7 @@ watch([eligibilityQuery.data, members.query.data], ([eligibility, memberRows]) = } }) watch( - () => props.instance.id, + () => instance.value.id, () => { importedModpackUnlinked.value = false }, @@ -455,6 +446,4 @@ watch( }, { immediate: true, flush: 'post' }, ) - -provideInstanceBackup(() => props.instance) diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-management-context.ts b/apps/app-frontend/src/pages/instance/share/shared-instance-management-context.ts new file mode 100644 index 0000000000..615d66e16e --- /dev/null +++ b/apps/app-frontend/src/pages/instance/share/shared-instance-management-context.ts @@ -0,0 +1,19 @@ +import { createContext } from '@modrinth/ui' +import type { ComputedRef, Ref } from 'vue' + +import type { ShareRow } from './shared-instance-share-types' + +export interface SharedInstanceManagementContext { + readonly rows: ComputedRef + readonly actionsLocked: Ref + readonly inviteDisabled: ComputedRef + readonly invitePending: Ref + readonly pushUpdateDisabled: ComputedRef + readonly pushUpdatePending: ComputedRef + invite: (event: MouseEvent) => void + remove: (row: ShareRow) => void + pushUpdate: (event: MouseEvent) => void +} + +export const [injectSharedInstanceManagement, provideSharedInstanceManagement] = + createContext('InstanceSharePage') diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue b/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue index 85cc02c8c2..756f9b0430 100644 --- a/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue +++ b/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue @@ -11,28 +11,29 @@ clearable />
@@ -120,15 +121,15 @@ @@ -145,10 +146,10 @@ import { UserPlusIcon, XIcon, } from '@modrinth/assets' +import { Button, IconButton } from '@modrinth/ui' import { AutoLink, Avatar, - ButtonStyled, defineMessages, type SortDirection, StyledInput, @@ -161,6 +162,7 @@ import { } from '@modrinth/ui' import { computed, ref, watch } from 'vue' +import { injectSharedInstanceManagement } from './shared-instance-management-context' import { type MethodFilter, methodLabels, @@ -169,19 +171,15 @@ import { type ShareTableColumn, } from './shared-instance-share-types' -const props = defineProps<{ - rows: ShareRow[] - actionsLocked?: boolean - inviteDisabled?: boolean - invitePending?: boolean - pushUpdateDisabled?: boolean - pushUpdatePending?: boolean -}>() -const emit = defineEmits<{ - invite: [event: MouseEvent] - remove: [row: ShareRow] - 'push-update': [event: MouseEvent] -}>() +const management = injectSharedInstanceManagement() +const { + rows, + actionsLocked, + inviteDisabled, + invitePending, + pushUpdateDisabled, + pushUpdatePending, +} = management const search = ref('') const methodFilter = ref('all') const sortColumn = ref('joined') @@ -194,7 +192,7 @@ const methodFilterOptions: Array<{ id: ShareMethod; label: string }> = [ { id: 'direct', label: methodLabels.direct }, { id: 'link', label: methodLabels.link }, ] -const hasMultipleMethods = computed(() => new Set(props.rows.map((row) => row.method)).size > 1) +const hasMultipleMethods = computed(() => new Set(rows.value.map((row) => row.method)).size > 1) const columns = computed[]>(() => { const result: TableColumn[] = [ { @@ -230,7 +228,7 @@ const columns = computed[]>(() => { cellClass: 'whitespace-nowrap !px-2', }, ] - if (!props.actionsLocked) + if (!actionsLocked.value) result.push({ key: 'actions', label: 'Actions', @@ -243,7 +241,7 @@ const columns = computed[]>(() => { }) const filteredRows = computed(() => { const query = search.value.trim().toLowerCase() - return props.rows.filter((row) => { + return rows.value.filter((row) => { if (methodFilter.value !== 'all' && row.method !== methodFilter.value) return false if (!query) return true return [ diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue b/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue index 1f7983dcdd..96d67be712 100644 --- a/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue +++ b/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue @@ -32,16 +32,12 @@
- - + +
@@ -52,7 +48,7 @@ import { UserXIcon, XIcon } from '@modrinth/assets' import { Admonition, Avatar, - ButtonStyled, + Button, commonMessages, defineMessages, NewModal, diff --git a/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts b/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts index 801f9a3552..cd0c43304c 100644 --- a/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts +++ b/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts @@ -12,13 +12,14 @@ import { } from '@/helpers/instance' import type { GameInstance } from '@/helpers/types' +import { instanceKeys } from '../query-options' import { normalizeInviteKey, SHARED_INSTANCE_USER_LIMIT, type ShareRow, } from './shared-instance-share-types' -type MembersQueryKey = readonly ['sharedInstanceUsers', string] +type MembersQueryKey = ReturnType type OptimisticChange = { queryKey: MembersQueryKey @@ -48,7 +49,7 @@ export function useSharedInstanceMembers(options: { onError: (error: unknown) => void }) { const queryClient = useQueryClient() - const queryKey = computed(() => ['sharedInstanceUsers', options.instance.value.id] as const) + const queryKey = computed(() => instanceKeys.sharedMembers(options.instance.value.id)) const invitingUserIds = new Set() const removingUserIds = new Set() const exclusiveMutationPending = ref(false) diff --git a/apps/app-frontend/src/pages/instance/shared-instance-context.ts b/apps/app-frontend/src/pages/instance/shared-instance-context.ts new file mode 100644 index 0000000000..683a7a0686 --- /dev/null +++ b/apps/app-frontend/src/pages/instance/shared-instance-context.ts @@ -0,0 +1,181 @@ +import { createContext, injectAuth } from '@modrinth/ui' +import { useQuery, useQueryClient } from '@tanstack/vue-query' +import { computed, type Ref, ref, watch } from 'vue' + +import { useUserQuery } from '@/composables/users/use-user-query' +import { + getSharedInstanceUnavailableReason, + install_get_shared_instance_update_preview, + isSharedInstanceUnavailableError, + type SharedInstanceUnavailableReason, +} from '@/helpers/install' +import { can_current_user_use_shared_instances } from '@/helpers/instance' +import type { GameInstance } from '@/helpers/types' + +import { instanceKeys } from './query-options' + +export type SharedInstanceManager = + | { + type: 'user' + name: string + avatarUrl?: string + tintBy: string + } + | { + type: 'server' + name: string + avatarUrl?: string + tintBy: string + } + +export function createSharedInstanceContext( + instance: Ref, + offline: Ref, + notifyError: (error: unknown) => void, +) { + const auth = injectAuth() + const queryClient = useQueryClient() + const forcedUnavailableReason = ref(null) + + const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null) + const wrongAccount = computed(() => { + if (auth.isReady && !auth.isReady.value) return false + if (!expectedUserId.value) return false + return auth.user.value?.id !== expectedUserId.value + }) + const actionsLocked = computed(() => wrongAccount.value) + const signedOut = computed(() => !auth.session_token.value) + const managerUserId = computed(() => { + const attachment = instance.value?.shared_instance + if (!attachment) return null + if (attachment.role === 'owner') { + return actionsLocked.value ? (attachment.linked_user_id ?? null) : null + } + return attachment.manager_id ?? null + }) + const managerUserQuery = useUserQuery(managerUserId) + const manager = computed(() => { + const attachment = instance.value?.shared_instance + if (!attachment) return null + + if (attachment.server_manager_name) { + return { + type: 'server', + name: attachment.server_manager_name, + avatarUrl: attachment.server_manager_icon_url ?? undefined, + tintBy: attachment.server_manager_name, + } + } + + const user = managerUserQuery.data.value + if (!user) return null + return { + type: 'user', + name: user.username, + avatarUrl: user.avatar_url ?? undefined, + tintBy: user.id, + } + }) + const unavailableManager = computed(() => manager.value?.name ?? null) + + const eligibilityQuery = useQuery({ + queryKey: computed(() => instanceKeys.sharedEligibility(auth.user.value?.id)), + queryFn: can_current_user_use_shared_instances, + enabled: () => !!auth.session_token.value && !!auth.user.value?.id, + retry: false, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }) + const currentUserCanUseSharedInstances = computed( + () => !auth.session_token.value || eligibilityQuery.data.value !== false, + ) + + const updatePreviewQuery = useQuery({ + queryKey: computed(() => + instanceKeys.sharedUpdatePreview(instance.value?.id ?? '', auth.user.value?.id), + ), + queryFn: () => install_get_shared_instance_update_preview(instance.value!.id), + enabled: computed( + () => + !!instance.value?.id && + !!instance.value.shared_instance && + !actionsLocked.value && + !offline.value && + (auth.isReady?.value ?? true) && + !!auth.session_token.value && + !!auth.user.value?.id, + ), + retry: false, + staleTime: 30_000, + refetchOnWindowFocus: false, + }) + + watch(updatePreviewQuery.data, (preview) => { + if (preview !== undefined) forcedUnavailableReason.value = null + }) + watch(updatePreviewQuery.error, (error) => { + if (!error) return + if (isSharedInstanceUnavailableError(error)) { + forcedUnavailableReason.value = getSharedInstanceUnavailableReason(error) + } else { + notifyError(error) + } + }) + + const unavailableReason = computed(() => forcedUnavailableReason.value) + const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null) + const updatePreview = computed(() => + unavailableReason.value ? null : (updatePreviewQuery.data.value ?? null), + ) + + watch( + () => instance.value?.id, + () => { + forcedUnavailableReason.value = null + }, + ) + + async function refreshAvailability() { + forcedUnavailableReason.value = null + if (!instance.value?.id) return + await queryClient.invalidateQueries({ + queryKey: instanceKeys.sharedUpdatePreview(instance.value.id, auth.user.value?.id), + }) + } + + async function refreshUpdatePreview() { + forcedUnavailableReason.value = null + if (!instance.value?.id || !auth.user.value?.id) return null + const result = await updatePreviewQuery.refetch({ throwOnError: true }) + return result.data ?? null + } + + function setUnavailable(reason: SharedInstanceUnavailableReason | null) { + forcedUnavailableReason.value = reason + } + + return { + actionsLocked, + shareActionsLocked, + unavailableReason, + unavailableManager, + manager, + updatePreview, + expectedUserId, + wrongAccount, + signedOut, + eligibilityQuery, + currentUserCanUseSharedInstances, + refreshAvailability, + refreshUpdatePreview, + setUnavailable, + } +} + +export type SharedInstanceContext = ReturnType + +export const [injectSharedInstance, provideSharedInstance] = createContext( + 'InstancePage', + 'sharedInstance', +) diff --git a/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts b/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts deleted file mode 100644 index 408bd3c2cc..0000000000 --- a/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { injectAuth } from '@modrinth/ui' -import { computed, inject, type InjectionKey, provide, type Ref, ref, watch } from 'vue' - -import { useUserQuery } from '@/composables/users/use-user-query' -import { - getSharedInstanceUnavailableReason, - install_get_shared_instance_update_preview, - isSharedInstanceUnavailableError, - type SharedInstanceUnavailableReason, -} from '@/helpers/install' -import type { GameInstance } from '@/helpers/types' - -export type SharedInstanceManager = - | { - type: 'user' - name: string - avatarUrl?: string - tintBy: string - } - | { - type: 'server' - name: string - avatarUrl?: string - tintBy: string - } - -export function useSharedInstanceState( - instance: Ref, - offline: Ref, - notifyError: (error: unknown) => void, -) { - const auth = injectAuth() - const updatePreview = - ref>>(null) - const updatePreviewLoaded = ref(false) - const unavailableReason = ref(null) - const availabilityCheckKey = ref(null) - const availabilityRefresh = ref(0) - let availabilityRequestId = 0 - let availabilityRequest: { - key: string - promise: Promise<{ - preview: Awaited> - error: unknown | null - }> - } | null = null - - const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null) - const wrongAccount = computed(() => { - if (auth.isReady && !auth.isReady.value) return false - if (!expectedUserId.value) return false - return auth.user.value?.id !== expectedUserId.value - }) - const actionsLocked = computed(() => wrongAccount.value) - const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null) - const signedOut = computed(() => !auth.session_token.value) - const managerUserId = computed(() => { - const attachment = instance.value?.shared_instance - if (!attachment) return null - if (attachment.role === 'owner') { - return actionsLocked.value ? (attachment.linked_user_id ?? null) : null - } - return attachment.manager_id ?? null - }) - const managerUserQuery = useUserQuery(managerUserId) - const manager = computed(() => { - const attachment = instance.value?.shared_instance - if (!attachment) return null - - if (attachment.server_manager_name) { - return { - type: 'server', - name: attachment.server_manager_name, - avatarUrl: attachment.server_manager_icon_url ?? undefined, - tintBy: attachment.server_manager_name, - } - } - - const user = managerUserQuery.data.value - if (!user) return null - return { - type: 'user', - name: user.username, - avatarUrl: user.avatar_url ?? undefined, - tintBy: user.id, - } - }) - const unavailableManager = computed(() => manager.value?.name ?? null) - - function reset() { - availabilityRequestId++ - availabilityRequest = null - availabilityCheckKey.value = null - updatePreview.value = null - updatePreviewLoaded.value = false - unavailableReason.value = null - } - - function refreshAvailability() { - availabilityCheckKey.value = null - updatePreviewLoaded.value = false - availabilityRefresh.value++ - } - - function setUnavailable(reason: SharedInstanceUnavailableReason | null) { - availabilityRequestId++ - availabilityRequest = null - availabilityCheckKey.value = null - updatePreview.value = null - updatePreviewLoaded.value = false - unavailableReason.value = reason - } - - async function checkAvailability(instanceId: string, key: string, throwError = false) { - const requestId = ++availabilityRequestId - let request = availabilityRequest - if (!request || request.key !== key) { - const promise = install_get_shared_instance_update_preview(instanceId).then( - (preview) => ({ preview, error: null }), - (error: unknown) => ({ preview: null, error }), - ) - request = { key, promise } - availabilityRequest = request - void promise.finally(() => { - if (availabilityRequest?.promise === promise) availabilityRequest = null - }) - } - - const result = await request.promise - if (!isCurrentRequest(requestId, instanceId, key)) return null - - if (result.error !== null) { - updatePreviewLoaded.value = false - if (isSharedInstanceUnavailableError(result.error)) { - updatePreview.value = null - unavailableReason.value = getSharedInstanceUnavailableReason(result.error) - } else if (!throwError) { - notifyError(result.error) - } - - if (throwError) throw result.error - return null - } - - updatePreview.value = result.preview - updatePreviewLoaded.value = true - unavailableReason.value = null - return result.preview - } - - async function refreshUpdatePreview() { - const instanceId = instance.value?.id - const userId = auth.user.value?.id - if (!instanceId || !userId) return null - - const key = `${instanceId}:${userId}` - availabilityCheckKey.value = key - return await checkAvailability(instanceId, key, true) - } - - function isCurrentRequest(requestId: number, instanceId: string, key: string) { - return ( - requestId === availabilityRequestId && - instance.value?.id === instanceId && - availabilityCheckKey.value === key - ) - } - - watch( - () => ({ - refresh: availabilityRefresh.value, - instanceId: instance.value?.id, - role: instance.value?.shared_instance?.role, - locked: actionsLocked.value, - offline: offline.value, - signedIn: !!auth.session_token.value, - userId: auth.user.value?.id ?? null, - authReady: auth.isReady?.value ?? true, - }), - async ({ instanceId, role, locked, offline, signedIn, userId, authReady }) => { - if (!instanceId || !role || locked || offline || !authReady || !signedIn || !userId) { - availabilityRequestId++ - availabilityRequest = null - availabilityCheckKey.value = null - updatePreview.value = null - updatePreviewLoaded.value = false - if (instanceId && role) unavailableReason.value = null - return - } - - const key = `${instanceId}:${userId}` - if (availabilityCheckKey.value === key) return - availabilityCheckKey.value = key - await checkAvailability(instanceId, key) - }, - { immediate: true }, - ) - - return { - actionsLocked, - shareActionsLocked, - unavailableReason, - unavailableManager, - manager, - updatePreview, - expectedUserId, - wrongAccount, - signedOut, - reset, - refreshAvailability, - refreshUpdatePreview, - setUnavailable, - } -} - -export type SharedInstanceState = ReturnType - -const sharedInstanceStateKey: InjectionKey = Symbol('shared-instance-state') - -export function provideSharedInstanceState(state: SharedInstanceState) { - provide(sharedInstanceStateKey, state) -} - -export function injectSharedInstanceState() { - const state = inject(sharedInstanceStateKey) - if (!state) throw new Error('Shared instance state has not been provided.') - return state -} diff --git a/apps/app-frontend/src/pages/instance/Worlds.vue b/apps/app-frontend/src/pages/instance/worlds/index.vue similarity index 87% rename from apps/app-frontend/src/pages/instance/Worlds.vue rename to apps/app-frontend/src/pages/instance/worlds/index.vue index 1ddcb5e943..1d9adca70d 100644 --- a/apps/app-frontend/src/pages/instance/Worlds.vue +++ b/apps/app-frontend/src/pages/instance/worlds/index.vue @@ -35,23 +35,14 @@ " />
- - - - - - + +
@@ -72,12 +63,17 @@ {{ option.label }}
- - - +
- - diff --git a/apps/frontend/src/components/ui/ConfirmTransferOrgModal.vue b/apps/frontend/src/components/ui/ConfirmTransferOrgModal.vue index e4f62b43f0..606be33b05 100644 --- a/apps/frontend/src/components/ui/ConfirmTransferOrgModal.vue +++ b/apps/frontend/src/components/ui/ConfirmTransferOrgModal.vue @@ -57,18 +57,14 @@
@@ -76,7 +72,7 @@ diff --git a/apps/frontend/src/components/ui/NotificationItem.vue b/apps/frontend/src/components/ui/NotificationItem.vue index 1bbac8bf38..4f3cfa001b 100644 --- a/apps/frontend/src/components/ui/NotificationItem.vue +++ b/apps/frontend/src/components/ui/NotificationItem.vue @@ -46,18 +46,18 @@ class="flex flex-wrap items-center gap-3" :class="{ 'gap-2': compact }" > - - - - - - + +
+ + + +
+
+
+ - - - -
-
-
- - - - +
- - - - Open link - - - - - - - - + + + Open link + + +
@@ -390,10 +399,12 @@ import { } from '@modrinth/assets' import { Avatar, - ButtonStyled, + Button, + ButtonLink, Categories, CopyCode, DoubleIcon, + IconButton, injectModrinthClient, injectNotificationManager, ProjectStatusBadge, diff --git a/apps/frontend/src/components/ui/OptionGroup.vue b/apps/frontend/src/components/ui/OptionGroup.vue deleted file mode 100644 index 36313921ef..0000000000 --- a/apps/frontend/src/components/ui/OptionGroup.vue +++ /dev/null @@ -1,128 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/ui/OrganizationPageHeader.vue b/apps/frontend/src/components/ui/OrganizationPageHeader.vue index c5f74542df..6063423a50 100644 --- a/apps/frontend/src/components/ui/OrganizationPageHeader.vue +++ b/apps/frontend/src/components/ui/OrganizationPageHeader.vue @@ -38,21 +38,19 @@ @@ -68,18 +66,17 @@ import { SettingsIcon, UsersIcon, } from '@modrinth/assets' +import { ButtonLink, TeleportOverflowMenu } from '@modrinth/ui' import { Avatar, - ButtonStyled, commonMessages, defineMessages, + type OverflowMenuOption, PageHeader, PageHeaderActions, PageHeaderBadgeItem, PageHeaderMetadata, PageHeaderMetadataNumberItem, - TeleportOverflowMenu, - type TeleportOverflowMenuItem, useFormatNumber, useVIntl, } from '@modrinth/ui' @@ -135,7 +132,7 @@ const emit = defineEmits<{ const { formatMessage } = useVIntl() const formatNumber = useFormatNumber() -const moreActions = computed(() => [ +const moreActions = computed(() => [ { id: 'manage-projects', label: formatMessage(messages.manageProjects), @@ -143,10 +140,7 @@ const moreActions = computed(() => [ action: () => emit('manageProjects'), shown: props.canManage, }, - { - divider: true, - shown: props.canManage, - }, + { type: 'divider', shown: props.canManage }, { id: 'copy-id', label: formatMessage(commonMessages.copyIdButton), diff --git a/apps/frontend/src/components/ui/OrganizationProjectTransferModal.vue b/apps/frontend/src/components/ui/OrganizationProjectTransferModal.vue index 6f66486fd0..46524dbff5 100644 --- a/apps/frontend/src/components/ui/OrganizationProjectTransferModal.vue +++ b/apps/frontend/src/components/ui/OrganizationProjectTransferModal.vue @@ -65,38 +65,37 @@
- - - +
diff --git a/apps/frontend/src/pages/index.vue b/apps/frontend/src/pages/index.vue index 406db83516..b74b666b6b 100644 --- a/apps/frontend/src/pages/index.vue +++ b/apps/frontend/src/pages/index.vue @@ -25,33 +25,37 @@

{{ formatMessage(messages.discoverHeading) }}

-
- - - - - - - - - - +
+ + + + + +
- @@ -63,7 +67,7 @@ {{ project.description }}
- +
@@ -444,7 +448,7 @@ import { } from '@modrinth/assets' import { Avatar, - ButtonStyled, + ButtonLink, commonMessages, defineMessages, DropdownSelect, @@ -736,13 +740,6 @@ const creatorFeatureMessages = defineMessages({ color: var(--landing-color-subheading); max-width: 50rem; } - - .button-group { - width: fit-content; - gap: 1.25rem; - margin: 0 auto 5rem; - justify-content: center; - } } .users-section-outer { diff --git a/apps/frontend/src/pages/moderation/external-projects.vue b/apps/frontend/src/pages/moderation/external-projects.vue index fde7ff7ef2..f77e72e99d 100644 --- a/apps/frontend/src/pages/moderation/external-projects.vue +++ b/apps/frontend/src/pages/moderation/external-projects.vue @@ -37,14 +37,10 @@ class="w-[30rem]" />
- - - - - - + +
@@ -59,24 +55,18 @@ clearable wrapper-class="flex-1 w-full" /> - - - - - - - - - + + +
- - + - - - - +