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