feat:移除了弹窗,服务器添加sls
This commit is contained in:
commit
6a295f9a7a
4082 files changed
+1322534
No files matched your search
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<nav class="breadcrumbs flex mb-4 items-center flex-wrap">
|
||||
<template v-for="(link, index) in linkStack" :key="index">
|
||||
<RouterLink
|
||||
:to="link.href"
|
||||
class="breadcrumb goto-link"
|
||||
:class="{ trim: link.allowTrimming ? link.allowTrimming : false }"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
<ChevronRightIcon />
|
||||
</template>
|
||||
<span class="breadcrumb">{{ currentTitle }}</span>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
|
||||
defineProps({
|
||||
linkStack: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.breadcrumbs {
|
||||
svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
a.breadcrumb {
|
||||
padding-block: var(--gap-xs);
|
||||
&.trim {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<div
|
||||
class="vue-notification-group"
|
||||
:class="{
|
||||
'intercom-present': isIntercomPresent,
|
||||
'location-left': notificationLocation === 'left',
|
||||
'location-right': notificationLocation === 'right',
|
||||
'has-sidebar': hasSidebar && !hasModalActive,
|
||||
}"
|
||||
>
|
||||
<transition-group name="notifs">
|
||||
<div
|
||||
v-for="(item, index) in notifications"
|
||||
:key="item.id"
|
||||
class="vue-notification-wrapper"
|
||||
@mouseenter="stopTimer(item)"
|
||||
@mouseleave="setNotificationTimer(item)"
|
||||
>
|
||||
<div class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl">
|
||||
<div
|
||||
class="w-2"
|
||||
:class="{
|
||||
'bg-red': item.type === 'error',
|
||||
'bg-orange': item.type === 'warning',
|
||||
'bg-green': item.type === 'success',
|
||||
'bg-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
class="grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2 gap-y-1 py-2 pl-1 pr-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center"
|
||||
:class="{
|
||||
'text-red': item.type === 'error',
|
||||
'text-orange': item.type === 'warning',
|
||||
'text-green': item.type === 'success',
|
||||
'text-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
|
||||
}"
|
||||
>
|
||||
<IssuesIcon v-if="item.type === 'warning'" class="h-6 w-6" />
|
||||
<CheckCircleIcon v-else-if="item.type === 'success'" class="h-6 w-6" />
|
||||
<XCircleIcon v-else-if="item.type === 'error'" class="h-6 w-6" />
|
||||
<InfoIcon v-else class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="m-0 text-wrap font-bold text-contrast">{{ item.title }}</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div v-if="item.count && item.count > 1" class="text-xs font-bold text-contrast">
|
||||
x{{ item.count }}
|
||||
</div>
|
||||
<ButtonStyled circular size="small">
|
||||
<button
|
||||
v-tooltip="
|
||||
item.supportData
|
||||
? formatMessage(messages.copySupport)
|
||||
: formatMessage(messages.copy)
|
||||
"
|
||||
@click="copyToClipboard(item)"
|
||||
>
|
||||
<CheckIcon v-if="copied[getCopyKey(item)]" />
|
||||
<CopyIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="item.type === 'error' && onErrorAction" circular size="small">
|
||||
<button
|
||||
v-tooltip="errorActionLabel || formatMessage(messages.exportErrorLogs)"
|
||||
:disabled="exporting[item.id]"
|
||||
@click="handleErrorAction(item)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular size="small">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.dismiss)"
|
||||
v-tooltip="formatMessage(messages.dismiss)"
|
||||
@click="dismissNotification(index)"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div></div>
|
||||
<div
|
||||
class="col-span-2 whitespace-pre-line break-words text-sm leading-5 text-primary line-clamp-5"
|
||||
>
|
||||
{{ item.text }}
|
||||
</div>
|
||||
<template v-if="item.errorCode">
|
||||
<div></div>
|
||||
<div class="m-0 text-wrap text-xs font-medium text-secondary">
|
||||
{{ item.errorCode }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
InfoIcon,
|
||||
IssuesIcon,
|
||||
XCircleIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { useModalStack } from '#ui/composables/modal-stack.ts'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { injectNotificationManager, type WebNotification } from '../../providers'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
|
||||
const notificationManager = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
copySupport: {
|
||||
id: 'notification.panel.copy-support',
|
||||
defaultMessage: 'Copy error details for support',
|
||||
},
|
||||
copy: { id: 'notification.panel.copy', defaultMessage: 'Copy to clipboard' },
|
||||
dismiss: { id: 'notification.panel.dismiss', defaultMessage: 'Dismiss' },
|
||||
exportErrorLogs: {
|
||||
id: 'notification.panel.export-error-logs',
|
||||
defaultMessage: 'Export error logs',
|
||||
},
|
||||
})
|
||||
const notifications = computed<WebNotification[]>(() =>
|
||||
[...notificationManager.getNotifications()]
|
||||
.filter((notification) => !notification.collapsed)
|
||||
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)),
|
||||
)
|
||||
const notificationLocation = computed(() => notificationManager.getNotificationLocation())
|
||||
|
||||
const isIntercomPresent = ref<boolean>(false)
|
||||
const copied = ref<Record<string, boolean>>({})
|
||||
const exporting = ref<Record<string | number, boolean>>({})
|
||||
|
||||
const stopTimer = (n: WebNotification) => notificationManager.stopNotificationTimer(n)
|
||||
const setNotificationTimer = (n: WebNotification) => notificationManager.setNotificationTimer(n)
|
||||
const dismissNotification = (n: number) => {
|
||||
const notification = notifications.value[n]
|
||||
if (notification) notificationManager.collapseNotification(notification.id)
|
||||
}
|
||||
|
||||
function createNotifText(notif: WebNotification): string {
|
||||
return [notif.title, notif.text, notif.errorCode].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function getCopyKey(notif: WebNotification): string {
|
||||
return notif.supportData ? `support-${notif.id}` : createNotifText(notif)
|
||||
}
|
||||
|
||||
function checkIntercomPresence(): void {
|
||||
isIntercomPresent.value = !!document.querySelector('.intercom-lightweight-app')
|
||||
}
|
||||
|
||||
function copyToClipboard(notif: WebNotification): void {
|
||||
// If supportData is present, copy the full JSON for support; otherwise copy plain text
|
||||
const text = notif.supportData
|
||||
? JSON.stringify(notif.supportData, null, 2)
|
||||
: createNotifText(notif)
|
||||
|
||||
const key = getCopyKey(notif)
|
||||
copied.value[key] = true
|
||||
navigator.clipboard.writeText(text)
|
||||
|
||||
setTimeout(() => {
|
||||
const { [key]: _, ...rest } = copied.value
|
||||
copied.value = rest
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
async function handleErrorAction(notification: WebNotification): Promise<void> {
|
||||
if (!onErrorAction || exporting.value[notification.id]) return
|
||||
|
||||
exporting.value[notification.id] = true
|
||||
try {
|
||||
await onErrorAction(notification)
|
||||
} finally {
|
||||
const { [notification.id]: _, ...rest } = exporting.value
|
||||
exporting.value = rest as Record<string | number, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkIntercomPresence()
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
checkIntercomPresence()
|
||||
})
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
const { hasModal: hasModalActive } = useModalStack()
|
||||
|
||||
const {
|
||||
hasSidebar = false,
|
||||
onErrorAction,
|
||||
errorActionLabel = 'Export error logs',
|
||||
} = defineProps<{
|
||||
hasSidebar?: boolean
|
||||
onErrorAction?: (notification: WebNotification) => void | Promise<void>
|
||||
errorActionLabel?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vue-notification-group {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
z-index: 200;
|
||||
width: 450px;
|
||||
transition: bottom 0.25s ease-in-out;
|
||||
|
||||
&.location-right {
|
||||
right: 1.5rem;
|
||||
transition: right 0.25s ease-in-out;
|
||||
|
||||
&.has-sidebar {
|
||||
right: 325px;
|
||||
}
|
||||
}
|
||||
|
||||
&.location-left {
|
||||
left: 1.5rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
width: calc(100% - 0.75rem * 2);
|
||||
bottom: 0.75rem;
|
||||
|
||||
&.location-right {
|
||||
right: 0.75rem;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
&.location-left {
|
||||
left: 0.75rem;
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&.intercom-present {
|
||||
bottom: 5rem;
|
||||
}
|
||||
|
||||
.vue-notification-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
transition: bottom 0.25s ease-in-out;
|
||||
bottom: calc(var(--size-mobile-navbar-height) + 10px) !important;
|
||||
|
||||
&.browse-menu-open {
|
||||
bottom: calc(var(--size-mobile-navbar-height-expanded) + 10px) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notifs-enter-active,
|
||||
.notifs-leave-active,
|
||||
.notifs-move {
|
||||
transition: all 0.25s ease-in-out;
|
||||
}
|
||||
.notifs-enter-from,
|
||||
.notifs-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.notifs-enter-from {
|
||||
transform: translateY(100%) scale(0.8);
|
||||
}
|
||||
|
||||
.notifs-leave-to {
|
||||
.location-right & {
|
||||
transform: translateX(100%) scale(0.8);
|
||||
}
|
||||
|
||||
.location-left & {
|
||||
transform: translateX(-100%) scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
body.floating-action-bar-shown .vue-notification-group {
|
||||
bottom: calc(90px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'banner-grid relative border-b-2 border-solid border-0 z-10',
|
||||
containerClasses[variant],
|
||||
{ 'no-actions': !$slots.actions, slim: slim },
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'[grid-area:title] flex items-center gap-2 font-bold text-[var(--font-size-md)]',
|
||||
iconClasses[variant],
|
||||
]"
|
||||
>
|
||||
<component :is="getSeverityIcon(variant)" aria-hidden="true" class="w-6 h-6 flex-shrink-0" />
|
||||
<slot name="title" />
|
||||
</div>
|
||||
|
||||
<div class="[grid-area:description] flex flex-col gap-3">
|
||||
<slot name="description" />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="[grid-area:actions] flex items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.actions_right || $slots.actions_top_right"
|
||||
class="[grid-area:actions_right] flex flex-col gap-2 items-end"
|
||||
>
|
||||
<div v-if="$slots.actions_top_right" class="flex items-center gap-2 justify-end">
|
||||
<slot name="actions_top_right" />
|
||||
</div>
|
||||
<div v-if="$slots.actions_right" class="flex items-center gap-2 justify-end my-auto">
|
||||
<slot name="actions_right" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { getSeverityIcon } from '../../utils'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant: 'error' | 'warning' | 'info'
|
||||
slim?: boolean
|
||||
}>(),
|
||||
{
|
||||
slim: false,
|
||||
},
|
||||
)
|
||||
|
||||
const containerClasses = {
|
||||
error: 'bg-banners-error-bg text-banners-error-text border-banners-error-border',
|
||||
warning: 'bg-banners-warning-bg text-banners-warning-text border-banners-warning-border',
|
||||
info: 'bg-banners-info-bg text-banners-info-text border-banners-info-border',
|
||||
}
|
||||
|
||||
const iconClasses = {
|
||||
error: 'text-brand-red',
|
||||
warning: 'text-brand-orange',
|
||||
info: 'text-brand-blue',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.banner-grid {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template-areas:
|
||||
'title actions_right'
|
||||
'description actions_right'
|
||||
'actions actions_right';
|
||||
padding-block: var(--gap-xl);
|
||||
padding-inline: max(calc((100% - 80rem) / 2 + var(--gap-md)), var(--gap-xl));
|
||||
}
|
||||
|
||||
.banner-grid.no-actions {
|
||||
grid-template-areas:
|
||||
'title actions_right'
|
||||
'description actions_right';
|
||||
}
|
||||
|
||||
.banner-grid.slim {
|
||||
@apply flex py-4 gap-2 items-center;
|
||||
}
|
||||
|
||||
.banner-grid a {
|
||||
@apply underline text-current;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,394 @@
|
||||
<template>
|
||||
<div
|
||||
class="popup-notification-group"
|
||||
:class="{
|
||||
'has-sidebar': hasSidebar,
|
||||
'has-modal': hasModalActive,
|
||||
}"
|
||||
:style="notificationGroupStyle"
|
||||
>
|
||||
<transition-group name="popup-notifs">
|
||||
<div
|
||||
v-for="item in notifications"
|
||||
:key="item.id"
|
||||
class="popup-notification-wrapper"
|
||||
@mouseenter="stopTimer(item)"
|
||||
@mouseleave="setNotificationTimer(item)"
|
||||
>
|
||||
<NotificationToast
|
||||
v-if="item.toast"
|
||||
:type="item.toast.type"
|
||||
:actor-name="item.toast.actorName"
|
||||
:actor-avatar-url="item.toast.actorAvatarUrl"
|
||||
:entity-name="item.toast.entityName"
|
||||
:entity-icon-url="item.toast.entityIconUrl"
|
||||
:status-text="item.toast.statusText"
|
||||
:progress="item.toast.progress"
|
||||
:waiting="item.toast.waiting"
|
||||
:show-progress="item.toast.showProgress"
|
||||
:progress-type="item.toast.progressType"
|
||||
:progress-current="item.toast.progressCurrent"
|
||||
:progress-total="item.toast.progressTotal"
|
||||
@accept="handleToastAction(item, item.toast.onAccept)"
|
||||
@decline="handleToastAction(item, item.toast.onDecline)"
|
||||
@dismiss="handleToastAction(item, item.toast.onDismiss)"
|
||||
@launch="handleToastAction(item, item.toast.onLaunch)"
|
||||
@open-actor="item.toast.onOpenActor?.()"
|
||||
@open-instance="handleToastAction(item, item.toast.onOpenInstance)"
|
||||
/>
|
||||
<div v-else-if="isDownloadNotification(item)" class="flex flex-col gap-4">
|
||||
<div v-for="progressItem in downloadToastItems(item)" :key="progressItem.id">
|
||||
<NotificationToast
|
||||
type="instance-download"
|
||||
:entity-name="progressItem.title || item.title"
|
||||
:entity-icon-url="progressItem.iconUrl ?? item.iconUrl ?? MinecraftServerIcon"
|
||||
:status-text="progressItem.text"
|
||||
:progress="progressItem.progress"
|
||||
:waiting="progressItem.waiting"
|
||||
:show-progress="progressItem.showProgress"
|
||||
:wrap-text="progressItem.wrapText"
|
||||
:progress-type="progressItem.progressType"
|
||||
:progress-current="progressItem.progressCurrent"
|
||||
:progress-total="progressItem.progressTotal"
|
||||
:actions="progressItem.buttons"
|
||||
class="cursor-pointer"
|
||||
@click="handleDownloadClick(item, $event)"
|
||||
@dismiss="handleProgressItemDismiss(item, progressItem)"
|
||||
@action="(index) => handleProgressItemAction(progressItem, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised shadow-xl border-surface-5 border-solid border p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<div class="flex items-center justify-between gap-2.5">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<component
|
||||
:is="item.titleLogo"
|
||||
v-if="item.titleLogo"
|
||||
class="h-7 w-auto min-w-0 max-w-full text-contrast"
|
||||
/>
|
||||
<template v-else>
|
||||
<div
|
||||
class="flex items-center"
|
||||
:class="{
|
||||
'text-red': item.type === 'error',
|
||||
'text-orange': item.type === 'warning',
|
||||
'text-green': item.type === 'download',
|
||||
'text-contrast': item.type === 'success',
|
||||
'text-blue':
|
||||
!item.type ||
|
||||
!['error', 'warning', 'success', 'download'].includes(item.type),
|
||||
}"
|
||||
>
|
||||
<IssuesIcon v-if="item.type === 'warning'" class="h-5 w-5" />
|
||||
<DownloadIcon v-else-if="item.type === 'download'" class="h-5 w-5" />
|
||||
<CheckCircleIcon v-else-if="item.type === 'success'" class="h-5 w-5" />
|
||||
<XCircleIcon v-else-if="item.type === 'error'" class="h-5 w-5" />
|
||||
<InfoIcon v-else class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="text-contrast font-semibold m-0 grow">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<ButtonStyled size="small" type="transparent" circular>
|
||||
<button @click="dismiss(item.id)">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span v-if="item.text" class="text-primary">
|
||||
{{ item.text }}
|
||||
</span>
|
||||
<component
|
||||
:is="item.bodyComponent"
|
||||
v-if="item.bodyComponent"
|
||||
v-bind="item.bodyProps ?? {}"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="item.progressItems?.length" class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="progressItem in item.progressItems"
|
||||
:key="progressItem.id"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="text-contrast truncate">
|
||||
{{ progressItem.title }}
|
||||
</div>
|
||||
<ProgressBar
|
||||
:progress="progressItem.progress"
|
||||
:max="1"
|
||||
:waiting="progressItem.waiting"
|
||||
:color="progressColorForType(item.type)"
|
||||
:gradient-border="false"
|
||||
full-width
|
||||
/>
|
||||
<div v-if="progressItem.text" class="text-sm text-secondary truncate">
|
||||
{{ progressItem.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProgressBar
|
||||
v-else-if="item.progress != null || item.waiting"
|
||||
:progress="item.progress ?? 0"
|
||||
:max="1"
|
||||
:waiting="item.waiting ?? false"
|
||||
:color="progressColorForType(item.type)"
|
||||
:gradient-border="false"
|
||||
full-width
|
||||
/>
|
||||
<div
|
||||
v-if="(item.type === 'error' && onErrorAction) || item.buttons?.length"
|
||||
class="flex gap-1.5"
|
||||
>
|
||||
<ButtonStyled v-if="item.type === 'error' && onErrorAction">
|
||||
<button :disabled="exporting[item.id]" @click="handleErrorAction(item)">
|
||||
<DownloadIcon /> {{ errorActionLabel || formatMessage(messages.exportErrorLogs) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-for="(btn, idx) in item.buttons"
|
||||
:key="idx"
|
||||
:color="btn.color || (idx === 0 ? 'brand' : undefined)"
|
||||
>
|
||||
<button @click="handleButtonClick(item.id, btn)">
|
||||
<component :is="btn.icon" v-if="btn.icon" />
|
||||
{{ btn.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
DownloadIcon,
|
||||
InfoIcon,
|
||||
IssuesIcon,
|
||||
MinecraftServerIcon,
|
||||
XCircleIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { useModalStack } from '../../composables/modal-stack'
|
||||
import {
|
||||
injectPopupNotificationManager,
|
||||
type PopupNotification,
|
||||
type PopupNotificationButton,
|
||||
type PopupNotificationProgressItem,
|
||||
} from '../../providers'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import ProgressBar from '../base/ProgressBar.vue'
|
||||
import NotificationToast from '../notifications/NotificationToast.vue'
|
||||
|
||||
const popupNotificationManager = injectPopupNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
exportErrorLogs: {
|
||||
id: 'notification.panel.export-error-logs',
|
||||
defaultMessage: 'Export error logs',
|
||||
},
|
||||
})
|
||||
const notifications = computed<PopupNotification[]>(() =>
|
||||
[...popupNotificationManager.getNotifications()]
|
||||
.filter((notification) => !notification.collapsed)
|
||||
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)),
|
||||
)
|
||||
const { stackCount } = useModalStack()
|
||||
const hasModalActive = computed(() => stackCount.value > 0)
|
||||
const notificationGroupStyle = computed(() => ({
|
||||
zIndex: hasModalActive.value ? 100 + stackCount.value * 10 + 8 : 200,
|
||||
}))
|
||||
const exporting = ref<Record<string | number, boolean>>({})
|
||||
// Download progress items share one parent popup notification. Keep dismissal
|
||||
// state per item so closing one row does not hide the other rows.
|
||||
const dismissedProgressItems = ref<Record<string, string[]>>({})
|
||||
|
||||
const stopTimer = (n: PopupNotification) => popupNotificationManager.stopNotificationTimer(n)
|
||||
const setNotificationTimer = (n: PopupNotification) =>
|
||||
popupNotificationManager.setNotificationTimer(n)
|
||||
const dismiss = (id: string | number) => popupNotificationManager.collapseNotification(id)
|
||||
|
||||
function isDownloadNotification(item: PopupNotification) {
|
||||
return (
|
||||
item.type === 'download' &&
|
||||
(!!item.progressItems?.length || item.progress != null || item.waiting)
|
||||
)
|
||||
}
|
||||
|
||||
function downloadToastItems(item: PopupNotification): PopupNotificationProgressItem[] {
|
||||
const dismissed = dismissedProgressItems.value[String(item.id)] ?? []
|
||||
const items = item.progressItems?.length
|
||||
? item.progressItems
|
||||
: [
|
||||
{
|
||||
id: `${item.id}`,
|
||||
title: item.title,
|
||||
text: item.text,
|
||||
iconUrl: item.iconUrl,
|
||||
progress: item.progress ?? 0,
|
||||
waiting: item.waiting ?? false,
|
||||
showProgress: true,
|
||||
progressType: 'percentage',
|
||||
},
|
||||
]
|
||||
|
||||
return items.filter((progressItem) => !dismissed.includes(progressItem.id))
|
||||
}
|
||||
|
||||
function handleDownloadClick(item: PopupNotification, event: MouseEvent) {
|
||||
if ((event.target as HTMLElement).closest('button')) return
|
||||
void item.onClick?.()
|
||||
}
|
||||
|
||||
async function handleProgressItemDismiss(
|
||||
item: PopupNotification,
|
||||
progressItem: PopupNotificationProgressItem,
|
||||
) {
|
||||
// Dismissal is presentation-only. Download history must remain available for diagnosis.
|
||||
const notificationKey = String(item.id)
|
||||
const dismissed = dismissedProgressItems.value[notificationKey] ?? []
|
||||
if (!dismissed.includes(progressItem.id)) {
|
||||
dismissedProgressItems.value = {
|
||||
...dismissedProgressItems.value,
|
||||
[notificationKey]: [...dismissed, progressItem.id],
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadToastItems(item).length === 0) {
|
||||
dismiss(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProgressItemAction(
|
||||
progressItem: PopupNotificationProgressItem,
|
||||
index: number,
|
||||
) {
|
||||
const button = progressItem.buttons?.[index]
|
||||
if (button) {
|
||||
await handleProgressItemButtonClick(progressItem, button)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProgressItemButtonClick(
|
||||
progressItem: PopupNotificationProgressItem,
|
||||
btn: PopupNotificationButton,
|
||||
) {
|
||||
await btn.action()
|
||||
if (!btn.keepOpen) {
|
||||
await progressItem.onDismiss?.()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleButtonClick(id: string | number, btn: PopupNotificationButton) {
|
||||
await btn.action()
|
||||
if (!btn.keepOpen) {
|
||||
popupNotificationManager.removeNotification(id)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToastAction(item: PopupNotification, action?: () => void | Promise<void>) {
|
||||
popupNotificationManager.removeNotification(item.id)
|
||||
await action?.()
|
||||
}
|
||||
|
||||
async function handleErrorAction(notification: PopupNotification): Promise<void> {
|
||||
if (!onErrorAction || exporting.value[notification.id]) return
|
||||
|
||||
exporting.value[notification.id] = true
|
||||
try {
|
||||
await onErrorAction(notification)
|
||||
} finally {
|
||||
const { [notification.id]: _, ...rest } = exporting.value
|
||||
exporting.value = rest as Record<string | number, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
function progressColorForType(type: PopupNotification['type']) {
|
||||
if (type === 'error') {
|
||||
return 'red'
|
||||
} else if (type === 'warning') {
|
||||
return 'orange'
|
||||
} else if (type === 'download') {
|
||||
return 'green'
|
||||
} else if (type === 'success') {
|
||||
return 'green'
|
||||
} else if (type === 'info') {
|
||||
return 'blue'
|
||||
}
|
||||
return 'green'
|
||||
}
|
||||
|
||||
const {
|
||||
hasSidebar = false,
|
||||
onErrorAction,
|
||||
errorActionLabel,
|
||||
} = defineProps<{
|
||||
hasSidebar?: boolean
|
||||
onErrorAction?: (notification: PopupNotification) => void | Promise<void>
|
||||
errorActionLabel?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.popup-notification-group {
|
||||
position: fixed;
|
||||
top: calc(var(--top-bar-height, 3rem) + 1.5rem);
|
||||
right: 1.5rem;
|
||||
z-index: 200;
|
||||
width: min(420px, calc(100vw - 1.5rem));
|
||||
min-width: min(420px, calc(100vw - 1.5rem));
|
||||
max-width: min(420px, calc(100vw - 1.5rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
transition:
|
||||
opacity 0.2s ease-in-out,
|
||||
transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.popup-notification-group.has-sidebar {
|
||||
right: calc(var(--right-bar-width, 0px) + 1.5rem);
|
||||
}
|
||||
|
||||
.popup-notification-group.has-modal {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-0.5rem);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.popup-notification-group {
|
||||
right: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-notification-group .popup-notification-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popup-notifs-enter-active,
|
||||
.popup-notifs-leave-active,
|
||||
.popup-notifs-move {
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.popup-notifs-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.8);
|
||||
}
|
||||
|
||||
.popup-notifs-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.8);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as Breadcrumbs } from './Breadcrumbs.vue'
|
||||
export { default as NotificationPanel } from './NotificationPanel.vue'
|
||||
export { default as PagewideBanner } from './PagewideBanner.vue'
|
||||
export { default as PopupNotificationPanel } from './PopupNotificationPanel.vue'
|
||||
Reference in new issue
Block a user