mirror of
https://github.com/modrinth/code.git
synced 2026-08-28 02:24:56 +00:00
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:
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user