Compare commits

...
Author SHA1 Message Date
François-X. T. 5d1ddeda24 Fmt 2026-05-09 17:09:10 -04:00
ProspectorandGitHub a0228dc2b1 Merge branch 'main' into patch-1 2026-05-09 14:01:54 -07:00
Calum H.andGitHub 07f9e3aedc feat: changelog 0.13.3 (#6055) 2026-05-09 19:13:37 +00:00
a79b8e0777 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>
2026-05-09 19:01:23 +00:00
aecsocketandGitHub 671f6d264a changelog (#6054) 2026-05-09 18:34:26 +00:00
aecsocketandGitHub e231df1f97 WebView window event handling fixes (#6038)
* WebView fixes

* UA override logic

* fix

* debug logs

* alter all webviews

* cookies stuff

* remove debug stuff
2026-05-09 15:10:51 +00:00
JerozgenandGitHub 3052a14d95 feat: make byte size units translatable (#5969)
Make byte size units translatable
2026-05-09 09:26:59 +00:00
e8665f43ca Filipino compact number plural rule (#5516)
Use `one` for compact numbers in Filipino

Signed-off-by: Jerozgen <jerozgen@gmail.com>
Co-authored-by: Calum H. <calum@modrinth.com>
2026-05-09 09:20:05 +00:00
Truman GaoandGitHub cba4550be4 feat: table component updates (#6042)
* feat: implement table header slot, empty state, and virtualization

* refactor: pnpm prepr
2026-05-08 21:29:06 +00:00
ProspectorandGitHub 384556a810 fix: instance redirects to root page with / making it appear as a sub-page in navigation (#6040) 2026-05-08 19:54:25 +00:00
Calum H.andGitHub a082e8597c fix: app user agent for api-client reqs using tauri http plugin (#6045)
fix: app user agent
2026-05-08 19:52:52 +00:00
Prospector 7048a35e9f changelog 2026-05-08 02:58:50 -07:00
ProspectorandGitHub c166ce52b3 fix: some buttons appear disabled even if they aren't (#6037)
This is because [disabled="false"] met the criteria of [disabled] as a css selector
2026-05-08 09:39:13 +00:00
ProspectorandGitHub 9c99518497 chore: improve moderation ux (#6035)
* feat: save project review queue filters

* reduce unnecessary network calls + prepr

* missed file

* ui tweaks

* add fucked up

* add label + prepr

* prepr

* update legacy badge labels

* globe

* fix margin

* be more reasonable

* pending state

* fix double review, prepr

* small badge text
2026-05-08 01:40:28 -07:00
Prospector 758ed818c8 changelog 2026-05-07 19:24:32 -07:00
ProspectorandGitHub 83e45d7a5c refactor: update modpack export modal, exclude /mods/.connector (#6032)
* refactor: update modpack export modal, exclude /mods/.connector

* Add slash suffix to folders

* prepr

* preprr
2026-05-07 19:23:46 -07:00
ProspectorandGitHub 77b30b27fe fix: make scrollbar gutter stable in app viewport (#6033) 2026-05-07 19:21:37 -07:00
ProspectorandGitHub 3d7aea5a45 feat: add download metadata to website (#6034)
* feat: add download metadata to website

* add to project cards
2026-05-07 19:20:54 -07:00
Calum H.andGitHub fd5d2797b3 fix: file picker in app not working with mrpack (#6027)
fix: file picker in app
2026-05-07 16:12:44 +00:00
Calum H.andGitHub ec85d9de1c fix: intercom bubble on console fullscreen (#6029) 2026-05-07 16:12:11 +00:00
Michael H.andGitHub 22415a4cc6 fix: check edited member, not editor, for org owner permission guard (#6024)
fix: check edited member, not editor, for org owner permission guard (#1400)
2026-05-07 14:59:05 +00:00
Corsican FrogandGitHub 3c6fadf5e8 Merge branch 'main' into patch-1 2026-05-07 15:54:36 +02:00
871672d8bf feat: date picker component (#6010)
* feat: date picker component

* fix: month and year input padding

* fix: chevron padding issue

* feat: more padding/style fixes

* feat: implement header disabled state for min/max dates

* feat: implement dragging on start/end dates to move dates

* feat: improve selected range styles

* fix: type error

* fix: time input problems

* feat: implement 2 calendar view

* fix: white bg when dragging on a normal day

* fix: selected date background incorrectly applied

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-05-07 13:22:48 +00:00
aecsocketandGitHub e8dc3c3150 Add update download reason to analytics (#6023)
* Add  download reason to analytics

* mark modpack updates as actual updates in analytics

* fmt
2026-05-07 13:07:20 +00:00
ProspectorandGitHub 56dae8f104 chore: give projects, orgs, and collections canonical URLs to hopefully improve SEO (#6014)
* Give projects, collections, and orgs canonical URLs

* prepr
2026-05-07 06:07:41 -07:00
Corsican FrogandGitHub 8af43e59b9 Merge branch 'main' into patch-1 2026-05-07 11:24:07 +02:00
Corsican FrogandGitHub 418e4f281d Merge branch 'main' into patch-1 2026-05-06 18:46:07 +02:00
Corsican FrogandGitHub abc6cf59d8 Whitelist scannable instance content files
Only scan supported content archives into instance content.

Accept .jar files for mods and .zip files for datapacks, resourcepacks, and shaderpacks, after trimming the .disabled suffix. 

This prevents .DS_Store and other unsupported files from appearing in the Content tab.

Signed-off-by: Corsican Frog <49497194+acorsicanfrog@users.noreply.github.com>
2026-05-05 20:56:54 +02:00
Corsican FrogandGitHub 849fe4e1ba Hide dotfiles from instance content scanning
Prevent hidden files such as .DS_Store from being treated as valid instance content.

This updates the profile scanning logic in [packages/app-lib/src/state/profiles.rs](/Users/froggy/Downloads/code-main/packages/app-lib/src/state/profiles.rs#L420) to ignore basenames that start with '.', and applies that filter consistently in both scan paths.

Signed-off-by: Corsican Frog <49497194+acorsicanfrog@users.noreply.github.com>
2026-05-05 13:35:01 +02:00
114 changed files with 7052 additions and 1693 deletions
+12 -3
View File
@@ -51,9 +51,10 @@ import {
providePageContext,
providePopupNotificationManager,
useDebugLogger,
useFormatBytes,
useVIntl,
} from '@modrinth/ui'
import { formatBytes, renderString } from '@modrinth/utils'
import { renderString } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
@@ -146,8 +147,9 @@ const popupNotificationManager = new AppPopupNotificationManager()
providePopupNotificationManager(popupNotificationManager)
const { addPopupNotification } = popupNotificationManager
const appVersion = getVersion()
const tauriApiClient = new TauriModrinthClient({
userAgent: `modrinth/theseus/${getVersion()} (support@modrinth.com)`,
userAgent: async () => `modrinth/theseus/${await appVersion} (support@modrinth.com)`,
labrinthBaseUrl: config.labrinthBaseUrl,
archonBaseUrl: config.archonBaseUrl,
features: [
@@ -261,6 +263,8 @@ onUnmounted(async () => {
})
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const messages = defineMessages({
updateInstalledToastTitle: {
id: 'app.update.complete-toast.title',
@@ -1567,11 +1571,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
.app-grid-navbar {
grid-area: nav;
position: relative;
z-index: 2;
}
.app-grid-statusbar {
grid-area: status;
padding-right: var(--window-controls-width, 0px);
position: relative;
z-index: 2;
}
[data-tauri-drag-region-exclude] {
@@ -1657,10 +1665,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
height: 100%;
overflow: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
}
.app-contents::before {
z-index: 1;
z-index: 30;
content: '';
position: fixed;
left: var(--left-bar-width);
@@ -149,7 +149,7 @@ const handleOptionsClick = async (args) => {
break
case 'edit':
await router.push({
path: `/instance/${encodeURIComponent(args.item.path)}/`,
path: `/instance/${encodeURIComponent(args.item.path)}`,
})
break
case 'duplicate':
@@ -1,6 +1,7 @@
<script setup>
import { PlusIcon, XIcon } from '@modrinth/assets'
import { WrenchIcon, XIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
Checkbox,
commonMessages,
@@ -9,7 +10,7 @@ import {
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { save } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
import { PackageIcon, VersionIcon } from '@/assets/icons'
@@ -40,9 +41,13 @@ const messages = defineMessages({
},
selectFilesLabel: {
id: 'app.export-modal.select-files-label',
defaultMessage: 'Select files and folders to include in pack',
defaultMessage: 'Configure which files are included in this export',
},
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
includeFile: {
id: 'app.export-modal.include-file-accessibility-label',
defaultMessage: 'Include "{file}"?',
},
})
const props = defineProps({
@@ -65,7 +70,6 @@ const exportDescription = ref('')
const versionInput = ref('1.0.0')
const files = ref([])
const folders = ref([])
const showingFiles = ref(false)
const initFiles = async () => {
const newFolders = new Map()
@@ -87,7 +91,12 @@ const initFiles = async () => {
folder.startsWith('modrinth_logs') ||
folder.startsWith('.fabric'),
}))
.filter((pathData) => !pathData.path.includes('.DS_Store'))
.filter(
(pathData) =>
!pathData.path.includes('.DS_Store') &&
pathData.path !== 'mods/.connector' &&
!pathData.path.startsWith('mods/.connector/'),
)
.forEach((pathData) => {
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
if (parent !== '') {
@@ -121,15 +130,20 @@ const exportPack = async () => {
}
})
})
const outputPath = await open({
directory: true,
multiple: false,
const outputPath = await save({
defaultPath: `${nameInput.value} ${versionInput.value}.mrpack`,
filters: [
{
name: 'Modrinth Modpack',
extensions: ['mrpack'],
},
],
})
if (outputPath) {
export_profile_mrpack(
props.instance.path,
outputPath + `/${nameInput.value} ${versionInput.value}.mrpack`,
outputPath,
filesToExport,
versionInput.value,
exportDescription.value,
@@ -142,97 +156,91 @@ const exportPack = async () => {
<template>
<ModalWrapper ref="exportModal" :header="formatMessage(messages.header)">
<div class="modal-body">
<div class="labeled_input">
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
<StyledInput
v-model="nameInput"
:icon="PackageIcon"
type="text"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
clearable
/>
</div>
<div class="labeled_input">
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
<StyledInput
v-model="versionInput"
:icon="VersionIcon"
type="text"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
clearable
/>
</div>
<div class="adjacent-input">
<div class="flex flex-col gap-4 w-[40rem]">
<div class="grid grid-cols-2 gap-4">
<div class="labeled_input">
<p>{{ formatMessage(commonMessages.descriptionLabel) }}</p>
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
<StyledInput
v-model="exportDescription"
multiline
:placeholder="formatMessage(messages.descriptionPlaceholder)"
v-model="nameInput"
:icon="PackageIcon"
type="text"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
clearable
/>
</div>
<div class="labeled_input">
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
<StyledInput
v-model="versionInput"
:icon="VersionIcon"
type="text"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
clearable
/>
</div>
</div>
<div class="table">
<div class="table-head">
<div class="table-cell row-wise">
{{ formatMessage(messages.selectFilesLabel) }}
<ButtonStyled circular>
<button @click="() => (showingFiles = !showingFiles)">
<PlusIcon v-if="!showingFiles" />
<XIcon v-else />
</button>
</ButtonStyled>
</div>
</div>
<div v-if="showingFiles" class="table-content">
<div v-for="[path, children] in folders" :key="path.name" class="table-row">
<div class="table-cell file-entry">
<div class="file-primary">
<div class="flex flex-col gap-2">
<p class="m-0">{{ formatMessage(commonMessages.descriptionLabel) }}</p>
<StyledInput
v-model="exportDescription"
multiline
:placeholder="formatMessage(messages.descriptionPlaceholder)"
/>
</div>
<Accordion
class="w-full bg-surface-4 border border-solid border-surface-5 rounded-2xl overflow-clip"
button-class="p-4 w-full border-b border-solid border-b-surface-5 bg-surface-2 -mb-px hover:brightness-[--hover-brightness] group"
>
<template #title>
<span class="flex items-center gap-3 text-contrast group-active:scale-[0.98]">
<WrenchIcon aria-hidden="true" class="size-5 text-secondary" />
Configure which files are included in this export
</span>
</template>
<div class="flex flex-col [&>*:nth-child(even)]:bg-surface-3">
<div v-for="[path, children] in folders" :key="path.name" class="flex flex-col">
<Accordion
class="flex flex-col"
button-class="flex gap-3 pr-4 hover:bg-surface-5 group"
>
<template #title>
<Checkbox
:model-value="children.every((child) => child.selected)"
:label="path.name"
class="select-checkbox"
:indeterminate="
!children.every((child) => child.selected) &&
children.some((child) => child.selected)
"
:description="formatMessage(messages.includeFile, { file: path.name })"
class="pl-4 py-2"
:disabled="children.every((x) => x.disabled)"
@update:model-value="
(newValue) => children.forEach((child) => (child.selected = newValue))
"
@click.stop
/>
<span class="ml-2 group-active:scale-95">{{ path.name }}/</span>
</template>
<div v-for="child in children" :key="child.path">
<Checkbox
v-model="path.showingMore"
class="select-checkbox dropdown"
collapsing-toggle-style
v-model="child.selected"
:label="child.name"
class="w-full px-8 py-2 hover:bg-surface-4 text-primary"
:disabled="child.disabled"
/>
</div>
<div v-if="path.showingMore" class="file-secondary">
<div v-for="child in children" :key="child.path" class="file-secondary-row">
<Checkbox
v-model="child.selected"
:label="child.name"
class="select-checkbox"
:disabled="child.disabled"
/>
</div>
</div>
</div>
</div>
<div v-for="file in files" :key="file.path" class="table-row">
<div class="table-cell file-entry">
<div class="file-primary">
<Checkbox
v-model="file.selected"
:label="file.name"
:disabled="file.disabled"
class="select-checkbox"
/>
</div>
</div>
</Accordion>
</div>
<Checkbox
v-for="file in files"
:key="file.path"
v-model="file.selected"
:label="file.name"
:disabled="file.disabled"
class="w-full px-4 py-2 hover:bg-surface-4 text-primary"
/>
</div>
</div>
<div class="button-row push-right">
</Accordion>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="exportModal.hide">
<XIcon />
@@ -249,83 +257,3 @@ const exportPack = async () => {
</div>
</ModalWrapper>
</template>
<style scoped lang="scss">
.modal-body {
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
.labeled_input {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
p {
margin: 0;
}
}
.select-checkbox {
gap: var(--gap-sm);
button.checkbox {
border: none;
}
&.dropdown {
margin-left: auto;
}
}
.table-content {
max-height: 18rem;
overflow-y: auto;
}
.table {
border: 1px solid var(--color-bg);
}
.file-entry {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
}
.file-primary {
display: flex;
align-items: center;
gap: var(--gap-sm);
}
.file-secondary {
margin-left: var(--gap-xl);
display: flex;
flex-direction: column;
gap: var(--gap-sm);
height: 100%;
vertical-align: center;
}
.file-secondary-row {
display: flex;
align-items: center;
gap: var(--gap-sm);
}
.button-row {
display: flex;
gap: var(--gap-sm);
align-items: center;
}
.row-wise {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
</style>
@@ -0,0 +1,332 @@
import type { Labrinth } from '@modrinth/api-client'
import { CheckIcon, PlayIcon, PlusIcon, StopCircleIcon } from '@modrinth/assets'
import type { CardAction } from '@modrinth/ui'
import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import type { ComputedRef, Ref } from 'vue'
import { onUnmounted, ref, shallowRef } from 'vue'
import type { Router } from 'vue-router'
import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import { kill, list as listInstances } from '@/helpers/profile.js'
import type { GameInstance } from '@/helpers/types'
import { add_server_to_profile, getServerLatency } from '@/helpers/worlds'
import { getServerAddress } from '@/store/install.js'
interface BrowseServerInstance {
name: string
path: string
}
interface ContextMenuHandle {
showMenu: (
event: MouseEvent,
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
options: { name: string }[],
) => void
}
interface ContextMenuOptionClick {
option: 'open_link' | 'copy_link'
item: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject
}
export interface UseAppServerBrowseOptions {
instance: Ref<BrowseServerInstance | null>
isFromWorlds: ComputedRef<boolean>
allInstalledIds: ComputedRef<Set<string>>
newlyInstalled: Ref<string[]>
installingServerProjects: Ref<string[]>
playServerProject: (projectId: string) => Promise<void>
showAddServerToInstanceModal: (serverName: string, serverAddress: string) => void
handleError: (error: unknown) => void
router: Router
}
const messages = defineMessages({
addToInstance: {
id: 'app.browse.add-to-instance',
defaultMessage: 'Add to instance',
},
addToInstanceName: {
id: 'app.browse.add-to-instance-name',
defaultMessage: 'Add to {instanceName}',
},
added: {
id: 'app.browse.added',
defaultMessage: 'Added',
},
alreadyAdded: {
id: 'app.browse.already-added',
defaultMessage: 'Already added',
},
})
export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
const { formatMessage } = useVIntl()
const debugLog = useDebugLogger('BrowseServer')
const serverPings = shallowRef<Record<string, number | undefined>>({})
const serverPingCache = new Map<string, number | undefined>()
const pendingServerPings = new Map<string, Promise<number | undefined>>()
const runningServerProjects = ref<Record<string, string>>({})
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const contextMenuRef = ref<ContextMenuHandle | null>(null)
let serverPingCacheActive = true
let unlistenProcesses: (() => void) | null = null
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
debugLog('checkServerRunningStates', { hitCount: hits.length })
const packs = await listInstances().catch((error) => {
options.handleError(error)
return []
})
const newRunning: Record<string, string> = {}
for (const hit of hits) {
const inst = packs.find(
(pack: GameInstance) => pack.linked_data?.project_id === hit.project_id,
)
if (inst) {
const processes = await get_by_profile_path(inst.path).catch(() => [])
if (Array.isArray(processes) && processes.length > 0) {
newRunning[hit.project_id] = inst.path
}
}
}
debugLog('runningServerProjects updated', newRunning)
runningServerProjects.value = newRunning
}
async function handleStopServerProject(projectId: string) {
debugLog('handleStopServerProject', projectId)
const instancePath = runningServerProjects.value[projectId]
if (!instancePath) return
await kill(instancePath).catch(() => {})
const { [projectId]: _, ...rest } = runningServerProjects.value
runningServerProjects.value = rest
}
async function handlePlayServerProject(projectId: string) {
debugLog('handlePlayServerProject', projectId)
await options.playServerProject(projectId)
checkServerRunningStates(lastServerHits.value)
}
async function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
debugLog('handleAddServerToInstance', { projectId: project.project_id, name: project.name })
const address = getServerAddress(project.minecraft_java_server)
if (!address) return
if (options.instance.value) {
try {
await add_server_to_profile(
options.instance.value.path,
project.name,
address,
'prompt',
project.project_id,
project.minecraft_java_server?.content?.kind,
)
options.newlyInstalled.value.push(project.project_id)
} catch (error) {
options.handleError(error)
}
} else {
options.showAddServerToInstanceModal(project.name, address)
}
}
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
debugLog('pingServerHits', { hitCount: hits.length })
const pingsToFetch = hits.flatMap((hit) => {
const address = hit.minecraft_java_server?.address
if (!address) return []
return [{ hit, address }]
})
const nextPings = { ...serverPings.value }
for (const { hit, address } of pingsToFetch) {
if (serverPingCache.has(address)) {
nextPings[hit.project_id] = serverPingCache.get(address)
}
}
serverPings.value = nextPings
await Promise.all(
pingsToFetch.map(async ({ hit, address }) => {
if (serverPingCache.has(address)) return
let pending = pendingServerPings.get(address)
if (!pending) {
pending = getServerLatency(address)
.then((latency) => {
if (serverPingCacheActive) serverPingCache.set(address, latency)
return latency
})
.catch((error) => {
console.error(`Failed to ping server ${address}:`, error)
if (serverPingCacheActive) serverPingCache.set(address, undefined)
return undefined
})
.finally(() => {
pendingServerPings.delete(address)
})
pendingServerPings.set(address, pending)
}
const latency = await pending
if (!serverPingCacheActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
}),
)
}
function updateServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
lastServerHits.value = hits
pingServerHits(hits)
checkServerRunningStates(hits)
}
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
const content = project.minecraft_java_server?.content
if (content?.kind === 'modpack') {
const { project_name, project_icon, project_id } = content
if (!project_name) return undefined
return {
name: project_name,
icon: project_icon ?? undefined,
onclick:
project_id !== project.project_id
? () => {
options.router.push(`/project/${project_id}`)
}
: undefined,
showCustomModpackTooltip: project_id === project.project_id,
}
}
return undefined
}
function getServerCardActions(
serverResult: Labrinth.Search.v3.ResultSearchProject,
): CardAction[] {
const isInstalled = options.allInstalledIds.value.has(serverResult.project_id)
if (options.isFromWorlds.value && options.instance.value) {
return [
{
key: 'add-to-instance',
label: formatMessage(isInstalled ? messages.added : messages.addToInstance),
icon: isInstalled ? CheckIcon : PlusIcon,
disabled: isInstalled,
color: 'brand',
type: 'outlined',
onClick: () => handleAddServerToInstance(serverResult),
},
]
}
const actions: CardAction[] = []
actions.push({
key: 'add',
label: '',
icon: isInstalled ? CheckIcon : PlusIcon,
disabled: isInstalled,
circular: true,
tooltip: isInstalled
? formatMessage(messages.alreadyAdded)
: options.instance.value
? formatMessage(messages.addToInstanceName, {
instanceName: options.instance.value.name,
})
: formatMessage(commonMessages.addServerToInstanceButton),
onClick: () => handleAddServerToInstance(serverResult),
})
if (runningServerProjects.value[serverResult.project_id]) {
actions.push({
key: 'stop',
label: formatMessage(commonMessages.stopButton),
icon: StopCircleIcon,
color: 'red',
type: 'outlined',
onClick: () => handleStopServerProject(serverResult.project_id),
})
} else {
const isInstalling = options.installingServerProjects.value.includes(serverResult.project_id)
actions.push({
key: 'play',
label: formatMessage(
isInstalling ? commonMessages.installingLabel : commonMessages.playButton,
),
icon: PlayIcon,
disabled: isInstalling,
color: 'brand',
type: 'outlined',
onClick: () => handlePlayServerProject(serverResult.project_id),
})
}
return actions
}
function handleRightClick(
event: MouseEvent,
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
) {
contextMenuRef.value?.showMenu(event, result, [{ name: 'open_link' }, { name: 'copy_link' }])
}
function handleOptionsClick(args: ContextMenuOptionClick) {
const url = getProjectUrl(args.item)
switch (args.option) {
case 'open_link':
openUrl(url)
break
case 'copy_link':
navigator.clipboard.writeText(url)
break
}
}
process_listener((event: { event: string; profile_path_id: string }) => {
debugLog('process event', event)
if (event.event === 'finished') {
const projectId = Object.entries(runningServerProjects.value).find(
([, path]) => path === event.profile_path_id,
)?.[0]
if (projectId) {
const { [projectId]: _, ...rest } = runningServerProjects.value
runningServerProjects.value = rest
}
}
})
.then((unlisten) => {
unlistenProcesses = unlisten
})
.catch(options.handleError)
onUnmounted(() => {
serverPingCacheActive = false
unlistenProcesses?.()
serverPingCache.clear()
pendingServerPings.clear()
})
return {
serverPings,
contextMenuRef,
updateServerHits,
getServerModpackContent,
getServerCardActions,
handleRightClick,
handleOptionsClick,
}
}
function getProjectUrl(
item: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
) {
const projectType = 'project_types' in item ? item.project_types?.[0] : item.project_type
return `https://modrinth.com/${projectType ?? 'project'}/${item.slug ?? item.project_id}`
}
@@ -36,18 +36,38 @@ export function useIntercomPositioning({
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const intercomBubbleHorizontalPadding = computed(() =>
const defaultIntercomBubbleHorizontalPadding = computed(() =>
sidebarVisible.value
? APP_SIDEBAR_WIDTH + INTERCOM_BUBBLE_DEFAULT_PADDING
: INTERCOM_BUBBLE_DEFAULT_PADDING,
)
const intercomBubbleRequestedHorizontalPadding = ref<number | null>(null)
const intercomBubbleHorizontalPadding = computed(
() =>
intercomBubbleRequestedHorizontalPadding.value ??
defaultIntercomBubbleHorizontalPadding.value,
)
const intercomBubbleVerticalClearance = ref<number | null>(null)
const intercomBubblePosition = computed(() => ({
horizontalPadding: intercomBubbleHorizontalPadding.value,
verticalPadding: intercomBubbleVerticalClearance.value ?? INTERCOM_BUBBLE_DEFAULT_PADDING,
}))
const intercomBubbleHorizontalPaddingRequests = new Map<symbol, number>()
const intercomBubbleClearanceRequests = new Map<symbol, number>()
function requestIntercomBubbleHorizontalPadding(id: symbol, padding: number | null) {
if (padding === null) {
intercomBubbleHorizontalPaddingRequests.delete(id)
} else {
intercomBubbleHorizontalPaddingRequests.set(id, padding)
}
intercomBubbleRequestedHorizontalPadding.value =
intercomBubbleHorizontalPaddingRequests.size > 0
? Math.max(...intercomBubbleHorizontalPaddingRequests.values())
: null
}
function requestIntercomBubbleVerticalClearance(id: symbol, clearance: number | null) {
if (clearance === null) {
intercomBubbleClearanceRequests.delete(id)
@@ -93,6 +113,7 @@ export function useIntercomPositioning({
intercomBubble: {
width: ref(INTERCOM_BUBBLE_WIDTH),
horizontalPadding: intercomBubbleHorizontalPadding,
requestHorizontalPadding: requestIntercomBubbleHorizontalPadding,
requestVerticalClearance: requestIntercomBubbleVerticalClearance,
},
},
+1 -1
View File
@@ -184,7 +184,7 @@ export async function update_project(path: string, projectPath: string): Promise
// Add a project to a profile from a version
// Returns a path to the new project file
export type DownloadReason = 'standalone' | 'dependency' | 'modpack'
export type DownloadReason = 'standalone' | 'dependency' | 'modpack' | 'update'
export async function add_project_from_version(
path: string,
+22 -16
View File
@@ -104,11 +104,11 @@
"app.auth-servers.unreachable.header": {
"message": "Cannot reach authentication servers"
},
"app.browse.add-server-to-instance": {
"message": "Add server to instance"
},
"app.browse.add-servers-to-instance": {
"message": "Add servers to your instance"
"message": "Adding server to instance"
},
"app.browse.add-to-an-instance": {
"message": "Add to an instance"
},
"app.browse.add-to-instance": {
"message": "Add to instance"
@@ -122,6 +122,9 @@
"app.browse.already-added": {
"message": "Already added"
},
"app.browse.back-to-instance": {
"message": "Back to instance"
},
"app.browse.discover-content": {
"message": "Discover content"
},
@@ -131,20 +134,11 @@
"app.browse.hide-added-servers": {
"message": "Hide already added servers"
},
"app.browse.hide-installed-content": {
"message": "Hide already installed content"
},
"app.browse.install-content-to-instance": {
"message": "Install content to instance"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
},
"app.browse.server.install": {
"message": "Install"
},
"app.browse.server.installed": {
"message": "Installed"
"app.browse.server-instance-content-warning": {
"message": "Adding content can break compatibility when joining the server. Any added content will also be lost when you update the server instance content."
},
"app.browse.server.installing": {
"message": "Installing"
@@ -164,6 +158,9 @@
"app.export-modal.header": {
"message": "Export modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Include \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Name"
},
@@ -171,7 +168,7 @@
"message": "Modpack name"
},
"app.export-modal.select-files-label": {
"message": "Select files and folders to include in pack"
"message": "Configure which files are included in this export"
},
"app.export-modal.version-number-label": {
"message": "Version number"
@@ -302,6 +299,15 @@
"app.modal.update-to-play.update-required-description": {
"message": "An update is required to play {name}. Please update to the latest version to launch the game."
},
"app.project.install-button.already-installed": {
"message": "This project is already installed"
},
"app.project.install-context.back-to-browse": {
"message": "Back to browse"
},
"app.project.install-context.install-content-to-instance": {
"message": "Install content to instance"
},
"app.settings.developer-mode-enabled": {
"message": "Developer mode enabled."
},
+288 -334
View File
@@ -5,46 +5,49 @@ import {
ClipboardCopyIcon,
ExternalIcon,
GlobeIcon,
PlayIcon,
PlusIcon,
SpinnerIcon,
StopCircleIcon,
} from '@modrinth/assets'
import type { CardAction, ProjectType, Tags } from '@modrinth/ui'
import type { BrowseInstallContentType, CardAction, ProjectType, Tags } from '@modrinth/ui'
import {
BrowsePageLayout,
BrowseSidebar,
commonMessages,
CreationFlowModal,
defineMessages,
getLatestMatchingInstallVersion,
getSelectedInstallPreferences,
getTargetInstallPreferences,
injectNotificationManager,
preferencesDiffer,
provideBrowseManager,
requestInstall,
useBrowseSearch,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import type { Ref } from 'vue'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { get_project_v3, get_search_results_v3 } from '@/helpers/cache.js'
import { process_listener } from '@/helpers/events'
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
import {
get_project,
get_project_v3,
get_search_results_v3,
get_version_many,
} from '@/helpers/cache.js'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get_by_profile_path } from '@/helpers/process'
import {
get as getInstance,
get_installed_project_ids as getInstalledProjectIds,
kill,
list as listInstances,
} from '@/helpers/profile.js'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import { add_server_to_profile, get_profile_worlds, getServerLatency } from '@/helpers/worlds'
import { get_profile_worlds } from '@/helpers/worlds'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import {
@@ -52,7 +55,6 @@ import {
provideServerInstallContent,
} from '@/providers/setup/server-install-content'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { getServerAddress } from '@/store/install.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -76,15 +78,27 @@ const {
effectiveServerWorldId,
serverContextServerData,
serverContentProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
selectedServerInstallProjects,
isInstallingQueuedServerInstalls,
queuedInstallProgress,
serverBackUrl,
serverBackLabel,
serverBrowseHeading,
clearQueuedServerInstalls,
removeQueuedServerInstall,
flushQueuedServerInstalls,
discardQueuedServerInstallsAndBack,
installQueuedServerInstallsAndBack,
initServerContext,
watchServerContextChanges,
searchServerModpacks,
getServerProjectVersions,
enforceSetupModpackRoute,
installProjectToServer,
getQueuedServerInstallPlans,
setQueuedServerInstallPlans,
openServerModpackInstallFlow,
onServerFlowBack,
handleServerModpackFlowCreate,
markServerProjectInstalled,
@@ -254,6 +268,7 @@ const instanceFilters = computed(() => {
})
const serverHideInstalled = ref(false)
const hideSelectedServerInstalls = ref(false)
if (route.query.shi) {
serverHideInstalled.value = route.query.shi === 'true'
}
@@ -291,6 +306,12 @@ const serverContextFilters = computed(() => {
filters.push({ type: 'plugin_loader', option: platform })
if (pt === 'mod') filters.push({ type: 'environment', option: 'server' })
if (hideSelectedServerInstalls.value && queuedServerInstallProjectIds.value.size > 0) {
for (const id of queuedServerInstallProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
}
}
}
if (pt === 'modpack') {
@@ -313,134 +334,24 @@ const combinedProvidedFilters = computed(() =>
isServerContext.value ? serverContextFilters.value : instanceFilters.value,
)
const serverPings = shallowRef<Record<string, number | undefined>>({})
const serverPingCache = new Map<string, number | undefined>()
const pendingServerPings = new Map<string, Promise<number | undefined>>()
let serverPingCacheActive = true
const runningServerProjects = ref<Record<string, string>>({})
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
debugLog('checkServerRunningStates', { hitCount: hits.length })
const packs = await listInstances()
const newRunning: Record<string, string> = {}
for (const hit of hits) {
const inst = packs.find((p: GameInstance) => p.linked_data?.project_id === hit.project_id)
if (inst) {
const processes = await get_by_profile_path(inst.path).catch(() => [])
if (Array.isArray(processes) && processes.length > 0) {
newRunning[hit.project_id] = inst.path
}
}
}
debugLog('runningServerProjects updated', newRunning)
runningServerProjects.value = newRunning
}
async function handleStopServerProject(projectId: string) {
debugLog('handleStopServerProject', projectId)
const instancePath = runningServerProjects.value[projectId]
if (!instancePath) return
await kill(instancePath).catch(() => {})
const { [projectId]: _, ...rest } = runningServerProjects.value
runningServerProjects.value = rest
}
async function handlePlayServerProject(projectId: string) {
debugLog('handlePlayServerProject', projectId)
await playServerProject(projectId)
checkServerRunningStates(lastServerHits.value)
}
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
async function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
debugLog('handleAddServerToInstance', { projectId: project.project_id, name: project.name })
const address = getServerAddress(project.minecraft_java_server)
if (!address) return
if (instance.value) {
try {
await add_server_to_profile(
instance.value.path,
project.name,
address,
'prompt',
project.project_id,
project.minecraft_java_server?.content?.kind,
)
newlyInstalled.value.push(project.project_id)
} catch (err) {
handleError(err as Error)
}
} else {
showAddServerToInstanceModal(project.name, address)
}
}
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
debugLog('pingServerHits', { hitCount: hits.length })
const pingsToFetch = hits.flatMap((hit) => {
const address = hit.minecraft_java_server?.address
if (!address) return []
return [{ hit, address }]
})
const nextPings = { ...serverPings.value }
for (const { hit, address } of pingsToFetch) {
if (serverPingCache.has(address)) {
nextPings[hit.project_id] = serverPingCache.get(address)
}
}
serverPings.value = nextPings
await Promise.all(
pingsToFetch.map(async ({ hit, address }) => {
if (serverPingCache.has(address)) return
let pending = pendingServerPings.get(address)
if (!pending) {
pending = getServerLatency(address)
.then((latency) => {
if (serverPingCacheActive) serverPingCache.set(address, latency)
return latency
})
.catch((err) => {
console.error(`Failed to ping server ${address}:`, err)
if (serverPingCacheActive) serverPingCache.set(address, undefined)
return undefined
})
.finally(() => {
pendingServerPings.delete(address)
})
pendingServerPings.set(address, pending)
}
const latency = await pending
if (!serverPingCacheActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
}),
)
}
const unlistenProcesses = await process_listener(
(e: { event: string; profile_path_id: string }) => {
debugLog('process event', e)
if (e.event === 'finished') {
const projectId = Object.entries(runningServerProjects.value).find(
([, path]) => path === e.profile_path_id,
)?.[0]
if (projectId) {
const { [projectId]: _, ...rest } = runningServerProjects.value
runningServerProjects.value = rest
}
}
},
)
onUnmounted(() => {
serverPingCacheActive = false
unlistenProcesses()
serverPingCache.clear()
pendingServerPings.clear()
const {
serverPings,
contextMenuRef,
updateServerHits,
getServerModpackContent,
getServerCardActions,
handleRightClick,
handleOptionsClick,
} = useAppServerBrowse({
instance,
isFromWorlds,
allInstalledIds,
newlyInstalled,
installingServerProjects,
playServerProject,
showAddServerToInstanceModal,
handleError,
router,
})
const offline = ref(!navigator.onLine)
@@ -454,29 +365,13 @@ window.addEventListener('online', () => {
})
const messages = defineMessages({
addServerToInstance: {
id: 'app.browse.add-server-to-instance',
defaultMessage: 'Add server to instance',
},
addServersToInstance: {
id: 'app.browse.add-servers-to-instance',
defaultMessage: 'Add servers to your instance',
defaultMessage: 'Adding server to instance',
},
addToInstance: {
id: 'app.browse.add-to-instance',
defaultMessage: 'Add to instance',
},
addToInstanceName: {
id: 'app.browse.add-to-instance-name',
defaultMessage: 'Add to {instanceName}',
},
added: {
id: 'app.browse.added',
defaultMessage: 'Added',
},
alreadyAdded: {
id: 'app.browse.already-added',
defaultMessage: 'Already added',
addToAnInstance: {
id: 'app.browse.add-to-an-instance',
defaultMessage: 'Add to an instance',
},
discoverContent: {
id: 'app.browse.discover-content',
@@ -502,26 +397,19 @@ const messages = defineMessages({
id: 'app.browse.hide-added-servers',
defaultMessage: 'Hide already added servers',
},
hideInstalledContent: {
id: 'app.browse.hide-installed-content',
defaultMessage: 'Hide already installed content',
},
installContentToInstance: {
id: 'app.browse.install-content-to-instance',
defaultMessage: 'Install content to instance',
},
installToServer: {
id: 'app.browse.server.install',
defaultMessage: 'Install',
},
installedToServer: {
id: 'app.browse.server.installed',
defaultMessage: 'Installed',
},
installingToServer: {
id: 'app.browse.server.installing',
defaultMessage: 'Installing',
},
backToInstance: {
id: 'app.browse.back-to-instance',
defaultMessage: 'Back to instance',
},
serverInstanceContentWarning: {
id: 'app.browse.server-instance-content-warning',
defaultMessage:
'Adding content can break compatibility when joining the server. Any added content will also be lost when you update the server instance content.',
},
modLoaderProvidedByInstance: {
id: 'search.filter.locked.instance-loader.title',
defaultMessage: 'Loader is provided by the instance',
@@ -654,46 +542,6 @@ const selectableProjectTypes = computed(() => {
]
})
const getServerModpackContent = (project: Labrinth.Search.v3.ResultSearchProject) => {
const content = project.minecraft_java_server?.content
if (content?.kind === 'modpack') {
const { project_name, project_icon, project_id } = content
if (!project_name) return undefined
return {
name: project_name,
icon: project_icon ?? undefined,
onclick:
project_id !== project.project_id
? () => {
router.push(`/project/${project_id}`)
}
: undefined,
showCustomModpackTooltip: project_id === project.project_id,
}
}
return undefined
}
const contextMenuRef = ref(null)
// @ts-expect-error - no event types
const handleRightClick = (event, result) => {
// @ts-ignore
contextMenuRef.value?.showMenu(event, result, [{ name: 'open_link' }, { name: 'copy_link' }])
}
// @ts-expect-error - no event types
const handleOptionsClick = (args) => {
switch (args.option) {
case 'open_link':
openUrl(`https://modrinth.com/${args.item.project_types?.[0] ?? 'project'}/${args.item.slug}`)
break
case 'copy_link':
navigator.clipboard.writeText(
`https://modrinth.com/${args.item.project_types?.[0] ?? 'project'}/${args.item.slug}`,
)
break
}
}
const installContext = computed(() => {
if (isServerContext.value && serverContextServerData.value) {
return {
@@ -707,6 +555,15 @@ const installContext = computed(() => {
backUrl: serverBackUrl.value,
backLabel: serverBackLabel.value,
heading: serverBrowseHeading.value,
queuedCount: queuedServerInstallCount.value,
selectedProjects: selectedServerInstallProjects.value,
isInstallingSelected: isInstallingQueuedServerInstalls.value,
installProgress: queuedInstallProgress.value,
clearQueued: clearQueuedServerInstalls,
clearSelected: clearQueuedServerInstalls,
onBack: flushQueuedServerInstalls,
discardSelectedAndBack: discardQueuedServerInstallsAndBack,
installSelected: installQueuedServerInstallsAndBack,
}
}
if (instance.value) {
@@ -716,13 +573,13 @@ const installContext = computed(() => {
gameVersion: instance.value.game_version,
iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
backUrl: `/instance/${encodeURIComponent(instance.value.path)}${isFromWorlds.value ? '/worlds' : ''}`,
backLabel: 'Back to instance',
backLabel: formatMessage(messages.backToInstance),
heading: formatMessage(
isFromWorlds.value ? messages.addServersToInstance : messages.installContentToInstance,
isFromWorlds.value ? messages.addServersToInstance : commonMessages.installingContentLabel,
),
warning:
isServerInstance.value && !isFromWorlds.value
? 'Adding content can break compatibility when joining the server. Any added content will also be lost when you update the server instance content.'
? formatMessage(messages.serverInstanceContentWarning)
: undefined,
}
}
@@ -741,71 +598,82 @@ function setProjectInstalling(projectId: string, installing: boolean) {
installingProjectIds.value = next
}
const serverInstallQueue = {
get: getQueuedServerInstallPlans,
set: setQueuedServerInstallPlans,
}
function getCurrentSelectedInstallPreferences(projectTypeValue: string) {
return getSelectedInstallPreferences({
contentType: projectTypeValue,
selectedFilters: searchState.currentFilters.value,
providedFilters: combinedProvidedFilters.value,
overriddenProvidedFilterTypes: searchState.overriddenProvidedFilterTypes.value,
})
}
function getServerInstallTargetPreferences(contentType: BrowseInstallContentType) {
return getTargetInstallPreferences(
{
gameVersion: serverContextServerData.value?.mc_version,
loader: serverContextServerData.value?.loader,
},
contentType,
)
}
function getInstanceInstallTargetPreferences(projectTypeValue: string) {
return getTargetInstallPreferences(
{
gameVersion: instance.value?.game_version,
loader: instance.value?.loader,
},
projectTypeValue,
)
}
async function getInstallProjectVersions(projectId: string) {
const project = await get_project(projectId, 'must_revalidate')
return (await get_version_many(
project.versions,
'must_revalidate',
)) as Labrinth.Versions.v2.Version[]
}
async function chooseInstanceInstallVersion(
project: Labrinth.Search.v2.ResultSearchProject & Labrinth.Search.v3.ResultSearchProject,
projectTypeValue: string,
) {
const targetInstance = instance.value
if (!targetInstance) {
return { versionId: null as string | null }
}
const selectedPreferences = getCurrentSelectedInstallPreferences(projectTypeValue)
const targetPreferences = getInstanceInstallTargetPreferences(projectTypeValue)
if (!preferencesDiffer(selectedPreferences, targetPreferences)) {
return { versionId: null as string | null }
}
const selectedVersion = getLatestMatchingInstallVersion(
await getInstallProjectVersions(project.project_id),
selectedPreferences,
projectTypeValue,
)
if (!selectedVersion) {
return { versionId: null as string | null }
}
return { versionId: selectedVersion.id }
}
function getCardActions(
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
currentProjectType: string,
): CardAction[] {
if (currentProjectType === 'server') {
const serverResult = result as Labrinth.Search.v3.ResultSearchProject
const isInstalled = allInstalledIds.value.has(serverResult.project_id)
if (isFromWorlds.value && instance.value) {
return [
{
key: 'add-to-instance',
label: formatMessage(isInstalled ? messages.added : messages.addToInstance),
icon: isInstalled ? CheckIcon : PlusIcon,
disabled: isInstalled,
color: 'brand',
type: 'outlined',
onClick: () => handleAddServerToInstance(serverResult),
},
]
}
const actions: CardAction[] = []
actions.push({
key: 'add',
label: '',
icon: isInstalled ? CheckIcon : PlusIcon,
disabled: isInstalled,
circular: true,
tooltip: isInstalled
? formatMessage(messages.alreadyAdded)
: instance.value
? formatMessage(messages.addToInstanceName, { instanceName: instance.value.name })
: formatMessage(messages.addServerToInstance),
onClick: () => handleAddServerToInstance(serverResult),
})
if (runningServerProjects.value[serverResult.project_id]) {
actions.push({
key: 'stop',
label: formatMessage(commonMessages.stopButton),
icon: StopCircleIcon,
color: 'red',
type: 'outlined',
onClick: () => handleStopServerProject(serverResult.project_id),
})
} else {
const isInstalling = (installingServerProjects.value as string[]).includes(
serverResult.project_id,
)
actions.push({
key: 'play',
label: formatMessage(
isInstalling ? commonMessages.installingLabel : commonMessages.playButton,
),
icon: PlayIcon,
disabled: isInstalling,
color: 'brand',
type: 'outlined',
onClick: () => handlePlayServerProject(serverResult.project_id),
})
}
return actions
return getServerCardActions(result as Labrinth.Search.v3.ResultSearchProject)
}
// Non-server project actions
@@ -817,39 +685,84 @@ function getCardActions(
const isInstalled =
projectResult.installed ||
allInstalledIds.value.has(projectResult.project_id || '') ||
serverContentProjectIds.value.has(projectResult.project_id || '')
serverContentProjectIds.value.has(projectResult.project_id || '') ||
serverContextServerData.value?.upstream?.project_id === projectResult.project_id
const isInstalling = installingProjectIds.value.has(projectResult.project_id)
if (
isServerContext.value &&
['modpack', 'mod', 'plugin', 'datapack'].includes(currentProjectType)
) {
const isQueued = queuedServerInstallProjectIds.value.has(projectResult.project_id)
const isInstallingSelection = isInstallingQueuedServerInstalls.value
const validatingInstall =
isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection
const installLabel = isInstalled
? commonMessages.installedLabel
: isQueued
? isInstalling || isInstallingSelection
? validatingInstall
? commonMessages.validatingLabel
: messages.installingToServer
: commonMessages.selectedLabel
: isInstalling || isInstallingSelection
? validatingInstall
? commonMessages.validatingLabel
: messages.installingToServer
: commonMessages.installButton
return [
{
key: 'install',
label: formatMessage(
isInstalling
? messages.installingToServer
: isInstalled
? messages.installedToServer
: messages.installToServer,
),
icon: isInstalled ? CheckIcon : PlusIcon,
iconClass: isInstalling ? 'animate-spin' : undefined,
disabled: isInstalled || isInstalling,
color: 'brand',
label: formatMessage(installLabel),
icon:
isInstalling || isInstallingSelection
? SpinnerIcon
: isQueued || isInstalled
? CheckIcon
: PlusIcon,
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
disabled: isInstalled || isInstalling || isInstallingSelection,
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
type: 'outlined',
onClick: async () => {
setProjectInstalling(projectResult.project_id, true)
if (isQueued) {
removeQueuedServerInstall(projectResult.project_id)
return
}
const contentType = currentProjectType as BrowseInstallContentType
const isModpack = contentType === 'modpack'
const shouldShowInstalling = isModpack || !isQueued
if (shouldShowInstalling) {
setProjectInstalling(projectResult.project_id, true)
}
try {
const didInstall = await installProjectToServer(projectResult)
if (didInstall !== false) {
onSearchResultInstalled(projectResult.project_id)
}
await requestInstall({
project: projectResult,
contentType,
mode: isModpack ? 'immediate' : 'queue',
selectedFilters: isModpack ? [] : searchState.currentFilters.value,
providedFilters: isModpack ? [] : combinedProvidedFilters.value,
overriddenProvidedFilterTypes: isModpack
? []
: searchState.overriddenProvidedFilterTypes.value,
targetPreferences: getServerInstallTargetPreferences(contentType),
getProjectVersions: getInstallProjectVersions,
queue: serverInstallQueue,
install: (plan) =>
openServerModpackInstallFlow({
projectId: plan.projectId,
versionId: plan.versionId,
name: plan.project.name,
iconUrl: plan.project.icon_url ?? undefined,
}),
})
} catch (err) {
handleError(err as Error)
} finally {
setProjectInstalling(projectResult.project_id, false)
if (shouldShowInstalling) {
setProjectInstalling(projectResult.project_id, false)
}
}
},
},
@@ -862,13 +775,15 @@ function getCardActions(
return [
{
key: 'install',
label: isInstalling
? 'Installing'
: isInstalled
? 'Installed'
: shouldUseInstallIcon
? 'Install'
: 'Add to an instance',
label: formatMessage(
isInstalling
? messages.installingToServer
: isInstalled
? commonMessages.installedLabel
: shouldUseInstallIcon
? commonMessages.installButton
: messages.addToAnInstance,
),
icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : PlusIcon,
iconClass: isInstalling ? 'animate-spin' : undefined,
disabled: isInstalled || isInstalling,
@@ -876,28 +791,39 @@ function getCardActions(
type: 'outlined',
onClick: async () => {
setProjectInstalling(projectResult.project_id, true)
await installVersion(
projectResult.project_id,
null,
instance.value ? instance.value.path : null,
'SearchCard',
(versionId) => {
try {
const selectedInstall = instance.value
? await chooseInstanceInstallVersion(projectResult, currentProjectType)
: { versionId: null as string | null }
if (selectedInstall === null) {
setProjectInstalling(projectResult.project_id, false)
if (versionId) {
onSearchResultInstalled(projectResult.project_id)
}
},
(profile) => {
router.push(`/instance/${profile}`)
},
{
preferredLoader: instance.value?.loader ?? undefined,
preferredGameVersion: instance.value?.game_version ?? undefined,
},
).catch((err) => {
return
}
const selectedPreferences = getCurrentSelectedInstallPreferences(currentProjectType)
await installVersion(
projectResult.project_id,
selectedInstall.versionId,
instance.value ? instance.value.path : null,
'SearchCard',
(versionId) => {
setProjectInstalling(projectResult.project_id, false)
if (versionId) {
onSearchResultInstalled(projectResult.project_id)
}
},
(profile) => {
router.push(`/instance/${profile}`)
},
{
preferredLoader: instance.value?.loader ?? selectedPreferences.loaders?.[0],
preferredGameVersion:
instance.value?.game_version ?? selectedPreferences.gameVersions?.[0],
},
)
} catch (err) {
setProjectInstalling(projectResult.project_id, false)
handleError(err)
})
}
},
},
]
@@ -937,9 +863,7 @@ async function search(requestParams: string) {
if (isServer) {
const hits = rawResults.result.hits ?? []
lastServerHits.value = hits
pingServerHits(hits)
checkServerRunningStates(hits)
updateServerHits(hits)
return {
projectHits: [],
serverHits: hits,
@@ -1024,6 +948,12 @@ watch(
{ deep: true },
)
watch(queuedServerInstallCount, (count) => {
if (count === 0) {
hideSelectedServerInstalls.value = false
}
})
if (instance.value?.game_version) {
const gv = instance.value.game_version
const alreadyHasGv = searchState.serverCurrentFilters.value.some(
@@ -1036,16 +966,26 @@ if (instance.value?.game_version) {
await searchState.refreshSearch()
function getProjectBrowseQuery() {
if (!installContext.value) return undefined
return {
...route.query,
b: route.fullPath,
}
}
provideBrowseManager({
tags,
projectType,
...searchState,
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => ({
path: `/project/${result.project_id ?? result.slug}`,
query: instance.value ? { i: instance.value.path } : undefined,
query: getProjectBrowseQuery(),
}),
getServerProjectLink: (result: Labrinth.Search.v3.ResultSearchProject) => ({
path: `/project/${result.slug ?? result.project_id}`,
query: getProjectBrowseQuery(),
}),
getServerProjectLink: (result: Labrinth.Search.v3.ResultSearchProject) =>
`/project/${result.slug ?? result.project_id}`,
selectableProjectTypes,
showProjectTypeTabs: computed(() => !isServerContext.value),
variant: 'app',
@@ -1068,8 +1008,18 @@ provideBrowseManager({
() => (isServerContext.value && projectType.value !== 'modpack') || !!instance.value,
),
hideInstalledLabel: computed(() =>
formatMessage(isFromWorlds.value ? messages.hideAddedServers : messages.hideInstalledContent),
formatMessage(
isFromWorlds.value ? messages.hideAddedServers : commonMessages.hideInstalledContentLabel,
),
),
hideSelected: hideSelectedServerInstalls,
showHideSelected: computed(
() =>
isServerContext.value &&
projectType.value !== 'modpack' &&
queuedServerInstallCount.value > 0,
),
hideSelectedLabel: computed(() => formatMessage(commonMessages.hideSelectedContentLabel)),
onInstalled: onSearchResultInstalled,
serverPings,
getServerModpackContent,
@@ -1084,8 +1034,12 @@ provideBrowseManager({
<BrowsePageLayout>
<template #after>
<ContextMenu ref="contextMenuRef" @option-clicked="handleOptionsClick">
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
<template #open_link>
<GlobeIcon /> {{ formatMessage(commonMessages.openInModrinthButton) }} <ExternalIcon />
</template>
<template #copy_link>
<ClipboardCopyIcon /> {{ formatMessage(commonMessages.copyLinkButton) }}
</template>
</ContextMenu>
</template>
</BrowsePageLayout>
@@ -311,7 +311,7 @@ async function updateProject(mod: ContentItem) {
const profile = await get(props.instance.path).catch(handleError)
if (profile) {
await installVersionDependencies(profile, versionData).catch(handleError)
await installVersionDependencies(profile, versionData, 'update').catch(handleError)
}
}
}
@@ -347,7 +347,7 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
const profile = await get(props.instance.path).catch(handleError)
if (profile) {
await installVersionDependencies(profile, version).catch(handleError)
await installVersionDependencies(profile, version, 'update').catch(handleError)
}
mod.file_path = newPath
+239 -19
View File
@@ -45,7 +45,13 @@
/>
</Teleport>
<div class="flex flex-col gap-4 p-6">
<InstanceIndicator v-if="instance" :instance="instance" />
<div
v-if="projectInstallContext"
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
>
<BrowseInstallHeader :install-context="projectInstallContext" />
</div>
<InstanceIndicator v-if="instance && !projectInstallContext" :instance="instance" />
<template v-if="data">
<Teleport
v-if="themeStore.featureFlags.project_background"
@@ -64,7 +70,7 @@
<ButtonStyled v-if="serverPlaying" size="large" color="red">
<button @click="handleStopServer">
<StopCircleIcon />
Stop
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand">
@@ -73,11 +79,18 @@
@click="handleClickPlay"
>
<PlayIcon />
{{ data && installingServerProjects.includes(data.id) ? 'Installing...' : 'Play' }}
{{
data && installingServerProjects.includes(data.id)
? formatMessage(commonMessages.installingLabel)
: formatMessage(commonMessages.playButton)
}}
</button>
</ButtonStyled>
<ButtonStyled size="large" circular>
<button v-tooltip="'Add server to instance'" @click="handleAddServerToInstance">
<button
v-tooltip="formatMessage(commonMessages.addServerToInstanceButton)"
@click="handleAddServerToInstance"
>
<PlusIcon />
</button>
</ButtonStyled>
@@ -111,13 +124,17 @@
<template v-else #actions>
<ButtonStyled size="large" color="brand">
<button
v-tooltip="installed ? `This project is already installed` : null"
:disabled="installed || installing"
v-tooltip="installButtonTooltip"
:disabled="installButtonDisabled"
@click="install(null)"
>
<DownloadIcon v-if="!installed && !installing" />
<CheckIcon v-else-if="installed" />
{{ installing ? 'Installing...' : installed ? 'Installed' : 'Install' }}
<SpinnerIcon
v-if="installButtonLoading && !installButtonInstalled"
class="animate-spin"
/>
<DownloadIcon v-else-if="!installButtonInstalled && !serverProjectSelected" />
<CheckIcon v-else />
{{ installButtonLabel }}
</button>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
@@ -166,7 +183,7 @@
:links="[
{
label: 'Description',
href: `/project/${$route.params.id}`,
href: projectDescriptionHref,
},
{
label: 'Versions',
@@ -176,7 +193,7 @@
},
{
label: 'Gallery',
href: `/project/${$route.params.id}/gallery`,
href: projectGalleryHref,
shown: data.gallery.length > 0,
},
]"
@@ -195,11 +212,39 @@
</template>
<template v-else> Project data couldn't not be loaded. </template>
</div>
<SelectedProjectsFloatingBar
v-if="projectInstallContext"
:install-context="projectInstallContext"
/>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #install> <DownloadIcon /> Install </template>
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
<template #install>
<DownloadIcon /> {{ formatMessage(commonMessages.installButton) }}
</template>
<template #open_link>
<GlobeIcon /> {{ formatMessage(commonMessages.openInModrinthButton) }} <ExternalIcon />
</template>
<template #copy_link>
<ClipboardCopyIcon /> {{ formatMessage(commonMessages.copyLinkButton) }}
</template>
</ContextMenu>
<CreationFlowModal
v-if="serverInstallContent.isServerContext.value && data?.project_type === 'modpack'"
ref="serverSetupModalRef"
:type="
serverInstallContent.serverFlowFrom.value === 'reset-server'
? 'reset-server'
: 'server-onboarding'
"
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
:show-snapshot-toggle="true"
:on-back="serverInstallContent.onServerFlowBack"
:search-modpacks="serverInstallContent.searchServerModpacks"
:get-project-versions="serverInstallContent.getServerProjectVersions"
:get-loader-manifest="getLoaderManifest"
@hide="() => {}"
@browse-modpacks="() => {}"
@create="serverInstallContent.handleServerModpackFlowCreate"
/>
</div>
</template>
@@ -216,10 +261,16 @@ import {
PlayIcon,
PlusIcon,
ReportIcon,
SpinnerIcon,
StopCircleIcon,
} from '@modrinth/assets'
import {
BrowseInstallHeader,
ButtonStyled,
commonMessages,
CreationFlowModal,
defineMessages,
getTargetInstallPreferences,
injectNotificationManager,
NavTabs,
OverflowMenu,
@@ -231,7 +282,11 @@ import {
ProjectSidebarLinks,
ProjectSidebarServerInfo,
ProjectSidebarTags,
requestInstall,
SelectedProjectsFloatingBar,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
@@ -249,6 +304,7 @@ import {
get_version_many,
} from '@/helpers/cache.js'
import { process_listener } from '@/helpers/events'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get_by_profile_path } from '@/helpers/process'
import {
get as getInstance,
@@ -260,6 +316,7 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { getServerLatency } from '@/helpers/worlds'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import { createServerInstallContent } from '@/providers/setup/server-install-content'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { getServerAddress } from '@/store/install.js'
import { useTheming } from '@/store/state.js'
@@ -272,6 +329,22 @@ const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const messages = defineMessages({
backToBrowse: {
id: 'app.project.install-context.back-to-browse',
defaultMessage: 'Back to browse',
},
installContentToInstance: {
id: 'app.project.install-context.install-content-to-instance',
defaultMessage: 'Install content to instance',
},
alreadyInstalled: {
id: 'app.project.install-button.already-installed',
defaultMessage: 'This project is already installed',
},
})
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
injectServerInstall()
@@ -296,6 +369,11 @@ const serverPing = ref(undefined)
const serverStatusOnline = ref(false)
const serverInstancePath = ref(null)
const serverPlaying = ref(false)
const serverSetupModalRef = ref(null)
const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
serverInstallContent.watchServerContextChanges()
await serverInstallContent.initServerContext()
const instanceFilters = computed(() => {
if (!instance.value) {
@@ -315,11 +393,9 @@ const instanceFilters = computed(() => {
return { l: loaders, g: instance.value.game_version }
})
const versionsHref = computed(() => {
const base = `/project/${route.params.id}/versions`
const filters = instanceFilters.value
function buildProjectHref(path, extraQuery = {}) {
const params = new URLSearchParams()
for (const [key, val] of Object.entries(filters)) {
for (const [key, val] of Object.entries({ ...route.query, ...extraQuery })) {
if (Array.isArray(val)) {
for (const v of val) params.append(key, v)
} else if (val) {
@@ -327,7 +403,102 @@ const versionsHref = computed(() => {
}
}
const qs = params.toString()
return qs ? `${base}?${qs}` : base
return qs ? `${path}?${qs}` : path
}
const projectDescriptionHref = computed(() => buildProjectHref(`/project/${route.params.id}`))
const versionsHref = computed(() =>
buildProjectHref(`/project/${route.params.id}/versions`, instanceFilters.value),
)
const projectGalleryHref = computed(() => buildProjectHref(`/project/${route.params.id}/gallery`))
const projectBrowseBackUrl = computed(() => {
const browsePath = route.query.b
if (typeof browsePath === 'string' && browsePath.startsWith('/browse/')) return browsePath
const type = data.value?.project_type ? `${data.value.project_type}s` : 'mods'
return `/browse/${type}`
})
const projectInstallContext = computed(() => {
const serverData = serverInstallContent.serverContextServerData.value
if (serverData) {
return {
name: serverData.name,
loader: serverData.loader ?? '',
gameVersion: serverData.mc_version ?? '',
serverId: serverInstallContent.serverIdQuery.value,
upstream: serverData.upstream,
iconSrc: null,
isMedal: serverData.is_medal,
backUrl: projectBrowseBackUrl.value,
backLabel: formatMessage(messages.backToBrowse),
heading: serverInstallContent.serverBrowseHeading.value,
queuedCount: serverInstallContent.queuedServerInstallCount.value,
selectedProjects: serverInstallContent.selectedServerInstallProjects.value,
isInstallingSelected: serverInstallContent.isInstallingQueuedServerInstalls.value,
installProgress: serverInstallContent.queuedInstallProgress.value,
clearQueued: serverInstallContent.clearQueuedServerInstalls,
clearSelected: serverInstallContent.clearQueuedServerInstalls,
discardSelectedAndBack: serverInstallContent.discardQueuedServerInstallsAndBack,
installSelected: serverInstallContent.installQueuedServerInstallsAndBack,
}
}
if (instance.value) {
return {
name: instance.value.name,
loader: instance.value.loader,
gameVersion: instance.value.game_version,
iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
backUrl: projectBrowseBackUrl.value,
backLabel: formatMessage(messages.backToBrowse),
heading: formatMessage(messages.installContentToInstance),
}
}
return null
})
const serverProjectInstallContext = computed(
() =>
!!serverInstallContent.serverContextServerData.value &&
['modpack', 'mod', 'plugin', 'datapack'].includes(data.value?.project_type),
)
const serverProjectSelected = computed(
() => !!data.value && serverInstallContent.queuedServerInstallProjectIds.value.has(data.value.id),
)
const serverProjectInstalled = computed(
() =>
!!data.value &&
(serverInstallContent.serverContentProjectIds.value.has(data.value.id) ||
serverInstallContent.serverContextServerData.value?.upstream?.project_id === data.value.id),
)
const installButtonLoading = computed(
() => installing.value || serverInstallContent.isInstallingQueuedServerInstalls.value,
)
const installButtonValidating = computed(
() =>
serverProjectInstallContext.value &&
installing.value &&
data.value?.project_type !== 'modpack' &&
!serverInstallContent.isInstallingQueuedServerInstalls.value,
)
const installButtonInstalled = computed(() =>
serverProjectInstallContext.value ? serverProjectInstalled.value : installed.value,
)
const installButtonDisabled = computed(
() => installButtonInstalled.value || installButtonLoading.value,
)
const installButtonLabel = computed(() => {
if (installButtonInstalled.value) return formatMessage(commonMessages.installedLabel)
if (installButtonValidating.value) return formatMessage(commonMessages.validatingLabel)
if (installButtonLoading.value) return formatMessage(commonMessages.installingLabel)
if (serverProjectSelected.value) return formatMessage(commonMessages.selectedLabel)
return formatMessage(commonMessages.installButton)
})
const installButtonTooltip = computed(() => {
if (installButtonInstalled.value) return formatMessage(messages.alreadyInstalled)
return null
})
const [allLoaders, allGameVersions] = await Promise.all([
@@ -499,6 +670,55 @@ watch(
)
async function install(version) {
if (serverProjectInstallContext.value && data.value) {
if (serverProjectSelected.value) {
serverInstallContent.removeQueuedServerInstall(data.value.id)
return
}
if (installButtonDisabled.value) return
installing.value = true
try {
const contentType = data.value.project_type
await requestInstall({
project: {
...data.value,
project_id: data.value.id,
icon_url: data.value.icon_url,
},
contentType,
mode: contentType === 'modpack' ? 'immediate' : 'queue',
selectedFilters: [],
providedFilters: [],
overriddenProvidedFilterTypes: [],
targetPreferences: getTargetInstallPreferences(
{
gameVersion: serverInstallContent.serverContextServerData.value?.mc_version,
loader: serverInstallContent.serverContextServerData.value?.loader,
},
contentType,
),
getProjectVersions: async () => versions.value,
queue: {
get: serverInstallContent.getQueuedServerInstallPlans,
set: serverInstallContent.setQueuedServerInstallPlans,
},
install: (plan) =>
serverInstallContent.openServerModpackInstallFlow({
projectId: plan.projectId,
versionId: plan.versionId,
name: plan.project.title ?? plan.project.name ?? data.value.title,
iconUrl: plan.project.icon_url ?? undefined,
}),
})
} catch (err) {
handleError(err)
} finally {
installing.value = false
}
return
}
installing.value = true
await installVersion(
data.value.id,
@@ -176,9 +176,10 @@ import {
ButtonStyled,
Card,
CopyCode,
useFormatBytes,
useFormatDateTime,
} from '@modrinth/ui'
import { formatBytes, renderString } from '@modrinth/utils'
import { renderString } from '@modrinth/utils'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
@@ -191,6 +192,7 @@ const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatBytes = useFormatBytes()
const breadcrumbs = useBreadcrumbs()
@@ -430,6 +430,7 @@ export function createContentInstall(opts: {
await installVersionDependencies(
profile,
version,
'dependency',
(depProject: Labrinth.Projects.v2.Project, depVersion?: Labrinth.Versions.v2.Version) => {
addInstallingItem(instance.id, depProject, depVersion)
installedProjectIds.push(depProject.id)
@@ -485,10 +486,10 @@ export function createContentInstall(opts: {
if (!id) return
await add_project_from_version(id, version.id, 'standalone')
await opts.router.push(`/instance/${encodeURIComponent(id)}/`)
await opts.router.push(`/instance/${encodeURIComponent(id)}`)
const instance = await get(id)
await installVersionDependencies(instance, version)
await installVersionDependencies(instance, version, 'dependency')
trackEvent('InstanceCreate', {
source: 'ProjectInstallModal',
@@ -512,7 +513,7 @@ export function createContentInstall(opts: {
function handleNavigate(instance: ContentInstallInstance) {
modalRef?.hide()
opts.router.push(`/instance/${encodeURIComponent(instance.id)}/`)
opts.router.push(`/instance/${encodeURIComponent(instance.id)}`)
}
function handleCancel() {
@@ -589,6 +590,7 @@ export function createContentInstall(opts: {
await installVersionDependencies(
instance,
version,
'dependency',
(
depProject: Labrinth.Projects.v2.Project,
depVersion?: Labrinth.Versions.v2.Version,
@@ -664,7 +666,7 @@ export function createContentInstall(opts: {
},
handleModpackDuplicateGoToInstance(instancePath: string) {
pendingModpackInstall = null
opts.router.push(`/instance/${encodeURIComponent(instancePath)}/`)
opts.router.push(`/instance/${encodeURIComponent(instancePath)}`)
},
setIncompatibilityWarningModal(ref: IncompatibilityWarningModalRef) {
incompatibilityWarningModalRef = ref
@@ -1,6 +1,17 @@
import { provideFilePicker } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { readFile } from '@tauri-apps/plugin-fs'
function getFileName(path: string, fallback: string) {
return path.split(/[\\/]/).pop() || fallback
}
async function createFileFromPath(path: string, fallbackName: string, type?: string) {
const bytes = await readFile(path)
const name = getFileName(path, fallbackName)
return new File([bytes], name, type ? { type } : undefined)
}
export function setupFilePickerProvider() {
provideFilePicker({
@@ -12,8 +23,7 @@ export function setupFilePickerProvider() {
if (!result) return null
const path = result.path ?? result
if (!path) return null
const name = path.split(/[\\/]/).pop() || 'icon'
const file = new File([], name)
const file = await createFileFromPath(path, 'icon')
return { file, path, previewUrl: convertFileSrc(path) }
},
async pickModpackFile() {
@@ -24,8 +34,11 @@ export function setupFilePickerProvider() {
if (!result) return null
const path = result.path ?? result
if (!path) return null
const name = path.split(/[\\/]/).pop() || 'modpack.mrpack'
const file = new File([], name)
const file = await createFileFromPath(
path,
'modpack.mrpack',
'application/x-modrinth-modpack+zip',
)
return { file, path, previewUrl: '' }
},
})
@@ -1,22 +1,32 @@
import type { Archon, Labrinth } from '@modrinth/api-client'
import type { AbstractModrinthClient, Archon, Labrinth } from '@modrinth/api-client'
import {
addPendingServerContentInstalls,
type BrowseInstallPlan,
type BrowseSelectedProject,
createContext,
type CreationFlowContextValue,
flushInstallQueue,
injectModrinthClient,
injectNotificationManager,
type PendingServerContentInstall,
type PendingServerContentInstallType,
readPendingServerContentInstalls,
removePendingServerContentInstall,
writePendingServerContentInstallBaseline,
} from '@modrinth/ui'
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
type ServerFlowFrom = 'onboarding' | 'reset-server'
type ServerInstallableType = 'modpack' | 'mod' | 'plugin' | 'datapack'
type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & {
title?: string
installing?: boolean
installed?: boolean
}
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
interface ServerModpackSelectionRequest {
export interface ServerModpackSelectionRequest {
projectId: string
versionId: string
name: string
@@ -40,9 +50,19 @@ export interface ServerInstallContentContext {
effectiveServerWorldId: ComputedRef<string | null>
serverContextServerData: Ref<Archon.Servers.v0.Server | null>
serverContentProjectIds: Ref<Set<string>>
queuedServerInstallProjectIds: ComputedRef<Set<string>>
queuedServerInstallCount: ComputedRef<number>
selectedServerInstallProjects: ComputedRef<BrowseSelectedProject[]>
isInstallingQueuedServerInstalls: Ref<boolean>
queuedInstallProgress: Ref<{ completed: number; total: number }>
serverBackUrl: ComputedRef<string>
serverBackLabel: ComputedRef<string>
serverBrowseHeading: ComputedRef<string>
clearQueuedServerInstalls: () => void
removeQueuedServerInstall: (projectId: string) => void
flushQueuedServerInstalls: () => Promise<boolean>
discardQueuedServerInstallsAndBack: () => Promise<void>
installQueuedServerInstallsAndBack: () => Promise<boolean>
initServerContext: () => Promise<void>
watchServerContextChanges: () => void
searchServerModpacks: (
@@ -51,7 +71,11 @@ export interface ServerInstallContentContext {
) => Promise<Labrinth.Projects.v2.SearchResult>
getServerProjectVersions: (projectId: string) => Promise<{ id: string }[]>
enforceSetupModpackRoute: (currentProjectType: string | undefined) => void
installProjectToServer: (project: InstallableSearchResult) => Promise<boolean>
getQueuedServerInstallPlans: () => Map<string, BrowseInstallPlan<InstallableSearchResult>>
setQueuedServerInstallPlans: (
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) => void
openServerModpackInstallFlow: (request: ServerModpackSelectionRequest) => Promise<void>
onServerFlowBack: () => void
handleServerModpackFlowCreate: (config: CreationFlowContextValue) => Promise<void>
markServerProjectInstalled: (id: string) => void
@@ -65,6 +89,145 @@ function readQueryString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function getQueueStorageKey(serverId: string | null, worldId: string | null) {
if (!serverId || !worldId) return null
return `server-install-queue:${serverId}:${worldId}`
}
function readStoredQueue(serverId: string | null, worldId: string | null) {
const key = getQueueStorageKey(serverId, worldId)
if (!key) return new Map<string, BrowseInstallPlan<InstallableSearchResult>>()
try {
const raw = localStorage.getItem(key)
if (!raw) return new Map<string, BrowseInstallPlan<InstallableSearchResult>>()
return new Map<string, BrowseInstallPlan<InstallableSearchResult>>(JSON.parse(raw))
} catch {
return new Map<string, BrowseInstallPlan<InstallableSearchResult>>()
}
}
function writeStoredQueue(
serverId: string | null,
worldId: string | null,
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
const key = getQueueStorageKey(serverId, worldId)
if (!key) return
if (plans.size === 0) {
localStorage.removeItem(key)
return
}
localStorage.setItem(key, JSON.stringify(Array.from(plans.entries())))
}
function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
if (project.organization) {
const ownerId = project.organization_id ?? project.organization
return {
id: ownerId,
name: project.organization,
type: 'organization' as const,
link: `https://modrinth.com/organization/${ownerId}`,
}
}
if (!project.author) return null
const ownerId = project.author_id ?? project.author
return {
id: ownerId,
name: project.author,
type: 'user' as const,
link: `https://modrinth.com/user/${ownerId}`,
}
}
async function getQueuedInstallOwner(
client: AbstractModrinthClient,
project: InstallableSearchResult,
) {
const fallback = getQueuedInstallOwnerFallback(project)
try {
if (project.organization) {
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
if (organization) {
return {
id: organization.id,
name: organization.name,
type: 'organization' as const,
avatar_url: organization.icon_url ?? undefined,
link: `https://modrinth.com/organization/${organization.slug}`,
}
}
}
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
const owner =
members.find((member) => member.user.id === project.author_id)?.user ??
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
members[0]?.user
if (owner) {
return {
id: owner.id,
name: owner.username,
type: 'user' as const,
avatar_url: owner.avatar_url,
link: `https://modrinth.com/user/${owner.username}`,
}
}
} catch {
return fallback
}
return fallback
}
function getQueuedAddonInstallPlans(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
}
function getQueuedInstallPlaceholder(
plan: BrowseInstallPlan<InstallableSearchResult>,
owner: PendingServerContentInstallInput['owner'],
): PendingServerContentInstallInput {
const project = plan.project as InstallableSearchResult & { slug?: string | null }
return {
projectId: plan.projectId,
versionId: plan.versionId,
contentType: plan.contentType as PendingServerContentInstallType,
title: project.title ?? project.name ?? 'Project',
versionName: plan.versionName ?? null,
versionNumber: plan.versionNumber ?? null,
fileName: plan.fileName ?? null,
owner,
slug: project.slug ?? plan.projectId,
iconUrl: project.icon_url ?? null,
}
}
function getQueuedInstallPlaceholderFallbacks(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return getQueuedAddonInstallPlans(plans).map((plan) =>
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
)
}
async function getQueuedInstallPlaceholders(
client: AbstractModrinthClient,
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Promise.all(
getQueuedAddonInstallPlans(plans).map(async (plan) =>
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(client, plan.project)),
),
)
}
export function createServerInstallContent(opts: {
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
}) {
@@ -72,7 +235,7 @@ export function createServerInstallContent(opts: {
const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const { addNotification, handleError } = injectNotificationManager()
const serverIdQuery = computed(() => readQueryString(route.query.sid))
const worldIdQuery = computed(() => readQueryString(route.query.wid))
@@ -90,8 +253,22 @@ export function createServerInstallContent(opts: {
const serverContextWorldId = ref<string | null>(worldIdQuery.value)
const serverContextServerData = ref<Archon.Servers.v0.Server | null>(null)
const serverContentProjectIds = ref<Set<string>>(new Set())
const serverContentInstallKeys = ref<Set<string>>(new Set())
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
new Map(),
)
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
const selectedServerInstallProjects = computed<BrowseSelectedProject[]>(() =>
Array.from(queuedServerInstalls.value.values()).map((plan) => ({
id: plan.projectId,
name: plan.project.title ?? plan.project.name ?? 'Project',
iconUrl: plan.project.icon_url ?? null,
})),
)
const isInstallingQueuedServerInstalls = ref(false)
const queuedInstallProgress = ref({ completed: 0, total: 0 })
const effectiveServerWorldId = computed(() => worldIdQuery.value ?? serverContextWorldId.value)
const serverBackUrl = computed(() => {
const sid = serverIdQuery.value
if (!sid) return '/hosting/manage'
@@ -110,9 +287,9 @@ export function createServerInstallContent(opts: {
})
const serverBrowseHeading = computed(() => {
if (serverFlowFrom.value === 'reset-server') {
return 'Select modpack to install after reset'
return 'Selecting modpack to install after reset'
}
return 'Install content to server'
return 'Installing content'
})
async function resolveServerContextWorldId(serverId: string) {
@@ -134,7 +311,11 @@ export function createServerInstallContent(opts: {
.map((addon) => addon.project_id)
.filter((projectId): projectId is string => !!projectId),
)
const keys = new Set(
(content.addons ?? []).map((addon) => addon.project_id ?? addon.filename),
)
serverContentProjectIds.value = ids
serverContentInstallKeys.value = keys
} catch (err) {
handleError(err as Error)
}
@@ -159,6 +340,7 @@ export function createServerInstallContent(opts: {
}
if (resolvedWorldId) {
queuedServerInstalls.value = readStoredQueue(sid, resolvedWorldId)
await refreshServerInstalledContent(sid, resolvedWorldId)
}
}
@@ -168,11 +350,15 @@ export function createServerInstallContent(opts: {
if (!sid) {
serverContextServerData.value = null
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
setQueuedServerInstallPlans(new Map())
return
}
if (sid !== prevSid) {
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
queuedServerInstalls.value = readStoredQueue(sid, wid)
try {
serverContextServerData.value = await client.archon.servers_v0.get(sid)
} catch (err) {
@@ -180,28 +366,16 @@ export function createServerInstallContent(opts: {
}
}
if (wid !== prevWid) {
queuedServerInstalls.value = readStoredQueue(sid, wid)
}
if (wid && (sid !== prevSid || wid !== prevWid)) {
await refreshServerInstalledContent(sid, wid)
}
})
}
function normalizeLoader(loader: string) {
return loader.toLowerCase().replaceAll('_', '').replaceAll('-', '').replaceAll(' ', '')
}
function getCompatibleLoaders(loader: string) {
const normalized = normalizeLoader(loader)
if (!normalized) return new Set<string>()
if (normalized === 'paper' || normalized === 'purpur' || normalized === 'spigot') {
return new Set(['paper', 'purpur', 'spigot', 'bukkit'])
}
if (normalized === 'neoforge' || normalized === 'neo') {
return new Set(['neoforge', 'neo'])
}
return new Set([normalized])
}
function enforceSetupModpackRoute(currentProjectType: string | undefined) {
if (!isSetupServerContext.value || currentProjectType === 'modpack') return
router.replace({
@@ -248,82 +422,132 @@ export function createServerInstallContent(opts: {
ctx.modal.value?.setStage('final-config')
}
function getCurrentServerInstallType(): ServerInstallableType {
const raw = Array.isArray(route.params.projectType)
? route.params.projectType[0]
: route.params.projectType
if (raw === 'modpack' || raw === 'mod' || raw === 'plugin' || raw === 'datapack') {
return raw
}
throw new Error('This content type cannot be installed to a server from browse.')
function clearQueuedServerInstalls() {
setQueuedServerInstallPlans(new Map())
}
async function installProjectToServer(project: InstallableSearchResult) {
const contentType = getCurrentServerInstallType()
const sid = serverIdQuery.value
const wid = effectiveServerWorldId.value
if (!sid || !wid) {
throw new Error('No server world is available for install.')
}
function removeQueuedServerInstall(projectId: string) {
const nextPlans = new Map(queuedServerInstalls.value)
nextPlans.delete(projectId)
setQueuedServerInstallPlans(nextPlans)
}
if (contentType === 'modpack') {
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, {
include_changelog: false,
})
const versionId = versions[0]?.id ?? project.version_id
if (!versionId) {
throw new Error('No version found for this modpack')
}
async function flushQueuedServerInstalls(
serverId: string | null = serverIdQuery.value,
worldId: string | null = effectiveServerWorldId.value,
) {
if (queuedServerInstalls.value.size === 0) return true
if (isInstallingQueuedServerInstalls.value) return false
await openServerModpackInstallFlow({
projectId: project.project_id,
versionId,
name: project.name,
iconUrl: project.icon_url ?? undefined,
})
if (!serverId || !worldId) {
handleError(new Error('No server world is available for install.'))
return false
}
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, {
include_changelog: false,
})
const serverLoader = (serverContextServerData.value?.loader ?? '').toLowerCase()
const serverGameVersion = (serverContextServerData.value?.mc_version ?? '').trim()
const compatibleLoaders = getCompatibleLoaders(serverLoader)
const hasGameVersionMatch = (version: Labrinth.Versions.v2.Version) =>
!serverGameVersion || version.game_versions.includes(serverGameVersion)
const hasLoaderMatch = (version: Labrinth.Versions.v2.Version) => {
if (contentType === 'datapack') return true
if (compatibleLoaders.size === 0) return true
return version.loaders.some((loader) => compatibleLoaders.has(normalizeLoader(loader)))
const installedProjectIds = new Set<string>()
isInstallingQueuedServerInstalls.value = true
queuedInstallProgress.value = {
completed: 0,
total: queuedServerInstalls.value.size,
}
let matchingVersion = versions.find(
(version) => hasGameVersionMatch(version) && hasLoaderMatch(version),
)
if (!matchingVersion) {
matchingVersion = versions.find((version) => hasLoaderMatch(version))
try {
const result = await flushInstallQueue({
queue: {
get: () => queuedServerInstalls.value,
set: (plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>) => {
queuedServerInstalls.value = plans
writeStoredQueue(serverId, worldId, plans)
},
},
install: async (plan) => {
await client.archon.content_v1.addAddon(serverId, worldId, {
project_id: plan.projectId,
version_id: plan.versionId,
})
installedProjectIds.add(plan.projectId)
},
onError: (error, plan) => {
removePendingServerContentInstall(serverId, worldId, plan.projectId)
handleError(error as Error)
},
onProgress: (completed, total) => {
queuedInstallProgress.value = { completed, total }
},
})
if (installedProjectIds.size > 0) {
serverContentProjectIds.value = new Set([
...serverContentProjectIds.value,
...installedProjectIds,
])
serverContentInstallKeys.value = new Set([
...serverContentInstallKeys.value,
...installedProjectIds,
])
}
return result.ok
} finally {
isInstallingQueuedServerInstalls.value = false
queuedInstallProgress.value = { completed: 0, total: 0 }
}
if (!matchingVersion) {
matchingVersion = versions.find((version) => hasGameVersionMatch(version))
}
async function discardQueuedServerInstallsAndBack() {
clearQueuedServerInstalls()
await router.push(serverBackUrl.value)
}
async function installQueuedServerInstallsAndBack() {
const sid = serverIdQuery.value
const wid = effectiveServerWorldId.value
const backUrl = serverBackUrl.value
const plans = new Map(queuedServerInstalls.value)
if (sid && wid) {
writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value)
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
void getQueuedInstallPlaceholders(client, plans)
.then((items) => {
const pendingProjectIds = new Set(
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
)
addPendingServerContentInstalls(
sid,
wid,
items.filter((item) => pendingProjectIds.has(item.projectId)),
)
})
.catch((err) => handleError(err as Error))
}
if (!matchingVersion) {
matchingVersion = versions[0]
}
if (!matchingVersion) {
throw new Error('No installable version was found for this project.')
await router.push(backUrl)
const ok = await flushQueuedServerInstalls(sid, wid)
if (!ok) {
queuedServerInstalls.value = new Map()
writeStoredQueue(sid, wid, new Map())
addNotification({
type: 'error',
title: 'Some projects failed to install',
text: 'Failed projects were not added. You can try installing them again.',
})
}
await client.archon.content_v1.addAddon(sid, wid, {
project_id: matchingVersion.project_id,
version_id: matchingVersion.id,
})
serverContentProjectIds.value = new Set([...serverContentProjectIds.value, project.project_id])
return true
}
function getQueuedServerInstallPlans() {
return queuedServerInstalls.value
}
function setQueuedServerInstallPlans(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
queuedServerInstalls.value = plans
writeStoredQueue(serverIdQuery.value, effectiveServerWorldId.value, plans)
}
function onServerFlowBack() {
serverSetupModalRef.value?.hide()
}
@@ -377,15 +601,27 @@ export function createServerInstallContent(opts: {
effectiveServerWorldId,
serverContextServerData,
serverContentProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
selectedServerInstallProjects,
isInstallingQueuedServerInstalls,
queuedInstallProgress,
serverBackUrl,
serverBackLabel,
serverBrowseHeading,
clearQueuedServerInstalls,
removeQueuedServerInstall,
flushQueuedServerInstalls,
discardQueuedServerInstallsAndBack,
installQueuedServerInstallsAndBack,
initServerContext,
watchServerContextChanges,
searchServerModpacks,
getServerProjectVersions,
enforceSetupModpackRoute,
installProjectToServer,
getQueuedServerInstallPlans,
setQueuedServerInstallPlans,
openServerModpackInstallFlow,
onServerFlowBack,
handleServerModpackFlowCreate,
markServerProjectInstalled,
+2 -2
View File
@@ -48,7 +48,7 @@ export const isVersionCompatible = (version, project, instance) => {
)
}
export const installVersionDependencies = async (profile, version, onDepInstalling) => {
export const installVersionDependencies = async (profile, version, reason, onDepInstalling) => {
const projectNames = new Map()
const storeProjectName = (p) => {
if (p?.id && p.title) projectNames.set(p.id, p.title)
@@ -177,7 +177,7 @@ export const installVersionDependencies = async (profile, version, onDepInstalli
const batch = queuedInstalls.slice(i, i + batchSize)
await Promise.all(
batch.map(async ({ versionId }) => {
await add_project_from_version(profile.path, versionId, 'dependency')
await add_project_from_version(profile.path, versionId, reason)
}),
)
}
+253 -19
View File
@@ -1,4 +1,6 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tauri::plugin::TauriPlugin;
use tauri::{Manager, PhysicalPosition, PhysicalSize, Runtime};
@@ -14,6 +16,148 @@ pub struct AdsState {
}
const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie";
#[cfg(not(target_os = "linux"))]
const ADS_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
#[cfg(windows)]
fn ads_user_agent_override_params() -> String {
serde_json::json!({
"userAgent": ADS_USER_AGENT,
"platform": "Win32",
"userAgentMetadata": {
"brands": [
{ "brand": "Chromium", "version": "128" },
{ "brand": "Google Chrome", "version": "128" },
{ "brand": "Not=A?Brand", "version": "99" },
],
"fullVersion": "128.0.0.0",
"fullVersionList": [
{ "brand": "Chromium", "version": "128.0.0.0" },
{ "brand": "Google Chrome", "version": "128.0.0.0" },
{ "brand": "Not=A?Brand", "version": "99.0.0.0" },
],
"platform": "Windows",
"platformVersion": "10.0.0",
"architecture": "x86",
"bitness": "64",
"model": "",
"mobile": false,
},
})
.to_string()
}
#[cfg(windows)]
fn configure_ads_cookie_settings(
core_webview2: &webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2,
) {
use webview2_com::Microsoft::Web::WebView2::Win32::{
COREWEBVIEW2_TRACKING_PREVENTION_LEVEL_NONE, ICoreWebView2,
ICoreWebView2_13, ICoreWebView2Profile3,
};
use windows_core::Interface;
match core_webview2
.cast::<ICoreWebView2_13>()
.and_then(|core_webview2| unsafe { core_webview2.Profile() })
.and_then(|profile| profile.cast::<ICoreWebView2Profile3>())
{
Ok(profile) => {
if let Err(error) = unsafe {
profile.SetPreferredTrackingPreventionLevel(
COREWEBVIEW2_TRACKING_PREVENTION_LEVEL_NONE,
)
} {
tracing::warn!(
?error,
"Failed to disable ads WebView2 tracking prevention"
);
}
}
Err(error) => {
tracing::warn!(
?error,
"Failed to access ads WebView2 profile tracking prevention settings"
);
}
}
}
fn set_webview_visible<R: Runtime>(
webview: &tauri::Webview<R>,
_visible: bool,
) {
webview
.with_webview(
#[allow(unused_variables)]
move |wv| {
#[cfg(windows)]
{
let controller = wv.controller();
unsafe { controller.SetIsVisible(_visible) }.ok();
}
},
)
.ok();
}
fn set_webview_visible_for_window<R: Runtime>(
app: &tauri::AppHandle<R>,
webview: &tauri::Webview<R>,
visible: bool,
) {
let is_minimized = app
.get_window("main")
.and_then(|window| window.is_minimized().ok())
.unwrap_or(false);
set_webview_visible(webview, visible && !is_minimized);
}
fn sync_webview_visibility_for_main_window<R: Runtime>(
app: &tauri::AppHandle<R>,
main_window: &tauri::Window<R>,
was_minimized: &AtomicBool,
) {
let is_minimized = main_window.is_minimized().unwrap_or(false);
let was = was_minimized.load(Ordering::SeqCst);
if is_minimized == was {
return;
}
was_minimized.store(is_minimized, Ordering::SeqCst);
let ads_visible = if is_minimized {
false
} else {
match app.state::<RwLock<AdsState>>().try_read() {
Ok(state) => state.shown && !state.modal_shown,
Err(_) => false,
}
};
let mut webviews = Vec::new();
let mut seen_webviews = HashSet::new();
for webview in main_window.webviews() {
seen_webviews.insert(webview.label().to_string());
webviews.push(webview);
}
for webview in app.webviews().into_values() {
if seen_webviews.insert(webview.label().to_string()) {
webviews.push(webview);
}
}
for webview in webviews {
let visible =
!is_minimized && (webview.label() != "ads-window" || ads_visible);
set_webview_visible(&webview, visible);
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::<R>::new("ads")
@@ -30,10 +174,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
// visible when we refresh, the Aditude wrapper will not make any ad requests
// unless Chromium reports the page as visible. The refresh does not reset the
// visibility state.
let app = app.clone();
let refresh_app = app.clone();
tauri::async_runtime::spawn(async move {
loop {
if let Some(webview) = app.webviews().get_mut("ads-window")
if let Some(webview) =
refresh_app.webviews().get_mut("ads-window")
{
let _ = webview.navigate(AD_LINK.parse().unwrap());
}
@@ -43,6 +188,34 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
}
});
if let Some(main_window) = app.get_window("main") {
let app_handle = app.clone();
let event_window = main_window.clone();
let was_minimized = Arc::new(AtomicBool::new(false));
main_window.on_window_event(move |_| {
sync_webview_visibility_for_main_window(
&app_handle,
&event_window,
&was_minimized,
);
let delayed_app_handle = app_handle.clone();
let delayed_event_window = event_window.clone();
let delayed_was_minimized = was_minimized.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
sync_webview_visibility_for_main_window(
&delayed_app_handle,
&delayed_event_window,
&delayed_was_minimized,
);
});
});
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -103,31 +276,38 @@ pub async fn init_ads_window<R: Runtime>(
webview.show().ok();
webview.set_position(position).ok();
webview.set_size(size).ok();
set_webview_visible_for_window(&app, webview, true);
} else {
webview.hide().ok();
webview
.set_position(PhysicalPosition::new(-1000, -1000))
.ok();
set_webview_visible(webview, false);
}
Some(webview.clone())
} else if let Some(window) = app.get_window("main") {
#[cfg(windows)]
let webview_url =
WebviewUrl::External("about:blank".parse().unwrap());
#[cfg(not(windows))]
let webview_url = WebviewUrl::External(AD_LINK.parse().unwrap());
let webview = window.add_child(
tauri::webview::WebviewBuilder::new(
"ads-window",
WebviewUrl::External(
AD_LINK.parse().unwrap(),
),
)
.initialization_script_for_all_frames(include_str!("ads-init.js"))
// We use a standard Chrome user agent for compatibility with our ad provider,
// since Tauri is not recognized by ad providers by default.
// Aditude has separately informed SSPs and IVT vendors that this traffic
// originates from a desktop app.
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36")
.zoom_hotkeys_enabled(false)
.transparent(true)
.on_new_window(|_, _| tauri::webview::NewWindowResponse::Deny),
tauri::webview::WebviewBuilder::new("ads-window", webview_url)
.initialization_script_for_all_frames(include_str!(
"ads-init.js"
))
// We use a standard Chrome user agent for compatibility with our ad provider,
// since Tauri is not recognized by ad providers by default.
// Aditude has separately informed SSPs and IVT vendors that this traffic
// originates from a desktop app.
.user_agent(ADS_USER_AGENT)
.zoom_hotkeys_enabled(false)
.transparent(true)
.on_new_window(|_, _| {
tauri::webview::NewWindowResponse::Deny
}),
// set both the `hide`/`show` state and `position`,
// to ensure that the webview is actually shown/hidden
if state.shown {
@@ -140,15 +320,68 @@ pub async fn init_ads_window<R: Runtime>(
if state.shown {
webview.show().ok();
set_webview_visible_for_window(&app, &webview, true);
} else {
webview.hide().ok();
set_webview_visible(&webview, false);
}
webview.with_webview(#[allow(unused_variables)] |webview2| {
#[cfg(windows)]
{
use webview2_com::CallDevToolsProtocolMethodCompletedHandler;
use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2_8;
use windows_core::Interface;
use windows_core::HSTRING;
let core_webview2 =
unsafe { webview2.controller().CoreWebView2() };
if let Ok(core_webview2) = core_webview2 {
configure_ads_cookie_settings(&core_webview2);
let navigate_webview = core_webview2.clone();
let handler =
CallDevToolsProtocolMethodCompletedHandler::create(
Box::new(move |result: windows_core::Result<()>, _| {
if let Err(error) = result {
tracing::error!(
?error,
"Failed to override ads user-agent client hints"
);
}
unsafe {
navigate_webview
.Navigate(&HSTRING::from(AD_LINK))
.ok();
}
Ok(())
}) as Box<_>,
);
unsafe {
if let Err(error) = core_webview2
.CallDevToolsProtocolMethod(
&HSTRING::from(
"Emulation.setUserAgentOverride",
),
&HSTRING::from(
ads_user_agent_override_params(),
),
&handler,
)
{
tracing::error!(
?error,
"Failed to install ads user-agent client hints override"
);
core_webview2.Navigate(&HSTRING::from(AD_LINK)).ok();
}
}
}
let webview2_controller = webview2.controller();
let Ok(webview2_8) = unsafe { webview2_controller.CoreWebView2() }
@@ -166,9 +399,9 @@ pub async fn init_ads_window<R: Runtime>(
None
};
let Some(webview) = webview.clone() else {
if webview.is_none() {
return Ok(());
};
}
// tauri::async_runtime::spawn(async move {
// loop {
@@ -249,6 +482,7 @@ pub async fn show_ads_window<R: Runtime>(
webview.set_size(size).ok();
webview.set_position(position).ok();
webview.show().ok();
set_webview_visible_for_window(&app, webview, true);
}
}
+47 -61
View File
@@ -17,74 +17,60 @@
</label>
</template>
<script>
import { fileIsValid } from '~/helpers/fileUtils.js'
<script setup lang="ts">
import { useFormatBytes } from '@modrinth/ui'
import { fileIsValid } from '@modrinth/utils'
import { ref } from 'vue'
export default {
components: {},
props: {
prompt: {
type: String,
default: 'Select file',
},
multiple: {
type: Boolean,
default: false,
},
accept: {
type: String,
default: null,
},
const props = withDefaults(
defineProps<{
prompt?: string
multiple?: boolean
accept?: string
/**
* The max file size in bytes
*/
maxSize: {
type: Number,
default: null,
},
showIcon: {
type: Boolean,
default: true,
},
shouldAlwaysReset: {
type: Boolean,
default: false,
},
longStyle: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
maxSize?: number | null
showIcon?: boolean
shouldAlwaysReset?: boolean
longStyle?: boolean
disabled?: boolean
}>(),
{
prompt: 'Select file',
multiple: false,
showIcon: true,
shouldAlwaysReset: false,
longStyle: false,
disabled: false,
},
emits: ['change'],
data() {
return {
files: [],
}
},
methods: {
addFiles(files, shouldNotReset) {
if (!shouldNotReset || this.shouldAlwaysReset) {
this.files = files
}
)
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true }
this.files = [...this.files].filter((file) => fileIsValid(file, validationOptions))
const emit = defineEmits<{ change: [files: File[]] }>()
if (this.files.length > 0) {
this.$emit('change', this.files)
}
},
handleDrop(e) {
this.addFiles(e.dataTransfer.files)
},
handleChange(e) {
this.addFiles(e.target.files)
},
},
const formatBytes = useFormatBytes()
const files = ref<File[]>([])
function addFiles(incoming: FileList, shouldNotReset = false) {
if (!shouldNotReset || props.shouldAlwaysReset) {
files.value = Array.from(incoming)
}
const validationOptions = { maxSize: props.maxSize, alertOnInvalid: true }
files.value = files.value.filter((file) => fileIsValid(file, validationOptions, formatBytes))
if (files.value.length > 0) {
emit('change', files.value)
}
}
function handleDrop(e: DragEvent) {
addFiles(e.dataTransfer!.files)
}
function handleChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
addFiles(input.files)
}
</script>
@@ -1,12 +1,19 @@
<template>
<div class="shadow-card rounded-2xl border border-surface-5 bg-surface-3 p-4">
<div class="shadow-card rounded-2xl border border-solid border-surface-5 bg-surface-3 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<Avatar
:src="queueEntry.project.icon_url"
size="4rem"
class="rounded-2xl border border-surface-5 bg-surface-4 !shadow-none"
/>
<NuxtLink
:to="`/project/${queueEntry.project.slug}`"
target="_blank"
tabindex="-1"
class="flex"
>
<Avatar
:src="queueEntry.project.icon_url"
size="4rem"
class="rounded-2xl border border-surface-5 bg-surface-4 !shadow-none"
/>
</NuxtLink>
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<NuxtLink
@@ -22,11 +29,15 @@
<component
:is="getProjectTypeIcon(queueEntry.project.project_types[0] as any)"
aria-hidden="true"
class="h-4 w-4"
class="size-4"
/>
<span class="text-sm font-medium text-secondary">
{{
queueEntry.project.project_types.map((t) => formatProjectType(t, true)).join(', ')
queueEntry.project.project_types.length === 0
? '???'
: queueEntry.project.project_types
.map((t) => formatProjectType(t, true))
.join(', ')
}}
</span>
</div>
@@ -35,37 +46,39 @@
class="flex items-center gap-2 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
>
<span class="text-sm text-secondary">Requesting</span>
<Badge :type="queueEntry.project.requested_status" class="status" />
<Badge :type="queueEntry.project.requested_status" class="text-sm" />
</div>
</div>
<div v-if="queueEntry.owner" class="flex items-center gap-1">
<Avatar
:src="queueEntry.owner.user.avatar_url"
size="1.5rem"
circle
class="border border-surface-5 bg-surface-4 !shadow-none"
/>
<div v-if="queueEntry.ownership?.kind === 'user'">
<NuxtLink
:to="`/user/${queueEntry.owner.user.username}`"
:to="`/user/${queueEntry.ownership.id}`"
target="_blank"
class="text-sm font-medium text-secondary hover:underline"
class="flex w-fit min-w-40 items-center gap-1 text-sm font-medium text-secondary hover:underline"
>
{{ queueEntry.owner.user.username }}
<Avatar
:src="queueEntry.ownership.icon_url"
size="1.5rem"
circle
class="border border-surface-5 bg-surface-4 !shadow-none"
/>
{{ queueEntry.ownership.name }}
</NuxtLink>
</div>
<div v-else-if="queueEntry.org" class="flex items-center gap-1">
<div
v-else-if="queueEntry.ownership?.kind === 'organization'"
class="flex items-center gap-1"
>
<Avatar
:src="queueEntry.org.icon_url"
:src="queueEntry.ownership.icon_url"
size="1.5rem"
circle
class="border border-surface-5 bg-surface-4 !shadow-none"
/>
<NuxtLink
:to="`/organization/${queueEntry.org.slug}`"
:to="`/organization/${queueEntry.ownership.id}`"
target="_blank"
class="text-sm font-medium text-secondary hover:underline"
>
{{ queueEntry.org.name }}
{{ queueEntry.ownership.name }}
</NuxtLink>
</div>
</div>
@@ -84,25 +97,20 @@
</span>
<div class="flex items-center gap-2">
<ButtonStyled circular color="orange">
<button @click="openProjectForReview">
<ScaleIcon class="size-5" />
<ButtonStyled circular>
<button v-tooltip="'Copy ID'" @click="copyId">
<ClipboardCopyIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions" :dropdown-id="`${baseId}-quick-actions`">
<template #default>
<EllipsisVerticalIcon class="size-4" />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
<button v-tooltip="'Copy link'" @click="copyLink">
<LinkIcon />
</button>
</ButtonStyled>
<ButtonStyled v-tooltip="'Begin review'" circular color="orange">
<button @click="openProjectForReview">
<ScaleIcon />
</button>
</ButtonStyled>
</div>
</div>
@@ -111,15 +119,13 @@
</template>
<script setup lang="ts">
import { ClipboardCopyIcon, EllipsisVerticalIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
import { ClipboardCopyIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
import {
Avatar,
Badge,
ButtonStyled,
getProjectTypeIcon,
injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useFormatDateTime,
useRelativeTime,
} from '@modrinth/ui'
@@ -143,8 +149,6 @@ const formatDateTimeFull = useFormatDateTime({
timeZone: 'UTC',
})
const baseId = useId()
const props = defineProps<{
queueEntry: ModerationProject
}>()
@@ -185,34 +189,27 @@ const formattedDate = computed(() => {
}
})
const quickActions: OverflowMenuOption[] = [
{
id: 'copy-link',
action: () => {
const base = window.location.origin
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
navigator.clipboard.writeText(projectUrl).then(() => {
addNotification({
type: 'success',
title: 'Project link copied',
text: 'The link to this project has been copied to your clipboard.',
})
})
},
},
{
id: 'copy-id',
action: () => {
navigator.clipboard.writeText(props.queueEntry.project.id).then(() => {
addNotification({
type: 'success',
title: 'Project ID copied',
text: 'The ID of this project has been copied to your clipboard.',
})
})
},
},
]
function copyLink() {
const base = window.location.origin
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
navigator.clipboard.writeText(projectUrl).then(() => {
addNotification({
type: 'success',
title: 'Project link copied',
text: 'The link to this project has been copied to your clipboard.',
})
})
}
function copyId() {
navigator.clipboard.writeText(props.queueEntry.project.id).then(() => {
addNotification({
type: 'success',
title: 'Project ID copied',
text: 'The ID of this project has been copied to your clipboard.',
})
})
}
function openProjectForReview() {
emit('startFromProject', props.queueEntry.project.id)
@@ -28,6 +28,7 @@ import {
injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useFormatBytes,
useFormatDateTime,
} from '@modrinth/ui'
import { NavTabs } from '@modrinth/ui'
@@ -56,6 +57,7 @@ const formatDateTimeUtc = useFormatDateTime({
timeZoneName: 'short',
timeZone: 'UTC',
})
const formatBytes = useFormatBytes()
type FlattenedFileReport = Labrinth.TechReview.Internal.FileReport & {
id: string
@@ -362,12 +364,6 @@ const formattedDate = computed(() => {
return `${diffDays} days ago`
})
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KiB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`
}
function viewFileFlags(file: FlattenedFileReport) {
selectedFileId.value = file.id
currentTab.value = 'File'
@@ -851,7 +847,7 @@ const reviewSummaryPreview = computed(() => {
const fileVerdict = fileUnsafe > 0 ? 'Unsafe' : 'Safe'
markdown += `### ${fileData.fileName}\n`
markdown += `> ${formatFileSize(fileData.fileSize)}${fileData.decisions.length} issues • Max severity: ${fileData.maxSeverity} • **Verdict:** ${fileVerdict}\n\n`
markdown += `> ${formatBytes(fileData.fileSize)}${fileData.decisions.length} issues • Max severity: ${fileData.maxSeverity} • **Verdict:** ${fileVerdict}\n\n`
markdown += `<details>\n<summary>Issues (${fileSafe} safe, ${fileUnsafe} unsafe)</summary>\n\n`
markdown += `| Class | Issue Type | Severity | Decision |\n`
markdown += `|-------|------------|----------|----------|\n`
@@ -1150,7 +1146,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</span>
<div class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1">
<span class="text-sm font-medium text-secondary">{{
formatFileSize(file.file_size)
formatBytes(file.file_size)
}}</span>
</div>
<div
@@ -24,8 +24,16 @@
</Checkbox>
<div class="input-group push-right">
<ButtonStyled color="orange">
<button :disabled="!submissionConfirmation" @click="resubmit()">
<ScaleIcon aria-hidden="true" />
<button
:disabled="!submissionConfirmation || isLoading"
@click="runBlockingAction('resubmit-modal', resubmit)"
>
<SpinnerIcon
v-if="loadingAction === 'resubmit-modal'"
class="animate-spin"
aria-hidden="true"
/>
<ScaleIcon v-else aria-hidden="true" />
Resubmit for review
</button>
</ButtonStyled>
@@ -54,8 +62,16 @@
</Checkbox>
<div class="input-group push-right">
<ButtonStyled color="brand">
<button :disabled="!replyConfirmation" @click="sendReplyFromModal()">
<ReplyIcon aria-hidden="true" />
<button
:disabled="!replyConfirmation || isLoading"
@click="runBlockingAction('reply-modal', () => sendReplyFromModal())"
>
<SpinnerIcon
v-if="loadingAction === 'reply-modal'"
class="animate-spin"
aria-hidden="true"
/>
<ReplyIcon v-else aria-hidden="true" />
Reply to thread
</button>
</ButtonStyled>
@@ -82,8 +98,9 @@
<template v-if="report && report.closed">
<p>This thread is closed and new messages cannot be sent to it.</p>
<ButtonStyled v-if="isStaff(auth.user)">
<button @click="reopenReport()">
<CheckCircleIcon aria-hidden="true" />
<button :disabled="isLoading" @click="runBlockingAction('reopen', () => reopenReport())">
<SpinnerIcon v-if="loadingAction === 'reopen'" class="animate-spin" aria-hidden="true" />
<CheckCircleIcon v-else aria-hidden="true" />
Reopen thread
</button>
</ButtonStyled>
@@ -100,35 +117,53 @@
<ButtonStyled color="brand">
<button
v-if="sortedMessages.length > 0"
:disabled="!replyBody"
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
:disabled="!replyBody || isLoading"
@click="
isApproved(project) && !isStaff(auth.user)
? openReplyModal()
: runBlockingAction('reply', () => sendReply())
"
>
<ReplyIcon aria-hidden="true" />
<SpinnerIcon v-if="loadingAction === 'reply'" class="animate-spin" aria-hidden="true" />
<ReplyIcon v-else aria-hidden="true" />
Reply
</button>
<button
v-else
:disabled="!replyBody"
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
:disabled="!replyBody || isLoading"
@click="
isApproved(project) && !isStaff(auth.user)
? openReplyModal()
: runBlockingAction('send', () => sendReply())
"
>
<SendIcon aria-hidden="true" />
<SpinnerIcon v-if="loadingAction === 'send'" class="animate-spin" aria-hidden="true" />
<SendIcon v-else aria-hidden="true" />
Send
</button>
</ButtonStyled>
<ButtonStyled v-if="isStaff(auth.user)">
<button :disabled="!replyBody" @click="sendReply(null, true)">
<ScaleIcon aria-hidden="true" />
<button
:disabled="!replyBody || isLoading"
@click="runBlockingAction('private-note', () => sendReply(null, true))"
>
<SpinnerIcon
v-if="loadingAction === 'private-note'"
class="animate-spin"
aria-hidden="true"
/>
<ScaleIcon v-else aria-hidden="true" />
Add private note
</button>
</ButtonStyled>
<template v-if="currentMember && !isStaff(auth.user)">
<template v-if="isRejected(project)">
<ButtonStyled color="orange">
<button v-if="replyBody" @click="openResubmitModal(true)">
<button v-if="replyBody" :disabled="isLoading" @click="openResubmitModal(true)">
<ScaleIcon aria-hidden="true" />
Resubmit for review with reply
</button>
<button v-else @click="openResubmitModal(false)">
<button v-else :disabled="isLoading" @click="openResubmitModal(false)">
<ScaleIcon aria-hidden="true" />
Resubmit for review
</button>
@@ -140,12 +175,30 @@
<template v-if="report">
<template v-if="isStaff(auth.user)">
<ButtonStyled color="red">
<button v-if="replyBody" @click="closeReport(true)">
<CheckCircleIcon aria-hidden="true" />
<button
v-if="replyBody"
:disabled="isLoading"
@click="runBlockingAction('close-with-reply', () => closeReport(true))"
>
<SpinnerIcon
v-if="loadingAction === 'close-with-reply'"
class="animate-spin"
aria-hidden="true"
/>
<CheckCircleIcon v-else aria-hidden="true" />
Close with reply
</button>
<button v-else @click="closeReport()">
<CheckCircleIcon aria-hidden="true" />
<button
v-else
:disabled="isLoading"
@click="runBlockingAction('close', () => closeReport())"
>
<SpinnerIcon
v-if="loadingAction === 'close'"
class="animate-spin"
aria-hidden="true"
/>
<CheckCircleIcon v-else aria-hidden="true" />
Close thread
</button>
</ButtonStyled>
@@ -154,92 +207,122 @@
<template v-if="project">
<template v-if="isStaff(auth.user)">
<ButtonStyled v-if="replyBody" color="green">
<button :disabled="isApproved(project)" @click="sendReply(requestedStatus)">
<CheckIcon aria-hidden="true" />
<button
:disabled="isApproved(project) || isLoading"
@click="runBlockingAction('approve-with-reply', () => sendReply(requestedStatus))"
>
<SpinnerIcon
v-if="loadingAction === 'approve-with-reply'"
class="animate-spin"
aria-hidden="true"
/>
<CheckIcon v-else aria-hidden="true" />
Approve with reply
</button>
</ButtonStyled>
<ButtonStyled v-else color="green">
<button :disabled="isApproved(project)" @click="setStatus(requestedStatus)">
<CheckIcon aria-hidden="true" />
<button
:disabled="isApproved(project) || isLoading"
@click="runBlockingAction('approve', () => setStatus(requestedStatus))"
>
<SpinnerIcon
v-if="loadingAction === 'approve'"
class="animate-spin"
aria-hidden="true"
/>
<CheckIcon v-else aria-hidden="true" />
Approve
</button>
</ButtonStyled>
<div class="joined-buttons">
<ButtonStyled v-if="replyBody" color="red">
<button :disabled="project.status === 'rejected'" @click="sendReply('rejected')">
<XIcon aria-hidden="true" />
<button
:disabled="project.status === 'rejected' || isLoading"
@click="runBlockingAction('reject-with-reply', () => sendReply('rejected'))"
>
<SpinnerIcon
v-if="loadingAction === 'reject-with-reply'"
class="animate-spin"
aria-hidden="true"
/>
<XIcon v-else aria-hidden="true" />
Reject with reply
</button>
</ButtonStyled>
<ButtonStyled v-else color="red">
<button :disabled="project.status === 'rejected'" @click="setStatus('rejected')">
<XIcon aria-hidden="true" />
<button
:disabled="project.status === 'rejected' || isLoading"
@click="runBlockingAction('reject', () => setStatus('rejected'))"
>
<SpinnerIcon
v-if="loadingAction === 'reject'"
class="animate-spin"
aria-hidden="true"
/>
<XIcon v-else aria-hidden="true" />
Reject
</button>
</ButtonStyled>
<ButtonStyled color="red">
<OverflowMenu
class="btn-dropdown-animation"
:disabled="isLoading"
:options="
replyBody
? [
{
id: 'withhold-reply',
color: 'danger',
action: () => {
sendReply('withheld')
},
action: () =>
runBlockingAction('withhold-reply', () => sendReply('withheld')),
hoverFilled: true,
disabled: project.status === 'withheld',
disabled: project.status === 'withheld' || isLoading,
},
{
id: 'set-to-draft-reply',
action: () => {
sendReply('draft')
},
action: () =>
runBlockingAction('set-to-draft-reply', () => sendReply('draft')),
hoverFilled: true,
disabled: project.status === 'draft',
disabled: project.status === 'draft' || isLoading,
},
{
id: 'send-to-review-reply',
action: () => {
sendReply('processing', true)
},
action: () =>
runBlockingAction('send-to-review-reply', () =>
sendReply('processing', true),
),
hoverFilled: true,
disabled: project.status === 'processing',
disabled: project.status === 'processing' || isLoading,
},
]
: [
{
id: 'withhold',
color: 'danger',
action: () => {
setStatus('withheld')
},
action: () =>
runBlockingAction('withhold', () => setStatus('withheld')),
hoverFilled: true,
disabled: project.status === 'withheld',
disabled: project.status === 'withheld' || isLoading,
},
{
id: 'set-to-draft',
action: () => {
setStatus('draft')
},
action: () =>
runBlockingAction('set-to-draft', () => setStatus('draft')),
hoverFilled: true,
disabled: project.status === 'draft',
disabled: project.status === 'draft' || isLoading,
},
{
id: 'send-to-review',
action: () => {
setStatus('processing')
},
action: () =>
runBlockingAction('send-to-review', () => setStatus('processing')),
hoverFilled: true,
disabled: project.status === 'processing',
disabled: project.status === 'processing' || isLoading,
},
]
"
>
<DropdownIcon aria-hidden="true" />
<SpinnerIcon v-if="isDropdownLoading" class="animate-spin" aria-hidden="true" />
<DropdownIcon v-else aria-hidden="true" />
<template #withhold-reply>
<EyeOffIcon aria-hidden="true" />
Withhold with reply
@@ -285,6 +368,7 @@ import {
ReplyIcon,
ScaleIcon,
SendIcon,
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import {
@@ -363,6 +447,30 @@ const sortedMessages = computed(() => {
const modalSubmit = ref(null)
const modalReply = ref(null)
const loadingAction = ref(null)
const isLoading = computed(() => loadingAction.value !== null)
const dropdownActionIds = [
'withhold',
'withhold-reply',
'set-to-draft',
'set-to-draft-reply',
'send-to-review',
'send-to-review-reply',
]
const isDropdownLoading = computed(() => dropdownActionIds.includes(loadingAction.value))
async function runBlockingAction(actionId, action) {
if (loadingAction.value !== null) {
return
}
loadingAction.value = actionId
try {
await action()
} finally {
loadingAction.value = null
}
}
async function updateThreadLocal() {
let threadId = null
if (props.project) {
@@ -390,8 +498,8 @@ async function onUploadImage(file) {
}
async function sendReplyFromModal(status = null, privateMessage = false) {
modalReply.value.hide()
await sendReply(status, privateMessage)
modalReply.value.hide()
}
async function sendReply(status = null, privateMessage = false) {
@@ -0,0 +1,140 @@
import type { FilterValue } from '@modrinth/ui'
import { LOADER_FILTER_TYPES } from '@modrinth/ui'
const TEN_MINUTES = 600
export type DownloadContext = {
gameVersion?: string
loader?: string
reason?: 'standalone' | 'dependency' | 'modpack' | 'update'
}
export type FilterSelection = {
gameVersion?: string
loader?: string
}
const cookieDefaults = {
maxAge: TEN_MINUTES,
sameSite: 'lax' as const,
secure: true,
path: '/',
httpOnly: false,
}
function readCookieValue(value: string | null | undefined): string | undefined {
if (typeof value !== 'string' || !value) {
return undefined
}
return value
}
function newFilterSelection(
gameVersion: string | undefined,
loader: string | undefined,
): FilterSelection | null {
if (!gameVersion && !loader) {
return null
} else if (!gameVersion) {
return {
loader,
}
} else if (!loader) {
return {
gameVersion,
}
} else {
return {
gameVersion,
loader,
}
}
}
export function useCdnDownloadContext() {
const filterGameVersionCookie = useCookie<string | null>('mr_download_filter_game_version', {
...cookieDefaults,
default: () => null,
})
const filterLoaderCookie = useCookie<string | null>('mr_download_filter_loader', {
...cookieDefaults,
default: () => null,
})
function createProjectDownloadUrl(originalUrl: string, context?: DownloadContext): string {
if (!originalUrl.startsWith('https://cdn.modrinth.com')) {
return originalUrl
}
const reason = context?.reason
const gameVersion = context?.gameVersion ?? readCookieValue(filterGameVersionCookie.value)
const loader = context?.loader ?? readCookieValue(filterLoaderCookie.value)
try {
const url = new URL(originalUrl)
if (reason) {
url.searchParams.set('mr_download_reason', reason)
}
if (gameVersion) {
url.searchParams.set('mr_game_version', gameVersion)
} else {
url.searchParams.delete('mr_game_version')
}
if (loader) {
url.searchParams.set('mr_loader', loader)
} else {
url.searchParams.delete('mr_loader')
}
return url.toString()
} catch {
return originalUrl
}
}
function persistFilterSelection(selection: FilterSelection | null) {
if (!selection) {
filterGameVersionCookie.value = null
filterLoaderCookie.value = null
return
}
filterGameVersionCookie.value = selection.gameVersion ?? null
filterLoaderCookie.value = selection.loader ?? null
}
function updateDiscoverFilterContext(filters: FilterValue[]) {
if (!import.meta.client) {
return
}
const versionFilters = [
...new Set(filters.filter((f) => f.type === 'game_version').map((f) => f.option)),
]
const loaderFilters = [
...new Set(
filters
.filter((f) => (LOADER_FILTER_TYPES as readonly string[]).includes(f.type))
.map((f) => f.option),
),
]
const gameVersion = versionFilters.length === 1 ? versionFilters[0] : undefined
const loader = loaderFilters.length === 1 ? loaderFilters[0] : undefined
persistFilterSelection(newFilterSelection(gameVersion, loader))
}
function updateVersionsFilterContext(gameVersions: string[], loaders: string[]) {
if (!import.meta.client) {
return
}
const gameVersion = gameVersions.length === 1 ? gameVersions[0] : undefined
const loader = loaders.length === 1 ? loaders[0] : undefined
persistFilterSelection(newFilterSelection(gameVersion, loader))
}
return {
createProjectDownloadUrl,
updateDiscoverFilterContext,
updateVersionsFilterContext,
}
}
-32
View File
@@ -1,32 +0,0 @@
import { formatBytes } from '@modrinth/utils'
export const fileIsValid = (file, validationOptions) => {
const { maxSize, alertOnInvalid } = validationOptions
if (maxSize !== null && maxSize !== undefined && file.size > maxSize) {
if (alertOnInvalid) {
alert(`File ${file.name} is too big! Must be less than ${formatBytes(maxSize)}`)
}
return false
}
return true
}
export const acceptFileFromProjectType = (projectType) => {
switch (projectType) {
case 'mod':
return '.jar,.zip,.litemod,application/java-archive,application/x-java-archive,application/zip'
case 'plugin':
return '.jar,.zip,application/java-archive,application/x-java-archive,application/zip'
case 'resourcepack':
return '.zip,application/zip'
case 'shader':
return '.zip,application/zip'
case 'datapack':
return '.zip,application/zip'
case 'modpack':
return '.mrpack,application/x-modrinth-modpack+zip,application/zip'
default:
return '*'
}
}
+27 -48
View File
@@ -181,56 +181,35 @@ export async function enrichReportBatch(reports: Report[]): Promise<ExtendedRepo
}
// Doesn't need to be in @modrinth/moderation because it is specific to the frontend.
export interface ModerationOwnershipUser {
kind: 'user'
id: string
name: string
icon_url: string | null
}
export interface ModerationOwnershipOrganization {
kind: 'organization'
id: string
name: string
icon_url: string | null
}
export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipOrganization
export interface ProjectWithOwnership {
ownership: ModerationOwnership
[key: string]: any
}
export interface ModerationProject {
project: any
owner: TeamMember | null
org: Organization | null
ownership: ModerationOwnership | null
}
export async function enrichProjectBatch(projects: any[]): Promise<ModerationProject[]> {
const teamIds = [...new Set(projects.map((p) => p.team_id).filter(Boolean))]
const orgIds = [...new Set(projects.map((p) => p.organization).filter(Boolean))]
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
teamIds.length > 0
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: Promise.resolve([]),
orgIds.length > 0
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
})
: Promise.resolve([]),
])
const cache = useModerationCache()
teamsData.forEach((team) => {
if (team.length > 0) cache.teams.value.set(team[0].team_id, team)
})
orgsData.forEach((org: Organization) => {
cache.orgs.value.set(org.id, org)
})
return projects.map((project) => {
let owner: TeamMember | null = null
let org: Organization | null = null
if (project.team_id) {
const teamMembers = cache.teams.value.get(project.team_id)
if (teamMembers) {
owner = teamMembers.find((member) => member.role === 'Owner') || null
}
}
if (project.organization) {
org = cache.orgs.value.get(project.organization) || null
}
return {
project,
owner,
org,
} as ModerationProject
})
export function toModerationProjects(projects: ProjectWithOwnership[]): ModerationProject[] {
return projects.map(({ ownership, ...project }) => ({
project,
ownership: ownership ?? null,
}))
}
@@ -1229,6 +1229,39 @@
"dashboard.withdraw.error.tax-form.title": {
"message": "Please complete tax form"
},
"discover.install.back-to-server": {
"message": "Back to server"
},
"discover.install.back-to-setup": {
"message": "Back to setup"
},
"discover.install.cancel-reset": {
"message": "Cancel reset"
},
"discover.install.error.no-server-world": {
"message": "No server world is available for install."
},
"discover.install.error.some-projects-failed.description": {
"message": "Failed projects were not added. You can try installing them again."
},
"discover.install.error.some-projects-failed.title": {
"message": "Some projects failed to install"
},
"discover.install.error.unsupported-content-type": {
"message": "This content type cannot be installed to a server from browse."
},
"discover.install.heading.reset-modpack": {
"message": "Selecting modpack to install after reset"
},
"discover.seo.description": {
"message": "Search and browse thousands of Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}."
},
"discover.seo.title": {
"message": "Search {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}"
},
"discover.seo.title-with-query": {
"message": "Search {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} | {query}"
},
"error.collection.404.list_item.1": {
"message": "You may have mistyped the collection's URL."
},
@@ -3062,6 +3095,9 @@
"search.filter.locked.server.sync": {
"message": "Sync with server"
},
"servers.manage.content.title": {
"message": "Content - {serverName} - Modrinth"
},
"servers.notice.actions": {
"message": "Actions"
},
+62 -5
View File
@@ -370,18 +370,21 @@
<VersionSummary
v-if="filteredRelease"
:version="filteredRelease"
:decorate-download-url="decorateModalDownloadUrl"
@on-download="onDownload"
@on-navigate="onVersionNavigate"
/>
<VersionSummary
v-if="filteredBeta"
:version="filteredBeta"
:decorate-download-url="decorateModalDownloadUrl"
@on-download="onDownload"
@on-navigate="onVersionNavigate"
/>
<VersionSummary
v-if="filteredAlpha"
:version="filteredAlpha"
:decorate-download-url="decorateModalDownloadUrl"
@on-download="onDownload"
@on-navigate="onVersionNavigate"
/>
@@ -1082,6 +1085,7 @@ import {
OpenInAppModal,
OverflowMenu,
PopoutMenu,
PROJECT_DEP_MARKER_QUERY,
ProjectBackgroundGradient,
ProjectEnvironmentModal,
ProjectHeader,
@@ -1108,7 +1112,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import { nextTick, useTemplateRef, watch } from 'vue'
import { nextTick, readonly, ref, useTemplateRef, watch } from 'vue'
import { navigateTo } from '#app'
import Accordion from '~/components/ui/Accordion.vue'
@@ -1137,6 +1141,7 @@ definePageMeta({
const data = useNuxtApp()
const route = useRoute()
const router = useRouter()
const signInRouteObj = computed(() => getSignInRouteObj(route))
const config = useRuntimeConfig()
const moderationQueue = useModerationQueue()
@@ -1146,6 +1151,23 @@ const { addNotification } = notifications
const auth = await useAuth()
const user = await useUser()
const { createProjectDownloadUrl } = useCdnDownloadContext()
const downloadReason = ref('standalone')
function absorbDepQuery() {
if (route.query.dep === PROJECT_DEP_MARKER_QUERY.dep) {
downloadReason.value = 'dependency'
if (import.meta.client) {
const newQuery = { ...route.query }
delete newQuery.dep
void router.replace({ path: route.path, query: newQuery, hash: route.hash })
}
}
}
watch(() => route.query.dep, absorbDepQuery, { immediate: true })
const tags = useGeneratedState()
const flags = useFeatureFlags()
const cosmetics = useCosmetics()
@@ -1210,6 +1232,14 @@ const currentPlatformText = computed(() => {
return formatMessage(getTagMessage(currentPlatform.value, 'loader'))
})
function decorateModalDownloadUrl(url) {
return createProjectDownloadUrl(url, {
reason: downloadReason.value,
gameVersion: currentGameVersion.value ?? undefined,
loader: currentPlatform.value ?? undefined,
})
}
const releaseVersions = computed(() => {
const set = new Set()
for (const gv of tags.value.gameVersions || []) {
@@ -1715,15 +1745,27 @@ const serverRequiredContent = computed(() => {
icon: content.project_icon,
onclickName:
content.project_id && content.project_id !== projectId.value
? () => navigateTo(`/project/${content.project_id}`)
? () => {
navigateTo({
path: `/project/${content.project_id}`,
query: { ...PROJECT_DEP_MARKER_QUERY },
})
}
: undefined,
onclickVersion:
content.project_id && content.project_id !== projectId.value
? () =>
navigateTo(`/project/${content.project_id}/version/${serverModpackVersion.value?.id}`)
? () => {
navigateTo({
path: `/project/${content.project_id}/version/${serverModpackVersion.value?.id}`,
query: { ...PROJECT_DEP_MARKER_QUERY },
})
}
: undefined,
onclickDownload: primaryFile?.url
? () => navigateTo(primaryFile.url, { external: true })
? () =>
navigateTo(createProjectDownloadUrl(primaryFile.url, { reason: 'dependency' }), {
external: true,
})
: undefined,
showCustomModpackTooltip: content.project_id === projectId.value,
}
@@ -2317,6 +2359,18 @@ const canCreateServerFrom = computed(() => {
return project.value.project_type === 'modpack' && project.value.server_side !== 'unsupported'
})
const createCanonicalUrl = () =>
project.value ? `https://modrinth.com/project/${project.value.id}` : undefined
useHead({
link: [
{
rel: 'canonical',
href: createCanonicalUrl,
},
],
})
if (!route.name.startsWith('type-id-settings')) {
useSeoMeta({
title: () => title.value,
@@ -2324,6 +2378,7 @@ if (!route.name.startsWith('type-id-settings')) {
ogTitle: () => title.value,
ogDescription: () => project.value?.description ?? '',
ogImage: () => project.value?.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
ogUrl: createCanonicalUrl,
robots: () =>
project.value?.status === 'approved' || project.value?.status === 'archived'
? 'all'
@@ -2332,6 +2387,7 @@ if (!route.name.startsWith('type-id-settings')) {
} else {
useSeoMeta({
robots: 'noindex',
ogUrl: createCanonicalUrl,
})
}
@@ -2689,6 +2745,7 @@ provideProjectPageContext({
// Lazy dependencies loading
dependencies,
dependenciesLoading: computed(() => dependenciesLoading.value),
cdnDownloadReason: readonly(downloadReason),
// Invalidate all project queries (auto-refetches active ones)
invalidate: invalidateProject,
@@ -56,7 +56,7 @@
<ButtonStyled color="brand" type="transparent">
<a
class="ml-auto"
:href="version.primaryFile?.url"
:href="createDownloadUrl(version)"
:title="`Download ${version.name}`"
>
<DownloadIcon aria-hidden="true" />
@@ -98,7 +98,9 @@ import {
import VersionFilterControl from '@modrinth/ui/src/components/version/VersionFilterControl.vue'
import { renderHighlightedString } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { onMounted } from 'vue'
import { onMounted, watch } from 'vue'
const { createProjectDownloadUrl, updateVersionsFilterContext } = useCdnDownloadContext()
const formatDate = useFormatDateTime({
month: 'short',
@@ -106,7 +108,8 @@ const formatDate = useFormatDateTime({
year: 'numeric',
})
const { projectV2, versions, versionsLoading, loadVersions } = injectProjectPageContext()
const { projectV2, versions, versionsLoading, loadVersions, cdnDownloadReason } =
injectProjectPageContext()
// Load versions on mount (client-side)
onMounted(() => {
@@ -216,6 +219,23 @@ function updateQuery(newQueries) {
},
})
}
watch(
() => [route.query.g, route.query.l],
() => {
updateVersionsFilterContext(
queryAsStringArray(route.query.g),
queryAsStringArray(route.query.l),
)
},
{ immediate: true },
)
function createDownloadUrl(version) {
return createProjectDownloadUrl(getPrimaryFile(version).url, {
reason: cdnDownloadReason.value,
})
}
</script>
<style lang="scss">
@@ -146,7 +146,11 @@
const input = e.target
if (input.files?.length) {
if (
fileIsValid(input.files[0], { maxSize: 524288000, alertOnInvalid: true })
fileIsValid(
input.files[0],
{ maxSize: 524288000, alertOnInvalid: true },
formatBytes,
)
)
showBannerPreview(Array.from(input.files))
}
@@ -379,6 +383,7 @@ import {
StyledInput,
Toggle,
UnsavedChangesPopup,
useFormatBytes,
usePageLeaveSafety,
} from '@modrinth/ui'
import { fileIsValid, formatProjectStatus, formatProjectType } from '@modrinth/utils'
@@ -405,6 +410,8 @@ const flags = useFeatureFlags()
const tags = useGeneratedState()
const router = useNativeRouter()
const formatBytes = useFormatBytes()
const name = ref(project.value.title)
const slug = ref(project.value.slug)
const summary = ref(project.value.description)
@@ -110,7 +110,7 @@
id: 'download',
color: 'primary',
hoverFilled: true,
link: getPrimaryFile(version).url,
link: createDownloadUrl(version),
action: () => {
emit('onDownload')
},
@@ -340,7 +340,7 @@ import {
ProjectPageVersions,
useVIntl,
} from '@modrinth/ui'
import { useTemplateRef } from 'vue'
import { useTemplateRef, watch } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
@@ -348,6 +348,8 @@ import { reportVersion } from '~/utils/report-helpers.ts'
const route = useRoute()
const { createProjectDownloadUrl, updateVersionsFilterContext } = useCdnDownloadContext()
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -357,6 +359,7 @@ const {
versions,
invalidate,
loadVersions,
cdnDownloadReason,
} = injectProjectPageContext()
// Load versions on mount (client-side)
@@ -401,6 +404,23 @@ function getPrimaryFile(version: Labrinth.Versions.v3.Version) {
return version.files.find((x) => x.primary) || version.files[0]
}
watch(
() => [route.query.g, route.query.l],
() => {
updateVersionsFilterContext(
queryAsStringArray(route.query.g),
queryAsStringArray(route.query.l),
)
},
{ immediate: true },
)
function createDownloadUrl(version: Labrinth.Versions.v3.Version) {
return createProjectDownloadUrl(getPrimaryFile(version).url, {
reason: cdnDownloadReason.value,
})
}
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text)
}
@@ -139,7 +139,7 @@
<ButtonStyled v-if="primaryFile && !currentMember" color="brand">
<a
v-tooltip="primaryFile.filename + ' (' + formatBytes(primaryFile.size) + ')'"
:href="primaryFile.url"
:href="decoratedPrimaryFileUrl"
@click="emit('onDownload')"
>
<DownloadIcon aria-hidden="true" />
@@ -213,14 +213,19 @@
:key="index"
class="dependency"
:class="{ 'button-transparent': !isEditing }"
@click="!isEditing ? router.push(dependency.link) : {}"
@click="!isEditing ? navigateToDependency(dependency) : {}"
>
<Avatar
:src="dependency.project ? dependency.project.icon_url : null"
alt="dependency-icon"
size="sm"
/>
<nuxt-link v-if="!isEditing" :to="dependency.link" class="info">
<nuxt-link
v-if="!isEditing"
:to="{ path: dependency.link, query: PROJECT_DEP_MARKER_QUERY }"
class="info"
@click.stop
>
<span class="project-title">
{{ dependency.project ? dependency.project.title : 'Unknown Project' }}
</span>
@@ -299,7 +304,7 @@
</span>
<ButtonStyled>
<a
:href="file.url"
:href="decorateDownloadUrl(file.url)"
class="raised-button"
:title="`Download ${file.filename}`"
tabindex="0"
@@ -435,10 +440,12 @@ import {
injectNotificationManager,
injectProjectPageContext,
MultiSelect,
PROJECT_DEP_MARKER_QUERY,
StyledInput,
useFormatBytes,
useFormatDateTime,
} from '@modrinth/ui'
import { formatBytes, renderHighlightedString } from '@modrinth/utils'
import { renderHighlightedString } from '@modrinth/utils'
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
@@ -461,11 +468,13 @@ const auth = await useAuth()
const tags = useGeneratedState()
const flags = useFeatureFlags()
const { addNotification } = injectNotificationManager()
const { createProjectDownloadUrl } = useCdnDownloadContext()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatDate = useFormatDateTime({ dateStyle: 'medium' })
const formatBytes = useFormatBytes()
// Helper for accessing nuxt app $formatVersion
const formatVersionDisplay = (versions: string[]) => (data as any).$formatVersion(versions)
@@ -481,6 +490,7 @@ const {
dependenciesLoading: contextDependenciesLoading,
loadDependencies,
invalidate,
cdnDownloadReason,
} = injectProjectPageContext()
// Load versions and dependencies in parallel
@@ -752,6 +762,21 @@ const sortedDeps = computed(() => {
)
})
const decoratedPrimaryFileUrl = computed(() =>
createProjectDownloadUrl(primaryFile.value?.url, { reason: cdnDownloadReason.value }),
)
function decorateDownloadUrl(url: string) {
return createProjectDownloadUrl(url, { reason: cdnDownloadReason.value })
}
function navigateToDependency(dependency: { link: string }) {
return router.push({
path: dependency.link,
query: { ...PROJECT_DEP_MARKER_QUERY },
})
}
const environment = computed(
() => ENVIRONMENTS_COPY[version.value.environment as keyof typeof ENVIRONMENTS_COPY],
)
@@ -46,7 +46,7 @@
<ButtonStyled circular type="transparent">
<a
v-tooltip="`Download`"
:href="getPrimaryFile(version).url"
:href="createDownloadUrl(version)"
class="hover:!bg-button-bg [&>svg]:!text-green"
aria-label="Download"
@click="emit('onDownload')"
@@ -100,7 +100,7 @@
id: 'download',
color: 'primary',
hoverFilled: true,
link: getPrimaryFile(version).url,
link: createDownloadUrl(version),
action: () => {
emit('onDownload')
},
@@ -266,7 +266,7 @@ import {
OverflowMenu,
ProjectPageVersions,
} from '@modrinth/ui'
import { onMounted, useTemplateRef } from 'vue'
import { onMounted, useTemplateRef, watch } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
@@ -274,6 +274,8 @@ import { reportVersion } from '~/utils/report-helpers.ts'
const route = useRoute()
const { createProjectDownloadUrl, updateVersionsFilterContext } = useCdnDownloadContext()
const tags = useGeneratedState()
const flags = useFeatureFlags()
const auth = await useAuth()
@@ -287,6 +289,7 @@ const {
versions,
versionsLoading,
loadVersions,
cdnDownloadReason,
} = injectProjectPageContext()
// Load versions on mount (client-side)
@@ -316,6 +319,23 @@ function getPrimaryFile(version) {
return version.files.find((x) => x.primary) || version.files[0]
}
watch(
() => [route.query.g, route.query.l],
() => {
updateVersionsFilterContext(
queryAsStringArray(route.query.g),
queryAsStringArray(route.query.l),
)
},
{ immediate: true },
)
function createDownloadUrl(version) {
return createProjectDownloadUrl(getPrimaryFile(version).url, {
reason: cdnDownloadReason.value,
})
}
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
}
+10 -2
View File
@@ -81,11 +81,19 @@
<script setup lang="ts">
import { FileIcon, SpinnerIcon, UploadIcon } from '@modrinth/assets'
import { Admonition, Avatar, CopyCode, injectNotificationManager } from '@modrinth/ui'
import { formatBytes, type Project, type Version } from '@modrinth/utils'
import {
Admonition,
Avatar,
CopyCode,
injectNotificationManager,
useFormatBytes,
} from '@modrinth/ui'
import type { Project, Version } from '@modrinth/utils'
const { addNotification } = injectNotificationManager()
const formatBytes = useFormatBytes()
const fileInput = ref<HTMLInputElement>()
const selectedFile = ref<File | null>(null)
const fileHashes = ref<{
@@ -657,6 +657,7 @@ watch(
[collection, creator],
([col, cre]) => {
if (col && cre) {
const canonicalUrl = col ? `https://modrinth.com/collection/${col.id}` : undefined
useSeoMeta({
title: formatMessage(messages.collectionTitle, { name: col.name }),
description: formatMessage(messages.collectionDescription, {
@@ -667,8 +668,17 @@ watch(
ogTitle: formatMessage(messages.collectionTitle, { name: col.name }),
ogDescription: col.description,
ogImage: col.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
ogUrl: canonicalUrl,
robots: col.status === 'listed' ? 'all' : 'noindex',
})
useHead({
link: [
{
rel: 'canonical',
href: canonicalUrl,
},
],
})
}
},
{ immediate: true },
+501 -83
View File
@@ -11,19 +11,37 @@ import {
MoreVerticalIcon,
SpinnerIcon,
} from '@modrinth/assets'
import type { CardAction, CreationFlowContextValue } from '@modrinth/ui'
import type {
BrowseInstallContentType,
BrowseInstallPlan,
CardAction,
CreationFlowContextValue,
PendingServerContentInstall,
PendingServerContentInstallType,
} from '@modrinth/ui'
import {
addPendingServerContentInstalls,
BrowseInstallHeader,
BrowsePageLayout,
BrowseSidebar,
commonMessages,
CreationFlowModal,
defineMessages,
flushInstallQueue,
getTargetInstallPreferences,
injectModrinthClient,
injectNotificationManager,
PROJECT_DEP_MARKER_QUERY,
provideBrowseManager,
readPendingServerContentInstalls,
removePendingServerContentInstall,
requestInstall,
SelectedProjectsFloatingBar,
useBrowseSearch,
useDebugLogger,
useStickyObserver,
useVIntl,
writePendingServerContentInstallBaseline,
} from '@modrinth/ui'
import { cycleValue } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
@@ -40,10 +58,15 @@ import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('Discover')
const { updateDiscoverFilterContext } = useCdnDownloadContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const filtersMenuOpen = ref(false)
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
useStickyObserver(stickyInstallHeaderRef, 'DiscoverInstallHeader')
const route = useRoute()
@@ -52,7 +75,7 @@ const tags = useGeneratedState()
const flags = useFeatureFlags()
const auth = await useAuth()
const { handleError } = injectNotificationManager()
const { addNotification, handleError } = injectNotificationManager()
let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
const HOVER_DURATION_TO_PREFETCH_MS = 500
@@ -140,7 +163,7 @@ function cycleSearchDisplayMode() {
const currentServerId = computed(() => queryAsString(route.query.sid) || null)
const fromContext = computed(() => queryAsString(route.query.from) || null)
const currentWorldId = computed(() => queryAsString(route.query.wid) || undefined)
const currentWorldId = computed(() => queryAsString(route.query.wid) || null)
const {
data: serverData,
@@ -173,11 +196,139 @@ const serverIcon = computed(() => {
})
const serverHideInstalled = ref(false)
const hideSelectedServerInstalls = ref(false)
const installingProjectIds = ref<Set<string>>(new Set())
const optimisticallyInstalledProjectIds = ref<Set<string>>(new Set())
const hiddenInstalledProjectIds = ref<Set<string>>(new Set())
const hiddenInstalledProjectIdsInitialized = ref(false)
interface InstallableSearchResult extends Labrinth.Search.v2.ResultSearchProject {
installed?: boolean
}
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(new Map())
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
const selectedServerInstallProjects = computed(() =>
Array.from(queuedServerInstalls.value.values()).map((plan) => ({
id: plan.projectId,
name: plan.project.title ?? formatMessage(commonMessages.projectLabel),
iconUrl: plan.project.icon_url ?? null,
})),
)
const isInstallingQueuedServerInstalls = ref(false)
const queuedInstallProgress = ref({ completed: 0, total: 0 })
const serverInstallQueue = {
get: () => queuedServerInstalls.value,
set: (plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>) => {
queuedServerInstalls.value = plans
},
}
function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
if (project.organization) {
const ownerId = project.organization_id ?? project.organization
return {
id: ownerId,
name: project.organization,
type: 'organization' as const,
link: `/organization/${ownerId}`,
}
}
if (!project.author) return null
const ownerId = project.author_id ?? project.author
return {
id: ownerId,
name: project.author,
type: 'user' as const,
link: `/user/${ownerId}`,
}
}
async function getQueuedInstallOwner(project: InstallableSearchResult) {
const fallback = getQueuedInstallOwnerFallback(project)
try {
if (project.organization) {
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
if (organization) {
return {
id: organization.id,
name: organization.name,
type: 'organization' as const,
avatar_url: organization.icon_url ?? undefined,
link: `/organization/${organization.slug}`,
}
}
}
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
const owner =
members.find((member) => member.user.id === project.author_id)?.user ??
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
members[0]?.user
if (owner) {
return {
id: owner.id,
name: owner.username,
type: 'user' as const,
avatar_url: owner.avatar_url,
link: `/user/${owner.username}`,
}
}
} catch {
return fallback
}
return fallback
}
function getQueuedAddonInstallPlans(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
}
function getQueuedInstallPlaceholder(
plan: BrowseInstallPlan<InstallableSearchResult>,
owner: PendingServerContentInstallInput['owner'],
): PendingServerContentInstallInput {
return {
projectId: plan.projectId,
versionId: plan.versionId,
contentType: plan.contentType as PendingServerContentInstallType,
title: plan.project.title ?? formatMessage(commonMessages.projectLabel),
versionName: plan.versionName ?? null,
versionNumber: plan.versionNumber ?? null,
fileName: plan.fileName ?? null,
owner,
slug: plan.project.slug ?? plan.projectId,
iconUrl: plan.project.icon_url ?? null,
}
}
function getQueuedInstallPlaceholderFallbacks(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return getQueuedAddonInstallPlans(plans).map((plan) =>
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
)
}
async function getQueuedInstallPlaceholders(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Promise.all(
getQueuedAddonInstallPlans(plans).map(async (plan) =>
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)),
),
)
}
function setProjectInstalling(projectId: string, installing: boolean) {
const next = new Set(installingProjectIds.value)
if (installing) {
@@ -203,6 +354,10 @@ function getServerInstalledProjectIds(data = serverContentData.value) {
)
}
function getServerInstalledContentKeys(data = serverContentData.value) {
return new Set((data?.addons ?? []).map((addon) => addon.project_id ?? addon.filename))
}
function syncHiddenInstalledProjectIds() {
hiddenInstalledProjectIds.value = new Set([
...getServerInstalledProjectIds(),
@@ -239,21 +394,22 @@ watch(
const installContentMutation = useMutation({
mutationFn: ({
serverId,
worldId,
projectId,
versionId,
}: {
serverId: string
worldId: string
projectId: string
versionId: string
}) =>
client.archon.content_v1.addAddon(serverId, currentWorldId.value!, {
client.archon.content_v1.addAddon(serverId, worldId, {
project_id: projectId,
version_id: versionId,
}),
onSuccess: () => {
if (currentServerId.value) {
queryClient.refetchQueries({ queryKey: ['content', 'list', currentServerId.value] })
}
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', variables.serverId] })
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
},
})
@@ -306,6 +462,16 @@ const serverFilters = computed(() => {
})
}
}
if (hideSelectedServerInstalls.value && queuedServerInstallProjectIds.value.size > 0) {
for (const id of queuedServerInstallProjectIds.value) {
filters.push({
type: 'project_id',
option: `project_id:${id}`,
negative: true,
})
}
}
}
if (currentServerId.value && projectType.value?.id === 'modpack') {
@@ -318,78 +484,199 @@ const serverFilters = computed(() => {
return filters
})
interface InstallableSearchResult extends Labrinth.Search.v2.ResultSearchProject {
installed?: boolean
function getCurrentServerInstallType(): BrowseInstallContentType {
const type = projectType.value?.id
if (type === 'modpack' || type === 'mod' || type === 'plugin' || type === 'datapack') {
return type
}
throw new Error(formatMessage(messages.unsupportedContentType))
}
function getServerInstallTargetPreferences(contentType: BrowseInstallContentType) {
return getTargetInstallPreferences(
{
gameVersion: serverData.value?.mc_version,
loader: serverData.value?.loader,
},
contentType,
)
}
function getInstallProjectVersions(projectId: string) {
return client.labrinth.versions_v2.getProjectVersions(projectId, {
include_changelog: false,
})
}
function clearQueuedServerInstalls() {
queuedServerInstalls.value = new Map()
}
function removeQueuedServerInstall(projectId: string) {
const nextPlans = new Map(queuedServerInstalls.value)
nextPlans.delete(projectId)
queuedServerInstalls.value = nextPlans
}
watch([currentServerId, currentWorldId], ([serverId, worldId], [prevServerId, prevWorldId]) => {
if (serverId !== prevServerId || worldId !== prevWorldId) {
clearQueuedServerInstalls()
}
})
async function flushQueuedServerInstalls(
serverId: string | null = currentServerId.value,
worldId: string | null = currentWorldId.value,
) {
if (queuedServerInstalls.value.size === 0) return true
if (isInstallingQueuedServerInstalls.value) return false
if (!serverId || !worldId) {
handleError(new Error(formatMessage(messages.noServerWorld)))
return false
}
isInstallingQueuedServerInstalls.value = true
queuedInstallProgress.value = {
completed: 0,
total: queuedServerInstalls.value.size,
}
try {
const result = await flushInstallQueue({
queue: serverInstallQueue,
install: async (plan) => {
await installContentMutation.mutateAsync({
serverId,
worldId,
projectId: plan.projectId,
versionId: plan.versionId,
})
markProjectInstalled(plan.projectId)
},
onError: (error, plan) => {
removePendingServerContentInstall(serverId, worldId, plan.projectId)
handleError(error as Error)
},
onProgress: (completed, total) => {
queuedInstallProgress.value = { completed, total }
},
})
return result.ok
} finally {
isInstallingQueuedServerInstalls.value = false
queuedInstallProgress.value = { completed: 0, total: 0 }
}
}
async function discardQueuedServerInstallsAndBack() {
clearQueuedServerInstalls()
await navigateTo(serverBackUrl.value)
}
async function installQueuedServerInstallsAndBack() {
const sid = currentServerId.value
const wid = currentWorldId.value
const backUrl = serverBackUrl.value
const plans = new Map(queuedServerInstalls.value)
if (sid && wid) {
writePendingServerContentInstallBaseline(sid, wid, [
...getServerInstalledContentKeys(),
...optimisticallyInstalledProjectIds.value,
])
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
void getQueuedInstallPlaceholders(plans)
.then((items) => {
const pendingProjectIds = new Set(
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
)
addPendingServerContentInstalls(
sid,
wid,
items.filter((item) => pendingProjectIds.has(item.projectId)),
)
})
.catch((err) => handleError(err as Error))
}
await navigateTo(backUrl)
const ok = await flushQueuedServerInstalls(sid, wid)
if (!ok) {
queuedServerInstalls.value = new Map()
addNotification({
type: 'error',
title: formatMessage(messages.someProjectsFailedTitle),
text: formatMessage(messages.someProjectsFailedText),
})
}
return true
}
async function serverInstall(project: InstallableSearchResult) {
if (!serverData.value || !currentServerId.value) {
if (!serverData.value || !currentServerId.value || !currentWorldId.value) {
handleError(new Error('No server to install to.'))
return
}
setProjectInstalling(project.project_id, true)
const contentType = getCurrentServerInstallType()
const isModpack = contentType === 'modpack'
try {
if (projectType.value?.id === 'modpack') {
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, {
include_changelog: false,
})
const versionId = versions[0]?.id ?? project.latest_version
if (!versionId) {
handleError(new Error('No version found for this modpack'))
setProjectInstalling(project.project_id, false)
return
}
const modalInstance = onboardingModalRef.value
if (modalInstance) {
onboardingInstallingProject.value = project
if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) {
removeQueuedServerInstall(project.project_id)
return
}
if (isModpack || !queuedServerInstallProjectIds.value.has(project.project_id)) {
setProjectInstalling(project.project_id, true)
}
await requestInstall({
project,
contentType,
mode: isModpack ? 'immediate' : 'queue',
selectedFilters: isModpack ? [] : searchState.currentFilters.value,
providedFilters: isModpack ? [] : serverFilters.value,
overriddenProvidedFilterTypes: isModpack
? []
: searchState.overriddenProvidedFilterTypes.value,
targetPreferences: getServerInstallTargetPreferences(contentType),
getProjectVersions: getInstallProjectVersions,
queue: serverInstallQueue,
install: async (plan) => {
const modalInstance = onboardingModalRef.value
if (!modalInstance) {
setProjectInstalling(plan.projectId, false)
return
}
onboardingInstallingProject.value = plan.project
modalInstance.show()
await nextTick()
const ctx = modalInstance.ctx
ctx.setupType.value = 'modpack'
ctx.modpackSelection.value = {
projectId: project.project_id,
versionId,
name: project.title,
iconUrl: project.icon_url ?? undefined,
projectId: plan.projectId,
versionId: plan.versionId,
name: plan.project.title,
iconUrl: plan.project.icon_url ?? undefined,
}
ctx.modal.value?.setStage('final-config')
}
return
} else if (
projectType.value?.id === 'mod' ||
projectType.value?.id === 'plugin' ||
projectType.value?.id === 'datapack'
) {
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id)
const isDatapack = projectType.value?.id === 'datapack'
const version = versions.find((x) => {
if (!x.game_versions.includes(serverData.value!.mc_version!)) return false
if (isDatapack) return true
return x.loaders.includes(serverData.value!.loader!.toLowerCase())
})
if (!version) {
handleError(
new Error(
isDatapack
? `No compatible version found for ${serverData.value!.mc_version}`
: `No compatible version found for ${serverData.value!.mc_version} / ${serverData.value!.loader}`,
),
)
setProjectInstalling(project.project_id, false)
return
}
await installContentMutation.mutateAsync({
serverId: currentServerId.value,
projectId: version.project_id,
versionId: version.id,
})
markProjectInstalled(project.project_id)
}
},
})
} catch (e) {
console.error(e)
handleError(new Error(`Error installing content ${e}`))
if (isModpack) {
setProjectInstalling(project.project_id, false)
}
handleError(e instanceof Error ? e : new Error(`Error installing content ${e}`))
} finally {
if (!isModpack) {
setProjectInstalling(project.project_id, false)
}
}
setProjectInstalling(project.project_id, false)
}
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
@@ -401,7 +688,13 @@ function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject
name: project_name,
icon: project_icon ?? undefined,
onclick:
project_id !== project.project_id ? () => navigateTo(`/project/${project_id}`) : undefined,
project_id !== project.project_id
? () =>
navigateTo({
path: `/project/${project_id}`,
query: { ...PROJECT_DEP_MARKER_QUERY },
})
: undefined,
showCustomModpackTooltip: project_id === project.project_id,
}
}
@@ -494,6 +787,7 @@ function getCardActions(
}
if (serverData.value) {
const isQueued = queuedServerInstallProjectIds.value.has(result.project_id)
const isInstalled =
projectResult.installed ||
optimisticallyInstalledProjectIds.value.has(result.project_id) ||
@@ -501,15 +795,36 @@ function getCardActions(
(serverContentData.value.addons ?? []).find((x) => x.project_id === result.project_id)) ||
serverData.value.upstream?.project_id === result.project_id
const isInstalling = installingProjectIds.value.has(result.project_id)
const isInstallingSelection = isInstallingQueuedServerInstalls.value
const validatingInstall =
isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection
const installLabel = isInstalled
? formatMessage(commonMessages.installedLabel)
: isQueued
? isInstalling || isInstallingSelection
? validatingInstall
? formatMessage(commonMessages.validatingLabel)
: formatMessage(commonMessages.installingLabel)
: formatMessage(commonMessages.selectedLabel)
: isInstalling || isInstallingSelection
? validatingInstall
? formatMessage(commonMessages.validatingLabel)
: formatMessage(commonMessages.installingLabel)
: formatMessage(commonMessages.installButton)
return [
{
key: 'install',
label: isInstalling ? 'Installing...' : isInstalled ? 'Installed' : 'Install',
icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : DownloadIcon,
iconClass: isInstalling ? 'animate-spin' : undefined,
disabled: !!isInstalled || isInstalling,
color: 'brand',
label: installLabel,
icon:
isInstalling || isInstallingSelection
? SpinnerIcon
: isQueued || isInstalled
? CheckIcon
: DownloadIcon,
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
disabled: !!isInstalled || isInstalling || isInstallingSelection,
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
type: 'outlined',
onClick: () => serverInstall(projectResult),
},
@@ -570,15 +885,15 @@ const serverBackUrl = computed(() => {
})
const serverBackLabel = computed(() => {
if (fromContext.value === 'onboarding') return 'Back to setup'
if (fromContext.value === 'reset-server') return 'Cancel reset'
return 'Back to server'
if (fromContext.value === 'onboarding') return formatMessage(messages.backToSetup)
if (fromContext.value === 'reset-server') return formatMessage(messages.cancelReset)
return formatMessage(messages.backToServer)
})
const serverBrowseHeading = computed(() =>
fromContext.value === 'reset-server'
? 'Select modpack to install after reset'
: 'Install content to server',
? formatMessage(messages.resetModpackHeading)
: formatMessage(commonMessages.installingContentLabel),
)
const installContext = computed(() => {
@@ -594,10 +909,51 @@ const installContext = computed(() => {
backUrl: serverBackUrl.value,
backLabel: serverBackLabel.value,
heading: serverBrowseHeading.value,
queuedCount: queuedServerInstallCount.value,
selectedProjects: selectedServerInstallProjects.value,
isInstallingSelected: isInstallingQueuedServerInstalls.value,
installProgress: queuedInstallProgress.value,
clearQueued: clearQueuedServerInstalls,
clearSelected: clearQueuedServerInstalls,
onBack: flushQueuedServerInstalls,
discardSelectedAndBack: discardQueuedServerInstallsAndBack,
installSelected: installQueuedServerInstallsAndBack,
}
})
const messages = defineMessages({
unsupportedContentType: {
id: 'discover.install.error.unsupported-content-type',
defaultMessage: 'This content type cannot be installed to a server from browse.',
},
noServerWorld: {
id: 'discover.install.error.no-server-world',
defaultMessage: 'No server world is available for install.',
},
someProjectsFailedTitle: {
id: 'discover.install.error.some-projects-failed.title',
defaultMessage: 'Some projects failed to install',
},
someProjectsFailedText: {
id: 'discover.install.error.some-projects-failed.description',
defaultMessage: 'Failed projects were not added. You can try installing them again.',
},
backToSetup: {
id: 'discover.install.back-to-setup',
defaultMessage: 'Back to setup',
},
cancelReset: {
id: 'discover.install.cancel-reset',
defaultMessage: 'Cancel reset',
},
backToServer: {
id: 'discover.install.back-to-server',
defaultMessage: 'Back to server',
},
resetModpackHeading: {
id: 'discover.install.heading.reset-modpack',
defaultMessage: 'Selecting modpack to install after reset',
},
gameVersionProvidedByServer: {
id: 'search.filter.locked.server-game-version.title',
defaultMessage: 'Game version is provided by the server',
@@ -614,6 +970,21 @@ const messages = defineMessages({
id: 'search.filter.locked.server.sync',
defaultMessage: 'Sync with server',
},
seoTitle: {
id: 'discover.seo.title',
defaultMessage:
'Search {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}',
},
seoTitleWithQuery: {
id: 'discover.seo.title-with-query',
defaultMessage:
'Search {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} | {query}',
},
seoDescription: {
id: 'discover.seo.description',
defaultMessage:
'Search and browse thousands of Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}.',
},
gameVersionShaderMessage: {
id: 'search.filter.game-version-shader-message',
defaultMessage:
@@ -639,6 +1010,21 @@ const searchState = useBrowseSearch({
displayMode: resultsDisplayMode,
})
watch(queuedServerInstallCount, (count) => {
if (count === 0) {
hideSelectedServerInstalls.value = false
}
})
watch(
() =>
searchState.isServerType.value
? searchState.serverCurrentFilters.value
: searchState.currentFilters.value,
(filters) => updateDiscoverFilterContext(filters),
{ deep: true, immediate: true },
)
watch(
[
() => searchState.query.value,
@@ -655,13 +1041,16 @@ watch(
debug('calling initial refreshSearch')
searchState.refreshSearch()
const ogTitle = computed(
() =>
`Search ${projectType.value?.display ?? 'project'}s${searchState.query.value ? ' | ' + searchState.query.value : ''}`,
const ogTitle = computed(() =>
searchState.query.value
? formatMessage(messages.seoTitleWithQuery, {
projectType: projectType.value?.id ?? 'project',
query: searchState.query.value,
})
: formatMessage(messages.seoTitle, { projectType: projectType.value?.id ?? 'project' }),
)
const description = computed(
() =>
`Search and browse thousands of Minecraft ${projectType.value?.display ?? 'project'}s on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft ${projectType.value?.display ?? 'project'}s.`,
const description = computed(() =>
formatMessage(messages.seoDescription, { projectType: projectType.value?.id ?? 'project' }),
)
useSeoMeta({
@@ -687,7 +1076,15 @@ provideBrowseManager({
providedFilters: serverFilters,
hideInstalled: serverHideInstalled,
showHideInstalled: computed(() => !!serverData.value && projectType.value?.id !== 'modpack'),
hideInstalledLabel: computed(() => 'Hide already installed content'),
hideInstalledLabel: computed(() => formatMessage(commonMessages.hideInstalledContentLabel)),
hideSelected: hideSelectedServerInstalls,
showHideSelected: computed(
() =>
!!serverData.value &&
projectType.value?.id !== 'modpack' &&
queuedServerInstallCount.value > 0,
),
hideSelectedLabel: computed(() => formatMessage(commonMessages.hideSelectedContentLabel)),
displayMode: resultsDisplayMode,
cycleDisplayMode: cycleSearchDisplayMode,
maxResultsOptions: currentMaxResultsOptions,
@@ -710,10 +1107,15 @@ provideBrowseManager({
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
<div class="search-background"></div>
</Teleport>
<div v-if="installContext" class="normal-page__header mb-4 flex flex-col gap-2">
<div
v-if="installContext"
ref="stickyInstallHeaderRef"
class="normal-page__header browse-install-header-bleed sticky top-0 z-20 mb-4 flex flex-col gap-2 border-0 bg-surface-1 py-3"
>
<BrowseInstallHeader />
</div>
<aside class="normal-page__sidebar" aria-label="Filters">
<SelectedProjectsFloatingBar v-if="installContext" :install-context="installContext" />
<aside class="normal-page__sidebar" :aria-label="formatMessage(commonMessages.filtersLabel)">
<AdPlaceholder v-if="!auth.user && !serverData" />
<BrowseSidebar />
</aside>
@@ -741,6 +1143,22 @@ provideBrowseManager({
</section>
</template>
<style lang="scss" scoped>
.browse-install-header-bleed {
grid-column: 1 / -1;
margin-inline: -1.5rem;
padding-inline: 0.75rem !important;
&::after {
content: '';
position: absolute;
right: 50%;
bottom: 0;
width: 100vw;
border-bottom: 1px solid var(--surface-5);
transform: translateX(50%);
}
}
.normal-page__content {
display: contents;
@@ -1,33 +1,72 @@
<script setup lang="ts">
import {
commonMessages,
defineMessages,
injectModrinthClient,
injectModrinthServerContext,
ServersManageContentPage,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const client = injectModrinthClient()
const { server, serverId, worldId } = injectModrinthServerContext()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
if (worldId.value) {
const messages = defineMessages({
title: {
id: 'servers.manage.content.title',
defaultMessage: 'Content - {serverName} - Modrinth',
},
})
async function getContentWorldId() {
if (worldId.value) return worldId.value
const serverFull = await queryClient.ensureQueryData({
queryKey: ['servers', 'v1', 'detail', serverId],
queryFn: () => client.archon.servers_v1.get(serverId),
staleTime: 30_000,
})
const activeWorld = serverFull.worlds.find((world) => world.is_active)
return activeWorld?.id ?? serverFull.worlds[0]?.id ?? null
}
const contentWorldId = await getContentWorldId()
if (contentWorldId) {
try {
await queryClient.ensureQueryData({
const content = await queryClient.ensureQueryData({
queryKey: ['content', 'list', 'v1', serverId],
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
client.archon.content_v1.getAddons(serverId, contentWorldId, { from_modpack: false }),
staleTime: 30_000,
})
const modpackProjectId =
content.modpack?.spec.platform === 'modrinth' ? content.modpack.spec.project_id : null
if (modpackProjectId) {
await queryClient.ensureQueryData({
queryKey: ['labrinth', 'project', modpackProjectId],
queryFn: () => client.labrinth.projects_v2.get(modpackProjectId),
staleTime: 30_000,
})
}
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
}
useHead({
title: `Content - ${server.value?.name ?? 'Server'} - Modrinth`,
title: () =>
formatMessage(messages.title, {
serverName: server.value?.name ?? formatMessage(commonMessages.serverLabel),
}),
})
</script>
<template>
<ServersManageContentPage />
<ServersManageContentPage :owner-avatar-url-base="''" />
</template>
+187 -44
View File
@@ -8,17 +8,12 @@
autocomplete="off"
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
clearable
wrapper-class="flex-1 lg:max-w-52"
input-class="h-[40px]"
wrapper-class="flex-1"
input-class="h-[40px] w-full"
@input="goToPage(1)"
/>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<div class="flex flex-col flex-wrap justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<div class="flex flex-col gap-2 sm:flex-row">
<Combobox
v-model="currentFilterType"
@@ -55,6 +50,20 @@
</span>
</template>
</Combobox>
<Combobox
v-model="itemsPerPage"
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
:options="itemsPerPageOptions"
placeholder="Items per page"
@select="goToPage(1)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<span class="truncate text-contrast">{{ itemsPerPage }} items</span>
</span>
</template>
</Combobox>
</div>
<ButtonStyled color="orange">
@@ -71,25 +80,44 @@
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<div v-if="totalPages > 1" class="flex items-center justify-between">
<div>
Showing {{ itemsPerPage * (currentPage - 1) + 1 }}{{
itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedProjects.length)
}}
of {{ filteredProjects.length }}
{{
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
}}
</div>
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
<div class="flex flex-col gap-4">
<div v-if="paginatedProjects.length === 0" class="universal-card h-24 animate-pulse"></div>
<div class="flex flex-col gap-3">
<template v-if="pending">
<div
v-for="i in 3"
:key="`loading-skeleton-${i}`"
class="flex h-[98px] w-full animate-pulse rounded-2xl bg-surface-3"
></div>
</template>
<EmptyState
v-else-if="paginatedProjects.length === 0"
:type="!!query ? 'no-search-result' : 'no-tasks'"
:heading="emptyStateHeading"
:description="emptyStateDescription"
/>
<ModerationQueueCard
v-for="item in paginatedProjects"
v-else
:key="item.project.id"
:queue-entry="item"
:owner="item.owner"
:org="item.org"
@start-from-project="startFromProject"
/>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<div v-if="totalPages > 1" class="flex justify-end">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
@@ -102,6 +130,7 @@ import {
type ComboboxOption,
commonMessages,
defineMessages,
EmptyState,
injectNotificationManager,
Pagination,
StyledInput,
@@ -111,7 +140,11 @@ import Fuse from 'fuse.js'
import ConfettiExplosion from 'vue-confetti-explosion'
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
import { enrichProjectBatch, type ModerationProject } from '~/helpers/moderation.ts'
import {
type ModerationProject,
type ProjectWithOwnership,
toModerationProjects,
} from '~/helpers/moderation.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
useHead({ title: 'Projects queue - Modrinth' })
@@ -141,39 +174,26 @@ const messages = defineMessages({
},
})
const { data: allProjects } = await useLazyAsyncData('moderation-projects', async () => {
const { data: allProjects, pending } = await useLazyAsyncData('moderation-projects', async () => {
const startTime = performance.now()
let currentOffset = 0
const PROJECT_ENDPOINT_COUNT = 350
const allProjects: ModerationProject[] = []
const enrichmentPromises: Promise<ModerationProject[]>[] = []
let projects: any[] = []
let projects: ProjectWithOwnership[] = []
do {
projects = (await useBaseFetch(
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ internal: true },
)) as any[]
)) as ProjectWithOwnership[]
if (projects.length === 0) break
const enrichmentPromise = enrichProjectBatch(projects)
enrichmentPromises.push(enrichmentPromise)
allProjects.push(...toModerationProjects(projects))
currentOffset += projects.length
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2))
allProjects.push(...completed.flat())
}
} while (projects.length === PROJECT_ENDPOINT_COUNT)
const remainingBatches = await Promise.all(enrichmentPromises)
allProjects.push(...remainingBatches.flat())
const endTime = performance.now()
const duration = endTime - startTime
const duration = performance.now() - startTime
console.debug(
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
@@ -212,7 +232,6 @@ watch(
},
)
const currentFilterType = ref('All projects')
const filterTypes: ComboboxOption<string>[] = [
{ value: 'All projects', label: 'All projects' },
{ value: 'Modpacks', label: 'Modpacks' },
@@ -222,17 +241,119 @@ const filterTypes: ComboboxOption<string>[] = [
{ value: 'Plugins', label: 'Plugins' },
{ value: 'Shaders', label: 'Shaders' },
{ value: 'Servers', label: 'Servers' },
{ value: 'Fucked up', label: 'Fucked up' },
]
const filterTypeValues = filterTypes.map((option) => option.value)
const DEFAULT_FILTER_TYPE = filterTypeValues[0]
const currentSortType = ref('Oldest')
const sortTypes: ComboboxOption<string>[] = [
{ value: 'Oldest', label: 'Oldest' },
{ value: 'Newest', label: 'Newest' },
]
const sortTypeValues = sortTypes.map((option) => option.value)
const DEFAULT_SORT_TYPE = sortTypeValues[0]
const itemsPerPageOptions: ComboboxOption<number>[] = [
{ value: 20, label: '20' },
{ value: 40, label: '40' },
{ value: 60, label: '60' },
{ value: 80, label: '80' },
{ value: 100, label: '100' },
{ value: 200, label: '200' },
]
const itemsPerPageValues = itemsPerPageOptions.map((option) => option.value)
const DEFAULT_ITEMS_PER_PAGE = 40
function parseFilterTypeFromQuery(value: LocationQueryValue | LocationQueryValue[]): string {
const query = queryAsStringOrEmpty(value)
return filterTypeValues.includes(query) ? query : DEFAULT_FILTER_TYPE
}
function parseSortTypeFromQuery(value: LocationQueryValue | LocationQueryValue[]): string {
const query = queryAsStringOrEmpty(value)
return sortTypeValues.includes(query) ? query : DEFAULT_SORT_TYPE
}
const currentFilterType = ref(parseFilterTypeFromQuery(route.query.filter))
const currentSortType = ref(parseSortTypeFromQuery(route.query.sort))
watch(
currentFilterType,
(newFilter) => {
const currentQuery = { ...route.query }
if (newFilter && newFilter !== DEFAULT_FILTER_TYPE) {
currentQuery.filter = newFilter
} else {
delete currentQuery.filter
}
router.replace({
path: route.path,
query: currentQuery,
})
},
{ immediate: false },
)
watch(
() => route.query.filter,
(newFilterParam) => {
const newValue = parseFilterTypeFromQuery(newFilterParam)
if (currentFilterType.value !== newValue) {
currentFilterType.value = newValue
}
},
)
watch(
currentSortType,
(newSort) => {
const currentQuery = { ...route.query }
if (newSort && newSort !== DEFAULT_SORT_TYPE) {
currentQuery.sort = newSort
} else {
delete currentQuery.sort
}
router.replace({
path: route.path,
query: currentQuery,
})
},
{ immediate: false },
)
watch(
() => route.query.sort,
(newSortParam) => {
const newValue = parseSortTypeFromQuery(newSortParam)
if (currentSortType.value !== newValue) {
currentSortType.value = newValue
}
},
)
const itemsPerPageCookie = useCookie<number>('moderation-items-per-page', {
default: () => DEFAULT_ITEMS_PER_PAGE,
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
path: '/',
})
const itemsPerPage = computed({
get() {
const value = Number(itemsPerPageCookie.value)
return itemsPerPageValues.includes(value) ? value : DEFAULT_ITEMS_PER_PAGE
},
set(value: number) {
itemsPerPageCookie.value = value
},
})
const currentPage = ref(1)
const itemsPerPage = 15
const totalPages = computed(() => Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage))
const totalPages = computed(() =>
Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage.value),
)
const fuse = computed(() => {
if (!allProjects.value || allProjects.value.length === 0) return null
@@ -254,9 +375,7 @@ const fuse = computed(() => {
name: 'project.project_type',
weight: 1,
},
'owner.user.username',
'org.name',
'org.slug',
'ownership.name',
],
includeScore: true,
threshold: 0.4,
@@ -274,7 +393,11 @@ const baseFiltered = computed(() => {
})
const typeFiltered = computed(() => {
if (currentFilterType.value === 'All projects') return baseFiltered.value
if (currentFilterType.value === 'All projects') {
return baseFiltered.value
} else if (currentFilterType.value === 'Fucked up') {
return baseFiltered.value.filter((queueItem) => queueItem.project.project_types.length === 0)
}
const filterMap: Record<string, string> = {
Modpacks: 'modpack',
@@ -319,11 +442,31 @@ const filteredProjects = computed(() => {
const paginatedProjects = computed(() => {
if (!filteredProjects.value) return []
const start = (currentPage.value - 1) * itemsPerPage
const end = start + itemsPerPage
const start = (currentPage.value - 1) * itemsPerPage.value
const end = start + itemsPerPage.value
return filteredProjects.value.slice(start, end)
})
const emptyStateHeading = computed(() => {
if (query.value) {
return 'Not finding anything...'
}
if (currentFilterType.value !== DEFAULT_FILTER_TYPE) {
return 'All done here!'
}
return 'The queue is empty!'
})
const emptyStateDescription = computed(() => {
if (query.value) {
return 'Check that your search query is correct!'
}
if (currentFilterType.value !== DEFAULT_FILTER_TYPE) {
return `There are no ${currentFilterType.value.toLowerCase()} in the queue`
}
return 'you will probably never see this but if you do, congrats!!! :D'
})
function goToPage(page: number) {
currentPage.value = page
}
@@ -368,7 +511,7 @@ async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
async function moderateAllInFilter() {
// Start from the current page - get projects from current page onwards
const startIndex = (currentPage.value - 1) * itemsPerPage
const startIndex = (currentPage.value - 1) * itemsPerPage.value
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
await moderationQueue.setQueue(projectIds)
+15 -1
View File
@@ -307,6 +307,7 @@ import {
injectModrinthClient,
NavTabs,
OverflowMenu,
PROJECT_DEP_MARKER_QUERY,
ProjectCard,
ProjectCardList,
useCompactNumber,
@@ -489,7 +490,10 @@ function getServerModpackContent(project: ProjectV3) {
onclick:
project_id !== project.id
? () => {
navigateTo(`/project/${project_id}`)
navigateTo({
path: `/project/${project_id}`,
query: { ...PROJECT_DEP_MARKER_QUERY },
})
}
: undefined,
showCustomModpackTooltip: project_id === project.id,
@@ -551,6 +555,7 @@ watch(
if (org) {
const title = `${org.name} - Organization`
const description = `${org.description} - View the organization ${org.name} on Modrinth`
const canonicalUrl = org ? `https://modrinth.com/organization/${org.id}` : undefined
useSeoMeta({
title,
@@ -558,6 +563,15 @@ watch(
ogTitle: title,
ogDescription: org.description,
ogImage: org.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
ogUrl: canonicalUrl,
})
useHead({
link: [
{
rel: 'canonical',
href: canonicalUrl,
},
],
})
}
},
@@ -0,0 +1,43 @@
import { type Labrinth, ModrinthApiError } from '@modrinth/api-client'
import { useServerModrinthClient } from '~/server/utils/api-client'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({
statusCode: 400,
message: 'Missing organization',
})
}
const client = useServerModrinthClient({ event })
let organization: Labrinth.Organizations.v3.Organization
try {
organization = await client.labrinth.organizations_v3.get(id)
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 404) {
throw createError({
statusCode: 404,
message: 'Organization not found',
})
}
throw createError({
statusCode: 502,
message: 'Failed to resolve organization avatar',
})
}
if (!organization.icon_url) {
throw createError({
statusCode: 404,
message: 'Organization avatar not found',
})
}
setHeader(event, 'cache-control', 'public, max-age=300, s-maxage=600')
return sendRedirect(event, organization.icon_url, 302)
})
@@ -0,0 +1,43 @@
import { type Labrinth, ModrinthApiError } from '@modrinth/api-client'
import { useServerModrinthClient } from '~/server/utils/api-client'
export default defineEventHandler(async (event) => {
const username = getRouterParam(event, 'username')
if (!username) {
throw createError({
statusCode: 400,
message: 'Missing username',
})
}
const client = useServerModrinthClient({ event })
let user: Labrinth.Users.v2.User
try {
user = await client.labrinth.users_v2.get(username)
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 404) {
throw createError({
statusCode: 404,
message: 'User not found',
})
}
throw createError({
statusCode: 502,
message: 'Failed to resolve user avatar',
})
}
if (!user.avatar_url) {
throw createError({
statusCode: 404,
message: 'User avatar not found',
})
}
setHeader(event, 'cache-control', 'public, max-age=300, s-maxage=600')
return sendRedirect(event, user.avatar_url, 302)
})
+9
View File
@@ -8,6 +8,15 @@ export function queryAsString(query: LocationQueryValue | LocationQueryValue[]):
return Array.isArray(query) ? (query[0] ?? null) : (query ?? null)
}
export function queryAsStringArray(
query: LocationQueryValue | LocationQueryValue[] | undefined,
): string[] {
if (query === undefined || query === null) {
return []
}
return Array.isArray(query) ? query.map(String) : [String(query)]
}
export function routeNameAsString(name: RouteRecordNameGeneric | undefined): string | undefined {
return name && typeof name === 'string' ? (name as string) : undefined
}
+2
View File
@@ -54,6 +54,8 @@ pub enum DownloadReason {
Dependency,
/// Project was downloaded as part of a modpack.
Modpack,
/// Project was re-downloaded due to an update.
Update,
}
impl std::str::FromStr for DownloadReason {
+13 -1
View File
@@ -760,7 +760,19 @@ pub async fn edit_team_member(
None
};
if organization_team_member
let edited_member_organization_team_member =
if let Some(organization) = &organization {
DBTeamMember::get_from_user_id(
organization.team_id,
user_id,
&**pool,
)
.await?
} else {
None
};
if edited_member_organization_team_member
.as_ref()
.is_some_and(|x| x.is_owner)
&& edit_member
+5 -7
View File
@@ -28,7 +28,7 @@ import { AuthFeature, GenericModrinthClient, type Labrinth } from '@modrinth/api
const client = new GenericModrinthClient({
userAgent: 'my-app/1.0.0',
features: [new AuthFeature({ token: 'mrp_...' })],
features: [new AuthFeature({ token: process.env.MODRINTH_TOKEN })],
})
const project: Labrinth.Projects.v2.Project = await client.labrinth.projects_v2.get('sodium')
@@ -51,14 +51,13 @@ import { AuthFeature, CircuitBreakerFeature, NuxtCircuitBreakerStorage, NuxtModr
export const useModrinthClient = async () => {
const config = useRuntimeConfig()
const auth = await useAuth()
return new NuxtModrinthClient({
userAgent: 'my-nuxt-app/1.0.0',
rateLimitKey: import.meta.server ? config.rateLimitKey : undefined,
features: [
new AuthFeature({
token: async () => auth.value.token,
token: process.env.MODRINTH_TOKEN,
}),
new CircuitBreakerFeature({
storage: new NuxtCircuitBreakerStorage(),
@@ -74,10 +73,9 @@ export const useModrinthClient = async () => {
import { getVersion } from '@tauri-apps/api/app'
import { AuthFeature, TauriModrinthClient } from '@modrinth/api-client'
const version = await getVersion()
const client = new TauriModrinthClient({
userAgent: `modrinth/theseus/${version} (support@modrinth.com)`,
features: [new AuthFeature({ token: 'mrp_...' })],
userAgent: async () => `modrinth/theseus/${await getVersion()} (support@modrinth.com)`,
features: [new AuthFeature({ token: process.env.MODRINTH_TOKEN })],
})
const project = await client.labrinth.projects_v2.get('sodium')
@@ -138,7 +136,7 @@ Features wrap requests before they reach the platform implementation:
import { AuthFeature, CircuitBreakerFeature, RetryFeature } from '@modrinth/api-client'
const client = new GenericModrinthClient({
features: [new AuthFeature({ token: async () => getToken() }), new RetryFeature({ maxAttempts: 3, backoffStrategy: 'exponential' }), new CircuitBreakerFeature({ maxFailures: 3, resetTimeout: 30_000 })],
features: [new AuthFeature({ token: async () => process.env.MODRINTH_TOKEN }), new RetryFeature({ maxAttempts: 3, backoffStrategy: 'exponential' }), new CircuitBreakerFeature({ maxFailures: 3, resetTimeout: 30_000 })],
})
```
@@ -125,13 +125,15 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
const url = this.buildUrl(path, baseUrl, options.version)
const defaultHeaders = await this.buildDefaultHeaders()
// Merge options with defaults
const mergedOptions: RequestOptions = {
method: 'GET',
timeout: this.config.timeout,
...options,
headers: {
...this.buildDefaultHeaders(),
...defaultHeaders,
...options.headers,
},
}
@@ -306,19 +308,25 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
* Subclasses can override this to add platform-specific headers
* (e.g., Nuxt rate limit key)
*/
protected buildDefaultHeaders(): Record<string, string> {
protected async buildDefaultHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...this.config.headers,
}
if (this.config.userAgent) {
headers['User-Agent'] = this.config.userAgent
const userAgent = await this.resolveUserAgent()
if (userAgent) {
headers['User-Agent'] = userAgent
}
return headers
}
private async resolveUserAgent(): Promise<string | undefined> {
const userAgent = this.config.userAgent
return typeof userAgent === 'function' ? await userAgent() : userAgent
}
protected attachArchonSentryCaptureHeader(options: RequestOptions): void {
if (options.api !== 'archon' || !options.headers || !this.shouldCaptureArchonRequests()) {
return
@@ -404,7 +412,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
* @example
* ```typescript
* const client = new GenericModrinthClient()
* client.addFeature(new AuthFeature({ token: 'mrp_...' }))
* client.addFeature(new AuthFeature({ token: async () => getOAuthToken() }))
* client.addFeature(new RetryFeature({ maxAttempts: 3 }))
* ```
*/
+1 -7
View File
@@ -33,14 +33,8 @@ export interface AuthConfig extends FeatureConfig {
*
* @example
* ```typescript
* // Static token
* const auth = new AuthFeature({
* token: 'mrp_...'
* })
*
* // Dynamic token (e.g., from auth state)
* const auth = new AuthFeature({
* token: async () => await getAuthToken()
* token: async () => process.env.MODRINTH_TOKEN
* })
* ```
*/
@@ -49,6 +49,20 @@ export class ArchonContentV1Module extends AbstractModule {
})
}
/** POST /v1/:server_id/worlds/:world_id/addons/install-many */
public async addAddons(
serverId: string,
worldId: string,
addons: Archon.Content.v1.AddAddonRequest[],
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/addons/install-many`, {
api: 'archon',
version: 1,
method: 'POST',
body: { addons } satisfies Archon.Content.v1.AddAddonsRequest,
})
}
/** POST /v1/:server_id/worlds/:world_id/addons/delete */
public async deleteAddon(
serverId: string,
@@ -51,6 +51,10 @@ export namespace Archon {
kind?: AddonKind
}
export type AddAddonsRequest = {
addons: AddAddonRequest[]
}
export type RemoveAddonRequest = {
kind: AddonKind
filename: string
+1 -1
View File
@@ -16,7 +16,7 @@ import { XHRUploadClient } from './xhr-upload-client'
* const client = new GenericModrinthClient({
* userAgent: 'my-app/1.0.0',
* features: [
* new AuthFeature({ token: 'mrp_...' }),
* new AuthFeature({ token: async () => getOAuthToken() }),
* new RetryFeature({ maxAttempts: 3 })
* ]
* })
+8 -4
View File
@@ -66,13 +66,17 @@ export interface NuxtClientConfig extends ClientConfig {
* ```typescript
* // In a Nuxt composable
* const config = useRuntimeConfig()
* const auth = await useAuth()
*
* const client = new NuxtModrinthClient({
* userAgent: 'my-nuxt-app/1.0.0',
* rateLimitKey: import.meta.server ? config.rateLimitKey : undefined,
* features: [
* new AuthFeature({ token: () => auth.value.token })
* new AuthFeature({
* token: async () => getOAuthToken()
* }),
* new CircuitBreakerFeature({
* storage: new NuxtCircuitBreakerStorage()
* })
* ]
* })
*
@@ -171,9 +175,9 @@ export class NuxtModrinthClient extends XHRUploadClient {
return super.normalizeError(error)
}
protected buildDefaultHeaders(): Record<string, string> {
protected async buildDefaultHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = {
...super.buildDefaultHeaders(),
...(await super.buildDefaultHeaders()),
}
// Use the resolved key (populated by resolveRateLimitKey in request())
+2 -3
View File
@@ -27,11 +27,10 @@ interface HttpError extends Error {
* ```typescript
* import { getVersion } from '@tauri-apps/api/app'
*
* const version = await getVersion()
* const client = new TauriModrinthClient({
* userAgent: `modrinth/theseus/${version} (support@modrinth.com)`,
* userAgent: async () => `modrinth/theseus/${await getVersion()} (support@modrinth.com)`,
* features: [
* new AuthFeature({ token: 'mrp_...' })
* new AuthFeature({ token: async () => getOAuthToken() })
* ]
* })
*
@@ -27,50 +27,57 @@ export abstract class XHRUploadClient extends AbstractModrinthClient {
const url = this.buildUrl(path, baseUrl, options.version)
// For FormData uploads, don't set Content-Type (let browser set multipart boundary)
// For file uploads, use application/octet-stream
const isFormData = 'formData' in options && options.formData instanceof FormData
const baseHeaders = this.buildDefaultHeaders()
// Remove Content-Type for FormData so browser can set multipart/form-data with boundary
if (isFormData) {
delete baseHeaders['Content-Type']
} else {
baseHeaders['Content-Type'] = 'application/octet-stream'
}
const mergedOptions: UploadRequestOptions = {
retry: false, // default: don't retry uploads
...options,
headers: {
...baseHeaders,
...options.headers,
},
}
this.attachArchonSentryCaptureHeader(mergedOptions)
const context = this.buildUploadContext(url, path, mergedOptions)
const progressCallbacks: Array<(p: UploadProgress) => void> = []
if (mergedOptions.onProgress) {
progressCallbacks.push(mergedOptions.onProgress)
if (options.onProgress) {
progressCallbacks.push(options.onProgress)
}
const abortController = new AbortController()
if (mergedOptions.signal) {
mergedOptions.signal.addEventListener('abort', () => abortController.abort())
if (options.signal) {
options.signal.addEventListener('abort', () => abortController.abort())
}
let context: RequestContext | undefined
const handle: UploadHandle<T> = {
promise: this.executeUploadFeatureChain<T>(context, progressCallbacks, abortController)
.then(async (result) => {
await this.config.hooks?.onResponse?.(result, context)
return result
})
.catch(async (error) => {
const apiError = this.normalizeError(error, context)
promise: (async () => {
const isFormData = 'formData' in options && options.formData instanceof FormData
const baseHeaders = await this.buildDefaultHeaders()
if (isFormData) {
delete baseHeaders['Content-Type']
} else {
baseHeaders['Content-Type'] = 'application/octet-stream'
}
const mergedOptions: UploadRequestOptions = {
retry: false,
...options,
headers: {
...baseHeaders,
...options.headers,
},
}
this.attachArchonSentryCaptureHeader(mergedOptions)
const uploadContext = this.buildUploadContext(url, path, mergedOptions)
context = uploadContext
if (abortController.signal.aborted) {
throw new ModrinthApiError('Upload cancelled')
}
const result = await this.executeUploadFeatureChain<T>(
uploadContext,
progressCallbacks,
abortController,
)
await this.config.hooks?.onResponse?.(result, uploadContext)
return result
})().catch(async (error) => {
const apiError = this.normalizeError(error, context)
if (context) {
await this.config.hooks?.onError?.(apiError, context)
throw apiError
}),
}
throw apiError
}),
onProgress: (callback) => {
progressCallbacks.push(callback)
return handle
+5 -2
View File
@@ -1,6 +1,9 @@
import type { AbstractFeature } from '../core/abstract-feature'
import type { RequestContext } from './request'
export type MaybePromise<T> = T | Promise<T>
export type UserAgentProvider = string | (() => MaybePromise<string | undefined>)
/**
* Request lifecycle hooks
*/
@@ -26,11 +29,11 @@ export type RequestHooks = {
*/
export interface ClientConfig {
/**
* User agent string for requests
* User agent string or provider for requests
* Should identify your application (e.g., 'my-app/1.0.0')
* If not provided, the platform's default user agent will be used
*/
userAgent?: string
userAgent?: UserAgentProvider
/**
* Base URL for Labrinth API (main Modrinth API)
@@ -218,9 +218,8 @@ pub async fn generate_pack_from_version_id(
icon_url: Option<String>,
profile_path: String,
// Existing loading bar. Unlike when existing_loading_bar is used, this one is pre-initialized with PackFileDownload
// For example, you might use this if multiple packs are being downloaded at once and you want to use the same loading bar
initialized_loading_bar: Option<LoadingBarId>,
reason: DownloadReason,
) -> crate::Result<CreatePack> {
let state = State::get().await?;
let has_icon_url = icon_url.is_some();
@@ -301,7 +300,7 @@ pub async fn generate_pack_from_version_id(
})?;
let download_meta = DownloadMeta {
reason: DownloadReason::Modpack,
reason,
game_version: profile.game_version.clone(),
loader: profile.loader.as_str().to_string(),
};
@@ -48,6 +48,7 @@ pub async fn install_zipped_mrpack(
icon_url,
profile_path.clone(),
None,
DownloadReason::Modpack,
)
.await?
}
@@ -57,7 +58,12 @@ pub async fn install_zipped_mrpack(
};
// Install pack files, and if it fails, fail safely by removing the profile
let result = install_zipped_mrpack_files(create_pack, false).await;
let result = install_zipped_mrpack_files(
create_pack,
false,
DownloadReason::Modpack,
)
.await;
match result {
Ok(profile) => Ok(profile),
@@ -74,6 +80,7 @@ pub async fn install_zipped_mrpack(
pub async fn install_zipped_mrpack_files(
create_pack: CreatePack,
ignore_lock: bool,
reason: DownloadReason,
) -> crate::Result<String> {
let state = &State::get().await?;
@@ -221,7 +228,7 @@ pub async fn install_zipped_mrpack_files(
})?;
let download_meta = DownloadMeta {
reason: DownloadReason::Modpack,
reason,
game_version: profile.game_version.clone(),
loader: profile.loader.as_str().to_string(),
};
+1 -1
View File
@@ -461,7 +461,7 @@ pub async fn update_project(
let mut path = Profile::add_project_version(
profile_path,
update_version,
fetch::DownloadReason::Standalone,
fetch::DownloadReason::Update,
&state.pool,
&state.fetch_semaphore,
&state.io_semaphore,
+7 -2
View File
@@ -1,4 +1,5 @@
use crate::state::CacheBehaviour;
use crate::util::fetch::DownloadReason;
use crate::{
LoadingBarType,
event::{
@@ -162,7 +163,8 @@ async fn replace_managed_modrinth(
profile.name.clone(),
None,
profile_path.to_string(),
Some(shared_loading_bar.clone())
Some(shared_loading_bar.clone()),
DownloadReason::Update,
),
generate_pack_from_version_id(
project_id.clone(),
@@ -170,7 +172,8 @@ async fn replace_managed_modrinth(
profile.name.clone(),
None,
profile_path.to_string(),
Some(shared_loading_bar)
Some(shared_loading_bar),
DownloadReason::Update,
)
)?
} else {
@@ -182,6 +185,7 @@ async fn replace_managed_modrinth(
None,
profile_path.to_string(),
None,
DownloadReason::Update,
)
.await?;
old_pack_creator.description.existing_loading_bar = None;
@@ -205,6 +209,7 @@ async fn replace_managed_modrinth(
pack::install_mrpack::install_zipped_mrpack_files(
new_pack_creator,
ignore_lock,
DownloadReason::Update,
)
.await?;
+24 -4
View File
@@ -417,6 +417,25 @@ struct InitialScanFile {
cache_key: String,
}
fn is_scannable_project_file(
project_type: ProjectType,
file_name: &str,
) -> bool {
let Some(extension) = Path::new(file_name.trim_end_matches(".disabled"))
.extension()
.and_then(|ext| ext.to_str())
else {
return false;
};
match project_type {
ProjectType::Mod => extension.eq_ignore_ascii_case("jar"),
ProjectType::DataPack
| ProjectType::ResourcePack
| ProjectType::ShaderPack => extension.eq_ignore_ascii_case("zip"),
}
}
impl Profile {
pub async fn get(
path: &str,
@@ -648,8 +667,10 @@ impl Profile {
&& let Some(file_name) = subdirectory
.file_name()
.and_then(|x| x.to_str())
&& !(project_type == ProjectType::ShaderPack
&& file_name.ends_with(".txt"))
&& is_scannable_project_file(
project_type,
file_name,
)
{
let file_size = subdirectory
.metadata()
@@ -1052,8 +1073,7 @@ impl Profile {
if subdirectory.is_file()
&& let Some(file_name) =
subdirectory.file_name().and_then(|x| x.to_str())
&& !(project_type == ProjectType::ShaderPack
&& file_name.ends_with(".txt"))
&& is_scannable_project_file(project_type, file_name)
{
let file_size = subdirectory
.metadata()
+1
View File
@@ -27,6 +27,7 @@ pub enum DownloadReason {
Standalone,
Dependency,
Modpack,
Update,
}
#[derive(Debug, Clone, Serialize)]
+2 -2
View File
@@ -3,6 +3,8 @@
import type { FunctionalComponent, SVGAttributes } from 'vue'
export type IconComponent = FunctionalComponent<SVGAttributes>
import _AffiliateIcon from './icons/affiliate.svg?component'
import _AlignLeftIcon from './icons/align-left.svg?component'
import _ArchiveIcon from './icons/archive.svg?component'
@@ -395,8 +397,6 @@ import _XCircleIcon from './icons/x-circle.svg?component'
import _ZoomInIcon from './icons/zoom-in.svg?component'
import _ZoomOutIcon from './icons/zoom-out.svg?component'
export type IconComponent = FunctionalComponent<SVGAttributes>
export const AffiliateIcon = _AffiliateIcon
export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon
+60
View File
@@ -10,6 +10,66 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-05-09T19:06:18+00:00`,
product: 'app',
version: '0.13.13',
body: `## Changed
- Improved the Browse page header so the back button no longer shifts the layout.
## Fixed
- Fixed instance redirects opening a broken page state.`,
},
{
date: `2026-05-09T19:06:18+00:00`,
product: 'web',
body: `## Changed
- Improved performance on search pages.`,
},
{
date: `2026-05-09T19:06:18+00:00`,
product: 'hosting',
body: `## Changed
- Updated Modrinth Hosting content browsing so multiple projects can be selected before installation.
- After installing selected content, the Modrinth Hosting content page now reopens immediately and shows pending projects as installing.
- Project icons now appear in the action bar when selecting multiple projects in the Content tab.
## Fixed
- Fixed selected content and dependencies not staying marked as installing.
- Fixed page shifts while dependencies resolve during installation.`,
},
{
date: `2026-05-08T09:58:39+00:00`,
product: 'web',
body: `## Fixed
- Fixed some buttons appearing as disabled even when they weren't, such as the project icon settings.`,
},
{
date: `2026-05-08T02:24:09+00:00`,
product: 'app',
version: '0.13.12',
body: `## Changed
- Updated the modpack exporting experience, fixing issues with it and excluding /mods/.connector from the exports.
## Fixed
- Fixed page width changing based on if there is scrollable content or not, causing things to move when you switch tabs between scrollable and non-scrollable content.`,
},
{
date: `2026-05-08T02:24:09+00:00`,
product: 'hosting',
body: `## Fixed
- Fixed failed \`mrpack\` uploads when uploading via the Modrinth App.
- Fixed support bubble being broken when the console is in full screen/expand mode.`,
},
{
date: `2026-05-08T02:24:09+00:00`,
product: 'web',
body: `## Changed
- Project pages now have canonical permalink URLs to hopefully optimize SEO a bit.
## Fixed
- Fixed error around editing org member permissions.`,
},
{
date: `2026-05-06T22:11:04+00:00`,
product: 'app',
+1
View File
@@ -76,6 +76,7 @@
"dayjs": "^1.11.10",
"dompurify": "^3.1.7",
"es-toolkit": "^1.44.0",
"flatpickr": "^4.6.13",
"floating-vue": "^5.2.2",
"fuse.js": "^6.6.2",
"highlight.js": "^11.9.0",
@@ -30,9 +30,10 @@
</template>
<script setup lang="ts">
import { formatBytes } from '@modrinth/utils'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useFormatBytes } from '#ui/composables'
interface Props {
maxValue: number
currentValue: number
@@ -59,6 +60,8 @@ const props = withDefaults(defineProps<Props>(), {
],
})
const formatBytes = useFormatBytes()
const currentPhrase = ref('')
const usedPhrases = ref(new Set<number>())
let phraseInterval: NodeJS.Timeout | null = null
+4 -4
View File
@@ -15,7 +15,7 @@
<!-- Project statuses -->
<template v-else-if="type === 'approved'">
<ListIcon aria-hidden="true" /> {{ formatMessage(messages.listedLabel) }}
<GlobeIcon aria-hidden="true" /> {{ formatMessage(messages.listedLabel) }}
</template>
<template v-else-if="type === 'approved-general'">
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.approvedLabel) }}
@@ -91,7 +91,7 @@ import {
CheckIcon,
EyeOffIcon,
FileTextIcon,
ListIcon,
GlobeIcon,
LockIcon,
ModrinthIcon,
ScaleIcon,
@@ -134,7 +134,7 @@ const messages = defineMessages({
},
listedLabel: {
id: 'omorphia.component.badge.label.listed',
defaultMessage: 'Listed',
defaultMessage: 'Public',
},
moderatorLabel: {
id: 'omorphia.component.badge.label.moderator',
@@ -186,7 +186,7 @@ const messages = defineMessages({
},
withheldLabel: {
id: 'omorphia.component.badge.label.withheld',
defaultMessage: 'Withheld',
defaultMessage: 'Unlisted by staff',
},
})
const { formatMessage } = useVIntl()
@@ -277,20 +277,20 @@ const fontSize = computed(() => {
flex-shrink: 0;
}
&[disabled],
&[disabled]:not([disabled='false']),
&[disabled='true'],
&.disabled,
&.looks-disabled {
@apply opacity-50;
}
&[disabled],
&[disabled]:not([disabled='false']),
&[disabled='true'],
&.disabled {
@apply cursor-not-allowed;
}
&:not([disabled]):not([disabled='true']):not(.disabled) {
&:not([disabled]:not([disabled='false'])):not([disabled='true']):not(.disabled) {
@apply hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
&:hover svg:first-child,
@@ -309,7 +309,7 @@ const fontSize = computed(() => {
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
&:not([disabled]):not([disabled='true']):not(.disabled) {
&:not([disabled]:not([disabled='false'])):not([disabled='true']):not(.disabled) {
@apply active:scale-95;
}
}
+5 -6
View File
@@ -14,12 +14,11 @@
>
<span
class="w-5 h-5 rounded-md flex items-center justify-center border-[1px] border-solid"
:class="
(modelValue
? 'bg-brand border-button-border text-brand-inverted'
: 'bg-surface-2 border-surface-5') +
(disabled ? '' : ' checkbox-shadow group-active:scale-95')
"
:class="{
'bg-brand border-button-border text-brand-inverted': modelValue,
'bg-surface-2 border-surface-5 text-primary': !modelValue,
'checkbox-shadow group-active:scale-95': !disabled,
}"
>
<MinusIcon v-if="indeterminate" aria-hidden="true" stroke-width="3" />
<CheckIcon v-else-if="modelValue" aria-hidden="true" stroke-width="3" />
File diff suppressed because it is too large Load Diff
@@ -50,6 +50,7 @@ import { FolderUpIcon } from '@modrinth/assets'
import { fileIsValid } from '@modrinth/utils'
import { ref } from 'vue'
import { useFormatBytes } from '../../composables'
import { injectNotificationManager } from '../../providers'
const { addNotification } = injectNotificationManager()
@@ -78,6 +79,8 @@ const props = withDefaults(
},
)
const formatBytes = useFormatBytes()
const files = ref<File[]>([])
function matchesAccept(file: File, accept?: string): boolean {
@@ -129,7 +132,7 @@ function addFiles(incoming: FileList, shouldNotReset = false) {
alertOnInvalid: true,
}
files.value = files.value.filter((file) => fileIsValid(file, validationOptions))
files.value = files.value.filter((file) => fileIsValid(file, validationOptions, formatBytes))
if (files.value.length > 0) {
emit('change', files.value)
+50 -61
View File
@@ -12,73 +12,62 @@
</label>
</template>
<script>
<script setup lang="ts">
import { fileIsValid } from '@modrinth/utils'
import { defineComponent } from 'vue'
import { ref } from 'vue'
export default defineComponent({
props: {
prompt: {
type: String,
default: 'Select file',
},
multiple: {
type: Boolean,
default: false,
},
accept: {
type: String,
default: null,
},
import { useFormatBytes } from '../../composables'
const props = withDefaults(
defineProps<{
prompt?: string
multiple?: boolean
accept?: string
/**
* The max file size in bytes
*/
maxSize: {
type: Number,
default: null,
},
showIcon: {
type: Boolean,
default: true,
},
shouldAlwaysReset: {
type: Boolean,
default: false,
},
longStyle: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
maxSize?: number | null
showIcon?: boolean
shouldAlwaysReset?: boolean
longStyle?: boolean
disabled?: boolean
}>(),
{
prompt: 'Select file',
multiple: false,
showIcon: true,
shouldAlwaysReset: false,
longStyle: false,
disabled: false,
},
emits: ['change'],
data() {
return {
files: [],
}
},
methods: {
addFiles(files, shouldNotReset) {
if (!shouldNotReset || this.shouldAlwaysReset) {
this.files = files
}
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true }
this.files = [...this.files].filter((file) => fileIsValid(file, validationOptions))
if (this.files.length > 0) {
this.$emit('change', this.files)
}
},
handleDrop(e) {
this.addFiles(e.dataTransfer.files)
},
handleChange(e) {
this.addFiles(e.target.files)
},
},
})
)
const emit = defineEmits<{ change: [files: File[]] }>()
const formatBytes = useFormatBytes()
const files = ref<File[]>([])
function addFiles(incoming: FileList, shouldNotReset = false) {
if (!shouldNotReset || props.shouldAlwaysReset) {
files.value = Array.from(incoming)
}
const validationOptions = { maxSize: props.maxSize, alertOnInvalid: true }
files.value = files.value.filter((file) => fileIsValid(file, validationOptions, formatBytes))
if (files.value.length > 0) {
emit('change', files.value)
}
}
function handleDrop(e: DragEvent) {
addFiles(e.dataTransfer!.files)
}
function handleChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
addFiles(input.files)
}
</script>
<style lang="scss" scoped>
@@ -8,6 +8,7 @@ const props = defineProps<{
shown: boolean
ariaLabel?: string
belowModal?: boolean
hideWhenModalOpen?: boolean
}>()
const INTERCOM_BUBBLE_GAP = 8
@@ -18,7 +19,7 @@ const compact = ref(false)
const { stackCount } = useModalStack()
const pageContext = injectPageContext(null)
const shown = computed(() => props.shown)
const shown = computed(() => props.shown && (!props.hideWhenModalOpen || stackCount.value === 0))
const intercomBubbleClearanceRequestId = Symbol('floating-action-bar')
const zIndex = computed(() => 100 + stackCount.value * 10 + 8 + (!props.belowModal ? 1 : 0))
const leftOffset = computed(
@@ -82,11 +83,11 @@ function updateIntercomBubbleClearance() {
)
}
function updateBodyState(shown = props.shown) {
function updateBodyState(isShown = shown.value) {
if (typeof document === 'undefined') return
document.body.classList.toggle('floating-action-bar-shown', shown)
if (!shown) {
document.body.classList.toggle('floating-action-bar-shown', isShown)
if (!isShown) {
clearIntercomBubbleClearance()
}
}
@@ -123,10 +124,10 @@ watch(
)
watch(
() => props.shown,
async (shown) => {
shown,
async (isShown) => {
await nextTick()
updateBodyState(shown)
updateBodyState(isShown)
scheduleIntercomBubbleClearanceUpdate()
},
{ immediate: true },
@@ -175,7 +176,7 @@ onUnmounted(() => {
ref="toolbarEl"
role="toolbar"
:aria-label="ariaLabel"
class="relative overflow-clip flex items-center gap-2 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto max-w-[60vw] px-4 py-3 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
class="relative overflow-clip flex items-center gap-1.5 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto max-w-[60vw] px-3 py-2.5 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
:class="{ 'bar-compact': compact }"
>
<slot />
+111 -29
View File
@@ -1,5 +1,11 @@
<template>
<div class="overflow-hidden rounded-2xl border border-solid border-surface-5">
<div
v-if="hasHeaderSlot"
class="border-solid border-0 border-b border-surface-5 bg-surface-3 p-4"
>
<slot name="header" />
</div>
<table class="w-full table-fixed border-separate border-spacing-0 border-surface-5">
<thead class="">
<tr class="bg-surface-3">
@@ -44,37 +50,62 @@
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIndex) in data"
:key="rowIndex"
:class="rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
>
<td v-if="showSelection" class="w-10 border-solid border-0 border-t border-surface-5">
<Checkbox
:model-value="isSelected(row)"
class="shrink-0 p-4"
@update:model-value="toggleSelection(row)"
/>
</td>
<td
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
:class="`text-${column.align ?? 'left'}`"
:style="column.width ? { width: column.width } : undefined"
>
<slot
:name="`cell-${column.key}`"
:row="row"
:value="row[column.key]"
:column="column"
:index="rowIndex"
>
{{ row[column.key] ?? '' }}
<tbody :ref="setListContainer">
<tr v-if="data.length === 0" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<slot name="empty-state">
<div class="text-secondary flex h-64 items-center justify-center">
No data available.
</div>
</slot>
</td>
</tr>
<template v-else>
<tr v-if="virtualized && topSpacerHeight > 0" aria-hidden="true">
<td
:colspan="columnSpan"
class="border-0 p-0"
:style="{ height: `${topSpacerHeight}px` }"
></td>
</tr>
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
>
<td v-if="showSelection" class="w-10 border-solid border-0 border-t border-surface-5">
<Checkbox
:model-value="isSelected(row)"
class="shrink-0 p-4"
@update:model-value="toggleSelection(row)"
/>
</td>
<td
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
:class="`text-${column.align ?? 'left'}`"
:style="column.width ? { width: column.width } : undefined"
>
<slot
:name="`cell-${column.key}`"
:row="row"
:value="row[column.key]"
:column="column"
:index="getAbsoluteRowIndex(rowIndex)"
>
{{ row[column.key] ?? '' }}
</slot>
</td>
</tr>
<tr v-if="virtualized && bottomSpacerHeight > 0" aria-hidden="true">
<td
:colspan="columnSpan"
class="border-0 p-0"
:style="{ height: `${bottomSpacerHeight}px` }"
></td>
</tr>
</template>
</tbody>
</table>
</div>
@@ -86,8 +117,9 @@
generic="K extends string = string, T extends Record<string, unknown> = Record<K, unknown>"
>
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { computed, toRef, useSlots } from 'vue'
import { useVirtualScroll } from '../../composables/virtual-scroll'
import Checkbox from './Checkbox.vue'
export type TableColumnAlign = 'left' | 'center' | 'right'
@@ -115,16 +147,49 @@ const props = withDefaults(
data: T[] /* Row data for table */
showSelection?: boolean
rowKey?: keyof T /* The key used to uniquely identify each row */
virtualized?: boolean
virtualRowHeight?: number
virtualBufferSize?: number /* The number of extra rows rendered above and below the visible viewport */
}>(),
{
showSelection: false,
rowKey: 'id' as keyof T,
virtualized: false,
virtualRowHeight: 56,
virtualBufferSize: 5,
},
)
const selectedIds = defineModel<unknown[]>('selectedIds', { default: () => [] })
const sortColumn = defineModel<string | undefined>('sortColumn')
const sortDirection = defineModel<SortDirection>('sortDirection', { default: 'asc' })
const slots = useSlots()
const hasHeaderSlot = computed(() => Boolean(slots.header))
const columnSpan = computed(() => Math.max(props.columns.length + (props.showSelection ? 1 : 0), 1))
const {
listContainer,
totalHeight,
visibleRange,
visibleTop: topSpacerHeight,
visibleItems,
} = useVirtualScroll(toRef(props, 'data'), {
itemHeight: props.virtualRowHeight,
bufferSize: props.virtualBufferSize,
enabled: toRef(props, 'virtualized'),
})
const renderedRows = computed(() => (props.virtualized ? visibleItems.value : props.data))
const bottomSpacerHeight = computed(() => {
if (!props.virtualized) {
return 0
}
return Math.max(
0,
totalHeight.value - topSpacerHeight.value - renderedRows.value.length * props.virtualRowHeight,
)
})
const emit = defineEmits<{
sort: [column: string, direction: SortDirection]
@@ -141,6 +206,23 @@ function getRowId(row: T): unknown {
return row[props.rowKey as keyof T]
}
function setListContainer(element: unknown) {
listContainer.value = props.virtualized ? (element as HTMLElement | null) : null
}
function getAbsoluteRowIndex(rowIndex: number): number {
return props.virtualized ? visibleRange.value.start + rowIndex : rowIndex
}
function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
const rowId = getRowId(row)
if (typeof rowId === 'string' || typeof rowId === 'number' || typeof rowId === 'symbol') {
return rowId
}
return rowIndex
}
function isSelected(row: T): boolean {
return selectedIds.value.includes(getRowId(row))
}
+1
View File
@@ -21,6 +21,7 @@ export type { ComboboxOption } from './Combobox.vue'
export { default as Combobox } from './Combobox.vue'
export { default as ContentPageHeader } from './ContentPageHeader.vue'
export { default as CopyCode } from './CopyCode.vue'
export { default as DatePicker } from './DatePicker.vue'
export { default as DoubleIcon } from './DoubleIcon.vue'
export { default as DropArea } from './DropArea.vue'
export type { DropdownFilterBarCategory, DropdownFilterBarOption } from './DropdownFilterBar.vue'
@@ -268,16 +268,12 @@ import {
Pagination,
TagItem,
useCompactNumber,
useFormatBytes,
useFormatDateTime,
VersionChannelIndicator,
VersionFilterControl,
} from '@modrinth/ui'
import {
formatBytes,
formatVersionsForDisplay,
type GameVersionTag,
type Version,
} from '@modrinth/utils'
import { formatVersionsForDisplay, type GameVersionTag, type Version } from '@modrinth/utils'
import { Menu } from 'floating-vue'
import { computed, type Ref, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -293,6 +289,7 @@ const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatBytes = useFormatBytes()
type VersionWithDisplayUrlEnding = Version & {
displayUrlEnding: string
@@ -7,18 +7,13 @@
:waiting="isWaiting"
@dismiss="emit('dismiss')"
>
<template #icon>
<slot v-if="!contentError" name="icon">
<SpinnerIcon class="h-6 w-6 flex-none animate-spin text-brand-blue" />
</slot>
</template>
<template #header>
{{ contentError ? 'Installation failed' : "We're preparing your server" }}
{{ headerLabel }}
</template>
<template v-if="contentError">
{{ errorLabel }}
</template>
<template v-else-if="progress">{{ phaseLabel }}</template>
<template v-else-if="effectivePhase">{{ phaseLabel }}</template>
<div v-else class="ticker-container">
<div class="ticker-content">
<div
@@ -35,7 +30,7 @@
<ButtonStyled color="red" type="outlined">
<button class="!border" type="button" @click="emit('retry')">
<RotateCounterClockwiseIcon class="size-5" />
Retry
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
</template>
@@ -44,9 +39,11 @@
<script setup lang="ts">
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
import SpinnerIcon from '@modrinth/assets/icons/spinner.svg'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import Admonition from '../base/Admonition.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
@@ -62,6 +59,7 @@ export interface ContentError {
const props = defineProps<{
progress?: SyncProgress | null
fallbackPhase?: SyncProgress['phase'] | null
contentError?: ContentError | null
dismissible?: boolean
}>()
@@ -71,44 +69,123 @@ const emit = defineEmits<{
dismiss: []
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
errorHeader: {
id: 'servers.installing-banner.error.header',
defaultMessage: 'Installation failed',
},
preparingHeader: {
id: 'servers.installing-banner.preparing.header',
defaultMessage: "We're preparing your server",
},
invalidLoaderVersionError: {
id: 'servers.installing-banner.error.invalid-loader-version',
defaultMessage:
'The specified loader or Minecraft version could not be installed. It may be invalid or unsupported.',
},
unsupportedLoaderVersionError: {
id: 'servers.installing-banner.error.unsupported-loader-version',
defaultMessage: 'This version of Minecraft or loader is not yet supported by Modrinth Hosting.',
},
internalPlatformError: {
id: 'servers.installing-banner.error.internal-platform',
defaultMessage: 'An internal error occurred while installing the platform. Please try again.',
},
noPrimaryFileError: {
id: 'servers.installing-banner.error.no-primary-file',
defaultMessage:
'This modpack version does not include a downloadable file. It may have been packaged incorrectly.',
},
modpackInstallFailedError: {
id: 'servers.installing-banner.error.modpack-install-failed',
defaultMessage: 'The modpack could not be installed. It may be corrupted or incompatible.',
},
unknownError: {
id: 'servers.installing-banner.error.unknown',
defaultMessage: 'An unexpected error occurred during installation.',
},
installingPlatform: {
id: 'servers.installing-banner.phase.installing-platform',
defaultMessage: 'Installing platform...',
},
installingModpack: {
id: 'servers.installing-banner.phase.installing-modpack',
defaultMessage: 'Installing modpack...',
},
installingAddons: {
id: 'servers.installing-banner.phase.installing-addons',
defaultMessage: 'Installing addons...',
},
tickerOrganizingFiles: {
id: 'servers.installing-banner.ticker.organizing-files',
defaultMessage: 'Organizing files...',
},
tickerDownloadingMods: {
id: 'servers.installing-banner.ticker.downloading-mods',
defaultMessage: 'Downloading mods...',
},
tickerConfiguringServer: {
id: 'servers.installing-banner.ticker.configuring-server',
defaultMessage: 'Configuring server...',
},
tickerSettingUpEnvironment: {
id: 'servers.installing-banner.ticker.setting-up-environment',
defaultMessage: 'Setting up environment...',
},
tickerAddingJava: {
id: 'servers.installing-banner.ticker.adding-java',
defaultMessage: 'Adding Java...',
},
})
const errorLabel = computed(() => {
const desc = props.contentError?.description?.toLowerCase()
const step = props.contentError?.step
if (step === 'modloader') {
if (desc === 'the specified version may be incorrect') {
return 'The specified loader or Minecraft version could not be installed. It may be invalid or unsupported.'
return formatMessage(messages.invalidLoaderVersionError)
}
if (desc === 'this version is not yet supported') {
return 'This version of Minecraft or loader is not yet supported by Modrinth Hosting.'
return formatMessage(messages.unsupportedLoaderVersionError)
}
if (desc === 'internal error') {
return 'An internal error occurred while installing the platform. Please try again.'
return formatMessage(messages.internalPlatformError)
}
}
if (step === 'modpack') {
if (desc?.includes('no primary file')) {
return 'This modpack version does not include a downloadable file. It may have been packaged incorrectly.'
return formatMessage(messages.noPrimaryFileError)
}
if (desc?.includes('failed to install')) {
return 'The modpack could not be installed. It may be corrupted or incompatible.'
return formatMessage(messages.modpackInstallFailedError)
}
}
return props.contentError?.description ?? 'An unexpected error occurred during installation.'
return props.contentError?.description ?? formatMessage(messages.unknownError)
})
const effectivePhase = computed(() => props.progress?.phase ?? props.fallbackPhase ?? null)
const headerLabel = computed(() => {
if (props.contentError) return formatMessage(messages.errorHeader)
if (effectivePhase.value === 'Addons') return formatMessage(commonMessages.installingContentLabel)
return formatMessage(messages.preparingHeader)
})
const phaseLabel = computed(() => {
switch (props.progress?.phase) {
switch (effectivePhase.value) {
case 'InstallingLoader':
return 'Installing platform...'
return formatMessage(messages.installingPlatform)
case 'InstallingPack':
return 'Installing modpack...'
return formatMessage(messages.installingModpack)
case 'Addons':
return 'Installing addons...'
return formatMessage(messages.installingAddons)
default:
return 'Installing...'
return formatMessage(commonMessages.installingLabel)
}
})
@@ -122,13 +199,13 @@ const isWaiting = computed(() => {
return !props.progress || props.progress.percent <= 0
})
const tickerMessages = [
'Organizing files...',
'Downloading mods...',
'Configuring server...',
'Setting up environment...',
'Adding Java...',
]
const tickerMessages = computed(() => [
formatMessage(messages.tickerOrganizingFiles),
formatMessage(messages.tickerDownloadingMods),
formatMessage(messages.tickerConfiguringServer),
formatMessage(messages.tickerSettingUpEnvironment),
formatMessage(messages.tickerAddingJava),
])
const currentIndex = ref(0)
@@ -136,7 +213,7 @@ let intervalId: ReturnType<typeof setInterval> | null = null
onMounted(() => {
intervalId = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % tickerMessages.length
currentIndex.value = (currentIndex.value + 1) % tickerMessages.value.length
}, 3000)
})
@@ -15,7 +15,7 @@
<span>
{{
formatMessage(messages.extracted, {
size: 'bytes_processed' in op ? formatBytes(op.bytes_processed ?? 0) : '0 B',
size: formatBytes(op.bytes_processed ?? 0),
})
}}
</span>
@@ -35,11 +35,11 @@
<script setup lang="ts">
import { PackageOpenIcon } from '@modrinth/assets'
import { formatBytes } from '@modrinth/utils'
import { computed } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { useFormatBytes } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
import { injectModrinthServerContext } from '#ui/providers'
@@ -53,6 +53,7 @@ const props = defineProps<{
}>()
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const ctx = injectModrinthServerContext()
const messages = defineMessages({
@@ -6,7 +6,6 @@ import Admonition from '#ui/components/base/Admonition.vue'
import StackedAdmonitions, {
type StackedAdmonitionItem,
} from '#ui/components/base/StackedAdmonitions.vue'
import { ServerIcon } from '#ui/components/servers/icons'
import InstallingBanner, {
type ContentError,
type SyncProgress,
@@ -23,7 +22,6 @@ import UploadAdmonition from './UploadAdmonition.vue'
const props = defineProps<{
syncProgress?: SyncProgress | null
contentError?: ContentError | null
serverImage?: string
}>()
const emit = defineEmits<{
@@ -59,7 +57,13 @@ const isOnContentTab = computed(() => route.path.includes('/content'))
const isOnFilesTab = computed(() => route.path.includes('/files'))
const bannerCoversInstalling = computed(
() => ctx.server.value?.status === 'installing' || ctx.isSyncingContent.value,
() =>
ctx.server.value?.status === 'installing' ||
ctx.isSyncingContent.value ||
ctx.busyReasons.value.some(
(r) =>
r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
),
)
function isBackupReason(id: string) {
@@ -165,8 +169,7 @@ type ServerAdmonitionItem = StackedAdmonitionItem & {
const showInstallingBanner = computed(() => {
if (!ctx.server.value) return false
const installing =
ctx.server.value.status === 'installing' || ctx.isSyncingContent.value || !!props.contentError
const installing = bannerCoversInstalling.value || !!props.contentError
if (!installing) return false
if (contentErrorKey.value && dismissedContentErrorKey.value === contentErrorKey.value)
return false
@@ -366,15 +369,12 @@ function onContentErrorDismiss() {
<InstallingBanner
v-if="item.kind === 'installing'"
:progress="syncProgress"
:fallback-phase="isOnContentTab && !syncProgress ? 'Addons' : null"
:content-error="contentError"
:dismissible="dismissible && !!contentError"
@dismiss="onContentErrorDismiss"
@retry="emit('content-retry')"
>
<template #icon>
<ServerIcon :image="serverImage" class="!h-6 !w-6" />
</template>
</InstallingBanner>
/>
<UploadAdmonition v-else-if="item.kind === 'upload'" />
<FileOperationAdmonition
v-else-if="item.kind === 'fs-op'"
@@ -25,13 +25,15 @@
<script setup lang="ts">
import { UploadIcon } from '@modrinth/assets'
import { formatBytes } from '@modrinth/utils'
import { computed } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { useFormatBytes } from '#ui/composables'
import { injectModrinthServerContext } from '#ui/providers'
const formatBytes = useFormatBytes()
const ctx = injectModrinthServerContext()
const state = computed(() => ctx.uploadState.value)
@@ -12,10 +12,20 @@ export type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { serverId, server, powerState, busyReasons } = injectModrinthServerContext()
const { serverId, server, powerState, isSyncingContent, busyReasons } =
injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const isInstalling = computed(() => server.value.status === 'installing')
const isInstalling = computed(
() =>
server.value.status === 'installing' ||
isSyncingContent.value ||
busyReasons.value.some(
(r) =>
r.reason.id === 'servers.busy.installing' ||
r.reason.id === 'servers.busy.syncing-content',
),
)
const isRunning = computed(() => powerState.value === 'running')
const isStopping = computed(() => powerState.value === 'stopping')
const isStarting = computed(() => powerState.value === 'starting')
@@ -39,11 +39,13 @@ import { ButtonStyled, VersionChannelIndicator } from '../index'
const props = defineProps<{
version: Version
decorateDownloadUrl?: (url: string) => string
}>()
const downloadUrl = computed(() => {
const primary: VersionFile = props.version.files.find((x) => x.primary) || props.version.files[0]
return primary.url
const raw = primary.url
return props.decorateDownloadUrl ? props.decorateDownloadUrl(raw) : raw
})
const emit = defineEmits<{
@@ -0,0 +1,39 @@
import { defineMessage, useVIntl } from '#ui/composables/i18n.ts'
const messages = [
defineMessage({
id: 'format.bytes.0',
defaultMessage: '{count, plural, one {# byte} other {# bytes}}',
}),
defineMessage({
id: 'format.bytes.1',
defaultMessage: '{count, number} KiB',
}),
defineMessage({
id: 'format.bytes.2',
defaultMessage: '{count, number} MiB',
}),
defineMessage({
id: 'format.bytes.3',
defaultMessage: '{count, number} GiB',
}),
defineMessage({
id: 'format.bytes.4',
defaultMessage: '{count, number} TiB',
}),
]
export function useFormatBytes() {
const { formatMessage } = useVIntl()
function format(bytes: number, decimals = 2): string {
if (bytes === 0) return formatMessage(messages[0], { count: 0 })
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), messages.length - 1)
return formatMessage(messages[exponent], {
count: (bytes / Math.pow(1024, exponent)).toFixed(decimals),
})
}
return format
}
+6 -8
View File
@@ -1,6 +1,7 @@
import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
import { LOCALES } from './i18n.ts'
const formatterCache = new LRUCache<string, Intl.NumberFormat>({ max: 15 })
@@ -35,16 +36,13 @@ export function useCompactNumber() {
return twoDigitsCompactFormatter.format(value)
}
function formatCompactNumberPlural(value: number | bigint): string {
function formatCompactNumberPlural(value: number | bigint): number | bigint {
if (value < 10_000) {
return value.toString()
return value
}
if (value < 1_000_000) {
const oneDigitCompactFormatter = getCompactFormatter(locale.value, 1)
return oneDigitCompactFormatter.format(value)
}
const twoDigitsCompactFormatter = getCompactFormatter(locale.value, 2)
return twoDigitsCompactFormatter.format(value)
const currentLocale = locale.value
const localeDefinition = LOCALES.find((l) => l.code === currentLocale)
return localeDefinition?.compactNumberPlural ?? NaN
}
return { formatCompactNumber, formatCompactNumberPlural }
+2
View File
@@ -30,6 +30,7 @@ export interface LocaleDefinition {
name: string
translatedName: MessageDescriptor
numeric?: Intl.RelativeTimeFormatNumeric
compactNumberPlural?: number
dir?: 'ltr' | 'rtl'
serverLanguageCode?: string
}
@@ -92,6 +93,7 @@ export const LOCALES: LocaleDefinition[] = [
code: 'fil-PH',
name: 'Filipino',
translatedName: defineMessage({ id: 'locale.fil-PH', defaultMessage: 'Filipino' }),
compactNumberPlural: 1,
serverLanguageCode: 'tl',
},
{
+1
View File
@@ -1,5 +1,6 @@
export * from './debug-logger'
export * from './dynamic-font-size'
export * from './format-bytes'
export * from './format-date-time'
export * from './format-money'
export * from './format-number'
+12 -2
View File
@@ -4,13 +4,21 @@ import { computed, ref, watch, watchEffect } from 'vue'
export interface VirtualScrollOptions {
itemHeight: number
bufferSize?: number
initialItemCount?: number
enabled?: Ref<boolean>
onNearEnd?: () => void
nearEndThreshold?: number
}
export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptions) {
const { itemHeight, bufferSize = 5, enabled, onNearEnd, nearEndThreshold = 0.2 } = options
const {
itemHeight,
bufferSize = 5,
initialItemCount = 20,
enabled,
onNearEnd,
nearEndThreshold = 0.2,
} = options
const listContainer = ref<HTMLElement | null>(null)
const scrollContainer = ref<HTMLElement | Window | null>(null)
@@ -68,7 +76,9 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
return { start: 0, end: items.value.length }
}
if (!listContainer.value || !scrollContainer.value) return { start: 0, end: 0 }
if (!listContainer.value || !scrollContainer.value) {
return { start: 0, end: Math.min(items.value.length, initialItemCount) }
}
const relativeScrollTop = Math.max(0, scrollTop.value - containerOffset.value)
@@ -0,0 +1,137 @@
<template>
<FloatingActionBar
:shown="shown"
:aria-label="formatMessage(messages.ariaLabel)"
hide-when-modal-open
>
<div class="flex min-w-0 items-center gap-0.5">
<div
v-if="selectedCount > 0"
class="relative h-8 shrink-0"
:style="{ width: `${iconStackWidth}px` }"
aria-hidden="true"
>
<div
v-for="(project, index) in visibleProjects"
:key="project.id"
v-tooltip="project.name"
class="absolute top-0 flex h-8 w-8 items-center justify-center overflow-hidden rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4"
:style="{ left: `${index * iconStackOffset}px`, zIndex: visibleProjects.length - index }"
>
<Avatar
:src="project.iconUrl"
:alt="project.name"
:tint-by="project.id"
size="100%"
no-shadow
class="selected-project-avatar"
/>
</div>
<div
v-if="overflowCount > 0"
class="absolute top-0 flex h-8 w-8 items-center justify-center rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4 text-xs font-bold text-contrast"
:style="{ left: `${visibleProjects.length * iconStackOffset}px`, zIndex: 0 }"
>
+{{ overflowCount }}
</div>
</div>
<span class="px-3 py-2 text-base font-semibold text-contrast tabular-nums">
{{ selectedCountText }}
</span>
<div class="mx-0.5 h-6 w-px bg-surface-5" />
<ButtonStyled type="transparent">
<button
type="button"
class="!text-primary"
:disabled="isInstallingSelected"
@click="clearSelected"
>
<span>{{ formatMessage(commonMessages.clearButton) }}</span>
</button>
</ButtonStyled>
</div>
<div class="ml-auto shrink-0">
<ButtonStyled color="green">
<button type="button" :disabled="isInstallingSelected" @click="installSelected">
<PlusIcon />
{{ installButtonText }}
</button>
</ButtonStyled>
</div>
</FloatingActionBar>
</template>
<script setup lang="ts">
import { PlusIcon } from '@modrinth/assets'
import { computed } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import { injectBrowseManager } from '../providers/browse-manager'
import type { BrowseInstallContext } from '../types'
const { formatMessage } = useVIntl()
const messages = defineMessages({
ariaLabel: {
id: 'browse.selected-projects-floating-bar.aria-label',
defaultMessage: 'Selected projects',
},
selectedCount: {
id: 'browse.selected-projects-floating-bar.selected-count',
defaultMessage: '{count, plural, one {# project selected} other {# projects selected}}',
},
installButton: {
id: 'browse.selected-projects-floating-bar.install',
defaultMessage: 'Install {count, plural, one {# project} other {# projects}}',
},
})
const props = defineProps<{
installContext?: BrowseInstallContext | null
}>()
const ctx = injectBrowseManager(null)
const installContext = computed(() => props.installContext ?? ctx?.installContext?.value ?? null)
const selectedProjects = computed(() => installContext.value?.selectedProjects ?? [])
const selectedCount = computed(() => selectedProjects.value.length)
const iconStackOffset = 24
const isInstallingSelected = computed(() => installContext.value?.isInstallingSelected ?? false)
const shown = computed(() => selectedCount.value > 0 && !isInstallingSelected.value)
const visibleProjects = computed(() => selectedProjects.value.slice(0, 3))
const overflowCount = computed(() => Math.max(0, selectedCount.value - 3))
const iconStackWidth = computed(() => {
if (selectedCount.value === 0) return 0
return (
32 + (visibleProjects.value.length - 1 + (overflowCount.value > 0 ? 1 : 0)) * iconStackOffset
)
})
const selectedCountText = computed(() =>
formatMessage(messages.selectedCount, { count: selectedCount.value }),
)
const installButtonText = computed(() =>
formatMessage(messages.installButton, { count: selectedCount.value }),
)
function clearSelected() {
if (isInstallingSelected.value) return
void (installContext.value?.clearSelected ?? installContext.value?.clearQueued)?.()
}
function installSelected() {
if (isInstallingSelected.value) return
void installContext.value?.installSelected?.()
}
</script>
<style scoped>
:deep(.selected-project-avatar) {
background-color: var(--color-button-bg);
}
</style>
@@ -0,0 +1,87 @@
<template>
<NewModal ref="modal" fade="warning" :header="formatMessage(messages.header)" max-width="560px">
<div class="flex flex-col gap-6">
{{ formatMessage(messages.admonitionBody, { count }) }}
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="resolve('cancel')">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button :disabled="installing" @click="resolve('discard')">
<TrashIcon />
{{ formatMessage(messages.discardButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="green">
<button :disabled="installing" @click="resolve('install')">
<PlusIcon />
{{ formatMessage(commonMessages.installButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { PlusIcon, TrashIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'browse.selected-projects-leave-modal.header',
defaultMessage: 'Selected projects not installed yet',
},
admonitionHeader: {
id: 'browse.selected-projects-leave-modal.admonition-header',
defaultMessage: 'Selected projects not installed yet',
},
admonitionBody: {
id: 'browse.selected-projects-leave-modal.admonition-body',
defaultMessage:
'You have selected {count, plural, one {# project} other {# projects}} to install. Install them now or go back without installing them.',
},
discardButton: {
id: 'browse.selected-projects-leave-modal.discard',
defaultMessage: 'Discard',
},
})
defineProps<{
count: number
installing?: boolean
}>()
type SelectedProjectsLeaveResult = 'cancel' | 'discard' | 'install'
const modal = ref<InstanceType<typeof NewModal>>()
let resolvePromise: ((value: SelectedProjectsLeaveResult) => void) | null = null
function prompt(): Promise<SelectedProjectsLeaveResult> {
return new Promise((resolve) => {
resolvePromise = resolve
modal.value?.show()
})
}
function resolve(result: SelectedProjectsLeaveResult) {
modal.value?.hide()
resolvePromise?.(result)
resolvePromise = null
}
defineExpose({ prompt })
</script>
@@ -1 +1,2 @@
export * from './install-logic'
export * from './use-browse-search'
@@ -0,0 +1,540 @@
import type { Labrinth } from '@modrinth/api-client'
import type { FilterValue } from '#ui/utils/search'
export type BrowseInstallContentType = 'modpack' | 'mod' | 'plugin' | 'datapack'
export type BrowseInstallAddonContentType = Exclude<BrowseInstallContentType, 'modpack'>
/**
* Indicates why a concrete version was selected.
*
* `filtered` means the current browse filters resolved the version.
* `target` means filter resolution failed or matched the target exactly, so the server/instance target won.
*/
export type BrowseInstallPlanSource = 'filtered' | 'target'
/**
* Version constraints used during install resolution.
*
* Empty arrays and blank values are normalized away, so missing properties mean "do not constrain".
*/
export interface BrowseInstallPreferences {
gameVersions?: string[]
loaders?: string[]
}
/**
* Server or instance metadata that should be used as the fallback compatibility target.
*/
export interface BrowseInstallTarget {
gameVersion?: string | null
loader?: string | null
}
/**
* Minimal project shape needed by shared install resolution.
*/
export interface BrowseInstallProject {
project_id: string
latest_version?: string | null
version_id?: string | null
title?: string
name?: string
icon_url?: string | null
}
/**
* Fully resolved install work item.
*
* This is intentionally concrete so queued installs can be flushed later without re-resolving
* against filters that may have changed since the user clicked install.
*/
export interface BrowseInstallPlan<TProject extends BrowseInstallProject = BrowseInstallProject> {
project: TProject
projectId: string
versionId: string
versionName?: string
versionNumber?: string
fileName?: string
contentType: BrowseInstallContentType
preferences: BrowseInstallPreferences
source: BrowseInstallPlanSource
}
/**
* Small adapter around caller-owned queue state.
*
* Callers keep their own reactive storage; shared logic only replaces the whole map.
*/
export interface BrowseInstallQueue<TProject extends BrowseInstallProject = BrowseInstallProject> {
get: () => Map<string, BrowseInstallPlan<TProject>>
set: (plans: Map<string, BrowseInstallPlan<TProject>>) => void
}
/**
* Filter inputs for deriving selected install preferences.
*
* Provided filters come from a target context, and overridden filter types are ignored so user
* choices can replace the target-provided constraints.
*/
export interface SelectedInstallPreferencesOptions {
contentType: string
selectedFilters?: readonly FilterValue[]
providedFilters?: readonly FilterValue[]
overriddenProvidedFilterTypes?: readonly string[]
}
/**
* Inputs for resolving one concrete install plan.
*
* Version fetching is injected so this module stays platform-agnostic and can be used by both web
* and app frontends.
*/
export interface ResolveInstallPlanOptions<
TProject extends BrowseInstallProject,
> extends SelectedInstallPreferencesOptions {
project: TProject
contentType: BrowseInstallContentType
targetPreferences?: BrowseInstallPreferences
getProjectVersions: (projectId: string) => Promise<Labrinth.Versions.v2.Version[]>
}
/**
* Install request wrapper around plan resolution.
*
* Queue mode stores the resolved plan; immediate mode passes it to the caller's install handler.
*/
export interface RequestInstallOptions<
TProject extends BrowseInstallProject,
> extends ResolveInstallPlanOptions<TProject> {
mode: 'queue' | 'immediate'
queue?: BrowseInstallQueue<TProject>
install?: (plan: BrowseInstallPlan<TProject>) => void | Promise<void>
}
/**
* Inputs for committing queued plans without re-running version matching.
*/
export interface FlushInstallQueueOptions<TProject extends BrowseInstallProject> {
queue: BrowseInstallQueue<TProject>
install: (plan: BrowseInstallPlan<TProject>) => void | Promise<void>
onError?: (error: unknown, plan: BrowseInstallPlan<TProject>) => void
onProgress?: (
completed: number,
total: number,
plan: BrowseInstallPlan<TProject>,
) => void | Promise<void>
}
/**
* Result of a queue flush. Failed plans are also written back to the queue.
*/
export interface FlushInstallQueueResult<TProject extends BrowseInstallProject> {
ok: boolean
successfulPlans: BrowseInstallPlan<TProject>[]
failedPlans: Map<string, BrowseInstallPlan<TProject>>
}
interface InstallCandidate {
preferences: BrowseInstallPreferences
source: BrowseInstallPlanSource
}
/**
* Maps a project/content type to the browse filter keys that represent its loader.
*/
export function getLoaderFilterTypes(contentType: string) {
if (contentType === 'mod') return ['mod_loader']
if (contentType === 'plugin') return ['plugin_loader', 'plugin_platform']
if (contentType === 'modpack') return ['modpack_loader']
if (contentType === 'shader') return ['shader_loader']
return []
}
/**
* Merges user-selected filters with target-provided filters for install decisions.
*
* User filters win per filter type, provided filters are dropped when overridden, and negative
* filters are excluded because they are browse-only constraints.
*/
export function getEffectiveInstallFilters({
selectedFilters = [],
providedFilters = [],
overriddenProvidedFilterTypes = [],
}: Omit<SelectedInstallPreferencesOptions, 'contentType'>) {
const effectiveProvidedFilters = providedFilters.filter(
(providedFilter) => !overriddenProvidedFilterTypes.includes(providedFilter.type),
)
const userFilters = selectedFilters.filter(
(userFilter) =>
!effectiveProvidedFilters.some((providedFilter) => providedFilter.type === userFilter.type),
)
return [...userFilters, ...effectiveProvidedFilters].filter((filter) => !filter.negative)
}
/**
* Converts effective browse filters into install preferences for a specific content type.
*/
export function getInstallPreferencesFromFilters(
contentType: string,
filters: readonly FilterValue[],
): BrowseInstallPreferences {
const loaderFilterTypes = getLoaderFilterTypes(contentType)
const gameVersions = uniqueDefined(
filters.filter((filter) => filter.type === 'game_version').map((filter) => filter.option),
)
const loaders = uniqueDefined(
filters
.filter((filter) => loaderFilterTypes.includes(filter.type))
.map((filter) => filter.option),
)
return normalizeInstallPreferences({
gameVersions: gameVersions.length > 0 ? gameVersions : undefined,
loaders: loaders.length > 0 ? loaders : undefined,
})
}
/**
* Derives the preferences represented by the current browse selection plus active provided filters.
*/
export function getSelectedInstallPreferences(
options: SelectedInstallPreferencesOptions,
): BrowseInstallPreferences {
return getInstallPreferencesFromFilters(options.contentType, getEffectiveInstallFilters(options))
}
/**
* Converts server/instance metadata into fallback install preferences.
*/
export function getTargetInstallPreferences(
target: BrowseInstallTarget,
contentType?: string,
): BrowseInstallPreferences {
const gameVersion = target.gameVersion?.trim()
const loader = target.loader?.trim()
const shouldUseTargetRuntime = contentType !== 'modpack'
return normalizeInstallPreferences({
gameVersions: gameVersion && shouldUseTargetRuntime ? [gameVersion] : undefined,
loaders: loader && shouldUseTargetRuntime ? [loader] : undefined,
})
}
/**
* Normalizes loader identifiers so API and UI aliases compare consistently.
*/
export function normalizeLoaderAlias(loader: string) {
return loader.toLowerCase().replaceAll('_', '').replaceAll('-', '').replaceAll(' ', '')
}
/**
* Returns aliases that should be considered mutually compatible for install matching.
*/
export function getCompatibleLoaderAliases(loader: string) {
const normalized = normalizeLoaderAlias(loader)
if (!normalized) return new Set<string>()
if (['paper', 'purpur', 'spigot', 'bukkit'].includes(normalized)) {
return new Set(['paper', 'purpur', 'spigot', 'bukkit'])
}
if (normalized === 'neoforge' || normalized === 'neo') {
return new Set(['neoforge', 'neo'])
}
return new Set([normalized])
}
/**
* Checks whether selected filters conflict with the target constraints.
*/
export function preferencesDiffer(
selected: BrowseInstallPreferences,
target: BrowseInstallPreferences,
) {
return (
preferencesConflict(selected.gameVersions, target.gameVersions) ||
loaderPreferencesConflict(selected.loaders, target.loaders)
)
}
/**
* Fills missing selected preferences from the target.
*
* This preserves the user's explicit filter choices while still constraining unconstrained axes to
* the server/instance target.
*/
export function mergeInstallPreferences(
selected: BrowseInstallPreferences,
target: BrowseInstallPreferences,
): BrowseInstallPreferences {
return normalizeInstallPreferences({
gameVersions: selected.gameVersions?.length ? selected.gameVersions : target.gameVersions,
loaders: selected.loaders?.length ? selected.loaders : target.loaders,
})
}
/**
* Finds the newest version matching the given preferences.
*/
export function getLatestMatchingInstallVersion(
versions: readonly Labrinth.Versions.v2.Version[],
preferences: BrowseInstallPreferences,
contentType: string,
) {
return [...versions]
.filter((version) => versionMatchesPreferences(version, preferences, contentType))
.sort((a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime())[0]
}
/**
* Resolves the concrete version to install.
*
* The resolver tries the filtered plan first, with target values filling any missing axes. If that
* cannot resolve and differs from the target, it falls back to the target-only plan.
*/
export async function resolveInstallPlan<TProject extends BrowseInstallProject>(
options: ResolveInstallPlanOptions<TProject>,
): Promise<BrowseInstallPlan<TProject>> {
const projectId = options.project.project_id
if (!projectId) {
throw new Error('No project is available for install.')
}
const selectedPreferences = getSelectedInstallPreferences(options)
const targetPreferences = normalizeInstallPreferences(options.targetPreferences)
const candidates = getInstallCandidates(selectedPreferences, targetPreferences)
const versions = await options.getProjectVersions(projectId)
let lastError: Error | null = null
for (const candidate of candidates) {
const version = getLatestMatchingInstallVersion(
versions,
candidate.preferences,
options.contentType,
)
if (version) {
const fileName =
version.files.find((file) => file.primary)?.filename ?? version.files[0]?.filename
return {
project: options.project,
projectId,
versionId: version.id,
versionName: version.name,
versionNumber: version.version_number,
fileName,
contentType: options.contentType,
preferences: candidate.preferences,
source: candidate.source,
}
}
lastError = createNoCompatibleVersionError(options.contentType, candidate.preferences)
}
throw lastError ?? new Error('No version found for this project.')
}
/**
* Resolves and either queues or immediately installs a project.
*
* Queue replacement is keyed by project ID, so clicking install again after changing filters
* replaces the previously resolved plan.
*/
export async function requestInstall<TProject extends BrowseInstallProject>(
options: RequestInstallOptions<TProject>,
) {
const plan = await resolveInstallPlan(options)
if (options.mode === 'queue') {
if (!options.queue) {
throw new Error('No install queue is available.')
}
const nextPlans = new Map(options.queue.get())
nextPlans.set(plan.projectId, plan)
options.queue.set(nextPlans)
return plan
}
await options.install?.(plan)
return plan
}
/**
* Commits queued install plans exactly as stored.
*
* Successful plans are removed; failed plans remain in the queue for retry or user action.
*/
export async function flushInstallQueue<TProject extends BrowseInstallProject>({
queue,
install,
onError,
onProgress,
}: FlushInstallQueueOptions<TProject>): Promise<FlushInstallQueueResult<TProject>> {
const queuedPlans = Array.from(queue.get().values())
const failedPlans = new Map<string, BrowseInstallPlan<TProject>>()
const successfulPlans: BrowseInstallPlan<TProject>[] = []
let completed = 0
for (const plan of queuedPlans) {
try {
await install(plan)
successfulPlans.push(plan)
} catch (error) {
failedPlans.set(plan.projectId, plan)
onError?.(error, plan)
} finally {
completed++
await onProgress?.(completed, queuedPlans.length, plan)
}
}
queue.set(failedPlans)
return {
ok: failedPlans.size === 0,
successfulPlans,
failedPlans,
}
}
/**
* Builds the ordered resolution attempts for an install request.
*/
function getInstallCandidates(
selectedPreferences: BrowseInstallPreferences,
targetPreferences: BrowseInstallPreferences,
): InstallCandidate[] {
const filteredPreferences = mergeInstallPreferences(selectedPreferences, targetPreferences)
const candidates: InstallCandidate[] = []
if (hasPreferences(filteredPreferences)) {
candidates.push({
preferences: filteredPreferences,
source: preferencesEquivalent(selectedPreferences, targetPreferences) ? 'target' : 'filtered',
})
} else {
candidates.push({ preferences: {}, source: 'filtered' })
}
if (
hasPreferences(targetPreferences) &&
preferencesDiffer(filteredPreferences, targetPreferences)
) {
candidates.push({ preferences: targetPreferences, source: 'target' })
}
return candidates
}
function hasPreferences(preferences: BrowseInstallPreferences) {
return !!preferences.gameVersions?.length || !!preferences.loaders?.length
}
function versionMatchesPreferences(
version: Labrinth.Versions.v2.Version,
preferences: BrowseInstallPreferences,
contentType: string,
) {
const gameVersionMatches =
!preferences.gameVersions?.length ||
version.game_versions.some((gameVersion) => preferences.gameVersions?.includes(gameVersion))
if (!gameVersionMatches) return false
if (contentType === 'datapack') return true
if (!preferences.loaders?.length) return true
const compatibleLoaders = getCompatibleLoaderAliasSet(preferences.loaders)
return version.loaders.some((loader) => compatibleLoaders.has(normalizeLoaderAlias(loader)))
}
function preferencesConflict(
selected: readonly string[] | undefined,
target: readonly string[] | undefined,
) {
if (!selected?.length || !target?.length) return false
return !selected.some((value) => target.includes(value))
}
function loaderPreferencesConflict(
selected: readonly string[] | undefined,
target: readonly string[] | undefined,
) {
if (!selected?.length || !target?.length) return false
const selectedLoaders = getCompatibleLoaderAliasSet(selected)
const targetLoaders = getCompatibleLoaderAliasSet(target)
return !Array.from(selectedLoaders).some((loader) => targetLoaders.has(loader))
}
function preferencesEquivalent(
selected: BrowseInstallPreferences,
target: BrowseInstallPreferences,
) {
return (
valueSetsEquivalent(selected.gameVersions, target.gameVersions) &&
loaderSetsEquivalent(selected.loaders, target.loaders)
)
}
function valueSetsEquivalent(
selected: readonly string[] | undefined,
target: readonly string[] | undefined,
) {
return setsEquivalent(new Set(selected ?? []), new Set(target ?? []))
}
function loaderSetsEquivalent(
selected: readonly string[] | undefined,
target: readonly string[] | undefined,
) {
return setsEquivalent(
getCompatibleLoaderAliasSet(selected ?? []),
getCompatibleLoaderAliasSet(target ?? []),
)
}
function getCompatibleLoaderAliasSet(loaders: readonly string[]) {
const aliases = new Set<string>()
for (const loader of loaders) {
for (const alias of getCompatibleLoaderAliases(loader)) {
aliases.add(alias)
}
}
return aliases
}
function setsEquivalent(a: Set<string>, b: Set<string>) {
if (a.size !== b.size) return false
return Array.from(a).every((value) => b.has(value))
}
function normalizeInstallPreferences(
preferences?: BrowseInstallPreferences,
): BrowseInstallPreferences {
return {
gameVersions: uniqueDefined(preferences?.gameVersions),
loaders: uniqueDefined(preferences?.loaders),
}
}
function uniqueDefined(values: readonly (string | null | undefined)[] = []) {
return Array.from(
new Set(values.map((value) => value?.trim()).filter((value): value is string => !!value)),
)
}
function createNoCompatibleVersionError(
contentType: BrowseInstallContentType,
preferences: BrowseInstallPreferences,
) {
const versionLabel = preferences.gameVersions?.length
? preferences.gameVersions.join(', ')
: 'any game version'
const loaderLabel = preferences.loaders?.length ? preferences.loaders.join(', ') : 'any loader'
return new Error(
contentType === 'datapack'
? `No compatible version found for ${versionLabel}.`
: `No compatible version found for ${versionLabel} / ${loaderLabel}.`,
)
}
@@ -5,7 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useDebugLogger } from '#ui/composables/debug-logger'
import type { FilterType, FilterValue, ProjectType, SortType } from '#ui/utils/search'
import { useSearch } from '#ui/utils/search'
import { LOADER_FILTER_TYPES, useSearch } from '#ui/utils/search'
import { useServerSearch } from '#ui/utils/server-search'
import type { BrowseSearchResponse } from '../types'
@@ -60,14 +60,6 @@ export interface BrowseSearchState {
onFilterChange: () => void
}
const LOADER_FILTER_TYPES = [
'mod_loader',
'plugin_loader',
'modpack_loader',
'shader_loader',
'plugin_platform',
] as const
export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchState {
const debug = useDebugLogger('BrowseSearch')
const route = useRoute()
@@ -1,22 +1,29 @@
<script setup lang="ts">
import { GameIcon, LeftArrowIcon, MinecraftServerIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { LeftArrowIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Admonition from '#ui/components/base/Admonition.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import ContentPageHeader from '#ui/components/base/ContentPageHeader.vue'
import { useServerImage } from '#ui/composables/use-server-image'
import { formatLoaderLabel } from '#ui/utils/loaders'
import SelectedProjectsLeaveModal from './components/SelectedProjectsLeaveModal.vue'
import { injectBrowseManager } from './providers/browse-manager'
import type { BrowseInstallContext } from './types'
const MEDAL_ICON_URL = 'https://cdn-raw.modrinth.com/medal_icon.webp'
const ctx = injectBrowseManager()
const router = useRouter()
const installContext = computed(() => ctx.installContext?.value ?? null)
const props = defineProps<{
installContext?: BrowseInstallContext | null
}>()
type SelectedProjectsLeaveResult = 'cancel' | 'discard' | 'install'
const ctx = injectBrowseManager(null)
const installContext = computed(() => props.installContext ?? ctx?.installContext?.value ?? null)
const selectedProjectsLeaveModal = ref<InstanceType<typeof SelectedProjectsLeaveModal>>()
const serverId = computed(() => installContext.value?.serverId ?? '')
const upstream = computed(() => installContext.value?.upstream ?? null)
@@ -27,35 +34,97 @@ const { image: fetchedIcon } = useServerImage(serverId, upstream, {
const iconSrc = computed(() => {
if (installContext.value?.isMedal) return MEDAL_ICON_URL
return fetchedIcon.value ?? installContext.value?.iconSrc ?? MinecraftServerIcon
return fetchedIcon.value ?? installContext.value?.iconSrc ?? null
})
const metadataItems = computed(() => {
const context = installContext.value
if (!context) return []
return [
context.heading,
context.gameVersion ? `MC ${context.gameVersion}` : '',
context.loader ? formatLoaderLabel(context.loader) : '',
].filter(Boolean)
})
const selectedCount = computed(() => installContext.value?.selectedProjects?.length ?? 0)
const isInstallingSelected = computed(() => installContext.value?.isInstallingSelected ?? false)
async function handleBack() {
const context = installContext.value
if (!context) return
if (selectedCount.value > 0 && !isInstallingSelected.value) {
const result = await selectedProjectsLeaveModal.value?.prompt()
await handleSelectedProjectsLeaveResult(result ?? 'cancel', context)
return
}
const shouldNavigate = await context.onBack?.()
if (shouldNavigate === false) return
await router.push(context.backUrl)
}
async function handleSelectedProjectsLeaveResult(
result: SelectedProjectsLeaveResult,
context: BrowseInstallContext,
) {
if (result === 'cancel') return
if (result === 'install') {
const shouldNavigate = await context.installSelected?.()
if (shouldNavigate === false) return
return
}
if (context.discardSelectedAndBack) {
await context.discardSelectedAndBack()
return
}
await (context.clearSelected ?? context.clearQueued)?.()
await router.push(context.backUrl)
}
</script>
<template>
<template v-if="installContext">
<ContentPageHeader class="mb-2">
<template #icon>
<Avatar :src="iconSrc" size="64px" />
</template>
<template #title>
{{ installContext.name }}
</template>
<template #summary>
<span class="flex items-center gap-2 text-sm font-semibold text-secondary">
<GameIcon class="h-5 w-5 text-secondary" />
{{ formatLoaderLabel(installContext.loader) }} {{ installContext.gameVersion }}
</span>
</template>
<template #actions>
<ButtonStyled>
<button @click="router.push(installContext.backUrl)">
<LeftArrowIcon />
{{ installContext.backLabel }}
</button>
</ButtonStyled>
</template>
</ContentPageHeader>
<h1 class="m-0 mb-1 text-xl font-extrabold">{{ installContext.heading }}</h1>
<SelectedProjectsLeaveModal
ref="selectedProjectsLeaveModal"
:count="selectedCount"
:installing="isInstallingSelected"
/>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-4">
<ButtonStyled circular size="large">
<button :aria-label="installContext.backLabel" @click="handleBack">
<LeftArrowIcon />
</button>
</ButtonStyled>
<Avatar v-if="iconSrc" :src="iconSrc" size="48px" class="shrink-0" />
<div class="flex min-w-0 flex-col justify-center gap-1">
<h1 class="m-0 truncate text-2xl font-semibold leading-8 text-contrast">
{{ installContext.name }}
</h1>
<div
v-if="metadataItems.length"
class="flex flex-wrap items-center gap-2 text-base font-medium leading-6 text-primary"
>
<template v-for="(item, index) in metadataItems" :key="item">
<span
v-if="index > 0"
class="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-60"
/>
<span>{{ item }}</span>
</template>
</div>
</div>
</div>
</div>
</div>
<Admonition v-if="installContext.warning" type="warning" class="mb-1">
{{ installContext.warning }}
</Admonition>
@@ -1,3 +1,4 @@
export { default as SelectedProjectsFloatingBar } from './components/SelectedProjectsFloatingBar.vue'
export * from './composables'
export { default as BrowseInstallHeader } from './header.vue'
export { default as BrowsePageLayout } from './layout.vue'
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { SearchIcon } from '@modrinth/assets'
import { computed, toValue } from 'vue'
import { computed, ref, toValue } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
@@ -12,13 +12,23 @@ import StyledInput from '#ui/components/base/StyledInput.vue'
import ProjectCard from '#ui/components/project/card/ProjectCard.vue'
import ProjectCardList from '#ui/components/project/ProjectCardList.vue'
import SearchFilterControl from '#ui/components/search/SearchFilterControl.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useStickyObserver } from '#ui/composables/sticky-observer'
import { commonMessages } from '#ui/utils/common-messages'
import type { SortType } from '#ui/utils/search'
import SelectedProjectsFloatingBar from './components/SelectedProjectsFloatingBar.vue'
import BrowseInstallHeader from './header.vue'
import { injectBrowseManager } from './providers/browse-manager'
const ctx = injectBrowseManager()
const { formatMessage } = useVIntl()
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
stickyInstallHeaderRef,
'BrowseInstallHeader',
)
const sortOptions = computed<ComboboxOption<SortType>[]>(() =>
ctx.effectiveSortTypes.value.map((st) => ({
@@ -33,12 +43,43 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
label: String(n),
})),
)
const messages = defineMessages({
searchPlaceholder: {
id: 'browse.search.placeholder',
defaultMessage:
'Search {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} server {servers} other {projects}}...',
},
viewPrefix: {
id: 'browse.view-prefix',
defaultMessage: 'View:',
},
filterResults: {
id: 'browse.filter-results',
defaultMessage: 'Filter results...',
},
offline: {
id: 'browse.offline',
defaultMessage: 'You are currently offline. Connect to the internet to browse Modrinth!',
},
noResults: {
id: 'browse.no-results',
defaultMessage: 'No results found for your query!',
},
})
</script>
<template>
<template v-if="ctx.installContext?.value && ctx.variant !== 'web'">
<BrowseInstallHeader />
<div
ref="stickyInstallHeaderRef"
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
:class="[isInstallHeaderStuck ? 'border-t' : '']"
>
<BrowseInstallHeader />
</div>
</template>
<SelectedProjectsFloatingBar v-if="ctx.installContext?.value && ctx.variant !== 'web'" />
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
@@ -47,7 +88,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
:icon="SearchIcon"
type="text"
autocomplete="off"
:placeholder="`Search ${ctx.projectType.value}s...`"
:placeholder="formatMessage(messages.searchPlaceholder, { projectType: ctx.projectType.value })"
clearable
wrapper-class="w-full"
:input-class="ctx.variant === 'web' ? '!h-12' : 'h-12'"
@@ -62,7 +103,9 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
@update:model-value="(val: SortType) => (ctx.effectiveCurrentSortType.value = val)"
>
<template #prefix>
<span class="font-semibold text-primary">Sort by:</span>
<span class="font-semibold text-primary">{{
formatMessage(commonMessages.sortByLabel)
}}</span>
</template>
</Combobox>
@@ -70,17 +113,19 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
:model-value="ctx.maxResults.value"
:options="maxResultsOptions"
:class="ctx.variant === 'web' ? '!w-auto flex-grow md:flex-grow-0' : 'max-w-[9rem]'"
placeholder="View"
:placeholder="formatMessage(commonMessages.viewLabel)"
@update:model-value="(val: number) => (ctx.maxResults.value = val)"
>
<template #prefix>
<span class="font-semibold text-primary">View:</span>
<span class="font-semibold text-primary">{{ formatMessage(messages.viewPrefix) }}</span>
</template>
</Combobox>
<div v-if="ctx.filtersMenuOpen && !ctx.filtersMenuOpen.value" class="lg:hidden">
<ButtonStyled>
<button @click="ctx.filtersMenuOpen.value = true">Filter results...</button>
<button @click="ctx.filtersMenuOpen.value = true">
{{ formatMessage(messages.filterResults) }}
</button>
</ButtonStyled>
</div>
@@ -119,7 +164,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
<component :is="ctx.loadingComponent ?? LoadingIndicator" />
</section>
<section v-else-if="ctx.offline?.value && ctx.totalHits.value === 0" class="offline">
You are currently offline. Connect to the internet to browse Modrinth!
{{ formatMessage(messages.offline) }}
</section>
<section
v-else-if="
@@ -129,7 +174,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
"
class="offline"
>
<p>No results found for your query!</p>
<p>{{ formatMessage(messages.noResults) }}</p>
</section>
<ProjectCardList v-else :layout="ctx.effectiveLayout.value">
@@ -66,6 +66,9 @@ export interface BrowseManagerContext {
hideInstalled?: Ref<boolean>
showHideInstalled?: ComputedRef<boolean>
hideInstalledLabel?: ComputedRef<string>
hideSelected?: Ref<boolean>
showHideSelected?: ComputedRef<boolean>
hideSelectedLabel?: ComputedRef<string>
onInstalled?: (projectId: string) => void
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
@@ -5,10 +5,13 @@ import { computed, toValue } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import SearchSidebarFilter from '#ui/components/search/SearchSidebarFilter.vue'
import { useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import { injectBrowseManager } from './providers/browse-manager'
const ctx = injectBrowseManager()
const { formatMessage } = useVIntl()
const isApp = computed(() => ctx.variant === 'app')
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
@@ -80,7 +83,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
v-if="ctx.filtersMenuOpen?.value"
class="sticky top-0 z-10 mx-1 flex items-center justify-between gap-3 border-0 border-b-[1px] border-solid border-divider bg-bg-raised px-6 py-4"
>
<h3 class="m-0 text-lg text-contrast">Filters</h3>
<h3 class="m-0 text-lg text-contrast">{{ formatMessage(commonMessages.filtersLabel) }}</h3>
<ButtonStyled circular>
<button @click="closeFiltersMenu">
<XIcon />
@@ -89,16 +92,29 @@ function getFilterOpenByDefault(filterId: string): boolean {
</div>
<div
v-if="ctx.showHideInstalled?.value"
v-if="ctx.showHideInstalled?.value || ctx.showHideSelected?.value"
:class="
isApp
? 'border-0 border-b-[1px] p-4 last:border-b-0 border-[--brand-gradient-border] border-solid'
: 'card-shadow rounded-2xl bg-bg-raised p-4'
? 'flex flex-col gap-3 border-0 border-b-[1px] p-4 last:border-b-0 border-[--brand-gradient-border] border-solid'
: 'card-shadow flex flex-col gap-3 rounded-2xl bg-bg-raised p-4'
"
>
<Checkbox
v-if="ctx.showHideInstalled?.value"
v-model="ctx.hideInstalled!.value"
:label="ctx.hideInstalledLabel?.value ?? 'Hide already installed content'"
:label="
ctx.hideInstalledLabel?.value ?? formatMessage(commonMessages.hideInstalledContentLabel)
"
class="filter-checkbox"
@update:model-value="ctx.onFilterChange()"
@click.prevent.stop
/>
<Checkbox
v-if="ctx.showHideSelected?.value"
v-model="ctx.hideSelected!.value"
:label="
ctx.hideSelectedLabel?.value ?? formatMessage(commonMessages.hideSelectedContentLabel)
"
class="filter-checkbox"
@update:model-value="ctx.onFilterChange()"
@click.prevent.stop
@@ -12,6 +12,12 @@ export interface BrowseSearchResponse {
per_page: number
}
export interface BrowseSelectedProject {
id: string
name: string
iconUrl?: string | null
}
export interface BrowseInstallContext {
name: string
loader: string
@@ -24,6 +30,19 @@ export interface BrowseInstallContext {
backLabel: string
heading: string
warning?: string
queuedCount?: number
queuedLabel?: string
clearQueued?: () => void | Promise<void>
onBack?: () => boolean | void | Promise<boolean | void>
selectedProjects?: BrowseSelectedProject[]
isInstallingSelected?: boolean
installProgress?: {
completed: number
total: number
}
clearSelected?: () => void | Promise<void>
discardSelectedAndBack?: () => void | Promise<void>
installSelected?: () => boolean | void | Promise<boolean | void>
}
export interface CardAction {
@@ -32,7 +51,7 @@ export interface CardAction {
icon: Component
iconClass?: string
disabled?: boolean
color?: 'brand' | 'red'
color?: 'brand' | 'red' | 'green'
type?: 'standard' | 'outlined' | 'transparent'
circular?: boolean
tooltip?: string
@@ -108,6 +108,7 @@ import NewModal from '#ui/components/modal/NewModal.vue'
import ShareModal from '#ui/components/modal/ShareModal.vue'
import { injectModrinthClient } from '#ui/providers'
import { injectModalBehavior } from '#ui/providers/modal-behavior'
import { injectPageContext } from '#ui/providers/page-context'
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
import ConsoleActionButtons from './components/ConsoleActionButtons.vue'
@@ -127,6 +128,7 @@ import type { LogLevel, LogLine } from './types'
const ctx = injectConsoleManager()
const client = injectModrinthClient()
const modalBehavior = injectModalBehavior()
const pageContext = injectPageContext(null)
const { addNotification } = injectNotificationManager()
const crashHeader = computed(() => {
@@ -149,6 +151,9 @@ const deleteModal = ref<InstanceType<typeof NewModal> | null>(null)
const isDeleting = ref(false)
const searchQuery = ref('')
const isFullscreen = ref(false)
const fullscreenBodyClass = 'modrinth-console-fullscreen-active'
const fullscreenIntercomPadding = 20
const fullscreenIntercomPaddingRequestId = Symbol('console-fullscreen')
const isApp =
typeof window !== 'undefined' && !!(window as Record<string, unknown>).__TAURI_INTERNALS__
const isSharing = ref(false)
@@ -187,6 +192,11 @@ function buildCombinedPredicate(): ((line: LogLine) => boolean) | null {
onBeforeUnmount(() => {
if (isFullscreen.value) {
document.body.style.overflow = ''
document.body.classList.remove(fullscreenBodyClass)
pageContext?.intercomBubble?.requestHorizontalPadding?.(
fullscreenIntercomPaddingRequestId,
null,
)
modalBehavior?.onHide?.()
}
})
@@ -266,9 +276,19 @@ function toggleFullscreen() {
isFullscreen.value = !isFullscreen.value
if (isFullscreen.value) {
document.body.style.overflow = 'hidden'
document.body.classList.add(fullscreenBodyClass)
pageContext?.intercomBubble?.requestHorizontalPadding?.(
fullscreenIntercomPaddingRequestId,
fullscreenIntercomPadding,
)
modalBehavior?.onShow?.()
} else {
document.body.style.overflow = ''
document.body.classList.remove(fullscreenBodyClass)
pageContext?.intercomBubble?.requestHorizontalPadding?.(
fullscreenIntercomPaddingRequestId,
null,
)
modalBehavior?.onHide?.()
}
nextTick(() => {
@@ -396,3 +416,17 @@ async function handleShare() {
}
}
</script>
<style>
.modrinth-console-fullscreen-active .intercom-lightweight-app,
.modrinth-console-fullscreen-active .intercom-lightweight-app-launcher,
.modrinth-console-fullscreen-active .intercom-lightweight-app-messenger,
.modrinth-console-fullscreen-active .intercom-launcher-frame,
.modrinth-console-fullscreen-active .intercom-messenger-frame,
.modrinth-console-fullscreen-active #intercom-container,
.modrinth-console-fullscreen-active #intercom-frame,
.modrinth-console-fullscreen-active iframe[name='intercom-launcher-frame'],
.modrinth-console-fullscreen-active iframe[name='intercom-messenger-frame'] {
z-index: 14 !important;
}
</style>
@@ -21,7 +21,7 @@ import Checkbox from '#ui/components/base/Checkbox.vue'
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import Toggle from '#ui/components/base/Toggle.vue'
import { useVIntl } from '#ui/composables/i18n'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import { truncatedTooltip } from '#ui/utils/truncate'
@@ -34,6 +34,13 @@ import type {
const { formatMessage } = useVIntl()
const messages = defineMessages({
selectProject: {
id: 'content.card.select-project',
defaultMessage: 'Select {project}',
},
})
interface Props {
project: ContentCardProject
projectLink?: string | RouteLocationRaw
@@ -111,7 +118,10 @@ const deleteHovered = ref(false)
<div
role="row"
class="flex h-[74px] items-center justify-between gap-4 px-3"
:class="{ 'opacity-50': disabled }"
:class="{
'opacity-50 grayscale': disabled && !installing,
'opacity-50': installing,
}"
>
<div
class="flex min-w-0 items-center gap-4"
@@ -122,7 +132,7 @@ const deleteHovered = ref(false)
<Checkbox
v-if="showCheckbox"
:model-value="selected ?? false"
:aria-label="`Select ${project.title}`"
:aria-label="formatMessage(messages.selectProject, { project: project.title })"
class="shrink-0"
@update:model-value="selected = $event"
/>
@@ -89,6 +89,7 @@ const { listContainer, totalHeight, visibleRange, visibleTop, visibleItems } = u
{
itemHeight: 74,
bufferSize: 5,
initialItemCount: 20,
enabled: toRef(props, 'virtualized'),
},
)
@@ -119,16 +119,18 @@ const collapsedOptions = computed(() => {
const containerRef = ref<HTMLElement | null>(null)
const isExpanded = ref(true)
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
isExpanded.value = entry.contentRect.width >= 700
}
})
let observer: ResizeObserver | null = null
onMounted(() => {
observer = new ResizeObserver((entries) => {
for (const entry of entries) {
isExpanded.value = entry.contentRect.width >= 700
}
})
if (containerRef.value) observer.observe(containerRef.value)
})
onUnmounted(() => {
observer.disconnect()
observer?.disconnect()
observer = null
})
</script>
@@ -2,6 +2,7 @@
import { PowerIcon, PowerOffIcon, XIcon } from '@modrinth/assets'
import { computed } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
@@ -93,6 +94,13 @@ const emit = defineEmits<{
}>()
const shown = computed(() => props.selectedItems.length > 0 || props.isBulkOperating)
const iconStackOffset = 24
const visibleItems = computed(() => props.selectedItems.slice(0, 3))
const overflowCount = computed(() => Math.max(0, props.selectedItems.length - 3))
const iconStackWidth = computed(() => {
if (props.selectedItems.length === 0) return 0
return 32 + (visibleItems.value.length - 1 + (overflowCount.value > 0 ? 1 : 0)) * iconStackOffset
})
const allDisabled = computed(() => props.selectedItems.every((m) => !m.enabled))
const allEnabled = computed(() => props.selectedItems.every((m) => m.enabled))
@@ -130,10 +138,41 @@ const bulkProgressMessage = computed(() => {
<template>
<FloatingActionBar :shown="shown" :aria-label="ariaLabel">
<div class="flex items-center gap-0.5">
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
<div
v-if="selectedItems.length > 0"
class="relative h-8 shrink-0"
:style="{ width: `${iconStackWidth}px` }"
aria-hidden="true"
>
<div
v-for="(item, index) in visibleItems"
:key="item.id"
v-tooltip="item.project?.title ?? item.file_name"
class="absolute top-0 flex h-8 w-8 items-center justify-center overflow-hidden rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4"
:style="{ left: `${index * iconStackOffset}px`, zIndex: visibleItems.length - index }"
>
<Avatar
:src="item.project?.icon_url"
:alt="item.project?.title ?? item.file_name"
:tint-by="item.id"
size="100%"
no-shadow
class="selected-content-avatar"
/>
</div>
<div
v-if="overflowCount > 0"
class="absolute top-0 flex h-8 w-8 items-center justify-center rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4 text-xs font-bold text-contrast"
:style="{ left: `${visibleItems.length * iconStackOffset}px`, zIndex: 0 }"
>
+{{ overflowCount }}
</div>
</div>
<span class="px-3 py-2 text-base font-semibold text-contrast tabular-nums">
{{ selectedCountText }}
</span>
<div class="mx-1 h-6 w-px bg-surface-5" />
<div class="mx-0.5 h-6 w-px bg-surface-5" />
<ButtonStyled type="transparent">
<button
v-tooltip="formatMessage(commonMessages.clearButton)"
@@ -223,4 +262,8 @@ const bulkProgressMessage = computed(() => {
.animate-indeterminate {
animation: indeterminate 1.5s ease-in-out infinite;
}
:deep(.selected-content-avatar) {
background-color: var(--color-button-bg);
}
</style>
@@ -104,6 +104,7 @@ import { computed, ref } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import { useFormatBytes } from '#ui/composables'
import { useFormatDateTime } from '#ui/composables/format-date-time'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectNotificationManager } from '#ui/providers/web-notifications'
@@ -164,8 +165,6 @@ const isDropTarget = computed(
)
const isDragSource = computed(() => fileDragActive.value && fileDragData.value?.path === props.path)
const units = Object.freeze(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'])
const formatDateTime = useFormatDateTime({
year: '2-digit',
month: '2-digit',
@@ -173,6 +172,7 @@ const formatDateTime = useFormatDateTime({
hour: 'numeric',
minute: 'numeric',
})
const formatBytes = useFormatBytes()
const containerClasses = computed(() => {
const dropTarget = isDropTarget.value
@@ -307,12 +307,7 @@ const formattedSize = computed(() => {
}
if (props.size === undefined) return ''
const bytes = props.size
if (bytes === 0) return '0 B'
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const size = (bytes / Math.pow(1024, exponent)).toFixed(2)
return `${size} ${units[exponent]}`
return formatBytes(props.size)
})
function openContextMenu(event: MouseEvent) {
@@ -115,12 +115,14 @@ import { CheckCircleIcon, FolderOpenIcon, SpinnerIcon, XCircleIcon } from '@modr
import { computed, nextTick, ref, watch } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { useFormatBytes } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectModrinthClient } from '#ui/providers/api-client'
import { injectNotificationManager } from '#ui/providers/web-notifications'
import { commonMessages } from '#ui/utils/common-messages'
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
@@ -240,13 +242,6 @@ watch(
{ deep: true },
)
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + ' KB'
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + ' MB'
return (bytes / 1024 ** 3).toFixed(1) + ' GB'
}
const cancelUpload = (item: UploadItem) => {
if (item.uploader && item.status === 'uploading') {
item.uploader.cancel()
@@ -269,7 +264,7 @@ const uploadFile = async (file: File) => {
file,
progress: 0,
status: 'pending',
size: formatFileSize(file.size),
size: formatBytes(file.size, 1),
}
uploadQueue.value.push(uploadItem)

Some files were not shown because too many files have changed in this diff Show More