mirror of
https://github.com/modrinth/code.git
synced 2026-08-01 13:45:53 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58fa954848 | ||
|
|
10c3622670 | ||
|
|
a1cf0087c1 | ||
|
|
37f3d38a8b | ||
|
|
98d4dbe359 | ||
|
|
c52738f31e | ||
|
|
0a207310e3 | ||
|
|
1067894a82 | ||
|
|
dbea158e16 | ||
|
|
b945871320 | ||
|
|
5511662a94 | ||
|
|
d5b4f87acc | ||
|
|
a562bf9e8d | ||
|
|
a1876eb3ec | ||
|
|
17852f959b | ||
|
|
20ed8e22cf | ||
|
|
a34f5b18c9 | ||
|
|
92f8eb40d5 | ||
|
|
38f02ea9a0 | ||
|
|
44ce6bfc33 | ||
|
|
a1ea6903f5 | ||
|
|
d88d555329 | ||
|
|
0723ef0aa5 | ||
|
|
5b86bb8c4a | ||
|
|
4f5f3bf1b1 | ||
|
|
2dbc982175 | ||
|
|
50e776ba4b | ||
|
|
1286480a8a | ||
|
|
70f08275af | ||
|
|
2da493e641 | ||
|
|
2351195d70 | ||
|
|
7726b2a5a3 | ||
|
|
fa286a7174 | ||
|
|
d6bd6a2c60 | ||
|
|
60fa3b7609 | ||
|
|
52c38f7506 | ||
|
|
4296e3b51f | ||
|
|
23ce9bc129 | ||
|
|
e9c141298d | ||
|
|
7c7f3584ac | ||
|
|
0ec70d29b9 |
+316
-25
@@ -16,6 +16,7 @@ import {
|
||||
HomeIcon,
|
||||
LeftArrowIcon,
|
||||
LibraryIcon,
|
||||
LinkIcon,
|
||||
LogInIcon,
|
||||
LogOutIcon,
|
||||
NewspaperIcon,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
defineMessages,
|
||||
I18nDebugPanel,
|
||||
LoadingBar,
|
||||
NewModal,
|
||||
NewsArticleCard,
|
||||
NotificationPanel,
|
||||
OverflowMenu,
|
||||
@@ -49,6 +51,7 @@ import {
|
||||
provideNotificationManager,
|
||||
providePageContext,
|
||||
providePopupNotificationManager,
|
||||
StyledInput,
|
||||
useDebugLogger,
|
||||
useFormatBytes,
|
||||
useHostingIntercom,
|
||||
@@ -80,6 +83,7 @@ import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
||||
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
|
||||
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
||||
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
|
||||
@@ -94,7 +98,13 @@ import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
|
||||
import { check_reachable } from '@/helpers/auth.js'
|
||||
import { get_user, get_version } from '@/helpers/cache.js'
|
||||
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
|
||||
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
|
||||
import {
|
||||
install_accept_shared_instance_invite,
|
||||
install_create_modpack_instance,
|
||||
install_get_modpack_preview,
|
||||
install_get_shared_instance_preview,
|
||||
install_shared_instance,
|
||||
} from '@/helpers/install'
|
||||
import { list, run } from '@/helpers/instance'
|
||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
||||
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
|
||||
@@ -147,6 +157,7 @@ const APP_SIDEBAR_WIDTH = 300
|
||||
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
|
||||
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
|
||||
const credentials = ref()
|
||||
let credentialsRefreshId = 0
|
||||
const sidebarToggled = ref(true)
|
||||
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
|
||||
sidebarToggled.value = !themeStore.toggleSidebar
|
||||
@@ -251,6 +262,7 @@ const {
|
||||
const news = ref([])
|
||||
const availableSurvey = ref(false)
|
||||
const displayedServerInviteNotifications = new Set()
|
||||
const displayedSharedInstanceInviteNotifications = new Set()
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
window.addEventListener('offline', () => {
|
||||
@@ -637,6 +649,10 @@ const contentInstallModpackAlreadyInstalledModal = ref()
|
||||
const addServerToInstanceModal = ref()
|
||||
const incompatibilityWarningModal = ref()
|
||||
const installToPlayModal = ref()
|
||||
const manualInviteLinkModal = ref()
|
||||
const manualInviteLink = ref('')
|
||||
const manualInviteProcessing = ref(false)
|
||||
const modrinthAccountRequiredModal = ref()
|
||||
const updateToPlayModal = ref()
|
||||
|
||||
const modrinthLoginFlowWaitModal = ref()
|
||||
@@ -647,8 +663,8 @@ watch(incompatibilityWarningModal, (modal) => {
|
||||
}
|
||||
})
|
||||
|
||||
setupAuthProvider(credentials, async (_redirectPath) => {
|
||||
await signIn()
|
||||
setupAuthProvider(credentials, async (_redirectPath, flow) => {
|
||||
await signIn(flow)
|
||||
})
|
||||
|
||||
async function validateSession(sessionToken) {
|
||||
@@ -665,23 +681,34 @@ async function validateSession(sessionToken) {
|
||||
}
|
||||
|
||||
async function fetchCredentials() {
|
||||
// TODO: move all of this into it's own helper
|
||||
const refreshId = ++credentialsRefreshId
|
||||
credentials.value = undefined
|
||||
|
||||
const creds = await getCreds().catch(handleError)
|
||||
if (refreshId !== credentialsRefreshId) return
|
||||
|
||||
if (creds && creds.user_id) {
|
||||
if (creds.session && !(await validateSession(creds.session))) {
|
||||
if (refreshId !== credentialsRefreshId) return
|
||||
|
||||
await logout().catch(handleError)
|
||||
if (refreshId !== credentialsRefreshId) return
|
||||
|
||||
credentials.value = null
|
||||
return
|
||||
}
|
||||
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
|
||||
if (refreshId !== credentialsRefreshId) return
|
||||
}
|
||||
credentials.value = creds ?? null
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
async function signIn(flow = 'sign-in') {
|
||||
modrinthLoginFlowWaitModal.value.show()
|
||||
|
||||
try {
|
||||
await login()
|
||||
await login(flow)
|
||||
await fetchCredentials()
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -699,6 +726,13 @@ async function signIn() {
|
||||
}
|
||||
|
||||
async function logOut() {
|
||||
await performLogOut()
|
||||
}
|
||||
|
||||
async function performLogOut() {
|
||||
credentialsRefreshId++
|
||||
credentials.value = undefined
|
||||
|
||||
await logout().catch(handleError)
|
||||
await fetchCredentials()
|
||||
}
|
||||
@@ -820,33 +854,242 @@ function openServerInviteInviterProfile(inviterName) {
|
||||
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
|
||||
function getSharedInstanceInvite(notification) {
|
||||
const body = notification.body
|
||||
|
||||
displayedServerInviteNotifications.add(notification.id)
|
||||
return {
|
||||
sharedInstanceId: body.shared_instance_id,
|
||||
sharedInstanceName: body.shared_instance_name,
|
||||
invitedById: body.invited_by,
|
||||
invitedByUsername: body.invited_by_username,
|
||||
invitedByAvatarUrl: body.invited_by_avatar_url,
|
||||
instanceIconUrl: body.instance_icon_url,
|
||||
}
|
||||
}
|
||||
|
||||
const serverName =
|
||||
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
|
||||
const inviterId = notification.body.invited_by
|
||||
async function resolveSharedInstanceInvite(notification) {
|
||||
const sharedInstanceInvite = getSharedInstanceInvite(notification)
|
||||
const invitedBy =
|
||||
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
|
||||
!sharedInstanceInvite.invitedByUsername && typeof sharedInstanceInvite.invitedById === 'string'
|
||||
? await get_user(sharedInstanceInvite.invitedById, 'bypass').catch(() => null)
|
||||
: null
|
||||
|
||||
addPopupNotification({
|
||||
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),
|
||||
},
|
||||
return {
|
||||
...sharedInstanceInvite,
|
||||
invitedByUsername: sharedInstanceInvite.invitedByUsername ?? invitedBy?.username ?? null,
|
||||
invitedByAvatarUrl: sharedInstanceInvite.invitedByAvatarUrl ?? invitedBy?.avatar_url ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptSharedInstanceInviteNotification(
|
||||
notification,
|
||||
resolvedSharedInstanceInvite = null,
|
||||
) {
|
||||
try {
|
||||
const sharedInstanceInvite =
|
||||
resolvedSharedInstanceInvite ?? (await resolveSharedInstanceInvite(notification))
|
||||
const preview = await install_get_shared_instance_preview(
|
||||
sharedInstanceInvite.sharedInstanceId,
|
||||
sharedInstanceInvite.sharedInstanceName,
|
||||
)
|
||||
if (sharedInstanceInvite.instanceIconUrl && !preview.iconUrl) {
|
||||
preview.iconUrl = sharedInstanceInvite.instanceIconUrl
|
||||
}
|
||||
const showInstallModal = installToPlayModal.value?.showSharedInstance
|
||||
|
||||
if (!showInstallModal) {
|
||||
throw new Error('Install to play modal is not available.')
|
||||
}
|
||||
|
||||
showInstallModal(
|
||||
{
|
||||
preview,
|
||||
invitedByUsername: sharedInstanceInvite.invitedByUsername,
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
await install_shared_instance(
|
||||
sharedInstanceInvite.sharedInstanceId,
|
||||
sharedInstanceInvite.sharedInstanceName,
|
||||
sharedInstanceInvite.invitedById,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
await markLiveNotificationRead(notification)
|
||||
queryClient.invalidateQueries({ queryKey: ['instances'] })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function declineSharedInstanceInviteNotification(notification) {
|
||||
try {
|
||||
getSharedInstanceInvite(notification)
|
||||
await markLiveNotificationRead(notification)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function waitForCredentialsReady() {
|
||||
if (credentials.value !== undefined) return Promise.resolve()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const stop = watch(credentials, (value) => {
|
||||
if (value !== undefined) {
|
||||
stop()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function requireModrinthAccountForInvite() {
|
||||
await waitForCredentialsReady()
|
||||
if (credentials.value?.session) return true
|
||||
|
||||
const modal = modrinthAccountRequiredModal.value
|
||||
if (modal?.show) {
|
||||
return await modal.show()
|
||||
}
|
||||
|
||||
await signIn()
|
||||
return !!credentials.value?.session
|
||||
}
|
||||
|
||||
async function requestModrinthAuth(flow) {
|
||||
await signIn(flow)
|
||||
return !!credentials.value?.session
|
||||
}
|
||||
|
||||
async function installSharedInstanceInviteFromDeepLink(inviteId) {
|
||||
try {
|
||||
if (!(await requireModrinthAccountForInvite())) return
|
||||
|
||||
const invitePreview = await install_accept_shared_instance_invite(inviteId)
|
||||
const showInstallModal = installToPlayModal.value?.showSharedInstance
|
||||
|
||||
if (!showInstallModal) {
|
||||
throw new Error('Install to play modal is not available.')
|
||||
}
|
||||
|
||||
showInstallModal(
|
||||
{
|
||||
preview: invitePreview.preview,
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
await install_shared_instance(
|
||||
invitePreview.sharedInstanceId,
|
||||
invitePreview.preview.name,
|
||||
invitePreview.managerId,
|
||||
invitePreview.serverManagerName,
|
||||
invitePreview.serverManagerIconUrl,
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['instances'] })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function manualInviteId() {
|
||||
const value = manualInviteLink.value.trim()
|
||||
if (!value) return null
|
||||
|
||||
try {
|
||||
const url = new URL(value)
|
||||
const match = /^\/share\/([^/]+)\/?$/.exec(url.pathname)
|
||||
return match ? decodeURIComponent(match[1]) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function showManualInviteLinkModal() {
|
||||
manualInviteLink.value = ''
|
||||
manualInviteLinkModal.value?.show()
|
||||
}
|
||||
|
||||
async function processManualInviteLink() {
|
||||
const inviteId = manualInviteId()
|
||||
if (!inviteId) return
|
||||
|
||||
manualInviteProcessing.value = true
|
||||
try {
|
||||
await invoke('plugin:install|install_shared_instance_invite', { inviteId })
|
||||
manualInviteLinkModal.value?.hide()
|
||||
queryClient.invalidateQueries({ queryKey: ['instances'] })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
manualInviteProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLiveNotification(notification) {
|
||||
if (!notification?.body || notification.read) return
|
||||
|
||||
if (notification.body.type === 'server_invite') {
|
||||
if (displayedServerInviteNotifications.has(notification.id)) return
|
||||
|
||||
displayedServerInviteNotifications.add(notification.id)
|
||||
|
||||
const serverName =
|
||||
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
|
||||
const inviterId = notification.body.invited_by
|
||||
const invitedBy =
|
||||
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
|
||||
|
||||
addPopupNotification({
|
||||
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),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (notification.body.type === 'shared_instance_invite') {
|
||||
if (displayedSharedInstanceInviteNotifications.has(notification.id)) return
|
||||
|
||||
displayedSharedInstanceInviteNotifications.add(notification.id)
|
||||
const sharedInstanceInvite = await resolveSharedInstanceInvite(notification)
|
||||
|
||||
addPopupNotification({
|
||||
title: sharedInstanceInvite.sharedInstanceName,
|
||||
autoCloseMs: null,
|
||||
toast: {
|
||||
type: 'instance-invite',
|
||||
actorName: sharedInstanceInvite.invitedByUsername,
|
||||
actorAvatarUrl: sharedInstanceInvite.invitedByAvatarUrl ?? undefined,
|
||||
entityName: sharedInstanceInvite.sharedInstanceName,
|
||||
entityIconUrl: sharedInstanceInvite.instanceIconUrl ?? undefined,
|
||||
onAccept: () => acceptSharedInstanceInviteNotification(notification, sharedInstanceInvite),
|
||||
onDecline: () => declineSharedInstanceInviteNotification(notification),
|
||||
onOpenActor: () => openServerInviteInviterProfile(sharedInstanceInvite.invitedByUsername),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommand(e) {
|
||||
if (!e) return
|
||||
|
||||
@@ -877,6 +1120,8 @@ async function handleCommand(e) {
|
||||
} else {
|
||||
await run(e.id).catch(handleError)
|
||||
}
|
||||
} else if (e.event === 'InstallSharedInstanceInvite') {
|
||||
await installSharedInstanceInviteFromDeepLink(e.invite_id)
|
||||
} else if (e.event === 'InstallServer') {
|
||||
await router.push(`/project/${e.id}`)
|
||||
await playServerProject(e.id).catch(handleError)
|
||||
@@ -1467,6 +1712,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<PlusIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="'Install from invite link'"
|
||||
:to="showManualInviteLinkModal"
|
||||
>
|
||||
<LinkIcon />
|
||||
</NavButton>
|
||||
<div class="flex flex-grow"></div>
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(commonMessages.settingsLabel)"
|
||||
@@ -1736,6 +1987,46 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
|
||||
@go-to-instance="handleContentInstallModpackDuplicateGoToInstance"
|
||||
/>
|
||||
<ModrinthAccountRequiredModal
|
||||
ref="modrinthAccountRequiredModal"
|
||||
:request-auth="requestModrinthAuth"
|
||||
/>
|
||||
<NewModal ref="manualInviteLinkModal" header="Install from invite link" max-width="32rem">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">Invite link</span>
|
||||
<StyledInput
|
||||
v-model="manualInviteLink"
|
||||
placeholder="https://modrinth.com/share/..."
|
||||
:disabled="manualInviteProcessing"
|
||||
@keydown.enter="processManualInviteLink"
|
||||
/>
|
||||
<span v-if="manualInviteLink && !manualInviteId()" class="text-sm text-red">
|
||||
Enter a valid Modrinth shared-instance invite link.
|
||||
</span>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button
|
||||
:disabled="manualInviteProcessing"
|
||||
@click="manualInviteLinkModal?.hide()"
|
||||
>
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="!manualInviteId() || manualInviteProcessing"
|
||||
@click="processManualInviteLink"
|
||||
>
|
||||
<LinkIcon />
|
||||
Process
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
<InstallToPlayModal ref="installToPlayModal" />
|
||||
<UpdateToPlayModal ref="updateToPlayModal" />
|
||||
</template>
|
||||
|
||||
@@ -39,7 +39,12 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
list: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -133,21 +138,49 @@ defineExpose({
|
||||
|
||||
const currentEvent = ref(null)
|
||||
|
||||
const unlisten = await process_listener((e) => {
|
||||
if (e.instance_id === props.instance.id) {
|
||||
currentEvent.value = e.event
|
||||
if (e.event === 'finished') {
|
||||
playing.value = false
|
||||
}
|
||||
const unlisten = props.list
|
||||
? () => {}
|
||||
: await process_listener((e) => {
|
||||
if (e.instance_id === props.instance.id) {
|
||||
currentEvent.value = e.event
|
||||
if (e.event === 'finished') {
|
||||
playing.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.list) {
|
||||
checkProcess()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => checkProcess())
|
||||
onUnmounted(() => unlisten())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="compact">
|
||||
<RouterLink
|
||||
v-if="list"
|
||||
:to="`/instance/${encodeURIComponent(instance.id)}`"
|
||||
class="card-shadow flex w-full min-w-0 cursor-pointer items-center gap-3 overflow-hidden rounded-2xl border border-solid border-surface-4 bg-surface-2 p-4 text-left no-underline transition-all hover:brightness-110"
|
||||
@click="emit('select', instance)"
|
||||
>
|
||||
<Avatar
|
||||
size="40px"
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
:tint-by="instance.id"
|
||||
alt="Instance icon"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<p class="m-0 truncate text-base font-semibold leading-5 text-contrast">
|
||||
{{ instance.name }}
|
||||
</p>
|
||||
<div class="flex min-w-0 items-center gap-1 text-sm font-medium text-secondary">
|
||||
<GameIcon class="shrink-0" />
|
||||
<span class="truncate capitalize">{{ instance.loader }} {{ instance.game_version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
<template v-else-if="compact">
|
||||
<div
|
||||
class="card-shadow grid grid-cols-[auto_1fr_auto] bg-bg-raised rounded-xl p-3 pl-4 gap-2 cursor-pointer hover:brightness-90 transition-all"
|
||||
@click="seeInstance"
|
||||
|
||||
@@ -10,17 +10,22 @@ import {
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { friend_listener } from '@/helpers/events'
|
||||
import {
|
||||
acceptCachedFriend,
|
||||
add_friend,
|
||||
friends,
|
||||
createPendingFriend,
|
||||
friendsQueryKey,
|
||||
type FriendWithUserData,
|
||||
getFriendsWithUserData,
|
||||
remove_friend,
|
||||
transformFriends,
|
||||
removeCachedFriend,
|
||||
upsertCachedFriend,
|
||||
} from '@/helpers/friends.ts'
|
||||
import type { ModrinthCredentials } from '@/helpers/mr_auth'
|
||||
|
||||
@@ -28,6 +33,7 @@ const { formatMessage } = useVIntl()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const props = defineProps<{
|
||||
credentials: ModrinthCredentials | null
|
||||
@@ -35,36 +41,22 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const userCredentials = computed(() => props.credentials)
|
||||
const friendsKey = computed(() => friendsQueryKey(userCredentials.value?.user_id))
|
||||
|
||||
const search = ref('')
|
||||
const friendInvitesModal = ref()
|
||||
|
||||
const username = ref('')
|
||||
const addFriendModal = ref()
|
||||
async function addFriendFromModal() {
|
||||
addFriendModal.value.hide()
|
||||
await add_friend(username.value).catch(handleError)
|
||||
username.value = ''
|
||||
await loadFriends()
|
||||
}
|
||||
|
||||
async function addFriend(friend: FriendWithUserData) {
|
||||
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
|
||||
if (id) {
|
||||
await add_friend(id).catch(handleError)
|
||||
await loadFriends()
|
||||
}
|
||||
}
|
||||
const friendsQuery = useQuery({
|
||||
queryKey: friendsKey,
|
||||
queryFn: () => getFriendsWithUserData(userCredentials.value),
|
||||
enabled: () => !!userCredentials.value,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
async function removeFriend(friend: FriendWithUserData) {
|
||||
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
|
||||
if (id) {
|
||||
await remove_friend(id).catch(handleError)
|
||||
await loadFriends()
|
||||
}
|
||||
}
|
||||
|
||||
const userFriends = ref<FriendWithUserData[]>([])
|
||||
const userFriends = computed(() => friendsQuery.data.value ?? [])
|
||||
const sortedFriends = computed<FriendWithUserData[]>(() =>
|
||||
userFriends.value.slice().sort((a, b) => {
|
||||
if (a.last_updated === null && b.last_updated === null) {
|
||||
@@ -108,39 +100,144 @@ const incomingRequests = computed(() =>
|
||||
.sort((a, b) => b.created.diff(a.created)),
|
||||
)
|
||||
|
||||
const loading = ref(true)
|
||||
async function loadFriends(timeout = false) {
|
||||
loading.value = timeout
|
||||
type FriendsMutationContext = {
|
||||
queryKey: ReturnType<typeof friendsQueryKey>
|
||||
previousFriends?: FriendWithUserData[]
|
||||
}
|
||||
|
||||
try {
|
||||
const friendsList = await friends()
|
||||
userFriends.value = await transformFriends(friendsList, userCredentials.value)
|
||||
loading.value = false
|
||||
} catch (e) {
|
||||
console.error('Error loading friends', e)
|
||||
if (timeout) {
|
||||
setTimeout(() => loadFriends(), 15 * 1000)
|
||||
}
|
||||
type AddFriendMutationVariables = {
|
||||
userId: string
|
||||
user: {
|
||||
id: string
|
||||
username: string
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
acceptExisting?: boolean
|
||||
}
|
||||
|
||||
type RemoveFriendMutationVariables = {
|
||||
userId: string
|
||||
user: FriendWithUserData
|
||||
}
|
||||
|
||||
const loading = computed(() => !!userCredentials.value && friendsQuery.isLoading.value)
|
||||
|
||||
const addFriendMutation = useMutation({
|
||||
mutationFn: ({ userId }: AddFriendMutationVariables) => add_friend(userId),
|
||||
onMutate: async ({ user, acceptExisting }): Promise<FriendsMutationContext> => {
|
||||
const queryKey = friendsKey.value
|
||||
await queryClient.cancelQueries({ queryKey })
|
||||
const previousFriends = queryClient.getQueryData<FriendWithUserData[]>(queryKey)
|
||||
|
||||
queryClient.setQueryData<FriendWithUserData[]>(queryKey, (friends = []) =>
|
||||
acceptExisting
|
||||
? acceptCachedFriend(friends, user.id, user.username, userCredentials.value?.user_id)
|
||||
: upsertCachedFriend(
|
||||
friends,
|
||||
createPendingFriend(user, userCredentials.value?.user_id),
|
||||
userCredentials.value?.user_id,
|
||||
),
|
||||
)
|
||||
|
||||
return { queryKey, previousFriends }
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
restoreFriendsQuery(context)
|
||||
handleError(toError(error))
|
||||
},
|
||||
onSettled: (_data, _error, _variables, context) => {
|
||||
void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? friendsKey.value })
|
||||
},
|
||||
})
|
||||
|
||||
const removeFriendMutation = useMutation({
|
||||
mutationFn: ({ userId }: RemoveFriendMutationVariables) => remove_friend(userId),
|
||||
onMutate: async ({ user, userId }): Promise<FriendsMutationContext> => {
|
||||
const queryKey = friendsKey.value
|
||||
await queryClient.cancelQueries({ queryKey })
|
||||
const previousFriends = queryClient.getQueryData<FriendWithUserData[]>(queryKey)
|
||||
|
||||
queryClient.setQueryData<FriendWithUserData[]>(queryKey, (friends = []) =>
|
||||
removeCachedFriend(friends, userId, user.username, userCredentials.value?.user_id),
|
||||
)
|
||||
|
||||
return { queryKey, previousFriends }
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
restoreFriendsQuery(context)
|
||||
handleError(toError(error))
|
||||
},
|
||||
onSettled: (_data, _error, _variables, context) => {
|
||||
void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? friendsKey.value })
|
||||
},
|
||||
})
|
||||
|
||||
function restoreFriendsQuery(context?: FriendsMutationContext) {
|
||||
if (!context) return
|
||||
|
||||
if (context.previousFriends === undefined) {
|
||||
queryClient.removeQueries({ queryKey: context.queryKey, exact: true })
|
||||
return
|
||||
}
|
||||
|
||||
queryClient.setQueryData(context.queryKey, context.previousFriends)
|
||||
}
|
||||
|
||||
function toError(error: unknown) {
|
||||
if (error instanceof Error) return error
|
||||
if (typeof error === 'string') return new Error(error)
|
||||
if (error && typeof error === 'object') {
|
||||
const record = error as Record<string, unknown>
|
||||
const message = record.message ?? record.error
|
||||
if (typeof message === 'string') return new Error(message)
|
||||
return new Error(JSON.stringify(error))
|
||||
}
|
||||
return new Error(String(error))
|
||||
}
|
||||
|
||||
function addFriendFromModal() {
|
||||
const target = username.value.trim()
|
||||
if (!target) return
|
||||
|
||||
addFriendModal.value.hide()
|
||||
addFriendMutation.mutate({
|
||||
userId: target,
|
||||
user: {
|
||||
id: target,
|
||||
username: target,
|
||||
},
|
||||
})
|
||||
username.value = ''
|
||||
}
|
||||
|
||||
function addFriend(friend: FriendWithUserData) {
|
||||
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
|
||||
if (id) {
|
||||
addFriendMutation.mutate({
|
||||
userId: id,
|
||||
user: {
|
||||
id,
|
||||
username: friend.username,
|
||||
avatarUrl: friend.avatar,
|
||||
},
|
||||
acceptExisting: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
userCredentials,
|
||||
() => {
|
||||
if (userCredentials.value === undefined) {
|
||||
userFriends.value = []
|
||||
loading.value = false
|
||||
} else if (userCredentials.value === null) {
|
||||
userFriends.value = []
|
||||
loading.value = false
|
||||
} else {
|
||||
loadFriends(true)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
function removeFriend(friend: FriendWithUserData) {
|
||||
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
|
||||
if (id) {
|
||||
removeFriendMutation.mutate({
|
||||
userId: id,
|
||||
user: friend,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const unlisten = await friend_listener(() => loadFriends())
|
||||
const unlisten = await friend_listener(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: friendsKey.value })
|
||||
})
|
||||
onUnmounted(() => {
|
||||
unlisten()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<StackedAdmonitions v-bind="$attrs" :items="stackItems" class="w-full">
|
||||
<template #item="{ item, dismissible }">
|
||||
<InstanceAdmonitionsSharedInstanceStale
|
||||
v-if="item.kind === 'shared-instance-stale'"
|
||||
:instance="instance"
|
||||
@published="emit('published')"
|
||||
/>
|
||||
<InstanceAdmonitionsSharedInstanceWrongAccount
|
||||
v-else-if="item.kind === 'shared-instance-wrong-account'"
|
||||
:expected-user-id="sharedInstanceExpectedUserId"
|
||||
:role="sharedInstanceRole"
|
||||
:signed-out="sharedInstanceSignedOut"
|
||||
/>
|
||||
<InstanceAdmonitionsSharedInstanceUnavailable
|
||||
v-else-if="item.kind === 'shared-instance-unavailable'"
|
||||
:reason="sharedInstanceUnavailableReason"
|
||||
:manager="sharedInstanceUnavailableManager"
|
||||
:dismissible="dismissible"
|
||||
@dismiss="sharedInstanceUnavailableDismissed = true"
|
||||
/>
|
||||
</template>
|
||||
</StackedAdmonitions>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { StackedAdmonitions } from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import InstanceAdmonitionsSharedInstanceStale from './instance-admonitions-shared-instance-stale.vue'
|
||||
import InstanceAdmonitionsSharedInstanceUnavailable from './instance-admonitions-shared-instance-unavailable.vue'
|
||||
import InstanceAdmonitionsSharedInstanceWrongAccount from './instance-admonitions-shared-instance-wrong-account.vue'
|
||||
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
sharedInstanceUnavailableReason?: SharedInstanceUnavailableReason | null
|
||||
sharedInstanceUnavailableManager?: string | null
|
||||
sharedInstanceWrongAccount?: boolean
|
||||
sharedInstanceExpectedUserId?: string | null
|
||||
sharedInstanceRole?: SharedInstanceRole | null
|
||||
sharedInstanceSignedOut?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
published: []
|
||||
}>()
|
||||
|
||||
const sharedInstanceWrongAccount = computed(() => props.sharedInstanceWrongAccount ?? false)
|
||||
const sharedInstanceUnavailableDismissed = ref(false)
|
||||
const showSharedInstancePublishAdmonition = computed(
|
||||
() =>
|
||||
!sharedInstanceWrongAccount.value &&
|
||||
props.instance.install_stage === 'installed' &&
|
||||
props.instance.shared_instance?.role === 'owner' &&
|
||||
props.instance.shared_instance.status === 'stale',
|
||||
)
|
||||
|
||||
const stackItems = computed<InstanceAdmonitionItem[]>(() => {
|
||||
const items: InstanceAdmonitionItem[] = []
|
||||
|
||||
if (sharedInstanceWrongAccount.value) {
|
||||
items.push({
|
||||
id: 'shared-instance-wrong-account',
|
||||
type: 'warning',
|
||||
dismissible: false,
|
||||
kind: 'shared-instance-wrong-account',
|
||||
})
|
||||
}
|
||||
|
||||
if (props.sharedInstanceUnavailableReason && !sharedInstanceUnavailableDismissed.value) {
|
||||
items.push({
|
||||
id: 'shared-instance-unavailable',
|
||||
type: 'warning',
|
||||
dismissible: true,
|
||||
kind: 'shared-instance-unavailable',
|
||||
})
|
||||
}
|
||||
|
||||
if (showSharedInstancePublishAdmonition.value) {
|
||||
items.push({
|
||||
id: 'shared-instance-stale',
|
||||
type: 'warning',
|
||||
dismissible: false,
|
||||
kind: 'shared-instance-stale',
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.instance.id, props.sharedInstanceUnavailableReason],
|
||||
() => {
|
||||
sharedInstanceUnavailableDismissed.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { defineMessages } from '@modrinth/ui'
|
||||
|
||||
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
|
||||
|
||||
export const instanceAdmonitionsMessages = defineMessages({
|
||||
sharedInstanceChangesHeader: {
|
||||
id: 'app.instance.admonitions.shared-instance.changes-header',
|
||||
defaultMessage: "Your changes haven't been shared yet",
|
||||
},
|
||||
sharedInstanceChangesBody: {
|
||||
id: 'app.instance.admonitions.shared-instance.changes-body',
|
||||
defaultMessage: "Your local instance is ahead of the users you've shared it with.",
|
||||
},
|
||||
sharedInstancePublishButton: {
|
||||
id: 'app.instance.admonitions.shared-instance.publish-button',
|
||||
defaultMessage: 'Push update',
|
||||
},
|
||||
sharedInstancePublishingButton: {
|
||||
id: 'app.instance.admonitions.shared-instance.publishing-button',
|
||||
defaultMessage: 'Pushing...',
|
||||
},
|
||||
sharedInstanceReviewingButton: {
|
||||
id: 'app.instance.admonitions.shared-instance.reviewing-button',
|
||||
defaultMessage: 'Reviewing...',
|
||||
},
|
||||
sharedInstanceReviewHeader: {
|
||||
id: 'app.instance.admonitions.shared-instance.review-header',
|
||||
defaultMessage: 'Review changes',
|
||||
},
|
||||
sharedInstanceReviewAdmonitionHeader: {
|
||||
id: 'app.instance.admonitions.shared-instance.review-admonition-header',
|
||||
defaultMessage: 'Push update',
|
||||
},
|
||||
sharedInstanceReviewDescription: {
|
||||
id: 'app.instance.admonitions.shared-instance.review-description',
|
||||
defaultMessage:
|
||||
'Review the content changes that will be shared with everyone using this instance.',
|
||||
},
|
||||
sharedInstanceAddedLabel: {
|
||||
id: 'app.instance.admonitions.shared-instance.added-label',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
sharedInstanceRemovedLabel: {
|
||||
id: 'app.instance.admonitions.shared-instance.removed-label',
|
||||
defaultMessage: 'Removed',
|
||||
},
|
||||
sharedInstanceUnavailableTitle: {
|
||||
id: 'instance.shared-instance.unavailable.title',
|
||||
defaultMessage: 'Shared instance no longer available',
|
||||
},
|
||||
sharedInstanceUnavailableText: {
|
||||
id: 'instance.shared-instance.unavailable.text-v2',
|
||||
defaultMessage:
|
||||
"Your local instance is still available, but it is no longer linked and won't receive updates.",
|
||||
},
|
||||
sharedInstanceDeletedText: {
|
||||
id: 'instance.shared-instance.unavailable.deleted-text-v2',
|
||||
defaultMessage:
|
||||
"The shared instance was deleted. Your local instance is still available, but it is no longer linked and won't receive updates.",
|
||||
},
|
||||
sharedInstanceAccessRevokedText: {
|
||||
id: 'instance.shared-instance.unavailable.access-revoked-text-v2',
|
||||
defaultMessage:
|
||||
"Your access to the shared instance was revoked. Your local instance is still available, but it is no longer linked and won't receive updates.",
|
||||
},
|
||||
sharedInstanceUnavailableFallbackManager: {
|
||||
id: 'instance.shared-instance.unavailable.manager-fallback',
|
||||
defaultMessage: 'the instance manager',
|
||||
},
|
||||
sharedInstanceErrorTitle: {
|
||||
id: 'instance.shared-instance.error.title',
|
||||
defaultMessage: 'Something has gone wrong',
|
||||
},
|
||||
sharedInstanceWrongAccountHeader: {
|
||||
id: 'app.instance.shared-instance-wrong-account.warning-header',
|
||||
defaultMessage: 'You are using the wrong Modrinth account',
|
||||
},
|
||||
sharedInstanceSignedOutHeader: {
|
||||
id: 'app.instance.shared-instance-wrong-account.signed-out-header',
|
||||
defaultMessage: 'You need to sign in to Modrinth',
|
||||
},
|
||||
sharedInstanceWrongAccountSignInAs: {
|
||||
id: 'app.instance.shared-instance-wrong-account.sign-in-as-label',
|
||||
defaultMessage: 'Sign in as',
|
||||
},
|
||||
sharedInstanceWrongAccountUserBody: {
|
||||
id: 'app.instance.shared-instance-wrong-account.user-admonition-body-v2',
|
||||
defaultMessage: 'to receive updates for this shared instance.',
|
||||
},
|
||||
sharedInstanceWrongAccountOwnerBody: {
|
||||
id: 'app.instance.shared-instance-wrong-account.owner-admonition-body-v2',
|
||||
defaultMessage: "to manage this shared instance. You won't be able to push updates to users.",
|
||||
},
|
||||
sharedInstanceWrongAccountFallbackUsername: {
|
||||
id: 'app.instance.shared-instance-wrong-account.fallback-username',
|
||||
defaultMessage: 'the linked account',
|
||||
},
|
||||
})
|
||||
|
||||
export function sharedInstanceUnavailableTextMessage(
|
||||
reason: SharedInstanceUnavailableReason | null,
|
||||
) {
|
||||
if (reason === 'deleted') return instanceAdmonitionsMessages.sharedInstanceDeletedText
|
||||
if (reason === 'access_revoked') {
|
||||
return instanceAdmonitionsMessages.sharedInstanceAccessRevokedText
|
||||
}
|
||||
|
||||
return instanceAdmonitionsMessages.sharedInstanceUnavailableText
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<Admonition
|
||||
type="warning"
|
||||
inline-actions
|
||||
:header="formatMessage(messages.sharedInstanceChangesHeader)"
|
||||
>
|
||||
{{ formatMessage(messages.sharedInstanceChangesBody) }}
|
||||
<template #actions>
|
||||
<ButtonStyled color="orange">
|
||||
<button class="!h-10" :disabled="isPublishButtonDisabled" @click="reviewChanges">
|
||||
<UploadIcon aria-hidden="true" />
|
||||
{{
|
||||
isPublishing
|
||||
? formatMessage(messages.sharedInstancePublishingButton)
|
||||
: isReviewingPublish
|
||||
? formatMessage(messages.sharedInstanceReviewingButton)
|
||||
: formatMessage(messages.sharedInstancePublishButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
|
||||
<ContentDiffModal
|
||||
ref="publishReviewModal"
|
||||
:header="formatMessage(messages.sharedInstanceReviewHeader)"
|
||||
:admonition-header="formatMessage(messages.sharedInstanceReviewAdmonitionHeader)"
|
||||
:description="formatMessage(messages.sharedInstanceReviewDescription)"
|
||||
:diffs="publishDiffs"
|
||||
:confirm-label="formatMessage(messages.sharedInstancePublishButton)"
|
||||
:confirm-icon="UploadIcon"
|
||||
:added-label="formatMessage(messages.sharedInstanceAddedLabel)"
|
||||
:removed-label="formatMessage(messages.sharedInstanceRemovedLabel)"
|
||||
@confirm="publishChanges"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
type ContentDiffItem,
|
||||
ContentDiffModal,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
getErrorMessage,
|
||||
getSharedInstanceUnavailableReason,
|
||||
isSharedInstanceUnavailableError,
|
||||
type SharedInstanceUnavailableReason,
|
||||
} from '@/helpers/install'
|
||||
import { get_shared_instance_publish_preview, publish_shared_instance } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import {
|
||||
instanceAdmonitionsMessages as messages,
|
||||
sharedInstanceUnavailableTextMessage,
|
||||
} from './instance-admonitions-messages'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
published: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const isPublishing = ref(false)
|
||||
const isReviewingPublish = ref(false)
|
||||
const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
|
||||
const publishDiffs = ref<ContentDiffItem[]>([])
|
||||
|
||||
const isPublishButtonDisabled = computed(() => isPublishing.value || isReviewingPublish.value)
|
||||
|
||||
function notifySharedInstanceUnavailable(reason: SharedInstanceUnavailableReason | null = null) {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: formatMessage(messages.sharedInstanceUnavailableTitle),
|
||||
text: formatMessage(sharedInstanceUnavailableTextMessage(reason), {
|
||||
manager: formatMessage(messages.sharedInstanceUnavailableFallbackManager),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function notifySharedInstanceError(error: unknown) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.sharedInstanceErrorTitle),
|
||||
text: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
|
||||
async function reviewChanges(e?: MouseEvent) {
|
||||
if (isPublishButtonDisabled.value) return
|
||||
|
||||
isReviewingPublish.value = true
|
||||
try {
|
||||
const preview = await get_shared_instance_publish_preview(props.instance.id)
|
||||
if (!preview) {
|
||||
notifySharedInstanceUnavailable()
|
||||
emit('published')
|
||||
return
|
||||
}
|
||||
if (preview.diffs.length === 0) {
|
||||
emit('published')
|
||||
return
|
||||
}
|
||||
|
||||
publishDiffs.value = preview.diffs.map((diff) => ({
|
||||
type: diff.type,
|
||||
projectName: diff.projectName ?? undefined,
|
||||
fileName: diff.fileName ?? undefined,
|
||||
currentVersionName: diff.currentVersionName ?? undefined,
|
||||
newVersionName: diff.newVersionName ?? undefined,
|
||||
disabled: diff.disabled,
|
||||
}))
|
||||
publishReviewModal.value?.show(e)
|
||||
} catch (err) {
|
||||
if (isSharedInstanceUnavailableError(err)) {
|
||||
notifySharedInstanceUnavailable(getSharedInstanceUnavailableReason(err))
|
||||
emit('published')
|
||||
return
|
||||
}
|
||||
|
||||
notifySharedInstanceError(err)
|
||||
} finally {
|
||||
isReviewingPublish.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function publishChanges() {
|
||||
if (isPublishing.value) return
|
||||
|
||||
isPublishing.value = true
|
||||
try {
|
||||
await publish_shared_instance(props.instance.id)
|
||||
emit('published')
|
||||
} catch (err) {
|
||||
if (isSharedInstanceUnavailableError(err)) {
|
||||
notifySharedInstanceUnavailable(getSharedInstanceUnavailableReason(err))
|
||||
emit('published')
|
||||
return
|
||||
}
|
||||
|
||||
notifySharedInstanceError(err)
|
||||
} finally {
|
||||
isPublishing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<Admonition
|
||||
type="warning"
|
||||
:header="formatMessage(messages.sharedInstanceUnavailableTitle)"
|
||||
:dismissible="dismissible"
|
||||
@dismiss="emit('dismiss')"
|
||||
>
|
||||
{{
|
||||
formatMessage(sharedInstanceUnavailableTextMessage(reason ?? null), {
|
||||
manager: manager || formatMessage(messages.sharedInstanceUnavailableFallbackManager),
|
||||
})
|
||||
}}
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Admonition, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
|
||||
|
||||
import {
|
||||
instanceAdmonitionsMessages as messages,
|
||||
sharedInstanceUnavailableTextMessage,
|
||||
} from './instance-admonitions-messages'
|
||||
|
||||
defineProps<{
|
||||
reason?: SharedInstanceUnavailableReason | null
|
||||
manager?: string | null
|
||||
dismissible?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
</script>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<Admonition type="warning" :header="formatMessage(headerMessage)">
|
||||
<span class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
|
||||
<span>{{ formatMessage(messages.sharedInstanceWrongAccountSignInAs) }}</span>
|
||||
<span
|
||||
v-if="sharedInstanceExpectedUser"
|
||||
class="inline-flex max-w-full min-w-0 items-center gap-1.5 align-middle font-semibold text-contrast"
|
||||
>
|
||||
<Avatar
|
||||
:src="sharedInstanceExpectedUser.avatarUrl"
|
||||
:alt="sharedInstanceExpectedUsername"
|
||||
:tint-by="sharedInstanceExpectedUser.tintBy"
|
||||
size="20px"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ sharedInstanceExpectedUsername }}</span>
|
||||
</span>
|
||||
<span v-else class="font-semibold">{{ sharedInstanceExpectedUsername }}</span>
|
||||
<span>{{ formatMessage(bodyMessage) }}</span>
|
||||
</span>
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Admonition, Avatar, useVIntl } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { get_user } from '@/helpers/cache'
|
||||
|
||||
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
|
||||
import type { SharedInstanceRole } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
expectedUserId?: string | null
|
||||
role?: SharedInstanceRole | null
|
||||
signedOut?: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const expectedUserId = computed(() => props.expectedUserId ?? null)
|
||||
const expectedUserQuery = useQuery({
|
||||
queryKey: computed(() => ['user', expectedUserId.value]),
|
||||
queryFn: async () => {
|
||||
if (!expectedUserId.value) return null
|
||||
|
||||
return await get_user(expectedUserId.value, 'bypass').catch(() => null)
|
||||
},
|
||||
enabled: () => !!expectedUserId.value,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const sharedInstanceExpectedUser = computed(() => {
|
||||
const user = expectedUserQuery.data.value
|
||||
if (!user) return null
|
||||
|
||||
return {
|
||||
username: user.username,
|
||||
avatarUrl: user.avatar_url ?? undefined,
|
||||
tintBy: user.id,
|
||||
}
|
||||
})
|
||||
const sharedInstanceExpectedUsername = computed(
|
||||
() =>
|
||||
sharedInstanceExpectedUser.value?.username ||
|
||||
formatMessage(messages.sharedInstanceWrongAccountFallbackUsername),
|
||||
)
|
||||
const headerMessage = computed(() =>
|
||||
props.signedOut
|
||||
? messages.sharedInstanceSignedOutHeader
|
||||
: messages.sharedInstanceWrongAccountHeader,
|
||||
)
|
||||
const bodyMessage = computed(() =>
|
||||
props.role === 'owner'
|
||||
? messages.sharedInstanceWrongAccountOwnerBody
|
||||
: messages.sharedInstanceWrongAccountUserBody,
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { StackedAdmonitionItem } from '@modrinth/ui'
|
||||
|
||||
export type InstanceAdmonitionKind =
|
||||
| 'shared-instance-stale'
|
||||
| 'shared-instance-unavailable'
|
||||
| 'shared-instance-wrong-account'
|
||||
|
||||
export type InstanceAdmonitionItem = StackedAdmonitionItem & {
|
||||
kind: InstanceAdmonitionKind
|
||||
}
|
||||
|
||||
export type SharedInstanceRole = 'owner' | 'member'
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
edit,
|
||||
get_linked_modpack_info,
|
||||
list,
|
||||
unlink_shared_instance,
|
||||
unpublish_shared_instance,
|
||||
update_managed_modrinth_version,
|
||||
update_repair_modrinth,
|
||||
} from '@/helpers/instance'
|
||||
@@ -111,9 +113,17 @@ debug('metadata queries configured', {
|
||||
const isModrinthLinkedModpack = computed(
|
||||
() =>
|
||||
instance.value.link?.type === 'modrinth_modpack' ||
|
||||
instance.value.link?.type === 'server_project_modpack',
|
||||
instance.value.link?.type === 'server_project_modpack' ||
|
||||
(instance.value.link?.type === 'shared_instance' &&
|
||||
!!instance.value.link.modpack_project_id &&
|
||||
!!instance.value.link.modpack_version_id),
|
||||
)
|
||||
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
|
||||
const isSharedInstanceManagedModpack = computed(
|
||||
() => instance.value.shared_instance?.role === 'member',
|
||||
)
|
||||
const canUnpublishSharedInstance = computed(() => instance.value.shared_instance?.role === 'owner')
|
||||
const canUnlinkSharedInstance = computed(() => instance.value.shared_instance?.role === 'member')
|
||||
|
||||
const modpackInfoQuery = useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
|
||||
@@ -124,6 +134,8 @@ const modpackInfo = modpackInfoQuery.data
|
||||
|
||||
const repairing = ref(false)
|
||||
const reinstalling = ref(false)
|
||||
const unpublishingSharedInstance = ref(false)
|
||||
const unlinkingSharedInstance = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
loaderVersion: {
|
||||
@@ -208,12 +220,19 @@ provideInstallationSettings({
|
||||
}
|
||||
return rows
|
||||
}),
|
||||
isLinked: computed(() => isModrinthLinkedModpack.value || isImportedModpack.value),
|
||||
isLinked: computed(
|
||||
() =>
|
||||
isModrinthLinkedModpack.value ||
|
||||
isImportedModpack.value ||
|
||||
isSharedInstanceManagedModpack.value,
|
||||
),
|
||||
isBusy: computed(
|
||||
() =>
|
||||
instance.value.install_stage !== 'installed' ||
|
||||
repairing.value ||
|
||||
reinstalling.value ||
|
||||
unlinkingSharedInstance.value ||
|
||||
unpublishingSharedInstance.value ||
|
||||
!!offline,
|
||||
),
|
||||
skipNonEssentialWarnings,
|
||||
@@ -396,6 +415,48 @@ provideInstallationSettings({
|
||||
debug('unlinkModpack: done')
|
||||
},
|
||||
|
||||
async unpublishSharedInstance() {
|
||||
debug('unpublishSharedInstance: called', { instanceId: instance.value.id })
|
||||
unpublishingSharedInstance.value = true
|
||||
try {
|
||||
await unpublish_shared_instance(instance.value.id)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['sharedInstanceUsers', instance.value.id],
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.id],
|
||||
})
|
||||
onUnlinked()
|
||||
debug('unpublishSharedInstance: done')
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
debug('unpublishSharedInstance: failed', { error })
|
||||
} finally {
|
||||
unpublishingSharedInstance.value = false
|
||||
}
|
||||
},
|
||||
|
||||
async unlinkSharedInstance() {
|
||||
debug('unlinkSharedInstance: called', { instanceId: instance.value.id })
|
||||
unlinkingSharedInstance.value = true
|
||||
try {
|
||||
await unlink_shared_instance(instance.value.id)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['sharedInstanceUsers', instance.value.id],
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.id],
|
||||
})
|
||||
onUnlinked()
|
||||
debug('unlinkSharedInstance: done')
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
debug('unlinkSharedInstance: failed', { error })
|
||||
} finally {
|
||||
unlinkingSharedInstance.value = false
|
||||
}
|
||||
},
|
||||
|
||||
getCachedModpackVersions: () => null,
|
||||
async fetchModpackVersions() {
|
||||
debug('fetchModpackVersions: called', {
|
||||
@@ -437,9 +498,17 @@ provideInstallationSettings({
|
||||
isServer: false,
|
||||
isApp: true,
|
||||
showModpackVersionActions: computed(
|
||||
() => isModrinthLinkedModpack.value && !isMinecraftServer.value,
|
||||
() =>
|
||||
isModrinthLinkedModpack.value &&
|
||||
!isMinecraftServer.value &&
|
||||
!isSharedInstanceManagedModpack.value,
|
||||
),
|
||||
isLocalFile: isImportedModpack,
|
||||
isSharedInstanceManagedModpack,
|
||||
canUnpublishSharedInstance,
|
||||
unpublishingSharedInstance,
|
||||
canUnlinkSharedInstance,
|
||||
unlinkingSharedInstance,
|
||||
repairing,
|
||||
reinstalling,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
|
||||
<div v-if="requiredContentProject" class="flex flex-col gap-6 max-w-[500px]">
|
||||
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true" no-padding>
|
||||
<div
|
||||
v-if="mode === 'server-project' && requiredContentProject"
|
||||
class="flex flex-col gap-6 max-w-[500px] p-6 pb-2"
|
||||
>
|
||||
<Admonition type="info" :header="formatMessage(messages.contentRequired)">
|
||||
{{ formatMessage(messages.serverRequiresMods) }}
|
||||
</Admonition>
|
||||
@@ -37,13 +40,70 @@
|
||||
<span class="text-sm text-secondary">
|
||||
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
|
||||
<template v-if="modCount">
|
||||
· {{ formatMessage(messages.modCount, { count: modCount }) }}
|
||||
<BulletDivider /> {{ formatMessage(messages.modCount, { count: modCount }) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="mode === 'shared-instance' && sharedInstance"
|
||||
class="flex flex-col gap-6 max-w-[500px] p-6 pb-2"
|
||||
>
|
||||
<Admonition type="warning" :header="formatMessage(messages.trustWarningHeader)">
|
||||
{{ formatMessage(messages.trustWarningDescription) }}
|
||||
</Admonition>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.sharedInstance)
|
||||
}}</span>
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="openViewContents">
|
||||
<EyeIcon />
|
||||
{{ formatMessage(messages.viewContents) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
|
||||
<Avatar
|
||||
:src="sharedInstance.preview.iconUrl"
|
||||
:alt="sharedInstance.preview.name"
|
||||
size="48px"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-semibold text-contrast">{{ sharedInstance.preview.name }}</span>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ sharedInstanceLoaderDisplay }} {{ sharedInstance.preview.gameVersion }}
|
||||
<template v-if="sharedInstance.preview.modCount">
|
||||
<BulletDivider />
|
||||
{{
|
||||
formatMessage(messages.modCount, {
|
||||
count: sharedInstance.preview.modCount,
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="sharedInstance.preview.externalFileCount"
|
||||
class="flex items-center gap-2 text-orange"
|
||||
>
|
||||
<IssuesIcon class="h-5 w-5 flex-none" />
|
||||
<span>{{
|
||||
formatMessage(messages.externalFileWarning, {
|
||||
count: sharedInstance.preview.externalFileCount,
|
||||
})
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
@@ -65,18 +125,20 @@
|
||||
|
||||
<ModpackContentModal
|
||||
ref="modpackContentModal"
|
||||
:modpack-name="project?.name ?? ''"
|
||||
:modpack-icon-url="project?.icon_url ?? undefined"
|
||||
:header="contentModalHeader"
|
||||
:modpack-name="contentModalName"
|
||||
:modpack-icon-url="contentModalIconUrl"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
|
||||
import { DownloadIcon, EyeIcon, IssuesIcon, XIcon } from '@modrinth/assets'
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
BulletDivider,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
@@ -89,14 +151,21 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
|
||||
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import type { SharedInstanceInstallPreview } from '@/helpers/install'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const mode = ref<'server-project' | 'shared-instance'>('server-project')
|
||||
const modpackVersionId = ref<string | null>(null)
|
||||
const modpackVersion = ref<Labrinth.Versions.v2.Version | null>(null)
|
||||
const project = ref<Labrinth.Projects.v3.Project | null>(null)
|
||||
const requiredContentProject = ref<Labrinth.Projects.v2.Project | null>(null)
|
||||
const onInstallComplete = ref<() => void>(() => {})
|
||||
const sharedInstance = ref<{
|
||||
preview: SharedInstanceInstallPreview
|
||||
invitedByUsername?: string | null
|
||||
} | null>(null)
|
||||
const onSharedInstanceInstall = ref<() => void | Promise<void>>(() => {})
|
||||
const { formatMessage } = useVIntl()
|
||||
const { installServerProject, startInstallingServer, stopInstallingServer } = injectServerInstall()
|
||||
|
||||
@@ -111,6 +180,28 @@ const loaderDisplay = computed(() => {
|
||||
})
|
||||
|
||||
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
|
||||
const sharedInstanceLoaderDisplay = computed(() => {
|
||||
const loader = sharedInstance.value?.preview.loader
|
||||
if (!loader) return ''
|
||||
return formatLoader(formatMessage, loader)
|
||||
})
|
||||
const contentModalName = computed(() =>
|
||||
mode.value === 'shared-instance'
|
||||
? (sharedInstance.value?.preview.name ?? '')
|
||||
: (project.value?.name ?? ''),
|
||||
)
|
||||
const contentModalIconUrl = computed(() =>
|
||||
mode.value === 'shared-instance'
|
||||
? (sharedInstance.value?.preview.iconUrl ?? undefined)
|
||||
: (project.value?.icon_url ?? undefined),
|
||||
)
|
||||
const contentModalHeader = computed(() =>
|
||||
mode.value === 'shared-instance' ? formatMessage(messages.sharedInstanceContent) : undefined,
|
||||
)
|
||||
|
||||
type VersionDependency = Labrinth.Versions.v2.Dependency & {
|
||||
version_id?: string
|
||||
}
|
||||
|
||||
async function fetchData(versionId: string) {
|
||||
// cache is making version null for some reason so bypassing for now
|
||||
@@ -123,6 +214,15 @@ async function fetchData(versionId: string) {
|
||||
|
||||
async function handleAccept() {
|
||||
hide()
|
||||
if (mode.value === 'shared-instance') {
|
||||
try {
|
||||
await onSharedInstanceInstall.value()
|
||||
} catch (error) {
|
||||
console.error('Failed to install shared instance from InstallToPlayModal:', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const serverProjectId = project.value?.id
|
||||
startInstallingServer(serverProjectId)
|
||||
try {
|
||||
@@ -142,6 +242,11 @@ function handleDecline() {
|
||||
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
|
||||
|
||||
async function openViewContents() {
|
||||
if (mode.value === 'shared-instance') {
|
||||
await openSharedInstanceContents()
|
||||
return
|
||||
}
|
||||
|
||||
modpackContentModal.value?.showLoading()
|
||||
try {
|
||||
// Ensure version data is available — the useQuery may not have resolved yet
|
||||
@@ -149,57 +254,7 @@ async function openViewContents() {
|
||||
const version =
|
||||
modpackVersion.value ?? (versionId ? await get_version(versionId, 'must_revalidate') : null)
|
||||
|
||||
const deps = version?.dependencies ?? []
|
||||
|
||||
const projectIds = deps
|
||||
.map((d: { project_id?: string }) => d.project_id)
|
||||
.filter((id: string | undefined): id is string => !!id)
|
||||
|
||||
const versionIds = deps
|
||||
.map((d: { version_id?: string }) => d.version_id)
|
||||
.filter((id: string | undefined): id is string => !!id)
|
||||
|
||||
const projects: Labrinth.Projects.v2.Project[] =
|
||||
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
|
||||
|
||||
const versions: Labrinth.Versions.v2.Version[] =
|
||||
versionIds.length > 0 ? await get_version_many(versionIds, 'must_revalidate') : []
|
||||
|
||||
const projectMap = new Map(projects.map((p: Labrinth.Projects.v2.Project) => [p.id, p]))
|
||||
|
||||
const contentItems: ContentItem[] = deps.map(
|
||||
(dep: Labrinth.Versions.v2.Dependency): ContentItem => {
|
||||
const depProject = dep.project_id ? projectMap.get(dep.project_id) : null
|
||||
// @ts-expect-error - version_id is missing from the type for some reason
|
||||
const depVersion = dep.version_id
|
||||
? // @ts-expect-error - version_id is missing from the type for some reason
|
||||
versions.find((v: Labrinth.Versions.v2.Version) => v.id === dep.version_id)
|
||||
: null
|
||||
|
||||
return {
|
||||
file_name: dep.file_name ?? depProject?.title ?? 'Unknown',
|
||||
project_type: depProject?.project_type ?? 'mod',
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
project: {
|
||||
id: depProject?.id ?? dep.project_id ?? dep.file_name ?? 'unknown',
|
||||
slug: depProject?.slug ?? dep.project_id ?? 'unknown',
|
||||
title: depProject?.title ?? dep.file_name ?? 'Unknown',
|
||||
icon_url: depProject?.icon_url ?? undefined,
|
||||
},
|
||||
...(depVersion
|
||||
? {
|
||||
version: {
|
||||
id: depVersion.id,
|
||||
file_name: depVersion.files?.[0]?.filename ?? dep.file_name,
|
||||
version_number: depVersion.version_number ?? undefined,
|
||||
date_published: depVersion.date_published ?? undefined,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
},
|
||||
)
|
||||
const contentItems = await contentItemsFromDependencies(version?.dependencies ?? [])
|
||||
modpackContentModal.value?.show(contentItems)
|
||||
} catch (err) {
|
||||
console.error('Failed to load modpack contents:', err)
|
||||
@@ -207,17 +262,156 @@ async function openViewContents() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openSharedInstanceContents() {
|
||||
const preview = sharedInstance.value?.preview
|
||||
if (!preview) return
|
||||
|
||||
modpackContentModal.value?.showLoading()
|
||||
try {
|
||||
const contentItems: ContentItem[] = [
|
||||
...preview.externalFiles.map(sharedExternalFileContentItem),
|
||||
...(await sharedInstanceModpackContentItems(preview)),
|
||||
...(await contentItemsFromVersionIds(
|
||||
preview.contentVersionIds.filter((id) => id !== preview.modpackVersionId),
|
||||
)),
|
||||
]
|
||||
|
||||
modpackContentModal.value?.show(contentItems)
|
||||
} catch (err) {
|
||||
console.error('Failed to load shared instance contents:', err)
|
||||
modpackContentModal.value?.show([])
|
||||
}
|
||||
}
|
||||
|
||||
async function sharedInstanceModpackContentItems(preview: SharedInstanceInstallPreview) {
|
||||
if (!preview.modpackVersionId) return []
|
||||
|
||||
const version = await get_version(preview.modpackVersionId, 'must_revalidate')
|
||||
return await contentItemsFromDependencies(version?.dependencies ?? [])
|
||||
}
|
||||
|
||||
async function contentItemsFromDependencies(deps: Labrinth.Versions.v2.Dependency[]) {
|
||||
const dependencies = deps as VersionDependency[]
|
||||
const projectIds = unique(
|
||||
dependencies.map((dependency) => dependency.project_id).filter((id): id is string => !!id),
|
||||
)
|
||||
const versionIds = unique(
|
||||
dependencies.map((dependency) => dependency.version_id).filter((id): id is string => !!id),
|
||||
)
|
||||
|
||||
const projects: Labrinth.Projects.v2.Project[] =
|
||||
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
|
||||
const versions: Labrinth.Versions.v2.Version[] =
|
||||
versionIds.length > 0 ? await get_version_many(versionIds, 'must_revalidate') : []
|
||||
const projectMap = new Map(projects.map((depProject) => [depProject.id, depProject]))
|
||||
const versionMap = new Map(versions.map((depVersion) => [depVersion.id, depVersion]))
|
||||
|
||||
return dependencies.map((dependency): ContentItem => {
|
||||
const depProject = dependency.project_id ? projectMap.get(dependency.project_id) : null
|
||||
const depVersion = dependency.version_id ? versionMap.get(dependency.version_id) : null
|
||||
const fileName =
|
||||
depVersion?.files?.[0]?.filename ?? dependency.file_name ?? depProject?.title ?? 'Unknown'
|
||||
const external = !depProject && !depVersion
|
||||
|
||||
return {
|
||||
id: dependency.version_id ?? dependency.project_id ?? fileName,
|
||||
file_name: fileName,
|
||||
project_type: depProject?.project_type ?? 'mod',
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
external,
|
||||
project: {
|
||||
id: depProject?.id ?? dependency.project_id ?? fileName,
|
||||
slug: depProject?.slug ?? dependency.project_id ?? fileName,
|
||||
title: depProject?.title ?? dependency.file_name ?? fileName,
|
||||
icon_url: depProject?.icon_url ?? undefined,
|
||||
},
|
||||
...(depVersion
|
||||
? {
|
||||
version: {
|
||||
id: depVersion.id,
|
||||
file_name: depVersion.files?.[0]?.filename ?? dependency.file_name ?? fileName,
|
||||
version_number: depVersion.version_number ?? undefined,
|
||||
date_published: depVersion.date_published ?? undefined,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function contentItemsFromVersionIds(versionIds: string[]) {
|
||||
const uniqueVersionIds = unique(versionIds)
|
||||
const versions: Labrinth.Versions.v2.Version[] =
|
||||
uniqueVersionIds.length > 0 ? await get_version_many(uniqueVersionIds, 'must_revalidate') : []
|
||||
const projectIds = unique(versions.map((version) => version.project_id).filter(Boolean))
|
||||
const projects: Labrinth.Projects.v2.Project[] =
|
||||
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
|
||||
const projectMap = new Map(projects.map((depProject) => [depProject.id, depProject]))
|
||||
|
||||
return versions.map((version): ContentItem => {
|
||||
const depProject = projectMap.get(version.project_id)
|
||||
const fileName = version.files?.[0]?.filename ?? depProject?.title ?? version.name ?? 'Unknown'
|
||||
|
||||
return {
|
||||
id: version.id,
|
||||
file_name: fileName,
|
||||
project_type: depProject?.project_type ?? 'mod',
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
project: {
|
||||
id: depProject?.id ?? version.project_id,
|
||||
slug: depProject?.slug ?? version.project_id,
|
||||
title: depProject?.title ?? version.name,
|
||||
icon_url: depProject?.icon_url ?? undefined,
|
||||
},
|
||||
version: {
|
||||
id: version.id,
|
||||
file_name: fileName,
|
||||
version_number: version.version_number ?? undefined,
|
||||
date_published: version.date_published ?? undefined,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function sharedExternalFileContentItem(
|
||||
file: SharedInstanceInstallPreview['externalFiles'][number],
|
||||
): ContentItem {
|
||||
return {
|
||||
id: `external:${file.fileType}:${file.fileName}`,
|
||||
file_name: file.fileName,
|
||||
project_type: file.fileType,
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
external: true,
|
||||
project: {
|
||||
id: file.fileName,
|
||||
slug: file.fileName,
|
||||
title: file.fileName,
|
||||
icon_url: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function unique<T>(values: T[]) {
|
||||
return Array.from(new Set(values))
|
||||
}
|
||||
|
||||
async function show(
|
||||
projectVal: Labrinth.Projects.v3.Project,
|
||||
modpackVersionIdVal: string | null = null,
|
||||
callback: () => void = () => {},
|
||||
e?: MouseEvent,
|
||||
) {
|
||||
mode.value = 'server-project'
|
||||
project.value = projectVal
|
||||
sharedInstance.value = null
|
||||
modpackVersionId.value = modpackVersionIdVal
|
||||
modpackVersion.value = null
|
||||
requiredContentProject.value = null
|
||||
onInstallComplete.value = callback
|
||||
onSharedInstanceInstall.value = () => {}
|
||||
|
||||
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
|
||||
|
||||
@@ -225,6 +419,27 @@ async function show(
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function showSharedInstance(
|
||||
instance: {
|
||||
preview: SharedInstanceInstallPreview
|
||||
invitedByUsername?: string | null
|
||||
},
|
||||
install: () => void | Promise<void>,
|
||||
e?: MouseEvent,
|
||||
) {
|
||||
mode.value = 'shared-instance'
|
||||
project.value = null
|
||||
modpackVersionId.value = null
|
||||
modpackVersion.value = null
|
||||
requiredContentProject.value = null
|
||||
onInstallComplete.value = () => {}
|
||||
sharedInstance.value = instance
|
||||
onSharedInstanceInstall.value = install
|
||||
|
||||
hide_ads_window()
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
show_ads_window()
|
||||
@@ -256,6 +471,24 @@ const messages = defineMessages({
|
||||
id: 'app.modal.install-to-play.shared-instance',
|
||||
defaultMessage: 'Shared instance',
|
||||
},
|
||||
sharedInstanceContent: {
|
||||
id: 'app.modal.install-to-play.shared-instance-content',
|
||||
defaultMessage: 'Shared instance content',
|
||||
},
|
||||
trustWarningHeader: {
|
||||
id: 'app.modal.install-to-play.trust-warning-header',
|
||||
defaultMessage: 'Do you trust this user?',
|
||||
},
|
||||
trustWarningDescription: {
|
||||
id: 'app.modal.install-to-play.trust-warning-description',
|
||||
defaultMessage:
|
||||
'A shared instance will install files on your computer and may include content not from Modrinth.',
|
||||
},
|
||||
externalFileWarning: {
|
||||
id: 'app.modal.install-to-play.external-file-warning',
|
||||
defaultMessage:
|
||||
'{count, plural, one {This instance includes # file that is not from Modrinth.} other {This instance includes # files that are not from Modrinth.}}',
|
||||
},
|
||||
modCount: {
|
||||
id: 'app.modal.install-to-play.mod-count',
|
||||
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
|
||||
@@ -270,5 +503,5 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
defineExpose({ show, showSharedInstance, hide })
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
:on-hide="() => finish(false)"
|
||||
no-padding
|
||||
max-width="548px"
|
||||
width="100%"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-6 p-6">
|
||||
<div class="flex flex-col gap-2 px-3">
|
||||
<h2 class="m-0 text-xl font-semibold leading-7 text-contrast">
|
||||
{{ formatMessage(messages.signInHeading) }}
|
||||
</h2>
|
||||
<p class="m-0 text-base leading-6 text-primary">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="w-full !shadow-none"
|
||||
type="button"
|
||||
:disabled="authenticating !== null"
|
||||
@click="authenticate('sign-up')"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="authenticating === 'sign-up'"
|
||||
aria-hidden="true"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<UserPlusIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.createAccountButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="w-full"
|
||||
type="button"
|
||||
:disabled="authenticating !== null"
|
||||
@click="authenticate('sign-in')"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="authenticating === 'sign-in'"
|
||||
aria-hidden="true"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<LogInIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.signInButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
|
||||
<IntlFormatted :message-id="messages.supportPrompt">
|
||||
<template #support="{ children }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline cursor-pointer border-0 bg-transparent p-0 text-base font-medium leading-6 text-blue hover:underline"
|
||||
@click="openSupport"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</button>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LogInIcon, SpinnerIcon, UserPlusIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, IntlFormatted, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ModrinthAuthFlow } from '@/helpers/mr_auth'
|
||||
|
||||
const props = defineProps<{
|
||||
requestAuth: (flow: ModrinthAuthFlow) => Promise<boolean>
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const authenticating = ref<ModrinthAuthFlow | null>(null)
|
||||
let resolveShow: ((signedIn: boolean) => void) | undefined
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
resolveShow?.(false)
|
||||
const modalInstance = modal.value
|
||||
if (!modalInstance) return Promise.resolve(false)
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveShow = resolve
|
||||
modalInstance.show(event)
|
||||
})
|
||||
}
|
||||
|
||||
function finish(signedIn: boolean) {
|
||||
resolveShow?.(signedIn)
|
||||
resolveShow = undefined
|
||||
}
|
||||
|
||||
async function authenticate(flow: ModrinthAuthFlow) {
|
||||
authenticating.value = flow
|
||||
try {
|
||||
if (await props.requestAuth(flow)) {
|
||||
finish(true)
|
||||
modal.value?.hide()
|
||||
}
|
||||
} finally {
|
||||
authenticating.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function openSupport() {
|
||||
openUrl('https://support.modrinth.com')
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'modal.modrinth-account-required.header',
|
||||
defaultMessage: 'Account required',
|
||||
},
|
||||
signInHeading: {
|
||||
id: 'modal.modrinth-account-required.sign-in-heading',
|
||||
defaultMessage: 'Sign in to a Modrinth account',
|
||||
},
|
||||
description: {
|
||||
id: 'modal.modrinth-account-required.description',
|
||||
defaultMessage:
|
||||
"You'll need to sign into your Modrinth account before you can use this feature.",
|
||||
},
|
||||
createAccountButton: {
|
||||
id: 'modal.modrinth-account-required.create-account-button',
|
||||
defaultMessage: 'Create an account',
|
||||
},
|
||||
signInButton: {
|
||||
id: 'modal.modrinth-account-required.sign-in-button',
|
||||
defaultMessage: 'Sign in to Modrinth',
|
||||
},
|
||||
supportPrompt: {
|
||||
id: 'modal.modrinth-account-required.support-prompt',
|
||||
defaultMessage: 'Having trouble signing in? <support>Get support</support>',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@@ -9,7 +9,9 @@
|
||||
:diffs="normalizedDiffs"
|
||||
:confirm-label="formatMessage(commonMessages.updateButton)"
|
||||
:confirm-icon="DownloadIcon"
|
||||
:show-report-button="true"
|
||||
:added-label="addedLabel"
|
||||
:removed-label="removedLabel"
|
||||
:show-report-button="showReportButton"
|
||||
@confirm="handleUpdate"
|
||||
@cancel="handleDecline"
|
||||
@report="handleReport"
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
type ContentDiffItem,
|
||||
ContentDiffModal,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
@@ -31,7 +34,15 @@ import dayjs from 'dayjs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import { wait_for_install_job } from '@/helpers/install'
|
||||
import {
|
||||
getErrorMessage,
|
||||
getSharedInstanceUnavailableReason,
|
||||
install_update_shared_instance,
|
||||
isSharedInstanceUnavailableError,
|
||||
type SharedInstanceUnavailableReason,
|
||||
type SharedInstanceUpdatePreview,
|
||||
wait_for_install_job,
|
||||
} from '@/helpers/install'
|
||||
import { update_managed_modrinth_version } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
@@ -74,24 +85,53 @@ type ProjectInfo = {
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
|
||||
type UpdateCompleteCallback = () => void | Promise<void>
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
complete: []
|
||||
sharedInstanceUnavailable: [reason: SharedInstanceUnavailableReason | null]
|
||||
}>()
|
||||
|
||||
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
|
||||
const instance = ref<GameInstance | null>(null)
|
||||
const mode = ref<'server-project' | 'shared-instance'>('server-project')
|
||||
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
|
||||
const diffs = ref<DependencyDiff[]>([])
|
||||
const sharedInstancePreview = ref<SharedInstanceUpdatePreview | null>(null)
|
||||
const modpackVersionId = ref<string | null>(null)
|
||||
const modpackVersion = ref<Version | null>(null)
|
||||
|
||||
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
|
||||
diffs.value.map((diff) => ({
|
||||
const normalizedDiffs = computed<ContentDiffItem[]>(() => {
|
||||
if (mode.value === 'shared-instance') {
|
||||
return (
|
||||
sharedInstancePreview.value?.diffs.map((diff) => ({
|
||||
type: diff.type,
|
||||
projectName: diff.projectName ?? undefined,
|
||||
fileName: diff.fileName ?? undefined,
|
||||
currentVersionName: diff.currentVersionName ?? undefined,
|
||||
newVersionName: diff.newVersionName ?? undefined,
|
||||
disabled: diff.disabled,
|
||||
})) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
return diffs.value.map((diff) => ({
|
||||
type: diff.type,
|
||||
projectName: diff.project?.title,
|
||||
fileName: diff.fileName,
|
||||
currentVersionName: diff.currentVersion?.version_number,
|
||||
newVersionName: diff.newVersion?.version_number,
|
||||
})),
|
||||
}))
|
||||
})
|
||||
const showReportButton = computed(() => mode.value !== 'shared-instance')
|
||||
const addedLabel = computed(() =>
|
||||
mode.value === 'shared-instance' ? formatMessage(messages.sharedInstanceAddedLabel) : undefined,
|
||||
)
|
||||
const removedLabel = computed(() =>
|
||||
mode.value === 'shared-instance' ? formatMessage(messages.sharedInstanceRemovedLabel) : undefined,
|
||||
)
|
||||
|
||||
async function computeDependencyDiffs(
|
||||
@@ -238,7 +278,30 @@ watch(
|
||||
)
|
||||
|
||||
async function handleUpdate() {
|
||||
hide()
|
||||
if (mode.value === 'shared-instance') {
|
||||
try {
|
||||
if (instance.value) {
|
||||
const job = await install_update_shared_instance(instance.value.id)
|
||||
await wait_for_install_job(job.job_id)
|
||||
await onUpdateComplete.value()
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSharedInstanceUnavailableError(error)) {
|
||||
emit('sharedInstanceUnavailable', getSharedInstanceUnavailableReason(error))
|
||||
return
|
||||
}
|
||||
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.sharedInstanceErrorTitle),
|
||||
text: getErrorMessage(error),
|
||||
})
|
||||
} finally {
|
||||
emit('complete')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const serverProjectId = instance.value?.link?.project_id
|
||||
if (serverProjectId) startInstallingServer(serverProjectId)
|
||||
try {
|
||||
@@ -251,6 +314,7 @@ async function handleUpdate() {
|
||||
console.error('Error updating instance:', error)
|
||||
} finally {
|
||||
if (serverProjectId) stopInstallingServer(serverProjectId)
|
||||
emit('complete')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +325,7 @@ function handleReport() {
|
||||
}
|
||||
|
||||
function handleDecline() {
|
||||
hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function show(
|
||||
@@ -270,12 +334,29 @@ function show(
|
||||
callback: UpdateCompleteCallback = () => {},
|
||||
e?: MouseEvent,
|
||||
) {
|
||||
mode.value = 'server-project'
|
||||
instance.value = instanceVal
|
||||
sharedInstancePreview.value = null
|
||||
modpackVersionId.value = modpackVersionIdVal
|
||||
onUpdateComplete.value = callback
|
||||
diffModal.value?.show(e)
|
||||
}
|
||||
|
||||
function showSharedInstance(
|
||||
instanceVal: GameInstance,
|
||||
preview: SharedInstanceUpdatePreview,
|
||||
callback: UpdateCompleteCallback = () => {},
|
||||
e?: MouseEvent,
|
||||
) {
|
||||
mode.value = 'shared-instance'
|
||||
instance.value = instanceVal
|
||||
sharedInstancePreview.value = preview
|
||||
modpackVersionId.value = null
|
||||
diffs.value = []
|
||||
onUpdateComplete.value = callback
|
||||
diffModal.value?.show(e)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
diffModal.value?.hide()
|
||||
}
|
||||
@@ -292,7 +373,19 @@ const messages = defineMessages({
|
||||
updateRequiredDescription: {
|
||||
id: 'app.modal.update-to-play.update-required-description',
|
||||
defaultMessage:
|
||||
'An update is required to play {name}. Please update to the latest version to launch the game.',
|
||||
'An update is required to play {name}. Please update to latest version to launch the game.',
|
||||
},
|
||||
sharedInstanceAddedLabel: {
|
||||
id: 'app.modal.update-to-play.shared-instance-added-label',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
sharedInstanceRemovedLabel: {
|
||||
id: 'app.modal.update-to-play.shared-instance-removed-label',
|
||||
defaultMessage: 'Removed',
|
||||
},
|
||||
sharedInstanceErrorTitle: {
|
||||
id: 'instance.shared-instance.error.title',
|
||||
defaultMessage: 'Something has gone wrong',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -301,5 +394,5 @@ const hasUpdate = computed(() => {
|
||||
return modpackVersionId.value != null && modpackVersionId.value !== instance.value.link.version_id
|
||||
})
|
||||
|
||||
defineExpose({ show, hide, hasUpdate })
|
||||
defineExpose({ show, showSharedInstance, hide, hasUpdate })
|
||||
</script>
|
||||
|
||||
@@ -59,6 +59,10 @@ const messages = defineMessages({
|
||||
id: 'app.action-bar.install.unknown-instance',
|
||||
defaultMessage: 'Unknown instance',
|
||||
},
|
||||
updatingSharedContent: {
|
||||
id: 'app.action-bar.install.updating-shared-content',
|
||||
defaultMessage: 'Updating shared content',
|
||||
},
|
||||
})
|
||||
|
||||
const phaseMessages = defineMessages({
|
||||
@@ -187,6 +191,9 @@ export async function useInstallJobNotifications(opts: {
|
||||
version: job.details.major_version,
|
||||
})
|
||||
}
|
||||
if (job.kind === 'update_shared_instance' && job.phase === 'downloading_content') {
|
||||
return formatMessage(messages.updatingSharedContent)
|
||||
}
|
||||
return formatMessage(phaseMessages[job.phase])
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import dayjs from 'dayjs'
|
||||
import { get_user_many } from '@/helpers/cache'
|
||||
import type { ModrinthCredentials } from '@/helpers/mr_auth'
|
||||
|
||||
export const friendsQueryKey = (userId?: string | null) => ['friends', userId ?? null] as const
|
||||
|
||||
export type UserStatus = {
|
||||
user_id: string
|
||||
instance_name: string | null
|
||||
@@ -46,6 +48,111 @@ export type FriendWithUserData = {
|
||||
online: boolean
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export type FriendCacheUser = {
|
||||
id: string
|
||||
username: string
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
export async function getFriendsWithUserData(
|
||||
credentials: ModrinthCredentials | null,
|
||||
): Promise<FriendWithUserData[]> {
|
||||
if (!credentials) return []
|
||||
|
||||
const friendsList = await friends()
|
||||
return await transformFriends(friendsList, credentials)
|
||||
}
|
||||
|
||||
export function createPendingFriend(
|
||||
user: FriendCacheUser,
|
||||
currentUserId?: string | null,
|
||||
): FriendWithUserData {
|
||||
return {
|
||||
id: user.id,
|
||||
friend_id: currentUserId ?? null,
|
||||
status: null,
|
||||
last_updated: null,
|
||||
created: dayjs(),
|
||||
username: user.username,
|
||||
accepted: false,
|
||||
online: false,
|
||||
avatar: user.avatarUrl ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export function getFriendUserId(
|
||||
friend: Pick<FriendWithUserData, 'id' | 'friend_id'>,
|
||||
currentUserId?: string | null,
|
||||
) {
|
||||
return friend.id === currentUserId && friend.friend_id ? friend.friend_id : friend.id
|
||||
}
|
||||
|
||||
export function matchesFriend(
|
||||
friend: FriendWithUserData,
|
||||
id: string,
|
||||
username: string,
|
||||
currentUserId?: string | null,
|
||||
) {
|
||||
const friendId = getFriendUserId(friend, currentUserId)
|
||||
return (
|
||||
normalizeFriendKey(friendId) === normalizeFriendKey(id) ||
|
||||
normalizeFriendKey(friend.username) === normalizeFriendKey(username)
|
||||
)
|
||||
}
|
||||
|
||||
export function upsertCachedFriend(
|
||||
friends: FriendWithUserData[],
|
||||
friend: FriendWithUserData,
|
||||
currentUserId?: string | null,
|
||||
) {
|
||||
const existingFriend = friends.find((cachedFriend) =>
|
||||
matchesFriend(cachedFriend, friend.id, friend.username, currentUserId),
|
||||
)
|
||||
|
||||
if (!existingFriend) return [friend, ...friends]
|
||||
|
||||
return friends.map((cachedFriend) =>
|
||||
matchesFriend(cachedFriend, friend.id, friend.username, currentUserId)
|
||||
? {
|
||||
...cachedFriend,
|
||||
...friend,
|
||||
id: cachedFriend.id,
|
||||
friend_id: cachedFriend.friend_id,
|
||||
}
|
||||
: cachedFriend,
|
||||
)
|
||||
}
|
||||
|
||||
export function acceptCachedFriend(
|
||||
friends: FriendWithUserData[],
|
||||
id: string,
|
||||
username: string,
|
||||
currentUserId?: string | null,
|
||||
) {
|
||||
return friends.map((friend) =>
|
||||
matchesFriend(friend, id, username, currentUserId)
|
||||
? {
|
||||
...friend,
|
||||
accepted: true,
|
||||
}
|
||||
: friend,
|
||||
)
|
||||
}
|
||||
|
||||
export function removeCachedFriend(
|
||||
friends: FriendWithUserData[],
|
||||
id: string,
|
||||
username: string,
|
||||
currentUserId?: string | null,
|
||||
) {
|
||||
return friends.filter((friend) => !matchesFriend(friend, id, username, currentUserId))
|
||||
}
|
||||
|
||||
function normalizeFriendKey(value: string) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export async function transformFriends(
|
||||
friends: UserFriend[],
|
||||
credentials: ModrinthCredentials | null,
|
||||
|
||||
@@ -44,6 +44,108 @@ export interface InstallPostInstallEdit {
|
||||
link?: InstanceLink | null
|
||||
}
|
||||
|
||||
export interface SharedInstanceInstallPreview {
|
||||
name: string
|
||||
iconUrl?: string | null
|
||||
gameVersion: string
|
||||
loader: InstanceLoader
|
||||
modCount: number
|
||||
externalFileCount: number
|
||||
modpackVersionId?: string | null
|
||||
contentVersionIds: string[]
|
||||
externalFiles: SharedInstanceExternalFilePreview[]
|
||||
}
|
||||
|
||||
export interface SharedInstanceInviteInstallPreview {
|
||||
sharedInstanceId: string
|
||||
managerId?: string | null
|
||||
serverManagerName?: string | null
|
||||
serverManagerIconUrl?: string | null
|
||||
preview: SharedInstanceInstallPreview
|
||||
}
|
||||
|
||||
export interface SharedInstanceExternalFilePreview {
|
||||
fileName: string
|
||||
fileType: string
|
||||
}
|
||||
|
||||
export interface SharedInstanceUpdatePreview {
|
||||
sharedInstanceId: string
|
||||
currentVersion?: number | null
|
||||
latestVersion: number
|
||||
updateAvailable: boolean
|
||||
diffs: SharedInstanceUpdateDiff[]
|
||||
}
|
||||
|
||||
export interface SharedInstanceUpdateDiff {
|
||||
type:
|
||||
| 'added'
|
||||
| 'removed'
|
||||
| 'updated'
|
||||
| 'modpack_linked'
|
||||
| 'modpack_updated'
|
||||
| 'modpack_unlinked'
|
||||
| 'game_version_updated'
|
||||
| 'loader_updated'
|
||||
projectId?: string | null
|
||||
projectName?: string | null
|
||||
fileName?: string | null
|
||||
currentVersionName?: string | null
|
||||
newVersionName?: string | null
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable'
|
||||
export const SHARED_INSTANCE_DELETED_ERROR_CODE = 'shared_instance_deleted'
|
||||
export const SHARED_INSTANCE_ACCESS_REVOKED_ERROR_CODE = 'shared_instance_access_revoked'
|
||||
|
||||
export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked' | 'unknown'
|
||||
|
||||
function errorSearchText(error: unknown, seen = new Set<object>()): string {
|
||||
if (error == null) return ''
|
||||
if (typeof error === 'string') return error
|
||||
if (typeof error === 'number' || typeof error === 'boolean') return String(error)
|
||||
if (error instanceof Error) {
|
||||
return [error.name, error.message, error.cause ? errorSearchText(error.cause, seen) : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
if (typeof error === 'object') {
|
||||
if (seen.has(error)) return ''
|
||||
seen.add(error)
|
||||
const value = error as {
|
||||
message?: unknown
|
||||
error?: unknown
|
||||
cause?: unknown
|
||||
details?: unknown
|
||||
}
|
||||
return [value.message, value.error, value.cause, value.details, ...Object.values(error)]
|
||||
.map((value) => errorSearchText(value, seen))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function isSharedInstanceUnavailableError(error: unknown) {
|
||||
return getSharedInstanceUnavailableReason(error) !== null
|
||||
}
|
||||
|
||||
export function getSharedInstanceUnavailableReason(
|
||||
error: unknown,
|
||||
): SharedInstanceUnavailableReason | null {
|
||||
const text = errorSearchText(error)
|
||||
if (text.includes(SHARED_INSTANCE_DELETED_ERROR_CODE)) return 'deleted'
|
||||
if (text.includes(SHARED_INSTANCE_ACCESS_REVOKED_ERROR_CODE)) return 'access_revoked'
|
||||
if (text.includes(SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE)) return 'unknown'
|
||||
return null
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
return errorSearchText(error) || 'Unknown error'
|
||||
}
|
||||
|
||||
export type InstallJobStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
@@ -91,6 +193,8 @@ export interface InstallJobSnapshot {
|
||||
kind:
|
||||
| 'create_instance'
|
||||
| 'create_modpack_instance'
|
||||
| 'create_shared_instance'
|
||||
| 'update_shared_instance'
|
||||
| 'import_instance'
|
||||
| 'duplicate_instance'
|
||||
| 'install_existing_instance'
|
||||
@@ -140,6 +244,62 @@ export async function install_create_modpack_instance(
|
||||
})
|
||||
}
|
||||
|
||||
export async function install_get_shared_instance_preview(sharedInstanceId: string, name: string) {
|
||||
return await invoke<SharedInstanceInstallPreview>(
|
||||
'plugin:install|install_get_shared_instance_preview',
|
||||
{
|
||||
sharedInstanceId,
|
||||
name,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function install_accept_shared_instance_invite(inviteId: string) {
|
||||
return await invoke<SharedInstanceInviteInstallPreview>(
|
||||
'plugin:install|install_accept_shared_instance_invite',
|
||||
{
|
||||
inviteId,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function install_get_shared_instance_update_preview(instanceId: string) {
|
||||
return await invoke<SharedInstanceUpdatePreview | null>(
|
||||
'plugin:install|install_get_shared_instance_update_preview',
|
||||
{
|
||||
instanceId,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function install_shared_instance(
|
||||
sharedInstanceId: string,
|
||||
name: string,
|
||||
managerId?: string | null,
|
||||
serverManagerName?: string | null,
|
||||
serverManagerIconUrl?: string | null,
|
||||
) {
|
||||
return await invoke<InstallJobSnapshot>('plugin:install|install_shared_instance', {
|
||||
sharedInstanceId,
|
||||
name,
|
||||
managerId,
|
||||
serverManagerName,
|
||||
serverManagerIconUrl,
|
||||
})
|
||||
}
|
||||
|
||||
export async function install_shared_instance_invite(inviteId: string) {
|
||||
return await invoke<InstallJobSnapshot>('plugin:install|install_shared_instance_invite', {
|
||||
inviteId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function install_update_shared_instance(instanceId: string) {
|
||||
return await invoke<InstallJobSnapshot>('plugin:install|install_update_shared_instance', {
|
||||
instanceId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function install_import_instance(
|
||||
launcherType: string,
|
||||
basePath: string,
|
||||
|
||||
@@ -7,13 +7,14 @@ import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { ContentItem, ContentOwner } from '@modrinth/ui'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
import type { InstallJobSnapshot } from './install'
|
||||
import type { InstallJobSnapshot, SharedInstanceUpdateDiff } from './install'
|
||||
import type {
|
||||
CacheBehaviour,
|
||||
ContentFile,
|
||||
ContentFileProjectType,
|
||||
GameInstance,
|
||||
InstanceLoader,
|
||||
SharedInstanceAttachment,
|
||||
} from './types'
|
||||
|
||||
export async function remove(instanceId: string): Promise<void> {
|
||||
@@ -331,3 +332,82 @@ export async function edit(instanceId: string, editInstance: Partial<GameInstanc
|
||||
export async function edit_icon(instanceId: string, iconPath: string | null): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_edit_icon', { instanceId, iconPath })
|
||||
}
|
||||
|
||||
export type SharedInstanceUsers = {
|
||||
user_ids: string[]
|
||||
users: SharedInstanceUser[]
|
||||
tokens: number
|
||||
}
|
||||
|
||||
export type SharedInstanceJoinType = 'owner' | 'invite' | 'link'
|
||||
|
||||
export type SharedInstanceUser = {
|
||||
id: string
|
||||
joined_at?: string | null
|
||||
join_type: SharedInstanceJoinType
|
||||
last_played?: string | null
|
||||
}
|
||||
|
||||
export interface SharedInstancePublishPreview {
|
||||
sharedInstanceId: string
|
||||
latestVersion: number
|
||||
diffs: SharedInstanceUpdateDiff[]
|
||||
}
|
||||
|
||||
export interface SharedInstanceInviteLink {
|
||||
inviteId: string
|
||||
expiresAt: string
|
||||
maxUses: number
|
||||
}
|
||||
|
||||
export async function get_shared_instance_users(instanceId: string): Promise<SharedInstanceUsers> {
|
||||
return await invoke('plugin:instance|instance_share_get_users', { instanceId })
|
||||
}
|
||||
|
||||
export async function invite_shared_instance_users(
|
||||
instanceId: string,
|
||||
userIds: string[],
|
||||
): Promise<SharedInstanceUsers> {
|
||||
return await invoke('plugin:instance|instance_share_invite_users', { instanceId, userIds })
|
||||
}
|
||||
|
||||
export async function create_shared_instance_invite_link(
|
||||
instanceId: string,
|
||||
options: {
|
||||
maxAgeSeconds?: number
|
||||
maxUses?: number
|
||||
replaceInviteId?: string
|
||||
} = {},
|
||||
): Promise<SharedInstanceInviteLink> {
|
||||
return await invoke('plugin:instance|instance_share_create_invite_link', {
|
||||
instanceId,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export async function remove_shared_instance_users(
|
||||
instanceId: string,
|
||||
userIds: string[],
|
||||
): Promise<SharedInstanceUsers> {
|
||||
return await invoke('plugin:instance|instance_share_remove_users', { instanceId, userIds })
|
||||
}
|
||||
|
||||
export async function get_shared_instance_publish_preview(
|
||||
instanceId: string,
|
||||
): Promise<SharedInstancePublishPreview | null> {
|
||||
return await invoke('plugin:instance|instance_share_get_publish_preview', { instanceId })
|
||||
}
|
||||
|
||||
export async function publish_shared_instance(
|
||||
instanceId: string,
|
||||
): Promise<SharedInstanceAttachment> {
|
||||
return await invoke('plugin:instance|instance_share_publish', { instanceId })
|
||||
}
|
||||
|
||||
export async function unlink_shared_instance(instanceId: string): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_share_unlink', { instanceId })
|
||||
}
|
||||
|
||||
export async function unpublish_shared_instance(instanceId: string): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_share_unpublish', { instanceId })
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ export type ModrinthCredentials = {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export async function login(): Promise<ModrinthCredentials> {
|
||||
return await invoke('plugin:mr-auth|modrinth_login')
|
||||
export type ModrinthAuthFlow = 'sign-in' | 'sign-up'
|
||||
|
||||
export async function login(flow: ModrinthAuthFlow = 'sign-in'): Promise<ModrinthCredentials> {
|
||||
return await invoke('plugin:mr-auth|modrinth_login', { flow })
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
|
||||
+31
-1
@@ -17,6 +17,7 @@ export type GameInstance = {
|
||||
groups: string[]
|
||||
|
||||
link?: InstanceLink | null
|
||||
shared_instance?: SharedInstanceAttachment | null
|
||||
update_channel: ReleaseChannel
|
||||
|
||||
created: Date
|
||||
@@ -86,18 +87,47 @@ export type InstanceLink = InstanceLinkIdentity &
|
||||
}
|
||||
| {
|
||||
type: 'shared_instance'
|
||||
shared_instance_id: string
|
||||
modpack_project_id?: ModrinthId | null
|
||||
modpack_version_id?: ModrinthId | null
|
||||
}
|
||||
)
|
||||
|
||||
export type SharedInstanceAttachment = {
|
||||
id: string
|
||||
role: 'owner' | 'member'
|
||||
manager_id?: string | null
|
||||
server_manager_name?: string | null
|
||||
server_manager_icon_url?: string | null
|
||||
linked_user_id?: string | null
|
||||
status:
|
||||
| 'unknown'
|
||||
| 'up_to_date'
|
||||
| 'update_available'
|
||||
| 'applying'
|
||||
| 'stale'
|
||||
| 'not_ready'
|
||||
| 'error'
|
||||
applied_version?: number | null
|
||||
latest_version?: number | null
|
||||
}
|
||||
|
||||
export type Instance = GameInstance
|
||||
|
||||
type ReleaseChannel = 'release' | 'beta' | 'alpha'
|
||||
|
||||
export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
|
||||
|
||||
export type ContentSourceKind =
|
||||
| 'local'
|
||||
| 'modrinth_modpack'
|
||||
| 'server_project'
|
||||
| 'modrinth_hosting'
|
||||
| 'imported_modpack'
|
||||
| 'shared_instance'
|
||||
|
||||
type ContentFile = {
|
||||
enabled: boolean
|
||||
source_kind?: ContentSourceKind | null
|
||||
metadata?: {
|
||||
project_id: string
|
||||
version_id: string
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export type SearchUser = {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
}
|
||||
|
||||
export async function search_user(query: string): Promise<SearchUser[]> {
|
||||
return await invoke<SearchUser[]>('plugin:users|search_user', { query })
|
||||
}
|
||||
@@ -35,6 +35,9 @@
|
||||
"app.action-bar.install.unknown-instance": {
|
||||
"message": "Unknown instance"
|
||||
},
|
||||
"app.action-bar.install.updating-shared-content": {
|
||||
"message": "Updating shared content"
|
||||
},
|
||||
"app.action-bar.installs": {
|
||||
"message": "Installs"
|
||||
},
|
||||
@@ -269,6 +272,36 @@
|
||||
"app.install.phase.running_loader_processors": {
|
||||
"message": "Running loader processors"
|
||||
},
|
||||
"app.instance.admonitions.shared-instance.added-label": {
|
||||
"message": "Added"
|
||||
},
|
||||
"app.instance.admonitions.shared-instance.changes-body": {
|
||||
"message": "Your local instance is ahead of the users you've shared it with."
|
||||
},
|
||||
"app.instance.admonitions.shared-instance.changes-header": {
|
||||
"message": "Your changes haven't been shared yet"
|
||||
},
|
||||
"app.instance.admonitions.shared-instance.publish-button": {
|
||||
"message": "Push update"
|
||||
},
|
||||
"app.instance.admonitions.shared-instance.publishing-button": {
|
||||
"message": "Pushing..."
|
||||
},
|
||||
"app.instance.admonitions.shared-instance.removed-label": {
|
||||
"message": "Removed"
|
||||
},
|
||||
"app.instance.admonitions.shared-instance.review-admonition-header": {
|
||||
"message": "Push update"
|
||||
},
|
||||
"app.instance.admonitions.shared-instance.review-description": {
|
||||
"message": "Review the content changes that will be shared with everyone using this instance."
|
||||
},
|
||||
"app.instance.admonitions.shared-instance.review-header": {
|
||||
"message": "Review changes"
|
||||
},
|
||||
"app.instance.admonitions.shared-instance.reviewing-button": {
|
||||
"message": "Reviewing..."
|
||||
},
|
||||
"app.instance.confirm-delete.admonition-body": {
|
||||
"message": "All data for your instance will be permanently deleted, including your worlds, configs, and all installed content."
|
||||
},
|
||||
@@ -320,6 +353,84 @@
|
||||
"app.instance.mods.successfully-uploaded": {
|
||||
"message": "Successfully uploaded"
|
||||
},
|
||||
"app.instance.share.empty.description": {
|
||||
"message": "You can share this instance with your friends!"
|
||||
},
|
||||
"app.instance.share.empty.heading": {
|
||||
"message": "No friends invited"
|
||||
},
|
||||
"app.instance.share.empty.invite-friends-button": {
|
||||
"message": "Invite friends"
|
||||
},
|
||||
"app.instance.share.invite-modal.heading": {
|
||||
"message": "Share {name}"
|
||||
},
|
||||
"app.instance.share.locked.empty-description-prefix": {
|
||||
"message": "You need to sign in as"
|
||||
},
|
||||
"app.instance.share.locked.empty-description-suffix": {
|
||||
"message": "to access this page."
|
||||
},
|
||||
"app.instance.share.locked.linked-account-fallback": {
|
||||
"message": "the linked account"
|
||||
},
|
||||
"app.instance.share.locked.signed-out-heading": {
|
||||
"message": "Not signed in"
|
||||
},
|
||||
"app.instance.share.locked.switch-account-button": {
|
||||
"message": "Switch account"
|
||||
},
|
||||
"app.instance.share.locked.wrong-account-heading": {
|
||||
"message": "Wrong account"
|
||||
},
|
||||
"app.instance.share.remove-user-modal.effect-access": {
|
||||
"message": "They will no longer receive updates for this shared instance"
|
||||
},
|
||||
"app.instance.share.remove-user-modal.effect-installed-copy": {
|
||||
"message": "Any copy they already installed will stay on their device"
|
||||
},
|
||||
"app.instance.share.remove-user-modal.effect-invite-again": {
|
||||
"message": "You can invite them again later"
|
||||
},
|
||||
"app.instance.share.remove-user-modal.effect-last-user": {
|
||||
"message": "If this is the last user, sharing will be turned off for this instance"
|
||||
},
|
||||
"app.instance.share.remove-user-modal.effects-label": {
|
||||
"message": "What happens?"
|
||||
},
|
||||
"app.instance.share.remove-user-modal.header": {
|
||||
"message": "Revoke access"
|
||||
},
|
||||
"app.instance.share.remove-user-modal.remove-button": {
|
||||
"message": "Revoke access"
|
||||
},
|
||||
"app.instance.share.remove-user-modal.user-avatar-alt": {
|
||||
"message": "{username}'s avatar"
|
||||
},
|
||||
"app.instance.share.remove-user-modal.warning-body": {
|
||||
"message": "If you revoke {username}'s access to this shared instance, you'll need to invite them again before they can receive updates."
|
||||
},
|
||||
"app.instance.share.sign-in.button": {
|
||||
"message": "Sign in"
|
||||
},
|
||||
"app.instance.shared-instance-wrong-account.fallback-username": {
|
||||
"message": "the linked account"
|
||||
},
|
||||
"app.instance.shared-instance-wrong-account.owner-admonition-body-v2": {
|
||||
"message": "to manage this shared instance. You won't be able to push updates to users."
|
||||
},
|
||||
"app.instance.shared-instance-wrong-account.sign-in-as-label": {
|
||||
"message": "Sign in as"
|
||||
},
|
||||
"app.instance.shared-instance-wrong-account.signed-out-header": {
|
||||
"message": "You need to sign in to Modrinth"
|
||||
},
|
||||
"app.instance.shared-instance-wrong-account.user-admonition-body-v2": {
|
||||
"message": "to receive updates for this shared instance."
|
||||
},
|
||||
"app.instance.shared-instance-wrong-account.warning-header": {
|
||||
"message": "You are using the wrong Modrinth account"
|
||||
},
|
||||
"app.instance.worlds.add-server": {
|
||||
"message": "Add server"
|
||||
},
|
||||
@@ -374,6 +485,9 @@
|
||||
"app.modal.install-to-play.content-required": {
|
||||
"message": "Content required"
|
||||
},
|
||||
"app.modal.install-to-play.external-file-warning": {
|
||||
"message": "{count, plural, one {This instance includes # file that is not from Modrinth.} other {This instance includes # files that are not from Modrinth.}}"
|
||||
},
|
||||
"app.modal.install-to-play.header": {
|
||||
"message": "Install to play"
|
||||
},
|
||||
@@ -392,20 +506,35 @@
|
||||
"app.modal.install-to-play.shared-instance": {
|
||||
"message": "Shared instance"
|
||||
},
|
||||
"app.modal.install-to-play.shared-instance-content": {
|
||||
"message": "Shared instance content"
|
||||
},
|
||||
"app.modal.install-to-play.shared-server-instance": {
|
||||
"message": "Shared server instance"
|
||||
},
|
||||
"app.modal.install-to-play.trust-warning-description": {
|
||||
"message": "A shared instance will install files on your computer and may include content not from Modrinth."
|
||||
},
|
||||
"app.modal.install-to-play.trust-warning-header": {
|
||||
"message": "Do you trust this user?"
|
||||
},
|
||||
"app.modal.install-to-play.view-contents": {
|
||||
"message": "View contents"
|
||||
},
|
||||
"app.modal.update-to-play.header": {
|
||||
"message": "Update to play"
|
||||
},
|
||||
"app.modal.update-to-play.shared-instance-added-label": {
|
||||
"message": "Added"
|
||||
},
|
||||
"app.modal.update-to-play.shared-instance-removed-label": {
|
||||
"message": "Removed"
|
||||
},
|
||||
"app.modal.update-to-play.update-required": {
|
||||
"message": "Update required"
|
||||
},
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "An update is required to play {name}. Please update to the latest version to launch the game."
|
||||
"message": "An update is required to play {name}. Please update to latest version to launch the game."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "This project is already installed"
|
||||
@@ -962,6 +1091,30 @@
|
||||
"instance.settings.tabs.window.width.enter": {
|
||||
"message": "Enter width..."
|
||||
},
|
||||
"instance.shared-instance.error.title": {
|
||||
"message": "Something has gone wrong"
|
||||
},
|
||||
"instance.shared-instance.owner-tooltip": {
|
||||
"message": "This instance's content is being shared to other users."
|
||||
},
|
||||
"instance.shared-instance.tooltip": {
|
||||
"message": "This instance's content is being managed by someone else."
|
||||
},
|
||||
"instance.shared-instance.unavailable.access-revoked-text-v2": {
|
||||
"message": "Your access to the shared instance was revoked. Your local instance is still available, but it is no longer linked and won't receive updates."
|
||||
},
|
||||
"instance.shared-instance.unavailable.deleted-text-v2": {
|
||||
"message": "The shared instance was deleted. Your local instance is still available, but it is no longer linked and won't receive updates."
|
||||
},
|
||||
"instance.shared-instance.unavailable.manager-fallback": {
|
||||
"message": "the instance manager"
|
||||
},
|
||||
"instance.shared-instance.unavailable.text-v2": {
|
||||
"message": "Your local instance is still available, but it is no longer linked and won't receive updates."
|
||||
},
|
||||
"instance.shared-instance.unavailable.title": {
|
||||
"message": "Shared instance no longer available"
|
||||
},
|
||||
"instance.worlds.a_minecraft_server": {
|
||||
"message": "A Minecraft Server"
|
||||
},
|
||||
@@ -1025,6 +1178,24 @@
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "Sign in to Minecraft"
|
||||
},
|
||||
"modal.modrinth-account-required.create-account-button": {
|
||||
"message": "Create an account"
|
||||
},
|
||||
"modal.modrinth-account-required.description": {
|
||||
"message": "You'll need to sign into your Modrinth account before you can use this feature."
|
||||
},
|
||||
"modal.modrinth-account-required.header": {
|
||||
"message": "Account required"
|
||||
},
|
||||
"modal.modrinth-account-required.sign-in-button": {
|
||||
"message": "Sign in to Modrinth"
|
||||
},
|
||||
"modal.modrinth-account-required.sign-in-heading": {
|
||||
"message": "Sign in to a Modrinth account"
|
||||
},
|
||||
"modal.modrinth-account-required.support-prompt": {
|
||||
"message": "Having trouble signing in? <support>Get support</support>"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Provided by the instance"
|
||||
},
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
:offline="offline"
|
||||
@unlinked="fetchInstance"
|
||||
/>
|
||||
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
|
||||
<UpdateToPlayModal
|
||||
ref="updateToPlayModal"
|
||||
:instance="instance"
|
||||
@shared-instance-unavailable="handleSharedInstanceUnavailable"
|
||||
/>
|
||||
<ContentPageHeader>
|
||||
<template #icon>
|
||||
<Avatar
|
||||
@@ -25,6 +29,18 @@
|
||||
<template #title>
|
||||
{{ instance.name }}
|
||||
</template>
|
||||
<template v-if="instance.shared_instance" #title-suffix>
|
||||
<div
|
||||
class="inline-flex h-7 items-center gap-1 rounded-full border border-solid border-blue bg-highlight-blue px-2.5 !text-base !font-normal leading-none text-blue"
|
||||
>
|
||||
Shared
|
||||
<UnknownIcon
|
||||
v-tooltip="sharedInstanceTooltip"
|
||||
class="size-4 cursor-help"
|
||||
aria-label="Shared instance information"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #stats>
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<template v-if="!isServerInstance">
|
||||
@@ -89,6 +105,39 @@
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="sharedInstanceServerManager">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-[5px] font-medium">
|
||||
Linked to
|
||||
<Avatar
|
||||
:src="sharedInstanceServerManager.iconUrl"
|
||||
:alt="sharedInstanceServerManager.name"
|
||||
:tint-by="sharedInstanceServerManager.name"
|
||||
size="24px"
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ sharedInstanceServerManager.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="sharedInstanceManager">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-[5px] font-medium">
|
||||
Managed by
|
||||
<Avatar
|
||||
:src="sharedInstanceManager.avatarUrl"
|
||||
:alt="sharedInstanceManager.username"
|
||||
:tint-by="sharedInstanceManager.tintBy"
|
||||
size="24px"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ sharedInstanceManager.username }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
@@ -217,6 +266,19 @@
|
||||
</div>
|
||||
<div :class="['px-6', { 'shrink-0': isFixedRender }]">
|
||||
<NavTabs :links="tabs" />
|
||||
<InstanceAdmonitions
|
||||
class="mt-4"
|
||||
:instance="instance"
|
||||
:shared-instance-unavailable-reason="sharedInstanceUnavailableReason"
|
||||
:shared-instance-unavailable-manager="
|
||||
sharedInstanceManager?.username ?? sharedInstanceServerManager?.name
|
||||
"
|
||||
:shared-instance-wrong-account="sharedInstanceWrongAccount"
|
||||
:shared-instance-expected-user-id="sharedInstanceExpectedUserId"
|
||||
:shared-instance-role="instance.shared_instance?.role"
|
||||
:shared-instance-signed-out="sharedInstanceSignedOut"
|
||||
@published="fetchInstance"
|
||||
/>
|
||||
</div>
|
||||
<div :class="['p-6 pt-4', { 'min-h-0 flex-1 overflow-y-auto': isFixedRender }]">
|
||||
<RouterView v-slot="{ Component }" :key="instance.id" :route="displayedInstanceRoute">
|
||||
@@ -235,7 +297,7 @@
|
||||
:installed="instance.install_stage !== 'installed'"
|
||||
:is-server-instance="isServerInstance"
|
||||
:open-settings="() => settingsModal?.show(1)"
|
||||
v-bind="contentSubpageProps"
|
||||
v-bind="instanceSubpageProps"
|
||||
@play="updatePlayState"
|
||||
@stop="() => stopInstance('InstanceSubpage')"
|
||||
></component>
|
||||
@@ -288,6 +350,7 @@ import {
|
||||
SettingsIcon,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
UnknownIcon,
|
||||
UpdatedIcon,
|
||||
UserPlusIcon,
|
||||
XIcon,
|
||||
@@ -296,6 +359,8 @@ import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
ContentPageHeader,
|
||||
defineMessages,
|
||||
injectAuth,
|
||||
injectNotificationManager,
|
||||
NavTabs,
|
||||
OverflowMenu,
|
||||
@@ -304,6 +369,7 @@ import {
|
||||
ServerRecentPlays,
|
||||
ServerRegion,
|
||||
useLoadingBarToken,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
@@ -315,6 +381,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ExportModal from '@/components/ui/ExportModal.vue'
|
||||
import InstanceAdmonitions from '@/components/ui/instance/instance-admonitions/index.vue'
|
||||
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import {
|
||||
@@ -323,9 +390,18 @@ import {
|
||||
} from '@/composables/instances/use-server-status-query'
|
||||
import { useInstanceConsole } from '@/composables/useInstanceConsole'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_v3 } from '@/helpers/cache.js'
|
||||
import { get_project_v3, get_user } from '@/helpers/cache.js'
|
||||
import { instance_listener, process_listener } from '@/helpers/events'
|
||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||
import {
|
||||
getErrorMessage,
|
||||
getSharedInstanceUnavailableReason,
|
||||
install_existing_instance,
|
||||
install_get_shared_instance_update_preview,
|
||||
install_pack_to_existing_instance,
|
||||
isSharedInstanceUnavailableError,
|
||||
type SharedInstanceUnavailableReason,
|
||||
type SharedInstanceUpdatePreview,
|
||||
} from '@/helpers/install'
|
||||
import { get, get_full_path, kill, run } from '@/helpers/instance'
|
||||
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
@@ -340,6 +416,7 @@ dayjs.extend(duration)
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const auth = injectAuth()
|
||||
const { playServerProject } = injectServerInstall()
|
||||
const queryClient = useQueryClient()
|
||||
const route = useRoute()
|
||||
@@ -368,6 +445,46 @@ const stopping = ref(false)
|
||||
const exportModal = ref<InstanceType<typeof ExportModal>>()
|
||||
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
sharedInstanceUnavailableTitle: {
|
||||
id: 'instance.shared-instance.unavailable.title',
|
||||
defaultMessage: 'Shared instance no longer available',
|
||||
},
|
||||
sharedInstanceUnavailableText: {
|
||||
id: 'instance.shared-instance.unavailable.text-v2',
|
||||
defaultMessage:
|
||||
"Your local instance is still available, but it is no longer linked and won't receive updates.",
|
||||
},
|
||||
sharedInstanceDeletedText: {
|
||||
id: 'instance.shared-instance.unavailable.deleted-text-v2',
|
||||
defaultMessage:
|
||||
"The shared instance was deleted. Your local instance is still available, but it is no longer linked and won't receive updates.",
|
||||
},
|
||||
sharedInstanceAccessRevokedText: {
|
||||
id: 'instance.shared-instance.unavailable.access-revoked-text-v2',
|
||||
defaultMessage:
|
||||
"Your access to the shared instance was revoked. Your local instance is still available, but it is no longer linked and won't receive updates.",
|
||||
},
|
||||
sharedInstanceUnavailableFallbackManager: {
|
||||
id: 'instance.shared-instance.unavailable.manager-fallback',
|
||||
defaultMessage: 'the instance manager',
|
||||
},
|
||||
sharedInstanceErrorTitle: {
|
||||
id: 'instance.shared-instance.error.title',
|
||||
defaultMessage: 'Something has gone wrong',
|
||||
},
|
||||
sharedInstanceTooltip: {
|
||||
id: 'instance.shared-instance.tooltip',
|
||||
defaultMessage: "This instance's content is being managed by someone else.",
|
||||
},
|
||||
sharedInstanceOwnerTooltip: {
|
||||
id: 'instance.shared-instance.owner-tooltip',
|
||||
defaultMessage: "This instance's content is being shared to other users.",
|
||||
},
|
||||
})
|
||||
|
||||
useLoadingBarToken(subpagePending)
|
||||
|
||||
const isServerInstance = ref(false)
|
||||
@@ -385,6 +502,102 @@ const playersOnline = ref<number | undefined>(undefined)
|
||||
const ping = ref<number | undefined>(undefined)
|
||||
const loadingServerPing = ref(false)
|
||||
const activeInstanceId = ref<string>()
|
||||
const sharedInstanceUpdatePreview = ref<SharedInstanceUpdatePreview | null>(null)
|
||||
const sharedInstanceUnavailableReason = ref<SharedInstanceUnavailableReason | null>(null)
|
||||
const sharedInstanceAvailabilityCheckKey = ref<string | null>(null)
|
||||
type SharedInstanceUserProfile = {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url?: string
|
||||
}
|
||||
const sharedInstanceManagerUser = ref<SharedInstanceUserProfile | null>(null)
|
||||
const sharedInstanceManagerUserId = computed(() => {
|
||||
const attachment = instance.value?.shared_instance
|
||||
if (!attachment) return null
|
||||
|
||||
if (attachment.role === 'owner') {
|
||||
if (!sharedInstanceActionsLocked.value) return null
|
||||
|
||||
return attachment.linked_user_id ?? null
|
||||
}
|
||||
|
||||
return attachment.manager_id ?? null
|
||||
})
|
||||
|
||||
const sharedInstanceManager = computed(() => {
|
||||
if (!instance.value?.shared_instance) return null
|
||||
|
||||
if (instance.value.shared_instance.role === 'owner') {
|
||||
if (!sharedInstanceActionsLocked.value) return null
|
||||
|
||||
const linkedUserId = instance.value.shared_instance.linked_user_id
|
||||
const linkedUser = sharedInstanceManagerUser.value
|
||||
if (!linkedUserId || !linkedUser) return null
|
||||
|
||||
return {
|
||||
username: linkedUser.username,
|
||||
avatarUrl: linkedUser.avatar_url ?? undefined,
|
||||
tintBy: linkedUser.id,
|
||||
}
|
||||
}
|
||||
|
||||
const user = sharedInstanceManagerUser.value
|
||||
if (!user) return null
|
||||
|
||||
return {
|
||||
username: user.username,
|
||||
avatarUrl: user.avatar_url ?? undefined,
|
||||
tintBy: user.id,
|
||||
}
|
||||
})
|
||||
const sharedInstanceServerManager = computed(() => {
|
||||
const attachment = instance.value?.shared_instance
|
||||
if (!attachment?.server_manager_name) return null
|
||||
|
||||
return {
|
||||
name: attachment.server_manager_name,
|
||||
iconUrl: attachment.server_manager_icon_url ?? undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const sharedInstanceExpectedUserId = computed(
|
||||
() => instance.value?.shared_instance?.linked_user_id ?? null,
|
||||
)
|
||||
const sharedInstanceWrongAccount = computed(() => {
|
||||
if (auth.isReady && !auth.isReady.value) return false
|
||||
|
||||
const expectedUserId = sharedInstanceExpectedUserId.value
|
||||
if (!expectedUserId) return false
|
||||
|
||||
return auth.user.value?.id !== expectedUserId
|
||||
})
|
||||
const sharedInstanceActionsLocked = computed(() => sharedInstanceWrongAccount.value)
|
||||
const sharedInstanceShareActionsLocked = computed(
|
||||
() => sharedInstanceActionsLocked.value || !!sharedInstanceUnavailableReason.value,
|
||||
)
|
||||
const sharedInstanceSignedOut = computed(() => !auth.session_token.value)
|
||||
const sharedInstanceTooltip = computed(() =>
|
||||
formatMessage(
|
||||
instance.value?.shared_instance?.role === 'owner'
|
||||
? messages.sharedInstanceOwnerTooltip
|
||||
: messages.sharedInstanceTooltip,
|
||||
),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sharedInstanceManagerUserId.value,
|
||||
async (userId) => {
|
||||
sharedInstanceManagerUser.value = null
|
||||
|
||||
if (!userId) return
|
||||
|
||||
const user = await get_user(userId, 'bypass').catch(() => null)
|
||||
if (sharedInstanceManagerUserId.value !== userId) return
|
||||
|
||||
sharedInstanceManagerUser.value = user
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value,
|
||||
@@ -418,6 +631,9 @@ async function fetchInstance() {
|
||||
isServerInstance.value = false
|
||||
linkedProjectV3.value = undefined
|
||||
preloadedContent.value = null
|
||||
sharedInstanceUpdatePreview.value = null
|
||||
sharedInstanceUnavailableReason.value = null
|
||||
sharedInstanceAvailabilityCheckKey.value = null
|
||||
resetServerStatus()
|
||||
|
||||
const nextInstance = await get(route.params.id as string).catch(handleError)
|
||||
@@ -442,6 +658,7 @@ async function fetchInstance() {
|
||||
}
|
||||
|
||||
const nextPreloadedContent = await contentPreloadPromise
|
||||
await ensureSharedInstanceExpectedUser(nextInstance)
|
||||
|
||||
instance.value = nextInstance ?? undefined
|
||||
linkedProjectV3.value = nextLinkedProjectV3
|
||||
@@ -460,6 +677,21 @@ async function fetchInstance() {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSharedInstanceExpectedUser(nextInstance?: GameInstance | null) {
|
||||
const userId = nextInstance?.shared_instance?.linked_user_id
|
||||
if (!userId) return
|
||||
|
||||
try {
|
||||
await queryClient.ensureQueryData({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => get_user(userId, 'bypass').catch(() => null),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
} catch {
|
||||
// Let InstanceAdmonitions' useQuery own any fallback state after the route renders.
|
||||
}
|
||||
}
|
||||
|
||||
function fetchDeferredData(instanceId?: string) {
|
||||
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
|
||||
if (isServerInstance.value && serverAddress) {
|
||||
@@ -512,6 +744,70 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
async function checkSharedInstanceAvailability(instanceId: string) {
|
||||
try {
|
||||
const preview = await install_get_shared_instance_update_preview(instanceId)
|
||||
if (instance.value?.id !== instanceId) return
|
||||
|
||||
sharedInstanceUpdatePreview.value = preview
|
||||
sharedInstanceUnavailableReason.value = null
|
||||
} catch (error) {
|
||||
if (instance.value?.id !== instanceId) return
|
||||
|
||||
if (isSharedInstanceUnavailableError(error)) {
|
||||
sharedInstanceUpdatePreview.value = null
|
||||
sharedInstanceUnavailableReason.value = getSharedInstanceUnavailableReason(error)
|
||||
return
|
||||
}
|
||||
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.sharedInstanceErrorTitle),
|
||||
text: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
instanceId: instance.value?.id,
|
||||
role: instance.value?.shared_instance?.role,
|
||||
locked: sharedInstanceActionsLocked.value,
|
||||
offline: offline.value,
|
||||
signedIn: !!auth.session_token.value,
|
||||
userId: auth.user.value?.id ?? null,
|
||||
authReady: auth.isReady?.value ?? true,
|
||||
}),
|
||||
async ({ instanceId, role, locked, offline, signedIn, userId, authReady }) => {
|
||||
if (
|
||||
!instanceId ||
|
||||
role !== 'member' ||
|
||||
locked ||
|
||||
offline ||
|
||||
!authReady ||
|
||||
!signedIn ||
|
||||
!userId
|
||||
) {
|
||||
const keepUnavailableReason =
|
||||
!!instanceId && !role && sharedInstanceUnavailableReason.value !== null
|
||||
|
||||
sharedInstanceUpdatePreview.value = null
|
||||
if (!keepUnavailableReason) {
|
||||
sharedInstanceUnavailableReason.value = null
|
||||
}
|
||||
sharedInstanceAvailabilityCheckKey.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${instanceId}:${userId ?? 'signed-out'}`
|
||||
if (sharedInstanceAvailabilityCheckKey.value === key) return
|
||||
|
||||
sharedInstanceAvailabilityCheckKey.value = key
|
||||
await checkSharedInstanceAvailability(instanceId)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const basePath = computed(
|
||||
() => `/instance/${encodeURIComponent(displayedInstanceRoute.value.params.id as string)}`,
|
||||
)
|
||||
@@ -531,29 +827,61 @@ const isFixedRender = computed(() => renderMode.value === 'fixed')
|
||||
const contentSubpageProps = computed(() =>
|
||||
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
|
||||
)
|
||||
const instanceSubpageProps = computed(() => ({
|
||||
...contentSubpageProps.value,
|
||||
...(displayedInstanceRoute.value.name === 'InstanceShare'
|
||||
? {
|
||||
sharedInstanceActionsLocked: sharedInstanceShareActionsLocked.value,
|
||||
sharedInstanceUnavailableReason: sharedInstanceUnavailableReason.value,
|
||||
sharedInstanceUnavailableManager:
|
||||
sharedInstanceManager.value?.username ?? sharedInstanceServerManager.value?.name,
|
||||
}
|
||||
: {}),
|
||||
}))
|
||||
const showShareTab = computed(() => {
|
||||
const linkType = instance.value?.link?.type
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
label: 'Content',
|
||||
href: `${basePath.value}`,
|
||||
icon: BoxesIcon,
|
||||
},
|
||||
{
|
||||
label: 'Files',
|
||||
href: `${basePath.value}/files`,
|
||||
icon: FolderOpenIcon,
|
||||
},
|
||||
{
|
||||
label: 'Worlds',
|
||||
href: `${basePath.value}/worlds`,
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
href: `${basePath.value}/logs`,
|
||||
icon: TerminalSquareIcon,
|
||||
},
|
||||
])
|
||||
return (
|
||||
instance.value?.shared_instance?.role !== 'member' &&
|
||||
linkType !== 'server_project' &&
|
||||
linkType !== 'server_project_modpack'
|
||||
)
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
const instanceTabs = [
|
||||
{
|
||||
label: 'Content',
|
||||
href: `${basePath.value}`,
|
||||
icon: BoxesIcon,
|
||||
},
|
||||
{
|
||||
label: 'Files',
|
||||
href: `${basePath.value}/files`,
|
||||
icon: FolderOpenIcon,
|
||||
},
|
||||
{
|
||||
label: 'Worlds',
|
||||
href: `${basePath.value}/worlds`,
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
href: `${basePath.value}/logs`,
|
||||
icon: TerminalSquareIcon,
|
||||
},
|
||||
]
|
||||
|
||||
if (showShareTab.value) {
|
||||
instanceTabs.push({
|
||||
label: 'Share',
|
||||
href: `${basePath.value}/share`,
|
||||
icon: UserPlusIcon,
|
||||
})
|
||||
}
|
||||
|
||||
return instanceTabs
|
||||
})
|
||||
|
||||
if (instance.value) {
|
||||
breadcrumbs.setName(
|
||||
@@ -571,13 +899,7 @@ if (instance.value) {
|
||||
|
||||
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
|
||||
const startInstance = async (context: string) => {
|
||||
if (!instance.value) return
|
||||
if (updateToPlayModal.value?.hasUpdate) {
|
||||
updateToPlayModal.value.show(instance.value)
|
||||
return
|
||||
}
|
||||
|
||||
const launchInstance = async (context: string) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await run(route.params.id as string)
|
||||
@@ -587,6 +909,7 @@ const startInstance = async (context: string) => {
|
||||
}
|
||||
loading.value = false
|
||||
|
||||
if (!instance.value) return
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
@@ -594,6 +917,64 @@ const startInstance = async (context: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
function sharedInstanceUnavailableTextMessage(reason: SharedInstanceUnavailableReason | null) {
|
||||
if (reason === 'deleted') return messages.sharedInstanceDeletedText
|
||||
if (reason === 'access_revoked') return messages.sharedInstanceAccessRevokedText
|
||||
return messages.sharedInstanceUnavailableText
|
||||
}
|
||||
|
||||
async function handleSharedInstanceUnavailable(
|
||||
reason: SharedInstanceUnavailableReason | null = null,
|
||||
) {
|
||||
const manager =
|
||||
sharedInstanceManager.value?.username ??
|
||||
sharedInstanceServerManager.value?.name ??
|
||||
formatMessage(messages.sharedInstanceUnavailableFallbackManager)
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: formatMessage(messages.sharedInstanceUnavailableTitle),
|
||||
text: formatMessage(sharedInstanceUnavailableTextMessage(reason), { manager }),
|
||||
})
|
||||
await fetchInstance()
|
||||
sharedInstanceUpdatePreview.value = null
|
||||
sharedInstanceUnavailableReason.value = reason
|
||||
}
|
||||
|
||||
const startInstance = async (context: string) => {
|
||||
if (!instance.value) return
|
||||
if (loading.value || playing.value) return
|
||||
|
||||
const isSharedInstanceMember = instance.value.shared_instance?.role === 'member'
|
||||
const canCheckSharedInstanceUpdate =
|
||||
isSharedInstanceMember && !sharedInstanceActionsLocked.value && !offline.value
|
||||
|
||||
if (canCheckSharedInstanceUpdate) {
|
||||
const preview = sharedInstanceUpdatePreview.value
|
||||
|
||||
if (preview?.updateAvailable && updateToPlayModal.value) {
|
||||
updateToPlayModal.value.showSharedInstance(instance.value, preview, async () => {
|
||||
await fetchInstance()
|
||||
await launchInstance(context)
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (updateToPlayModal.value?.hasUpdate) {
|
||||
if (isSharedInstanceMember) {
|
||||
updateToPlayModal.value.show(instance.value, null, async () => {
|
||||
await fetchInstance()
|
||||
await launchInstance(context)
|
||||
})
|
||||
} else {
|
||||
updateToPlayModal.value.show(instance.value)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await launchInstance(context)
|
||||
}
|
||||
|
||||
const stopInstance = async (context: string) => {
|
||||
stopping.value = true
|
||||
await kill(route.params.id as string).catch(handleError)
|
||||
|
||||
@@ -12,14 +12,23 @@
|
||||
ref="modpackContentModal"
|
||||
:modpack-name="displayedModpackProject?.title"
|
||||
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
|
||||
:enable-toggle="!props.isServerInstance"
|
||||
:enable-toggle="!props.isServerInstance && !isSharedMember"
|
||||
:busy="isBulkOperating"
|
||||
:get-overflow-options="getOverflowOptions"
|
||||
:switch-version="handleSwitchVersion"
|
||||
:switch-version="
|
||||
props.isServerInstance || isSharedMember ? undefined : handleSwitchVersion
|
||||
"
|
||||
@update:enabled="handleModpackContentToggle"
|
||||
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
|
||||
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
|
||||
/>
|
||||
<ConfirmDisableModal
|
||||
ref="sharedDisableConfirmModal"
|
||||
:count="pendingModpackDisableItems.length"
|
||||
:item-type="formatMessage(messages.contentTypeProject)"
|
||||
:action-disabled="isInstanceBusy"
|
||||
@disable="confirmPendingModpackContentDisable"
|
||||
/>
|
||||
<ConfirmModpackUpdateModal
|
||||
ref="modpackUpdateConfirmModal"
|
||||
:downgrade="isModpackUpdateDowngrade"
|
||||
@@ -71,6 +80,7 @@ import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets'
|
||||
import {
|
||||
type BulkOperationStatus,
|
||||
commonMessages,
|
||||
ConfirmDisableModal,
|
||||
ConfirmModpackUpdateModal,
|
||||
ContentCardLayout as ContentPageLayout,
|
||||
type ContentItem,
|
||||
@@ -273,6 +283,7 @@ watch(
|
||||
const isModpackUpdating = ref(false)
|
||||
const isBulkOperating = ref(false)
|
||||
const isInstanceBusy = computed(() => props.instance?.install_stage !== 'installed')
|
||||
const isSharedMember = computed(() => props.instance.shared_instance?.role === 'member')
|
||||
const isPackLocked = computed(
|
||||
() =>
|
||||
props.instance?.link?.type === 'modrinth_modpack' ||
|
||||
@@ -284,6 +295,8 @@ const exportModal = ref(null)
|
||||
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>()
|
||||
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal> | null>()
|
||||
const modpackUpdateConfirmModal = ref<InstanceType<typeof ConfirmModpackUpdateModal> | null>()
|
||||
const sharedDisableConfirmModal = ref<InstanceType<typeof ConfirmDisableModal> | null>()
|
||||
const pendingModpackDisableItems = ref<ContentItem[]>([])
|
||||
|
||||
const modpackContentQueryKey = computed(() => ['linkedModpackContent', props.instance.id])
|
||||
const modpackContentQuery = useQuery({
|
||||
@@ -362,8 +375,33 @@ function hasContentOperation(item: ContentItem) {
|
||||
return keys.some((key) => activeContentOperationKeys.value.has(key))
|
||||
}
|
||||
|
||||
function isSharedInstanceManagedContent(item: ContentItem) {
|
||||
return (
|
||||
isSharedMember.value &&
|
||||
(item.source_kind === 'shared_instance' ||
|
||||
item.source_kind === 'modrinth_modpack' ||
|
||||
item.source_kind === 'imported_modpack')
|
||||
)
|
||||
}
|
||||
|
||||
function canMutateContent(item: ContentItem) {
|
||||
return !isSharedInstanceManagedContent(item)
|
||||
}
|
||||
|
||||
function canDeleteContent(item: ContentItem) {
|
||||
return canMutateContent(item)
|
||||
}
|
||||
|
||||
function shouldWarnBeforeDelete(items: ContentItem[]) {
|
||||
return items.some(isSharedInstanceManagedContent)
|
||||
}
|
||||
|
||||
function shouldWarnBeforeDisable(items: ContentItem[]) {
|
||||
return items.some(isSharedInstanceManagedContent)
|
||||
}
|
||||
|
||||
function canUpdateProject(item: ContentItem) {
|
||||
return !!item.file_path && !!item.has_update && !!item.update_version_id
|
||||
return canMutateContent(item) && !!item.file_path && !!item.has_update && !!item.update_version_id
|
||||
}
|
||||
|
||||
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
|
||||
@@ -716,6 +754,7 @@ async function updateProject(mod: ContentItem) {
|
||||
}
|
||||
|
||||
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
|
||||
if (!canMutateContent(mod)) return
|
||||
if (!mod.file_path) return
|
||||
const operation = beginContentOperation(mod)
|
||||
if (!operation) return
|
||||
@@ -852,6 +891,7 @@ async function handleUpdate(id: string) {
|
||||
}
|
||||
|
||||
async function handleSwitchVersion(item: ContentItem) {
|
||||
if (!canMutateContent(item)) return
|
||||
if (!item.project?.id || !item.version?.id) return
|
||||
|
||||
const requestId = beginUpdateRequest()
|
||||
@@ -879,10 +919,32 @@ async function handleSwitchVersion(item: ContentItem) {
|
||||
}
|
||||
|
||||
async function handleModpackContentToggle(item: ContentItem, enabled: boolean) {
|
||||
if (!enabled && shouldWarnBeforeDisable([item])) {
|
||||
pendingModpackDisableItems.value = [item]
|
||||
sharedDisableConfirmModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
await toggleDisableDebounced(item, enabled)
|
||||
}
|
||||
|
||||
async function handleModpackContentBulkToggle(items: ContentItem[], enabled: boolean) {
|
||||
if (!enabled && shouldWarnBeforeDisable(items)) {
|
||||
pendingModpackDisableItems.value = items
|
||||
sharedDisableConfirmModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
await setModpackContentEnabled(items, enabled)
|
||||
}
|
||||
|
||||
async function confirmPendingModpackContentDisable() {
|
||||
const items = [...pendingModpackDisableItems.value]
|
||||
pendingModpackDisableItems.value = []
|
||||
await setModpackContentEnabled(items, false)
|
||||
}
|
||||
|
||||
async function setModpackContentEnabled(items: ContentItem[], enabled: boolean) {
|
||||
await Promise.all(items.map((item) => toggleDisableMod(item, enabled)))
|
||||
}
|
||||
|
||||
@@ -1249,13 +1311,6 @@ provideAppBackup({
|
||||
},
|
||||
})
|
||||
|
||||
const CONTENT_HINT_KEY = 'content-tab-modpack-hint-dismissed'
|
||||
const showContentHint = ref(localStorage.getItem(CONTENT_HINT_KEY) === null)
|
||||
function dismissContentHint() {
|
||||
showContentHint.value = false
|
||||
localStorage.setItem(CONTENT_HINT_KEY, 'true')
|
||||
}
|
||||
|
||||
provideContentManager({
|
||||
items: mergedProjects,
|
||||
loading,
|
||||
@@ -1316,15 +1371,25 @@ provideContentManager({
|
||||
toggleEnabled: toggleDisableDebounced,
|
||||
bulkEnableItems: (items: ContentItem[]) =>
|
||||
Promise.all(
|
||||
items.filter((item) => !item.enabled).map((item) => toggleDisableMod(item, true)),
|
||||
items
|
||||
.filter((item) => canMutateContent(item) && !item.enabled)
|
||||
.map((item) => toggleDisableMod(item, true)),
|
||||
).then(() => {}),
|
||||
bulkDisableItems: (items: ContentItem[]) =>
|
||||
Promise.all(
|
||||
items.filter((item) => item.enabled).map((item) => toggleDisableMod(item, false)),
|
||||
items
|
||||
.filter((item) => canMutateContent(item) && item.enabled)
|
||||
.map((item) => toggleDisableMod(item, false)),
|
||||
).then(() => {}),
|
||||
deleteItem: removeMod,
|
||||
bulkDeleteItems: (items: ContentItem[]) =>
|
||||
Promise.all(items.map((item) => removeMod(item))).then(() => {}),
|
||||
Promise.all(items.filter(canMutateContent).map((item) => removeMod(item))).then(() => {}),
|
||||
canDeleteItem: canDeleteContent,
|
||||
canToggleItem: canMutateContent,
|
||||
getDeleteWarningMode: (items: ContentItem[]) =>
|
||||
shouldWarnBeforeDelete(items) ? 'shared-instance' : 'default',
|
||||
getDisableWarningMode: (items: ContentItem[]) =>
|
||||
shouldWarnBeforeDisable(items) ? 'shared-instance' : 'default',
|
||||
getDeleteDependencyWarning,
|
||||
refresh: () => initProjects('must_revalidate'),
|
||||
browse: handleBrowseContent,
|
||||
@@ -1333,14 +1398,12 @@ provideContentManager({
|
||||
updateItem: handleUpdate,
|
||||
bulkUpdateAll: bulkUpdateAllProjects,
|
||||
bulkUpdateItem: updateProject,
|
||||
updateModpack: props.isServerInstance ? undefined : handleModpackUpdate,
|
||||
updateModpack: props.isServerInstance || isSharedMember.value ? undefined : handleModpackUpdate,
|
||||
viewModpackContent: handleModpackContent,
|
||||
unlinkModpack: unpairInstance,
|
||||
openSettings: props.openSettings,
|
||||
switchVersion: handleSwitchVersion,
|
||||
getOverflowOptions,
|
||||
showContentHint,
|
||||
dismissContentHint,
|
||||
shareItems: handleShareItems,
|
||||
getItemId: getContentItemId,
|
||||
mapToTableItem: (item: ContentItem) => ({
|
||||
@@ -1372,8 +1435,11 @@ provideContentManager({
|
||||
link: () => openUrl(`https://modrinth.com/${item.owner!.type}/${item.owner!.id}`),
|
||||
}
|
||||
: undefined,
|
||||
enabled: item.enabled,
|
||||
enabled: canMutateContent(item) ? item.enabled : undefined,
|
||||
installing: item.installing,
|
||||
hideDelete: !canDeleteContent(item),
|
||||
hideSwitchVersion: !canMutateContent(item),
|
||||
hasUpdate: canUpdateProject(item),
|
||||
}),
|
||||
filterPersistKey: props.instance.id,
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import Index from './Index.vue'
|
||||
import Logs from './Logs.vue'
|
||||
import Mods from './Mods.vue'
|
||||
import Overview from './Overview.vue'
|
||||
import Share from './Share.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
|
||||
export { Files, Index, Logs, Mods, Overview, Worlds }
|
||||
export { Files, Index, Logs, Mods, Overview, Share, Worlds }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
|
||||
import { type AuthFlow, type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
|
||||
import { computed, type Ref, ref, watchEffect } from 'vue'
|
||||
|
||||
type AppCredentials = {
|
||||
@@ -9,7 +9,7 @@ type AppCredentials = {
|
||||
|
||||
export function setupAuthProvider(
|
||||
credentials: Ref<AppCredentials | null | undefined>,
|
||||
requestSignIn: (redirectPath: string) => void | Promise<void>,
|
||||
requestSignIn: (redirectPath: string, flow?: AuthFlow) => void | Promise<void>,
|
||||
) {
|
||||
const sessionToken = ref<string | null>(null)
|
||||
const user = ref<AuthUser | null>(null)
|
||||
|
||||
@@ -215,6 +215,15 @@ export default new createRouter({
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'share',
|
||||
name: 'InstanceShare',
|
||||
component: Instance.Share,
|
||||
meta: {
|
||||
useRootContext: true,
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Share' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
name: 'Mods',
|
||||
|
||||
@@ -148,6 +148,12 @@ fn main() {
|
||||
"install_get_modpack_preview",
|
||||
"install_create_instance",
|
||||
"install_create_modpack_instance",
|
||||
"install_get_shared_instance_preview",
|
||||
"install_accept_shared_instance_invite",
|
||||
"install_get_shared_instance_update_preview",
|
||||
"install_shared_instance",
|
||||
"install_shared_instance_invite",
|
||||
"install_update_shared_instance",
|
||||
"install_import_instance",
|
||||
"install_duplicate_instance",
|
||||
"install_existing_instance",
|
||||
@@ -209,6 +215,14 @@ fn main() {
|
||||
"instance_kill",
|
||||
"instance_edit",
|
||||
"instance_edit_icon",
|
||||
"instance_share_get_users",
|
||||
"instance_share_invite_users",
|
||||
"instance_share_create_invite_link",
|
||||
"instance_share_remove_users",
|
||||
"instance_share_get_publish_preview",
|
||||
"instance_share_publish",
|
||||
"instance_share_unlink",
|
||||
"instance_share_unpublish",
|
||||
"instance_export_mrpack",
|
||||
"instance_get_pack_export_candidates",
|
||||
])
|
||||
@@ -250,6 +264,14 @@ fn main() {
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
),
|
||||
)
|
||||
.plugin(
|
||||
"users",
|
||||
InlinedPlugin::new()
|
||||
.commands(&["search_user"])
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
),
|
||||
)
|
||||
.plugin(
|
||||
"utils",
|
||||
InlinedPlugin::new()
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"settings:default",
|
||||
"shortcuts:default",
|
||||
"tags:default",
|
||||
"users:default",
|
||||
"utils:default",
|
||||
"ads:default",
|
||||
"friends:default",
|
||||
|
||||
@@ -6,6 +6,10 @@ use theseus::data::ModLoader;
|
||||
use theseus::install::{
|
||||
InstallJobSnapshot, InstallModpackPreview, InstallPostInstallEdit,
|
||||
};
|
||||
use theseus::instance::{
|
||||
SharedInstanceInstallPreview, SharedInstanceInviteInstallPreview,
|
||||
SharedInstanceUpdatePreview,
|
||||
};
|
||||
use theseus::pack::import::ImportLauncherType;
|
||||
use theseus::pack::install_from::CreatePackLocation;
|
||||
use uuid::Uuid;
|
||||
@@ -16,6 +20,12 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
install_get_modpack_preview,
|
||||
install_create_instance,
|
||||
install_create_modpack_instance,
|
||||
install_get_shared_instance_preview,
|
||||
install_accept_shared_instance_invite,
|
||||
install_get_shared_instance_update_preview,
|
||||
install_shared_instance,
|
||||
install_shared_instance_invite,
|
||||
install_update_shared_instance,
|
||||
install_import_instance,
|
||||
install_duplicate_instance,
|
||||
install_existing_instance,
|
||||
@@ -100,6 +110,72 @@ pub async fn install_create_modpack_instance(
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_get_shared_instance_preview(
|
||||
shared_instance_id: String,
|
||||
name: String,
|
||||
) -> Result<SharedInstanceInstallPreview> {
|
||||
Ok(theseus::instance::get_shared_instance_install_preview(
|
||||
&shared_instance_id,
|
||||
name,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_accept_shared_instance_invite(
|
||||
invite_id: String,
|
||||
) -> Result<SharedInstanceInviteInstallPreview> {
|
||||
Ok(
|
||||
theseus::instance::accept_shared_instance_invite_for_install(
|
||||
&invite_id,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_get_shared_instance_update_preview(
|
||||
instance_id: String,
|
||||
) -> Result<Option<SharedInstanceUpdatePreview>> {
|
||||
Ok(
|
||||
theseus::instance::get_shared_instance_update_preview(&instance_id)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_shared_instance(
|
||||
shared_instance_id: String,
|
||||
name: String,
|
||||
manager_id: Option<String>,
|
||||
server_manager_name: Option<String>,
|
||||
server_manager_icon_url: Option<String>,
|
||||
) -> Result<InstallJobSnapshot> {
|
||||
Ok(theseus::instance::install_shared_instance(
|
||||
&shared_instance_id,
|
||||
name,
|
||||
manager_id,
|
||||
server_manager_name,
|
||||
server_manager_icon_url,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_shared_instance_invite(
|
||||
invite_id: String,
|
||||
) -> Result<InstallJobSnapshot> {
|
||||
Ok(theseus::instance::install_shared_instance_invite(&invite_id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_update_shared_instance(
|
||||
instance_id: String,
|
||||
) -> Result<InstallJobSnapshot> {
|
||||
Ok(theseus::instance::update_shared_instance(&instance_id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_import_instance(
|
||||
launcher_type: ImportLauncherType,
|
||||
|
||||
+129
-19
@@ -10,6 +10,8 @@ use theseus::data::{
|
||||
EditInstance as CoreEditInstance, InstanceInstallCandidate,
|
||||
InstanceInstallTarget, InstanceLaunchOverridesPatch,
|
||||
InstanceLink as CoreInstanceLink, InstanceMetadata, LinkedModpackInfo,
|
||||
SharedInstanceAttachment as CoreSharedInstanceAttachment,
|
||||
SharedInstanceRole,
|
||||
};
|
||||
use theseus::instance::InstallProjectWithDependenciesRequest;
|
||||
use theseus::instance::QuickPlayType;
|
||||
@@ -49,6 +51,14 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
instance_kill,
|
||||
instance_edit,
|
||||
instance_edit_icon,
|
||||
instance_share_get_users,
|
||||
instance_share_invite_users,
|
||||
instance_share_create_invite_link,
|
||||
instance_share_remove_users,
|
||||
instance_share_get_publish_preview,
|
||||
instance_share_publish,
|
||||
instance_share_unlink,
|
||||
instance_share_unpublish,
|
||||
instance_export_mrpack,
|
||||
instance_get_pack_export_candidates,
|
||||
])
|
||||
@@ -69,6 +79,7 @@ pub struct Instance {
|
||||
pub loader_version: Option<String>,
|
||||
pub groups: Vec<String>,
|
||||
pub link: Option<InstanceLink>,
|
||||
pub shared_instance: Option<SharedInstanceAttachment>,
|
||||
pub update_channel: ReleaseChannel,
|
||||
pub created: chrono::DateTime<chrono::Utc>,
|
||||
pub modified: chrono::DateTime<chrono::Utc>,
|
||||
@@ -114,10 +125,36 @@ pub enum InstanceLink {
|
||||
active_instance_id: Option<String>,
|
||||
},
|
||||
SharedInstance {
|
||||
shared_instance_id: String,
|
||||
modpack_project_id: Option<String>,
|
||||
modpack_version_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct SharedInstanceAttachment {
|
||||
pub id: String,
|
||||
pub role: SharedInstanceRole,
|
||||
pub manager_id: Option<String>,
|
||||
pub linked_user_id: Option<String>,
|
||||
pub status: String,
|
||||
pub applied_version: Option<i32>,
|
||||
pub latest_version: Option<i32>,
|
||||
}
|
||||
|
||||
impl From<CoreSharedInstanceAttachment> for SharedInstanceAttachment {
|
||||
fn from(attachment: CoreSharedInstanceAttachment) -> Self {
|
||||
Self {
|
||||
id: attachment.id.to_string(),
|
||||
role: attachment.role,
|
||||
manager_id: attachment.manager_id,
|
||||
linked_user_id: attachment.linked_user_id,
|
||||
status: attachment.status.as_str().to_string(),
|
||||
applied_version: attachment.applied_version,
|
||||
latest_version: attachment.latest_version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct EditInstance {
|
||||
pub name: Option<String>,
|
||||
@@ -200,6 +237,7 @@ impl From<InstanceMetadata> for Instance {
|
||||
loader_version: metadata.applied_content_set.loader_version,
|
||||
groups: metadata.groups,
|
||||
link: InstanceLink::from_core(metadata.link),
|
||||
shared_instance: metadata.shared_instance.map(Into::into),
|
||||
update_channel: metadata.instance.update_channel,
|
||||
created: metadata.instance.created,
|
||||
modified: metadata.instance.modified,
|
||||
@@ -267,11 +305,13 @@ impl InstanceLink {
|
||||
.collect(),
|
||||
active_instance_id: active_instance_id.map(|id| id.to_string()),
|
||||
}),
|
||||
CoreInstanceLink::SharedInstance { shared_instance_id } => {
|
||||
Some(Self::SharedInstance {
|
||||
shared_instance_id: shared_instance_id.to_string(),
|
||||
})
|
||||
}
|
||||
CoreInstanceLink::SharedInstance {
|
||||
modpack_project_id,
|
||||
modpack_version_id,
|
||||
} => Some(Self::SharedInstance {
|
||||
modpack_project_id,
|
||||
modpack_version_id,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,19 +384,13 @@ impl InstanceLink {
|
||||
})
|
||||
.transpose()?,
|
||||
}),
|
||||
Self::SharedInstance { shared_instance_id } => {
|
||||
Ok(CoreInstanceLink::SharedInstance {
|
||||
shared_instance_id: shared_instance_id.parse().map_err(
|
||||
|err| {
|
||||
theseus::Error::from(
|
||||
theseus::ErrorKind::InputError(format!(
|
||||
"Invalid shared instance id: {err}"
|
||||
)),
|
||||
)
|
||||
},
|
||||
)?,
|
||||
})
|
||||
}
|
||||
Self::SharedInstance {
|
||||
modpack_project_id,
|
||||
modpack_version_id,
|
||||
} => Ok(CoreInstanceLink::SharedInstance {
|
||||
modpack_project_id,
|
||||
modpack_version_id,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -738,3 +772,79 @@ pub async fn instance_edit_icon(
|
||||
theseus::instance::edit_icon(instance_id, icon_path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_share_get_users(
|
||||
instance_id: &str,
|
||||
) -> Result<theseus::instance::SharedInstanceUsers> {
|
||||
Ok(theseus::instance::get_shared_instance_users(instance_id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_share_invite_users(
|
||||
instance_id: &str,
|
||||
user_ids: Vec<String>,
|
||||
) -> Result<theseus::instance::SharedInstanceUsers> {
|
||||
Ok(
|
||||
theseus::instance::invite_shared_instance_users(instance_id, user_ids)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_share_create_invite_link(
|
||||
instance_id: &str,
|
||||
max_age_seconds: Option<i32>,
|
||||
max_uses: Option<i32>,
|
||||
replace_invite_id: Option<String>,
|
||||
) -> Result<theseus::instance::SharedInstanceInviteLink> {
|
||||
Ok(theseus::instance::create_shared_instance_invite_link(
|
||||
instance_id,
|
||||
max_age_seconds,
|
||||
max_uses,
|
||||
replace_invite_id,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_share_remove_users(
|
||||
instance_id: &str,
|
||||
user_ids: Vec<String>,
|
||||
) -> Result<theseus::instance::SharedInstanceUsers> {
|
||||
Ok(
|
||||
theseus::instance::remove_shared_instance_users(instance_id, user_ids)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_share_get_publish_preview(
|
||||
instance_id: &str,
|
||||
) -> Result<Option<theseus::instance::SharedInstancePublishPreview>> {
|
||||
Ok(
|
||||
theseus::instance::get_shared_instance_publish_preview(instance_id)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_share_publish(
|
||||
instance_id: &str,
|
||||
) -> Result<SharedInstanceAttachment> {
|
||||
Ok(theseus::instance::publish_shared_instance(instance_id)
|
||||
.await?
|
||||
.into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_share_unlink(instance_id: &str) -> Result<()> {
|
||||
theseus::instance::unlink_shared_instance(instance_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_share_unpublish(instance_id: &str) -> Result<()> {
|
||||
theseus::instance::unpublish_shared_instance(instance_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod process;
|
||||
pub mod settings;
|
||||
pub mod shortcuts;
|
||||
pub mod tags;
|
||||
pub mod users;
|
||||
pub mod utils;
|
||||
|
||||
pub mod ads;
|
||||
|
||||
@@ -22,6 +22,7 @@ pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
||||
#[tauri::command]
|
||||
pub async fn modrinth_login<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
flow: mr_auth::ModrinthAuthFlow,
|
||||
) -> Result<ModrinthCredentials> {
|
||||
let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
|
||||
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
|
||||
@@ -32,7 +33,7 @@ pub async fn modrinth_login<R: Runtime>(
|
||||
|
||||
let auth_request_uri = format!(
|
||||
"{}?launcher=true&ipver={}&port={}",
|
||||
mr_auth::authenticate_begin_flow(),
|
||||
mr_auth::authenticate_begin_flow(flow),
|
||||
if auth_code_recv_socket.is_ipv4() {
|
||||
"4"
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
use crate::api::Result;
|
||||
use theseus::users::SearchUser;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_user(query: &str) -> Result<Vec<SearchUser>> {
|
||||
Ok(theseus::users::search_user(query).await?)
|
||||
}
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("users")
|
||||
.invoke_handler(tauri::generate_handler![search_user])
|
||||
.build()
|
||||
}
|
||||
@@ -243,6 +243,7 @@ fn main() {
|
||||
.plugin(api::settings::init())
|
||||
.plugin(api::shortcuts::init())
|
||||
.plugin(api::tags::init())
|
||||
.plugin(api::users::init())
|
||||
.plugin(api::utils::init())
|
||||
.plugin(api::cache::init())
|
||||
.plugin(api::files::init())
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'empty',
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
robots: 'noindex',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const deepLink = computed(() => {
|
||||
const inviteId = encodeURIComponent(route.params.inviteId as string)
|
||||
return `modrinth://share/${inviteId}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="grid min-h-screen place-items-center bg-bg px-4">
|
||||
<a :href="deepLink" class="btn btn-primary">Open in Modrinth App</a>
|
||||
</main>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
MODRINTH_URL=http://localhost:3000/
|
||||
MODRINTH_API_BASE_URL=http://localhost:8000/
|
||||
SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com/
|
||||
MODRINTH_API_URL=http://127.0.0.1:8000/v2/
|
||||
MODRINTH_API_URL_V3=http://127.0.0.1:8000/v3/
|
||||
MODRINTH_SOCKET_URL=ws://127.0.0.1:8000/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
MODRINTH_URL=https://modrinth.com/
|
||||
MODRINTH_API_BASE_URL=https://api.modrinth.com/
|
||||
SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com/
|
||||
MODRINTH_ARCHON_BASE_URL=https://archon.modrinth.com/
|
||||
MODRINTH_API_URL=https://api.modrinth.com/v2/
|
||||
MODRINTH_API_URL_V3=https://api.modrinth.com/v3/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
MODRINTH_URL=https://modrinth.com/
|
||||
MODRINTH_API_BASE_URL=https://api.modrinth.com/
|
||||
SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com/
|
||||
MODRINTH_ARCHON_BASE_URL=https://staging-archon.modrinth.com/
|
||||
MODRINTH_API_URL=https://api.modrinth.com/v2/
|
||||
MODRINTH_API_URL_V3=https://api.modrinth.com/v3/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
MODRINTH_URL=https://staging.modrinth.com/
|
||||
MODRINTH_API_BASE_URL=https://staging-api.modrinth.com/
|
||||
SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com/
|
||||
MODRINTH_ARCHON_BASE_URL=https://staging-archon.modrinth.com/
|
||||
MODRINTH_API_URL=https://staging-api.modrinth.com/v2/
|
||||
MODRINTH_API_URL_V3=https://staging-api.modrinth.com/v3/
|
||||
|
||||
+43
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n WITH requested AS (\n SELECT value AS id, key AS ord\n FROM json_each(?)\n )\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM requested\n INNER JOIN instances i\n ON i.id = requested.id\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ORDER BY requested.ord\n ",
|
||||
"query": "\n WITH requested AS (\n SELECT value AS id, key AS ord\n FROM json_each(?)\n )\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM requested\n INNER JOIN instances i\n ON i.id = requested.id\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ORDER BY requested.ord\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -174,28 +174,58 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_name?: String",
|
||||
"name": "shared_instance_role?: String",
|
||||
"ordinal": 34,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_version_number?: String",
|
||||
"name": "shared_instance_manager_id?: String",
|
||||
"ordinal": 35,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_filename?: String",
|
||||
"name": "shared_instance_linked_user_id?: String",
|
||||
"ordinal": 36,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "groups!: String",
|
||||
"name": "shared_sync_applied_update_id?: String",
|
||||
"ordinal": 37,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "shared_sync_latest_available_update_id?: String",
|
||||
"ordinal": 38,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "shared_sync_status?: String",
|
||||
"ordinal": 39,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_name?: String",
|
||||
"ordinal": 40,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_version_number?: String",
|
||||
"ordinal": 41,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_filename?: String",
|
||||
"ordinal": 42,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "groups!: String",
|
||||
"ordinal": 43,
|
||||
"type_info": "Null"
|
||||
},
|
||||
{
|
||||
"name": "launch_overrides?: String",
|
||||
"ordinal": 38,
|
||||
"ordinal": 44,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
@@ -240,9 +270,15 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e53cd252e1e9eebcb3fb9714d7b5ee588e751fdc3f18c48bc4cfd94504c37306"
|
||||
"hash": "0751df4d528f5d0f2258c798d537187035b55e3fb15ea29532c1853e7a763aa2"
|
||||
}
|
||||
+43
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?\n ",
|
||||
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -174,28 +174,58 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_name?: String",
|
||||
"name": "shared_instance_role?: String",
|
||||
"ordinal": 34,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_version_number?: String",
|
||||
"name": "shared_instance_manager_id?: String",
|
||||
"ordinal": 35,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_filename?: String",
|
||||
"name": "shared_instance_linked_user_id?: String",
|
||||
"ordinal": 36,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "groups!: String",
|
||||
"name": "shared_sync_applied_update_id?: String",
|
||||
"ordinal": 37,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "shared_sync_latest_available_update_id?: String",
|
||||
"ordinal": 38,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "shared_sync_status?: String",
|
||||
"ordinal": 39,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_name?: String",
|
||||
"ordinal": 40,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_version_number?: String",
|
||||
"ordinal": 41,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_filename?: String",
|
||||
"ordinal": 42,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "groups!: String",
|
||||
"ordinal": 43,
|
||||
"type_info": "Null"
|
||||
},
|
||||
{
|
||||
"name": "launch_overrides?: String",
|
||||
"ordinal": 38,
|
||||
"ordinal": 44,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
@@ -240,9 +270,15 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "bcc93c48dd3c9ccbe196cfbe27e36b774d1b506e6cf5f2ab38469e9e3e6c9b6d"
|
||||
"hash": "1956049580e792f822ec878ca4a5aaabacd7a574513ac4378ce2f134a1b44b40"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tINSERT INTO instance_content_set_remote_refs (\n\t\t\tcontent_set_id,\n\t\t\tref_type,\n\t\t\tref_id\n\t\t)\n\t\tVALUES (?, ?, ?)\n\t\tON CONFLICT (content_set_id, ref_type) DO UPDATE SET\n\t\t\tref_id = excluded.ref_id\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2bf4029dd68e983460b8a0b06e1209885ef8e87ad2a8666a9cf1c851a2948cbc"
|
||||
}
|
||||
+43
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ",
|
||||
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -174,28 +174,58 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_name?: String",
|
||||
"name": "shared_instance_role?: String",
|
||||
"ordinal": 34,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_version_number?: String",
|
||||
"name": "shared_instance_manager_id?: String",
|
||||
"ordinal": 35,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_filename?: String",
|
||||
"name": "shared_instance_linked_user_id?: String",
|
||||
"ordinal": 36,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "groups!: String",
|
||||
"name": "shared_sync_applied_update_id?: String",
|
||||
"ordinal": 37,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "launch_overrides?: String",
|
||||
"name": "shared_sync_latest_available_update_id?: String",
|
||||
"ordinal": 38,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "shared_sync_status?: String",
|
||||
"ordinal": 39,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_name?: String",
|
||||
"ordinal": 40,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_version_number?: String",
|
||||
"ordinal": 41,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_filename?: String",
|
||||
"ordinal": 42,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "groups!: String",
|
||||
"ordinal": 43,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "launch_overrides?: String",
|
||||
"ordinal": 44,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
@@ -240,9 +270,15 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "f0d8d4f98f7fdf3d656a7811f232d429a2cc73bc2c752594fcad7da19967ff7b"
|
||||
"hash": "33e5231e178e126da0abdfff353f003e6fb2fd825e90594ff820b48eba34c65d"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tINSERT INTO instance_content_set_sync_state (\n\t\t\tcontent_set_id,\n\t\t\tprovider,\n\t\t\tapplied_update_id,\n\t\t\tlatest_available_update_id,\n\t\t\tchecked_at,\n\t\t\tstatus\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT (content_set_id) DO UPDATE SET\n\t\t\tprovider = excluded.provider,\n\t\t\tapplied_update_id = excluded.applied_update_id,\n\t\t\tlatest_available_update_id = excluded.latest_available_update_id,\n\t\t\tchecked_at = excluded.checked_at,\n\t\t\tstatus = excluded.status\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6231cfa8f3b21c0fd502e16f823881fd9c44bccaedb9b06585ff8f76146c6e57"
|
||||
}
|
||||
+23
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n instance_id,\n link_kind,\n modrinth_project_id,\n modrinth_version_id,\n server_project_id,\n content_project_id,\n content_version_id,\n hosting_server_id,\n json(hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n hosting_active_instance_id,\n shared_instance_id,\n imported_name,\n imported_version_number,\n imported_filename\n FROM instance_links\n WHERE instance_id = ?\n ",
|
||||
"query": "\n SELECT\n instance_id,\n link_kind,\n modrinth_project_id,\n modrinth_version_id,\n server_project_id,\n content_project_id,\n content_version_id,\n hosting_server_id,\n json(hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n hosting_active_instance_id,\n shared_instance_id,\n shared_instance_role,\n shared_instance_manager_id,\n shared_instance_linked_user_id,\n imported_name,\n imported_version_number,\n imported_filename\n FROM instance_links\n WHERE instance_id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -59,19 +59,34 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_name",
|
||||
"name": "shared_instance_role",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_version_number",
|
||||
"name": "shared_instance_manager_id",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_filename",
|
||||
"name": "shared_instance_linked_user_id",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_name",
|
||||
"ordinal": 14,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_version_number",
|
||||
"ordinal": 15,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_filename",
|
||||
"ordinal": 16,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -91,8 +106,11 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "26d23094e9d76e5595d5e5a0cd6bc631c45e6090bf40b26e25024de434fe0281"
|
||||
"hash": "6db40d30b3beca48327edfa92dfba2c8b9074904d1012079660de316a20e7dfa"
|
||||
}
|
||||
+43
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n '[]' AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?\n ",
|
||||
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n '[]' AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -174,28 +174,58 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_name?: String",
|
||||
"name": "shared_instance_role?: String",
|
||||
"ordinal": 34,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_version_number?: String",
|
||||
"name": "shared_instance_manager_id?: String",
|
||||
"ordinal": 35,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_filename?: String",
|
||||
"name": "shared_instance_linked_user_id?: String",
|
||||
"ordinal": 36,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "groups!: String",
|
||||
"name": "shared_sync_applied_update_id?: String",
|
||||
"ordinal": 37,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "shared_sync_latest_available_update_id?: String",
|
||||
"ordinal": 38,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "shared_sync_status?: String",
|
||||
"ordinal": 39,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_name?: String",
|
||||
"ordinal": 40,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_version_number?: String",
|
||||
"ordinal": 41,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "imported_filename?: String",
|
||||
"ordinal": 42,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "groups!: String",
|
||||
"ordinal": 43,
|
||||
"type_info": "Null"
|
||||
},
|
||||
{
|
||||
"name": "launch_overrides?: String",
|
||||
"ordinal": 38,
|
||||
"ordinal": 44,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
@@ -240,9 +270,15 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "474496fe9b4f0ae920ceef97176dac61544130300cdf2cfc8deb7fbf2efaf8c7"
|
||||
"hash": "7055b32c58c7c46df91771195ae92c3fff9f7755306494a5ad38d1782b57a614"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tDELETE FROM instance_content_set_remote_refs\n\t\tWHERE content_set_id = ? AND ref_type = ?\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "76988815779ec3c1def6712dcb4156fd72100b0cf97b4ecb11045c6c4f550652"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tDELETE FROM instance_content_set_sync_state\n\t\tWHERE content_set_id = ?\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "becbba7c6dfb4b22520cc1887718af544c08d3e274a930a68daf3e94e3612243"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tmodrinth_project_id,\n\t\t\tmodrinth_version_id,\n\t\t\tserver_project_id,\n\t\t\tcontent_project_id,\n\t\t\tcontent_version_id,\n\t\t\thosting_server_id,\n\t\t\thosting_instance_ids,\n\t\t\thosting_active_instance_id,\n\t\t\timported_name,\n\t\t\timported_version_number,\n\t\t\timported_filename\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = excluded.link_kind,\n\t\t\tmodrinth_project_id = excluded.modrinth_project_id,\n\t\t\tmodrinth_version_id = excluded.modrinth_version_id,\n\t\t\tserver_project_id = excluded.server_project_id,\n\t\t\tcontent_project_id = excluded.content_project_id,\n\t\t\tcontent_version_id = excluded.content_version_id,\n\t\t\thosting_server_id = excluded.hosting_server_id,\n\t\t\thosting_instance_ids = excluded.hosting_instance_ids,\n\t\t\thosting_active_instance_id = excluded.hosting_active_instance_id,\n\t\t\timported_name = excluded.imported_name,\n\t\t\timported_version_number = excluded.imported_version_number,\n\t\t\timported_filename = excluded.imported_filename\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 13
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bf813910a5b0db8563a689088eedf95c67b5ceda04aa1b0eb782b581e23ec06b"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tshared_instance_id,\n\t\t\tshared_instance_role,\n\t\t\tshared_instance_manager_id,\n\t\t\tshared_instance_linked_user_id\n\t\t)\n\t\tVALUES (?, 'unmanaged', ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = CASE\n\t\t\t\tWHEN excluded.shared_instance_id IS NULL\n\t\t\t\t\tAND instance_links.link_kind = 'shared_instance'\n\t\t\t\t\tTHEN 'unmanaged'\n\t\t\t\tELSE instance_links.link_kind\n\t\t\tEND,\n\t\t\tshared_instance_id = excluded.shared_instance_id,\n\t\t\tshared_instance_role = excluded.shared_instance_role,\n\t\t\tshared_instance_manager_id = excluded.shared_instance_manager_id,\n\t\t\tshared_instance_linked_user_id = excluded.shared_instance_linked_user_id\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c321179e6961ec61be909ea902b688226e60f212e59e7be10630fda957895db8"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tmodrinth_project_id,\n\t\t\tmodrinth_version_id,\n\t\t\tserver_project_id,\n\t\t\tcontent_project_id,\n\t\t\tcontent_version_id,\n\t\t\thosting_server_id,\n\t\t\thosting_instance_ids,\n\t\t\thosting_active_instance_id,\n\t\t\tshared_instance_id,\n\t\t\timported_name,\n\t\t\timported_version_number,\n\t\t\timported_filename\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = excluded.link_kind,\n\t\t\tmodrinth_project_id = excluded.modrinth_project_id,\n\t\t\tmodrinth_version_id = excluded.modrinth_version_id,\n\t\t\tserver_project_id = excluded.server_project_id,\n\t\t\tcontent_project_id = excluded.content_project_id,\n\t\t\tcontent_version_id = excluded.content_version_id,\n\t\t\thosting_server_id = excluded.hosting_server_id,\n\t\t\thosting_instance_ids = excluded.hosting_instance_ids,\n\t\t\thosting_active_instance_id = excluded.hosting_active_instance_id,\n\t\t\tshared_instance_id = excluded.shared_instance_id,\n\t\t\timported_name = excluded.imported_name,\n\t\t\timported_version_number = excluded.imported_version_number,\n\t\t\timported_filename = excluded.imported_filename\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 14
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "eca3e44d4ba62e218289fa058dd563cebedb6b8add6c936c551a857783645ff8"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE instance_links
|
||||
ADD COLUMN shared_instance_role TEXT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE instance_links
|
||||
ADD COLUMN shared_instance_manager_id TEXT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE instance_links
|
||||
ADD COLUMN shared_instance_linked_user_id TEXT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE instance_links
|
||||
ADD COLUMN shared_instance_access_token TEXT NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE instance_links
|
||||
ADD COLUMN shared_instance_server_manager_name TEXT NULL;
|
||||
|
||||
ALTER TABLE instance_links
|
||||
ADD COLUMN shared_instance_server_manager_icon_url TEXT NULL;
|
||||
@@ -30,6 +30,26 @@ pub async fn handle_url(sublink: &str) -> crate::Result<CommandPayload> {
|
||||
Some(("server", id)) => {
|
||||
CommandPayload::InstallServer { id: id.to_string() }
|
||||
}
|
||||
// /share/{invite_id}
|
||||
Some(("share", raw)) => {
|
||||
let (raw, _) = raw.split_once('?').unwrap_or((raw, ""));
|
||||
|
||||
match decode(raw) {
|
||||
Ok(decoded) => CommandPayload::InstallSharedInstanceInvite {
|
||||
invite_id: decoded.to_string(),
|
||||
},
|
||||
Err(e) => {
|
||||
emit_warning(&format!(
|
||||
"Invalid UTF-8 in shared instance invite path: {e}"
|
||||
))
|
||||
.await?;
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Invalid UTF-8 in shared instance invite path: {e}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
// /launch/instance/{id} - Launches an instance
|
||||
Some(("launch", rest)) if rest.starts_with("instance/") => {
|
||||
let raw = rest.trim_start_matches("instance/");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Theseus instance management interface
|
||||
|
||||
mod content;
|
||||
mod content_set_diff;
|
||||
mod export_mrpack;
|
||||
mod get;
|
||||
mod install;
|
||||
@@ -8,6 +9,7 @@ mod lifecycle;
|
||||
mod paths;
|
||||
mod projects;
|
||||
mod run;
|
||||
mod shared;
|
||||
|
||||
pub use self::content::{
|
||||
get_content_items, get_dependencies_as_content_items,
|
||||
@@ -33,3 +35,20 @@ pub use self::projects::{
|
||||
pub use self::run::{
|
||||
QuickPlayType, kill, run, try_update_playtime_by_instance_id,
|
||||
};
|
||||
pub(crate) use self::shared::mark_shared_instance_stale;
|
||||
pub use self::shared::{
|
||||
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
|
||||
SharedInstanceInviteInstallPreview, SharedInstanceInviteLink,
|
||||
SharedInstanceJoinType, SharedInstancePublishPreview,
|
||||
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
|
||||
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
|
||||
accept_pending_shared_instance_invite,
|
||||
accept_shared_instance_invite_for_install,
|
||||
create_shared_instance_invite_link, decline_pending_shared_instance_invite,
|
||||
get_shared_instance_install_preview, get_shared_instance_publish_preview,
|
||||
get_shared_instance_update_preview, get_shared_instance_users,
|
||||
install_shared_instance, install_shared_instance_invite,
|
||||
invite_shared_instance_users, publish_shared_instance,
|
||||
remove_shared_instance_users, unlink_shared_instance,
|
||||
unpublish_shared_instance, update_shared_instance,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
use crate::state::Version;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ContentSetDiffKind {
|
||||
Added,
|
||||
Removed,
|
||||
Updated,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum ContentSetDiffEntry {
|
||||
Project {
|
||||
kind: ContentSetDiffKind,
|
||||
project_id: String,
|
||||
current_version_name: Option<String>,
|
||||
new_version_name: Option<String>,
|
||||
disabled: bool,
|
||||
},
|
||||
ExternalFile {
|
||||
kind: ContentSetDiffKind,
|
||||
file_name: String,
|
||||
disabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ContentSetDiffOptions {
|
||||
pub removed_disabled_project_ids: HashSet<String>,
|
||||
pub removed_disabled_external_files: HashSet<String>,
|
||||
pub common_external_files_are_updated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ContentSetSnapshot {
|
||||
pub versions: Vec<ContentSetSnapshotVersion>,
|
||||
pub external_files: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ContentSetSnapshotVersion {
|
||||
pub project_id: String,
|
||||
pub version_id: String,
|
||||
pub version_name: String,
|
||||
}
|
||||
|
||||
impl From<Version> for ContentSetSnapshotVersion {
|
||||
fn from(version: Version) -> Self {
|
||||
Self {
|
||||
project_id: version.project_id,
|
||||
version_id: version.id,
|
||||
version_name: version.version_number,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn diff_content_sets(
|
||||
current: &ContentSetSnapshot,
|
||||
latest: &ContentSetSnapshot,
|
||||
options: &ContentSetDiffOptions,
|
||||
) -> Vec<ContentSetDiffEntry> {
|
||||
let current_versions = versions_by_project(current);
|
||||
let latest_versions = versions_by_project(latest);
|
||||
let project_ids = current_versions
|
||||
.keys()
|
||||
.chain(latest_versions.keys())
|
||||
.copied()
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let mut diffs = Vec::new();
|
||||
for project_id in project_ids {
|
||||
let current = current_versions.get(project_id);
|
||||
let latest = latest_versions.get(project_id);
|
||||
|
||||
match (current, latest) {
|
||||
(None, Some(latest)) => {
|
||||
diffs.push(ContentSetDiffEntry::Project {
|
||||
kind: ContentSetDiffKind::Added,
|
||||
project_id: project_id.to_string(),
|
||||
current_version_name: None,
|
||||
new_version_name: Some(latest.version_name.clone()),
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
(Some(current), None) => {
|
||||
diffs.push(ContentSetDiffEntry::Project {
|
||||
kind: ContentSetDiffKind::Removed,
|
||||
project_id: project_id.to_string(),
|
||||
current_version_name: Some(current.version_name.clone()),
|
||||
new_version_name: None,
|
||||
disabled: options
|
||||
.removed_disabled_project_ids
|
||||
.contains(project_id),
|
||||
});
|
||||
}
|
||||
(Some(current), Some(latest))
|
||||
if current.version_id != latest.version_id =>
|
||||
{
|
||||
diffs.push(ContentSetDiffEntry::Project {
|
||||
kind: ContentSetDiffKind::Updated,
|
||||
project_id: project_id.to_string(),
|
||||
current_version_name: Some(current.version_name.clone()),
|
||||
new_version_name: Some(latest.version_name.clone()),
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
for file_name in latest.external_files.difference(¤t.external_files) {
|
||||
diffs.push(ContentSetDiffEntry::ExternalFile {
|
||||
kind: ContentSetDiffKind::Added,
|
||||
file_name: file_name.clone(),
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
if options.common_external_files_are_updated {
|
||||
for file_name in
|
||||
latest.external_files.intersection(¤t.external_files)
|
||||
{
|
||||
diffs.push(ContentSetDiffEntry::ExternalFile {
|
||||
kind: ContentSetDiffKind::Updated,
|
||||
file_name: file_name.clone(),
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for file_name in current.external_files.difference(&latest.external_files) {
|
||||
diffs.push(ContentSetDiffEntry::ExternalFile {
|
||||
kind: ContentSetDiffKind::Removed,
|
||||
file_name: file_name.clone(),
|
||||
disabled: options
|
||||
.removed_disabled_external_files
|
||||
.contains(file_name),
|
||||
});
|
||||
}
|
||||
|
||||
diffs
|
||||
}
|
||||
|
||||
fn versions_by_project(
|
||||
snapshot: &ContentSetSnapshot,
|
||||
) -> HashMap<&str, &ContentSetSnapshotVersion> {
|
||||
snapshot
|
||||
.versions
|
||||
.iter()
|
||||
.map(|version| (version.project_id.as_str(), version))
|
||||
.collect()
|
||||
}
|
||||
@@ -59,8 +59,13 @@ pub async fn edit(
|
||||
patch: EditInstance,
|
||||
) -> crate::Result<InstanceMetadata> {
|
||||
let state = State::get().await?;
|
||||
let should_reconcile_shared_publish = patch.link.is_some();
|
||||
crate::state::edit_instance(instance_id, patch, &state.pool).await?;
|
||||
|
||||
if should_reconcile_shared_publish {
|
||||
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
|
||||
}
|
||||
|
||||
let instance = crate::state::get_instance(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::event::emit::{emit_instance, emit_loading, init_loading};
|
||||
use crate::event::{InstancePayloadType, LoadingBarType};
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use crate::state::{ProjectType, State};
|
||||
use crate::state::{ContentSourceKind, ProjectType, SharedInstanceRole, State};
|
||||
use crate::util::fetch;
|
||||
use modrinth_content_management::{
|
||||
ContentType, ResolutionPreferences, ResolveContentPlan,
|
||||
@@ -38,6 +38,7 @@ pub async fn update_all_projects(
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
|
||||
emit_loading(&loading_bar, 100.0, Some("Updated instance"))?;
|
||||
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
||||
|
||||
@@ -51,6 +52,12 @@ pub async fn update_project(
|
||||
skip_send_event: Option<bool>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
ensure_shared_instance_can_modify_project(
|
||||
instance_id,
|
||||
project_path,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
let path = crate::state::instances::commands::update_project(
|
||||
instance_id,
|
||||
project_path,
|
||||
@@ -58,6 +65,7 @@ pub async fn update_project(
|
||||
)
|
||||
.await?;
|
||||
|
||||
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
|
||||
if !skip_send_event.unwrap_or(false) {
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
}
|
||||
@@ -83,6 +91,7 @@ pub async fn add_project_from_version(
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(project_path)
|
||||
@@ -121,6 +130,16 @@ pub async fn install_project_with_dependencies(
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if let Err(error) = super::shared::mark_shared_instance_stale(
|
||||
&instance_id,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to mark shared instance stale after content install: {error}"
|
||||
);
|
||||
}
|
||||
if let Err(error) = emit_instance(
|
||||
&instance_id,
|
||||
InstancePayloadType::ContentInstallFinished {
|
||||
@@ -181,6 +200,12 @@ pub async fn switch_project_version_with_dependencies(
|
||||
version_id: &str,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
ensure_shared_instance_can_modify_project(
|
||||
instance_id,
|
||||
project_path,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
let metadata = super::get::get(instance_id).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
@@ -192,6 +217,7 @@ pub async fn switch_project_version_with_dependencies(
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
|
||||
emit_instance(&metadata.instance.id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(path)
|
||||
@@ -204,13 +230,18 @@ pub async fn add_project_from_path(
|
||||
project_type: Option<ProjectType>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
crate::state::instances::commands::add_project_from_path(
|
||||
instance_id,
|
||||
path,
|
||||
project_type,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
let project_path =
|
||||
crate::state::instances::commands::add_project_from_path(
|
||||
instance_id,
|
||||
path,
|
||||
project_type,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(project_path)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
@@ -220,6 +251,8 @@ pub async fn toggle_disable_project(
|
||||
desired_enabled: Option<bool>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
ensure_shared_instance_can_modify_project(instance_id, project, &state)
|
||||
.await?;
|
||||
let res = crate::state::instances::commands::toggle_disable_project(
|
||||
instance_id,
|
||||
project,
|
||||
@@ -227,6 +260,7 @@ pub async fn toggle_disable_project(
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(res)
|
||||
@@ -238,17 +272,71 @@ pub async fn remove_project(
|
||||
project: &str,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
ensure_shared_instance_can_modify_project(instance_id, project, &state)
|
||||
.await?;
|
||||
crate::state::instances::commands::remove_project(
|
||||
instance_id,
|
||||
project,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_shared_instance_can_modify_project(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let metadata = crate::state::instances::commands::get_instance_metadata(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
if !metadata
|
||||
.shared_instance
|
||||
.is_some_and(|attachment| attachment.role == SharedInstanceRole::Member)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let source_kind =
|
||||
crate::state::instances::commands::content_source_kind_for_project_path(
|
||||
instance_id,
|
||||
project_path,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
if is_shared_instance_managed_source_kind(source_kind) {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Shared instance managed content cannot be changed directly."
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_shared_instance_managed_source_kind(
|
||||
source_kind: Option<ContentSourceKind>,
|
||||
) -> bool {
|
||||
matches!(
|
||||
source_kind,
|
||||
Some(
|
||||
ContentSourceKind::SharedInstance
|
||||
| ContentSourceKind::ModrinthModpack
|
||||
| ContentSourceKind::ImportedModpack
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn update_managed_modrinth_version(
|
||||
instance_id: &str,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ pub mod process;
|
||||
pub mod server_address;
|
||||
pub mod settings;
|
||||
pub mod tags;
|
||||
pub mod users;
|
||||
pub mod worlds;
|
||||
|
||||
pub mod data {
|
||||
@@ -26,7 +27,8 @@ pub mod data {
|
||||
JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader,
|
||||
ModrinthCredentials, Organization, OwnerType, ProcessMetadata, Project,
|
||||
ProjectType, ProjectV3, SearchResult, SearchResults, SearchResultsV3,
|
||||
Settings, TeamMember, Theme, User, UserFriend, Version, WindowSize,
|
||||
Settings, SharedInstanceAttachment, SharedInstanceRole, TeamMember,
|
||||
Theme, User, UserFriend, Version, WindowSize,
|
||||
};
|
||||
pub use ariadne::users::UserStatus;
|
||||
pub use modrinth_content_management::{
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
use crate::state::ModrinthCredentials;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ModrinthAuthFlow {
|
||||
SignIn,
|
||||
SignUp,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub fn authenticate_begin_flow() -> &'static str {
|
||||
crate::state::get_login_url()
|
||||
pub fn authenticate_begin_flow(flow: ModrinthAuthFlow) -> &'static str {
|
||||
match flow {
|
||||
ModrinthAuthFlow::SignIn => crate::state::get_login_url(),
|
||||
ModrinthAuthFlow::SignUp => crate::state::get_signup_url(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use crate::State;
|
||||
use crate::util::fetch::fetch_json;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct SearchUser {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn search_user(query: &str) -> crate::Result<Vec<SearchUser>> {
|
||||
let state = State::get().await?;
|
||||
let query = urlencoding::encode(query);
|
||||
|
||||
fetch_json(
|
||||
Method::GET,
|
||||
&format!(
|
||||
"{}users/search?query={}",
|
||||
env!("MODRINTH_API_URL_V3"),
|
||||
query
|
||||
),
|
||||
None,
|
||||
None,
|
||||
Some("/v3/users/search"),
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -234,6 +234,9 @@ pub enum CommandPayload {
|
||||
server: Option<String>,
|
||||
singleplayer_world: Option<String>,
|
||||
},
|
||||
InstallSharedInstanceInvite {
|
||||
invite_id: String,
|
||||
},
|
||||
RunMRPack {
|
||||
// run or install .mrpack
|
||||
path: PathBuf,
|
||||
|
||||
@@ -9,10 +9,13 @@ pub use model::{
|
||||
InstallErrorView, InstallJavaStep, InstallJobKind, InstallJobSnapshot,
|
||||
InstallJobStatus, InstallModpackPreview, InstallPhaseDetails,
|
||||
InstallPhaseId, InstallPostInstallEdit, InstallProgress,
|
||||
InstallProgressSecondary, InstallRequest,
|
||||
InstallProgressSecondary, InstallRequest, SharedInstanceExternalFileData,
|
||||
SharedInstanceInstallData, SharedInstanceInstallModpack,
|
||||
};
|
||||
pub use runner::{
|
||||
cancel_job, create_instance, create_modpack_instance, dismiss_job,
|
||||
duplicate_instance, get_job, import_instance, install_existing_instance,
|
||||
cancel_job, create_instance, create_modpack_instance,
|
||||
create_shared_instance, dismiss_job, duplicate_instance, get_job,
|
||||
import_instance, install_existing_instance,
|
||||
install_pack_to_existing_instance, list_jobs, retry_job,
|
||||
update_shared_instance,
|
||||
};
|
||||
|
||||
@@ -64,6 +64,9 @@ pub enum InstallRequest {
|
||||
#[serde(default)]
|
||||
post_install_edit: Option<InstallPostInstallEdit>,
|
||||
},
|
||||
CreateSharedInstance {
|
||||
data: SharedInstanceInstallData,
|
||||
},
|
||||
ImportInstance {
|
||||
launcher_type: ImportLauncherType,
|
||||
base_path: PathBuf,
|
||||
@@ -82,6 +85,10 @@ pub enum InstallRequest {
|
||||
#[serde(default)]
|
||||
post_install_edit: Option<InstallPostInstallEdit>,
|
||||
},
|
||||
UpdateSharedInstance {
|
||||
instance_id: String,
|
||||
data: SharedInstanceInstallData,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
@@ -96,6 +103,45 @@ pub struct InstallPostInstallEdit {
|
||||
pub link: Option<InstanceLink>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct SharedInstanceInstallData {
|
||||
pub shared_instance_id: String,
|
||||
pub manager_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub server_manager_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub server_manager_icon_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub linked_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub access_token: Option<String>,
|
||||
pub name: String,
|
||||
pub version: i32,
|
||||
pub modrinth_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub external_files: Vec<SharedInstanceExternalFileData>,
|
||||
pub modpack: Option<SharedInstanceInstallModpack>,
|
||||
pub game_version: String,
|
||||
pub loader: ModLoader,
|
||||
pub loader_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct SharedInstanceExternalFileData {
|
||||
pub file_name: String,
|
||||
pub file_type: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct SharedInstanceInstallModpack {
|
||||
pub project_id: String,
|
||||
pub version_id: String,
|
||||
pub title: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub dependency_count: usize,
|
||||
}
|
||||
|
||||
impl InstallRequest {
|
||||
pub fn kind(&self) -> InstallJobKind {
|
||||
match self {
|
||||
@@ -103,6 +149,9 @@ impl InstallRequest {
|
||||
Self::CreateModpackInstance { .. } => {
|
||||
InstallJobKind::CreateModpackInstance
|
||||
}
|
||||
Self::CreateSharedInstance { .. } => {
|
||||
InstallJobKind::CreateSharedInstance
|
||||
}
|
||||
Self::ImportInstance { .. } => InstallJobKind::ImportInstance,
|
||||
Self::DuplicateInstance { .. } => InstallJobKind::DuplicateInstance,
|
||||
Self::InstallExistingInstance { .. } => {
|
||||
@@ -111,13 +160,17 @@ impl InstallRequest {
|
||||
Self::InstallPackToExistingInstance { .. } => {
|
||||
InstallJobKind::InstallPackToExistingInstance
|
||||
}
|
||||
Self::UpdateSharedInstance { .. } => {
|
||||
InstallJobKind::UpdateSharedInstance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target(&self) -> InstallTarget {
|
||||
match self {
|
||||
Self::InstallExistingInstance { instance_id, .. }
|
||||
| Self::InstallPackToExistingInstance { instance_id, .. } => {
|
||||
| Self::InstallPackToExistingInstance { instance_id, .. }
|
||||
| Self::UpdateSharedInstance { instance_id, .. } => {
|
||||
InstallTarget::ExistingInstance {
|
||||
instance_id: instance_id.clone(),
|
||||
}
|
||||
@@ -129,7 +182,8 @@ impl InstallRequest {
|
||||
pub fn cleanup(&self) -> InstallCleanup {
|
||||
match self {
|
||||
Self::InstallExistingInstance { instance_id, .. }
|
||||
| Self::InstallPackToExistingInstance { instance_id, .. } => {
|
||||
| Self::InstallPackToExistingInstance { instance_id, .. }
|
||||
| Self::UpdateSharedInstance { instance_id, .. } => {
|
||||
InstallCleanup::RestoreExistingInstance {
|
||||
instance_id: instance_id.clone(),
|
||||
}
|
||||
@@ -144,10 +198,12 @@ impl InstallRequest {
|
||||
pub enum InstallJobKind {
|
||||
CreateInstance,
|
||||
CreateModpackInstance,
|
||||
CreateSharedInstance,
|
||||
ImportInstance,
|
||||
DuplicateInstance,
|
||||
InstallExistingInstance,
|
||||
InstallPackToExistingInstance,
|
||||
UpdateSharedInstance,
|
||||
}
|
||||
|
||||
impl InstallJobKind {
|
||||
@@ -155,24 +211,28 @@ impl InstallJobKind {
|
||||
match self {
|
||||
Self::CreateInstance => "create_instance",
|
||||
Self::CreateModpackInstance => "create_modpack_instance",
|
||||
Self::CreateSharedInstance => "create_shared_instance",
|
||||
Self::ImportInstance => "import_instance",
|
||||
Self::DuplicateInstance => "duplicate_instance",
|
||||
Self::InstallExistingInstance => "install_existing_instance",
|
||||
Self::InstallPackToExistingInstance => {
|
||||
"install_pack_to_existing_instance"
|
||||
}
|
||||
Self::UpdateSharedInstance => "update_shared_instance",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_stored_str(value: &str) -> Self {
|
||||
match value {
|
||||
"create_modpack_instance" => Self::CreateModpackInstance,
|
||||
"create_shared_instance" => Self::CreateSharedInstance,
|
||||
"import_instance" => Self::ImportInstance,
|
||||
"duplicate_instance" => Self::DuplicateInstance,
|
||||
"install_existing_instance" => Self::InstallExistingInstance,
|
||||
"install_pack_to_existing_instance" => {
|
||||
Self::InstallPackToExistingInstance
|
||||
}
|
||||
"update_shared_instance" => Self::UpdateSharedInstance,
|
||||
_ => Self::CreateInstance,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,15 @@ fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
|
||||
}),
|
||||
crate::api::pack::install_from::CreatePackLocation::FromFile { .. } => None,
|
||||
},
|
||||
InstallRequest::CreateSharedInstance { data } => {
|
||||
Some(InstallJobDisplay {
|
||||
title: data.name.clone(),
|
||||
icon: data
|
||||
.modpack
|
||||
.as_ref()
|
||||
.and_then(|modpack| modpack.icon_url.clone()),
|
||||
})
|
||||
}
|
||||
InstallRequest::ImportInstance {
|
||||
instance_folder, ..
|
||||
} => Some(InstallJobDisplay {
|
||||
@@ -80,7 +89,8 @@ fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
|
||||
}),
|
||||
InstallRequest::DuplicateInstance { .. }
|
||||
| InstallRequest::InstallExistingInstance { .. }
|
||||
| InstallRequest::InstallPackToExistingInstance { .. } => {
|
||||
| InstallRequest::InstallPackToExistingInstance { .. }
|
||||
| InstallRequest::UpdateSharedInstance { .. } => {
|
||||
state.rollback.as_ref().map(|rollback| InstallJobDisplay {
|
||||
title: rollback.instance.instance.name.clone(),
|
||||
icon: rollback.instance.instance.icon_path.clone(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -271,6 +271,7 @@ pub async fn install_minecraft_with_reporter(
|
||||
};
|
||||
|
||||
let state = State::get().await?;
|
||||
let previous_install_stage = instance.install_stage;
|
||||
|
||||
crate::state::instances::commands::set_instance_install_stage(
|
||||
&instance.id,
|
||||
@@ -280,6 +281,7 @@ pub async fn install_minecraft_with_reporter(
|
||||
.await?;
|
||||
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
||||
|
||||
let result = async {
|
||||
let instance_path = get_instance_full_path(&instance.path).await?;
|
||||
if let Some(reporter) = &reporter {
|
||||
reporter
|
||||
@@ -598,7 +600,34 @@ pub async fn install_minecraft_with_reporter(
|
||||
emit_loading(loading_bar, 1.0, Some("Finished installing"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok::<(), crate::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
if let Err(error) =
|
||||
crate::state::instances::commands::set_instance_install_stage(
|
||||
&instance.id,
|
||||
previous_install_stage,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to restore install stage for instance {}: {error}",
|
||||
instance.id
|
||||
);
|
||||
} else if let Err(error) =
|
||||
emit_instance(&instance.id, InstancePayloadType::Edited).await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to emit restored install stage for instance {}: {error}",
|
||||
instance.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn install_minecraft_for_instance_id_with_reporter(
|
||||
|
||||
@@ -240,6 +240,11 @@ impl FriendsSocket {
|
||||
}
|
||||
|
||||
async fn handle_notification(notification: Value) -> crate::Result<()> {
|
||||
tracing::info!(
|
||||
notification = %notification,
|
||||
"Received websocket notification payload"
|
||||
);
|
||||
|
||||
if notification
|
||||
.get("body")
|
||||
.and_then(|body| body.get("type"))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
use super::instances::ContentSourceKind;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceInstallStage {
|
||||
@@ -122,6 +124,7 @@ pub struct ContentFile {
|
||||
pub metadata: Option<FileMetadata>,
|
||||
pub update_version_id: Option<String>,
|
||||
pub project_type: ProjectType,
|
||||
pub source_kind: Option<ContentSourceKind>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -186,6 +189,18 @@ impl ProjectType {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"mod" | "mods" => Some(ProjectType::Mod),
|
||||
"datapack" | "datapacks" => Some(ProjectType::DataPack),
|
||||
"resourcepack" | "resourcepacks" => Some(ProjectType::ResourcePack),
|
||||
"shader" | "shaderpack" | "shaderpacks" => {
|
||||
Some(ProjectType::ShaderPack)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_folder(&self) -> &'static str {
|
||||
match self {
|
||||
ProjectType::Mod => "mods",
|
||||
|
||||
@@ -374,6 +374,56 @@ where
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_content_set_remote_ref(
|
||||
remote_ref: &ContentSetRemoteRef,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let content_set_id = remote_ref.content_set_id.as_str();
|
||||
let ref_type = remote_ref.ref_type.as_str();
|
||||
let ref_id = remote_ref.ref_id.as_str();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO instance_content_set_remote_refs (
|
||||
content_set_id,
|
||||
ref_type,
|
||||
ref_id
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (content_set_id, ref_type) DO UPDATE SET
|
||||
ref_id = excluded.ref_id
|
||||
",
|
||||
content_set_id,
|
||||
ref_type,
|
||||
ref_id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_content_set_remote_ref(
|
||||
content_set_id: &str,
|
||||
ref_type: ContentSetRemoteRefType,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let ref_type = ref_type.as_str();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM instance_content_set_remote_refs
|
||||
WHERE content_set_id = ? AND ref_type = ?
|
||||
",
|
||||
content_set_id,
|
||||
ref_type,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn get_content_set_sync_state<'e, E>(
|
||||
content_set_id: &str,
|
||||
exec: E,
|
||||
@@ -396,6 +446,66 @@ where
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_content_set_sync_state(
|
||||
sync_state: &ContentSetSyncState,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let content_set_id = sync_state.content_set_id.as_str();
|
||||
let provider = sync_state.provider.as_str();
|
||||
let applied_update_id = sync_state.applied_update_id.as_deref();
|
||||
let latest_available_update_id =
|
||||
sync_state.latest_available_update_id.as_deref();
|
||||
let checked_at = sync_state.checked_at.map(|value| value.timestamp());
|
||||
let status = sync_state.status.as_str();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO instance_content_set_sync_state (
|
||||
content_set_id,
|
||||
provider,
|
||||
applied_update_id,
|
||||
latest_available_update_id,
|
||||
checked_at,
|
||||
status
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (content_set_id) DO UPDATE SET
|
||||
provider = excluded.provider,
|
||||
applied_update_id = excluded.applied_update_id,
|
||||
latest_available_update_id = excluded.latest_available_update_id,
|
||||
checked_at = excluded.checked_at,
|
||||
status = excluded.status
|
||||
",
|
||||
content_set_id,
|
||||
provider,
|
||||
applied_update_id,
|
||||
latest_available_update_id,
|
||||
checked_at,
|
||||
status,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_content_set_sync_state(
|
||||
content_set_id: &str,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM instance_content_set_sync_state
|
||||
WHERE content_set_id = ?
|
||||
",
|
||||
content_set_id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instance_files<'e, E>(
|
||||
instance_id: &str,
|
||||
exec: E,
|
||||
@@ -1011,16 +1121,12 @@ pub(crate) async fn upsert_content_update_check(
|
||||
}
|
||||
|
||||
fn project_type_from_str(value: &str) -> crate::Result<ProjectType> {
|
||||
match value {
|
||||
"mod" => Ok(ProjectType::Mod),
|
||||
"datapack" => Ok(ProjectType::DataPack),
|
||||
"resourcepack" => Ok(ProjectType::ResourcePack),
|
||||
"shader" | "shaderpack" => Ok(ProjectType::ShaderPack),
|
||||
other => Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown content project type {other}"
|
||||
ProjectType::from_name(value).ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Unknown content project type {value}"
|
||||
))
|
||||
.into()),
|
||||
}
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn timestamp(value: i64) -> DateTime<Utc> {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::state::instances::{
|
||||
ContentSet, ContentSetStatus, ContentSourceKind, Instance,
|
||||
InstanceLaunchContext, InstanceLaunchOverrides,
|
||||
InstanceLaunchOverridesData, InstanceLink, playtime_to_storage,
|
||||
ContentSet, ContentSetStatus, ContentSetSyncStatus, ContentSourceKind,
|
||||
Instance, InstanceLaunchContext, InstanceLaunchOverrides,
|
||||
InstanceLaunchOverridesData, InstanceLink, SharedInstanceAttachment,
|
||||
SharedInstanceRole, playtime_to_storage,
|
||||
};
|
||||
use crate::state::{
|
||||
InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel,
|
||||
@@ -73,6 +74,9 @@ pub(crate) struct InstanceLinkRow {
|
||||
pub hosting_instance_ids: Option<String>,
|
||||
pub hosting_active_instance_id: Option<String>,
|
||||
pub shared_instance_id: Option<String>,
|
||||
pub shared_instance_role: Option<String>,
|
||||
pub shared_instance_manager_id: Option<String>,
|
||||
pub shared_instance_linked_user_id: Option<String>,
|
||||
pub imported_name: Option<String>,
|
||||
pub imported_version_number: Option<String>,
|
||||
pub imported_filename: Option<String>,
|
||||
@@ -137,10 +141,8 @@ impl TryFrom<InstanceLinkRow> for InstanceLink {
|
||||
filename: row.imported_filename,
|
||||
}),
|
||||
"shared_instance" => Ok(Self::SharedInstance {
|
||||
shared_instance_id: parse_uuid(
|
||||
row.shared_instance_id,
|
||||
"shared_instance_id",
|
||||
)?,
|
||||
modpack_project_id: row.modrinth_project_id,
|
||||
modpack_version_id: row.modrinth_version_id,
|
||||
}),
|
||||
other => Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance link kind {other}"
|
||||
@@ -161,6 +163,7 @@ pub(crate) struct InstanceMetadataRecord {
|
||||
pub instance: Instance,
|
||||
pub applied_content_set: ContentSet,
|
||||
pub link: InstanceLink,
|
||||
pub shared_instance: Option<SharedInstanceAttachment>,
|
||||
pub groups: Vec<String>,
|
||||
pub launch_overrides: InstanceLaunchOverrides,
|
||||
}
|
||||
@@ -207,6 +210,12 @@ struct InstanceMetadataRow {
|
||||
hosting_instance_ids: Option<String>,
|
||||
hosting_active_instance_id: Option<String>,
|
||||
shared_instance_id: Option<String>,
|
||||
shared_instance_role: Option<String>,
|
||||
shared_instance_manager_id: Option<String>,
|
||||
shared_instance_linked_user_id: Option<String>,
|
||||
shared_sync_applied_update_id: Option<String>,
|
||||
shared_sync_latest_available_update_id: Option<String>,
|
||||
shared_sync_status: Option<String>,
|
||||
imported_name: Option<String>,
|
||||
imported_version_number: Option<String>,
|
||||
imported_filename: Option<String>,
|
||||
@@ -300,12 +309,26 @@ impl InstanceMetadataRow {
|
||||
hosting_server_id: self.hosting_server_id,
|
||||
hosting_instance_ids: self.hosting_instance_ids,
|
||||
hosting_active_instance_id: self.hosting_active_instance_id,
|
||||
shared_instance_id: self.shared_instance_id,
|
||||
shared_instance_id: self.shared_instance_id.clone(),
|
||||
shared_instance_role: self.shared_instance_role.clone(),
|
||||
shared_instance_manager_id: self.shared_instance_manager_id.clone(),
|
||||
shared_instance_linked_user_id: self
|
||||
.shared_instance_linked_user_id
|
||||
.clone(),
|
||||
imported_name: self.imported_name,
|
||||
imported_version_number: self.imported_version_number,
|
||||
imported_filename: self.imported_filename,
|
||||
}
|
||||
.try_into()?;
|
||||
let shared_instance = shared_instance_attachment(
|
||||
self.shared_instance_id,
|
||||
self.shared_instance_role,
|
||||
self.shared_instance_manager_id,
|
||||
self.shared_instance_linked_user_id,
|
||||
self.shared_sync_status,
|
||||
self.shared_sync_applied_update_id,
|
||||
self.shared_sync_latest_available_update_id,
|
||||
)?;
|
||||
let groups = parse_groups(self.groups)?;
|
||||
let launch_overrides =
|
||||
launch_overrides_from_json(instance_id, self.launch_overrides)?;
|
||||
@@ -314,6 +337,7 @@ impl InstanceMetadataRow {
|
||||
instance,
|
||||
applied_content_set,
|
||||
link,
|
||||
shared_instance,
|
||||
groups,
|
||||
launch_overrides,
|
||||
})
|
||||
@@ -460,6 +484,12 @@ pub(crate) async fn get_instance_metadata_by_id(
|
||||
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
|
||||
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
|
||||
link.shared_instance_id AS "shared_instance_id?: String",
|
||||
link.shared_instance_role AS "shared_instance_role?: String",
|
||||
link.shared_instance_manager_id AS "shared_instance_manager_id?: String",
|
||||
link.shared_instance_linked_user_id AS "shared_instance_linked_user_id?: String",
|
||||
sync.applied_update_id AS "shared_sync_applied_update_id?: String",
|
||||
sync.latest_available_update_id AS "shared_sync_latest_available_update_id?: String",
|
||||
sync.status AS "shared_sync_status?: String",
|
||||
link.imported_name AS "imported_name?: String",
|
||||
link.imported_version_number AS "imported_version_number?: String",
|
||||
link.imported_filename AS "imported_filename?: String",
|
||||
@@ -479,6 +509,9 @@ pub(crate) async fn get_instance_metadata_by_id(
|
||||
AND cs.instance_id = i.id
|
||||
LEFT JOIN instance_links link
|
||||
ON link.instance_id = i.id
|
||||
LEFT JOIN instance_content_set_sync_state sync
|
||||
ON sync.content_set_id = cs.id
|
||||
AND sync.provider = 'shared_instance'
|
||||
LEFT JOIN instance_launch_overrides overrides
|
||||
ON overrides.instance_id = i.id
|
||||
WHERE i.id = ?
|
||||
@@ -488,7 +521,13 @@ pub(crate) async fn get_instance_metadata_by_id(
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(InstanceMetadataRow::into_record).transpose()
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let record =
|
||||
hydrate_shared_instance_fields(row.into_record()?, pool).await?;
|
||||
|
||||
Ok(Some(record))
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instance_metadata_many(
|
||||
@@ -542,6 +581,12 @@ pub(crate) async fn get_instance_metadata_many(
|
||||
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
|
||||
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
|
||||
link.shared_instance_id AS "shared_instance_id?: String",
|
||||
link.shared_instance_role AS "shared_instance_role?: String",
|
||||
link.shared_instance_manager_id AS "shared_instance_manager_id?: String",
|
||||
link.shared_instance_linked_user_id AS "shared_instance_linked_user_id?: String",
|
||||
sync.applied_update_id AS "shared_sync_applied_update_id?: String",
|
||||
sync.latest_available_update_id AS "shared_sync_latest_available_update_id?: String",
|
||||
sync.status AS "shared_sync_status?: String",
|
||||
link.imported_name AS "imported_name?: String",
|
||||
link.imported_version_number AS "imported_version_number?: String",
|
||||
link.imported_filename AS "imported_filename?: String",
|
||||
@@ -563,6 +608,9 @@ pub(crate) async fn get_instance_metadata_many(
|
||||
AND cs.instance_id = i.id
|
||||
LEFT JOIN instance_links link
|
||||
ON link.instance_id = i.id
|
||||
LEFT JOIN instance_content_set_sync_state sync
|
||||
ON sync.content_set_id = cs.id
|
||||
AND sync.provider = 'shared_instance'
|
||||
LEFT JOIN instance_launch_overrides overrides
|
||||
ON overrides.instance_id = i.id
|
||||
ORDER BY requested.ord
|
||||
@@ -572,9 +620,14 @@ pub(crate) async fn get_instance_metadata_many(
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(InstanceMetadataRow::into_record)
|
||||
.collect()
|
||||
let mut records = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
records.push(
|
||||
hydrate_shared_instance_fields(row.into_record()?, pool).await?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_instance_metadata(
|
||||
@@ -618,6 +671,12 @@ pub(crate) async fn list_instance_metadata(
|
||||
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
|
||||
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
|
||||
link.shared_instance_id AS "shared_instance_id?: String",
|
||||
link.shared_instance_role AS "shared_instance_role?: String",
|
||||
link.shared_instance_manager_id AS "shared_instance_manager_id?: String",
|
||||
link.shared_instance_linked_user_id AS "shared_instance_linked_user_id?: String",
|
||||
sync.applied_update_id AS "shared_sync_applied_update_id?: String",
|
||||
sync.latest_available_update_id AS "shared_sync_latest_available_update_id?: String",
|
||||
sync.status AS "shared_sync_status?: String",
|
||||
link.imported_name AS "imported_name?: String",
|
||||
link.imported_version_number AS "imported_version_number?: String",
|
||||
link.imported_filename AS "imported_filename?: String",
|
||||
@@ -637,6 +696,9 @@ pub(crate) async fn list_instance_metadata(
|
||||
AND cs.instance_id = i.id
|
||||
LEFT JOIN instance_links link
|
||||
ON link.instance_id = i.id
|
||||
LEFT JOIN instance_content_set_sync_state sync
|
||||
ON sync.content_set_id = cs.id
|
||||
AND sync.provider = 'shared_instance'
|
||||
LEFT JOIN instance_launch_overrides overrides
|
||||
ON overrides.instance_id = i.id
|
||||
"#,
|
||||
@@ -644,9 +706,14 @@ pub(crate) async fn list_instance_metadata(
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(InstanceMetadataRow::into_record)
|
||||
.collect()
|
||||
let mut records = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
records.push(
|
||||
hydrate_shared_instance_fields(row.into_record()?, pool).await?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instance_launch_context(
|
||||
@@ -691,6 +758,12 @@ pub(crate) async fn get_instance_launch_context(
|
||||
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
|
||||
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
|
||||
link.shared_instance_id AS "shared_instance_id?: String",
|
||||
link.shared_instance_role AS "shared_instance_role?: String",
|
||||
link.shared_instance_manager_id AS "shared_instance_manager_id?: String",
|
||||
link.shared_instance_linked_user_id AS "shared_instance_linked_user_id?: String",
|
||||
sync.applied_update_id AS "shared_sync_applied_update_id?: String",
|
||||
sync.latest_available_update_id AS "shared_sync_latest_available_update_id?: String",
|
||||
sync.status AS "shared_sync_status?: String",
|
||||
link.imported_name AS "imported_name?: String",
|
||||
link.imported_version_number AS "imported_version_number?: String",
|
||||
link.imported_filename AS "imported_filename?: String",
|
||||
@@ -702,6 +775,9 @@ pub(crate) async fn get_instance_launch_context(
|
||||
AND cs.instance_id = i.id
|
||||
LEFT JOIN instance_links link
|
||||
ON link.instance_id = i.id
|
||||
LEFT JOIN instance_content_set_sync_state sync
|
||||
ON sync.content_set_id = cs.id
|
||||
AND sync.provider = 'shared_instance'
|
||||
LEFT JOIN instance_launch_overrides overrides
|
||||
ON overrides.instance_id = i.id
|
||||
WHERE i.id = ?
|
||||
@@ -753,6 +829,9 @@ where
|
||||
json(hosting_instance_ids) AS "hosting_instance_ids?: String",
|
||||
hosting_active_instance_id,
|
||||
shared_instance_id,
|
||||
shared_instance_role,
|
||||
shared_instance_manager_id,
|
||||
shared_instance_linked_user_id,
|
||||
imported_name,
|
||||
imported_version_number,
|
||||
imported_filename
|
||||
@@ -949,7 +1028,6 @@ pub(crate) async fn upsert_instance_link(
|
||||
let hosting_instance_ids = columns.hosting_instance_ids.as_deref();
|
||||
let hosting_active_instance_id =
|
||||
columns.hosting_active_instance_id.as_deref();
|
||||
let shared_instance_id = columns.shared_instance_id.as_deref();
|
||||
let imported_name = columns.imported_name.as_deref();
|
||||
let imported_version_number = columns.imported_version_number.as_deref();
|
||||
let imported_filename = columns.imported_filename.as_deref();
|
||||
@@ -967,12 +1045,11 @@ pub(crate) async fn upsert_instance_link(
|
||||
hosting_server_id,
|
||||
hosting_instance_ids,
|
||||
hosting_active_instance_id,
|
||||
shared_instance_id,
|
||||
imported_name,
|
||||
imported_version_number,
|
||||
imported_filename
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?)
|
||||
ON CONFLICT (instance_id) DO UPDATE SET
|
||||
link_kind = excluded.link_kind,
|
||||
modrinth_project_id = excluded.modrinth_project_id,
|
||||
@@ -983,7 +1060,6 @@ pub(crate) async fn upsert_instance_link(
|
||||
hosting_server_id = excluded.hosting_server_id,
|
||||
hosting_instance_ids = excluded.hosting_instance_ids,
|
||||
hosting_active_instance_id = excluded.hosting_active_instance_id,
|
||||
shared_instance_id = excluded.shared_instance_id,
|
||||
imported_name = excluded.imported_name,
|
||||
imported_version_number = excluded.imported_version_number,
|
||||
imported_filename = excluded.imported_filename
|
||||
@@ -998,7 +1074,6 @@ pub(crate) async fn upsert_instance_link(
|
||||
hosting_server_id,
|
||||
hosting_instance_ids,
|
||||
hosting_active_instance_id,
|
||||
shared_instance_id,
|
||||
imported_name,
|
||||
imported_version_number,
|
||||
imported_filename,
|
||||
@@ -1009,6 +1084,76 @@ pub(crate) async fn upsert_instance_link(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_shared_instance_attachment(
|
||||
instance_id: &str,
|
||||
attachment: Option<&SharedInstanceAttachment>,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let shared_instance_id = attachment.map(|value| value.id.to_string());
|
||||
let shared_instance_role =
|
||||
attachment.map(|value| value.role.as_str().to_string());
|
||||
let shared_instance_manager_id =
|
||||
attachment.and_then(|value| value.manager_id.as_deref());
|
||||
let shared_instance_linked_user_id =
|
||||
attachment.and_then(|value| value.linked_user_id.as_deref());
|
||||
let shared_instance_access_token =
|
||||
attachment.and_then(|value| value.access_token.as_deref());
|
||||
let shared_instance_server_manager_name =
|
||||
attachment.and_then(|value| value.server_manager_name.as_deref());
|
||||
let shared_instance_server_manager_icon_url =
|
||||
attachment.and_then(|value| value.server_manager_icon_url.as_deref());
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO instance_links (
|
||||
instance_id,
|
||||
link_kind,
|
||||
shared_instance_id,
|
||||
shared_instance_role,
|
||||
shared_instance_manager_id,
|
||||
shared_instance_linked_user_id
|
||||
)
|
||||
VALUES (?, 'unmanaged', ?, ?, ?, ?)
|
||||
ON CONFLICT (instance_id) DO UPDATE SET
|
||||
link_kind = CASE
|
||||
WHEN excluded.shared_instance_id IS NULL
|
||||
AND instance_links.link_kind = 'shared_instance'
|
||||
THEN 'unmanaged'
|
||||
ELSE instance_links.link_kind
|
||||
END,
|
||||
shared_instance_id = excluded.shared_instance_id,
|
||||
shared_instance_role = excluded.shared_instance_role,
|
||||
shared_instance_manager_id = excluded.shared_instance_manager_id,
|
||||
shared_instance_linked_user_id = excluded.shared_instance_linked_user_id
|
||||
",
|
||||
instance_id,
|
||||
shared_instance_id,
|
||||
shared_instance_role,
|
||||
shared_instance_manager_id,
|
||||
shared_instance_linked_user_id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
UPDATE instance_links
|
||||
SET shared_instance_access_token = ?,
|
||||
shared_instance_server_manager_name = ?,
|
||||
shared_instance_server_manager_icon_url = ?
|
||||
WHERE instance_id = ?
|
||||
",
|
||||
)
|
||||
.bind(shared_instance_access_token)
|
||||
.bind(shared_instance_server_manager_name)
|
||||
.bind(shared_instance_server_manager_icon_url)
|
||||
.bind(instance_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn replace_instance_groups(
|
||||
instance_id: &str,
|
||||
groups: &[String],
|
||||
@@ -1094,7 +1239,6 @@ struct InstanceLinkColumns {
|
||||
hosting_server_id: Option<String>,
|
||||
hosting_instance_ids: Option<String>,
|
||||
hosting_active_instance_id: Option<String>,
|
||||
shared_instance_id: Option<String>,
|
||||
imported_name: Option<String>,
|
||||
imported_version_number: Option<String>,
|
||||
imported_filename: Option<String>,
|
||||
@@ -1114,7 +1258,6 @@ fn instance_link_columns(
|
||||
hosting_server_id: None,
|
||||
hosting_instance_ids: None,
|
||||
hosting_active_instance_id: None,
|
||||
shared_instance_id: None,
|
||||
imported_name: None,
|
||||
imported_version_number: None,
|
||||
imported_filename: None,
|
||||
@@ -1132,7 +1275,6 @@ fn instance_link_columns(
|
||||
hosting_server_id: None,
|
||||
hosting_instance_ids: None,
|
||||
hosting_active_instance_id: None,
|
||||
shared_instance_id: None,
|
||||
imported_name: None,
|
||||
imported_version_number: None,
|
||||
imported_filename: None,
|
||||
@@ -1147,7 +1289,6 @@ fn instance_link_columns(
|
||||
hosting_server_id: None,
|
||||
hosting_instance_ids: None,
|
||||
hosting_active_instance_id: None,
|
||||
shared_instance_id: None,
|
||||
imported_name: None,
|
||||
imported_version_number: None,
|
||||
imported_filename: None,
|
||||
@@ -1166,7 +1307,6 @@ fn instance_link_columns(
|
||||
hosting_server_id: None,
|
||||
hosting_instance_ids: None,
|
||||
hosting_active_instance_id: None,
|
||||
shared_instance_id: None,
|
||||
imported_name: None,
|
||||
imported_version_number: None,
|
||||
imported_filename: None,
|
||||
@@ -1186,7 +1326,6 @@ fn instance_link_columns(
|
||||
hosting_instance_ids: Some(serde_json::to_string(instance_ids)?),
|
||||
hosting_active_instance_id: active_instance_id
|
||||
.map(|value| value.to_string()),
|
||||
shared_instance_id: None,
|
||||
imported_name: None,
|
||||
imported_version_number: None,
|
||||
imported_filename: None,
|
||||
@@ -1207,28 +1346,27 @@ fn instance_link_columns(
|
||||
hosting_server_id: None,
|
||||
hosting_instance_ids: None,
|
||||
hosting_active_instance_id: None,
|
||||
shared_instance_id: None,
|
||||
imported_name: name.clone(),
|
||||
imported_version_number: version_number.clone(),
|
||||
imported_filename: filename.clone(),
|
||||
}),
|
||||
InstanceLink::SharedInstance { shared_instance_id } => {
|
||||
Ok(InstanceLinkColumns {
|
||||
link_kind: "shared_instance",
|
||||
modrinth_project_id: None,
|
||||
modrinth_version_id: None,
|
||||
server_project_id: None,
|
||||
content_project_id: None,
|
||||
content_version_id: None,
|
||||
hosting_server_id: None,
|
||||
hosting_instance_ids: None,
|
||||
hosting_active_instance_id: None,
|
||||
shared_instance_id: Some(shared_instance_id.to_string()),
|
||||
imported_name: None,
|
||||
imported_version_number: None,
|
||||
imported_filename: None,
|
||||
})
|
||||
}
|
||||
InstanceLink::SharedInstance {
|
||||
modpack_project_id,
|
||||
modpack_version_id,
|
||||
} => Ok(InstanceLinkColumns {
|
||||
link_kind: "shared_instance",
|
||||
modrinth_project_id: modpack_project_id.clone(),
|
||||
modrinth_version_id: modpack_version_id.clone(),
|
||||
server_project_id: None,
|
||||
content_project_id: None,
|
||||
content_version_id: None,
|
||||
hosting_server_id: None,
|
||||
hosting_instance_ids: None,
|
||||
hosting_active_instance_id: None,
|
||||
imported_name: None,
|
||||
imported_version_number: None,
|
||||
imported_filename: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1273,6 +1411,96 @@ fn launch_overrides_from_json(
|
||||
}
|
||||
}
|
||||
|
||||
async fn hydrate_shared_instance_fields(
|
||||
mut record: InstanceMetadataRecord,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<InstanceMetadataRecord> {
|
||||
if let Some(attachment) = record.shared_instance.as_mut() {
|
||||
let (access_token, server_manager_name, server_manager_icon_url) =
|
||||
shared_instance_fields(&record.instance.id, pool).await?;
|
||||
attachment.access_token = access_token;
|
||||
attachment.server_manager_name = server_manager_name;
|
||||
attachment.server_manager_icon_url = server_manager_icon_url;
|
||||
}
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
async fn shared_instance_fields(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<(Option<String>, Option<String>, Option<String>)> {
|
||||
Ok(
|
||||
sqlx::query_as::<_, (Option<String>, Option<String>, Option<String>)>(
|
||||
"
|
||||
SELECT
|
||||
shared_instance_access_token,
|
||||
shared_instance_server_manager_name,
|
||||
shared_instance_server_manager_icon_url
|
||||
FROM instance_links
|
||||
WHERE instance_id = ?
|
||||
",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.unwrap_or((None, None, None)),
|
||||
)
|
||||
}
|
||||
|
||||
fn shared_instance_attachment(
|
||||
shared_instance_id: Option<String>,
|
||||
shared_instance_role: Option<String>,
|
||||
shared_instance_manager_id: Option<String>,
|
||||
shared_instance_linked_user_id: Option<String>,
|
||||
shared_sync_status: Option<String>,
|
||||
applied_update_id: Option<String>,
|
||||
latest_available_update_id: Option<String>,
|
||||
) -> crate::Result<Option<SharedInstanceAttachment>> {
|
||||
let Some(id) = shared_instance_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let role = match shared_instance_role {
|
||||
Some(role) => SharedInstanceRole::from_stored_str(&role)?,
|
||||
None => SharedInstanceRole::Member,
|
||||
};
|
||||
let status = match shared_sync_status {
|
||||
Some(status) => ContentSetSyncStatus::from_str(&status)?,
|
||||
None => ContentSetSyncStatus::Unknown,
|
||||
};
|
||||
|
||||
Ok(Some(SharedInstanceAttachment {
|
||||
id,
|
||||
role,
|
||||
manager_id: shared_instance_manager_id,
|
||||
server_manager_name: None,
|
||||
server_manager_icon_url: None,
|
||||
linked_user_id: shared_instance_linked_user_id,
|
||||
access_token: None,
|
||||
status,
|
||||
applied_version: optional_i32(applied_update_id, "applied_update_id")?,
|
||||
latest_version: optional_i32(
|
||||
latest_available_update_id,
|
||||
"latest_available_update_id",
|
||||
)?,
|
||||
}))
|
||||
}
|
||||
|
||||
fn optional_i32(
|
||||
value: Option<String>,
|
||||
column: &str,
|
||||
) -> crate::Result<Option<i32>> {
|
||||
value
|
||||
.map(|value| {
|
||||
value.parse().map_err(|err| {
|
||||
crate::ErrorKind::InputError(format!("Invalid {column}: {err}"))
|
||||
.into()
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn parse_uuid(value: Option<String>, column: &str) -> crate::Result<Uuid> {
|
||||
let value = required(value, column)?;
|
||||
|
||||
|
||||
@@ -724,6 +724,31 @@ pub(crate) async fn remove_project(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn content_source_kind_for_project_path(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<Option<ContentSourceKind>> {
|
||||
let scope = resolve_content_scope(instance_id, None, state).await?;
|
||||
let Some(file) = content_rows::get_instance_file_by_relative_path(
|
||||
&scope.instance.id,
|
||||
project_path,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let entries =
|
||||
content_rows::get_content_entries(&scope.content_set_id, &state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(entries.into_iter().find_map(|entry| {
|
||||
(entry.file_id.as_deref() == Some(file.id.as_str()))
|
||||
.then_some(entry.source_kind)
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_project_files(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::state::instances::{
|
||||
ContentEntry, ContentSet, ContentSourceKind, InstanceFile,
|
||||
SharedInstanceRole,
|
||||
adapters::sqlite::{content_rows, instance_rows},
|
||||
};
|
||||
use crate::state::{
|
||||
@@ -52,6 +53,7 @@ struct InstalledProject {
|
||||
relative_path: String,
|
||||
project_id: Option<String>,
|
||||
version_id: Option<String>,
|
||||
source_kind: ContentSourceKind,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
@@ -281,8 +283,14 @@ async fn plan_bulk_update(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<BulkUpdatePlan> {
|
||||
let updateable_paths =
|
||||
bulk_updateable_project_paths(instance_id, state).await?;
|
||||
let shared_instance_member =
|
||||
is_shared_instance_member(instance_id, state).await?;
|
||||
let updateable_paths = bulk_updateable_project_paths(
|
||||
instance_id,
|
||||
shared_instance_member,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
if updateable_paths.is_empty() {
|
||||
return Ok(BulkUpdatePlan {
|
||||
project_updates: Vec::new(),
|
||||
@@ -316,6 +324,32 @@ async fn plan_bulk_update(
|
||||
})?;
|
||||
let installed =
|
||||
installed_projects(instance_id, &content_set, state).await?;
|
||||
let updateable_paths = if shared_instance_member {
|
||||
let managed_paths = installed
|
||||
.iter()
|
||||
.filter(|project| {
|
||||
is_shared_instance_managed_source_kind(project.source_kind)
|
||||
})
|
||||
.map(|project| project.relative_path.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
updateable_paths
|
||||
.into_iter()
|
||||
.filter(|path| !managed_paths.contains(path))
|
||||
.collect::<HashSet<_>>()
|
||||
} else {
|
||||
updateable_paths
|
||||
};
|
||||
let updates = updates
|
||||
.into_iter()
|
||||
.filter(|update| updateable_paths.contains(&update.relative_path))
|
||||
.collect::<Vec<_>>();
|
||||
if updates.is_empty() {
|
||||
return Ok(BulkUpdatePlan {
|
||||
project_updates: Vec::new(),
|
||||
dependency_additions: Vec::new(),
|
||||
});
|
||||
}
|
||||
let installed_by_project = installed
|
||||
.iter()
|
||||
.filter_map(|project| {
|
||||
@@ -402,6 +436,7 @@ async fn plan_bulk_update(
|
||||
|
||||
async fn bulk_updateable_project_paths(
|
||||
instance_id: &str,
|
||||
shared_instance_member: bool,
|
||||
state: &State,
|
||||
) -> crate::Result<HashSet<String>> {
|
||||
let items = super::list_content::list_content(
|
||||
@@ -412,7 +447,16 @@ async fn bulk_updateable_project_paths(
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(items.into_iter().map(|item| item.file_path).collect())
|
||||
Ok(items
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
!shared_instance_member
|
||||
|| !item
|
||||
.source_kind
|
||||
.is_some_and(is_shared_instance_managed_source_kind)
|
||||
})
|
||||
.map(|item| item.file_path)
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn installed_projects(
|
||||
@@ -457,10 +501,38 @@ fn installed_project_from_row(
|
||||
relative_path: file.relative_path.clone(),
|
||||
project_id: entry.project_id.clone(),
|
||||
version_id: entry.version_id.clone(),
|
||||
source_kind: entry.source_kind,
|
||||
enabled: entry.enabled && file.enabled,
|
||||
})
|
||||
}
|
||||
|
||||
async fn is_shared_instance_member(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<bool> {
|
||||
let Some(metadata) =
|
||||
instance_rows::get_instance_metadata_by_id(instance_id, &state.pool)
|
||||
.await?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
Ok(metadata.shared_instance.is_some_and(|attachment| {
|
||||
attachment.role == SharedInstanceRole::Member
|
||||
}))
|
||||
}
|
||||
|
||||
fn is_shared_instance_managed_source_kind(
|
||||
source_kind: ContentSourceKind,
|
||||
) -> bool {
|
||||
matches!(
|
||||
source_kind,
|
||||
ContentSourceKind::SharedInstance
|
||||
| ContentSourceKind::ModrinthModpack
|
||||
| ContentSourceKind::ImportedModpack
|
||||
)
|
||||
}
|
||||
|
||||
async fn dependency_closure(
|
||||
root_versions: Vec<Version>,
|
||||
content_set: &ContentSet,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::state::instances::{
|
||||
ContentSet, Instance, InstanceLaunchOverrides, InstanceLink,
|
||||
adapters::sqlite::instance_rows,
|
||||
SharedInstanceAttachment, adapters::sqlite::instance_rows,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
@@ -10,6 +10,7 @@ pub struct InstanceMetadata {
|
||||
pub instance: Instance,
|
||||
pub applied_content_set: ContentSet,
|
||||
pub link: InstanceLink,
|
||||
pub shared_instance: Option<SharedInstanceAttachment>,
|
||||
pub groups: Vec<String>,
|
||||
pub launch_overrides: InstanceLaunchOverrides,
|
||||
}
|
||||
@@ -61,6 +62,7 @@ impl From<instance_rows::InstanceMetadataRecord> for InstanceMetadata {
|
||||
instance: record.instance,
|
||||
applied_content_set: record.applied_content_set,
|
||||
link: record.link,
|
||||
shared_instance: record.shared_instance,
|
||||
groups: record.groups,
|
||||
launch_overrides: record.launch_overrides,
|
||||
}
|
||||
|
||||
@@ -528,6 +528,7 @@ pub(crate) async fn dependencies_to_content_items(
|
||||
has_update: false,
|
||||
update_version_id: None,
|
||||
date_added: None,
|
||||
source_kind: None,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -738,6 +739,7 @@ async fn content_projects_for_scope(
|
||||
size: file.size,
|
||||
metadata: file_metadata_from_entry_or_cache(entry, metadata),
|
||||
project_type,
|
||||
source_kind: entry.map(|entry| entry.source_kind),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -900,6 +902,7 @@ async fn content_files_to_content_items(
|
||||
has_update: file.update_version_id.is_some(),
|
||||
update_version_id: file.update_version_id.clone(),
|
||||
date_added: modification_times[index].clone(),
|
||||
source_kind: file.source_kind,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -1089,6 +1092,10 @@ fn linked_modpack_ids(link: &InstanceLink) -> Option<(String, String)> {
|
||||
version_id: Some(version_id),
|
||||
..
|
||||
} => Some((project_id.clone(), version_id.clone())),
|
||||
InstanceLink::SharedInstance {
|
||||
modpack_project_id: Some(project_id),
|
||||
modpack_version_id: Some(version_id),
|
||||
} => Some((project_id.clone(), version_id.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1103,6 +1110,10 @@ fn linked_modpack_source_kind(
|
||||
InstanceLink::ServerProjectModpack { .. } => {
|
||||
Some(ContentSourceKind::ServerProject)
|
||||
}
|
||||
InstanceLink::SharedInstance {
|
||||
modpack_project_id: Some(_),
|
||||
modpack_version_id: Some(_),
|
||||
} => Some(ContentSourceKind::ModrinthModpack),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1348,12 +1359,7 @@ async fn get_modpack_identifiers(
|
||||
}
|
||||
|
||||
fn project_type_from_api_name(project_type: &str) -> ProjectType {
|
||||
match project_type {
|
||||
"resourcepack" => ProjectType::ResourcePack,
|
||||
"shader" => ProjectType::ShaderPack,
|
||||
"datapack" => ProjectType::DataPack,
|
||||
_ => ProjectType::Mod,
|
||||
}
|
||||
ProjectType::from_name(project_type).unwrap_or(ProjectType::Mod)
|
||||
}
|
||||
|
||||
fn sort_content_items(items: &mut [ContentItem]) {
|
||||
|
||||
@@ -41,3 +41,9 @@ mod check_content_updates;
|
||||
|
||||
mod apply_content_update;
|
||||
pub(crate) use self::apply_content_update::*;
|
||||
|
||||
mod shared_instance;
|
||||
pub(crate) use self::shared_instance::{
|
||||
attach_shared_instance, clear_shared_instance, mark_shared_instance_stale,
|
||||
set_shared_instance_sync_status,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
use crate::state::instances::adapters::sqlite::{content_rows, instance_rows};
|
||||
use crate::state::instances::{
|
||||
ContentSetRemoteRef, ContentSetRemoteRefType, ContentSetSyncProvider,
|
||||
ContentSetSyncState, ContentSetSyncStatus, InstanceLink,
|
||||
SharedInstanceAttachment, SharedInstanceRole,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub(crate) async fn attach_shared_instance(
|
||||
instance_id: &str,
|
||||
shared_instance_id: &str,
|
||||
role: SharedInstanceRole,
|
||||
manager_id: Option<String>,
|
||||
server_manager_name: Option<String>,
|
||||
server_manager_icon_url: Option<String>,
|
||||
linked_user_id: Option<String>,
|
||||
access_token: Option<String>,
|
||||
status: ContentSetSyncStatus,
|
||||
applied_version: Option<i32>,
|
||||
latest_version: Option<i32>,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let metadata =
|
||||
instance_rows::get_instance_metadata_by_id(instance_id, pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let content_set_id = metadata.applied_content_set.id;
|
||||
let attachment = SharedInstanceAttachment {
|
||||
id: shared_instance_id.to_string(),
|
||||
role,
|
||||
manager_id,
|
||||
server_manager_name,
|
||||
server_manager_icon_url,
|
||||
linked_user_id,
|
||||
access_token,
|
||||
status,
|
||||
applied_version,
|
||||
latest_version,
|
||||
};
|
||||
let sync_state =
|
||||
shared_sync_state(&content_set_id, &attachment, Some(Utc::now()));
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
instance_rows::set_shared_instance_attachment(
|
||||
instance_id,
|
||||
Some(&attachment),
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
content_rows::upsert_content_set_remote_ref(
|
||||
&ContentSetRemoteRef {
|
||||
content_set_id: content_set_id.clone(),
|
||||
ref_type: ContentSetRemoteRefType::SharedContentSet,
|
||||
ref_id: shared_instance_id.to_string(),
|
||||
},
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
content_rows::upsert_content_set_sync_state(&sync_state, &mut tx).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn clear_shared_instance(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let Some(metadata) =
|
||||
instance_rows::get_instance_metadata_by_id(instance_id, pool).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let content_set_id = metadata.applied_content_set.id;
|
||||
let retained_modpack_link = match metadata.link {
|
||||
InstanceLink::SharedInstance {
|
||||
modpack_project_id: Some(project_id),
|
||||
modpack_version_id: Some(version_id),
|
||||
} => Some(InstanceLink::ModrinthModpack {
|
||||
project_id,
|
||||
version_id,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
instance_rows::set_shared_instance_attachment(instance_id, None, &mut tx)
|
||||
.await?;
|
||||
if let Some(link) = retained_modpack_link.as_ref() {
|
||||
instance_rows::upsert_instance_link(instance_id, link, &mut tx).await?;
|
||||
}
|
||||
content_rows::delete_content_set_remote_ref(
|
||||
&content_set_id,
|
||||
ContentSetRemoteRefType::SharedContentSet,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
content_rows::delete_content_set_sync_state(&content_set_id, &mut tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_shared_instance_sync_status(
|
||||
instance_id: &str,
|
||||
status: ContentSetSyncStatus,
|
||||
applied_version: Option<i32>,
|
||||
latest_version: Option<i32>,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let metadata =
|
||||
instance_rows::get_instance_metadata_by_id(instance_id, pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let Some(mut attachment) = metadata.shared_instance else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
attachment.status = status;
|
||||
attachment.applied_version = applied_version;
|
||||
attachment.latest_version = latest_version;
|
||||
|
||||
let sync_state = shared_sync_state(
|
||||
&metadata.applied_content_set.id,
|
||||
&attachment,
|
||||
Some(Utc::now()),
|
||||
);
|
||||
let mut tx = pool.begin().await?;
|
||||
content_rows::upsert_content_set_sync_state(&sync_state, &mut tx).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_shared_instance_stale(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let Some(metadata) =
|
||||
instance_rows::get_instance_metadata_by_id(instance_id, pool).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(attachment) = metadata.shared_instance else {
|
||||
return Ok(());
|
||||
};
|
||||
if attachment.role != SharedInstanceRole::Owner {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
set_shared_instance_sync_status(
|
||||
instance_id,
|
||||
ContentSetSyncStatus::Stale,
|
||||
attachment.applied_version,
|
||||
attachment.latest_version,
|
||||
pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn shared_sync_state(
|
||||
content_set_id: &str,
|
||||
attachment: &SharedInstanceAttachment,
|
||||
checked_at: Option<chrono::DateTime<Utc>>,
|
||||
) -> ContentSetSyncState {
|
||||
ContentSetSyncState {
|
||||
content_set_id: content_set_id.to_string(),
|
||||
provider: ContentSetSyncProvider::SharedInstance,
|
||||
applied_update_id: attachment
|
||||
.applied_version
|
||||
.map(|value| value.to_string()),
|
||||
latest_available_update_id: attachment
|
||||
.latest_version
|
||||
.map(|value| value.to_string()),
|
||||
checked_at,
|
||||
status: attachment.status,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::ContentSourceKind;
|
||||
use crate::state::{Project, ProjectType, Version};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -15,6 +16,7 @@ pub struct ContentItem {
|
||||
pub has_update: bool,
|
||||
pub update_version_id: Option<String>,
|
||||
pub date_added: Option<String>,
|
||||
pub source_kind: Option<ContentSourceKind>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
|
||||
@@ -10,6 +10,10 @@ pub use self::commands::{
|
||||
AppliedContentSetPatch, CreateInstance, EditInstance,
|
||||
InstanceLaunchOverridesPatch, InstanceMetadata,
|
||||
};
|
||||
pub(crate) use self::commands::{
|
||||
attach_shared_instance, clear_shared_instance, mark_shared_instance_stale,
|
||||
set_shared_instance_sync_status,
|
||||
};
|
||||
pub(crate) use self::commands::{
|
||||
create_instance, edit_instance, get_instance, get_instances_metadata,
|
||||
list_instances, refresh_all_instances, remove_instance,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::ContentSetSyncStatus;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceLink {
|
||||
@@ -33,6 +35,47 @@ pub enum InstanceLink {
|
||||
filename: Option<String>,
|
||||
},
|
||||
SharedInstance {
|
||||
shared_instance_id: Uuid,
|
||||
modpack_project_id: Option<String>,
|
||||
modpack_version_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SharedInstanceRole {
|
||||
Owner,
|
||||
Member,
|
||||
}
|
||||
|
||||
impl SharedInstanceRole {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Owner => "owner",
|
||||
Self::Member => "member",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_stored_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"owner" => Ok(Self::Owner),
|
||||
"member" => Ok(Self::Member),
|
||||
other => Err(super::unknown_value("shared instance role", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SharedInstanceAttachment {
|
||||
pub id: String,
|
||||
pub role: SharedInstanceRole,
|
||||
pub manager_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub server_manager_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub server_manager_icon_url: Option<String>,
|
||||
pub linked_user_id: Option<String>,
|
||||
pub access_token: Option<String>,
|
||||
pub status: ContentSetSyncStatus,
|
||||
pub applied_version: Option<i32>,
|
||||
pub latest_version: Option<i32>,
|
||||
}
|
||||
|
||||
@@ -138,7 +138,25 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
|
||||
};
|
||||
if let Some(event) = event {
|
||||
let emit_instance_id = instance_id.clone();
|
||||
let mark_shared_stale = matches!(
|
||||
&event,
|
||||
InstancePayloadType::Synced
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
if mark_shared_stale
|
||||
&& let Ok(state) =
|
||||
State::get().await
|
||||
&& let Err(error) =
|
||||
crate::api::instance::mark_shared_instance_stale(
|
||||
&emit_instance_id,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to mark shared instance stale after filesystem sync: {error}"
|
||||
);
|
||||
}
|
||||
let _ = emit_instance(
|
||||
&emit_instance_id,
|
||||
event,
|
||||
|
||||
@@ -195,6 +195,10 @@ pub const fn get_login_url() -> &'static str {
|
||||
concat!(env!("MODRINTH_URL"), "auth/sign-in")
|
||||
}
|
||||
|
||||
pub const fn get_signup_url() -> &'static str {
|
||||
concat!(env!("MODRINTH_URL"), "auth/sign-up")
|
||||
}
|
||||
|
||||
pub async fn finish_login_flow(
|
||||
code: &str,
|
||||
semaphore: &FetchSemaphore,
|
||||
|
||||
@@ -9,25 +9,47 @@
|
||||
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
|
||||
<component :is="getSeverityIcon(type)" :class="['h-6 w-6 flex-none', iconClasses[type]]" />
|
||||
</slot>
|
||||
<div class="col-start-2 flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div
|
||||
class="col-start-2 min-w-0"
|
||||
:class="
|
||||
inlineActions && !showActionsUnderneath && $slots.actions
|
||||
? 'flex flex-wrap items-start gap-x-4 gap-y-3'
|
||||
: 'flex flex-1 flex-col gap-2'
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="header || $slots.header || normalizedTimestamp"
|
||||
class="flex flex-wrap items-center gap-2 text-lg font-semibold leading-6"
|
||||
class="flex min-w-0 flex-1 flex-col gap-2"
|
||||
:class="
|
||||
inlineActions && !showActionsUnderneath && $slots.actions
|
||||
? 'admonition-inline-content'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<slot name="header">{{ header }}</slot>
|
||||
<span
|
||||
v-if="normalizedTimestamp"
|
||||
v-tooltip="timestampTooltip"
|
||||
class="flex items-center gap-1.5 text-base font-medium leading-normal text-secondary"
|
||||
<div
|
||||
v-if="header || $slots.header || normalizedTimestamp"
|
||||
class="flex flex-wrap items-center gap-2 text-lg font-semibold leading-6"
|
||||
>
|
||||
<ClockIcon class="size-4" />
|
||||
{{ relativeTimeLabel }}
|
||||
</span>
|
||||
<slot name="header">{{ header }}</slot>
|
||||
<span
|
||||
v-if="normalizedTimestamp"
|
||||
v-tooltip="timestampTooltip"
|
||||
class="flex items-center gap-1.5 text-base font-medium leading-normal text-secondary"
|
||||
>
|
||||
<ClockIcon class="size-4" />
|
||||
{{ relativeTimeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="font-normal text-contrast/85 leading-tight">
|
||||
<slot>{{ body }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-normal text-contrast/85 leading-tight">
|
||||
<slot>{{ body }}</slot>
|
||||
<div
|
||||
v-if="inlineActions && !showActionsUnderneath && $slots.actions"
|
||||
class="ml-auto flex shrink-0 items-center justify-end self-center"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<div v-if="showActionsUnderneath || $slots.actions" class="mt-2">
|
||||
<div v-else-if="showActionsUnderneath || $slots.actions" class="mt-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,6 +105,7 @@ const props = withDefaults(
|
||||
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning'
|
||||
header?: string
|
||||
body?: string
|
||||
inlineActions?: boolean
|
||||
showActionsUnderneath?: boolean
|
||||
dismissible?: boolean
|
||||
progress?: number
|
||||
@@ -95,6 +118,7 @@ const props = withDefaults(
|
||||
type: 'info',
|
||||
header: '',
|
||||
body: '',
|
||||
inlineActions: false,
|
||||
showActionsUnderneath: false,
|
||||
dismissible: false,
|
||||
progress: undefined,
|
||||
@@ -185,6 +209,10 @@ const progressFillClasses = {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admonition-inline-content {
|
||||
min-width: min(100%, 16rem);
|
||||
}
|
||||
|
||||
.admonition-progress--waiting {
|
||||
animation: admonition-progress-waiting 1s linear infinite;
|
||||
position: relative;
|
||||
|
||||
@@ -6,23 +6,45 @@
|
||||
:class="{ 'drop-shadow-xl border border-solid border-surface-4': mode === 'navigation' }"
|
||||
>
|
||||
<template v-if="mode === 'navigation'">
|
||||
<RouterLink
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown ?? true"
|
||||
:key="link.href"
|
||||
ref="tabLinkElements"
|
||||
:replace="replace"
|
||||
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
|
||||
:class="getSSRFallbackClasses(index)"
|
||||
@mouseenter="link.onHover?.()"
|
||||
@focus="link.onHover?.()"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
|
||||
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<template v-for="(link, index) in filteredLinks" :key="link.href">
|
||||
<div
|
||||
v-if="link.disabled"
|
||||
v-show="link.shown ?? true"
|
||||
ref="tabLinkElements"
|
||||
v-tooltip="link.tooltip"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 opacity-60 focus:rounded-full"
|
||||
:class="getSSRFallbackClasses(index)"
|
||||
:aria-disabled="true"
|
||||
@mouseenter="link.onHover?.()"
|
||||
@focus="link.onHover?.()"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="size-5 text-secondary" />
|
||||
<span class="text-nowrap text-secondary">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</div>
|
||||
<RouterLink
|
||||
v-else
|
||||
v-show="link.shown ?? true"
|
||||
ref="tabLinkElements"
|
||||
:replace="replace"
|
||||
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
|
||||
:class="getSSRFallbackClasses(index)"
|
||||
@mouseenter="link.onHover?.()"
|
||||
@focus="link.onHover?.()"
|
||||
>
|
||||
<component
|
||||
:is="link.icon"
|
||||
v-if="link.icon"
|
||||
class="size-5"
|
||||
:class="getIconClasses(index)"
|
||||
/>
|
||||
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -67,6 +89,8 @@ interface Tab {
|
||||
label: string
|
||||
href: string
|
||||
shown?: boolean
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
icon?: Component
|
||||
subpages?: string[]
|
||||
onHover?: () => void
|
||||
|
||||
@@ -14,6 +14,7 @@ export * from './project'
|
||||
export * from './search'
|
||||
export * from './servers'
|
||||
export * from './settings'
|
||||
export * from './sharing'
|
||||
export * from './skin'
|
||||
export * from './user'
|
||||
export * from './version'
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Install to play" :closable="true">
|
||||
<div class="flex flex-col gap-4 max-w-[500px]">
|
||||
<Admonition type="info" header="Shared server instance">
|
||||
This server requires modded content to play. Accept to install the needed files from
|
||||
Modrinth.
|
||||
</Admonition>
|
||||
|
||||
<div v-if="sharedBy?.name" class="flex items-center gap-2 text-sm text-secondary">
|
||||
<Avatar
|
||||
v-if="sharedBy?.icon_url"
|
||||
:src="sharedBy.icon_url"
|
||||
:alt="sharedBy.name"
|
||||
size="24px"
|
||||
/>
|
||||
<span>
|
||||
<span class="font-semibold text-contrast">{{ sharedBy.name }}</span>
|
||||
shared this instance with you today.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-secondary">Shared instance</span>
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-4 p-3">
|
||||
<Avatar :src="project.icon_url" :alt="project.title" size="48px" />
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-semibold text-contrast">{{ project.title }}</span>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ loaderDisplay }} {{ project.game_versions?.[0] }}
|
||||
<template v-if="modCount"> · {{ modCount }} mods </template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="handleDecline">
|
||||
<XIcon />
|
||||
Decline
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleAccept">
|
||||
<CheckIcon />
|
||||
Accept
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import type { Project } from '@modrinth/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useVIntl } from '../../composables'
|
||||
import { formatLoader } from '../../utils'
|
||||
import Admonition from '../base/Admonition.vue'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import NewModal from './NewModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
project: Project
|
||||
sharedBy?: {
|
||||
name: string
|
||||
icon_url?: string
|
||||
}
|
||||
modCount?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
accept: []
|
||||
decline: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const loaderDisplay = computed(() => {
|
||||
const loader = props.project.loaders?.[0]
|
||||
if (!loader) return ''
|
||||
return formatLoader(formatMessage, loader)
|
||||
})
|
||||
|
||||
function handleAccept() {
|
||||
// TODO: Implement accept logic
|
||||
emit('accept')
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleDecline() {
|
||||
emit('decline')
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -170,6 +170,7 @@ const props = withDefaults(
|
||||
header?: string
|
||||
hideHeader?: boolean
|
||||
onHide?: () => void
|
||||
onAfterHide?: () => void
|
||||
onShow?: () => void
|
||||
mergeHeader?: boolean
|
||||
scrollable?: boolean
|
||||
@@ -196,6 +197,7 @@ const props = withDefaults(
|
||||
header: undefined,
|
||||
hideHeader: false,
|
||||
onHide: () => {},
|
||||
onAfterHide: () => {},
|
||||
onShow: () => {},
|
||||
mergeHeader: false,
|
||||
// TODO: migrate all modals to use scrollable and remove this prop
|
||||
@@ -226,6 +228,7 @@ const visible = ref(false)
|
||||
const stackDepth = ref(0)
|
||||
const modalBodyRef = ref<HTMLElement | null>(null)
|
||||
let previousFocusEl: Element | null = null
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(scrollContainer)
|
||||
@@ -239,6 +242,10 @@ function getFocusableElements(): HTMLElement[] {
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout)
|
||||
hideTimeout = null
|
||||
}
|
||||
props.onShow?.()
|
||||
const wasEmpty = modalStackSize() === 0
|
||||
stackDepth.value = modalStackSize()
|
||||
@@ -287,8 +294,10 @@ function hide() {
|
||||
previousFocusEl.focus()
|
||||
}
|
||||
previousFocusEl = null
|
||||
setTimeout(() => {
|
||||
hideTimeout = setTimeout(() => {
|
||||
open.value = false
|
||||
hideTimeout = null
|
||||
props.onAfterHide?.()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
@@ -334,6 +343,10 @@ function resetMousePosition() {
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout)
|
||||
hideTimeout = null
|
||||
}
|
||||
if (open.value) {
|
||||
popModal()
|
||||
window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { default as ConfirmLeaveModal } from './ConfirmLeaveModal.vue'
|
||||
export { default as ConfirmModal } from './ConfirmModal.vue'
|
||||
export { default as InstallToPlayModal } from './InstallToPlayModal.vue'
|
||||
export { default as Modal } from './Modal.vue'
|
||||
export { default as NewModal } from './NewModal.vue'
|
||||
export type { ServerProject as OpenInAppModalServerProject } from './OpenInAppModal.vue'
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<NotificationToast
|
||||
v-if="item.toast"
|
||||
:type="item.toast.type"
|
||||
:action-loading="toastActionLoading(item.id)"
|
||||
:actor-name="item.toast.actorName"
|
||||
:actor-avatar-url="item.toast.actorAvatarUrl"
|
||||
:entity-name="item.toast.entityName"
|
||||
@@ -29,7 +30,7 @@
|
||||
:progress-type="item.toast.progressType"
|
||||
:progress-current="item.toast.progressCurrent"
|
||||
:progress-total="item.toast.progressTotal"
|
||||
@accept="handleToastAction(item, item.toast.onAccept)"
|
||||
@accept="handleToastAccept(item, item.toast.onAccept)"
|
||||
@decline="handleToastAction(item, item.toast.onDecline)"
|
||||
@dismiss="handleToastAction(item, item.toast.onDismiss)"
|
||||
@launch="handleToastAction(item, item.toast.onLaunch)"
|
||||
@@ -167,7 +168,7 @@ import {
|
||||
XCircleIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useModalStack } from '../../composables/modal-stack'
|
||||
import {
|
||||
@@ -189,11 +190,13 @@ const hasModalActive = computed(() => stackCount.value > 0)
|
||||
const notificationGroupStyle = computed(() => ({
|
||||
zIndex: hasModalActive.value ? 100 + stackCount.value * 10 + 8 : 200,
|
||||
}))
|
||||
const activeToastActions = ref<Record<string, 'accept'>>({})
|
||||
|
||||
const stopTimer = (n: PopupNotification) => popupNotificationManager.stopNotificationTimer(n)
|
||||
const setNotificationTimer = (n: PopupNotification) =>
|
||||
popupNotificationManager.setNotificationTimer(n)
|
||||
const dismiss = (id: string | number) => popupNotificationManager.removeNotification(id)
|
||||
const toastActionLoading = (id: string | number) => activeToastActions.value[String(id)] ?? null
|
||||
|
||||
function isDownloadNotification(item: PopupNotification) {
|
||||
return (
|
||||
@@ -262,6 +265,26 @@ async function handleToastAction(item: PopupNotification, action?: () => void |
|
||||
await action?.()
|
||||
}
|
||||
|
||||
async function handleToastAccept(item: PopupNotification, action?: () => void | Promise<void>) {
|
||||
if (toastActionLoading(item.id) != null) return
|
||||
|
||||
const actionId = String(item.id)
|
||||
popupNotificationManager.stopNotificationTimer(item)
|
||||
activeToastActions.value = {
|
||||
...activeToastActions.value,
|
||||
[actionId]: 'accept',
|
||||
}
|
||||
|
||||
try {
|
||||
await action?.()
|
||||
} finally {
|
||||
activeToastActions.value = Object.fromEntries(
|
||||
Object.entries(activeToastActions.value).filter(([key]) => key !== actionId),
|
||||
)
|
||||
popupNotificationManager.removeNotification(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
function progressColorForType(type: PopupNotification['type']) {
|
||||
if (type === 'error') {
|
||||
return 'red'
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
>
|
||||
<div v-if="isInviteNotification" class="flex w-full items-start gap-3">
|
||||
<Avatar
|
||||
:src="actorAvatarUrl"
|
||||
:alt="actorLabel"
|
||||
:tint-by="actorLabel"
|
||||
:src="inviteAvatarUrl"
|
||||
:alt="inviteAvatarLabel"
|
||||
:tint-by="inviteAvatarLabel"
|
||||
size="44px"
|
||||
circle
|
||||
:circle="inviteAvatarCircle"
|
||||
no-shadow
|
||||
class="border border-solid border-surface-5"
|
||||
/>
|
||||
@@ -35,20 +35,17 @@
|
||||
>.
|
||||
</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
|
||||
>.
|
||||
<Avatar
|
||||
:src="entityIconUrl"
|
||||
:alt="entityLabel"
|
||||
:tint-by="entityLabel"
|
||||
size="28px"
|
||||
no-shadow
|
||||
raised
|
||||
class="mx-1 inline-block !rounded-lg align-middle"
|
||||
/>
|
||||
<span class="font-semibold text-contrast">{{ entityLabel }}</span>
|
||||
<span> instance.</span>
|
||||
</template>
|
||||
</template>
|
||||
</p>
|
||||
@@ -65,10 +62,17 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="$emit('accept')">Accept</button>
|
||||
<button :disabled="actionLoading != null" @click="$emit('accept')">
|
||||
<SpinnerIcon v-if="actionLoading === 'accept'" class="animate-spin" />
|
||||
<CheckIcon v-else />
|
||||
Accept
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="$emit('decline')">Decline</button>
|
||||
<button :disabled="actionLoading != null" @click="$emit('decline')">
|
||||
<XIcon />
|
||||
Decline
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,7 +175,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { CheckIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { type Component, computed, ref } from 'vue'
|
||||
|
||||
import { useFormatBytes, useFormatNumber } from '../../composables'
|
||||
@@ -186,10 +190,12 @@ type NotificationToastType =
|
||||
| 'instance-invite'
|
||||
| 'instance-download'
|
||||
| 'instance-ready'
|
||||
type NotificationToastAction = 'accept'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type: NotificationToastType
|
||||
actionLoading?: NotificationToastAction | null
|
||||
actorName?: string | null
|
||||
actorAvatarUrl?: string | null
|
||||
entityName?: string
|
||||
@@ -206,6 +212,7 @@ const props = withDefaults(
|
||||
actionIcon?: Component
|
||||
}>(),
|
||||
{
|
||||
actionLoading: null,
|
||||
actorName: null,
|
||||
actorAvatarUrl: null,
|
||||
entityName: '',
|
||||
@@ -236,6 +243,9 @@ const isInviteNotification = computed(
|
||||
|
||||
const actorLabel = computed(() => props.actorName || 'Someone')
|
||||
const entityLabel = computed(() => props.entityName || '')
|
||||
const inviteAvatarUrl = computed(() => props.actorAvatarUrl)
|
||||
const inviteAvatarLabel = computed(() => actorLabel.value)
|
||||
const inviteAvatarCircle = computed(() => true)
|
||||
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)
|
||||
@@ -247,7 +257,7 @@ const inviteActionText = computed(() => {
|
||||
return 'invited you to manage the server'
|
||||
}
|
||||
|
||||
return 'invited you to play the instance'
|
||||
return 'invited you to'
|
||||
})
|
||||
|
||||
const resolvedStatusText = computed(() => {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as InvitePlayersModal } from './invite-players-modal/index.vue'
|
||||
export * from './invite-players-modal/types'
|
||||
@@ -0,0 +1,724 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="header"
|
||||
width="min(34rem, calc(100vw - 2rem))"
|
||||
max-width="34rem"
|
||||
no-padding
|
||||
>
|
||||
<div class="flex max-h-[calc(100vh-8rem)] min-h-0 flex-col">
|
||||
<div class="border-0 border-b border-solid border-surface-5 p-6">
|
||||
<div class="flex items-start gap-2">
|
||||
<Combobox
|
||||
:key="searchInputKey"
|
||||
:model-value="undefined"
|
||||
:options="searchOptions"
|
||||
:search-value="searchTarget"
|
||||
:search-placeholder="searchPlaceholderLabel"
|
||||
:placeholder="searchPlaceholderLabel"
|
||||
:no-options-message="searchLookupMessage"
|
||||
:min-search-length-to-open="searchMinimumLength"
|
||||
:disable-search-filter="usesRemoteSearch"
|
||||
class="min-w-0 flex-1"
|
||||
searchable
|
||||
show-search-icon
|
||||
:show-chevron="false"
|
||||
search-type="search"
|
||||
search-name="modrinth-player-invite-search"
|
||||
search-inputmode="search"
|
||||
search-autocomplete="new-password"
|
||||
search-autocorrect="off"
|
||||
search-autocapitalize="none"
|
||||
:search-spellcheck="false"
|
||||
:search-input-attrs="passwordManagerIgnoreAttrs"
|
||||
@search-input="handleSearchInput"
|
||||
@select="handleSearchSelect"
|
||||
>
|
||||
<template #option="{ item, isSelected }">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Avatar
|
||||
:src="findSearchUser(item.value)?.avatarUrl"
|
||||
:alt="formatMessage(messages.avatarAlt, { username: item.label })"
|
||||
:tint-by="item.label"
|
||||
size="1.5rem"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 truncate font-semibold"
|
||||
:class="isSelected ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Combobox>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="searchInviteTooltip"
|
||||
class="shrink-0"
|
||||
:disabled="!canInviteSearchTarget"
|
||||
@click="inviteSearchTarget"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{{ addButtonLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[11rem] overflow-y-auto bg-surface-2 px-6 py-4">
|
||||
<div class="mb-2 text-base font-semibold text-primary">
|
||||
{{ friendsHeading }}
|
||||
</div>
|
||||
<div
|
||||
v-if="friends.length === 0"
|
||||
class="flex min-h-32 items-center justify-center text-secondary"
|
||||
>
|
||||
{{ emptyFriendsLabel }}
|
||||
</div>
|
||||
<div v-else class="-mx-6 flex flex-col">
|
||||
<InvitePlayersModalUserRow
|
||||
v-for="friend in sortedFriends"
|
||||
:key="friend.id"
|
||||
:user="friend"
|
||||
:avatar-alt="formatMessage(messages.avatarAlt, { username: friend.username })"
|
||||
:added-label="addedButtonLabel"
|
||||
:cancel-label="cancelButtonLabel"
|
||||
:invite-label="inviteButtonLabel"
|
||||
:requested-label="requestedButtonLabel"
|
||||
:requested-tooltip="requestedTooltip(friend.username)"
|
||||
:user-profile-link="userProfileLink"
|
||||
@invite="inviteFriend"
|
||||
@cancel="cancelInvite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="link" class="border-0 border-t border-solid border-surface-5 p-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-base font-semibold text-contrast">
|
||||
{{ inviteLinkHeading }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-full cursor-pointer items-center justify-between gap-3 rounded-[14px] border-none bg-surface-2 px-4 text-left transition-all hover:brightness-110 active:scale-[0.98]"
|
||||
@click="copyInviteLink"
|
||||
>
|
||||
<span class="min-w-0 truncate text-base font-semibold text-primary">
|
||||
{{ link }}
|
||||
</span>
|
||||
<ClipboardCopyIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
<p v-if="link && linkExpiryDescription" class="m-0 text-base text-primary">
|
||||
{{ linkExpiryDescription }}
|
||||
<button
|
||||
v-if="updateInviteLink"
|
||||
type="button"
|
||||
class="cursor-pointer border-0 bg-transparent p-0 text-base font-medium text-blue hover:underline"
|
||||
@click="showEditInviteLink"
|
||||
>
|
||||
{{ formatMessage(messages.editInviteLink) }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
|
||||
<NewModal
|
||||
ref="editInviteLinkModal"
|
||||
:header="formatMessage(messages.editInviteLinkTitle)"
|
||||
max-width="30rem"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.expiryLabel) }}</span>
|
||||
<DatePicker
|
||||
v-model="editExpiry"
|
||||
:disabled="savingInviteLink"
|
||||
:min-date="minimumExpiry"
|
||||
:max-date="maximumExpiry"
|
||||
date-format="Y-m-d H:i"
|
||||
alt-format="F j, Y at h:i K"
|
||||
enable-time
|
||||
wrapper-class="w-full"
|
||||
input-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.maxUsesLabel) }}</span>
|
||||
<StyledInput
|
||||
v-model="editMaxUses"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="2147483647"
|
||||
:step="1"
|
||||
:disabled="savingInviteLink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button :disabled="savingInviteLink" @click="editInviteLinkModal?.hide()">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canSaveInviteLink" @click="saveInviteLink">
|
||||
<SpinnerIcon v-if="savingInviteLink" class="animate-spin" aria-hidden="true" />
|
||||
<SaveIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.saveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClipboardCopyIcon, PlusIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import { injectNotificationManager } from '../../../providers'
|
||||
import Avatar from '../../base/Avatar.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
|
||||
import DatePicker from '../../base/DatePicker.vue'
|
||||
import StyledInput from '../../base/StyledInput.vue'
|
||||
import NewModal from '../../modal/NewModal.vue'
|
||||
import InvitePlayersModalUserRow from './invite-players-modal-user-row.vue'
|
||||
import type {
|
||||
InviteLinkSettings,
|
||||
InvitePlayersInvitePayload,
|
||||
InvitePlayersSearchUser,
|
||||
InvitePlayersUser,
|
||||
InvitePlayersUserProfileLink,
|
||||
InvitePlayersUserStatus,
|
||||
} from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
header?: string
|
||||
friends?: InvitePlayersUser[]
|
||||
suggestions?: InvitePlayersSearchUser[]
|
||||
searchUsers?: (query: string) => Promise<InvitePlayersSearchUser[]>
|
||||
link?: string
|
||||
linkExpiresAt?: string | Date | null
|
||||
linkMaxUses?: number
|
||||
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
|
||||
friendsLabel?: string
|
||||
searchPlaceholder?: string
|
||||
addLabel?: string
|
||||
inviteLabel?: string
|
||||
addedLabel?: string
|
||||
cancelLabel?: string
|
||||
requestedLabel?: string
|
||||
emptyFriendsLabel?: string
|
||||
canInvite?: boolean
|
||||
inviteDisabledMessage?: string
|
||||
userProfileLink?: (username: string) => InvitePlayersUserProfileLink
|
||||
}>(),
|
||||
{
|
||||
header: 'Share instance',
|
||||
friends: () => [],
|
||||
suggestions: () => [],
|
||||
canInvite: true,
|
||||
linkMaxUses: 10,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
invite: [payload: InvitePlayersInvitePayload]
|
||||
cancel: [user: InvitePlayersUser]
|
||||
'copy-link': [link: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const notificationManager = injectNotificationManager(null)
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const editInviteLinkModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const editExpiry = ref('')
|
||||
const editMaxUses = ref<number>()
|
||||
const minimumExpiry = ref(new Date())
|
||||
const maximumExpiry = ref(new Date())
|
||||
const savingInviteLink = ref(false)
|
||||
const searchTarget = ref('')
|
||||
const searchInputKey = ref(0)
|
||||
const selectedSearchUser = ref<InvitePlayersSearchUser | null>(null)
|
||||
const remoteSearchUsers = ref<InvitePlayersSearchUser[]>([])
|
||||
const searchLookupStatus = ref<'idle' | 'loading' | 'loaded'>('idle')
|
||||
const searchLookupRequestId = ref(0)
|
||||
const friendOrder = ref(new Map<string, number>())
|
||||
const searchMinimumLength = 1
|
||||
const passwordManagerIgnoreAttrs = {
|
||||
'data-1p-ignore': 'true',
|
||||
'data-bwignore': 'true',
|
||||
'data-form-type': 'other',
|
||||
'data-lpignore': 'true',
|
||||
'data-protonpass-ignore': 'true',
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
friendsHeading: {
|
||||
id: 'sharing.invite-players-modal.friends-heading',
|
||||
defaultMessage: 'Your friends - {count}',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
id: 'sharing.invite-players-modal.search-placeholder',
|
||||
defaultMessage: 'Enter Modrinth username',
|
||||
},
|
||||
addButton: {
|
||||
id: 'sharing.invite-players-modal.add',
|
||||
defaultMessage: 'Add',
|
||||
},
|
||||
inviteButton: {
|
||||
id: 'sharing.invite-players-modal.invite',
|
||||
defaultMessage: 'Invite',
|
||||
},
|
||||
addedButton: {
|
||||
id: 'sharing.invite-players-modal.added',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'sharing.invite-players-modal.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
requestedButton: {
|
||||
id: 'sharing.invite-players-modal.requested',
|
||||
defaultMessage: 'Request sent',
|
||||
},
|
||||
requestedTooltip: {
|
||||
id: 'sharing.invite-players-modal.requested-tooltip',
|
||||
defaultMessage: '{username} needs to accept your friend request first',
|
||||
},
|
||||
noFriends: {
|
||||
id: 'sharing.invite-players-modal.no-friends',
|
||||
defaultMessage: 'No friends found.',
|
||||
},
|
||||
noSearchResults: {
|
||||
id: 'sharing.invite-players-modal.no-search-results',
|
||||
defaultMessage: 'No matching users found.',
|
||||
},
|
||||
searching: {
|
||||
id: 'sharing.invite-players-modal.searching',
|
||||
defaultMessage: 'Searching...',
|
||||
},
|
||||
alreadyInvited: {
|
||||
id: 'sharing.invite-players-modal.already-invited',
|
||||
defaultMessage: 'This user has already been invited.',
|
||||
},
|
||||
inviteLinkHeading: {
|
||||
id: 'sharing.invite-players-modal.invite-link-heading',
|
||||
defaultMessage: 'Or use an invite link',
|
||||
},
|
||||
inviteExpiryDescription: {
|
||||
id: 'sharing.invite-players-modal.invite-expiry-description',
|
||||
defaultMessage: 'Your invite link expires in {duration}.',
|
||||
},
|
||||
editInviteLink: {
|
||||
id: 'sharing.invite-players-modal.edit-invite-link',
|
||||
defaultMessage: 'Edit invite link.',
|
||||
},
|
||||
editInviteLinkTitle: {
|
||||
id: 'sharing.invite-players-modal.edit-invite-link-title',
|
||||
defaultMessage: 'Edit invite link',
|
||||
},
|
||||
expiryLabel: {
|
||||
id: 'sharing.invite-players-modal.expiry-label',
|
||||
defaultMessage: 'Expiry date',
|
||||
},
|
||||
maxUsesLabel: {
|
||||
id: 'sharing.invite-players-modal.max-uses-label',
|
||||
defaultMessage: 'Maximum uses',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'sharing.invite-players-modal.cancel-button',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
saveButton: {
|
||||
id: 'sharing.invite-players-modal.save-button',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
updateInviteLinkFailedTitle: {
|
||||
id: 'sharing.invite-players-modal.update-invite-link-failed-title',
|
||||
defaultMessage: 'Failed to update invite link',
|
||||
},
|
||||
linkCopiedTitle: {
|
||||
id: 'sharing.invite-players-modal.link-copied-title',
|
||||
defaultMessage: 'Link copied',
|
||||
},
|
||||
linkCopiedText: {
|
||||
id: 'sharing.invite-players-modal.link-copied-text',
|
||||
defaultMessage: 'The invite link has been copied to your clipboard.',
|
||||
},
|
||||
linkCopyFailedTitle: {
|
||||
id: 'sharing.invite-players-modal.link-copy-failed-title',
|
||||
defaultMessage: 'Failed to copy link',
|
||||
},
|
||||
avatarAlt: {
|
||||
id: 'sharing.invite-players-modal.avatar-alt',
|
||||
defaultMessage: "{username}'s avatar",
|
||||
},
|
||||
})
|
||||
|
||||
const normalizedSearchTarget = computed(() => searchTarget.value.trim())
|
||||
const usesRemoteSearch = computed(() => !!props.searchUsers)
|
||||
const friendsHeading = computed(
|
||||
() =>
|
||||
props.friendsLabel ??
|
||||
formatMessage(messages.friendsHeading, {
|
||||
count: props.friends.length,
|
||||
}),
|
||||
)
|
||||
const searchPlaceholderLabel = computed(
|
||||
() => props.searchPlaceholder ?? formatMessage(messages.searchPlaceholder),
|
||||
)
|
||||
const addButtonLabel = computed(() => props.addLabel ?? formatMessage(messages.addButton))
|
||||
const inviteButtonLabel = computed(() => props.inviteLabel ?? formatMessage(messages.inviteButton))
|
||||
const addedButtonLabel = computed(() => props.addedLabel ?? formatMessage(messages.addedButton))
|
||||
const cancelButtonLabel = computed(() => props.cancelLabel ?? formatMessage(messages.cancelButton))
|
||||
const requestedButtonLabel = computed(
|
||||
() => props.requestedLabel ?? formatMessage(messages.requestedButton),
|
||||
)
|
||||
const requestedTooltip = (username: string) =>
|
||||
formatMessage(messages.requestedTooltip, {
|
||||
username,
|
||||
})
|
||||
const emptyFriendsLabel = computed(
|
||||
() => props.emptyFriendsLabel ?? formatMessage(messages.noFriends),
|
||||
)
|
||||
const inviteLinkHeading = computed(() => formatMessage(messages.inviteLinkHeading))
|
||||
const linkExpiryDescription = computed(() => {
|
||||
if (!props.linkExpiresAt) return ''
|
||||
|
||||
const expiresAt = new Date(props.linkExpiresAt)
|
||||
if (Number.isNaN(expiresAt.getTime())) return ''
|
||||
const hours = Math.max(1, Math.ceil((expiresAt.getTime() - Date.now()) / 3_600_000))
|
||||
const duration =
|
||||
hours < 48 ? `${hours} ${hours === 1 ? 'hour' : 'hours'}` : `${Math.ceil(hours / 24)} days`
|
||||
|
||||
return formatMessage(messages.inviteExpiryDescription, { duration })
|
||||
})
|
||||
const canSaveInviteLink = computed(() => {
|
||||
const expiry = parseLocalDate(editExpiry.value)
|
||||
return (
|
||||
!savingInviteLink.value &&
|
||||
!!expiry &&
|
||||
expiry >= minimumExpiry.value &&
|
||||
expiry <= maximumExpiry.value &&
|
||||
Number.isInteger(editMaxUses.value ?? 0) &&
|
||||
(editMaxUses.value ?? 0) > 0 &&
|
||||
(editMaxUses.value ?? 0) <= 2147483647
|
||||
)
|
||||
})
|
||||
const inviteDisabledMessage = computed(
|
||||
() => props.inviteDisabledMessage ?? formatMessage(messages.alreadyInvited),
|
||||
)
|
||||
const searchLookupMessage = computed(() =>
|
||||
usesRemoteSearch.value && searchLookupStatus.value !== 'loaded'
|
||||
? formatMessage(messages.searching)
|
||||
: formatMessage(messages.noSearchResults),
|
||||
)
|
||||
const searchableUsers = computed(() => {
|
||||
const users = new Map<string, InvitePlayersSearchUser>()
|
||||
|
||||
for (const user of [...remoteSearchUsers.value, ...props.suggestions]) {
|
||||
users.set(user.id.toLowerCase(), user)
|
||||
users.set(user.username.toLowerCase(), user)
|
||||
if (user.email) users.set(user.email.toLowerCase(), user)
|
||||
}
|
||||
|
||||
return [...new Set(users.values())]
|
||||
})
|
||||
const searchOptions = computed<ComboboxOption<string>[]>(() =>
|
||||
searchableUsers.value.map((user) => ({
|
||||
value: user.username,
|
||||
label: user.username,
|
||||
searchTerms: [user.username, user.id, user.email].filter(Boolean) as string[],
|
||||
})),
|
||||
)
|
||||
const sortedFriends = computed(() =>
|
||||
props.friends
|
||||
.map((friend, index) => ({
|
||||
friend,
|
||||
order: friendOrder.value.get(friend.id) ?? friendOrder.value.size + index,
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(({ friend }) => friend),
|
||||
)
|
||||
const matchedSearchUser = computed(() => {
|
||||
if (
|
||||
selectedSearchUser.value &&
|
||||
normalizeInviteKey(selectedSearchUser.value.username) ===
|
||||
normalizeInviteKey(normalizedSearchTarget.value)
|
||||
) {
|
||||
return selectedSearchUser.value
|
||||
}
|
||||
|
||||
return findSearchUser(normalizedSearchTarget.value)
|
||||
})
|
||||
const invitedUserKeys = computed(() => {
|
||||
const keys = new Set<string>()
|
||||
|
||||
for (const friend of props.friends) {
|
||||
if (friendStatus(friend) === 'available') continue
|
||||
keys.add(normalizeInviteKey(friend.id))
|
||||
keys.add(normalizeInviteKey(friend.username))
|
||||
}
|
||||
|
||||
return keys
|
||||
})
|
||||
const searchTargetAlreadyInvited = computed(() => {
|
||||
const user = matchedSearchUser.value
|
||||
if (!user) return false
|
||||
|
||||
return (
|
||||
invitedUserKeys.value.has(normalizeInviteKey(user.id)) ||
|
||||
invitedUserKeys.value.has(normalizeInviteKey(user.username))
|
||||
)
|
||||
})
|
||||
const canInviteSearchTarget = computed(
|
||||
() =>
|
||||
props.canInvite &&
|
||||
normalizedSearchTarget.value.length >= searchMinimumLength &&
|
||||
!!matchedSearchUser.value &&
|
||||
!searchTargetAlreadyInvited.value &&
|
||||
(!usesRemoteSearch.value ||
|
||||
searchLookupStatus.value === 'loaded' ||
|
||||
!!selectedSearchUser.value),
|
||||
)
|
||||
const searchInviteTooltip = computed(() => {
|
||||
if (!props.canInvite) return inviteDisabledMessage.value
|
||||
if (searchTargetAlreadyInvited.value) return formatMessage(messages.alreadyInvited)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const searchTargetUsers = useDebounceFn(async (query: string, requestId: number) => {
|
||||
const searchUsers = props.searchUsers
|
||||
if (!searchUsers) return
|
||||
|
||||
try {
|
||||
const users = await searchUsers(query)
|
||||
if (requestId !== searchLookupRequestId.value || query !== normalizedSearchTarget.value) return
|
||||
|
||||
remoteSearchUsers.value = users
|
||||
} catch {
|
||||
if (requestId !== searchLookupRequestId.value || query !== normalizedSearchTarget.value) return
|
||||
|
||||
remoteSearchUsers.value = []
|
||||
} finally {
|
||||
if (requestId === searchLookupRequestId.value && query === normalizedSearchTarget.value) {
|
||||
searchLookupStatus.value = 'loaded'
|
||||
}
|
||||
}
|
||||
}, 250)
|
||||
|
||||
function friendStatus(friend: InvitePlayersUser): InvitePlayersUserStatus {
|
||||
return friend.status ?? 'available'
|
||||
}
|
||||
|
||||
function friendStatusSort(friend: InvitePlayersUser) {
|
||||
switch (friendStatus(friend)) {
|
||||
case 'available':
|
||||
return 0
|
||||
case 'requested':
|
||||
return 1
|
||||
case 'pending':
|
||||
return 2
|
||||
case 'added':
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
function syncFriendOrder(friends: InvitePlayersUser[]) {
|
||||
const nextOrder = new Map(friendOrder.value)
|
||||
let orderChanged = false
|
||||
let nextIndex = nextOrder.size
|
||||
|
||||
const unorderedFriends = friends.filter((friend) => !nextOrder.has(friend.id))
|
||||
const orderedFriends = unorderedFriends
|
||||
.map((friend, index) => ({ friend, index }))
|
||||
.sort((a, b) => {
|
||||
const statusSort = friendStatusSort(a.friend) - friendStatusSort(b.friend)
|
||||
return statusSort || a.index - b.index
|
||||
})
|
||||
|
||||
for (const { friend } of orderedFriends) {
|
||||
nextOrder.set(friend.id, nextIndex)
|
||||
nextIndex += 1
|
||||
orderChanged = true
|
||||
}
|
||||
|
||||
if (orderChanged) {
|
||||
friendOrder.value = nextOrder
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInviteKey(value: string) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function findSearchUser(value: string) {
|
||||
const normalizedValue = normalizeInviteKey(value)
|
||||
return searchableUsers.value.find(
|
||||
(user) =>
|
||||
normalizeInviteKey(user.username) === normalizedValue ||
|
||||
normalizeInviteKey(user.id) === normalizedValue ||
|
||||
(!!user.email && normalizeInviteKey(user.email) === normalizedValue),
|
||||
)
|
||||
}
|
||||
|
||||
function handleSearchInput(value: string) {
|
||||
searchTarget.value = value
|
||||
selectedSearchUser.value = null
|
||||
remoteSearchUsers.value = []
|
||||
searchLookupRequestId.value += 1
|
||||
|
||||
if (normalizedSearchTarget.value.length < searchMinimumLength) {
|
||||
searchLookupStatus.value = 'idle'
|
||||
return
|
||||
}
|
||||
|
||||
if (!usesRemoteSearch.value) {
|
||||
searchLookupStatus.value = 'loaded'
|
||||
return
|
||||
}
|
||||
|
||||
searchLookupStatus.value = 'loading'
|
||||
void searchTargetUsers(normalizedSearchTarget.value, searchLookupRequestId.value)
|
||||
}
|
||||
|
||||
function handleSearchSelect(option: ComboboxOption<string>) {
|
||||
searchTarget.value = option.value
|
||||
selectedSearchUser.value = findSearchUser(option.value) ?? null
|
||||
searchLookupStatus.value = 'loaded'
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
searchTarget.value = ''
|
||||
searchInputKey.value += 1
|
||||
selectedSearchUser.value = null
|
||||
remoteSearchUsers.value = []
|
||||
searchLookupStatus.value = 'idle'
|
||||
searchLookupRequestId.value += 1
|
||||
}
|
||||
|
||||
function inviteSearchTarget() {
|
||||
if (!canInviteSearchTarget.value) return
|
||||
const user = matchedSearchUser.value
|
||||
if (!user) return
|
||||
|
||||
emit('invite', {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
},
|
||||
source: 'search',
|
||||
})
|
||||
resetSearch()
|
||||
}
|
||||
|
||||
function inviteFriend(friend: InvitePlayersUser) {
|
||||
emit('invite', {
|
||||
user: friend,
|
||||
source: 'friend',
|
||||
})
|
||||
}
|
||||
|
||||
function cancelInvite(friend: InvitePlayersUser) {
|
||||
emit('cancel', friend)
|
||||
}
|
||||
|
||||
async function copyInviteLink() {
|
||||
if (!props.link) return
|
||||
|
||||
emit('copy-link', props.link)
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.link)
|
||||
notificationManager?.addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.linkCopiedTitle),
|
||||
text: formatMessage(messages.linkCopiedText),
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
notificationManager?.addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.linkCopyFailedTitle),
|
||||
text: message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function formatLocalDate(date: Date) {
|
||||
const pad = (value: number) => value.toString().padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function parseLocalDate(value: string) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(value)
|
||||
if (!match) return null
|
||||
const [, year, month, day, hour, minute] = match
|
||||
const date = new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function showEditInviteLink() {
|
||||
const now = new Date()
|
||||
minimumExpiry.value = new Date(now.getTime() + 3_600_000)
|
||||
minimumExpiry.value.setSeconds(0, 0)
|
||||
minimumExpiry.value.setMinutes(minimumExpiry.value.getMinutes() + 1)
|
||||
maximumExpiry.value = new Date(now.getTime() + 7 * 86_400_000)
|
||||
maximumExpiry.value.setSeconds(0, 0)
|
||||
const currentExpiry = props.linkExpiresAt ? new Date(props.linkExpiresAt) : maximumExpiry.value
|
||||
const expiry =
|
||||
Number.isNaN(currentExpiry.getTime()) || currentExpiry < minimumExpiry.value
|
||||
? minimumExpiry.value
|
||||
: currentExpiry > maximumExpiry.value
|
||||
? maximumExpiry.value
|
||||
: currentExpiry
|
||||
editExpiry.value = formatLocalDate(expiry)
|
||||
editMaxUses.value = props.linkMaxUses
|
||||
editInviteLinkModal.value?.show()
|
||||
}
|
||||
|
||||
async function saveInviteLink() {
|
||||
const expiry = parseLocalDate(editExpiry.value)
|
||||
if (!canSaveInviteLink.value || !expiry || !props.updateInviteLink) return
|
||||
|
||||
savingInviteLink.value = true
|
||||
try {
|
||||
await props.updateInviteLink({ expiresAt: expiry, maxUses: editMaxUses.value ?? 1 })
|
||||
editInviteLinkModal.value?.hide()
|
||||
} catch (error) {
|
||||
notificationManager?.addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.updateInviteLinkFailedTitle),
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
} finally {
|
||||
savingInviteLink.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
resetSearch()
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
watch(() => props.friends, syncFriendOrder, { immediate: true })
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-10 items-center justify-between gap-3 px-6 transition-colors hover:bg-surface-3"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<AutoLink
|
||||
v-tooltip="user.username"
|
||||
:to="profileLink"
|
||||
:target="profileTarget"
|
||||
class="inline-flex min-w-0 items-center gap-1.5"
|
||||
:class="profileLink ? 'text-primary hover:underline' : ''"
|
||||
>
|
||||
<span class="relative flex shrink-0">
|
||||
<Avatar
|
||||
:src="user.avatarUrl"
|
||||
:alt="avatarAlt"
|
||||
:tint-by="user.username"
|
||||
size="1.5rem"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span
|
||||
v-if="user.online"
|
||||
class="absolute bottom-[1.5px] right-[-1.5px] size-[9px] rounded-full border-[1.5px] border-solid border-surface-2 bg-brand"
|
||||
/>
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-base font-medium">
|
||||
{{ user.username }}
|
||||
</span>
|
||||
</AutoLink>
|
||||
</div>
|
||||
|
||||
<ButtonStyled v-if="status === 'added'" type="standard" color-fill="none">
|
||||
<button disabled>
|
||||
<CheckIcon aria-hidden="true" />
|
||||
{{ addedLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="status === 'pending'" type="outlined">
|
||||
<button @click="$emit('cancel', user)">
|
||||
{{ cancelLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<span v-else-if="status === 'requested'" v-tooltip="requestedTooltip" class="inline-flex">
|
||||
<ButtonStyled type="standard" color-fill="none">
|
||||
<button disabled>
|
||||
{{ requestedLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</span>
|
||||
<ButtonStyled v-else color-fill="none">
|
||||
<button @click="$emit('invite', user)">
|
||||
{{ inviteLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
import Avatar from '../../base/Avatar.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import type { InvitePlayersUser, InvitePlayersUserProfileLink } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
user: InvitePlayersUser
|
||||
avatarAlt: string
|
||||
addedLabel: string
|
||||
cancelLabel: string
|
||||
inviteLabel: string
|
||||
requestedLabel: string
|
||||
requestedTooltip: string
|
||||
userProfileLink?: (username: string) => InvitePlayersUserProfileLink
|
||||
}>(),
|
||||
{
|
||||
userProfileLink: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
invite: [user: InvitePlayersUser]
|
||||
cancel: [user: InvitePlayersUser]
|
||||
}>()
|
||||
|
||||
const status = computed(() => props.user.status ?? 'available')
|
||||
const profileLink = computed(() => getUserProfileLink(props.user.username))
|
||||
const profileTarget = computed(() =>
|
||||
typeof profileLink.value === 'string' && profileLink.value.startsWith('http')
|
||||
? '_blank'
|
||||
: undefined,
|
||||
)
|
||||
|
||||
function getUserProfileLink(username: string): InvitePlayersUserProfileLink {
|
||||
if (!username || username.includes('@')) return undefined
|
||||
return props.userProfileLink?.(username) ?? `/user/${encodeURIComponent(username)}`
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
export type InvitePlayersUserStatus = 'available' | 'requested' | 'pending' | 'added'
|
||||
export type InvitePlayersUserProfileLink =
|
||||
| RouteLocationRaw
|
||||
| (() => void | Promise<void>)
|
||||
| undefined
|
||||
|
||||
export interface InvitePlayersUser {
|
||||
id: string
|
||||
username: string
|
||||
avatarUrl?: string | null
|
||||
status?: InvitePlayersUserStatus
|
||||
online?: boolean
|
||||
}
|
||||
|
||||
export interface InvitePlayersSearchUser {
|
||||
id: string
|
||||
username: string
|
||||
avatarUrl?: string | null
|
||||
email?: string
|
||||
}
|
||||
|
||||
export interface InvitePlayersInvitePayload {
|
||||
user: InvitePlayersUser
|
||||
source: 'friend' | 'search'
|
||||
}
|
||||
|
||||
export interface InviteLinkSettings {
|
||||
expiresAt: Date
|
||||
maxUses: number
|
||||
}
|
||||
@@ -67,7 +67,8 @@ const hasEnabledListener = computed(
|
||||
const hasAnyActions = computed(() => {
|
||||
// Check if there are listeners for actions
|
||||
const hasListeners =
|
||||
(hasDeleteListener.value && !props.hideDelete) ||
|
||||
(hasDeleteListener.value &&
|
||||
props.items.some((item) => !props.hideDelete && !item.hideDelete)) ||
|
||||
hasUpdateListener.value ||
|
||||
hasSwitchVersionListener.value ||
|
||||
hasEnabledListener.value
|
||||
@@ -273,7 +274,7 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
:toggle-disabled="item.toggleDisabled"
|
||||
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
|
||||
:show-checkbox="showSelection"
|
||||
:hide-delete="hideDelete"
|
||||
:hide-delete="hideDelete || item.hideDelete"
|
||||
:hide-actions="!hasAnyActions"
|
||||
:selected="isItemSelected(item.id)"
|
||||
:class="[
|
||||
@@ -326,13 +327,14 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
:has-update="item.hasUpdate"
|
||||
:is-client-only="item.isClientOnly"
|
||||
:client-warning="item.clientWarning"
|
||||
:hide-switch-version="item.hideSwitchVersion"
|
||||
:overflow-options="item.overflowOptions"
|
||||
:disabled="item.disabled"
|
||||
:disabled-tooltip="item.disabledTooltip"
|
||||
:toggle-disabled="item.toggleDisabled"
|
||||
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
|
||||
:show-checkbox="showSelection"
|
||||
:hide-delete="hideDelete"
|
||||
:hide-delete="hideDelete || item.hideDelete"
|
||||
:hide-actions="!hasAnyActions"
|
||||
:selected="isItemSelected(item.id)"
|
||||
:class="[
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user