feat: hosting access tab (#5995)

* feat: implement access tab with dummy data

* fix: spacing

* feat: qa

* feat: implement backend

* qa: qa pass

* feat: fix user "search"

* fix: lint

* feat: change to bitfield

* feat: fix fields

* fix: lint

* fix: lint

* feat: hook up api

* feat: fix permissions

* feat: audit log table event start

* feat: better mobile mode for audit log table

* feat: i18n

* feat: qa

* feat: enforce permissions

* feat: email template start

* feat: qa

* fix: tooltip bug

* feat: qa

* impl: sse support in api-client

* feat: sse impl

* fix: desync path

* feat: time frame picker from analytics

* feat: QA

* fix: spacing

* fix: permisison audit log entries

* fix: hosting manage page shared server detection

* fix: lint

* feat: qa + lint

* feat: audit log table sort by time

* feat: finish frontend panel stuff

* fix: lint

* fix: backend alignment

* fix: lint

* fix: supress friend errors

* feat: qa

* fix: qa

* fix: lint

* fix: utils barrel

* fix: safari cookies in dev

* fix: pin nuxt

* feat: fixes + notif fix

* fix: notifications

* feat: qa

* fix: notification sync not happening immediately

* fix: qa

* fix: qa

* feat: qa

* blog + prepr

* feat: toast shit

* blog images

* thumbnail update one last time

* prepr

* feat: use reinvite route

* update images

* fix: reinvite stuff

* fix: lint

* fix: alignment of save bar

* fix: notif sizing

* fix: split up access

* fix: lint

* fix: lint

* fix: link

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-06-04 16:58:01 +01:00
committed by GitHub
parent 58ad58f958
commit bd97ace974
227 changed files with 15578 additions and 2153 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
"files.insertFinalNewline": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "always"
"source.organizeImports": "never"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[vue]": {
+15 -26
View File
@@ -11,7 +11,6 @@ import {
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
CheckIcon,
CompassIcon,
DownloadIcon,
ExternalIcon,
@@ -41,7 +40,6 @@ import {
defineMessages,
I18nDebugPanel,
LoadingBar,
ModrinthHostingLogo,
NewsArticleCard,
NotificationPanel,
OverflowMenu,
@@ -86,7 +84,6 @@ import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import ServerInvitePopupBody from '@/components/ui/notifications/ServerInvitePopupBody.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
@@ -804,6 +801,11 @@ async function declineServerInviteNotification(notification) {
}
}
function openServerInviteInviterProfile(inviterName) {
if (!inviterName) return
openUrl(`${config.siteUrl}/user/${encodeURIComponent(inviterName)}`)
}
async function handleLiveNotification(notification) {
if (notification?.body?.type !== 'server_invite' || notification.read) return
if (displayedServerInviteNotifications.has(notification.id)) return
@@ -817,30 +819,17 @@ async function handleLiveNotification(notification) {
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
addPopupNotification({
title: 'Modrinth Hosting',
titleLogo: ModrinthHostingLogo,
bodyComponent: ServerInvitePopupBody,
bodyProps: {
inviterName: invitedBy?.username ?? null,
inviterAvatarUrl: invitedBy?.avatar_url ?? null,
serverName,
},
type: 'info',
buttons: [
{
label: 'Accept',
action: () => acceptServerInviteNotification(notification),
icon: CheckIcon,
color: 'brand',
},
{
label: 'Decline',
action: () => declineServerInviteNotification(notification),
icon: XIcon,
color: 'red',
},
],
title: serverName,
autoCloseMs: null,
toast: {
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
},
})
}
@@ -133,6 +133,7 @@ import {
type PopupNotificationProgressItem,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Dropdown } from 'floating-vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -284,6 +285,7 @@ function goToTerminal(path?: string) {
}
const currentLoadingBars = ref<LoadingBar[]>([])
const currentLoadingBarIconUrls = ref<Record<string, string | null>>({})
const notificationId = ref<string | number | null>(null)
const dismissed = ref(false)
@@ -303,6 +305,16 @@ function getLoadingText(loadingBar: LoadingBar): string {
return loadingBar.message ? `${percent}% ${loadingBar.message}` : `${percent}%`
}
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) {
return null
}
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) {
return icon
}
return convertFileSrc(icon)
}
function getNotification(): PopupNotification | null {
if (!notificationId.value) {
return null
@@ -326,6 +338,7 @@ function buildDownloadItems(): PopupNotificationProgressItem[] {
id: getLoadingBarKey(bar),
title: bar.title ?? '',
text: getLoadingText(bar),
iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null,
progress: getLoadingProgress(bar),
waiting: !bar.total || bar.total <= 0,
}))
@@ -400,6 +413,32 @@ async function refreshLoadingBars() {
.map(formatLoadingBars)
.filter((bar) => bar?.bar_type?.type !== 'launcher_update')
const profilePaths = Array.from(
new Set(
currentLoadingBars.value
.map((bar) => bar.bar_type?.profile_path)
.filter((path): path is string => !!path),
),
)
const profiles = profilePaths.length
? await getInstances(profilePaths).catch((error) => {
handleError(error)
return []
})
: []
const profileIconUrls = new Map(
profiles.map((profile) => [profile.path, getDisplayIconUrl(profile.icon_path)]),
)
currentLoadingBarIconUrls.value = Object.fromEntries(
currentLoadingBars.value.map((bar) => {
const barIconUrl = getDisplayIconUrl(bar.bar_type?.icon)
const profileIconUrl = bar.bar_type?.profile_path
? profileIconUrls.get(bar.bar_type.profile_path)
: null
return [getLoadingBarKey(bar), barIconUrl ?? profileIconUrl ?? null]
}),
)
currentLoadingBars.value.sort((a, b) => {
const aKey = `${a.loading_bar_uuid ?? a.id ?? ''}`
const bKey = `${b.loading_bar_uuid ?? b.id ?? ''}`
@@ -8,6 +8,7 @@ import {
InstallationSettingsLayout,
provideAppBackup,
provideInstallationSettings,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
@@ -34,9 +35,17 @@ import type { Manifest } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const debug = useDebugLogger('AppInstallationSettings')
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
debug('metadata load: start', {
instancePath: instance.value.path,
loader: instance.value.loader,
gameVersion: instance.value.game_version,
installStage: instance.value.install_stage,
})
const [
fabric_versions,
forge_versions,
@@ -72,6 +81,15 @@ const [
.catch(handleError),
])
debug('metadata load: done', {
hasFabricManifest: !!fabric_versions?.value,
hasForgeManifest: !!forge_versions?.value,
hasQuiltManifest: !!quilt_versions?.value,
hasNeoforgeManifest: !!neoforge_versions?.value,
gameVersions: all_game_versions?.value?.length ?? 0,
availablePlatforms: loaders?.value?.map((loader) => loader.name) ?? [],
})
const { data: modpackInfo } = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.value.path]),
queryFn: () => get_linked_modpack_info(instance.value.path, 'must_revalidate'),
@@ -95,11 +113,21 @@ function getManifest(loader: string) {
quilt: quilt_versions,
neoforge: neoforge_versions,
}
return map[loader]
const manifest = map[loader]
debug('getManifest:', {
loader,
hasManifest: !!manifest?.value,
gameVersions: manifest?.value?.gameVersions?.length ?? 0,
})
return manifest
}
provideAppBackup({
async createBackup() {
debug('createBackup: start', {
instancePath: instance.value.path,
instanceName: instance.value.name,
})
const allProfiles = await list()
const prefix = `${instance.value.name} - Backup #`
const existingNums = allProfiles
@@ -109,6 +137,7 @@ provideAppBackup({
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const newPath = await duplicate(instance.value.path)
await edit(newPath, { name: `${prefix}${nextNum}` })
debug('createBackup: done', { newPath, backupName: `${prefix}${nextNum}` })
},
})
@@ -165,32 +194,72 @@ provideInstallationSettings({
const manifest = getManifest(loader)
return !!manifest?.value?.gameVersions?.some((x) => item.version === x.id)
})
return (showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')).map(
(x) => ({ value: x.version, label: x.version }),
)
const result = (
showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')
).map((x) => ({ value: x.version, label: x.version }))
debug('resolveGameVersions:', {
loader,
showSnapshots,
totalVersions: versions.length,
filteredVersions: filtered.length,
resultVersions: result.length,
})
return result
},
resolveLoaderVersions(loader, gameVersion) {
if (loader === 'vanilla' || !gameVersion) return []
const manifest = getManifest(loader)
if (!manifest?.value) return []
if (loader === 'fabric' || loader === 'quilt') {
return manifest.value.gameVersions[0]?.loaders ?? []
if (loader === 'vanilla' || !gameVersion) {
debug('resolveLoaderVersions: skipped', { loader, gameVersion })
return []
}
return manifest.value.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
const manifest = getManifest(loader)
if (!manifest?.value) {
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
return []
}
if (loader === 'fabric' || loader === 'quilt') {
const result = manifest.value.gameVersions[0]?.loaders ?? []
debug('resolveLoaderVersions: fabric/quilt result', {
loader,
gameVersion,
count: result.length,
})
return result
}
const result =
manifest.value.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
return result
},
resolveHasSnapshots(loader) {
const versions = all_game_versions?.value ?? []
if (loader === 'vanilla') return versions.some((x) => x.version_type !== 'release')
if (loader === 'vanilla') {
const result = versions.some((x) => x.version_type !== 'release')
debug('resolveHasSnapshots: vanilla', { loader, result })
return result
}
const manifest = getManifest(loader)
const supported = versions.filter(
(item) => !!manifest?.value?.gameVersions?.some((x) => item.version === x.id),
)
return supported.some((x) => x.version_type !== 'release')
const result = supported.some((x) => x.version_type !== 'release')
debug('resolveHasSnapshots:', {
loader,
totalVersions: versions.length,
supportedVersions: supported.length,
result,
})
return result
},
async save(platform, gameVersion, loaderVersionId) {
debug('save: called', {
instancePath: instance.value.path,
platform,
gameVersion,
loaderVersionId,
})
const editProfile: Record<string, string | undefined> = {
loader: platform,
game_version: gameVersion,
@@ -199,17 +268,21 @@ provideInstallationSettings({
editProfile.loader_version = loaderVersionId
}
await edit(instance.value.path, editProfile).catch(handleError)
debug('save: edit complete', { editProfile })
},
afterSave: async () => {
debug('afterSave: installing', { instancePath: instance.value.path })
await install(instance.value.path, false).catch(handleError)
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('afterSave: done')
},
async repair() {
debug('repair: called', { instancePath: instance.value.path })
repairing.value = true
await install(instance.value.path, true).catch(handleError)
repairing.value = false
@@ -217,9 +290,11 @@ provideInstallationSettings({
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('repair: done')
},
async reinstallModpack() {
debug('reinstallModpack: called', { instancePath: instance.value.path })
reinstalling.value = true
await update_repair_modrinth(instance.value.path).catch(handleError)
reinstalling.value = false
@@ -227,9 +302,11 @@ provideInstallationSettings({
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('reinstallModpack: done')
},
async unlinkModpack() {
debug('unlinkModpack: called', { instancePath: instance.value.path })
await edit(instance.value.path, {
linked_data: null as unknown as undefined,
})
@@ -237,27 +314,38 @@ provideInstallationSettings({
queryKey: ['linkedModpackInfo', instance.value.path],
})
onUnlinked()
debug('unlinkModpack: done')
},
getCachedModpackVersions: () => null,
async fetchModpackVersions() {
debug('fetchModpackVersions: called', {
projectId: instance.value.linked_data?.project_id,
})
const versions = await get_project_versions(instance.value.linked_data!.project_id!).catch(
handleError,
)
debug('fetchModpackVersions: done', { count: versions?.length ?? 0 })
return (versions ?? []) as Labrinth.Versions.v2.Version[]
},
async getVersionChangelog(versionId: string) {
debug('getVersionChangelog: called', { versionId })
return (await get_version(versionId, 'must_revalidate').catch(
() => null,
)) as Labrinth.Versions.v2.Version | null
},
async onModpackVersionConfirm(version) {
debug('onModpackVersionConfirm: called', {
versionId: version.id,
instancePath: instance.value.path,
})
await update_managed_modrinth_version(instance.value.path, version.id)
await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', instance.value.path],
})
debug('onModpackVersionConfirm: done')
},
updaterModalProps: computed(() => ({
@@ -1,52 +0,0 @@
<template>
<div class="flex flex-wrap items-center gap-x-1.5 gap-y-1 leading-snug text-primary">
<button
v-if="inviterName"
type="button"
class="inline-flex min-w-0 items-center border-0 bg-transparent p-0 font-semibold text-contrast hover:underline"
@click="openInviterProfile(inviterName)"
>
<Avatar
:src="inviterAvatarUrl"
:alt="inviterName"
circle
size="xxs"
no-shadow
class="mr-1.5 inline-flex"
/>
<span>{{ inviterName }}</span>
</button>
<span>
<span v-if="inviterName" class="whitespace-nowrap">has invited you to manage</span>
<span v-else class="whitespace-nowrap">You have been invited to manage</span>
<span class="font-semibold text-contrast ml-1">{{ serverName }}</span>
<span>.</span>
</span>
</div>
</template>
<script setup>
import { Avatar } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { config } from '@/config'
defineProps({
inviterName: {
type: String,
default: null,
},
inviterAvatarUrl: {
type: String,
default: null,
},
serverName: {
type: String,
required: true,
},
})
function openInviterProfile(username) {
openUrl(`${config.siteUrl}/user/${encodeURIComponent(username)}`)
}
</script>
+1
View File
@@ -10,6 +10,7 @@ export interface LoadingBarType {
version?: string
profile_path?: string
pack_name?: string
icon?: string | null
}
export interface LoadingBar {
@@ -0,0 +1,33 @@
<script setup lang="ts">
import {
injectModrinthClient,
injectModrinthServerContext,
ServersManageAccessPage,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const client = injectModrinthClient()
const { serverId } = injectModrinthServerContext()
const queryClient = useQueryClient()
try {
await Promise.all([
queryClient.ensureQueryData({
queryKey: ['servers', 'users', 'v1', serverId],
queryFn: () => client.archon.server_users_v1.list(serverId),
staleTime: 30_000,
}),
queryClient.ensureQueryData({
queryKey: ['servers', 'v1', 'detail', serverId],
queryFn: () => client.archon.servers_v1.get(serverId),
staleTime: 30_000,
}),
])
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
</script>
<template>
<ServersManageAccessPage />
</template>
@@ -1,7 +1,8 @@
import Access from './Access.vue'
import Backups from './Backups.vue'
import Content from './Content.vue'
import Files from './Files.vue'
import Index from './Index.vue'
import Overview from './Overview.vue'
export { Backups, Content, Files, Index, Overview }
export { Access, Backups, Content, Files, Index, Overview }
+8
View File
@@ -73,6 +73,14 @@ export default new createRouter({
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'access',
name: 'ServerManageAccess',
component: Hosting.Access,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
],
},
{
-1
View File
@@ -40,4 +40,3 @@ These composables are deprecated and should not be used in new code:
- **`useAsyncData`** - we use tanstack, not nuxt's built in async data utility.
- **`useBaseFetch`** (`src/composables/fetch.js`) — legacy Labrinth fetch wrapper. Use `client.labrinth.*` modules instead.
- **`useServersFetch`** (`src/composables/servers/servers-fetch.ts`) — legacy Archon fetch wrapper with manual retry/circuit-breaker. Use `client.archon.*` modules instead — refer to the `packages/api-client/CLAUDE.md` for more information.
+1
View File
@@ -224,6 +224,7 @@ export default defineNuxtConfig({
globalThis.INTERCOM_APP_ID ||
'ykeritl9',
production: isProduction(),
cookieSecure: isProduction(),
buildEnv: process.env.BUILD_ENV,
preview: process.env.PREVIEW === 'true',
featureFlagOverrides: getFeatureFlagOverrides(),
+1 -1
View File
@@ -26,7 +26,7 @@
"@types/semver": "^7.7.1",
"autoprefixer": "^10.4.19",
"glob": "^10.2.7",
"nuxt": "^3.20.2",
"nuxt": "=3.20.2",
"postcss": "^8.4.39",
"prettier-plugin-tailwindcss": "^0.6.5",
"sass": "^1.58.0",
@@ -1,244 +1,284 @@
<template>
<div
class="notification"
:class="{
'has-body': hasBody,
compact: compact,
read: notification.read,
}"
:class="
type === 'server_invite'
? { read: notification.read }
: {
notification: true,
'has-body': hasBody,
compact: compact,
read: notification.read,
}
"
>
<nuxt-link
v-if="!type"
:to="notification.link"
class="notification__icon backed-svg"
:class="{ raised: raised }"
>
<BellIcon />
</nuxt-link>
<DoubleIcon v-else class="notification__icon">
<template #primary>
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link
v-else-if="organization"
:to="`/organization/${organization.slug}`"
tabindex="-1"
>
<Avatar size="xs" :src="organization.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
</nuxt-link>
<Avatar v-else size="xs" :raised="raised" no-shadow />
</template>
<template #secondary>
<ScaleIcon
v-if="type === 'moderator_message' || type === 'status_change'"
class="moderation-color"
/>
<UserPlusIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
<UserPlusIcon
v-else-if="type === 'organization_invite' && organization"
class="creator-color"
/>
<VersionIcon v-else-if="type === 'project_update' && project && version" />
<BellIcon v-else />
</template>
</DoubleIcon>
<div class="notification__title">
<template v-if="type === 'project_update' && project && version">
A project you follow,
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link>
, has been updated:
</template>
<template v-else-if="type === 'team_invite' && project">
<nuxt-link
:to="`/user/${invitedBy.username}`"
class="iconified-link title-link inline-flex"
>
<Avatar
:src="invitedBy.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
class="inline-flex"
/>
<span class="space">&nbsp;</span>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<span>
has invited you to join
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }} </nuxt-link
>.
</span>
</template>
<template v-else-if="type === 'organization_invite' && organization">
<nuxt-link
:to="`/user/${invitedBy.username}`"
class="iconified-link title-link inline-flex"
>
<Avatar
:src="invitedBy.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
class="inline-flex"
/>
<span class="space">&nbsp;</span>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<span>
has invited you to join
<nuxt-link :to="`/organization/${organization.slug}`" class="title-link">
{{ organization.name }} </nuxt-link
>.
</span>
</template>
<template v-else-if="type === 'status_change' && project">
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<template v-if="tags.rejectedStatuses.includes(notification.body.new_status)">
has been
<ProjectStatusBadge :status="notification.body.new_status" />
</template>
<template v-else>
updated from
<ProjectStatusBadge :status="notification.body.old_status" />
to
<ProjectStatusBadge :status="notification.body.new_status" />
</template>
by the moderators.
</template>
<template v-else-if="type === 'moderator_message' && thread && project && !report">
Your project,
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link>
, has received
<template v-if="notification.grouped_notifs"> messages</template>
<template v-else>a message</template>
from the moderators.
</template>
<template v-else-if="type === 'moderator_message' && thread && report">
A moderator replied to your report of
<template v-if="version">
version
<nuxt-link :to="getVersionLink(project, version)" class="title-link">
{{ version.name }}
</nuxt-link>
of project
</template>
<nuxt-link v-if="project" :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" class="title-link">
{{ user.username }}
</nuxt-link>
.
</template>
<nuxt-link v-else :to="notification.link" class="title-link">
<span v-html="renderString(notification.title)" />
</nuxt-link>
<!-- <span v-else class="known-errors">Error reading notification.</span>-->
</div>
<div v-if="hasBody" class="notification__body">
<ThreadSummary
v-if="type === 'moderator_message' && thread"
:thread="thread"
:link="threadLink"
:raised="raised"
:messages="getMessages()"
class="thread-summary"
:auth="auth"
/>
<div v-else-if="type === 'project_update'" class="version-list">
<template v-if="type === 'server_invite'">
<div class="flex flex-col gap-4">
<ModrinthServersIcon class="h-auto w-56 max-w-full text-[var(--color-heading)]" />
<div
v-for="notif in (notification.grouped_notifs
? [notification, ...notification.grouped_notifs]
: [notification]
).filter((x) => x.extra_data.version)"
:key="notif.id"
class="version-link"
class="flex flex-wrap items-center gap-x-1.5 gap-y-2 text-lg leading-tight text-[var(--color-heading)]"
>
<VersionIcon />
<nuxt-link
:to="getVersionLink(notif.extra_data.project, notif.extra_data.version)"
class="text-link"
v-if="invitedBy"
:to="`/user/${invitedBy.username}`"
class="inline-flex items-center font-bold text-[var(--color-heading)] hover:underline"
>
{{ notif.extra_data.version.name }}
</nuxt-link>
<span class="version-info">
for
<Categories
:categories="getLoaderCategories(notif.extra_data.version)"
:type="notif.extra_data.project.project_type"
class="categories"
<Avatar
:src="invitedBy.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
class="mr-1.5 inline-flex"
/>
{{ $formatVersion(notif.extra_data.version.game_versions) }}
<span v-tooltip="formatDateTime(notif.extra_data.version.date_published)" class="date">
{{ formatRelativeTime(notif.extra_data.version.date_published) }}
</span>
</span>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<span v-if="invitedBy">has invited you to manage</span>
<span v-else>You have been invited to manage</span>
<span
><strong class="font-bold text-[var(--color-heading)]">{{
notification.body.server_name
}}</strong
>.</span
>
</div>
</div>
<template v-else>
{{ notification.text }}
</template>
</div>
<span class="notification__date">
<span v-if="notification.read" class="read-badge inline-flex">
<CheckCircleIcon /> Read
</span>
<span v-tooltip="formatDateTime(notification.created)" class="inline-flex">
<CalendarIcon class="mr-1" /> Received
{{ formatRelativeTime(notification.created) }}
</span>
</span>
<div v-if="compact" class="notification__actions">
<template v-if="type === 'team_invite' || type === 'organization_invite'">
<ButtonStyled circular color="brand" type="transparent">
<button
v-tooltip="`Accept`"
@click="
() => {
acceptTeamInvite(notification.body.team_id)
read()
}
"
>
<CheckIcon />
</button>
</ButtonStyled>
<ButtonStyled circular color="red" type="transparent">
<button
v-tooltip="`Decline`"
@click="
() => {
removeSelfFromTeam(notification.body.team_id)
read()
}
"
>
<XIcon />
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else-if="!notification.read" circular type="transparent">
<button v-tooltip="`Mark as read`" @click="read()">
<XIcon />
</button>
</ButtonStyled>
</div>
<div v-else class="notification__actions">
<div v-if="type !== null" class="input-group">
<template
v-if="(type === 'team_invite' || type === 'organization_invite') && !notification.read"
<div
v-if="!notification.read"
class="flex flex-wrap items-center gap-3"
:class="{ 'gap-2': compact }"
>
<ButtonStyled color="brand">
<button @click="performActionByTitle(notification, 'Accept')">
<CheckIcon />
Accept
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="performActionByTitle(notification, 'Deny')">
<XIcon />
Decline
</button>
</ButtonStyled>
</div>
<div
class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[var(--color-text-secondary)]"
>
<span
v-if="notification.read"
class="inline-flex items-center font-bold text-[var(--color-text)]"
>
<CheckCircleIcon /> Read
</span>
<span v-tooltip="formatDateTime(notification.created)" class="inline-flex items-center">
<CalendarIcon class="mr-1" /> Received
{{ formatRelativeTime(notification.created) }}
</span>
<CopyCode v-if="flags.developerMode" :text="notification.id" />
</div>
</div>
</template>
<template v-else>
<nuxt-link
v-if="!type"
:to="notification.link"
class="notification__icon backed-svg"
:class="{ raised: raised }"
>
<BellIcon />
</nuxt-link>
<DoubleIcon v-else class="notification__icon">
<template #primary>
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link
v-else-if="organization"
:to="`/organization/${organization.slug}`"
tabindex="-1"
>
<Avatar size="xs" :src="organization.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
</nuxt-link>
<Avatar v-else size="xs" :raised="raised" no-shadow />
</template>
<template #secondary>
<ScaleIcon
v-if="type === 'moderator_message' || type === 'status_change'"
class="moderation-color"
/>
<UserPlusIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
<UserPlusIcon
v-else-if="type === 'organization_invite' && organization"
class="creator-color"
/>
<VersionIcon v-else-if="type === 'project_update' && project && version" />
<BellIcon v-else />
</template>
</DoubleIcon>
<div class="notification__title">
<template v-if="type === 'project_update' && project && version">
A project you follow,
<nuxt-link :to="getProjectLink(project)" class="title-link">{{
project.title
}}</nuxt-link>
, has been updated:
</template>
<template v-else-if="type === 'team_invite' && project">
<nuxt-link
:to="`/user/${invitedBy.username}`"
class="iconified-link title-link inline-flex"
>
<Avatar
:src="invitedBy.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
class="inline-flex"
/>
<span class="space">&nbsp;</span>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<span>
has invited you to join
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }} </nuxt-link
>.
</span>
</template>
<template v-else-if="type === 'organization_invite' && organization">
<nuxt-link
:to="`/user/${invitedBy.username}`"
class="iconified-link title-link inline-flex"
>
<Avatar
:src="invitedBy.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
class="inline-flex"
/>
<span class="space">&nbsp;</span>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<span>
has invited you to join
<nuxt-link :to="`/organization/${organization.slug}`" class="title-link">
{{ organization.name }} </nuxt-link
>.
</span>
</template>
<template v-else-if="type === 'status_change' && project">
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<template v-if="tags.rejectedStatuses.includes(notification.body.new_status)">
has been
<ProjectStatusBadge :status="notification.body.new_status" />
</template>
<template v-else>
updated from
<ProjectStatusBadge :status="notification.body.old_status" />
to
<ProjectStatusBadge :status="notification.body.new_status" />
</template>
by the moderators.
</template>
<template v-else-if="type === 'moderator_message' && thread && project && !report">
Your project,
<nuxt-link :to="getProjectLink(project)" class="title-link">{{
project.title
}}</nuxt-link>
, has received
<template v-if="notification.grouped_notifs"> messages</template>
<template v-else>a message</template>
from the moderators.
</template>
<template v-else-if="type === 'moderator_message' && thread && report">
A moderator replied to your report of
<template v-if="version">
version
<nuxt-link :to="getVersionLink(project, version)" class="title-link">
{{ version.name }}
</nuxt-link>
of project
</template>
<nuxt-link v-if="project" :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" class="title-link">
{{ user.username }}
</nuxt-link>
.
</template>
<nuxt-link v-else :to="notification.link" class="title-link">
<span v-html="renderString(notification.title)" />
</nuxt-link>
<!-- <span v-else class="known-errors">Error reading notification.</span>-->
</div>
<div v-if="hasBody" class="notification__body">
<ThreadSummary
v-if="type === 'moderator_message' && thread"
:thread="thread"
:link="threadLink"
:raised="raised"
:messages="getMessages()"
class="thread-summary"
:auth="auth"
/>
<div v-else-if="type === 'project_update'" class="version-list">
<div
v-for="notif in (notification.grouped_notifs
? [notification, ...notification.grouped_notifs]
: [notification]
).filter((x) => x.extra_data.version)"
:key="notif.id"
class="version-link"
>
<VersionIcon />
<nuxt-link
:to="getVersionLink(notif.extra_data.project, notif.extra_data.version)"
class="text-link"
>
{{ notif.extra_data.version.name }}
</nuxt-link>
<span class="version-info">
for
<Categories
:categories="getLoaderCategories(notif.extra_data.version)"
:type="notif.extra_data.project.project_type"
class="categories"
/>
{{ $formatVersion(notif.extra_data.version.game_versions) }}
<span
v-tooltip="formatDateTime(notif.extra_data.version.date_published)"
class="date"
>
{{ formatRelativeTime(notif.extra_data.version.date_published) }}
</span>
</span>
</div>
</div>
<template v-else>
{{ notification.text }}
</template>
</div>
<span class="notification__date">
<span v-if="notification.read" class="read-badge inline-flex">
<CheckCircleIcon /> Read
</span>
<span v-tooltip="formatDateTime(notification.created)" class="inline-flex">
<CalendarIcon class="mr-1" /> Received
{{ formatRelativeTime(notification.created) }}
</span>
</span>
<div v-if="compact" class="notification__actions">
<template v-if="type === 'team_invite' || type === 'organization_invite'">
<ButtonStyled circular color="brand" type="transparent">
<button
v-tooltip="`Accept`"
@click="
() => {
acceptTeamInvite(notification.body.team_id)
@@ -247,11 +287,11 @@
"
>
<CheckIcon />
Accept
</button>
</ButtonStyled>
<ButtonStyled color="red">
<ButtonStyled circular color="red" type="transparent">
<button
v-tooltip="`Decline`"
@click="
() => {
removeSelfFromTeam(notification.body.team_id)
@@ -260,41 +300,79 @@
"
>
<XIcon />
Decline
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else-if="!notification.read">
<button @click="read()">
<CheckIcon />
Mark as read
<ButtonStyled v-else-if="!notification.read" circular type="transparent">
<button v-tooltip="`Mark as read`" @click="read()">
<XIcon />
</button>
</ButtonStyled>
<CopyCode v-if="flags.developerMode" :text="notification.id" />
</div>
<div v-else class="input-group">
<ButtonStyled v-if="notification.link && notification.link !== '#'">
<nuxt-link :to="notification.link" target="_blank">
<ExternalIcon />
Open link
</nuxt-link>
</ButtonStyled>
<ButtonStyled v-for="(action, actionIndex) in notification.actions" :key="actionIndex">
<button @click="performAction(notification, actionIndex)">
<CheckIcon v-if="action.title === 'Accept'" />
<XIcon v-else-if="action.title === 'Deny'" />
{{ action.title }}
</button>
</ButtonStyled>
<ButtonStyled v-if="notification.actions.length === 0 && !notification.read">
<button @click="performAction(notification, null)">
<CheckIcon />
Mark as read
</button>
</ButtonStyled>
<CopyCode v-if="flags.developerMode" :text="notification.id" />
<div v-else class="notification__actions">
<div v-if="type !== null" class="input-group">
<template
v-if="(type === 'team_invite' || type === 'organization_invite') && !notification.read"
>
<ButtonStyled color="brand">
<button
@click="
() => {
acceptTeamInvite(notification.body.team_id)
read()
}
"
>
<CheckIcon />
Accept
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button
@click="
() => {
removeSelfFromTeam(notification.body.team_id)
read()
}
"
>
<XIcon />
Decline
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else-if="!notification.read">
<button @click="read()">
<CheckIcon />
Mark as read
</button>
</ButtonStyled>
<CopyCode v-if="flags.developerMode" :text="notification.id" />
</div>
<div v-else class="input-group">
<ButtonStyled v-if="notification.link && notification.link !== '#'">
<nuxt-link :to="notification.link" target="_blank">
<ExternalIcon />
Open link
</nuxt-link>
</ButtonStyled>
<ButtonStyled v-for="(action, actionIndex) in notification.actions" :key="actionIndex">
<button @click="performAction(notification, actionIndex)">
<CheckIcon v-if="action.title === 'Accept'" />
<XIcon v-else-if="action.title === 'Deny'" />
{{ action.title }}
</button>
</ButtonStyled>
<ButtonStyled v-if="notification.actions.length === 0 && !notification.read">
<button @click="performAction(notification, null)">
<CheckIcon />
Mark as read
</button>
</ButtonStyled>
<CopyCode v-if="flags.developerMode" :text="notification.id" />
</div>
</div>
</div>
</template>
</div>
</template>
@@ -328,11 +406,13 @@ import { markAsRead } from '~/helpers/platform-notifications'
import { getProjectLink, getVersionLink } from '~/helpers/projects'
import { acceptTeamInvite, removeSelfFromTeam } from '~/helpers/teams'
import ModrinthServersIcon from '../brand/ModrinthServersIcon.vue'
import ThreadSummary from './thread/ThreadSummary.vue'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const emit = defineEmits(['update:notifications'])
const router = useRouter()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
@@ -415,9 +495,29 @@ async function performAction(notification, actionIndex) {
await read()
if (actionIndex !== null) {
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
})
const action = notification.actions[actionIndex]
if (type.value === 'server_invite') {
const actionName = action.title.toLowerCase()
const inviteAction = actionName === 'accept' ? 'accept' : 'decline'
const serverId = notification.body.server_id
await client.request(`/servers/${serverId}/invites/${inviteAction}`, {
api: 'archon',
version: 1,
method: 'POST',
})
if (inviteAction === 'accept') {
await router.push(`/hosting/manage/${encodeURIComponent(serverId)}`)
}
} else {
const [method, route] = action.action_route
await useBaseFetch(route, {
method: method.toUpperCase(),
})
}
}
} catch (err) {
addNotification({
@@ -429,6 +529,20 @@ async function performAction(notification, actionIndex) {
stopLoading()
}
function performActionByTitle(notification, title) {
const actionIndex = notification.actions.findIndex((action) => action.title === title)
if (actionIndex === -1) {
addNotification({
title: 'An error occurred',
text: `Missing ${title.toLowerCase()} action for notification.`,
type: 'error',
})
return
}
return performAction(notification, actionIndex)
}
function getMessages() {
const messages = []
if (props.notification.body.message_id) {
@@ -1,20 +1,22 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { PlusIcon, XIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
injectModrinthClient,
injectNotificationManager,
NewModal,
ServerNotice,
StyledInput,
TagItem,
} from '@modrinth/ui'
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
import { ref } from 'vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
type ServerNoticeType = Archon.Notices.v0.ListedNotice
const modal = ref<InstanceType<typeof NewModal>>()
@@ -32,28 +34,23 @@ const assignedNodes = computed(() => assigned.value.filter((n) => n.kind === 'no
const inputField = ref('')
async function refresh() {
await useServersFetch('notices').then((res) => {
const notices = res as ServerNoticeType[]
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? []
})
const notices = await client.archon.notices_v0.list()
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? []
}
async function assign(server: boolean = true) {
const input = inputField.value.trim()
if (input !== '' && notice.value) {
await useServersFetch(
`notices/${notice.value.id}/assign?${server ? 'server' : 'node'}=${input}`,
{
method: 'PUT',
},
).catch((err) => {
addNotification({
title: 'Error assigning notice',
text: err,
type: 'error',
await client.archon.notices_v0
.assign(notice.value.id, server ? { server: input } : { node: input })
.catch((err) => {
addNotification({
title: 'Error assigning notice',
text: err,
type: 'error',
})
})
})
} else {
addNotification({
title: 'Error assigning notice',
@@ -84,18 +81,15 @@ async function unassignDetect() {
async function unassign(id: string, server: boolean = true) {
if (notice.value) {
await useServersFetch(
`notices/${notice.value.id}/unassign?${server ? 'server' : 'node'}=${id}`,
{
method: 'PUT',
},
).catch((err) => {
addNotification({
title: 'Error unassigning notice',
text: err,
type: 'error',
await client.archon.notices_v0
.unassign(notice.value.id, server ? { server: id } : { node: id })
.catch((err) => {
addNotification({
title: 'Error unassigning notice',
text: err,
type: 'error',
})
})
})
}
await refresh()
}
@@ -125,7 +119,7 @@ defineExpose({ show, hide })
:level="notice.level"
:message="notice.message"
:dismissable="notice.dismissable"
:title="notice.title"
:title="notice.title ?? undefined"
preview
/>
<div class="flex flex-col gap-2">
@@ -133,6 +133,7 @@ import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
injectModrinthClient,
injectNotificationManager,
NewModal,
StyledInput,
@@ -143,9 +144,9 @@ import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
import { computed, ref } from 'vue'
import { useBaseFetch } from '#imports'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const modal = ref<InstanceType<typeof NewModal>>()
@@ -205,12 +206,12 @@ const applyDisabled = computed(() => {
async function ensureOverview() {
if (regions.value.length || nodeHostnames.value.length) return
try {
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
regions.value = (data.regions || []).map((r: any) => ({
const data = await client.archon.nodes_internal.overview()
regions.value = data.regions.map((r) => ({
value: r.key,
label: `${r.display_name} (${r.key})`,
}))
nodeHostnames.value = data.node_hostnames || []
nodeHostnames.value = data.node_hostnames
if (!selectedRegion.value && regions.value.length) selectedRegion.value = regions.value[0].value
} catch (err) {
addNotification({ title: 'Failed to load nodes overview', text: String(err), type: 'error' })
@@ -198,6 +198,7 @@ import {
ButtonStyled,
Chips,
Combobox,
injectModrinthClient,
injectNotificationManager,
NewModal,
StyledInput,
@@ -207,13 +208,12 @@ import {
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const emit = defineEmits<{
success: []
}>()
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const modal = ref<InstanceType<typeof NewModal>>()
@@ -341,12 +341,12 @@ const submitDisabled = computed(() => {
async function ensureOverview() {
if (regions.value.length || nodeHostnames.value.length) return
try {
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
regions.value = (data.regions || []).map((r: any) => ({
const data = await client.archon.nodes_internal.overview()
regions.value = data.regions.map((r) => ({
value: r.key,
label: `${r.display_name} (${r.key})`,
}))
nodeHostnames.value = data.node_hostnames || []
nodeHostnames.value = data.node_hostnames
if (!selectedRegion.value && regions.value.length) {
selectedRegion.value = regions.value[0].value
}
@@ -364,30 +364,22 @@ async function submit() {
scheduleOption.value === 'now' ? undefined : dayjs(scheduledDate.value).toISOString()
if (mode.value === 'servers') {
await useServersFetch('/transfers/schedule/servers', {
version: 'internal',
method: 'POST',
body: {
server_ids: parsedServerIds.value,
scheduled_at: scheduledAt,
target_region: selectedRegion.value || undefined,
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
reason: reason.value.trim(),
},
await client.archon.transfers_internal.scheduleServers({
server_ids: parsedServerIds.value,
scheduled_at: scheduledAt,
target_region: selectedRegion.value || undefined,
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
reason: reason.value.trim(),
})
} else {
await useServersFetch('/transfers/schedule/nodes', {
version: 'internal',
method: 'POST',
body: {
node_hostnames: selectedNodes.value.slice(),
scheduled_at: scheduledAt,
target_region: selectedRegion.value || undefined,
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
reason: reason.value.trim(),
cordon_nodes: cordonNodes.value,
tag_nodes: tagNodes.value.trim() || undefined,
},
await client.archon.transfers_internal.scheduleNodes({
node_hostnames: selectedNodes.value.slice(),
scheduled_at: scheduledAt,
target_region: selectedRegion.value || undefined,
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
reason: reason.value.trim(),
cordon_nodes: cordonNodes.value,
tag_nodes: tagNodes.value.trim() || undefined,
})
}
@@ -48,7 +48,12 @@
>{{ isIncome ? '' : '-' }}{{ formatMoney(transaction.amount) }}</span
>
<template v-if="transaction.type === 'withdrawal' && transaction.status === 'in-transit'">
<Tooltip theme="dismissable-prompt" :triggers="['hover', 'focus']" no-auto-focus>
<Tooltip
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
no-auto-focus
>
<span class="my-auto align-middle"
><ButtonStyled circular type="outlined" size="small">
<button class="align-middle" @click="cancelPayout">
+2 -1
View File
@@ -1,8 +1,9 @@
export const useAffiliates = () => {
const config = useRuntimeConfig()
const affiliateCookie = useCookie('mrs_afl', {
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: 'lax',
secure: true,
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
})
+2 -1
View File
@@ -30,10 +30,11 @@ export const initAuth = async (oldToken = null) => {
}
const route = useRoute()
const config = useRuntimeConfig()
const authCookie = useCookie('auth-token', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
})
+12 -9
View File
@@ -54,6 +54,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showViewProdRouteBanner: false,
showModeratorProjectMemberUi: false,
showModeratorPrivateMessageHighlight: true,
archonApiStaging: false,
showHostingAccessInstanceAuditLog: false,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
@@ -64,19 +66,20 @@ export type AllFeatureFlags = {
export type PartialFeatureFlags = Partial<AllFeatureFlags>
const COOKIE_OPTIONS = {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
httpOnly: false,
path: '/',
} satisfies CookieOptions<PartialFeatureFlags>
const getCookieOptions = () =>
({
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: useRuntimeConfig().public.cookieSecure,
httpOnly: false,
path: '/',
}) satisfies CookieOptions<PartialFeatureFlags>
export const useFeatureFlags = () =>
useState<AllFeatureFlags>('featureFlags', () => {
const config = useRuntimeConfig()
const savedFlags = useCookie<PartialFeatureFlags>('featureFlags', COOKIE_OPTIONS)
const savedFlags = useCookie<PartialFeatureFlags>('featureFlags', getCookieOptions())
if (!savedFlags.value) {
savedFlags.value = {}
@@ -106,6 +109,6 @@ export const useFeatureFlags = () =>
export const saveFeatureFlags = () => {
const flags = useFeatureFlags()
const cookie = useCookie<PartialFeatureFlags>('featureFlags', COOKIE_OPTIONS)
const cookie = useCookie<PartialFeatureFlags>('featureFlags', getCookieOptions())
cookie.value = flags.value
}
@@ -1,264 +0,0 @@
/**
* @deprecated Use `@modrinth/api-client` via `injectModrinthClient()` instead.
* The api-client's archon modules (`client.archon.servers_v0`, etc.) handle auth,
* retry, and circuit breaking automatically. This composable is kept for legacy
* code that hasn't been migrated yet.
*/
import { PANEL_VERSION } from '@modrinth/api-client'
import type { V1ErrorInfo } from '@modrinth/utils'
import { ModrinthServerError, ModrinthServersFetchError } from '@modrinth/utils'
import { $fetch, FetchError } from 'ofetch'
export interface ServersFetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
contentType?: string
body?: Record<string, any>
version?: number | 'internal'
override?: {
url?: string
token?: string
}
retry?: number | boolean
bypassAuth?: boolean
}
export async function useServersFetch<T>(
path: string,
options: ServersFetchOptions = {},
module?: string,
errorContext?: string,
): Promise<T> {
const config = useRuntimeConfig()
const auth = await useAuth()
const authToken = auth.value?.token
if (!authToken && !options.bypassAuth) {
const error = new ModrinthServersFetchError(
'[Modrinth Hosting] Cannot fetch without auth',
10000,
)
throw new ModrinthServerError('Missing auth token', 401, error, module, undefined, undefined)
}
const {
method = 'GET',
contentType = 'application/json',
body,
version = 0,
override,
retry = method === 'GET' ? 3 : 0,
} = options
const circuitBreakerKey = `${module || 'default'}_${path}`
const failureCount = useState<number>(`fetch_failures_${circuitBreakerKey}`, () => 0)
const lastFailureTime = useState<number>(`last_failure_${circuitBreakerKey}`, () => 0)
const now = Date.now()
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
const error = new ModrinthServersFetchError(
'[Modrinth Hosting] Circuit breaker open - too many recent failures',
503,
)
throw new ModrinthServerError(
'Service temporarily unavailable',
503,
error,
module,
undefined,
undefined,
)
}
if (now - lastFailureTime.value > 30000) {
failureCount.value = 0
}
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/,
'',
)
if (!base) {
const error = new ModrinthServersFetchError(
'[Modrinth Hosting] Cannot fetch without base url. Make sure to set a PYRO_BASE_URL in environment variables',
10001,
)
throw new ModrinthServerError(
'Configuration error: Missing PYRO_BASE_URL',
500,
error,
module,
undefined,
undefined,
)
}
const versionString = `v${version}`
let newOverrideUrl = override?.url
if (newOverrideUrl && newOverrideUrl.includes('v0') && version !== 0) {
newOverrideUrl = newOverrideUrl.replace('v0', versionString)
}
const fullUrl = newOverrideUrl
? `https://${newOverrideUrl}/${path.replace(/^\//, '')}`
: version === 0
? `${base}/modrinth/v${version}/${path.replace(/^\//, '')}`
: version === 'internal'
? `${base}/_internal/${path.replace(/^\//, '')}`
: `${base}/v${version}/${path.replace(/^\//, '')}`
const headers: Record<string, string> = {
'User-Agent': 'Modrinth/1.0 (https://modrinth.com)',
'X-Archon-Request': 'true',
'X-Panel-Version': String(PANEL_VERSION),
Vary: 'Accept, Origin',
}
if (!options.bypassAuth) {
headers.Authorization = `Bearer ${override?.token ?? authToken}`
headers['Access-Control-Allow-Headers'] = 'Authorization'
}
if (contentType !== 'none') {
headers['Content-Type'] = contentType
}
if (import.meta.client && typeof window !== 'undefined') {
headers.Origin = window.location.origin
}
let attempts = 0
const maxAttempts = (typeof retry === 'boolean' ? (retry ? 3 : 1) : retry) + 1
let lastError: Error | null = null
while (attempts < maxAttempts) {
try {
const response = await $fetch<T>(fullUrl, {
method,
headers,
body:
body && contentType === 'application/json' ? JSON.stringify(body) : (body ?? undefined),
timeout: 10000,
})
failureCount.value = 0
return response
} catch (error) {
lastError = error as Error
attempts++
if (error instanceof FetchError) {
const statusCode = error.response?.status
const statusText = error.response?.statusText || 'Unknown error'
if (statusCode && statusCode >= 500) {
failureCount.value++
lastFailureTime.value = now
}
let v1Error: V1ErrorInfo | undefined
if (error.data?.error && error.data?.description) {
v1Error = {
context: errorContext,
...error.data,
}
}
const errorMessages: { [key: number]: string } = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
408: 'Request Timeout',
429: "You're making requests too quickly. Please wait a moment and try again.",
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
}
const message =
statusCode && statusCode in errorMessages
? errorMessages[statusCode]
: `HTTP Error: ${statusCode || 'unknown'} ${statusText}`
const isRetryable = statusCode ? [408, 429].includes(statusCode) : false
const is5xxRetryable =
statusCode && statusCode >= 500 && statusCode < 600 && method === 'GET' && attempts === 1
if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) {
console.error('Fetch error:', error)
const fetchError = new ModrinthServersFetchError(
`[Modrinth Hosting] ${error.message}`,
statusCode,
error,
)
throw new ModrinthServerError(
`[Modrinth Hosting] ${message}`,
statusCode,
fetchError,
module,
v1Error,
error.data,
)
}
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000
const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000)
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`)
await new Promise((resolve) => setTimeout(resolve, delay))
continue
}
console.error('Unexpected fetch error:', error)
const fetchError = new ModrinthServersFetchError(
'[Modrinth Hosting] An unexpected error occurred during the fetch operation.',
undefined,
error as Error,
)
throw new ModrinthServerError(
'Unexpected error during fetch operation',
undefined,
fetchError,
module,
undefined,
undefined,
)
}
}
console.error('All retry attempts failed:', lastError)
if (lastError instanceof FetchError) {
const statusCode = lastError.response?.status
const pyroError = new ModrinthServersFetchError(
'Maximum retry attempts reached',
statusCode,
lastError,
)
throw new ModrinthServerError(
'Maximum retry attempts reached',
statusCode,
pyroError,
module,
undefined,
lastError.data,
)
}
const fetchError = new ModrinthServersFetchError(
'Maximum retry attempts reached',
undefined,
lastError || undefined,
)
throw new ModrinthServerError(
'Maximum retry attempts reached',
undefined,
fetchError,
module,
undefined,
undefined,
)
}
@@ -17,7 +17,6 @@ export type FilterSelection = {
const cookieDefaults = {
maxAge: TEN_MINUTES,
sameSite: 'lax' as const,
secure: true,
path: '/',
httpOnly: false,
}
@@ -52,13 +51,19 @@ function newFilterSelection(
}
export function useCdnDownloadContext() {
const filterGameVersionCookie = useCookie<string | null>('mr_download_filter_game_version', {
const config = useRuntimeConfig()
const cookieOptions = {
...cookieDefaults,
secure: config.public.cookieSecure,
}
const filterGameVersionCookie = useCookie<string | null>('mr_download_filter_game_version', {
...cookieOptions,
default: () => null,
})
const filterLoaderCookie = useCookie<string | null>('mr_download_filter_loader', {
...cookieDefaults,
...cookieOptions,
default: () => null,
})
+3 -1
View File
@@ -14,6 +14,7 @@ import {
import type { Ref } from 'vue'
import { useFeatureFlags } from '~/composables/featureFlags.ts'
import { withStagingArchonBaseUrl } from '~/helpers/archon.ts'
async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
try {
@@ -37,7 +38,8 @@ export function createModrinthClient(
const clientConfig: NuxtClientConfig = {
labrinthBaseUrl: config.apiBaseUrl,
archonBaseUrl: config.archonBaseUrl,
archonBaseUrl: () =>
withStagingArchonBaseUrl(config.archonBaseUrl, flags.value.archonApiStaging),
archonSentryCapture: () => flags.value.archonSentryCapture,
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
features: [
+12
View File
@@ -0,0 +1,12 @@
export const STAGING_ARCHON_BASE_URL = 'https://staging-archon.modrinth.com/'
export function withStagingArchonBaseUrl(
baseUrl: string,
useStaging = useFeatureFlags().value.archonApiStaging,
) {
if (!useStaging) {
return baseUrl
}
return STAGING_ARCHON_BASE_URL
}
+6 -2
View File
@@ -23,6 +23,10 @@ function copy(id: string) {
navigator.clipboard?.writeText(`/_internal/templates/email/${id}`).catch(() => {})
}
function uncachedPreviewUrl(id: string) {
return `/_internal/templates/email/${id}?preview=${Date.now()}`
}
const previewModal = ref<{ hide: () => void; show: () => void } | null>(null)
const previewTemplate = ref<string | null>(null)
const previewLoading = ref(false)
@@ -73,7 +77,7 @@ async function openPreview(id: string, event?: MouseEvent) {
variableValues.value = {}
try {
const response = await fetch(`/_internal/templates/email/${id}`)
const response = await fetch(uncachedPreviewUrl(id), { cache: 'no-store' })
previewHtml.value = await response.text()
if (!response.ok) {
@@ -103,7 +107,7 @@ function openPopupPreview(id: string, offset = 0) {
const left = window.screenX + (window.outerWidth - width) / 2 + ((offset * 28) % 320)
const top = window.screenY + (window.outerHeight - height) / 2 + ((offset * 28) % 320)
window.open(
`/_internal/templates/email/${id}`,
uncachedPreviewUrl(id),
`email-${id}`,
`popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,menubar=no,toolbar=no,location=no,status=no`,
)
@@ -218,7 +218,7 @@
:level="notice.level"
:message="notice.message"
:dismissable="notice.dismissable"
:title="notice.title"
:title="notice.title ?? undefined"
preview
/>
<div class="mt-4 flex items-center gap-2">
@@ -260,6 +260,7 @@
</div>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { EditIcon, PlusIcon, SaveIcon, SettingsIcon, TrashIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
@@ -267,6 +268,7 @@ import {
commonMessages,
CopyCode,
defineMessages,
injectModrinthClient,
injectNotificationManager,
NewModal,
ServerNotice,
@@ -278,14 +280,13 @@ import {
useVIntl,
} from '@modrinth/ui'
import { NOTICE_LEVELS } from '@modrinth/ui/src/utils/notices.ts'
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import AssignNoticeModal from '~/components/ui/admin/AssignNoticeModal.vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
@@ -297,6 +298,8 @@ const formatDateTimeShortMonth = useFormatDateTime({
dateStyle: 'medium',
})
type ServerNoticeType = Archon.Notices.v0.ListedNotice
const notices = ref<ServerNoticeType[]>([])
const createNoticeModal = ref<InstanceType<typeof NewModal>>()
const assignNoticeModal = ref<InstanceType<typeof AssignNoticeModal>>()
@@ -304,16 +307,14 @@ const assignNoticeModal = ref<InstanceType<typeof AssignNoticeModal>>()
await refreshNotices()
async function refreshNotices() {
await useServersFetch('notices').then((res) => {
notices.value = res as ServerNoticeType[]
notices.value.sort((a, b) => {
const dateDiff = dayjs(b.announce_at).diff(dayjs(a.announce_at))
if (dateDiff === 0) {
return b.id - a.id
}
notices.value = await client.archon.notices_v0.list()
notices.value.sort((a, b) => {
const dateDiff = dayjs(b.announce_at).diff(dayjs(a.announce_at))
if (dateDiff === 0) {
return b.id - a.id
}
return dateDiff
})
return dateDiff
})
}
@@ -347,7 +348,7 @@ function startEditing(notice: ServerNoticeType, assignments: boolean = false) {
newNoticeLevel.value = levelOptions.find((x) => x.id === notice.level) ?? levelOptions[0]
newNoticeDismissable.value = notice.dismissable
newNoticeMessage.value = notice.message
newNoticeTitle.value = notice.title
newNoticeTitle.value = notice.title ?? undefined
newNoticeScheduledDate.value = dayjs(notice.announce_at).format(DATE_TIME_FORMAT)
newNoticeExpiresDate.value = notice.expires
? dayjs(notice.expires).format(DATE_TIME_FORMAT)
@@ -361,9 +362,8 @@ function startEditing(notice: ServerNoticeType, assignments: boolean = false) {
}
async function deleteNotice(notice: ServerNoticeType) {
await useServersFetch(`notices/${notice.id}`, {
method: 'DELETE',
})
await client.archon.notices_v0
.delete(notice.id)
.then(() => {
addNotification({
title: `Successfully deleted notice #${notice.id}`,
@@ -412,9 +412,10 @@ async function saveChanges() {
return
}
await useServersFetch(`notices/${editingNotice.value?.id}`, {
method: 'PATCH',
body: {
if (!editingNotice.value) return
await client.archon.notices_v0
.update(editingNotice.value.id, {
message: newNoticeMessage.value,
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
level: newNoticeLevel.value.id,
@@ -425,14 +426,14 @@ async function saveChanges() {
expires: newNoticeExpiresDate.value
? dayjs(newNoticeExpiresDate.value).toISOString()
: undefined,
},
}).catch((err) => {
addNotification({
title: 'Error saving changes to notice',
text: err,
type: 'error',
})
})
.catch((err) => {
addNotification({
title: 'Error saving changes to notice',
text: err,
type: 'error',
})
})
await refreshNotices()
createNoticeModal.value?.hide()
}
@@ -442,9 +443,8 @@ async function createNotice() {
return
}
await useServersFetch('notices', {
method: 'POST',
body: {
await client.archon.notices_v0
.create({
message: newNoticeMessage.value,
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
level: newNoticeLevel.value.id,
@@ -455,14 +455,14 @@ async function createNotice() {
expires: newNoticeExpiresDate.value
? dayjs(newNoticeExpiresDate.value).toISOString()
: undefined,
},
}).catch((err) => {
addNotification({
title: 'Error creating notice',
text: err,
type: 'error',
})
})
.catch((err) => {
addNotification({
title: 'Error creating notice',
text: err,
type: 'error',
})
})
await refreshNotices()
createNoticeModal.value?.hide()
}
@@ -108,11 +108,13 @@
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { PlusIcon, XCircleIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
ConfirmModal,
injectModrinthClient,
injectNotificationManager,
Pagination,
TagItem,
@@ -124,38 +126,16 @@ import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import TransferModal from '~/components/ui/admin/TransferModal.vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
// Types
interface ProvisionOptions {
region?: string | null
node_tags?: string[]
}
interface TransferBatch {
id: number
created_by: string
created_at: string
reason: string | null
scheduled_at: string
cancelled: boolean
log_count: number
provision_options: ProvisionOptions
}
interface HistoryResponse {
batches: TransferBatch[]
total: number
page: number
page_size: number
}
type TransferBatch = Archon.Transfers.Internal.TransferLogBatchEntry
const transferModal = ref<InstanceType<typeof TransferModal>>()
const cancelModal = ref<InstanceType<typeof ConfirmModal>>()
@@ -178,10 +158,10 @@ async function refreshHistory() {
loading.value = true
error.value = null
try {
const data = await useServersFetch<HistoryResponse>(
`/transfers/history?page=${currentPage.value}&page_size=${pageSize}`,
{ version: 'internal' },
)
const data = await client.archon.transfers_internal.history({
page: currentPage.value,
page_size: pageSize,
})
batches.value = data.batches || []
total.value = data.total || 0
@@ -283,12 +263,8 @@ function showCancelModal(batchId: number) {
async function confirmCancel() {
if (!cancellingBatchId.value) return
try {
await useServersFetch('/transfers/cancel', {
version: 'internal',
method: 'POST',
body: {
batch_ids: [cancellingBatchId.value],
},
await client.archon.transfers_internal.cancel({
batch_ids: [cancellingBatchId.value],
})
addNotification({
title: 'Transfer cancelled',
@@ -72,6 +72,7 @@
}}
<Tooltip
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
no-auto-focus
:aria-id="`${baseId}-date-segment-tooltip-${i}`"
@@ -106,6 +107,7 @@
{{ formatMessage(messages.processing) }}
<Tooltip
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
no-auto-focus
:aria-id="`${baseId}-processing-tooltip`"
@@ -11,6 +11,7 @@
:auth-user="authUser"
:navigate-to-billing="() => router.push('/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
constrain-width
:browse-modpacks="
({ serverId: sid, worldId: wid, from }) => {
navigateTo({
@@ -0,0 +1,82 @@
<script setup lang="ts">
import {
injectModrinthClient,
injectModrinthServerContext,
ServersManageAccessPage,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const client = injectModrinthClient()
const { server, serverId } = injectModrinthServerContext()
const queryClient = useQueryClient()
const flags = useFeatureFlags()
const ACTION_LOG_PAGE_SIZE = 200
const ACTION_LOG_SORT_DIRECTION = 'desc'
const actionLogDateFilter = defaultActionLogDateFilter()
await Promise.allSettled([
queryClient.ensureQueryData({
queryKey: ['servers', 'users', 'v1', serverId],
queryFn: () => client.archon.server_users_v1.list(serverId),
staleTime: 30_000,
}),
queryClient.ensureQueryData({
queryKey: ['servers', 'v1', 'detail', serverId],
queryFn: () => client.archon.servers_v1.get(serverId),
staleTime: 30_000,
}),
queryClient.prefetchInfiniteQuery({
queryKey: [
'servers',
'action-log',
'v1',
'infinite',
serverId,
null,
actionLogDateFilter.min_datetime,
actionLogDateFilter.max_datetime,
ACTION_LOG_SORT_DIRECTION,
],
queryFn: ({ pageParam = 0 }) => {
const offset = typeof pageParam === 'number' ? pageParam : 0
return client.archon.actions_v1.list(serverId, {
limit: ACTION_LOG_PAGE_SIZE,
offset,
order: ACTION_LOG_SORT_DIRECTION,
...actionLogDateFilter,
})
},
getNextPageParam: (lastPage) =>
typeof lastPage.next_offset === 'number' ? lastPage.next_offset : undefined,
initialPageParam: 0,
staleTime: 30_000,
}),
])
useHead({
title: computed(() => `Access - ${server.value?.name ?? 'Server'} - Modrinth`),
})
function defaultActionLogDateFilter() {
const endDate = new Date()
const startDate = new Date(endDate)
startDate.setDate(startDate.getDate() - 6)
return {
min_datetime: startOfDay(startDate).toISOString(),
max_datetime: endOfDay(endDate).toISOString(),
}
}
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
function endOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999)
}
</script>
<template>
<ServersManageAccessPage :show-audit-log-instances="flags.showHostingAccessInstanceAuditLog" />
</template>
+2 -1
View File
@@ -27,10 +27,11 @@ export interface Cosmetics {
export default defineNuxtPlugin({
name: 'cosmetics',
setup() {
const config = useRuntimeConfig()
const cosmetics = useCookie<Cosmetics>('cosmetics', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
default: () => ({
@@ -8,10 +8,11 @@ interface ThemeSettings {
export function useThemeSettings(getDefaultTheme?: () => Theme) {
getDefaultTheme ??= () => 'dark'
const config = useRuntimeConfig()
const $settings = useCookie<ThemeSettings>('color-mode', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

@@ -1,5 +1,12 @@
{
"articles": [
{
"title": "Manage servers together",
"summary": "Add other users to your server, assign roles, and track whats changed.",
"thumbnail": "https://modrinth.com/news/article/server-access/thumbnail.webp",
"date": "2026-06-03T20:10:28.823Z",
"link": "https://modrinth.com/news/article/server-access"
},
{
"title": "Pride 2026 Fundraiser: Matching up to $10,000",
"summary": "Celebrating our community and working together to make a difference.",
+9 -1
View File
@@ -4,9 +4,17 @@
<description><![CDATA[Keep up-to-date on the latest news from Modrinth.]]></description>
<link>https://modrinth.com/news/</link>
<generator>@modrinth/blog</generator>
<lastBuildDate>Sun, 31 May 2026 23:20:41 GMT</lastBuildDate>
<lastBuildDate>Wed, 03 Jun 2026 21:05:41 GMT</lastBuildDate>
<atom:link href="https://modrinth.com/news/feed/rss.xml" rel="self" type="application/rss+xml"/>
<language><![CDATA[en]]></language>
<item>
<title><![CDATA[Manage servers together]]></title>
<description><![CDATA[Add other users to your server, assign roles, and track whats changed.]]></description>
<link>https://modrinth.com/news/article/server-access/</link>
<guid isPermaLink="false">https://modrinth.com/news/article/server-access/</guid>
<pubDate>Wed, 03 Jun 2026 20:10:28 GMT</pubDate>
<content:encoded>&lt;![CDATA[&lt;p&gt;Hey everyone,&lt;/p&gt;&lt;p&gt;With this release, you can now give other users access to your server! This has been one of the most requested features for Modrinth Hosting and were excited to finally get it out.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;/news/article/server-access/server-access.webp&quot; alt=&quot;The new Access tab in the Modrinth Hosting panel, featuring a list of invited users and their permissions, invite new users, and an activity log to see what changes are being made to your server and by whom.&quot;&gt;&lt;/p&gt;&lt;h2&gt;TL;DR&lt;/h2&gt;&lt;ul&gt;&lt;li&gt;Add users to your server&lt;/li&gt;&lt;li&gt;Set permission roles&lt;/li&gt;&lt;li&gt;View activity log&lt;/li&gt;&lt;/ul&gt;&lt;h2&gt;Invite your friends&lt;/h2&gt;&lt;p&gt;You can now give other users access to your server so they can help manage content, start the server, and more. To invite someone, just enter their Modrinth username and theyll receive an invite by email or as a notification in the app if theyre signed in.&lt;/p&gt;&lt;p&gt;Alongside this release, weve also improved state syncing between the website panel, app, and other users, so everything should stay up to date in real time.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;/news/article/server-access/add-user-modal.webp&quot; alt=&quot;A pop-up modal for adding a user to your server. Search by Modrinth username, select their role (editor or limited), and an option to also send them a friend request.&quot;&gt;&lt;/p&gt;&lt;h2&gt;Permission roles&lt;/h2&gt;&lt;p&gt;When adding someone to your server, you can choose what level of access they have. There are three roles, with each role inheriting the permissions of the previous one:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Owner:&lt;/strong&gt; Full access to the server including billing (you)&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Editor:&lt;/strong&gt; Manage content, files, backups, settings, and more&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Limited:&lt;/strong&gt; Start, stop, and view the server without making changes&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;You can find a full permission breakdown below:&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Permission&lt;/th&gt;&lt;th&gt;Owner&lt;/th&gt;&lt;th&gt;Editor&lt;/th&gt;&lt;th&gt;Limited&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Start / stop server&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Execute commands&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Edit settings&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Edit installation&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Manage content&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Manage files&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Create &amp;amp; restore backups&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Invite users&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Reset server&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Manage billing&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;h2&gt;See what changed&lt;/h2&gt;&lt;p&gt;Along with adding users, weve introduced an activity log. This is a chronological history of actions related to your server so you can see what changed, who changed it, and when it happened. Some actions are grouped together, like updating multiple projects at once, to keep things easier to read.&lt;/p&gt;&lt;p&gt;You can select a time timeframe and filter by user or action type if youre looking for something specific.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;/news/article/server-access/activity-log.webp&quot; alt=&quot;The activity log section of the Access tab, where you can see the user that performed an action on the left column, the action that was performed in the center, and the time it happened on the right.&quot;&gt;&lt;/p&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;Thank you for your continued support! 💚&lt;/p&gt;]]&gt;</content:encoded>
</item>
<item>
<title><![CDATA[Pride 2026 Fundraiser: Matching up to $10,000]]></title>
<description><![CDATA[Celebrating our community and working together to make a difference.]]></description>
@@ -1,10 +1,11 @@
import type { InferredClientModules } from '../modules'
import { buildModuleStructure } from '../modules'
import type { ClientConfig } from '../types/client'
import type { BaseUrlConfig, ClientConfig } from '../types/client'
import type { RequestContext, RequestOptions } from '../types/request'
import type { UploadMetadata, UploadProgress, UploadRequestOptions } from '../types/upload'
import type { AbstractFeature } from './abstract-feature'
import type { AbstractModule } from './abstract-module'
import type { AbstractSyncClient } from './abstract-sync'
import { AbstractUploadClient } from './abstract-upload-client'
import type { AbstractWebSocketClient } from './abstract-websocket'
import { ModrinthApiError, ModrinthServerError } from './errors'
@@ -32,7 +33,10 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
private _moduleNamespaces: Map<string, Record<string, AbstractModule>> = new Map()
public readonly labrinth!: InferredClientModules['labrinth']
public readonly archon!: ArchonClientModules & { sockets: AbstractWebSocketClient }
public readonly archon!: ArchonClientModules & {
sockets: AbstractWebSocketClient
sync: AbstractSyncClient
}
public readonly kyros!: InferredClientModules['kyros']
public readonly iso3166!: InferredClientModules['iso3166']
public readonly mclogs!: InferredClientModules['mclogs']
@@ -116,9 +120,9 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
async request<T>(path: string, options: RequestOptions): Promise<T> {
let baseUrl: string
if (options.api === 'labrinth') {
baseUrl = this.config.labrinthBaseUrl!
baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!)
} else if (options.api === 'archon') {
baseUrl = this.config.archonBaseUrl!
baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!)
} else {
baseUrl = options.api
}
@@ -160,13 +164,55 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
}
}
async stream(path: string, options: RequestOptions): Promise<ReadableStream<Uint8Array>> {
let baseUrl: string
if (options.api === 'labrinth') {
baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!)
} else if (options.api === 'archon') {
baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!)
} else {
baseUrl = options.api
}
const url = this.buildUrl(path, baseUrl, options.version)
const defaultHeaders = await this.buildDefaultHeaders()
const mergedOptions: RequestOptions = {
method: 'GET',
retry: false,
circuitBreaker: false,
...options,
headers: {
...defaultHeaders,
Accept: 'text/event-stream',
...options.headers,
},
}
this.attachArchonSentryCaptureHeader(mergedOptions)
const context = this.buildContext(url, path, mergedOptions)
try {
return await this.executeFeatureChain<ReadableStream<Uint8Array>>(context, () =>
this.executeStreamRequest(context.url, context.options),
)
} catch (error) {
const apiError = this.normalizeError(error, context)
await this.config.hooks?.onError?.(apiError, context)
throw apiError
}
}
/**
* Execute the feature chain and the actual request
*
* Features are executed in order, with each feature calling next() to continue.
* The last "feature" in the chain is the actual request execution.
*/
protected async executeFeatureChain<T>(context: RequestContext): Promise<T> {
protected async executeFeatureChain<T>(
context: RequestContext,
executeTerminal: () => Promise<T> = () => this.executeRequest<T>(context.url, context.options),
): Promise<T> {
// Filter to only features that should apply
const applicableFeatures = this.features.filter((feature) => feature.shouldApply(context))
@@ -184,7 +230,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
} else {
// We've reached the end of the chain, execute the actual request
await this.config.hooks?.onRequest?.(context)
return this.executeRequest<T>(context.url, context.options)
return executeTerminal()
}
}
@@ -243,6 +289,10 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
return `${base}${versionPath}${cleanPath}`
}
protected resolveBaseUrl(baseUrl: BaseUrlConfig): string {
return typeof baseUrl === 'function' ? baseUrl() : baseUrl
}
/**
* Build the request context
*/
@@ -354,6 +404,11 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
*/
protected abstract executeRequest<T>(url: string, options: RequestOptions): Promise<T>
protected abstract executeStreamRequest(
url: string,
options: RequestOptions,
): Promise<ReadableStream<Uint8Array>>
/**
* Execute the actual XHR upload
*
@@ -0,0 +1,167 @@
import type mitt from 'mitt'
import type { Archon } from '../modules/archon/types'
import type { RequestOptions } from '../types/request'
export type SyncEventType = Archon.Sync.v1.SyncEvent['type']
export type SyncEventOfType<E extends SyncEventType> = Extract<
Archon.Sync.v1.SyncEvent,
{ type: E }
>
export type SyncEventHandler<E extends Archon.Sync.v1.SyncEvent = Archon.Sync.v1.SyncEvent> = (
event: E,
) => void
export type SyncStatusState =
| 'idle'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'disconnected'
| 'error'
export type SyncStatus = {
state: SyncStatusState
connected: boolean
reconnecting: boolean
reconnectAttempts: number
retryDelay: number
lastEventId?: string
error?: unknown
}
export type SyncStatusHandler = (status: SyncStatus) => void
export type SyncConnectOptions = {
intent?: Archon.Sync.v1.SyncIntent
force?: boolean
}
export type SyncConnection = {
serverId: string
intent: Archon.Sync.v1.SyncIntent
controller?: AbortController
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
reconnectResolve?: () => void
retryDelay: number
lastEventId?: string
stopped: boolean
status: SyncStatusState
error?: unknown
}
export type SyncEmitterEvents = Record<string, unknown>
export abstract class AbstractSyncClient {
protected connections = new Map<string, SyncConnection>()
protected abstract emitter: ReturnType<typeof mitt<SyncEmitterEvents>>
constructor(
protected client: {
stream: (path: string, options: RequestOptions) => Promise<ReadableStream<Uint8Array>>
},
) {}
abstract safeConnectServer(serverId: string, options?: SyncConnectOptions): Promise<void>
abstract disconnect(serverId: string): void
abstract disconnectAll(): void
on<E extends SyncEventType>(
serverId: string,
eventType: E,
handler: SyncEventHandler<SyncEventOfType<E>>,
): () => void {
const eventKey = this.getEventKey(serverId, eventType)
const wrapped = handler as (event: unknown) => void
this.emitter.on(eventKey, wrapped)
return () => {
this.emitter.off(eventKey, wrapped)
}
}
onAny(serverId: string, handler: SyncEventHandler): () => void {
const eventKey = this.getAnyEventKey(serverId)
const wrapped = handler as (event: unknown) => void
this.emitter.on(eventKey, wrapped)
return () => {
this.emitter.off(eventKey, wrapped)
}
}
onStatus(serverId: string, handler: SyncStatusHandler): () => void {
const eventKey = this.getStatusEventKey(serverId)
const wrapped = handler as (event: unknown) => void
this.emitter.on(eventKey, wrapped)
return () => {
this.emitter.off(eventKey, wrapped)
}
}
getStatus(serverId: string): SyncStatus | null {
const connection = this.connections.get(serverId)
if (!connection) return null
return this.connectionToStatus(connection)
}
protected emitSyncEvent(serverId: string, event: Archon.Sync.v1.SyncEvent): void {
this.emitter.emit(this.getEventKey(serverId, event.type), event)
this.emitter.emit(this.getAnyEventKey(serverId), event)
}
protected updateStatus(
connection: SyncConnection,
status: SyncStatusState,
error?: unknown,
): void {
connection.status = status
connection.error = error
this.emitter.emit(
this.getStatusEventKey(connection.serverId),
this.connectionToStatus(connection),
)
}
protected clearListeners(serverId: string): void {
this.emitter.all.forEach((_handlers, type) => {
if (type.toString().startsWith(`${serverId}:`)) {
this.emitter.all.delete(type)
}
})
}
protected connectionToStatus(connection: SyncConnection): SyncStatus {
return {
state: connection.status,
connected: connection.status === 'connected',
reconnecting: connection.status === 'reconnecting',
reconnectAttempts: connection.reconnectAttempts,
retryDelay: connection.retryDelay,
lastEventId: connection.lastEventId,
error: connection.error,
}
}
private getEventKey(serverId: string, eventType: string): string {
return `${serverId}:${eventType}`
}
private getAnyEventKey(serverId: string): string {
return `${serverId}:*`
}
private getStatusEventKey(serverId: string): string {
return `${serverId}:__status`
}
}
+19
View File
@@ -1,5 +1,16 @@
export { AbstractModrinthClient } from './core/abstract-client'
export { AbstractFeature, type FeatureConfig } from './core/abstract-feature'
export {
AbstractSyncClient,
type SyncConnection,
type SyncConnectOptions,
type SyncEventHandler,
type SyncEventOfType,
type SyncEventType,
type SyncStatus,
type SyncStatusHandler,
type SyncStatusState,
} from './core/abstract-sync'
export { AbstractUploadClient } from './core/abstract-upload-client'
export {
AbstractWebSocketClient,
@@ -25,10 +36,18 @@ export * from './modules/types'
export { GenericModrinthClient } from './platform/generic'
export type { NuxtClientConfig } from './platform/nuxt'
export { NuxtCircuitBreakerStorage, NuxtModrinthClient } from './platform/nuxt'
export { GenericSyncClient } from './platform/sync-generic'
export type { TauriClientConfig } from './platform/tauri'
export { TauriModrinthClient } from './platform/tauri'
export { XHRUploadClient } from './platform/xhr-upload-client'
export { clearNodeAuthState, nodeAuthState, setNodeAuthState } from './state/node-auth'
export * from './types'
export { withJWTRetry } from './utils/jwt-retry'
export {
type ParsedSseEvent,
type ParsedSseItem,
type ParsedSseRetry,
parseSyncEventData,
SseParser,
} from './utils/sse'
export type { Override, RawDecimal } from './utils/types'
@@ -0,0 +1,35 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonActionsV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_actions_v1'
}
/**
* Get server action log entries.
* GET /v1/servers/:server_id/action-log
*/
public async list(
serverId: string,
options: Archon.Actions.v1.ListActionLogOptions = {},
): Promise<Archon.Actions.v1.ActionLogResponse> {
const params: Record<string, string | number> = {}
if (options.filter) params.filter = JSON.stringify(options.filter)
if (options.limit !== undefined) params.limit = options.limit
if (options.offset !== undefined) params.offset = options.offset
if (options.order !== undefined) params.order = options.order
if (options.min_datetime !== undefined) params.min_datetime = options.min_datetime
if (options.max_datetime !== undefined) params.max_datetime = options.max_datetime
return this.client.request<Archon.Actions.v1.ActionLogResponse>(
`/servers/${serverId}/action-log`,
{
api: 'archon',
version: 1,
method: 'GET',
params: Object.keys(params).length > 0 ? params : undefined,
},
)
}
}
@@ -1,3 +1,4 @@
export * from './actions/v1'
export * from './backups/v1'
export * from './backups-queue/v1'
export * from './content/v1'
@@ -0,0 +1,20 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonNodesInternalModule extends AbstractModule {
public getModuleID(): string {
return 'archon_nodes_internal'
}
/**
* Get node hostnames and region summary for admin tooling.
* GET /_internal/nodes/overview
*/
public async overview(): Promise<Archon.Nodes.Internal.Overview> {
return this.client.request<Archon.Nodes.Internal.Overview>('/nodes/overview', {
api: 'archon',
version: 'internal',
method: 'GET',
})
}
}
@@ -0,0 +1,98 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonNoticesV0Module extends AbstractModule {
public getModuleID(): string {
return 'archon_notices_v0'
}
/**
* Get all server notices.
* GET /modrinth/v0/notices
*/
public async list(): Promise<Archon.Notices.v0.ListedNotice[]> {
return this.client.request<Archon.Notices.v0.ListedNotice[]>('/notices', {
api: 'archon',
version: 'modrinth/v0',
method: 'GET',
})
}
/**
* Create a server notice.
* POST /modrinth/v0/notices
*/
public async create(
request: Archon.Notices.v0.Announce,
): Promise<Archon.Notices.v0.PostNoticeResponseBody> {
return this.client.request<Archon.Notices.v0.PostNoticeResponseBody>('/notices', {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
body: request,
})
}
/**
* Update a server notice.
* PATCH /modrinth/v0/notices/:id
*/
public async update(id: number, request: Archon.Notices.v0.AnnouncePatch): Promise<void> {
await this.client.request(`/notices/${id}`, {
api: 'archon',
version: 'modrinth/v0',
method: 'PATCH',
body: request,
})
}
/**
* Delete a server notice.
* DELETE /modrinth/v0/notices/:id
*/
public async delete(id: number): Promise<void> {
await this.client.request(`/notices/${id}`, {
api: 'archon',
version: 'modrinth/v0',
method: 'DELETE',
})
}
/**
* Assign a notice to a server or node.
* PUT /modrinth/v0/notices/:id/assign?server=:serverId
* PUT /modrinth/v0/notices/:id/assign?node=:nodeId
*/
public async assign(id: number, target: Archon.Notices.v0.AssignmentTarget): Promise<void> {
await this.client.request(`/notices/${id}/assign`, {
api: 'archon',
version: 'modrinth/v0',
method: 'PUT',
params: this.assignmentTargetToParams(target),
})
}
/**
* Unassign a notice from a server or node.
* PUT /modrinth/v0/notices/:id/unassign?server=:serverId
* PUT /modrinth/v0/notices/:id/unassign?node=:nodeId
*/
public async unassign(id: number, target: Archon.Notices.v0.AssignmentTarget): Promise<void> {
await this.client.request(`/notices/${id}/unassign`, {
api: 'archon',
version: 'modrinth/v0',
method: 'PUT',
params: this.assignmentTargetToParams(target),
})
}
private assignmentTargetToParams(
target: Archon.Notices.v0.AssignmentTarget,
): Record<string, string> {
if ('server' in target) {
return { server: target.server }
}
return { node: target.node }
}
}
@@ -0,0 +1,83 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonServerUsersV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_server_users_v1'
}
/**
* Get list of users with access to a server
* GET /v1/servers/:server_id/users
*/
public async list(serverId: string): Promise<Archon.ServerUsers.v1.ServerUser[]> {
return this.client.request<Archon.ServerUsers.v1.ServerUser[]>(`/servers/${serverId}/users`, {
api: 'archon',
version: 1,
method: 'GET',
})
}
/**
* Add a user to a server
* POST /v1/servers/:server_id/users
*/
public async add(
serverId: string,
user: Archon.ServerUsers.v1.AddServerUserRequest,
): Promise<void> {
await this.client.request(`/servers/${serverId}/users`, {
api: 'archon',
version: 1,
method: 'POST',
body: user,
})
}
/**
* Re-send an invite to a pending server user.
* POST /v1/servers/:server_id/users/:user_id/reinvite
*/
public async reinvite(
serverId: string,
userId: string,
): Promise<Archon.ServerUsers.v1.ReinviteResponse> {
return this.client.request<Archon.ServerUsers.v1.ReinviteResponse>(
`/servers/${serverId}/users/${userId}/reinvite`,
{
api: 'archon',
version: 1,
method: 'POST',
},
)
}
/**
* Remove a user from a server
* DELETE /v1/servers/:server_id/users/:user_id
*/
public async delete(serverId: string, userId: string): Promise<void> {
await this.client.request(`/servers/${serverId}/users/${userId}`, {
api: 'archon',
version: 1,
method: 'DELETE',
})
}
/**
* Update a user's server role
* PATCH /v1/servers/:server_id/users/:user_id
*/
public async update(
serverId: string,
userId: string,
role: Archon.ServerUsers.v1.AssignableServerUserRole,
): Promise<void> {
await this.client.request(`/servers/${serverId}/users/${userId}`, {
api: 'archon',
version: 1,
method: 'PATCH',
body: JSON.stringify(role),
})
}
}
@@ -0,0 +1,84 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonTransfersInternalModule extends AbstractModule {
public getModuleID(): string {
return 'archon_transfers_internal'
}
/**
* Schedule transfers for specific servers.
* POST /_internal/transfers/schedule/servers
*/
public async scheduleServers(
request: Archon.Transfers.Internal.ScheduleServerTransfersRequest,
): Promise<Archon.Transfers.Internal.ScheduleTransfersResponse> {
return this.client.request<Archon.Transfers.Internal.ScheduleTransfersResponse>(
'/transfers/schedule/servers',
{
api: 'archon',
version: 'internal',
method: 'POST',
body: request,
},
)
}
/**
* Schedule transfers for all servers on specific nodes.
* POST /_internal/transfers/schedule/nodes
*/
public async scheduleNodes(
request: Archon.Transfers.Internal.ScheduleNodeTransfersRequest,
): Promise<Archon.Transfers.Internal.ScheduleTransfersResponse> {
return this.client.request<Archon.Transfers.Internal.ScheduleTransfersResponse>(
'/transfers/schedule/nodes',
{
api: 'archon',
version: 'internal',
method: 'POST',
body: request,
},
)
}
/**
* Get transfer batch history.
* GET /_internal/transfers/history
*/
public async history(
options?: Archon.Transfers.Internal.TransferHistoryQuery,
): Promise<Archon.Transfers.Internal.TransferHistoryResponse> {
const params: Record<string, number> = {}
if (options?.page !== undefined) params.page = options.page
if (options?.page_size !== undefined) params.page_size = options.page_size
return this.client.request<Archon.Transfers.Internal.TransferHistoryResponse>(
'/transfers/history',
{
api: 'archon',
version: 'internal',
method: 'GET',
params,
},
)
}
/**
* Cancel pending transfer batches.
* POST /_internal/transfers/cancel
*/
public async cancel(
request: Archon.Transfers.Internal.CancelTransfersRequest,
): Promise<Archon.Transfers.Internal.CancelTransfersResponse> {
return this.client.request<Archon.Transfers.Internal.CancelTransfersResponse>(
'/transfers/cancel',
{
api: 'archon',
version: 'internal',
method: 'POST',
body: request,
},
)
}
}
+449 -4
View File
@@ -1,6 +1,287 @@
import type { Labrinth } from '../labrinth/types'
export namespace Archon {
export namespace Nodes {
export namespace Internal {
export type Node = {
id: string
hostname: string
region: string
created_at: string | null
locked: boolean
}
export type Server = {
id: string
available: boolean
}
export type NodeFull = Node & {
servers: Server[]
}
export type Overview = {
node_hostnames: string[]
regions: Region[]
total_servers_active: number
}
export type Region = {
display_name: string
country_code: string
key: string
server_count: number
node_count: number
}
export type RegionWithStatistics = {
region: Region
active_servers: string[]
}
}
}
export namespace Notices {
export namespace v0 {
export type Notice = {
id: number
dismissable: boolean
title: string | null
message: string
level: string
announced: string
}
export type ListedNotice = {
id: number
dismissable: boolean
message: string
title: string | null
level: string
announce_at: string
expires: string | null
assigned: Assignment[]
dismissed_by: Dismisser[]
}
export type Dismisser = {
server: string
dismissed_on: string
}
export type Assignment = {
kind: string
id: string
name: string
}
export type AssignmentTarget = { server: string } | { node: string }
export type Announce = {
message: string
title?: string | null
level: string
dismissable: boolean
announce_at: string
expires?: string | null
}
export type AnnouncePatch = {
message?: string
title?: string | null
level?: string
dismissable?: boolean
announce_at?: string
expires?: string | null
}
export type PostNoticeResponseBody = {
id: number
}
}
}
export namespace Actions {
export namespace v1 {
export type SortOrder = 'asc' | 'desc'
export type ActionName =
| 'server_created'
| 'changed_server_name'
| 'changed_server_subdomain'
| 'server_reallocated'
| 'server_plan_changed'
| 'user_invited'
| 'user_invite_revoked'
| 'user_permission_modified'
| 'user_removed'
| 'addon_added'
| 'addon_uploaded'
| 'addon_disabled'
| 'addon_enabled'
| 'addon_deleted'
| 'addon_updated'
| 'modpack_changed'
| 'modpack_unlinked'
| 'server_repaired'
| 'server_reset'
| 'server_started'
| 'server_stopped'
| 'server_restarted'
| 'server_killed'
| 'port_allocation_added'
| 'port_allocation_removed'
| 'loader_version_edited'
| 'game_version_edited'
| 'server_properties_modified'
| 'file_uploaded'
| 'file_deleted'
| 'file_renamed'
| 'file_edited'
| 'sftp_login'
| 'console_command_executed'
| 'console_cleared'
| 'backup_created'
| 'backup_renamed'
| 'backup_restored'
| 'backup_deleted'
| 'startup_command_modified'
| 'java_runtime_modified'
| 'java_version_modified'
export type Action = {
action: ActionName | string
metadata?: unknown
}
export type UserPermissionsActionMetadata = {
user_id: string
permissions?: ServerUsers.v1.UserScope | null
}
export type ActionUser =
| {
type: 'user'
user_id: string
}
| {
type: 'support'
user_id?: string | null
}
export type ActionEntry = {
actor: ActionUser
action: Action
server_id: string
world_id?: string | null
timestamp: string
}
export type UserResp = {
username: string
avatar_url?: string | null
}
export type AddonResp = {
title: string
slug?: string | null
icon_url?: string | null
version?: string | null
}
export type VersionResp = {
name: string
version_number?: string | null
}
export type ActionLogResponse = {
next_offset?: number | null
data: ActionEntry[]
users: Record<string, UserResp>
addons: Record<string, AddonResp>
versions: Record<string, VersionResp>
}
export type ActionLogFilter = {
users?: string[]
worlds?: Array<string | null>
actions?: ActionName[]
}
export type ListActionLogOptions = {
filter?: ActionLogFilter
limit?: number
offset?: number
order?: SortOrder
min_datetime?: string
max_datetime?: string
}
}
}
export namespace Transfers {
export namespace Internal {
export type ProvisionOptions = {
region?: string | null
node_tags: string[]
}
export type ScheduleServerTransfersRequest = {
server_ids: string[]
scheduled_at?: string | null
target_region?: string | null
node_tags?: string[]
reason?: string | null
}
export type ScheduleNodeTransfersRequest = {
node_hostnames: string[]
scheduled_at?: string | null
target_region?: string | null
node_tags?: string[]
reason?: string | null
cordon_nodes?: boolean
tag_nodes?: string | null
}
export type ScheduleTransfersResponse = {
batch_id: number
scheduled_count: number
}
export type CancelTransfersRequest = {
batch_ids: number[]
}
export type CancelTransfersResponse = {
cancelled_count: number
}
export type TransferLogBatchEntry = {
id: number
created_by: string
created_at: string
reason?: string | null
scheduled_at: string
cancelled: boolean
log_count: number
provision_options: ProvisionOptions
}
export type TransferHistoryQuery = {
page?: number
page_size?: number
}
export type TransferHistoryResponse = {
batches: TransferLogBatchEntry[]
total: number
page: number
page_size: number
}
}
}
export namespace Content {
export namespace v1 {
export type AddonKind = 'mod' | 'plugin' | 'datapack' | 'shader' | 'resourcepack'
@@ -222,11 +503,66 @@ export namespace Archon {
}
}
export namespace ServerUsers {
export namespace v1 {
export type ServerUserRole = 'Owner' | 'Editor' | 'Viewer' | 'Unknown'
export type AssignableServerUserRole = Exclude<ServerUserRole, 'Owner' | 'Unknown'>
export const UserScope = {
NONE: '',
SERVER_ADMIN: 'SERVER_ADMIN',
BASE_READ: 'BASE_READ',
POWER_ACTIONS: 'POWER_ACTIONS',
EXEC_COMMANDS: 'EXEC_COMMANDS',
FILES_WRITE: 'FILES_WRITE',
SETUP: 'SETUP',
BACKUPS: 'BACKUPS',
ADVANCED: 'ADVANCED',
RESET_SERVER: 'RESET_SERVER',
MANAGE_USERS: 'MANAGE_USERS',
SUPPORT_AGENT: 'SUPPORT_AGENT',
INFRA_MANAGER: 'INFRA_MANAGER',
INFRA_MANAGER_READ: 'INFRA_MANAGER_READ',
INFRA_SERVERS_XFER: 'INFRA_SERVERS_XFER',
INFRA_USERS: 'INFRA_USERS',
} as const
export type UserScope = string | number
export type UserResp = {
id: string
username: string
avatar_url?: string | null
}
export type ServerUser = {
user: UserResp
added_on?: string | null
last_invite_sent?: string | null
permissions: UserScope
}
export type AddServerUserRequest = {
server_id?: string | null
user_id: string
added_on?: string | null
role: ServerUserRole
}
export type ReinviteResponse = {
sent: boolean
cooldown_seconds: number | null
}
}
}
export namespace Servers {
export namespace v0 {
export type ServerGetResponse = {
servers: Server[]
pagination: Pagination
users: Record<string, ServerOwner>
}
export type Pagination = {
@@ -236,6 +572,12 @@ export namespace Archon {
total_items: number
}
export type ServerOwner = {
id: string
username: string
avatar_url?: string | null
}
export type Status = 'installing' | 'broken' | 'available' | 'suspended'
export type SuspensionReason =
@@ -281,12 +623,15 @@ export namespace Archon {
node: NodeInfo | null
flows: Flows
is_medal: boolean
current_user_permissions: UserScope
medal_expires?: string
}
export type UserScope = number
export type Net = {
ip: string
ip: string | null
port: number
domain: string
}
@@ -422,9 +767,9 @@ export namespace Archon {
modloader: string
modloader_version: string
game_version: string
java_version: number
invocation: string
original_invocation: string
java_version: number | null
invocation: string | null
original_invocation: string | null
}
export type Region = {
@@ -555,6 +900,106 @@ export namespace Archon {
}
}
export namespace Sync {
export namespace v1 {
export type SyncCategory = 'backup' | 'users' | 'server' | 'protocol' | 'world'
export type SyncIntent = 'all' | SyncCategory | SyncCategory[]
export type BackupOperationStatus = 'completed' | 'cancelled' | 'failed' | 'timed-out'
export type ServerNetworkPort = { port: number; name: string }
export type ProtocolResetEvent = { type: 'protocol.reset' }
export type ProtocolInvalidEvent = { type: 'protocol.invalid' }
export type ProtocolErrorEvent = { type: 'protocol.error'; error: string }
export type BackupNewEvent = { type: 'backup.new'; id: string }
export type BackupPatchEvent = {
type: 'backup.patch'
world_id: string
backup_id: string
name: string
}
export type BackupDeleteEvent = {
type: 'backup.delete'
world_id: string
backup_id: string
}
export type BackupOperationStartEvent = {
type:
| 'backup.operation.create.init'
| 'backup.operation.create.start'
| 'backup.operation.restore.init'
| 'backup.operation.restore.start'
world_id: string
backup_id: string
operation_id: number
}
export type BackupOperationDoneEvent = {
type: 'backup.operation.create.done' | 'backup.operation.restore.done'
world_id: string
backup_id: string
operation_id: number
status: BackupOperationStatus
}
export type ServerPatchEvent = {
type: 'server.patch'
name: string
subdomain: string
}
export type ServerNetworkPatchEvent = {
type: 'server.network.patch'
ports: ServerNetworkPort[]
}
export type ServerTransferEvent = {
type: 'server.transfer.start' | 'server.transfer.done'
target_node: string
}
export type UsersPatchEvent = { type: 'users.patch' }
export type WorldPatchEvent = {
type: 'world.patch'
world_id: string
name: string
}
export type WorldStartupPatchEvent = {
type: 'world.startup.patch'
world_id: string
java_version: number | null
invocation: string | null
original_invocation: string | null
}
export type WorldContentAddonPatchEvent = {
type: 'world.content.addon.patch'
world_id: string
specs: Archon.Content.v1.Addon[]
}
export type WorldContentBaseUpdateEvent = {
type: 'world.content.base.update'
world_id: string
spec: Archon.Content.v1.Addons
}
export type SyncEvent =
| ProtocolResetEvent
| ProtocolInvalidEvent
| ProtocolErrorEvent
| BackupNewEvent
| BackupPatchEvent
| BackupDeleteEvent
| BackupOperationStartEvent
| BackupOperationDoneEvent
| ServerPatchEvent
| ServerNetworkPatchEvent
| ServerTransferEvent
| UsersPatchEvent
| WorldPatchEvent
| WorldStartupPatchEvent
| WorldContentAddonPatchEvent
| WorldContentBaseUpdateEvent
}
}
export namespace Websocket {
export namespace v0 {
export type WSAuth = {
+12
View File
@@ -1,12 +1,17 @@
import type { AbstractModrinthClient } from '../core/abstract-client'
import type { AbstractModule } from '../core/abstract-module'
import { ArchonActionsV1Module } from './archon/actions/v1'
import { ArchonBackupsV1Module } from './archon/backups/v1'
import { ArchonBackupsQueueV1Module } from './archon/backups-queue/v1'
import { ArchonContentV1Module } from './archon/content/v1'
import { ArchonNodesInternalModule } from './archon/nodes/internal'
import { ArchonNoticesV0Module } from './archon/notices/v0'
import { ArchonOptionsV1Module } from './archon/options/v1'
import { ArchonPropertiesV1Module } from './archon/properties/v1'
import { ArchonServerUsersV1Module } from './archon/server-users/v1'
import { ArchonServersV0Module } from './archon/servers/v0'
import { ArchonServersV1Module } from './archon/servers/v1'
import { ArchonTransfersInternalModule } from './archon/transfers/internal'
import { ISO3166Module } from './iso3166'
import { KyrosContentV1Module } from './kyros/content/v1'
import { KyrosFilesV0Module } from './kyros/files/v0'
@@ -21,6 +26,7 @@ import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
import { LabrinthCampaignInternalModule } from './labrinth/campaign/internal'
import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal'
import { LabrinthFriendsV3Module } from './labrinth/friends/v3'
import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal'
import { LabrinthLimitsV3Module } from './labrinth/limits/v3'
import { LabrinthModerationInternalModule } from './labrinth/moderation/internal'
@@ -61,13 +67,18 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
* TODO: Better way? Probably not
*/
export const MODULE_REGISTRY = {
archon_actions_v1: ArchonActionsV1Module,
archon_backups_queue_v1: ArchonBackupsQueueV1Module,
archon_backups_v1: ArchonBackupsV1Module,
archon_content_v1: ArchonContentV1Module,
archon_nodes_internal: ArchonNodesInternalModule,
archon_notices_v0: ArchonNoticesV0Module,
archon_options_v1: ArchonOptionsV1Module,
archon_properties_v1: ArchonPropertiesV1Module,
archon_server_users_v1: ArchonServerUsersV1Module,
archon_servers_v0: ArchonServersV0Module,
archon_servers_v1: ArchonServersV1Module,
archon_transfers_internal: ArchonTransfersInternalModule,
iso3166_data: ISO3166Module,
mclogs_insights_v1: MclogsInsightsV1Module,
mclogs_logs_v1: MclogsLogsV1Module,
@@ -84,6 +95,7 @@ export const MODULE_REGISTRY = {
labrinth_campaign_internal: LabrinthCampaignInternalModule,
labrinth_collections: LabrinthCollectionsModule,
labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule,
labrinth_friends_v3: LabrinthFriendsV3Module,
labrinth_globals_internal: LabrinthGlobalsInternalModule,
labrinth_moderation_internal: LabrinthModerationInternalModule,
labrinth_notifications_v2: LabrinthNotificationsV2Module,
@@ -0,0 +1,47 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthFriendsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_friends_v3'
}
/**
* Get friends and pending friend requests for the authenticated user
*
* @returns Promise resolving to friend relationships
*/
public async list(): Promise<Labrinth.Friends.v3.UserFriend[]> {
return this.client.request<Labrinth.Friends.v3.UserFriend[]>('/friends', {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Send or accept a friend request
*
* @param idOrUsername - The target user's ID or username
*/
public async add(idOrUsername: string): Promise<void> {
return this.client.request(`/friend/${encodeURIComponent(idOrUsername)}`, {
api: 'labrinth',
version: 3,
method: 'POST',
})
}
/**
* Remove a friend or pending friend request
*
* @param idOrUsername - The target user's ID or username
*/
public async remove(idOrUsername: string): Promise<void> {
return this.client.request(`/friend/${encodeURIComponent(idOrUsername)}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}
@@ -4,6 +4,7 @@ export * from './auth/v2'
export * from './billing/internal'
export * from './collections'
export * from './external-projects/internal'
export * from './friends/v3'
export * from './globals/internal'
export * from './limits/v3'
export * from './moderation/internal'
@@ -1350,6 +1350,12 @@ export namespace Labrinth {
github_id?: number
}
export type SearchUser = {
id: string
username: string
avatar_url: string | null
}
export type AllProjectsResponse = {
projects: Projects.v3.Project[]
organizations: Record<string, Organizations.v3.Organization>
@@ -1357,6 +1363,17 @@ export namespace Labrinth {
}
}
export namespace Friends {
export namespace v3 {
export type UserFriend = {
id: string
friend_id: string
accepted: boolean
created: string
}
}
}
export namespace ServerPing {
export namespace Internal {
export type MinecraftJavaPingRequest = {
@@ -37,6 +37,25 @@ export class LabrinthUsersV3Module extends AbstractModule {
)
}
/**
* Search users by username prefix.
*
* @param query - Username search query
* @returns Promise resolving to compact user search results
*
* GET /v3/users/search?query=:query
*/
public async search(query: string): Promise<Labrinth.Users.v3.SearchUser[]> {
return this.client.request<Labrinth.Users.v3.SearchUser[]>(
`/users/search?query=${encodeURIComponent(query)}`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Get all projects the authenticated user can access directly or through
* their organizations.
+41 -1
View File
@@ -1,8 +1,10 @@
import { $fetch, FetchError } from 'ofetch'
import type { ModrinthApiError } from '../core/errors'
import { ModrinthApiError } from '../core/errors'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch'
import { GenericSyncClient } from './sync-generic'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
@@ -34,6 +36,12 @@ export class GenericModrinthClient extends XHRUploadClient {
enumerable: true,
configurable: false,
})
Object.defineProperty(this.archon, 'sync', {
value: new GenericSyncClient(this),
writable: false,
enumerable: true,
configurable: false,
})
}
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
@@ -54,6 +62,38 @@ export class GenericModrinthClient extends XHRUploadClient {
}
}
protected async executeStreamRequest(
url: string,
options: RequestOptions,
): Promise<ReadableStream<Uint8Array>> {
try {
const response = await fetch(appendRequestParams(url, options.params), {
method: options.method ?? 'GET',
headers: options.headers,
body: toFetchBody(options.body),
signal: options.signal,
})
if (!response.ok) {
throw this.createNormalizedError(
new Error(`HTTP ${response.status}: ${response.statusText}`),
response.status,
await parseResponseErrorData(response),
)
}
if (!response.body) {
throw new ModrinthApiError('Streaming response has no readable body', {
statusCode: response.status,
})
}
return response.body
} catch (error) {
throw this.normalizeError(error)
}
}
protected normalizeError(error: unknown): ModrinthApiError {
if (error instanceof FetchError) {
return this.createNormalizedError(error, error.response?.status, error.data)
+42
View File
@@ -5,6 +5,8 @@ import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/cir
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import type { UploadHandle, UploadRequestOptions } from '../types/upload'
import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch'
import { GenericSyncClient } from './sync-generic'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
@@ -97,6 +99,12 @@ export class NuxtModrinthClient extends XHRUploadClient {
enumerable: true,
configurable: false,
})
Object.defineProperty(this.archon, 'sync', {
value: new GenericSyncClient(this),
writable: false,
enumerable: true,
configurable: false,
})
}
/**
@@ -167,6 +175,40 @@ export class NuxtModrinthClient extends XHRUploadClient {
}
}
protected async executeStreamRequest(
url: string,
options: RequestOptions,
): Promise<ReadableStream<Uint8Array>> {
try {
const response = await fetch(appendRequestParams(url, options.params), {
method: options.method ?? 'GET',
headers: options.headers,
body: toFetchBody(options.body),
signal: options.signal,
// @ts-expect-error - import.meta is provided by Nuxt
cache: import.meta.server ? undefined : 'no-store',
})
if (!response.ok) {
throw this.createNormalizedError(
new Error(`HTTP ${response.status}: ${response.statusText}`),
response.status,
await parseResponseErrorData(response),
)
}
if (!response.body) {
throw new ModrinthApiError('Streaming response has no readable body', {
statusCode: response.status,
})
}
return response.body
} catch (error) {
throw this.normalizeError(error)
}
}
protected normalizeError(error: unknown): ModrinthApiError {
if (error instanceof FetchError) {
return this.createNormalizedError(error, error.response?.status, error.data)
@@ -0,0 +1,229 @@
import mitt from 'mitt'
import {
AbstractSyncClient,
type SyncConnection,
type SyncConnectOptions,
type SyncEmitterEvents,
} from '../core/abstract-sync'
import type { Archon } from '../modules/archon/types'
import { type ParsedSseItem, parseSyncEventData, SseParser } from '../utils/sse'
type StreamReadResult = 'closed' | 'protocol-reconnect'
const DEFAULT_RETRY_DELAY = 1000
const MAX_RECONNECT_DELAY = 30000
const JITTER_MS = 1000
export class GenericSyncClient extends AbstractSyncClient {
protected emitter = mitt<SyncEmitterEvents>()
async safeConnectServer(serverId: string, options: SyncConnectOptions = {}): Promise<void> {
const existing = this.connections.get(serverId)
if (existing && !options.force && !existing.stopped && existing.status !== 'disconnected') {
return
}
if (existing) {
this.closeConnection(serverId)
}
const connection: SyncConnection = {
serverId,
intent: options.intent ?? 'all',
reconnectAttempts: 0,
retryDelay: DEFAULT_RETRY_DELAY,
stopped: false,
status: 'idle',
}
this.connections.set(serverId, connection)
void this.runConnection(connection)
}
disconnect(serverId: string): void {
this.closeConnection(serverId)
this.clearListeners(serverId)
}
disconnectAll(): void {
for (const serverId of this.connections.keys()) {
this.disconnect(serverId)
}
}
private async runConnection(connection: SyncConnection): Promise<void> {
while (!connection.stopped) {
const hadConnected = connection.status === 'connected'
this.updateStatus(connection, hadConnected ? 'reconnecting' : 'connecting')
const controller = new AbortController()
connection.controller = controller
try {
const stream = await this.client.stream('/sync', {
api: 'archon',
version: 1,
method: 'GET',
params: {
scope: `server:${connection.serverId}`,
intent: this.intentToParam(connection.intent),
},
headers: connection.lastEventId
? {
'Last-Event-Id': connection.lastEventId,
}
: undefined,
signal: controller.signal,
retry: false,
circuitBreaker: false,
})
if (connection.stopped) return
connection.reconnectAttempts = 0
this.updateStatus(connection, 'connected')
const result = await this.consumeStream(connection, stream)
connection.controller = undefined
if (connection.stopped) return
if (result === 'protocol-reconnect') {
connection.reconnectAttempts = 0
continue
}
await this.waitForReconnect(connection)
} catch (error) {
connection.controller = undefined
if (connection.stopped || this.isAbortError(error)) return
connection.reconnectAttempts++
this.updateStatus(connection, 'error', error)
console.warn(`[Sync] Connection failed for server ${connection.serverId}:`, error)
await this.waitForReconnect(connection)
}
}
}
private async consumeStream(
connection: SyncConnection,
stream: ReadableStream<Uint8Array>,
): Promise<StreamReadResult> {
const reader = stream.getReader()
const decoder = new TextDecoder()
const parser = new SseParser()
try {
while (!connection.stopped) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const result = this.processParsedItems(connection, parser.feed(chunk))
if (result === 'protocol-reconnect') {
await reader.cancel()
connection.controller?.abort()
return result
}
}
const finalChunk = decoder.decode()
const finalItems = finalChunk ? parser.feed(finalChunk) : []
const result = this.processParsedItems(connection, [...finalItems, ...parser.end()])
if (result === 'protocol-reconnect') {
await reader.cancel()
connection.controller?.abort()
return result
}
} finally {
reader.releaseLock()
}
return 'closed'
}
private processParsedItems(connection: SyncConnection, items: ParsedSseItem[]): StreamReadResult {
for (const item of items) {
if (item.kind === 'retry') {
connection.retryDelay = Math.min(item.retry, MAX_RECONNECT_DELAY)
continue
}
this.updateLastEventId(connection, item.id)
const event = parseSyncEventData(item.data)
if (!event) {
console.warn('[Sync] Dropping malformed SSE payload:', {
serverId: connection.serverId,
event: item.event,
data: item.data,
})
continue
}
this.emitSyncEvent(connection.serverId, event)
if (event.type === 'protocol.reset' || event.type === 'protocol.invalid') {
connection.lastEventId = undefined
return 'protocol-reconnect'
}
}
return 'closed'
}
private async waitForReconnect(connection: SyncConnection): Promise<void> {
if (connection.stopped) return
this.updateStatus(connection, 'reconnecting')
const delay = this.getReconnectDelay(connection)
await new Promise<void>((resolve) => {
connection.reconnectResolve = resolve
connection.reconnectTimer = setTimeout(() => {
connection.reconnectTimer = undefined
connection.reconnectResolve = undefined
resolve()
}, delay)
})
}
private closeConnection(serverId: string): void {
const connection = this.connections.get(serverId)
if (!connection) return
connection.stopped = true
connection.controller?.abort()
if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer)
connection.reconnectTimer = undefined
}
connection.reconnectResolve?.()
connection.reconnectResolve = undefined
this.updateStatus(connection, 'disconnected')
this.connections.delete(serverId)
}
private getReconnectDelay(connection: SyncConnection): number {
const exponentialDelay =
connection.retryDelay * Math.pow(2, Math.max(connection.reconnectAttempts - 1, 0))
return Math.min(exponentialDelay, MAX_RECONNECT_DELAY) + Math.random() * JITTER_MS
}
private updateLastEventId(connection: SyncConnection, id: string | undefined): void {
if (id === undefined) return
connection.lastEventId = id || undefined
}
private intentToParam(intent: Archon.Sync.v1.SyncIntent): string {
return Array.isArray(intent) ? intent.join(',') : intent
}
private isAbortError(error: unknown): boolean {
if (!(error instanceof Error)) return false
return error.name === 'AbortError' || error.message.toLowerCase().includes('abort')
}
}
+45 -30
View File
@@ -1,6 +1,8 @@
import type { ModrinthApiError } from '../core/errors'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch'
import { GenericSyncClient } from './sync-generic'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
@@ -49,6 +51,12 @@ export class TauriModrinthClient extends XHRUploadClient {
enumerable: true,
configurable: false,
})
Object.defineProperty(this.archon, 'sync', {
value: new GenericSyncClient(this),
writable: false,
enumerable: true,
configurable: false,
})
}
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
@@ -57,36 +65,8 @@ export class TauriModrinthClient extends XHRUploadClient {
// This allows the package to be used in non-Tauri environments
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
let body: BodyInit | null | undefined = undefined
if (options.body) {
const raw = options.body
if (
typeof raw === 'object' &&
!(raw instanceof FormData) &&
!(raw instanceof URLSearchParams) &&
!(raw instanceof Blob) &&
!(raw instanceof ArrayBuffer) &&
!ArrayBuffer.isView(raw as ArrayBufferView)
) {
body = JSON.stringify(raw)
} else {
body = raw as BodyInit
}
}
let fullUrl = url
if (options.params) {
const filteredParams: Record<string, string> = {}
for (const [key, value] of Object.entries(options.params)) {
if (value !== undefined && value !== null) {
filteredParams[key] = String(value)
}
}
const queryString = new URLSearchParams(filteredParams).toString()
if (queryString) {
fullUrl = `${url}?${queryString}`
}
}
const body = toFetchBody(options.body)
const fullUrl = appendRequestParams(url, options.params)
const response = await tauriFetch(fullUrl, {
method: options.method ?? 'GET',
@@ -147,6 +127,41 @@ export class TauriModrinthClient extends XHRUploadClient {
}
}
protected async executeStreamRequest(
url: string,
options: RequestOptions,
): Promise<ReadableStream<Uint8Array>> {
try {
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
const response = await tauriFetch(appendRequestParams(url, options.params), {
method: options.method ?? 'GET',
headers: options.headers,
body: toFetchBody(options.body),
signal: options.signal,
})
if (!response.ok) {
throw this.createNormalizedError(
new Error(`HTTP ${response.status}: ${response.statusText}`),
response.status,
await parseResponseErrorData(response),
)
}
if (!response.body) {
throw this.createNormalizedError(
new Error('Streaming response has no readable body'),
response.status,
undefined,
)
}
return response.body
} catch (error) {
throw this.normalizeError(error)
}
}
protected normalizeError(error: unknown): ModrinthApiError {
if (error instanceof Error) {
const httpError = error as HttpError
@@ -18,9 +18,9 @@ export abstract class XHRUploadClient extends AbstractModrinthClient {
upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T> {
let baseUrl: string
if (options.api === 'labrinth') {
baseUrl = this.config.labrinthBaseUrl!
baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!)
} else if (options.api === 'archon') {
baseUrl = this.config.archonBaseUrl!
baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!)
} else {
baseUrl = options.api
}
+5 -2
View File
@@ -3,6 +3,7 @@ import type { RequestContext } from './request'
export type MaybePromise<T> = T | Promise<T>
export type UserAgentProvider = string | (() => MaybePromise<string | undefined>)
export type BaseUrlConfig = string | (() => string)
/**
* Request lifecycle hooks
@@ -39,13 +40,15 @@ export interface ClientConfig {
* Base URL for Labrinth API (main Modrinth API)
* @default 'https://api.modrinth.com'
*/
labrinthBaseUrl?: string
labrinthBaseUrl?: BaseUrlConfig
/**
* Base URL for Archon API (Modrinth Hosting API)
* Can be a callback so apps can drive this from runtime feature flags.
*
* @default 'https://archon.modrinth.com'
*/
archonBaseUrl?: string
archonBaseUrl?: BaseUrlConfig
/**
* Default request timeout in milliseconds
+1 -1
View File
@@ -7,7 +7,7 @@ export type {
} from '../features/circuit-breaker'
export type { BackoffStrategy, RetryConfig } from '../features/retry'
export type { Archon } from '../modules/archon/types'
export type { ClientConfig, RequestHooks } from './client'
export type { BaseUrlConfig, ClientConfig, RequestHooks } from './client'
export type { ApiErrorData, ModrinthErrorResponse } from './errors'
export { isModrinthErrorResponse } from './errors'
export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request'
+55
View File
@@ -0,0 +1,55 @@
import type { RequestOptions } from '../types/request'
export function appendRequestParams(url: string, params?: RequestOptions['params']): string {
if (!params) return url
const filteredParams: Record<string, string> = {}
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
filteredParams[key] = String(value)
}
}
const queryString = new URLSearchParams(filteredParams).toString()
if (!queryString) return url
return `${url}${url.includes('?') ? '&' : '?'}${queryString}`
}
export function toFetchBody(body: unknown): BodyInit | null | undefined {
if (!body) return undefined
if (
typeof body === 'object' &&
!(body instanceof FormData) &&
!(body instanceof URLSearchParams) &&
!(body instanceof Blob) &&
!(body instanceof ArrayBuffer) &&
!ArrayBuffer.isView(body as ArrayBufferView)
) {
return JSON.stringify(body)
}
return body as BodyInit
}
export async function parseResponseErrorData(response: Response): Promise<unknown> {
const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''
try {
if (contentType.includes('application/json') || contentType.includes('+json')) {
return await response.json()
}
const text = await response.text()
if (!text) return undefined
try {
return JSON.parse(text)
} catch {
return text
}
} catch {
return undefined
}
}
+139
View File
@@ -0,0 +1,139 @@
import type { Archon } from '../modules/archon/types'
export type ParsedSseEvent = {
kind: 'event'
id?: string
event?: string
data: string
}
export type ParsedSseRetry = {
kind: 'retry'
retry: number
}
export type ParsedSseItem = ParsedSseEvent | ParsedSseRetry
export class SseParser {
private buffer = ''
private eventName = ''
private data = ''
private id: string | undefined
feed(chunk: string): ParsedSseItem[] {
this.buffer += chunk
const items: ParsedSseItem[] = []
while (true) {
const lineEnd = this.findLineEnd()
if (!lineEnd) break
const { line, length } = lineEnd
this.buffer = this.buffer.slice(length)
this.processLine(line, items)
}
return items
}
end(): ParsedSseItem[] {
const items: ParsedSseItem[] = []
if (this.buffer.length > 0) {
this.processLine(this.buffer.endsWith('\r') ? this.buffer.slice(0, -1) : this.buffer, items)
this.buffer = ''
}
this.dispatch(items)
return items
}
private findLineEnd(): { line: string; length: number } | null {
const lf = this.buffer.indexOf('\n')
const cr = this.buffer.indexOf('\r')
if (lf === -1 && cr === -1) return null
if (cr !== -1 && (lf === -1 || cr < lf)) {
if (cr === this.buffer.length - 1) return null
const length = this.buffer[cr + 1] === '\n' ? cr + 2 : cr + 1
return {
line: this.buffer.slice(0, cr),
length,
}
}
return {
line: this.buffer.slice(0, lf),
length: lf + 1,
}
}
private processLine(line: string, items: ParsedSseItem[]): void {
if (line === '') {
this.dispatch(items)
return
}
if (line.startsWith(':')) return
const colon = line.indexOf(':')
const field = colon === -1 ? line : line.slice(0, colon)
let value = colon === -1 ? '' : line.slice(colon + 1)
if (value.startsWith(' ')) value = value.slice(1)
switch (field) {
case 'event':
this.eventName = value
break
case 'data':
this.data += `${value}\n`
break
case 'id':
this.id = value
break
case 'retry': {
const retry = Number(value)
if (Number.isInteger(retry) && retry >= 0) {
items.push({ kind: 'retry', retry })
}
break
}
}
}
private dispatch(items: ParsedSseItem[]): void {
if (!this.data) {
this.eventName = ''
this.id = undefined
return
}
items.push({
kind: 'event',
id: this.id,
event: this.eventName || undefined,
data: this.data.endsWith('\n') ? this.data.slice(0, -1) : this.data,
})
this.eventName = ''
this.data = ''
this.id = undefined
}
}
export function parseSyncEventData(data: string): Archon.Sync.v1.SyncEvent | null {
let parsed: unknown
try {
parsed = JSON.parse(data)
} catch {
return null
}
if (!parsed || typeof parsed !== 'object') return null
const event = parsed as { type?: unknown }
if (typeof event.type !== 'string') return null
return parsed as Archon.Sync.v1.SyncEvent
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+2
View File
@@ -41,6 +41,7 @@ import _DiscordIcon from './external/discord.svg?component'
import _FacebookIcon from './external/facebook.svg?component'
import _FlathubIcon from './external/flathub.svg?component'
import _GithubIcon from './external/github.svg?component'
import _IntercomBubbleIcon from './external/illustrations/intercom_bubble_icon.png?url'
import _MinecraftServerIcon from './external/illustrations/minecraft_server_icon.png?url'
import _InstagramIcon from './external/instagram.svg?component'
import _KoFiIcon from './external/kofi.svg?component'
@@ -132,6 +133,7 @@ export const VenmoIcon = _VenmoIcon
export const PolygonIcon = _PolygonIcon
export const USDCColorIcon = _USDCColorIcon
export const VisaIcon = _VisaIcon
export const IntercomBubbleIcon = _IntercomBubbleIcon
export const MinecraftServerIcon = _MinecraftServerIcon
export * from './generated-icons'
+93 -23
View File
@@ -639,8 +639,11 @@ a:not(.no-click-animation),
// TOOLTIPS
.v-popper--theme-dropdown,
.v-popper--theme-dropdown.v-popper--theme-ribbit-popout {
.v-popper__popper.v-popper--theme-dropdown,
.v-popper__popper.v-popper--theme-dropdown.v-popper--theme-ribbit-popout {
--_popper-arrow-bg: var(--surface-3);
--_popper-arrow-border: var(--surface-5);
.v-popper__inner {
border: 1px solid var(--surface-5) !important;
padding: var(--gap-sm) !important;
@@ -649,14 +652,6 @@ a:not(.no-click-animation),
background-color: var(--surface-3) !important;
box-shadow: 3px 3px 0.8rem rgba(0, 0, 0, 0.3) !important;
}
.v-popper__arrow-outer {
border-color: var(--surface-5) !important;
}
.v-popper__arrow-inner {
border-color: var(--surface-3) !important;
}
}
.v-popper__popper[data-popper-placement='bottom-end'] .v-popper__wrapper {
@@ -698,7 +693,16 @@ a:not(.no-click-animation),
//transform: scale(.9);
}
.v-popper--theme-tooltip {
.v-popper__popper.v-popper--theme-tooltip {
--_popper-arrow-bg: var(--color-tooltip-bg);
--_popper-arrow-border: var(--surface-5);
pointer-events: none;
&.v-popper--interactive {
pointer-events: auto;
}
.v-popper__inner {
background: var(--color-tooltip-bg) !important;
color: var(--color-tooltip-text) !important;
@@ -710,18 +714,12 @@ a:not(.no-click-animation),
line-height: 1;
border: 1px solid var(--surface-5);
}
.v-popper__arrow-outer {
border-color: var(--surface-5) !important;
}
.v-popper__arrow-inner {
visibility: visible;
border-color: var(--surface-3) !important;
}
}
.v-popper--theme-dismissable-prompt {
.v-popper__popper.v-popper--theme-dismissable-prompt {
--_popper-arrow-bg: var(--color-raised-bg);
--_popper-arrow-border: var(--color-button-border);
z-index: 10;
.v-popper__inner {
@@ -734,13 +732,85 @@ a:not(.no-click-animation),
font-weight: bold;
line-height: 1;
}
}
.v-popper__popper.v-popper--theme-dropdown,
.v-popper__popper.v-popper--theme-tooltip,
.v-popper__popper.v-popper--theme-dismissable-prompt {
.v-popper__arrow-container {
width: 0.875rem !important;
height: 0.875rem !important;
}
.v-popper__arrow-outer {
border-color: var(--color-button-border);
border-color: transparent !important;
border-style: solid !important;
height: 0 !important;
width: 0 !important;
}
.v-popper__arrow-inner {
border-color: var(--color-raised-bg);
border-color: transparent !important;
border-style: solid !important;
height: 0 !important;
visibility: visible !important;
width: 0 !important;
}
&[data-popper-placement^='top'] {
.v-popper__arrow-outer {
border-width: 0.4375rem 0.4375rem 0 !important;
border-top-color: var(--_popper-arrow-border) !important;
}
.v-popper__arrow-inner {
border-width: 0.375rem 0.375rem 0 !important;
border-top-color: var(--_popper-arrow-bg) !important;
left: 0.0625rem !important;
top: -0.0625rem !important;
}
}
&[data-popper-placement^='bottom'] {
.v-popper__arrow-outer {
border-width: 0 0.4375rem 0.4375rem !important;
border-bottom-color: var(--_popper-arrow-border) !important;
}
.v-popper__arrow-inner {
border-width: 0 0.375rem 0.375rem !important;
border-bottom-color: var(--_popper-arrow-bg) !important;
bottom: -0.0625rem !important;
left: 0.0625rem !important;
}
}
&[data-popper-placement^='left'] {
.v-popper__arrow-outer {
border-width: 0.4375rem 0 0.4375rem 0.4375rem !important;
border-left-color: var(--_popper-arrow-border) !important;
}
.v-popper__arrow-inner {
border-width: 0.375rem 0 0.375rem 0.375rem !important;
left: -0.0625rem !important;
border-left-color: var(--_popper-arrow-bg) !important;
top: 0.0625rem !important;
}
}
&[data-popper-placement^='right'] {
.v-popper__arrow-outer {
border-width: 0.4375rem 0.4375rem 0.4375rem 0 !important;
border-right-color: var(--_popper-arrow-border) !important;
}
.v-popper__arrow-inner {
border-width: 0.375rem 0.375rem 0.375rem 0 !important;
border-right-color: var(--_popper-arrow-bg) !important;
right: -0.0625rem !important;
top: 0.0625rem !important;
}
}
}
+61
View File
@@ -0,0 +1,61 @@
---
title: Manage servers together
summary: Add other users to your server, assign roles, and track whats changed.
date: 2026-06-03T20:10:28.823Z
authors: ['bOHH0P9Z', 'AJfd8YH6']
---
Hey everyone,
With this release, you can now give other users access to your server! This has been one of the most requested features for Modrinth Hosting and were excited to finally get it out.
![The new Access tab in the Modrinth Hosting panel, featuring a list of invited users and their permissions, invite new users, and an activity log to see what changes are being made to your server and by whom.](./server-access.webp)
## TL;DR
- Add users to your server
- Set permission roles
- View activity log
## Invite your friends
You can now give other users access to your server so they can help manage content, start the server, and more. To invite someone, just enter their Modrinth username and theyll receive an invite by email or as a notification in the app if theyre signed in.
Alongside this release, weve also improved state syncing between the website panel, app, and other users, so everything should stay up to date in real time.
![A pop-up modal for adding a user to your server. Search by Modrinth username, select their role (editor or limited), and an option to also send them a friend request.](./add-user-modal.webp)
## Permission roles
When adding someone to your server, you can choose what level of access they have. There are three roles, with each role inheriting the permissions of the previous one:
- **Owner:** Full access to the server including billing (you)
- **Editor:** Manage content, files, backups, settings, and more
- **Limited:** Start, stop, and view the server without making changes
You can find a full permission breakdown below:
| Permission | Owner | Editor | Limited |
| ------------------------ | ----- | ------ | ------- |
| Start / stop server | ✅ | ✅ | ✅ |
| Execute commands | ✅ | ✅ | ❌ |
| Edit settings | ✅ | ✅ | ❌ |
| Edit installation | ✅ | ✅ | ❌ |
| Manage content | ✅ | ✅ | ❌ |
| Manage files | ✅ | ✅ | ❌ |
| Create & restore backups | ✅ | ✅ | ❌ |
| Invite users | ✅ | ❌ | ❌ |
| Reset server | ✅ | ❌ | ❌ |
| Manage billing | ✅ | ❌ | ❌ |
## See what changed
Along with adding users, weve introduced an activity log. This is a chronological history of actions related to your server so you can see what changed, who changed it, and when it happened. Some actions are grouped together, like updating multiple projects at once, to keep things easier to read.
You can select a time timeframe and filter by user or action type if youre looking for something specific.
![The activity log section of the Access tab, where you can see the user that performed an action on the left column, the action that was performed in the center, and the time it happened on the right.](./activity-log.webp)
Thank you for your continued support! 💚
+2
View File
@@ -30,6 +30,7 @@ import { article as pride_campaign_2025 } from "./pride_campaign_2025";
import { article as pride_campaign_2026 } from "./pride_campaign_2026";
import { article as redesign } from "./redesign";
import { article as russian_censorship } from "./russian_censorship";
import { article as server_access } from "./server_access";
import { article as skins_now_in_modrinth_app } from "./skins_now_in_modrinth_app";
import { article as standing_by_our_values } from "./standing_by_our_values";
import { article as standing_by_our_values_russian } from "./standing_by_our_values_russian";
@@ -71,6 +72,7 @@ export const articles = [
pride_campaign_2026,
redesign,
russian_censorship,
server_access,
skins_now_in_modrinth_app,
standing_by_our_values,
standing_by_our_values_russian,
@@ -0,0 +1,2 @@
// AUTO-GENERATED FILE - DO NOT EDIT
export const html = `<p>Hey everyone,</p><p>With this release, you can now give other users access to your server! This has been one of the most requested features for Modrinth Hosting and were excited to finally get it out.</p><p><img src="/news/article/server-access/server-access.webp" alt="The new Access tab in the Modrinth Hosting panel, featuring a list of invited users and their permissions, invite new users, and an activity log to see what changes are being made to your server and by whom."></p><h2>TL;DR</h2><ul><li>Add users to your server</li><li>Set permission roles</li><li>View activity log</li></ul><h2>Invite your friends</h2><p>You can now give other users access to your server so they can help manage content, start the server, and more. To invite someone, just enter their Modrinth username and theyll receive an invite by email or as a notification in the app if theyre signed in.</p><p>Alongside this release, weve also improved state syncing between the website panel, app, and other users, so everything should stay up to date in real time.</p><p><img src="/news/article/server-access/add-user-modal.webp" alt="A pop-up modal for adding a user to your server. Search by Modrinth username, select their role (editor or limited), and an option to also send them a friend request."></p><h2>Permission roles</h2><p>When adding someone to your server, you can choose what level of access they have. There are three roles, with each role inheriting the permissions of the previous one:</p><ul><li><strong>Owner:</strong> Full access to the server including billing (you)</li><li><strong>Editor:</strong> Manage content, files, backups, settings, and more</li><li><strong>Limited:</strong> Start, stop, and view the server without making changes</li></ul><p>You can find a full permission breakdown below:</p><table><thead><tr><th>Permission</th><th>Owner</th><th>Editor</th><th>Limited</th></tr></thead><tbody><tr><td>Start / stop server</td><td>✅</td><td>✅</td><td>✅</td></tr><tr><td>Execute commands</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>Edit settings</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>Edit installation</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>Manage content</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>Manage files</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>Create &amp; restore backups</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>Invite users</td><td>✅</td><td>❌</td><td>❌</td></tr><tr><td>Reset server</td><td>✅</td><td>❌</td><td>❌</td></tr><tr><td>Manage billing</td><td>✅</td><td>❌</td><td>❌</td></tr></tbody></table><h2>See what changed</h2><p>Along with adding users, weve introduced an activity log. This is a chronological history of actions related to your server so you can see what changed, who changed it, and when it happened. Some actions are grouped together, like updating multiple projects at once, to keep things easier to read.</p><p>You can select a time timeframe and filter by user or action type if youre looking for something specific.</p><p><img src="/news/article/server-access/activity-log.webp" alt="The activity log section of the Access tab, where you can see the user that performed an action on the left column, the action that was performed in the center, and the time it happened on the right."></p><p>—</p><p>Thank you for your continued support! 💚</p>`;
+12
View File
@@ -0,0 +1,12 @@
// AUTO-GENERATED FILE - DO NOT EDIT
export const article = {
html: () => import(`./server_access.content`).then(m => m.html),
title: "Manage servers together",
summary: "Add other users to your server, assign roles, and track whats changed.",
date: "2026-06-03T20:10:28.823Z",
slug: "server-access",
authors: ["bOHH0P9Z","AJfd8YH6"],
unlisted: false,
thumbnail: true,
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

+17
View File
@@ -0,0 +1,17 @@
html {
min-height: 100%;
overflow: auto;
}
body {
position: static !important;
width: auto !important;
min-height: 100vh;
height: auto !important;
overflow: auto !important;
}
#storybook-root {
min-height: 100vh;
height: auto;
}
+1
View File
@@ -6,6 +6,7 @@ import '../../assets/styles/defaults.scss'
// ---
// app-frontend css imports
import '../../../apps/app-frontend/src/assets/stylesheets/global.scss'
import './preview.scss'
import type { Labrinth } from '@modrinth/api-client'
import { GenericModrinthClient } from '@modrinth/api-client'
@@ -26,8 +26,9 @@
>
<StyledInput
v-model="commandInput"
v-tooltip="disableInput ? disableInputTooltip : undefined"
:icon="TerminalSquareIcon"
:placeholder="disableInput ? 'Server is not running' : 'Send a command'"
:placeholder="disableInput ? disabledInputPlaceholder : 'Send a command'"
:disabled="disableInput"
wrapper-class="w-full"
input-class="!h-10"
@@ -51,6 +52,8 @@ const props = withDefaults(
scrollback?: number
showInput?: boolean
disableInput?: boolean
disableInputTooltip?: string
disabledInputPlaceholder?: string
fullscreen?: boolean
emptyStateType?: 'server' | 'instance'
loading?: boolean
@@ -59,6 +62,8 @@ const props = withDefaults(
scrollback: Infinity,
showInput: false,
disableInput: false,
disableInputTooltip: undefined,
disabledInputPlaceholder: 'Server is not running',
fullscreen: false,
emptyStateType: undefined,
loading: false,
@@ -215,6 +220,7 @@ watch(
)
const submitCommand = () => {
if (props.disableInput) return
const cmd = commandInput.value.trim()
if (!cmd) return
emit('command', cmd)
@@ -349,20 +349,19 @@ const fontSize = computed(() => {
}
/*noinspection CssUnresolvedCustomProperty*/
.btn-wrapper :deep(:is(button, a, .button-like):first-child) > svg:first-child,
.btn-wrapper :slotted(:is(button, a, .button-like):first-child) > svg:first-child,
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child > svg:first-child,
.btn-wrapper
:slotted(*)
> *:first-child
> :is(button, a, .button-like):first-child
> svg:first-child,
.btn-wrapper :deep(:is(button, a, .button-like):first-child) > svg,
.btn-wrapper :slotted(:is(button, a, .button-like):first-child) > svg,
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child > svg,
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child > svg,
.btn-wrapper
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child
> svg:first-child {
> svg {
display: block;
width: var(--_icon-size, 1rem);
height: var(--_icon-size, 1rem);
min-width: var(--_icon-size, 1rem);
min-height: var(--_icon-size, 1rem);
}
+38 -7
View File
@@ -23,6 +23,10 @@
type="text"
:placeholder="searchPlaceholder || placeholder"
:disabled="disabled"
:autocomplete="searchAutocomplete"
:autocorrect="searchAutocorrect"
:autocapitalize="searchAutocapitalize"
:spellcheck="searchSpellcheck"
wrapper-class="w-full !bg-transparent"
:input-class="searchableInputClass"
class="relative z-[1]"
@@ -73,7 +77,7 @@
<slot name="selected" :label="triggerText">{{ triggerText }}</slot>
</span>
</div>
<div class="flex items-center gap-1">
<div class="flex shrink-0 items-center gap-1">
<slot name="suffix"></slot>
<ChevronLeftIcon
v-if="showChevron"
@@ -97,6 +101,7 @@
:class="[
props.dropdownClass,
openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl',
props.dropdownClass,
]"
:style="dropdownStyle"
:role="listbox ? 'listbox' : 'menu'"
@@ -174,7 +179,7 @@
</div>
</div>
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
<div v-else-if="searchQuery" class="p-4 text-center text-sm text-secondary">
{{ noOptionsMessage }}
</div>
@@ -276,12 +281,19 @@ const props = withDefaults(
forceDirection?: 'up' | 'down'
noOptionsMessage?: string
disableSearchFilter?: boolean
dropdownClass?: string
dropdownMinWidth?: string
minSearchLengthToOpen?: number
/** Keep the selected option's label in the input after selection, and show all options on focus */
syncWithSelection?: boolean
/** Select the searchable input text when the field receives focus */
selectSearchTextOnFocus?: boolean
/** Show a search icon in the searchable input */
showSearchIcon?: boolean
searchAutocomplete?: string
searchAutocorrect?: 'on' | 'off'
searchAutocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
searchSpellcheck?: boolean
}>(),
{
placeholder: 'Select an option',
@@ -293,6 +305,7 @@ const props = withDefaults(
showIconInSelected: false,
maxHeight: DEFAULT_MAX_HEIGHT,
noOptionsMessage: 'No results found',
minSearchLengthToOpen: 0,
syncWithSelection: true,
selectSearchTextOnFocus: false,
showSearchIcon: false,
@@ -377,6 +390,10 @@ const triggerText = computed(() => {
return props.placeholder
})
const hasMinimumSearchLength = computed(
() => !props.searchable || searchQuery.value.trim().length >= props.minSearchLengthToOpen,
)
const optionsWithKeys = computed(() => {
return props.options.map((opt, index) => ({
...opt,
@@ -413,8 +430,7 @@ function getOptionClasses(item: ComboboxOption<T> & { key: string }, _index: num
item.class,
{
'bg-surface-4 text-contrast hover:brightness-[115%] focus:brightness-[115%]': !isSelected,
'bg-highlight-green text-green !cursor-default hover:bg-highlight-green focus:bg-highlight-green':
isSelected,
'bg-highlight-green text-green hover:bg-highlight-green focus:bg-highlight-green': isSelected,
'cursor-not-allowed opacity-50 pointer-events-none': item.disabled,
},
]
@@ -583,7 +599,8 @@ function destroyOptionsOverlayScrollbars() {
}
async function openDropdown() {
if (props.disabled || isOpen.value || !hasDropdownContent.value) return
if (props.disabled || isOpen.value || !hasMinimumSearchLength.value || !hasDropdownContent.value)
return
isOpen.value = true
emit('open')
@@ -628,7 +645,11 @@ function handleTriggerClick(event: MouseEvent) {
function handleOptionClick(option: ComboboxOption<T>, index: number) {
if (option.disabled || option.type === 'divider') return
const isSelected = props.listbox && option.value === props.modelValue
if (isSelected) return
if (isSelected) {
focusedIndex.value = index
if (option.type !== 'link') closeDropdown()
return
}
focusedIndex.value = index
@@ -782,6 +803,10 @@ function handleSearchKeydown(event: KeyboardEvent) {
function handleSearchInput() {
userHasTyped.value = true
emit('searchInput', searchQuery.value)
if (!hasMinimumSearchLength.value) {
closeDropdown()
return
}
if (!isOpen.value) {
openDropdown()
}
@@ -906,10 +931,16 @@ watch(hasDropdownContent, (value) => {
}
})
watch(hasMinimumSearchLength, (canOpen) => {
if (!canOpen) {
closeDropdown()
}
})
watch(
[() => props.modelValue, () => props.options],
([val]) => {
if (props.searchable && props.syncWithSelection && !isOpen.value) {
if (props.searchable && props.syncWithSelection && !isOpen.value && !userHasTyped.value) {
const opt = props.options.find((o) => isDropdownOption(o) && o.value === val)
searchQuery.value = opt && isDropdownOption(opt) ? opt.label : ''
}
@@ -109,7 +109,7 @@
<button
ref="addMenuTrigger"
type="button"
:class="addButtonClass"
:class="addButtonClass ?? '!border'"
:aria-expanded="isAddMenuOpen"
aria-haspopup="menu"
@click="handleAddMenuTriggerClick"
@@ -262,58 +262,82 @@
:style="activeCategoryOptionsListStyle"
>
<div
v-for="{ option, index } in renderedVisibleActiveCategoryOptions"
:key="`${activeCategory.key}-${option.value}`"
v-for="{ item, index } in renderedVisibleActiveCategoryOptions"
:key="getActiveCategoryItemKey(item, index)"
:class="shouldVirtualizeActiveCategoryOptions ? 'absolute left-0 right-0' : undefined"
:style="getActiveCategoryOptionWrapperStyle(index)"
>
<div
v-if="isDropdownFilterSectionHeader(item)"
class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm font-semibold text-secondary"
:class="item.class"
>
<span class="flex min-w-0 items-center gap-2">
<component
:is="item.icon"
v-if="item.icon"
class="size-4 shrink-0"
aria-hidden="true"
/>
<span class="min-w-0 truncate">{{ item.label }}</span>
</span>
<button
v-if="hasSelectableSectionHeaderOptions(item)"
type="button"
class="border-0 bg-transparent p-0 text-sm font-semibold text-secondary shadow-none transition-colors hover:text-contrast"
@click="toggleSectionHeaderOptions(item)"
>
{{ areSectionHeaderOptionsSelected(item) ? 'Clear' : 'Select all' }}
</button>
</div>
<button
v-else
type="button"
class="flex w-full cursor-pointer items-center gap-2.5 border-0 px-4 py-3.5 text-left text-contrast shadow-none transition-all duration-150 bg-surface-4 hover:brightness-[115%] focus-visible:brightness-[115%] focus-visible:outline-none"
:class="[
shouldVirtualizeActiveCategoryOptions ? 'h-12' : undefined,
{
'brightness-[115%]': option.selected,
'pointer-events-none cursor-not-allowed opacity-50': option.disabled,
'brightness-[115%]': item.selected,
'pointer-events-none cursor-not-allowed opacity-50': item.disabled,
},
]"
:aria-disabled="option.disabled || undefined"
:aria-checked="option.selected"
:aria-disabled="item.disabled || undefined"
:aria-checked="item.selected"
role="checkbox"
@click="toggleFilterOption(activeCategory.key, option)"
@click="toggleFilterOption(activeCategory.key, item)"
>
<span
v-if="checkboxPosition === 'left'"
class="checkbox-shadow flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-[1px] border-solid"
:class="
option.selected
item.selected
? 'border-button-border bg-brand text-brand-inverted'
: 'border-surface-5 bg-surface-2'
"
>
<CheckIcon v-if="option.selected" aria-hidden="true" stroke-width="3" />
<CheckIcon v-if="item.selected" aria-hidden="true" stroke-width="3" />
</span>
<div class="flex min-w-0 flex-1 items-center justify-between gap-3">
<slot
v-if="$slots.option"
name="option"
:category="activeCategory"
:option="option"
:selected="option.selected"
:option="item"
:selected="item.selected"
:index="index"
></slot>
<template v-else>
<span
class="min-w-0 truncate font-semibold leading-tight"
:class="option.selected ? 'text-contrast' : 'text-primary'"
:class="item.selected ? 'text-contrast' : 'text-primary'"
>
{{ option.label }}
{{ item.label }}
</span>
<slot
name="option-right"
:category="activeCategory"
:option="option"
:selected="option.selected"
:option="item"
:selected="item.selected"
></slot>
</template>
</div>
@@ -321,7 +345,7 @@
v-if="checkboxPosition === 'right'"
class="flex shrink-0 items-center justify-center text-brand"
>
<CheckIcon v-if="option.selected" aria-hidden="true" class="size-5" />
<CheckIcon v-if="item.selected" aria-hidden="true" class="size-5" />
</span>
</button>
</div>
@@ -353,12 +377,12 @@ import {
} from '@modrinth/assets'
import { onClickOutside } from '@vueuse/core'
import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars'
import type { ComponentPublicInstance, CSSProperties } from 'vue'
import type { Component, ComponentPublicInstance, CSSProperties } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useVirtualScroll } from '../../composables/virtual-scroll'
import ButtonStyled from './ButtonStyled.vue'
import MultiSelect, { type MultiSelectOption } from './MultiSelect.vue'
import MultiSelect, { type MultiSelectItem } from './MultiSelect.vue'
import StyledInput from './StyledInput.vue'
export type DropdownFilterBarOption = {
@@ -368,10 +392,20 @@ export type DropdownFilterBarOption = {
disabled?: boolean
}
export type DropdownFilterBarSectionHeader = {
type: 'section-header'
label: string
key?: string
icon?: Component
class?: string
}
export type DropdownFilterBarItem = DropdownFilterBarOption | DropdownFilterBarSectionHeader
export type DropdownFilterBarCategory = {
key: string
label: string
options: DropdownFilterBarOption[]
options: DropdownFilterBarItem[]
syntheticOptions?: DropdownFilterBarOption[]
searchable?: boolean
searchPlaceholder?: string
@@ -388,8 +422,12 @@ type RenderedDropdownFilterBarOption = DropdownFilterBarOption & {
selected: boolean
}
type RenderedDropdownFilterBarItem =
| RenderedDropdownFilterBarOption
| DropdownFilterBarSectionHeader
type VisibleDropdownFilterBarOption = {
option: RenderedDropdownFilterBarOption
item: RenderedDropdownFilterBarItem
index: number
}
@@ -464,6 +502,7 @@ const props = withDefaults(
showClear?: boolean
showLabel?: boolean
useFilterIcon?: boolean
applyImmediately?: boolean
showPreviewFilterIcon?: boolean
previewTriggerClass?: string
addButtonClass?: string
@@ -478,6 +517,7 @@ const props = withDefaults(
showClear: false,
showLabel: true,
useFilterIcon: false,
applyImmediately: false,
showPreviewFilterIcon: false,
emptyOptionsLabel: 'No options available.',
emptySearchLabel: 'No options found.',
@@ -519,6 +559,18 @@ let pendingCategoryTimeout: ReturnType<typeof setTimeout> | null = null
let previousMousePosition: Point | null = null
let addMenuPositionRafId: number | null = null
function isDropdownFilterSectionHeader(
item: DropdownFilterBarItem | RenderedDropdownFilterBarItem,
): item is DropdownFilterBarSectionHeader {
return 'type' in item && item.type === 'section-header'
}
function isDropdownFilterOption(
item: DropdownFilterBarItem | RenderedDropdownFilterBarItem,
): item is DropdownFilterBarOption {
return !isDropdownFilterSectionHeader(item)
}
const filterCategories = computed<DropdownFilterBarCategory[]>(() => {
const source = isAddMenuOpen.value ? 'draft' : 'committed'
return props.categories.map((category) => {
@@ -569,15 +621,32 @@ const filteredActiveCategoryOptions = computed(() => {
return activeCategory.value.options
}
return activeCategory.value.options.filter((option) => {
if (option.label.toLowerCase().includes(query)) {
return true
const items: DropdownFilterBarItem[] = []
let pendingSectionHeader: DropdownFilterBarSectionHeader | null = null
for (const option of activeCategory.value.options) {
if (isDropdownFilterSectionHeader(option)) {
pendingSectionHeader = option
continue
}
if (option.value.toLowerCase().includes(query)) {
return true
const matches =
option.label.toLowerCase().includes(query) ||
option.value.toLowerCase().includes(query) ||
(option.searchTerms?.some((term) => term.toLowerCase().includes(query)) ?? false)
if (!matches) {
continue
}
return option.searchTerms?.some((term) => term.toLowerCase().includes(query)) ?? false
})
if (pendingSectionHeader) {
items.push(pendingSectionHeader)
pendingSectionHeader = null
}
items.push(option)
}
return items
})
const shouldVirtualizeActiveCategoryOptions = computed(
@@ -599,11 +668,13 @@ const {
})
const renderedVisibleActiveCategoryOptions = computed<VisibleDropdownFilterBarOption[]>(() =>
visibleActiveCategoryOptions.value.map((option, offset) => ({
option: {
...option,
selected: activeCategorySelectedValueSet.value.has(option.value),
},
visibleActiveCategoryOptions.value.map((item, offset) => ({
item: isDropdownFilterSectionHeader(item)
? item
: {
...item,
selected: activeCategorySelectedValueSet.value.has(item.value),
},
index: activeCategoryOptionsVisibleRange.value.start + offset,
})),
)
@@ -721,14 +792,14 @@ function areSelectedFiltersEqual(
}
function getOptionsWithSelectedValues(
options: DropdownFilterBarOption[],
options: DropdownFilterBarItem[],
selectedValues: string[],
): DropdownFilterBarOption[] {
): DropdownFilterBarItem[] {
if (selectedValues.length === 0) {
return options
}
const knownValues = new Set(options.map((option) => option.value))
const knownValues = new Set(options.filter(isDropdownFilterOption).map((option) => option.value))
const missingSelectedOptions = selectedValues
.filter((value) => !knownValues.has(value))
.map((value) => ({
@@ -748,15 +819,17 @@ function getCategorySyntheticValues(categoryKey: string): Set<string> {
return category ? getCategorySyntheticValueSet(category) : new Set()
}
function getVisiblePreviewOptions(
category: DropdownFilterBarCategory,
): MultiSelectOption<string>[] {
return category.options.map((option) => ({
value: option.value,
label: option.label,
searchTerms: option.searchTerms,
disabled: option.disabled,
})) as MultiSelectOption<string>[]
function getVisiblePreviewOptions(category: DropdownFilterBarCategory): MultiSelectItem<string>[] {
return category.options.map((option) =>
isDropdownFilterSectionHeader(option)
? option
: {
value: option.value,
label: option.label,
searchTerms: option.searchTerms,
disabled: option.disabled,
},
) as MultiSelectItem<string>[]
}
function getPreviewOptionLabel(
@@ -764,7 +837,7 @@ function getPreviewOptionLabel(
selectedValue: string,
): string | undefined {
return [...(category.syntheticOptions ?? []), ...category.options].find(
(option) => option.value === selectedValue,
(option) => isDropdownFilterOption(option) && option.value === selectedValue,
)?.label
}
@@ -797,6 +870,9 @@ function setSelectedValues(
if (isAddMenuOpen.value && activeCategoryKey.value === categoryKey) {
scheduleSubmenuPositionUpdate()
}
if (props.applyImmediately) {
emit('update:modelValue', nextFilters)
}
} else {
emit('update:modelValue', nextFilters)
}
@@ -950,6 +1026,70 @@ function toggleFilterOption(categoryKey: string, option: DropdownFilterBarOption
toggleFilterValue(categoryKey, option.value, !isFilterValueSelected(categoryKey, option.value))
}
function getActiveCategoryItemKey(item: RenderedDropdownFilterBarItem, index: number) {
return isDropdownFilterSectionHeader(item)
? (item.key ?? `${activeCategory.value?.key ?? 'category'}-section-${item.label}-${index}`)
: `${activeCategory.value?.key ?? 'category'}-${item.value}`
}
function getSectionHeaderOptions(sectionHeader: DropdownFilterBarSectionHeader) {
const category = activeCategory.value
if (!category) {
return []
}
const sectionHeaderIndex = category.options.findIndex((item) => item === sectionHeader)
if (sectionHeaderIndex === -1) {
return []
}
const options: DropdownFilterBarOption[] = []
for (let index = sectionHeaderIndex + 1; index < category.options.length; index += 1) {
const item = category.options[index]
if (!item || isDropdownFilterSectionHeader(item)) {
break
}
if (!item.disabled) {
options.push(item)
}
}
return options
}
function hasSelectableSectionHeaderOptions(sectionHeader: DropdownFilterBarSectionHeader) {
return getSectionHeaderOptions(sectionHeader).length > 1
}
function areSectionHeaderOptionsSelected(sectionHeader: DropdownFilterBarSectionHeader) {
const options = getSectionHeaderOptions(sectionHeader)
return (
options.length > 0 &&
options.every((option) => activeCategorySelectedValueSet.value.has(option.value))
)
}
function toggleSectionHeaderOptions(sectionHeader: DropdownFilterBarSectionHeader) {
const category = activeCategory.value
if (!category) {
return
}
const options = getSectionHeaderOptions(sectionHeader)
if (options.length === 0) {
return
}
const optionValues = options.map((option) => option.value)
const optionValueSet = new Set(optionValues)
const currentValues = activeCategorySelectedValues.value
const nextValues = areSectionHeaderOptionsSelected(sectionHeader)
? currentValues.filter((value) => !optionValueSet.has(value))
: [...currentValues, ...optionValues.filter((value) => !currentValues.includes(value))]
setSelectedValues(category.key, nextValues, 'draft')
}
function getActiveCategoryOptionWrapperStyle(index: number): CSSProperties | undefined {
if (!shouldVirtualizeActiveCategoryOptions.value) {
return undefined
@@ -1076,9 +1216,13 @@ function getPreviewSelectedValues(categoryKey: string): string[] {
}
function setPreviewSelectedValues(categoryKey: string, values: string[]) {
const normalizedValues = normalizeSelectedValues(values)
previewSelectedValueDrafts.value = {
...previewSelectedValueDrafts.value,
[categoryKey]: normalizeSelectedValues(values),
[categoryKey]: normalizedValues,
}
if (props.applyImmediately) {
setSelectedValues(categoryKey, normalizedValues)
}
}
@@ -31,11 +31,15 @@ const shown = computed(() => props.shown && (!props.hideWhenModalOpen || stackCo
const floatingActionBarId = Symbol('floating-action-bar')
const intercomBubbleClearanceRequestId = Symbol('floating-action-bar')
const zIndex = computed(() => 100 + stackCount.value * 10 + 8 + (!props.belowModal ? 1 : 0))
const leftOffset = computed(
() => pageContext?.floatingActionBarOffsets?.left.value ?? 'var(--left-bar-width, 0px)',
const leftOffset = computed(() =>
stackCount.value > 0
? '0px'
: (pageContext?.floatingActionBarOffsets?.left.value ?? 'var(--left-bar-width, 0px)'),
)
const rightOffset = computed(
() => pageContext?.floatingActionBarOffsets?.right.value ?? 'var(--right-bar-width, 0px)',
const rightOffset = computed(() =>
stackCount.value > 0
? '0px'
: (pageContext?.floatingActionBarOffsets?.right.value ?? 'var(--right-bar-width, 0px)'),
)
const barStyle = computed(() => ({
zIndex: zIndex.value,
@@ -2,6 +2,7 @@
<div class="joined-buttons">
<ButtonStyled :color="color" :size="size">
<button
v-tooltip="primaryTooltip"
:class="{ 'joined-buttons__primary--muted': primaryMuted }"
:disabled="primaryDisabledResolved"
@click="handlePrimaryAction"
@@ -15,6 +16,7 @@
class="btn-dropdown-animation !w-10"
:options="dropdownOptions"
:disabled="dropdownDisabledResolved"
:tooltip="dropdownTooltip"
>
<DropdownIcon />
<template v-for="action in dropdownActions" :key="action.id" #[action.id]>
@@ -53,6 +55,8 @@ interface Props {
primaryDisabled?: boolean
dropdownDisabled?: boolean
primaryMuted?: boolean
primaryTooltip?: string
dropdownTooltip?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -62,6 +66,8 @@ const props = withDefaults(defineProps<Props>(), {
primaryDisabled: undefined,
dropdownDisabled: undefined,
primaryMuted: false,
primaryTooltip: undefined,
dropdownTooltip: undefined,
})
const primaryDisabledResolved = computed(() => props.primaryDisabled ?? props.disabled)
@@ -74,6 +74,7 @@
>
<ButtonStyled v-if="leftButtonConfig" type="outlined">
<button
v-tooltip="leftButtonConfig.tooltip"
:class="leftButtonConfig.buttonClass"
:disabled="leftButtonConfig.disabled"
@click="leftButtonConfig.onClick"
@@ -84,6 +85,7 @@
</ButtonStyled>
<ButtonStyled v-if="rightButtonConfig" :color="rightButtonConfig.color">
<button
v-tooltip="rightButtonConfig.tooltip"
class="!shadow-none"
:class="rightButtonConfig.buttonClass"
:disabled="rightButtonConfig.disabled || rightButtonConfig.loading"
@@ -128,6 +130,7 @@ export interface StageButtonConfig {
color?: InstanceType<typeof ButtonStyled>['$props']['color']
disabled?: boolean
loading?: boolean
tooltip?: string
iconClass?: string | null
buttonClass?: string | null
onClick?: () => void
@@ -28,6 +28,9 @@
:readonly="readonly"
:name="name"
:autocomplete="autocomplete"
:autocorrect="autocorrect"
:autocapitalize="autocapitalize"
:spellcheck="spellcheck"
:maxlength="maxlength"
:rows="rows"
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow bg-surface-4 border-none rounded-xl"
@@ -55,6 +58,9 @@
:readonly="readonly"
:name="name"
:autocomplete="autocomplete"
:autocorrect="autocorrect"
:autocapitalize="autocapitalize"
:spellcheck="spellcheck"
:inputmode="inputmode"
:maxlength="maxlength"
:min="min"
@@ -124,6 +130,9 @@ const props = withDefaults(
id?: string
name?: string
autocomplete?: string
autocorrect?: 'on' | 'off'
autocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
spellcheck?: boolean
inputmode?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'
maxlength?: number
min?: number
+56 -2
View File
@@ -62,7 +62,56 @@
</th>
</tr>
</thead>
<tbody :ref="setListContainer">
<TransitionGroup
v-if="rowTransitionName && !virtualized"
:name="rowTransitionName"
tag="tbody"
>
<tr v-if="data.length === 0" key="empty" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<slot name="empty-state">
<div class="text-secondary flex h-64 items-center justify-center">
No data available.
</div>
</slot>
</td>
</tr>
<template v-else>
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getRowClass(getAbsoluteRowIndex(rowIndex))"
>
<td
v-if="showSelection"
class="w-12 border-solid border-0 border-t border-surface-5 focus:outline-none"
>
<Checkbox
:model-value="isSelected(row)"
class="shrink-0 p-4 -outline-offset-[14px] outline rounded-2xl"
@update:model-value="(selectRow, event) => toggleSelection(row, selectRow, event)"
/>
</td>
<td
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
:class="`text-${column.align ?? 'left'}`"
>
<slot
:name="`cell-${column.key}`"
:row="row"
:value="row[column.key]"
:column="column"
:index="getAbsoluteRowIndex(rowIndex)"
>
{{ row[column.key] ?? '' }}
</slot>
</td>
</tr>
</template>
</TransitionGroup>
<tbody v-else :ref="setListContainer">
<tr v-if="data.length === 0" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<slot name="empty-state">
@@ -83,7 +132,7 @@
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
:class="getRowClass(getAbsoluteRowIndex(rowIndex))"
>
<td
v-if="showSelection"
@@ -169,6 +218,7 @@ const props = withDefaults(
virtualized?: boolean
virtualRowHeight?: number
virtualBufferSize?: number /* The number of extra rows rendered above and below the visible viewport */
rowTransitionName?: string
/**
* Sets a minimum width for the table content, allowing horizontal overflow below that width.
*/
@@ -269,6 +319,10 @@ function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
return rowIndex
}
function getRowClass(rowIndex: number): string {
return rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
}
function isSelected(row: T): boolean {
return selectedIdSet.value.has(getSelectionId(row))
}
@@ -96,6 +96,14 @@ const { data: regionsData } = useQuery({
queryFn: () => archon.servers_v1.getRegions(),
})
const PING_COUNT = 20
const PING_INTERVAL = 200
const MAX_PING_TIME = 1000
const initialIndex = {
'eu-lim': 31,
}
watch(
customerData,
(newCustomer) => {
@@ -136,14 +144,6 @@ async function fetchStock(
return result.available
}
const PING_COUNT = 20
const PING_INTERVAL = 200
const MAX_PING_TIME = 1000
const initialIndex = {
'eu-lim': 31,
}
function runPingTest(
region: Archon.Servers.v1.Region,
index = initialIndex[region.shortcode] ?? 1,
@@ -5,8 +5,10 @@
}}</span>
<Combobox
v-model="ctx.modpackSearchProjectId.value"
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
:options="ctx.modpackSearchOptions.value"
searchable
:disabled="ctx.finishDisabled.value"
:search-placeholder="formatMessage(messages.searchModpackPlaceholder)"
:no-options-message="
searchLoading
@@ -29,13 +31,23 @@
</div>
<div class="flex gap-3">
<ButtonStyled type="outlined">
<button class="flex-1" @click="triggerFileInput">
<button
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
class="flex-1"
:disabled="ctx.finishDisabled.value"
@click="triggerFileInput"
>
<ImportIcon />
{{ formatMessage(messages.importModpack) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="flex-1" @click="ctx.browseModpacks()">
<button
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
class="flex-1"
:disabled="ctx.finishDisabled.value"
@click="ctx.browseModpacks()"
>
<CompassIcon />
{{ formatMessage(messages.browseModpacks) }}
</button>
@@ -87,6 +99,8 @@ const messages = defineMessages({
})
function proceedWithModpack() {
if (ctx.finishDisabled.value) return
debug('proceedWithModpack:', {
flowType: ctx.flowType,
modpackSelection: ctx.modpackSelection.value,
@@ -196,6 +210,8 @@ watch(
)
async function triggerFileInput() {
if (ctx.finishDisabled.value) return
const picked = await filePicker.pickModpackFile({
readFile: ctx.flowType !== 'instance',
})
@@ -186,6 +186,8 @@ export interface CreationFlowContextValue {
// Loading state (set when finish() is called, cleared on reset)
loading: Ref<boolean>
finishDisabled: ComputedRef<boolean>
finishDisabledTooltip: ComputedRef<string | undefined>
// Backup state (set by InlineBackupCreator in reset-server flow)
isBackingUp: Ref<boolean>
@@ -232,6 +234,8 @@ export interface CreationFlowOptions {
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest?: LoaderManifestResolver
finishDisabled?: ComputedRef<boolean>
finishDisabledTooltip?: ComputedRef<string | undefined>
}
export function createCreationFlowContext(
@@ -257,6 +261,8 @@ export function createCreationFlowContext(
const searchModpacks = options.searchModpacks!
const getProjectVersions = options.getProjectVersions!
const getLoaderManifest = options.getLoaderManifest ?? null
const finishDisabled = options.finishDisabled ?? computed(() => false)
const finishDisabledTooltip = options.finishDisabledTooltip ?? computed(() => undefined)
const setupType = ref<SetupType | null>(null)
const isImportMode = ref(false)
@@ -502,6 +508,8 @@ export function createCreationFlowContext(
}
function finish() {
if (finishDisabled.value) return
debug('finish() called, state:', {
setupType: setupType.value,
selectedLoader: selectedLoader.value,
@@ -585,6 +593,8 @@ export function createCreationFlowContext(
importSearchQuery,
hardReset,
loading,
finishDisabled,
finishDisabledTooltip,
isBackingUp,
cancelBackup,
modal,
@@ -10,7 +10,7 @@
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { computed, useTemplateRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import MultiStageModal from '../../base/MultiStageModal.vue'
@@ -38,6 +38,8 @@ const props = withDefaults(
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest?: LoaderManifestResolver
finishDisabled?: boolean
finishDisabledTooltip?: string
}>(),
{
type: 'world',
@@ -78,6 +80,8 @@ const ctx = createCreationFlowContext(
searchModpacks: props.searchModpacks,
getProjectVersions: props.getProjectVersions,
getLoaderManifest: props.getLoaderManifest,
finishDisabled: computed(() => props.finishDisabled ?? false),
finishDisabledTooltip: computed(() => props.finishDisabledTooltip),
},
)
provideCreationFlowContext(ctx)
@@ -46,12 +46,14 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
icon: PlusIcon,
iconPosition: 'before' as const,
color: 'brand' as const,
disabled,
disabled: disabled || ctx.finishDisabled.value,
loading: ctx.loading.value,
tooltip: ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined,
onClick: () => ctx.finish(),
}
}
const finishDisabled = !goesToNextStage && ctx.finishDisabled.value
return {
label: ctx.formatMessage(
goesToNextStage ? commonMessages.continueButton : creationFlowMessages.finishButton,
@@ -59,7 +61,8 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
icon: goesToNextStage ? RightArrowIcon : null,
iconPosition: 'after' as const,
color: goesToNextStage ? undefined : ('brand' as const),
disabled,
disabled: disabled || finishDisabled,
tooltip: finishDisabled ? ctx.finishDisabledTooltip.value : undefined,
onClick: () => {
if (goesToNextStage) {
ctx.modal.value?.nextStage()
@@ -51,8 +51,10 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
icon: isFinish ? PlusIcon : RightArrowIcon,
iconPosition: isFinish ? ('before' as const) : ('after' as const),
color: isReset ? ('red' as const) : isFinish ? ('brand' as const) : undefined,
disabled: isForwardBlocked(ctx) || ctx.isBackingUp.value,
disabled:
isForwardBlocked(ctx) || ctx.isBackingUp.value || (isFinish && ctx.finishDisabled.value),
loading: isFinish && ctx.loading.value,
tooltip: isFinish && ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined,
onClick: () => {
if (isFinish) {
ctx.finish()
@@ -38,7 +38,8 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
icon: DownloadIcon,
iconPosition: 'before' as const,
color: 'brand' as const,
disabled: count === 0,
disabled: count === 0 || ctx.finishDisabled.value,
tooltip: ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined,
onClick: () => ctx.finish(),
}
},
+1
View File
@@ -8,6 +8,7 @@ export * from './content'
export * from './external_files'
export * from './modal'
export * from './nav'
export * from './notifications'
export * from './page'
export * from './project'
export * from './search'
+93 -1
View File
@@ -136,6 +136,7 @@
import { XIcon } from '@modrinth/assets'
import { computed, nextTick, onUnmounted, ref } from 'vue'
import { useDebugLogger } from '../../composables/debug-logger'
import { useVIntl } from '../../composables/i18n'
import { useModalStack } from '../../composables/modal-stack'
import { useScrollIndicator } from '../../composables/scroll-indicator'
@@ -144,6 +145,7 @@ import { commonMessages } from '../../utils/common-messages'
import ButtonStyled from '../base/ButtonStyled.vue'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('NewModal')
const modalBehavior = injectModalBehavior(null)
const {
@@ -233,54 +235,138 @@ function getFocusableElements(): HTMLElement[] {
}
function show(event?: MouseEvent) {
debug('show: start', {
header: props.header,
open: open.value,
visible: visible.value,
stackSize: modalStackSize(),
hasEvent: !!event,
})
props.onShow?.()
debug('show: after onShow', { header: props.header })
const wasEmpty = modalStackSize() === 0
stackDepth.value = modalStackSize()
debug('show: before open=true', {
header: props.header,
wasEmpty,
stackDepth: stackDepth.value,
})
open.value = true
debug('show: after open=true', {
header: props.header,
open: open.value,
modalBodyExists: !!modalBodyRef.value,
})
previousFocusEl = document.activeElement
debug('show: previous focus captured', {
header: props.header,
previousFocusTag: previousFocusEl instanceof HTMLElement ? previousFocusEl.tagName : null,
previousFocusClass: previousFocusEl instanceof HTMLElement ? previousFocusEl.className : null,
})
pushModal()
debug('show: after pushModal', { header: props.header, stackSize: modalStackSize() })
if (wasEmpty) modalBehavior?.onShow?.()
debug('show: after modalBehavior onShow', { header: props.header })
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleWindowKeyDown)
window.addEventListener('mousedown', updateMousePosition)
debug('show: listeners attached', { header: props.header })
if (event) {
updateMousePosition(event)
} else {
mouseX.value = Math.round(window.innerWidth / 2)
mouseY.value = Math.round(window.innerHeight / 2)
}
debug('show: mouse position set', {
header: props.header,
mouseX: mouseX.value,
mouseY: mouseY.value,
})
setTimeout(() => {
debug('show: timeout before visible=true', {
header: props.header,
open: open.value,
visible: visible.value,
modalBodyExists: !!modalBodyRef.value,
})
visible.value = true
debug('show: timeout after visible=true', {
header: props.header,
open: open.value,
visible: visible.value,
modalBodyExists: !!modalBodyRef.value,
})
nextTick(() => {
debug('show: nextTick focus start', {
header: props.header,
modalBodyExists: !!modalBodyRef.value,
})
const focusable = getFocusableElements()
debug('show: focusable elements', {
header: props.header,
count: focusable.length,
firstTag: focusable[0]?.tagName,
})
if (focusable.length > 0) {
focusable[0].focus()
} else {
modalBodyRef.value?.focus()
}
debug('show: nextTick focus done', { header: props.header })
})
}, 50)
debug('show: end', { header: props.header })
}
function hide() {
if (props.disableClose) return
debug('hide: start', {
header: props.header,
open: open.value,
visible: visible.value,
disableClose: props.disableClose,
stackSize: modalStackSize(),
})
if (props.disableClose) {
debug('hide: ignored disableClose', { header: props.header })
return
}
props.onHide?.()
debug('hide: after onHide', { header: props.header })
visible.value = false
debug('hide: after visible=false', { header: props.header, visible: visible.value })
popModal()
debug('hide: after popModal', { header: props.header, stackSize: modalStackSize() })
if (modalStackSize() === 0) {
modalBehavior?.onHide?.()
document.body.style.overflow = ''
}
window.removeEventListener('keydown', handleWindowKeyDown)
window.removeEventListener('mousedown', updateMousePosition)
debug('hide: listeners removed', { header: props.header })
if (previousFocusEl instanceof HTMLElement) {
debug('hide: restoring focus', {
header: props.header,
previousFocusTag: previousFocusEl.tagName,
previousFocusClass: previousFocusEl.className,
})
previousFocusEl.focus()
}
previousFocusEl = null
setTimeout(() => {
debug('hide: timeout before open=false', {
header: props.header,
open: open.value,
visible: visible.value,
})
open.value = false
debug('hide: timeout after open=false', {
header: props.header,
open: open.value,
visible: visible.value,
})
}, 300)
debug('hide: end', { header: props.header })
}
defineExpose({
@@ -308,6 +394,12 @@ function updateMousePosition(event: { clientX: number; clientY: number }) {
}
onUnmounted(() => {
debug('unmounted', {
header: props.header,
open: open.value,
visible: visible.value,
stackSize: modalStackSize(),
})
if (open.value) {
popModal()
window.removeEventListener('keydown', handleWindowKeyDown)
@@ -13,7 +13,38 @@
@mouseenter="stopTimer(item)"
@mouseleave="setNotificationTimer(item)"
>
<NotificationToast
v-if="item.toast"
:type="item.toast.type"
:actor-name="item.toast.actorName"
:actor-avatar-url="item.toast.actorAvatarUrl"
:entity-name="item.toast.entityName"
:entity-icon-url="item.toast.entityIconUrl"
:status-text="item.toast.statusText"
:progress="item.toast.progress"
:waiting="item.toast.waiting"
@accept="handleToastAction(item, item.toast.onAccept)"
@decline="handleToastAction(item, item.toast.onDecline)"
@dismiss="handleToastAction(item, item.toast.onDismiss)"
@launch="handleToastAction(item, item.toast.onLaunch)"
@open-actor="item.toast.onOpenActor?.()"
@open-instance="handleToastAction(item, item.toast.onOpenInstance)"
/>
<div v-else-if="isDownloadNotification(item)" class="flex flex-col gap-4">
<NotificationToast
v-for="progressItem in downloadToastItems(item)"
:key="progressItem.id"
type="instance-download"
:entity-name="progressItem.title || item.title"
:entity-icon-url="progressItem.iconUrl ?? item.iconUrl ?? MinecraftServerIcon"
:status-text="downloadStatusText(progressItem)"
:progress="progressItem.progress"
:waiting="progressItem.waiting"
@dismiss="dismiss(item.id)"
/>
</div>
<div
v-else
class="flex w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised shadow-xl border-surface-5 border-solid border p-4"
>
<div class="flex flex-col gap-2 w-full">
@@ -118,6 +149,7 @@ import {
DownloadIcon,
InfoIcon,
IssuesIcon,
MinecraftServerIcon,
XCircleIcon,
XIcon,
} from '@modrinth/assets'
@@ -127,9 +159,11 @@ import {
injectPopupNotificationManager,
type PopupNotification,
type PopupNotificationButton,
type PopupNotificationProgressItem,
} from '../../providers'
import ButtonStyled from '../base/ButtonStyled.vue'
import ProgressBar from '../base/ProgressBar.vue'
import NotificationToast from '../notifications/NotificationToast.vue'
const popupNotificationManager = injectPopupNotificationManager()
const notifications = computed<PopupNotification[]>(() =>
@@ -141,6 +175,34 @@ const setNotificationTimer = (n: PopupNotification) =>
popupNotificationManager.setNotificationTimer(n)
const dismiss = (id: string | number) => popupNotificationManager.removeNotification(id)
function isDownloadNotification(item: PopupNotification) {
return (
item.type === 'download' &&
(!!item.progressItems?.length || item.progress != null || item.waiting)
)
}
function downloadToastItems(item: PopupNotification): PopupNotificationProgressItem[] {
if (item.progressItems?.length) {
return item.progressItems
}
return [
{
id: `${item.id}`,
title: item.title,
text: item.text,
iconUrl: item.iconUrl,
progress: item.progress ?? 0,
waiting: item.waiting ?? false,
},
]
}
function downloadStatusText(progressItem: PopupNotificationProgressItem) {
return progressItem.text?.replace(/^\d+%\s*/, '') ?? ''
}
function handleButtonClick(id: string | number, btn: PopupNotificationButton) {
btn.action()
if (!btn.keepOpen) {
@@ -148,6 +210,11 @@ function handleButtonClick(id: string | number, btn: PopupNotificationButton) {
}
}
async function handleToastAction(item: PopupNotification, action?: () => void | Promise<void>) {
popupNotificationManager.removeNotification(item.id)
await action?.()
}
function progressColorForType(type: PopupNotification['type']) {
if (type === 'error') {
return 'red'
@@ -179,8 +246,9 @@ withDefaults(
top: calc(var(--top-bar-height, 3rem) + 1.5rem);
right: 1.5rem;
z-index: 200;
width: 520px;
max-width: calc(100vw - 3rem);
width: min(420px, calc(100vw - 1.5rem));
min-width: min(420px, calc(100vw - 1.5rem));
max-width: min(420px, calc(100vw - 1.5rem));
display: flex;
flex-direction: column;
gap: 0.75rem;
@@ -192,8 +260,6 @@ withDefaults(
@media screen and (max-width: 500px) {
.popup-notification-group {
width: calc(100% - 1.5rem);
max-width: none;
right: 0.75rem;
}
}
@@ -0,0 +1,61 @@
<template>
<div
class="notification-stack"
:class="{
'has-sidebar': hasSidebar,
}"
>
<TransitionGroup name="notification-stack-item" tag="div" class="notification-stack-items">
<slot />
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
hasSidebar?: boolean
}>(),
{
hasSidebar: false,
},
)
</script>
<style scoped>
.notification-stack {
position: fixed;
top: calc(var(--top-bar-height, 3rem) + 1rem);
right: 1rem;
z-index: 200;
width: min(420px, calc(100vw - 1.5rem));
}
.notification-stack.has-sidebar {
right: calc(var(--right-bar-width, 0px) + 1rem);
}
@media screen and (max-width: 500px) {
.notification-stack {
right: 0.75rem;
}
}
.notification-stack-items {
display: flex;
flex-direction: column;
gap: 1rem;
}
:global(.notification-stack-item-enter-active),
:global(.notification-stack-item-leave-active),
:global(.notification-stack-item-move) {
transition: all 0.3s ease-in-out;
}
:global(.notification-stack-item-enter-from),
:global(.notification-stack-item-leave-to) {
opacity: 0;
transform: translateX(100%) scale(0.95);
}
</style>
@@ -0,0 +1,273 @@
<template>
<div
class="notification-toast relative overflow-hidden rounded-[20px] border border-solid border-surface-5 bg-surface-3 p-4 shadow-[0px_4px_8px_0px_rgba(0,0,0,0.1),0px_1px_3px_0px_rgba(0,0,0,0.2)]"
>
<div v-if="isInviteNotification" class="flex w-full items-start gap-3">
<Avatar
:src="actorAvatarUrl"
:alt="actorLabel"
:tint-by="actorLabel"
size="44px"
circle
no-shadow
class="border border-solid border-surface-5"
/>
<div class="flex min-w-0 flex-1 flex-col gap-2.5">
<div class="flex w-full items-start gap-1">
<p class="m-0 min-w-0 flex-1 break-words text-lg font-normal leading-6 text-contrast/85">
<template v-if="type === 'friend-request'">
<span class="font-semibold text-contrast">{{ actorLabel }}</span>
<span> sent you a friend request.</span>
</template>
<template v-else>
<button
v-if="actorName"
type="button"
class="m-0 inline border-0 bg-transparent p-0 text-lg font-semibold leading-6 text-contrast hover:underline"
@click="$emit('open-actor')"
>
{{ actorName }}
</button>
<span v-else class="font-semibold text-contrast">Someone</span>
<span class="mx-1">{{ inviteActionText }}</span>
<template v-if="type === 'server-invite'">
<span class="font-semibold text-contrast">{{ entityLabel }}</span
>.
</template>
<template v-else>
<span class="inline-flex max-w-full items-center gap-[5px] align-[-4px]">
<Avatar
:src="entityIconUrl"
:alt="entityLabel"
size="24px"
no-shadow
raised
:tint-by="entityLabel"
class="!rounded-[7px]"
/>
<span class="min-w-0 truncate font-semibold text-contrast">{{
entityLabel
}}</span> </span
>.
</template>
</template>
</p>
<ButtonStyled size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
aria-label="Dismiss notification"
@click="$emit('dismiss')"
>
<XIcon />
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<ButtonStyled color="brand">
<button @click="$emit('accept')">Accept</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="$emit('decline')">Decline</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else class="flex w-full items-start gap-3">
<Avatar
:src="entityIconUrl"
:alt="entityLabel"
size="44px"
no-shadow
raised
:tint-by="entityLabel"
class="!rounded-xl border border-solid border-surface-5"
/>
<div class="flex min-w-0 flex-1 flex-col" :class="{ 'gap-2.5': type === 'instance-ready' }">
<div class="flex min-w-0 flex-1 items-start gap-1">
<div class="flex min-w-0 flex-1 flex-col gap-[3px] text-base leading-5">
<p
ref="titleRef"
v-tooltip="truncatedTooltip(titleRef, entityLabel)"
class="m-0 min-w-0 truncate text-lg font-semibold leading-6 text-contrast"
>
{{ entityLabel }}
</p>
<p
ref="statusRef"
v-tooltip="truncatedTooltip(statusRef, statusLine)"
class="m-0 min-w-0 truncate font-normal leading-tight text-contrast/85"
>
{{ statusLine }}
</p>
</div>
<ButtonStyled size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
aria-label="Dismiss notification"
@click="$emit('dismiss')"
>
<XIcon />
</button>
</ButtonStyled>
</div>
<div v-if="type === 'instance-ready'" class="flex items-center gap-2">
<ButtonStyled color="brand">
<button @click="$emit('launch')">Launch game</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="$emit('open-instance')">Instance</button>
</ButtonStyled>
</div>
</div>
</div>
<div
v-if="showsBottomProgress"
class="notification-bottom-progress-track absolute inset-x-0 bottom-0 h-1 overflow-hidden"
role="progressbar"
:aria-valuenow="isWaitingProgress ? undefined : progressPercent"
aria-valuemin="0"
aria-valuemax="100"
>
<div
class="h-full transition-[left,width] duration-200 ease-in-out"
:class="[
type === 'instance-ready' ? 'bg-surface-5' : 'bg-brand',
{ 'notification-bottom-progress--waiting': isWaitingProgress },
]"
:style="isWaitingProgress ? undefined : { width: `${progressPercent}%` }"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { truncatedTooltip } from '../../utils/truncate'
import Avatar from '../base/Avatar.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
type NotificationToastType =
| 'friend-request'
| 'server-invite'
| 'instance-invite'
| 'instance-download'
| 'instance-ready'
const props = withDefaults(
defineProps<{
type: NotificationToastType
actorName?: string | null
actorAvatarUrl?: string | null
entityName?: string
entityIconUrl?: string | null
statusText?: string
progress?: number
waiting?: boolean
}>(),
{
actorName: null,
actorAvatarUrl: null,
entityName: '',
entityIconUrl: null,
waiting: false,
},
)
defineEmits<{
accept: []
decline: []
dismiss: []
launch: []
'open-actor': []
'open-instance': []
}>()
const isInviteNotification = computed(
() =>
props.type === 'friend-request' ||
props.type === 'server-invite' ||
props.type === 'instance-invite',
)
const actorLabel = computed(() => props.actorName || 'Someone')
const entityLabel = computed(() => props.entityName || '')
const progressValue = computed(() => Math.max(0, Math.min(1, props.progress ?? 0)))
const progressPercent = computed(() => Math.round(progressValue.value * 100))
const isWaitingProgress = computed(() => props.type === 'instance-download' && props.waiting)
const inviteActionText = computed(() => {
if (props.type === 'server-invite') {
return 'invited you to manage the server'
}
return 'invited you to play the instance'
})
const resolvedStatusText = computed(() => {
if (props.type === 'instance-ready') {
return props.statusText ?? 'Installed and ready to play.'
}
return props.statusText ?? ''
})
const statusLine = computed(() => {
if (props.type !== 'instance-download' || props.waiting) {
return resolvedStatusText.value
}
const status = resolvedStatusText.value.trim()
return status ? `${status} ${progressPercent.value}%` : `${progressPercent.value}%`
})
const showsBottomProgress = computed(
() =>
props.type === 'instance-download' ||
(props.type === 'instance-ready' && props.progress != null),
)
const titleRef = ref<HTMLElement | null>(null)
const statusRef = ref<HTMLElement | null>(null)
</script>
<style scoped>
.notification-toast {
width: min(420px, calc(100vw - 1.5rem));
}
.notification-toast-dismiss {
--_height: 1.25rem;
--_width: 1.25rem;
--_padding-x: 0;
--_padding-y: 0;
--_icon-size: 1.25rem;
--_box-shadow: none;
--_text: var(--color-base);
--_hover-bg: transparent;
--_hover-text: var(--color-contrast);
}
.notification-bottom-progress--waiting {
animation: notification-bottom-progress-waiting 1s linear infinite;
position: relative;
width: 20%;
}
@keyframes notification-bottom-progress-waiting {
0% {
left: -20%;
}
100% {
left: 100%;
}
}
.notification-bottom-progress-track {
background-color: color-mix(in srgb, var(--surface-2) 50%, transparent);
}
</style>
@@ -0,0 +1,2 @@
export { default as NotificationStack } from './NotificationStack.vue'
export { default as NotificationToast } from './NotificationToast.vue'
@@ -28,7 +28,13 @@
</div>
<template v-if="contentError" #top-right-actions>
<ButtonStyled color="red" type="outlined">
<button class="!border" type="button" @click="emit('retry')">
<button
v-tooltip="retryDisabled ? retryDisabledTooltip : undefined"
class="!border"
type="button"
:disabled="retryDisabled"
@click="emit('retry')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
@@ -62,6 +68,8 @@ const props = defineProps<{
fallbackPhase?: SyncProgress['phase'] | null
contentError?: ContentError | null
dismissible?: boolean
retryDisabled?: boolean
retryDisabledTooltip?: string
}>()
const emit = defineEmits<{
@@ -35,10 +35,26 @@
</div>
<ServerIcon v-else :image="image ?? undefined" :disabled="isDisabled" />
<div class="ml-4 flex flex-col gap-1.5">
<div class="flex flex-row items-center gap-2">
<div class="flex flex-row items-center gap-2.5">
<h2 class="m-0 text-xl font-bold text-contrast" :class="{ 'opacity-50': isDisabled }">
{{ name }}
</h2>
<div
v-if="owner"
v-tooltip="formatMessage(messages.ownerTooltip, { username: owner.username })"
class="flex min-w-0 items-center gap-1 rounded-full bg-surface-4 px-2 pr-2.5 py-1 text-sm font-medium text-primary !border !border-surface-5 border-solid"
:class="{ 'opacity-50': isDisabled }"
>
<Avatar
:src="owner.avatarUrl"
:alt="formatMessage(messages.ownerAvatarAlt, { username: owner.username })"
:tint-by="owner.username"
size="1.25rem"
circle
no-shadow
/>
<span class="max-w-32 truncate">{{ owner.username }}</span>
</div>
<div
v-if="isConfiguring && noticeType !== 'cancelled' && noticeType !== 'setToCancel'"
class="flex min-w-0 items-center gap-2 truncate text-sm font-medium text-brand rounded-full bg-brand-highlight border border-solid border-brand px-2.5 h-[28px]"
@@ -262,6 +278,7 @@ import { injectModrinthClient } from '../../providers/api-client'
import Avatar from '../base/Avatar.vue'
import IntlFormatted from '../base/IntlFormatted.vue'
import ServersSpecs from '../billing/ServersSpecs.vue'
import type { ServerListingOwner } from './access/types'
import ServerIcon from './icons/ServerIcon.vue'
import ServerInfoLabels from './labels/ServerInfoLabels.vue'
@@ -281,6 +298,14 @@ const messages = defineMessages({
id: 'servers.listing.using-project-label',
defaultMessage: 'Using {projectTitle}',
},
ownerTooltip: {
id: 'servers.listing.owner-tooltip',
defaultMessage: 'Owned by {username}',
},
ownerAvatarAlt: {
id: 'servers.listing.owner-avatar-alt',
defaultMessage: "{username}'s avatar",
},
provisioningNotice: {
id: 'servers.listing.notice.provisioning',
defaultMessage: 'Please wait while we set up your server. This can take up to 10 minutes.',
@@ -402,6 +427,7 @@ type ServerListingProps = {
cancellationDate?: string | Date | null
onResubscribe?: (() => void) | null
onDownloadBackup?: (() => void) | null
owner?: ServerListingOwner
}
const props = defineProps<ServerListingProps>()
@@ -60,13 +60,13 @@
<script setup lang="ts">
import { CpuIcon, DatabaseIcon, FolderOpenIcon } from '@modrinth/assets'
import type { Stats } from '@modrinth/utils'
import { useStorage } from '@vueuse/core'
import { computed, defineAsyncComponent, onMounted, ref, shallowRef, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useFormatBytes } from '#ui/composables'
import { injectModrinthServerContext, injectPageContext } from '#ui/providers'
import type { ServerStats } from '#ui/providers/server-context'
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
@@ -82,7 +82,7 @@ const { featureFlags } = injectPageContext()
const props = withDefaults(
defineProps<{
data?: Stats
data?: ServerStats
loading?: boolean
showMemoryAsBytes?: boolean
}>(),

Some files were not shown because too many files have changed in this diff Show More