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

* feat: implement instance share page + search_users backend call

* feat: invite players modal

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

* feat: base shared instances implementation

* fix: admon style

* feat: impl instance admonitions like server panel

* fix: impl get + del usage

* feat: support modpack links

* feat: invite notif accepting

* fix: lint + fmt

* feat: impl install to play

* feat: impl usage of UpdateToPlayModal

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

* fix: send instance name

* feat: align with backend

* feat: shared instances qa

* feat: wrong account protection

* feat: qa

* fix: smartly apply updates

* fix: install bug

* fix: 401/404 differentiation

* fix: fmt+prepr

* feat: qa

* feat: qa

* fix: signing out messes up revoke/deleted checks

* feat: qa

* fix: fmt + lint

* feat: lock content if part of shared instance

* fix: lint

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

* fix: wrong cmd

* feat: invite page

* fix: server-manager DTO mismatch

* fix: drop anonymous invite link acceptance

* refactor: structured shared-instance unavailable errors

* refactor: centralise error presentations

* refactor: dedupe shared instance diff detection

* fix: logging in reqwests

* refactor: move app.vue shared instances into handler

* refactor: break up Share.vue

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

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

* refactor: centralized managed content

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

* refactor: dedupe sql for instance metadata enrichmnt

* refactor: friends composable + dedupe friends logic across usages

* chore: reduced unused code

* fix: align with backend

* fix: lint

* fix: file sha changes

* fix: invite links not working due to icon signed

* feat: qa

* feat: reporting frontend dummy

* fix: try use header

* remove: file hash field

* fix: pin box

* feat: malware warning for shared instances

* fix: cache rule

* feat: config files syncing

* feat: disable config sharing

* fix: header

* fix: use mark ready

* fix: dont cause push update for configs

* fix: lint

* feat: sharing page in settings

* feat: move config + change flow

* fix: qa

* fix: lint prepr

* feat: proxy file upload thru shared instances backend

* fix: use collapisible

* fix: push config

* fix: config

* feat: swap out sign in modal for new one

* fix: report flow

* fix: exclude configs.zip from external warnings

* fix: nuxi init

* fix: config bundle downloading

* fix: error notif

* fix: polling

* fix: qa

* fix: lint + prepr

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

* fix: report copy

* fix: lint

* fix: lint

* fix: modrinth ids being undefined

* feat: instance quarantining

* fix: prepr + fmt

* fix: quarantined -> locked terminology

* fix: missing endpoint impls + fmt

* fix: missing api in build.rs

* fix: share tab jittery

* fix: fmt

*PT bug

* fix: invites count as users even if pending

* fix: prepr

* fix: invite page owner in users list

* fix: lint

* fix: qa

* fix: lint

* fix: members stale not clearing

* fix: invite use joined_at field

* fix: lint

* fix: qa

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-24 13:06:38 +00:00
committed by GitHub
co-authored by sychic
parent 2e0d797bb0
commit e58af98f21
269 changed files with 18202 additions and 2579 deletions
+48 -15
View File
@@ -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'
+1
View File
@@ -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>
+14 -4
View File
@@ -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>
@@ -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>
@@ -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,
}
}
@@ -99,7 +99,7 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
<template v-if="ctx.installContext?.value && ctx.variant !== 'web'">
<div
ref="stickyInstallHeaderRef"
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 px-3 py-4 border-surface-5"
:class="[isInstallHeaderStuck ? 'border-t' : '']"
>
<BrowseInstallHeader />
@@ -27,6 +27,7 @@ interface Props {
hideDelete?: boolean
hideHeader?: boolean
flat?: boolean
showItemActions?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -38,6 +39,7 @@ const props = withDefaults(defineProps<Props>(), {
hideDelete: false,
hideHeader: false,
flat: false,
showItemActions: false,
})
const stickyHeaderRef = ref<HTMLElement | null>(null)
@@ -67,7 +69,8 @@ const hasEnabledListener = computed(
const hasAnyActions = computed(() => {
// Check if there are listeners for actions
const hasListeners =
(hasDeleteListener.value && !props.hideDelete) ||
(hasDeleteListener.value &&
props.items.some((item) => !props.hideDelete && !item.hideDelete)) ||
hasUpdateListener.value ||
hasSwitchVersionListener.value ||
hasEnabledListener.value
@@ -80,7 +83,7 @@ const hasAnyActions = computed(() => {
item.enabled !== undefined,
)
return hasListeners || hasItemActions
return hasListeners || hasItemActions || props.showItemActions
})
// Virtualization
@@ -273,7 +276,7 @@ function handleSort(column: ContentCardTableSortColumn) {
:toggle-disabled="item.toggleDisabled"
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
:show-checkbox="showSelection"
:hide-delete="hideDelete"
:hide-delete="hideDelete || item.hideDelete"
:hide-actions="!hasAnyActions"
:selected="isItemSelected(item.id)"
:class="[
@@ -296,6 +299,9 @@ function handleSort(column: ContentCardTableSortColumn) {
hasSwitchVersionListener ? { switchVersion: () => emit('switchVersion', item.id) } : {}
"
>
<template #title-badges>
<slot name="itemTitleBadges" :item="item" :index="visibleRange.start + idx" />
</template>
<template #additionalButtonsLeft>
<slot name="itemButtonsLeft" :item="item" :index="visibleRange.start + idx" />
</template>
@@ -326,13 +332,14 @@ function handleSort(column: ContentCardTableSortColumn) {
:has-update="item.hasUpdate"
:is-client-only="item.isClientOnly"
:client-warning="item.clientWarning"
:hide-switch-version="item.hideSwitchVersion"
:overflow-options="item.overflowOptions"
:disabled="item.disabled"
:disabled-tooltip="item.disabledTooltip"
:toggle-disabled="item.toggleDisabled"
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
:show-checkbox="showSelection"
:hide-delete="hideDelete"
:hide-delete="hideDelete || item.hideDelete"
:hide-actions="!hasAnyActions"
:selected="isItemSelected(item.id)"
:class="[
@@ -350,6 +357,9 @@ function handleSort(column: ContentCardTableSortColumn) {
@update="emit('update', item.id)"
@switch-version="emit('switchVersion', item.id)"
>
<template #title-badges>
<slot name="itemTitleBadges" :item="item" :index="index" />
</template>
<template #additionalButtonsLeft>
<slot name="itemButtonsLeft" :item="item" :index="index" />
</template>
@@ -7,9 +7,7 @@ import {
MoreVerticalIcon,
Settings2Icon,
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { Tooltip } from 'floating-vue'
import { computed, getCurrentInstance, onMounted, onUnmounted, ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
@@ -23,7 +21,7 @@ import OverflowMenu, {
import TagTagItem from '#ui/components/base/TagTagItem.vue'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import { useRelativeTime } from '#ui/composables/how-ago'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import type {
@@ -35,21 +33,6 @@ import type {
const { formatMessage } = useVIntl()
const messages = defineMessages({
contentHintTitle: {
id: 'content.modpack-card.content-hint-title',
defaultMessage: 'Modpack content moved',
},
contentHintDescription: {
id: 'content.modpack-card.content-hint-description',
defaultMessage: "Your modpack's content can now be found here!",
},
dismissHint: {
id: 'content.modpack-card.dismiss-hint',
defaultMessage: "Don't show again",
},
})
interface Props {
project: ContentModpackCardProject
projectLink?: string | RouteLocationRaw
@@ -61,7 +44,6 @@ interface Props {
overflowOptions?: OverflowMenuOption[]
hasUpdate?: boolean
disabledText?: string
showContentHint?: boolean
}
withDefaults(defineProps<Props>(), {
@@ -74,14 +56,12 @@ withDefaults(defineProps<Props>(), {
overflowOptions: undefined,
hasUpdate: false,
disabledText: undefined,
showContentHint: false,
})
const emit = defineEmits<{
update: []
content: []
settings: []
'dismiss-content-hint': []
}>()
const instance = getCurrentInstance()
@@ -143,7 +123,7 @@ onUnmounted(() => {
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex min-w-0 flex-1 items-center gap-4">
<AutoLink :to="projectLink" class="shrink-0">
<Avatar :src="project.icon_url" :alt="project.title" size="5rem" no-shadow raised />
<Avatar :src="project.icon_url" :alt="project.title" size="5rem" no-shadow />
</AutoLink>
<div class="flex min-w-0 flex-col gap-1.5">
<div class="flex min-w-0 flex-col">
@@ -230,60 +210,15 @@ onUnmounted(() => {
</button>
</ButtonStyled>
<Tooltip
v-if="hasContentListener"
theme="dismissable-prompt"
class="inline-flex"
:triggers="[]"
:shown="showContentHint && isExpanded"
:auto-hide="false"
placement="bottom-end"
>
<ButtonStyled>
<button
class="!shadow-none"
@click="
() => {
emit('content')
emit('dismiss-content-hint')
}
"
>
<BoxesIcon />
{{ formatMessage(commonMessages.contentLabel) }}
</button>
</ButtonStyled>
<template #popper>
<div class="grid grid-cols-[min-content] gap-1">
<div class="flex min-w-48 items-center justify-between gap-8">
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
{{ formatMessage(messages.contentHintTitle) }}
</h3>
<ButtonStyled size="small" circular>
<button
v-tooltip="formatMessage(messages.dismissHint)"
@click="emit('dismiss-content-hint')"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ formatMessage(messages.contentHintDescription) }}
</p>
</div>
</template>
</Tooltip>
<ButtonStyled v-if="hasContentListener">
<button class="!shadow-none" @click="emit('content')">
<BoxesIcon />
{{ formatMessage(commonMessages.contentLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="hasSettingsListener" type="outlined" circular>
<button
@click="
() => {
emit('settings')
emit('dismiss-content-hint')
}
"
>
<button @click="emit('settings')">
<Settings2Icon />
</button>
</ButtonStyled>
@@ -300,53 +235,19 @@ onUnmounted(() => {
</button>
</ButtonStyled>
</div>
<Tooltip
v-if="collapsedOptions.length"
theme="dismissable-prompt"
class="inline-flex"
:triggers="[]"
:shown="showContentHint && !isExpanded"
:auto-hide="false"
placement="bottom-end"
>
<ButtonStyled circular type="outlined"
><TeleportOverflowMenu
:options="collapsedOptions"
class="flex @[700px]:hidden"
@open="emit('dismiss-content-hint')"
>
<MoreVerticalIcon class="size-5" />
<template #content>
<BoxesIcon class="size-5" />
{{ formatMessage(commonMessages.contentLabel) }}
</template>
<template #settings>
<Settings2Icon class="size-5" />
{{ formatMessage(commonMessages.settingsLabel) }}
</template>
</TeleportOverflowMenu></ButtonStyled
>
<template #popper>
<div class="grid grid-cols-[min-content] gap-1">
<div class="flex min-w-48 items-center justify-between gap-8">
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
{{ formatMessage(messages.contentHintTitle) }}
</h3>
<ButtonStyled size="small" circular>
<button
v-tooltip="formatMessage(messages.dismissHint)"
@click="emit('dismiss-content-hint')"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ formatMessage(messages.contentHintDescription) }}
</p>
</div>
</template>
</Tooltip>
<ButtonStyled v-if="collapsedOptions.length" circular type="outlined">
<TeleportOverflowMenu :options="collapsedOptions" class="flex @[700px]:hidden">
<MoreVerticalIcon class="size-5" />
<template #content>
<BoxesIcon class="size-5" />
{{ formatMessage(commonMessages.contentLabel) }}
</template>
<template #settings>
<Settings2Icon class="size-5" />
{{ formatMessage(commonMessages.settingsLabel) }}
</template>
</TeleportOverflowMenu>
</ButtonStyled>
<ButtonStyled
v-if="overflowOptions?.length"
@@ -94,6 +94,7 @@ interface Props {
bulkItemCount?: number
ariaLabel?: string
getItemId?: (item: ContentItem) => string
toggleItems?: ContentItem[]
}
const props = withDefaults(defineProps<Props>(), {
@@ -109,6 +110,7 @@ const props = withDefaults(defineProps<Props>(), {
bulkItemCount: 0,
ariaLabel: undefined,
getItemId: undefined,
toggleItems: undefined,
})
const emit = defineEmits<{
@@ -130,8 +132,10 @@ function resolveItemId(item: ContentItem) {
return props.getItemId?.(item) ?? item.file_path ?? item.file_name ?? item.id
}
const allDisabled = computed(() => props.selectedItems.every((m) => !m.enabled))
const allEnabled = computed(() => props.selectedItems.every((m) => m.enabled))
const toggleActionItems = computed(() => props.toggleItems ?? props.selectedItems)
const hasToggleActions = computed(() => toggleActionItems.value.length > 0)
const allDisabled = computed(() => toggleActionItems.value.every((m) => !m.enabled))
const allEnabled = computed(() => toggleActionItems.value.every((m) => m.enabled))
const selectedCountText = computed(() => {
const count = props.isBulkOperating
@@ -231,7 +235,7 @@ const bulkProgressMessage = computed(() => {
<div v-if="!isBulkOperating" class="ml-auto flex items-center gap-0.5">
<slot name="actions" />
<ButtonStyled type="transparent">
<ButtonStyled v-if="hasToggleActions" type="transparent">
<button
v-tooltip="
isBusy && busyTooltip
@@ -247,7 +251,7 @@ const bulkProgressMessage = computed(() => {
<span class="bar-label">{{ formatMessage(commonMessages.enableButton) }}</span>
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<ButtonStyled v-if="hasToggleActions" type="transparent">
<button
v-tooltip="
isBusy && busyTooltip
@@ -11,8 +11,8 @@
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody) }}
<Admonition type="warning" :header="admonitionHeader">
{{ admonitionBody }}
</Admonition>
<InlineBackupCreator
ref="backupCreator"
@@ -36,12 +36,7 @@
@click="confirm"
>
<TrashIcon />
{{
formatMessage(messages.deleteButton, {
count: visibleCount,
itemType: formatContentTypeSentence(formatMessage, visibleItemType, visibleCount),
})
}}
{{ deleteButtonLabel }}
</button>
</ButtonStyled>
</div>
@@ -51,7 +46,7 @@
<script setup lang="ts">
import { TrashIcon, XIcon } from '@modrinth/assets'
import { nextTick, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
@@ -59,6 +54,7 @@ import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
import type { ContentActionWarning } from '../../types'
import InlineBackupCreator from './InlineBackupCreator.vue'
const { formatMessage } = useVIntl()
@@ -87,12 +83,14 @@ const props = withDefaults(
defineProps<{
count: number
itemType: string
warning?: ContentActionWarning | null
variant?: 'instance' | 'server'
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
}>(),
{
warning: null,
variant: 'instance',
backupTip: undefined,
actionDisabled: false,
@@ -109,11 +107,35 @@ const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
const visibleCount = ref(props.count)
const visibleItemType = ref(props.itemType)
const visibleWarning = ref(props.warning)
const formattedItemType = computed(() =>
formatContentTypeSentence(formatMessage, visibleItemType.value, visibleCount.value),
)
const admonitionHeader = computed(
() => visibleWarning.value?.admonitionHeader ?? formatMessage(messages.admonitionHeader),
)
const admonitionBody = computed(() => {
return visibleWarning.value?.admonitionBody ?? formatMessage(messages.admonitionBody)
})
const deleteButtonLabel = computed(() => {
return (
visibleWarning.value?.actionLabel ??
formatMessage(messages.deleteButton, {
count: visibleCount.value,
itemType: formattedItemType.value,
})
)
})
async function show() {
await nextTick()
visibleCount.value = props.count
visibleItemType.value = props.itemType
visibleWarning.value = props.warning
modal.value?.show()
}
@@ -0,0 +1,103 @@
<template>
<NewModal
ref="modal"
:header="
formatMessage(messages.header, {
itemType: formatContentTypeSentence(formatMessage, visibleItemType, visibleCount),
})
"
fade="warning"
max-width="500px"
>
<div class="flex flex-col gap-6">
<Admonition type="warning" :header="visibleWarning?.admonitionHeader ?? ''">
{{ visibleWarning?.admonitionBody }}
</Admonition>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="props.actionDisabled"
@click="confirm"
>
<SlashIcon />
{{ visibleWarning?.actionLabel }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { SlashIcon, XIcon } from '@modrinth/assets'
import { nextTick, ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
import type { ContentActionWarning } from '../../types'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'content.confirm-disable.header',
defaultMessage: 'Disable {itemType}',
},
})
const props = withDefaults(
defineProps<{
count: number
itemType: string
warning?: ContentActionWarning | null
actionDisabled?: boolean
actionDisabledTooltip?: string
}>(),
{
actionDisabled: false,
actionDisabledTooltip: undefined,
warning: null,
},
)
const emit = defineEmits<{
(e: 'disable'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const visibleCount = ref(props.count)
const visibleItemType = ref(props.itemType)
const visibleWarning = ref(props.warning)
async function show() {
await nextTick()
visibleCount.value = props.count
visibleItemType.value = props.itemType
visibleWarning.value = props.warning
modal.value?.show()
}
function confirm() {
if (props.actionDisabled) return
modal.value?.hide()
emit('disable')
}
defineExpose({
show,
})
</script>
@@ -19,6 +19,9 @@
})
}}
</Admonition>
<Admonition v-if="managedWarning" type="warning" :header="managedWarning.header">
{{ managedWarning.body }}
</Admonition>
<InlineBackupCreator
ref="backupCreator"
:backup-name="backupName"
@@ -65,6 +68,7 @@ import InlineBackupCreator from './InlineBackupCreator.vue'
const props = defineProps<{
downgrade?: boolean
managedWarning?: { header: string; body: string } | null
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
@@ -1,14 +1,14 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
:header="props.header ?? formatMessage(messages.header)"
fade="warning"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody) }}
<Admonition type="warning" :header="admonitionHeader">
{{ admonitionBody }}
</Admonition>
<InlineBackupCreator
ref="backupCreator"
@@ -32,7 +32,7 @@
@click="confirm"
>
<UnlinkIcon />
{{ formatMessage(props.server ? messages.header : messages.unlinkButton) }}
{{ formatMessage(actionMessage) }}
</button>
</ButtonStyled>
</div>
@@ -42,7 +42,7 @@
<script setup lang="ts">
import { UnlinkIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
@@ -54,6 +54,8 @@ import { commonMessages } from '#ui/utils/common-messages'
import InlineBackupCreator from './InlineBackupCreator.vue'
const props = defineProps<{
header?: string
warning?: { header: string; body: string } | null
server?: boolean
backupTip?: string
actionDisabled?: boolean
@@ -90,6 +92,15 @@ const emit = defineEmits<{
const modal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
const admonitionHeader = computed(() => {
if (props.warning) return props.warning.header
return formatMessage(messages.admonitionHeader)
})
const admonitionBody = computed(() => {
if (props.warning) return props.warning.body
return formatMessage(messages.admonitionBody)
})
const actionMessage = computed(() => (props.server ? messages.header : messages.unlinkButton))
function show() {
debug('show: called', {
@@ -2,6 +2,7 @@
import {
ArrowLeftRightIcon,
BoxIcon,
ExternalIcon,
FilterIcon,
GlassesIcon,
PaintbrushIcon,
@@ -13,6 +14,7 @@ import { computed, nextTick, ref, watchSyncEffect } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
import BulletDivider from '#ui/components/base/BulletDivider.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
@@ -33,6 +35,7 @@ import ContentSelectionBar from '../ContentSelectionBar.vue'
const { formatMessage } = useVIntl()
interface Props {
header?: string
modpackName?: string
modpackIconUrl?: string
enableToggle?: boolean
@@ -43,6 +46,7 @@ interface Props {
}
const props = withDefaults(defineProps<Props>(), {
header: undefined,
modpackName: undefined,
modpackIconUrl: undefined,
enableToggle: false,
@@ -84,6 +88,18 @@ const messages = defineMessages({
id: 'instances.modpack-content-modal.no-results',
defaultMessage: 'No projects match your search.',
},
externalContent: {
id: 'instances.modpack-content-modal.external-content',
defaultMessage: 'External',
},
externalContentDescription: {
id: 'instances.modpack-content-modal.external-content-description',
defaultMessage: 'This file is not published on Modrinth.',
},
openInSlicer: {
id: 'instances.modpack-content-modal.open-in-slicer',
defaultMessage: 'Open in Slicer',
},
})
export interface ModpackContentModalState {
@@ -104,18 +120,17 @@ const selectedFilters = ref<string[]>([])
const selectedIds = ref<string[]>([])
const selectedItems = computed(() =>
items.value.filter((item) => selectedIds.value.includes(item.file_name)),
items.value.filter((item) => selectedIds.value.includes(item.id)),
)
const allSelected = computed(() => {
if (filteredItems.value.length === 0) return false
return filteredItems.value.every((item) => selectedIds.value.includes(item.file_name))
return filteredItems.value.every((item) => selectedIds.value.includes(item.id))
})
const someSelected = computed(() => {
return (
filteredItems.value.some((item) => selectedIds.value.includes(item.file_name)) &&
!allSelected.value
filteredItems.value.some((item) => selectedIds.value.includes(item.id)) && !allSelected.value
)
})
@@ -123,7 +138,7 @@ function toggleSelectAll() {
if (allSelected.value || someSelected.value) {
selectedIds.value = []
} else {
selectedIds.value = filteredItems.value.map((item) => item.file_name)
selectedIds.value = filteredItems.value.map((item) => item.id)
}
}
@@ -160,7 +175,7 @@ const filterOptions = computed(() => {
options.push({ id: 'warnings', label: 'Warnings' })
}
if (items.value.some((item) => !item.enabled)) {
if (props.enableToggle && items.value.some((item) => item.enabled === false)) {
options.push({ id: 'disabled', label: 'Disabled' })
}
@@ -190,7 +205,7 @@ const attributeFilterIds = new Set(['disabled', 'warnings'])
const typeFilteredCount = computed(() => {
if (selectedFilters.value.length === 0) return items.value.length
const typeFilters = selectedFilters.value.filter((f) => !attributeFilterIds.has(f))
const hasDisabledFilter = selectedFilters.value.includes('disabled')
const hasDisabledFilter = props.enableToggle && selectedFilters.value.includes('disabled')
const hasWarningsFilter = selectedFilters.value.includes('warnings')
return items.value.filter((item) => {
if (typeFilters.length > 0 && !typeFilters.includes(normalizeProjectType(item.project_type)))
@@ -208,16 +223,12 @@ const filteredItems = computed(() => {
if (query) {
result = fuse.search(query).map(({ item }) => item)
} else {
result = [...items.value].sort((a, b) => {
const nameA = a.project?.title ?? a.file_name
const nameB = b.project?.title ?? b.file_name
return nameA.toLowerCase().localeCompare(nameB.toLowerCase())
})
result = sortContentItems(items.value)
}
if (selectedFilters.value.length > 0) {
const typeFilters = selectedFilters.value.filter((f) => !attributeFilterIds.has(f))
const hasDisabledFilter = selectedFilters.value.includes('disabled')
const hasDisabledFilter = props.enableToggle && selectedFilters.value.includes('disabled')
const hasWarningsFilter = selectedFilters.value.includes('warnings')
result = result.filter((item) => {
if (typeFilters.length > 0 && !typeFilters.includes(normalizeProjectType(item.project_type)))
@@ -228,21 +239,21 @@ const filteredItems = computed(() => {
})
}
return result
return sortContentItems(result, !query)
})
const tableItems = computed<ContentCardTableItem[]>(() =>
filteredItems.value.map((item) => ({
id: item.file_name,
id: item.id,
project: item.project ?? {
id: item.file_name,
id: item.id,
slug: null,
title: item.file_name,
icon_url: null,
},
projectLink: item.project?.id ? `/project/${item.project.id}` : undefined,
projectLink: !item.external && item.project?.id ? `/project/${item.project.id}` : undefined,
version: item.version ?? {
id: item.file_name,
id: item.id,
version_number: 'Unknown',
file_name: item.file_name,
},
@@ -278,6 +289,20 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
],
})),
)
const externalItemIds = computed(
() => new Set(items.value.filter((item) => item.external).map((item) => item.id)),
)
const externalSlicerUrls = computed(() => {
const urls: Record<string, string> = {}
for (const item of items.value) {
if (item.external && item.external_url) {
urls[item.id] = `https://slicer.run/?url=${encodeURIComponent(item.external_url)}`
}
}
return urls
})
const hasExternalSlicerUrls = computed(() => Object.keys(externalSlicerUrls.value).length > 0)
const showTableActions = computed(() => props.enableToggle || hasExternalSlicerUrls.value)
function getTypeIcon(type: string) {
switch (type) {
@@ -293,9 +318,23 @@ function getTypeIcon(type: string) {
}
}
function handleEnabledChange(fileName: string, value: boolean) {
function sortContentItems(contentItems: ContentItem[], sortByName = true) {
return [...contentItems].sort((a, b) => {
const externalDiff = Number(b.external === true) - Number(a.external === true)
if (externalDiff !== 0) return externalDiff
if (!sortByName) return 0
return itemDisplayName(a).toLowerCase().localeCompare(itemDisplayName(b).toLowerCase())
})
}
function itemDisplayName(item: ContentItem) {
return item.project?.title ?? item.file_name
}
function handleEnabledChange(id: string, value: boolean) {
if (props.actionDisabled) return
const item = items.value.find((i) => i.file_name === fileName)
const item = items.value.find((item) => item.id === id)
if (!item) return
emit('update:enabled', item, value)
}
@@ -388,9 +427,10 @@ function updateItem(fileName: string, updates: Partial<ContentItem> & { disabled
}
function setItems(contentItems: ContentItem[]) {
const contentIds = new Set(contentItems.map((item) => item.id))
const contentFileNames = new Set(contentItems.map((item) => item.file_name))
items.value = contentItems.map((item) => ({ ...item }))
selectedIds.value = selectedIds.value.filter((id) => contentFileNames.has(id))
selectedIds.value = selectedIds.value.filter((id) => contentIds.has(id))
disabledIds.value = new Set([...disabledIds.value].filter((id) => contentFileNames.has(id)))
loading.value = false
}
@@ -414,7 +454,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
:tint-by="props.modpackName"
/>
<span class="text-lg font-extrabold text-contrast">
{{ formatMessage(messages.header) }}
{{ props.header ?? formatMessage(messages.header) }}
</span>
</template>
<div class="flex flex-col h-[min(600px,calc(95vh-10rem))]">
@@ -498,7 +538,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
<div
class="flex min-w-0 items-center gap-4"
:class="
props.enableToggle
showTableActions
? 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
: 'flex-1'
"
@@ -517,13 +557,13 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
</div>
<div
class="hidden @[800px]:flex"
:class="props.enableToggle ? 'flex-1 min-w-0' : 'flex-1'"
:class="showTableActions ? 'flex-1 min-w-0' : 'flex-1'"
>
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.versionLabel)
}}</span>
</div>
<div v-if="props.enableToggle" class="min-w-[160px] shrink-0 text-right">
<div v-if="showTableActions" class="min-w-[160px] shrink-0 text-right">
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.actionsLabel)
}}</span>
@@ -534,6 +574,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
v-model:selected-ids="selectedIds"
:items="tableItems"
:show-selection="props.enableToggle"
:show-item-actions="hasExternalSlicerUrls"
hide-delete
hide-header
flat
@@ -542,7 +583,30 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
? { 'update:enabled': (id: string, val: boolean) => handleEnabledChange(id, val) }
: {}
"
/>
>
<template #itemTitleBadges="{ item }">
<span
v-if="externalItemIds.has(item.id)"
v-tooltip="formatMessage(messages.externalContentDescription)"
class="inline-flex shrink-0 items-center rounded-full border border-solid border-orange bg-orange-highlight px-2 py-0.5 text-xs font-semibold leading-4 text-orange"
>
{{ formatMessage(messages.externalContent) }}
</span>
</template>
<template #itemButtonsRight="{ item }">
<ButtonStyled v-if="externalSlicerUrls[item.id]" circular type="transparent">
<a
v-tooltip="formatMessage(messages.openInSlicer)"
:aria-label="formatMessage(messages.openInSlicer)"
:href="externalSlicerUrls[item.id]"
target="_blank"
rel="noopener noreferrer"
>
<ExternalIcon class="size-4" />
</a>
</ButtonStyled>
</template>
</ContentCardTable>
</div>
</div>
</div>
@@ -4,6 +4,7 @@ export { default as ContentCardTable } from './components/ContentCardTable.vue'
export { default as ContentModpackCard } from './components/ContentModpackCard.vue'
export { default as ConfirmBulkUpdateModal } from './components/modals/ConfirmBulkUpdateModal.vue'
export { default as ConfirmDeletionModal } from './components/modals/ConfirmDeletionModal.vue'
export { default as ConfirmDisableModal } from './components/modals/ConfirmDisableModal.vue'
export { default as ConfirmModpackUpdateModal } from './components/modals/ConfirmModpackUpdateModal.vue'
export { default as ConfirmReinstallModal } from './components/modals/ConfirmReinstallModal.vue'
export { default as ConfirmRepairModal } from './components/modals/ConfirmRepairModal.vue'
@@ -16,6 +17,7 @@ export type {
ContentInstallProjectOwner,
} from './components/modals/ContentInstallModal.vue'
export { default as ContentInstallModal } from './components/modals/ContentInstallModal.vue'
export { default as InlineBackupCreator } from './components/modals/InlineBackupCreator.vue'
export type { ModpackContentModalState } from './components/modals/ModpackContentModal.vue'
export { default as ModpackContentModal } from './components/modals/ModpackContentModal.vue'
export { default as ContentCardLayout } from './layout.vue'
@@ -33,6 +33,7 @@ import ContentModpackCard from './components/ContentModpackCard.vue'
import ContentSelectionBar from './components/ContentSelectionBar.vue'
import ConfirmBulkUpdateModal from './components/modals/ConfirmBulkUpdateModal.vue'
import ConfirmDeletionModal from './components/modals/ConfirmDeletionModal.vue'
import ConfirmDisableModal from './components/modals/ConfirmDisableModal.vue'
import ConfirmUnlinkModal from './components/modals/ConfirmUnlinkModal.vue'
import ContentDependencyWarningModal from './components/modals/ContentDependencyWarningModal.vue'
import {
@@ -45,7 +46,12 @@ import {
useContentSelection,
} from './composables'
import { injectContentManager } from './providers/content-manager'
import type { BulkOperationStatus, ContentCardTableItem, ContentItem } from './types'
import type {
BulkOperationStatus,
ContentActionWarning,
ContentCardTableItem,
ContentItem,
} from './types'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('ContentPageLayout')
@@ -280,13 +286,14 @@ const tableItems = computed<ContentCardTableItem[]>(() => {
toggleDisabled: ctx.isBusy.value,
toggleDisabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
installing: item.installing === true,
hasUpdate: item.has_update,
hasUpdate: base.hasUpdate ?? item.has_update,
isClientOnly:
isClientOnlyEnvironment(item.environment) ||
!!item.pack_client_retained ||
!!item.pack_client_depends,
clientWarning: getClientWarningType(item),
hideSwitchVersion: !base.versionLink,
hideDelete: base.hideDelete,
hideSwitchVersion: base.hideSwitchVersion ?? !base.versionLink,
overflowOptions: ctx.getOverflowOptions?.(item),
}
})
@@ -322,8 +329,12 @@ const hasOutdatedProjects = computed(() => {
// Deletion
const pendingDeletionItems = ref<ContentItem[]>([])
const pendingDeletionWarning = ref<ContentActionWarning | null>(null)
const confirmDeletionModal = ref<InstanceType<typeof ConfirmDeletionModal>>()
const confirmDisableModal = ref<InstanceType<typeof ConfirmDisableModal>>()
const contentDependencyWarningModal = ref<InstanceType<typeof ContentDependencyWarningModal>>()
const pendingDisableItems = ref<ContentItem[]>([])
const pendingDisableWarning = ref<ContentActionWarning | null>(null)
const pendingDependencyWarningItems = ref<ContentCardTableItem[]>([])
const pendingDependencyWarningDependents = ref<
Array<{
@@ -340,17 +351,30 @@ function mapToDisplayItem(item: ContentItem) {
}
}
function canDeleteItem(item: ContentItem) {
return ctx.canDeleteItem?.(item) ?? true
}
function canToggleItem(item: ContentItem) {
return ctx.canToggleItem?.(item) ?? true
}
const deletableSelectedItems = computed(() => selectedItems.value.filter(canDeleteItem))
const toggleableSelectedItems = computed(() => selectedItems.value.filter(canToggleItem))
async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
if (items.length === 0) return
pendingDeletionItems.value = items
const deletableItems = items.filter(canDeleteItem)
if (deletableItems.length === 0) return
pendingDeletionItems.value = deletableItems
pendingDeletionWarning.value = ctx.getDeleteWarning?.(deletableItems) ?? null
pendingDependencyWarningItems.value = []
pendingDependencyWarningDependents.value = []
pendingDependencyWarningDisableTargets.value = []
const deletingIds = new Set(items.map(getItemId))
const deletingIds = new Set(deletableItems.map(getItemId))
const warning = ctx.getDeleteDependencyWarning
? await Promise.resolve()
.then(() => ctx.getDeleteDependencyWarning!(items))
.then(() => ctx.getDeleteDependencyWarning!(deletableItems))
.catch(() => null)
: null
if (warning) {
@@ -366,7 +390,7 @@ async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
const relevantDependencyIds = new Set(
remainingDependents.flatMap((dependent) => dependent.dependencies.map(getItemId)),
)
const warningItems = items.filter((item) => relevantDependencyIds.has(getItemId(item)))
const warningItems = deletableItems.filter((item) => relevantDependencyIds.has(getItemId(item)))
if (warningItems.length === 0) {
showDeletionConfirmation(event)
return
@@ -390,7 +414,11 @@ async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
}
async function showDeletionConfirmation(event?: MouseEvent) {
if ((event?.shiftKey || skipNonEssentialWarnings.value) && !ctx.isBusy.value) {
if (
!pendingDeletionWarning.value &&
(event?.shiftKey || skipNonEssentialWarnings.value) &&
!ctx.isBusy.value
) {
confirmDelete()
} else {
await nextTick()
@@ -419,6 +447,11 @@ async function confirmDependencyWarningDelete(disableDependentsAfterDeleting: bo
pendingDependencyWarningItems.value = []
pendingDependencyWarningDependents.value = []
if (pendingDeletionWarning.value) {
confirmDeletionModal.value?.show()
return
}
await confirmDelete()
}
@@ -427,6 +460,10 @@ async function disablePendingDependencyWarningDependents() {
pendingDependencyWarningDisableTargets.value = []
if (items.length === 0) return
await promptDisableItems(items)
}
async function disableItemsWithoutWarning(items: ContentItem[]) {
if (ctx.bulkDisableItems) {
await ctx.bulkDisableItems(items)
return
@@ -447,6 +484,7 @@ async function confirmDelete() {
if (ctx.isBusy.value) return
const itemsToDelete = [...pendingDeletionItems.value]
pendingDeletionItems.value = []
pendingDeletionWarning.value = null
if (itemsToDelete.length === 0) return
if (ctx.bulkDeleteItems && itemsToDelete.length > 1) {
@@ -495,10 +533,75 @@ async function confirmDelete() {
await disablePendingDependencyWarningDependents()
}
async function promptDisableItems(items: ContentItem[]) {
if (items.length === 0) return
pendingDisableItems.value = items
const warning = ctx.getDisableWarning?.(items) ?? null
if (warning) {
pendingDisableWarning.value = warning
confirmDisableModal.value?.show()
return
}
await confirmDisable()
}
async function confirmDisable() {
if (ctx.isBusy.value) return
const itemsToDisable = [...pendingDisableItems.value]
pendingDisableItems.value = []
pendingDisableWarning.value = null
if (itemsToDisable.length === 0) return
if (ctx.bulkDisableItems && itemsToDisable.length > 1) {
isBulkOperating.value = true
bulkOperation.value = 'disable'
bulkProgress.value = 0
bulkTotal.value = itemsToDisable.length
bulkWaiting.value = true
try {
await disableItemsWithoutWarning(itemsToDisable)
} finally {
clearSelection()
isBulkOperating.value = false
bulkOperation.value = null
bulkProgress.value = 0
bulkTotal.value = 0
bulkWaiting.value = false
}
return
}
if (itemsToDisable.length === 1) {
const item = itemsToDisable[0]
const id = getItemId(item)
markChanging(id)
try {
if (ctx.bulkDisableItems) {
await ctx.bulkDisableItems(itemsToDisable)
} else {
await ctx.toggleEnabled(item)
}
} finally {
unmarkChanging(id)
}
return
}
await runBulk('disable', itemsToDisable, (item) => disableItemsWithoutWarning([item]), {
onComplete: clearSelection,
})
}
async function handleToggleEnabledById(id: string, _value: boolean) {
if (ctx.isBusy.value) return
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (!item) return
if (!canToggleItem(item)) return
if (!_value) {
await promptDisableItems([item])
return
}
markChanging(id)
try {
await ctx.toggleEnabled(item)
@@ -509,7 +612,7 @@ async function handleToggleEnabledById(id: string, _value: boolean) {
async function bulkEnable() {
if (ctx.isBusy.value) return
const items = selectedItems.value.filter((item) => !item.enabled)
const items = toggleableSelectedItems.value.filter((item) => !item.enabled)
if (items.length === 0) return
if (ctx.bulkEnableItems) {
isBulkOperating.value = true
@@ -534,27 +637,9 @@ async function bulkEnable() {
async function bulkDisable() {
if (ctx.isBusy.value) return
const items = selectedItems.value.filter((item) => item.enabled)
const items = toggleableSelectedItems.value.filter((item) => item.enabled)
if (items.length === 0) return
if (ctx.bulkDisableItems) {
isBulkOperating.value = true
bulkOperation.value = 'disable'
bulkProgress.value = 0
bulkTotal.value = items.length
bulkWaiting.value = true
try {
await ctx.bulkDisableItems(items)
} finally {
clearSelection()
isBulkOperating.value = false
bulkOperation.value = null
bulkProgress.value = 0
bulkTotal.value = 0
bulkWaiting.value = false
}
return
}
await runBulk('disable', items, (item) => ctx.toggleEnabled(item), { onComplete: clearSelection })
await promptDisableItems(items)
}
function handleUpdateById(id: string) {
@@ -696,16 +781,12 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:has-update="ctx.modpack.value.hasUpdate"
:disabled="ctx.modpack.value.disabled"
:disabled-text="ctx.modpack.value.disabledText"
:show-content-hint="
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
"
v-on="{
...(ctx.updateModpack ? { update: () => ctx.updateModpack?.() } : {}),
...(ctx.viewModpackContent ? { content: () => ctx.viewModpackContent?.() } : {}),
...(ctx.unlinkModpack ? { unlink: () => confirmUnlinkModal?.show() } : {}),
...(ctx.openSettings ? { settings: () => ctx.openSettings?.() } : {}),
}"
@dismiss-content-hint="ctx.dismissContentHint?.()"
/>
<template v-if="ctx.items.value.length > 0">
@@ -737,20 +818,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
/>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 flex items-center gap-2"
@click="ctx.browse"
>
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseContent) }}</span>
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button
v-tooltip="
@@ -765,6 +832,20 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
{{ formatMessage(messages.uploadFiles) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 flex items-center gap-2"
@click="ctx.browse"
>
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseContent) }}</span>
</button>
</ButtonStyled>
</div>
</div>
@@ -947,6 +1028,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:bulk-item-count="bulkItemCount"
:aria-label="formatMessage(commonMessages.selectionActionsLabel)"
:get-item-id="getItemId"
:toggle-items="toggleableSelectedItems"
@clear="clearSelection"
@enable="bulkEnable"
@disable="bulkDisable"
@@ -1013,9 +1095,10 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
</template>
<template #actions-end>
<div class="mx-1 h-6 w-px bg-surface-5" />
<div v-if="deletableSelectedItems.length > 0" class="mx-1 h-6 w-px bg-surface-5" />
<ButtonStyled
v-if="deletableSelectedItems.length > 0"
type="transparent"
color="red"
color-fill="text"
@@ -1036,12 +1119,22 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
ref="confirmDeletionModal"
:count="pendingDeletionItems.length"
:item-type="ctx.contentTypeLabel.value"
:warning="pendingDeletionWarning"
:variant="ctx.deletionContext ?? 'instance'"
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@delete="confirmDelete"
/>
<ConfirmDisableModal
ref="confirmDisableModal"
:count="pendingDisableItems.length"
:item-type="ctx.contentTypeLabel.value"
:warning="pendingDisableWarning"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@disable="confirmDisable"
/>
<ContentDependencyWarningModal
ref="contentDependencyWarningModal"
:items="pendingDependencyWarningItems"
@@ -6,6 +6,7 @@ import { createContext } from '#ui/providers/create-context'
import type {
BulkOperationStatus,
ContentActionWarning,
ContentCardTableItem,
ContentItem,
ContentModpackCardCategory,
@@ -65,6 +66,10 @@ export interface ContentManagerContext {
bulkDeleteItems?: (items: ContentItem[]) => Promise<void>
bulkEnableItems?: (items: ContentItem[]) => Promise<void>
bulkDisableItems?: (items: ContentItem[]) => Promise<void>
canDeleteItem?: (item: ContentItem) => boolean
canToggleItem?: (item: ContentItem) => boolean
getDeleteWarning?: (items: ContentItem[]) => ContentActionWarning | null
getDisableWarning?: (items: ContentItem[]) => ContentActionWarning | null
getDeleteDependencyWarning?: (
items: ContentItem[],
) => ContentDependencyWarning | null | Promise<ContentDependencyWarning | null>
@@ -100,10 +105,6 @@ export interface ContentManagerContext {
// Deletion context (controls modal variant)
deletionContext?: 'instance' | 'server'
// One-time content hint (optional — shows tooltip on modpack content button)
showContentHint?: Ref<boolean>
dismissContentHint?: () => void
// Table item mapping (link generation differs per platform)
mapToTableItem: (item: ContentItem) => ContentCardTableItem
@@ -23,6 +23,20 @@ export interface ContentOwner {
export type ClientWarningType = 'retained' | 'depends' | 'environment'
export type ContentSourceKind =
| 'local'
| 'modrinth_modpack'
| 'server_project'
| 'modrinth_hosting'
| 'imported_modpack'
| 'shared_instance'
export interface ContentActionWarning {
admonitionHeader: string
admonitionBody: string
actionLabel: string
}
export interface ContentCardTableItem {
id: string
project: ContentCardProject
@@ -39,6 +53,7 @@ export interface ContentCardTableItem {
hasUpdate?: boolean
isClientOnly?: boolean
clientWarning?: ClientWarningType | null
hideDelete?: boolean
hideSwitchVersion?: boolean
overflowOptions?: OverflowMenuOption[]
}
@@ -70,6 +85,9 @@ export interface ContentItem extends Omit<
pack_client_retained?: boolean
pack_client_depends?: boolean
installing?: boolean
source_kind?: ContentSourceKind | null
external?: boolean
external_url?: string
}
export type ContentModpackCardProject = Pick<
@@ -4,19 +4,17 @@
:header="header"
:closable="true"
:disable-close="disableClose"
:on-hide="handleHide"
max-width="544px"
width="544px"
no-padding
scrollable
>
<div class="flex flex-col gap-4" :class="hasExternalDiffs ? 'px-6 py-4' : 'p-4'">
<template v-if="hasExternalDiffs">
<p v-if="description" class="m-0 text-primary">{{ description }}</p>
<Admonition
v-if="hasExternalDiffs"
type="warning"
:header="formatMessage(messages.unknownFilesWarning)"
>
{{ formatMessage(messages.unknownFilesDescription) }}
<Admonition type="warning" :header="formatMessage(messages.unknownFilesWarning)">
{{ externalWarningDescription || formatMessage(messages.unknownFilesDescription) }}
</Admonition>
</template>
<Admonition v-else :type="hasUnknownContent ? 'warning' : 'info'" :header="admonitionHeader">
@@ -41,17 +39,28 @@
<MinusIcon class="size-4" />
{{ formatMessage(messages.removedCount, { count: removedCount }) }}
</div>
<div v-if="removedDisabledCount" class="flex items-center gap-1">
<MinusIcon class="size-4" />
{{ formatMessage(messages.removedDisabledCount, { count: removedDisabledCount }) }}
</div>
</div>
</div>
</div>
<div
v-if="diffs.length"
class="flex max-h-[272px] flex-col overflow-y-auto border-0 border-y border-solid border-surface-5 bg-surface-2 px-3 py-4"
class="flex max-h-[272px] flex-col overflow-y-auto border-0 border-y border-solid border-surface-5 bg-surface-2 px-3 py-2"
>
<div v-if="!diffs.length" class="flex h-10 min-h-10 items-center gap-2 px-2">
<div class="flex w-4 shrink-0 items-center justify-center">
<MinusIcon class="size-4" />
</div>
<span class="text-sm font-medium text-contrast">
{{ formatMessage(messages.noContentChanges) }}
</span>
</div>
<div
v-for="(diff, index) in sortedDiffs"
:key="diff.projectName || diff.fileName || index"
:key="diff.projectName || diff.fileName || (isConfigurationDiff(diff) ? diff.type : index)"
class="flex h-10 min-h-10 items-center gap-2"
:class="showExternalWarning(diff) ? '-mx-3 px-5' : 'px-2'"
:style="
@@ -67,8 +76,14 @@
v-if="index > 0"
class="absolute left-1/2 top-0 h-3 w-px -translate-x-1/2 bg-surface-5"
/>
<PlusIcon v-if="diff.type === 'added'" class="relative z-[1] size-4" />
<MinusIcon v-else-if="diff.type === 'removed'" class="relative z-[1] size-4 text-red" />
<PlusIcon
v-if="diff.type === 'added' || diff.type === 'modpack_linked'"
class="relative z-[1] size-4"
/>
<MinusIcon
v-else-if="diff.type === 'removed' || diff.type === 'modpack_unlinked'"
class="relative z-[1] size-4 text-red"
/>
<RefreshCwIcon v-else class="relative z-[1] size-4" />
<div
v-if="index < sortedDiffs.length - 1"
@@ -84,7 +99,10 @@
{{ formatMessage(messages.unknownProject) }}
</span>
</template>
<span v-else class="truncate font-medium text-contrast">
<span
v-else-if="!isConfigurationDiff(diff) || diff.type === 'modpack_updated'"
class="truncate font-medium text-contrast"
>
{{ diff.projectName || (diff.fileName ? decodeURIComponent(diff.fileName) : '') }}
</span>
</div>
@@ -99,6 +117,10 @@
</div>
</div>
<div v-if="$slots['additional-content']" class="px-4 pt-4">
<slot name="additional-content" />
</div>
<div
v-if="showBackupCreator"
class="p-4 border-t border-solid border-surface-5 border-b-0 border-l-0 border-r-0"
@@ -114,38 +136,52 @@
<template #actions>
<div v-if="hasExternalDiffs" class="flex flex-col gap-6 p-2">
<p class="m-0 text-primary">{{ formatMessage(messages.reviewedFiles) }}</p>
<div class="flex justify-end gap-2">
<ButtonStyled type="transparent" color="orange">
<button :disabled="buttonsDisabled" @click="handleConfirm">
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleCancel">
<BanIcon />
{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
<div class="flex justify-between gap-2">
<div>
<ButtonStyled v-if="showReportButton" color="red" type="transparent">
<button @click="emit('report', $event)">
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
</div>
<div class="flex gap-2">
<ButtonStyled type="transparent" color="orange">
<button :disabled="buttonsDisabled || confirmDisabled" @click="handleConfirm">
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleCancel">
<BanIcon />
{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else class="flex justify-between gap-2 pt-4">
<div
v-else
class="flex justify-between gap-2"
:class="{ 'pt-4': !$slots['additional-content'] }"
>
<div>
<ButtonStyled v-if="showReportButton" color="red" type="transparent">
<button @click="emit('report')">
<button @click="emit('report', $event)">
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
</div>
<div class="flex gap-2">
<ButtonStyled>
<button @click="handleCancel">
<ButtonStyled type="outlined">
<button class="!border" @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="buttonsDisabled" @click="handleConfirm">
<button :disabled="buttonsDisabled || confirmDisabled" @click="handleConfirm">
<component :is="confirmIcon" v-if="confirmIcon" />
{{ confirmLabel || formatMessage(commonMessages.confirmButton) }}
</button>
@@ -187,16 +223,19 @@ const props = defineProps<{
confirmIcon?: Component
showReportButton?: boolean
showBackupCreator?: boolean
addedLabel?: string
removedLabel?: string
confirmDisabled?: boolean
disableClose?: boolean
showExternalWarnings?: boolean
externalWarningDescription?: string
versionDate?: string
}>()
const emit = defineEmits<{
confirm: []
cancel: []
report: []
report: [event?: MouseEvent]
}>()
const { formatMessage } = useVIntl()
@@ -204,38 +243,105 @@ const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
const closingFromAction = ref(false)
const removedCount = computed(() => props.diffs.filter((d) => d.type === 'removed').length)
const addedCount = computed(() => props.diffs.filter((d) => d.type === 'added').length)
const updatedCount = computed(() => props.diffs.filter((d) => d.type === 'updated').length)
const removedCount = computed(
() => props.diffs.filter((diff) => diff.type === 'removed' && !diff.disabled).length,
)
const removedDisabledCount = computed(
() => props.diffs.filter((diff) => diff.type === 'removed' && diff.disabled).length,
)
const addedCount = computed(() => props.diffs.filter((diff) => diff.type === 'added').length)
const updatedCount = computed(
() =>
props.diffs.filter((diff) => diff.type === 'updated' || diff.type === 'config_files_updated')
.length,
)
const hasExternalDiffs = computed(() => props.diffs.some(showExternalWarning))
type DependencyDiffType = Extract<ContentDiffItem['type'], 'added' | 'removed' | 'updated'>
type ConfigurationDiffType = Exclude<ContentDiffItem['type'], DependencyDiffType>
const configurationDiffTypes = new Set<ConfigurationDiffType>([
'modpack_linked',
'modpack_updated',
'modpack_unlinked',
'game_version_updated',
'loader_updated',
'config_files_updated',
])
function isDependencyDiff(
diff: ContentDiffItem,
): diff is ContentDiffItem & { type: DependencyDiffType } {
return diff.type === 'added' || diff.type === 'removed' || diff.type === 'updated'
}
function isConfigurationDiff(
diff: ContentDiffItem,
): diff is ContentDiffItem & { type: ConfigurationDiffType } {
return configurationDiffTypes.has(diff.type as ConfigurationDiffType)
}
function showExternalWarning(diff: ContentDiffItem) {
return Boolean(
props.showExternalWarnings &&
diff.external &&
isDependencyDiff(diff) &&
diff.type !== 'removed',
)
}
const sortedDiffs = computed(() =>
[...props.diffs].sort((a, b) => {
const aExternal = showExternalWarning(a)
const bExternal = showExternalWarning(b)
if (aExternal !== bExternal) return aExternal ? -1 : 1
const typeOrder = { added: 0, updated: 1, removed: 2 }
const typeOrder: Record<ContentDiffItem['type'], number> = {
modpack_linked: 0,
modpack_updated: 0,
modpack_unlinked: 0,
game_version_updated: 1,
loader_updated: 2,
config_files_updated: 3,
added: 4,
updated: 5,
removed: 6,
}
return typeOrder[a.type] - typeOrder[b.type]
}),
)
function getDiffTypeLabel(diff: ContentDiffItem) {
if (showExternalWarning(diff)) return formatMessage(externalDiffTypeMessages[diff.type])
if (showExternalWarning(diff) && isDependencyDiff(diff)) {
return formatMessage(externalDiffTypeMessages[diff.type])
}
if (diff.type === 'modpack_updated') return formatMessage(diffTypeMessages.updated)
if (isConfigurationDiff(diff)) return formatMessage(configurationDiffMessages[diff.type])
if (diff.type === 'removed' && diff.disabled) {
return formatMessage(diffTypeMessages.removedDisabled)
}
if (diff.type === 'added' && props.addedLabel) return props.addedLabel
if (diff.type === 'removed' && props.removedLabel) return props.removedLabel
return formatMessage(diffTypeMessages[diff.type])
}
function getVersionLabel(diff: ContentDiffItem) {
if (showExternalWarning(diff) && diff.fileName) return decodeURIComponent(diff.fileName)
if (diff.type === 'config_files_updated' && diff.fileCount !== undefined) {
return formatMessage(messages.fileCount, { count: diff.fileCount })
}
if (diff.type === 'modpack_updated') return diff.newVersionName
if (isConfigurationDiff(diff)) {
if (diff.currentVersionName && diff.newVersionName) {
return `${diff.currentVersionName}${diff.newVersionName}`
}
return diff.newVersionName ?? diff.currentVersionName
}
return diff.type === 'removed' ? diff.currentVersionName : diff.newVersionName
}
function showExternalWarning(diff: ContentDiffItem) {
return Boolean(props.showExternalWarnings && diff.external && diff.type !== 'removed')
}
function show(e?: MouseEvent) {
modal.value?.show(e)
}
@@ -245,13 +351,25 @@ function hide() {
}
function handleConfirm() {
closingFromAction.value = true
hide()
emit('confirm')
closingFromAction.value = false
}
function handleCancel() {
closingFromAction.value = true
hide()
emit('cancel')
closingFromAction.value = false
}
function handleHide() {
if (closingFromAction.value) {
closingFromAction.value = false
return
}
emit('cancel')
}
const messages = defineMessages({
@@ -259,6 +377,10 @@ const messages = defineMessages({
id: 'content.diff-modal.removed-count',
defaultMessage: '{count} removed',
},
removedDisabledCount: {
id: 'content.diff-modal.removed-disabled-count',
defaultMessage: '{count} removed (disabled)',
},
addedCount: {
id: 'content.diff-modal.added-count',
defaultMessage: '{count} added',
@@ -267,6 +389,14 @@ const messages = defineMessages({
id: 'content.diff-modal.updated-count',
defaultMessage: '{count} updated',
},
noContentChanges: {
id: 'content.diff-modal.no-content-changes',
defaultMessage: 'No content changes',
},
fileCount: {
id: 'content.diff-modal.file-count',
defaultMessage: '{count, plural, one {# file} other {# files}}',
},
unknownContentBody: {
id: 'content.diff-modal.unknown-content-body',
defaultMessage:
@@ -300,6 +430,33 @@ const messages = defineMessages({
},
})
const configurationDiffMessages = defineMessages({
modpack_linked: {
id: 'content.diff-modal.modpack-linked',
defaultMessage: 'Linked modpack',
},
modpack_updated: {
id: 'content.diff-modal.modpack-updated',
defaultMessage: 'Updated modpack',
},
modpack_unlinked: {
id: 'content.diff-modal.modpack-unlinked',
defaultMessage: 'Unlinked modpack',
},
game_version_updated: {
id: 'content.diff-modal.game-version-updated',
defaultMessage: 'Game version',
},
loader_updated: {
id: 'content.diff-modal.loader-updated',
defaultMessage: 'Loader',
},
config_files_updated: {
id: 'content.diff-modal.config-files-updated',
defaultMessage: 'Changed config files',
},
})
const diffTypeMessages = defineMessages({
added: {
id: 'content.diff-modal.diff-type.added',
@@ -309,6 +466,10 @@ const diffTypeMessages = defineMessages({
id: 'content.diff-modal.diff-type.removed',
defaultMessage: 'Disabled',
},
removedDisabled: {
id: 'content.diff-modal.diff-type.removed-disabled',
defaultMessage: 'Removed (disabled)',
},
updated: {
id: 'content.diff-modal.diff-type.updated',
defaultMessage: 'Updated',
@@ -16,6 +16,7 @@ import {
import { computed, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import Admonition from '#ui/components/base/Admonition.vue'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
@@ -204,6 +205,12 @@ const isLocalFile = computed(() => {
return typeof val === 'boolean' ? val : val.value
})
const isManagedModpack = computed(() => {
const val = ctx.isManagedModpack
if (val == null) return false
return typeof val === 'boolean' ? val : val.value
})
const isLinkedModpack = computed(() => showModpackVersionActions.value || isLocalFile.value)
function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event?: MouseEvent) {
@@ -226,10 +233,15 @@ function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event
? new Date(version.date_published) < new Date(currentVersion.date_published)
: false
const shouldShowWarning =
isManagedModpack.value ||
isUpdateDowngrade.value ||
versionChangesGameVersion(version, ctx.updaterModalProps.value.currentGameVersion)
if (event?.shiftKey || skipNonEssentialWarnings.value || !shouldShowWarning) {
if (
event?.shiftKey ||
(skipNonEssentialWarnings.value && !isManagedModpack.value) ||
!shouldShowWarning
) {
debug('handleModpackUpdateRequest: confirming without warning', {
isUpdateDowngrade: isUpdateDowngrade.value,
shouldShowWarning,
@@ -391,7 +403,7 @@ function handleShowUnlinkModal(event: MouseEvent) {
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
if (event.shiftKey || skipNonEssentialWarnings.value) {
if (event.shiftKey || (skipNonEssentialWarnings.value && !isManagedModpack.value)) {
handleUnlink()
return
}
@@ -593,7 +605,7 @@ const messages = defineMessages({
<!-- LINKED -->
<template v-if="ctx.isLinked.value">
<!-- Installed Modpack -->
<div class="flex flex-col gap-2.5">
<div v-if="ctx.modpack.value" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(commonMessages.installedModpackTitle) }}
</span>
@@ -667,10 +679,17 @@ const messages = defineMessages({
</button>
</ButtonStyled>
</div>
<Admonition
v-if="isManagedModpack && (showModpackVersionActions || isLocalFile)"
type="warning"
:header="ctx.managedModpackWarning?.value.admonitionHeader"
>
{{ ctx.managedModpackWarning?.value.changeVersionBody }}
</Admonition>
</div>
<!-- Unlink -->
<div class="flex flex-col gap-2.5">
<div v-if="!isManagedModpack" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{
formatMessage(messages.linkedInstanceTitle, {
@@ -1048,6 +1067,14 @@ const messages = defineMessages({
<ConfirmModpackUpdateModal
ref="modpackUpdateModal"
:downgrade="isUpdateDowngrade"
:managed-warning="
isManagedModpack && ctx.managedModpackWarning
? {
header: ctx.managedModpackWarning.value.admonitionHeader,
body: ctx.managedModpackWarning.value.changeVersionBody,
}
: null
"
:backup-tip="
[ctx.modpack.value?.title, pendingUpdateVersion?.version_number].filter(Boolean).join(' ')
"
@@ -1067,7 +1094,6 @@ const messages = defineMessages({
:backup-tip="ctx.modpack.value?.title"
@unlink="handleUnlink"
/>
<IncompatibleContentModal
v-if="form.incompatibleContentVariant.value"
ref="incompatibleContentModal"
@@ -63,6 +63,14 @@ export interface InstallationSettingsContext {
/** True when the linked modpack was uploaded as a local file rather than from Modrinth */
isLocalFile?: boolean | ComputedRef<boolean>
/** True when an external source controls the linked modpack. */
isManagedModpack?: boolean | ComputedRef<boolean>
managedModpackWarning?: ComputedRef<{
admonitionHeader: string
changeVersionBody: string
unlinkBody: string
}>
repairing?: Ref<boolean>
reinstalling?: Ref<boolean>
@@ -36,12 +36,23 @@ export interface LoaderVersionEntry {
}
export interface ContentDiffItem {
type: 'added' | 'removed' | 'updated'
external?: boolean
type:
| 'added'
| 'removed'
| 'updated'
| 'modpack_linked'
| 'modpack_updated'
| 'modpack_unlinked'
| 'game_version_updated'
| 'loader_updated'
| 'config_files_updated'
projectName?: string
fileName?: string
currentVersionName?: string
newVersionName?: string
fileCount?: number
disabled?: boolean
external?: boolean
}
export interface ContentDiffPreview {
-4
View File
@@ -266,9 +266,6 @@
"content.confirm-modpack-update.header": {
"defaultMessage": "{action, select, downgrade {خفض ٳصدار} other {تحديث}} حزمة التعديلات"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": ""
},
"form.label.address-line": {
"defaultMessage": "خط عنوان"
},
@@ -735,4 +732,3 @@
"defaultMessage": "ماينكرافت: إصدار جافا"
}
}
-10
View File
@@ -497,15 +497,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "svět"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Obsah modpacku nyní najdeš zde!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Obsah modpacku byl přesunut"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Znovu nezobrazovat"
},
"content.page-layout.additional-content": {
"defaultMessage": "Dodatečný obsah"
},
@@ -4569,4 +4560,3 @@
"defaultMessage": "Typ"
}
}
-10
View File
@@ -533,15 +533,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "Welt"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Die Inhalte deines Modpacks können jetzt hier gefunden werden!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Modpack-Inhalte verschoben"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Nicht erneut anzeigen"
},
"content.page-layout.additional-content": {
"defaultMessage": "Zusätzliche Inhalte"
},
@@ -5802,4 +5793,3 @@
"defaultMessage": "Typ"
}
}
-10
View File
@@ -533,15 +533,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "Welt"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Die Inhalte deines Modpacks findest du jetzt hier!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Modpack-Inhalte verschoben"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Nicht erneut anzeigen"
},
"content.page-layout.additional-content": {
"defaultMessage": "Zusätzliche Inhalte"
},
@@ -5802,4 +5793,3 @@
"defaultMessage": "Typ"
}
}
+117 -9
View File
@@ -377,6 +377,9 @@
"content.confirm-deletion.header": {
"defaultMessage": "Delete {itemType}"
},
"content.confirm-disable.header": {
"defaultMessage": "Disable {itemType}"
},
"content.confirm-modpack-update.admonition-body": {
"defaultMessage": "{action, select, downgrade {Downgrading} other {Updating}} may cause compatibility issues. Mods or content you added on top of the modpack will be kept, but may not be compatible with the new version."
},
@@ -446,12 +449,18 @@
"content.diff-modal.added-count": {
"defaultMessage": "{count} added"
},
"content.diff-modal.config-files-updated": {
"defaultMessage": "Changed config files"
},
"content.diff-modal.diff-type.added": {
"defaultMessage": "Added (dependency)"
},
"content.diff-modal.diff-type.removed": {
"defaultMessage": "Disabled"
},
"content.diff-modal.diff-type.removed-disabled": {
"defaultMessage": "Removed (disabled)"
},
"content.diff-modal.diff-type.updated": {
"defaultMessage": "Updated"
},
@@ -467,12 +476,36 @@
"content.diff-modal.external-diff-type.updated": {
"defaultMessage": "Updated"
},
"content.diff-modal.file-count": {
"defaultMessage": "{count, plural, one {# file} other {# files}}"
},
"content.diff-modal.game-version-updated": {
"defaultMessage": "Game version"
},
"content.diff-modal.install-anyway": {
"defaultMessage": "Install anyway"
},
"content.diff-modal.loader-updated": {
"defaultMessage": "Loader"
},
"content.diff-modal.modpack-linked": {
"defaultMessage": "Linked modpack"
},
"content.diff-modal.modpack-unlinked": {
"defaultMessage": "Unlinked modpack"
},
"content.diff-modal.modpack-updated": {
"defaultMessage": "Updated modpack"
},
"content.diff-modal.no-content-changes": {
"defaultMessage": "No content changes"
},
"content.diff-modal.removed-count": {
"defaultMessage": "{count} removed"
},
"content.diff-modal.removed-disabled-count": {
"defaultMessage": "{count} removed (disabled)"
},
"content.diff-modal.reviewed-files": {
"defaultMessage": "A file is only reviewed if its published to Modrinth, regardless of its file format (including .mrpack)."
},
@@ -533,15 +566,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "world"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Your modpack's content can now be found here!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Modpack content moved"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Don't show again"
},
"content.page-layout.additional-content": {
"defaultMessage": "Additional content"
},
@@ -2054,6 +2078,12 @@
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "No content found"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "External"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "This file is not published on Modrinth."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Modpack content"
},
@@ -2063,6 +2093,9 @@
"instances.modpack-content-modal.no-results": {
"defaultMessage": "No projects match your search."
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "Open in Slicer"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Search {count, number} {count, plural, one {project} other {projects}}"
},
@@ -5069,6 +5102,81 @@
"settings.sessions.title": {
"defaultMessage": "Sessions"
},
"sharing.invite-players-modal.add": {
"defaultMessage": "Add"
},
"sharing.invite-players-modal.added": {
"defaultMessage": "Added"
},
"sharing.invite-players-modal.already-invited": {
"defaultMessage": "This user has already been invited."
},
"sharing.invite-players-modal.avatar-alt": {
"defaultMessage": "{username}'s avatar"
},
"sharing.invite-players-modal.cancel": {
"defaultMessage": "Cancel"
},
"sharing.invite-players-modal.cancel-button": {
"defaultMessage": "Cancel"
},
"sharing.invite-players-modal.edit-invite-link": {
"defaultMessage": "Edit invite link."
},
"sharing.invite-players-modal.edit-invite-link-title": {
"defaultMessage": "Edit invite link"
},
"sharing.invite-players-modal.expiry-label": {
"defaultMessage": "Expiry date"
},
"sharing.invite-players-modal.friends-heading": {
"defaultMessage": "Your friends - {count}"
},
"sharing.invite-players-modal.invite": {
"defaultMessage": "Invite"
},
"sharing.invite-players-modal.invite-expiry-description": {
"defaultMessage": "Your invite link expires in {duration}."
},
"sharing.invite-players-modal.invite-link-heading": {
"defaultMessage": "Or use an invite link"
},
"sharing.invite-players-modal.link-copied-text": {
"defaultMessage": "The invite link has been copied to your clipboard."
},
"sharing.invite-players-modal.link-copied-title": {
"defaultMessage": "Link copied"
},
"sharing.invite-players-modal.link-copy-failed-title": {
"defaultMessage": "Failed to copy link"
},
"sharing.invite-players-modal.max-uses-label": {
"defaultMessage": "Maximum uses"
},
"sharing.invite-players-modal.no-friends": {
"defaultMessage": "No friends found."
},
"sharing.invite-players-modal.no-search-results": {
"defaultMessage": "No matching users found."
},
"sharing.invite-players-modal.requested": {
"defaultMessage": "Request sent"
},
"sharing.invite-players-modal.requested-tooltip": {
"defaultMessage": "{username} needs to accept your friend request first"
},
"sharing.invite-players-modal.save-button": {
"defaultMessage": "Save"
},
"sharing.invite-players-modal.search-placeholder": {
"defaultMessage": "Enter Modrinth username"
},
"sharing.invite-players-modal.searching": {
"defaultMessage": "Searching..."
},
"sharing.invite-players-modal.update-invite-link-failed-title": {
"defaultMessage": "Failed to update invite link"
},
"tag.category.128x": {
"defaultMessage": "128x"
},
-10
View File
@@ -533,15 +533,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "mundo"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "¡Ya puedes encontrar el contenido de tu modpack aquí!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Se ha movido el contenido del modpack"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "No volver a mostrar"
},
"content.page-layout.additional-content": {
"defaultMessage": "Contenido adicional"
},
@@ -5802,4 +5793,3 @@
"defaultMessage": "Tipo"
}
}
-9
View File
@@ -482,15 +482,6 @@
"content.inline-backup.world-label": {
"defaultMessage": " mundo"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "¡Ya puedes encontrar el contenido de tu modpack aquí!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Se ha trasladado el contenido del modpack"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "No mostrar de nuevo"
},
"content.page-layout.additional-content": {
"defaultMessage": "Contenido adicional"
},
-9
View File
@@ -533,15 +533,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "monde"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Le contenu de votre modpack peut maintenant être retrouvé ici !"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Contenu du modpack déplacé"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Ne plus afficher"
},
"content.page-layout.additional-content": {
"defaultMessage": "Contenu supplémentaire"
},
-3
View File
@@ -257,9 +257,6 @@
"content.inline-backup.create-backup": {
"defaultMessage": "צור גיבוי"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "אל תציג שוב"
},
"content.page-layout.additional-content": {
"defaultMessage": "תוכן נוסף"
},
-9
View File
@@ -437,15 +437,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "világ"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Mostantól itt találhatod meg!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "A modcsomag tartalmát áthelyeztük"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Ne mutasd többször"
},
"content.page-layout.additional-content": {
"defaultMessage": "További tartalom"
},
-10
View File
@@ -452,15 +452,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "dunia"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Konten paket mod Anda kini dapat ditemukan di sini!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Konten paket mod telah berpindah"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Jangan tampilkan lagi"
},
"content.page-layout.additional-content": {
"defaultMessage": "Konten tambahan"
},
@@ -2997,4 +2988,3 @@
"defaultMessage": "Anda memiliki perubahan yang belum tersimpan."
}
}
-9
View File
@@ -512,15 +512,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "mondo"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "I contenuti del tuo pacchetto ora si trovano qui!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Contenuti del pacchetto spostati"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Non mostrare più"
},
"content.page-layout.additional-content": {
"defaultMessage": "Contenuto aggiuntivo"
},
-9
View File
@@ -473,15 +473,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "ワールド"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "あなたのModパックのコンテンツは、こちらで確認できます!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Modパックのコンテンツは移動しました"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "再度表示しない"
},
"content.page-layout.additional-content": {
"defaultMessage": "追加のコンテンツ"
},
-9
View File
@@ -533,15 +533,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "세계"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "여기서 모드팩의 콘텐츠를 확인할 수 있습니다!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "모드팩 콘텐츠 이동됨"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "다시 보지 않기"
},
"content.page-layout.additional-content": {
"defaultMessage": "추가 콘텐츠"
},
-10
View File
@@ -437,15 +437,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "dunia"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Kandungan pek mod anda kini boleh didapati di sini!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Kandungan pek mod telah berpindah"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Jangan tunjukkan lagi"
},
"content.page-layout.additional-content": {
"defaultMessage": "Kandungan tambahan"
},
@@ -5046,4 +5037,3 @@
"defaultMessage": "Jenis"
}
}
-9
View File
@@ -533,15 +533,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "wereld"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "De inhoud van je modpack is nu hier te vinden!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Inhoud van modpack is verplaatst"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Niet opnieuw tonen"
},
"content.page-layout.additional-content": {
"defaultMessage": "Extra inhoud"
},
-10
View File
@@ -383,15 +383,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "verden"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Modpakkeinnholdet ditt kan nå bli funnet her!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Modpakkeinnhold flytta"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Ikke vis igjen"
},
"content.page-layout.additional-content": {
"defaultMessage": "Yttligere innhold"
},
@@ -2790,4 +2781,3 @@
"defaultMessage": "Du har ulagrede endringer."
}
}
-9
View File
@@ -533,15 +533,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "Twój świat"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Informacje o zawartości Twojej paczki modów możesz teraz znaleźć tutaj!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Zawartość paczek modów została przeniesiona"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Nie pokazuj ponownie"
},
"content.page-layout.additional-content": {
"defaultMessage": "Dodatkowa zawartość"
},
-9
View File
@@ -533,15 +533,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "mundo"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "O conteúdo do seu pacote de mods agora pode ser encontrado aqui!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Conteúdo do pacote de mods movido"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Não mostrar novamente"
},
"content.page-layout.additional-content": {
"defaultMessage": "Conteúdo adicional"
},
-10
View File
@@ -350,15 +350,6 @@
"content.inline-backup.shift-click-hint": {
"defaultMessage": "Pressiona Shift enquanto clicas para saltar a confirmação."
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "O conteúdo do teu modpack agora pode ser encontrado aqui!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Conteúdo do modpack foi movido"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Não mostrar novamente"
},
"content.page-layout.additional-content": {
"defaultMessage": "Conteúdo adicional"
},
@@ -2538,4 +2529,3 @@
"defaultMessage": "Tens alterações por guardar."
}
}
-9
View File
@@ -506,15 +506,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "мир"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Теперь он находится здесь!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Контент сборки перемещён"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Больше не показывать"
},
"content.page-layout.additional-content": {
"defaultMessage": "Дополнительный контент"
},
-10
View File
@@ -533,15 +533,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "svet"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Sadržaj tvog modpacka sada možeš pronaći ovde!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Sadržaj modpacka pomeren"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Ne prikazuj ponovo"
},
"content.page-layout.additional-content": {
"defaultMessage": "Dodatni sadržaj"
},
@@ -5802,4 +5793,3 @@
"defaultMessage": "Tip"
}
}
-10
View File
@@ -506,15 +506,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "värld"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Modpaketets innehåll kan nu ses här!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Modpaketets innehåll flyttades"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Visa inte igen"
},
"content.page-layout.additional-content": {
"defaultMessage": "Ytterligare innehåll"
},
@@ -3837,4 +3828,3 @@
"defaultMessage": "Typ"
}
}
-9
View File
@@ -530,15 +530,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "dünya"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Mod paketinizin içeriği artık burada bulunabilir!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Mod paketi içeriği taşındı"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Bir daha gösterme"
},
"content.page-layout.additional-content": {
"defaultMessage": "Fazladan içerik"
},
-9
View File
@@ -530,15 +530,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "світ"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Уміст вашої збірки тепер можна знайти тут!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Уміст збірки перемістився"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Не показувати знову"
},
"content.page-layout.additional-content": {
"defaultMessage": "Додатковий уміст"
},
-10
View File
@@ -479,15 +479,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "thế giới"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Nội dung modpack của bạn hiện đã có ở đây!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Nội dung modpack đã được di chuyển"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Không hiển thị lại"
},
"content.page-layout.additional-content": {
"defaultMessage": "Mod bổ sung"
},
@@ -5094,4 +5085,3 @@
"defaultMessage": "Đội ngũ Modrinth"
}
}
-10
View File
@@ -533,15 +533,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "世界"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "你现在可以在这里找到你的整合包内容!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "整合包内容已移动"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "不再显示"
},
"content.page-layout.additional-content": {
"defaultMessage": "附加内容"
},
@@ -5802,4 +5793,3 @@
"defaultMessage": "类型"
}
}
-10
View File
@@ -533,15 +533,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "世界"
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "你模組包的內容現在可以在這裡找到了!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "模組包內容已移動"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "不再顯示"
},
"content.page-layout.additional-content": {
"defaultMessage": "額外內容"
},
@@ -5802,4 +5793,3 @@
"defaultMessage": "類型"
}
}
+9 -1
View File
@@ -4,13 +4,21 @@ import type { Ref } from 'vue'
import { createContext } from './create-context'
export type AuthUser = Labrinth.Users.v2.User | Labrinth.Users.v3.User
export type AuthFlow = 'sign-in' | 'sign-up'
export type AuthRequestOptions = {
showModal?: boolean
}
export interface AuthProvider {
session_token: Ref<string | null>
user: Ref<AuthUser | null>
/** True once the initial auth check has completed (regardless of result). */
isReady?: Ref<boolean>
requestSignIn: (redirectPath: string) => void | Promise<void>
requestSignIn: (
redirectPath: string,
flow?: AuthFlow,
options?: AuthRequestOptions,
) => void | Promise<void>
}
export const [injectAuth, provideAuth] = createContext<AuthProvider>('root', 'auth')
@@ -60,8 +60,8 @@ export const FigmaExamples: Story = {
type="instance-invite"
actor-name="Fetch"
:actor-avatar-url="avatarUrl"
entity-name="New Creation"
:entity-icon-url="instanceIconUrl"
entity-name="New Creation"
@accept="noop"
@decline="noop"
@dismiss="noop"
@@ -0,0 +1,202 @@
import type { Labrinth } from '@modrinth/api-client'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import InvitePlayersModal from '../../components/sharing/invite-players-modal/index.vue'
import type {
InviteLinkSettings,
InvitePlayersInvitePayload,
InvitePlayersUser,
InvitePlayersUserStatus,
} from '../../components/sharing/invite-players-modal/types'
const apiUsers: Labrinth.Users.v3.SearchUser[] = [
{
id: 'geometrically',
username: 'Geometrically',
avatar_url:
'https://cdn.modrinth.com/user/u6dRKJwZ/7ba3bdb11590a64843e9d2ab83ef85eaab42ec8e.png',
},
{
id: 'prospector',
username: 'Prospector',
avatar_url:
'https://cdn.modrinth.com/user/PHyAPGui/30a3a3f53866531831db4aa006794e6bbcfc4121.png',
},
{
id: 'fetch',
username: 'Fetch',
avatar_url:
'https://cdn.modrinth.com/user/yol4bNw3/ee2c7a7580ed475cfe3cfe8cc92df45ce33031e0.png',
},
{
id: 'imb11',
username: 'IMB11',
avatar_url: null,
},
{
id: 'josh',
username: 'Josh',
avatar_url: null,
},
{
id: 'emma',
username: 'Emma',
avatar_url: null,
},
]
const friendStatuses: Record<string, InvitePlayersUserStatus> = {
geometrically: 'added',
fetch: 'pending',
}
const meta = {
title: 'Sharing/InvitePlayersModal',
component: InvitePlayersModal,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Invite players modal for app instance sharing and server player invites. Callers provide the user search proxy and own invite/cancel persistence.',
},
},
},
} satisfies Meta<typeof InvitePlayersModal>
export default meta
type Story = StoryObj<typeof meta>
function toInviteUser(
user: Labrinth.Users.v3.SearchUser,
status: InvitePlayersUserStatus = 'available',
): InvitePlayersUser {
return {
id: user.id,
username: user.username,
avatarUrl: user.avatar_url,
status,
online: user.id === 'prospector',
}
}
function createFriends() {
return apiUsers
.slice(0, 4)
.map((user) => toInviteUser(user, friendStatuses[user.id] ?? 'available'))
}
function createSearchUsers() {
return apiUsers.map((user) => ({
id: user.id,
username: user.username,
avatarUrl: user.avatar_url,
}))
}
function createRender(args: Record<string, unknown>) {
return {
components: { ButtonStyled, InvitePlayersModal },
setup() {
const modalRef = ref<InstanceType<typeof InvitePlayersModal> | null>(null)
const friends = ref<InvitePlayersUser[]>(createFriends())
const searchUsers = createSearchUsers()
const lastAction = ref('')
const invitedSearchUserIds = ref(new Set<string>())
async function updateInviteLink(settings: InviteLinkSettings) {
await new Promise((resolve) => setTimeout(resolve, 500))
lastAction.value = `Updated invite link to ${settings.maxUses} uses, expiring ${settings.expiresAt.toLocaleString()}`
}
async function searchInviteUsers(query: string) {
await new Promise((resolve) => setTimeout(resolve, 250))
const normalizedQuery = query.trim().toLowerCase()
const friendKeys = new Set(
friends.value.flatMap((friend) => [
friend.id.toLowerCase(),
friend.username.toLowerCase(),
]),
)
return searchUsers.filter(
(user) =>
user.username.toLowerCase().startsWith(normalizedQuery) &&
!friendKeys.has(user.id.toLowerCase()) &&
!friendKeys.has(user.username.toLowerCase()) &&
!invitedSearchUserIds.value.has(user.id),
)
}
function handleInvite(payload: InvitePlayersInvitePayload) {
const existingFriend = friends.value.find((friend) => friend.id === payload.user.id)
if (payload.source === 'search') {
invitedSearchUserIds.value = new Set([...invitedSearchUserIds.value, payload.user.id])
lastAction.value = `Sent friend request and direct invite to ${payload.user.username}`
return
}
if (existingFriend) {
existingFriend.status = 'pending'
}
lastAction.value = `Invited ${payload.user.username} from ${payload.source}`
}
function handleCancel(user: InvitePlayersUser) {
const existingFriend = friends.value.find((friend) => friend.id === user.id)
if (existingFriend) existingFriend.status = 'available'
lastAction.value = `Cancelled invite for ${user.username}`
}
return {
args,
friends,
handleCancel,
handleInvite,
lastAction,
modalRef,
searchInviteUsers,
updateInviteLink,
}
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="brand">
<button @click="modalRef?.show($event)">Open modal</button>
</ButtonStyled>
<p v-if="lastAction" class="m-0 text-sm text-secondary">{{ lastAction }}</p>
<InvitePlayersModal
ref="modalRef"
v-bind="args"
:friends="friends"
:search-users="searchInviteUsers"
:update-invite-link="updateInviteLink"
@invite="handleInvite"
@cancel="handleCancel"
/>
</div>
`,
}
}
export const ShareInstance: Story = {
args: {
header: 'Share instance',
link: 'https://modrinth.com/instance/abc123',
linkExpiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
linkMaxUses: 10,
},
render: (args) => createRender(args),
}
export const FriendsOnly: Story = {
args: {
header: 'Invite players',
},
render: (args) => createRender(args),
}