mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 09:04:55 +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:
@@ -0,0 +1,2 @@
|
||||
export { default as InvitePlayersModal } from './invite-players-modal/index.vue'
|
||||
export * from './invite-players-modal/types'
|
||||
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="header"
|
||||
width="min(34rem, calc(100vw - 2rem))"
|
||||
max-width="34rem"
|
||||
no-padding
|
||||
noblur
|
||||
>
|
||||
<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>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
type="button"
|
||||
class="!h-10 w-full !justify-between !px-4 text-left !shadow-none"
|
||||
@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>
|
||||
</ButtonStyled>
|
||||
<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="inviteLinkEditor?.show()"
|
||||
>
|
||||
{{ formatMessage(messages.editInviteLink) }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
|
||||
<InvitePlayersModalInviteLinkEditor
|
||||
v-if="updateInviteLink"
|
||||
ref="inviteLinkEditor"
|
||||
:link-expires-at="linkExpiresAt"
|
||||
:link-max-uses="linkMaxUses"
|
||||
:update-invite-link="updateInviteLink"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClipboardCopyIcon, PlusIcon } from '@modrinth/assets'
|
||||
import { computed, ref } 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 from '../../base/Combobox.vue'
|
||||
import NewModal from '../../modal/NewModal.vue'
|
||||
import InvitePlayersModalInviteLinkEditor from './invite-players-modal-invite-link-editor.vue'
|
||||
import InvitePlayersModalUserRow from './invite-players-modal-user-row.vue'
|
||||
import type {
|
||||
InviteLinkSettings,
|
||||
InvitePlayersInvitePayload,
|
||||
InvitePlayersSearchUser,
|
||||
InvitePlayersUser,
|
||||
InvitePlayersUserProfileLink,
|
||||
} from './types'
|
||||
import { useInvitePlayersSearch } from './use-invite-players-search'
|
||||
|
||||
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 inviteLinkEditor = ref<InstanceType<typeof InvitePlayersModalInviteLinkEditor> | null>(null)
|
||||
|
||||
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.',
|
||||
},
|
||||
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 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 inviteDisabledMessage = computed(
|
||||
() => props.inviteDisabledMessage ?? formatMessage(messages.alreadyInvited),
|
||||
)
|
||||
const {
|
||||
searchTarget,
|
||||
searchInputKey,
|
||||
searchOptions,
|
||||
searchLookupMessage,
|
||||
searchMinimumLength,
|
||||
usesRemoteSearch,
|
||||
passwordManagerIgnoreAttrs,
|
||||
sortedFriends,
|
||||
canInviteSearchTarget,
|
||||
searchInviteTooltip,
|
||||
findSearchUser,
|
||||
handleSearchInput,
|
||||
handleSearchSelect,
|
||||
inviteSearchTarget,
|
||||
resetSearch,
|
||||
} = useInvitePlayersSearch({
|
||||
friends: () => props.friends,
|
||||
suggestions: () => props.suggestions,
|
||||
searchUsers: () => props.searchUsers,
|
||||
canInvite: () => props.canInvite,
|
||||
inviteDisabledMessage,
|
||||
alreadyInvitedMessage: () => formatMessage(messages.alreadyInvited),
|
||||
searchingMessage: () => formatMessage(messages.searching),
|
||||
noResultsMessage: () => formatMessage(messages.noSearchResults),
|
||||
onInvite: (payload) => emit('invite', payload),
|
||||
})
|
||||
|
||||
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 show(event?: MouseEvent) {
|
||||
resetSearch()
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" 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="expiry"
|
||||
:disabled="saving"
|
||||
: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="maxUses"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="2147483647"
|
||||
:step="1"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button :disabled="saving" @click="modal?.hide()">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canSave" @click="save">
|
||||
<SpinnerIcon v-if="saving" class="animate-spin" aria-hidden="true" />
|
||||
<SaveIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.save) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import { injectNotificationManager } from '../../../providers'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import DatePicker from '../../base/DatePicker.vue'
|
||||
import StyledInput from '../../base/StyledInput.vue'
|
||||
import NewModal from '../../modal/NewModal.vue'
|
||||
import type { InviteLinkSettings } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
linkExpiresAt?: string | Date | null
|
||||
linkMaxUses: number
|
||||
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
|
||||
}>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const notificationManager = injectNotificationManager(null)
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const expiry = ref('')
|
||||
const maxUses = ref<number>()
|
||||
const minimumExpiry = ref(new Date())
|
||||
const maximumExpiry = ref(new Date())
|
||||
const saving = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
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',
|
||||
},
|
||||
cancel: {
|
||||
id: 'sharing.invite-players-modal.cancel-button',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
save: {
|
||||
id: 'sharing.invite-players-modal.save-button',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
failed: {
|
||||
id: 'sharing.invite-players-modal.update-invite-link-failed-title',
|
||||
defaultMessage: 'Failed to update invite link',
|
||||
},
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
const date = parseLocalDate(expiry.value)
|
||||
return (
|
||||
!saving.value &&
|
||||
!!date &&
|
||||
date >= minimumExpiry.value &&
|
||||
date <= maximumExpiry.value &&
|
||||
Number.isInteger(maxUses.value ?? 0) &&
|
||||
(maxUses.value ?? 0) > 0 &&
|
||||
(maxUses.value ?? 0) <= 2147483647
|
||||
)
|
||||
})
|
||||
|
||||
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 show() {
|
||||
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 date =
|
||||
Number.isNaN(currentExpiry.getTime()) || currentExpiry < minimumExpiry.value
|
||||
? minimumExpiry.value
|
||||
: currentExpiry > maximumExpiry.value
|
||||
? maximumExpiry.value
|
||||
: currentExpiry
|
||||
expiry.value = formatLocalDate(date)
|
||||
maxUses.value = props.linkMaxUses
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const date = parseLocalDate(expiry.value)
|
||||
if (!canSave.value || !date || !props.updateInviteLink) return
|
||||
saving.value = true
|
||||
try {
|
||||
await props.updateInviteLink({ expiresAt: date, maxUses: maxUses.value ?? 1 })
|
||||
modal.value?.hide()
|
||||
} catch (error) {
|
||||
notificationManager?.addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failed),
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</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,36 @@
|
||||
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
|
||||
}
|
||||
|
||||
export function normalizeInviteKey(value: string) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, type MaybeRefOrGetter, ref, toValue, watch } from 'vue'
|
||||
|
||||
import type { ComboboxOption } from '../../base/Combobox.vue'
|
||||
import {
|
||||
type InvitePlayersInvitePayload,
|
||||
type InvitePlayersSearchUser,
|
||||
type InvitePlayersUser,
|
||||
type InvitePlayersUserStatus,
|
||||
normalizeInviteKey,
|
||||
} from './types'
|
||||
|
||||
export function useInvitePlayersSearch(options: {
|
||||
friends: MaybeRefOrGetter<InvitePlayersUser[]>
|
||||
suggestions: MaybeRefOrGetter<InvitePlayersSearchUser[]>
|
||||
searchUsers: MaybeRefOrGetter<((query: string) => Promise<InvitePlayersSearchUser[]>) | undefined>
|
||||
canInvite: MaybeRefOrGetter<boolean>
|
||||
inviteDisabledMessage: MaybeRefOrGetter<string>
|
||||
alreadyInvitedMessage: MaybeRefOrGetter<string>
|
||||
searchingMessage: MaybeRefOrGetter<string>
|
||||
noResultsMessage: MaybeRefOrGetter<string>
|
||||
onInvite: (payload: InvitePlayersInvitePayload) => void
|
||||
}) {
|
||||
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 normalizedSearchTarget = computed(() => searchTarget.value.trim())
|
||||
const usesRemoteSearch = computed(() => !!toValue(options.searchUsers))
|
||||
const searchLookupMessage = computed(() =>
|
||||
usesRemoteSearch.value && searchLookupStatus.value !== 'loaded'
|
||||
? toValue(options.searchingMessage)
|
||||
: toValue(options.noResultsMessage),
|
||||
)
|
||||
const searchableUsers = computed(() => {
|
||||
const users = new Map<string, InvitePlayersSearchUser>()
|
||||
for (const user of [...remoteSearchUsers.value, ...toValue(options.suggestions)]) {
|
||||
users.set(normalizeInviteKey(user.id), user)
|
||||
users.set(normalizeInviteKey(user.username), user)
|
||||
if (user.email) users.set(normalizeInviteKey(user.email), 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(() =>
|
||||
toValue(options.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),
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
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 toValue(options.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(
|
||||
() =>
|
||||
toValue(options.canInvite) &&
|
||||
normalizedSearchTarget.value.length >= searchMinimumLength &&
|
||||
!!matchedSearchUser.value &&
|
||||
!searchTargetAlreadyInvited.value &&
|
||||
(!usesRemoteSearch.value ||
|
||||
searchLookupStatus.value === 'loaded' ||
|
||||
!!selectedSearchUser.value),
|
||||
)
|
||||
const searchInviteTooltip = computed(() => {
|
||||
if (!toValue(options.canInvite)) return toValue(options.inviteDisabledMessage)
|
||||
if (searchTargetAlreadyInvited.value) return toValue(options.alreadyInvitedMessage)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const searchTargetUsers = useDebounceFn(async (query: string, requestId: number) => {
|
||||
const searchUsers = toValue(options.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 syncFriendOrder(friends: InvitePlayersUser[]) {
|
||||
const nextOrder = new Map(friendOrder.value)
|
||||
let nextIndex = nextOrder.size
|
||||
const unorderedFriends = friends.filter((friend) => !nextOrder.has(friend.id))
|
||||
if (unorderedFriends.length === 0) return
|
||||
for (const friend of unorderedFriends) {
|
||||
nextOrder.set(friend.id, nextIndex)
|
||||
nextIndex += 1
|
||||
}
|
||||
friendOrder.value = nextOrder
|
||||
}
|
||||
|
||||
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
|
||||
options.onInvite({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
},
|
||||
source: 'search',
|
||||
})
|
||||
resetSearch()
|
||||
}
|
||||
|
||||
watch(() => toValue(options.friends), syncFriendOrder, { immediate: true })
|
||||
|
||||
return {
|
||||
searchTarget,
|
||||
searchInputKey,
|
||||
searchOptions,
|
||||
searchLookupMessage,
|
||||
searchMinimumLength,
|
||||
usesRemoteSearch,
|
||||
passwordManagerIgnoreAttrs,
|
||||
sortedFriends,
|
||||
canInviteSearchTarget,
|
||||
searchInviteTooltip,
|
||||
findSearchUser,
|
||||
handleSearchInput,
|
||||
handleSearchSelect,
|
||||
inviteSearchTarget,
|
||||
resetSearch,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user