feat: clean up browse shared layout logic + introduce queuing (#6030)
* feat: clean up edge case behaviour and add queued to install logic * fix: remove version choice modal * feat: queued flow * feat: standardize headers in app on proj pages * fix: clear btn * feat: installing floating popup * fix: lint * fix: onboarding/reset logic change for modpacks * qa: big ol qa * fix: lint * fix: lint --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -29,6 +29,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'project-type.all',
|
||||
defaultMessage: 'All',
|
||||
},
|
||||
addServerToInstanceButton: {
|
||||
id: 'button.add-server-to-instance',
|
||||
defaultMessage: 'Add server to instance',
|
||||
},
|
||||
backButton: {
|
||||
id: 'button.back',
|
||||
defaultMessage: 'Back',
|
||||
@@ -133,6 +137,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'label.filter-by',
|
||||
defaultMessage: 'Filter by',
|
||||
},
|
||||
filtersLabel: {
|
||||
id: 'label.filters',
|
||||
defaultMessage: 'Filters',
|
||||
},
|
||||
followButton: {
|
||||
id: 'button.follow',
|
||||
defaultMessage: 'Follow',
|
||||
@@ -189,6 +197,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'button.open-folder',
|
||||
defaultMessage: 'Open folder',
|
||||
},
|
||||
openInModrinthButton: {
|
||||
id: 'button.open-in-modrinth',
|
||||
defaultMessage: 'Open in Modrinth',
|
||||
},
|
||||
orLabel: {
|
||||
id: 'label.or',
|
||||
defaultMessage: 'or',
|
||||
@@ -385,6 +397,34 @@ export const commonMessages = defineMessages({
|
||||
id: 'label.installation-info',
|
||||
defaultMessage: 'Installation info',
|
||||
},
|
||||
installButton: {
|
||||
id: 'button.install',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
installedLabel: {
|
||||
id: 'label.installed',
|
||||
defaultMessage: 'Installed',
|
||||
},
|
||||
validatingLabel: {
|
||||
id: 'label.validating',
|
||||
defaultMessage: 'Validating',
|
||||
},
|
||||
selectedLabel: {
|
||||
id: 'label.selected',
|
||||
defaultMessage: 'Selected',
|
||||
},
|
||||
installingContentLabel: {
|
||||
id: 'label.installing-content',
|
||||
defaultMessage: 'Installing content',
|
||||
},
|
||||
hideInstalledContentLabel: {
|
||||
id: 'label.hide-installed-content',
|
||||
defaultMessage: 'Hide already installed content',
|
||||
},
|
||||
hideSelectedContentLabel: {
|
||||
id: 'label.hide-selected-content',
|
||||
defaultMessage: 'Hide selected content',
|
||||
},
|
||||
installedModpackTitle: {
|
||||
id: 'label.installed-modpack',
|
||||
defaultMessage: 'Installed modpack',
|
||||
@@ -451,6 +491,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'label.version',
|
||||
defaultMessage: 'Version',
|
||||
},
|
||||
viewLabel: {
|
||||
id: 'label.view',
|
||||
defaultMessage: 'View',
|
||||
},
|
||||
projectLabel: {
|
||||
id: 'label.project',
|
||||
defaultMessage: 'Project',
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from './loaders'
|
||||
export * from './notices'
|
||||
export * from './savable'
|
||||
export * from './search'
|
||||
export * from './server-content-installing'
|
||||
export * from './server-search'
|
||||
export * from './tag-messages'
|
||||
export * from './truncate'
|
||||
|
||||
203
packages/ui/src/utils/server-content-installing.ts
Normal file
203
packages/ui/src/utils/server-content-installing.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type {
|
||||
ContentCardProject,
|
||||
ContentCardVersion,
|
||||
ContentOwner,
|
||||
} from '../layouts/shared/content-tab/types'
|
||||
|
||||
export type PendingServerContentInstallType = 'mod' | 'plugin' | 'datapack'
|
||||
type PendingServerContentOwner = Omit<ContentOwner, 'link'> & { link?: string }
|
||||
|
||||
export interface PendingServerContentInstall {
|
||||
projectId: string
|
||||
versionId: string
|
||||
contentType: PendingServerContentInstallType
|
||||
title: ContentCardProject['title']
|
||||
versionName?: ContentCardVersion['version_number'] | null
|
||||
versionNumber?: ContentCardVersion['version_number'] | null
|
||||
fileName?: ContentCardVersion['file_name'] | null
|
||||
owner?: PendingServerContentOwner | null
|
||||
slug?: ContentCardProject['slug'] | null
|
||||
iconUrl?: ContentCardProject['icon_url'] | null
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface PendingServerContentInstallBaseline {
|
||||
contentKeys: string[]
|
||||
projectIds?: string[]
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export const pendingServerContentInstallsEvent = 'modrinth:pending-server-content-installs'
|
||||
|
||||
const stalePendingInstallAge = 30 * 60 * 1000
|
||||
|
||||
function getPendingServerContentInstallsKey(serverId: string | null, worldId: string | null) {
|
||||
if (!serverId || !worldId) return null
|
||||
return `server-content-installing:${serverId}:${worldId}`
|
||||
}
|
||||
|
||||
function getPendingServerContentInstallBaselineKey(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
) {
|
||||
if (!serverId || !worldId) return null
|
||||
return `server-content-installing-baseline:${serverId}:${worldId}`
|
||||
}
|
||||
|
||||
function isPendingServerContentInstall(value: unknown): value is PendingServerContentInstall {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const record = value as Record<string, unknown>
|
||||
return (
|
||||
typeof record.projectId === 'string' &&
|
||||
typeof record.versionId === 'string' &&
|
||||
(record.contentType === 'mod' ||
|
||||
record.contentType === 'plugin' ||
|
||||
record.contentType === 'datapack') &&
|
||||
typeof record.title === 'string' &&
|
||||
typeof record.createdAt === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isPendingServerContentInstallBaseline(
|
||||
value: unknown,
|
||||
): value is PendingServerContentInstallBaseline {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const record = value as Record<string, unknown>
|
||||
const contentKeys = record.contentKeys ?? record.projectIds
|
||||
return (
|
||||
Array.isArray(contentKeys) &&
|
||||
contentKeys.every((contentKey) => typeof contentKey === 'string') &&
|
||||
typeof record.createdAt === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function filterFreshPendingServerContentInstalls(items: PendingServerContentInstall[]) {
|
||||
const cutoff = Date.now() - stalePendingInstallAge
|
||||
return items.filter((item) => item.createdAt >= cutoff)
|
||||
}
|
||||
|
||||
function isFreshPendingServerContentInstallBaseline(item: PendingServerContentInstallBaseline) {
|
||||
return item.createdAt >= Date.now() - stalePendingInstallAge
|
||||
}
|
||||
|
||||
function emitPendingServerContentInstallsChanged(serverId: string | null, worldId: string | null) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(pendingServerContentInstallsEvent, {
|
||||
detail: { serverId, worldId },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function readPendingServerContentInstalls(serverId: string | null, worldId: string | null) {
|
||||
const key = getPendingServerContentInstallsKey(serverId, worldId)
|
||||
if (!key || typeof localStorage === 'undefined') return []
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
const freshItems = filterFreshPendingServerContentInstalls(
|
||||
parsed.filter(isPendingServerContentInstall),
|
||||
)
|
||||
if (freshItems.length !== parsed.length) {
|
||||
writePendingServerContentInstalls(serverId, worldId, freshItems)
|
||||
}
|
||||
return freshItems
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function writePendingServerContentInstalls(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
items: PendingServerContentInstall[],
|
||||
) {
|
||||
const key = getPendingServerContentInstallsKey(serverId, worldId)
|
||||
if (!key || typeof localStorage === 'undefined') return
|
||||
|
||||
const freshItems = filterFreshPendingServerContentInstalls(items)
|
||||
if (freshItems.length === 0) {
|
||||
localStorage.removeItem(key)
|
||||
const baselineKey = getPendingServerContentInstallBaselineKey(serverId, worldId)
|
||||
if (baselineKey) {
|
||||
localStorage.removeItem(baselineKey)
|
||||
}
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(freshItems))
|
||||
}
|
||||
emitPendingServerContentInstallsChanged(serverId, worldId)
|
||||
}
|
||||
|
||||
export function readPendingServerContentInstallBaseline(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
) {
|
||||
const key = getPendingServerContentInstallBaselineKey(serverId, worldId)
|
||||
if (!key || typeof localStorage === 'undefined') return null
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!isPendingServerContentInstallBaseline(parsed)) return null
|
||||
if (!isFreshPendingServerContentInstallBaseline(parsed)) {
|
||||
localStorage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
return new Set(parsed.contentKeys ?? parsed.projectIds)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writePendingServerContentInstallBaseline(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
contentKeys: Iterable<string>,
|
||||
) {
|
||||
const key = getPendingServerContentInstallBaselineKey(serverId, worldId)
|
||||
if (!key || typeof localStorage === 'undefined') return
|
||||
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
contentKeys: Array.from(new Set(contentKeys)),
|
||||
createdAt: Date.now(),
|
||||
} satisfies PendingServerContentInstallBaseline),
|
||||
)
|
||||
emitPendingServerContentInstallsChanged(serverId, worldId)
|
||||
}
|
||||
|
||||
export function addPendingServerContentInstalls(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
items: Omit<PendingServerContentInstall, 'createdAt'>[],
|
||||
) {
|
||||
if (items.length === 0) return
|
||||
|
||||
const now = Date.now()
|
||||
const next = new Map(
|
||||
readPendingServerContentInstalls(serverId, worldId).map((item) => [item.projectId, item]),
|
||||
)
|
||||
for (const item of items) {
|
||||
next.set(item.projectId, { ...item, createdAt: now })
|
||||
}
|
||||
writePendingServerContentInstalls(serverId, worldId, Array.from(next.values()))
|
||||
}
|
||||
|
||||
export function removePendingServerContentInstall(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
projectId: string,
|
||||
) {
|
||||
writePendingServerContentInstalls(
|
||||
serverId,
|
||||
worldId,
|
||||
readPendingServerContentInstalls(serverId, worldId).filter(
|
||||
(item) => item.projectId !== projectId,
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user