mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 09:34:50 +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:
@@ -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>
|
||||
@@ -80,9 +102,10 @@ import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning'
|
||||
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning' | 'neutral'
|
||||
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,
|
||||
@@ -143,6 +167,7 @@ const typeClasses = {
|
||||
critical: 'border-brand-red bg-bg-red',
|
||||
success: 'border-brand-green bg-bg-green',
|
||||
moderation: 'border-brand-orange bg-bg-orange',
|
||||
neutral: 'border-surface-4 bg-surface-3',
|
||||
}
|
||||
|
||||
const iconClasses = {
|
||||
@@ -152,6 +177,7 @@ const iconClasses = {
|
||||
critical: 'text-brand-red',
|
||||
success: 'text-brand-green',
|
||||
moderation: 'text-brand-orange',
|
||||
neutral: 'text-secondary',
|
||||
}
|
||||
|
||||
const buttonColors = {
|
||||
@@ -161,6 +187,7 @@ const buttonColors = {
|
||||
critical: 'red',
|
||||
success: 'green',
|
||||
moderation: 'orange',
|
||||
neutral: 'standard',
|
||||
} as const
|
||||
|
||||
const progressTrackClasses = {
|
||||
@@ -170,6 +197,7 @@ const progressTrackClasses = {
|
||||
critical: 'bg-brand-red/20',
|
||||
success: 'bg-brand-green/20',
|
||||
moderation: 'bg-brand-orange/20',
|
||||
neutral: 'bg-surface-4',
|
||||
}
|
||||
|
||||
const progressFillClasses = {
|
||||
@@ -181,10 +209,15 @@ const progressFillClasses = {
|
||||
blue: 'bg-brand-blue',
|
||||
green: 'bg-brand-green',
|
||||
red: 'bg-brand-red',
|
||||
neutral: 'bg-surface-5',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admonition-inline-content {
|
||||
min-width: min(100%, 16rem);
|
||||
}
|
||||
|
||||
.admonition-progress--waiting {
|
||||
animation: admonition-progress-waiting 1s linear infinite;
|
||||
position: relative;
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
</div>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<button
|
||||
v-if="showSize"
|
||||
type="button"
|
||||
class="hidden w-[92px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
|
||||
:class="sortField === 'size' ? 'text-contrast' : 'text-secondary'"
|
||||
@@ -52,6 +53,7 @@
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="showModified"
|
||||
type="button"
|
||||
class="hidden w-[132px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
|
||||
:class="sortField === 'modified' ? 'text-contrast' : 'text-secondary'"
|
||||
@@ -90,8 +92,11 @@
|
||||
{{ formatMessage(messages.parentFolder) }}
|
||||
</span>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<span class="hidden w-[92px] text-left text-sm text-secondary sm:block" />
|
||||
<span class="hidden w-[132px] text-left text-sm text-secondary sm:block" />
|
||||
<span v-if="showSize" class="hidden w-[92px] text-left text-sm text-secondary sm:block" />
|
||||
<span
|
||||
v-if="showModified"
|
||||
class="hidden w-[132px] text-left text-sm text-secondary sm:block"
|
||||
/>
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,10 +145,16 @@
|
||||
{{ entry.name }}
|
||||
</span>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<span class="hidden w-[92px] truncate text-left text-sm text-secondary sm:block">
|
||||
<span
|
||||
v-if="showSize"
|
||||
class="hidden w-[92px] truncate text-left text-sm text-secondary sm:block"
|
||||
>
|
||||
{{ formatSize(entry) }}
|
||||
</span>
|
||||
<span class="hidden w-[132px] truncate text-left text-sm text-secondary sm:block">
|
||||
<span
|
||||
v-if="showModified"
|
||||
class="hidden w-[132px] truncate text-left text-sm text-secondary sm:block"
|
||||
>
|
||||
{{ formatModified(entry) }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
@@ -164,8 +175,8 @@
|
||||
<span class="size-4 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium opacity-0">.</span>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<span class="hidden w-[92px] text-left text-sm sm:block" />
|
||||
<span class="hidden w-[132px] text-left text-sm sm:block" />
|
||||
<span v-if="showSize" class="hidden w-[92px] text-left text-sm sm:block" />
|
||||
<span v-if="showModified" class="hidden w-[132px] text-left text-sm sm:block" />
|
||||
<span class="size-4 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -262,10 +273,14 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
items: FileTreeSelectItem[]
|
||||
modelValue: string[]
|
||||
showSize?: boolean
|
||||
showModified?: boolean
|
||||
}>(),
|
||||
{
|
||||
items: () => [],
|
||||
modelValue: () => [],
|
||||
showSize: true,
|
||||
showModified: true,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="metadata"
|
||||
class="page-header-metadata flex min-w-0 flex-wrap items-center gap-x-2 gap-y-2"
|
||||
class="page-header-metadata flex min-w-0 flex-wrap items-center gap-x-[1.625rem] gap-y-2"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div :class="rootClass" data-page-header-metadata-item v-bind="$attrs">
|
||||
<BulletDivider class="page-header-metadata-item-divider shrink-0" />
|
||||
<span
|
||||
class="page-header-metadata-item-divider absolute right-full flex h-full w-[1.625rem] items-center justify-center"
|
||||
>
|
||||
<BulletDivider class="shrink-0" />
|
||||
</span>
|
||||
<AutoLink
|
||||
v-if="to && !disabled"
|
||||
v-tooltip="tooltip"
|
||||
@@ -72,7 +76,7 @@ const props = withDefaults(defineProps<PageHeaderMetadataItemProps>(), {
|
||||
|
||||
const defaultIconClass = 'block size-5 shrink-0 text-current'
|
||||
const baseClass =
|
||||
'flex min-w-0 items-center gap-2 font-medium leading-none text-secondary text-nowrap'
|
||||
'relative flex min-w-0 items-center font-medium leading-none text-secondary text-nowrap'
|
||||
const contentBaseClass = 'inline-flex min-w-0 items-center gap-2 text-inherit'
|
||||
const interactiveClass = 'm-0 cursor-pointer border-0 bg-transparent p-0 hover:underline'
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -89,11 +89,11 @@
|
||||
ref="scrollContainer"
|
||||
data-modal-content
|
||||
:class="[
|
||||
'flex-1 min-h-0',
|
||||
props.noPadding ? '' : 'overflow-y-auto p-6 !pb-1 sm:pb-6',
|
||||
'flex-1 min-h-0 overflow-y-auto',
|
||||
props.noPadding ? '' : 'p-6 !pb-1 sm:pb-6',
|
||||
{ 'pt-12': props.mergeHeader && closable && !props.noPadding },
|
||||
]"
|
||||
:style="props.noPadding ? {} : { maxHeight: maxContentHeight }"
|
||||
:style="{ maxHeight: maxContentHeight }"
|
||||
@scroll="checkScrollState"
|
||||
>
|
||||
<slot> You just lost the game.</slot>
|
||||
@@ -231,6 +231,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)
|
||||
@@ -244,6 +245,10 @@ function getFocusableElements(): HTMLElement[] {
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout)
|
||||
hideTimeout = null
|
||||
}
|
||||
props.onShow?.()
|
||||
const wasEmpty = modalStackSize() === 0
|
||||
stackDepth.value = modalStackSize()
|
||||
@@ -292,8 +297,9 @@ function hide() {
|
||||
previousFocusEl.focus()
|
||||
}
|
||||
previousFocusEl = null
|
||||
setTimeout(() => {
|
||||
hideTimeout = setTimeout(() => {
|
||||
open.value = false
|
||||
hideTimeout = null
|
||||
nextTick(() => props.onAfterHide?.())
|
||||
}, 300)
|
||||
}
|
||||
@@ -340,6 +346,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 (
|
||||
@@ -265,6 +268,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="inline-block !rounded-lg align-middle"
|
||||
/>
|
||||
<span class="ml-1 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>
|
||||
@@ -175,7 +179,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { CheckIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useFormatBytes, useFormatNumber } from '../../composables'
|
||||
@@ -190,10 +194,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
|
||||
@@ -209,6 +215,7 @@ const props = withDefaults(
|
||||
actions?: PopupNotificationButton[]
|
||||
}>(),
|
||||
{
|
||||
actionLoading: null,
|
||||
actorName: null,
|
||||
actorAvatarUrl: null,
|
||||
entityName: '',
|
||||
@@ -239,6 +246,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)
|
||||
@@ -250,7 +260,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,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