feat:移除了弹窗,服务器添加sls

This commit is contained in:
AxTps committed 2026-09-08 22:39:45 +08:00
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>
+10
View File
@@ -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'