diff --git a/.vscode/settings.json b/.vscode/settings.json index 9caec96a..4d178702 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,7 @@ "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "editor.detectIndentation": true, "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" + "source.fixAll.eslint": "explicit", + "source.organizeImports": "always", } } diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index abfabdf5..6d2451c3 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -1,6 +1,35 @@ diff --git a/apps/frontend/src/components/ui/CollectionCreateModal.vue b/apps/frontend/src/components/ui/CollectionCreateModal.vue index 02b07560..ca34868f 100644 --- a/apps/frontend/src/components/ui/CollectionCreateModal.vue +++ b/apps/frontend/src/components/ui/CollectionCreateModal.vue @@ -51,7 +51,9 @@ diff --git a/apps/frontend/src/pages/organization/[id]/settings/analytics.vue b/apps/frontend/src/pages/organization/[id]/settings/analytics.vue index f78ef4b7..23c0dc6a 100644 --- a/apps/frontend/src/pages/organization/[id]/settings/analytics.vue +++ b/apps/frontend/src/pages/organization/[id]/settings/analytics.vue @@ -16,8 +16,9 @@ diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 85b7f2da..59e62f46 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -25,7 +25,6 @@ export { default as HeadingLink } from './base/HeadingLink.vue' export { default as LoadingIndicator } from './base/LoadingIndicator.vue' export { default as ManySelect } from './base/ManySelect.vue' export { default as MarkdownEditor } from './base/MarkdownEditor.vue' -export { default as Notifications } from './base/Notifications.vue' export { default as OverflowMenu } from './base/OverflowMenu.vue' export type { Option as OverflowMenuOption } from './base/OverflowMenu.vue' export { default as Page } from './base/Page.vue' @@ -64,9 +63,9 @@ export { default as NewsArticleCard } from './content/NewsArticleCard.vue' export type { Article as NewsArticle } from './content/NewsArticleCard.vue' // Modals -export { default as NewModal } from './modal/NewModal.vue' -export { default as Modal } from './modal/Modal.vue' export { default as ConfirmModal } from './modal/ConfirmModal.vue' +export { default as Modal } from './modal/Modal.vue' +export { default as NewModal } from './modal/NewModal.vue' export { default as ShareModal } from './modal/ShareModal.vue' export { default as TabbedModal } from './modal/TabbedModal.vue' export type { Tab as TabbedModalTab } from './modal/TabbedModal.vue' @@ -76,6 +75,7 @@ export { default as Breadcrumbs } from './nav/Breadcrumbs.vue' export { default as NavItem } from './nav/NavItem.vue' export { default as NavRow } from './nav/NavRow.vue' export { default as NavStack } from './nav/NavStack.vue' +export { default as NotificationPanel } from './nav/NotificationPanel.vue' export { default as PagewideBanner } from './nav/PagewideBanner.vue' // Project @@ -100,16 +100,16 @@ export { default as SearchFilterOption } from './search/SearchFilterOption.vue' export { default as SearchSidebarFilter } from './search/SearchSidebarFilter.vue' // Billing -export { default as PurchaseModal } from './billing/PurchaseModal.vue' export { default as AddPaymentMethodModal } from './billing/AddPaymentMethodModal.vue' export { default as ModrinthServersPurchaseModal } from './billing/ModrinthServersPurchaseModal.vue' +export { default as PurchaseModal } from './billing/PurchaseModal.vue' // Skins -export { default as SkinPreviewRenderer } from './skin/SkinPreviewRenderer.vue' export { default as CapeButton } from './skin/CapeButton.vue' export { default as CapeLikeTextButton } from './skin/CapeLikeTextButton.vue' export { default as SkinButton } from './skin/SkinButton.vue' export { default as SkinLikeTextButton } from './skin/SkinLikeTextButton.vue' +export { default as SkinPreviewRenderer } from './skin/SkinPreviewRenderer.vue' // Version export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue' @@ -120,6 +120,6 @@ export { default as VersionSummary } from './version/VersionSummary.vue' export { default as ThemeSelector } from './settings/ThemeSelector.vue' // Servers -export { default as ServersPromo } from './servers/ServersPromo.vue' -export { default as BackupWarning } from './servers/backups/BackupWarning.vue' export { default as ServersSpecs } from './billing/ServersSpecs.vue' +export { default as BackupWarning } from './servers/backups/BackupWarning.vue' +export { default as ServersPromo } from './servers/ServersPromo.vue' diff --git a/apps/frontend/src/components/ui/Notifications.vue b/packages/ui/src/components/nav/NotificationPanel.vue similarity index 66% rename from apps/frontend/src/components/ui/Notifications.vue rename to packages/ui/src/components/nav/NotificationPanel.vue index ebb43c2e..c99cfdb3 100644 --- a/apps/frontend/src/components/ui/Notifications.vue +++ b/packages/ui/src/components/nav/NotificationPanel.vue @@ -3,7 +3,9 @@ class="vue-notification-group experimental-styles-within" :class="{ 'intercom-present': isIntercomPresent, - rightwards: moveNotificationsRight, + 'location-left': notificationLocation === 'left', + 'location-right': notificationLocation === 'right', + 'has-sidebar': hasSidebar, }" > @@ -53,7 +55,7 @@ - @@ -73,106 +75,117 @@ - + diff --git a/packages/ui/src/providers/index.ts b/packages/ui/src/providers/index.ts new file mode 100644 index 00000000..c8859edc --- /dev/null +++ b/packages/ui/src/providers/index.ts @@ -0,0 +1,81 @@ +/** + * MIT License + * + * Copyright (c) 2023 UnoVue + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * @source https://github.com/unovue/reka-ui/blob/53b4734734f8ebef9a344b1e62db291177c59bfe/packages/core/src/shared/createContext.ts + */ + +import type { InjectionKey } from 'vue' +import { inject, provide } from 'vue' + +/** + * @param providerComponentName - The name(s) of the component(s) providing the context. + * + * There are situations where context can come from multiple components. In such cases, you might need to give an array of component names to provide your context, instead of just a single string. + * + * @param contextName The description for injection key symbol. + */ +export function createContext( + providerComponentName: string | string[], + contextName?: string, +) { + const symbolDescription = + typeof providerComponentName === 'string' && !contextName + ? `${providerComponentName}Context` + : contextName + + const injectionKey: InjectionKey = Symbol(symbolDescription) + + /** + * @param fallback The context value to return if the injection fails. + * + * @throws When context injection failed and no fallback is specified. + * This happens when the component injecting the context is not a child of the root component providing the context. + */ + const injectContext = ( + fallback?: T, + ): T extends null ? ContextValue | null : ContextValue => { + const context = inject(injectionKey, fallback) + if (context) return context + + if (context === null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return context as any + + throw new Error( + `Injection \`${injectionKey.toString()}\` not found. Component must be used within ${ + Array.isArray(providerComponentName) + ? `one of the following components: ${providerComponentName.join(', ')}` + : `\`${providerComponentName}\`` + }`, + ) + } + + const provideContext = (contextValue: ContextValue) => { + provide(injectionKey, contextValue) + return contextValue + } + + return [injectContext, provideContext] as const +} + +export * from './web-notifications' diff --git a/packages/ui/src/providers/web-notifications.ts b/packages/ui/src/providers/web-notifications.ts new file mode 100644 index 00000000..c56507b0 --- /dev/null +++ b/packages/ui/src/providers/web-notifications.ts @@ -0,0 +1,133 @@ +import { createContext } from '.' + +export interface WebNotification { + id: string | number + title?: string + text?: string + type?: 'error' | 'warning' | 'success' | 'info' + errorCode?: string + count?: number + timer?: NodeJS.Timeout +} + +export type NotificationPanelLocation = 'left' | 'right' + +export abstract class AbstractWebNotificationManager { + protected readonly AUTO_DISMISS_DELAY_MS = 30 * 1000 + + abstract getNotifications(): WebNotification[] + abstract getNotificationLocation(): NotificationPanelLocation + abstract setNotificationLocation(location: NotificationPanelLocation): void + + protected abstract addNotificationToStorage(notification: WebNotification): void + protected abstract removeNotificationFromStorage(id: string | number): void + protected abstract removeNotificationFromStorageByIndex(index: number): void + protected abstract clearAllNotificationsFromStorage(): void + + addNotification = (notification: Partial): WebNotification => { + const existingNotif = this.findExistingNotification(notification) + + if (existingNotif) { + this.refreshNotificationTimer(existingNotif) + existingNotif.count = (existingNotif.count || 0) + 1 + return existingNotif + } + + const newNotification = this.createNotification(notification) + this.setNotificationTimer(newNotification) + this.addNotificationToStorage(newNotification) + return newNotification + } + + /** + * @deprecated You should use `addNotification` instead to provide a more human-readable error message to the user. + */ + handleError = (error: Error): void => { + this.addNotification({ + title: 'An error occurred', + text: error.message ?? error, + type: 'error', + }) + } + + removeNotification = (id: string | number): WebNotification | undefined => { + const notifications = this.getNotifications() + const notification = notifications.find((n) => n.id === id) + + if (notification) { + this.clearNotificationTimer(notification) + this.removeNotificationFromStorage(id) + } + + return notification + } + + removeNotificationByIndex = (index: number): WebNotification | null => { + const notifications = this.getNotifications() + + if (index >= 0 && index < notifications.length) { + const notification = notifications[index] + this.clearNotificationTimer(notification) + this.removeNotificationFromStorageByIndex(index) + + return notification + } + + return null + } + + clearAllNotifications = (): void => { + const notifications = this.getNotifications() + notifications.forEach((notification) => { + this.clearNotificationTimer(notification) + }) + this.clearAllNotificationsFromStorage() + } + + setNotificationTimer = (notification: WebNotification): void => { + if (!notification) return + + this.clearNotificationTimer(notification) + + notification.timer = setTimeout(() => { + this.removeNotification(notification.id) + }, this.AUTO_DISMISS_DELAY_MS) + } + + stopNotificationTimer = (notification: WebNotification): void => { + this.clearNotificationTimer(notification) + } + + private refreshNotificationTimer(notification: WebNotification): void { + this.setNotificationTimer(notification) + } + + private clearNotificationTimer(notification: WebNotification): void { + if (notification.timer) { + clearTimeout(notification.timer) + notification.timer = undefined + } + } + + private findExistingNotification( + notification: Partial, + ): WebNotification | undefined { + return this.getNotifications().find( + (existing) => + existing.text === notification.text && + existing.title === notification.title && + existing.type === notification.type, + ) + } + + private createNotification(notification: Partial): WebNotification { + return { + ...notification, + id: new Date().getTime(), + count: 1, + } as WebNotification + } +} + +export const [injectNotificationManager, provideNotificationManager] = + createContext('root', 'notificationManager') diff --git a/packages/utils/types.ts b/packages/utils/types.ts index bc3f33d9..ede87cab 100644 --- a/packages/utils/types.ts +++ b/packages/utils/types.ts @@ -49,6 +49,76 @@ export interface GalleryImage { description?: string } +export interface ProjectV3 { + id: ModrinthId + slug?: string + project_types: string[] + games: string[] + team_id: ModrinthId + organization?: ModrinthId + name: string + summary: string + description: string + + published: string + updated: string + approved?: string + queued?: string + + status: ProjectStatus + requested_status?: ProjectStatus + + /** @deprecated moved to threads system */ + moderator_message?: { + message: string + body?: string + } + + license: { + id: string + name: string + url?: string + } + + downloads: number + followers: number + + categories: string[] + additional_categories: string[] + loaders: string[] + + versions: ModrinthId[] + icon_url?: string + + link_urls: Record< + string, + { + platform: string + donation: boolean + url: string + } + > + + gallery: { + url: string + raw_url: string + featured: boolean + name?: string + description?: string + created: string + ordering: number + }[] + + color?: number + thread_id: ModrinthId + monetization_status: MonetizationStatus + side_types_migration_review_status: 'reviewed' | 'pending' + + [key: string]: unknown +} + +export type SideTypesMigrationReviewStatus = 'reviewed' | 'pending' + export interface Project { id: ModrinthId project_type: ProjectType