mirror of
https://github.com/modrinth/code.git
synced 2026-08-03 06:35:53 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44d320c994 | ||
|
|
7366c32df3 | ||
|
|
707e219ff8 | ||
|
|
dfe12d4ecb | ||
|
|
c653228fe7 | ||
|
|
352a196795 | ||
|
|
cef9b1efe5 |
@@ -57,8 +57,11 @@ jobs:
|
||||
|
||||
- name: Extract app changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ env.VERSION_TAG }}
|
||||
run: npx --yes tsx scripts/build-theseus-release-notes.ts
|
||||
run: |
|
||||
LAST_GITHUB_RELEASE_PUBLISHED_AT=$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.published_at // ""' 2>/dev/null || true)
|
||||
LAST_GITHUB_RELEASE_PUBLISHED_AT="$LAST_GITHUB_RELEASE_PUBLISHED_AT" npx --yes tsx scripts/build-theseus-release-notes.ts
|
||||
|
||||
- name: Generate version manifest
|
||||
run: |
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
ContentInstallModal,
|
||||
ContentUpdaterModal,
|
||||
CreationFlowModal,
|
||||
defineMessages,
|
||||
I18nDebugPanel,
|
||||
@@ -75,7 +76,6 @@ import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||
import ErrorModal from '@/components/ui/ErrorModal.vue'
|
||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
|
||||
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
|
||||
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
|
||||
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
|
||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
@@ -612,6 +612,16 @@ const {
|
||||
handleModpackDuplicateCreateAnyway: handleContentInstallModpackDuplicateCreateAnyway,
|
||||
handleModpackDuplicateGoToInstance: handleContentInstallModpackDuplicateGoToInstance,
|
||||
setIncompatibilityWarningModal: setContentIncompatibilityWarningModal,
|
||||
incompatibilityWarningVersions: contentInstallIncompatibilityWarningVersions,
|
||||
incompatibilityWarningCurrentGameVersion: contentInstallIncompatibilityWarningCurrentGameVersion,
|
||||
incompatibilityWarningCurrentLoader: contentInstallIncompatibilityWarningCurrentLoader,
|
||||
incompatibilityWarningProjectType: contentInstallIncompatibilityWarningProjectType,
|
||||
incompatibilityWarningProjectIconUrl: contentInstallIncompatibilityWarningProjectIconUrl,
|
||||
incompatibilityWarningProjectName: contentInstallIncompatibilityWarningProjectName,
|
||||
incompatibilityWarningMessage: contentInstallIncompatibilityWarningMessage,
|
||||
incompatibilityWarningInstalling: contentInstallIncompatibilityWarningInstalling,
|
||||
handleIncompatibilityWarningInstall: handleContentInstallIncompatibilityWarningInstall,
|
||||
handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel,
|
||||
} = contentInstall
|
||||
|
||||
const serverInstall = createServerInstall({ router, handleError, popupNotificationManager })
|
||||
@@ -633,6 +643,12 @@ const updateToPlayModal = ref()
|
||||
|
||||
const modrinthLoginFlowWaitModal = ref()
|
||||
|
||||
watch(incompatibilityWarningModal, (modal) => {
|
||||
if (modal) {
|
||||
setContentIncompatibilityWarningModal(modal)
|
||||
}
|
||||
})
|
||||
|
||||
setupAuthProvider(credentials, async (_redirectPath) => {
|
||||
await signIn()
|
||||
})
|
||||
@@ -1631,7 +1647,22 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
@go-to-instance="handleModpackDuplicateGoToInstance"
|
||||
/>
|
||||
<AddServerToInstanceModal ref="addServerToInstanceModal" />
|
||||
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
|
||||
<ContentUpdaterModal
|
||||
ref="incompatibilityWarningModal"
|
||||
mode="incompatibility-warning"
|
||||
:versions="contentInstallIncompatibilityWarningVersions"
|
||||
:current-game-version="contentInstallIncompatibilityWarningCurrentGameVersion"
|
||||
:current-loader="contentInstallIncompatibilityWarningCurrentLoader"
|
||||
current-version-id=""
|
||||
:is-app="true"
|
||||
:project-type="contentInstallIncompatibilityWarningProjectType"
|
||||
:project-icon-url="contentInstallIncompatibilityWarningProjectIconUrl"
|
||||
:project-name="contentInstallIncompatibilityWarningProjectName"
|
||||
:warning="contentInstallIncompatibilityWarningMessage"
|
||||
:action-loading="contentInstallIncompatibilityWarningInstalling"
|
||||
@update="handleContentInstallIncompatibilityWarningInstall"
|
||||
@cancel="handleContentInstallIncompatibilityWarningCancel"
|
||||
/>
|
||||
<ModpackAlreadyInstalledModal
|
||||
ref="contentInstallModpackAlreadyInstalledModal"
|
||||
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
<template>
|
||||
<ModalWrapper ref="incompatibleModal" header="Incompatibility warning" :on-hide="onInstall">
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
This {{ versions?.length > 0 ? 'project' : 'version' }} is not compatible with the instance
|
||||
you're trying to install it on. Are you sure you want to continue? Dependencies will not be
|
||||
installed.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="header">
|
||||
<th>{{ instance?.name }}</th>
|
||||
<th>{{ project.title }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="content">
|
||||
<td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td>
|
||||
<td>
|
||||
<Combobox
|
||||
v-if="versions?.length > 1"
|
||||
v-model="selectedVersionId"
|
||||
:options="versionOptions"
|
||||
:searchable="true"
|
||||
placeholder="Select version"
|
||||
force-direction="up"
|
||||
:max-height="150"
|
||||
/>
|
||||
<span v-else>
|
||||
<span>{{ selectedVersionLabel }}</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="button-group">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="() => incompatibleModal.hide()"><XIcon />Cancel</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="installing" @click="install()">
|
||||
<DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
formatLoader,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { add_project_from_version as installMod } from '@/helpers/profile'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const instance = ref(null)
|
||||
const project = ref(null)
|
||||
const versions = ref(null)
|
||||
const selectedVersion = ref(null)
|
||||
const incompatibleModal = ref(null)
|
||||
const installing = ref(false)
|
||||
|
||||
const onInstall = ref(() => {})
|
||||
|
||||
const selectedVersionLabel = computed(() => {
|
||||
if (!selectedVersion.value) return ''
|
||||
return `${selectedVersion.value.name} (${selectedVersion.value.loaders
|
||||
.map((name) => formatLoader(formatMessage, name))
|
||||
.join(', ')} - ${selectedVersion.value.game_versions.join(', ')})`
|
||||
})
|
||||
|
||||
const versionOptions = computed(() =>
|
||||
(versions.value ?? []).map((version) => ({
|
||||
value: version.id,
|
||||
label: `${version.name} (${version.loaders
|
||||
.map((name) => formatLoader(formatMessage, name))
|
||||
.join(', ')} - ${version.game_versions.join(', ')})`,
|
||||
})),
|
||||
)
|
||||
|
||||
const selectedVersionId = computed({
|
||||
get: () => selectedVersion.value?.id ?? null,
|
||||
set: (value) => {
|
||||
if (!value) return
|
||||
selectedVersion.value = (versions.value ?? []).find((version) => version.id === value) ?? null
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
|
||||
instance.value = instanceVal
|
||||
versions.value = projectVersions ?? []
|
||||
selectedVersion.value = selected ?? projectVersions?.[0] ?? null
|
||||
|
||||
project.value = projectVal
|
||||
|
||||
onInstall.value = callback
|
||||
installing.value = false
|
||||
|
||||
incompatibleModal.value.show()
|
||||
|
||||
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
|
||||
},
|
||||
})
|
||||
|
||||
const install = async () => {
|
||||
installing.value = true
|
||||
await installMod(instance.value.path, selectedVersion.value.id, 'standalone').catch(handleError)
|
||||
installing.value = false
|
||||
onInstall.value(selectedVersion.value.id)
|
||||
incompatibleModal.value.hide()
|
||||
|
||||
trackEvent('ProjectInstall', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
id: project.value,
|
||||
version_id: selectedVersion.value.id,
|
||||
project_type: project.value.project_type,
|
||||
title: project.value.title,
|
||||
source: 'ProjectIncompatibilityWarningModal',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.data {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-lg);
|
||||
border-collapse: collapse;
|
||||
box-shadow: 0 0 0 1px var(--color-button-bg);
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg);
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--color-button-bg);
|
||||
}
|
||||
|
||||
th:first-child {
|
||||
border-top-left-radius: var(--radius-lg);
|
||||
border-right: 1px solid var(--color-button-bg);
|
||||
}
|
||||
|
||||
th:last-child {
|
||||
border-top-right-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
border-right: 1px solid var(--color-button-bg);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -149,6 +149,9 @@
|
||||
"app.browse.server.installing": {
|
||||
"message": "Installing"
|
||||
},
|
||||
"app.content-install.no-compatible-versions": {
|
||||
"message": "No available versions match {compatibilityLabel}. Select a version to install anyway. Dependencies will not be installed automatically."
|
||||
},
|
||||
"app.creation-modal.installing-modpack.description": {
|
||||
"message": "{fileName}"
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ServersManageAccessPage,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { serverId } = injectModrinthServerContext()
|
||||
@@ -26,8 +27,12 @@ try {
|
||||
} catch {
|
||||
// Let mounted layouts' useQuery surface errors; do not fail route setup.
|
||||
}
|
||||
|
||||
function userProfileLink(username: string) {
|
||||
return () => openUrl(`https://modrinth.com/user/${encodeURIComponent(username)}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ServersManageAccessPage />
|
||||
<ServersManageAccessPage :user-profile-link="userProfileLink" />
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { ContentInstallInstance, ContentInstallProjectInfo, ContentItem } from '@modrinth/ui'
|
||||
import { createContext } from '@modrinth/ui'
|
||||
import { createContext, defineMessage, useVIntl } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from '@/store/install.js'
|
||||
|
||||
interface ModalRef {
|
||||
show: () => void
|
||||
show: (initialVersionId?: string) => void
|
||||
hide: () => void
|
||||
}
|
||||
|
||||
@@ -42,19 +42,14 @@ interface ModpackAlreadyInstalledModalRef {
|
||||
show: (instanceName: string, instancePath: string) => void
|
||||
}
|
||||
|
||||
interface IncompatibilityWarningModalRef {
|
||||
show: (
|
||||
instance: GameInstance,
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
versions: Labrinth.Versions.v2.Version[],
|
||||
version: Labrinth.Versions.v2.Version,
|
||||
callback: (versionId?: string) => void,
|
||||
) => void
|
||||
}
|
||||
|
||||
const LOADER_ORDER = ['vanilla', 'fabric', 'quilt', 'neoforge', 'forge']
|
||||
const SUPPORTED_LOADERS: Set<string> = new Set(['vanilla', 'forge', 'fabric', 'quilt', 'neoforge'])
|
||||
const VANILLA_COMPATIBLE_LOADERS: Set<string> = new Set(['minecraft', 'datapack'])
|
||||
const noCompatibleVersionsMessage = defineMessage({
|
||||
id: 'app.content-install.no-compatible-versions',
|
||||
defaultMessage:
|
||||
'No available versions match {compatibilityLabel}. Select a version to install anyway. Dependencies will not be installed automatically.',
|
||||
})
|
||||
|
||||
function sortLoaders(loaders: string[]): string[] {
|
||||
return loaders.slice().sort((a, b) => {
|
||||
@@ -91,7 +86,17 @@ export interface ContentInstallContext {
|
||||
setModpackAlreadyInstalledModal: (ref: ModpackAlreadyInstalledModalRef) => void
|
||||
handleModpackDuplicateCreateAnyway: () => Promise<void>
|
||||
handleModpackDuplicateGoToInstance: (instancePath: string) => void
|
||||
setIncompatibilityWarningModal: (ref: IncompatibilityWarningModalRef) => void
|
||||
setIncompatibilityWarningModal: (ref: ModalRef) => void
|
||||
incompatibilityWarningVersions: Ref<Labrinth.Versions.v2.Version[]>
|
||||
incompatibilityWarningCurrentGameVersion: Ref<string>
|
||||
incompatibilityWarningCurrentLoader: Ref<string>
|
||||
incompatibilityWarningProjectType: Ref<string | undefined>
|
||||
incompatibilityWarningProjectIconUrl: Ref<string | undefined>
|
||||
incompatibilityWarningProjectName: Ref<string | undefined>
|
||||
incompatibilityWarningMessage: Ref<string | undefined>
|
||||
incompatibilityWarningInstalling: Ref<boolean>
|
||||
handleIncompatibilityWarningInstall: (version: Labrinth.Versions.v2.Version) => Promise<void>
|
||||
handleIncompatibilityWarningCancel: () => void
|
||||
install: (
|
||||
projectId: string,
|
||||
versionId?: string | null,
|
||||
@@ -113,6 +118,7 @@ export function createContentInstall(opts: {
|
||||
router: Router
|
||||
handleError: (err: unknown) => void
|
||||
}): ContentInstallContext {
|
||||
const { formatMessage } = useVIntl()
|
||||
const instances = ref<ContentInstallInstance[]>([])
|
||||
const compatibleLoaders = ref<string[]>([])
|
||||
const gameVersions = ref<string[]>([])
|
||||
@@ -124,6 +130,14 @@ export function createContentInstall(opts: {
|
||||
|
||||
const projectInfo = ref<ContentInstallProjectInfo | null>(null)
|
||||
const installingItems = ref<Map<string, ContentItem[]>>(new Map())
|
||||
const incompatibilityWarningVersions = ref<Labrinth.Versions.v2.Version[]>([])
|
||||
const incompatibilityWarningCurrentGameVersion = ref('')
|
||||
const incompatibilityWarningCurrentLoader = ref('')
|
||||
const incompatibilityWarningProjectType = ref<string | undefined>(undefined)
|
||||
const incompatibilityWarningProjectIconUrl = ref<string | undefined>(undefined)
|
||||
const incompatibilityWarningProjectName = ref<string | undefined>(undefined)
|
||||
const incompatibilityWarningMessage = ref<string | undefined>(undefined)
|
||||
const incompatibilityWarningInstalling = ref(false)
|
||||
|
||||
function addInstallingItem(
|
||||
instancePath: string,
|
||||
@@ -239,11 +253,15 @@ export function createContentInstall(opts: {
|
||||
|
||||
let modalRef: ModalRef | null = null
|
||||
let modpackAlreadyInstalledModalRef: ModpackAlreadyInstalledModalRef | null = null
|
||||
let incompatibilityWarningModalRef: IncompatibilityWarningModalRef | null = null
|
||||
let incompatibilityWarningModalRef: ModalRef | null = null
|
||||
let currentProject: Labrinth.Projects.v2.Project | null = null
|
||||
let currentVersions: Labrinth.Versions.v2.Version[] = []
|
||||
let currentCallback: (versionId?: string) => void = () => {}
|
||||
let profileMap: Record<string, GameInstance> = {}
|
||||
let incompatibilityWarningInstance: GameInstance | null = null
|
||||
let incompatibilityWarningProject: Labrinth.Projects.v2.Project | null = null
|
||||
let incompatibilityWarningCallback: (versionId?: string) => void = () => {}
|
||||
let incompatibilityWarningInstalled = false
|
||||
|
||||
let pendingModpackInstall: {
|
||||
project: Labrinth.Projects.v2.Project
|
||||
@@ -410,15 +428,35 @@ export function createContentInstall(opts: {
|
||||
async function handleInstallToInstance(instance: ContentInstallInstance) {
|
||||
const profile = profileMap[instance.id]
|
||||
const storeInstance = instances.value.find((i) => i.id === instance.id)
|
||||
if (storeInstance) storeInstance.installing = true
|
||||
if (!currentProject || !profile) {
|
||||
opts.handleError('No project or instance found')
|
||||
return
|
||||
}
|
||||
|
||||
const version = findPreferredVersion(currentVersions, currentProject, profile)
|
||||
if (!version) {
|
||||
if (storeInstance) storeInstance.installing = false
|
||||
opts.handleError('No compatible version found')
|
||||
if (currentVersions.length > 0 && incompatibilityWarningModalRef) {
|
||||
const onIncompatibleInstall = (versionId?: string) => {
|
||||
if (versionId && storeInstance) {
|
||||
storeInstance.installed = true
|
||||
}
|
||||
currentCallback(versionId)
|
||||
}
|
||||
await showIncompatibilityWarning(
|
||||
profile,
|
||||
currentProject,
|
||||
currentVersions,
|
||||
currentVersions[0],
|
||||
onIncompatibleInstall,
|
||||
)
|
||||
} else {
|
||||
opts.handleError('No version found')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (storeInstance) storeInstance.installing = true
|
||||
|
||||
const installedProjectIds: string[] = []
|
||||
if (currentProject) {
|
||||
addInstallingItem(instance.id, currentProject, version)
|
||||
@@ -458,6 +496,73 @@ export function createContentInstall(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
async function showIncompatibilityWarning(
|
||||
instance: GameInstance,
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
versions: Labrinth.Versions.v2.Version[],
|
||||
version: Labrinth.Versions.v2.Version,
|
||||
callback: (versionId?: string) => void,
|
||||
) {
|
||||
incompatibilityWarningInstance = instance
|
||||
incompatibilityWarningProject = project
|
||||
incompatibilityWarningCallback = callback
|
||||
incompatibilityWarningInstalled = false
|
||||
incompatibilityWarningInstalling.value = false
|
||||
incompatibilityWarningVersions.value = versions
|
||||
incompatibilityWarningCurrentGameVersion.value = instance.game_version ?? ''
|
||||
incompatibilityWarningCurrentLoader.value = instance.loader ?? ''
|
||||
incompatibilityWarningProjectType.value = project.project_type
|
||||
incompatibilityWarningProjectIconUrl.value = project.icon_url ?? undefined
|
||||
incompatibilityWarningProjectName.value = project.title
|
||||
|
||||
const compatibilityLabel =
|
||||
project.project_type === 'resourcepack' || project.project_type === 'datapack'
|
||||
? (instance.game_version ?? '')
|
||||
: `${instance.loader ?? ''} ${instance.game_version ?? ''}`.trim()
|
||||
incompatibilityWarningMessage.value = formatMessage(noCompatibleVersionsMessage, {
|
||||
compatibilityLabel,
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
incompatibilityWarningModalRef?.show(version.id)
|
||||
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
|
||||
}
|
||||
|
||||
async function handleIncompatibilityWarningInstall(version: Labrinth.Versions.v2.Version) {
|
||||
if (!incompatibilityWarningInstance || !incompatibilityWarningProject) return
|
||||
|
||||
incompatibilityWarningInstalling.value = true
|
||||
try {
|
||||
await add_project_from_version(incompatibilityWarningInstance.path, version.id, 'standalone')
|
||||
} catch (err) {
|
||||
opts.handleError(err)
|
||||
incompatibilityWarningInstalling.value = false
|
||||
return
|
||||
}
|
||||
|
||||
incompatibilityWarningInstalling.value = false
|
||||
incompatibilityWarningInstalled = true
|
||||
incompatibilityWarningCallback(version.id)
|
||||
incompatibilityWarningModalRef?.hide()
|
||||
|
||||
trackEvent('ProjectInstall', {
|
||||
loader: incompatibilityWarningInstance.loader,
|
||||
game_version: incompatibilityWarningInstance.game_version,
|
||||
id: incompatibilityWarningProject.id,
|
||||
version_id: version.id,
|
||||
project_type: incompatibilityWarningProject.project_type,
|
||||
title: incompatibilityWarningProject.title,
|
||||
source: 'ProjectIncompatibilityWarningModal',
|
||||
})
|
||||
}
|
||||
|
||||
function handleIncompatibilityWarningCancel() {
|
||||
if (!incompatibilityWarningInstalled) {
|
||||
incompatibilityWarningCallback()
|
||||
}
|
||||
incompatibilityWarningInstalled = false
|
||||
}
|
||||
|
||||
async function handleCreateAndInstall(data: {
|
||||
name: string
|
||||
iconPath: string | null
|
||||
@@ -614,7 +719,7 @@ export function createContentInstall(opts: {
|
||||
removeInstallingItems(instancePath, installedProjectIds)
|
||||
}
|
||||
} else {
|
||||
incompatibilityWarningModalRef?.show(instance, project, projectVersions, version, callback)
|
||||
await showIncompatibilityWarning(instance, project, projectVersions, version, callback)
|
||||
}
|
||||
} else {
|
||||
let versions = (
|
||||
@@ -668,9 +773,19 @@ export function createContentInstall(opts: {
|
||||
pendingModpackInstall = null
|
||||
opts.router.push(`/instance/${encodeURIComponent(instancePath)}`)
|
||||
},
|
||||
setIncompatibilityWarningModal(ref: IncompatibilityWarningModalRef) {
|
||||
setIncompatibilityWarningModal(ref: ModalRef) {
|
||||
incompatibilityWarningModalRef = ref
|
||||
},
|
||||
incompatibilityWarningVersions,
|
||||
incompatibilityWarningCurrentGameVersion,
|
||||
incompatibilityWarningCurrentLoader,
|
||||
incompatibilityWarningProjectType,
|
||||
incompatibilityWarningProjectIconUrl,
|
||||
incompatibilityWarningProjectName,
|
||||
incompatibilityWarningMessage,
|
||||
incompatibilityWarningInstalling,
|
||||
handleIncompatibilityWarningInstall,
|
||||
handleIncompatibilityWarningCancel,
|
||||
install,
|
||||
installingItems,
|
||||
}
|
||||
|
||||
@@ -1874,12 +1874,18 @@ const isSettings = computed(() => route.name.startsWith('type-project-settings')
|
||||
|
||||
// Transform versionsV3 to be same shape as versionsV2 for compatibility in project pages
|
||||
const versionsRaw = computed(() => {
|
||||
return (versionsV3.value ?? []).map((v) => {
|
||||
const isModpack = v.project_types?.includes('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 isModpack = version.project_types?.includes('modpack')
|
||||
const mrpackLoaders = Array.isArray(version.mrpack_loaders) ? version.mrpack_loaders : []
|
||||
|
||||
return {
|
||||
...v,
|
||||
loaders: isModpack && v.mrpack_loaders ? v.mrpack_loaders : v.loaders,
|
||||
...version,
|
||||
files,
|
||||
game_versions: gameVersions,
|
||||
loaders: isModpack && mrpackLoaders.length ? mrpackLoaders : loaders,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,11 +53,11 @@
|
||||
{{ formatDate(version.date_published) }}</span
|
||||
>
|
||||
</div>
|
||||
<ButtonStyled color="brand" type="transparent">
|
||||
<ButtonStyled v-if="getPrimaryFile(version)" color="brand" type="transparent">
|
||||
<a
|
||||
class="ml-auto"
|
||||
:href="createDownloadUrl(version)"
|
||||
:download="getPrimaryFile(version).filename"
|
||||
:download="getPrimaryFile(version)?.filename"
|
||||
:title="`Download ${version.name}`"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
@@ -145,10 +145,10 @@ const filteredVersions = computed(() => {
|
||||
(projectVersion) =>
|
||||
(selectedGameVersions.length === 0 ||
|
||||
selectedGameVersions.some((gameVersion) =>
|
||||
projectVersion.game_versions.includes(gameVersion),
|
||||
getVersionGameVersions(projectVersion).includes(gameVersion),
|
||||
)) &&
|
||||
(selectedLoaders.length === 0 ||
|
||||
selectedLoaders.some((loader) => projectVersion.loaders.includes(loader))) &&
|
||||
selectedLoaders.some((loader) => getVersionLoaders(projectVersion).includes(loader))) &&
|
||||
(selectedVersionTypes.length === 0 ||
|
||||
selectedVersionTypes.includes(projectVersion.version_type)),
|
||||
)
|
||||
@@ -233,14 +233,25 @@ watch(
|
||||
)
|
||||
|
||||
function getPrimaryFile(version) {
|
||||
return version.files.find((x) => x.primary) || version.files[0]
|
||||
return version.files?.find((x) => x.primary) || version.files?.[0]
|
||||
}
|
||||
|
||||
function createDownloadUrl(version) {
|
||||
return createProjectDownloadUrl(getPrimaryFile(version).url, {
|
||||
const file = getPrimaryFile(version)
|
||||
if (!file?.url) return undefined
|
||||
|
||||
return createProjectDownloadUrl(file.url, {
|
||||
reason: cdnDownloadReason.value,
|
||||
})
|
||||
}
|
||||
|
||||
function getVersionGameVersions(version) {
|
||||
return Array.isArray(version.game_versions) ? version.game_versions : []
|
||||
}
|
||||
|
||||
function getVersionLoaders(version) {
|
||||
return Array.isArray(version.loaders) ? version.loaders : []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -111,10 +111,11 @@
|
||||
color: 'primary',
|
||||
hoverFilled: true,
|
||||
link: createDownloadUrl(version),
|
||||
download: getPrimaryFile(version).filename,
|
||||
download: getPrimaryFile(version)?.filename,
|
||||
action: () => {
|
||||
emit('onDownload')
|
||||
},
|
||||
shown: !!getPrimaryFile(version),
|
||||
},
|
||||
{
|
||||
id: 'new-tab',
|
||||
@@ -402,7 +403,7 @@ const emit = defineEmits(['onDownload'])
|
||||
const baseDropdownId = useId()
|
||||
|
||||
function getPrimaryFile(version: Labrinth.Versions.v3.Version) {
|
||||
return version.files.find((x) => x.primary) || version.files[0]
|
||||
return version.files?.find((x) => x.primary) || version.files?.[0]
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -417,7 +418,10 @@ watch(
|
||||
)
|
||||
|
||||
function createDownloadUrl(version: Labrinth.Versions.v3.Version) {
|
||||
return createProjectDownloadUrl(getPrimaryFile(version).url, {
|
||||
const file = getPrimaryFile(version)
|
||||
if (!file?.url) return undefined
|
||||
|
||||
return createProjectDownloadUrl(file.url, {
|
||||
reason: cdnDownloadReason.value,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,11 +43,11 @@
|
||||
:open-modal="currentMember ? () => handleOpenCreateVersionModal() : undefined"
|
||||
>
|
||||
<template #actions="{ version }">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<ButtonStyled v-if="getPrimaryFile(version)" circular type="transparent">
|
||||
<a
|
||||
v-tooltip="`Download`"
|
||||
:href="createDownloadUrl(version)"
|
||||
:download="getPrimaryFile(version).filename"
|
||||
:download="getPrimaryFile(version)?.filename"
|
||||
class="hover:!bg-button-bg [&>svg]:!text-green"
|
||||
aria-label="Download"
|
||||
@click="emit('onDownload')"
|
||||
@@ -102,10 +102,11 @@
|
||||
color: 'primary',
|
||||
hoverFilled: true,
|
||||
link: createDownloadUrl(version),
|
||||
download: getPrimaryFile(version).filename,
|
||||
download: getPrimaryFile(version)?.filename,
|
||||
action: () => {
|
||||
emit('onDownload')
|
||||
},
|
||||
shown: !!getPrimaryFile(version),
|
||||
},
|
||||
{
|
||||
id: 'new-tab',
|
||||
@@ -318,7 +319,7 @@ const emit = defineEmits(['onDownload', 'deleteVersion'])
|
||||
const baseDropdownId = useId()
|
||||
|
||||
function getPrimaryFile(version) {
|
||||
return version.files.find((x) => x.primary) || version.files[0]
|
||||
return version.files?.find((x) => x.primary) || version.files?.[0]
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -333,7 +334,10 @@ watch(
|
||||
)
|
||||
|
||||
function createDownloadUrl(version) {
|
||||
return createProjectDownloadUrl(getPrimaryFile(version).url, {
|
||||
const file = getPrimaryFile(version)
|
||||
if (!file?.url) return undefined
|
||||
|
||||
return createProjectDownloadUrl(file.url, {
|
||||
reason: cdnDownloadReason.value,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,15 +8,11 @@
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="flex-1 lg:max-w-52"
|
||||
input-class="h-[40px]"
|
||||
wrapper-class="flex-1"
|
||||
input-class="h-[40px] w-full"
|
||||
@input="goToPage(1)"
|
||||
/>
|
||||
|
||||
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
|
||||
>
|
||||
@@ -56,6 +52,72 @@
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<MultiSelect
|
||||
v-model="currentReporterOrProject"
|
||||
:options="reporterOrProjectOptions"
|
||||
:max-height="500"
|
||||
dropdown-min-width="360px"
|
||||
no-options-message="no options found"
|
||||
:searchable="reporterOrProjectOptions.length > 6"
|
||||
:max-tag-rows="1"
|
||||
fit-content
|
||||
checkbox-position="right"
|
||||
show-selection-actions
|
||||
should-show-select-all
|
||||
@update:model-value="goToPage(1)"
|
||||
>
|
||||
<template #input-content="{ isOpen, openDirection }">
|
||||
<div class="flex min-h-7 min-w-0 max-w-full flex-1 items-center gap-1.5 pr-1">
|
||||
<LayersIcon class="size-5 shrink-0 text-primary" />
|
||||
<span class="min-w-0 flex-1 truncate px-0.5 font-semibold text-primary">
|
||||
{{
|
||||
currentReporterOrProject.length === 0
|
||||
? 'All Reports'
|
||||
: `${currentReporterOrProject.length} selected`
|
||||
}}
|
||||
</span>
|
||||
<ChevronLeftIcon
|
||||
class="size-5 shrink-0 text-primary transition-transform duration-150"
|
||||
:class="
|
||||
isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #top>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center gap-1.5 border-0 bg-surface-4 px-4 py-3 text-left shadow-none transition-all duration-150 hover:brightness-[115%] focus:brightness-[115%]"
|
||||
:aria-selected="currentReporterOrProject.length === 0"
|
||||
:class="currentReporterOrProject.length === 0 ? 'text-contrast' : 'text-primary'"
|
||||
role="option"
|
||||
@click="
|
||||
() => {
|
||||
currentReporterOrProject = []
|
||||
goToPage(1)
|
||||
}
|
||||
"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
>
|
||||
<LayersIcon
|
||||
class="h-5 w-5 shrink-0 text-primary"
|
||||
:class="currentReporterOrProject.length === 0 ? 'text-contrast' : 'text-primary'"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 font-semibold leading-tight">All Reports</span>
|
||||
<span class="flex shrink-0 items-center justify-center text-brand">
|
||||
<CheckIcon
|
||||
v-if="currentReporterOrProject.length === 0"
|
||||
aria-hidden="true"
|
||||
class="size-5"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<FloatingPanel button-class="!h-10 !shadow-none !text-contrast" :auto-focus="false">
|
||||
<BlendIcon class="size-5" /> Advanced filters
|
||||
<template #panel>
|
||||
@@ -67,6 +129,7 @@
|
||||
class="!w-full"
|
||||
:options="reportTargetFilterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
@select="goToPage(1)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-w-64 flex-col gap-3">
|
||||
@@ -77,6 +140,7 @@
|
||||
class="!w-full"
|
||||
:options="reportIssueFilterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
@select="goToPage(1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,6 +151,7 @@
|
||||
class="!w-full"
|
||||
:options="projectTypeFilterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
@select="goToPage(1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,6 +160,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between">
|
||||
<div>
|
||||
Showing
|
||||
{{ itemsPerPage * (currentPage - 1) + 1 }}
|
||||
–
|
||||
{{ itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedReports.length) }}
|
||||
of {{ sortedReports.length }} reports
|
||||
</div>
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
@@ -111,18 +187,30 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BlendIcon, ListFilterIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
BlendIcon,
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
LayersIcon,
|
||||
ListFilterIcon,
|
||||
SearchIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { ExtendedReport } from '@modrinth/moderation'
|
||||
import {
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
FloatingPanel,
|
||||
MultiSelect,
|
||||
type MultiSelectItem,
|
||||
Pagination,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { Report } from '@modrinth/utils'
|
||||
import type { Report, User } from '@modrinth/utils'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
import ReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
|
||||
@@ -254,6 +342,64 @@ const reportIssueFilterTypes = computed<ComboboxOption<string>[]>(() => {
|
||||
return [...base, ...sortedTypes.map((type) => ({ value: type, label: type }))]
|
||||
})
|
||||
|
||||
type ReportedType<T> = T & { report_item_count: number }
|
||||
|
||||
const currentReporterOrProject = ref<string[]>([])
|
||||
const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
|
||||
if (!allReports.value) return []
|
||||
const options: MultiSelectItem<string>[] = []
|
||||
|
||||
const uniqueProjectIds: { [id: string]: ReportedType<Labrinth.Projects.v2.Project> } = {}
|
||||
const uniqueReporterIds: { [id: string]: ReportedType<User> } = {}
|
||||
|
||||
for (const report of filteredReports.value) {
|
||||
if (report.project)
|
||||
uniqueProjectIds[report.project.id] = {
|
||||
...report.project,
|
||||
report_item_count: (uniqueProjectIds[report.project.id]?.report_item_count || 0) + 1,
|
||||
}
|
||||
if (report.reporter_user)
|
||||
uniqueReporterIds[report.reporter_user.id] = {
|
||||
...report.reporter_user,
|
||||
report_item_count: (uniqueReporterIds[report.reporter_user.id]?.report_item_count || 0) + 1,
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(uniqueProjectIds).length !== 0) {
|
||||
options.push({ type: 'section-header', label: 'Projects' })
|
||||
Object.values(uniqueProjectIds)
|
||||
.sort((a, b) =>
|
||||
a.report_item_count === b.report_item_count
|
||||
? a.title.localeCompare(b.title)
|
||||
: b.report_item_count - a.report_item_count,
|
||||
)
|
||||
.forEach((project) => {
|
||||
options.push({
|
||||
value: `project/${project.id}`,
|
||||
label: `${project.title} (${project.report_item_count})`,
|
||||
icon: project.icon_url ? h('img', { src: project.icon_url }) : undefined,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
options.push({ type: 'section-header', label: 'Reporters' })
|
||||
Object.values(uniqueReporterIds)
|
||||
.sort((a, b) =>
|
||||
a.report_item_count === b.report_item_count
|
||||
? a.username.localeCompare(b.username)
|
||||
: b.report_item_count - a.report_item_count,
|
||||
)
|
||||
.forEach((reporter) => {
|
||||
options.push({
|
||||
value: `reporter/${reporter.id}`,
|
||||
label: `${reporter.username} (${reporter.report_item_count})`,
|
||||
icon: reporter.avatar_url ? h('img', { src: reporter.avatar_url }) : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = 15
|
||||
const totalPages = computed(() => Math.ceil((sortedReports.value?.length || 0) / itemsPerPage))
|
||||
@@ -379,7 +525,19 @@ const filteredReports = computed(() => {
|
||||
})
|
||||
|
||||
const sortedReports = computed(() => {
|
||||
const filtered = [...filteredReports.value]
|
||||
const reporterOrProjectFilter = currentReporterOrProject.value
|
||||
const filtered =
|
||||
reporterOrProjectFilter.length === 0
|
||||
? [...filteredReports.value]
|
||||
: filteredReports.value.filter((report) => {
|
||||
const reporterOrProjectFilterLookup = new Set(reporterOrProjectFilter)
|
||||
const reporterValue = report.reporter_user ? `reporter/${report.reporter_user.id}` : null
|
||||
const projectValue = report.project ? `project/${report.project.id}` : null
|
||||
return (
|
||||
(reporterValue && reporterOrProjectFilterLookup.has(reporterValue)) ||
|
||||
(projectValue && reporterOrProjectFilterLookup.has(projectValue))
|
||||
)
|
||||
})
|
||||
|
||||
if (currentSortTypeSorting.value === 'oldest') {
|
||||
filtered.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime())
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"title": "Manage servers together",
|
||||
"summary": "Add other users to your server, assign roles, and track what’s changed.",
|
||||
"thumbnail": "https://modrinth.com/news/article/server-access/thumbnail.webp",
|
||||
"date": "2026-06-03T20:10:28.823Z",
|
||||
"date": "2026-06-04T15:59:11.000Z",
|
||||
"link": "https://modrinth.com/news/article/server-access"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -858,6 +858,12 @@ export namespace Archon {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type UserInfo = {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
}
|
||||
|
||||
export type DeleteManyBackupRequest = {
|
||||
backup_ids: string[]
|
||||
}
|
||||
@@ -865,22 +871,26 @@ export namespace Archon {
|
||||
export type ActiveOperation = {
|
||||
backup_id: string
|
||||
operation_type: BackupQueueOperationType
|
||||
operation_id?: number | null
|
||||
operation_id: number | null
|
||||
has_parent: boolean
|
||||
scheduled_for: string
|
||||
started_at: string | null
|
||||
synthetic_legacy: boolean
|
||||
user_info: UserInfo | null
|
||||
}
|
||||
|
||||
export type BackupQueueOperation = {
|
||||
operation_type: BackupQueueOperationType
|
||||
operation_id?: number | null
|
||||
operation_id: number | null
|
||||
state: BackupQueueState
|
||||
scheduled_for: string
|
||||
completed_at?: string | null
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
has_parent: boolean
|
||||
error?: string | null
|
||||
error: string | null
|
||||
should_prompt: boolean
|
||||
synthetic_legacy: boolean
|
||||
user_info: UserInfo | null
|
||||
}
|
||||
|
||||
export type BackupQueueBackup = {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { UploadHandle } from '../../../types/upload'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
const VERSION_UPLOAD_TIMEOUT_MS = 30 * 60 * 1000
|
||||
|
||||
export class LabrinthVersionsV3Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_versions_v3'
|
||||
@@ -199,7 +201,7 @@ export class LabrinthVersionsV3Module extends AbstractModule {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
formData,
|
||||
timeout: 60 * 5 * 1000,
|
||||
timeout: VERSION_UPLOAD_TIMEOUT_MS,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -284,7 +286,7 @@ export class LabrinthVersionsV3Module extends AbstractModule {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
formData,
|
||||
timeout: 60 * 5 * 1000,
|
||||
timeout: VERSION_UPLOAD_TIMEOUT_MS,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,11 @@ export abstract class XHRUploadClient extends AbstractModrinthClient {
|
||||
|
||||
xhr.addEventListener('error', () => reject(new ModrinthApiError('Upload failed')))
|
||||
xhr.addEventListener('abort', () => reject(new ModrinthApiError('Upload cancelled')))
|
||||
xhr.addEventListener('timeout', () => reject(new ModrinthApiError('Upload timed out')))
|
||||
|
||||
if (context.options.timeout !== undefined) {
|
||||
xhr.timeout = context.options.timeout
|
||||
}
|
||||
|
||||
// build URL with params (unlike $fetch, XHR doesn't handle params automatically)
|
||||
let url = context.url
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Manage servers together
|
||||
summary: Add other users to your server, assign roles, and track what’s changed.
|
||||
date: 2026-06-03T20:10:28.823Z
|
||||
date: 2026-06-04T15:59:11+00:00
|
||||
authors: ['bOHH0P9Z', 'AJfd8YH6']
|
||||
---
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ export const article = {
|
||||
html: () => import(`./server_access.content`).then(m => m.html),
|
||||
title: "Manage servers together",
|
||||
summary: "Add other users to your server, assign roles, and track what’s changed.",
|
||||
date: "2026-06-03T20:10:28.823Z",
|
||||
date: "2026-06-04T15:59:11.000Z",
|
||||
slug: "server-access",
|
||||
authors: ["bOHH0P9Z","AJfd8YH6"],
|
||||
unlisted: false,
|
||||
|
||||
@@ -20,13 +20,16 @@
|
||||
ref="searchTriggerRef"
|
||||
v-model="searchQuery"
|
||||
:icon="showSearchIcon ? SearchIcon : undefined"
|
||||
type="text"
|
||||
:type="searchType"
|
||||
:name="searchName"
|
||||
:placeholder="searchPlaceholder || placeholder"
|
||||
:disabled="disabled"
|
||||
:autocomplete="searchAutocomplete"
|
||||
:autocorrect="searchAutocorrect"
|
||||
:autocapitalize="searchAutocapitalize"
|
||||
:spellcheck="searchSpellcheck"
|
||||
:inputmode="searchInputmode"
|
||||
:input-attrs="searchInputAttrs"
|
||||
wrapper-class="w-full !bg-transparent"
|
||||
:input-class="searchableInputClass"
|
||||
class="relative z-[1]"
|
||||
@@ -281,8 +284,6 @@ const props = withDefaults(
|
||||
forceDirection?: 'up' | 'down'
|
||||
noOptionsMessage?: string
|
||||
disableSearchFilter?: boolean
|
||||
dropdownClass?: string
|
||||
dropdownMinWidth?: string
|
||||
minSearchLengthToOpen?: number
|
||||
/** Keep the selected option's label in the input after selection, and show all options on focus */
|
||||
syncWithSelection?: boolean
|
||||
@@ -290,10 +291,14 @@ const props = withDefaults(
|
||||
selectSearchTextOnFocus?: boolean
|
||||
/** Show a search icon in the searchable input */
|
||||
showSearchIcon?: boolean
|
||||
searchType?: 'text' | 'search'
|
||||
searchName?: string
|
||||
searchInputmode?: 'text' | 'search'
|
||||
searchAutocomplete?: string
|
||||
searchAutocorrect?: 'on' | 'off'
|
||||
searchAutocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
|
||||
searchSpellcheck?: boolean
|
||||
searchInputAttrs?: Record<string, string | number | boolean | undefined>
|
||||
}>(),
|
||||
{
|
||||
placeholder: 'Select an option',
|
||||
@@ -309,6 +314,7 @@ const props = withDefaults(
|
||||
syncWithSelection: true,
|
||||
selectSearchTextOnFocus: false,
|
||||
showSearchIcon: false,
|
||||
searchType: 'text',
|
||||
outsideClickIgnore: () => [],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -269,8 +269,13 @@
|
||||
>
|
||||
<div
|
||||
v-if="isDropdownFilterSectionHeader(item)"
|
||||
class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm font-semibold text-secondary"
|
||||
:class="item.class"
|
||||
class="flex items-center justify-between gap-3 border-0 px-4 py-2.5 text-sm font-semibold text-secondary"
|
||||
:class="[
|
||||
item.class,
|
||||
item.dividerBefore && index > 0
|
||||
? 'border-t border-solid border-surface-5'
|
||||
: undefined,
|
||||
]"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<component
|
||||
@@ -398,6 +403,7 @@ export type DropdownFilterBarSectionHeader = {
|
||||
key?: string
|
||||
icon?: Component
|
||||
class?: string
|
||||
dividerBefore?: boolean
|
||||
}
|
||||
|
||||
export type DropdownFilterBarItem = DropdownFilterBarOption | DropdownFilterBarSectionHeader
|
||||
|
||||
@@ -83,7 +83,6 @@ const props = withDefaults(
|
||||
{
|
||||
page: 1,
|
||||
count: 1,
|
||||
linkFunction: (page: number) => void page,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<textarea
|
||||
v-if="multiline"
|
||||
:id="id"
|
||||
v-bind="inputAttrs"
|
||||
ref="inputRef"
|
||||
:value="model"
|
||||
:placeholder="placeholder"
|
||||
@@ -50,6 +51,7 @@
|
||||
<input
|
||||
v-else
|
||||
:id="id"
|
||||
v-bind="inputAttrs"
|
||||
ref="inputRef"
|
||||
:type="type"
|
||||
:value="model"
|
||||
@@ -77,9 +79,6 @@
|
||||
variant === 'outlined'
|
||||
? 'bg-transparent border border-solid border-button-bg rounded-l-xl border-r-0'
|
||||
: 'bg-surface-4 border-none rounded-xl',
|
||||
{
|
||||
'placeholder:text-sm': type === 'search',
|
||||
},
|
||||
]"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@@ -149,6 +148,7 @@ const props = withDefaults(
|
||||
resize?: 'none' | 'vertical' | 'both'
|
||||
inputClass?: string
|
||||
wrapperClass?: string
|
||||
inputAttrs?: Record<string, string | number | boolean | undefined>
|
||||
}>(),
|
||||
{
|
||||
type: 'text',
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div class="overflow-hidden rounded-2xl border border-solid border-surface-5">
|
||||
<div class="overflow-hidden rounded-2xl border border-solid border-surface-4">
|
||||
<div
|
||||
v-if="hasHeaderSlot"
|
||||
class="border-solid border-0 border-b border-surface-5 bg-surface-3 p-4"
|
||||
class="border-solid border-0 border-b border-surface-4 bg-surface-3 p-4"
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div class="overflow-x-auto overflow-y-hidden">
|
||||
<table
|
||||
class="w-full table-fixed border-separate border-spacing-0 border-surface-5"
|
||||
class="w-full table-fixed border-separate border-spacing-0 border-surface-4"
|
||||
:style="tableMinWidth ? { minWidth: tableMinWidth } : undefined"
|
||||
>
|
||||
<colgroup>
|
||||
@@ -68,7 +68,7 @@
|
||||
tag="tbody"
|
||||
>
|
||||
<tr v-if="data.length === 0" key="empty" class="bg-surface-2">
|
||||
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
|
||||
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-4 p-0">
|
||||
<slot name="empty-state">
|
||||
<div class="text-secondary flex h-64 items-center justify-center">
|
||||
No data available.
|
||||
@@ -84,7 +84,7 @@
|
||||
>
|
||||
<td
|
||||
v-if="showSelection"
|
||||
class="w-12 border-solid border-0 border-t border-surface-5 focus:outline-none"
|
||||
class="w-12 border-solid border-0 border-t border-surface-4 focus:outline-none"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="isSelected(row)"
|
||||
@@ -95,7 +95,7 @@
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
|
||||
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-4"
|
||||
:class="`text-${column.align ?? 'left'}`"
|
||||
>
|
||||
<slot
|
||||
@@ -113,7 +113,7 @@
|
||||
</TransitionGroup>
|
||||
<tbody v-else :ref="setListContainer">
|
||||
<tr v-if="data.length === 0" class="bg-surface-2">
|
||||
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
|
||||
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-4 p-0">
|
||||
<slot name="empty-state">
|
||||
<div class="text-secondary flex h-64 items-center justify-center">
|
||||
No data available.
|
||||
@@ -136,7 +136,7 @@
|
||||
>
|
||||
<td
|
||||
v-if="showSelection"
|
||||
class="w-12 border-solid border-0 border-t border-surface-5 focus:outline-none"
|
||||
class="w-12 border-solid border-0 border-t border-surface-4 focus:outline-none"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="isSelected(row)"
|
||||
@@ -147,7 +147,7 @@
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
|
||||
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-4"
|
||||
:class="`text-${column.align ?? 'left'}`"
|
||||
>
|
||||
<slot
|
||||
|
||||
@@ -327,15 +327,22 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
function getModpackLoaders(version: VersionWithDisplayUrlEnding): string[] {
|
||||
const loaders = Array.isArray(version.loaders) ? version.loaders : []
|
||||
|
||||
if (props.project.project_type !== 'modpack') {
|
||||
return version.loaders
|
||||
return loaders
|
||||
}
|
||||
|
||||
if (version.mrpack_loaders?.length) {
|
||||
return version.mrpack_loaders
|
||||
const mrpackLoaders = Array.isArray(version.mrpack_loaders) ? version.mrpack_loaders : []
|
||||
if (mrpackLoaders.length) {
|
||||
return mrpackLoaders
|
||||
}
|
||||
|
||||
return version.loaders.filter((loader) => loader !== 'mrpack')
|
||||
return loaders.filter((loader) => loader !== 'mrpack')
|
||||
}
|
||||
|
||||
function getGameVersions(version: VersionWithDisplayUrlEnding): string[] {
|
||||
return Array.isArray(version.game_versions) ? version.game_versions : []
|
||||
}
|
||||
|
||||
function hasNoModLoader(loaders: string[]): boolean {
|
||||
@@ -350,10 +357,12 @@ function hasNoModLoader(loaders: string[]): boolean {
|
||||
const normalizedVersions = computed<DisplayVersion[]>(() =>
|
||||
props.versions.map((version) => {
|
||||
const loaders = getModpackLoaders(version)
|
||||
const gameVersions = getGameVersions(version)
|
||||
const noModLoader = hasNoModLoader(loaders)
|
||||
|
||||
return {
|
||||
...version,
|
||||
game_versions: gameVersions,
|
||||
loaders: noModLoader ? [] : loaders,
|
||||
noModLoader,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:button-class="buttonClass ?? 'flex flex-col gap-2 justify-start items-start'"
|
||||
:content-class="contentClass"
|
||||
title-wrapper-class="flex flex-col gap-2 justify-start items-start"
|
||||
:open-by-default="!locked && (openByDefault !== undefined ? openByDefault : true)"
|
||||
:open-by-default="openByDefault !== undefined ? openByDefault : true"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="header" :filter="filterType">
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
>
|
||||
<template #cell-user="{ row: member }">
|
||||
<AutoLink
|
||||
:to="userProfilePath(member.user.username)"
|
||||
:to="getUserProfileLink(member.user.username)"
|
||||
:target="userProfileTarget(member.user.username)"
|
||||
class="inline-flex max-w-full min-w-0 items-center gap-2"
|
||||
:class="userProfilePath(member.user.username) ? 'text-primary hover:underline' : ''"
|
||||
:class="getUserProfileLink(member.user.username) ? 'text-primary hover:underline' : ''"
|
||||
>
|
||||
<Avatar
|
||||
:src="member.user.avatarUrl"
|
||||
@@ -61,7 +62,7 @@
|
||||
<template #cell-joined="{ row: member }">
|
||||
<span
|
||||
v-if="member.pending"
|
||||
class="inline-flex h-7 items-center rounded-full border border-surface-5 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
|
||||
class="inline-flex h-7 items-center rounded-full border border-surface-4 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.pendingLabel) }}
|
||||
</span>
|
||||
@@ -102,7 +103,7 @@
|
||||
|
||||
<div
|
||||
v-if="members.length > 0"
|
||||
class="overflow-hidden rounded-2xl border border-solid border-surface-5 sm:hidden"
|
||||
class="overflow-hidden rounded-2xl border border-solid border-surface-4 sm:hidden"
|
||||
>
|
||||
<div
|
||||
class="grid min-h-14 grid-cols-[minmax(0,1.35fr)_7.75rem_minmax(6rem,0.8fr)_4rem] bg-surface-3"
|
||||
@@ -147,15 +148,16 @@
|
||||
<div
|
||||
v-for="(member, index) in sortedMembers"
|
||||
:key="member.id"
|
||||
class="grid min-h-16 grid-cols-[minmax(0,1.35fr)_7.75rem_minmax(6rem,0.8fr)_4rem] items-center border-0 border-t border-solid border-surface-5"
|
||||
class="grid min-h-16 grid-cols-[minmax(0,1.35fr)_7.75rem_minmax(6rem,0.8fr)_4rem] items-center border-0 border-t border-solid border-surface-4"
|
||||
:class="index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
|
||||
>
|
||||
<div class="flex min-w-0 items-center pl-4">
|
||||
<AutoLink
|
||||
v-tooltip="member.user.username"
|
||||
:to="userProfilePath(member.user.username)"
|
||||
:to="getUserProfileLink(member.user.username)"
|
||||
:target="userProfileTarget(member.user.username)"
|
||||
class="inline-flex min-w-0 items-center gap-2"
|
||||
:class="userProfilePath(member.user.username) ? 'text-primary hover:underline' : ''"
|
||||
:class="getUserProfileLink(member.user.username) ? 'text-primary hover:underline' : ''"
|
||||
>
|
||||
<Avatar
|
||||
:src="member.user.avatarUrl"
|
||||
@@ -207,7 +209,7 @@
|
||||
<div class="min-w-0 py-3 pr-2 text-right text-secondary">
|
||||
<span
|
||||
v-if="member.pending"
|
||||
class="inline-flex h-7 max-w-full items-center rounded-full border border-surface-5 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
|
||||
class="inline-flex h-7 max-w-full items-center rounded-full border border-surface-4 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.pendingLabel) }}
|
||||
</span>
|
||||
@@ -248,7 +250,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-2xl border border-solid border-surface-5">
|
||||
<div v-else class="overflow-hidden rounded-2xl border border-solid border-surface-4">
|
||||
<div
|
||||
class="grid min-h-14 grid-cols-[3.75rem_7.25rem_minmax(0,1fr)_2.75rem] bg-surface-3 sm:h-14 sm:grid-cols-[32%_28%_28%_12%]"
|
||||
>
|
||||
@@ -266,7 +268,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-0 border-t border-solid border-surface-5 bg-surface-2 px-4 py-8 text-center text-secondary"
|
||||
class="border-0 border-t border-solid border-surface-4 bg-surface-2 px-4 py-8 text-center text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.emptyState) }}
|
||||
</div>
|
||||
@@ -293,7 +295,12 @@ import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
|
||||
import Table, { type SortDirection, type TableColumn } from '../../base/Table.vue'
|
||||
import TeleportOverflowMenu from '../../base/TeleportOverflowMenu.vue'
|
||||
import type { ServerAccessMember, ServerAccessRole, ServerAccessRoleOption } from './types'
|
||||
import type {
|
||||
ServerAccessMember,
|
||||
ServerAccessRole,
|
||||
ServerAccessRoleOption,
|
||||
ServerAccessUserProfileLink,
|
||||
} from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -301,6 +308,7 @@ const props = withDefaults(
|
||||
roles: ServerAccessRoleOption[]
|
||||
canManageUsers?: boolean
|
||||
permissionDeniedMessage?: string
|
||||
userProfileLink?: (username: string) => ServerAccessUserProfileLink
|
||||
}>(),
|
||||
{
|
||||
canManageUsers: true,
|
||||
@@ -353,11 +361,11 @@ const messages = defineMessages({
|
||||
},
|
||||
cancelInvite: {
|
||||
id: 'servers.access-table.action.cancel-invite',
|
||||
defaultMessage: 'Cancel invite',
|
||||
defaultMessage: 'Revoke invite',
|
||||
},
|
||||
removeUser: {
|
||||
id: 'servers.access-table.action.remove-user',
|
||||
defaultMessage: 'Remove user',
|
||||
defaultMessage: 'Revoke access',
|
||||
},
|
||||
emptyState: {
|
||||
id: 'servers.access-table.empty',
|
||||
@@ -533,9 +541,14 @@ function roleTriggerClass(role: ServerAccessRole): string {
|
||||
return roleClasses(role)
|
||||
}
|
||||
|
||||
function userProfilePath(username: string): string | undefined {
|
||||
function getUserProfileLink(username: string): ServerAccessUserProfileLink {
|
||||
if (!username || username.includes('@')) return undefined
|
||||
return `/user/${encodeURIComponent(username)}`
|
||||
return props.userProfileLink?.(username) ?? `/user/${encodeURIComponent(username)}`
|
||||
}
|
||||
|
||||
function userProfileTarget(username: string): string | undefined {
|
||||
const link = getUserProfileLink(username)
|
||||
return typeof link === 'string' && link.startsWith('http') ? '_blank' : undefined
|
||||
}
|
||||
|
||||
function resendInviteCooldownSeconds(member: ServerAccessMember): number {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
:trigger-class="timeframePickerTriggerClass"
|
||||
/>
|
||||
<template v-if="slots.filters">
|
||||
<div class="hidden h-8 w-[1px] shrink-0 bg-surface-5 @[640px]:ml-1 @[640px]:block"></div>
|
||||
<div class="hidden h-8 w-[1px] shrink-0 bg-surface-4 @[640px]:ml-1 @[640px]:block"></div>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<slot name="filters"></slot>
|
||||
</div>
|
||||
@@ -128,7 +128,7 @@
|
||||
<div
|
||||
v-for="entry in filteredEntries"
|
||||
:key="entry.id"
|
||||
class="flex min-w-0 flex-col gap-3 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
|
||||
class="flex min-w-0 flex-col gap-3 rounded-2xl border border-solid border-surface-4 bg-surface-2 p-4"
|
||||
>
|
||||
<AutoLink
|
||||
v-tooltip="actorName(entry)"
|
||||
@@ -166,7 +166,7 @@
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-2xl border border-solid border-surface-5">
|
||||
<div v-else class="overflow-hidden rounded-2xl border border-solid border-surface-4">
|
||||
<div
|
||||
class="hidden min-h-14 bg-surface-3 @[800px]:grid @[800px]:h-14"
|
||||
:class="
|
||||
@@ -224,7 +224,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-0 border-solid border-surface-5 bg-surface-2 px-4 py-8 text-center text-secondary @[800px]:border-t"
|
||||
class="border-0 border-solid border-surface-4 bg-surface-2 px-4 py-8 text-center text-secondary @[800px]:border-t"
|
||||
>
|
||||
{{ formatMessage(emptyStateMessage) }}
|
||||
</div>
|
||||
|
||||
@@ -22,10 +22,14 @@
|
||||
searchable
|
||||
show-search-icon
|
||||
:show-chevron="false"
|
||||
search-autocomplete="off"
|
||||
search-type="search"
|
||||
search-name="modrinth-server-access-member-search"
|
||||
search-inputmode="search"
|
||||
search-autocomplete="new-password"
|
||||
search-autocorrect="off"
|
||||
search-autocapitalize="none"
|
||||
:search-spellcheck="false"
|
||||
:search-input-attrs="passwordManagerIgnoreAttrs"
|
||||
@open="targetComboboxOpen = true"
|
||||
@close="targetComboboxOpen = false"
|
||||
@search-input="handleTargetSearch"
|
||||
@@ -191,6 +195,13 @@ const targetLookupStatus = ref<'idle' | 'loading' | 'loaded'>('idle')
|
||||
const targetLookupRequestId = ref(0)
|
||||
const hasSelectedTarget = ref(false)
|
||||
const targetComboboxOpen = ref(false)
|
||||
const passwordManagerIgnoreAttrs = {
|
||||
'data-1p-ignore': 'true',
|
||||
'data-bwignore': 'true',
|
||||
'data-form-type': 'other',
|
||||
'data-lpignore': 'true',
|
||||
'data-protonpass-ignore': 'true',
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
|
||||
@@ -135,29 +135,29 @@ const cachedState = ref({
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'servers.remove-access-modal.header',
|
||||
defaultMessage: 'Remove user',
|
||||
defaultMessage: 'Revoke access',
|
||||
},
|
||||
cancelHeader: {
|
||||
id: 'servers.remove-access-modal.cancel-header',
|
||||
defaultMessage: 'Cancel invite',
|
||||
defaultMessage: 'Revoke invite',
|
||||
},
|
||||
warningBody: {
|
||||
id: 'servers.remove-access-modal.warning-body',
|
||||
defaultMessage:
|
||||
"If you remove a user from your server, you'll need to re-invite them to restore access.",
|
||||
"If you revoke a user's access to your server, you'll need to re-invite them to restore access.",
|
||||
},
|
||||
cancelWarningBody: {
|
||||
id: 'servers.remove-access-modal.cancel-warning-body',
|
||||
defaultMessage:
|
||||
'If you cancel this invite, {username} will need a new invitation before they can join this server.',
|
||||
'If you revoke this invite, {username} will need a new invitation before they can join this server.',
|
||||
},
|
||||
removeButton: {
|
||||
id: 'servers.remove-access-modal.remove-button',
|
||||
defaultMessage: 'Remove user',
|
||||
defaultMessage: 'Revoke access',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'servers.remove-access-modal.cancel-button',
|
||||
defaultMessage: 'Cancel invite',
|
||||
defaultMessage: 'Revoke invite',
|
||||
},
|
||||
userAvatarAlt: {
|
||||
id: 'servers.remove-access-modal.user-avatar-alt',
|
||||
|
||||
@@ -63,7 +63,7 @@ const messages = defineMessages({
|
||||
},
|
||||
removed: {
|
||||
id: 'servers.audit-log.event.user-removed',
|
||||
defaultMessage: 'Removed <target-user></target-user>',
|
||||
defaultMessage: 'Revoked access for <target-user></target-user>',
|
||||
},
|
||||
ownerRole: {
|
||||
id: 'servers.access-role.owner',
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import type { AuditActor, AuditWorld, ParsedAuditEvent } from './events/types'
|
||||
|
||||
export type ServerAccessRole = 'owner' | 'editor' | 'viewer'
|
||||
export type ServerAccessUserProfileLink =
|
||||
| RouteLocationRaw
|
||||
| (() => void | Promise<void>)
|
||||
| undefined
|
||||
|
||||
export interface ServerAccessUser extends AuditActor {
|
||||
id: string
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
IntercomBubbleIcon,
|
||||
MoreVerticalIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
ShieldIcon,
|
||||
@@ -15,6 +16,8 @@ import { computed, ref } from 'vue'
|
||||
import { useFormatDateTime } from '../../../composables'
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import { commonMessages, truncatedTooltip } from '../../../utils'
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
import Avatar from '../../base/Avatar.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
|
||||
|
||||
@@ -32,6 +35,7 @@ const emit = defineEmits<{
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
backup: Archon.BackupsQueue.v1.BackupQueueBackup
|
||||
creator?: Archon.BackupsQueue.v1.UserInfo | null
|
||||
preview?: boolean
|
||||
kyrosUrl?: string
|
||||
jwt?: string
|
||||
@@ -44,6 +48,7 @@ const props = withDefaults(
|
||||
highlighted?: boolean
|
||||
}>(),
|
||||
{
|
||||
creator: undefined,
|
||||
preview: false,
|
||||
kyrosUrl: undefined,
|
||||
jwt: undefined,
|
||||
@@ -59,6 +64,22 @@ const props = withDefaults(
|
||||
|
||||
const nameRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const backupCreator = computed(() => {
|
||||
if (props.creator !== undefined) return props.creator
|
||||
|
||||
return (
|
||||
props.backup.history.find(
|
||||
(operation) => operation.operation_type === 'create' && operation.user_info,
|
||||
)?.user_info ?? null
|
||||
)
|
||||
})
|
||||
|
||||
const creatorProfileLink = computed(() =>
|
||||
backupCreator.value && backupCreator.value.id !== 'support'
|
||||
? `https://modrinth.com/user/${encodeURIComponent(backupCreator.value.username)}`
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const backupIcon = computed(() => {
|
||||
if (props.backup.automated) {
|
||||
return ShieldIcon
|
||||
@@ -137,7 +158,29 @@ const messages = defineMessages({
|
||||
id: 'servers.backups.item.manual-backup',
|
||||
defaultMessage: 'Manual backup',
|
||||
},
|
||||
creatorAvatarAlt: {
|
||||
id: 'servers.backups.item.creator-avatar-alt',
|
||||
defaultMessage: "{username}'s avatar",
|
||||
},
|
||||
supportCreator: {
|
||||
id: 'servers.backups.item.creator.support',
|
||||
defaultMessage: 'Support',
|
||||
},
|
||||
})
|
||||
|
||||
const creatorName = computed(() => {
|
||||
if (!backupCreator.value) return ''
|
||||
if (backupCreator.value.id !== 'support') return backupCreator.value.username
|
||||
return backupCreator.value.username === 'support'
|
||||
? formatMessage(messages.supportCreator)
|
||||
: backupCreator.value.username
|
||||
})
|
||||
|
||||
const creatorAvatarSrc = computed(() =>
|
||||
backupCreator.value?.id === 'support'
|
||||
? IntercomBubbleIcon
|
||||
: (backupCreator.value?.avatar_url ?? undefined),
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
@@ -174,10 +217,36 @@ const messages = defineMessages({
|
||||
{{ formatMessage(messages.auto) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-sm font-medium text-secondary">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-secondary">
|
||||
<template v-if="preview">
|
||||
<span>{{ formatDateTime(backup.created_at) }}</span>
|
||||
</template>
|
||||
<template v-else-if="backupCreator">
|
||||
<AutoLink
|
||||
:to="creatorProfileLink"
|
||||
:target="creatorProfileLink ? '_blank' : undefined"
|
||||
:rel="creatorProfileLink ? 'noopener noreferrer' : undefined"
|
||||
class="group flex min-w-0 items-center gap-1.5"
|
||||
:class="creatorProfileLink ? 'text-secondary hover:underline' : 'text-primary'"
|
||||
>
|
||||
<Avatar
|
||||
:src="creatorAvatarSrc"
|
||||
:alt="formatMessage(messages.creatorAvatarAlt, { username: creatorName })"
|
||||
:tint-by="creatorName"
|
||||
size="24px"
|
||||
circle
|
||||
no-shadow
|
||||
class="shrink-0 transition"
|
||||
:class="creatorProfileLink ? 'group-hover:brightness-125' : ''"
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 truncate font-medium"
|
||||
:class="backupCreator.id === 'support' ? 'text-blue' : ''"
|
||||
>
|
||||
{{ creatorName }}
|
||||
</span>
|
||||
</AutoLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>
|
||||
{{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
v-if="loader === 'Fabric'"
|
||||
v-if="normalizedLoader === 'Fabric'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
@@ -19,7 +19,7 @@
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'Quilt'"
|
||||
v-else-if="normalizedLoader === 'Quilt'"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
@@ -59,7 +59,7 @@
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'Forge'"
|
||||
v-else-if="normalizedLoader === 'Forge'"
|
||||
ml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
@@ -77,7 +77,7 @@
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'NeoForge'"
|
||||
v-else-if="normalizedLoader === 'NeoForge'"
|
||||
enable-background="new 0 0 24 24"
|
||||
version="1.1"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -109,7 +109,7 @@
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'Paper'"
|
||||
v-else-if="normalizedLoader === 'Paper'"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
@@ -124,7 +124,7 @@
|
||||
<path fill="currentColor" d="m12 18-4-2 10-9-6 11Z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'Spigot'"
|
||||
v-else-if="normalizedLoader === 'Spigot'"
|
||||
viewBox="0 0 332 284"
|
||||
style="
|
||||
fill-rule: evenodd;
|
||||
@@ -141,7 +141,7 @@
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'Bukkit'"
|
||||
v-else-if="normalizedLoader === 'Bukkit'"
|
||||
viewBox="0 0 292 319"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linecap: round; stroke-linejoin: round"
|
||||
stroke="currentColor"
|
||||
@@ -154,7 +154,7 @@
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'Purpur'"
|
||||
v-else-if="normalizedLoader === 'Purpur'"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
@@ -212,7 +212,7 @@
|
||||
transform="matrix(-1.125 0 0 1.2569 309 -40.78)"
|
||||
></use>
|
||||
</svg>
|
||||
<svg v-else-if="loader === 'Vanilla'" viewBox="0 0 20 20" fill="currentColor">
|
||||
<svg v-else-if="normalizedLoader === 'Vanilla'" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.504 1.132a1 1 0 01.992 0l1.75 1a1 1 0 11-.992 1.736L10 3.152l-1.254.716a1 1 0 11-.992-1.736l1.75-1zM5.618 4.504a1 1 0 01-.372 1.364L5.016 6l.23.132a1 1 0 11-.992 1.736L4 7.723V8a1 1 0 01-2 0V6a.996.996 0 01.52-.878l1.734-.99a1 1 0 011.364.372zm8.764 0a1 1 0 011.364-.372l1.733.99A1.002 1.002 0 0118 6v2a1 1 0 11-2 0v-.277l-.254.145a1 1 0 11-.992-1.736l.23-.132-.23-.132a1 1 0 01-.372-1.364zm-7 4a1 1 0 011.364-.372L10 8.848l1.254-.716a1 1 0 11.992 1.736L11 10.58V12a1 1 0 11-2 0v-1.42l-1.246-.712a1 1 0 01-.372-1.364zM3 11a1 1 0 011 1v1.42l1.246.712a1 1 0 11-.992 1.736l-1.75-1A1 1 0 012 14v-2a1 1 0 011-1zm14 0a1 1 0 011 1v2a1 1 0 01-.504.868l-1.75 1a1 1 0 11-.992-1.736L16 13.42V12a1 1 0 011-1zm-9.618 5.504a1 1 0 011.364-.372l.254.145V16a1 1 0 112 0v.277l.254-.145a1 1 0 11.992 1.736l-1.735.992a.995.995 0 01-1.022 0l-1.735-.992a1 1 0 01-.372-1.364z"
|
||||
@@ -224,10 +224,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoaderIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ServerLoader } from '#ui/utils/loaders'
|
||||
import { formatLoaderLabel, type ServerLoader } from '#ui/utils/loaders'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
loader: ServerLoader
|
||||
}>()
|
||||
|
||||
const normalizedLoader = computed(() => formatLoaderLabel(props.loader))
|
||||
</script>
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
Configuring server...
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap items-center gap-2">
|
||||
<div v-if="props.server?.loader" class="flex items-center gap-2 font-medium capitalize">
|
||||
<div v-if="props.server?.loader" class="flex items-center gap-2 font-medium">
|
||||
<LoaderIcon :loader="props.server.loader" class="flex shrink-0 [&&]:size-5" />
|
||||
{{ props.server.loader }} {{ props.server.mc_version }}
|
||||
{{ formatLoaderLabel(props.server.loader) }} {{ props.server.mc_version }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -81,7 +81,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { NuxtModrinthClient } from '@modrinth/api-client'
|
||||
import { LinkIcon, LoaderIcon, SettingsIcon, TimerIcon } from '@modrinth/assets'
|
||||
import { LinkIcon, SettingsIcon, TimerIcon } from '@modrinth/assets'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -91,6 +91,9 @@ import {
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '#ui/providers'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
|
||||
import LoaderIcon from '../icons/LoaderIcon.vue'
|
||||
|
||||
type ServerProjectSummary = {
|
||||
id: string
|
||||
|
||||
@@ -109,15 +109,15 @@ const filterOptions = computed(() => {
|
||||
platform: [],
|
||||
}
|
||||
|
||||
const platformSet = new Set()
|
||||
const gameVersionSet = new Set()
|
||||
const channelSet = new Set()
|
||||
const platformSet = new Set<Filter>()
|
||||
const gameVersionSet = new Set<Filter>()
|
||||
const channelSet = new Set<Filter>()
|
||||
|
||||
for (const version of props.versions) {
|
||||
for (const loader of version.loaders) {
|
||||
for (const loader of Array.isArray(version.loaders) ? version.loaders : []) {
|
||||
platformSet.add(loader)
|
||||
}
|
||||
for (const gameVersion of version.game_versions) {
|
||||
for (const gameVersion of Array.isArray(version.game_versions) ? version.game_versions : []) {
|
||||
gameVersionSet.add(gameVersion)
|
||||
}
|
||||
channelSet.add(version.version_type)
|
||||
|
||||
@@ -43,7 +43,14 @@ const buttonClass = computed(() => {
|
||||
const contentClass = computed(() => (isApp.value ? 'mt-2 mb-3' : 'mb-4 mx-3'))
|
||||
const innerPanelClass = computed(() => (isApp.value ? 'ml-2 mr-3' : 'p-1'))
|
||||
|
||||
function hasProvidedFilter(filterId: string): boolean {
|
||||
return (ctx.providedFilters?.value ?? []).some((filter) => filter.type === filterId)
|
||||
}
|
||||
|
||||
function getFilterOpenByDefault(filterId: string): boolean {
|
||||
if (hasProvidedFilter(filterId)) {
|
||||
return true
|
||||
}
|
||||
if (ctx.isServerType.value) {
|
||||
return ![
|
||||
'server_category_minecraft_server_meta',
|
||||
|
||||
+7
-3
@@ -8,12 +8,12 @@
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{ formatMessage(messages.admonitionBody, { count: props.count }) }}
|
||||
{{ formatMessage(messages.admonitionBody, { count: visibleCount }) }}
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
:backup-name="
|
||||
props.backupTip ? `Before bulk update (${props.backupTip})` : 'Before bulk update'
|
||||
visibleBackupTip ? `Before bulk update (${visibleBackupTip})` : 'Before bulk update'
|
||||
"
|
||||
:shift-click-hint-override="formatMessage(messages.shiftClickHint)"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
@@ -35,7 +35,7 @@
|
||||
@click="confirm"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.updateButton, { count: props.count }) }}
|
||||
{{ formatMessage(messages.updateButton, { count: visibleCount }) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -97,8 +97,12 @@ const emit = defineEmits<{
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const buttonsDisabled = ref(false)
|
||||
const visibleCount = ref(props.count)
|
||||
const visibleBackupTip = ref(props.backupTip)
|
||||
|
||||
function show() {
|
||||
visibleCount.value = props.count
|
||||
visibleBackupTip.value = props.backupTip
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -3,7 +3,7 @@
|
||||
ref="modal"
|
||||
:header="
|
||||
formatMessage(messages.header, {
|
||||
itemType: formatContentTypeSentence(formatMessage, props.itemType, props.count),
|
||||
itemType: formatContentTypeSentence(formatMessage, visibleItemType, visibleCount),
|
||||
})
|
||||
"
|
||||
:fade="props.variant === 'server' ? 'warning' : 'danger'"
|
||||
@@ -41,8 +41,8 @@
|
||||
<TrashIcon />
|
||||
{{
|
||||
formatMessage(messages.deleteButton, {
|
||||
count: props.count,
|
||||
itemType: formatContentTypeSentence(formatMessage, props.itemType, props.count),
|
||||
count: visibleCount,
|
||||
itemType: formatContentTypeSentence(formatMessage, visibleItemType, visibleCount),
|
||||
})
|
||||
}}
|
||||
</button>
|
||||
@@ -110,8 +110,12 @@ const emit = defineEmits<{
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const buttonsDisabled = ref(false)
|
||||
const visibleCount = ref(props.count)
|
||||
const visibleItemType = ref(props.itemType)
|
||||
|
||||
function show() {
|
||||
visibleCount.value = props.count
|
||||
visibleItemType.value = props.itemType
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
|
||||
+20
-10
@@ -98,14 +98,10 @@
|
||||
v-for="inst in filteredInstances"
|
||||
:key="inst.id"
|
||||
class="flex items-center justify-between px-6 py-1.5"
|
||||
:class="
|
||||
!inst.compatible ? 'opacity-40' : inst.installed ? 'opacity-60' : 'hover:bg-surface-3'
|
||||
"
|
||||
:class="inst.installed ? 'opacity-60' : 'hover:bg-surface-3'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
!inst.compatible ? 'This instance is not compatible with this project' : undefined
|
||||
"
|
||||
v-tooltip="!inst.compatible ? formatMessage(messages.incompatibleTooltip) : undefined"
|
||||
class="flex min-w-0 cursor-pointer items-center gap-2.5 overflow-hidden border-0 bg-transparent p-0 text-left"
|
||||
@click="emit('navigate', inst)"
|
||||
>
|
||||
@@ -120,8 +116,17 @@
|
||||
{{ formatMessage(messages.installedBadge) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="inst.compatible">
|
||||
<button :disabled="inst.installing" @click="emit('install', inst)">
|
||||
<ButtonStyled
|
||||
v-else
|
||||
:type="inst.compatible ? 'standard' : 'outlined'"
|
||||
:color="inst.compatible ? 'standard' : 'orange'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="!inst.compatible ? formatMessage(messages.incompatibleTooltip) : undefined"
|
||||
:disabled="inst.installing"
|
||||
@click="emit('install', inst)"
|
||||
>
|
||||
<TriangleAlertIcon v-if="!inst.compatible" />
|
||||
{{
|
||||
inst.installing
|
||||
? formatMessage(commonMessages.installingLabel)
|
||||
@@ -247,6 +252,7 @@ import {
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
SearchIcon,
|
||||
TriangleAlertIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
@@ -296,6 +302,11 @@ const messages = defineMessages({
|
||||
id: 'instances.content-install.install-button',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
incompatibleTooltip: {
|
||||
id: 'instances.content-install.incompatible-tooltip',
|
||||
defaultMessage:
|
||||
'This instance uses a different loader or game version than this project supports.',
|
||||
},
|
||||
selectIcon: {
|
||||
id: 'instances.content-install.select-icon',
|
||||
defaultMessage: 'Select icon',
|
||||
@@ -452,7 +463,7 @@ function removeIcon() {
|
||||
function resetState() {
|
||||
tab.value = props.defaultTab ?? 'existing'
|
||||
searchFilter.value = ''
|
||||
hideUninstallable.value = true
|
||||
hideUninstallable.value = false
|
||||
instanceName.value = `New instance (${props.instances.length + 1})`
|
||||
iconPath.value = null
|
||||
iconPreviewUrl.value = null
|
||||
@@ -471,7 +482,6 @@ function resetState() {
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
resetState()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
|
||||
+112
-32
@@ -3,20 +3,12 @@
|
||||
ref="modal"
|
||||
:max-width="'min(928px, calc(95vw - 10rem))'"
|
||||
:width="'min(928px, calc(95vw - 10rem))'"
|
||||
:on-hide="handleModalHide"
|
||||
no-padding
|
||||
>
|
||||
<template #title>
|
||||
<Avatar v-if="projectIconUrl" :src="projectIconUrl" size="3rem" :tint-by="projectName" />
|
||||
<span class="text-lg font-extrabold text-contrast">{{
|
||||
header ??
|
||||
formatMessage(
|
||||
isModpack
|
||||
? messages.switchModpackVersionHeader
|
||||
: switchMode
|
||||
? messages.switchVersionHeader
|
||||
: messages.updateVersionHeader,
|
||||
)
|
||||
}}</span>
|
||||
<span class="text-lg font-extrabold text-contrast">{{ header ?? defaultHeader }}</span>
|
||||
</template>
|
||||
<div
|
||||
class="flex h-[min(550px,calc(95vh-10rem))] border-solid border-transparent border-[1px] border-b-surface-4"
|
||||
@@ -99,7 +91,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isModpack"
|
||||
v-if="!isModpack && !incompatibilityWarningMode"
|
||||
class="absolute bottom-0 left-0 right-0 pointer-events-none flex flex-col items-center justify-end bg-gradient-to-b from-transparent to-bg-raised to-70% pb-3 h-24"
|
||||
>
|
||||
<div class="pointer-events-auto">
|
||||
@@ -204,7 +196,14 @@
|
||||
>
|
||||
<TriangleAlertIcon class="size-6 shrink-0" />
|
||||
<span>{{
|
||||
formatMessage(isApp ? messages.updateWarningApp : messages.updateWarningWeb)
|
||||
warning ??
|
||||
formatMessage(
|
||||
incompatibilityWarningMode
|
||||
? messages.incompatibilityWarning
|
||||
: isApp
|
||||
? messages.updateWarningApp
|
||||
: messages.updateWarningWeb,
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 shrink-0 ml-auto">
|
||||
@@ -214,26 +213,34 @@
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<ButtonStyled :color="incompatibilityWarningMode ? 'orange' : 'brand'">
|
||||
<button
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="
|
||||
props.actionDisabled || !selectedVersion || selectedVersion.id === currentVersionId
|
||||
actionLoading ||
|
||||
props.actionDisabled ||
|
||||
!selectedVersion ||
|
||||
(!incompatibilityWarningMode && selectedVersion.id === currentVersionId)
|
||||
"
|
||||
@click="handleUpdate"
|
||||
>
|
||||
<DownloadIcon />
|
||||
<SpinnerIcon v-if="actionLoading" class="size-5 animate-spin" />
|
||||
<DownloadIcon v-else />
|
||||
{{
|
||||
formatMessage(
|
||||
isDowngrade
|
||||
? messages.downgradeToVersion
|
||||
: switchMode
|
||||
? messages.switchToVersion
|
||||
: messages.updateToVersion,
|
||||
{
|
||||
version: selectedVersion?.version_number ?? '...',
|
||||
},
|
||||
)
|
||||
actionLoading
|
||||
? formatMessage(commonMessages.installingLabel)
|
||||
: incompatibilityWarningMode
|
||||
? formatMessage(messages.installAnywayButton)
|
||||
: formatMessage(
|
||||
isDowngrade
|
||||
? messages.downgradeToVersion
|
||||
: switchMode
|
||||
? messages.switchToVersion
|
||||
: messages.updateToVersion,
|
||||
{
|
||||
version: selectedVersion?.version_number ?? '...',
|
||||
},
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -270,7 +277,12 @@ import {
|
||||
TriangleAlertIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { capitalizeString, renderHighlightedString } from '@modrinth/utils'
|
||||
import {
|
||||
capitalizeString,
|
||||
formatVersionsForDisplay,
|
||||
type GameVersionTag,
|
||||
renderHighlightedString,
|
||||
} from '@modrinth/utils'
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
@@ -282,6 +294,7 @@ import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import VersionChannelIndicator from '#ui/components/version/VersionChannelIndicator.vue'
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectTags } from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import {
|
||||
versionChangesGameVersion,
|
||||
@@ -290,12 +303,17 @@ import {
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const debug = useDebugLogger('ContentUpdaterModal')
|
||||
const tags = injectTags(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
updateVersionHeader: {
|
||||
id: 'instances.updater-modal.header',
|
||||
defaultMessage: 'Update version',
|
||||
},
|
||||
incompatibilityWarningHeader: {
|
||||
id: 'instances.updater-modal.incompatibility-warning-header',
|
||||
defaultMessage: 'Choose version',
|
||||
},
|
||||
switchModpackVersionHeader: {
|
||||
id: 'instances.updater-modal.header-modpack',
|
||||
defaultMessage: 'Switch modpack version',
|
||||
@@ -333,6 +351,11 @@ const messages = defineMessages({
|
||||
id: 'instances.updater-modal.warning-web',
|
||||
defaultMessage: 'Updating can break your world. Review version changelogs and back up first.',
|
||||
},
|
||||
incompatibilityWarning: {
|
||||
id: 'instances.updater-modal.incompatibility-warning',
|
||||
defaultMessage:
|
||||
'This version is not marked as compatible with this instance. Dependencies will not be installed automatically.',
|
||||
},
|
||||
downgradeToVersion: {
|
||||
id: 'instances.updater-modal.downgrade-to',
|
||||
defaultMessage: 'Downgrade to {version}',
|
||||
@@ -378,6 +401,10 @@ const messages = defineMessages({
|
||||
id: 'instances.updater-modal.incompatible-update.proceed',
|
||||
defaultMessage: 'Update anyway',
|
||||
},
|
||||
installAnywayButton: {
|
||||
id: 'instances.updater-modal.install-anyway',
|
||||
defaultMessage: 'Install anyway',
|
||||
},
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -392,6 +419,9 @@ const props = withDefaults(
|
||||
projectIconUrl?: string
|
||||
projectName?: string
|
||||
header?: string
|
||||
mode?: 'version' | 'incompatibility-warning'
|
||||
warning?: string
|
||||
actionLoading?: boolean
|
||||
/** Whether versions are currently being loaded */
|
||||
loading?: boolean
|
||||
/** Whether changelog is being loaded for the selected version */
|
||||
@@ -404,6 +434,9 @@ const props = withDefaults(
|
||||
projectIconUrl: undefined,
|
||||
projectName: undefined,
|
||||
header: undefined,
|
||||
mode: 'version',
|
||||
warning: undefined,
|
||||
actionLoading: false,
|
||||
loading: false,
|
||||
loadingChangelog: false,
|
||||
actionDisabled: false,
|
||||
@@ -412,6 +445,20 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const isModpack = computed(() => props.projectType === 'modpack')
|
||||
const incompatibilityWarningMode = computed(() => props.mode === 'incompatibility-warning')
|
||||
const defaultHeader = computed(() => {
|
||||
if (incompatibilityWarningMode.value) {
|
||||
return formatMessage(messages.incompatibilityWarningHeader)
|
||||
}
|
||||
|
||||
return formatMessage(
|
||||
isModpack.value
|
||||
? messages.switchModpackVersionHeader
|
||||
: switchMode.value
|
||||
? messages.switchVersionHeader
|
||||
: messages.updateVersionHeader,
|
||||
)
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [version: Labrinth.Versions.v2.Version, event: MouseEvent]
|
||||
@@ -431,6 +478,7 @@ const pendingIncompatibleUpdate = ref<{
|
||||
version: Labrinth.Versions.v2.Version
|
||||
event: MouseEvent
|
||||
} | null>(null)
|
||||
const suppressCancelOnHide = ref(false)
|
||||
// Store the initial version ID to select when versions become available
|
||||
const pendingInitialVersionId = ref<string | undefined>(undefined)
|
||||
const pinnedInitialVersionId = ref<string | undefined>(undefined)
|
||||
@@ -509,12 +557,16 @@ const filteredVersions = computed(() => {
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
versions = versions.filter(
|
||||
(v) => v.name.toLowerCase().includes(query) || v.version_number.toLowerCase().includes(query),
|
||||
(v) =>
|
||||
v.name.toLowerCase().includes(query) ||
|
||||
v.version_number.toLowerCase().includes(query) ||
|
||||
(incompatibilityWarningMode.value &&
|
||||
[...v.loaders, ...v.game_versions].some((value) => value.toLowerCase().includes(query))),
|
||||
)
|
||||
}
|
||||
|
||||
const beforeFilterCount = versions.length
|
||||
if (!isModpack.value && hideIncompatibleState.value) {
|
||||
if (!incompatibilityWarningMode.value && !isModpack.value && hideIncompatibleState.value) {
|
||||
versions = versions.filter(
|
||||
(version) =>
|
||||
version.id === props.currentVersionId ||
|
||||
@@ -537,6 +589,7 @@ const filteredVersions = computed(() => {
|
||||
})
|
||||
|
||||
function shouldShowBadge(version: Labrinth.Versions.v2.Version): boolean {
|
||||
if (incompatibilityWarningMode.value) return false
|
||||
return version.id === props.currentVersionId || shouldShowIncompatibleBadge(version)
|
||||
}
|
||||
|
||||
@@ -596,8 +649,20 @@ function formatLongDate(dateString: string): string {
|
||||
|
||||
function formatLoaderGameVersion(version: Labrinth.Versions.v2.Version): string {
|
||||
const loader = capitalizeString(version.loaders[0] || '')
|
||||
const gameVersion = version.game_versions[0] || ''
|
||||
return `${loader} ${gameVersion}`
|
||||
const gameVersions = formatGameVersions(version)
|
||||
return [loader, gameVersions].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function formatGameVersions(version: Labrinth.Versions.v2.Version): string {
|
||||
if (!incompatibilityWarningMode.value) {
|
||||
return version.game_versions[0] || ''
|
||||
}
|
||||
|
||||
const gameVersions = tags?.gameVersions.value?.length
|
||||
? formatVersionsForDisplay(version.game_versions, tags.gameVersions.value as GameVersionTag[])
|
||||
: version.game_versions
|
||||
|
||||
return gameVersions.join(', ')
|
||||
}
|
||||
|
||||
let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
|
||||
@@ -623,8 +688,13 @@ function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
}
|
||||
|
||||
function handleUpdate(event: MouseEvent) {
|
||||
if (props.actionDisabled) return
|
||||
if (props.actionLoading || props.actionDisabled) return
|
||||
if (selectedVersion.value) {
|
||||
if (incompatibilityWarningMode.value) {
|
||||
emitUpdate(selectedVersion.value, event, { hide: false })
|
||||
return
|
||||
}
|
||||
|
||||
const changesGameVersion = versionChangesGameVersion(
|
||||
selectedVersion.value,
|
||||
props.currentGameVersion,
|
||||
@@ -689,9 +759,18 @@ function handleCancel() {
|
||||
hide()
|
||||
}
|
||||
|
||||
function handleModalHide() {
|
||||
if (suppressCancelOnHide.value) {
|
||||
suppressCancelOnHide.value = false
|
||||
return
|
||||
}
|
||||
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function show(initialVersionId?: string, options?: { switchMode?: boolean }) {
|
||||
searchQuery.value = ''
|
||||
hideIncompatibleState.value = !isModpack.value
|
||||
hideIncompatibleState.value = incompatibilityWarningMode.value ? false : !isModpack.value
|
||||
pendingIncompatibleUpdate.value = null
|
||||
pinnedInitialVersionId.value = initialVersionId
|
||||
switchMode.value = options?.switchMode ?? false
|
||||
@@ -735,6 +814,7 @@ function show(initialVersionId?: string, options?: { switchMode?: boolean }) {
|
||||
}
|
||||
|
||||
function hide() {
|
||||
suppressCancelOnHide.value = true
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
:roles="roleOptions"
|
||||
:can-manage-users="canManageUsers"
|
||||
:permission-denied-message="permissionDeniedMessage"
|
||||
:user-profile-link="props.userProfileLink"
|
||||
@update-role="updateMemberRole"
|
||||
@resend-invite="resendInvite"
|
||||
@cancel-invite="requestCancelInvite"
|
||||
@@ -127,6 +128,7 @@ import {
|
||||
type ServerAccessMember,
|
||||
type ServerAccessRole,
|
||||
type ServerAccessRoleOption,
|
||||
type ServerAccessUserProfileLink,
|
||||
} from '#ui/components/servers/access'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { useServerPermissions } from '#ui/composables/server-permissions'
|
||||
@@ -144,6 +146,7 @@ type RoleFilter = ServerAccessRole | 'all'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showAuditLogInstances?: boolean
|
||||
userProfileLink?: (username: string) => ServerAccessUserProfileLink
|
||||
}>(),
|
||||
{
|
||||
showAuditLogInstances: false,
|
||||
|
||||
@@ -260,6 +260,7 @@ export function useAccessAuditLog({
|
||||
key: group.key,
|
||||
label: formatMessage(group.label),
|
||||
icon: group.icon,
|
||||
dividerBefore: true,
|
||||
},
|
||||
...group.actions.map((action) => ({
|
||||
value: action,
|
||||
|
||||
@@ -103,19 +103,19 @@ export const accessMessages = defineMessages({
|
||||
},
|
||||
inviteCancelledTitle: {
|
||||
id: 'servers.access-page.notification.invite-cancelled.title',
|
||||
defaultMessage: 'Invite cancelled',
|
||||
defaultMessage: 'Invite revoked',
|
||||
},
|
||||
inviteCancelledText: {
|
||||
id: 'servers.access-page.notification.invite-cancelled.text',
|
||||
defaultMessage: 'Cancelled the invite for {target}.',
|
||||
defaultMessage: 'Revoked the invite for {target}.',
|
||||
},
|
||||
memberRemovedTitle: {
|
||||
id: 'servers.access-page.notification.member-removed.title',
|
||||
defaultMessage: 'Access removed',
|
||||
defaultMessage: 'Access revoked',
|
||||
},
|
||||
memberRemovedText: {
|
||||
id: 'servers.access-page.notification.member-removed.text',
|
||||
defaultMessage: 'Removed {target} from this server.',
|
||||
defaultMessage: 'Revoked access for {target}.',
|
||||
},
|
||||
loadFailedTitle: {
|
||||
id: 'servers.access-page.notification.load-failed.title',
|
||||
@@ -178,7 +178,7 @@ export const actionLogActionMessages = defineMessages({
|
||||
},
|
||||
user_removed: {
|
||||
id: 'servers.access-page.activity-log-filter.action.user-removed',
|
||||
defaultMessage: 'Removed user',
|
||||
defaultMessage: 'Revoked access',
|
||||
},
|
||||
addon_added: {
|
||||
id: 'servers.access-page.activity-log-filter.action.addon-added',
|
||||
|
||||
@@ -156,6 +156,7 @@
|
||||
<BackupItem
|
||||
class="my-1.5 min-w-0 flex-1"
|
||||
:backup="backup"
|
||||
:creator="backupCreator(backup)"
|
||||
:selected="selectedIds.has(backup.id)"
|
||||
:highlighted="highlightedBackupId === backup.id"
|
||||
:restore-disabled="backupRestoreDisabled"
|
||||
@@ -461,6 +462,15 @@ type BackupGroup = {
|
||||
backups: Archon.BackupsQueue.v1.BackupQueueBackup[]
|
||||
}
|
||||
|
||||
function backupCreator(
|
||||
backup: Archon.BackupsQueue.v1.BackupQueueBackup,
|
||||
): Archon.BackupsQueue.v1.UserInfo | null {
|
||||
return (
|
||||
backup.history.find((operation) => operation.operation_type === 'create' && operation.user_info)
|
||||
?.user_info ?? null
|
||||
)
|
||||
}
|
||||
|
||||
const groupedBackups = computed((): BackupGroup[] => {
|
||||
if (!filteredBackups.value.length) return []
|
||||
|
||||
|
||||
@@ -1646,6 +1646,9 @@
|
||||
"instances.content-install.header": {
|
||||
"defaultMessage": "Install project"
|
||||
},
|
||||
"instances.content-install.incompatible-tooltip": {
|
||||
"defaultMessage": "This instance uses a different loader or game version than this project supports."
|
||||
},
|
||||
"instances.content-install.install-button": {
|
||||
"defaultMessage": "Install"
|
||||
},
|
||||
@@ -1718,6 +1721,12 @@
|
||||
"instances.updater-modal.hide-incompatible": {
|
||||
"defaultMessage": "Hide incompatible"
|
||||
},
|
||||
"instances.updater-modal.incompatibility-warning": {
|
||||
"defaultMessage": "This version is not marked as compatible with this instance. Dependencies will not be installed automatically."
|
||||
},
|
||||
"instances.updater-modal.incompatibility-warning-header": {
|
||||
"defaultMessage": "Choose version"
|
||||
},
|
||||
"instances.updater-modal.incompatible-update.description": {
|
||||
"defaultMessage": "{version} is not marked as compatible with this installation. It may fail to launch or behave unexpectedly."
|
||||
},
|
||||
@@ -1727,6 +1736,9 @@
|
||||
"instances.updater-modal.incompatible-update.proceed": {
|
||||
"defaultMessage": "Update anyway"
|
||||
},
|
||||
"instances.updater-modal.install-anyway": {
|
||||
"defaultMessage": "Install anyway"
|
||||
},
|
||||
"instances.updater-modal.loading-changelog": {
|
||||
"defaultMessage": "Loading changelog..."
|
||||
},
|
||||
@@ -3414,7 +3426,7 @@
|
||||
"defaultMessage": "Changed user permissions"
|
||||
},
|
||||
"servers.access-page.activity-log-filter.action.user-removed": {
|
||||
"defaultMessage": "Removed user"
|
||||
"defaultMessage": "Revoked access"
|
||||
},
|
||||
"servers.access-page.activity-log-filter.add": {
|
||||
"defaultMessage": "Add filter"
|
||||
@@ -3450,10 +3462,10 @@
|
||||
"defaultMessage": "Friend request could not be sent"
|
||||
},
|
||||
"servers.access-page.notification.invite-cancelled.text": {
|
||||
"defaultMessage": "Cancelled the invite for {target}."
|
||||
"defaultMessage": "Revoked the invite for {target}."
|
||||
},
|
||||
"servers.access-page.notification.invite-cancelled.title": {
|
||||
"defaultMessage": "Invite cancelled"
|
||||
"defaultMessage": "Invite revoked"
|
||||
},
|
||||
"servers.access-page.notification.invite-failed.title": {
|
||||
"defaultMessage": "Invite could not be sent"
|
||||
@@ -3477,10 +3489,10 @@
|
||||
"defaultMessage": "Access could not be loaded"
|
||||
},
|
||||
"servers.access-page.notification.member-removed.text": {
|
||||
"defaultMessage": "Removed {target} from this server."
|
||||
"defaultMessage": "Revoked access for {target}."
|
||||
},
|
||||
"servers.access-page.notification.member-removed.title": {
|
||||
"defaultMessage": "Access removed"
|
||||
"defaultMessage": "Access revoked"
|
||||
},
|
||||
"servers.access-page.notification.remove-failed.title": {
|
||||
"defaultMessage": "Access could not be removed"
|
||||
@@ -3525,10 +3537,10 @@
|
||||
"defaultMessage": "Limited"
|
||||
},
|
||||
"servers.access-table.action.cancel-invite": {
|
||||
"defaultMessage": "Cancel invite"
|
||||
"defaultMessage": "Revoke invite"
|
||||
},
|
||||
"servers.access-table.action.remove-user": {
|
||||
"defaultMessage": "Remove user"
|
||||
"defaultMessage": "Revoke access"
|
||||
},
|
||||
"servers.access-table.action.resend-invite": {
|
||||
"defaultMessage": "Resend invite"
|
||||
@@ -3807,7 +3819,7 @@
|
||||
"defaultMessage": "Changed permissions for <target-user></target-user> to <permission-label>{permissions}</permission-label>"
|
||||
},
|
||||
"servers.audit-log.event.user-removed": {
|
||||
"defaultMessage": "Removed <target-user></target-user>"
|
||||
"defaultMessage": "Revoked access for <target-user></target-user>"
|
||||
},
|
||||
"servers.audit-log.scope.server": {
|
||||
"defaultMessage": "Server"
|
||||
@@ -3935,6 +3947,12 @@
|
||||
"servers.backups.item.backup-schedule": {
|
||||
"defaultMessage": "Backup schedule"
|
||||
},
|
||||
"servers.backups.item.creator-avatar-alt": {
|
||||
"defaultMessage": "{username}'s avatar"
|
||||
},
|
||||
"servers.backups.item.creator.support": {
|
||||
"defaultMessage": "Support"
|
||||
},
|
||||
"servers.backups.item.manual-backup": {
|
||||
"defaultMessage": "Manual backup"
|
||||
},
|
||||
@@ -4440,7 +4458,7 @@
|
||||
"defaultMessage": "Added {time}"
|
||||
},
|
||||
"servers.remove-access-modal.cancel-button": {
|
||||
"defaultMessage": "Cancel invite"
|
||||
"defaultMessage": "Revoke invite"
|
||||
},
|
||||
"servers.remove-access-modal.cancel-effect-access": {
|
||||
"defaultMessage": "They will not be added to this server"
|
||||
@@ -4449,13 +4467,13 @@
|
||||
"defaultMessage": "You can send them another invite later"
|
||||
},
|
||||
"servers.remove-access-modal.cancel-header": {
|
||||
"defaultMessage": "Cancel invite"
|
||||
"defaultMessage": "Revoke invite"
|
||||
},
|
||||
"servers.remove-access-modal.cancel-warning-body": {
|
||||
"defaultMessage": "If you cancel this invite, {username} will need a new invitation before they can join this server."
|
||||
"defaultMessage": "If you revoke this invite, {username} will need a new invitation before they can join this server."
|
||||
},
|
||||
"servers.remove-access-modal.header": {
|
||||
"defaultMessage": "Remove user"
|
||||
"defaultMessage": "Revoke access"
|
||||
},
|
||||
"servers.remove-access-modal.invited-label": {
|
||||
"defaultMessage": "Invited {time}"
|
||||
@@ -4464,7 +4482,7 @@
|
||||
"defaultMessage": "Pending invite"
|
||||
},
|
||||
"servers.remove-access-modal.remove-button": {
|
||||
"defaultMessage": "Remove user"
|
||||
"defaultMessage": "Revoke access"
|
||||
},
|
||||
"servers.remove-access-modal.remove-effect-access": {
|
||||
"defaultMessage": "They will immediately lose access to the server panel and will no longer be able to edit content"
|
||||
@@ -4479,7 +4497,7 @@
|
||||
"defaultMessage": "{username}'s avatar"
|
||||
},
|
||||
"servers.remove-access-modal.warning-body": {
|
||||
"defaultMessage": "If you remove a user from your server, you'll need to re-invite them to restore access."
|
||||
"defaultMessage": "If you revoke a user's access to your server, you'll need to re-invite them to restore access."
|
||||
},
|
||||
"servers.remove-access-modal.what-happens-label": {
|
||||
"defaultMessage": "What happens?"
|
||||
|
||||
@@ -17,6 +17,18 @@ const meta = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const creator: Archon.BackupsQueue.v1.UserInfo = {
|
||||
id: 'traben',
|
||||
username: 'Traben',
|
||||
avatar_url: 'https://cdn.modrinth.com/user/6Qo4A5QT/9d81be1a9fb1afd163b7f2f05a791955e7693c90.png',
|
||||
}
|
||||
|
||||
const supportCreator: Archon.BackupsQueue.v1.UserInfo = {
|
||||
id: 'support',
|
||||
username: 'Support',
|
||||
avatar_url: null,
|
||||
}
|
||||
|
||||
function makeBackup(
|
||||
overrides: Partial<Archon.BackupsQueue.v1.BackupQueueBackup> = {},
|
||||
): Archon.BackupsQueue.v1.BackupQueueBackup {
|
||||
@@ -36,6 +48,7 @@ export const Default: Story = {
|
||||
name: 'Default (manual)',
|
||||
args: {
|
||||
backup: makeBackup({ name: 'Base finished!!' }),
|
||||
creator,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,6 +59,14 @@ export const Automated: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const SupportCreated: Story = {
|
||||
name: 'Support created',
|
||||
args: {
|
||||
backup: makeBackup({ name: 'Support recovery point' }),
|
||||
creator: supportCreator,
|
||||
},
|
||||
}
|
||||
|
||||
export const Preview: Story = {
|
||||
name: 'Preview (compact, used in delete modal)',
|
||||
args: {
|
||||
@@ -85,12 +106,16 @@ export const CommonStates: Story = {
|
||||
|
||||
return {
|
||||
manual: makeBackup({ name: 'Base finished!!' }),
|
||||
support: makeBackup({ id: 'backup-support', name: 'Support recovery point' }),
|
||||
automated: makeBackup({ automated: true, name: 'Backup #2' }),
|
||||
creator,
|
||||
supportCreator,
|
||||
}
|
||||
},
|
||||
template: /* html */ `
|
||||
<div style="display: flex; flex-direction: column; gap: 0.75rem; max-width: 900px;">
|
||||
<BackupItem :backup="manual" />
|
||||
<BackupItem :backup="manual" :creator="creator" />
|
||||
<BackupItem :backup="support" :creator="supportCreator" />
|
||||
<BackupItem :backup="automated" />
|
||||
<BackupItem :backup="manual" preview />
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ export const loaderDisplayNames: Record<string, string> = {
|
||||
forge: 'Forge',
|
||||
quilt: 'Quilt',
|
||||
paper: 'Paper',
|
||||
spigot: 'Spigot',
|
||||
purpur: 'Purpur',
|
||||
bukkit: 'Bukkit',
|
||||
vanilla: 'Vanilla',
|
||||
|
||||
@@ -92,37 +92,34 @@ function parseChangelogEntries(src: string): ChangelogEntry[] {
|
||||
return entries
|
||||
}
|
||||
|
||||
function findAppAndHosting(entries: ChangelogEntry[], version: string): { appBody: string; hostingEntries: ChangelogEntry[] } {
|
||||
function getLatestUncoveredHosting(entries: ChangelogEntry[], lastReleaseDate: string | undefined): ChangelogEntry[] {
|
||||
const latestHosting = entries.find((e) => e.product === 'hosting')
|
||||
if (!latestHosting || !lastReleaseDate) {
|
||||
return latestHosting ? [latestHosting] : []
|
||||
}
|
||||
|
||||
const latestHostingTime = new Date(latestHosting.date).getTime()
|
||||
const lastReleaseTime = new Date(lastReleaseDate).getTime()
|
||||
if (Number.isNaN(latestHostingTime) || Number.isNaN(lastReleaseTime)) {
|
||||
return [latestHosting]
|
||||
}
|
||||
|
||||
return latestHostingTime > lastReleaseTime ? [latestHosting] : []
|
||||
}
|
||||
|
||||
function findAppAndHosting(
|
||||
entries: ChangelogEntry[],
|
||||
version: string,
|
||||
lastReleaseDate: string | undefined,
|
||||
): { appBody: string; hostingEntries: ChangelogEntry[] } | undefined {
|
||||
const currentIdx = entries.findIndex((e) => e.product === 'app' && e.version === version)
|
||||
if (currentIdx === -1) {
|
||||
throw new Error(`No app changelog entry found for version ${version}`)
|
||||
}
|
||||
|
||||
let newerAppIdx = -1
|
||||
for (let i = currentIdx - 1; i >= 0; i--) {
|
||||
if (entries[i].product === 'app') {
|
||||
newerAppIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
let previousAppIdx = entries.length
|
||||
for (let i = currentIdx + 1; i < entries.length; i++) {
|
||||
if (entries[i].product === 'app') {
|
||||
previousAppIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const hostingEntries: ChangelogEntry[] = []
|
||||
for (let i = newerAppIdx + 1; i < previousAppIdx; i++) {
|
||||
if (entries[i].product === 'hosting') {
|
||||
hostingEntries.push(entries[i])
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
appBody: entries[currentIdx].body,
|
||||
hostingEntries,
|
||||
hostingEntries: getLatestUncoveredHosting(entries, lastReleaseDate),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,9 +216,16 @@ function mergeAppAndHosting(appBody: string, hostingEntries: ChangelogEntry[]):
|
||||
function main() {
|
||||
const { dryRun, version, outFile } = parseArgs(process.argv.slice(2))
|
||||
const changelogPath = join(REPO_ROOT, 'packages/blog/changelog.ts')
|
||||
const lastReleaseDate = process.env.LAST_GITHUB_RELEASE_PUBLISHED_AT || undefined
|
||||
const src = fs.readFileSync(changelogPath, 'utf8')
|
||||
const entries = parseChangelogEntries(src)
|
||||
const { appBody, hostingEntries } = findAppAndHosting(entries, version)
|
||||
const entry = findAppAndHosting(entries, version, lastReleaseDate)
|
||||
if (!entry) {
|
||||
fs.writeFileSync(outFile, '', 'utf8')
|
||||
return
|
||||
}
|
||||
|
||||
const { appBody, hostingEntries } = entry
|
||||
const output = mergeAppAndHosting(appBody, hostingEntries)
|
||||
|
||||
fs.writeFileSync(outFile, output, 'utf8')
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Surface System
|
||||
|
||||
Use `surface-*` variables to describe UI elevation and separation. The scale is ordered from the page base up through stronger raised surfaces and strokes.
|
||||
|
||||
## Layers
|
||||
|
||||
| Token | Use |
|
||||
| ----------- | ------------------------------------------------------------------- |
|
||||
| `surface-1` | Page background. |
|
||||
| `surface-2` | Default raised surfaces, table rows, and standard card backgrounds. |
|
||||
| `surface-3` | Header bands, inputs, dropdown surfaces, and card hover states. |
|
||||
| `surface-4` | Standard strokes and outlines, including table outlines. |
|
||||
| `surface-5` | Strong strokes for surfaces that need extra separation. |
|
||||
|
||||
## Strokes
|
||||
|
||||
Use `surface-4` for normal outlines and dividers. Tables should use `surface-4` for their outer border and row separators.
|
||||
|
||||
Reserve `surface-5` for stronger outlines, such as modal frames, high-emphasis separators, or hover states on elements that already sit on `surface-4`.
|
||||
|
||||
## Backgrounds
|
||||
|
||||
Use `surface-1` for page backgrounds and `surface-2` for ordinary raised content. Use `surface-3` for header strips, inputs, and temporary elevation such as hover states. Use `surface-4` sparingly as a stronger raised background, usually for controls or badges that need to sit above nearby content.
|
||||
|
||||
Avoid using legacy aliased background variables for new UI. Prefer explicit `bg-surface-*` and `border-surface-*` utilities so the layer intent is visible in the component.
|
||||
Reference in New Issue
Block a user