mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7226c25af | ||
|
|
40f0e41d23 | ||
|
|
bf93e01114 | ||
|
|
105d8e7619 | ||
|
|
b81e4304b1 | ||
|
|
ea86db450b | ||
|
|
bbdaa171e3 | ||
|
|
af8daca059 | ||
|
|
2434f0c45b | ||
|
|
332dfe1074 | ||
|
|
c07727aa49 | ||
|
|
57a977f7f3 | ||
|
|
323d088ebd | ||
|
|
46f94bd067 | ||
|
|
e877167db7 | ||
|
|
623b51e6ea | ||
|
|
5face9e56a | ||
|
|
40aff1e8bb | ||
|
|
5f8837604d | ||
|
|
795dad040f | ||
|
|
9ca181dc1a | ||
|
|
c84b658e41 | ||
|
|
9006dce2b0 | ||
|
|
efdfb51149 | ||
|
|
79cc5fef72 | ||
|
|
ddefc28ca6 | ||
|
|
654757f9fb | ||
|
|
407641ee7c | ||
|
|
96ab120cd0 | ||
|
|
3ed75a146a | ||
|
|
5a1c2a46bc | ||
|
|
df8ebbd3e0 | ||
|
|
517c3d2d72 | ||
|
|
7f15772f59 | ||
|
|
0905ea72f6 | ||
|
|
8d20fd82db | ||
|
|
c831e38e32 | ||
|
|
40b8fb3a4a | ||
|
|
977bb2ff58 | ||
|
|
732bdfc79b | ||
|
|
1cce1eb248 | ||
|
|
76320f227f | ||
|
|
34228a5b6c | ||
|
|
6d66aee4ec |
@@ -94,10 +94,11 @@ jobs:
|
||||
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
|
||||
|
||||
- name: Install build dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
with:
|
||||
packages: cmake libcurl4-openssl-dev
|
||||
version: v2 # cache key
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake libcurl4-openssl-dev
|
||||
#uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
#with:
|
||||
# packages: cmake libcurl4-openssl-dev
|
||||
# version: v2 # cache key
|
||||
|
||||
- name: Cache Cargo registry and index
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
|
||||
Generated
+26
@@ -1628,6 +1628,31 @@ dependencies = [
|
||||
"serde_with",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bon"
|
||||
version = "3.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561"
|
||||
dependencies = [
|
||||
"bon-macros",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bon-macros"
|
||||
version = "3.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"ident_case",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.5.7"
|
||||
@@ -10849,6 +10874,7 @@ dependencies = [
|
||||
"async-walkdir",
|
||||
"async_zip",
|
||||
"base64 0.22.1",
|
||||
"bon",
|
||||
"bytemuck",
|
||||
"bytes",
|
||||
"chardetng",
|
||||
|
||||
@@ -51,6 +51,7 @@ aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
|
||||
] }
|
||||
base64 = "0.22.1"
|
||||
bitflags = "2.9.4"
|
||||
bon = "3.9.3"
|
||||
bytemuck = "1.24.0"
|
||||
bytes = "1.10.1"
|
||||
censor = "0.3.0"
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Modrinth Monorepo
|
||||
|
||||
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains  lines of code and has  contributors!
|
||||
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains  lines of code and has  contributors!
|
||||
|
||||
If you're not a developer and you've stumbled upon this repository, you can access the web interface on the [Modrinth website](https://modrinth.com) and download the latest release of the app [here](https://modrinth.com/app).
|
||||
|
||||
|
||||
@@ -269,16 +269,31 @@ provideInstallationSettings({
|
||||
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
const result = manifest.gameVersions[0]?.loaders ?? []
|
||||
debug('resolveLoaderVersions: fabric/quilt result', {
|
||||
const entry = manifest.gameVersions?.find((item) => item.id === gameVersion)
|
||||
if (entry?.versionGroup) {
|
||||
const result =
|
||||
manifest.versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? []
|
||||
debug('resolveLoaderVersions: version group result', {
|
||||
loader,
|
||||
gameVersion,
|
||||
versionGroup: entry.versionGroup,
|
||||
count: result.length,
|
||||
})
|
||||
return result
|
||||
}
|
||||
const placeholder = manifest.gameVersions?.find((item) => item.id === '${modrinth.gameVersion}')
|
||||
if (placeholder) {
|
||||
const result = manifest.gameVersions?.some((item) => item.id === gameVersion)
|
||||
? placeholder.loaders
|
||||
: []
|
||||
debug('resolveLoaderVersions: placeholder result', {
|
||||
loader,
|
||||
gameVersion,
|
||||
count: result.length,
|
||||
})
|
||||
return result
|
||||
}
|
||||
const result = manifest.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
|
||||
const result = entry?.loaders ?? []
|
||||
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
|
||||
return result
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UpdatedIcon } from '@modrinth/assets'
|
||||
import { CheckIcon, CopyIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import {
|
||||
defineMessages,
|
||||
type PopupNotificationButton,
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
install_job_dismiss,
|
||||
install_job_list,
|
||||
install_job_retry,
|
||||
install_job_support_details,
|
||||
installJobInstanceId,
|
||||
type InstallJobSnapshot,
|
||||
type InstallJobStatus,
|
||||
type InstallPhaseId,
|
||||
type InstallProgress,
|
||||
} from '@/helpers/install'
|
||||
import { get_many as getInstances } from '@/helpers/instance'
|
||||
@@ -31,6 +33,14 @@ const messages = defineMessages({
|
||||
id: 'app.action-bar.install.retry',
|
||||
defaultMessage: 'Retry',
|
||||
},
|
||||
copyDetails: {
|
||||
id: 'app.action-bar.install.copy-details',
|
||||
defaultMessage: 'Copy details',
|
||||
},
|
||||
copied: {
|
||||
id: 'app.action-bar.install.copied-details',
|
||||
defaultMessage: 'Copied',
|
||||
},
|
||||
dismiss: {
|
||||
id: 'app.action-bar.install.dismiss',
|
||||
defaultMessage: 'Dismiss',
|
||||
@@ -39,22 +49,6 @@ const messages = defineMessages({
|
||||
id: 'app.action-bar.install.open-instance',
|
||||
defaultMessage: 'Open instance',
|
||||
},
|
||||
installFailed: {
|
||||
id: 'app.action-bar.install.failed',
|
||||
defaultMessage: 'Install failed',
|
||||
},
|
||||
installFailedAppClosed: {
|
||||
id: 'app.action-bar.install.failed-app-closed',
|
||||
defaultMessage: 'Installation failed due to app closing.',
|
||||
},
|
||||
installFailedNetwork: {
|
||||
id: 'app.action-bar.install.failed-network',
|
||||
defaultMessage: 'Installation failed due to a network error.',
|
||||
},
|
||||
installFailedUnknown: {
|
||||
id: 'app.action-bar.install.failed-unknown',
|
||||
defaultMessage: 'Installation failed due to an unknown error.',
|
||||
},
|
||||
unknownInstance: {
|
||||
id: 'app.action-bar.install.unknown-instance',
|
||||
defaultMessage: 'Unknown instance',
|
||||
@@ -64,7 +58,7 @@ const messages = defineMessages({
|
||||
const phaseMessages = defineMessages({
|
||||
preparing_instance: {
|
||||
id: 'app.install.phase.preparing_instance',
|
||||
defaultMessage: 'Preparing instance',
|
||||
defaultMessage: 'Queued to install',
|
||||
},
|
||||
resolving_pack: {
|
||||
id: 'app.install.phase.resolving_pack',
|
||||
@@ -139,7 +133,97 @@ const javaStepMessages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const failureSummaryMessages = defineMessages({
|
||||
canceled: {
|
||||
id: 'app.action-bar.install.summary.canceled',
|
||||
defaultMessage: 'Canceled',
|
||||
},
|
||||
appClosed: {
|
||||
id: 'app.action-bar.install.summary.app-closing',
|
||||
defaultMessage: 'Canceled due to app closing',
|
||||
},
|
||||
downloadFailed: {
|
||||
id: 'app.action-bar.install.summary.download-failed',
|
||||
defaultMessage: "Download couldn't finish",
|
||||
},
|
||||
modrinthUnreachable: {
|
||||
id: 'app.action-bar.install.summary.modrinth-unreachable',
|
||||
defaultMessage: "Couldn't reach Modrinth",
|
||||
},
|
||||
packDownloadFailed: {
|
||||
id: 'app.action-bar.install.summary.pack-download-failed',
|
||||
defaultMessage: "Couldn't download pack",
|
||||
},
|
||||
badModpackFile: {
|
||||
id: 'app.action-bar.install.summary.bad-modpack-file',
|
||||
defaultMessage: "Couldn't read modpack",
|
||||
},
|
||||
invalidModpack: {
|
||||
id: 'app.action-bar.install.summary.invalid-modpack',
|
||||
defaultMessage: 'Modpack data invalid',
|
||||
},
|
||||
contentDownloadFailed: {
|
||||
id: 'app.action-bar.install.summary.content-download-failed',
|
||||
defaultMessage: "Couldn't download files",
|
||||
},
|
||||
corruptDownload: {
|
||||
id: 'app.action-bar.install.summary.corrupt-download',
|
||||
defaultMessage: 'Downloaded file is corrupt',
|
||||
},
|
||||
invalidModpackFiles: {
|
||||
id: 'app.action-bar.install.summary.invalid-modpack-files',
|
||||
defaultMessage: 'Modpack files have invalid metadata',
|
||||
},
|
||||
noWritePermission: {
|
||||
id: 'app.action-bar.install.summary.no-write-permission',
|
||||
defaultMessage: 'No permission to write',
|
||||
},
|
||||
couldNotSaveFiles: {
|
||||
id: 'app.action-bar.install.summary.could-not-save-files',
|
||||
defaultMessage: "Couldn't save files",
|
||||
},
|
||||
invalidFilePath: {
|
||||
id: 'app.action-bar.install.summary.invalid-file-path',
|
||||
defaultMessage: 'File path is invalid',
|
||||
},
|
||||
instanceNotFound: {
|
||||
id: 'app.action-bar.install.summary.instance-not-found',
|
||||
defaultMessage: "Instance couldn't be found",
|
||||
},
|
||||
cleanupIncomplete: {
|
||||
id: 'app.action-bar.install.summary.cleanup-incomplete',
|
||||
defaultMessage: "Cleanup didn't finish",
|
||||
},
|
||||
javaSetupFailed: {
|
||||
id: 'app.action-bar.install.summary.java-setup-failed',
|
||||
defaultMessage: "Java setup couldn't finish",
|
||||
},
|
||||
minecraftSetupFailed: {
|
||||
id: 'app.action-bar.install.summary.minecraft-setup-failed',
|
||||
defaultMessage: 'Minecraft setup failed',
|
||||
},
|
||||
loaderSetupFailed: {
|
||||
id: 'app.action-bar.install.summary.loader-setup-failed',
|
||||
defaultMessage: 'Loader setup failed',
|
||||
},
|
||||
localDataError: {
|
||||
id: 'app.action-bar.install.summary.local-data-error',
|
||||
defaultMessage: "Couldn't update local data",
|
||||
},
|
||||
unexpectedError: {
|
||||
id: 'app.action-bar.install.summary.unexpected-error',
|
||||
defaultMessage: 'Something went wrong',
|
||||
},
|
||||
})
|
||||
|
||||
const visibleJobStatuses = new Set<InstallJobStatus>(['queued', 'running', 'failed', 'interrupted'])
|
||||
const copyDetailsStallMs = 30_000
|
||||
|
||||
interface ProgressSnapshot {
|
||||
signature: string
|
||||
changedAt: number
|
||||
timeout: number | null
|
||||
}
|
||||
|
||||
function getDisplayIconUrl(icon: string | null | undefined): string | null {
|
||||
if (!icon) return null
|
||||
@@ -156,10 +240,13 @@ export async function useInstallJobNotifications(opts: {
|
||||
const jobs = ref<InstallJobSnapshot[]>([])
|
||||
const iconUrls = ref<Record<string, string | null>>({})
|
||||
const instanceNames = ref<Record<string, string>>({})
|
||||
const copiedJobIds = ref<Set<string>>(new Set())
|
||||
const jobOrder = new Map<string, number>()
|
||||
let refreshRequest = 0
|
||||
let metadataRequest = 0
|
||||
let nextJobOrder = 0
|
||||
const copiedResetTimeouts = new Map<string, number>()
|
||||
const progressSnapshots = new Map<string, ProgressSnapshot>()
|
||||
|
||||
function getTitle(job: InstallJobSnapshot): string {
|
||||
if (job.display?.title) return job.display.title
|
||||
@@ -174,13 +261,7 @@ export async function useInstallJobNotifications(opts: {
|
||||
|
||||
function getText(job: InstallJobSnapshot): string {
|
||||
if (job.status === 'failed' || job.status === 'interrupted') {
|
||||
if (job.error?.code === 'interrupted') {
|
||||
return formatMessage(messages.installFailedAppClosed)
|
||||
}
|
||||
if (job.error?.code === 'network_error') {
|
||||
return formatMessage(messages.installFailedNetwork)
|
||||
}
|
||||
return formatMessage(messages.installFailedUnknown)
|
||||
return getFailureSummary(job)
|
||||
}
|
||||
if (job.phase === 'preparing_java' && job.details.type === 'java') {
|
||||
return formatMessage(javaStepMessages[job.details.step], {
|
||||
@@ -190,6 +271,104 @@ export async function useInstallJobNotifications(opts: {
|
||||
return formatMessage(phaseMessages[job.phase])
|
||||
}
|
||||
|
||||
function getFailureSummary(job: InstallJobSnapshot): string {
|
||||
const code = job.error?.code
|
||||
const phase = job.error?.phase ?? job.phase
|
||||
|
||||
if (code === 'app_closed' || (job.status === 'interrupted' && code === 'interrupted')) {
|
||||
return formatMessage(failureSummaryMessages.appClosed)
|
||||
}
|
||||
if (code === 'canceled') {
|
||||
return formatMessage(failureSummaryMessages.canceled)
|
||||
}
|
||||
if (job.rollback_error || code === 'rollback_error') {
|
||||
return formatMessage(failureSummaryMessages.cleanupIncomplete)
|
||||
}
|
||||
if (hasPermissionError(job)) {
|
||||
return formatMessage(failureSummaryMessages.noWritePermission)
|
||||
}
|
||||
|
||||
switch (code) {
|
||||
case 'network_error':
|
||||
return formatMessage(
|
||||
phase === 'downloading_pack_file'
|
||||
? failureSummaryMessages.packDownloadFailed
|
||||
: failureSummaryMessages.downloadFailed,
|
||||
)
|
||||
case 'api_error':
|
||||
return formatMessage(failureSummaryMessages.modrinthUnreachable)
|
||||
case 'pack_error':
|
||||
return formatMessage(
|
||||
phase === 'downloading_pack_file'
|
||||
? failureSummaryMessages.packDownloadFailed
|
||||
: failureSummaryMessages.invalidModpack,
|
||||
)
|
||||
case 'archive_error':
|
||||
return formatMessage(failureSummaryMessages.badModpackFile)
|
||||
case 'parse_error':
|
||||
return formatMessage(failureSummaryMessages.invalidModpack)
|
||||
case 'content_error':
|
||||
return formatMessage(failureSummaryMessages.invalidModpackFiles)
|
||||
case 'hash_error':
|
||||
return formatMessage(failureSummaryMessages.corruptDownload)
|
||||
case 'filesystem_error':
|
||||
return formatMessage(failureSummaryMessages.couldNotSaveFiles)
|
||||
case 'path_error':
|
||||
return formatMessage(failureSummaryMessages.invalidFilePath)
|
||||
case 'instance_error':
|
||||
return formatMessage(failureSummaryMessages.instanceNotFound)
|
||||
case 'java_error':
|
||||
return formatMessage(failureSummaryMessages.javaSetupFailed)
|
||||
case 'loader_error':
|
||||
case 'processor_error':
|
||||
return formatMessage(failureSummaryMessages.loaderSetupFailed)
|
||||
case 'database_error':
|
||||
return formatMessage(failureSummaryMessages.localDataError)
|
||||
case 'launcher_error':
|
||||
case 'metadata_error':
|
||||
return getFailureSummaryForPhase(phase)
|
||||
default:
|
||||
return getFailureSummaryForPhase(phase)
|
||||
}
|
||||
}
|
||||
|
||||
function getFailureSummaryForPhase(phase: InstallPhaseId): string {
|
||||
switch (phase) {
|
||||
case 'downloading_pack_file':
|
||||
return formatMessage(failureSummaryMessages.packDownloadFailed)
|
||||
case 'resolving_pack':
|
||||
case 'reading_pack_manifest':
|
||||
return formatMessage(failureSummaryMessages.invalidModpack)
|
||||
case 'downloading_content':
|
||||
return formatMessage(failureSummaryMessages.contentDownloadFailed)
|
||||
case 'extracting_overrides':
|
||||
return formatMessage(failureSummaryMessages.couldNotSaveFiles)
|
||||
case 'resolving_minecraft':
|
||||
case 'downloading_minecraft':
|
||||
return formatMessage(failureSummaryMessages.minecraftSetupFailed)
|
||||
case 'resolving_loader':
|
||||
case 'running_loader_processors':
|
||||
return formatMessage(failureSummaryMessages.loaderSetupFailed)
|
||||
case 'preparing_java':
|
||||
return formatMessage(failureSummaryMessages.javaSetupFailed)
|
||||
case 'preparing_instance':
|
||||
return formatMessage(failureSummaryMessages.instanceNotFound)
|
||||
case 'rolling_back':
|
||||
return formatMessage(failureSummaryMessages.cleanupIncomplete)
|
||||
default:
|
||||
return formatMessage(failureSummaryMessages.unexpectedError)
|
||||
}
|
||||
}
|
||||
|
||||
function hasPermissionError(job: InstallJobSnapshot): boolean {
|
||||
const message = job.error?.message.toLowerCase() ?? ''
|
||||
return (
|
||||
message.includes('permission denied') ||
|
||||
message.includes('access is denied') ||
|
||||
message.includes('operation not permitted')
|
||||
)
|
||||
}
|
||||
|
||||
function getProgressType(job: InstallJobSnapshot): PopupNotificationProgressType | undefined {
|
||||
if (!getEffectiveProgress(job)) return undefined
|
||||
if (
|
||||
@@ -235,11 +414,165 @@ export async function useInstallJobNotifications(opts: {
|
||||
return job.status === 'failed' || job.status === 'interrupted'
|
||||
}
|
||||
|
||||
function getTerminalButtons(job: InstallJobSnapshot): PopupNotificationButton[] | undefined {
|
||||
if (!isTerminalJob(job)) return undefined
|
||||
function canShowStalledProgressDetails(job: InstallJobSnapshot): boolean {
|
||||
return (
|
||||
job.status === 'running' &&
|
||||
job.phase !== 'preparing_instance' &&
|
||||
job.phase !== 'finalizing' &&
|
||||
job.phase !== 'rolling_back'
|
||||
)
|
||||
}
|
||||
|
||||
function getJobSortRank(job: InstallJobSnapshot): number {
|
||||
if (isTerminalJob(job)) return 0
|
||||
if (job.status === 'queued' || job.phase === 'preparing_instance') return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function progressSignature(job: InstallJobSnapshot): string {
|
||||
const progress = job.progress
|
||||
const secondary = progress?.secondary
|
||||
return [
|
||||
{
|
||||
job.status,
|
||||
job.phase,
|
||||
JSON.stringify(job.details),
|
||||
progress?.current ?? '',
|
||||
progress?.total ?? '',
|
||||
secondary?.current ?? '',
|
||||
secondary?.total ?? '',
|
||||
].join(':')
|
||||
}
|
||||
|
||||
function clearCopied(jobId: string) {
|
||||
if (!copiedJobIds.value.has(jobId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = copiedResetTimeouts.get(jobId)
|
||||
if (timeout != null) {
|
||||
window.clearTimeout(timeout)
|
||||
copiedResetTimeouts.delete(jobId)
|
||||
}
|
||||
|
||||
const nextCopiedJobIds = new Set(copiedJobIds.value)
|
||||
nextCopiedJobIds.delete(jobId)
|
||||
copiedJobIds.value = nextCopiedJobIds
|
||||
}
|
||||
|
||||
function clearProgressSnapshot(jobId: string) {
|
||||
const snapshot = progressSnapshots.get(jobId)
|
||||
if (snapshot?.timeout != null) {
|
||||
window.clearTimeout(snapshot.timeout)
|
||||
}
|
||||
progressSnapshots.delete(jobId)
|
||||
}
|
||||
|
||||
function scheduleStaleProgressRefresh(jobId: string) {
|
||||
const snapshot = progressSnapshots.get(jobId)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot.timeout = window.setTimeout(() => {
|
||||
const snapshot = progressSnapshots.get(jobId)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot.timeout = null
|
||||
opts.onChange()
|
||||
}, copyDetailsStallMs)
|
||||
}
|
||||
|
||||
function syncProgressSnapshots(nextJobs: InstallJobSnapshot[]) {
|
||||
const trackedJobIds = new Set<string>()
|
||||
const now = Date.now()
|
||||
|
||||
for (const job of nextJobs) {
|
||||
if (!canShowStalledProgressDetails(job)) {
|
||||
continue
|
||||
}
|
||||
|
||||
trackedJobIds.add(job.job_id)
|
||||
const signature = progressSignature(job)
|
||||
const snapshot = progressSnapshots.get(job.job_id)
|
||||
if (snapshot?.signature === signature) {
|
||||
continue
|
||||
}
|
||||
|
||||
clearProgressSnapshot(job.job_id)
|
||||
clearCopied(job.job_id)
|
||||
progressSnapshots.set(job.job_id, {
|
||||
signature,
|
||||
changedAt: now,
|
||||
timeout: null,
|
||||
})
|
||||
scheduleStaleProgressRefresh(job.job_id)
|
||||
}
|
||||
|
||||
for (const jobId of progressSnapshots.keys()) {
|
||||
if (!trackedJobIds.has(jobId)) {
|
||||
clearProgressSnapshot(jobId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasStalledProgress(job: InstallJobSnapshot): boolean {
|
||||
const snapshot = progressSnapshots.get(job.job_id)
|
||||
return !!snapshot && Date.now() - snapshot.changedAt >= copyDetailsStallMs
|
||||
}
|
||||
|
||||
function shouldShowCopyDetails(job: InstallJobSnapshot): boolean {
|
||||
return isTerminalJob(job) || (canShowStalledProgressDetails(job) && hasStalledProgress(job))
|
||||
}
|
||||
|
||||
function isCopied(job: InstallJobSnapshot): boolean {
|
||||
return copiedJobIds.value.has(job.job_id)
|
||||
}
|
||||
|
||||
function setCopied(job: InstallJobSnapshot) {
|
||||
copiedJobIds.value = new Set([...copiedJobIds.value, job.job_id])
|
||||
const existingTimeout = copiedResetTimeouts.get(job.job_id)
|
||||
if (existingTimeout != null) {
|
||||
window.clearTimeout(existingTimeout)
|
||||
}
|
||||
copiedResetTimeouts.set(
|
||||
job.job_id,
|
||||
window.setTimeout(() => {
|
||||
copiedResetTimeouts.delete(job.job_id)
|
||||
if (!copiedJobIds.value.has(job.job_id)) {
|
||||
return
|
||||
}
|
||||
const nextCopiedJobIds = new Set(copiedJobIds.value)
|
||||
nextCopiedJobIds.delete(job.job_id)
|
||||
copiedJobIds.value = nextCopiedJobIds
|
||||
opts.onChange()
|
||||
}, 1_000),
|
||||
)
|
||||
opts.onChange()
|
||||
}
|
||||
|
||||
async function copyJobDetails(job: InstallJobSnapshot) {
|
||||
const details = await install_job_support_details(job.job_id).catch((error) => {
|
||||
opts.handleError(error)
|
||||
return null
|
||||
})
|
||||
if (!details) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(details)
|
||||
setCopied(job)
|
||||
} catch (error) {
|
||||
opts.handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function getButtons(job: InstallJobSnapshot): PopupNotificationButton[] {
|
||||
const buttons: PopupNotificationButton[] = []
|
||||
|
||||
if (isTerminalJob(job)) {
|
||||
buttons.push({
|
||||
label: formatMessage(messages.retry),
|
||||
icon: UpdatedIcon,
|
||||
color: 'brand',
|
||||
@@ -248,8 +581,23 @@ export async function useInstallJobNotifications(opts: {
|
||||
await install_job_retry(job.job_id).catch(opts.handleError)
|
||||
await refresh()
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowCopyDetails(job)) {
|
||||
const copied = isCopied(job)
|
||||
buttons.push({
|
||||
label: formatMessage(copied ? messages.copied : messages.copyDetails),
|
||||
icon: copied ? CheckIcon : CopyIcon,
|
||||
color: 'standard',
|
||||
keepOpen: true,
|
||||
action: async () => {
|
||||
await copyJobDetails(job)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return buttons
|
||||
}
|
||||
|
||||
function setJobs(nextJobs: InstallJobSnapshot[]) {
|
||||
@@ -259,13 +607,15 @@ export async function useInstallJobNotifications(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
jobs.value = nextJobs
|
||||
.filter((job) => visibleJobStatuses.has(job.status))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.created.localeCompare(b.created) ||
|
||||
(jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0),
|
||||
)
|
||||
const visibleJobs = nextJobs.filter((job) => visibleJobStatuses.has(job.status))
|
||||
syncProgressSnapshots(visibleJobs)
|
||||
|
||||
jobs.value = visibleJobs.sort(
|
||||
(a, b) =>
|
||||
getJobSortRank(a) - getJobSortRank(b) ||
|
||||
a.created.localeCompare(b.created) ||
|
||||
(jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0),
|
||||
)
|
||||
}
|
||||
|
||||
const progressItems = computed<PopupNotificationProgressItem[]>(() =>
|
||||
@@ -284,7 +634,7 @@ export async function useInstallJobNotifications(opts: {
|
||||
progressType: isTerminalJob(job) ? undefined : getProgressType(job),
|
||||
progressCurrent: isTerminalJob(job) ? undefined : progress?.current,
|
||||
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
|
||||
buttons: getTerminalButtons(job),
|
||||
buttons: getButtons(job),
|
||||
onDismiss: isTerminalJob(job)
|
||||
? async () => {
|
||||
await install_job_dismiss(job.job_id).catch(opts.handleError)
|
||||
@@ -382,6 +732,14 @@ export async function useInstallJobNotifications(opts: {
|
||||
progressItems,
|
||||
buttons,
|
||||
refresh,
|
||||
dispose: () => unlisten(),
|
||||
dispose: () => {
|
||||
for (const timeout of copiedResetTimeouts.values()) {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
for (const jobId of progressSnapshots.keys()) {
|
||||
clearProgressSnapshot(jobId)
|
||||
}
|
||||
unlisten()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,18 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
import { onUnmounted, ref, shallowRef } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
import {
|
||||
fetchCachedServerStatus,
|
||||
getFreshCachedServerStatus,
|
||||
} from '@/composables/instances/use-server-status-query'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { kill, list as listInstances } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { add_server_to_instance, getServerAddress, getServerLatency } from '@/helpers/worlds'
|
||||
import { add_server_to_instance, getServerAddress } from '@/helpers/worlds'
|
||||
|
||||
interface BrowseServerInstance {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
@@ -68,12 +73,10 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
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 serverPingsActive = true
|
||||
let unlistenProcesses: (() => void) | null = null
|
||||
|
||||
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
||||
@@ -146,37 +149,26 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
})
|
||||
const nextPings = { ...serverPings.value }
|
||||
for (const { hit, address } of pingsToFetch) {
|
||||
if (serverPingCache.has(address)) {
|
||||
nextPings[hit.project_id] = serverPingCache.get(address)
|
||||
const cachedStatus = getFreshCachedServerStatus(queryClient, address)
|
||||
if (cachedStatus) {
|
||||
nextPings[hit.project_id] = cachedStatus.ping
|
||||
}
|
||||
}
|
||||
serverPings.value = nextPings
|
||||
|
||||
await Promise.all(
|
||||
pingsToFetch.map(async ({ hit, address }) => {
|
||||
if (serverPingCache.has(address)) return
|
||||
if (getFreshCachedServerStatus(queryClient, 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)
|
||||
try {
|
||||
const status = await fetchCachedServerStatus(queryClient, address)
|
||||
if (!serverPingsActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
|
||||
} catch (error) {
|
||||
console.error(`Failed to ping server ${address}:`, error)
|
||||
if (!serverPingsActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: undefined }
|
||||
}
|
||||
|
||||
const latency = await pending
|
||||
if (!serverPingCacheActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -308,10 +300,8 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
.catch(options.handleError)
|
||||
|
||||
onUnmounted(() => {
|
||||
serverPingCacheActive = false
|
||||
serverPingsActive = false
|
||||
unlistenProcesses?.()
|
||||
serverPingCache.clear()
|
||||
pendingServerPings.clear()
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { QueryClient } from '@tanstack/vue-query'
|
||||
|
||||
import {
|
||||
get_server_status,
|
||||
normalizeServerAddress,
|
||||
type ProtocolVersion,
|
||||
type ServerStatus,
|
||||
} from '@/helpers/worlds'
|
||||
|
||||
export const SERVER_STATUS_CACHE_MS = 10 * 60 * 1000
|
||||
|
||||
function getProtocolVersionKey(protocolVersion: ProtocolVersion | null) {
|
||||
if (!protocolVersion) return 'default'
|
||||
return `${protocolVersion.version}:${protocolVersion.legacy ? 'legacy' : 'modern'}`
|
||||
}
|
||||
|
||||
export function getServerStatusQueryKey(
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
) {
|
||||
return [
|
||||
'minecraft-server-status',
|
||||
normalizeServerAddress(address) || address.trim().toLowerCase(),
|
||||
getProtocolVersionKey(protocolVersion),
|
||||
] as const
|
||||
}
|
||||
|
||||
export function getFreshCachedServerStatus(
|
||||
queryClient: QueryClient,
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
) {
|
||||
const queryKey = getServerStatusQueryKey(address, protocolVersion)
|
||||
const updatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0
|
||||
if (!updatedAt || Date.now() - updatedAt >= SERVER_STATUS_CACHE_MS) return undefined
|
||||
return queryClient.getQueryData<ServerStatus>(queryKey)
|
||||
}
|
||||
|
||||
export async function fetchCachedServerStatus(
|
||||
queryClient: QueryClient,
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
) {
|
||||
return await queryClient.fetchQuery({
|
||||
queryKey: getServerStatusQueryKey(address, protocolVersion),
|
||||
queryFn: () => get_server_status(address, protocolVersion),
|
||||
staleTime: SERVER_STATUS_CACHE_MS,
|
||||
gcTime: SERVER_STATUS_CACHE_MS,
|
||||
})
|
||||
}
|
||||
@@ -85,6 +85,36 @@ export type InstallJavaStep =
|
||||
| 'extracting'
|
||||
| 'validating'
|
||||
|
||||
export interface InstallErrorView {
|
||||
code: string
|
||||
phase?: InstallPhaseId | null
|
||||
message: string
|
||||
api?: {
|
||||
error: string
|
||||
status?: number | null
|
||||
method?: string | null
|
||||
url?: string | null
|
||||
route?: string | null
|
||||
} | null
|
||||
context?: {
|
||||
operation: string
|
||||
source_path?: string | null
|
||||
target_path?: string | null
|
||||
file_path?: string | null
|
||||
entry_path?: string | null
|
||||
urls?: string[]
|
||||
expected_hash?: string | null
|
||||
expected_size?: number | null
|
||||
project_id?: string | null
|
||||
version_id?: string | null
|
||||
minecraft_version?: string | null
|
||||
loader?: string | null
|
||||
java_version?: number | null
|
||||
os?: string | null
|
||||
arch?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface InstallJobSnapshot {
|
||||
job_id: string
|
||||
instance_id?: string | null
|
||||
@@ -114,7 +144,8 @@ export interface InstallJobSnapshot {
|
||||
}
|
||||
| { type: 'import'; launcher_type: string; instance_folder: string }
|
||||
display?: { title: string; icon?: string | null } | null
|
||||
error?: { code: string; message: string } | null
|
||||
error?: InstallErrorView | null
|
||||
rollback_error?: InstallErrorView | null
|
||||
created: string
|
||||
modified: string
|
||||
finished?: string | null
|
||||
@@ -197,6 +228,10 @@ export async function install_job_dismiss(jobId: string) {
|
||||
return await invoke<void>('plugin:install|install_job_dismiss', { jobId })
|
||||
}
|
||||
|
||||
export async function install_job_support_details(jobId: string) {
|
||||
return await invoke<string>('plugin:install|install_job_support_details', { jobId })
|
||||
}
|
||||
|
||||
export function installJobInstanceId(job: InstallJobSnapshot): string | null {
|
||||
return job.instance_id ?? job.target.instance_id ?? null
|
||||
}
|
||||
|
||||
+7
@@ -133,11 +133,18 @@ type Hooks = {
|
||||
|
||||
type Manifest = {
|
||||
gameVersions: ManifestGameVersion[]
|
||||
versionGroups?: ManifestVersionGroup[]
|
||||
}
|
||||
|
||||
type ManifestGameVersion = {
|
||||
id: string
|
||||
stable: boolean
|
||||
versionGroup?: string
|
||||
loaders: ManifestLoaderVersion[]
|
||||
}
|
||||
|
||||
type ManifestVersionGroup = {
|
||||
id: string
|
||||
loaders: ManifestLoaderVersion[]
|
||||
}
|
||||
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "Zavřít"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "Instalace selhala"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "Instalace selhala kvůli zavření aplikace."
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "Instalace selhala kvůli chybě sítě."
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "Instalace selhala kvůli neznámé chybě."
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "Otevřít instanci"
|
||||
},
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "Ignorieren"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "Installation fehlgeschlagen"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "Installation aufgrund des Schließens der App fehlgeschlagen."
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "Installation aufgrund eines Netzwerkfehlers fehlgeschlagen."
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "Installation aufgrund eines unbekannten Fehlers fehlgeschlagen."
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "Instanz öffnen"
|
||||
},
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "Ausblenden"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "Installation fehlgeschlagen"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "Installation aufgrund des Schließens der App fehlgeschlagen."
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "Installation aufgrund eines Netzwerkfehlers fehlgeschlagen."
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "Installation aufgrund eines unbekannten Fehlers fehlgeschlagen."
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "Instanz öffnen"
|
||||
},
|
||||
|
||||
@@ -11,27 +11,81 @@
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "Hide more running instances"
|
||||
},
|
||||
"app.action-bar.install.copied-details": {
|
||||
"message": "Copied"
|
||||
},
|
||||
"app.action-bar.install.copy-details": {
|
||||
"message": "Copy details"
|
||||
},
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "Dismiss"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "Install failed"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "Installation failed due to app closing."
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "Installation failed due to a network error."
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "Installation failed due to an unknown error."
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "Open instance"
|
||||
},
|
||||
"app.action-bar.install.retry": {
|
||||
"message": "Retry"
|
||||
},
|
||||
"app.action-bar.install.summary.app-closing": {
|
||||
"message": "Canceled due to app closing"
|
||||
},
|
||||
"app.action-bar.install.summary.bad-modpack-file": {
|
||||
"message": "Couldn't read modpack"
|
||||
},
|
||||
"app.action-bar.install.summary.canceled": {
|
||||
"message": "Canceled"
|
||||
},
|
||||
"app.action-bar.install.summary.cleanup-incomplete": {
|
||||
"message": "Cleanup didn't finish"
|
||||
},
|
||||
"app.action-bar.install.summary.content-download-failed": {
|
||||
"message": "Couldn't download files"
|
||||
},
|
||||
"app.action-bar.install.summary.corrupt-download": {
|
||||
"message": "Downloaded file is corrupt"
|
||||
},
|
||||
"app.action-bar.install.summary.could-not-save-files": {
|
||||
"message": "Couldn't save files"
|
||||
},
|
||||
"app.action-bar.install.summary.download-failed": {
|
||||
"message": "Download couldn't finish"
|
||||
},
|
||||
"app.action-bar.install.summary.instance-not-found": {
|
||||
"message": "Instance couldn't be found"
|
||||
},
|
||||
"app.action-bar.install.summary.invalid-file-path": {
|
||||
"message": "File path is invalid"
|
||||
},
|
||||
"app.action-bar.install.summary.invalid-modpack": {
|
||||
"message": "Modpack data invalid"
|
||||
},
|
||||
"app.action-bar.install.summary.invalid-modpack-files": {
|
||||
"message": "Modpack files have invalid metadata"
|
||||
},
|
||||
"app.action-bar.install.summary.java-setup-failed": {
|
||||
"message": "Java setup couldn't finish"
|
||||
},
|
||||
"app.action-bar.install.summary.loader-setup-failed": {
|
||||
"message": "Loader setup failed"
|
||||
},
|
||||
"app.action-bar.install.summary.local-data-error": {
|
||||
"message": "Couldn't update local data"
|
||||
},
|
||||
"app.action-bar.install.summary.minecraft-setup-failed": {
|
||||
"message": "Minecraft setup failed"
|
||||
},
|
||||
"app.action-bar.install.summary.modrinth-unreachable": {
|
||||
"message": "Couldn't reach Modrinth"
|
||||
},
|
||||
"app.action-bar.install.summary.no-write-permission": {
|
||||
"message": "No permission to write"
|
||||
},
|
||||
"app.action-bar.install.summary.pack-download-failed": {
|
||||
"message": "Couldn't download pack"
|
||||
},
|
||||
"app.action-bar.install.summary.unexpected-error": {
|
||||
"message": "Something went wrong"
|
||||
},
|
||||
"app.action-bar.install.unknown-instance": {
|
||||
"message": "Unknown instance"
|
||||
},
|
||||
@@ -231,7 +285,7 @@
|
||||
"message": "Finalizing"
|
||||
},
|
||||
"app.install.phase.preparing_instance": {
|
||||
"message": "Preparing instance"
|
||||
"message": "Queued to install"
|
||||
},
|
||||
"app.install.phase.preparing_java": {
|
||||
"message": "Preparing Java"
|
||||
@@ -410,12 +464,27 @@
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "This project is already installed"
|
||||
},
|
||||
"app.project.install-button.switch-version": {
|
||||
"message": "Switch version"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Back to discover"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Install content to instance"
|
||||
},
|
||||
"app.project.version.all-versions": {
|
||||
"message": "All versions"
|
||||
},
|
||||
"app.project.version.download-in-browser": {
|
||||
"message": "Download in browser"
|
||||
},
|
||||
"app.project.version.installing": {
|
||||
"message": "Installing"
|
||||
},
|
||||
"app.project.versions.already-installed": {
|
||||
"message": "Already installed"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Developer mode enabled."
|
||||
},
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "Descartar"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "La instalación falló"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "La instalación falló porque la aplicación se cerró."
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "La instalación falló debido a un error de red."
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "La instalación falló debido a un error desconocido."
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "Abrir instancia"
|
||||
},
|
||||
|
||||
@@ -14,15 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "Descartar"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "La instalación falló"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "La instalación falló porque la aplicación se cerró."
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "La instalación falló debido a un error de red."
|
||||
},
|
||||
"app.action-bar.make-primary-instance": {
|
||||
"message": "Establecer como instancia principal"
|
||||
},
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "Hylkää"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "Asennus epäonnistui"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "Asennus epäonnistui sovelluksen sulkeutumisen vuoksi."
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "Asennus epäonnistui verkkovirheen vuoksi."
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "Asennus epäonnistui tuntemattoman virheen vuoksi."
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "Avaa instanssi"
|
||||
},
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "Rejeter"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "Échec de l'installation"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "Échec de l'installation dû à la fermeture de l'app."
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "Échec de l'installation dû à une erreur réseau."
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "Échec de l'installation dû à une erreur inconnue."
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "Ouvrir l'instance"
|
||||
},
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "Ignora"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "Installazione fallita"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "Installazione fallita: l'app è stata chiusa."
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "Installazione fallita: errore di rete."
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "Installazione fallita: errore sconosciuto."
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "Apri istanza"
|
||||
},
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "Odrzuć"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "Instalacja nie powiodła się"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "Instalacja nie powiodła się z powodu zamknięcia aplikacji."
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "Instalacja nie powiodła się z powodu problemów z połączeniem."
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "Instalacja nie powiodła się z nieznanych przyczyn."
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "Otwórz instancję"
|
||||
},
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "Dispensar"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "Instalação falha"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "A instalação falhou devido ao aplicativo ter fechado."
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "A instalação falhou devido a um erro na rede."
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "A instalação falhou devido a um erro desconhecido."
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "Abrir instância"
|
||||
},
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "Закрыть"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "Установка не удалась"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "Установка не удалась из-за закрытия приложения."
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "Установка не удалась из-за сетевой ошибки."
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "Установка не удалась из-за неизвестной ошибки."
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "Открыть сборку"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
{
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "Neuspešna instalacija"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "Neuspešna instalacija zbog zatvaranja aplikacije."
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "Minecraft serveri za autentifikaciju su možda trenutno nedostupni. Molimo vas da proverite vašu internet vezu i pokušajte ponovo kasnije."
|
||||
},
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "Avvisa"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "Installationen misslyckades"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "Installationen misslyckades för att appen stängdes."
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "Installationen misslyckades av ett nätverksfel."
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "Installationen misslyckades med ett okänt fel."
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "Öppna instans"
|
||||
},
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "Reddet"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "Yükleme yapılamadı"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "Yükleme uygulama kapandığından yapılamadı."
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "Yükleme ağ hatasından dolayı yapılamadı."
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "Yükleme bilinmeyen bir hatadan yapılamadı."
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "Kurulumu aç"
|
||||
},
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "忽略"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "安装失败"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "由于应用关闭,安装失败。"
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "由于网络错误,安装失败。"
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "由于未知错误,安装失败。"
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "打开实例"
|
||||
},
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
"app.action-bar.install.dismiss": {
|
||||
"message": "忽略"
|
||||
},
|
||||
"app.action-bar.install.failed": {
|
||||
"message": "無法安裝"
|
||||
},
|
||||
"app.action-bar.install.failed-app-closed": {
|
||||
"message": "由於應用程式關閉,無法安裝。"
|
||||
},
|
||||
"app.action-bar.install.failed-network": {
|
||||
"message": "由於網路錯誤,無法安裝。"
|
||||
},
|
||||
"app.action-bar.install.failed-unknown": {
|
||||
"message": "由於未知錯誤,無法安裝。"
|
||||
},
|
||||
"app.action-bar.install.open-instance": {
|
||||
"message": "開啟實例"
|
||||
},
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
preferencesDiffer,
|
||||
provideBrowseManager,
|
||||
requestInstall,
|
||||
stripServerRuntimeInstallFilters,
|
||||
stripServerRuntimeInstallOverrides,
|
||||
useBrowseSearch,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
@@ -47,6 +49,7 @@ import {
|
||||
get_installed_project_ids as getInstalledProjectIds,
|
||||
} from '@/helpers/instance'
|
||||
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { get_instance_worlds } from '@/helpers/worlds'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
@@ -680,7 +683,6 @@ async function chooseInstanceInstallVersion(
|
||||
const selectedVersion = getLatestMatchingInstallVersion(
|
||||
await getInstallProjectVersions(project.project_id),
|
||||
selectedPreferences,
|
||||
projectTypeValue,
|
||||
)
|
||||
|
||||
if (!selectedVersion) {
|
||||
@@ -763,11 +765,15 @@ function getCardActions(
|
||||
project: projectResult,
|
||||
contentType,
|
||||
mode: isModpack ? 'immediate' : 'queue',
|
||||
selectedFilters: isModpack ? [] : searchState.currentFilters.value,
|
||||
selectedFilters: isModpack
|
||||
? []
|
||||
: stripServerRuntimeInstallFilters(searchState.currentFilters.value),
|
||||
providedFilters: isModpack ? [] : combinedProvidedFilters.value,
|
||||
overriddenProvidedFilterTypes: isModpack
|
||||
? []
|
||||
: searchState.overriddenProvidedFilterTypes.value,
|
||||
: stripServerRuntimeInstallOverrides(
|
||||
searchState.overriddenProvidedFilterTypes.value,
|
||||
),
|
||||
targetPreferences: getServerInstallTargetPreferences(contentType),
|
||||
getProjectVersions: getInstallProjectVersions,
|
||||
queue: serverInstallQueue,
|
||||
@@ -878,7 +884,7 @@ async function search(requestParams: string) {
|
||||
const rawResults = await queryClient.fetchQuery({
|
||||
queryKey: ['search', 'v3', requestParams],
|
||||
queryFn: () =>
|
||||
get_search_results_v3(requestParams) as Promise<{
|
||||
get_search_results_v3(requestParams, 'must_revalidate') as Promise<{
|
||||
result: Labrinth.Search.v3.SearchResults & {
|
||||
hits: (Labrinth.Search.v3.ResultSearchProject & { installed?: boolean })[]
|
||||
}
|
||||
@@ -1036,10 +1042,24 @@ function getProjectBrowseQuery() {
|
||||
}
|
||||
}
|
||||
|
||||
const advancedFiltersCollapsed = computed({
|
||||
get: () => themeStore.getFeatureFlag('advanced_filters_collapsed'),
|
||||
set: (value) => {
|
||||
themeStore.featureFlags['advanced_filters_collapsed'] = value
|
||||
getSettings()
|
||||
.then((settings) => {
|
||||
settings.feature_flags['advanced_filters_collapsed'] = value
|
||||
return setSettings(settings)
|
||||
})
|
||||
.catch(handleError)
|
||||
},
|
||||
})
|
||||
|
||||
provideBrowseManager({
|
||||
tags,
|
||||
projectType,
|
||||
...searchState,
|
||||
advancedFiltersCollapsed,
|
||||
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => ({
|
||||
path: `/project/${result.project_id ?? result.slug}`,
|
||||
query: getProjectBrowseQuery(),
|
||||
|
||||
@@ -317,6 +317,10 @@ import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ExportModal from '@/components/ui/ExportModal.vue'
|
||||
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import {
|
||||
fetchCachedServerStatus,
|
||||
getFreshCachedServerStatus,
|
||||
} from '@/composables/instances/use-server-status-query'
|
||||
import { useInstanceConsole } from '@/composables/useInstanceConsole'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_v3 } from '@/helpers/cache.js'
|
||||
@@ -327,7 +331,7 @@ import { type InstanceContentData, loadInstanceContentData } from '@/helpers/ins
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { get_server_status, refreshWorlds } from '@/helpers/worlds'
|
||||
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { useBreadcrumbs, useTheming } from '@/store/state'
|
||||
@@ -372,13 +376,15 @@ const selected = ref<unknown[]>([])
|
||||
|
||||
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
|
||||
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
|
||||
const statusOnline = computed(() => !!javaServerPingData.value)
|
||||
const liveServerStatusOnline = ref(false)
|
||||
const statusOnline = computed(() => liveServerStatusOnline.value || !!javaServerPingData.value)
|
||||
const recentPlays = computed(
|
||||
() => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined,
|
||||
)
|
||||
const playersOnline = ref<number | undefined>(undefined)
|
||||
const ping = ref<number | undefined>(undefined)
|
||||
const loadingServerPing = ref(false)
|
||||
const activeInstanceId = ref<string>()
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value,
|
||||
@@ -390,6 +396,20 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function applyServerStatus(status: ServerStatus) {
|
||||
playersOnline.value = status.players?.online
|
||||
ping.value = status.ping
|
||||
liveServerStatusOnline.value = true
|
||||
loadingServerPing.value = true
|
||||
}
|
||||
|
||||
function resetServerStatus() {
|
||||
ping.value = undefined
|
||||
playersOnline.value = undefined
|
||||
liveServerStatusOnline.value = false
|
||||
loadingServerPing.value = false
|
||||
}
|
||||
|
||||
function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
|
||||
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
|
||||
}
|
||||
@@ -398,9 +418,7 @@ async function fetchInstance() {
|
||||
isServerInstance.value = false
|
||||
linkedProjectV3.value = undefined
|
||||
preloadedContent.value = null
|
||||
ping.value = undefined
|
||||
playersOnline.value = undefined
|
||||
loadingServerPing.value = false
|
||||
resetServerStatus()
|
||||
|
||||
const nextInstance = await get(route.params.id as string).catch(handleError)
|
||||
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
|
||||
@@ -429,8 +447,9 @@ async function fetchInstance() {
|
||||
linkedProjectV3.value = nextLinkedProjectV3
|
||||
isServerInstance.value = nextIsServerInstance
|
||||
preloadedContent.value = nextPreloadedContent
|
||||
activeInstanceId.value = nextInstance?.id
|
||||
|
||||
fetchDeferredData()
|
||||
fetchDeferredData(nextInstance?.id)
|
||||
|
||||
if (nextInstance) {
|
||||
queryClient.prefetchQuery({
|
||||
@@ -441,18 +460,32 @@ async function fetchInstance() {
|
||||
}
|
||||
}
|
||||
|
||||
function fetchDeferredData() {
|
||||
function fetchDeferredData(instanceId?: string) {
|
||||
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
|
||||
if (isServerInstance.value && serverAddress) {
|
||||
get_server_status(serverAddress)
|
||||
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
|
||||
if (cachedStatus) {
|
||||
applyServerStatus(cachedStatus)
|
||||
} else {
|
||||
playersOnline.value = undefined
|
||||
ping.value = undefined
|
||||
loadingServerPing.value = false
|
||||
}
|
||||
|
||||
fetchCachedServerStatus(queryClient, serverAddress)
|
||||
.then((status) => {
|
||||
playersOnline.value = status.players?.online
|
||||
ping.value = status.ping
|
||||
if (
|
||||
activeInstanceId.value !== instanceId ||
|
||||
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
|
||||
)
|
||||
return
|
||||
applyServerStatus(status)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeInstanceId.value !== instanceId) return
|
||||
loadingServerPing.value = true
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -1351,7 +1351,9 @@ provideContentManager({
|
||||
title: item.file_name.replace('.disabled', ''),
|
||||
icon_url: null,
|
||||
},
|
||||
projectLink: item.project?.id ? { path: `/project/${item.project.id}` } : undefined,
|
||||
projectLink: item.project?.id
|
||||
? { path: `/project/${item.project.id}`, query: { i: props.instance.id } }
|
||||
: undefined,
|
||||
version: item.version ?? {
|
||||
id: item.file_name,
|
||||
version_number: formatMessage(commonMessages.unknownLabel),
|
||||
@@ -1361,6 +1363,7 @@ provideContentManager({
|
||||
item.project?.id && item.version?.id
|
||||
? {
|
||||
path: `/project/${item.project.id}/version/${item.version.id}`,
|
||||
query: { i: props.instance.id },
|
||||
}
|
||||
: undefined,
|
||||
owner: item.owner
|
||||
|
||||
@@ -116,13 +116,27 @@
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
|
||||
<template #open-in-browser>
|
||||
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
|
||||
</template>
|
||||
<template #report> <ReportIcon /> Report </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else #actions>
|
||||
<ButtonStyled size="large" color="brand">
|
||||
<ButtonStyled v-if="showSwitchVersion && onVersionsPage" size="large">
|
||||
<button v-tooltip="installButtonTooltip" disabled>
|
||||
<CheckIcon />
|
||||
{{ formatMessage(commonMessages.installedLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="showSwitchVersion" size="large">
|
||||
<button @click="goToVersions">
|
||||
<SwapIcon />
|
||||
{{ formatMessage(messages.switchVersion) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else size="large" color="brand">
|
||||
<button
|
||||
v-tooltip="installButtonTooltip"
|
||||
:disabled="installButtonDisabled"
|
||||
@@ -171,7 +185,9 @@
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
|
||||
<template #open-in-browser>
|
||||
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
|
||||
</template>
|
||||
<template #follow> <HeartIcon /> Follow </template>
|
||||
<template #save> <BookmarkIcon /> Save </template>
|
||||
<template #report> <ReportIcon /> Report </template>
|
||||
@@ -286,6 +302,7 @@ import {
|
||||
SelectedProjectsFloatingBar,
|
||||
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 dayjs from 'dayjs'
|
||||
@@ -293,8 +310,13 @@ import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { SwapIcon } from '@/assets/icons/index.js'
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import {
|
||||
fetchCachedServerStatus,
|
||||
getFreshCachedServerStatus,
|
||||
} from '@/composables/instances/use-server-status-query'
|
||||
import {
|
||||
get_organization,
|
||||
get_project,
|
||||
@@ -313,7 +335,7 @@ import {
|
||||
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { getServerAddress, getServerLatency } from '@/helpers/worlds'
|
||||
import { getServerAddress } from '@/helpers/worlds'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { createServerInstallContent } from '@/providers/setup/server-install-content'
|
||||
@@ -326,6 +348,7 @@ const { handleError } = injectNotificationManager()
|
||||
const { install: installVersion } = injectContentInstall()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const themeStore = useTheming()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -343,6 +366,10 @@ const messages = defineMessages({
|
||||
id: 'app.project.install-button.already-installed',
|
||||
defaultMessage: 'This project is already installed',
|
||||
},
|
||||
switchVersion: {
|
||||
id: 'app.project.install-button.switch-version',
|
||||
defaultMessage: 'Switch version',
|
||||
},
|
||||
})
|
||||
|
||||
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
|
||||
@@ -514,6 +541,13 @@ const installButtonTooltip = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
const showSwitchVersion = computed(() => !!instance.value && installed.value)
|
||||
const onVersionsPage = computed(() => route.name === 'Versions')
|
||||
|
||||
function goToVersions() {
|
||||
router.push(versionsHref.value)
|
||||
}
|
||||
|
||||
const [allLoaders, allGameVersions] = await Promise.all([
|
||||
get_loaders().catch(handleError).then(ref),
|
||||
get_game_versions().catch(handleError).then(ref),
|
||||
@@ -600,10 +634,19 @@ async function fetchProjectData() {
|
||||
function fetchDeferredServerData(project) {
|
||||
const serverAddress = projectV3.value?.minecraft_java_server?.address
|
||||
if (serverAddress) {
|
||||
serverPing.value = undefined
|
||||
getServerLatency(serverAddress)
|
||||
.then((latency) => {
|
||||
serverPing.value = latency
|
||||
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
|
||||
if (cachedStatus) {
|
||||
serverPing.value = cachedStatus.ping
|
||||
serverStatusOnline.value = true
|
||||
} else {
|
||||
serverPing.value = undefined
|
||||
}
|
||||
|
||||
fetchCachedServerStatus(queryClient, serverAddress)
|
||||
.then((status) => {
|
||||
if (projectV3.value?.minecraft_java_server?.address !== serverAddress) return
|
||||
serverPing.value = status.ping
|
||||
serverStatusOnline.value = true
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to ping server ${serverAddress}:`, error)
|
||||
|
||||
@@ -1,259 +1,152 @@
|
||||
<template>
|
||||
<div>
|
||||
<Card>
|
||||
<Breadcrumbs
|
||||
:current-title="version.name"
|
||||
:link-stack="[
|
||||
{
|
||||
href: buildProjectHref(`/project/${route.params.id}/versions`),
|
||||
label: 'Versions',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div class="version-title">
|
||||
<h2>{{ version.name }}</h2>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<div class="flex flex-col">
|
||||
<router-link
|
||||
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
|
||||
:to="buildProjectHref(`/project/${route.params.id}/versions`)"
|
||||
>
|
||||
<ChevronLeftIcon class="shrink-0" /> {{ formatMessage(messages.allVersions) }}
|
||||
</router-link>
|
||||
<VersionPage
|
||||
v-if="version"
|
||||
:version="version"
|
||||
:enrichment="enrichment"
|
||||
:enrichment-loading="enrichmentLoading"
|
||||
:members="members"
|
||||
:dependency-link-creator="createDependencyLink"
|
||||
>
|
||||
<template #headerActions>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="installing || (installed && installedVersion === version.id)"
|
||||
@click="() => install(version.id)"
|
||||
@click="() => version && install(version.id)"
|
||||
>
|
||||
<DownloadIcon v-if="!installed" />
|
||||
<SwapIcon v-else-if="installedVersion !== version.id" />
|
||||
<CheckIcon v-else />
|
||||
{{
|
||||
installing
|
||||
? 'Installing...'
|
||||
? formatMessage(messages.installing)
|
||||
: installed && installedVersion === version.id
|
||||
? 'Installed'
|
||||
: 'Install'
|
||||
? formatMessage(commonMessages.installedLabel)
|
||||
: installed
|
||||
? formatMessage(commonMessages.switchToVersionButton)
|
||||
: formatMessage(commonMessages.installButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button>
|
||||
<ReportIcon />
|
||||
Report
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
:href="`https://modrinth.com/mod/${route.params.id}/version/${route.params.version}`"
|
||||
rel="external"
|
||||
<ButtonStyled type="outlined" circular>
|
||||
<OverflowMenu
|
||||
v-tooltip="formatMessage(commonMessages.moreOptionsButton)"
|
||||
:options="[
|
||||
{
|
||||
id: 'open-in-browser',
|
||||
link: `https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
color: 'red',
|
||||
hoverFilled: true,
|
||||
link: `https://modrinth.com/report?item=version&itemID=${version.id}`,
|
||||
external: true,
|
||||
},
|
||||
]"
|
||||
aria-label="More options"
|
||||
>
|
||||
Modrinth website
|
||||
<ExternalIcon />
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #open-in-browser>
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.openInBrowserButton) }}
|
||||
</template>
|
||||
<template #report>
|
||||
<ReportIcon aria-hidden="true" /> {{ formatMessage(commonMessages.reportButton) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template #supplementaryResourceActions="{ file }">
|
||||
<ButtonStyled>
|
||||
<a :href="file.url" :download="file.filename" target="_blank">
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.downloadInBrowser) }}
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</Card>
|
||||
<div class="version-container">
|
||||
<div class="description-cards">
|
||||
<Card>
|
||||
<h3 class="card-title">Changelog</h3>
|
||||
<div class="markdown-body" v-html="renderString(version.changelog ?? '')" />
|
||||
</Card>
|
||||
<Card>
|
||||
<h3 class="card-title">Files</h3>
|
||||
<Card
|
||||
v-for="file in version.files"
|
||||
:key="file.id"
|
||||
:class="{ primary: file.primary }"
|
||||
class="file"
|
||||
>
|
||||
<span class="label">
|
||||
<FileIcon />
|
||||
<span>
|
||||
<span class="title">
|
||||
{{ file.filename }}
|
||||
</span>
|
||||
({{ formatBytes(file.size) }})
|
||||
<span v-if="file.primary" class="primary-label"> Primary </span>
|
||||
</span>
|
||||
</span>
|
||||
<ButtonStyled v-if="project.project_type !== 'modpack' || file.primary" color="brand">
|
||||
<button class="download" :disabled="installed" @click="() => install(version.id)">
|
||||
<DownloadIcon v-if="!installed" />
|
||||
<CheckIcon v-else />
|
||||
{{ installed ? 'Installed' : 'Install' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</Card>
|
||||
</Card>
|
||||
<Card v-if="displayDependencies.length > 0">
|
||||
<h2>Dependencies</h2>
|
||||
<div v-for="dependency in displayDependencies" :key="dependency.title">
|
||||
<router-link v-if="dependency.link" class="btn dependency" :to="dependency.link">
|
||||
<Avatar size="sm" :src="dependency.icon" />
|
||||
<div>
|
||||
<span class="title"> {{ dependency.title }} </span> <br />
|
||||
<span> {{ dependency.subtitle }} </span>
|
||||
</div>
|
||||
</router-link>
|
||||
<div v-else class="dependency disabled" disabled="">
|
||||
<Avatar size="sm" :src="dependency.icon" />
|
||||
<div class="text">
|
||||
<div class="title">{{ dependency.title }}</div>
|
||||
<div>{{ dependency.subtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card class="metadata-card">
|
||||
<h3 class="card-title">Metadata</h3>
|
||||
<div class="metadata">
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Release Channel</span>
|
||||
<span class="metadata-value"
|
||||
><Badge
|
||||
:color="releaseColor(version.version_type)"
|
||||
:type="
|
||||
version.version_type.charAt(0).toUpperCase() + version.version_type.slice(1)
|
||||
"
|
||||
/></span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Version Number</span>
|
||||
<span class="metadata-value">{{ version.version_number }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Loaders</span>
|
||||
<span class="metadata-value">{{
|
||||
version.loaders
|
||||
.map((loader) => loader.charAt(0).toUpperCase() + loader.slice(1))
|
||||
.join(', ')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Game Versions</span>
|
||||
<span class="metadata-value"> {{ version.game_versions.join(', ') }} </span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Downloads</span>
|
||||
<span class="metadata-value">{{ version.downloads }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Publication Date</span>
|
||||
<span class="metadata-value">
|
||||
{{ formatDateTime(version.date_published) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="author" class="metadata-item">
|
||||
<span class="metadata-label">Author</span>
|
||||
<a
|
||||
:href="`https://modrinth.com/user/${author.user.username}`"
|
||||
rel="external"
|
||||
class="metadata-value btn author"
|
||||
>
|
||||
<Avatar size="sm" :src="author.user.avatar_url" circle />
|
||||
<span>
|
||||
<strong>
|
||||
{{ author.user.username }}
|
||||
</strong>
|
||||
<br />
|
||||
{{ author.role }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Version ID</span>
|
||||
<span class="metadata-value"><CopyCode class="copycode" :text="version.id" /></span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
</VersionPage>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CheckIcon, DownloadIcon, ExternalIcon, FileIcon, ReportIcon } from '@modrinth/assets'
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
MoreVerticalIcon,
|
||||
ReportIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
ButtonStyled,
|
||||
Card,
|
||||
CopyCode,
|
||||
useFormatBytes,
|
||||
useFormatDateTime,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
type DependencyContext,
|
||||
OverflowMenu,
|
||||
useVIntl,
|
||||
VersionPage,
|
||||
} from '@modrinth/ui'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { SwapIcon } from '@/assets/icons'
|
||||
import { get_project_many, get_version_many } from '@/helpers/cache.js'
|
||||
import { releaseColor } from '@/helpers/utils'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
|
||||
const formatDateTime = useFormatDateTime({
|
||||
timeStyle: 'short',
|
||||
dateStyle: 'long',
|
||||
})
|
||||
const formatBytes = useFormatBytes()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
versions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
members: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
install: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
installed: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
const messages = defineMessages({
|
||||
allVersions: {
|
||||
id: 'app.project.version.all-versions',
|
||||
defaultMessage: 'All versions',
|
||||
},
|
||||
installing: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
id: 'app.project.version.installing',
|
||||
defaultMessage: 'Installing',
|
||||
},
|
||||
installedVersion: {
|
||||
type: String,
|
||||
required: true,
|
||||
downloadInBrowser: {
|
||||
id: 'app.project.version.download-in-browser',
|
||||
defaultMessage: 'Download in browser',
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps<{
|
||||
project: Labrinth.Projects.v2.Project
|
||||
versions: Labrinth.Versions.v3.Version[]
|
||||
members: Labrinth.Projects.v3.TeamMember[]
|
||||
install: (version: string | null) => void
|
||||
installed: boolean
|
||||
installing: boolean
|
||||
installedVersion: string
|
||||
}>()
|
||||
|
||||
const version = ref(props.versions.find((version) => version.id === route.params.version))
|
||||
breadcrumbs.setName('Version', version.value.name)
|
||||
if (version.value) {
|
||||
breadcrumbs.setName('Version', version.value.name)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.versions,
|
||||
async () => {
|
||||
if (route.params.version) {
|
||||
version.value = props.versions.find((version) => version.id === route.params.version)
|
||||
await refreshDisplayDependencies()
|
||||
breadcrumbs.setName('Version', version.value.name)
|
||||
}
|
||||
},
|
||||
)
|
||||
const enrichment = ref<Labrinth.Projects.v2.DependencyInfo | undefined>(undefined)
|
||||
const enrichmentLoading = ref(false)
|
||||
|
||||
const author = computed(() =>
|
||||
props.members ? props.members.find((member) => member.user.id === version.value.author_id) : null,
|
||||
)
|
||||
|
||||
const displayDependencies = ref({})
|
||||
|
||||
function buildProjectHref(path) {
|
||||
function buildProjectHref(path: string): string {
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, val] of Object.entries(route.query)) {
|
||||
if (Array.isArray(val)) {
|
||||
for (const v of val) params.append(key, v)
|
||||
for (const v of val) {
|
||||
if (v != null) params.append(key, v)
|
||||
}
|
||||
} else if (val) {
|
||||
params.append(key, String(val))
|
||||
}
|
||||
@@ -262,222 +155,65 @@ function buildProjectHref(path) {
|
||||
return qs ? `${path}?${qs}` : path
|
||||
}
|
||||
|
||||
async function refreshDisplayDependencies() {
|
||||
const projectIds = new Set()
|
||||
const versionIds = new Set()
|
||||
if (version.value.dependencies) {
|
||||
for (const dependency of version.value.dependencies) {
|
||||
if (dependency.project_id) {
|
||||
projectIds.add(dependency.project_id)
|
||||
}
|
||||
if (dependency.version_id) {
|
||||
versionIds.add(dependency.version_id)
|
||||
}
|
||||
}
|
||||
function createDependencyLink(context: DependencyContext): string | undefined {
|
||||
if (context.version) {
|
||||
return buildProjectHref(`/project/${context.version.project_id}/version/${context.version.id}`)
|
||||
}
|
||||
const [projectDeps, versionDeps] = await Promise.all([
|
||||
get_project_many([...projectIds]),
|
||||
get_version_many([...versionIds]),
|
||||
])
|
||||
|
||||
const dependencies = {
|
||||
projects: projectDeps,
|
||||
versions: versionDeps,
|
||||
if (context.project) {
|
||||
return buildProjectHref(`/project/${context.project.id}`)
|
||||
}
|
||||
|
||||
displayDependencies.value = version.value.dependencies.map((dependency) => {
|
||||
const version = dependencies.versions.find((obj) => obj.id === dependency.version_id)
|
||||
if (version) {
|
||||
const project = dependencies.projects.find(
|
||||
(obj) => obj.id === version.project_id || obj.id === dependency.project_id,
|
||||
)
|
||||
return {
|
||||
icon: project?.icon_url,
|
||||
title: project?.title || project?.name,
|
||||
subtitle: `Version ${version.version_number} is ${dependency.dependency_type}`,
|
||||
link: buildProjectHref(`/project/${project.slug}/version/${version.id}`),
|
||||
}
|
||||
} else {
|
||||
const project = dependencies.projects.find((obj) => obj.id === dependency.project_id)
|
||||
|
||||
if (project) {
|
||||
return {
|
||||
icon: project?.icon_url,
|
||||
title: project?.title || project?.name,
|
||||
subtitle: `${dependency.dependency_type}`,
|
||||
link: buildProjectHref(`/project/${project.slug}`),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
icon: null,
|
||||
title: dependency.file_name,
|
||||
subtitle: `Added via overrides`,
|
||||
link: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
await refreshDisplayDependencies()
|
||||
|
||||
async function refreshEnrichment() {
|
||||
if (!version.value) return
|
||||
|
||||
const projectIds = new Set<string>()
|
||||
const versionIds = new Set<string>()
|
||||
for (const dependency of version.value.dependencies ?? []) {
|
||||
if (dependency.project_id) {
|
||||
projectIds.add(dependency.project_id)
|
||||
}
|
||||
if (dependency.version_id) {
|
||||
versionIds.add(dependency.version_id)
|
||||
}
|
||||
}
|
||||
|
||||
if (projectIds.size === 0 && versionIds.size === 0) {
|
||||
enrichment.value = { projects: [], versions: [] }
|
||||
return
|
||||
}
|
||||
|
||||
enrichmentLoading.value = true
|
||||
try {
|
||||
const versionResults = versionIds.size > 0 ? await get_version_many([...versionIds]) : []
|
||||
for (const dependencyVersion of versionResults ?? []) {
|
||||
if (dependencyVersion.project_id) {
|
||||
projectIds.add(dependencyVersion.project_id)
|
||||
}
|
||||
}
|
||||
const projectResults = projectIds.size > 0 ? await get_project_many([...projectIds]) : []
|
||||
enrichment.value = {
|
||||
projects: projectResults ?? [],
|
||||
versions: versionResults ?? [],
|
||||
}
|
||||
} finally {
|
||||
enrichmentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.versions,
|
||||
async () => {
|
||||
if (route.params.version) {
|
||||
version.value = props.versions.find((v) => v.id === route.params.version)
|
||||
if (version.value) {
|
||||
breadcrumbs.setName('Version', version.value.name)
|
||||
}
|
||||
await refreshEnrichment()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await refreshEnrichment()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.version-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.version-title {
|
||||
margin-bottom: 1rem;
|
||||
h2 {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-contrast);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dependency {
|
||||
display: flex;
|
||||
padding: 0.5rem 1rem 0.5rem 0.5rem;
|
||||
gap: 0.5rem;
|
||||
background: var(--color-raised-bg);
|
||||
color: var(--color-base);
|
||||
width: 100%;
|
||||
|
||||
.title {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
:deep(svg) {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.file {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
background: var(--color-button-bg);
|
||||
color: var(--color-base);
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
.download {
|
||||
margin-left: auto;
|
||||
background-color: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
margin: auto 0 auto;
|
||||
gap: 0.5rem;
|
||||
|
||||
.title {
|
||||
font-weight: bolder;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
svg {
|
||||
min-width: 1.1rem;
|
||||
min-height: 1.1rem;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.primary-label {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--color-brand-highlight);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-contrast);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.description-cards {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metadata-card {
|
||||
width: 20rem;
|
||||
height: min-content;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
|
||||
.metadata-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.metadata-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: var(--color-base);
|
||||
background: var(--color-raised-bg);
|
||||
padding: 0.5rem;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
:deep(hr),
|
||||
:deep(h1),
|
||||
:deep(h2),
|
||||
img {
|
||||
max-width: max(60rem, 90%) !important;
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.copycode {
|
||||
border: 0;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
vertical-align: center;
|
||||
align-items: center;
|
||||
cursor: not-allowed;
|
||||
border-radius: var(--radius-lg);
|
||||
|
||||
.text {
|
||||
filter: brightness(0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,10 +9,18 @@
|
||||
:version-link="(version) => buildProjectHref(`/project/${project.id}/version/${version.id}`)"
|
||||
>
|
||||
<template #actions="{ version }">
|
||||
<ButtonStyled circular type="transparent" color="green">
|
||||
<ButtonStyled
|
||||
circular
|
||||
type="transparent"
|
||||
:color="installed && version.id === installedVersion ? 'standard' : 'green'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
!installed ? 'Install' : version.id !== installedVersion ? 'Swap version' : ''
|
||||
!installed
|
||||
? formatMessage(commonMessages.installButton)
|
||||
: version.id !== installedVersion
|
||||
? formatMessage(commonMessages.switchToVersionButton)
|
||||
: formatMessage(messages.alreadyInstalled)
|
||||
"
|
||||
:disabled="installing || (installed && version.id === installedVersion)"
|
||||
@click.stop="() => install(version.id)"
|
||||
@@ -45,11 +53,13 @@
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
Add to another instance
|
||||
</template>
|
||||
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
|
||||
<template #open-in-browser>
|
||||
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
<a
|
||||
v-else
|
||||
v-tooltip="`Open in browser`"
|
||||
v-tooltip="formatMessage(commonMessages.openInBrowserButton)"
|
||||
:href="`https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`"
|
||||
target="_blank"
|
||||
>
|
||||
@@ -65,9 +75,12 @@
|
||||
import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
ProjectPageVersions,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
@@ -76,8 +89,16 @@ import { SwapIcon } from '@/assets/icons/index.js'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
|
||||
import { useTheming } from '@/store/theme.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const themeStore = useTheming()
|
||||
|
||||
const messages = defineMessages({
|
||||
alreadyInstalled: {
|
||||
id: 'app.project.versions.already-installed',
|
||||
defaultMessage: 'Already installed',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
|
||||
@@ -16,6 +16,7 @@ export const DEFAULT_FEATURE_FLAGS = {
|
||||
pride_fundraiser: true,
|
||||
i18n_debug: false,
|
||||
show_instance_play_time: true,
|
||||
advanced_filters_collapsed: true,
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
|
||||
|
||||
@@ -157,6 +157,7 @@ fn main() {
|
||||
"install_job_retry",
|
||||
"install_job_cancel",
|
||||
"install_job_dismiss",
|
||||
"install_job_support_details",
|
||||
])
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
@@ -276,6 +277,8 @@ fn main() {
|
||||
"hide_ads_window",
|
||||
"scroll_ads_window",
|
||||
"show_ads_window",
|
||||
"show_ads_consent_overlay",
|
||||
"hide_ads_consent_overlay",
|
||||
"record_ads_click",
|
||||
"open_link",
|
||||
"get_ads_personalization",
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
const MODRINTH_ORIGIN = 'https://modrinth.com'
|
||||
|
||||
document.addEventListener(
|
||||
'click',
|
||||
function (e) {
|
||||
window.top.postMessage({ modrinthAdClick: true }, 'https://modrinth.com')
|
||||
window.top.postMessage({ modrinthAdClick: true }, MODRINTH_ORIGIN)
|
||||
|
||||
let target = e.target
|
||||
while (target != null) {
|
||||
if (target.matches('a')) {
|
||||
e.preventDefault()
|
||||
if (target.href) {
|
||||
window.top.postMessage({ modrinthOpenUrl: target.href }, 'https://modrinth.com')
|
||||
window.top.postMessage({ modrinthOpenUrl: target.href }, MODRINTH_ORIGIN)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -19,7 +21,97 @@ document.addEventListener(
|
||||
)
|
||||
|
||||
window.open = (url, target, features) => {
|
||||
window.top.postMessage({ modrinthOpenUrl: url }, 'https://modrinth.com')
|
||||
window.top.postMessage({ modrinthOpenUrl: url }, MODRINTH_ORIGIN)
|
||||
}
|
||||
|
||||
let modrinthAdsConsentOverlayShown = false
|
||||
let modrinthTcfListenerInstalled = false
|
||||
let modrinthTcfListenerAttempts = 0
|
||||
|
||||
function installAdsConsentOverlayStyle() {
|
||||
if (document.getElementById('modrinth-ads-consent-overlay-style')) {
|
||||
return
|
||||
}
|
||||
const style = document.createElement('style')
|
||||
style.id = 'modrinth-ads-consent-overlay-style'
|
||||
style.textContent = `
|
||||
html.modrinth-ads-consent-overlay #modrinth-rail-1 {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
.qc-cmp2-close-icon {
|
||||
background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath d='M.5.5l23 23m0-23l-23 23' fill='none' stroke='%23b0bac5' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10'/%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3C/svg%3E") 0% 0% / 66% auto no-repeat !important;
|
||||
}
|
||||
`
|
||||
document.documentElement.appendChild(style)
|
||||
}
|
||||
|
||||
function getTauriInvoke() {
|
||||
return window.__TAURI__?.core?.invoke ?? window.__TAURI_INTERNALS__?.invoke
|
||||
}
|
||||
|
||||
function invokeAdsConsentOverlayCommand(shown) {
|
||||
const invoke = getTauriInvoke()
|
||||
|
||||
if (typeof invoke !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
const command = shown ? 'show_ads_consent_overlay' : 'hide_ads_consent_overlay'
|
||||
const args = shown ? {} : { dpr: window.devicePixelRatio }
|
||||
|
||||
invoke(`plugin:ads|${command}`, args).catch(() => {})
|
||||
}
|
||||
|
||||
function setAdsConsentOverlay(shown) {
|
||||
if (modrinthAdsConsentOverlayShown === shown) return
|
||||
|
||||
modrinthAdsConsentOverlayShown = shown
|
||||
installAdsConsentOverlayStyle()
|
||||
document.documentElement.classList.toggle('modrinth-ads-consent-overlay', shown)
|
||||
|
||||
if (window.top === window) {
|
||||
invokeAdsConsentOverlayCommand(shown)
|
||||
} else {
|
||||
window.top.postMessage({ modrinthAdsConsentOverlay: shown }, MODRINTH_ORIGIN)
|
||||
}
|
||||
}
|
||||
|
||||
if (window.top === window) {
|
||||
window.addEventListener('message', (event) => {
|
||||
if (
|
||||
event.origin === MODRINTH_ORIGIN &&
|
||||
typeof event.data?.modrinthAdsConsentOverlay === 'boolean'
|
||||
) {
|
||||
setAdsConsentOverlay(event.data.modrinthAdsConsentOverlay)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleTcfConsentEvent(tcData, success) {
|
||||
if (!success || !tcData) return
|
||||
|
||||
if (tcData.eventStatus === 'cmpuishown') {
|
||||
setAdsConsentOverlay(true)
|
||||
} else if (tcData.eventStatus === 'useractioncomplete' || tcData.eventStatus === 'tcloaded') {
|
||||
setAdsConsentOverlay(false)
|
||||
}
|
||||
}
|
||||
|
||||
// polling to install listener on tcf api
|
||||
function installTcfConsentListener() {
|
||||
if (modrinthTcfListenerInstalled) return
|
||||
|
||||
if (typeof window.__tcfapi === 'function') {
|
||||
modrinthTcfListenerInstalled = true
|
||||
window.__tcfapi('addEventListener', 2, handleTcfConsentEvent)
|
||||
return
|
||||
}
|
||||
|
||||
if (modrinthTcfListenerAttempts < 60) {
|
||||
modrinthTcfListenerAttempts += 1
|
||||
setTimeout(installTcfConsentListener, 500)
|
||||
}
|
||||
}
|
||||
|
||||
function muteAudioContext() {
|
||||
@@ -98,9 +190,13 @@ function muteVideos() {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
installAdsConsentOverlayStyle()
|
||||
muteVideos()
|
||||
muteAudioContext()
|
||||
installTcfConsentListener()
|
||||
|
||||
const observer = new MutationObserver(muteVideos)
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
})
|
||||
|
||||
installTcfConsentListener()
|
||||
|
||||
+157
-18
@@ -11,12 +11,14 @@ use tokio::sync::RwLock;
|
||||
pub struct AdsState {
|
||||
pub shown: bool,
|
||||
pub modal_shown: bool,
|
||||
pub consent_overlay_shown: bool,
|
||||
pub occluded: bool,
|
||||
pub last_click: Option<Instant>,
|
||||
pub malicious_origins: HashSet<String>,
|
||||
}
|
||||
|
||||
const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie";
|
||||
const APP_TITLE_BAR_HEIGHT: f32 = 48.0;
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 0.5;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -131,13 +133,16 @@ fn set_webview_visible_for_window<R: Runtime>(
|
||||
.and_then(|window| window.is_minimized().ok())
|
||||
.unwrap_or(false);
|
||||
|
||||
let is_occluded = app
|
||||
let (is_occluded, consent_overlay_shown) = app
|
||||
.state::<RwLock<AdsState>>()
|
||||
.try_read()
|
||||
.map(|state| state.occluded)
|
||||
.unwrap_or(false);
|
||||
.map(|state| (state.occluded, state.consent_overlay_shown))
|
||||
.unwrap_or((false, false));
|
||||
|
||||
set_webview_visible(webview, visible && !is_minimized && !is_occluded);
|
||||
set_webview_visible(
|
||||
webview,
|
||||
visible && !is_minimized && (!is_occluded || consent_overlay_shown),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
@@ -195,7 +200,8 @@ async fn sync_ads_occlusion<R: Runtime>(app: &tauri::AppHandle<R>) {
|
||||
}
|
||||
|
||||
state.occluded = occluded;
|
||||
let visible = state.shown && !state.modal_shown;
|
||||
let visible =
|
||||
state.shown && (!state.modal_shown || state.consent_overlay_shown);
|
||||
drop(state);
|
||||
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
@@ -211,21 +217,36 @@ fn sync_webview_visibility_for_main_window<R: Runtime>(
|
||||
let is_minimized = main_window.is_minimized().unwrap_or(false);
|
||||
let was = was_minimized.load(Ordering::SeqCst);
|
||||
|
||||
let ads_state = if is_minimized {
|
||||
None
|
||||
} else {
|
||||
match app.state::<RwLock<AdsState>>().try_read() {
|
||||
Ok(state) => Some((
|
||||
state.shown
|
||||
&& (!state.modal_shown || state.consent_overlay_shown)
|
||||
&& (!state.occluded || state.consent_overlay_shown),
|
||||
state.consent_overlay_shown,
|
||||
)),
|
||||
Err(_) => None,
|
||||
}
|
||||
};
|
||||
let ads_visible = ads_state.map(|state| state.0).unwrap_or(false);
|
||||
|
||||
if ads_state.map(|state| state.1).unwrap_or(false)
|
||||
&& let Some(webview) = app.webviews().get("ads-window")
|
||||
&& let Ok((position, size)) =
|
||||
get_overlay_webview_position_for_window(main_window)
|
||||
{
|
||||
webview.set_position(position).ok();
|
||||
webview.set_size(size).ok();
|
||||
}
|
||||
|
||||
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 && !state.occluded,
|
||||
Err(_) => false,
|
||||
}
|
||||
};
|
||||
|
||||
let mut webviews = Vec::new();
|
||||
let mut seen_webviews = HashSet::new();
|
||||
|
||||
@@ -254,6 +275,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
app.manage(RwLock::new(AdsState {
|
||||
shown: true,
|
||||
modal_shown: false,
|
||||
consent_overlay_shown: false,
|
||||
occluded: false,
|
||||
last_click: None,
|
||||
malicious_origins: HashSet::new(),
|
||||
@@ -269,7 +291,10 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
.state::<RwLock<AdsState>>()
|
||||
.try_read()
|
||||
.map(|state| {
|
||||
state.shown && !state.modal_shown && !state.occluded
|
||||
state.shown
|
||||
&& !state.modal_shown
|
||||
&& !state.consent_overlay_shown
|
||||
&& !state.occluded
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -332,6 +357,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
init_ads_window,
|
||||
hide_ads_window,
|
||||
show_ads_window,
|
||||
show_ads_consent_overlay,
|
||||
hide_ads_consent_overlay,
|
||||
record_ads_click,
|
||||
open_link,
|
||||
get_ads_personalization,
|
||||
@@ -358,6 +385,42 @@ fn get_webview_position<R: Runtime>(
|
||||
))
|
||||
}
|
||||
|
||||
fn get_overlay_webview_position_for_window<R: Runtime>(
|
||||
main_window: &tauri::Window<R>,
|
||||
) -> crate::api::Result<(PhysicalPosition<f32>, PhysicalSize<f32>)> {
|
||||
let main_window_size = main_window.outer_size()?;
|
||||
let title_bar_height =
|
||||
APP_TITLE_BAR_HEIGHT * main_window.scale_factor()? as f32;
|
||||
|
||||
Ok((
|
||||
PhysicalPosition::new(0.0, title_bar_height),
|
||||
PhysicalSize::new(
|
||||
main_window_size.width as f32,
|
||||
(main_window_size.height as f32 - title_bar_height).max(0.0),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn get_overlay_webview_position<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
) -> crate::api::Result<(PhysicalPosition<f32>, PhysicalSize<f32>)> {
|
||||
let main_window = app.get_window("main").unwrap();
|
||||
|
||||
get_overlay_webview_position_for_window(&main_window)
|
||||
}
|
||||
|
||||
fn get_device_pixel_ratio<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
dpr: Option<f32>,
|
||||
) -> f32 {
|
||||
dpr.unwrap_or_else(|| {
|
||||
app.get_window("main")
|
||||
.and_then(|window| window.scale_factor().ok())
|
||||
.map(|scale_factor| scale_factor as f32)
|
||||
.unwrap_or(1.0)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub async fn init_ads_window<R: Runtime>(
|
||||
@@ -374,11 +437,17 @@ pub async fn init_ads_window<R: Runtime>(
|
||||
state.shown = true;
|
||||
}
|
||||
|
||||
if state.modal_shown {
|
||||
if state.modal_shown && !state.consent_overlay_shown {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Ok((position, size)) = get_webview_position(&app, dpr) {
|
||||
let layout = if state.consent_overlay_shown {
|
||||
get_overlay_webview_position(&app)
|
||||
} else {
|
||||
get_webview_position(&app, dpr)
|
||||
};
|
||||
|
||||
if let Ok((position, size)) = layout {
|
||||
let webview = if let Some(webview) = app.webviews().get("ads-window") {
|
||||
// set both the `hide`/`show` state and `position`,
|
||||
// to ensure that the webview is actually shown/hidden
|
||||
@@ -586,7 +655,11 @@ pub async fn show_ads_window<R: Runtime>(
|
||||
state.modal_shown = false;
|
||||
|
||||
if state.shown {
|
||||
let (position, size) = get_webview_position(&app, dpr)?;
|
||||
let (position, size) = if state.consent_overlay_shown {
|
||||
get_overlay_webview_position(&app)?
|
||||
} else {
|
||||
get_webview_position(&app, dpr)?
|
||||
};
|
||||
// set both the `hide`/`show` state and `position`,
|
||||
// to ensure that the webview is actually shown/hidden
|
||||
webview.set_size(size).ok();
|
||||
@@ -610,8 +683,19 @@ pub async fn hide_ads_window<R: Runtime>(
|
||||
|
||||
if reset.unwrap_or(false) {
|
||||
state.shown = false;
|
||||
state.consent_overlay_shown = false;
|
||||
} else {
|
||||
state.modal_shown = true;
|
||||
|
||||
if state.consent_overlay_shown {
|
||||
let (position, size) = get_overlay_webview_position(&app)?;
|
||||
webview.set_size(size).ok();
|
||||
webview.set_position(position).ok();
|
||||
webview.show().ok();
|
||||
set_webview_visible_for_window(&app, webview, true);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// set both the `hide`/`show` state and `position`,
|
||||
@@ -625,6 +709,61 @@ pub async fn hide_ads_window<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn show_ads_consent_overlay<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> crate::api::Result<()> {
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let mut state = state.write().await;
|
||||
|
||||
// dont show for hidden ads so consent events cannot re-enable the webview.
|
||||
if !state.shown {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
state.consent_overlay_shown = true;
|
||||
|
||||
let (position, size) = get_overlay_webview_position(&app)?;
|
||||
webview.set_size(size).ok();
|
||||
webview.set_position(position).ok();
|
||||
webview.show().ok();
|
||||
set_webview_visible_for_window(&app, webview, true);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hide_ads_consent_overlay<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
dpr: Option<f32>,
|
||||
) -> crate::api::Result<()> {
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let mut state = state.write().await;
|
||||
|
||||
state.consent_overlay_shown = false;
|
||||
|
||||
if state.shown && !state.modal_shown {
|
||||
let dpr = get_device_pixel_ratio(&app, dpr);
|
||||
let (position, size) = get_webview_position(&app, dpr)?;
|
||||
|
||||
webview.set_size(size).ok();
|
||||
webview.set_position(position).ok();
|
||||
webview.show().ok();
|
||||
set_webview_visible_for_window(&app, webview, true);
|
||||
} else {
|
||||
webview
|
||||
.set_position(PhysicalPosition::new(-1000, -1000))
|
||||
.ok();
|
||||
webview.hide().ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn record_ads_click<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
|
||||
@@ -25,6 +25,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
install_job_retry,
|
||||
install_job_cancel,
|
||||
install_job_dismiss,
|
||||
install_job_support_details,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@@ -169,3 +170,8 @@ pub async fn install_job_cancel(job_id: Uuid) -> Result<InstallJobSnapshot> {
|
||||
pub async fn install_job_dismiss(job_id: Uuid) -> Result<()> {
|
||||
Ok(theseus::install::dismiss_job(job_id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_job_support_details(job_id: Uuid) -> Result<String> {
|
||||
Ok(theseus::install::job_support_details(job_id).await?)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ pub enum ErrorKind {
|
||||
inner: Box<s3::error::S3Error>,
|
||||
file: String,
|
||||
},
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Error acquiring semaphore: {0}")]
|
||||
Acquire(#[from] tokio::sync::AcquireError),
|
||||
#[error("Tracing error: {0}")]
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
//! Fetches Fabric-compatible loader metadata.
|
||||
//!
|
||||
//! Fabric and Quilt both expose loader profiles for a concrete Minecraft
|
||||
//! version, but Daedalus publishes templated profiles using
|
||||
//! `${modrinth.gameVersion}`. A group is a set of Minecraft versions whose
|
||||
//! upstream loader profiles have the same structure after the concrete
|
||||
//! Minecraft version is replaced with `${modrinth.gameVersion}`. Fabric uses
|
||||
//! one universal group, so its public profile paths stay as
|
||||
//! `versions/{loader}.json`. Quilt has more than one group: versions before
|
||||
//! 26.x include hashed/intermediary libraries, while 26.x versions do not. For
|
||||
//! Quilt, Daedalus writes one templated profile per group at
|
||||
//! `version-group/{group}/loader-version/{loader}`.
|
||||
|
||||
use crate::metadata_groups::{
|
||||
UNIVERSAL_METADATA_GROUP, metadata_group_for_game_version, metadata_groups,
|
||||
};
|
||||
use crate::util::{download_file, fetch_json, format_url};
|
||||
use crate::{
|
||||
Error, FetchResult, MirrorArtifact, UploadFile, insert_mirrored_artifact,
|
||||
@@ -64,7 +80,112 @@ async fn fetch(
|
||||
&semaphore,
|
||||
)
|
||||
.await?;
|
||||
let all_loader_versions = fabric_manifest.loader.clone();
|
||||
let all_game_versions = fabric_manifest.game.clone();
|
||||
let metadata_groups = metadata_groups(
|
||||
mod_loader,
|
||||
all_game_versions.iter().map(|x| x.version.as_str()),
|
||||
);
|
||||
|
||||
if metadata_groups
|
||||
.iter()
|
||||
.any(|group| group.id != UNIVERSAL_METADATA_GROUP)
|
||||
{
|
||||
let loaders = all_loader_versions
|
||||
.iter()
|
||||
.filter(|x| !skip_versions.contains(&&*x.version))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let profile_requests = metadata_groups
|
||||
.iter()
|
||||
.flat_map(|group| {
|
||||
loaders.iter().map(move |loader| ProfileRequest {
|
||||
group: group.id.to_string(),
|
||||
loader_profile_template_game_version: group
|
||||
.loader_profile_template_game_version
|
||||
.clone(),
|
||||
game_versions: group.game_versions.clone(),
|
||||
loader_version: loader.version.clone(),
|
||||
url: format!(
|
||||
"{}/versions/loader/{}/{}/profile/json",
|
||||
meta_url,
|
||||
group.loader_profile_template_game_version,
|
||||
loader.version
|
||||
),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
fetch_metadata_profiles(
|
||||
mod_loader,
|
||||
format_version,
|
||||
maven_url,
|
||||
profile_requests,
|
||||
&upload_files,
|
||||
&mirror_artifacts,
|
||||
&semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let version_groups = metadata_groups
|
||||
.iter()
|
||||
.map(|group| daedalus::modded::VersionGroup {
|
||||
id: group.id.to_string(),
|
||||
loaders: loaders
|
||||
.iter()
|
||||
.map(|loader| {
|
||||
let version_path = metadata_version_path(
|
||||
mod_loader,
|
||||
format_version,
|
||||
&loader.version,
|
||||
group.id,
|
||||
);
|
||||
|
||||
daedalus::modded::LoaderVersion {
|
||||
id: loader.version.clone(),
|
||||
url: format_url(&version_path),
|
||||
stable: loader.stable,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let manifest = daedalus::modded::Manifest {
|
||||
game_versions: all_game_versions
|
||||
.into_iter()
|
||||
.map(|game_version| {
|
||||
let group = metadata_group_for_game_version(
|
||||
&metadata_groups,
|
||||
mod_loader,
|
||||
&game_version.version,
|
||||
)
|
||||
.expect("game version should have a metadata group");
|
||||
|
||||
daedalus::modded::Version {
|
||||
id: game_version.version.clone(),
|
||||
stable: game_version.stable,
|
||||
version_group: Some(group.id.to_string()),
|
||||
loaders: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
version_groups,
|
||||
};
|
||||
|
||||
upload_files.insert(
|
||||
format!("{mod_loader}/v{format_version}/manifest.json"),
|
||||
UploadFile {
|
||||
file: bytes::Bytes::from(serde_json::to_vec(&manifest)?),
|
||||
content_type: Some("application/json".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
return Ok(FetchResult {
|
||||
upload_files,
|
||||
mirror_artifacts,
|
||||
});
|
||||
}
|
||||
// We check Modrinth's manifest to find newly added loader versions,
|
||||
// intermediary/mapping artifacts, and game versions.
|
||||
let (
|
||||
@@ -125,8 +246,6 @@ async fn fetch(
|
||||
)
|
||||
};
|
||||
|
||||
const DUMMY_GAME_VERSION: &str = "1.21";
|
||||
|
||||
if !fetch_intermediary_versions.is_empty() {
|
||||
for x in &fetch_intermediary_versions {
|
||||
insert_mirrored_artifact(
|
||||
@@ -140,94 +259,38 @@ async fn fetch(
|
||||
}
|
||||
|
||||
if !fetch_fabric_versions.is_empty() {
|
||||
let fabric_version_manifest_urls = fetch_fabric_versions
|
||||
let universal_group = metadata_groups
|
||||
.iter()
|
||||
.map(|x| {
|
||||
format!(
|
||||
.find(|group| group.id == UNIVERSAL_METADATA_GROUP)
|
||||
.expect("fabric metadata should have a universal group");
|
||||
let profile_requests = fetch_fabric_versions
|
||||
.iter()
|
||||
.map(|loader| ProfileRequest {
|
||||
group: universal_group.id.to_string(),
|
||||
loader_profile_template_game_version: universal_group
|
||||
.loader_profile_template_game_version
|
||||
.clone(),
|
||||
game_versions: universal_group.game_versions.clone(),
|
||||
loader_version: loader.version.clone(),
|
||||
url: format!(
|
||||
"{}/versions/loader/{}/{}/profile/json",
|
||||
meta_url, DUMMY_GAME_VERSION, x.version
|
||||
)
|
||||
meta_url,
|
||||
universal_group.loader_profile_template_game_version,
|
||||
loader.version
|
||||
),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let fabric_version_manifests = futures::future::try_join_all(
|
||||
fabric_version_manifest_urls
|
||||
.iter()
|
||||
.map(|x| download_file(x, None, &semaphore)),
|
||||
|
||||
fetch_metadata_profiles(
|
||||
mod_loader,
|
||||
format_version,
|
||||
maven_url,
|
||||
profile_requests,
|
||||
&upload_files,
|
||||
&mirror_artifacts,
|
||||
&semaphore,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|x| serde_json::from_slice(&x))
|
||||
.collect::<Result<Vec<PartialVersionInfo>, serde_json::Error>>()?;
|
||||
|
||||
let patched_version_manifests = fabric_version_manifests
|
||||
.into_iter()
|
||||
.map(|mut version_info| {
|
||||
for lib in &mut version_info.libraries {
|
||||
let new_name = lib
|
||||
.name
|
||||
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
|
||||
|
||||
// Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries
|
||||
if &*lib.name == "net.minecraft:launchwrapper:1.12" {
|
||||
lib.url = Some(
|
||||
"https://libraries.minecraft.net/".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// If a library is not intermediary, we add it to mirror artifacts to be mirrored
|
||||
if lib.name == new_name {
|
||||
insert_mirrored_artifact(
|
||||
&new_name,
|
||||
None,
|
||||
vec![
|
||||
lib.url
|
||||
.clone()
|
||||
.unwrap_or_else(|| maven_url.to_string()),
|
||||
],
|
||||
false,
|
||||
&mirror_artifacts,
|
||||
)?;
|
||||
} else {
|
||||
lib.name = new_name;
|
||||
}
|
||||
|
||||
lib.url = Some(format_url("maven/"));
|
||||
}
|
||||
|
||||
version_info.id = version_info
|
||||
.id
|
||||
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
|
||||
version_info.inherits_from = version_info
|
||||
.inherits_from
|
||||
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
|
||||
|
||||
Ok(version_info)
|
||||
})
|
||||
.collect::<Result<Vec<_>, Error>>()?;
|
||||
let serialized_version_manifests = patched_version_manifests
|
||||
.iter()
|
||||
.map(|x| serde_json::to_vec(x).map(bytes::Bytes::from))
|
||||
.collect::<Result<Vec<_>, serde_json::Error>>()?;
|
||||
|
||||
serialized_version_manifests
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.for_each(|(index, bytes)| {
|
||||
let loader = fetch_fabric_versions[index];
|
||||
|
||||
let version_path = format!(
|
||||
"{mod_loader}/v{format_version}/versions/{}.json",
|
||||
loader.version
|
||||
);
|
||||
|
||||
upload_files.insert(
|
||||
version_path,
|
||||
UploadFile {
|
||||
file: bytes,
|
||||
content_type: Some("application/json".to_string()),
|
||||
},
|
||||
);
|
||||
});
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !fetch_fabric_versions.is_empty()
|
||||
@@ -240,17 +303,20 @@ async fn fetch(
|
||||
let loader_versions = daedalus::modded::Version {
|
||||
id: DUMMY_REPLACE_STRING.to_string(),
|
||||
stable: true,
|
||||
loaders: fabric_manifest
|
||||
.loader
|
||||
.into_iter()
|
||||
version_group: None,
|
||||
loaders: all_loader_versions
|
||||
.iter()
|
||||
.filter(|x| !skip_versions.contains(&&*x.version))
|
||||
.map(|x| {
|
||||
let version_path = format!(
|
||||
"{mod_loader}/v{format_version}/versions/{}.json",
|
||||
x.version,
|
||||
let version_path = metadata_version_path(
|
||||
mod_loader,
|
||||
format_version,
|
||||
&x.version,
|
||||
UNIVERSAL_METADATA_GROUP,
|
||||
);
|
||||
|
||||
daedalus::modded::LoaderVersion {
|
||||
id: x.version,
|
||||
id: x.version.clone(),
|
||||
url: format_url(&version_path),
|
||||
stable: x.stable,
|
||||
}
|
||||
@@ -260,14 +326,16 @@ async fn fetch(
|
||||
|
||||
let manifest = daedalus::modded::Manifest {
|
||||
game_versions: std::iter::once(loader_versions)
|
||||
.chain(fabric_manifest.game.into_iter().map(|x| {
|
||||
.chain(all_game_versions.into_iter().map(|x| {
|
||||
daedalus::modded::Version {
|
||||
id: x.version,
|
||||
stable: x.stable,
|
||||
version_group: None,
|
||||
loaders: vec![],
|
||||
}
|
||||
}))
|
||||
.collect(),
|
||||
version_groups: Vec::new(),
|
||||
};
|
||||
|
||||
upload_files.insert(
|
||||
@@ -285,6 +353,145 @@ async fn fetch(
|
||||
})
|
||||
}
|
||||
|
||||
struct ProfileRequest {
|
||||
group: String,
|
||||
loader_profile_template_game_version: String,
|
||||
game_versions: Vec<String>,
|
||||
loader_version: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
fn metadata_version_path(
|
||||
mod_loader: &str,
|
||||
format_version: usize,
|
||||
loader_version: &str,
|
||||
group: &str,
|
||||
) -> String {
|
||||
if group == UNIVERSAL_METADATA_GROUP {
|
||||
format!("{mod_loader}/v{format_version}/versions/{loader_version}.json")
|
||||
} else {
|
||||
format!(
|
||||
"{mod_loader}/v{format_version}/version-group/{group}/loader-version/{loader_version}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_metadata_profiles(
|
||||
mod_loader: &str,
|
||||
format_version: usize,
|
||||
maven_url: &str,
|
||||
profile_requests: Vec<ProfileRequest>,
|
||||
upload_files: &DashMap<String, UploadFile>,
|
||||
mirror_artifacts: &DashMap<String, MirrorArtifact>,
|
||||
semaphore: &Arc<Semaphore>,
|
||||
) -> Result<(), Error> {
|
||||
let version_manifests = futures::future::try_join_all(
|
||||
profile_requests
|
||||
.iter()
|
||||
.map(|x| download_file(&x.url, None, semaphore)),
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|x| serde_json::from_slice(&x))
|
||||
.collect::<Result<Vec<PartialVersionInfo>, serde_json::Error>>()?;
|
||||
|
||||
let patched_version_manifests = version_manifests
|
||||
.into_iter()
|
||||
.zip(profile_requests.iter())
|
||||
.map(|(mut version_info, request)| {
|
||||
patch_version_info(
|
||||
&mut version_info,
|
||||
&request.loader_profile_template_game_version,
|
||||
&request.game_versions,
|
||||
maven_url,
|
||||
mirror_artifacts,
|
||||
)?;
|
||||
|
||||
Ok(version_info)
|
||||
})
|
||||
.collect::<Result<Vec<_>, Error>>()?;
|
||||
let serialized_version_manifests = patched_version_manifests
|
||||
.iter()
|
||||
.map(|x| serde_json::to_vec(x).map(bytes::Bytes::from))
|
||||
.collect::<Result<Vec<_>, serde_json::Error>>()?;
|
||||
|
||||
serialized_version_manifests
|
||||
.into_iter()
|
||||
.zip(profile_requests)
|
||||
.for_each(|(bytes, request)| {
|
||||
let version_path = metadata_version_path(
|
||||
mod_loader,
|
||||
format_version,
|
||||
&request.loader_version,
|
||||
&request.group,
|
||||
);
|
||||
|
||||
upload_files.insert(
|
||||
version_path,
|
||||
UploadFile {
|
||||
file: bytes,
|
||||
content_type: Some("application/json".to_string()),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch_version_info(
|
||||
version_info: &mut PartialVersionInfo,
|
||||
game_version: &str,
|
||||
game_versions: &[String],
|
||||
maven_url: &str,
|
||||
mirror_artifacts: &DashMap<String, MirrorArtifact>,
|
||||
) -> Result<(), Error> {
|
||||
for lib in &mut version_info.libraries {
|
||||
let new_name = lib.name.replace(game_version, DUMMY_REPLACE_STRING);
|
||||
|
||||
// Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries
|
||||
if &*lib.name == "net.minecraft:launchwrapper:1.12" {
|
||||
lib.url = Some("https://libraries.minecraft.net/".to_string());
|
||||
}
|
||||
let source_url =
|
||||
lib.url.clone().unwrap_or_else(|| maven_url.to_string());
|
||||
|
||||
if lib.name == new_name {
|
||||
insert_mirrored_artifact(
|
||||
&new_name,
|
||||
None,
|
||||
vec![source_url],
|
||||
false,
|
||||
mirror_artifacts,
|
||||
)?;
|
||||
} else {
|
||||
for concrete_game_version in game_versions {
|
||||
let concrete_name =
|
||||
lib.name.replace(game_version, concrete_game_version);
|
||||
|
||||
insert_mirrored_artifact(
|
||||
&concrete_name,
|
||||
None,
|
||||
vec![source_url.clone()],
|
||||
false,
|
||||
mirror_artifacts,
|
||||
)?;
|
||||
}
|
||||
|
||||
lib.name = new_name;
|
||||
}
|
||||
|
||||
lib.url = Some(format_url("maven/"));
|
||||
}
|
||||
|
||||
version_info.id =
|
||||
version_info.id.replace(game_version, DUMMY_REPLACE_STRING);
|
||||
version_info.inherits_from = version_info
|
||||
.inherits_from
|
||||
.replace(game_version, DUMMY_REPLACE_STRING);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct FabricVersions {
|
||||
pub loader: Vec<FabricLoaderVersion>,
|
||||
|
||||
@@ -12,7 +12,10 @@ use itertools::Itertools;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[tracing::instrument(skip(semaphore))]
|
||||
@@ -243,12 +246,45 @@ async fn fetch(
|
||||
};
|
||||
|
||||
if !fetch_versions.is_empty() {
|
||||
let forge_installers = futures::future::try_join_all(
|
||||
fetch_versions
|
||||
.iter()
|
||||
.map(|x| download_file(&x.installer_url, None, &semaphore)),
|
||||
)
|
||||
.await?;
|
||||
let total_installers = fetch_versions.len();
|
||||
let downloaded_installers = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
tracing::info!(
|
||||
mod_loader,
|
||||
total_files = total_installers,
|
||||
"Downloading loader installers"
|
||||
);
|
||||
|
||||
let forge_installers =
|
||||
futures::future::try_join_all(fetch_versions.iter().map(|x| {
|
||||
let downloaded_installers = downloaded_installers.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
|
||||
async move {
|
||||
let installer =
|
||||
download_file(&x.installer_url, None, &semaphore)
|
||||
.await?;
|
||||
let downloaded = downloaded_installers
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
+ 1;
|
||||
|
||||
if downloaded.is_multiple_of(100)
|
||||
|| downloaded == total_installers
|
||||
{
|
||||
tracing::info!(
|
||||
mod_loader,
|
||||
downloaded_files = downloaded,
|
||||
remaining_files =
|
||||
total_installers.saturating_sub(downloaded),
|
||||
total_files = total_installers,
|
||||
"Downloaded loader installers"
|
||||
);
|
||||
}
|
||||
|
||||
Ok::<_, Error>(installer)
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
|
||||
#[tracing::instrument(skip(raw, upload_files, mirror_artifacts))]
|
||||
async fn read_forge_installer(
|
||||
@@ -761,21 +797,23 @@ async fn fetch(
|
||||
.into_iter()
|
||||
.map(|(game_version, loaders)| {
|
||||
daedalus::modded::Version {
|
||||
id: game_version,
|
||||
stable: true,
|
||||
loaders: loaders
|
||||
.map(|x| daedalus::modded::LoaderVersion {
|
||||
url: format_url(&format!(
|
||||
"{mod_loader}/v{format_version}/versions/{}.json",
|
||||
x.loader_version
|
||||
)),
|
||||
id: x.loader_version,
|
||||
stable: false,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
id: game_version,
|
||||
stable: true,
|
||||
version_group: None,
|
||||
loaders: loaders
|
||||
.map(|x| daedalus::modded::LoaderVersion {
|
||||
url: format_url(&format!(
|
||||
"{mod_loader}/v{format_version}/versions/{}.json",
|
||||
x.loader_version
|
||||
)),
|
||||
id: x.loader_version,
|
||||
stable: false,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
version_groups: Vec::new(),
|
||||
};
|
||||
|
||||
upload_files.insert(
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use crate::util::{
|
||||
REQWEST_CLIENT, format_url, upload_file_to_bucket,
|
||||
upload_url_to_bucket_mirrors,
|
||||
upload_url_to_bucket_mirrors, write_file_to_local_output,
|
||||
write_url_to_local_output_mirrors,
|
||||
};
|
||||
use daedalus::get_path_from_artifact;
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing_error::ErrorLayer;
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
@@ -12,6 +16,7 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
mod error;
|
||||
mod fabric;
|
||||
mod forge;
|
||||
mod metadata_groups;
|
||||
mod minecraft;
|
||||
pub mod util;
|
||||
|
||||
@@ -44,51 +49,92 @@ async fn main() -> Result<()> {
|
||||
));
|
||||
|
||||
let mut fetch_result = FetchResult::default();
|
||||
let only_loader = dotenvy::var("DAEDALUS_ONLY").ok();
|
||||
|
||||
match minecraft::fetch(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Minecraft fetch failed"),
|
||||
if should_fetch(only_loader.as_deref(), "minecraft") {
|
||||
match minecraft::fetch(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Minecraft fetch failed"),
|
||||
}
|
||||
}
|
||||
|
||||
match fabric::fetch_fabric(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Fabric fetch failed"),
|
||||
if should_fetch(only_loader.as_deref(), "fabric") {
|
||||
match fabric::fetch_fabric(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Fabric fetch failed"),
|
||||
}
|
||||
}
|
||||
|
||||
match fabric::fetch_quilt(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Quilt fetch failed"),
|
||||
if should_fetch(only_loader.as_deref(), "quilt") {
|
||||
match fabric::fetch_quilt(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Quilt fetch failed"),
|
||||
}
|
||||
}
|
||||
|
||||
match forge::fetch_neo(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "NeoForge fetch failed"),
|
||||
if should_fetch(only_loader.as_deref(), "neo") {
|
||||
match forge::fetch_neo(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "NeoForge fetch failed"),
|
||||
}
|
||||
}
|
||||
|
||||
match forge::fetch_forge(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Forge fetch failed"),
|
||||
if should_fetch(only_loader.as_deref(), "forge") {
|
||||
match forge::fetch_forge(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Forge fetch failed"),
|
||||
}
|
||||
}
|
||||
|
||||
let FetchResult {
|
||||
upload_files,
|
||||
mirror_artifacts,
|
||||
} = fetch_result;
|
||||
let upload_file_total = upload_files.len();
|
||||
let mirror_file_total = mirror_artifacts.len();
|
||||
|
||||
futures::future::try_join_all(upload_files.iter().map(|entry| {
|
||||
upload_file_to_bucket(
|
||||
entry.key().clone(),
|
||||
entry.value().file.clone(),
|
||||
entry.value().content_type.clone(),
|
||||
&semaphore,
|
||||
)
|
||||
}))
|
||||
.await?;
|
||||
if dotenvy::var("LOCAL_OUTPUT_DIR").is_ok() {
|
||||
let written_files = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
|
||||
upload_url_to_bucket_mirrors(
|
||||
format!("maven/{}", entry.key()),
|
||||
entry
|
||||
tracing::info!(
|
||||
total_files = upload_file_total,
|
||||
"Writing local metadata files"
|
||||
);
|
||||
|
||||
futures::future::try_join_all(upload_files.iter().map(|entry| {
|
||||
let path = entry.key().clone();
|
||||
let file = entry.value().file.clone();
|
||||
let written_files = written_files.clone();
|
||||
|
||||
async move {
|
||||
write_file_to_local_output(&path, file).await?;
|
||||
let written = written_files.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
if written.is_multiple_of(100) || written == upload_file_total {
|
||||
tracing::info!(
|
||||
written_files = written,
|
||||
remaining_files =
|
||||
upload_file_total.saturating_sub(written),
|
||||
total_files = upload_file_total,
|
||||
"Wrote local metadata files"
|
||||
);
|
||||
}
|
||||
|
||||
Ok::<_, Error>(())
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let written_mirror_files = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
tracing::info!(
|
||||
total_files = mirror_file_total,
|
||||
"Writing local mirror files"
|
||||
);
|
||||
|
||||
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
|
||||
let path = format!("maven/{}", entry.key());
|
||||
let mirrors = entry
|
||||
.value()
|
||||
.mirrors
|
||||
.iter()
|
||||
@@ -99,12 +145,117 @@ async fn main() -> Result<()> {
|
||||
format!("{}{}", mirror.path, entry.key())
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
entry.value().sha1.clone(),
|
||||
&semaphore,
|
||||
)
|
||||
}))
|
||||
.await?;
|
||||
.collect();
|
||||
let sha1 = entry.value().sha1.clone();
|
||||
let written_mirror_files = written_mirror_files.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
|
||||
async move {
|
||||
write_url_to_local_output_mirrors(
|
||||
path, mirrors, sha1, &semaphore,
|
||||
)
|
||||
.await?;
|
||||
let written =
|
||||
written_mirror_files.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
if written.is_multiple_of(100) || written == mirror_file_total {
|
||||
tracing::info!(
|
||||
written_files = written,
|
||||
remaining_files =
|
||||
mirror_file_total.saturating_sub(written),
|
||||
total_files = mirror_file_total,
|
||||
"Wrote local mirror files"
|
||||
);
|
||||
}
|
||||
|
||||
Ok::<_, Error>(())
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
} else {
|
||||
let uploaded_files = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
tracing::info!(
|
||||
total_files = upload_file_total,
|
||||
"Uploading metadata files"
|
||||
);
|
||||
|
||||
futures::future::try_join_all(upload_files.iter().map(|entry| {
|
||||
let path = entry.key().clone();
|
||||
let file = entry.value().file.clone();
|
||||
let content_type = entry.value().content_type.clone();
|
||||
let uploaded_files = uploaded_files.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
|
||||
async move {
|
||||
upload_file_to_bucket(path, file, content_type, &semaphore)
|
||||
.await?;
|
||||
let uploaded =
|
||||
uploaded_files.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
if uploaded.is_multiple_of(100) || uploaded == upload_file_total
|
||||
{
|
||||
tracing::info!(
|
||||
uploaded_files = uploaded,
|
||||
remaining_files =
|
||||
upload_file_total.saturating_sub(uploaded),
|
||||
total_files = upload_file_total,
|
||||
"Uploaded metadata files"
|
||||
);
|
||||
}
|
||||
|
||||
Ok::<_, Error>(())
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let uploaded_mirror_files = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
tracing::info!(
|
||||
total_files = mirror_file_total,
|
||||
"Uploading mirror files"
|
||||
);
|
||||
|
||||
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
|
||||
let path = format!("maven/{}", entry.key());
|
||||
let mirrors = entry
|
||||
.value()
|
||||
.mirrors
|
||||
.iter()
|
||||
.map(|mirror| {
|
||||
if mirror.entire_url {
|
||||
mirror.path.clone()
|
||||
} else {
|
||||
format!("{}{}", mirror.path, entry.key())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sha1 = entry.value().sha1.clone();
|
||||
let uploaded_mirror_files = uploaded_mirror_files.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
|
||||
async move {
|
||||
upload_url_to_bucket_mirrors(path, mirrors, sha1, &semaphore)
|
||||
.await?;
|
||||
let uploaded =
|
||||
uploaded_mirror_files.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
if uploaded.is_multiple_of(100) || uploaded == mirror_file_total
|
||||
{
|
||||
tracing::info!(
|
||||
uploaded_files = uploaded,
|
||||
remaining_files =
|
||||
mirror_file_total.saturating_sub(uploaded),
|
||||
total_files = mirror_file_total,
|
||||
"Uploaded mirror files"
|
||||
);
|
||||
}
|
||||
|
||||
Ok::<_, Error>(())
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
}
|
||||
|
||||
if dotenvy::var("CLOUDFLARE_INTEGRATION")
|
||||
.ok()
|
||||
@@ -151,6 +302,20 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_fetch(only_loader: Option<&str>, loader: &str) -> bool {
|
||||
let Some(only_loader) = only_loader else {
|
||||
return true;
|
||||
};
|
||||
|
||||
only_loader.split(',').any(|entry| {
|
||||
let entry = entry.trim();
|
||||
|
||||
entry.eq_ignore_ascii_case("all")
|
||||
|| entry.eq_ignore_ascii_case(loader)
|
||||
|| (loader == "neo" && entry.eq_ignore_ascii_case("neoforge"))
|
||||
})
|
||||
}
|
||||
|
||||
pub struct UploadFile {
|
||||
file: bytes::Bytes,
|
||||
content_type: Option<String>,
|
||||
@@ -248,11 +413,13 @@ fn check_env_vars() -> bool {
|
||||
|
||||
failed |= check_var::<String>("BASE_URL");
|
||||
|
||||
failed |= check_var::<String>("S3_ACCESS_TOKEN");
|
||||
failed |= check_var::<String>("S3_SECRET");
|
||||
failed |= check_var::<String>("S3_URL");
|
||||
failed |= check_var::<String>("S3_REGION");
|
||||
failed |= check_var::<String>("S3_BUCKET_NAME");
|
||||
if dotenvy::var("LOCAL_OUTPUT_DIR").is_err() {
|
||||
failed |= check_var::<String>("S3_ACCESS_TOKEN");
|
||||
failed |= check_var::<String>("S3_SECRET");
|
||||
failed |= check_var::<String>("S3_URL");
|
||||
failed |= check_var::<String>("S3_REGION");
|
||||
failed |= check_var::<String>("S3_BUCKET_NAME");
|
||||
}
|
||||
|
||||
if dotenvy::var("CLOUDFLARE_INTEGRATION")
|
||||
.ok()
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Determines how version info is generated for pairs of game and loader
|
||||
//! versions.
|
||||
//!
|
||||
//! When a user installs a version of the game, they install two things: the
|
||||
//! game (of some specific version), and a loader (of some other specific
|
||||
//! version). Each combination of game and loader version requires a specific
|
||||
//! configuration, like a specific set of libraries that must be downloaded and
|
||||
//! run along with the game. However, some versions of the game or loader may
|
||||
//! change configuration requirements without the other version being affected.
|
||||
//! For example, pre-26.x game versions with Quilt require the Quilt `hashed`
|
||||
//! libraries to also be downloaded. However, 26.x and later don't require the
|
||||
//! `hashed` libraries, and don't even have a download for them. The problem is
|
||||
//! that Quilt loader 0.30.0 can be used for both pre-26.x and 26.x - but our v0
|
||||
//! manifest files can't differentiate the two. The result is that you either
|
||||
//! break compatibility for 0.30.0 game versions pre-26.x, or break 0.30.0
|
||||
//! on 26.x and later.
|
||||
//!
|
||||
//! To fix this, v1 introduces the concept of *version groups*: game versions
|
||||
//! before 26.x are version group v1, and 26.x and later are v2. Then, we
|
||||
//! parameterize our version info on both version group and loader version,
|
||||
//! letting us specify the right configuration based on both game version and
|
||||
//! loader version.
|
||||
//!
|
||||
//! Why not parameterize on game version and loader version directly? Most game
|
||||
//! versions have the same configuration as their surrounding game versions, so
|
||||
//! we'd end up with many duplicate configurations: the number of game versions
|
||||
//! multiplied by the number of loader versions.
|
||||
//!
|
||||
//! This file lets you configure what game versions are grouped together.
|
||||
//!
|
||||
//! Each version group is templated from a specific game version - e.g. game
|
||||
//! version 1.21 is used as the template file for 1.20, 1.19, etc.
|
||||
|
||||
pub const UNIVERSAL_METADATA_GROUP: &str = "universal";
|
||||
pub const QUILT_LEGACY_METADATA_GROUP: &str = "v1";
|
||||
pub const QUILT_MODERN_METADATA_GROUP: &str = "v2";
|
||||
|
||||
pub struct MetadataGroup {
|
||||
pub id: &'static str,
|
||||
/// Minecraft version used to fetch and template this group's loader profiles.
|
||||
pub loader_profile_template_game_version: String,
|
||||
pub game_versions: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn metadata_groups<'a>(
|
||||
mod_loader: &str,
|
||||
game_versions: impl IntoIterator<Item = &'a str>,
|
||||
) -> Vec<MetadataGroup> {
|
||||
// Non-Quilt loaders don't need the concept of version groups, so we just
|
||||
// make one "universal" group, and template it on 1.21.
|
||||
if mod_loader != "quilt" {
|
||||
return vec![MetadataGroup {
|
||||
id: UNIVERSAL_METADATA_GROUP,
|
||||
loader_profile_template_game_version: "1.21".to_string(),
|
||||
game_versions: game_versions
|
||||
.into_iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect(),
|
||||
}];
|
||||
}
|
||||
|
||||
let game_versions = game_versions.into_iter().collect::<Vec<_>>();
|
||||
let legacy_game_versions = game_versions
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|game_version| {
|
||||
metadata_group_id_for_game_version(mod_loader, game_version)
|
||||
== QUILT_LEGACY_METADATA_GROUP
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let modern_game_versions = game_versions
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|game_version| {
|
||||
metadata_group_id_for_game_version(mod_loader, game_version)
|
||||
== QUILT_MODERN_METADATA_GROUP
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut groups = Vec::new();
|
||||
|
||||
if !legacy_game_versions.is_empty() {
|
||||
groups.push(MetadataGroup {
|
||||
id: QUILT_LEGACY_METADATA_GROUP,
|
||||
loader_profile_template_game_version: legacy_game_versions
|
||||
.iter()
|
||||
.find(|x| **x == "1.21")
|
||||
.copied()
|
||||
.unwrap_or(legacy_game_versions[0])
|
||||
.to_string(),
|
||||
game_versions: legacy_game_versions
|
||||
.iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
|
||||
if !modern_game_versions.is_empty() {
|
||||
groups.push(MetadataGroup {
|
||||
id: QUILT_MODERN_METADATA_GROUP,
|
||||
loader_profile_template_game_version: modern_game_versions[0]
|
||||
.to_string(),
|
||||
game_versions: modern_game_versions
|
||||
.iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
|
||||
groups
|
||||
}
|
||||
|
||||
pub fn metadata_group_for_game_version<'a>(
|
||||
groups: &'a [MetadataGroup],
|
||||
mod_loader: &str,
|
||||
game_version: &str,
|
||||
) -> Option<&'a MetadataGroup> {
|
||||
let group_id = metadata_group_id_for_game_version(mod_loader, game_version);
|
||||
|
||||
groups.iter().find(|group| group.id == group_id)
|
||||
}
|
||||
|
||||
fn metadata_group_id_for_game_version(
|
||||
mod_loader: &str,
|
||||
game_version: &str,
|
||||
) -> &'static str {
|
||||
if mod_loader == "quilt" && is_modern_quilt_game_version(game_version) {
|
||||
QUILT_MODERN_METADATA_GROUP
|
||||
} else if mod_loader == "quilt" {
|
||||
QUILT_LEGACY_METADATA_GROUP
|
||||
} else {
|
||||
UNIVERSAL_METADATA_GROUP
|
||||
}
|
||||
}
|
||||
|
||||
// Update these Quilt group boundaries if upstream loader profiles gain another
|
||||
// structural incompatibility between Minecraft versions.
|
||||
fn is_modern_quilt_game_version(game_version: &str) -> bool {
|
||||
let major = game_version
|
||||
.split(['.', 'w'])
|
||||
.next()
|
||||
.and_then(|x| x.parse::<usize>().ok());
|
||||
|
||||
major.is_some_and(|x| x >= 26)
|
||||
}
|
||||
@@ -3,7 +3,11 @@ use bytes::Bytes;
|
||||
use s3::creds::Credentials;
|
||||
use s3::{Bucket, Region};
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{
|
||||
Arc, LazyLock,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
static BUCKET: LazyLock<Bucket> = LazyLock::new(|| {
|
||||
@@ -55,6 +59,8 @@ pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
static DOWNLOADED_FILE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
#[tracing::instrument(skip(bytes, semaphore))]
|
||||
pub async fn upload_file_to_bucket(
|
||||
path: String,
|
||||
@@ -135,6 +141,74 @@ pub async fn upload_url_to_bucket(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn write_file_to_local_output(
|
||||
path: &str,
|
||||
bytes: Bytes,
|
||||
) -> Result<(), Error> {
|
||||
let output_path = local_output_path(path)?;
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
tokio::fs::write(output_path, bytes).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn write_url_to_local_output_mirrors(
|
||||
output_path: String,
|
||||
mirrors: Vec<String>,
|
||||
sha1: Option<String>,
|
||||
semaphore: &Arc<Semaphore>,
|
||||
) -> Result<(), Error> {
|
||||
if mirrors.is_empty() {
|
||||
return Err(ErrorKind::InvalidInput(
|
||||
"No mirrors provided!".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
for (index, mirror) in mirrors.iter().enumerate() {
|
||||
let result = write_url_to_local_output(
|
||||
output_path.clone(),
|
||||
mirror.clone(),
|
||||
sha1.clone(),
|
||||
semaphore,
|
||||
)
|
||||
.await;
|
||||
|
||||
if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn write_url_to_local_output(
|
||||
path: String,
|
||||
url: String,
|
||||
sha1: Option<String>,
|
||||
semaphore: &Arc<Semaphore>,
|
||||
) -> Result<(), Error> {
|
||||
let data = download_file(&url, sha1.as_deref(), semaphore).await?;
|
||||
|
||||
write_file_to_local_output(&path, data).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn local_output_path(path: &str) -> Result<PathBuf, Error> {
|
||||
let output_dir = dotenvy::var("LOCAL_OUTPUT_DIR").map_err(|_| {
|
||||
ErrorKind::InvalidInput(
|
||||
"LOCAL_OUTPUT_DIR is required for local output".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(PathBuf::from(output_dir).join(path))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(bytes))]
|
||||
pub async fn sha1_async(bytes: Bytes) -> Result<String, Error> {
|
||||
let hash = tokio::task::spawn_blocking(move || {
|
||||
@@ -182,6 +256,16 @@ pub async fn download_file(
|
||||
}
|
||||
}
|
||||
|
||||
let downloaded = DOWNLOADED_FILE_COUNT
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
+ 1;
|
||||
if downloaded.is_multiple_of(100) {
|
||||
tracing::info!(
|
||||
downloaded_files = downloaded,
|
||||
"Downloaded metadata files"
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(bytes);
|
||||
} else if attempt <= RETRIES {
|
||||
continue;
|
||||
|
||||
@@ -692,6 +692,12 @@ components:
|
||||
type: string
|
||||
description: The ID of the project
|
||||
example: AABBCCDD
|
||||
all_project_types:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: All project types across every version of the project, unlike `project_type` which only reflects a version-specific type
|
||||
example: [mod, plugin, datapack]
|
||||
author:
|
||||
type: string
|
||||
description: The username of the project's author
|
||||
@@ -748,6 +754,7 @@ components:
|
||||
- client_side
|
||||
- server_side
|
||||
- project_id
|
||||
- all_project_types
|
||||
- author
|
||||
- versions
|
||||
- follows
|
||||
@@ -1948,6 +1955,7 @@ paths:
|
||||
|
||||
These are the most commonly used facet types:
|
||||
- `project_type`
|
||||
- `all_project_types` (matches against every project type across all of the project's versions, not just the primary/version-specific type)
|
||||
- `categories` (loaders are lumped in with categories in search)
|
||||
- `versions`
|
||||
- `client_side`
|
||||
@@ -1961,11 +1969,8 @@ paths:
|
||||
- `project_id`
|
||||
- `license`
|
||||
- `downloads`
|
||||
- `color`
|
||||
- `created_timestamp` (uses Unix timestamp)
|
||||
- `modified_timestamp` (uses Unix timestamp)
|
||||
- `date_created` (uses ISO-8601 timestamp)
|
||||
- `date_modified` (uses ISO-8601 timestamp)
|
||||
|
||||
In order to then use these facets, you need a value to filter by, as well as an operation to perform on this value.
|
||||
The most common operation is `:` (same as `=`), though you can also use `!=`, `>=`, `>`, `<=`, and `<`.
|
||||
|
||||
@@ -482,3 +482,8 @@ input {
|
||||
.button-transparent {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.qc-cmp2-close-tooltip {
|
||||
background-color: transparent;
|
||||
color: hsl(145, 78%, 28%);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div
|
||||
:role="selectable ? 'radio' : undefined"
|
||||
:aria-checked="selectable ? selected : undefined"
|
||||
:tabindex="selectable ? 0 : undefined"
|
||||
class="grid items-center gap-3 rounded-2xl border border-solid px-3 py-3 transition-all"
|
||||
:class="{
|
||||
'grid-cols-[min-content_minmax(0,1fr)_min-content]': selectable,
|
||||
'grid-cols-[minmax(0,1fr)_min-content]': !selectable,
|
||||
'cursor-pointer border-brand bg-surface-4 text-contrast': selectable && selected,
|
||||
'cursor-pointer border-surface-5 bg-surface-4 hover:brightness-[115%]':
|
||||
selectable && !selected,
|
||||
'border-transparent bg-surface-2': !selectable,
|
||||
}"
|
||||
@click="select"
|
||||
@keydown.enter.self.prevent="select"
|
||||
@keydown.space.self.prevent="select"
|
||||
>
|
||||
<template v-if="selectable">
|
||||
<RadioButtonCheckedIcon v-if="selected" aria-hidden="true" class="size-5 text-brand" />
|
||||
<RadioButtonIcon v-else aria-hidden="true" class="size-5 text-secondary" />
|
||||
</template>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<nuxt-link
|
||||
v-tooltip="truncatedTooltip(versionNumberRef, version.version_number)"
|
||||
:to="`/${project.project_type}/${project.slug || project.id}/version/${version.id}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block min-w-0 text-contrast no-underline hover:underline"
|
||||
>
|
||||
<span ref="versionNumberRef" class="block truncate font-semibold">
|
||||
{{ version.version_number }}
|
||||
</span>
|
||||
</nuxt-link>
|
||||
<VersionChannelTag :channel="version.version_type" class="relative -top-px !py-1" />
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-1.5 text-sm text-secondary">
|
||||
<span v-tooltip="publishedTooltip" class="min-w-0 truncate">
|
||||
{{ publishedLabel }}
|
||||
</span>
|
||||
<div
|
||||
v-if="primaryFile"
|
||||
class="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-primary opacity-30"
|
||||
></div>
|
||||
<span v-if="primaryFile" class="flex-shrink-0">
|
||||
{{ primaryFileSizeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
v-if="primaryFile && showDownload"
|
||||
:color="color"
|
||||
:type="type"
|
||||
:circular="circular"
|
||||
>
|
||||
<a
|
||||
v-tooltip="circular ? formatMessage(messages.download) : null"
|
||||
:href="primaryFileDownloadUrl"
|
||||
:download="primaryFile.filename"
|
||||
:aria-label="
|
||||
formatMessage(messages.downloadVersion, {
|
||||
version: version.version_number,
|
||||
})
|
||||
"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<template v-if="!circular">
|
||||
{{ formatMessage(messages.download) }}
|
||||
</template>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { DownloadIcon, RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
type CdnDownloadReason,
|
||||
defineMessages,
|
||||
truncatedTooltip,
|
||||
useFormatBytes,
|
||||
useFormatDateTime,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import VersionChannelTag from '@modrinth/ui/src/components/version/VersionChannelTag.vue'
|
||||
import { capitalizeString, type DisplayProjectType } from '@modrinth/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'CompatibleVersionCard',
|
||||
})
|
||||
|
||||
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
|
||||
project_type: DisplayProjectType
|
||||
actualProjectType: Labrinth.Projects.v2.ProjectType
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
project: DownloadModalProject
|
||||
version: Labrinth.Versions.v3.Version
|
||||
downloadReason?: CdnDownloadReason
|
||||
currentGameVersion?: string | null
|
||||
currentPlatform?: string | null
|
||||
color?: 'brand' | 'standard'
|
||||
type?: 'standard' | 'transparent'
|
||||
circular?: boolean
|
||||
selectable?: boolean
|
||||
selected?: boolean
|
||||
showDownload?: boolean
|
||||
}>(),
|
||||
{
|
||||
downloadReason: 'standalone',
|
||||
currentGameVersion: null,
|
||||
currentPlatform: null,
|
||||
color: 'brand',
|
||||
type: 'standard',
|
||||
circular: false,
|
||||
selectable: false,
|
||||
selected: false,
|
||||
showDownload: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: []
|
||||
select: []
|
||||
}>()
|
||||
|
||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatBytes = useFormatBytes()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
timeStyle: 'short',
|
||||
dateStyle: 'long',
|
||||
})
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const versionNumberRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const primaryFile = computed<Labrinth.Versions.v3.VersionFile | null>(() => {
|
||||
return props.version.files?.find((file) => file.primary) || props.version.files?.[0] || null
|
||||
})
|
||||
|
||||
const primaryFileDownloadUrl = computed(() => {
|
||||
if (!primaryFile.value) return '#'
|
||||
|
||||
return createProjectDownloadUrl(primaryFile.value.url, {
|
||||
reason: props.downloadReason,
|
||||
gameVersion: props.currentGameVersion ?? undefined,
|
||||
loader: props.currentPlatform ?? undefined,
|
||||
})
|
||||
})
|
||||
|
||||
const publishedLabel = computed(() =>
|
||||
capitalizeString(formatRelativeTime(props.version.date_published)),
|
||||
)
|
||||
const publishedTooltip = computed(() => formatDateTime(props.version.date_published))
|
||||
const primaryFileSizeLabel = computed(() => {
|
||||
if (!primaryFile.value) return ''
|
||||
return formatBytes(primaryFile.value.size)
|
||||
})
|
||||
|
||||
function select() {
|
||||
if (!props.selectable) return
|
||||
emit('select')
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
downloadVersion: {
|
||||
id: 'project.download.download-version',
|
||||
defaultMessage: 'Download {version}',
|
||||
},
|
||||
download: {
|
||||
id: 'project.download.download',
|
||||
defaultMessage: 'Download',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,494 +1,111 @@
|
||||
<template>
|
||||
<div v-if="downloadRows.length > 0" class="flex flex-col gap-1">
|
||||
<div v-if="showTitle" class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="m-0 flex items-center gap-1.5 text-base font-semibold text-contrast">
|
||||
{{ sectionTitle }}
|
||||
<InfoIcon
|
||||
v-if="duplicateDependencyRowsHidden"
|
||||
v-tooltip="formatMessage(messages.duplicateDependenciesHidden)"
|
||||
aria-hidden="true"
|
||||
class="size-4 text-secondary"
|
||||
<div v-if="downloadRows.length > 0 || recommendedRows.length > 0" class="flex flex-col gap-4">
|
||||
<div v-if="downloadRows.length > 0" class="flex flex-col gap-2.5">
|
||||
<div v-if="showTitle" class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="m-0 flex items-center gap-1.5 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.dependenciesTitle) }}
|
||||
<InfoIcon
|
||||
v-if="duplicateDependencyRowsHidden"
|
||||
v-tooltip="formatMessage(messages.duplicateDependenciesHidden)"
|
||||
aria-hidden="true"
|
||||
class="size-4 text-secondary"
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
<Admonition v-if="requiredResourcePackAdmonitionVisible" type="info">
|
||||
<IntlFormatted :message-id="messages.requiredResourcePackAdmonition">
|
||||
<template #folder>
|
||||
<code class="text-sm">resourcepacks</code>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</Admonition>
|
||||
<Admonition v-if="dependencyResourcePackAdmonitionVisible" type="info">
|
||||
<IntlFormatted :message-id="messages.dependencyResourcePackAdmonition">
|
||||
<template #folder>
|
||||
<code class="text-sm">resourcepacks</code>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</Admonition>
|
||||
<div class="rounded-2xl bg-surface-2 p-2 pl-4 pr-3">
|
||||
<DownloadDependency
|
||||
v-for="dependency in downloadRows"
|
||||
:key="dependency.key"
|
||||
:dependency="dependency"
|
||||
@download="emit('download')"
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DownloadDependency
|
||||
v-for="dependency in downloadRows"
|
||||
:key="dependency.key"
|
||||
:dependency="dependency"
|
||||
@download="emit('download')"
|
||||
/>
|
||||
|
||||
<div v-if="recommendedRows.length > 0" class="flex flex-col gap-2.5">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.recommendedTitle) }}
|
||||
</h3>
|
||||
<div class="rounded-2xl bg-surface-2 p-2 pl-4 pr-3">
|
||||
<DownloadDependency
|
||||
v-for="dependency in recommendedRows"
|
||||
:key="dependency.key"
|
||||
:dependency="dependency"
|
||||
@download="emit('download')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { FileIcon, InfoIcon } from '@modrinth/assets'
|
||||
import {
|
||||
type CdnDownloadReason,
|
||||
defineMessages,
|
||||
fileTypeMessages,
|
||||
injectModrinthClient,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { type Component, computed, watch } from 'vue'
|
||||
import { InfoIcon } from '@modrinth/assets'
|
||||
import { Admonition, defineMessages, IntlFormatted, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { injectDownloadModalProvider } from './download-modal-provider'
|
||||
import DownloadDependency from './DownloadDependency.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'DownloadDependencies',
|
||||
})
|
||||
|
||||
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
|
||||
project_type: DisplayProjectType
|
||||
actualProjectType: Labrinth.Projects.v2.ProjectType
|
||||
}
|
||||
|
||||
type ResolvedContent = Labrinth.Content.v3.ResolvedContent | Labrinth.Content.v3.SkippedContent
|
||||
|
||||
interface DownloadDependencyRow {
|
||||
key: string
|
||||
name: string
|
||||
icon?: string
|
||||
fallbackIcon?: Component
|
||||
projectHref?: string
|
||||
downloadHref?: string
|
||||
filename?: string
|
||||
fileSize?: number
|
||||
typeLabel: string
|
||||
unavailableTooltip: string
|
||||
dependencies: DownloadDependencyRow[]
|
||||
}
|
||||
|
||||
interface DownloadableDependencyFile {
|
||||
href: string
|
||||
filename: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
dependencies?: DownloadDependencyRow[] | null
|
||||
project?: DownloadModalProject | null
|
||||
selectedVersion?: Labrinth.Versions.v3.Version | null
|
||||
currentGameVersion?: string | null
|
||||
currentPlatform?: string | null
|
||||
downloadReason?: CdnDownloadReason
|
||||
additionalFiles?: Labrinth.Versions.v3.VersionFile[]
|
||||
showTitle?: boolean
|
||||
}>(),
|
||||
{
|
||||
dependencies: null,
|
||||
project: null,
|
||||
selectedVersion: null,
|
||||
currentGameVersion: null,
|
||||
currentPlatform: null,
|
||||
downloadReason: 'standalone',
|
||||
additionalFiles: () => [],
|
||||
showTitle: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: []
|
||||
'update:downloadable-files': [files: DownloadableDependencyFile[]]
|
||||
}>()
|
||||
const client = injectModrinthClient()
|
||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const shouldResolveDependencies = computed(
|
||||
() => !props.dependencies && !!props.project && !!props.selectedVersion,
|
||||
)
|
||||
|
||||
const dependencyResolutionPreferences = computed<Labrinth.Content.v3.ResolutionPreferences>(() => ({
|
||||
game_versions: props.selectedVersion?.game_versions || [],
|
||||
loaders: props.currentPlatform ? [props.currentPlatform] : props.selectedVersion?.loaders || [],
|
||||
}))
|
||||
|
||||
const { data: dependencyResolution } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'project-download-modal',
|
||||
'content-resolve',
|
||||
props.project?.id,
|
||||
props.selectedVersion?.id,
|
||||
props.project?.project_type,
|
||||
dependencyResolutionPreferences.value,
|
||||
]),
|
||||
queryFn: () =>
|
||||
client.labrinth.content_v3.resolve({
|
||||
project_id: props.project!.id,
|
||||
version_id: props.selectedVersion!.id,
|
||||
content_type: resolveContentType(props.project!.project_type),
|
||||
selected: dependencyResolutionPreferences.value,
|
||||
target: dependencyResolutionPreferences.value,
|
||||
}),
|
||||
enabled: shouldResolveDependencies,
|
||||
})
|
||||
|
||||
const visibleResolvedDependencies = computed<ResolvedContent[]>(() => {
|
||||
return [
|
||||
...(dependencyResolution.value?.dependencies || []),
|
||||
...(dependencyResolution.value?.skipped || []),
|
||||
].filter(shouldShowDependency)
|
||||
})
|
||||
|
||||
const dependencyVersionIds = computed<string[]>(() => {
|
||||
return [
|
||||
...new Set(
|
||||
visibleResolvedDependencies.value
|
||||
.filter((dependency) => !('reason' in dependency))
|
||||
.map((dependency) => dependency.version_id)
|
||||
.filter((versionId): versionId is string => !!versionId),
|
||||
),
|
||||
]
|
||||
})
|
||||
|
||||
const { data: dependencyVersions } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'project-download-modal',
|
||||
'resolved-versions',
|
||||
dependencyVersionIds.value,
|
||||
]),
|
||||
queryFn: () => client.labrinth.versions_v3.getVersions(dependencyVersionIds.value),
|
||||
enabled: computed(() => shouldResolveDependencies.value && dependencyVersionIds.value.length > 0),
|
||||
})
|
||||
|
||||
const dependencyVersionById = computed(() => {
|
||||
const map = new Map<string, Labrinth.Versions.v3.Version>()
|
||||
for (const version of dependencyVersions.value || []) {
|
||||
if (!version) continue
|
||||
map.set(version.id, version)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const dependencyProjectIds = computed<string[]>(() => {
|
||||
return [
|
||||
...new Set(
|
||||
visibleResolvedDependencies.value
|
||||
.map((dependency) => dependency.project_id)
|
||||
.filter((projectId): projectId is string => !!projectId),
|
||||
),
|
||||
]
|
||||
})
|
||||
|
||||
const { data: dependencyProjects } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'project-download-modal',
|
||||
'resolved-projects',
|
||||
dependencyProjectIds.value,
|
||||
]),
|
||||
queryFn: () => client.labrinth.projects_v2.getMultiple(dependencyProjectIds.value),
|
||||
enabled: computed(() => shouldResolveDependencies.value && dependencyProjectIds.value.length > 0),
|
||||
})
|
||||
|
||||
const dependencyProjectById = computed(() => {
|
||||
const map = new Map<string, Labrinth.Projects.v2.Project>()
|
||||
for (const project of dependencyProjects.value || []) {
|
||||
map.set(project.id, project)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const dependenciesByParentVersionId = computed(() => {
|
||||
const map = new Map<string, ResolvedContent[]>()
|
||||
|
||||
for (const dependency of visibleResolvedDependencies.value) {
|
||||
if (!dependency.dependent_on_version_id) continue
|
||||
|
||||
const dependencies = map.get(dependency.dependent_on_version_id) || []
|
||||
dependencies.push(dependency)
|
||||
map.set(dependency.dependent_on_version_id, dependencies)
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const dependenciesLoaded = computed(() => {
|
||||
if (!shouldResolveDependencies.value) return false
|
||||
if (!dependencyResolution.value) return false
|
||||
if (
|
||||
dependencyResolution.value.primary.version_id &&
|
||||
dependencyResolution.value.primary.version_id !== props.selectedVersion?.id
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!dependencyVersionIds.value.every((versionId) => dependencyVersionById.value.has(versionId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!dependencyProjectIds.value.every((projectId) => dependencyProjectById.value.has(projectId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const resolvedDependencyRows = computed<DownloadDependencyRow[]>(() => {
|
||||
if (!dependenciesLoaded.value) return []
|
||||
|
||||
const primaryVersionId =
|
||||
dependencyResolution.value?.primary.version_id || props.selectedVersion?.id
|
||||
if (!primaryVersionId) return []
|
||||
|
||||
const dependencies = dependenciesByParentVersionId.value.get(primaryVersionId) || []
|
||||
|
||||
return dependencies.flatMap((dependency) => {
|
||||
const row = createDependencyRow(dependency)
|
||||
return row ? [row] : []
|
||||
})
|
||||
})
|
||||
|
||||
const dependencyRows = computed<DownloadDependencyRow[]>(
|
||||
() => props.dependencies || resolvedDependencyRows.value,
|
||||
)
|
||||
|
||||
const visibleDependencyRows = computed<DownloadDependencyRow[]>(() =>
|
||||
dedupeDependencyRows(dependencyRows.value),
|
||||
)
|
||||
|
||||
const duplicateDependencyRowsHidden = computed(() =>
|
||||
hasDuplicateDependencyRows(dependencyRows.value),
|
||||
)
|
||||
|
||||
const additionalFileRows = computed<DownloadDependencyRow[]>(() =>
|
||||
props.additionalFiles.map((file) => ({
|
||||
key: `additional-file-${additionalFileKey(file)}`,
|
||||
name: file.filename,
|
||||
fallbackIcon: FileIcon,
|
||||
downloadHref: getDownloadUrl(file.url),
|
||||
filename: file.filename,
|
||||
fileSize: file.size,
|
||||
typeLabel: fileTypeLabel(file.file_type),
|
||||
unavailableTooltip: formatMessage(messages.unavailableFile),
|
||||
dependencies: [],
|
||||
})),
|
||||
)
|
||||
|
||||
const downloadRows = computed<DownloadDependencyRow[]>(() => [
|
||||
...visibleDependencyRows.value,
|
||||
...additionalFileRows.value,
|
||||
])
|
||||
|
||||
const sectionTitle = computed(() =>
|
||||
formatMessage(
|
||||
visibleDependencyRows.value.length > 0
|
||||
? messages.dependenciesTitle
|
||||
: messages.additionalFilesTitle,
|
||||
),
|
||||
)
|
||||
|
||||
const downloadableDependencyFiles = computed<DownloadableDependencyFile[]>(() =>
|
||||
collectDownloadableDependencyFiles(visibleDependencyRows.value),
|
||||
)
|
||||
|
||||
watch(
|
||||
downloadableDependencyFiles,
|
||||
(files) => {
|
||||
emit('update:downloadable-files', files)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function primaryFileForVersion(version?: Labrinth.Versions.v3.Version) {
|
||||
return version?.files?.find((file) => file.primary) || version?.files?.[0]
|
||||
}
|
||||
|
||||
function shouldShowDependency(dependency: ResolvedContent) {
|
||||
return !(
|
||||
'reason' in dependency && ['duplicate_project', 'quilt_fabric_api'].includes(dependency.reason)
|
||||
)
|
||||
}
|
||||
|
||||
function createDependencyRow(dependency: ResolvedContent): DownloadDependencyRow | null {
|
||||
const versionId = dependency.version_id ?? undefined
|
||||
const version = versionId ? dependencyVersionById.value.get(versionId) : undefined
|
||||
const project = dependencyProjectById.value.get(dependency.project_id)
|
||||
if (!project) return null
|
||||
|
||||
const primaryFile = primaryFileForVersion(version)
|
||||
const unavailableTooltip =
|
||||
'reason' in dependency && dependency.reason
|
||||
? skippedReasonLabel(dependency.reason)
|
||||
: formatMessage(messages.unavailableDependency)
|
||||
const name = project.title
|
||||
|
||||
return {
|
||||
key: `${dependency.project_id}-${versionId ?? 'unresolved'}-${
|
||||
'reason' in dependency ? dependency.reason : 'resolved'
|
||||
}`,
|
||||
name,
|
||||
icon: project.icon_url ?? undefined,
|
||||
projectHref: `/${project.project_type}/${project.slug || project.id}`,
|
||||
downloadHref:
|
||||
'reason' in dependency || !primaryFile ? undefined : getDownloadUrl(primaryFile.url),
|
||||
filename: primaryFile?.filename,
|
||||
fileSize: primaryFile?.size,
|
||||
typeLabel: 'Required',
|
||||
unavailableTooltip,
|
||||
dependencies: (versionId && dependenciesByParentVersionId.value.get(versionId)
|
||||
? dependenciesByParentVersionId.value.get(versionId)!
|
||||
: []
|
||||
).flatMap((subDependency) => {
|
||||
const row = createDependencyRow(subDependency)
|
||||
return row ? [row] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function skippedReasonLabel(reason: Labrinth.Content.v3.SkippedContent['reason']) {
|
||||
return (
|
||||
{
|
||||
already_installed: formatMessage(messages.alreadyInstalledDependency),
|
||||
duplicate_project: formatMessage(messages.duplicateDependency),
|
||||
conflicting_dependency: formatMessage(messages.conflictingDependency),
|
||||
no_compatible_version: formatMessage(messages.noCompatibleDependency),
|
||||
missing_version: formatMessage(messages.missingDependencyVersion),
|
||||
quilt_fabric_api: formatMessage(messages.quiltFabricApiDependency),
|
||||
}[reason] || formatMessage(messages.unavailableDependency)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveContentType(projectType: DisplayProjectType): Labrinth.Content.v3.ContentType {
|
||||
return ['mod', 'plugin', 'datapack', 'resourcepack', 'shader', 'modpack'].includes(projectType)
|
||||
? (projectType as Labrinth.Content.v3.ContentType)
|
||||
: 'mod'
|
||||
}
|
||||
|
||||
function getDownloadUrl(url: string) {
|
||||
return createProjectDownloadUrl(url, {
|
||||
reason: props.downloadReason,
|
||||
gameVersion: props.currentGameVersion ?? undefined,
|
||||
loader: props.currentPlatform ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function fileTypeLabel(type?: Labrinth.Versions.v3.FileType | null) {
|
||||
return formatMessage(fileTypeMessages[type ?? 'unknown'] ?? fileTypeMessages.unknown)
|
||||
}
|
||||
|
||||
function additionalFileKey(file: Labrinth.Versions.v3.VersionFile) {
|
||||
return file.hashes?.sha1 ?? file.filename
|
||||
}
|
||||
|
||||
function dedupeDependencyRows(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenDependencies = new Set<string>(),
|
||||
): DownloadDependencyRow[] {
|
||||
return rows.flatMap((row) => {
|
||||
const identity = dependencyRowIdentity(row)
|
||||
if (seenDependencies.has(identity)) return []
|
||||
|
||||
seenDependencies.add(identity)
|
||||
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
dependencies: dedupeDependencyRows(row.dependencies, seenDependencies),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function dependencyRowIdentity(row: DownloadDependencyRow) {
|
||||
return row.projectHref ?? row.downloadHref ?? row.key
|
||||
}
|
||||
|
||||
function hasDuplicateDependencyRows(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenDependencies = new Set<string>(),
|
||||
): boolean {
|
||||
for (const row of rows) {
|
||||
const rowId = dependencyRowIdentity(row)
|
||||
if (seenDependencies.has(rowId)) return true
|
||||
seenDependencies.add(rowId)
|
||||
if (hasDuplicateDependencyRows(row.dependencies, seenDependencies)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function collectDownloadableDependencyFiles(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenHrefs = new Set<string>(),
|
||||
): DownloadableDependencyFile[] {
|
||||
const files: DownloadableDependencyFile[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.downloadHref && !seenHrefs.has(row.downloadHref)) {
|
||||
seenHrefs.add(row.downloadHref)
|
||||
files.push({
|
||||
href: row.downloadHref,
|
||||
filename: row.filename || filenameFromUrl(row.downloadHref),
|
||||
name: row.name,
|
||||
})
|
||||
}
|
||||
|
||||
files.push(...collectDownloadableDependencyFiles(row.dependencies, seenHrefs))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function filenameFromUrl(url: string) {
|
||||
try {
|
||||
const filename = new URL(url).pathname.split('/').pop()
|
||||
return filename ? decodeURIComponent(filename) : 'dependency.jar'
|
||||
} catch {
|
||||
return 'dependency.jar'
|
||||
}
|
||||
}
|
||||
const {
|
||||
dependencyResourcePackAdmonitionVisible,
|
||||
duplicateDependencyRowsHidden,
|
||||
downloadRows,
|
||||
recommendedRows,
|
||||
requiredResourcePackAdmonitionVisible,
|
||||
} = injectDownloadModalProvider()
|
||||
|
||||
const messages = defineMessages({
|
||||
dependenciesTitle: {
|
||||
id: 'project.download.dependencies-title',
|
||||
defaultMessage: 'Dependencies',
|
||||
},
|
||||
recommendedTitle: {
|
||||
id: 'project.download.recommended-title',
|
||||
defaultMessage: 'Recommended',
|
||||
},
|
||||
duplicateDependenciesHidden: {
|
||||
id: 'project.download.duplicate-dependencies-hidden',
|
||||
defaultMessage: 'Duplicate dependencies are hidden',
|
||||
},
|
||||
additionalFilesTitle: {
|
||||
id: 'project.download.additional-files-title',
|
||||
defaultMessage: 'Additional files',
|
||||
requiredResourcePackAdmonition: {
|
||||
id: 'project.download.required-resource-pack-admonition',
|
||||
defaultMessage:
|
||||
'This data pack also requires a resource pack. Download it and place it in your {folder} folder.',
|
||||
},
|
||||
alreadyInstalledDependency: {
|
||||
id: 'project.download.dependency-already-installed',
|
||||
defaultMessage: 'This dependency is already installed',
|
||||
},
|
||||
conflictingDependency: {
|
||||
id: 'project.download.dependency-conflicting',
|
||||
defaultMessage: 'This dependency conflicts with another dependency',
|
||||
},
|
||||
duplicateDependency: {
|
||||
id: 'project.download.dependency-duplicate',
|
||||
defaultMessage: 'This dependency is already included',
|
||||
},
|
||||
missingDependencyVersion: {
|
||||
id: 'project.download.dependency-missing-version',
|
||||
defaultMessage: 'This dependency version is unavailable',
|
||||
},
|
||||
noCompatibleDependency: {
|
||||
id: 'project.download.dependency-no-compatible-version',
|
||||
defaultMessage: 'No compatible version is available for this dependency',
|
||||
},
|
||||
quiltFabricApiDependency: {
|
||||
id: 'project.download.dependency-quilt-fabric-api',
|
||||
defaultMessage: 'Fabric API is skipped for Quilt',
|
||||
},
|
||||
unavailableDependency: {
|
||||
id: 'project.download.dependency-unavailable',
|
||||
defaultMessage: 'This dependency cannot be downloaded',
|
||||
},
|
||||
unavailableFile: {
|
||||
id: 'project.download.file-unavailable',
|
||||
defaultMessage: 'This file cannot be downloaded',
|
||||
dependencyResourcePackAdmonition: {
|
||||
id: 'project.download.dependency-resource-pack-admonition',
|
||||
defaultMessage:
|
||||
'This project has a dependency with a required resource pack. Download it and place it in your {folder} folder.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<div
|
||||
class="grid min-h-10 grid-cols-[minmax(0,1fr)_min-content] items-center gap-3 rounded-xl bg-button-bg py-0 pl-3.5 pr-2 text-primary"
|
||||
class="z-10 grid h-11 grid-cols-[minmax(0,1fr)_min-content] items-center gap-1 text-primary"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-3">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<Avatar
|
||||
v-if="dependency.icon"
|
||||
v-if="dependency.icon && !dependency.hideIcon"
|
||||
:src="dependency.icon"
|
||||
:alt="dependency.name"
|
||||
size="24px"
|
||||
class="!rounded-lg !shadow-none"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex size-4 flex-shrink-0 items-center justify-center rounded-lg border border-solid border-surface-5 text-secondary"
|
||||
v-else-if="!dependency.hideIcon"
|
||||
class="flex size-6 flex-shrink-0 items-center justify-center rounded-lg border border-solid border-surface-5 text-secondary"
|
||||
>
|
||||
<component
|
||||
:is="dependency.fallbackIcon ?? PackageIcon"
|
||||
aria-hidden="true"
|
||||
class="size-5"
|
||||
class="size-4"
|
||||
/>
|
||||
</span>
|
||||
<a
|
||||
@@ -28,7 +28,7 @@
|
||||
:href="dependency.projectHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="min-w-0 truncate text-base font-semibold text-contrast no-underline hover:underline"
|
||||
class="min-w-0 truncate bg-surface-2 text-base font-semibold text-contrast hover:underline"
|
||||
>
|
||||
{{ dependency.name }}
|
||||
</a>
|
||||
@@ -36,13 +36,25 @@
|
||||
v-else
|
||||
ref="dependencyNameRef"
|
||||
v-tooltip="truncatedTooltip(dependencyNameRef, dependency.name)"
|
||||
class="min-w-0 truncate text-base font-semibold text-contrast"
|
||||
class="min-w-0 truncate bg-surface-2 text-base text-contrast"
|
||||
:class="dependency.isAdditionalFile ? 'font-medium' : 'font-semibold'"
|
||||
>
|
||||
{{ dependency.name }}
|
||||
</span>
|
||||
<TagItem class="shrink-0 border !border-solid border-surface-5 !px-3 !py-1 text-base">
|
||||
{{ dependency.typeLabel }}
|
||||
<TagItem
|
||||
v-if="dependency.isAdditionalFile"
|
||||
v-tooltip="metadataTooltip"
|
||||
class="min-w-0 max-w-[50%] shrink-0 truncate border !border-solid border-surface-5"
|
||||
>
|
||||
{{ metadataLabel }}
|
||||
</TagItem>
|
||||
<span
|
||||
v-else
|
||||
v-tooltip="metadataTooltip"
|
||||
class="min-w-0 max-w-[50%] truncate text-sm text-secondary"
|
||||
>
|
||||
{{ metadataLabel }}
|
||||
</span>
|
||||
</span>
|
||||
<ButtonStyled v-if="dependency.downloadHref" circular type="transparent">
|
||||
<a
|
||||
@@ -68,14 +80,18 @@
|
||||
<div
|
||||
v-for="childDependency in dependency.dependencies"
|
||||
:key="childDependency.key"
|
||||
class="group/dependency relative pl-10"
|
||||
class="group/dependency relative pl-8"
|
||||
>
|
||||
<DownloadDependency :dependency="childDependency" class="z-1" @download="emit('download')" />
|
||||
<DownloadDependency
|
||||
:dependency="childDependency"
|
||||
class="relative z-10"
|
||||
@download="emit('download')"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute -top-2 left-6 z-0 h-[calc(100%+1rem)] w-0.5 bg-surface-5 group-first/dependency:-top-2 group-first/dependency:h-20 group-last/dependency:h-7"
|
||||
class="absolute -top-2.5 left-3 z-0 h-full w-0.5 bg-surface-5 group-last/dependency:h-8"
|
||||
/>
|
||||
<div aria-hidden="true" class="absolute left-6 top-5 z-0 h-0.5 w-4 bg-surface-5" />
|
||||
<div aria-hidden="true" class="absolute left-3 top-[21px] z-0 h-0.5 w-7 bg-surface-5" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -102,10 +118,13 @@ interface DownloadDependencyRow {
|
||||
name: string
|
||||
icon?: string
|
||||
fallbackIcon?: Component
|
||||
hideIcon?: boolean
|
||||
isAdditionalFile?: boolean
|
||||
projectHref?: string
|
||||
downloadHref?: string
|
||||
filename?: string
|
||||
fileSize?: number
|
||||
metadataLabel?: string
|
||||
typeLabel: string
|
||||
unavailableTooltip: string
|
||||
dependencies: DownloadDependencyRow[]
|
||||
@@ -123,6 +142,13 @@ const { formatMessage } = useVIntl()
|
||||
const formatBytes = useFormatBytes()
|
||||
const dependencyNameRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const metadataLabel = computed(() => props.dependency.metadataLabel ?? props.dependency.typeLabel)
|
||||
const metadataTooltip = computed(() => {
|
||||
if (props.dependency.isAdditionalFile) return null
|
||||
if (metadataLabel.value === props.dependency.typeLabel) return null
|
||||
return metadataLabel.value
|
||||
})
|
||||
|
||||
const downloadTooltip = computed(() => {
|
||||
const filename = props.dependency.filename || props.dependency.name
|
||||
|
||||
|
||||
@@ -80,107 +80,76 @@
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedVersion" class="flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="relative top-0.5 m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.compatibleVersionTitle) }}
|
||||
</h3>
|
||||
<ButtonStyled v-if="downloadAllFiles.length > 1" type="transparent">
|
||||
<button :disabled="downloadingSelectedVersion" @click="downloadSelectedVersionFiles">
|
||||
<SpinnerIcon v-if="downloadingSelectedVersion" aria-hidden="true" class="animate-spin" />
|
||||
<DownloadIcon v-else aria-hidden="true" />
|
||||
{{
|
||||
formatMessage(
|
||||
downloadingSelectedVersion
|
||||
? messages.downloadingSelectedVersion
|
||||
: messages.downloadAllSelectedVersion,
|
||||
{
|
||||
current: selectedVersionDownloadProgress.current,
|
||||
total: selectedVersionDownloadProgress.total,
|
||||
},
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_min-content] items-center gap-3 rounded-2xl bg-surface-2 px-3 py-3"
|
||||
<div
|
||||
v-if="selectedVersion && downloadDataLoaded"
|
||||
:role="compatibleVersions.length > 1 ? 'radiogroup' : undefined"
|
||||
:aria-label="
|
||||
compatibleVersions.length > 1 ? formatMessage(messages.compatibleVersionTitle) : undefined
|
||||
"
|
||||
class="flex flex-col gap-2.5"
|
||||
>
|
||||
<h3
|
||||
v-if="compatibleVersions.length > 1"
|
||||
class="relative top-0.5 m-0 text-base font-semibold text-contrast"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<nuxt-link
|
||||
v-tooltip="truncatedTooltip(versionNumberRef, selectedVersion.version_number)"
|
||||
:to="`/${project.project_type}/${project.slug || project.id}/version/${selectedVersion.id}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block min-w-0 text-contrast no-underline hover:underline"
|
||||
>
|
||||
<span ref="versionNumberRef" class="block truncate font-semibold">
|
||||
{{ selectedVersion.version_number }}
|
||||
</span>
|
||||
</nuxt-link>
|
||||
<VersionChannelTag
|
||||
:channel="selectedVersion.version_type"
|
||||
class="relative -top-px !py-0.5"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
ref="versionNameRef"
|
||||
v-tooltip="truncatedTooltip(versionNameRef, selectedVersion.name)"
|
||||
class="m-0 w-fit max-w-full truncate text-sm text-secondary"
|
||||
>
|
||||
{{ selectedVersion.name }}
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled v-if="selectedPrimaryFile" color="brand" circular>
|
||||
<a
|
||||
v-tooltip="'Download'"
|
||||
:href="selectedPrimaryFileDownloadUrl"
|
||||
:download="selectedPrimaryFile.filename"
|
||||
:aria-label="
|
||||
formatMessage(messages.downloadVersion, {
|
||||
version: selectedVersion.version_number,
|
||||
})
|
||||
"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
{{ formatMessage(messages.compatibleVersionTitle) }}
|
||||
</h3>
|
||||
<CompatibleVersionCard
|
||||
v-for="compatibleVersion in compatibleVersions"
|
||||
:key="compatibleVersion.id"
|
||||
:project="project"
|
||||
:version="compatibleVersion"
|
||||
:download-reason="downloadReason"
|
||||
:current-game-version="currentGameVersion"
|
||||
:current-platform="currentPlatform"
|
||||
:selectable="compatibleVersions.length > 1"
|
||||
:selected="compatibleVersion.id === selectedVersion.id"
|
||||
:show-download="
|
||||
compatibleVersions.length === 1 || compatibleVersion.id === selectedVersion.id
|
||||
"
|
||||
:color="
|
||||
compatibleVersion.id === selectedVersion.id &&
|
||||
compatibleVersions.length === 1 &&
|
||||
!hasAdditionalDownloads
|
||||
? 'brand'
|
||||
: 'standard'
|
||||
"
|
||||
:type="
|
||||
compatibleVersion.id === selectedVersion.id &&
|
||||
compatibleVersions.length === 1 &&
|
||||
!hasAdditionalDownloads
|
||||
? 'standard'
|
||||
: 'transparent'
|
||||
"
|
||||
:circular="hasAdditionalDownloads || compatibleVersions.length > 1"
|
||||
@select="selectCompatibleVersion(compatibleVersion)"
|
||||
@download="emit('download')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="showNoCompatibleVersions" class="pl-1 text-base text-primary" role="status">
|
||||
{{ noCompatibleVersionsDescription }}
|
||||
</div>
|
||||
<p v-else-if="currentPlatform && currentGameVersion && versions.length > 0">
|
||||
{{
|
||||
formatMessage(messages.noVersionsAvailable, {
|
||||
gameVersion: currentGameVersion,
|
||||
platform: currentPlatformText,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { DownloadIcon, SpinnerIcon, TriangleAlertIcon } from '@modrinth/assets'
|
||||
import { TriangleAlertIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
type CdnDownloadReason,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
defineMessages,
|
||||
getTagMessage,
|
||||
injectNotificationManager,
|
||||
truncatedTooltip,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import VersionChannelTag from '@modrinth/ui/src/components/version/VersionChannelTag.vue'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import JSZip from 'jszip'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import CompatibleVersionCard from './CompatibleVersionCard.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'DownloadProject',
|
||||
})
|
||||
@@ -207,6 +176,8 @@ const props = withDefaults(
|
||||
project: DownloadModalProject
|
||||
versions?: Labrinth.Versions.v3.Version[]
|
||||
dependencyDownloadFiles?: DownloadableFile[]
|
||||
downloadDataLoaded?: boolean
|
||||
versionsLoaded?: boolean
|
||||
downloadReason?: CdnDownloadReason
|
||||
initialGameVersion?: string | null
|
||||
initialPlatform?: string | null
|
||||
@@ -217,6 +188,8 @@ const props = withDefaults(
|
||||
{
|
||||
versions: () => [],
|
||||
dependencyDownloadFiles: () => [],
|
||||
downloadDataLoaded: false,
|
||||
versionsLoaded: false,
|
||||
downloadReason: 'standalone',
|
||||
initialGameVersion: null,
|
||||
initialPlatform: null,
|
||||
@@ -233,22 +206,19 @@ const emit = defineEmits<{
|
||||
'update:selection': [selection: ProjectDownloadSelection]
|
||||
}>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const debug = useDebugLogger('DownloadProject')
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const userSelectedGameVersion = ref<string | null>(props.initialGameVersion)
|
||||
const userSelectedPlatform = ref<string | null>(props.initialPlatform)
|
||||
const userSelectedCompatibleVersionId = ref<string | null>(null)
|
||||
const showAllVersions = ref(defaultShowAllVersions())
|
||||
const versionFilter = ref('')
|
||||
const versionNumberRef = ref<HTMLElement | null>(null)
|
||||
const versionNameRef = ref<HTMLElement | null>(null)
|
||||
const downloadingSelectedVersion = ref(false)
|
||||
const selectedVersionDownloadProgress = ref({
|
||||
current: 0,
|
||||
total: 0,
|
||||
})
|
||||
const preferredPlatformRanks = new Map([
|
||||
['fabric', 0],
|
||||
['forge', 1],
|
||||
['neoforge', 2],
|
||||
])
|
||||
|
||||
const incompatibleGameVersionsSet = computed(() => new Set(props.incompatibleGameVersions))
|
||||
const incompatibleLoadersSet = computed(() => new Set(props.incompatibleLoaders))
|
||||
@@ -376,13 +346,12 @@ const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
|
||||
|
||||
const platformOptions = computed<ComboboxOption<string>[]>(() => {
|
||||
return props.project.loaders
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((platform) => ({
|
||||
value: platform,
|
||||
label: loaderLabel(platform),
|
||||
class: '!px-0 !py-1',
|
||||
}))
|
||||
.sort(comparePlatformOptions)
|
||||
})
|
||||
|
||||
const filteredVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
|
||||
@@ -407,31 +376,71 @@ const filteredVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
|
||||
})
|
||||
|
||||
const filteredRelease = computed<Labrinth.Versions.v3.Version | undefined>(() => {
|
||||
return filteredVersions.value.find((x) => x.version_type === 'release')
|
||||
return latestVersionByType('release')
|
||||
})
|
||||
|
||||
const filteredBeta = computed<Labrinth.Versions.v3.Version | undefined>(() => {
|
||||
return filteredVersions.value.find(
|
||||
(x) =>
|
||||
x.version_type === 'beta' &&
|
||||
(!filteredRelease.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))),
|
||||
)
|
||||
return latestVersionByType('beta')
|
||||
})
|
||||
|
||||
const filteredAlpha = computed<Labrinth.Versions.v3.Version | undefined>(() => {
|
||||
return filteredVersions.value.find(
|
||||
(x) =>
|
||||
x.version_type === 'alpha' &&
|
||||
(!filteredRelease.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))) &&
|
||||
(!filteredBeta.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredBeta.value.date_published))),
|
||||
return latestVersionByType('alpha')
|
||||
})
|
||||
|
||||
const defaultSelectedVersion = computed<Labrinth.Versions.v3.Version | null>(() => {
|
||||
return filteredRelease.value || filteredBeta.value || filteredAlpha.value || null
|
||||
})
|
||||
|
||||
const suggestedPreReleaseVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
|
||||
if (!defaultSelectedVersion.value || defaultSelectedVersion.value.version_type !== 'release')
|
||||
return []
|
||||
|
||||
const versions: Labrinth.Versions.v3.Version[] = []
|
||||
const beta = filteredBeta.value
|
||||
if (beta && isNewerThan(beta, defaultSelectedVersion.value)) {
|
||||
versions.push(beta)
|
||||
}
|
||||
|
||||
const alpha = filteredAlpha.value
|
||||
if (alpha && isNewerThan(alpha, defaultSelectedVersion.value)) {
|
||||
versions.push(alpha)
|
||||
}
|
||||
|
||||
return versions
|
||||
})
|
||||
|
||||
const compatibleVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
|
||||
if (!defaultSelectedVersion.value) return []
|
||||
return [defaultSelectedVersion.value, ...suggestedPreReleaseVersions.value]
|
||||
})
|
||||
|
||||
const showNoCompatibleVersions = computed(() => {
|
||||
return (
|
||||
props.versionsLoaded &&
|
||||
compatibleVersions.value.length === 0 &&
|
||||
!!currentGameVersion.value &&
|
||||
!!currentPlatform.value
|
||||
)
|
||||
})
|
||||
|
||||
const noCompatibleVersionsDescription = computed(() => {
|
||||
const gameVersion = currentGameVersion.value
|
||||
if (!gameVersion || !currentPlatform.value) return ''
|
||||
|
||||
return formatMessage(messages.noVersionsAvailable, {
|
||||
gameVersion,
|
||||
platform: currentPlatformText.value,
|
||||
})
|
||||
})
|
||||
|
||||
const selectedVersion = computed<Labrinth.Versions.v3.Version | null>(() => {
|
||||
return filteredRelease.value || filteredBeta.value || filteredAlpha.value || null
|
||||
return (
|
||||
compatibleVersions.value.find(
|
||||
(version) => version.id === userSelectedCompatibleVersionId.value,
|
||||
) ||
|
||||
defaultSelectedVersion.value ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const selectedPrimaryFile = computed<Labrinth.Versions.v3.VersionFile | null>(() => {
|
||||
@@ -442,39 +451,47 @@ const selectedPrimaryFile = computed<Labrinth.Versions.v3.VersionFile | null>(()
|
||||
)
|
||||
})
|
||||
|
||||
const selectedPrimaryFileDownloadUrl = computed(() => {
|
||||
if (!selectedPrimaryFile.value) return '#'
|
||||
return getDownloadUrl(selectedPrimaryFile.value.url)
|
||||
const requiredResourcePackFile = computed<Labrinth.Versions.v3.VersionFile | null>(() => {
|
||||
if (props.project.project_type !== 'datapack') return null
|
||||
|
||||
return (
|
||||
selectedVersion.value?.files?.find(
|
||||
(file) => file !== selectedPrimaryFile.value && file.file_type === 'required-resource-pack',
|
||||
) || null
|
||||
)
|
||||
})
|
||||
|
||||
const selectedVersionDownloadFiles = computed(() => {
|
||||
if (!selectedVersion.value) return []
|
||||
const recommendedResourcePackFiles = computed<Labrinth.Versions.v3.VersionFile[]>(() => {
|
||||
if (props.project.project_type !== 'datapack') return []
|
||||
|
||||
return selectedVersion.value.files.map((file) => ({
|
||||
href: getDownloadUrl(file.url),
|
||||
filename: file.filename,
|
||||
}))
|
||||
return (
|
||||
selectedVersion.value?.files?.filter(
|
||||
(file) => file !== selectedPrimaryFile.value && file.file_type === 'optional-resource-pack',
|
||||
) || []
|
||||
)
|
||||
})
|
||||
|
||||
const downloadAllFiles = computed(() => {
|
||||
const files: DownloadableFile[] = []
|
||||
const hasAdditionalDownloads = computed(() => {
|
||||
const hrefs = new Set<string>()
|
||||
|
||||
for (const file of [...selectedVersionDownloadFiles.value, ...props.dependencyDownloadFiles]) {
|
||||
if (hrefs.has(file.href)) continue
|
||||
hrefs.add(file.href)
|
||||
files.push(file)
|
||||
if (selectedPrimaryFile.value) {
|
||||
hrefs.add(selectedPrimaryFile.value.url)
|
||||
}
|
||||
|
||||
return files
|
||||
})
|
||||
if (requiredResourcePackFile.value) {
|
||||
hrefs.add(requiredResourcePackFile.value.url)
|
||||
}
|
||||
|
||||
const selectedVersionZipFilename = computed(() => {
|
||||
if (!selectedVersion.value) return `${sanitizeFilename(props.project.title)}.zip`
|
||||
for (const file of recommendedResourcePackFiles.value) {
|
||||
hrefs.add(file.url)
|
||||
}
|
||||
|
||||
return `${sanitizeFilename(props.project.title)} ${sanitizeFilename(
|
||||
selectedVersion.value.version_number,
|
||||
)}.zip`
|
||||
for (const file of props.dependencyDownloadFiles) {
|
||||
if (hrefs.has(file.href)) continue
|
||||
hrefs.add(file.href)
|
||||
}
|
||||
|
||||
return hrefs.size > 1
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -490,11 +507,16 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch([currentGameVersion, currentPlatform], () => {
|
||||
userSelectedCompatibleVersionId.value = null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.resetKey,
|
||||
() => {
|
||||
userSelectedGameVersion.value = props.initialGameVersion
|
||||
userSelectedPlatform.value = props.initialPlatform
|
||||
userSelectedCompatibleVersionId.value = null
|
||||
showAllVersions.value = defaultShowAllVersions()
|
||||
versionFilter.value = ''
|
||||
},
|
||||
@@ -513,115 +535,39 @@ function selectPlatform(platform?: string) {
|
||||
emit('selectPlatform', platform)
|
||||
}
|
||||
|
||||
function getDownloadUrl(url: string) {
|
||||
return createProjectDownloadUrl(url, {
|
||||
reason: props.downloadReason,
|
||||
gameVersion: currentGameVersion.value ?? undefined,
|
||||
loader: currentPlatform.value ?? undefined,
|
||||
})
|
||||
function selectCompatibleVersion(version: Labrinth.Versions.v3.Version) {
|
||||
userSelectedCompatibleVersionId.value = version.id
|
||||
}
|
||||
|
||||
async function downloadSelectedVersionFiles() {
|
||||
if (downloadingSelectedVersion.value || downloadAllFiles.value.length <= 1) return
|
||||
|
||||
downloadingSelectedVersion.value = true
|
||||
const files = [...downloadAllFiles.value]
|
||||
selectedVersionDownloadProgress.value = {
|
||||
current: 0,
|
||||
total: files.length,
|
||||
}
|
||||
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
const usedFilenames = new Set<string>()
|
||||
|
||||
for (const [index, file] of files.entries()) {
|
||||
selectedVersionDownloadProgress.value = {
|
||||
current: index + 1,
|
||||
total: files.length,
|
||||
}
|
||||
const response = await fetch(file.href)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${file.filename}`)
|
||||
}
|
||||
|
||||
zip.file(uniqueFilename(file.filename, usedFilenames), await response.blob())
|
||||
}
|
||||
|
||||
downloadBlob(
|
||||
await zip.generateAsync({
|
||||
type: 'blob',
|
||||
mimeType: 'application/zip',
|
||||
}),
|
||||
selectedVersionZipFilename.value,
|
||||
)
|
||||
emit('download')
|
||||
} catch (error) {
|
||||
console.error('Failed to download selected version files:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.downloadSelectedVersionFailedTitle),
|
||||
text: formatMessage(messages.downloadSelectedVersionFailedText),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
downloadingSelectedVersion.value = false
|
||||
selectedVersionDownloadProgress.value = {
|
||||
current: 0,
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
function latestVersionByType(type: Labrinth.Versions.v3.VersionChannel) {
|
||||
return filteredVersions.value
|
||||
.filter((version) => version.version_type === type)
|
||||
.reduce<Labrinth.Versions.v3.Version | undefined>((latest, version) => {
|
||||
if (!latest || isNewerThan(version, latest)) return version
|
||||
return latest
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
|
||||
function sanitizeFilename(value: string) {
|
||||
const sanitized = value
|
||||
.replace(/[<>:"/\\|?*]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
return sanitized || 'download'
|
||||
}
|
||||
|
||||
function uniqueFilename(filename: string, usedFilenames: Set<string>) {
|
||||
const sanitizedFilename = sanitizeFilename(filename)
|
||||
|
||||
if (!usedFilenames.has(sanitizedFilename)) {
|
||||
usedFilenames.add(sanitizedFilename)
|
||||
return sanitizedFilename
|
||||
}
|
||||
|
||||
const extensionIndex = sanitizedFilename.lastIndexOf('.')
|
||||
const basename =
|
||||
extensionIndex > 0 ? sanitizedFilename.slice(0, extensionIndex) : sanitizedFilename
|
||||
const extension = extensionIndex > 0 ? sanitizedFilename.slice(extensionIndex) : ''
|
||||
let index = 2
|
||||
let candidate = `${basename} (${index})${extension}`
|
||||
|
||||
while (usedFilenames.has(candidate)) {
|
||||
index += 1
|
||||
candidate = `${basename} (${index})${extension}`
|
||||
}
|
||||
|
||||
usedFilenames.add(candidate)
|
||||
return candidate
|
||||
function isNewerThan(
|
||||
version: Labrinth.Versions.v3.Version,
|
||||
comparison: Labrinth.Versions.v3.Version,
|
||||
) {
|
||||
return dayjs(version.date_published).isAfter(dayjs(comparison.date_published))
|
||||
}
|
||||
|
||||
function loaderLabel(loader: string) {
|
||||
return formatMessage(getTagMessage(loader, 'loader') ?? messages.unknownLoader)
|
||||
}
|
||||
|
||||
function comparePlatformOptions(a: ComboboxOption<string>, b: ComboboxOption<string>) {
|
||||
const aRank = preferredPlatformRanks.get(a.value) ?? Number.MAX_SAFE_INTEGER
|
||||
const bRank = preferredPlatformRanks.get(b.value) ?? Number.MAX_SAFE_INTEGER
|
||||
|
||||
if (aRank !== bRank) return aRank - bRank
|
||||
|
||||
return a.label.localeCompare(b.label)
|
||||
}
|
||||
|
||||
function isReleaseGameVersion(version: string) {
|
||||
if (releaseVersions.value.has(version)) return true
|
||||
if (nonReleaseVersions.value.has(version)) return false
|
||||
@@ -706,29 +652,9 @@ const messages = defineMessages({
|
||||
id: 'project.download.game-version-unsupported-tooltip',
|
||||
defaultMessage: '{title} does not support {gameVersion} for {platform}',
|
||||
},
|
||||
downloadVersion: {
|
||||
id: 'project.download.download-version',
|
||||
defaultMessage: 'Download {version}',
|
||||
},
|
||||
compatibleVersionTitle: {
|
||||
id: 'project.download.compatible-version-title',
|
||||
defaultMessage: 'Compatible version',
|
||||
},
|
||||
downloadAllSelectedVersion: {
|
||||
id: 'project.download.selected-version-download-all',
|
||||
defaultMessage: 'Download all (.zip)',
|
||||
},
|
||||
downloadingSelectedVersion: {
|
||||
id: 'project.download.selected-version-downloading',
|
||||
defaultMessage: 'Downloading... ({current}/{total})',
|
||||
},
|
||||
downloadSelectedVersionFailedTitle: {
|
||||
id: 'project.download.selected-version-failed-title',
|
||||
defaultMessage: 'Could not download version',
|
||||
},
|
||||
downloadSelectedVersionFailedText: {
|
||||
id: 'project.download.selected-version-failed-text',
|
||||
defaultMessage: 'One or more version files could not be downloaded. Please try again.',
|
||||
defaultMessage: 'Compatible versions',
|
||||
},
|
||||
noGameVersionsFound: {
|
||||
id: 'project.download.no-game-versions-found',
|
||||
|
||||
@@ -6,29 +6,19 @@
|
||||
"
|
||||
class="modrinth-app-section contents"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<a
|
||||
class="modrinth-app-install-card flex items-center justify-between gap-3 rounded-2xl border border-solid border-brand-highlight bg-surface-1 px-4 py-3 text-primary no-underline transition-[filter] hover:brightness-110"
|
||||
:href="`modrinth://mod/${project.slug}`"
|
||||
@click="installWithApp"
|
||||
>
|
||||
<span class="flex w-full min-w-0 flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex min-w-0 items-center gap-1.5 font-medium text-contrast">
|
||||
Install with
|
||||
<span class="text-brand">Modrinth App</span>
|
||||
<ModrinthIcon aria-hidden="true" class="size-4 flex-shrink-0 text-brand" />
|
||||
</span>
|
||||
<ExternalIcon
|
||||
aria-hidden="true"
|
||||
class="size-4 flex-shrink-0 text-contrast transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<span class="truncate text-base text-contrast opacity-80">
|
||||
{{ formatMessage(messages.installWithModrinthAppDescription) }}
|
||||
<div class="flex flex-col items-center">
|
||||
<ButtonStyled color="brand">
|
||||
<a
|
||||
class="!min-h-10 w-fit no-underline"
|
||||
:href="`modrinth://mod/${project.slug}`"
|
||||
@click="installWithApp"
|
||||
>
|
||||
<ModrinthIcon aria-hidden="true" />
|
||||
<span class="min-w-0 text-center">
|
||||
{{ formatMessage(messages.installWithModrinthApp) }}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<Accordion ref="getModrinthAppAccordion">
|
||||
<nuxt-link class="mt-2 flex justify-center text-brand-blue hover:underline" to="/app">
|
||||
{{ formatMessage(messages.dontHaveModrinthApp) }}
|
||||
@@ -48,8 +38,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ExternalIcon, ModrinthIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { ModrinthIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
@@ -73,6 +63,10 @@ const tags = useGeneratedState()
|
||||
const getModrinthAppAccordion = ref<InstanceType<typeof Accordion> | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
installWithModrinthApp: {
|
||||
id: 'project.download.install-with-app',
|
||||
defaultMessage: 'Install with Modrinth App',
|
||||
},
|
||||
dontHaveModrinthApp: {
|
||||
id: 'project.download.no-app',
|
||||
defaultMessage: "Don't have Modrinth App?",
|
||||
@@ -81,10 +75,6 @@ const messages = defineMessages({
|
||||
id: 'project.download.manually',
|
||||
defaultMessage: 'Download manually',
|
||||
},
|
||||
installWithModrinthAppDescription: {
|
||||
id: 'project.download.install-with-app-description',
|
||||
defaultMessage: 'Automatically install the correct version and dependencies.',
|
||||
},
|
||||
})
|
||||
|
||||
function installWithApp() {
|
||||
@@ -95,14 +85,6 @@ function installWithApp() {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modrinth-app-install-card {
|
||||
background: radial-gradient(
|
||||
ellipse 90% 250% at 50% 200%,
|
||||
color-mix(in srgb, var(--color-brand-shadow) 50%, var(--surface-1)) -30%,
|
||||
var(--surface-1) 72%
|
||||
);
|
||||
}
|
||||
|
||||
@media (hover: none) and (max-width: 767px) {
|
||||
.modrinth-app-section {
|
||||
display: none;
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
type CdnDownloadReason,
|
||||
createContext,
|
||||
defineMessages,
|
||||
fileTypeMessages,
|
||||
injectModrinthClient,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { type Component, computed, type ComputedRef } from 'vue'
|
||||
|
||||
import { STALE_TIME } from '~/composables/queries/project'
|
||||
|
||||
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
|
||||
project_type: DisplayProjectType
|
||||
actualProjectType: Labrinth.Projects.v2.ProjectType
|
||||
}
|
||||
|
||||
type ResolvedContent = Labrinth.Content.v3.ResolvedContent | Labrinth.Content.v3.SkippedContent
|
||||
|
||||
export interface DownloadDependencyRow {
|
||||
key: string
|
||||
name: string
|
||||
icon?: string
|
||||
fallbackIcon?: Component
|
||||
hideIcon?: boolean
|
||||
isAdditionalFile?: boolean
|
||||
projectHref?: string
|
||||
downloadHref?: string
|
||||
filename?: string
|
||||
fileSize?: number
|
||||
metadataLabel?: string
|
||||
typeLabel: string
|
||||
unavailableTooltip: string
|
||||
dependencies: DownloadDependencyRow[]
|
||||
}
|
||||
|
||||
export interface DownloadableDependencyFile {
|
||||
href: string
|
||||
filename: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ProjectDownloadSelection {
|
||||
currentGameVersion: string | null
|
||||
currentPlatform: string | null
|
||||
selectedVersion: Labrinth.Versions.v3.Version | null
|
||||
selectedPrimaryFile: Labrinth.Versions.v3.VersionFile | null
|
||||
}
|
||||
|
||||
interface DownloadModalProviderOptions {
|
||||
project: ComputedRef<DownloadModalProject | null>
|
||||
selectedVersion: ComputedRef<Labrinth.Versions.v3.Version | null>
|
||||
selectedPrimaryFile: ComputedRef<Labrinth.Versions.v3.VersionFile | null>
|
||||
currentGameVersion: ComputedRef<string | null>
|
||||
currentPlatform: ComputedRef<string | null>
|
||||
downloadReason: ComputedRef<CdnDownloadReason>
|
||||
additionalFiles: ComputedRef<Labrinth.Versions.v3.VersionFile[]>
|
||||
}
|
||||
|
||||
export interface DownloadModalProvider {
|
||||
visibleDependencyRows: ComputedRef<DownloadDependencyRow[]>
|
||||
duplicateDependencyRowsHidden: ComputedRef<boolean>
|
||||
downloadRows: ComputedRef<DownloadDependencyRow[]>
|
||||
recommendedRows: ComputedRef<DownloadDependencyRow[]>
|
||||
downloadRowsLoaded: ComputedRef<boolean>
|
||||
requiredResourcePackAdmonitionVisible: ComputedRef<boolean>
|
||||
dependencyResourcePackAdmonitionVisible: ComputedRef<boolean>
|
||||
downloadableDependencyFiles: ComputedRef<DownloadableDependencyFile[]>
|
||||
downloadableDependencyFilesLoaded: ComputedRef<boolean>
|
||||
preloadDependenciesForSelection: (selection: ProjectDownloadSelection) => Promise<void>
|
||||
}
|
||||
|
||||
export const [injectDownloadModalProvider, provideDownloadModalContext] =
|
||||
createContext<DownloadModalProvider>('DownloadModal')
|
||||
|
||||
export function provideDownloadModalProvider(
|
||||
options: DownloadModalProviderOptions,
|
||||
): DownloadModalProvider {
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const shouldResolveDependencies = computed(
|
||||
() => !!options.project.value && !!options.selectedVersion.value,
|
||||
)
|
||||
const dependencyResolutionPreferences = computed(() =>
|
||||
createResolutionPreferences(options.selectedVersion.value, options.currentPlatform.value),
|
||||
)
|
||||
|
||||
const { data: dependencyResolution, isFetching: dependencyResolutionFetching } = useQuery({
|
||||
...dependencyResolutionQueryOptions(
|
||||
client,
|
||||
options.project,
|
||||
options.selectedVersion,
|
||||
dependencyResolutionPreferences,
|
||||
),
|
||||
enabled: shouldResolveDependencies,
|
||||
})
|
||||
|
||||
const visibleResolvedDependencies = computed<ResolvedContent[]>(() =>
|
||||
visibleDependencies(dependencyResolution.value),
|
||||
)
|
||||
|
||||
const dependencyVersionIds = computed(() =>
|
||||
sortedUnique(
|
||||
visibleResolvedDependencies.value
|
||||
.filter((dependency) => !('reason' in dependency))
|
||||
.map((dependency) => dependency.version_id)
|
||||
.filter((versionId): versionId is string => !!versionId),
|
||||
),
|
||||
)
|
||||
|
||||
const { data: dependencyVersions, isFetching: dependencyVersionsFetching } = useQuery({
|
||||
...dependencyVersionsQueryOptions(client, dependencyVersionIds),
|
||||
enabled: computed(
|
||||
() => shouldResolveDependencies.value && dependencyVersionIds.value.length > 0,
|
||||
),
|
||||
})
|
||||
|
||||
const dependencyVersionById = computed(() => {
|
||||
const map = new Map<string, Labrinth.Versions.v3.Version>()
|
||||
for (const version of dependencyVersions.value || []) {
|
||||
if (!version) continue
|
||||
map.set(version.id, version)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const dependencyProjectIds = computed(() =>
|
||||
sortedUnique(
|
||||
visibleResolvedDependencies.value
|
||||
.map((dependency) => dependency.project_id)
|
||||
.filter((projectId): projectId is string => !!projectId),
|
||||
),
|
||||
)
|
||||
|
||||
const { data: dependencyProjects, isFetching: dependencyProjectsFetching } = useQuery({
|
||||
...dependencyProjectsQueryOptions(client, dependencyProjectIds),
|
||||
enabled: computed(
|
||||
() => shouldResolveDependencies.value && dependencyProjectIds.value.length > 0,
|
||||
),
|
||||
})
|
||||
|
||||
const dependencyProjectById = computed(() => {
|
||||
const map = new Map<string, Labrinth.Projects.v2.Project>()
|
||||
for (const project of dependencyProjects.value || []) {
|
||||
map.set(project.id, project)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const dependenciesByParentVersionId = computed(() => {
|
||||
const map = new Map<string, ResolvedContent[]>()
|
||||
|
||||
for (const dependency of visibleResolvedDependencies.value) {
|
||||
if (!dependency.dependent_on_version_id) continue
|
||||
|
||||
const dependencies = map.get(dependency.dependent_on_version_id) || []
|
||||
dependencies.push(dependency)
|
||||
map.set(dependency.dependent_on_version_id, dependencies)
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const dependenciesLoaded = computed(() => {
|
||||
if (!shouldResolveDependencies.value) return false
|
||||
if (dependencyResolutionFetching.value) return false
|
||||
if (!dependencyResolution.value) return false
|
||||
if (
|
||||
dependencyResolution.value.primary.version_id &&
|
||||
dependencyResolution.value.primary.version_id !== options.selectedVersion.value?.id
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
dependencyVersionsFetching.value ||
|
||||
!dependencyVersionIds.value.every((versionId) => dependencyVersionById.value.has(versionId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
dependencyProjectsFetching.value ||
|
||||
!dependencyProjectIds.value.every((projectId) => dependencyProjectById.value.has(projectId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const selectedDownloadRowsLoaded = computed(() => {
|
||||
if (!options.selectedPrimaryFile.value) return false
|
||||
return dependenciesLoaded.value
|
||||
})
|
||||
const keepPreviousDownloadRows = computed(
|
||||
() => shouldResolveDependencies.value && !!options.selectedPrimaryFile.value,
|
||||
)
|
||||
|
||||
const resolvedDependencyRows = computed<DownloadDependencyRow[]>(() => {
|
||||
if (!selectedDownloadRowsLoaded.value) return []
|
||||
|
||||
const primaryVersionId =
|
||||
dependencyResolution.value?.primary.version_id || options.selectedVersion.value?.id
|
||||
if (!primaryVersionId) return []
|
||||
|
||||
const dependencies = dependenciesByParentVersionId.value.get(primaryVersionId) || []
|
||||
|
||||
return dependencies.flatMap((dependency) => {
|
||||
const row = createDependencyRow(dependency)
|
||||
return row ? [row] : []
|
||||
})
|
||||
})
|
||||
|
||||
const visibleDependencyRows = computed<DownloadDependencyRow[]>((previous) => {
|
||||
if (selectedDownloadRowsLoaded.value) {
|
||||
return dedupeDependencyRows(resolvedDependencyRows.value)
|
||||
}
|
||||
|
||||
return keepPreviousDownloadRows.value ? (previous ?? []) : []
|
||||
})
|
||||
|
||||
const duplicateDependencyRowsHidden = computed<boolean>((previous) => {
|
||||
if (selectedDownloadRowsLoaded.value) {
|
||||
return (
|
||||
hasSkippedDuplicateDependency(dependencyResolution.value) ||
|
||||
hasDuplicateDependencyRows(resolvedDependencyRows.value)
|
||||
)
|
||||
}
|
||||
|
||||
return keepPreviousDownloadRows.value ? (previous ?? false) : false
|
||||
})
|
||||
|
||||
const visibleRequiredResourcePackFiles = computed(() => {
|
||||
if (options.project.value?.project_type !== 'datapack') return []
|
||||
|
||||
return options.additionalFiles.value.filter(
|
||||
(file) => file.file_type === 'required-resource-pack',
|
||||
)
|
||||
})
|
||||
|
||||
const visibleRecommendedResourcePackFiles = computed(() => {
|
||||
if (options.project.value?.project_type !== 'datapack') return []
|
||||
|
||||
return options.additionalFiles.value.filter(
|
||||
(file) => file.file_type === 'optional-resource-pack',
|
||||
)
|
||||
})
|
||||
|
||||
const additionalFileRows = computed<DownloadDependencyRow[]>(() =>
|
||||
selectedDownloadRowsLoaded.value
|
||||
? visibleRequiredResourcePackFiles.value.map(createAdditionalFileRow)
|
||||
: [],
|
||||
)
|
||||
|
||||
const recommendedRows = computed<DownloadDependencyRow[]>(() =>
|
||||
selectedDownloadRowsLoaded.value
|
||||
? visibleRecommendedResourcePackFiles.value.map(createAdditionalFileRow)
|
||||
: [],
|
||||
)
|
||||
|
||||
function createAdditionalFileRow(file: Labrinth.Versions.v3.VersionFile): DownloadDependencyRow {
|
||||
return {
|
||||
key: `additional-file-${additionalFileKey(file)}`,
|
||||
name: file.filename,
|
||||
hideIcon: true,
|
||||
isAdditionalFile: true,
|
||||
downloadHref: getDownloadUrl(file.url),
|
||||
filename: file.filename,
|
||||
fileSize: file.size,
|
||||
metadataLabel: fileTypeDisplayLabel(file.file_type),
|
||||
typeLabel: fileTypeLabel(file.file_type),
|
||||
unavailableTooltip: formatMessage(messages.unavailableFile),
|
||||
dependencies: [],
|
||||
}
|
||||
}
|
||||
|
||||
const downloadRows = computed<DownloadDependencyRow[]>((previous) => {
|
||||
if (selectedDownloadRowsLoaded.value) {
|
||||
return [...visibleDependencyRows.value, ...additionalFileRows.value]
|
||||
}
|
||||
|
||||
return keepPreviousDownloadRows.value ? (previous ?? []) : []
|
||||
})
|
||||
|
||||
const downloadRowsLoaded = computed<boolean>((previous) => {
|
||||
if (selectedDownloadRowsLoaded.value) return true
|
||||
return keepPreviousDownloadRows.value ? (previous ?? false) : false
|
||||
})
|
||||
|
||||
const requiredResourcePackAdmonitionVisible = computed(() => {
|
||||
return selectedDownloadRowsLoaded.value && visibleRequiredResourcePackFiles.value.length > 0
|
||||
})
|
||||
|
||||
const dependencyResourcePackAdmonitionVisible = computed(() => {
|
||||
return selectedDownloadRowsLoaded.value && hasAdditionalFileRows(visibleDependencyRows.value)
|
||||
})
|
||||
|
||||
const downloadableDependencyFiles = computed<DownloadableDependencyFile[]>(() =>
|
||||
collectDownloadableDependencyFiles(visibleDependencyRows.value),
|
||||
)
|
||||
|
||||
const downloadableDependencyFilesLoaded = computed(() => {
|
||||
return selectedDownloadRowsLoaded.value
|
||||
})
|
||||
|
||||
async function preloadDependenciesForSelection(selection: ProjectDownloadSelection) {
|
||||
if (!options.project.value || !selection.selectedVersion) return
|
||||
|
||||
const preferences = createResolutionPreferences(
|
||||
selection.selectedVersion,
|
||||
selection.currentPlatform,
|
||||
)
|
||||
|
||||
const resolution = await queryClient.ensureQueryData({
|
||||
queryKey: [
|
||||
'project-download-modal',
|
||||
'content-resolve',
|
||||
options.project.value.id,
|
||||
selection.selectedVersion.id,
|
||||
options.project.value.project_type,
|
||||
preferences,
|
||||
],
|
||||
queryFn: () =>
|
||||
client.labrinth.content_v3.resolve({
|
||||
project_id: options.project.value!.id,
|
||||
version_id: selection.selectedVersion!.id,
|
||||
content_type: resolveContentType(options.project.value!.project_type),
|
||||
selected: preferences,
|
||||
target: preferences,
|
||||
}),
|
||||
staleTime: STALE_TIME,
|
||||
})
|
||||
const visible = visibleDependencies(resolution)
|
||||
const versionIds = getDependencyVersionIds(visible)
|
||||
const projectIds = getDependencyProjectIds(visible)
|
||||
|
||||
await Promise.all([
|
||||
versionIds.length > 0
|
||||
? queryClient.ensureQueryData({
|
||||
queryKey: ['project-download-modal', 'resolved-versions', versionIds],
|
||||
queryFn: () => client.labrinth.versions_v3.getVersions(versionIds),
|
||||
staleTime: STALE_TIME,
|
||||
})
|
||||
: Promise.resolve(),
|
||||
projectIds.length > 0
|
||||
? queryClient.ensureQueryData({
|
||||
queryKey: ['project-download-modal', 'resolved-projects', projectIds],
|
||||
queryFn: () => client.labrinth.projects_v2.getMultiple(projectIds),
|
||||
staleTime: STALE_TIME,
|
||||
})
|
||||
: Promise.resolve(),
|
||||
])
|
||||
}
|
||||
|
||||
function createDependencyRow(dependency: ResolvedContent): DownloadDependencyRow | null {
|
||||
const versionId = dependency.version_id ?? undefined
|
||||
const version = versionId ? dependencyVersionById.value.get(versionId) : undefined
|
||||
const project = dependencyProjectById.value.get(dependency.project_id)
|
||||
if (!project) return null
|
||||
|
||||
const primaryFile = primaryFileForVersion(version)
|
||||
const unavailableTooltip =
|
||||
'reason' in dependency && dependency.reason
|
||||
? skippedReasonLabel(dependency.reason)
|
||||
: formatMessage(messages.unavailableDependency)
|
||||
const name = project.title
|
||||
const metadataLabel = isProjectOnlyDependencyReference(dependency)
|
||||
? formatMessage(messages.anyCompatibleDependency)
|
||||
: (version?.version_number ?? formatMessage(messages.anyCompatibleDependency))
|
||||
const childDependencies = (
|
||||
versionId && dependenciesByParentVersionId.value.get(versionId)
|
||||
? dependenciesByParentVersionId.value.get(versionId)!
|
||||
: []
|
||||
).flatMap((subDependency) => {
|
||||
const row = createDependencyRow(subDependency)
|
||||
return row ? [row] : []
|
||||
})
|
||||
|
||||
return {
|
||||
key: `${dependency.project_id}-${versionId ?? 'unresolved'}-${
|
||||
'reason' in dependency ? dependency.reason : 'resolved'
|
||||
}`,
|
||||
name,
|
||||
icon: project.icon_url ?? undefined,
|
||||
projectHref: `/${project.project_type}/${project.slug || project.id}`,
|
||||
downloadHref:
|
||||
'reason' in dependency || !primaryFile ? undefined : getDownloadUrl(primaryFile.url),
|
||||
filename: primaryFile?.filename,
|
||||
fileSize: primaryFile?.size,
|
||||
metadataLabel,
|
||||
typeLabel: 'Required',
|
||||
unavailableTooltip,
|
||||
dependencies: [
|
||||
...childDependencies,
|
||||
...createRequiredResourcePackRowsForDependency(project, version, primaryFile),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function createRequiredResourcePackRowsForDependency(
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
version: Labrinth.Versions.v3.Version | undefined,
|
||||
primaryFile: Labrinth.Versions.v3.VersionFile | undefined,
|
||||
): DownloadDependencyRow[] {
|
||||
if (!version || !isDataPackProject(project)) return []
|
||||
|
||||
return version.files
|
||||
.filter((file) => file !== primaryFile && file.file_type === 'required-resource-pack')
|
||||
.map((file) => ({
|
||||
key: `dependency-resource-pack-${version.id}-${additionalFileKey(file)}`,
|
||||
name: file.filename,
|
||||
hideIcon: true,
|
||||
isAdditionalFile: true,
|
||||
downloadHref: getDownloadUrl(file.url),
|
||||
filename: file.filename,
|
||||
fileSize: file.size,
|
||||
metadataLabel: fileTypeDisplayLabel(file.file_type),
|
||||
typeLabel: fileTypeLabel(file.file_type),
|
||||
unavailableTooltip: formatMessage(messages.unavailableFile),
|
||||
dependencies: [],
|
||||
}))
|
||||
}
|
||||
|
||||
function isDataPackProject(project: Labrinth.Projects.v2.Project) {
|
||||
return (
|
||||
project.project_type === 'datapack' ||
|
||||
project.loaders.some((loader) => tags.value.loaderData.dataPackLoaders.includes(loader))
|
||||
)
|
||||
}
|
||||
|
||||
function isProjectOnlyDependencyReference(dependency: ResolvedContent) {
|
||||
const parentVersionId = dependency.dependent_on_version_id
|
||||
const parentVersion =
|
||||
parentVersionId === options.selectedVersion.value?.id
|
||||
? options.selectedVersion.value
|
||||
: parentVersionId
|
||||
? dependencyVersionById.value.get(parentVersionId)
|
||||
: undefined
|
||||
|
||||
return !!parentVersion?.dependencies?.some(
|
||||
(parentDependency) =>
|
||||
parentDependency.project_id === dependency.project_id && !parentDependency.version_id,
|
||||
)
|
||||
}
|
||||
|
||||
function skippedReasonLabel(reason: Labrinth.Content.v3.SkippedContent['reason']) {
|
||||
return (
|
||||
{
|
||||
already_installed: formatMessage(messages.alreadyInstalledDependency),
|
||||
duplicate_project: formatMessage(messages.duplicateDependency),
|
||||
conflicting_dependency: formatMessage(messages.conflictingDependency),
|
||||
no_compatible_version: formatMessage(messages.noCompatibleDependency),
|
||||
missing_version: formatMessage(messages.missingDependencyVersion),
|
||||
quilt_fabric_api: formatMessage(messages.quiltFabricApiDependency),
|
||||
}[reason] || formatMessage(messages.unavailableDependency)
|
||||
)
|
||||
}
|
||||
|
||||
function getDownloadUrl(url: string) {
|
||||
return createProjectDownloadUrl(url, {
|
||||
reason: options.downloadReason.value,
|
||||
gameVersion: options.currentGameVersion.value ?? undefined,
|
||||
loader: options.currentPlatform.value ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function fileTypeLabel(type?: Labrinth.Versions.v3.FileType | null) {
|
||||
return formatMessage(fileTypeMessages[type ?? 'unknown'] ?? fileTypeMessages.unknown)
|
||||
}
|
||||
|
||||
function fileTypeDisplayLabel(type?: Labrinth.Versions.v3.FileType | null) {
|
||||
if (type === 'required-resource-pack') return formatMessage(messages.requiredResourcePackShort)
|
||||
|
||||
return fileTypeLabel(type)
|
||||
}
|
||||
|
||||
const provider = {
|
||||
visibleDependencyRows,
|
||||
duplicateDependencyRowsHidden,
|
||||
downloadRows,
|
||||
recommendedRows,
|
||||
downloadRowsLoaded,
|
||||
requiredResourcePackAdmonitionVisible,
|
||||
dependencyResourcePackAdmonitionVisible,
|
||||
downloadableDependencyFiles,
|
||||
downloadableDependencyFilesLoaded,
|
||||
preloadDependenciesForSelection,
|
||||
}
|
||||
|
||||
provideDownloadModalContext(provider)
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
function dependencyResolutionQueryOptions(
|
||||
client: AbstractModrinthClient,
|
||||
project: ComputedRef<DownloadModalProject | null>,
|
||||
selectedVersion: ComputedRef<Labrinth.Versions.v3.Version | null>,
|
||||
preferences: ComputedRef<Labrinth.Content.v3.ResolutionPreferences>,
|
||||
) {
|
||||
return {
|
||||
queryKey: computed(() => [
|
||||
'project-download-modal',
|
||||
'content-resolve',
|
||||
project.value?.id,
|
||||
selectedVersion.value?.id,
|
||||
project.value?.project_type,
|
||||
preferences.value,
|
||||
]),
|
||||
queryFn: () =>
|
||||
client.labrinth.content_v3.resolve({
|
||||
project_id: project.value!.id,
|
||||
version_id: selectedVersion.value!.id,
|
||||
content_type: resolveContentType(project.value!.project_type),
|
||||
selected: preferences.value,
|
||||
target: preferences.value,
|
||||
}),
|
||||
staleTime: STALE_TIME,
|
||||
}
|
||||
}
|
||||
|
||||
function dependencyVersionsQueryOptions(
|
||||
client: AbstractModrinthClient,
|
||||
versionIds: ComputedRef<string[]>,
|
||||
) {
|
||||
return {
|
||||
queryKey: computed(() => ['project-download-modal', 'resolved-versions', versionIds.value]),
|
||||
queryFn: () => client.labrinth.versions_v3.getVersions(versionIds.value),
|
||||
staleTime: STALE_TIME,
|
||||
}
|
||||
}
|
||||
|
||||
function dependencyProjectsQueryOptions(
|
||||
client: AbstractModrinthClient,
|
||||
projectIds: ComputedRef<string[]>,
|
||||
) {
|
||||
return {
|
||||
queryKey: computed(() => ['project-download-modal', 'resolved-projects', projectIds.value]),
|
||||
queryFn: () => client.labrinth.projects_v2.getMultiple(projectIds.value),
|
||||
staleTime: STALE_TIME,
|
||||
}
|
||||
}
|
||||
|
||||
function createResolutionPreferences(
|
||||
version: Labrinth.Versions.v3.Version | null,
|
||||
currentPlatform: string | null,
|
||||
): Labrinth.Content.v3.ResolutionPreferences {
|
||||
return {
|
||||
game_versions: version?.game_versions || [],
|
||||
loaders: currentPlatform ? [currentPlatform] : version?.loaders || [],
|
||||
}
|
||||
}
|
||||
|
||||
function visibleDependencies(resolution?: Labrinth.Content.v3.ResolveContentPlan) {
|
||||
return [...(resolution?.dependencies || []), ...(resolution?.skipped || [])].filter(
|
||||
shouldShowDependency,
|
||||
)
|
||||
}
|
||||
|
||||
function getDependencyVersionIds(dependencies: ResolvedContent[]) {
|
||||
return sortedUnique(
|
||||
dependencies
|
||||
.filter((dependency) => !('reason' in dependency))
|
||||
.map((dependency) => dependency.version_id)
|
||||
.filter((versionId): versionId is string => !!versionId),
|
||||
)
|
||||
}
|
||||
|
||||
function getDependencyProjectIds(dependencies: ResolvedContent[]) {
|
||||
return sortedUnique(
|
||||
dependencies
|
||||
.map((dependency) => dependency.project_id)
|
||||
.filter((projectId): projectId is string => !!projectId),
|
||||
)
|
||||
}
|
||||
|
||||
function primaryFileForVersion(version?: Labrinth.Versions.v3.Version) {
|
||||
return version?.files?.find((file) => file.primary) || version?.files?.[0]
|
||||
}
|
||||
|
||||
function shouldShowDependency(dependency: ResolvedContent) {
|
||||
return !(
|
||||
'reason' in dependency && ['duplicate_project', 'quilt_fabric_api'].includes(dependency.reason)
|
||||
)
|
||||
}
|
||||
|
||||
function hasSkippedDuplicateDependency(resolution?: Labrinth.Content.v3.ResolveContentPlan) {
|
||||
return (resolution?.skipped || []).some((dependency) => dependency.reason === 'duplicate_project')
|
||||
}
|
||||
|
||||
function resolveContentType(projectType: DisplayProjectType): Labrinth.Content.v3.ContentType {
|
||||
return ['mod', 'plugin', 'datapack', 'resourcepack', 'shader', 'modpack'].includes(projectType)
|
||||
? (projectType as Labrinth.Content.v3.ContentType)
|
||||
: 'mod'
|
||||
}
|
||||
|
||||
function additionalFileKey(file: Labrinth.Versions.v3.VersionFile) {
|
||||
return file.hashes?.sha1 ?? file.filename
|
||||
}
|
||||
|
||||
function dedupeDependencyRows(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenDependencies = new Set<string>(),
|
||||
): DownloadDependencyRow[] {
|
||||
return rows.flatMap((row) => {
|
||||
const identity = dependencyRowIdentity(row)
|
||||
if (seenDependencies.has(identity)) return []
|
||||
|
||||
seenDependencies.add(identity)
|
||||
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
dependencies: dedupeDependencyRows(row.dependencies, seenDependencies),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function dependencyRowIdentity(row: DownloadDependencyRow) {
|
||||
return row.projectHref ?? row.downloadHref ?? row.key
|
||||
}
|
||||
|
||||
function hasDuplicateDependencyRows(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenDependencies = new Set<string>(),
|
||||
): boolean {
|
||||
for (const row of rows) {
|
||||
const rowId = dependencyRowIdentity(row)
|
||||
if (seenDependencies.has(rowId)) return true
|
||||
seenDependencies.add(rowId)
|
||||
if (hasDuplicateDependencyRows(row.dependencies, seenDependencies)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function hasAdditionalFileRows(rows: DownloadDependencyRow[]): boolean {
|
||||
for (const row of rows) {
|
||||
if (row.isAdditionalFile) return true
|
||||
if (hasAdditionalFileRows(row.dependencies)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function collectDownloadableDependencyFiles(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenHrefs = new Set<string>(),
|
||||
): DownloadableDependencyFile[] {
|
||||
const files: DownloadableDependencyFile[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.downloadHref && !seenHrefs.has(row.downloadHref)) {
|
||||
seenHrefs.add(row.downloadHref)
|
||||
files.push({
|
||||
href: row.downloadHref,
|
||||
filename: row.filename || filenameFromUrl(row.downloadHref),
|
||||
name: row.name,
|
||||
})
|
||||
}
|
||||
|
||||
files.push(...collectDownloadableDependencyFiles(row.dependencies, seenHrefs))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function filenameFromUrl(url: string) {
|
||||
try {
|
||||
const filename = new URL(url).pathname.split('/').pop()
|
||||
return filename ? decodeURIComponent(filename) : 'dependency.jar'
|
||||
} catch {
|
||||
return 'dependency.jar'
|
||||
}
|
||||
}
|
||||
|
||||
function sortedUnique(values: string[]) {
|
||||
return [...new Set(values)].sort()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
anyCompatibleDependency: {
|
||||
id: 'project.download.dependency-any-compatible',
|
||||
defaultMessage: 'Any compatible',
|
||||
},
|
||||
alreadyInstalledDependency: {
|
||||
id: 'project.download.dependency-already-installed',
|
||||
defaultMessage: 'This dependency is already installed',
|
||||
},
|
||||
conflictingDependency: {
|
||||
id: 'project.download.dependency-conflicting',
|
||||
defaultMessage: 'This dependency conflicts with another dependency',
|
||||
},
|
||||
duplicateDependency: {
|
||||
id: 'project.download.dependency-duplicate',
|
||||
defaultMessage: 'This dependency is already included',
|
||||
},
|
||||
missingDependencyVersion: {
|
||||
id: 'project.download.dependency-missing-version',
|
||||
defaultMessage: 'This dependency version is unavailable',
|
||||
},
|
||||
noCompatibleDependency: {
|
||||
id: 'project.download.dependency-no-compatible-version',
|
||||
defaultMessage: 'No compatible version is available for this dependency',
|
||||
},
|
||||
quiltFabricApiDependency: {
|
||||
id: 'project.download.dependency-quilt-fabric-api',
|
||||
defaultMessage: 'Fabric API is skipped for Quilt',
|
||||
},
|
||||
unavailableDependency: {
|
||||
id: 'project.download.dependency-unavailable',
|
||||
defaultMessage: 'This dependency cannot be downloaded',
|
||||
},
|
||||
unavailableFile: {
|
||||
id: 'project.download.file-unavailable',
|
||||
defaultMessage: 'This file cannot be downloaded',
|
||||
},
|
||||
requiredResourcePackShort: {
|
||||
id: 'project.download.required-resource-pack-short',
|
||||
defaultMessage: 'Resource pack',
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :on-show="onShow" :on-hide="onHide" width="544px">
|
||||
<NewModal ref="modal" :on-show="onShow" :on-hide="onHide" width="544px" actions-divider>
|
||||
<template #title>
|
||||
<template v-if="project">
|
||||
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
|
||||
@@ -19,6 +19,8 @@
|
||||
:project="project"
|
||||
:versions="versions"
|
||||
:dependency-download-files="dependencyDownloadFiles"
|
||||
:download-data-loaded="downloadRowsLoaded"
|
||||
:versions-loaded="versionsLoaded"
|
||||
:download-reason="downloadReason"
|
||||
:initial-game-version="initialGameVersion"
|
||||
:initial-platform="initialPlatform"
|
||||
@@ -27,20 +29,11 @@
|
||||
:reset-key="downloadProjectResetKey"
|
||||
@select-game-version="selectGameVersion"
|
||||
@select-platform="selectPlatform"
|
||||
@update:selection="projectDownloadSelection = $event"
|
||||
@update:selection="updateProjectDownloadSelection"
|
||||
@download="onDownload"
|
||||
/>
|
||||
<div class="flex flex-col gap-4">
|
||||
<DownloadDependencies
|
||||
:project="project"
|
||||
:selected-version="selectedVersion"
|
||||
:current-game-version="currentGameVersion"
|
||||
:current-platform="currentPlatform"
|
||||
:download-reason="downloadReason"
|
||||
:additional-files="additionalFiles"
|
||||
@update:downloadable-files="dependencyDownloadFiles = $event"
|
||||
@download="onDownload"
|
||||
/>
|
||||
<DownloadDependencies @download="onDownload" />
|
||||
</div>
|
||||
<ServersPromo
|
||||
v-if="flags.showProjectPageDownloadModalServersPromo"
|
||||
@@ -54,16 +47,61 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showDependencyDownloadActions" #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2 p-2">
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="!shadow-none"
|
||||
:disabled="!!downloadingActionType || !dependencyDownloadFilesLoaded"
|
||||
@click="downloadSelectedVersionZip"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="downloadingActionType === 'zip'"
|
||||
aria-hidden="true"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<DownloadIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.downloadAsZip) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<JoinedButtons
|
||||
v-if="hasRecommendedDownloadFiles"
|
||||
color="brand"
|
||||
:actions="downloadWithRecommendedActions"
|
||||
:disabled="!!downloadingActionType || !dependencyDownloadFilesLoaded"
|
||||
/>
|
||||
<ButtonStyled v-else color="brand">
|
||||
<button
|
||||
class="!shadow-none"
|
||||
:disabled="!!downloadingActionType || !dependencyDownloadFilesLoaded"
|
||||
@click="downloadFilesWithDependencies"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="downloadingActionType === 'dependencies'"
|
||||
aria-hidden="true"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<DownloadIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.downloadWithDependencies) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
type CdnDownloadReason,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
type JoinedButtonAction,
|
||||
JoinedButtons,
|
||||
NewModal,
|
||||
ServersPromo,
|
||||
truncatedTooltip,
|
||||
@@ -71,13 +109,16 @@ import {
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import JSZip from 'jszip'
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { navigateTo } from '#app'
|
||||
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
|
||||
|
||||
import { provideDownloadModalProvider } from './download-modal-provider'
|
||||
import DownloadDependencies from './DownloadDependencies.vue'
|
||||
import DownloadProject from './DownloadProject.vue'
|
||||
import InstallWithModrinthApp from './InstallWithModrinthApp.vue'
|
||||
@@ -99,6 +140,12 @@ type DownloadableFile = {
|
||||
filename: string
|
||||
}
|
||||
|
||||
type DownloadedFile = DownloadableFile & {
|
||||
blob: Blob
|
||||
}
|
||||
|
||||
type DownloadActionType = 'zip' | 'dependencies' | 'recommended'
|
||||
|
||||
type NewModalRef = {
|
||||
show: (event?: MouseEvent) => void
|
||||
hide: () => void
|
||||
@@ -138,19 +185,31 @@ const route = useRoute()
|
||||
const flags = useFeatureFlags()
|
||||
const tags = useGeneratedState()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const debug = useDebugLogger('DownloadModal')
|
||||
|
||||
const modal = ref<NewModalRef | null>(null)
|
||||
const downloadTitleRef = ref<HTMLElement | null>(null)
|
||||
const modalOpening = ref(false)
|
||||
const modalOpen = ref(false)
|
||||
const showProjectId = ref<string | null>(null)
|
||||
const showOptions = ref<ResolvedProjectDownloadModalShowOptions>(getDefaultShowOptions())
|
||||
const downloadProjectResetKey = ref(0)
|
||||
const projectDownloadSelection = ref<ProjectDownloadSelection>(getDefaultProjectDownloadSelection())
|
||||
const dependencyDownloadFiles = ref<DownloadableFile[]>([])
|
||||
const pendingRouteSelection = ref({
|
||||
gameVersion: getStringQueryValue(route.query.version),
|
||||
platform: getStringQueryValue(route.query.loader),
|
||||
})
|
||||
const downloadingActionType = ref<DownloadActionType | null>(null)
|
||||
const MODAL_CLOSE_STATE_RESET_MS = 350
|
||||
const DOWNLOAD_URL_REVOKE_MS = 60000
|
||||
const DOWNLOAD_STAGGER_MS = 500
|
||||
let closeStateResetTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let modalShowRequestId = 0
|
||||
let unmounted = false
|
||||
|
||||
const routeProjectId = computed(() => showProjectId.value ?? props.projectId ?? null)
|
||||
|
||||
@@ -183,11 +242,7 @@ const downloadTitle = computed(() => {
|
||||
})
|
||||
|
||||
const versionsEnabled = ref(false)
|
||||
const {
|
||||
data: versionsV3,
|
||||
error: _versionsV3Error,
|
||||
isFetching: versionsV3Loading,
|
||||
} = useQuery({
|
||||
const { data: versionsV3, isFetching: versionsV3Loading } = useQuery({
|
||||
queryKey: computed(() => ['project', resolvedProjectId.value, 'versions', 'v3']),
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v3.getProjectVersions(resolvedProjectId.value!, {
|
||||
@@ -198,24 +253,12 @@ const {
|
||||
enabled: computed(() => !!resolvedProjectId.value && versionsEnabled.value),
|
||||
})
|
||||
|
||||
const versions = computed<Labrinth.Versions.v3.Version[]>(() => {
|
||||
const isModpack =
|
||||
project.value?.actualProjectType === 'modpack' || project.value?.project_type === 'modpack'
|
||||
|
||||
return (versionsV3.value ?? []).map((version) => {
|
||||
const files = Array.isArray(version.files) ? version.files : []
|
||||
const gameVersions = Array.isArray(version.game_versions) ? version.game_versions : []
|
||||
const loaders = Array.isArray(version.loaders) ? version.loaders : []
|
||||
const mrpackLoaders = Array.isArray(version.mrpack_loaders) ? version.mrpack_loaders : []
|
||||
|
||||
return {
|
||||
...version,
|
||||
files,
|
||||
game_versions: gameVersions,
|
||||
loaders: isModpack && mrpackLoaders.length ? mrpackLoaders : loaders,
|
||||
}
|
||||
})
|
||||
})
|
||||
const versions = computed<Labrinth.Versions.v3.Version[]>(() =>
|
||||
normalizeVersionsForDownload(versionsV3.value ?? []),
|
||||
)
|
||||
const versionsLoaded = computed(
|
||||
() => versionsEnabled.value && !versionsV3Loading.value && Array.isArray(versionsV3.value),
|
||||
)
|
||||
|
||||
const initialGameVersion = computed(() => {
|
||||
const version = route.query.version
|
||||
@@ -238,6 +281,88 @@ const additionalFiles = computed(() => {
|
||||
return selectedVersion.value.files.filter((file) => file !== selectedPrimaryFile.value)
|
||||
})
|
||||
|
||||
const hasRequiredResourcePackAdditionalFile = computed(() =>
|
||||
additionalFiles.value.some((file) => file.file_type === 'required-resource-pack'),
|
||||
)
|
||||
|
||||
const downloadModalProvider = provideDownloadModalProvider({
|
||||
project,
|
||||
selectedVersion,
|
||||
selectedPrimaryFile,
|
||||
currentGameVersion,
|
||||
currentPlatform,
|
||||
downloadReason: computed(() => props.downloadReason),
|
||||
additionalFiles,
|
||||
})
|
||||
const dependencyDownloadFiles = downloadModalProvider.downloadableDependencyFiles
|
||||
const dependencyDownloadFilesLoaded = downloadModalProvider.downloadableDependencyFilesLoaded
|
||||
const downloadRowsLoaded = downloadModalProvider.downloadRowsLoaded
|
||||
|
||||
const selectedVersionDownloadFiles = computed<DownloadableFile[]>(() => {
|
||||
if (!selectedVersion.value) return []
|
||||
|
||||
return selectedVersion.value.files
|
||||
.filter(
|
||||
(file) => file === selectedPrimaryFile.value || file.file_type === 'required-resource-pack',
|
||||
)
|
||||
.map((file) => ({
|
||||
href: createProjectDownloadUrl(file.url, {
|
||||
reason: props.downloadReason,
|
||||
gameVersion: currentGameVersion.value ?? undefined,
|
||||
loader: currentPlatform.value ?? undefined,
|
||||
}),
|
||||
filename: file.filename,
|
||||
}))
|
||||
})
|
||||
|
||||
const recommendedDownloadFiles = computed<DownloadableFile[]>(() => {
|
||||
if (project.value?.project_type !== 'datapack' || !selectedVersion.value) return []
|
||||
|
||||
return selectedVersion.value.files
|
||||
.filter(
|
||||
(file) => file !== selectedPrimaryFile.value && file.file_type === 'optional-resource-pack',
|
||||
)
|
||||
.map((file) => ({
|
||||
href: createProjectDownloadUrl(file.url, {
|
||||
reason: props.downloadReason,
|
||||
gameVersion: currentGameVersion.value ?? undefined,
|
||||
loader: currentPlatform.value ?? undefined,
|
||||
}),
|
||||
filename: file.filename,
|
||||
}))
|
||||
})
|
||||
|
||||
const hasRecommendedDownloadFiles = computed(() => recommendedDownloadFiles.value.length > 0)
|
||||
|
||||
const showDependencyDownloadActions = computed(
|
||||
() =>
|
||||
selectedVersionDownloadFiles.value.length > 0 &&
|
||||
(dependencyDownloadFiles.value.length > 0 ||
|
||||
hasRequiredResourcePackAdditionalFile.value ||
|
||||
hasRecommendedDownloadFiles.value),
|
||||
)
|
||||
|
||||
const downloadWithRecommendedActions = computed<JoinedButtonAction[]>(() => [
|
||||
{
|
||||
id: 'download-with-dependencies',
|
||||
label: formatMessage(messages.downloadWithDependencies),
|
||||
icon: downloadingActionType.value === 'dependencies' ? SpinnerIcon : DownloadIcon,
|
||||
action: () => void downloadFilesWithDependencies(),
|
||||
},
|
||||
{
|
||||
id: 'download-with-recommended',
|
||||
label: formatMessage(messages.downloadWithRecommended),
|
||||
icon: DownloadIcon,
|
||||
action: () => void downloadFilesWithRecommended(),
|
||||
},
|
||||
{
|
||||
id: 'download-with-recommended-zip',
|
||||
label: formatMessage(messages.downloadWithRecommendedAsZip),
|
||||
icon: DownloadIcon,
|
||||
action: () => void downloadSelectedVersionZip(),
|
||||
},
|
||||
])
|
||||
|
||||
watch(projectV2Error, (error) => {
|
||||
if (error) {
|
||||
debug('project query failed', error)
|
||||
@@ -249,6 +374,30 @@ const messages = defineMessages({
|
||||
id: 'project.download.title',
|
||||
defaultMessage: 'Download {title}',
|
||||
},
|
||||
downloadAsZip: {
|
||||
id: 'project.download.download-as-zip',
|
||||
defaultMessage: 'Download as .zip',
|
||||
},
|
||||
downloadWithDependencies: {
|
||||
id: 'project.download.download-with-dependencies',
|
||||
defaultMessage: 'Download with deps',
|
||||
},
|
||||
downloadWithRecommended: {
|
||||
id: 'project.download.download-with-recommended',
|
||||
defaultMessage: 'Download with recommended',
|
||||
},
|
||||
downloadWithRecommendedAsZip: {
|
||||
id: 'project.download.download-with-recommended-as-zip',
|
||||
defaultMessage: 'Download with recommended as .zip',
|
||||
},
|
||||
downloadZipFailedTitle: {
|
||||
id: 'project.download.zip-failed-title',
|
||||
defaultMessage: 'Could not download files',
|
||||
},
|
||||
downloadZipFailedText: {
|
||||
id: 'project.download.zip-failed-text',
|
||||
defaultMessage: 'One or more files could not be downloaded. Please try again.',
|
||||
},
|
||||
})
|
||||
|
||||
function getProjectTypeForUrl(
|
||||
@@ -278,15 +427,27 @@ function updateDownloadQuery({
|
||||
platform: string | null
|
||||
}) {
|
||||
if (!props.updateRouteSelection) return
|
||||
const nextGameVersion =
|
||||
gameVersion ??
|
||||
pendingRouteSelection.value.gameVersion ??
|
||||
getStringQueryValue(route.query.version)
|
||||
const nextPlatform =
|
||||
platform ?? pendingRouteSelection.value.platform ?? getStringQueryValue(route.query.loader)
|
||||
|
||||
pendingRouteSelection.value = {
|
||||
gameVersion: nextGameVersion,
|
||||
platform: nextPlatform,
|
||||
}
|
||||
|
||||
navigateTo(
|
||||
{
|
||||
query: {
|
||||
...route.query,
|
||||
...(gameVersion && {
|
||||
version: gameVersion,
|
||||
...(nextGameVersion && {
|
||||
version: nextGameVersion,
|
||||
}),
|
||||
...(platform && {
|
||||
loader: platform,
|
||||
...(nextPlatform && {
|
||||
loader: nextPlatform,
|
||||
}),
|
||||
},
|
||||
hash: route.hash,
|
||||
@@ -298,17 +459,25 @@ function updateDownloadQuery({
|
||||
function selectGameVersion(gameVersion: string) {
|
||||
updateDownloadQuery({
|
||||
gameVersion,
|
||||
platform: currentPlatform.value,
|
||||
platform: null,
|
||||
})
|
||||
}
|
||||
|
||||
function selectPlatform(platform: string) {
|
||||
updateDownloadQuery({
|
||||
gameVersion: currentGameVersion.value,
|
||||
gameVersion: null,
|
||||
platform,
|
||||
})
|
||||
}
|
||||
|
||||
function updateProjectDownloadSelection(selection: ProjectDownloadSelection) {
|
||||
projectDownloadSelection.value = selection
|
||||
pendingRouteSelection.value = {
|
||||
gameVersion: selection.currentGameVersion,
|
||||
platform: selection.currentPlatform,
|
||||
}
|
||||
}
|
||||
|
||||
function onShow() {
|
||||
clearCloseStateResetTimeout()
|
||||
modalOpen.value = true
|
||||
@@ -337,22 +506,37 @@ async function show(
|
||||
event?: MouseEvent,
|
||||
options: ProjectDownloadModalShowOptions = {},
|
||||
): Promise<void> {
|
||||
if (!modal.value || modalOpen.value) return
|
||||
await waitForCloseStateReset()
|
||||
if (!modal.value || modalOpen.value) return
|
||||
showOptions.value = {
|
||||
...getDefaultShowOptions(),
|
||||
...options,
|
||||
if (!modal.value || modalOpening.value || modalOpen.value) return
|
||||
const showRequestId = ++modalShowRequestId
|
||||
modalOpening.value = true
|
||||
|
||||
try {
|
||||
await waitForCloseStateReset()
|
||||
if (!isActiveShowRequest(showRequestId)) return
|
||||
showOptions.value = {
|
||||
...getDefaultShowOptions(),
|
||||
...options,
|
||||
}
|
||||
showProjectId.value = showOptions.value.projectId ?? null
|
||||
await nextTick()
|
||||
if (!isActiveShowRequest(showRequestId)) return
|
||||
if (!(await loadProjectForModal(!!showOptions.value.projectId))) return
|
||||
if (!isActiveShowRequest(showRequestId)) return
|
||||
resetDownloadState()
|
||||
await preloadRouteSelectedDownload()
|
||||
if (!isActiveShowRequest(showRequestId)) return
|
||||
modalOpen.value = true
|
||||
modal.value.show(event)
|
||||
} finally {
|
||||
if (modalShowRequestId === showRequestId) {
|
||||
modalOpening.value = false
|
||||
}
|
||||
}
|
||||
showProjectId.value = showOptions.value.projectId ?? null
|
||||
await nextTick()
|
||||
if (!(await loadProjectForModal(!!showOptions.value.projectId))) return
|
||||
resetDownloadState()
|
||||
modalOpen.value = true
|
||||
modal.value.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modalShowRequestId += 1
|
||||
modalOpening.value = false
|
||||
if (!modal.value || !modalOpen.value) return
|
||||
modal.value?.hide()
|
||||
}
|
||||
@@ -361,6 +545,199 @@ function onDownload() {
|
||||
emit('download')
|
||||
}
|
||||
|
||||
async function downloadSelectedVersionZip() {
|
||||
if (downloadingActionType.value || !dependencyDownloadFilesLoaded.value) return
|
||||
|
||||
downloadingActionType.value = 'zip'
|
||||
const files = dedupeDownloadFiles([
|
||||
...selectedVersionDownloadFiles.value,
|
||||
...dependencyDownloadFiles.value,
|
||||
...recommendedDownloadFiles.value,
|
||||
])
|
||||
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
const usedFilenames = new Set<string>()
|
||||
const downloadedFiles = await downloadFileBlobs(files)
|
||||
|
||||
for (const file of downloadedFiles) {
|
||||
zip.file(uniqueFilename(file.filename, usedFilenames), file.blob)
|
||||
}
|
||||
|
||||
downloadBlob(
|
||||
await zip.generateAsync({
|
||||
type: 'blob',
|
||||
mimeType: 'application/zip',
|
||||
}),
|
||||
selectedVersionZipFilename(),
|
||||
)
|
||||
emit('download')
|
||||
} catch (error) {
|
||||
console.error('Failed to download selected version files:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.downloadZipFailedTitle),
|
||||
text: formatMessage(messages.downloadZipFailedText),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
downloadingActionType.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFilesWithDependencies() {
|
||||
if (downloadingActionType.value || !dependencyDownloadFilesLoaded.value) return
|
||||
|
||||
downloadingActionType.value = 'dependencies'
|
||||
|
||||
try {
|
||||
const files = dedupeDownloadFiles([
|
||||
...selectedVersionDownloadFiles.value,
|
||||
...dependencyDownloadFiles.value,
|
||||
])
|
||||
|
||||
await downloadFiles(files)
|
||||
|
||||
emit('download')
|
||||
} catch (error) {
|
||||
console.error('Failed to download selected version files:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.downloadZipFailedTitle),
|
||||
text: formatMessage(messages.downloadZipFailedText),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
downloadingActionType.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFilesWithRecommended() {
|
||||
if (downloadingActionType.value || !dependencyDownloadFilesLoaded.value) return
|
||||
|
||||
downloadingActionType.value = 'recommended'
|
||||
|
||||
try {
|
||||
const files = dedupeDownloadFiles([
|
||||
...selectedVersionDownloadFiles.value,
|
||||
...dependencyDownloadFiles.value,
|
||||
...recommendedDownloadFiles.value,
|
||||
])
|
||||
|
||||
await downloadFiles(files)
|
||||
|
||||
emit('download')
|
||||
} catch (error) {
|
||||
console.error('Failed to download selected version files:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.downloadZipFailedTitle),
|
||||
text: formatMessage(messages.downloadZipFailedText),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
downloadingActionType.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFileBlobs(files: DownloadableFile[]): Promise<DownloadedFile[]> {
|
||||
return Promise.all(files.map((file) => downloadFileBlob(file)))
|
||||
}
|
||||
|
||||
async function downloadFileBlob(file: DownloadableFile): Promise<DownloadedFile> {
|
||||
const response = await fetch(file.href)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${file.filename}`)
|
||||
}
|
||||
|
||||
return {
|
||||
...file,
|
||||
blob: await response.blob(),
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFiles(files: DownloadableFile[]) {
|
||||
await Promise.all(
|
||||
files.map(async (file, index) => {
|
||||
await delay(DOWNLOAD_STAGGER_MS * index)
|
||||
downloadFileLink(file)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function dedupeDownloadFiles(files: DownloadableFile[]) {
|
||||
const result: DownloadableFile[] = []
|
||||
const hrefs = new Set<string>()
|
||||
|
||||
for (const file of files) {
|
||||
if (hrefs.has(file.href)) continue
|
||||
hrefs.add(file.href)
|
||||
result.push(file)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), DOWNLOAD_URL_REVOKE_MS)
|
||||
}
|
||||
|
||||
function downloadFileLink(file: DownloadableFile) {
|
||||
const link = document.createElement('a')
|
||||
link.href = file.href
|
||||
link.download = file.filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
|
||||
function selectedVersionZipFilename() {
|
||||
if (!project.value || !selectedVersion.value) return 'download.zip'
|
||||
|
||||
return `${sanitizeFilename(project.value.title)} ${sanitizeFilename(
|
||||
selectedVersion.value.version_number,
|
||||
)}-EXTRACT_ME.zip`
|
||||
}
|
||||
|
||||
function sanitizeFilename(value: string) {
|
||||
const sanitized = value
|
||||
.replace(/[<>:"/\\|?*]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
return sanitized || 'download'
|
||||
}
|
||||
|
||||
function uniqueFilename(filename: string, usedFilenames: Set<string>) {
|
||||
const sanitizedFilename = sanitizeFilename(filename)
|
||||
|
||||
if (!usedFilenames.has(sanitizedFilename)) {
|
||||
usedFilenames.add(sanitizedFilename)
|
||||
return sanitizedFilename
|
||||
}
|
||||
|
||||
const extensionIndex = sanitizedFilename.lastIndexOf('.')
|
||||
const basename =
|
||||
extensionIndex > 0 ? sanitizedFilename.slice(0, extensionIndex) : sanitizedFilename
|
||||
const extension = extensionIndex > 0 ? sanitizedFilename.slice(extensionIndex) : ''
|
||||
let index = 2
|
||||
let candidate = `${basename} (${index})${extension}`
|
||||
|
||||
while (usedFilenames.has(candidate)) {
|
||||
index += 1
|
||||
candidate = `${basename} (${index})${extension}`
|
||||
}
|
||||
|
||||
usedFilenames.add(candidate)
|
||||
return candidate
|
||||
}
|
||||
|
||||
function getDefaultProjectDownloadSelection(): ProjectDownloadSelection {
|
||||
return {
|
||||
currentGameVersion: null,
|
||||
@@ -378,6 +755,19 @@ function getDefaultShowOptions(): ResolvedProjectDownloadModalShowOptions {
|
||||
}
|
||||
}
|
||||
|
||||
function getStringQueryValue(value: unknown) {
|
||||
return typeof value === 'string' ? value : null
|
||||
}
|
||||
|
||||
function shouldPreloadRouteSelectedDownload() {
|
||||
return (
|
||||
props.useRouteHash &&
|
||||
!showOptions.value.projectId &&
|
||||
!!getStringQueryValue(route.query.version) &&
|
||||
!!getStringQueryValue(route.query.loader)
|
||||
)
|
||||
}
|
||||
|
||||
function clearCloseStateResetTimeout() {
|
||||
if (!closeStateResetTimeout) return
|
||||
clearTimeout(closeStateResetTimeout)
|
||||
@@ -386,7 +776,11 @@ function clearCloseStateResetTimeout() {
|
||||
|
||||
async function waitForCloseStateReset() {
|
||||
if (!closeStateResetTimeout) return
|
||||
await new Promise((resolve) => setTimeout(resolve, MODAL_CLOSE_STATE_RESET_MS))
|
||||
await delay(MODAL_CLOSE_STATE_RESET_MS)
|
||||
}
|
||||
|
||||
async function delay(ms: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function loadProjectForModal(forceRefetch: boolean) {
|
||||
@@ -397,9 +791,127 @@ async function loadProjectForModal(forceRefetch: boolean) {
|
||||
return !!data
|
||||
}
|
||||
|
||||
async function loadVersionsForModal() {
|
||||
if (!resolvedProjectId.value) return null
|
||||
versionsEnabled.value = true
|
||||
if (versionsV3.value) return versions.value
|
||||
|
||||
const data = await queryClient.ensureQueryData({
|
||||
queryKey: ['project', resolvedProjectId.value, 'versions', 'v3'],
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v3.getProjectVersions(resolvedProjectId.value!, {
|
||||
include_changelog: false,
|
||||
apiVersion: 3,
|
||||
}),
|
||||
staleTime: STALE_TIME_LONG,
|
||||
})
|
||||
|
||||
return Array.isArray(data) ? normalizeVersionsForDownload(data) : null
|
||||
}
|
||||
|
||||
async function preloadRouteSelectedDownload() {
|
||||
if (!shouldPreloadRouteSelectedDownload()) return
|
||||
|
||||
try {
|
||||
const routeVersions = await loadVersionsForModal()
|
||||
if (!routeVersions) return
|
||||
|
||||
const selection = getRouteSelectedDownloadSelection(routeVersions)
|
||||
if (!selection) return
|
||||
|
||||
await preloadDependenciesForSelection(selection)
|
||||
} catch (error) {
|
||||
debug('failed to preload selected route download', error)
|
||||
}
|
||||
}
|
||||
|
||||
function getRouteSelectedDownloadSelection(
|
||||
versionList: Labrinth.Versions.v3.Version[] = versions.value,
|
||||
): ProjectDownloadSelection | null {
|
||||
const gameVersion = initialGameVersion.value
|
||||
const platform = initialPlatform.value
|
||||
const version = getSelectedRouteVersion(gameVersion, platform, versionList)
|
||||
const primaryFile = version?.files?.find((file) => file.primary) || version?.files?.[0] || null
|
||||
|
||||
if (!gameVersion || !platform || !version || !primaryFile) return null
|
||||
|
||||
return {
|
||||
currentGameVersion: gameVersion,
|
||||
currentPlatform: platform,
|
||||
selectedVersion: version,
|
||||
selectedPrimaryFile: primaryFile,
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedRouteVersion(
|
||||
gameVersion: string | null,
|
||||
platform: string | null,
|
||||
versionList: Labrinth.Versions.v3.Version[],
|
||||
) {
|
||||
if (!gameVersion || !platform || !project.value) return null
|
||||
|
||||
const filteredVersions = versionList.filter((version) => {
|
||||
const matchesPlatform =
|
||||
project.value?.project_type === 'resourcepack' ||
|
||||
(!!platform && version.loaders.includes(platform))
|
||||
|
||||
return version.game_versions.includes(gameVersion) && matchesPlatform
|
||||
})
|
||||
|
||||
return (
|
||||
latestVersionByType(filteredVersions, 'release') ||
|
||||
latestVersionByType(filteredVersions, 'beta') ||
|
||||
latestVersionByType(filteredVersions, 'alpha') ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeVersionsForDownload(
|
||||
versionList: Labrinth.Versions.v3.Version[],
|
||||
): Labrinth.Versions.v3.Version[] {
|
||||
const isModpack =
|
||||
project.value?.actualProjectType === 'modpack' || project.value?.project_type === 'modpack'
|
||||
|
||||
return versionList.map((version) => {
|
||||
const files = Array.isArray(version.files) ? version.files : []
|
||||
const gameVersions = Array.isArray(version.game_versions) ? version.game_versions : []
|
||||
const loaders = Array.isArray(version.loaders) ? version.loaders : []
|
||||
const mrpackLoaders = Array.isArray(version.mrpack_loaders) ? version.mrpack_loaders : []
|
||||
|
||||
return {
|
||||
...version,
|
||||
files,
|
||||
game_versions: gameVersions,
|
||||
loaders: isModpack && mrpackLoaders.length ? mrpackLoaders : loaders,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function latestVersionByType(
|
||||
versionList: Labrinth.Versions.v3.Version[],
|
||||
type: Labrinth.Versions.v3.VersionChannel,
|
||||
) {
|
||||
return versionList
|
||||
.filter((version) => version.version_type === type)
|
||||
.reduce<Labrinth.Versions.v3.Version | undefined>((latest, version) => {
|
||||
if (!latest || dayjs(version.date_published).isAfter(dayjs(latest.date_published))) {
|
||||
return version
|
||||
}
|
||||
|
||||
return latest
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
async function preloadDependenciesForSelection(selection: ProjectDownloadSelection) {
|
||||
await downloadModalProvider.preloadDependenciesForSelection(selection)
|
||||
}
|
||||
|
||||
function isActiveShowRequest(showRequestId: number) {
|
||||
return !unmounted && modalShowRequestId === showRequestId && !!modal.value && !modalOpen.value
|
||||
}
|
||||
|
||||
function resetDownloadState() {
|
||||
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
|
||||
dependencyDownloadFiles.value = []
|
||||
downloadProjectResetKey.value += 1
|
||||
}
|
||||
|
||||
@@ -407,6 +919,7 @@ function openFromHash() {
|
||||
if (
|
||||
!props.useRouteHash ||
|
||||
!modal.value ||
|
||||
modalOpening.value ||
|
||||
modalOpen.value ||
|
||||
showProjectId.value ||
|
||||
route.hash !== '#download'
|
||||
@@ -437,11 +950,14 @@ watch(modal, openFromHash)
|
||||
watch(() => route.hash, openFromHash)
|
||||
watch(routeProjectId, () => {
|
||||
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
|
||||
dependencyDownloadFiles.value = []
|
||||
downloadProjectResetKey.value += 1
|
||||
})
|
||||
|
||||
onUnmounted(clearCloseStateResetTimeout)
|
||||
onUnmounted(() => {
|
||||
unmounted = true
|
||||
modalShowRequestId += 1
|
||||
clearCloseStateResetTimeout()
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:description="
|
||||
formatMessage(messages.removePasskeyConfirmDescription, { name: passkeyToRemove?.name })
|
||||
"
|
||||
:proceed-label="formatMessage(commonMessages.removeButton)"
|
||||
:proceed-label="formatMessage(messages.deletePasskeyButton)"
|
||||
@proceed="removePasskey()"
|
||||
/>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.removeButton)"
|
||||
v-tooltip="formatMessage(messages.deletePasskeyButton)"
|
||||
@click="
|
||||
() => {
|
||||
passkeyToRemove = passkey
|
||||
@@ -88,7 +88,7 @@
|
||||
<ButtonStyled>
|
||||
<button @click="registerPasskey()">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.managePasskeyAddPasskey) }}
|
||||
{{ formatMessage(messages.managePasskeyAddPasskeyButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
@@ -104,7 +104,7 @@
|
||||
<NewModal
|
||||
ref="addPasskeyModal"
|
||||
width="500px"
|
||||
:header="formatMessage(messages.managePasskeyAddPasskey)"
|
||||
:header="formatMessage(messages.passkeyAddModalTitle)"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
@@ -133,7 +133,7 @@
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!pendingPasskeyName" @click="finishRegisterPasskey()">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.managePasskeyAddPasskey) }}
|
||||
{{ formatMessage(messages.managePasskeyAddPasskeyButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -189,7 +189,7 @@
|
||||
<div>
|
||||
<ButtonStyled>
|
||||
<button id="manage-passkeys" @click="showPasskeyModal">
|
||||
<UserKeyIcon /> {{ formatMessage(messages.managePasskeyTitle) }}
|
||||
<UserKeyIcon /> {{ formatMessage(messages.managePasskeyButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -247,17 +247,21 @@ const messages = defineMessages({
|
||||
id: 'settings.account.security.passkey.title',
|
||||
defaultMessage: 'Manage passkeys',
|
||||
},
|
||||
managePasskeyButton: {
|
||||
id: 'settings.account.security.passkey.button',
|
||||
defaultMessage: 'Manage passkeys',
|
||||
},
|
||||
managePasskeyDescription: {
|
||||
id: 'settings.account.security.passkey.description',
|
||||
defaultMessage: 'Manage your registered passkeys, or add a new one.',
|
||||
},
|
||||
managePasskeyAddPasskey: {
|
||||
id: 'settings.account.security.passkey.add',
|
||||
managePasskeyAddPasskeyButton: {
|
||||
id: 'settings.account.security.passkey.add.button',
|
||||
defaultMessage: 'Add passkey',
|
||||
},
|
||||
managePasskeyModalLoading: {
|
||||
id: 'settings.account.security.passkey.modal.loading',
|
||||
defaultMessage: 'Loading passkeys…',
|
||||
defaultMessage: 'Loading passkeys...',
|
||||
},
|
||||
managePasskeyModalNoPasskeys: {
|
||||
id: 'settings.account.security.passkey.modal.no-passkeys',
|
||||
@@ -275,6 +279,10 @@ const messages = defineMessages({
|
||||
id: 'settings.account.security.passkey.modal.never-used',
|
||||
defaultMessage: 'Never used',
|
||||
},
|
||||
passkeyAddModalTitle: {
|
||||
id: 'settings.account.security.passkey.add-modal.title',
|
||||
defaultMessage: 'Add passkey',
|
||||
},
|
||||
passkeyNameLabel: {
|
||||
id: 'settings.account.security.passkey.add-modal.name.label',
|
||||
defaultMessage: 'Name',
|
||||
@@ -292,6 +300,10 @@ const messages = defineMessages({
|
||||
id: 'settings.account.security.passkey.rename-modal.header',
|
||||
defaultMessage: 'Rename passkey',
|
||||
},
|
||||
deletePasskeyButton: {
|
||||
id: 'settings.account.security.passkey.remove.button',
|
||||
defaultMessage: 'Delete passkey',
|
||||
},
|
||||
removePasskeyConfirmTitle: {
|
||||
id: 'settings.account.security.passkey.remove.title',
|
||||
defaultMessage: 'Are you sure you want to remove this passkey?',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-divider bg-bg-raised p-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 break-words font-semibold text-contrast">
|
||||
{{ trace.project_name }}
|
||||
</p>
|
||||
<p class="m-0 mt-1 break-all text-sm text-secondary">
|
||||
Project {{ trace.project_slug ?? trace.project_id }} / Version
|
||||
{{ trace.version_number }} / File {{ trace.file_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm text-secondary">Local</span>
|
||||
<Badge :type="trace.local_status" />
|
||||
<span class="text-sm text-secondary">Effective</span>
|
||||
<Badge :type="trace.effective_status" />
|
||||
<ButtonStyled>
|
||||
<NuxtLink :to="localTraceLink">
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
View
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-2 text-sm text-secondary md:grid-cols-2">
|
||||
<p class="m-0 break-all">
|
||||
<span class="font-semibold text-contrast">Issue</span>
|
||||
{{ trace.issue_type }}
|
||||
</p>
|
||||
<p class="m-0 break-all">
|
||||
<span class="font-semibold text-contrast">Severity</span>
|
||||
{{ trace.severity }}
|
||||
</p>
|
||||
<p class="m-0 break-all">
|
||||
<span class="font-semibold text-contrast">Path</span>
|
||||
{{ trace.file_path }}
|
||||
</p>
|
||||
<p v-if="trace.jar" class="m-0 break-all">
|
||||
<span class="font-semibold text-contrast">JAR</span>
|
||||
{{ trace.jar }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ExternalIcon } from '@modrinth/assets'
|
||||
import { Badge, ButtonStyled } from '@modrinth/ui'
|
||||
|
||||
const props = defineProps<{
|
||||
trace: Labrinth.TechReview.Internal.GlobalIssueDetailTrace
|
||||
}>()
|
||||
|
||||
const localTraceLink = computed(
|
||||
() =>
|
||||
`/moderation/technical-review/${props.trace.project_id}?detail=${encodeURIComponent(
|
||||
props.trace.detail_id,
|
||||
)}`,
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div>
|
||||
<form class="flex flex-col gap-2 sm:flex-row" @submit.prevent="executeSearch">
|
||||
<StyledInput
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Search global trace keys..."
|
||||
clearable
|
||||
wrapper-class="flex-1 w-full"
|
||||
/>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="submit" :disabled="isLoading">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
Search
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="!isLoading && !loadError && total > 0"
|
||||
class="mt-4 flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
<p class="m-0 text-sm text-secondary">Showing {{ pageStart }}-{{ pageEnd }} of {{ total }}</p>
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="isLoading"
|
||||
type="no-search-result"
|
||||
heading="Loading global detail traces..."
|
||||
/>
|
||||
<EmptyState
|
||||
v-else-if="loadError"
|
||||
type="no-search-result"
|
||||
heading="Failed to load global detail traces"
|
||||
/>
|
||||
<div v-else-if="traces.length > 0" class="mt-4 flex flex-col gap-3">
|
||||
<article
|
||||
v-for="trace in traces"
|
||||
:key="trace.detail_key"
|
||||
class="universal-card flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<HashIcon class="shrink-0 text-secondary" aria-hidden="true" />
|
||||
<h2 class="m-0 min-w-0 text-lg font-semibold text-contrast">
|
||||
Trace
|
||||
<span class="break-all font-mono text-base">{{ trace.detail_key }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<p class="m-0 mt-1 text-sm text-secondary">
|
||||
{{ formatTraceCount(trace.local_trace_count) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Badge :type="trace.verdict" />
|
||||
<ButtonStyled color="red">
|
||||
<button
|
||||
:disabled="removingTraceKeys.has(trace.detail_key)"
|
||||
@click="removeGlobalTrace(trace)"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Remove
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="getPreviewLocalTraces(trace).length > 0" class="flex flex-col gap-2">
|
||||
<div
|
||||
v-if="getVisibleLocalTraceTotal(trace) > getPreviewLocalTraces(trace).length"
|
||||
class="flex flex-wrap items-center justify-between gap-2"
|
||||
>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
Showing first {{ getPreviewLocalTraces(trace).length }} of
|
||||
{{ getVisibleLocalTraceTotal(trace) }} local traces
|
||||
</p>
|
||||
<ButtonStyled>
|
||||
<NuxtLink :to="getGlobalTraceLink(trace)">
|
||||
<ListIcon aria-hidden="true" />
|
||||
View all
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<GlobalDetailLocalTraceCard
|
||||
v-for="localTrace in getPreviewLocalTraces(trace)"
|
||||
:key="localTrace.detail_id"
|
||||
:trace="localTrace"
|
||||
/>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
type="no-search-result"
|
||||
heading="No local traces currently match this key"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
<EmptyState v-else type="no-search-result" heading="No global detail traces found" />
|
||||
|
||||
<div v-if="!isLoading && !loadError && total > 0" class="mt-4 flex justify-end">
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { HashIcon, ListIcon, SearchIcon, TrashIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Badge,
|
||||
ButtonStyled,
|
||||
EmptyState,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
Pagination,
|
||||
StyledInput,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const query = ref('')
|
||||
const activeQuery = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const loadError = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = 20
|
||||
const localTracePreviewLimit = 10
|
||||
const total = ref(0)
|
||||
const traces = ref<Labrinth.TechReview.Internal.GlobalIssueDetail[]>([])
|
||||
const removingTraceKeys = reactive<Set<string>>(new Set())
|
||||
|
||||
const pageCount = computed(() => Math.max(Math.ceil(total.value / itemsPerPage), 1))
|
||||
const pageStart = computed(() =>
|
||||
total.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage + 1,
|
||||
)
|
||||
const pageEnd = computed(() => Math.min(currentPage.value * itemsPerPage, total.value))
|
||||
|
||||
function formatTraceCount(count: number) {
|
||||
return `${count} local ${count === 1 ? 'trace' : 'traces'}`
|
||||
}
|
||||
|
||||
function getPreviewLocalTraces(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
||||
return trace.local_traces.slice(0, localTracePreviewLimit)
|
||||
}
|
||||
|
||||
function getVisibleLocalTraceTotal(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
||||
return Math.max(trace.local_trace_count, trace.local_traces.length)
|
||||
}
|
||||
|
||||
function getGlobalTraceLink(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
||||
return `/moderation/global-traces/${encodeURIComponent(trace.detail_key)}`
|
||||
}
|
||||
|
||||
async function loadTraces() {
|
||||
isLoading.value = true
|
||||
loadError.value = false
|
||||
|
||||
try {
|
||||
const response = await client.labrinth.tech_review_internal.searchGlobalIssueDetails({
|
||||
query: activeQuery.value,
|
||||
limit: itemsPerPage,
|
||||
page: currentPage.value - 1,
|
||||
})
|
||||
|
||||
traces.value = response.traces
|
||||
total.value = response.total
|
||||
} catch (error) {
|
||||
console.error('Failed to load global detail traces', error)
|
||||
traces.value = []
|
||||
total.value = 0
|
||||
loadError.value = true
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function executeSearch() {
|
||||
activeQuery.value = query.value.trim() || null
|
||||
currentPage.value = 1
|
||||
await loadTraces()
|
||||
}
|
||||
|
||||
async function switchPage(page: number) {
|
||||
currentPage.value = page
|
||||
await loadTraces()
|
||||
}
|
||||
|
||||
async function removeGlobalTrace(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
||||
if (removingTraceKeys.has(trace.detail_key)) return
|
||||
|
||||
removingTraceKeys.add(trace.detail_key)
|
||||
try {
|
||||
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
|
||||
{ detail_key: trace.detail_key, verdict: 'pending' },
|
||||
])
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Global trace removed',
|
||||
text: 'The global verdict for this trace key has been removed.',
|
||||
})
|
||||
|
||||
if (traces.value.length === 1 && currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
}
|
||||
|
||||
await loadTraces()
|
||||
} catch (error) {
|
||||
console.error('Failed to remove global trace', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to remove global trace',
|
||||
text: 'An error occurred while removing the global trace verdict.',
|
||||
})
|
||||
} finally {
|
||||
removingTraceKeys.delete(trace.detail_key)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTraces)
|
||||
</script>
|
||||
@@ -2,12 +2,7 @@
|
||||
<div class="shadow-card rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<NuxtLink
|
||||
:to="`/project/${queueEntry.project.slug}`"
|
||||
target="_blank"
|
||||
tabindex="-1"
|
||||
class="flex"
|
||||
>
|
||||
<NuxtLink :to="`/project/${projectRouteParam}`" target="_blank" tabindex="-1" class="flex">
|
||||
<Avatar
|
||||
:src="queueEntry.project.icon_url"
|
||||
size="4rem"
|
||||
@@ -17,7 +12,7 @@
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="`/project/${queueEntry.project.slug}`"
|
||||
:to="`/project/${projectRouteParam}`"
|
||||
target="_blank"
|
||||
class="text-lg font-semibold text-contrast hover:underline"
|
||||
>
|
||||
@@ -177,7 +172,7 @@ function getDaysQueued(date: Date): number {
|
||||
const queuedDate = computed(() => {
|
||||
return dayjs(
|
||||
props.queueEntry.project.queued ||
|
||||
props.queueEntry.project.created ||
|
||||
props.queueEntry.project.published ||
|
||||
props.queueEntry.project.updated,
|
||||
)
|
||||
})
|
||||
@@ -186,10 +181,14 @@ const daysInQueue = computed(() => {
|
||||
return getDaysQueued(queuedDate.value.toDate())
|
||||
})
|
||||
|
||||
const projectRouteParam = computed(
|
||||
() => props.queueEntry.project.slug || props.queueEntry.project.id,
|
||||
)
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const date =
|
||||
props.queueEntry.project.queued ||
|
||||
props.queueEntry.project.created ||
|
||||
props.queueEntry.project.published ||
|
||||
props.queueEntry.project.updated
|
||||
if (!date) return 'Unknown'
|
||||
|
||||
@@ -202,7 +201,7 @@ const formattedDate = computed(() => {
|
||||
|
||||
function copyLink() {
|
||||
const base = window.location.origin
|
||||
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
|
||||
const projectUrl = `${base}/project/${projectRouteParam.value}`
|
||||
navigator.clipboard.writeText(projectUrl).then(() => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
|
||||
@@ -37,21 +37,22 @@
|
||||
>
|
||||
{{ formatRelativeTime(report.created) }}
|
||||
</span>
|
||||
<ButtonStyled circular>
|
||||
<OverflowMenu :options="quickActions">
|
||||
<template #default>
|
||||
<EllipsisVerticalIcon class="size-4" />
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled circular>
|
||||
<button v-tooltip="'Copy ID'" @click="copyId">
|
||||
<ClipboardCopyIcon />
|
||||
<span class="hidden sm:inline">Copy ID</span>
|
||||
</template>
|
||||
<template #copy-link>
|
||||
<LinkIcon />
|
||||
<span class="hidden sm:inline">Copy link</span>
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="'Open in new tab'"
|
||||
:href="`/moderation/reports/${props.report.id}`"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -183,12 +184,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClipboardCopyIcon,
|
||||
EllipsisVerticalIcon,
|
||||
LinkIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { CheckCircleIcon, ClipboardCopyIcon, ExternalIcon } from '@modrinth/assets'
|
||||
import { type ExtendedReport, reportQuickReplies } from '@modrinth/moderation'
|
||||
import {
|
||||
Avatar,
|
||||
@@ -196,8 +192,6 @@ import {
|
||||
CollapsibleRegion,
|
||||
getProjectTypeIcon,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
useFormatDateTime,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
@@ -328,35 +322,6 @@ function updateThread(newThread: any) {
|
||||
}
|
||||
}
|
||||
|
||||
const quickActions: OverflowMenuOption[] = [
|
||||
{
|
||||
id: 'copy-link',
|
||||
action: () => {
|
||||
const base = window.location.origin
|
||||
const reportUrl = `${base}/moderation/reports/${props.report.id}`
|
||||
navigator.clipboard.writeText(reportUrl).then(() => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Report link copied',
|
||||
text: 'The link to this report has been copied to your clipboard.',
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'copy-id',
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(props.report.id).then(() => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Report ID copied',
|
||||
text: 'The ID of this report has been copied to your clipboard.',
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const reportItemAvatarUrl = computed(() => {
|
||||
switch (props.report.item_type) {
|
||||
case 'project':
|
||||
@@ -395,4 +360,14 @@ const formattedReportType = computed(() => {
|
||||
const words = reportType.includes('-') ? reportType.split('-') : reportType.split(' ')
|
||||
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||
})
|
||||
|
||||
function copyId() {
|
||||
navigator.clipboard.writeText(props.report.id).then(() => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Report ID copied',
|
||||
text: 'The ID of this report has been copied to your clipboard.',
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
BanIcon,
|
||||
BugIcon,
|
||||
CheckCheckIcon,
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
EyeOffIcon,
|
||||
LoaderCircleIcon,
|
||||
ScaleIcon,
|
||||
ShieldAlertIcon,
|
||||
ShieldCheckIcon,
|
||||
SpinnerIcon,
|
||||
TimerIcon,
|
||||
@@ -45,7 +48,7 @@ import {
|
||||
type User,
|
||||
} from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
|
||||
import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue'
|
||||
import ThreadView from '~/components/ui/thread/ThreadView.vue'
|
||||
@@ -89,6 +92,7 @@ const props = defineProps<{
|
||||
thread: Labrinth.TechReview.Internal.Thread
|
||||
reports: FlattenedFileReport[]
|
||||
}
|
||||
focusedDetailId?: string | null
|
||||
loadingIssues: Set<string>
|
||||
decompiledSources: Map<string, string>
|
||||
}>()
|
||||
@@ -119,30 +123,36 @@ const projectStatusActions = computed<OverflowMenuOption[]>(() => [
|
||||
color: 'green',
|
||||
action: () => setStatus('approved'),
|
||||
hoverFilled: true,
|
||||
disabled: isProjectApproved.value || isLoadingStatusAction.value,
|
||||
disabled: isStatusActionDisabled('approved'),
|
||||
},
|
||||
{
|
||||
id: 'withhold',
|
||||
color: 'orange',
|
||||
action: () => setStatus('withheld'),
|
||||
hoverFilled: true,
|
||||
disabled: projectStatus.value === 'withheld' || isLoadingStatusAction.value,
|
||||
disabled: isStatusActionDisabled('withheld'),
|
||||
},
|
||||
{
|
||||
id: 'send-to-review',
|
||||
action: () => setStatus('processing'),
|
||||
hoverFilled: true,
|
||||
disabled: projectStatus.value === 'processing' || isLoadingStatusAction.value,
|
||||
disabled: isStatusActionDisabled('processing'),
|
||||
},
|
||||
{
|
||||
id: 'reject',
|
||||
color: 'red',
|
||||
action: () => setStatus('rejected'),
|
||||
hoverFilled: true,
|
||||
disabled: projectStatus.value === 'rejected' || isLoadingStatusAction.value,
|
||||
disabled: isStatusActionDisabled('rejected'),
|
||||
},
|
||||
])
|
||||
|
||||
function isStatusActionDisabled(status: Labrinth.Projects.v2.ProjectStatus): boolean {
|
||||
const currentStatus = projectStatus.value
|
||||
const isLoading = isLoadingStatusAction.value
|
||||
return currentStatus === status || isLoading
|
||||
}
|
||||
|
||||
async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) {
|
||||
isLoadingStatusAction.value = true
|
||||
try {
|
||||
@@ -192,7 +202,12 @@ watch(selectedFile, (newFile) => {
|
||||
|
||||
const client = injectModrinthClient()
|
||||
|
||||
async function updateIssueDetails(data: { detail_id: string; verdict: 'safe' | 'unsafe' }[]) {
|
||||
async function updateIssueDetails(
|
||||
data: {
|
||||
detail_id: string
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus
|
||||
}[],
|
||||
) {
|
||||
await client.request('/moderation/tech-review/issue-detail', {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
@@ -201,15 +216,31 @@ async function updateIssueDetails(data: { detail_id: string; verdict: 'safe' | '
|
||||
})
|
||||
}
|
||||
|
||||
async function updateGlobalIssueDetail(
|
||||
detailKey: string,
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
) {
|
||||
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
|
||||
{ detail_key: detailKey, verdict },
|
||||
])
|
||||
}
|
||||
|
||||
const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record<string, number>
|
||||
|
||||
type DetailDecision = 'safe' | 'malware'
|
||||
type DetailDecision = 'safe' | 'malware' | 'pending'
|
||||
type DetailDecisionScope = 'local' | 'global'
|
||||
|
||||
const detailDecisions = reactive<Map<string, DetailDecision>>(new Map())
|
||||
const detailDecisionScopes = reactive<Map<string, DetailDecisionScope>>(new Map())
|
||||
const updatingDetails = reactive<Set<string>>(new Set())
|
||||
const updatingGlobalDetailKeys = reactive<Set<string>>(new Set())
|
||||
|
||||
function verdictToDecision(verdict: 'safe' | 'unsafe'): DetailDecision {
|
||||
return verdict === 'safe' ? 'safe' : 'malware'
|
||||
function verdictToDecision(
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
): DetailDecision {
|
||||
if (verdict === 'safe') return 'safe'
|
||||
if (verdict === 'unsafe') return 'malware'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
|
||||
@@ -219,6 +250,7 @@ function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
|
||||
function applyDecisionToRelatedDetails(
|
||||
detailIds: string[],
|
||||
decision: DetailDecision,
|
||||
scope: DetailDecisionScope,
|
||||
): { otherMatchedCount: number } {
|
||||
const allDetails = getAllDetails()
|
||||
const selectedDetailIds = new Set(detailIds)
|
||||
@@ -236,12 +268,14 @@ function applyDecisionToRelatedDetails(
|
||||
|
||||
if (matchingDetails.length === 0) {
|
||||
detailDecisions.set(detailId, decision)
|
||||
detailDecisionScopes.set(detailId, scope)
|
||||
updatedDetailIds.add(detailId)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const matchingDetail of matchingDetails) {
|
||||
detailDecisions.set(matchingDetail.id, decision)
|
||||
detailDecisionScopes.set(matchingDetail.id, scope)
|
||||
updatedDetailIds.add(matchingDetail.id)
|
||||
}
|
||||
}
|
||||
@@ -252,6 +286,98 @@ function applyDecisionToRelatedDetails(
|
||||
}
|
||||
}
|
||||
|
||||
function statusMatchesDecision(
|
||||
status: Labrinth.TechReview.Internal.DelphiReportIssueStatus | null,
|
||||
decision: DetailDecision,
|
||||
): boolean {
|
||||
if (status === 'safe') return decision === 'safe'
|
||||
if (status === 'unsafe') return decision === 'malware'
|
||||
return false
|
||||
}
|
||||
|
||||
function isDetailActionSelected(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: DetailDecision,
|
||||
scope: DetailDecisionScope,
|
||||
): boolean {
|
||||
const localDecision = detailDecisions.get(detail.id)
|
||||
const localScope = detailDecisionScopes.get(detail.id)
|
||||
if (localDecision && localScope) {
|
||||
if (localDecision === 'pending') {
|
||||
if (localScope === 'local') {
|
||||
if (scope === 'local') return false
|
||||
return statusMatchesDecision(detail.global_status, decision)
|
||||
}
|
||||
|
||||
if (scope === 'global') return false
|
||||
return statusMatchesDecision(detail.local_status, decision)
|
||||
}
|
||||
|
||||
return localDecision === decision && localScope === scope
|
||||
}
|
||||
|
||||
if (scope === 'global') {
|
||||
return statusMatchesDecision(detail.global_status, decision)
|
||||
}
|
||||
|
||||
if (detail.global_status) {
|
||||
return false
|
||||
}
|
||||
|
||||
return statusMatchesDecision(detail.local_status, decision)
|
||||
}
|
||||
|
||||
function decisionToVerdict(
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
|
||||
return decision === 'safe' ? 'safe' : 'unsafe'
|
||||
}
|
||||
|
||||
function getToggledDetailVerdict(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
scope: DetailDecisionScope,
|
||||
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
|
||||
return isDetailActionSelected(detail, decision, scope) ? 'pending' : decisionToVerdict(decision)
|
||||
}
|
||||
|
||||
function getDetailActionTooltip(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
scope: DetailDecisionScope,
|
||||
): string {
|
||||
const action = decision === 'safe' ? 'pass' : 'fail'
|
||||
const scopeLabel = scope === 'global' ? 'Global' : 'Local'
|
||||
|
||||
if (scope === 'global' && !canUpdateGlobalDetail(detail)) {
|
||||
return 'Global verdict unavailable for generated trace keys'
|
||||
}
|
||||
|
||||
if (isDetailActionSelected(detail, decision, scope)) {
|
||||
return `Unset ${scopeLabel.toLowerCase()} ${action}`
|
||||
}
|
||||
|
||||
return `${scopeLabel} ${action}`
|
||||
}
|
||||
|
||||
function updateLocalDetailAction(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
) {
|
||||
return updateDetailStatus(detail.id, getToggledDetailVerdict(detail, decision, 'local'))
|
||||
}
|
||||
|
||||
function updateGlobalDetailAction(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
) {
|
||||
return updateGlobalDetailStatus(detail, getToggledDetailVerdict(detail, decision, 'global'))
|
||||
}
|
||||
|
||||
function canUpdateGlobalDetail(detail: Labrinth.TechReview.Internal.ReportIssueDetail): boolean {
|
||||
return detail.key.length > 0 && !detail.key.startsWith('<no-key-')
|
||||
}
|
||||
|
||||
function getFileHighestSeverity(
|
||||
file: FlattenedFileReport,
|
||||
): Labrinth.TechReview.Internal.DelphiSeverity {
|
||||
@@ -381,6 +507,49 @@ function viewFileFlags(file: FlattenedFileReport) {
|
||||
currentTab.value = 'File'
|
||||
}
|
||||
|
||||
function getDetailElementId(detailId: string) {
|
||||
return `tech-review-detail-${detailId}`
|
||||
}
|
||||
|
||||
function findFileForDetail(detailId: string): FlattenedFileReport | null {
|
||||
for (const report of props.item.reports) {
|
||||
for (const issue of report.issues) {
|
||||
if (issue.details.some((detail) => detail.id === detailId)) {
|
||||
return report
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function focusDetail(detailId: string) {
|
||||
const file = findFileForDetail(detailId)
|
||||
if (!file) return
|
||||
|
||||
viewFileFlags(file)
|
||||
await nextTick()
|
||||
|
||||
const classItem = groupedByClass.value.find((group) =>
|
||||
group.flags.some((flag) => flag.detail.id === detailId),
|
||||
)
|
||||
|
||||
if (classItem) {
|
||||
expandClass(classItem)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (!import.meta.client) return
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
document.getElementById(getDetailElementId(detailId))?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function backToFileList() {
|
||||
selectedFileId.value = null
|
||||
if (currentTab.value === 'File') {
|
||||
@@ -446,6 +615,18 @@ const remainingUnmarkedCount = computed(() => {
|
||||
return getFileDetailCount(selectedFile.value) - getFileMarkedCount(selectedFile.value)
|
||||
})
|
||||
|
||||
function getJarFlags(jarGroup: JarGroup): ClassGroup['flags'] {
|
||||
return jarGroup.classes.flatMap((classItem) => classItem.flags)
|
||||
}
|
||||
|
||||
function getJarMarkedCount(jarGroup: JarGroup): number {
|
||||
return getMarkedFlagsCount(getJarFlags(jarGroup))
|
||||
}
|
||||
|
||||
function getJarRemainingUnmarkedCount(jarGroup: JarGroup): number {
|
||||
return getJarFlags(jarGroup).length - getJarMarkedCount(jarGroup)
|
||||
}
|
||||
|
||||
const isBatchUpdating = ref(false)
|
||||
|
||||
async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
||||
@@ -469,7 +650,7 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
||||
try {
|
||||
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
|
||||
|
||||
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict))
|
||||
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
@@ -499,7 +680,54 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe') {
|
||||
async function batchMarkRemainingInJar(jarGroup: JarGroup, verdict: 'safe' | 'unsafe') {
|
||||
if (isBatchUpdating.value) return
|
||||
|
||||
const detailIds = getJarFlags(jarGroup)
|
||||
.filter((flag) => getDetailDecision(flag.detail.id, flag.detail.status) === 'pending')
|
||||
.map((flag) => flag.detail.id)
|
||||
|
||||
if (detailIds.length === 0) return
|
||||
|
||||
isBatchUpdating.value = true
|
||||
try {
|
||||
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
|
||||
|
||||
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: `Marked ${detailIds.length} traces as ${verdict}`,
|
||||
text: `All remaining traces in this JAR have been marked as ${
|
||||
verdict === 'safe' ? 'false positives' : 'malicious'
|
||||
}.`,
|
||||
})
|
||||
|
||||
if (selectedFile.value) {
|
||||
const markedCount = getFileMarkedCount(selectedFile.value)
|
||||
const totalCount = getFileDetailCount(selectedFile.value)
|
||||
if (markedCount === totalCount) {
|
||||
backToFileList()
|
||||
}
|
||||
}
|
||||
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to batch update JAR traces:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Batch update failed',
|
||||
text: 'An error occurred while updating JAR traces.',
|
||||
})
|
||||
} finally {
|
||||
isBatchUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDetailStatus(
|
||||
detailId: string,
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
) {
|
||||
let priorDecision: 'safe' | 'malware' | 'pending' = 'pending'
|
||||
outer: for (const report of props.item.reports) {
|
||||
for (const issue of report.issues) {
|
||||
@@ -519,10 +747,11 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
||||
const { otherMatchedCount } = applyDecisionToRelatedDetails(
|
||||
[detailId],
|
||||
verdictToDecision(verdict),
|
||||
'local',
|
||||
)
|
||||
|
||||
// Only collapse if the prior state was 'pending' (new decision, not updating existing)
|
||||
if (priorDecision === 'pending') {
|
||||
if (verdict !== 'pending' && priorDecision === 'pending') {
|
||||
for (const classGroup of groupedByClass.value) {
|
||||
const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId)
|
||||
if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
|
||||
@@ -533,7 +762,7 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
||||
}
|
||||
|
||||
// Jump back to Files tab when all flags in the current file are marked
|
||||
if (selectedFile.value) {
|
||||
if (verdict !== 'pending' && selectedFile.value) {
|
||||
const markedCount = getFileMarkedCount(selectedFile.value)
|
||||
const totalCount = getFileDetailCount(selectedFile.value)
|
||||
if (markedCount === totalCount) {
|
||||
@@ -546,7 +775,13 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
||||
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked)`
|
||||
: ''
|
||||
|
||||
if (verdict === 'safe') {
|
||||
if (verdict === 'pending') {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Local trace verdict unset',
|
||||
text: `The project-local verdict has been removed.${otherText}`,
|
||||
})
|
||||
} else if (verdict === 'safe') {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Issue marked as pass',
|
||||
@@ -573,6 +808,82 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGlobalDetailStatus(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
) {
|
||||
if (!canUpdateGlobalDetail(detail)) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Global update unavailable',
|
||||
text: 'Generated trace keys cannot be marked globally.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updatingGlobalDetailKeys.add(detail.key)
|
||||
|
||||
try {
|
||||
await updateGlobalIssueDetail(detail.key, verdict)
|
||||
|
||||
const { otherMatchedCount } = applyDecisionToRelatedDetails(
|
||||
[detail.id],
|
||||
verdictToDecision(verdict),
|
||||
'global',
|
||||
)
|
||||
|
||||
if (verdict !== 'pending') {
|
||||
for (const classGroup of groupedByClass.value) {
|
||||
if (getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
|
||||
expandedClasses.delete(classGroup.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (verdict !== 'pending' && selectedFile.value) {
|
||||
const markedCount = getFileMarkedCount(selectedFile.value)
|
||||
const totalCount = getFileDetailCount(selectedFile.value)
|
||||
if (markedCount === totalCount) {
|
||||
backToFileList()
|
||||
}
|
||||
}
|
||||
|
||||
const otherText =
|
||||
otherMatchedCount > 0
|
||||
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked in this project)`
|
||||
: ''
|
||||
|
||||
if (verdict === 'pending') {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Global trace verdict unset',
|
||||
text: `The global verdict for this trace key has been removed.${otherText}`,
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title:
|
||||
verdict === 'safe' ? 'Trace globally marked as pass' : 'Trace globally marked as fail',
|
||||
text:
|
||||
verdict === 'safe'
|
||||
? `This trace key has been marked as a global false positive.${otherText}`
|
||||
: `This trace key has been globally flagged as malicious.${otherText}`,
|
||||
})
|
||||
}
|
||||
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to update global detail status:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to update global trace',
|
||||
text: 'An error occurred while updating the global trace status.',
|
||||
})
|
||||
} finally {
|
||||
updatingGlobalDetailKeys.delete(detail.key)
|
||||
}
|
||||
}
|
||||
|
||||
const expandedClasses = reactive<Set<string>>(new Set())
|
||||
const autoExpandedFileIds = reactive<Set<string>>(new Set())
|
||||
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
|
||||
@@ -691,6 +1002,16 @@ const groupedByJar = computed<JarGroup[]>(() => {
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.focusedDetailId,
|
||||
(detailId) => {
|
||||
if (detailId) {
|
||||
focusDetail(detailId)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Auto-expand/load source for small files; keep larger files lazy.
|
||||
watch(
|
||||
[selectedFileId, groupedByClass],
|
||||
@@ -1293,26 +1614,49 @@ function copyId() {
|
||||
v-if="jarGroup.segments.length > 0"
|
||||
class="border-b border-solid border-surface-1 px-4 py-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<template
|
||||
v-for="(segment, index) in jarGroup.segments"
|
||||
:key="`${jarGroup.key}-${index}`"
|
||||
>
|
||||
<span
|
||||
class="font-mono text-sm"
|
||||
:class="
|
||||
index === jarGroup.segments.length - 1
|
||||
? 'font-semibold text-contrast'
|
||||
: 'text-secondary'
|
||||
"
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<template
|
||||
v-for="(segment, index) in jarGroup.segments"
|
||||
:key="`${jarGroup.key}-${index}`"
|
||||
>
|
||||
{{ segment }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="index < jarGroup.segments.length - 1"
|
||||
class="size-4 text-secondary"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
class="font-mono text-sm"
|
||||
:class="
|
||||
index === jarGroup.segments.length - 1
|
||||
? 'font-semibold text-contrast'
|
||||
: 'text-secondary'
|
||||
"
|
||||
>
|
||||
{{ segment }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="index < jarGroup.segments.length - 1"
|
||||
class="size-4 text-secondary"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="getJarRemainingUnmarkedCount(jarGroup) > 0" class="flex gap-2">
|
||||
<ButtonStyled color="brand" size="small">
|
||||
<button
|
||||
:disabled="isBatchUpdating"
|
||||
@click="batchMarkRemainingInJar(jarGroup, 'safe')"
|
||||
>
|
||||
<CheckCircleIcon class="size-4" />
|
||||
Remaining safe ({{ getJarRemainingUnmarkedCount(jarGroup) }})
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" size="small">
|
||||
<button
|
||||
:disabled="isBatchUpdating"
|
||||
@click="batchMarkRemainingInJar(jarGroup, 'unsafe')"
|
||||
>
|
||||
<TriangleAlertIcon class="size-4" />
|
||||
Remaining malware ({{ getJarRemainingUnmarkedCount(jarGroup) }})
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1384,8 +1728,12 @@ function copyId() {
|
||||
>
|
||||
<div
|
||||
v-for="flag in classItem.flags"
|
||||
:id="getDetailElementId(flag.detail.id)"
|
||||
:key="`${flag.issueId}-${flag.detail.id}`"
|
||||
class="flex flex-col gap-2 rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
|
||||
:class="{
|
||||
'!border-brand bg-brand-highlight': props.focusedDetailId === flag.detail.id,
|
||||
}"
|
||||
>
|
||||
<div class="grid grid-cols-[1fr_auto] items-center">
|
||||
<div
|
||||
@@ -1407,38 +1755,94 @@ function copyId() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-40 items-center justify-center gap-2">
|
||||
<ButtonStyled
|
||||
color="brand"
|
||||
:type="
|
||||
getDetailDecision(flag.detail.id, flag.detail.status) === 'safe'
|
||||
? undefined
|
||||
: 'outlined'
|
||||
"
|
||||
<div class="detail-verdict-action-groups">
|
||||
<div
|
||||
class="detail-verdict-buttons"
|
||||
role="group"
|
||||
aria-label="Trace verdict actions"
|
||||
>
|
||||
<button
|
||||
:disabled="updatingDetails.has(flag.detail.id)"
|
||||
@click="updateDetailStatus(flag.detail.id, 'safe')"
|
||||
v-tooltip="getDetailActionTooltip(flag.detail, 'safe', 'global')"
|
||||
class="detail-verdict-button detail-verdict-button--safe"
|
||||
:class="{
|
||||
'detail-verdict-button--selected': isDetailActionSelected(
|
||||
flag.detail,
|
||||
'safe',
|
||||
'global',
|
||||
),
|
||||
}"
|
||||
aria-label="Global pass"
|
||||
:disabled="
|
||||
!canUpdateGlobalDetail(flag.detail) ||
|
||||
updatingGlobalDetailKeys.has(flag.detail.key) ||
|
||||
updatingDetails.has(flag.detail.id)
|
||||
"
|
||||
@click="updateGlobalDetailAction(flag.detail, 'safe')"
|
||||
>
|
||||
Pass
|
||||
<CheckCheckIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled
|
||||
color="red"
|
||||
:type="
|
||||
getDetailDecision(flag.detail.id, flag.detail.status) === 'malware'
|
||||
? undefined
|
||||
: 'outlined'
|
||||
"
|
||||
>
|
||||
<button
|
||||
:disabled="updatingDetails.has(flag.detail.id)"
|
||||
@click="updateDetailStatus(flag.detail.id, 'unsafe')"
|
||||
v-tooltip="getDetailActionTooltip(flag.detail, 'safe', 'local')"
|
||||
class="detail-verdict-button detail-verdict-button--safe"
|
||||
:class="{
|
||||
'detail-verdict-button--selected': isDetailActionSelected(
|
||||
flag.detail,
|
||||
'safe',
|
||||
'local',
|
||||
),
|
||||
}"
|
||||
aria-label="Local pass"
|
||||
:disabled="
|
||||
updatingDetails.has(flag.detail.id) ||
|
||||
updatingGlobalDetailKeys.has(flag.detail.key)
|
||||
"
|
||||
@click="updateLocalDetailAction(flag.detail, 'safe')"
|
||||
>
|
||||
Fail
|
||||
<CheckIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<button
|
||||
v-tooltip="getDetailActionTooltip(flag.detail, 'malware', 'local')"
|
||||
class="detail-verdict-button detail-verdict-button--unsafe"
|
||||
:class="{
|
||||
'detail-verdict-button--selected': isDetailActionSelected(
|
||||
flag.detail,
|
||||
'malware',
|
||||
'local',
|
||||
),
|
||||
}"
|
||||
aria-label="Local fail"
|
||||
:disabled="
|
||||
updatingDetails.has(flag.detail.id) ||
|
||||
updatingGlobalDetailKeys.has(flag.detail.key)
|
||||
"
|
||||
@click="updateLocalDetailAction(flag.detail, 'malware')"
|
||||
>
|
||||
<BanIcon aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-tooltip="getDetailActionTooltip(flag.detail, 'malware', 'global')"
|
||||
class="detail-verdict-button detail-verdict-button--unsafe"
|
||||
:class="{
|
||||
'detail-verdict-button--selected': isDetailActionSelected(
|
||||
flag.detail,
|
||||
'malware',
|
||||
'global',
|
||||
),
|
||||
}"
|
||||
aria-label="Global fail"
|
||||
:disabled="
|
||||
!canUpdateGlobalDetail(flag.detail) ||
|
||||
updatingGlobalDetailKeys.has(flag.detail.key) ||
|
||||
updatingDetails.has(flag.detail.id)
|
||||
"
|
||||
@click="updateGlobalDetailAction(flag.detail, 'malware')"
|
||||
>
|
||||
<ShieldAlertIcon aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -1546,4 +1950,90 @@ pre {
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.detail-verdict-action-groups {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-verdict-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--surface-5);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.detail-verdict-button {
|
||||
display: flex;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--surface-5);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease-in-out,
|
||||
filter 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.detail-verdict-button:first-child {
|
||||
border-left: 0;
|
||||
border-start-start-radius: calc(var(--radius-md) - 1px);
|
||||
border-end-start-radius: calc(var(--radius-md) - 1px);
|
||||
}
|
||||
|
||||
.detail-verdict-button:last-child {
|
||||
border-start-end-radius: calc(var(--radius-md) - 1px);
|
||||
border-end-end-radius: calc(var(--radius-md) - 1px);
|
||||
}
|
||||
|
||||
.detail-verdict-button:hover,
|
||||
.detail-verdict-button:focus-visible {
|
||||
background: var(--surface-4);
|
||||
}
|
||||
|
||||
.detail-verdict-button--selected {
|
||||
background: var(--color-green-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--color-green);
|
||||
}
|
||||
|
||||
.detail-verdict-button--selected:hover,
|
||||
.detail-verdict-button--selected:focus-visible {
|
||||
background: var(--color-green-bg);
|
||||
}
|
||||
|
||||
.detail-verdict-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--color-brand);
|
||||
}
|
||||
|
||||
.detail-verdict-button--selected:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--color-green);
|
||||
}
|
||||
|
||||
.detail-verdict-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.detail-verdict-button svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.detail-verdict-button--safe {
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.detail-verdict-button--unsafe {
|
||||
color: var(--color-red);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<kbd
|
||||
v-for="(definition, index) in definitions"
|
||||
:key="`keybind-${index}`"
|
||||
ref="keybinding"
|
||||
class="cursor-pointer border-2 !text-lg font-bold"
|
||||
:class="{
|
||||
editing: editing === index,
|
||||
}"
|
||||
@click="startEditing(index)"
|
||||
>
|
||||
{{ toDisplay(definition) }}
|
||||
</kbd>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type KeybindDefinition, toKeybindDefinition } from '@modrinth/moderation'
|
||||
import { onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
definitions: KeybindDefinition[]
|
||||
onChange: (definitions: KeybindDefinition[]) => void
|
||||
}>()
|
||||
|
||||
const keybinding = useTemplateRef('keybinding')
|
||||
const definitions = ref(structuredClone(props.definitions))
|
||||
const editing = ref(-1)
|
||||
|
||||
function startEditing(index: number) {
|
||||
if (editing.value === index) {
|
||||
stopEditing()
|
||||
} else {
|
||||
editing.value = index
|
||||
window.addEventListener('keyup', handleKeybinds)
|
||||
window.addEventListener('click', handleMouse)
|
||||
}
|
||||
}
|
||||
|
||||
function stopEditing() {
|
||||
console.log('stop editing')
|
||||
|
||||
editing.value = -1
|
||||
window.removeEventListener('keyup', handleKeybinds)
|
||||
window.removeEventListener('click', handleMouse)
|
||||
}
|
||||
|
||||
function handleMouse(event: MouseEvent) {
|
||||
if (keybinding.value && event.target && editing.value != -1) {
|
||||
const editingRef = keybinding.value[editing.value]
|
||||
if (editingRef === event.target || editingRef.contains(event.target)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
stopEditing()
|
||||
}
|
||||
|
||||
function handleKeybinds(event: KeyboardEvent) {
|
||||
definitions.value[editing.value] = toKeybindDefinition(event)
|
||||
props.onChange(definitions.value)
|
||||
stopEditing()
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function toDisplay(definition: KeybindDefinition): string {
|
||||
const keys = []
|
||||
|
||||
if (definition.ctrl || definition.meta) {
|
||||
keys.push(isMac() ? 'CMD' : 'CTRL')
|
||||
}
|
||||
if (definition.shift) keys.push('SHIFT')
|
||||
if (definition.alt) keys.push('ALT')
|
||||
|
||||
const mainKey = definition.key
|
||||
.toUpperCase()
|
||||
.replace('ARROWLEFT', '←')
|
||||
.replace('ARROWRIGHT', '→')
|
||||
.replace('ARROWUP', '↑')
|
||||
.replace('ARROWDOWN', '↓')
|
||||
.replace('ENTER', '↵')
|
||||
.replace('ESCAPE', 'ESC')
|
||||
|
||||
keys.push(mainKey)
|
||||
|
||||
return keys.join(' + ')
|
||||
}
|
||||
|
||||
function isMac() {
|
||||
return navigator.platform.toUpperCase().includes('MAC')
|
||||
}
|
||||
|
||||
onUnmounted(stopEditing)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.editing {
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
border-color: var(--color-red);
|
||||
box-shadow: 0 0 10px 1px var(--color-red);
|
||||
}
|
||||
|
||||
50% {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,26 +1,32 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Moderation shortcuts" :closable="true">
|
||||
<div>
|
||||
<div id="moderation-checklist-keybinds-modal">
|
||||
<div class="keybinds-sections">
|
||||
<div class="grid grid-cols-2 gap-x-12 gap-y-3">
|
||||
<div
|
||||
v-for="keybind in keybinds"
|
||||
:key="keybind.id"
|
||||
class="keybind-item flex items-center justify-between gap-4"
|
||||
v-for="[id, keybind] in Object.entries(keybinds)"
|
||||
:key="id"
|
||||
class="keybind-item flex flex-wrap items-center justify-between gap-4"
|
||||
:class="{
|
||||
'col-span-2': keybinds.length % 2 === 1 && keybinds[keybinds.length - 1] === keybind,
|
||||
'col-span-2':
|
||||
Object.keys(keybinds).length % 2 === 1 &&
|
||||
Object.keys(keybinds)[Object.keys(keybinds).length - 1] === id,
|
||||
}"
|
||||
>
|
||||
<span class="text-sm text-secondary">{{ keybind.description }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<kbd
|
||||
v-for="(key, index) in parseKeybindDisplay(keybind.keybind)"
|
||||
:key="`${keybind.id}-key-${index}`"
|
||||
class="keybind-key"
|
||||
>
|
||||
{{ key }}
|
||||
</kbd>
|
||||
</div>
|
||||
<ChecklistKeybind
|
||||
:definitions="
|
||||
(!Array.isArray(keybind.keybind) ? [keybind.keybind] : keybind.keybind).map(
|
||||
normalizeKeybind,
|
||||
)
|
||||
"
|
||||
:on-change="
|
||||
(definitions) => {
|
||||
keybinds[id].keybind = definitions
|
||||
saveModerationKeybinds()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,43 +35,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type KeybindListener, keybinds, normalizeKeybind } from '@modrinth/moderation'
|
||||
import { normalizeKeybind } from '@modrinth/moderation'
|
||||
import NewModal from '@modrinth/ui/src/components/modal/NewModal.vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { saveModerationKeybinds } from '#imports'
|
||||
import ChecklistKeybind from '~/components/ui/moderation/checklist/ChecklistKeybind.vue'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
function parseKeybindDisplay(keybind: KeybindListener['keybind']): string[] {
|
||||
const keybinds = Array.isArray(keybind) ? keybind : [keybind]
|
||||
const normalized = keybinds[0]
|
||||
const def = normalizeKeybind(normalized)
|
||||
|
||||
const keys = []
|
||||
|
||||
if (def.ctrl || def.meta) {
|
||||
keys.push(isMac() ? 'CMD' : 'CTRL')
|
||||
}
|
||||
if (def.shift) keys.push('SHIFT')
|
||||
if (def.alt) keys.push('ALT')
|
||||
|
||||
const mainKey = def.key
|
||||
.replace('ArrowLeft', '←')
|
||||
.replace('ArrowRight', '→')
|
||||
.replace('ArrowUp', '↑')
|
||||
.replace('ArrowDown', '↓')
|
||||
.replace('Enter', '↵')
|
||||
.replace('Space', 'SPACE')
|
||||
.replace('Escape', 'ESC')
|
||||
.toUpperCase()
|
||||
|
||||
keys.push(mainKey)
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
function isMac() {
|
||||
return navigator.platform.toUpperCase().includes('MAC')
|
||||
}
|
||||
const keybinds = useModerationKeybinds()
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
modal.value?.show(event)
|
||||
@@ -82,29 +60,6 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.keybind-key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-contrast);
|
||||
|
||||
+ .keybind-key {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.keybind-item {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.keybinds-sections {
|
||||
.grid {
|
||||
|
||||
@@ -478,7 +478,6 @@ import {
|
||||
handleKeybind,
|
||||
initializeActionState,
|
||||
kebabToTitleCase,
|
||||
keybinds,
|
||||
type MultiSelectChipsAction,
|
||||
processMessage,
|
||||
type Stage,
|
||||
@@ -533,6 +532,7 @@ import ModpackPermissionsFlow from './ModpackPermissionsFlow.vue'
|
||||
const notifications = injectNotificationManager()
|
||||
const { addNotification } = notifications
|
||||
const debug = useDebugLogger('ModerationChecklist')
|
||||
const keybinds = useModerationKeybinds()
|
||||
|
||||
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>()
|
||||
const takeOverModal = ref<InstanceType<typeof ConfirmModal>>()
|
||||
@@ -1266,7 +1266,7 @@ function handleKeybinds(event: KeyboardEvent) {
|
||||
},
|
||||
},
|
||||
},
|
||||
keybinds,
|
||||
Object.values(keybinds.value),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1949,7 +1949,10 @@ function generateModpackMessage(allFiles: {
|
||||
|
||||
const hasNextProject = ref(false)
|
||||
async function refreshModerationCaches(threadId?: string) {
|
||||
const refreshes: Promise<unknown>[] = [invalidate(), refreshNuxtData('moderation-projects')]
|
||||
const refreshes: Promise<unknown>[] = [
|
||||
invalidate(),
|
||||
queryClient.invalidateQueries({ queryKey: ['moderation-projects'] }),
|
||||
]
|
||||
|
||||
if (threadId) {
|
||||
refreshes.push(queryClient.invalidateQueries({ queryKey: ['thread', threadId] }))
|
||||
|
||||
@@ -54,15 +54,21 @@
|
||||
class="message__icon backed-svg circle moderation-color"
|
||||
:class="{
|
||||
raised: raised,
|
||||
'system-message-icon': ['tech_review_entered', 'tech_review_exit_file_deleted'].includes(
|
||||
message.body.type,
|
||||
),
|
||||
'system-message-icon': [
|
||||
'tech_review_entered',
|
||||
'tech_review_exited',
|
||||
'tech_review_exit_file_deleted',
|
||||
].includes(message.body.type),
|
||||
}"
|
||||
>
|
||||
<ScaleIcon />
|
||||
</div>
|
||||
<span
|
||||
v-if="!['tech_review_entered', 'tech_review_exit_file_deleted'].includes(message.body.type)"
|
||||
v-if="
|
||||
!['tech_review_entered', 'tech_review_exited', 'tech_review_exit_file_deleted'].includes(
|
||||
message.body.type,
|
||||
)
|
||||
"
|
||||
class="message__author moderation-color"
|
||||
>
|
||||
Moderator
|
||||
@@ -100,6 +106,9 @@
|
||||
<span v-else-if="message.body.type === 'tech_review_entered'">
|
||||
The project has entered the technical review queue.
|
||||
</span>
|
||||
<span v-else-if="message.body.type === 'tech_review_exited'">
|
||||
The project has left the technical review queue as all pending traces have been resolved.
|
||||
</span>
|
||||
<span v-else-if="message.body.type === 'tech_review_exit_file_deleted'">
|
||||
The project has left the technical review queue as all files pending review were deleted by
|
||||
the user.
|
||||
@@ -214,9 +223,12 @@ const timeSincePosted = ref(formatRelativeTime(props.message.created))
|
||||
const isPrivateMessage = computed(() => {
|
||||
return (
|
||||
props.message.body.private ||
|
||||
['tech_review', 'tech_review_entered', 'tech_review_exit_file_deleted'].includes(
|
||||
props.message.body.type,
|
||||
)
|
||||
[
|
||||
'tech_review',
|
||||
'tech_review_entered',
|
||||
'tech_review_exited',
|
||||
'tech_review_exit_file_deleted',
|
||||
].includes(props.message.body.type)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
showHostingAccessInstanceAuditLog: false,
|
||||
versionDevInfoCollapsed: true,
|
||||
alwaysShowVersionDevInfo: false,
|
||||
advancedFiltersCollapsed: true,
|
||||
} as const)
|
||||
|
||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
type KeybindDefinition,
|
||||
type KeybindListener,
|
||||
keybinds,
|
||||
normalizeKeybind,
|
||||
} from '@modrinth/moderation'
|
||||
|
||||
import type { CookieOptions } from '#app'
|
||||
|
||||
const moderationKeybindsId = 'moderation-keybinds'
|
||||
|
||||
type StoredKeybinds = { [id: string]: KeybindDefinition[] }
|
||||
type PartialStoredKeybinds = Partial<StoredKeybinds>
|
||||
|
||||
const getCookieOptions = () =>
|
||||
({
|
||||
maxAge: 60 * 60 * 24 * 365 * 10,
|
||||
sameSite: 'lax',
|
||||
secure: useRuntimeConfig().public.cookieSecure,
|
||||
httpOnly: false,
|
||||
path: '/',
|
||||
}) satisfies CookieOptions<PartialStoredKeybinds>
|
||||
|
||||
export const useModerationKeybinds = () =>
|
||||
useState<{ [id: string]: KeybindListener }>(moderationKeybindsId, () => {
|
||||
const storedKeybinds = useCookie<PartialStoredKeybinds>(
|
||||
moderationKeybindsId,
|
||||
getCookieOptions(),
|
||||
)
|
||||
|
||||
if (!storedKeybinds.value) {
|
||||
storedKeybinds.value = {}
|
||||
}
|
||||
|
||||
const output: { [id: string]: KeybindListener } = {}
|
||||
|
||||
for (const [id, keybind] of Object.entries(keybinds)) {
|
||||
const definitions = storedKeybinds.value[id]
|
||||
output[id] = {
|
||||
keybind: definitions !== undefined ? definitions : keybind.keybind,
|
||||
description: keybind.description,
|
||||
enabled: keybind.enabled,
|
||||
action: keybind.action,
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
})
|
||||
|
||||
export const saveModerationKeybinds = () => {
|
||||
const keybinds = useModerationKeybinds()
|
||||
const cookie = useCookie<PartialStoredKeybinds>(moderationKeybindsId, getCookieOptions())
|
||||
|
||||
const storedKeybinds: PartialStoredKeybinds = {}
|
||||
for (const [id, keybind] of Object.entries(keybinds.value)) {
|
||||
storedKeybinds[id] = (Array.isArray(keybind.keybind) ? keybind.keybind : [keybind.keybind]).map(
|
||||
normalizeKeybind,
|
||||
)
|
||||
}
|
||||
cookie.value = storedKeybinds
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
readStoredServerInstallQueue,
|
||||
removePendingServerContentInstall,
|
||||
requestInstall,
|
||||
stripServerRuntimeInstallFilters,
|
||||
stripServerRuntimeInstallOverrides,
|
||||
useVIntl,
|
||||
writePendingServerContentInstallBaseline,
|
||||
writeStoredServerInstallQueue,
|
||||
@@ -604,11 +606,15 @@ export function useServerInstallContent({
|
||||
project,
|
||||
contentType,
|
||||
mode: isModpack ? 'immediate' : 'queue',
|
||||
selectedFilters: isModpack ? [] : browseSearchState.currentFilters.value,
|
||||
selectedFilters: isModpack
|
||||
? []
|
||||
: stripServerRuntimeInstallFilters(browseSearchState.currentFilters.value),
|
||||
providedFilters: isModpack ? [] : serverFilters.value,
|
||||
overriddenProvidedFilterTypes: isModpack
|
||||
? []
|
||||
: browseSearchState.overriddenProvidedFilterTypes.value,
|
||||
: stripServerRuntimeInstallOverrides(
|
||||
browseSearchState.overriddenProvidedFilterTypes.value,
|
||||
),
|
||||
targetPreferences: getServerInstallTargetPreferences(contentType),
|
||||
getProjectVersions: getInstallProjectVersions,
|
||||
queue: serverInstallQueue,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { ExtendedReport, OwnershipTarget } from '@modrinth/moderation'
|
||||
import type {
|
||||
Organization,
|
||||
@@ -197,14 +198,10 @@ export interface ModerationOwnershipOrganization {
|
||||
|
||||
export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipOrganization
|
||||
|
||||
export interface ProjectWithOwnership {
|
||||
ownership: ModerationOwnership
|
||||
external_dependencies_count: number
|
||||
[key: string]: any
|
||||
}
|
||||
export type ProjectWithOwnership = Labrinth.Moderation.Internal.QueueProject
|
||||
|
||||
export interface ModerationProject {
|
||||
project: any
|
||||
project: Omit<ProjectWithOwnership, 'ownership' | 'external_dependencies_count'>
|
||||
ownership: ModerationOwnership | null
|
||||
external_dependencies_count: number
|
||||
}
|
||||
|
||||
@@ -1766,9 +1766,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Har ikke Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Ingen versioner tilgængelig til {gameVersion} og {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} understøtter ikke {platform} til {gameVersion}"
|
||||
},
|
||||
|
||||
@@ -3227,9 +3227,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Du hast die Modrinth App nicht?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Keinen versionen verfügbar für {gameVersion} und {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} unterstützt {platform} für {gameVersion} nicht"
|
||||
},
|
||||
@@ -4055,9 +4052,6 @@
|
||||
"settings.account.security.email.title": {
|
||||
"message": "E-Mail"
|
||||
},
|
||||
"settings.account.security.passkey.add": {
|
||||
"message": "Passkey hinzufügen"
|
||||
},
|
||||
"settings.account.security.passkey.add-modal.name.description": {
|
||||
"message": "Stell sicher, dass du etwas nimmst, was du dir gut merken kannst, damit du den Passkey später identifizieren kannst."
|
||||
},
|
||||
|
||||
@@ -3227,9 +3227,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Du hast die Modrinth App nicht?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Keine Versionen für {gameVersion} und {platform} verfügbar."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} unterstützt {platform} für die {gameVersion} nicht"
|
||||
},
|
||||
@@ -4055,9 +4052,6 @@
|
||||
"settings.account.security.email.title": {
|
||||
"message": "E-Mail"
|
||||
},
|
||||
"settings.account.security.passkey.add": {
|
||||
"message": "Passkey hinzufügen"
|
||||
},
|
||||
"settings.account.security.passkey.add-modal.name.description": {
|
||||
"message": "Stell sicher, dass du etwas nimmst, was du dir gut merken kannst, damit du den Passkey später identifizieren kannst."
|
||||
},
|
||||
|
||||
@@ -2672,12 +2672,18 @@
|
||||
"layout.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Upgrade to Modrinth+"
|
||||
},
|
||||
"moderation.exclude-technical-review": {
|
||||
"message": "Exclude TR"
|
||||
},
|
||||
"moderation.moderate": {
|
||||
"message": "Moderate"
|
||||
},
|
||||
"moderation.page.external-projects": {
|
||||
"message": "External projects"
|
||||
},
|
||||
"moderation.page.global-detail-traces": {
|
||||
"message": "Global traces"
|
||||
},
|
||||
"moderation.page.projects": {
|
||||
"message": "Projects"
|
||||
},
|
||||
@@ -3272,9 +3278,6 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licensed"
|
||||
},
|
||||
"project.download.additional-files-title": {
|
||||
"message": "Additional files"
|
||||
},
|
||||
"project.download.base-game-version-incompatible-tooltip": {
|
||||
"message": "This game version is incompatible with the base project."
|
||||
},
|
||||
@@ -3282,7 +3285,7 @@
|
||||
"message": "This loader is incompatible with the base project."
|
||||
},
|
||||
"project.download.compatible-version-title": {
|
||||
"message": "Compatible version"
|
||||
"message": "Compatible versions"
|
||||
},
|
||||
"project.download.dependencies-title": {
|
||||
"message": "Dependencies"
|
||||
@@ -3290,6 +3293,9 @@
|
||||
"project.download.dependency-already-installed": {
|
||||
"message": "This dependency is already installed"
|
||||
},
|
||||
"project.download.dependency-any-compatible": {
|
||||
"message": "Any compatible"
|
||||
},
|
||||
"project.download.dependency-conflicting": {
|
||||
"message": "This dependency conflicts with another dependency"
|
||||
},
|
||||
@@ -3311,12 +3317,30 @@
|
||||
"project.download.dependency-quilt-fabric-api": {
|
||||
"message": "Fabric API is skipped for Quilt"
|
||||
},
|
||||
"project.download.dependency-resource-pack-admonition": {
|
||||
"message": "This project has a dependency with a required resource pack. Download it and place it in your {folder} folder."
|
||||
},
|
||||
"project.download.dependency-unavailable": {
|
||||
"message": "This dependency cannot be downloaded"
|
||||
},
|
||||
"project.download.download": {
|
||||
"message": "Download"
|
||||
},
|
||||
"project.download.download-as-zip": {
|
||||
"message": "Download as .zip"
|
||||
},
|
||||
"project.download.download-version": {
|
||||
"message": "Download {version}"
|
||||
},
|
||||
"project.download.download-with-dependencies": {
|
||||
"message": "Download with deps"
|
||||
},
|
||||
"project.download.download-with-recommended": {
|
||||
"message": "Download with recommended"
|
||||
},
|
||||
"project.download.download-with-recommended-as-zip": {
|
||||
"message": "Download with recommended as .zip"
|
||||
},
|
||||
"project.download.duplicate-dependencies-hidden": {
|
||||
"message": "Duplicate dependencies are hidden"
|
||||
},
|
||||
@@ -3326,8 +3350,8 @@
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} does not support {gameVersion} for {platform}"
|
||||
},
|
||||
"project.download.install-with-app-description": {
|
||||
"message": "Automatically install the correct version and dependencies."
|
||||
"project.download.install-with-app": {
|
||||
"message": "Install with Modrinth App"
|
||||
},
|
||||
"project.download.manually": {
|
||||
"message": "Download manually"
|
||||
@@ -3344,6 +3368,15 @@
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} does not support {platform} for {gameVersion}"
|
||||
},
|
||||
"project.download.recommended-title": {
|
||||
"message": "Recommended"
|
||||
},
|
||||
"project.download.required-resource-pack-admonition": {
|
||||
"message": "This data pack also requires a resource pack. Download it and place it in your {folder} folder."
|
||||
},
|
||||
"project.download.required-resource-pack-short": {
|
||||
"message": "Resource pack"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Select game version"
|
||||
},
|
||||
@@ -3353,18 +3386,6 @@
|
||||
"project.download.select-platform": {
|
||||
"message": "Select platform"
|
||||
},
|
||||
"project.download.selected-version-download-all": {
|
||||
"message": "Download all (.zip)"
|
||||
},
|
||||
"project.download.selected-version-downloading": {
|
||||
"message": "Downloading... ({current}/{total})"
|
||||
},
|
||||
"project.download.selected-version-failed-text": {
|
||||
"message": "One or more version files could not be downloaded. Please try again."
|
||||
},
|
||||
"project.download.selected-version-failed-title": {
|
||||
"message": "Could not download version"
|
||||
},
|
||||
"project.download.show-all-versions": {
|
||||
"message": "Show all versions"
|
||||
},
|
||||
@@ -3374,6 +3395,12 @@
|
||||
"project.download.unknown-loader": {
|
||||
"message": "Unknown loader"
|
||||
},
|
||||
"project.download.zip-failed-text": {
|
||||
"message": "One or more files could not be downloaded. Please try again."
|
||||
},
|
||||
"project.download.zip-failed-title": {
|
||||
"message": "Could not download files"
|
||||
},
|
||||
"project.environment.migration-no-permission.message": {
|
||||
"message": "We've just overhauled the Environments system on Modrinth and new options are now available. You don't have permission to modify these settings, but please let another member of the project know that the environment metadata needs to be verified."
|
||||
},
|
||||
@@ -4181,9 +4208,6 @@
|
||||
"settings.account.security.email.title": {
|
||||
"message": "Email"
|
||||
},
|
||||
"settings.account.security.passkey.add": {
|
||||
"message": "Add passkey"
|
||||
},
|
||||
"settings.account.security.passkey.add-modal.name.description": {
|
||||
"message": "Make sure to pick something memorable, so you can identify this passkey later."
|
||||
},
|
||||
@@ -4193,6 +4217,15 @@
|
||||
"settings.account.security.passkey.add-modal.name.placeholder": {
|
||||
"message": "My passkey"
|
||||
},
|
||||
"settings.account.security.passkey.add-modal.title": {
|
||||
"message": "Add passkey"
|
||||
},
|
||||
"settings.account.security.passkey.add.button": {
|
||||
"message": "Add passkey"
|
||||
},
|
||||
"settings.account.security.passkey.button": {
|
||||
"message": "Manage passkeys"
|
||||
},
|
||||
"settings.account.security.passkey.description": {
|
||||
"message": "Manage your registered passkeys, or add a new one."
|
||||
},
|
||||
@@ -4203,7 +4236,7 @@
|
||||
"message": "Last used {ago}"
|
||||
},
|
||||
"settings.account.security.passkey.modal.loading": {
|
||||
"message": "Loading passkeys…"
|
||||
"message": "Loading passkeys..."
|
||||
},
|
||||
"settings.account.security.passkey.modal.never-used": {
|
||||
"message": "Never used"
|
||||
@@ -4211,6 +4244,9 @@
|
||||
"settings.account.security.passkey.modal.no-passkeys": {
|
||||
"message": "You do not have any passkeys registered."
|
||||
},
|
||||
"settings.account.security.passkey.remove.button": {
|
||||
"message": "Delete passkey"
|
||||
},
|
||||
"settings.account.security.passkey.remove.description": {
|
||||
"message": "This will permanently remove the passkey \"{name}\". You will no longer be able to sign in with it."
|
||||
},
|
||||
|
||||
@@ -3227,9 +3227,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "¿No tienes la Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "No hay versiones disponibles para {gameVersion} y {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} no soporta {platform} para {gameVersion}"
|
||||
},
|
||||
@@ -4055,9 +4052,6 @@
|
||||
"settings.account.security.email.title": {
|
||||
"message": "Correo electrónico"
|
||||
},
|
||||
"settings.account.security.passkey.add": {
|
||||
"message": "Agregar clave de acceso"
|
||||
},
|
||||
"settings.account.security.passkey.add-modal.name.description": {
|
||||
"message": "Asegúrate de elegir algo fácil de recordar, para que puedas identificar esta contraseña más adelante."
|
||||
},
|
||||
|
||||
@@ -3107,9 +3107,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "¿No tienes la aplicación Modrinth?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "No hay versiones disponibles para {gameVersion} y {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} no está soportado para {platform} en la {gameVersion}"
|
||||
},
|
||||
|
||||
@@ -2414,9 +2414,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Wala kang Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Walang bersiyong magagamit para sa {gameVersion} at {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "Hindi masuport ng {title} ang {platform} para sa {gameVersion}"
|
||||
},
|
||||
|
||||
@@ -3227,9 +3227,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Vous n'avez pas Modrinth App ?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Aucune version disponible pour {gameVersion} et {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} ne supporte pas {platform} pour {gameVersion}"
|
||||
},
|
||||
@@ -4055,9 +4052,6 @@
|
||||
"settings.account.security.email.title": {
|
||||
"message": "E-mail"
|
||||
},
|
||||
"settings.account.security.passkey.add": {
|
||||
"message": "Ajouter une clé d'accès"
|
||||
},
|
||||
"settings.account.security.passkey.add-modal.name.description": {
|
||||
"message": "Veillez à choisir un nom facile à retenir, afin de pouvoir identifier tę klucz d'accès plus tard."
|
||||
},
|
||||
|
||||
@@ -2033,9 +2033,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "אין לכם את אפליקציית Modrinth?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "אין גרסאות זמינות עבור {gameVersion} ו-{platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} אינו תומך ב-{platform} עבור {gameVersion}"
|
||||
},
|
||||
|
||||
@@ -2924,9 +2924,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Nincs meg a Modrinth App? "
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nem érhető el verzió ehhez: {platform} {gameVersion}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "A(z) {title} nem támogatja ezt a verziót: {platform} {gameVersion}"
|
||||
},
|
||||
|
||||
@@ -2429,9 +2429,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Tidak memiliki Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Tidak ada versi tersedia untuk {gameVersion} dan {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} tidak mendukung {platform} untuk {gameVersion}"
|
||||
},
|
||||
|
||||
@@ -3218,9 +3218,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Non hai Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nessuna versione disponibile per {gameVersion} e {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} non supporta {platform} per {gameVersion}"
|
||||
},
|
||||
@@ -4028,9 +4025,6 @@
|
||||
"settings.account.security.email.title": {
|
||||
"message": "Email"
|
||||
},
|
||||
"settings.account.security.passkey.add": {
|
||||
"message": "Aggiungi passkey"
|
||||
},
|
||||
"settings.account.security.passkey.add-modal.name.description": {
|
||||
"message": "Assicurati di scegliere un nome facile da ricordare, così da poter identificare la passkey più tardi."
|
||||
},
|
||||
|
||||
@@ -2735,9 +2735,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Modrinth Appをお持ちでないですか?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "{gameVersion}および{platform}向けのバージョンは利用できません。"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title}は{gameVersion}の{platform}に対応していません"
|
||||
},
|
||||
|
||||
@@ -2549,9 +2549,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Modrinth 앱을 설치하지 않으셨나요?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "{gameVersion} 및 {platform}용 버전이 없습니다."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} 은(는) {gameVersion} 용 {platform} 을(를) 지원하지 않습니다."
|
||||
},
|
||||
|
||||
@@ -2924,9 +2924,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Tidak ada Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Tiada versi tersedia untuk {gameVersion} dan {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} tidak menyokong {platform} untuk {gameVersion}"
|
||||
},
|
||||
|
||||
@@ -2798,9 +2798,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Heb je de Modrinth App niet?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Geen versies beschikbaar voor {gameVersion} en {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} steunt niet {platform} voor {gameVersion}"
|
||||
},
|
||||
|
||||
@@ -2255,9 +2255,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Har du ikke en Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Ingen versjoner tilgjengelige for {gameVersion} og {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} støtter ikke {platform} for {gameVersion}"
|
||||
},
|
||||
|
||||
@@ -3221,9 +3221,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Nie masz Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Brak dostępnych wersji dla {gameVersion} i {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} nie obsługuje {platform} dla {gameVersion}"
|
||||
},
|
||||
@@ -4049,9 +4046,6 @@
|
||||
"settings.account.security.email.title": {
|
||||
"message": "E-mail"
|
||||
},
|
||||
"settings.account.security.passkey.add": {
|
||||
"message": "Dodaj klucz logowania"
|
||||
},
|
||||
"settings.account.security.passkey.add-modal.name.description": {
|
||||
"message": "Wybierz coś, co zapamiętasz, by móć później rozpoznać ten klucz logowania."
|
||||
},
|
||||
|
||||
@@ -3218,9 +3218,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Não tem o Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nenhuma versão disponível para {gameVersion} e {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} não suporta {platform} para {gameVersion}"
|
||||
},
|
||||
@@ -4046,9 +4043,6 @@
|
||||
"settings.account.security.email.title": {
|
||||
"message": "E-mail"
|
||||
},
|
||||
"settings.account.security.passkey.add": {
|
||||
"message": "Adicionar chave de acesso"
|
||||
},
|
||||
"settings.account.security.passkey.add-modal.name.description": {
|
||||
"message": "Certifique-se de adicionar algo memorável, para poder identificar mais tarde."
|
||||
},
|
||||
|
||||
@@ -2228,9 +2228,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Não tens a Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nenhuma versão disponível para {gameVersion} e {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} não suporta {platform} para {gameVersion}"
|
||||
},
|
||||
|
||||
@@ -1421,9 +1421,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Nu ai Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nu există versiuni disponibile pentru {gameVersion} și {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} nu este compatibil cu {platform} pentru {gameVersion}"
|
||||
},
|
||||
|
||||
@@ -3221,9 +3221,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "У вас нет Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Нет версий, доступных для {gameVersion} и {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} не поддерживает {platform} для {gameVersion}"
|
||||
},
|
||||
@@ -4025,9 +4022,6 @@
|
||||
"settings.account.security.email.title": {
|
||||
"message": "Почта"
|
||||
},
|
||||
"settings.account.security.passkey.add": {
|
||||
"message": "Добавить ключ доступа"
|
||||
},
|
||||
"settings.account.security.passkey.add-modal.name.description": {
|
||||
"message": "Выберите что-то запоминающееся, чтобы потом опознать этот ключ доступа."
|
||||
},
|
||||
|
||||
@@ -2810,9 +2810,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Har du inte Modrinth appen?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Inga versioner finns tillgängliga för {gameVersion} och {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} stöttar inte {platform} för {gameVersion}"
|
||||
},
|
||||
|
||||
@@ -3167,9 +3167,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Modrinth App Yok Mu?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "{gameVersion} ve {platform} için sürüm mevcut değil."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title}, {gameVersion} sürümünde {platform} desteklemiyor"
|
||||
},
|
||||
@@ -3941,9 +3938,6 @@
|
||||
"settings.account.security.email.title": {
|
||||
"message": "Eposta"
|
||||
},
|
||||
"settings.account.security.passkey.add": {
|
||||
"message": "Giriş anahtarı ekle"
|
||||
},
|
||||
"settings.account.security.passkey.add-modal.name.description": {
|
||||
"message": "Giriş anahtarınızı hatırlamanız için akılda kalıcı bir şey seçin."
|
||||
},
|
||||
|
||||
@@ -2855,9 +2855,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Не маєте Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Немає доступних версій для {gameVersion} та {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} не підтримує {platform} для {gameVersion}"
|
||||
},
|
||||
|
||||
@@ -3071,9 +3071,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "Chưa có Ứng dụng Modrinth?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Không có phiên bản khả dụng cho {gameVersion} và {platform}."
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} không hỗ trợ {platform} cho {gameVersion}"
|
||||
},
|
||||
|
||||
@@ -3221,9 +3221,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "还没有 Modrinth App 吗?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "没有支持 {platform} {gameVersion} 的版本。"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} 不支持 {gameVersion} 的 {platform}"
|
||||
},
|
||||
@@ -4049,9 +4046,6 @@
|
||||
"settings.account.security.email.title": {
|
||||
"message": "电子邮箱"
|
||||
},
|
||||
"settings.account.security.passkey.add": {
|
||||
"message": "添加通行密钥"
|
||||
},
|
||||
"settings.account.security.passkey.add-modal.name.description": {
|
||||
"message": "请确保你选择的名称易于记忆,方便你日后辨认出该通行密钥。"
|
||||
},
|
||||
|
||||
@@ -3227,9 +3227,6 @@
|
||||
"project.download.no-app": {
|
||||
"message": "還沒有 Modrinth App 嗎?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "{platform} {gameVersion} 沒有可用的版本。"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} 不支援 {platform} {gameVersion}"
|
||||
},
|
||||
@@ -4055,9 +4052,6 @@
|
||||
"settings.account.security.email.title": {
|
||||
"message": "電子郵件"
|
||||
},
|
||||
"settings.account.security.passkey.add": {
|
||||
"message": "新增通行金鑰"
|
||||
},
|
||||
"settings.account.security.passkey.add-modal.name.description": {
|
||||
"message": "請務必選擇容易記住的名稱,以便日後能辨識出這個通行金鑰。"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
import { projectQueryOptions } from '~/composables/queries/project'
|
||||
import { useAppQueryClient } from '~/composables/query-client'
|
||||
import { createModrinthClient } from '~/helpers/api.ts'
|
||||
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||
import { useServerModrinthClient } from '~/server/utils/api-client'
|
||||
|
||||
@@ -18,9 +19,6 @@ const PROJECT_TYPES = [
|
||||
]
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Only run this middleware on the server - it relies on server-only runtime config
|
||||
if (import.meta.client) return
|
||||
|
||||
const routeProjectParam = to.params.project
|
||||
const projectId = Array.isArray(routeProjectParam) ? routeProjectParam[0] : routeProjectParam
|
||||
const routeType = Array.isArray(to.params.type) ? to.params.type[0] : to.params.type
|
||||
@@ -31,10 +29,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
}
|
||||
|
||||
const queryClient = useAppQueryClient()
|
||||
const authToken = useCookie('auth-token')
|
||||
const client = useServerModrinthClient({ authToken: authToken.value || undefined })
|
||||
const client = await getProjectMiddlewareClient()
|
||||
const tags = useGeneratedState()
|
||||
|
||||
if (import.meta.client) startLoading()
|
||||
|
||||
try {
|
||||
// Fetch v2 and v3 in parallel — cache both for the page's useQuery calls
|
||||
const [project, projectV3] = await Promise.all([
|
||||
@@ -48,9 +47,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Cache by slug if we looked up by ID (or vice versa)
|
||||
if (projectId !== project.slug) {
|
||||
queryClient.setQueryData(['project', 'v2', project.slug], project)
|
||||
queryClient.setQueryData(['project', 'v3', project.slug], projectV3)
|
||||
}
|
||||
if (projectId !== project.id) {
|
||||
queryClient.setQueryData(['project', 'v2', project.id], project)
|
||||
queryClient.setQueryData(['project', 'v3', project.id], projectV3)
|
||||
}
|
||||
|
||||
const projectType = projectV3.minecraft_server != null ? 'server' : project.project_type
|
||||
@@ -81,5 +82,23 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
}
|
||||
} catch {
|
||||
// Let the page handle 404s and other errors
|
||||
} finally {
|
||||
if (import.meta.client) stopLoading()
|
||||
}
|
||||
})
|
||||
|
||||
async function getProjectMiddlewareClient() {
|
||||
if (import.meta.server) {
|
||||
const authToken = useCookie('auth-token')
|
||||
return useServerModrinthClient({ authToken: authToken.value || undefined })
|
||||
}
|
||||
|
||||
const auth = await useAuth()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
return createModrinthClient(auth, {
|
||||
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
|
||||
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
|
||||
rateLimitKey: config.rateLimitKey,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ onMounted(() => {
|
||||
{{ previewError }}
|
||||
</div>
|
||||
|
||||
<div v-if="previewLoading" class="my-4 text-sm text-secondary">Loading preview…</div>
|
||||
<div v-if="previewLoading" class="my-4 text-sm text-secondary">Loading preview...</div>
|
||||
<div v-else>
|
||||
<div v-if="previewVariables.length" class="mt-2 grid gap-3 md:grid-cols-2">
|
||||
<label
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -372,6 +372,14 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const advancedFiltersCollapsed = computed({
|
||||
get: () => flags.value.advancedFiltersCollapsed,
|
||||
set: (value) => {
|
||||
flags.value.advancedFiltersCollapsed = value
|
||||
saveFeatureFlags()
|
||||
},
|
||||
})
|
||||
|
||||
const projectTypeId = computed(() => projectType.value?.id ?? 'mod')
|
||||
|
||||
debug('projectTypeId:', projectTypeId.value)
|
||||
@@ -478,6 +486,7 @@ provideBrowseManager({
|
||||
showServerOnly: showServerOnlyToggle,
|
||||
serverOnlyLabel: computed(() => formatMessage(commonMessages.serverOnlyLabel)),
|
||||
hiddenFilterTypes: computed(() => (showServerOnlyToggle.value ? ['environment'] : [])),
|
||||
advancedFiltersCollapsed,
|
||||
displayMode: resultsDisplayMode,
|
||||
cycleDisplayMode: cycleSearchDisplayMode,
|
||||
maxResultsOptions: currentMaxResultsOptions,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderIcon, GlobeIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
|
||||
import { FolderIcon, GlobeIcon, HashIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
|
||||
import { Chips, defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
|
||||
|
||||
definePageMeta({
|
||||
@@ -47,6 +47,10 @@ const messages = defineMessages({
|
||||
id: 'moderation.page.external-projects',
|
||||
defaultMessage: 'External projects',
|
||||
},
|
||||
globalDetailTracesTitle: {
|
||||
id: 'moderation.page.global-detail-traces',
|
||||
defaultMessage: 'Global traces',
|
||||
},
|
||||
})
|
||||
|
||||
const moderationLinks = [
|
||||
@@ -62,6 +66,11 @@ const moderationLinks = [
|
||||
href: '/moderation/external-projects',
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.globalDetailTracesTitle),
|
||||
href: '/moderation/global-traces',
|
||||
icon: HashIcon,
|
||||
},
|
||||
]
|
||||
|
||||
const mobileNavOptions = [
|
||||
@@ -69,15 +78,20 @@ const mobileNavOptions = [
|
||||
formatMessage(messages.technicalReviewTitle),
|
||||
formatMessage(messages.reportsTitle),
|
||||
formatMessage(messages.externalFilesTitle),
|
||||
formatMessage(messages.globalDetailTracesTitle),
|
||||
]
|
||||
|
||||
const selectedChip = computed({
|
||||
get() {
|
||||
const path = route.path
|
||||
if (path === '/moderation/technical-review') {
|
||||
if (path.startsWith('/moderation/technical-review')) {
|
||||
return formatMessage(messages.technicalReviewTitle)
|
||||
} else if (path.startsWith('/moderation/reports/')) {
|
||||
} else if (path.startsWith('/moderation/reports')) {
|
||||
return formatMessage(messages.reportsTitle)
|
||||
} else if (path.startsWith('/moderation/external-projects')) {
|
||||
return formatMessage(messages.externalFilesTitle)
|
||||
} else if (path.startsWith('/moderation/global-traces')) {
|
||||
return formatMessage(messages.globalDetailTracesTitle)
|
||||
} else {
|
||||
return formatMessage(messages.projectsTitle)
|
||||
}
|
||||
@@ -92,6 +106,10 @@ function navigateToPage(selectedOption: string) {
|
||||
router.push('/moderation/technical-review')
|
||||
} else if (selectedOption === formatMessage(messages.reportsTitle)) {
|
||||
router.push('/moderation/reports')
|
||||
} else if (selectedOption === formatMessage(messages.externalFilesTitle)) {
|
||||
router.push('/moderation/external-projects')
|
||||
} else if (selectedOption === formatMessage(messages.globalDetailTracesTitle)) {
|
||||
router.push('/moderation/global-traces')
|
||||
} else {
|
||||
router.push('/moderation')
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user