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

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,55 @@
<template>
<div
class="notification-stack"
:class="{
'has-sidebar': hasSidebar,
}"
>
<TransitionGroup name="notification-stack-item" tag="div" class="flex flex-col gap-4">
<slot />
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
hasSidebar?: boolean
}>(),
{
hasSidebar: false,
},
)
</script>
<style scoped>
.notification-stack {
position: fixed;
top: calc(var(--top-bar-height, 3rem) + 1rem);
right: 1rem;
z-index: 200;
width: min(420px, calc(100vw - 1.5rem));
}
.notification-stack.has-sidebar {
right: calc(var(--right-bar-width, 0px) + 1rem);
}
@media screen and (max-width: 500px) {
.notification-stack {
right: 0.75rem;
}
}
:global(.notification-stack-item-enter-active),
:global(.notification-stack-item-leave-active),
:global(.notification-stack-item-move) {
transition: all 0.3s ease-in-out;
}
:global(.notification-stack-item-enter-from),
:global(.notification-stack-item-leave-to) {
opacity: 0;
transform: translateX(100%) scale(0.95);
}
</style>

View File

@ -0,0 +1,360 @@
<template>
<div
class="notification-toast relative overflow-hidden rounded-[20px] border border-solid border-surface-5 bg-surface-3 p-4 shadow-[0px_4px_8px_0px_rgba(0,0,0,0.1),0px_1px_3px_0px_rgba(0,0,0,0.2)]"
>
<div v-if="isInviteNotification" class="flex w-full items-start gap-3">
<Avatar
:src="actorAvatarUrl"
:alt="actorLabel"
:tint-by="actorLabel"
size="44px"
circle
no-shadow
class="border border-solid border-surface-5"
/>
<div class="flex min-w-0 flex-1 flex-col gap-2.5">
<div class="flex w-full items-start gap-1">
<p class="m-0 min-w-0 flex-1 break-words text-lg font-normal leading-6 text-contrast/85">
<template v-if="type === 'friend-request'">
<span class="font-semibold text-contrast">{{ actorLabel }}</span>
<span> sent you a friend request.</span>
</template>
<template v-else>
<button
v-if="actorName"
type="button"
class="m-0 inline border-0 bg-transparent p-0 text-lg font-semibold leading-6 text-contrast hover:underline"
@click="$emit('open-actor')"
>
{{ actorName }}
</button>
<span v-else class="font-semibold text-contrast">Someone</span>
<span class="mx-1">{{ inviteActionText }}</span>
<template v-if="type === 'server-invite'">
<span class="font-semibold text-contrast">{{ entityLabel }}</span
>.
</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
>.
</template>
</template>
</p>
<ButtonStyled size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
aria-label="Dismiss notification"
@click="$emit('dismiss')"
>
<XIcon />
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<ButtonStyled color="brand">
<button @click="$emit('accept')">Accept</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="$emit('decline')">Decline</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else class="flex w-full items-start gap-3">
<Avatar
:src="entityIconUrl"
:alt="entityLabel"
size="44px"
no-shadow
raised
:tint-by="entityLabel"
class="!rounded-xl border border-solid border-surface-5"
/>
<div
class="grid grid-cols-[minmax(0,1fr)_auto] gap-x-1 gap-y-[0.1875rem] min-w-0 flex-1 text-base leading-5"
>
<p
ref="titleRef"
v-tooltip="truncatedTooltip(titleRef, entityLabel)"
class="col-start-1 col-end-3 row-start-1 m-0 min-w-0 truncate pr-9 text-lg font-semibold leading-6 text-contrast"
>
{{ entityLabel }}
</p>
<div class="col-start-2 row-start-1 justify-self-end">
<ButtonStyled size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
aria-label="Dismiss notification"
@click="$emit('dismiss')"
>
<XIcon />
</button>
</ButtonStyled>
</div>
<div
class="col-start-1 col-end-3 row-start-2 flex min-w-0 items-center justify-between gap-0.5"
>
<p
ref="statusRef"
v-tooltip="truncatedTooltip(statusRef, statusLine)"
class="m-0 min-w-0 flex-1 font-normal leading-tight text-contrast/85"
:class="wrapText ? 'whitespace-normal break-words' : 'truncate'"
>
{{ statusLine }}
</p>
<div
v-if="type === 'instance-download' && progressLabel"
class="flex-none text-xs text-secondary tabular-nums pointer-events-none text-right whitespace-nowrap"
>
{{ progressLabel }}
</div>
</div>
<div
v-if="type === 'instance-ready'"
class="col-start-1 col-end-3 row-start-3 mt-2 flex min-w-0 items-center justify-between gap-2"
>
<div class="flex min-w-0 items-center gap-2">
<ButtonStyled color="brand">
<button @click="$emit('launch')">Launch game</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="$emit('open-instance')">Instance</button>
</ButtonStyled>
</div>
<div
v-if="progressLabel"
class="flex-none text-secondary tabular-nums pointer-events-none text-right whitespace-nowrap"
>
{{ progressLabel }}
</div>
</div>
<div
v-if="type === 'instance-download' && actions?.length"
class="col-start-1 col-end-3 row-start-3 mt-2 flex min-w-0 flex-wrap items-center gap-2"
>
<ButtonStyled
v-for="(action, index) in actions"
:key="index"
:color="action.color || (index === 0 ? 'brand' : undefined)"
>
<button class="!shadow-none" @click="$emit('action', index)">
<component :is="action.icon" v-if="action.icon" />
{{ action.label }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div
v-if="showsBottomProgress"
class="notification-bottom-progress-track absolute inset-x-0 bottom-0 h-1 overflow-hidden"
role="progressbar"
:aria-valuenow="isWaitingProgress ? undefined : progressPercent"
aria-valuemin="0"
aria-valuemax="100"
>
<div
class="h-full transition-[left,width] duration-200 ease-in-out"
:class="[
type === 'instance-ready' ? 'bg-surface-5' : 'bg-brand',
{ 'notification-bottom-progress--waiting': isWaitingProgress },
]"
:style="isWaitingProgress ? undefined : { width: `${progressPercent}%` }"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { useFormatBytes, useFormatNumber } from '../../composables'
import type { PopupNotificationButton, PopupNotificationProgressType } from '../../providers'
import { truncatedTooltip } from '../../utils/truncate'
import Avatar from '../base/Avatar.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
type NotificationToastType =
| 'friend-request'
| 'server-invite'
| 'instance-invite'
| 'instance-download'
| 'instance-ready'
const props = withDefaults(
defineProps<{
type: NotificationToastType
actorName?: string | null
actorAvatarUrl?: string | null
entityName?: string
entityIconUrl?: string | null
statusText?: string
progress?: number
waiting?: boolean
showProgress?: boolean
wrapText?: boolean
progressType?: PopupNotificationProgressType
progressCurrent?: number
progressTotal?: number
actions?: PopupNotificationButton[]
}>(),
{
actorName: null,
actorAvatarUrl: null,
entityName: '',
entityIconUrl: null,
waiting: false,
showProgress: true,
wrapText: false,
progressType: 'percentage',
},
)
defineEmits<{
accept: []
decline: []
dismiss: []
action: [index: number]
launch: []
'open-actor': []
'open-instance': []
}>()
const isInviteNotification = computed(
() =>
props.type === 'friend-request' ||
props.type === 'server-invite' ||
props.type === 'instance-invite',
)
const actorLabel = computed(() => props.actorName || 'Someone')
const entityLabel = computed(() => props.entityName || '')
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)
const formatBytes = useFormatBytes()
const formatNumber = useFormatNumber()
const inviteActionText = computed(() => {
if (props.type === 'server-invite') {
return 'invited you to manage the server'
}
return 'invited you to play the instance'
})
const resolvedStatusText = computed(() => {
if (props.type === 'instance-ready') {
return props.statusText ?? 'Installed and ready to play.'
}
return props.statusText ?? ''
})
const statusLine = computed(() => {
return resolvedStatusText.value
})
const showsBottomProgress = computed(
() =>
(props.type === 'instance-download' && props.showProgress) ||
(props.type === 'instance-ready' && props.progress != null),
)
const progressCurrent = computed(() => {
if (props.progressCurrent != null) {
return Math.max(0, props.progressCurrent)
}
if (props.progressTotal != null) {
return Math.round(progressValue.value * props.progressTotal)
}
return progressPercent.value
})
const progressTotal = computed(() => Math.max(0, props.progressTotal ?? 0))
function formatProgressLabel(
type: PopupNotificationProgressType | undefined,
current: number,
total: number,
): string {
if (type === 'bytes' && total > 0) {
return `${formatBytes(Math.min(current, total), 1)} / ${formatBytes(total, 1)}`
}
if (type === 'count' && total > 0) {
return `${formatNumber(Math.min(current, total))} / ${formatNumber(total)}`
}
return `${progressPercent.value}%`
}
const progressLabel = computed(() => {
if (!showsBottomProgress.value || isWaitingProgress.value) {
return ''
}
const primary = formatProgressLabel(
props.progressType,
progressCurrent.value,
progressTotal.value,
)
return primary
})
const titleRef = ref<HTMLElement | null>(null)
const statusRef = ref<HTMLElement | null>(null)
</script>
<style scoped>
.notification-toast {
width: min(420px, calc(100vw - 1.5rem));
}
.notification-toast-dismiss {
--_height: 1.25rem;
--_width: 1.25rem;
--_padding-x: 0;
--_padding-y: 0;
--_icon-size: 1.25rem;
--_box-shadow: none;
--_text: var(--color-base);
--_hover-bg: transparent;
--_hover-text: var(--color-contrast);
}
.notification-bottom-progress--waiting {
animation: notification-bottom-progress-waiting 1s linear infinite;
position: relative;
width: 20%;
}
@keyframes notification-bottom-progress-waiting {
0% {
left: -20%;
}
100% {
left: 100%;
}
}
.notification-bottom-progress-track {
background-color: color-mix(in srgb, var(--surface-2) 50%, transparent);
}
</style>

View File

@ -0,0 +1,2 @@
export { default as NotificationStack } from './NotificationStack.vue'
export { default as NotificationToast } from './NotificationToast.vue'