feat: instance sharing thru shared-instances service (#6569)

* feat: implement instance share page + search_users backend call

* feat: invite players modal

* feat: use tanstack queries for friends sync across app pages

* feat: base shared instances implementation

* fix: admon style

* feat: impl instance admonitions like server panel

* fix: impl get + del usage

* feat: support modpack links

* feat: invite notif accepting

* fix: lint + fmt

* feat: impl install to play

* feat: impl usage of UpdateToPlayModal

* feat: warnings on deleting/disabling shared-instance version content

* fix: send instance name

* feat: align with backend

* feat: shared instances qa

* feat: wrong account protection

* feat: qa

* fix: smartly apply updates

* fix: install bug

* fix: 401/404 differentiation

* fix: fmt+prepr

* feat: qa

* feat: qa

* fix: signing out messes up revoke/deleted checks

* feat: qa

* fix: fmt + lint

* feat: lock content if part of shared instance

* fix: lint

* [do not merge] feat: rough invite links impl temp (#6666)

* fix: wrong cmd

* feat: invite page

* fix: server-manager DTO mismatch

* fix: drop anonymous invite link acceptance

* refactor: structured shared-instance unavailable errors

* refactor: centralise error presentations

* refactor: dedupe shared instance diff detection

* fix: logging in reqwests

* refactor: move app.vue shared instances into handler

* refactor: break up Share.vue

* refactor: split up shared instances state outside of instance index

* refactor: dedicated shared instances install/update modals + split up page

* refactor: centralized managed content

* refactor: split up install shared to own runner + shared.rs split up

* refactor: dedupe sql for instance metadata enrichmnt

* refactor: friends composable + dedupe friends logic across usages

* chore: reduced unused code

* fix: align with backend

* fix: lint

* fix: file sha changes

* fix: invite links not working due to icon signed

* feat: qa

* feat: reporting frontend dummy

* fix: try use header

* remove: file hash field

* fix: pin box

* feat: malware warning for shared instances

* fix: cache rule

* feat: config files syncing

* feat: disable config sharing

* fix: header

* fix: use mark ready

* fix: dont cause push update for configs

* fix: lint

* feat: sharing page in settings

* feat: move config + change flow

* fix: qa

* fix: lint prepr

* feat: proxy file upload thru shared instances backend

* fix: use collapisible

* fix: push config

* fix: config

* feat: swap out sign in modal for new one

* fix: report flow

* fix: exclude configs.zip from external warnings

* fix: nuxi init

* fix: config bundle downloading

* fix: error notif

* fix: polling

* fix: qa

* fix: lint + prepr

* feat: shared instances moderation frontend + hook up report flow

* fix: report copy

* fix: lint

* fix: lint

* fix: modrinth ids being undefined

* feat: instance quarantining

* fix: prepr + fmt

* fix: quarantined -> locked terminology

* fix: missing endpoint impls + fmt

* fix: missing api in build.rs

* fix: share tab jittery

* fix: fmt

*PT bug

* fix: invites count as users even if pending

* fix: prepr

* fix: invite page owner in users list

* fix: lint

* fix: qa

* fix: lint

* fix: members stale not clearing

* fix: invite use joined_at field

* fix: lint

* fix: qa

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-24 13:06:38 +00:00
committed by GitHub
co-authored by sychic
parent 2e0d797bb0
commit e58af98f21
269 changed files with 18202 additions and 2579 deletions
@@ -60,8 +60,8 @@ export const FigmaExamples: Story = {
type="instance-invite"
actor-name="Fetch"
:actor-avatar-url="avatarUrl"
entity-name="New Creation"
:entity-icon-url="instanceIconUrl"
entity-name="New Creation"
@accept="noop"
@decline="noop"
@dismiss="noop"
@@ -0,0 +1,202 @@
import type { Labrinth } from '@modrinth/api-client'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import InvitePlayersModal from '../../components/sharing/invite-players-modal/index.vue'
import type {
InviteLinkSettings,
InvitePlayersInvitePayload,
InvitePlayersUser,
InvitePlayersUserStatus,
} from '../../components/sharing/invite-players-modal/types'
const apiUsers: Labrinth.Users.v3.SearchUser[] = [
{
id: 'geometrically',
username: 'Geometrically',
avatar_url:
'https://cdn.modrinth.com/user/u6dRKJwZ/7ba3bdb11590a64843e9d2ab83ef85eaab42ec8e.png',
},
{
id: 'prospector',
username: 'Prospector',
avatar_url:
'https://cdn.modrinth.com/user/PHyAPGui/30a3a3f53866531831db4aa006794e6bbcfc4121.png',
},
{
id: 'fetch',
username: 'Fetch',
avatar_url:
'https://cdn.modrinth.com/user/yol4bNw3/ee2c7a7580ed475cfe3cfe8cc92df45ce33031e0.png',
},
{
id: 'imb11',
username: 'IMB11',
avatar_url: null,
},
{
id: 'josh',
username: 'Josh',
avatar_url: null,
},
{
id: 'emma',
username: 'Emma',
avatar_url: null,
},
]
const friendStatuses: Record<string, InvitePlayersUserStatus> = {
geometrically: 'added',
fetch: 'pending',
}
const meta = {
title: 'Sharing/InvitePlayersModal',
component: InvitePlayersModal,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Invite players modal for app instance sharing and server player invites. Callers provide the user search proxy and own invite/cancel persistence.',
},
},
},
} satisfies Meta<typeof InvitePlayersModal>
export default meta
type Story = StoryObj<typeof meta>
function toInviteUser(
user: Labrinth.Users.v3.SearchUser,
status: InvitePlayersUserStatus = 'available',
): InvitePlayersUser {
return {
id: user.id,
username: user.username,
avatarUrl: user.avatar_url,
status,
online: user.id === 'prospector',
}
}
function createFriends() {
return apiUsers
.slice(0, 4)
.map((user) => toInviteUser(user, friendStatuses[user.id] ?? 'available'))
}
function createSearchUsers() {
return apiUsers.map((user) => ({
id: user.id,
username: user.username,
avatarUrl: user.avatar_url,
}))
}
function createRender(args: Record<string, unknown>) {
return {
components: { ButtonStyled, InvitePlayersModal },
setup() {
const modalRef = ref<InstanceType<typeof InvitePlayersModal> | null>(null)
const friends = ref<InvitePlayersUser[]>(createFriends())
const searchUsers = createSearchUsers()
const lastAction = ref('')
const invitedSearchUserIds = ref(new Set<string>())
async function updateInviteLink(settings: InviteLinkSettings) {
await new Promise((resolve) => setTimeout(resolve, 500))
lastAction.value = `Updated invite link to ${settings.maxUses} uses, expiring ${settings.expiresAt.toLocaleString()}`
}
async function searchInviteUsers(query: string) {
await new Promise((resolve) => setTimeout(resolve, 250))
const normalizedQuery = query.trim().toLowerCase()
const friendKeys = new Set(
friends.value.flatMap((friend) => [
friend.id.toLowerCase(),
friend.username.toLowerCase(),
]),
)
return searchUsers.filter(
(user) =>
user.username.toLowerCase().startsWith(normalizedQuery) &&
!friendKeys.has(user.id.toLowerCase()) &&
!friendKeys.has(user.username.toLowerCase()) &&
!invitedSearchUserIds.value.has(user.id),
)
}
function handleInvite(payload: InvitePlayersInvitePayload) {
const existingFriend = friends.value.find((friend) => friend.id === payload.user.id)
if (payload.source === 'search') {
invitedSearchUserIds.value = new Set([...invitedSearchUserIds.value, payload.user.id])
lastAction.value = `Sent friend request and direct invite to ${payload.user.username}`
return
}
if (existingFriend) {
existingFriend.status = 'pending'
}
lastAction.value = `Invited ${payload.user.username} from ${payload.source}`
}
function handleCancel(user: InvitePlayersUser) {
const existingFriend = friends.value.find((friend) => friend.id === user.id)
if (existingFriend) existingFriend.status = 'available'
lastAction.value = `Cancelled invite for ${user.username}`
}
return {
args,
friends,
handleCancel,
handleInvite,
lastAction,
modalRef,
searchInviteUsers,
updateInviteLink,
}
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="brand">
<button @click="modalRef?.show($event)">Open modal</button>
</ButtonStyled>
<p v-if="lastAction" class="m-0 text-sm text-secondary">{{ lastAction }}</p>
<InvitePlayersModal
ref="modalRef"
v-bind="args"
:friends="friends"
:search-users="searchInviteUsers"
:update-invite-link="updateInviteLink"
@invite="handleInvite"
@cancel="handleCancel"
/>
</div>
`,
}
}
export const ShareInstance: Story = {
args: {
header: 'Share instance',
link: 'https://modrinth.com/instance/abc123',
linkExpiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
linkMaxUses: 10,
},
render: (args) => createRender(args),
}
export const FriendsOnly: Story = {
args: {
header: 'Invite players',
},
render: (args) => createRender(args),
}