mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
feat: implement custom notification and popup components (#6768)
* feat: implement custom popup * feat: add privacy setting * style fixes * more style fixes * feat: implement notification panel changes and better popup in website * feat: update popup button * fix: remove unnecessary modal props * fixes * pnpm prepr * remove devtools opening * update query to find buttons * update copy * update copy * fix: manage permissions depends on showAds booleans instead of managemetn availability
This commit is contained in:
@@ -16,26 +16,32 @@
|
||||
@mouseenter="stopTimer(item)"
|
||||
@mouseleave="setNotificationTimer(item)"
|
||||
>
|
||||
<div class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl">
|
||||
<div
|
||||
class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl border border-solid border-surface-5"
|
||||
:class="item.containerClass"
|
||||
>
|
||||
<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),
|
||||
'bg-blue': !item.type || item.type === 'info',
|
||||
'bg-transparent': item.type === 'neutral',
|
||||
}"
|
||||
></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
|
||||
v-if="!item.noIcon"
|
||||
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),
|
||||
'text-blue': !item.type || item.type === 'info',
|
||||
'text-contrast': item.type === 'neutral',
|
||||
}"
|
||||
>
|
||||
<IssuesIcon v-if="item.type === 'warning'" class="h-6 w-6" />
|
||||
@@ -43,12 +49,17 @@
|
||||
<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="m-0 text-wrap font-bold text-contrast"
|
||||
:class="{ 'col-span-2': item.noIcon }"
|
||||
>
|
||||
{{ 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">
|
||||
<ButtonStyled v-if="item.copyable !== false" circular size="small">
|
||||
<button
|
||||
v-tooltip="
|
||||
item.supportData ? 'Copy error details for support' : 'Copy to clipboard'
|
||||
@@ -59,13 +70,13 @@
|
||||
<CopyIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular size="small">
|
||||
<ButtonStyled v-if="item.dismissible !== false" circular size="small">
|
||||
<button v-tooltip="`Dismiss`" @click="dismissNotification(index)">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div></div>
|
||||
<div v-if="item.type !== 'neutral'"></div>
|
||||
<div class="col-span-2 text-sm text-primary">{{ item.text }}</div>
|
||||
<template v-if="item.errorCode">
|
||||
<div></div>
|
||||
@@ -73,6 +84,20 @@
|
||||
{{ item.errorCode }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="item.buttons?.length">
|
||||
<div class="col-span-2 flex flex-wrap gap-1.5 pt-1">
|
||||
<ButtonStyled
|
||||
v-for="(button, buttonIndex) in item.buttons"
|
||||
:key="buttonIndex"
|
||||
:color="button.color"
|
||||
>
|
||||
<button class="!shadow-none" @click="handleButtonClick(item, button)">
|
||||
<component :is="button.icon" v-if="button.icon" />
|
||||
{{ button.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +119,11 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { useModalStack } from '#ui/composables/modal-stack.ts'
|
||||
|
||||
import { injectNotificationManager, type WebNotification } from '../../providers'
|
||||
import {
|
||||
injectNotificationManager,
|
||||
type WebNotification,
|
||||
type WebNotificationButton,
|
||||
} from '../../providers'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
|
||||
const notificationManager = injectNotificationManager()
|
||||
@@ -108,6 +137,13 @@ const stopTimer = (n: WebNotification) => notificationManager.stopNotificationTi
|
||||
const setNotificationTimer = (n: WebNotification) => notificationManager.setNotificationTimer(n)
|
||||
const dismissNotification = (n: number) => notificationManager.removeNotificationByIndex(n)
|
||||
|
||||
async function handleButtonClick(item: WebNotification, button: WebNotificationButton) {
|
||||
await button.action()
|
||||
if (!button.keepOpen) {
|
||||
notificationManager.removeNotification(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
function createNotifText(notif: WebNotification): string {
|
||||
return [notif.title, notif.text, notif.errorCode].filter(Boolean).join('\n')
|
||||
}
|
||||
@@ -170,7 +206,7 @@ withDefaults(
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
z-index: 200;
|
||||
width: 450px;
|
||||
width: 460px;
|
||||
transition: bottom 0.25s ease-in-out;
|
||||
|
||||
&.location-right {
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
/>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!item.hideIcon"
|
||||
class="flex items-center"
|
||||
:class="{
|
||||
'text-red': item.type === 'error',
|
||||
@@ -92,8 +93,8 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<ButtonStyled size="small" type="transparent" circular>
|
||||
<button @click="dismiss(item.id)">
|
||||
<ButtonStyled v-if="item.dismissible !== false" type="transparent" circular>
|
||||
<button class="-m-1.5" @click="dismiss(item.id)">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -144,7 +145,7 @@
|
||||
:key="idx"
|
||||
:color="btn.color || (idx === 0 ? 'brand' : undefined)"
|
||||
>
|
||||
<button @click="handleButtonClick(item.id, btn)">
|
||||
<button class="!shadow-none" @click="handleButtonClick(item.id, btn)">
|
||||
<component :is="btn.icon" v-if="btn.icon" />
|
||||
{{ btn.label }}
|
||||
</button>
|
||||
@@ -295,9 +296,9 @@ withDefaults(
|
||||
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));
|
||||
width: min(440px, calc(100vw - 1.5rem));
|
||||
min-width: min(440px, calc(100vw - 1.5rem));
|
||||
max-width: min(440px, calc(100vw - 1.5rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface PageContext {
|
||||
// pages may render sidebar content in #sidebar-teleport-target instead of in the main layout when true
|
||||
hierarchicalSidebarAvailable: Ref<boolean>
|
||||
showAds: Ref<boolean>
|
||||
adConsentAvailable: Ref<boolean>
|
||||
floatingActionBarOffsets?: {
|
||||
left: Ref<string> | ComputedRef<string>
|
||||
right: Ref<string> | ComputedRef<string>
|
||||
|
||||
@@ -64,12 +64,14 @@ export interface PopupNotification {
|
||||
bodyProps?: Record<string, unknown>
|
||||
text?: string
|
||||
iconUrl?: string | null
|
||||
hideIcon?: boolean
|
||||
type?: 'error' | 'warning' | 'success' | 'info' | 'download'
|
||||
progress?: number
|
||||
waiting?: boolean
|
||||
progressItems?: PopupNotificationProgressItem[]
|
||||
buttons?: PopupNotificationButton[]
|
||||
toast?: PopupNotificationToast
|
||||
dismissible?: boolean
|
||||
autoCloseMs?: number | null
|
||||
timer?: NodeJS.Timeout
|
||||
}
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import { createContext } from '.'
|
||||
|
||||
export interface WebNotificationButton {
|
||||
label: string
|
||||
action: () => void | Promise<void>
|
||||
icon?: Component
|
||||
color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'standard'
|
||||
keepOpen?: boolean
|
||||
}
|
||||
|
||||
export interface WebNotification {
|
||||
id: string | number
|
||||
title?: string
|
||||
text?: string
|
||||
type?: 'error' | 'warning' | 'success' | 'info'
|
||||
type?: 'error' | 'warning' | 'success' | 'info' | 'neutral'
|
||||
errorCode?: string
|
||||
count?: number
|
||||
autoCloseMs?: number | null // null means do not dismiss automatically
|
||||
timer?: NodeJS.Timeout
|
||||
supportData?: Record<string, unknown>
|
||||
containerClass?: string
|
||||
noIcon?: boolean
|
||||
buttons?: WebNotificationButton[]
|
||||
dismissible?: boolean
|
||||
copyable?: boolean
|
||||
}
|
||||
|
||||
export type NotificationPanelLocation = 'left' | 'right'
|
||||
|
||||
@@ -50,11 +50,39 @@ export const Default: StoryObj = {
|
||||
})
|
||||
}
|
||||
|
||||
const showActions = () => {
|
||||
notificationManager.addNotification({
|
||||
title: 'Your privacy and how ads support Modrinth',
|
||||
text: 'Choose how our advertising partners may use your data.',
|
||||
type: 'neutral',
|
||||
autoCloseMs: null,
|
||||
dismissible: false,
|
||||
copyable: false,
|
||||
noIcon: true,
|
||||
buttons: [
|
||||
{
|
||||
label: 'Manage preferences',
|
||||
action: () => console.log('Manage preferences clicked'),
|
||||
},
|
||||
{
|
||||
label: 'Reject all',
|
||||
action: () => console.log('Reject all clicked'),
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
label: 'Accept all',
|
||||
action: () => console.log('Accept all clicked'),
|
||||
color: 'brand',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
notificationManager.clearAllNotifications()
|
||||
}
|
||||
|
||||
return { showSuccess, showError, showWarning, showInfo, clearAll }
|
||||
return { showSuccess, showError, showWarning, showInfo, showActions, clearAll }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div>
|
||||
@@ -71,6 +99,9 @@ export const Default: StoryObj = {
|
||||
<ButtonStyled color="blue">
|
||||
<button @click="showInfo">Info</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="showActions">Actions</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="clearAll">Clear All</button>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -107,6 +107,36 @@ export const Default: StoryObj = {
|
||||
})
|
||||
}
|
||||
|
||||
const showBlocking = () => {
|
||||
popupManager.addPopupNotification({
|
||||
title: 'Your privacy and how ads support Modrinth',
|
||||
text: 'Ads make Modrinth possible and fund creator rewards. Our partners may store unique identifiers to personalize ads and measure performance.',
|
||||
type: 'info',
|
||||
autoCloseMs: null,
|
||||
dismissible: false,
|
||||
buttons: [
|
||||
{
|
||||
label: 'Manage preferences',
|
||||
action: () => console.log('Manage preferences clicked'),
|
||||
color: 'standard',
|
||||
keepOpen: true,
|
||||
},
|
||||
{
|
||||
label: 'Reject all',
|
||||
action: () => console.log('Reject all clicked'),
|
||||
color: 'brand',
|
||||
keepOpen: true,
|
||||
},
|
||||
{
|
||||
label: 'Accept all',
|
||||
action: () => console.log('Accept all clicked'),
|
||||
color: 'brand',
|
||||
keepOpen: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const showWaitingProgress = () => {
|
||||
popupManager.addPopupNotification({
|
||||
title: 'Installing modpack...',
|
||||
@@ -169,6 +199,7 @@ export const Default: StoryObj = {
|
||||
showInfo,
|
||||
showNoButtons,
|
||||
showPermanent,
|
||||
showBlocking,
|
||||
showWaitingProgress,
|
||||
showDeterminateProgress,
|
||||
showGroupedDownloads,
|
||||
@@ -196,6 +227,9 @@ export const Default: StoryObj = {
|
||||
<ButtonStyled>
|
||||
<button @click="showPermanent">Permanent</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="showBlocking">Blocking</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="showWaitingProgress">Waiting Progress</button>
|
||||
</ButtonStyled>
|
||||
|
||||
Reference in New Issue
Block a user