feat:移除了弹窗,服务器添加sls
This commit is contained in:
commit
6a295f9a7a
4082 files changed
+1322534
No files matched your search
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="localizeIfPossible(title)" fade="warning" max-width="500px">
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition :type="admonitionType" :header="localizeIfPossible(header)">
|
||||
{{ localizeIfPossible(body) }}
|
||||
</Admonition>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="cancel">
|
||||
<XIcon />
|
||||
{{ localizeIfPossible(stayLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="leave">
|
||||
<RightArrowIcon />
|
||||
{{ localizeIfPossible(leaveLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
import NewModal from './NewModal.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: MessageDescriptor | string
|
||||
header?: MessageDescriptor | string
|
||||
body?: MessageDescriptor | string
|
||||
stayLabel?: MessageDescriptor | string
|
||||
leaveLabel?: MessageDescriptor | string
|
||||
admonitionType?: 'warning' | 'critical' | 'info'
|
||||
}>(),
|
||||
{
|
||||
title: () =>
|
||||
defineMessage({
|
||||
id: 'ui.confirm-leave-modal.title',
|
||||
defaultMessage: 'Leave page?',
|
||||
}),
|
||||
header: () =>
|
||||
defineMessage({
|
||||
id: 'ui.confirm-leave-modal.header',
|
||||
defaultMessage: 'You have unsaved changes',
|
||||
}),
|
||||
body: () =>
|
||||
defineMessage({
|
||||
id: 'ui.confirm-leave-modal.body',
|
||||
defaultMessage: 'You have unsaved changes that will be lost if you leave this page.',
|
||||
}),
|
||||
stayLabel: () =>
|
||||
defineMessage({
|
||||
id: 'ui.confirm-leave-modal.stay',
|
||||
defaultMessage: 'Stay on page',
|
||||
}),
|
||||
leaveLabel: () =>
|
||||
defineMessage({
|
||||
id: 'ui.confirm-leave-modal.leave',
|
||||
defaultMessage: 'Leave page',
|
||||
}),
|
||||
admonitionType: 'critical',
|
||||
},
|
||||
)
|
||||
|
||||
function localizeIfPossible(message: MessageDescriptor | string) {
|
||||
return typeof message === 'string' ? message : formatMessage(message)
|
||||
}
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
let resolvePromise: ((value: boolean) => void) | null = null
|
||||
|
||||
function prompt(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
modal.value?.show()
|
||||
})
|
||||
}
|
||||
|
||||
function leave() {
|
||||
modal.value?.hide()
|
||||
resolvePromise?.(true)
|
||||
resolvePromise = null
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
modal.value?.hide()
|
||||
resolvePromise?.(false)
|
||||
resolvePromise = null
|
||||
}
|
||||
|
||||
defineExpose({ prompt })
|
||||
</script>
|
||||
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:noblur="noblur"
|
||||
:fade="danger ? 'danger' : 'standard'"
|
||||
:on-hide="onHide"
|
||||
max-width="550px"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="title">
|
||||
<span class="font-extrabold text-contrast text-lg">{{ title }}</span>
|
||||
</slot>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<template v-if="description">
|
||||
<div
|
||||
v-if="markdown"
|
||||
class="markdown-body max-w-[35rem]"
|
||||
v-html="renderString(description)"
|
||||
/>
|
||||
<p v-else class="max-w-[35rem] m-0">
|
||||
{{ description }}
|
||||
</p>
|
||||
</template>
|
||||
<slot />
|
||||
<label v-if="hasToType" for="confirmation">
|
||||
<span>
|
||||
To confirm you want to proceed, type
|
||||
<span class="italic font-bold">{{ confirmationText }}</span> below:
|
||||
</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
v-if="hasToType"
|
||||
id="confirmation"
|
||||
v-model="confirmation_typed"
|
||||
placeholder="Type here..."
|
||||
wrapper-class="max-w-[20rem]"
|
||||
/>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled>
|
||||
<button class="!shadow-none" @click="hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled :color="danger ? 'red' : 'brand'">
|
||||
<button :disabled="action_disabled" @click="proceed">
|
||||
<component :is="proceedIcon" />
|
||||
{{ proceedLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import StyledInput from '../base/StyledInput.vue'
|
||||
import NewModal from './NewModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
confirmationText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasToType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'No title defined',
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
proceedIcon: {
|
||||
type: Object,
|
||||
default: () => TrashIcon,
|
||||
},
|
||||
proceedLabel: {
|
||||
type: String,
|
||||
default: 'Proceed',
|
||||
},
|
||||
noblur: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
danger: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
onHide: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
markdown: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['proceed'])
|
||||
const modal = ref(null)
|
||||
|
||||
const confirmation_typed = ref('')
|
||||
|
||||
const action_disabled = computed(
|
||||
() =>
|
||||
props.hasToType &&
|
||||
confirmation_typed.value.toLowerCase() !== props.confirmationText.toLowerCase(),
|
||||
)
|
||||
|
||||
function proceed() {
|
||||
modal.value.hide()
|
||||
confirmation_typed.value = ''
|
||||
emit('proceed')
|
||||
}
|
||||
|
||||
function show() {
|
||||
modal.value.show()
|
||||
}
|
||||
function hide() {
|
||||
modal.value.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
<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>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon, XCircleIcon } from '@modrinth/assets'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
error?: boolean
|
||||
}>(),
|
||||
{
|
||||
error: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex items-center gap-2 font-semibold" :class="error ? 'text-red' : 'animate-pulse'">
|
||||
<XCircleIcon v-if="error" class="w-6 h-6" />
|
||||
<SpinnerIcon v-else class="w-6 h-6 animate-spin" />
|
||||
<slot v-if="error" name="error" />
|
||||
<slot v-else />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
scale: 1;
|
||||
}
|
||||
50% {
|
||||
scale: 0.95;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,507 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="modal-root">
|
||||
<div
|
||||
:class="{ shown: visible }"
|
||||
class="tauri-overlay"
|
||||
data-tauri-drag-region
|
||||
@click="() => (closeOnClickOutside && closable ? hide() : {})"
|
||||
/>
|
||||
<div
|
||||
:class="[
|
||||
'modal-overlay',
|
||||
{
|
||||
shown: visible,
|
||||
noblur: effectiveNoblur,
|
||||
},
|
||||
computedFade,
|
||||
]"
|
||||
@click="() => (closeOnClickOutside && closable ? hide() : {})"
|
||||
/>
|
||||
<div class="modal-container" :class="{ shown: visible }">
|
||||
<div
|
||||
ref="modalBodyRef"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="headerId"
|
||||
class="modal-body flex flex-col bg-surface-3 rounded-2xl border border-solid border-surface-5"
|
||||
v-bind="$attrs"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<div
|
||||
v-if="!hideHeader"
|
||||
data-tauri-drag-region
|
||||
class="grid grid-cols-[auto_min-content] items-center gap-4 p-6 border-solid border-0 border-b-[1px] border-surface-5 max-w-full"
|
||||
>
|
||||
<div class="flex text-wrap break-words items-center gap-3 min-w-0">
|
||||
<slot name="title">
|
||||
<span v-if="header" :id="headerId" class="text-2xl font-semibold text-contrast">
|
||||
{{ header }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
<ButtonStyled v-if="closable" circular>
|
||||
<button
|
||||
v-tooltip="closeLabel"
|
||||
:aria-label="closeLabel"
|
||||
:disabled="disableClose"
|
||||
@click="hide"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<ButtonStyled v-if="props.mergeHeader && closable" circular>
|
||||
<button
|
||||
v-tooltip="closeLabel"
|
||||
class="absolute top-4 right-4 z-10"
|
||||
:aria-label="closeLabel"
|
||||
:disabled="disableClose"
|
||||
@click="hide"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<div v-if="scrollable" class="relative flex-1 min-h-0 flex flex-col">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-6"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-6"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-6 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
data-modal-content
|
||||
:class="[
|
||||
'flex-1 min-h-0',
|
||||
props.noPadding ? '' : 'overflow-y-auto p-6 !pb-1 sm:pb-6',
|
||||
{ 'pt-12': props.mergeHeader && closable && !props.noPadding },
|
||||
]"
|
||||
:style="props.noPadding ? {} : { maxHeight: maxContentHeight }"
|
||||
@scroll="checkScrollState"
|
||||
>
|
||||
<slot> You just lost the game.</slot>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-6"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-6"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-6 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
data-modal-content
|
||||
:class="[
|
||||
'min-h-0',
|
||||
props.noPadding ? '' : 'overflow-y-auto p-6',
|
||||
{ 'pt-12': props.mergeHeader && closable && !props.noPadding },
|
||||
]"
|
||||
>
|
||||
<slot> You just lost the game.</slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
:class="{ 'pt-4 border-0 border-t border-solid border-surface-5': actionsDivider }"
|
||||
class="p-4"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useVIntl } from '../../composables/i18n'
|
||||
import { useModalStack } from '../../composables/modal-stack'
|
||||
import { useScrollIndicator } from '../../composables/scroll-indicator'
|
||||
import { injectModalBehavior } from '../../providers'
|
||||
import { commonMessages } from '../../utils/common-messages'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const modalBehavior = injectModalBehavior(null)
|
||||
const {
|
||||
push: pushModal,
|
||||
pop: popModal,
|
||||
isTopmost: isTopmostModal,
|
||||
stackSize: modalStackSize,
|
||||
} = useModalStack()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
noblur?: boolean
|
||||
closable?: boolean
|
||||
/** @deprecated Use `fade="danger"` instead */
|
||||
danger?: boolean
|
||||
fade?: 'standard' | 'warning' | 'danger'
|
||||
closeOnEsc?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
warnOnClose?: boolean
|
||||
header?: string
|
||||
hideHeader?: boolean
|
||||
onHide?: () => void
|
||||
onShow?: () => void
|
||||
mergeHeader?: boolean
|
||||
scrollable?: boolean
|
||||
maxContentHeight?: string
|
||||
/** Removes padding from the content area. Useful for edge-to-edge layouts. */
|
||||
noPadding?: boolean
|
||||
/** Max width for the modal (e.g., '460px', '600px'). Defaults to '60rem'. */
|
||||
maxWidth?: string
|
||||
/** Width for the modal body (e.g., '460px', '600px'). */
|
||||
width?: string
|
||||
/** Disables all close actions (close button, ESC key, click outside). */
|
||||
disableClose?: boolean
|
||||
actionsDivider?: boolean
|
||||
onAfterHide?: () => void
|
||||
}>(),
|
||||
{
|
||||
type: true,
|
||||
noblur: undefined,
|
||||
closable: true,
|
||||
danger: false,
|
||||
fade: undefined,
|
||||
closeOnClickOutside: false,
|
||||
closeOnEsc: true,
|
||||
warnOnClose: false,
|
||||
header: undefined,
|
||||
hideHeader: false,
|
||||
onHide: () => {},
|
||||
onShow: () => {},
|
||||
mergeHeader: false,
|
||||
// TODO: migrate all modals to use scrollable and remove this prop
|
||||
scrollable: false,
|
||||
maxContentHeight: '70vh',
|
||||
noPadding: false,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
disableClose: false,
|
||||
actionsDivider: false,
|
||||
},
|
||||
)
|
||||
|
||||
const effectiveNoblur = computed(() => props.noblur ?? modalBehavior?.noblur.value ?? false)
|
||||
|
||||
const computedFade = computed(() => {
|
||||
if (props.fade) return props.fade
|
||||
if (props.danger) return 'danger'
|
||||
return 'standard'
|
||||
})
|
||||
|
||||
const modalId = `modal-${Math.random().toString(36).slice(2, 9)}`
|
||||
const headerId = `${modalId}-header`
|
||||
const closeLabel = computed(() => formatMessage(commonMessages.closeButton))
|
||||
|
||||
const open = ref(false)
|
||||
const visible = ref(false)
|
||||
const stackDepth = ref(0)
|
||||
const modalBodyRef = ref<HTMLElement | null>(null)
|
||||
let previousFocusEl: Element | null = null
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(scrollContainer)
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
function getFocusableElements(): HTMLElement[] {
|
||||
if (!modalBodyRef.value) return []
|
||||
return Array.from(modalBodyRef.value.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
props.onShow?.()
|
||||
const wasEmpty = modalStackSize() === 0
|
||||
stackDepth.value = modalStackSize()
|
||||
open.value = true
|
||||
previousFocusEl = document.activeElement
|
||||
pushModal()
|
||||
if (wasEmpty) modalBehavior?.onShow?.()
|
||||
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleWindowKeyDown)
|
||||
window.addEventListener('mousedown', updateMousePosition)
|
||||
if (event) {
|
||||
updateMousePosition(event)
|
||||
} else {
|
||||
mouseX.value = Math.round(window.innerWidth / 2)
|
||||
mouseY.value = Math.round(window.innerHeight / 2)
|
||||
}
|
||||
setTimeout(() => {
|
||||
visible.value = true
|
||||
nextTick(() => {
|
||||
const focusable = getFocusableElements()
|
||||
if (focusable.length > 0) {
|
||||
focusable[0].focus()
|
||||
} else {
|
||||
modalBodyRef.value?.focus()
|
||||
}
|
||||
})
|
||||
}, 50)
|
||||
}
|
||||
|
||||
async function hide() {
|
||||
if (props.disableClose) {
|
||||
return
|
||||
}
|
||||
props.onHide?.()
|
||||
resetMousePosition()
|
||||
visible.value = false
|
||||
popModal()
|
||||
if (modalStackSize() === 0) {
|
||||
modalBehavior?.onHide?.()
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
window.removeEventListener('mousedown', updateMousePosition)
|
||||
if (previousFocusEl instanceof HTMLElement) {
|
||||
previousFocusEl.focus()
|
||||
}
|
||||
previousFocusEl = null
|
||||
//把原有300ms等待挪出来,避免闪烁
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 300)
|
||||
})
|
||||
open.value = false
|
||||
await nextTick()
|
||||
props.onAfterHide?.()
|
||||
}
|
||||
|
||||
async function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
|
||||
await nextTick()
|
||||
if (!scrollContainer.value) return
|
||||
|
||||
scrollContainer.value.scrollTo({
|
||||
top: scrollContainer.value.scrollHeight,
|
||||
behavior,
|
||||
})
|
||||
requestAnimationFrame(checkScrollState)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
checkScrollState,
|
||||
scrollToBottom,
|
||||
})
|
||||
|
||||
const mouseX = ref(0)
|
||||
const mouseY = ref(0)
|
||||
|
||||
const MODAL_STACK_BASE_Z = 100
|
||||
const stackZBase = computed(() => MODAL_STACK_BASE_Z + stackDepth.value * 10)
|
||||
const stackOverlayZ = computed(() => stackZBase.value + 19)
|
||||
const stackTauriZ = computed(() => stackZBase.value + 20)
|
||||
const stackContainerZ = computed(() => stackZBase.value + 21)
|
||||
const resolvedMaxWidth = computed(() => props.maxWidth ?? '60rem')
|
||||
const resolvedWidth = computed(() => props.width ?? 'fit-content')
|
||||
const mouseXOffset = computed(() => `calc((-50vw + ${mouseX.value}px) / 16)`)
|
||||
const mouseYOffset = computed(() => `calc((-50vh + ${mouseY.value}px) / 16)`)
|
||||
|
||||
function updateMousePosition(event: { clientX: number; clientY: number }) {
|
||||
mouseX.value = event.clientX
|
||||
mouseY.value = event.clientY
|
||||
}
|
||||
|
||||
function resetMousePosition() {
|
||||
mouseX.value = Math.round(window.innerWidth / 2)
|
||||
mouseY.value = Math.round(window.innerHeight / 2)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (open.value) {
|
||||
popModal()
|
||||
window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
window.removeEventListener('mousedown', updateMousePosition)
|
||||
if (modalStackSize() === 0) {
|
||||
document.body.style.overflow = ''
|
||||
modalBehavior?.onHide?.()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function handleWindowKeyDown(event: KeyboardEvent) {
|
||||
if (props.closeOnEsc && event.key === 'Escape' && props.closable) {
|
||||
if (!isTopmostModal()) return
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Tab') {
|
||||
const focusable = getFocusableElements()
|
||||
if (focusable.length === 0) return
|
||||
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tauri-overlay {
|
||||
position: fixed;
|
||||
visibility: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
z-index: v-bind(stackTauriZ);
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: -5rem;
|
||||
z-index: v-bind(stackOverlayZ);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: all 0.2s ease-out;
|
||||
//transform: translate(
|
||||
// calc((-50vw + var(--_mouse-x, 50vw) * 1px) / 2),
|
||||
// calc((-50vh + var(--_mouse-y, 50vh) * 1px) / 2)
|
||||
// )
|
||||
// scaleX(0.8) scaleY(0.5);
|
||||
border-radius: 180px;
|
||||
//filter: blur(5px);
|
||||
|
||||
// Fade variants
|
||||
&.standard {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--color-brand-shadow) 10%, rgba(0, 0, 0, 0.52)) 0%,
|
||||
color-mix(in srgb, var(--color-brand-shadow) 2%, rgba(0, 0, 0, 0.95)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: linear-gradient(to bottom, rgba(48, 38, 29, 0.52) 0%, rgba(26, 20, 14, 0.95) 100%);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: linear-gradient(to bottom, rgba(43, 18, 26, 0.52) 0%, rgba(49, 10, 15, 0.95) 100%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
&.noblur {
|
||||
backdrop-filter: none;
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modrinth-parent__no-modal-blurs {
|
||||
.modal-overlay {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: v-bind(stackContainerZ);
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translate(v-bind(mouseXOffset), v-bind(mouseYOffset));
|
||||
transition: none;
|
||||
|
||||
&.shown {
|
||||
visibility: visible;
|
||||
transform: translate(0, 0);
|
||||
transition: all 0.2s ease-out;
|
||||
|
||||
> .modal-body {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
|
||||
> .modal-body {
|
||||
position: fixed;
|
||||
box-shadow: 4px 4px 26px 10px rgba(0, 0, 0, 0.08);
|
||||
max-height: calc(100% - 2 * var(--gap-lg));
|
||||
max-width: min(v-bind(resolvedMaxWidth), calc(100% - 2 * var(--gap-lg)));
|
||||
overflow-y: hidden;
|
||||
overflow-x: hidden;
|
||||
width: v-bind(resolvedWidth);
|
||||
pointer-events: auto;
|
||||
scale: 0.97;
|
||||
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
width: calc(100% - 2 * var(--gap-lg));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div v-if="open" class="open-in-app-modal">
|
||||
<div :class="{ shown: visible }" class="fullscreen-overlay" @click="hide" />
|
||||
<div class="modal-content" :class="{ shown: visible }">
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div v-if="countdown > 0" class="countdown-container">
|
||||
<svg class="countdown-svg" viewBox="0 0 100 100">
|
||||
<circle
|
||||
class="stroke-surface-4"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke-width="6"
|
||||
/>
|
||||
<circle
|
||||
class="countdown-progress stroke-green"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke-width="6"
|
||||
:stroke-dasharray="circumference"
|
||||
:stroke-dashoffset="strokeDashoffset"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-5xl font-bold text-contrast z-[1]">{{ countdown }}</span>
|
||||
</div>
|
||||
|
||||
<h2 class="m-0 text-3xl font-bold text-contrast text-center">
|
||||
{{ formatMessage(messages.openingApp) }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 bg-surface-3 rounded-3xl border border-solid border-surface-5 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3 w-full">
|
||||
<Avatar :src="serverProject.icon" :alt="serverProject.name" size="48px" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-semibold text-contrast">{{ serverProject.name }}</span>
|
||||
<div class="flex items-center gap-2 text-secondary">
|
||||
<ServerOnlinePlayers
|
||||
:online="serverProject.numPlayers ?? 0"
|
||||
:status-online="serverProject.statusOnline"
|
||||
/>
|
||||
<ServerRegion v-if="serverProject.region" :region="serverProject.region" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col text-left gap-3">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.whyUseApp)
|
||||
}}</span>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex text-base gap-2 items-center">
|
||||
<div
|
||||
class="w-5 h-5 border border-solid rounded-full flex items-center justify-center border-brand bg-brand-highlight text-brand"
|
||||
>
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<span>{{ formatMessage(messages.benefitLaunch) }}</span>
|
||||
</div>
|
||||
<div class="flex text-base gap-2 items-center">
|
||||
<div
|
||||
class="w-5 h-5 border border-solid rounded-full flex items-center justify-center border-brand bg-brand-highlight text-brand"
|
||||
>
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<span>{{ formatMessage(messages.benefitInstall) }}</span>
|
||||
</div>
|
||||
<div class="flex text-base gap-2 items-center">
|
||||
<div
|
||||
class="w-5 h-5 border border-solid rounded-full flex items-center justify-center border-brand bg-brand-highlight text-brand"
|
||||
>
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<span>{{ formatMessage(messages.benefitUpdate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span v-if="countdown > 0" class="text-secondary">{{
|
||||
formatMessage(messages.openingAutomatically)
|
||||
}}</span>
|
||||
<div v-else class="grid grid-cols-2 gap-2 w-full">
|
||||
<ButtonStyled>
|
||||
<button class="flex-1" @click="hide">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.closeButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<a
|
||||
class="flex-1"
|
||||
href="https://modrinth.com/app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.getApp) }}
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { commonMessages } from '@modrinth/ui'
|
||||
import { computed, nextTick, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { Avatar, ButtonStyled } from '../base'
|
||||
import ServerOnlinePlayers from '../project/server/ServerOnlinePlayers.vue'
|
||||
import ServerRegion from '../project/server/ServerRegion.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
openingApp: {
|
||||
id: 'modal.open-in-app.title',
|
||||
defaultMessage: 'Opening Modrinth App',
|
||||
},
|
||||
whyUseApp: {
|
||||
id: 'modal.open-in-app.why-use',
|
||||
defaultMessage: 'Why use the Modrinth App',
|
||||
},
|
||||
benefitLaunch: {
|
||||
id: 'modal.open-in-app.benefit.launch',
|
||||
defaultMessage: 'Launch the game straight into the server',
|
||||
},
|
||||
benefitInstall: {
|
||||
id: 'modal.open-in-app.benefit.install',
|
||||
defaultMessage: 'Automatically install required content',
|
||||
},
|
||||
benefitUpdate: {
|
||||
id: 'modal.open-in-app.benefit.update',
|
||||
defaultMessage: 'Keep files updated when the server changes',
|
||||
},
|
||||
openingAutomatically: {
|
||||
id: 'modal.open-in-app.opening-automatically',
|
||||
defaultMessage: 'The Modrinth App will open automatically...',
|
||||
},
|
||||
getApp: {
|
||||
id: 'modal.open-in-app.get-app',
|
||||
defaultMessage: 'Get Modrinth App',
|
||||
},
|
||||
})
|
||||
|
||||
export interface ServerProject {
|
||||
name: string
|
||||
slug?: string
|
||||
numPlayers?: number
|
||||
icon?: string
|
||||
statusOnline?: boolean
|
||||
region?: string
|
||||
}
|
||||
|
||||
const open = ref(false)
|
||||
const visible = ref(false)
|
||||
const countdown = ref(3)
|
||||
const countdownProgress = ref(1)
|
||||
let countdownInterval: ReturnType<typeof setInterval> | null = null
|
||||
let progressInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const circumference = 2 * Math.PI * 45
|
||||
const strokeDashoffset = computed(() => {
|
||||
return circumference * (1 - countdownProgress.value)
|
||||
})
|
||||
|
||||
const serverProject = ref<ServerProject>({
|
||||
name: '',
|
||||
slug: '',
|
||||
numPlayers: 0,
|
||||
icon: undefined,
|
||||
statusOnline: false,
|
||||
region: '',
|
||||
})
|
||||
const appLink = computed(() => {
|
||||
return `modrinth://server/${serverProject.value.slug}`
|
||||
})
|
||||
|
||||
function startCountdown() {
|
||||
countdown.value = 3
|
||||
countdownProgress.value = 1
|
||||
|
||||
const totalDuration = 3000
|
||||
const progressUpdateInterval = 16
|
||||
const progressDecrement = progressUpdateInterval / totalDuration
|
||||
|
||||
progressInterval = setInterval(() => {
|
||||
countdownProgress.value = Math.max(0, countdownProgress.value - progressDecrement)
|
||||
}, progressUpdateInterval)
|
||||
|
||||
countdownInterval = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
stopCountdown()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval)
|
||||
countdownInterval = null
|
||||
}
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval)
|
||||
progressInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
interface ShowOpenInAppOptions {
|
||||
serverProject: ServerProject
|
||||
}
|
||||
|
||||
async function show(options: ShowOpenInAppOptions) {
|
||||
serverProject.value = options.serverProject
|
||||
|
||||
await nextTick()
|
||||
|
||||
window.open(appLink.value, '_self')
|
||||
open.value = true
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
setTimeout(() => {
|
||||
visible.value = true
|
||||
startCountdown()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible.value = false
|
||||
document.body.style.overflow = ''
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
stopCountdown()
|
||||
setTimeout(() => {
|
||||
open.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopCountdown()
|
||||
})
|
||||
|
||||
defineExpose({ show, hide, open })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.open-in-app-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fullscreen-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom, rgba(66, 131, 92, 0.23) 0%, rgba(17, 35, 43, 0.4) 97%);
|
||||
backdrop-filter: blur(12px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-out;
|
||||
cursor: pointer;
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 2.5rem;
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition: all 0.3s ease-out;
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.countdown-container {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.countdown-svg {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.countdown-progress {
|
||||
transition: stroke-dashoffset 0.05s linear;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,284 @@
|
||||
<script setup>
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
ExternalIcon,
|
||||
GlobeIcon,
|
||||
MailIcon,
|
||||
MastodonIcon,
|
||||
RedditIcon,
|
||||
ShareIcon,
|
||||
TwitterIcon,
|
||||
} from '@modrinth/assets'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { injectNotificationManager } from '#ui/providers'
|
||||
|
||||
import { ButtonStyled, NewModal, StyledInput } from '../index'
|
||||
|
||||
const props = defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: 'Share',
|
||||
},
|
||||
shareTitle: {
|
||||
type: String,
|
||||
default: 'Modrinth',
|
||||
},
|
||||
shareText: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
link: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
openInNewTab: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
noblur: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
socialButtons: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
onHide: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const shareModal = ref(null)
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const qrCode = ref(null)
|
||||
const qrImage = ref(null)
|
||||
const content = ref(null)
|
||||
const url = ref(null)
|
||||
const canShare = ref(false)
|
||||
const share = () => {
|
||||
navigator.share(
|
||||
props.link
|
||||
? {
|
||||
title: props.shareTitle.toString(),
|
||||
text: props.shareText,
|
||||
url: url.value,
|
||||
}
|
||||
: {
|
||||
title: props.shareTitle.toString(),
|
||||
text: content.value,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const show = async (passedContent) => {
|
||||
content.value = props.shareText ? `${props.shareText}\n\n${passedContent}` : passedContent
|
||||
shareModal.value.show()
|
||||
if (props.link) {
|
||||
url.value = passedContent
|
||||
nextTick(() => {
|
||||
console.log(qrCode.value)
|
||||
fetch(qrCode.value.getElementsByTagName('canvas')[0].toDataURL('image/png'))
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
console.log(blob)
|
||||
qrImage.value = blob
|
||||
})
|
||||
})
|
||||
}
|
||||
if (navigator.canShare({ title: props.shareTitle.toString(), text: content.value })) {
|
||||
canShare.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const copyImage = async () => {
|
||||
const item = new ClipboardItem({ 'image/png': qrImage.value })
|
||||
await navigator.clipboard.write([item])
|
||||
}
|
||||
|
||||
const copyText = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url.value ?? content.value)
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Link copied',
|
||||
text: 'The link has been copied to your clipboard.',
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to copy text',
|
||||
text: message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sendEmail = computed(
|
||||
() =>
|
||||
`mailto:user@test.com
|
||||
?subject=${encodeURIComponent(props.shareTitle)}
|
||||
&body=${encodeURIComponent(content.value)}`,
|
||||
)
|
||||
|
||||
const targetParameter = computed(() => (props.openInNewTab ? '_blank' : '_self'))
|
||||
|
||||
const sendTweet = computed(
|
||||
() => `https://twitter.com/intent/tweet?text=${encodeURIComponent(content.value)}`,
|
||||
)
|
||||
|
||||
const sendToot = computed(() => `https://tootpick.org/#text=${encodeURIComponent(content.value)}`)
|
||||
|
||||
const postOnReddit = computed(
|
||||
() =>
|
||||
`https://www.reddit.com/submit?title=${encodeURIComponent(props.shareTitle)}&text=${encodeURIComponent(
|
||||
content.value,
|
||||
)}`,
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="shareModal" :header="header" :noblur="noblur" :on-hide="onHide">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div
|
||||
:class="['flex items-center justify-center', link ? 'flex-wrap gap-4' : 'flex-col gap-2']"
|
||||
>
|
||||
<div v-if="link" class="group relative shrink-0">
|
||||
<div ref="qrCode">
|
||||
<QrcodeVue :value="url" class="!bg-white rounded-[var(--radius-md)]" margin="3" />
|
||||
</div>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="'Copy QR code'"
|
||||
class="absolute top-0 right-0 m-2"
|
||||
aria-label="Copy QR code"
|
||||
@click="copyImage"
|
||||
>
|
||||
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<StyledInput
|
||||
v-else
|
||||
v-model="content"
|
||||
multiline
|
||||
resize="vertical"
|
||||
wrapper-class="h-full w-[30rem]"
|
||||
>
|
||||
<template #right>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="'Copy Text'"
|
||||
type="button"
|
||||
aria-label="Copy Text"
|
||||
class="absolute top-0 right-0 m-2"
|
||||
@click="copyText"
|
||||
>
|
||||
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</StyledInput>
|
||||
<div
|
||||
v-if="link || socialButtons"
|
||||
:class="['flex flex-col justify-center gap-2', link ? 'w-64 max-w-full' : 'flex-grow']"
|
||||
>
|
||||
<button
|
||||
v-if="link"
|
||||
v-tooltip="'Copy Link'"
|
||||
type="button"
|
||||
aria-label="Copy Link"
|
||||
class="flex h-10 w-full cursor-pointer items-center justify-between gap-2 rounded-xl border-none bg-button-bg px-3 pr-1.5 text-primary transition-all hover:bg-button-bg-hover hover:brightness-125 active:scale-95"
|
||||
@click="copyText"
|
||||
>
|
||||
<span class="min-w-0 cursor-pointer truncate text-left font-semibold text-primary">
|
||||
{{ url }}
|
||||
</span>
|
||||
<div class="grid h-10 w-10 place-content-center">
|
||||
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</button>
|
||||
<ButtonStyled v-if="link">
|
||||
<a
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Open in new tab"
|
||||
class="w-full"
|
||||
>
|
||||
Open in new tab
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<div v-if="socialButtons" class="flex flex-row gap-1">
|
||||
<ButtonStyled v-if="canShare" circular>
|
||||
<button v-tooltip="'Share'" aria-label="Share" @click="share">
|
||||
<ShareIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="'Send as an email'"
|
||||
:href="sendEmail"
|
||||
:target="targetParameter"
|
||||
aria-label="Send as an email"
|
||||
>
|
||||
<MailIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-if="link"
|
||||
v-tooltip="'Open link in browser'"
|
||||
:target="targetParameter"
|
||||
:href="url"
|
||||
aria-label="Open link in browser"
|
||||
>
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="'Toot about it'"
|
||||
:target="targetParameter"
|
||||
:href="sendToot"
|
||||
aria-label="Toot about it"
|
||||
>
|
||||
<MastodonIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="'Tweet about it'"
|
||||
:target="targetParameter"
|
||||
:href="sendTweet"
|
||||
aria-label="Tweet about it"
|
||||
>
|
||||
<TwitterIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="'Share on Reddit'"
|
||||
:target="targetParameter"
|
||||
:href="postOnReddit"
|
||||
aria-label="Share on Reddit"
|
||||
>
|
||||
<RedditIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script lang="ts"></script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon } from '@modrinth/assets'
|
||||
import { type Component, computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
|
||||
import { useScrollIndicator } from '../../composables/scroll-indicator'
|
||||
import NewModal from './NewModal.vue'
|
||||
export interface Tab {
|
||||
name: MessageDescriptor
|
||||
icon: Component
|
||||
content?: Component
|
||||
flushContent?: boolean
|
||||
href?: string
|
||||
badge?: MessageDescriptor
|
||||
shown?: boolean
|
||||
onboardingId?: string
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tabs: Tab[]
|
||||
header?: string
|
||||
maxWidth?: string
|
||||
width?: string
|
||||
closable?: boolean
|
||||
onHide?: () => void
|
||||
onShow?: () => void
|
||||
}>(),
|
||||
{
|
||||
header: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
closable: true,
|
||||
onHide: undefined,
|
||||
onShow: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const visibleTabs = computed(() => props.tabs.filter((tab) => tab.shown !== false))
|
||||
|
||||
const selectedTab = ref(0)
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const { showTopFade, showBottomFade, checkScrollState, forceCheck } =
|
||||
useScrollIndicator(scrollContainer)
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
|
||||
function setTab(index: number) {
|
||||
selectedTab.value = index
|
||||
nextTick(() => forceCheck())
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide, selectedTab, setTab })
|
||||
</script>
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="header"
|
||||
:max-width="maxWidth"
|
||||
:width="width"
|
||||
:closable="closable"
|
||||
:on-hide="onHide"
|
||||
:on-show="onShow"
|
||||
no-padding
|
||||
>
|
||||
<template v-if="$slots.title" #title>
|
||||
<slot name="title" />
|
||||
</template>
|
||||
<div
|
||||
class="grid min-h-0 grid-cols-[auto_minmax(0,1fr)] grid-rows-[minmax(0,1fr)] p-6 pb-3 pr-0"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-1 border-solid pr-4 border-0 border-r-[1px] border-divider min-w-[200px]"
|
||||
>
|
||||
<component
|
||||
:is="tab.href ? 'a' : 'button'"
|
||||
v-for="(tab, index) in visibleTabs"
|
||||
:key="index"
|
||||
:href="tab.href ?? undefined"
|
||||
:data-onboarding-id="tab.onboardingId"
|
||||
:target="tab.href ? '_blank' : undefined"
|
||||
:rel="tab.href ? 'noopener noreferrer' : undefined"
|
||||
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all no-underline ${!tab.href && selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
|
||||
@click="!tab.href && setTab(index)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-4 h-4 flex-shrink-0" />
|
||||
<span>{{ formatMessage(tab.name) }}</span>
|
||||
<span
|
||||
v-if="tab.badge"
|
||||
class="rounded-full px-1.5 py-0.5 text-xs font-bold bg-brand-highlight text-brand"
|
||||
>
|
||||
{{ formatMessage(tab.badge) }}
|
||||
</span>
|
||||
<RightArrowIcon v-if="tab.href" class="size-4 ml-auto" />
|
||||
</component>
|
||||
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
<div class="relative min-h-0 min-w-0 overflow-hidden">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-4"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-4"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-4 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
class="h-screen min-h-0 max-h-[min(65vh,600px)]"
|
||||
:class="
|
||||
visibleTabs[selectedTab]?.flushContent ? 'overflow-hidden' : 'overflow-y-auto px-6 pb-6'
|
||||
"
|
||||
@scroll="checkScrollState"
|
||||
>
|
||||
<Suspense>
|
||||
<component
|
||||
:is="visibleTabs[selectedTab]?.content"
|
||||
v-if="visibleTabs[selectedTab]?.content"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-16"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-16"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-16 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" :closable="false">
|
||||
<div class="flex flex-col gap-4 md:w-[400px]">
|
||||
<AppearingProgressBar :max-value="totalBytes" :current-value="uploadedBytes" />
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.warningText) }}</p>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UploadHandle } from '@modrinth/api-client'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { AppearingProgressBar } from '#ui/components/base'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
import NewModal from './NewModal.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'servers.setup.uploading-modpack.header',
|
||||
defaultMessage: 'Uploading modpack',
|
||||
},
|
||||
warningText: {
|
||||
id: 'servers.setup.upload-warning',
|
||||
defaultMessage: "Please don't close this page while uploading.",
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const uploadedBytes = ref(0)
|
||||
const totalBytes = ref(0)
|
||||
|
||||
function track<T>(handle: UploadHandle<T>): Promise<T> {
|
||||
uploadedBytes.value = 0
|
||||
totalBytes.value = 0
|
||||
modal.value?.show()
|
||||
handle.onProgress(({ loaded, total }) => {
|
||||
uploadedBytes.value = loaded
|
||||
totalBytes.value = total
|
||||
})
|
||||
return handle.promise.finally(() => {
|
||||
modal.value?.hide()
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ track })
|
||||
</script>
|
||||
@@ -0,0 +1,10 @@
|
||||
export { default as ConfirmLeaveModal } from './ConfirmLeaveModal.vue'
|
||||
export { default as ConfirmModal } from './ConfirmModal.vue'
|
||||
export { default as InstallToPlayModal } from './InstallToPlayModal.vue'
|
||||
export { default as NewModal } from './NewModal.vue'
|
||||
export type { ServerProject as OpenInAppModalServerProject } from './OpenInAppModal.vue'
|
||||
export { default as OpenInAppModal } from './OpenInAppModal.vue'
|
||||
export { default as ShareModal } from './ShareModal.vue'
|
||||
export type { Tab as TabbedModalTab } from './TabbedModal.vue'
|
||||
export { default as TabbedModal } from './TabbedModal.vue'
|
||||
export { default as UploadProgressModal } from './UploadProgressModal.vue'
|
||||
Reference in new issue
Block a user