mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 09:34:50 +00:00
feat: instance sharing thru shared-instances service (#6569)
* feat: implement instance share page + search_users backend call * feat: invite players modal * feat: use tanstack queries for friends sync across app pages * feat: base shared instances implementation * fix: admon style * feat: impl instance admonitions like server panel * fix: impl get + del usage * feat: support modpack links * feat: invite notif accepting * fix: lint + fmt * feat: impl install to play * feat: impl usage of UpdateToPlayModal * feat: warnings on deleting/disabling shared-instance version content * fix: send instance name * feat: align with backend * feat: shared instances qa * feat: wrong account protection * feat: qa * fix: smartly apply updates * fix: install bug * fix: 401/404 differentiation * fix: fmt+prepr * feat: qa * feat: qa * fix: signing out messes up revoke/deleted checks * feat: qa * fix: fmt + lint * feat: lock content if part of shared instance * fix: lint * [do not merge] feat: rough invite links impl temp (#6666) * fix: wrong cmd * feat: invite page * fix: server-manager DTO mismatch * fix: drop anonymous invite link acceptance * refactor: structured shared-instance unavailable errors * refactor: centralise error presentations * refactor: dedupe shared instance diff detection * fix: logging in reqwests * refactor: move app.vue shared instances into handler * refactor: break up Share.vue * refactor: split up shared instances state outside of instance index * refactor: dedicated shared instances install/update modals + split up page * refactor: centralized managed content * refactor: split up install shared to own runner + shared.rs split up * refactor: dedupe sql for instance metadata enrichmnt * refactor: friends composable + dedupe friends logic across usages * chore: reduced unused code * fix: align with backend * fix: lint * fix: file sha changes * fix: invite links not working due to icon signed * feat: qa * feat: reporting frontend dummy * fix: try use header * remove: file hash field * fix: pin box * feat: malware warning for shared instances * fix: cache rule * feat: config files syncing * feat: disable config sharing * fix: header * fix: use mark ready * fix: dont cause push update for configs * fix: lint * feat: sharing page in settings * feat: move config + change flow * fix: qa * fix: lint prepr * feat: proxy file upload thru shared instances backend * fix: use collapisible * fix: push config * fix: config * feat: swap out sign in modal for new one * fix: report flow * fix: exclude configs.zip from external warnings * fix: nuxi init * fix: config bundle downloading * fix: error notif * fix: polling * fix: qa * fix: lint + prepr * feat: shared instances moderation frontend + hook up report flow * fix: report copy * fix: lint * fix: lint * fix: modrinth ids being undefined * feat: instance quarantining * fix: prepr + fmt * fix: quarantined -> locked terminology * fix: missing endpoint impls + fmt * fix: missing api in build.rs * fix: share tab jittery * fix: fmt *PT bug * fix: invites count as users even if pending * fix: prepr * fix: invite page owner in users list * fix: lint * fix: qa * fix: lint * fix: members stale not clearing * fix: invite use joined_at field * fix: lint * fix: qa --------- Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
This commit is contained in:
@@ -9,25 +9,47 @@
|
||||
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
|
||||
<component :is="getSeverityIcon(type)" :class="['h-6 w-6 flex-none', iconClasses[type]]" />
|
||||
</slot>
|
||||
<div class="col-start-2 flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div
|
||||
class="col-start-2 min-w-0"
|
||||
:class="
|
||||
inlineActions && !showActionsUnderneath && $slots.actions
|
||||
? 'flex flex-wrap items-start gap-x-4 gap-y-3'
|
||||
: 'flex flex-1 flex-col gap-2'
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="header || $slots.header || normalizedTimestamp"
|
||||
class="flex flex-wrap items-center gap-2 text-lg font-semibold leading-6"
|
||||
class="flex min-w-0 flex-1 flex-col gap-2"
|
||||
:class="
|
||||
inlineActions && !showActionsUnderneath && $slots.actions
|
||||
? 'admonition-inline-content'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<slot name="header">{{ header }}</slot>
|
||||
<span
|
||||
v-if="normalizedTimestamp"
|
||||
v-tooltip="timestampTooltip"
|
||||
class="flex items-center gap-1.5 text-base font-medium leading-normal text-secondary"
|
||||
<div
|
||||
v-if="header || $slots.header || normalizedTimestamp"
|
||||
class="flex flex-wrap items-center gap-2 text-lg font-semibold leading-6"
|
||||
>
|
||||
<ClockIcon class="size-4" />
|
||||
{{ relativeTimeLabel }}
|
||||
</span>
|
||||
<slot name="header">{{ header }}</slot>
|
||||
<span
|
||||
v-if="normalizedTimestamp"
|
||||
v-tooltip="timestampTooltip"
|
||||
class="flex items-center gap-1.5 text-base font-medium leading-normal text-secondary"
|
||||
>
|
||||
<ClockIcon class="size-4" />
|
||||
{{ relativeTimeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="font-normal text-contrast/85 leading-tight">
|
||||
<slot>{{ body }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-normal text-contrast/85 leading-tight">
|
||||
<slot>{{ body }}</slot>
|
||||
<div
|
||||
v-if="inlineActions && !showActionsUnderneath && $slots.actions"
|
||||
class="ml-auto flex shrink-0 items-center justify-end self-center"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<div v-if="showActionsUnderneath || $slots.actions" class="mt-2">
|
||||
<div v-else-if="showActionsUnderneath || $slots.actions" class="mt-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,9 +102,10 @@ import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning'
|
||||
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning' | 'neutral'
|
||||
header?: string
|
||||
body?: string
|
||||
inlineActions?: boolean
|
||||
showActionsUnderneath?: boolean
|
||||
dismissible?: boolean
|
||||
progress?: number
|
||||
@@ -95,6 +118,7 @@ const props = withDefaults(
|
||||
type: 'info',
|
||||
header: '',
|
||||
body: '',
|
||||
inlineActions: false,
|
||||
showActionsUnderneath: false,
|
||||
dismissible: false,
|
||||
progress: undefined,
|
||||
@@ -143,6 +167,7 @@ const typeClasses = {
|
||||
critical: 'border-brand-red bg-bg-red',
|
||||
success: 'border-brand-green bg-bg-green',
|
||||
moderation: 'border-brand-orange bg-bg-orange',
|
||||
neutral: 'border-surface-4 bg-surface-3',
|
||||
}
|
||||
|
||||
const iconClasses = {
|
||||
@@ -152,6 +177,7 @@ const iconClasses = {
|
||||
critical: 'text-brand-red',
|
||||
success: 'text-brand-green',
|
||||
moderation: 'text-brand-orange',
|
||||
neutral: 'text-secondary',
|
||||
}
|
||||
|
||||
const buttonColors = {
|
||||
@@ -161,6 +187,7 @@ const buttonColors = {
|
||||
critical: 'red',
|
||||
success: 'green',
|
||||
moderation: 'orange',
|
||||
neutral: 'standard',
|
||||
} as const
|
||||
|
||||
const progressTrackClasses = {
|
||||
@@ -170,6 +197,7 @@ const progressTrackClasses = {
|
||||
critical: 'bg-brand-red/20',
|
||||
success: 'bg-brand-green/20',
|
||||
moderation: 'bg-brand-orange/20',
|
||||
neutral: 'bg-surface-4',
|
||||
}
|
||||
|
||||
const progressFillClasses = {
|
||||
@@ -181,10 +209,15 @@ const progressFillClasses = {
|
||||
blue: 'bg-brand-blue',
|
||||
green: 'bg-brand-green',
|
||||
red: 'bg-brand-red',
|
||||
neutral: 'bg-surface-5',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admonition-inline-content {
|
||||
min-width: min(100%, 16rem);
|
||||
}
|
||||
|
||||
.admonition-progress--waiting {
|
||||
animation: admonition-progress-waiting 1s linear infinite;
|
||||
position: relative;
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
</div>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<button
|
||||
v-if="showSize"
|
||||
type="button"
|
||||
class="hidden w-[92px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
|
||||
:class="sortField === 'size' ? 'text-contrast' : 'text-secondary'"
|
||||
@@ -52,6 +53,7 @@
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="showModified"
|
||||
type="button"
|
||||
class="hidden w-[132px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
|
||||
:class="sortField === 'modified' ? 'text-contrast' : 'text-secondary'"
|
||||
@@ -90,8 +92,11 @@
|
||||
{{ formatMessage(messages.parentFolder) }}
|
||||
</span>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<span class="hidden w-[92px] text-left text-sm text-secondary sm:block" />
|
||||
<span class="hidden w-[132px] text-left text-sm text-secondary sm:block" />
|
||||
<span v-if="showSize" class="hidden w-[92px] text-left text-sm text-secondary sm:block" />
|
||||
<span
|
||||
v-if="showModified"
|
||||
class="hidden w-[132px] text-left text-sm text-secondary sm:block"
|
||||
/>
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,10 +145,16 @@
|
||||
{{ entry.name }}
|
||||
</span>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<span class="hidden w-[92px] truncate text-left text-sm text-secondary sm:block">
|
||||
<span
|
||||
v-if="showSize"
|
||||
class="hidden w-[92px] truncate text-left text-sm text-secondary sm:block"
|
||||
>
|
||||
{{ formatSize(entry) }}
|
||||
</span>
|
||||
<span class="hidden w-[132px] truncate text-left text-sm text-secondary sm:block">
|
||||
<span
|
||||
v-if="showModified"
|
||||
class="hidden w-[132px] truncate text-left text-sm text-secondary sm:block"
|
||||
>
|
||||
{{ formatModified(entry) }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
@@ -164,8 +175,8 @@
|
||||
<span class="size-4 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium opacity-0">.</span>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<span class="hidden w-[92px] text-left text-sm sm:block" />
|
||||
<span class="hidden w-[132px] text-left text-sm sm:block" />
|
||||
<span v-if="showSize" class="hidden w-[92px] text-left text-sm sm:block" />
|
||||
<span v-if="showModified" class="hidden w-[132px] text-left text-sm sm:block" />
|
||||
<span class="size-4 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -262,10 +273,14 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
items: FileTreeSelectItem[]
|
||||
modelValue: string[]
|
||||
showSize?: boolean
|
||||
showModified?: boolean
|
||||
}>(),
|
||||
{
|
||||
items: () => [],
|
||||
modelValue: () => [],
|
||||
showSize: true,
|
||||
showModified: true,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="metadata"
|
||||
class="page-header-metadata flex min-w-0 flex-wrap items-center gap-x-2 gap-y-2"
|
||||
class="page-header-metadata flex min-w-0 flex-wrap items-center gap-x-[1.625rem] gap-y-2"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div :class="rootClass" data-page-header-metadata-item v-bind="$attrs">
|
||||
<BulletDivider class="page-header-metadata-item-divider shrink-0" />
|
||||
<span
|
||||
class="page-header-metadata-item-divider absolute right-full flex h-full w-[1.625rem] items-center justify-center"
|
||||
>
|
||||
<BulletDivider class="shrink-0" />
|
||||
</span>
|
||||
<AutoLink
|
||||
v-if="to && !disabled"
|
||||
v-tooltip="tooltip"
|
||||
@@ -72,7 +76,7 @@ const props = withDefaults(defineProps<PageHeaderMetadataItemProps>(), {
|
||||
|
||||
const defaultIconClass = 'block size-5 shrink-0 text-current'
|
||||
const baseClass =
|
||||
'flex min-w-0 items-center gap-2 font-medium leading-none text-secondary text-nowrap'
|
||||
'relative flex min-w-0 items-center font-medium leading-none text-secondary text-nowrap'
|
||||
const contentBaseClass = 'inline-flex min-w-0 items-center gap-2 text-inherit'
|
||||
const interactiveClass = 'm-0 cursor-pointer border-0 bg-transparent p-0 hover:underline'
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export * from './project'
|
||||
export * from './search'
|
||||
export * from './servers'
|
||||
export * from './settings'
|
||||
export * from './sharing'
|
||||
export * from './skin'
|
||||
export * from './user'
|
||||
export * from './version'
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Install to play" :closable="true">
|
||||
<div class="flex flex-col gap-4 max-w-[500px]">
|
||||
<Admonition type="info" header="Shared server instance">
|
||||
This server requires modded content to play. Accept to install the needed files from
|
||||
Modrinth.
|
||||
</Admonition>
|
||||
|
||||
<div v-if="sharedBy?.name" class="flex items-center gap-2 text-sm text-secondary">
|
||||
<Avatar
|
||||
v-if="sharedBy?.icon_url"
|
||||
:src="sharedBy.icon_url"
|
||||
:alt="sharedBy.name"
|
||||
size="24px"
|
||||
/>
|
||||
<span>
|
||||
<span class="font-semibold text-contrast">{{ sharedBy.name }}</span>
|
||||
shared this instance with you today.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-secondary">Shared instance</span>
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-4 p-3">
|
||||
<Avatar :src="project.icon_url" :alt="project.title" size="48px" />
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-semibold text-contrast">{{ project.title }}</span>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ loaderDisplay }} {{ project.game_versions?.[0] }}
|
||||
<template v-if="modCount"> · {{ modCount }} mods </template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="handleDecline">
|
||||
<XIcon />
|
||||
Decline
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleAccept">
|
||||
<CheckIcon />
|
||||
Accept
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import type { Project } from '@modrinth/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useVIntl } from '../../composables'
|
||||
import { formatLoader } from '../../utils'
|
||||
import Admonition from '../base/Admonition.vue'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import NewModal from './NewModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
project: Project
|
||||
sharedBy?: {
|
||||
name: string
|
||||
icon_url?: string
|
||||
}
|
||||
modCount?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
accept: []
|
||||
decline: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const loaderDisplay = computed(() => {
|
||||
const loader = props.project.loaders?.[0]
|
||||
if (!loader) return ''
|
||||
return formatLoader(formatMessage, loader)
|
||||
})
|
||||
|
||||
function handleAccept() {
|
||||
// TODO: Implement accept logic
|
||||
emit('accept')
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleDecline() {
|
||||
emit('decline')
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -89,11 +89,11 @@
|
||||
ref="scrollContainer"
|
||||
data-modal-content
|
||||
:class="[
|
||||
'flex-1 min-h-0',
|
||||
props.noPadding ? '' : 'overflow-y-auto p-6 !pb-1 sm:pb-6',
|
||||
'flex-1 min-h-0 overflow-y-auto',
|
||||
props.noPadding ? '' : 'p-6 !pb-1 sm:pb-6',
|
||||
{ 'pt-12': props.mergeHeader && closable && !props.noPadding },
|
||||
]"
|
||||
:style="props.noPadding ? {} : { maxHeight: maxContentHeight }"
|
||||
:style="{ maxHeight: maxContentHeight }"
|
||||
@scroll="checkScrollState"
|
||||
>
|
||||
<slot> You just lost the game.</slot>
|
||||
@@ -231,6 +231,7 @@ const visible = ref(false)
|
||||
const stackDepth = ref(0)
|
||||
const modalBodyRef = ref<HTMLElement | null>(null)
|
||||
let previousFocusEl: Element | null = null
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(scrollContainer)
|
||||
@@ -244,6 +245,10 @@ function getFocusableElements(): HTMLElement[] {
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout)
|
||||
hideTimeout = null
|
||||
}
|
||||
props.onShow?.()
|
||||
const wasEmpty = modalStackSize() === 0
|
||||
stackDepth.value = modalStackSize()
|
||||
@@ -292,8 +297,9 @@ function hide() {
|
||||
previousFocusEl.focus()
|
||||
}
|
||||
previousFocusEl = null
|
||||
setTimeout(() => {
|
||||
hideTimeout = setTimeout(() => {
|
||||
open.value = false
|
||||
hideTimeout = null
|
||||
nextTick(() => props.onAfterHide?.())
|
||||
}, 300)
|
||||
}
|
||||
@@ -340,6 +346,10 @@ function resetMousePosition() {
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout)
|
||||
hideTimeout = null
|
||||
}
|
||||
if (open.value) {
|
||||
popModal()
|
||||
window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { default as ConfirmLeaveModal } from './ConfirmLeaveModal.vue'
|
||||
export { default as ConfirmModal } from './ConfirmModal.vue'
|
||||
export { default as InstallToPlayModal } from './InstallToPlayModal.vue'
|
||||
export { default as Modal } from './Modal.vue'
|
||||
export { default as NewModal } from './NewModal.vue'
|
||||
export type { ServerProject as OpenInAppModalServerProject } from './OpenInAppModal.vue'
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<NotificationToast
|
||||
v-if="item.toast"
|
||||
:type="item.toast.type"
|
||||
:action-loading="toastActionLoading(item.id)"
|
||||
:actor-name="item.toast.actorName"
|
||||
:actor-avatar-url="item.toast.actorAvatarUrl"
|
||||
:entity-name="item.toast.entityName"
|
||||
@@ -29,7 +30,7 @@
|
||||
:progress-type="item.toast.progressType"
|
||||
:progress-current="item.toast.progressCurrent"
|
||||
:progress-total="item.toast.progressTotal"
|
||||
@accept="handleToastAction(item, item.toast.onAccept)"
|
||||
@accept="handleToastAccept(item, item.toast.onAccept)"
|
||||
@decline="handleToastAction(item, item.toast.onDecline)"
|
||||
@dismiss="handleToastAction(item, item.toast.onDismiss)"
|
||||
@launch="handleToastAction(item, item.toast.onLaunch)"
|
||||
@@ -167,7 +168,7 @@ import {
|
||||
XCircleIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useModalStack } from '../../composables/modal-stack'
|
||||
import {
|
||||
@@ -189,11 +190,13 @@ const hasModalActive = computed(() => stackCount.value > 0)
|
||||
const notificationGroupStyle = computed(() => ({
|
||||
zIndex: hasModalActive.value ? 100 + stackCount.value * 10 + 8 : 200,
|
||||
}))
|
||||
const activeToastActions = ref<Record<string, 'accept'>>({})
|
||||
|
||||
const stopTimer = (n: PopupNotification) => popupNotificationManager.stopNotificationTimer(n)
|
||||
const setNotificationTimer = (n: PopupNotification) =>
|
||||
popupNotificationManager.setNotificationTimer(n)
|
||||
const dismiss = (id: string | number) => popupNotificationManager.removeNotification(id)
|
||||
const toastActionLoading = (id: string | number) => activeToastActions.value[String(id)] ?? null
|
||||
|
||||
function isDownloadNotification(item: PopupNotification) {
|
||||
return (
|
||||
@@ -265,6 +268,26 @@ async function handleToastAction(item: PopupNotification, action?: () => void |
|
||||
await action?.()
|
||||
}
|
||||
|
||||
async function handleToastAccept(item: PopupNotification, action?: () => void | Promise<void>) {
|
||||
if (toastActionLoading(item.id) != null) return
|
||||
|
||||
const actionId = String(item.id)
|
||||
popupNotificationManager.stopNotificationTimer(item)
|
||||
activeToastActions.value = {
|
||||
...activeToastActions.value,
|
||||
[actionId]: 'accept',
|
||||
}
|
||||
|
||||
try {
|
||||
await action?.()
|
||||
} finally {
|
||||
activeToastActions.value = Object.fromEntries(
|
||||
Object.entries(activeToastActions.value).filter(([key]) => key !== actionId),
|
||||
)
|
||||
popupNotificationManager.removeNotification(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
function progressColorForType(type: PopupNotification['type']) {
|
||||
if (type === 'error') {
|
||||
return 'red'
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
>
|
||||
<div v-if="isInviteNotification" class="flex w-full items-start gap-3">
|
||||
<Avatar
|
||||
:src="actorAvatarUrl"
|
||||
:alt="actorLabel"
|
||||
:tint-by="actorLabel"
|
||||
:src="inviteAvatarUrl"
|
||||
:alt="inviteAvatarLabel"
|
||||
:tint-by="inviteAvatarLabel"
|
||||
size="44px"
|
||||
circle
|
||||
:circle="inviteAvatarCircle"
|
||||
no-shadow
|
||||
class="border border-solid border-surface-5"
|
||||
/>
|
||||
@@ -35,20 +35,17 @@
|
||||
>.
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="inline-flex max-w-full items-center gap-[5px] align-[-4px]">
|
||||
<Avatar
|
||||
:src="entityIconUrl"
|
||||
:alt="entityLabel"
|
||||
size="24px"
|
||||
no-shadow
|
||||
raised
|
||||
:tint-by="entityLabel"
|
||||
class="!rounded-[7px]"
|
||||
/>
|
||||
<span class="min-w-0 truncate font-semibold text-contrast">{{
|
||||
entityLabel
|
||||
}}</span> </span
|
||||
>.
|
||||
<Avatar
|
||||
:src="entityIconUrl"
|
||||
:alt="entityLabel"
|
||||
:tint-by="entityLabel"
|
||||
size="28px"
|
||||
no-shadow
|
||||
raised
|
||||
class="inline-block !rounded-lg align-middle"
|
||||
/>
|
||||
<span class="ml-1 font-semibold text-contrast">{{ entityLabel }}</span>
|
||||
<span> instance.</span>
|
||||
</template>
|
||||
</template>
|
||||
</p>
|
||||
@@ -65,10 +62,17 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="$emit('accept')">Accept</button>
|
||||
<button :disabled="actionLoading != null" @click="$emit('accept')">
|
||||
<SpinnerIcon v-if="actionLoading === 'accept'" class="animate-spin" />
|
||||
<CheckIcon v-else />
|
||||
Accept
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="$emit('decline')">Decline</button>
|
||||
<button :disabled="actionLoading != null" @click="$emit('decline')">
|
||||
<XIcon />
|
||||
Decline
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,7 +179,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { CheckIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useFormatBytes, useFormatNumber } from '../../composables'
|
||||
@@ -190,10 +194,12 @@ type NotificationToastType =
|
||||
| 'instance-invite'
|
||||
| 'instance-download'
|
||||
| 'instance-ready'
|
||||
type NotificationToastAction = 'accept'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type: NotificationToastType
|
||||
actionLoading?: NotificationToastAction | null
|
||||
actorName?: string | null
|
||||
actorAvatarUrl?: string | null
|
||||
entityName?: string
|
||||
@@ -209,6 +215,7 @@ const props = withDefaults(
|
||||
actions?: PopupNotificationButton[]
|
||||
}>(),
|
||||
{
|
||||
actionLoading: null,
|
||||
actorName: null,
|
||||
actorAvatarUrl: null,
|
||||
entityName: '',
|
||||
@@ -239,6 +246,9 @@ const isInviteNotification = computed(
|
||||
|
||||
const actorLabel = computed(() => props.actorName || 'Someone')
|
||||
const entityLabel = computed(() => props.entityName || '')
|
||||
const inviteAvatarUrl = computed(() => props.actorAvatarUrl)
|
||||
const inviteAvatarLabel = computed(() => actorLabel.value)
|
||||
const inviteAvatarCircle = computed(() => true)
|
||||
const progressValue = computed(() => Math.max(0, Math.min(1, props.progress ?? 0)))
|
||||
const progressPercent = computed(() => Math.round(progressValue.value * 100))
|
||||
const isWaitingProgress = computed(() => props.type === 'instance-download' && props.waiting)
|
||||
@@ -250,7 +260,7 @@ const inviteActionText = computed(() => {
|
||||
return 'invited you to manage the server'
|
||||
}
|
||||
|
||||
return 'invited you to play the instance'
|
||||
return 'invited you to'
|
||||
})
|
||||
|
||||
const resolvedStatusText = computed(() => {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as InvitePlayersModal } from './invite-players-modal/index.vue'
|
||||
export * from './invite-players-modal/types'
|
||||
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="header"
|
||||
width="min(34rem, calc(100vw - 2rem))"
|
||||
max-width="34rem"
|
||||
no-padding
|
||||
noblur
|
||||
>
|
||||
<div class="flex max-h-[calc(100vh-8rem)] min-h-0 flex-col">
|
||||
<div class="border-0 border-b border-solid border-surface-5 p-6">
|
||||
<div class="flex items-start gap-2">
|
||||
<Combobox
|
||||
:key="searchInputKey"
|
||||
:model-value="undefined"
|
||||
:options="searchOptions"
|
||||
:search-value="searchTarget"
|
||||
:search-placeholder="searchPlaceholderLabel"
|
||||
:placeholder="searchPlaceholderLabel"
|
||||
:no-options-message="searchLookupMessage"
|
||||
:min-search-length-to-open="searchMinimumLength"
|
||||
:disable-search-filter="usesRemoteSearch"
|
||||
class="min-w-0 flex-1"
|
||||
searchable
|
||||
show-search-icon
|
||||
:show-chevron="false"
|
||||
search-type="search"
|
||||
search-name="modrinth-player-invite-search"
|
||||
search-inputmode="search"
|
||||
search-autocomplete="new-password"
|
||||
search-autocorrect="off"
|
||||
search-autocapitalize="none"
|
||||
:search-spellcheck="false"
|
||||
:search-input-attrs="passwordManagerIgnoreAttrs"
|
||||
@search-input="handleSearchInput"
|
||||
@select="handleSearchSelect"
|
||||
>
|
||||
<template #option="{ item, isSelected }">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Avatar
|
||||
:src="findSearchUser(item.value)?.avatarUrl"
|
||||
:alt="formatMessage(messages.avatarAlt, { username: item.label })"
|
||||
:tint-by="item.label"
|
||||
size="1.5rem"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 truncate font-semibold"
|
||||
:class="isSelected ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Combobox>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="searchInviteTooltip"
|
||||
class="shrink-0"
|
||||
:disabled="!canInviteSearchTarget"
|
||||
@click="inviteSearchTarget"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{{ addButtonLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[11rem] overflow-y-auto bg-surface-2 px-6 py-4">
|
||||
<div class="mb-2 text-base font-semibold text-primary">
|
||||
{{ friendsHeading }}
|
||||
</div>
|
||||
<div
|
||||
v-if="friends.length === 0"
|
||||
class="flex min-h-32 items-center justify-center text-secondary"
|
||||
>
|
||||
{{ emptyFriendsLabel }}
|
||||
</div>
|
||||
<div v-else class="-mx-6 flex flex-col">
|
||||
<InvitePlayersModalUserRow
|
||||
v-for="friend in sortedFriends"
|
||||
:key="friend.id"
|
||||
:user="friend"
|
||||
:avatar-alt="formatMessage(messages.avatarAlt, { username: friend.username })"
|
||||
:added-label="addedButtonLabel"
|
||||
:cancel-label="cancelButtonLabel"
|
||||
:invite-label="inviteButtonLabel"
|
||||
:requested-label="requestedButtonLabel"
|
||||
:requested-tooltip="requestedTooltip(friend.username)"
|
||||
:user-profile-link="userProfileLink"
|
||||
@invite="inviteFriend"
|
||||
@cancel="cancelInvite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="link" class="border-0 border-t border-solid border-surface-5 p-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-base font-semibold text-contrast">
|
||||
{{ inviteLinkHeading }}
|
||||
</div>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
type="button"
|
||||
class="!h-10 w-full !justify-between !px-4 text-left !shadow-none"
|
||||
@click="copyInviteLink"
|
||||
>
|
||||
<span class="min-w-0 truncate text-base font-semibold text-primary">
|
||||
{{ link }}
|
||||
</span>
|
||||
<ClipboardCopyIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p v-if="link && linkExpiryDescription" class="m-0 text-base text-primary">
|
||||
{{ linkExpiryDescription }}
|
||||
<button
|
||||
v-if="updateInviteLink"
|
||||
type="button"
|
||||
class="cursor-pointer border-0 bg-transparent p-0 text-base font-medium text-blue hover:underline"
|
||||
@click="inviteLinkEditor?.show()"
|
||||
>
|
||||
{{ formatMessage(messages.editInviteLink) }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
|
||||
<InvitePlayersModalInviteLinkEditor
|
||||
v-if="updateInviteLink"
|
||||
ref="inviteLinkEditor"
|
||||
:link-expires-at="linkExpiresAt"
|
||||
:link-max-uses="linkMaxUses"
|
||||
:update-invite-link="updateInviteLink"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClipboardCopyIcon, PlusIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import { injectNotificationManager } from '../../../providers'
|
||||
import Avatar from '../../base/Avatar.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import Combobox from '../../base/Combobox.vue'
|
||||
import NewModal from '../../modal/NewModal.vue'
|
||||
import InvitePlayersModalInviteLinkEditor from './invite-players-modal-invite-link-editor.vue'
|
||||
import InvitePlayersModalUserRow from './invite-players-modal-user-row.vue'
|
||||
import type {
|
||||
InviteLinkSettings,
|
||||
InvitePlayersInvitePayload,
|
||||
InvitePlayersSearchUser,
|
||||
InvitePlayersUser,
|
||||
InvitePlayersUserProfileLink,
|
||||
} from './types'
|
||||
import { useInvitePlayersSearch } from './use-invite-players-search'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
header?: string
|
||||
friends?: InvitePlayersUser[]
|
||||
suggestions?: InvitePlayersSearchUser[]
|
||||
searchUsers?: (query: string) => Promise<InvitePlayersSearchUser[]>
|
||||
link?: string
|
||||
linkExpiresAt?: string | Date | null
|
||||
linkMaxUses?: number
|
||||
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
|
||||
friendsLabel?: string
|
||||
searchPlaceholder?: string
|
||||
addLabel?: string
|
||||
inviteLabel?: string
|
||||
addedLabel?: string
|
||||
cancelLabel?: string
|
||||
requestedLabel?: string
|
||||
emptyFriendsLabel?: string
|
||||
canInvite?: boolean
|
||||
inviteDisabledMessage?: string
|
||||
userProfileLink?: (username: string) => InvitePlayersUserProfileLink
|
||||
}>(),
|
||||
{
|
||||
header: 'Share instance',
|
||||
friends: () => [],
|
||||
suggestions: () => [],
|
||||
canInvite: true,
|
||||
linkMaxUses: 10,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
invite: [payload: InvitePlayersInvitePayload]
|
||||
cancel: [user: InvitePlayersUser]
|
||||
'copy-link': [link: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const notificationManager = injectNotificationManager(null)
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const inviteLinkEditor = ref<InstanceType<typeof InvitePlayersModalInviteLinkEditor> | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
friendsHeading: {
|
||||
id: 'sharing.invite-players-modal.friends-heading',
|
||||
defaultMessage: 'Your friends - {count}',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
id: 'sharing.invite-players-modal.search-placeholder',
|
||||
defaultMessage: 'Enter Modrinth username',
|
||||
},
|
||||
addButton: {
|
||||
id: 'sharing.invite-players-modal.add',
|
||||
defaultMessage: 'Add',
|
||||
},
|
||||
inviteButton: {
|
||||
id: 'sharing.invite-players-modal.invite',
|
||||
defaultMessage: 'Invite',
|
||||
},
|
||||
addedButton: {
|
||||
id: 'sharing.invite-players-modal.added',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'sharing.invite-players-modal.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
requestedButton: {
|
||||
id: 'sharing.invite-players-modal.requested',
|
||||
defaultMessage: 'Request sent',
|
||||
},
|
||||
requestedTooltip: {
|
||||
id: 'sharing.invite-players-modal.requested-tooltip',
|
||||
defaultMessage: '{username} needs to accept your friend request first',
|
||||
},
|
||||
noFriends: {
|
||||
id: 'sharing.invite-players-modal.no-friends',
|
||||
defaultMessage: 'No friends found.',
|
||||
},
|
||||
noSearchResults: {
|
||||
id: 'sharing.invite-players-modal.no-search-results',
|
||||
defaultMessage: 'No matching users found.',
|
||||
},
|
||||
searching: {
|
||||
id: 'sharing.invite-players-modal.searching',
|
||||
defaultMessage: 'Searching...',
|
||||
},
|
||||
alreadyInvited: {
|
||||
id: 'sharing.invite-players-modal.already-invited',
|
||||
defaultMessage: 'This user has already been invited.',
|
||||
},
|
||||
inviteLinkHeading: {
|
||||
id: 'sharing.invite-players-modal.invite-link-heading',
|
||||
defaultMessage: 'Or use an invite link',
|
||||
},
|
||||
inviteExpiryDescription: {
|
||||
id: 'sharing.invite-players-modal.invite-expiry-description',
|
||||
defaultMessage: 'Your invite link expires in {duration}.',
|
||||
},
|
||||
editInviteLink: {
|
||||
id: 'sharing.invite-players-modal.edit-invite-link',
|
||||
defaultMessage: 'Edit invite link.',
|
||||
},
|
||||
linkCopiedTitle: {
|
||||
id: 'sharing.invite-players-modal.link-copied-title',
|
||||
defaultMessage: 'Link copied',
|
||||
},
|
||||
linkCopiedText: {
|
||||
id: 'sharing.invite-players-modal.link-copied-text',
|
||||
defaultMessage: 'The invite link has been copied to your clipboard.',
|
||||
},
|
||||
linkCopyFailedTitle: {
|
||||
id: 'sharing.invite-players-modal.link-copy-failed-title',
|
||||
defaultMessage: 'Failed to copy link',
|
||||
},
|
||||
avatarAlt: {
|
||||
id: 'sharing.invite-players-modal.avatar-alt',
|
||||
defaultMessage: "{username}'s avatar",
|
||||
},
|
||||
})
|
||||
|
||||
const friendsHeading = computed(
|
||||
() =>
|
||||
props.friendsLabel ??
|
||||
formatMessage(messages.friendsHeading, {
|
||||
count: props.friends.length,
|
||||
}),
|
||||
)
|
||||
const searchPlaceholderLabel = computed(
|
||||
() => props.searchPlaceholder ?? formatMessage(messages.searchPlaceholder),
|
||||
)
|
||||
const addButtonLabel = computed(() => props.addLabel ?? formatMessage(messages.addButton))
|
||||
const inviteButtonLabel = computed(() => props.inviteLabel ?? formatMessage(messages.inviteButton))
|
||||
const addedButtonLabel = computed(() => props.addedLabel ?? formatMessage(messages.addedButton))
|
||||
const cancelButtonLabel = computed(() => props.cancelLabel ?? formatMessage(messages.cancelButton))
|
||||
const requestedButtonLabel = computed(
|
||||
() => props.requestedLabel ?? formatMessage(messages.requestedButton),
|
||||
)
|
||||
const requestedTooltip = (username: string) =>
|
||||
formatMessage(messages.requestedTooltip, {
|
||||
username,
|
||||
})
|
||||
const emptyFriendsLabel = computed(
|
||||
() => props.emptyFriendsLabel ?? formatMessage(messages.noFriends),
|
||||
)
|
||||
const inviteLinkHeading = computed(() => formatMessage(messages.inviteLinkHeading))
|
||||
const linkExpiryDescription = computed(() => {
|
||||
if (!props.linkExpiresAt) return ''
|
||||
|
||||
const expiresAt = new Date(props.linkExpiresAt)
|
||||
if (Number.isNaN(expiresAt.getTime())) return ''
|
||||
const hours = Math.max(1, Math.ceil((expiresAt.getTime() - Date.now()) / 3_600_000))
|
||||
const duration =
|
||||
hours < 48 ? `${hours} ${hours === 1 ? 'hour' : 'hours'}` : `${Math.ceil(hours / 24)} days`
|
||||
|
||||
return formatMessage(messages.inviteExpiryDescription, { duration })
|
||||
})
|
||||
const inviteDisabledMessage = computed(
|
||||
() => props.inviteDisabledMessage ?? formatMessage(messages.alreadyInvited),
|
||||
)
|
||||
const {
|
||||
searchTarget,
|
||||
searchInputKey,
|
||||
searchOptions,
|
||||
searchLookupMessage,
|
||||
searchMinimumLength,
|
||||
usesRemoteSearch,
|
||||
passwordManagerIgnoreAttrs,
|
||||
sortedFriends,
|
||||
canInviteSearchTarget,
|
||||
searchInviteTooltip,
|
||||
findSearchUser,
|
||||
handleSearchInput,
|
||||
handleSearchSelect,
|
||||
inviteSearchTarget,
|
||||
resetSearch,
|
||||
} = useInvitePlayersSearch({
|
||||
friends: () => props.friends,
|
||||
suggestions: () => props.suggestions,
|
||||
searchUsers: () => props.searchUsers,
|
||||
canInvite: () => props.canInvite,
|
||||
inviteDisabledMessage,
|
||||
alreadyInvitedMessage: () => formatMessage(messages.alreadyInvited),
|
||||
searchingMessage: () => formatMessage(messages.searching),
|
||||
noResultsMessage: () => formatMessage(messages.noSearchResults),
|
||||
onInvite: (payload) => emit('invite', payload),
|
||||
})
|
||||
|
||||
function inviteFriend(friend: InvitePlayersUser) {
|
||||
emit('invite', {
|
||||
user: friend,
|
||||
source: 'friend',
|
||||
})
|
||||
}
|
||||
|
||||
function cancelInvite(friend: InvitePlayersUser) {
|
||||
emit('cancel', friend)
|
||||
}
|
||||
|
||||
async function copyInviteLink() {
|
||||
if (!props.link) return
|
||||
|
||||
emit('copy-link', props.link)
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.link)
|
||||
notificationManager?.addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.linkCopiedTitle),
|
||||
text: formatMessage(messages.linkCopiedText),
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
notificationManager?.addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.linkCopyFailedTitle),
|
||||
text: message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
resetSearch()
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" max-width="30rem">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.expiryLabel) }}</span>
|
||||
<DatePicker
|
||||
v-model="expiry"
|
||||
:disabled="saving"
|
||||
:min-date="minimumExpiry"
|
||||
:max-date="maximumExpiry"
|
||||
date-format="Y-m-d H:i"
|
||||
alt-format="F j, Y at h:i K"
|
||||
enable-time
|
||||
wrapper-class="w-full"
|
||||
input-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.maxUsesLabel) }}</span>
|
||||
<StyledInput
|
||||
v-model="maxUses"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="2147483647"
|
||||
:step="1"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button :disabled="saving" @click="modal?.hide()">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canSave" @click="save">
|
||||
<SpinnerIcon v-if="saving" class="animate-spin" aria-hidden="true" />
|
||||
<SaveIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.save) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import { injectNotificationManager } from '../../../providers'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import DatePicker from '../../base/DatePicker.vue'
|
||||
import StyledInput from '../../base/StyledInput.vue'
|
||||
import NewModal from '../../modal/NewModal.vue'
|
||||
import type { InviteLinkSettings } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
linkExpiresAt?: string | Date | null
|
||||
linkMaxUses: number
|
||||
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
|
||||
}>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const notificationManager = injectNotificationManager(null)
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const expiry = ref('')
|
||||
const maxUses = ref<number>()
|
||||
const minimumExpiry = ref(new Date())
|
||||
const maximumExpiry = ref(new Date())
|
||||
const saving = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'sharing.invite-players-modal.edit-invite-link-title',
|
||||
defaultMessage: 'Edit invite link',
|
||||
},
|
||||
expiryLabel: {
|
||||
id: 'sharing.invite-players-modal.expiry-label',
|
||||
defaultMessage: 'Expiry date',
|
||||
},
|
||||
maxUsesLabel: {
|
||||
id: 'sharing.invite-players-modal.max-uses-label',
|
||||
defaultMessage: 'Maximum uses',
|
||||
},
|
||||
cancel: {
|
||||
id: 'sharing.invite-players-modal.cancel-button',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
save: {
|
||||
id: 'sharing.invite-players-modal.save-button',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
failed: {
|
||||
id: 'sharing.invite-players-modal.update-invite-link-failed-title',
|
||||
defaultMessage: 'Failed to update invite link',
|
||||
},
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
const date = parseLocalDate(expiry.value)
|
||||
return (
|
||||
!saving.value &&
|
||||
!!date &&
|
||||
date >= minimumExpiry.value &&
|
||||
date <= maximumExpiry.value &&
|
||||
Number.isInteger(maxUses.value ?? 0) &&
|
||||
(maxUses.value ?? 0) > 0 &&
|
||||
(maxUses.value ?? 0) <= 2147483647
|
||||
)
|
||||
})
|
||||
|
||||
function formatLocalDate(date: Date) {
|
||||
const pad = (value: number) => value.toString().padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function parseLocalDate(value: string) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(value)
|
||||
if (!match) return null
|
||||
const [, year, month, day, hour, minute] = match
|
||||
const date = new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function show() {
|
||||
const now = new Date()
|
||||
minimumExpiry.value = new Date(now.getTime() + 3_600_000)
|
||||
minimumExpiry.value.setSeconds(0, 0)
|
||||
minimumExpiry.value.setMinutes(minimumExpiry.value.getMinutes() + 1)
|
||||
maximumExpiry.value = new Date(now.getTime() + 7 * 86_400_000)
|
||||
maximumExpiry.value.setSeconds(0, 0)
|
||||
const currentExpiry = props.linkExpiresAt ? new Date(props.linkExpiresAt) : maximumExpiry.value
|
||||
const date =
|
||||
Number.isNaN(currentExpiry.getTime()) || currentExpiry < minimumExpiry.value
|
||||
? minimumExpiry.value
|
||||
: currentExpiry > maximumExpiry.value
|
||||
? maximumExpiry.value
|
||||
: currentExpiry
|
||||
expiry.value = formatLocalDate(date)
|
||||
maxUses.value = props.linkMaxUses
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const date = parseLocalDate(expiry.value)
|
||||
if (!canSave.value || !date || !props.updateInviteLink) return
|
||||
saving.value = true
|
||||
try {
|
||||
await props.updateInviteLink({ expiresAt: date, maxUses: maxUses.value ?? 1 })
|
||||
modal.value?.hide()
|
||||
} catch (error) {
|
||||
notificationManager?.addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failed),
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-10 items-center justify-between gap-3 px-6 transition-colors hover:bg-surface-3"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<AutoLink
|
||||
v-tooltip="user.username"
|
||||
:to="profileLink"
|
||||
:target="profileTarget"
|
||||
class="inline-flex min-w-0 items-center gap-1.5"
|
||||
:class="profileLink ? 'text-primary hover:underline' : ''"
|
||||
>
|
||||
<span class="relative flex shrink-0">
|
||||
<Avatar
|
||||
:src="user.avatarUrl"
|
||||
:alt="avatarAlt"
|
||||
:tint-by="user.username"
|
||||
size="1.5rem"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span
|
||||
v-if="user.online"
|
||||
class="absolute bottom-[1.5px] right-[-1.5px] size-[9px] rounded-full border-[1.5px] border-solid border-surface-2 bg-brand"
|
||||
/>
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-base font-medium">
|
||||
{{ user.username }}
|
||||
</span>
|
||||
</AutoLink>
|
||||
</div>
|
||||
|
||||
<ButtonStyled v-if="status === 'added'" type="standard" color-fill="none">
|
||||
<button disabled>
|
||||
<CheckIcon aria-hidden="true" />
|
||||
{{ addedLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="status === 'pending'" type="outlined">
|
||||
<button @click="$emit('cancel', user)">
|
||||
{{ cancelLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<span v-else-if="status === 'requested'" v-tooltip="requestedTooltip" class="inline-flex">
|
||||
<ButtonStyled type="standard" color-fill="none">
|
||||
<button disabled>
|
||||
{{ requestedLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</span>
|
||||
<ButtonStyled v-else color-fill="none">
|
||||
<button @click="$emit('invite', user)">
|
||||
{{ inviteLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
import Avatar from '../../base/Avatar.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import type { InvitePlayersUser, InvitePlayersUserProfileLink } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
user: InvitePlayersUser
|
||||
avatarAlt: string
|
||||
addedLabel: string
|
||||
cancelLabel: string
|
||||
inviteLabel: string
|
||||
requestedLabel: string
|
||||
requestedTooltip: string
|
||||
userProfileLink?: (username: string) => InvitePlayersUserProfileLink
|
||||
}>(),
|
||||
{
|
||||
userProfileLink: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
invite: [user: InvitePlayersUser]
|
||||
cancel: [user: InvitePlayersUser]
|
||||
}>()
|
||||
|
||||
const status = computed(() => props.user.status ?? 'available')
|
||||
const profileLink = computed(() => getUserProfileLink(props.user.username))
|
||||
const profileTarget = computed(() =>
|
||||
typeof profileLink.value === 'string' && profileLink.value.startsWith('http')
|
||||
? '_blank'
|
||||
: undefined,
|
||||
)
|
||||
|
||||
function getUserProfileLink(username: string): InvitePlayersUserProfileLink {
|
||||
if (!username || username.includes('@')) return undefined
|
||||
return props.userProfileLink?.(username) ?? `/user/${encodeURIComponent(username)}`
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
export type InvitePlayersUserStatus = 'available' | 'requested' | 'pending' | 'added'
|
||||
export type InvitePlayersUserProfileLink =
|
||||
| RouteLocationRaw
|
||||
| (() => void | Promise<void>)
|
||||
| undefined
|
||||
|
||||
export interface InvitePlayersUser {
|
||||
id: string
|
||||
username: string
|
||||
avatarUrl?: string | null
|
||||
status?: InvitePlayersUserStatus
|
||||
online?: boolean
|
||||
}
|
||||
|
||||
export interface InvitePlayersSearchUser {
|
||||
id: string
|
||||
username: string
|
||||
avatarUrl?: string | null
|
||||
email?: string
|
||||
}
|
||||
|
||||
export interface InvitePlayersInvitePayload {
|
||||
user: InvitePlayersUser
|
||||
source: 'friend' | 'search'
|
||||
}
|
||||
|
||||
export interface InviteLinkSettings {
|
||||
expiresAt: Date
|
||||
maxUses: number
|
||||
}
|
||||
|
||||
export function normalizeInviteKey(value: string) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, type MaybeRefOrGetter, ref, toValue, watch } from 'vue'
|
||||
|
||||
import type { ComboboxOption } from '../../base/Combobox.vue'
|
||||
import {
|
||||
type InvitePlayersInvitePayload,
|
||||
type InvitePlayersSearchUser,
|
||||
type InvitePlayersUser,
|
||||
type InvitePlayersUserStatus,
|
||||
normalizeInviteKey,
|
||||
} from './types'
|
||||
|
||||
export function useInvitePlayersSearch(options: {
|
||||
friends: MaybeRefOrGetter<InvitePlayersUser[]>
|
||||
suggestions: MaybeRefOrGetter<InvitePlayersSearchUser[]>
|
||||
searchUsers: MaybeRefOrGetter<((query: string) => Promise<InvitePlayersSearchUser[]>) | undefined>
|
||||
canInvite: MaybeRefOrGetter<boolean>
|
||||
inviteDisabledMessage: MaybeRefOrGetter<string>
|
||||
alreadyInvitedMessage: MaybeRefOrGetter<string>
|
||||
searchingMessage: MaybeRefOrGetter<string>
|
||||
noResultsMessage: MaybeRefOrGetter<string>
|
||||
onInvite: (payload: InvitePlayersInvitePayload) => void
|
||||
}) {
|
||||
const searchTarget = ref('')
|
||||
const searchInputKey = ref(0)
|
||||
const selectedSearchUser = ref<InvitePlayersSearchUser | null>(null)
|
||||
const remoteSearchUsers = ref<InvitePlayersSearchUser[]>([])
|
||||
const searchLookupStatus = ref<'idle' | 'loading' | 'loaded'>('idle')
|
||||
const searchLookupRequestId = ref(0)
|
||||
const friendOrder = ref(new Map<string, number>())
|
||||
const searchMinimumLength = 1
|
||||
const passwordManagerIgnoreAttrs = {
|
||||
'data-1p-ignore': 'true',
|
||||
'data-bwignore': 'true',
|
||||
'data-form-type': 'other',
|
||||
'data-lpignore': 'true',
|
||||
'data-protonpass-ignore': 'true',
|
||||
}
|
||||
const normalizedSearchTarget = computed(() => searchTarget.value.trim())
|
||||
const usesRemoteSearch = computed(() => !!toValue(options.searchUsers))
|
||||
const searchLookupMessage = computed(() =>
|
||||
usesRemoteSearch.value && searchLookupStatus.value !== 'loaded'
|
||||
? toValue(options.searchingMessage)
|
||||
: toValue(options.noResultsMessage),
|
||||
)
|
||||
const searchableUsers = computed(() => {
|
||||
const users = new Map<string, InvitePlayersSearchUser>()
|
||||
for (const user of [...remoteSearchUsers.value, ...toValue(options.suggestions)]) {
|
||||
users.set(normalizeInviteKey(user.id), user)
|
||||
users.set(normalizeInviteKey(user.username), user)
|
||||
if (user.email) users.set(normalizeInviteKey(user.email), user)
|
||||
}
|
||||
return [...new Set(users.values())]
|
||||
})
|
||||
const searchOptions = computed<ComboboxOption<string>[]>(() =>
|
||||
searchableUsers.value.map((user) => ({
|
||||
value: user.username,
|
||||
label: user.username,
|
||||
searchTerms: [user.username, user.id, user.email].filter(Boolean) as string[],
|
||||
})),
|
||||
)
|
||||
const sortedFriends = computed(() =>
|
||||
toValue(options.friends)
|
||||
.map((friend, index) => ({
|
||||
friend,
|
||||
order: friendOrder.value.get(friend.id) ?? friendOrder.value.size + index,
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(({ friend }) => friend),
|
||||
)
|
||||
|
||||
function findSearchUser(value: string) {
|
||||
const normalizedValue = normalizeInviteKey(value)
|
||||
return searchableUsers.value.find(
|
||||
(user) =>
|
||||
normalizeInviteKey(user.username) === normalizedValue ||
|
||||
normalizeInviteKey(user.id) === normalizedValue ||
|
||||
(!!user.email && normalizeInviteKey(user.email) === normalizedValue),
|
||||
)
|
||||
}
|
||||
|
||||
const matchedSearchUser = computed(() => {
|
||||
if (
|
||||
selectedSearchUser.value &&
|
||||
normalizeInviteKey(selectedSearchUser.value.username) ===
|
||||
normalizeInviteKey(normalizedSearchTarget.value)
|
||||
) {
|
||||
return selectedSearchUser.value
|
||||
}
|
||||
return findSearchUser(normalizedSearchTarget.value)
|
||||
})
|
||||
const invitedUserKeys = computed(() => {
|
||||
const keys = new Set<string>()
|
||||
for (const friend of toValue(options.friends)) {
|
||||
if (friendStatus(friend) === 'available') continue
|
||||
keys.add(normalizeInviteKey(friend.id))
|
||||
keys.add(normalizeInviteKey(friend.username))
|
||||
}
|
||||
return keys
|
||||
})
|
||||
const searchTargetAlreadyInvited = computed(() => {
|
||||
const user = matchedSearchUser.value
|
||||
if (!user) return false
|
||||
return (
|
||||
invitedUserKeys.value.has(normalizeInviteKey(user.id)) ||
|
||||
invitedUserKeys.value.has(normalizeInviteKey(user.username))
|
||||
)
|
||||
})
|
||||
const canInviteSearchTarget = computed(
|
||||
() =>
|
||||
toValue(options.canInvite) &&
|
||||
normalizedSearchTarget.value.length >= searchMinimumLength &&
|
||||
!!matchedSearchUser.value &&
|
||||
!searchTargetAlreadyInvited.value &&
|
||||
(!usesRemoteSearch.value ||
|
||||
searchLookupStatus.value === 'loaded' ||
|
||||
!!selectedSearchUser.value),
|
||||
)
|
||||
const searchInviteTooltip = computed(() => {
|
||||
if (!toValue(options.canInvite)) return toValue(options.inviteDisabledMessage)
|
||||
if (searchTargetAlreadyInvited.value) return toValue(options.alreadyInvitedMessage)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const searchTargetUsers = useDebounceFn(async (query: string, requestId: number) => {
|
||||
const searchUsers = toValue(options.searchUsers)
|
||||
if (!searchUsers) return
|
||||
try {
|
||||
const users = await searchUsers(query)
|
||||
if (requestId !== searchLookupRequestId.value || query !== normalizedSearchTarget.value)
|
||||
return
|
||||
remoteSearchUsers.value = users
|
||||
} catch {
|
||||
if (requestId !== searchLookupRequestId.value || query !== normalizedSearchTarget.value)
|
||||
return
|
||||
remoteSearchUsers.value = []
|
||||
} finally {
|
||||
if (requestId === searchLookupRequestId.value && query === normalizedSearchTarget.value) {
|
||||
searchLookupStatus.value = 'loaded'
|
||||
}
|
||||
}
|
||||
}, 250)
|
||||
|
||||
function friendStatus(friend: InvitePlayersUser): InvitePlayersUserStatus {
|
||||
return friend.status ?? 'available'
|
||||
}
|
||||
|
||||
function syncFriendOrder(friends: InvitePlayersUser[]) {
|
||||
const nextOrder = new Map(friendOrder.value)
|
||||
let nextIndex = nextOrder.size
|
||||
const unorderedFriends = friends.filter((friend) => !nextOrder.has(friend.id))
|
||||
if (unorderedFriends.length === 0) return
|
||||
for (const friend of unorderedFriends) {
|
||||
nextOrder.set(friend.id, nextIndex)
|
||||
nextIndex += 1
|
||||
}
|
||||
friendOrder.value = nextOrder
|
||||
}
|
||||
|
||||
function handleSearchInput(value: string) {
|
||||
searchTarget.value = value
|
||||
selectedSearchUser.value = null
|
||||
remoteSearchUsers.value = []
|
||||
searchLookupRequestId.value += 1
|
||||
if (normalizedSearchTarget.value.length < searchMinimumLength) {
|
||||
searchLookupStatus.value = 'idle'
|
||||
return
|
||||
}
|
||||
if (!usesRemoteSearch.value) {
|
||||
searchLookupStatus.value = 'loaded'
|
||||
return
|
||||
}
|
||||
searchLookupStatus.value = 'loading'
|
||||
void searchTargetUsers(normalizedSearchTarget.value, searchLookupRequestId.value)
|
||||
}
|
||||
|
||||
function handleSearchSelect(option: ComboboxOption<string>) {
|
||||
searchTarget.value = option.value
|
||||
selectedSearchUser.value = findSearchUser(option.value) ?? null
|
||||
searchLookupStatus.value = 'loaded'
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
searchTarget.value = ''
|
||||
searchInputKey.value += 1
|
||||
selectedSearchUser.value = null
|
||||
remoteSearchUsers.value = []
|
||||
searchLookupStatus.value = 'idle'
|
||||
searchLookupRequestId.value += 1
|
||||
}
|
||||
|
||||
function inviteSearchTarget() {
|
||||
if (!canInviteSearchTarget.value) return
|
||||
const user = matchedSearchUser.value
|
||||
if (!user) return
|
||||
options.onInvite({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
},
|
||||
source: 'search',
|
||||
})
|
||||
resetSearch()
|
||||
}
|
||||
|
||||
watch(() => toValue(options.friends), syncFriendOrder, { immediate: true })
|
||||
|
||||
return {
|
||||
searchTarget,
|
||||
searchInputKey,
|
||||
searchOptions,
|
||||
searchLookupMessage,
|
||||
searchMinimumLength,
|
||||
usesRemoteSearch,
|
||||
passwordManagerIgnoreAttrs,
|
||||
sortedFriends,
|
||||
canInviteSearchTarget,
|
||||
searchInviteTooltip,
|
||||
findSearchUser,
|
||||
handleSearchInput,
|
||||
handleSearchSelect,
|
||||
inviteSearchTarget,
|
||||
resetSearch,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+31
-9
@@ -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>
|
||||
+4
@@ -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
|
||||
|
||||
+16
-5
@@ -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', {
|
||||
|
||||
+90
-26
@@ -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<
|
||||
|
||||
+200
-39
@@ -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"
|
||||
|
||||
+8
@@ -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 {
|
||||
|
||||
@@ -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": "ماينكرافت: إصدار جافا"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 it’s 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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -257,9 +257,6 @@
|
||||
"content.inline-backup.create-backup": {
|
||||
"defaultMessage": "צור גיבוי"
|
||||
},
|
||||
"content.modpack-card.dismiss-hint": {
|
||||
"defaultMessage": "אל תציג שוב"
|
||||
},
|
||||
"content.page-layout.additional-content": {
|
||||
"defaultMessage": "תוכן נוסף"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "追加のコンテンツ"
|
||||
},
|
||||
|
||||
@@ -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": "추가 콘텐츠"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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ść"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "Дополнительный контент"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Додатковий уміст"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "类型"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "類型"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user