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>
@@ -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]": {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
|
||||
@@ -73,6 +73,14 @@ export default new createRouter({
|
||||
breadcrumb: [{ name: '?Server' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'access',
|
||||
name: 'ServerManageAccess',
|
||||
component: Hosting.Access,
|
||||
meta: {
|
||||
breadcrumb: [{ name: '?Server' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"> </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"> </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"> </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"> </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">
|
||||
|
||||
@@ -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: '/',
|
||||
})
|
||||
|
||||
@@ -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: '/',
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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: '/',
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
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 what’s 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.",
|
||||
|
||||
@@ -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 what’s 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><![CDATA[<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 we’re 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 they’ll receive an invite by email or as a notification in the app if they’re signed in.</p><p>Alongside this release, we’ve 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, we’ve 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 you’re 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>]]></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`
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 32 KiB |
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: Manage servers together
|
||||
summary: Add other users to your server, assign roles, and track what’s 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 we’re excited to finally get it out.
|
||||
|
||||

|
||||
|
||||
## 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 they’ll receive an invite by email or as a notification in the app if they’re signed in.
|
||||
|
||||
Alongside this release, we’ve also improved state syncing between the website panel, app, and other users, so everything should stay up to date in real time.
|
||||
|
||||

|
||||
|
||||
## 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, we’ve 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 you’re looking for something specific.
|
||||
|
||||

|
||||
|
||||
—
|
||||
|
||||
Thank you for your continued support! 💚
|
||||
@@ -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 we’re 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 they’ll receive an invite by email or as a notification in the app if they’re signed in.</p><p>Alongside this release, we’ve 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 & 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, we’ve 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 you’re 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>`;
|
||||
@@ -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 what’s changed.",
|
||||
date: "2026-06-03T20:10:28.823Z",
|
||||
slug: "server-access",
|
||||
authors: ["bOHH0P9Z","AJfd8YH6"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 194 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}>(),
|
||||
|
||||