mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58cbdaccc5 | ||
|
|
991d5e2e5b | ||
|
|
333bf8d77f | ||
|
|
f49951084e | ||
|
|
047b8c3bf7 | ||
|
|
5c1ffd9ff2 | ||
|
|
670b3c17b6 | ||
|
|
ec7c538888 | ||
|
|
b88341e3a8 | ||
|
|
2048d8008a | ||
|
|
527f2f800b | ||
|
|
c7d3229fd3 | ||
|
|
ec49a3b051 | ||
|
|
cf1d948030 | ||
|
|
fe8fa4b6f7 | ||
|
|
2c62cf1d12 | ||
|
|
84b91f32f8 | ||
|
|
64edf2ddeb | ||
|
|
2f248027d6 | ||
|
|
f96520638b | ||
|
|
9e5d29ced6 | ||
|
|
3e15d0b287 | ||
|
|
bcce7e28fd | ||
|
|
3889d0f5ec | ||
|
|
6f44c5b039 | ||
|
|
ea967845d9 | ||
|
|
a6967cf9cb | ||
|
|
5bf92863b0 | ||
|
|
71b6ecc10c | ||
|
|
ed28bc7551 | ||
|
|
3e4197db7c | ||
|
|
e12230ff59 | ||
|
|
aeb9f5a075 | ||
|
|
8b17441f40 | ||
|
|
f9d47e8edc | ||
|
|
a58bc3dc21 | ||
|
|
1e46444fb0 | ||
|
|
1511e55597 | ||
|
|
5727e156ed | ||
|
|
657186398d | ||
|
|
3ab2273782 | ||
|
|
893ec00fc6 | ||
|
|
aa7dd1d210 | ||
|
|
f8733b0488 | ||
|
|
d077d44540 | ||
|
|
4e1a61d8b6 | ||
|
|
71dee4de40 | ||
|
|
f74fad0cae | ||
|
|
07e81ac036 | ||
|
|
6e7835fb35 | ||
|
|
2f95c4c441 | ||
|
|
451b2d0e44 | ||
|
|
215643c846 | ||
|
|
d8b1415f9c | ||
|
|
3eeb549d20 | ||
|
|
c3fe7b4232 | ||
|
|
079a10bba9 | ||
|
|
3c3d5702ba | ||
|
|
a34576a2c3 | ||
|
|
d8e4915a31 | ||
|
|
ed723fa186 | ||
|
|
d6c8d4475b | ||
|
|
302b60d89c | ||
|
|
f106dc580f | ||
|
|
244c263e40 | ||
|
|
48bb44155d | ||
|
|
3f2e76ae7e | ||
|
|
6479eca0e9 | ||
|
|
8767bc9184 | ||
|
|
d1185414b6 | ||
|
|
510439acbf | ||
|
|
c564495e11 | ||
|
|
8dd1490c8a | ||
|
|
b72bc18a6b | ||
|
|
cee942dcef | ||
|
|
02a7774722 | ||
|
|
e9eb98f97e | ||
|
|
e5bbd9d409 | ||
|
|
83dddfd512 | ||
|
|
0ffdabb2a3 | ||
|
|
d87f93fdd5 | ||
|
|
f6fa486dea | ||
|
|
78d978b22e | ||
|
|
71559d62c8 |
@@ -9,8 +9,6 @@ body:
|
||||
options:
|
||||
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate feature requests
|
||||
required: true
|
||||
- label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com)
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: projects
|
||||
attributes:
|
||||
|
||||
@@ -56,6 +56,17 @@ jobs:
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install script dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Configure Git author
|
||||
id: git-author
|
||||
uses: MarcoIeni/git-config@59144859caf016f8b817a2ac9b051578729173c4 # v0.1.2
|
||||
@@ -95,6 +106,9 @@ jobs:
|
||||
shell: bash
|
||||
run: sudo chown -R $USER:$USER .
|
||||
|
||||
- name: Prune stale ICU translations
|
||||
run: pnpm scripts i18n-icu-contract prune-local
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
|
||||
@@ -4,11 +4,14 @@ on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- '.github/workflows/i18n.push.yml'
|
||||
- '.github/workflows/i18n-push.yml'
|
||||
- 'apps/*/src/locales/en-US/**'
|
||||
- 'apps/*/locales/en-US/**'
|
||||
- 'packages/*/src/locales/en-US/**'
|
||||
- 'packages/*/locales/en-US/**'
|
||||
- 'scripts/i18n-icu-contract.ts'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'crowdin.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -56,6 +59,18 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install script dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Query branch name
|
||||
id: branch-name
|
||||
@@ -79,3 +94,9 @@ jobs:
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Clear stale ICU translations in Crowdin
|
||||
run: pnpm scripts i18n-icu-contract clear-crowdin-changed --base-ref HEAD^ --crowdin-branch "[${{ github.repository_owner }}.${{ github.event.repository.name }}] ${{ steps.branch-name.outputs.safe_branch_name }}"
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
with:
|
||||
context: ./apps/labrinth/docker-stage
|
||||
file: ./apps/labrinth/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: true
|
||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||
labels: ${{ steps.docker-meta.outputs.labels }}
|
||||
annotations: ${{ steps.docker-meta.outputs.annotations }}
|
||||
|
||||
@@ -204,4 +204,5 @@ jobs:
|
||||
- name: Verify intl:extract has been run
|
||||
run: |
|
||||
pnpm turbo run intl:extract --force
|
||||
git diff --exit-code --color */*/src/locales/en-US/index.json
|
||||
pnpm scripts i18n-icu-contract prune-local --check
|
||||
git diff --exit-code --color */*/src/locales/*/index.json
|
||||
|
||||
Generated
+4
-24
@@ -3059,29 +3059,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elasticsearch"
|
||||
version = "9.1.0-alpha.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12bb303aa6e1d28c0c86b6fbfe484fd0fd3f512629aeed1ac4f6b85f81d9834a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"dyn-clone",
|
||||
"flate2",
|
||||
"lazy_static",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"reqwest 0.12.24",
|
||||
"rustc_version",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"tokio",
|
||||
"url",
|
||||
"void",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
@@ -5275,7 +5252,6 @@ dependencies = [
|
||||
"dotenv-build",
|
||||
"dotenvy",
|
||||
"either",
|
||||
"elasticsearch",
|
||||
"eyre",
|
||||
"futures",
|
||||
"futures-util",
|
||||
@@ -10589,6 +10565,8 @@ version = "1.0.0-local"
|
||||
dependencies = [
|
||||
"async_zip",
|
||||
"chrono",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"daedalus",
|
||||
"dashmap",
|
||||
"either",
|
||||
@@ -10596,6 +10574,7 @@ dependencies = [
|
||||
"hyper 1.7.0",
|
||||
"hyper-util",
|
||||
"native-dialog",
|
||||
"objc2-app-kit",
|
||||
"paste",
|
||||
"path-util",
|
||||
"serde",
|
||||
@@ -10621,6 +10600,7 @@ dependencies = [
|
||||
"urlencoding",
|
||||
"uuid 1.18.1",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
|
||||
+3
-1
@@ -64,6 +64,8 @@ clickhouse = "0.14.0"
|
||||
color-eyre = "0.6.5"
|
||||
color-thief = "0.2.2"
|
||||
const_format = "0.2.34"
|
||||
core-foundation = "0.10.1"
|
||||
core-graphics = "0.24.0"
|
||||
daedalus = { path = "packages/daedalus" }
|
||||
darling = { version = "0.23" }
|
||||
dashmap = "6.1.0"
|
||||
@@ -77,7 +79,6 @@ dotenv-build = "0.1.1"
|
||||
dotenvy = "0.15.7"
|
||||
dunce = "1.0.5"
|
||||
either = "1.15.0"
|
||||
elasticsearch = "9.1.0-alpha.1"
|
||||
encoding_rs = "0.8.35"
|
||||
enumset = "1.1.10"
|
||||
eyre = "0.6.12"
|
||||
@@ -124,6 +125,7 @@ murmur2 = "0.1.0"
|
||||
native-dialog = "0.9.2"
|
||||
notify = { version = "8.2.0", default-features = false }
|
||||
notify-debouncer-mini = { version = "0.7.0", default-features = false }
|
||||
objc2-app-kit = { version = "0.3.2", default-features = false }
|
||||
p256 = "0.13.2"
|
||||
parking_lot = "0.12.5"
|
||||
paste = "1.0.15"
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/app-frontend",
|
||||
"test": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@intercom/messenger-js-sdk": "^0.0.14",
|
||||
"@modrinth/api-client": "workspace:^",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import { Intercom, shutdown as shutdownIntercom } from '@intercom/messenger-js-sdk'
|
||||
import {
|
||||
AuthFeature,
|
||||
ModrinthApiError,
|
||||
@@ -53,6 +52,7 @@ import {
|
||||
providePopupNotificationManager,
|
||||
useDebugLogger,
|
||||
useFormatBytes,
|
||||
useHostingIntercom,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
@@ -88,7 +88,6 @@ import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
import WindowControls from '@/components/ui/WindowControls.vue'
|
||||
import { useIntercomPositioning } from '@/composables/intercom-positioning'
|
||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||
import { config } from '@/config'
|
||||
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
|
||||
@@ -132,15 +131,36 @@ import { AppPopupNotificationManager } from './providers/app-popup-notifications
|
||||
const themeStore = useTheming()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const intercomBubblePositioning = useIntercomPositioning({ route, themeStore })
|
||||
const {
|
||||
sidebarToggled,
|
||||
forceSidebar,
|
||||
sidebarVisible,
|
||||
intercomBubblePosition,
|
||||
updateIntercomBubbleStyles,
|
||||
clearIntercomBubbleStyles,
|
||||
} = intercomBubblePositioning
|
||||
const APP_LEFT_NAV_WIDTH = '4rem'
|
||||
const APP_SIDEBAR_WIDTH = 300
|
||||
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
|
||||
const credentials = ref()
|
||||
const sidebarToggled = ref(true)
|
||||
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
|
||||
sidebarToggled.value = !themeStore.toggleSidebar
|
||||
})
|
||||
const forceSidebar = computed(
|
||||
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
|
||||
)
|
||||
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
|
||||
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
|
||||
const hostingIntercomIdentityKey = computed(() => {
|
||||
const rawServerId = route.params.id
|
||||
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
|
||||
const userId = credentials.value?.user_id ?? credentials.value?.user?.id ?? 'anonymous'
|
||||
return `${userId}:${serverId ?? 'hosting'}`
|
||||
})
|
||||
const hostingIntercom = useHostingIntercom({
|
||||
enabled: computed(() => hostingRouteActive.value && !!credentials.value?.session),
|
||||
appId: 'ykeritl9',
|
||||
fetchToken: fetchIntercomToken,
|
||||
identityKey: hostingIntercomIdentityKey,
|
||||
horizontalPadding: computed(() =>
|
||||
sidebarVisible.value
|
||||
? APP_SIDEBAR_WIDTH + INTERCOM_BUBBLE_DEFAULT_PADDING
|
||||
: INTERCOM_BUBBLE_DEFAULT_PADDING,
|
||||
),
|
||||
})
|
||||
|
||||
const notificationManager = new AppNotificationManager()
|
||||
provideNotificationManager(notificationManager)
|
||||
@@ -175,7 +195,11 @@ provideModrinthClient(tauriApiClient)
|
||||
providePageContext({
|
||||
hierarchicalSidebarAvailable: ref(true),
|
||||
showAds: ref(false),
|
||||
...intercomBubblePositioning.pageContext,
|
||||
floatingActionBarOffsets: {
|
||||
left: ref(APP_LEFT_NAV_WIDTH),
|
||||
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
|
||||
},
|
||||
intercomBubble: hostingIntercom.intercomBubble,
|
||||
featureFlags: {
|
||||
serverRamAsBytesAlwaysOn: computed(() =>
|
||||
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
|
||||
@@ -259,8 +283,7 @@ onMounted(async () => {
|
||||
onUnmounted(async () => {
|
||||
document.querySelector('body').removeEventListener('click', handleClick)
|
||||
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
|
||||
shutdownHostingIntercom()
|
||||
clearIntercomBubbleStyles()
|
||||
unsubscribeSidebarToggle()
|
||||
|
||||
await unlistenUpdateDownload?.()
|
||||
})
|
||||
@@ -307,6 +330,7 @@ async function setupApp() {
|
||||
locale,
|
||||
telemetry,
|
||||
collapsed_navigation,
|
||||
hide_nametag_skins_page,
|
||||
advanced_rendering,
|
||||
onboarded,
|
||||
default_page,
|
||||
@@ -337,6 +361,7 @@ async function setupApp() {
|
||||
themeStore.setThemeState(theme)
|
||||
themeStore.collapsedNavigation = collapsed_navigation
|
||||
themeStore.advancedRendering = advanced_rendering
|
||||
themeStore.hideNametagSkinsPage = hide_nametag_skins_page
|
||||
themeStore.toggleSidebar = toggle_sidebar
|
||||
themeStore.devMode = developer_mode
|
||||
themeStore.featureFlags = feature_flags
|
||||
@@ -593,8 +618,6 @@ const incompatibilityWarningModal = ref()
|
||||
const installToPlayModal = ref()
|
||||
const updateToPlayModal = ref()
|
||||
|
||||
const credentials = ref()
|
||||
|
||||
const modrinthLoginFlowWaitModal = ref()
|
||||
|
||||
setupAuthProvider(credentials, async (_redirectPath) => {
|
||||
@@ -664,10 +687,6 @@ const hasPlus = computed(
|
||||
const showAd = computed(
|
||||
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
|
||||
)
|
||||
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
|
||||
|
||||
let intercomBooting = false
|
||||
let intercomBooted = false
|
||||
|
||||
async function fetchIntercomToken() {
|
||||
const creds = await getCreds()
|
||||
@@ -676,8 +695,10 @@ async function fetchIntercomToken() {
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (route.path.startsWith('/hosting/manage/') && typeof route.params.id === 'string') {
|
||||
params.set('server_id', route.params.id)
|
||||
const rawServerId = route.params.id
|
||||
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
|
||||
if (route.path.startsWith('/hosting/manage/') && typeof serverId === 'string') {
|
||||
params.set('server_id', serverId)
|
||||
}
|
||||
const query = params.size > 0 ? `?${params.toString()}` : ''
|
||||
|
||||
@@ -693,69 +714,6 @@ async function fetchIntercomToken() {
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async function bootIntercom() {
|
||||
if (
|
||||
intercomBooting ||
|
||||
intercomBooted ||
|
||||
!hostingRouteActive.value ||
|
||||
!credentials.value?.session
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
intercomBooting = true
|
||||
console.debug('[APP][INTERCOM] initializing secure support chat')
|
||||
try {
|
||||
const { token } = await fetchIntercomToken()
|
||||
Intercom({
|
||||
app_id: 'ykeritl9',
|
||||
intercom_user_jwt: token,
|
||||
session_duration: 1000 * 60 * 60 * 24,
|
||||
alignment: 'right',
|
||||
horizontal_padding: intercomBubblePosition.value.horizontalPadding,
|
||||
vertical_padding: intercomBubblePosition.value.verticalPadding,
|
||||
})
|
||||
intercomBooted = true
|
||||
} catch (error) {
|
||||
console.warn('[APP][INTERCOM] failed to initialize secure support chat', error)
|
||||
} finally {
|
||||
intercomBooting = false
|
||||
}
|
||||
}
|
||||
|
||||
function shutdownHostingIntercom() {
|
||||
if (!intercomBooted && !intercomBooting) return
|
||||
shutdownIntercom()
|
||||
intercomBooting = false
|
||||
intercomBooted = false
|
||||
}
|
||||
|
||||
watch(
|
||||
intercomBubblePosition,
|
||||
(position) => {
|
||||
updateIntercomBubbleStyles(position)
|
||||
if (intercomBooted) {
|
||||
window.Intercom?.('update', {
|
||||
horizontal_padding: position.horizontalPadding,
|
||||
vertical_padding: position.verticalPadding,
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[hostingRouteActive, credentials],
|
||||
([active]) => {
|
||||
if (active) {
|
||||
void bootIntercom()
|
||||
} else {
|
||||
shutdownHostingIntercom()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(showAd, () => {
|
||||
if (!showAd.value) {
|
||||
hide_ads_window(true)
|
||||
@@ -1271,7 +1229,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<CompassIcon />
|
||||
</NavButton>
|
||||
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
|
||||
<NavButton v-tooltip.right="'Skin selector'" to="/skins">
|
||||
<ChangeSkinIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
@@ -1822,14 +1780,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
--os-handle-bg-active: var(--color-scrollbar) !important;
|
||||
}
|
||||
|
||||
.intercom-lightweight-app-launcher,
|
||||
.intercom-launcher-frame,
|
||||
iframe[name='intercom-launcher-frame'] {
|
||||
right: var(--app-support-launcher-right, 20px) !important;
|
||||
bottom: var(--app-support-launcher-bottom, 20px) !important;
|
||||
z-index: 9 !important;
|
||||
}
|
||||
|
||||
.mac {
|
||||
.app-grid-statusbar {
|
||||
padding-left: 5rem;
|
||||
|
||||
@@ -150,7 +150,10 @@ async function refreshValues() {
|
||||
if (equippedSkin.value) {
|
||||
try {
|
||||
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
|
||||
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
|
||||
headUrlCache.value = new Map(headUrlCache.value).set(
|
||||
equippedSkin.value.texture_key,
|
||||
headUrl,
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('Failed to get head render for equipped skin:', error)
|
||||
}
|
||||
@@ -160,12 +163,24 @@ async function refreshValues() {
|
||||
}
|
||||
}
|
||||
|
||||
async function setEquippedSkin(skin: Skin) {
|
||||
equippedSkin.value = skin
|
||||
|
||||
try {
|
||||
const headUrl = await getPlayerHeadUrl(skin)
|
||||
headUrlCache.value = new Map(headUrlCache.value).set(skin.texture_key, headUrl)
|
||||
} catch (error) {
|
||||
console.warn('Failed to get head render for equipped skin:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function setLoginDisabled(value: boolean) {
|
||||
loginDisabled.value = value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refreshValues,
|
||||
setEquippedSkin,
|
||||
setLoginDisabled,
|
||||
loginDisabled,
|
||||
})
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
<template>
|
||||
<ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false">
|
||||
<div class="auto-detect-modal">
|
||||
<div class="table">
|
||||
<div class="table-row table-head">
|
||||
<div class="table-cell table-text">Version</div>
|
||||
<div class="table-cell table-text">Path</div>
|
||||
<div class="table-cell table-text">Actions</div>
|
||||
</div>
|
||||
<div v-for="javaInstall in chosenInstallOptions" :key="javaInstall.path" class="table-row">
|
||||
<div class="table-cell table-text">
|
||||
<span>{{ javaInstall.version }}</span>
|
||||
</div>
|
||||
<div v-tooltip="javaInstall.path" class="table-cell table-text">
|
||||
<span>{{ javaInstall.path }}</span>
|
||||
</div>
|
||||
<div class="table-cell table-text manage">
|
||||
<ButtonStyled v-if="currentSelected.path === javaInstall.path">
|
||||
<button disabled><CheckIcon /> Selected</button>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Table :columns="javaInstallColumns" :data="chosenInstallOptions" row-key="path">
|
||||
<template #cell-version="{ value }">
|
||||
<span class="font-semibold text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-path="{ value }">
|
||||
<span v-tooltip="value" class="block truncate font-mono text-xs">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center justify-end">
|
||||
<ButtonStyled v-if="currentSelected.path === row.path">
|
||||
<button class="!shadow-none" disabled><CheckIcon /> Selected</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button @click="setJavaInstall(javaInstall)"><PlusIcon /> Select</button>
|
||||
<button class="!shadow-none" @click="setJavaInstall(row)"><PlusIcon /> Select</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="chosenInstallOptions.length === 0" class="table-row entire-row">
|
||||
<div class="table-cell table-text">No java installations found!</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group push-right">
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<div class="p-4 text-secondary">No java installations found!</div>
|
||||
</template>
|
||||
</Table>
|
||||
<div class="flex justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="$refs.detectJavaModal.hide()">
|
||||
<button
|
||||
class="!shadow-none !border-surface-4 !border"
|
||||
@click="$refs.detectJavaModal.hide()"
|
||||
>
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
@@ -40,7 +38,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
|
||||
import { ButtonStyled, injectNotificationManager, Table } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
@@ -52,6 +50,11 @@ const { handleError } = injectNotificationManager()
|
||||
const chosenInstallOptions = ref([])
|
||||
const detectJavaModal = ref(null)
|
||||
const currentSelected = ref({})
|
||||
const javaInstallColumns = [
|
||||
{ key: 'version', label: 'Version', width: '9rem' },
|
||||
{ key: 'path', label: 'Path' },
|
||||
{ key: 'actions', label: 'Actions', align: 'right', width: '10rem' },
|
||||
]
|
||||
|
||||
defineExpose({
|
||||
show: async (version, currentSelectedJava) => {
|
||||
@@ -77,25 +80,3 @@ function setJavaInstall(javaInstall) {
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.auto-detect-modal {
|
||||
.table {
|
||||
.table-row {
|
||||
grid-template-columns: 1fr 4fr min-content;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inherit;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.manage {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,85 +1,104 @@
|
||||
<template>
|
||||
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" />
|
||||
<div class="toggle-setting" :class="{ compact }">
|
||||
<StyledInput
|
||||
autocomplete="off"
|
||||
:disabled="props.disabled"
|
||||
:model-value="props.modelValue ? props.modelValue.path : ''"
|
||||
:placeholder="placeholder ?? '/path/to/java'"
|
||||
wrapper-class="installation-input"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
path: val,
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div :id="props.id" class="toggle-setting" :class="{ compact }">
|
||||
<div class="input-with-status">
|
||||
<StyledInput
|
||||
autocomplete="off"
|
||||
:disabled="props.disabled"
|
||||
:model-value="props.modelValue ? props.modelValue.path : ''"
|
||||
:placeholder="placeholder ?? '/path/to/java'"
|
||||
wrapper-class="installation-input"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
path: val,
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
<ButtonStyled
|
||||
:color="
|
||||
!hoveringTest && !testingJava
|
||||
? testingJavaSuccess === true
|
||||
? 'green'
|
||||
: 'red'
|
||||
: 'standard'
|
||||
"
|
||||
color-fill="text"
|
||||
>
|
||||
<button
|
||||
class="!shadow-none"
|
||||
:disabled="testingJava || props.disabled"
|
||||
@click="runTest(props.modelValue?.path)"
|
||||
@mouseenter="!props.disabled && (hoveringTest = true)"
|
||||
@mouseleave="hoveringTest = false"
|
||||
>
|
||||
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
|
||||
<CheckCircleIcon
|
||||
v-else-if="testingJavaSuccess === true && !hoveringTest"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<XCircleIcon v-else-if="testingJavaSuccess !== true && !hoveringTest" class="h-4 w-4" />
|
||||
<RefreshCwIcon v-else-if="!props.disabled" class="h-4 w-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="installation-buttons">
|
||||
<ButtonStyled v-if="props.version">
|
||||
<button :disabled="props.disabled || installingJava" @click="reinstallJava">
|
||||
<button
|
||||
v-tooltip="testingJavaSuccess === true ? 'Already installed' : undefined"
|
||||
class="!shadow-none"
|
||||
:disabled="props.disabled || installingJava || testingJavaSuccess === true"
|
||||
@click="reinstallJava"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ installingJava ? 'Installing...' : 'Install recommended' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="props.disabled" @click="autoDetect">
|
||||
<button class="!shadow-none" :disabled="props.disabled" @click="autoDetect">
|
||||
<SearchIcon />
|
||||
Detect
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="props.disabled" @click="handleJavaFileInput()">
|
||||
<button class="!shadow-none" :disabled="props.disabled" @click="handleJavaFileInput()">
|
||||
<FolderSearchIcon />
|
||||
Browse
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="testingJava">
|
||||
<button disabled>Testing...</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="testingJavaSuccess === true">
|
||||
<button disabled>
|
||||
<CheckIcon />
|
||||
Success
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="testingJavaSuccess === false">
|
||||
<button disabled>
|
||||
<XIcon />
|
||||
Failed
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button :disabled="props.disabled" @click="testJava">
|
||||
<PlayIcon />
|
||||
Test
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
CheckIcon,
|
||||
CheckCircleIcon,
|
||||
DownloadIcon,
|
||||
FolderSearchIcon,
|
||||
PlayIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
SpinnerIcon,
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager, StyledInput } from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
|
||||
import useJavaTest from '@/composables/useJavaTest'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
|
||||
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
version: {
|
||||
type: Number,
|
||||
required: false,
|
||||
@@ -110,29 +129,36 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const testingJava = ref(false)
|
||||
const testingJavaSuccess = ref(null)
|
||||
const {
|
||||
testingJava,
|
||||
javaTestResult: testingJavaSuccess,
|
||||
testJavaInstallationDebounced,
|
||||
testJavaInstallation,
|
||||
} = useJavaTest()
|
||||
|
||||
const installingJava = ref(false)
|
||||
const hoveringTest = ref(false)
|
||||
let hasInitialized = false
|
||||
|
||||
async function testJava() {
|
||||
testingJava.value = true
|
||||
testingJavaSuccess.value = await test_jre(
|
||||
props.modelValue ? props.modelValue.path : '',
|
||||
props.version,
|
||||
)
|
||||
testingJava.value = false
|
||||
|
||||
trackEvent('JavaTest', {
|
||||
path: props.modelValue ? props.modelValue.path : '',
|
||||
success: testingJavaSuccess.value,
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
testingJavaSuccess.value = null
|
||||
}, 2000)
|
||||
async function runTest(path) {
|
||||
await testJavaInstallation(path, props.version, true)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue?.path,
|
||||
(newPath) => {
|
||||
if (newPath) {
|
||||
if (!hasInitialized) {
|
||||
testJavaInstallation(newPath, props.version, false)
|
||||
hasInitialized = true
|
||||
} else {
|
||||
testJavaInstallationDebounced(newPath, props.version)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function handleJavaFileInput() {
|
||||
const filePath = await open()
|
||||
|
||||
@@ -142,6 +168,7 @@ async function handleJavaFileInput() {
|
||||
result = {
|
||||
path: filePath.path ?? filePath,
|
||||
version: props.version.toString(),
|
||||
parsed_version: props.version,
|
||||
architecture: 'x86',
|
||||
}
|
||||
}
|
||||
@@ -175,6 +202,7 @@ async function reinstallJava() {
|
||||
result = {
|
||||
path: path,
|
||||
version: props.version.toString(),
|
||||
parsed_version: props.version,
|
||||
architecture: 'x86',
|
||||
}
|
||||
}
|
||||
@@ -186,13 +214,23 @@ async function reinstallJava() {
|
||||
|
||||
emit('update:modelValue', result)
|
||||
installingJava.value = false
|
||||
runTest(result.path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.input-with-status {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.installation-input {
|
||||
width: 100% !important;
|
||||
flex-grow: 1;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toggle-setting {
|
||||
@@ -215,12 +253,4 @@ async function reinstallJava() {
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.test-success {
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.test-fail {
|
||||
color: var(--color-red);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -35,7 +35,7 @@ const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { instance, offline, isMinecraftServer, onUnlinked } = injectInstanceSettings()
|
||||
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
|
||||
|
||||
const [
|
||||
fabric_versions,
|
||||
@@ -113,6 +113,7 @@ provideAppBackup({
|
||||
})
|
||||
|
||||
provideInstallationSettings({
|
||||
closeSettings: closeModal,
|
||||
loading: ref(false),
|
||||
installationInfo: computed(() => {
|
||||
const rows = [
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CoffeeIcon,
|
||||
FolderSearchIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
@@ -8,9 +17,11 @@ import {
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { computed, readonly, ref, watch } from 'vue'
|
||||
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
|
||||
import useJavaTest from '@/composables/useJavaTest'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
import { edit, get_optimal_jre_key } from '@/helpers/profile'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
@@ -25,9 +36,54 @@ const { instance } = injectInstanceSettings()
|
||||
|
||||
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
|
||||
|
||||
const overrideJavaInstall = ref(!!instance.value.java_path)
|
||||
const optimalJava = readonly(await get_optimal_jre_key(instance.value.path).catch(handleError))
|
||||
const javaInstall = ref({ path: optimalJava.path ?? instance.value.java_path })
|
||||
|
||||
const overrideJavaInstall = ref(!!instance.value.java_path)
|
||||
const javaPath = ref(instance.value.java_path ?? optimalJava?.path ?? '')
|
||||
|
||||
const activePath = computed(() =>
|
||||
overrideJavaInstall.value ? javaPath.value : (optimalJava?.path ?? ''),
|
||||
)
|
||||
|
||||
watch(overrideJavaInstall, (enabled) => {
|
||||
if (enabled && !javaPath.value) {
|
||||
javaPath.value = optimalJava?.path ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
const { testingJava, javaTestResult, testJavaInstallationDebounced, testJavaInstallation } =
|
||||
useJavaTest()
|
||||
|
||||
const hoveringTest = ref(false)
|
||||
let hasInitialized = false
|
||||
|
||||
watch(
|
||||
activePath,
|
||||
(newPath) => {
|
||||
if (newPath && optimalJava?.parsed_version) {
|
||||
if (!hasInitialized) {
|
||||
testJavaInstallation(newPath, optimalJava?.parsed_version, false)
|
||||
hasInitialized = true
|
||||
} else {
|
||||
testJavaInstallationDebounced(newPath, optimalJava?.parsed_version)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const javaDetectionModal = ref<{ show: (version: number, current: object) => void } | null>(null)
|
||||
|
||||
async function handleBrowseJava() {
|
||||
const result = await open({ multiple: false })
|
||||
if (result) {
|
||||
javaPath.value = result
|
||||
}
|
||||
}
|
||||
|
||||
function handleDetectJava() {
|
||||
javaDetectionModal.value?.show(optimalJava?.parsed_version, { path: javaPath.value })
|
||||
}
|
||||
|
||||
const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0)
|
||||
const javaArgs = ref(
|
||||
@@ -51,8 +107,8 @@ const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) a
|
||||
const editProfileObject = computed(() => {
|
||||
return {
|
||||
java_path:
|
||||
overrideJavaInstall.value && javaInstall.value.path !== ''
|
||||
? javaInstall.value.path.replace('java.exe', 'javaw.exe')
|
||||
overrideJavaInstall.value && javaPath.value
|
||||
? javaPath.value.replace('java.exe', 'javaw.exe')
|
||||
: null,
|
||||
extra_launch_args: overrideJavaArgs.value
|
||||
? javaArgs.value.trim().split(/\s+/).filter(Boolean)
|
||||
@@ -71,7 +127,7 @@ const editProfileObject = computed(() => {
|
||||
watch(
|
||||
[
|
||||
overrideJavaInstall,
|
||||
javaInstall,
|
||||
javaPath,
|
||||
overrideJavaArgs,
|
||||
javaArgs,
|
||||
overrideEnvVars,
|
||||
@@ -90,17 +146,45 @@ const messages = defineMessages({
|
||||
id: 'instance.settings.tabs.java.java-installation',
|
||||
defaultMessage: 'Java installation',
|
||||
},
|
||||
customJavaInstallation: {
|
||||
id: 'instance.settings.tabs.java.custom-java-installation',
|
||||
defaultMessage: 'Custom Java installation',
|
||||
},
|
||||
javaPathPlaceholder: {
|
||||
id: 'instance.settings.tabs.java.java-path-placeholder',
|
||||
defaultMessage: '/path/to/java',
|
||||
},
|
||||
javaMemory: {
|
||||
id: 'instance.settings.tabs.java.java-memory',
|
||||
defaultMessage: 'Memory allocated',
|
||||
},
|
||||
customMemoryAllocation: {
|
||||
id: 'instance.settings.tabs.java.custom-memory-allocation',
|
||||
defaultMessage: 'Custom memory allocation',
|
||||
},
|
||||
javaArguments: {
|
||||
id: 'instance.settings.tabs.java.java-arguments',
|
||||
defaultMessage: 'Java arguments',
|
||||
},
|
||||
customJavaArguments: {
|
||||
id: 'instance.settings.tabs.java.custom-java-arguments',
|
||||
defaultMessage: 'Custom Java arguments',
|
||||
},
|
||||
enterJavaArguments: {
|
||||
id: 'instance.settings.tabs.java.enter-java-arguments',
|
||||
defaultMessage: 'Enter Java arguments...',
|
||||
},
|
||||
javaEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.environment-variables',
|
||||
defaultMessage: 'Environment variables',
|
||||
},
|
||||
javaMemory: {
|
||||
id: 'instance.settings.tabs.java.java-memory',
|
||||
defaultMessage: 'Memory allocated',
|
||||
customEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.custom-environment-variables',
|
||||
defaultMessage: 'Custom environment variables',
|
||||
},
|
||||
enterEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.enter-environment-variables',
|
||||
defaultMessage: 'Enter environmental variables...',
|
||||
},
|
||||
hooks: {
|
||||
id: 'instance.settings.tabs.java.hooks',
|
||||
@@ -111,43 +195,86 @@ const messages = defineMessages({
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 id="project-name" class="m-0 mb-2.5 text-lg font-semibold text-contrast block">
|
||||
<JavaDetectionModal ref="javaDetectionModal" @submit="(val) => (javaPath = val.path)" />
|
||||
<h2 class="m-0 mb-2 text-lg font-extrabold text-contrast block">
|
||||
{{ formatMessage(messages.javaInstallation) }}
|
||||
</h2>
|
||||
<Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2.5" />
|
||||
<template v-if="!overrideJavaInstall">
|
||||
<div class="flex my-2 items-center gap-2 font-semibold">
|
||||
<template v-if="javaInstall">
|
||||
<CheckCircleIcon class="text-brand-green h-4 w-4" />
|
||||
<span>Using default Java {{ optimalJava.major_version }} installation:</span>
|
||||
</template>
|
||||
<template v-else-if="optimalJava">
|
||||
<XCircleIcon class="text-brand-red h-5 w-5" />
|
||||
<span
|
||||
>Could not find a default Java {{ optimalJava.major_version }} installation. Please set
|
||||
one below:</span
|
||||
<Checkbox
|
||||
v-model="overrideJavaInstall"
|
||||
:label="formatMessage(messages.customJavaInstallation)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<div class="flex gap-4 p-4 bg-bg rounded-2xl">
|
||||
<div class="flex gap-3 items-start flex-1 min-w-0">
|
||||
<div
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-button-bg border-solid border-[1px] border-button-border p-2 mt-1 shrink-0 [&_svg]:h-full [&_svg]:w-full"
|
||||
>
|
||||
<CoffeeIcon />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 flex-1 min-w-0">
|
||||
<span class="font-semibold leading-none mt-2"
|
||||
>Java {{ optimalJava?.parsed_version }}</span
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<XCircleIcon class="text-brand-red h-5 w-5" />
|
||||
<span
|
||||
>Could not automatically determine a Java installation to use. Please set one
|
||||
below:</span
|
||||
>
|
||||
</template>
|
||||
<div class="flex gap-2 items-center">
|
||||
<StyledInput
|
||||
:model-value="activePath"
|
||||
:disabled="!overrideJavaInstall"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.javaPathPlaceholder)"
|
||||
wrapper-class="flex-1 min-w-0"
|
||||
@update:model-value="(val) => (javaPath = String(val))"
|
||||
/>
|
||||
<ButtonStyled
|
||||
:color="
|
||||
!hoveringTest && !testingJava
|
||||
? javaTestResult === true
|
||||
? 'green'
|
||||
: 'red'
|
||||
: 'standard'
|
||||
"
|
||||
color-fill="text"
|
||||
>
|
||||
<button
|
||||
:disabled="!overrideJavaInstall || testingJava"
|
||||
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
|
||||
@mouseenter="overrideJavaInstall && (hoveringTest = true)"
|
||||
@mouseleave="hoveringTest = false"
|
||||
>
|
||||
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
|
||||
<CheckCircleIcon
|
||||
v-else-if="javaTestResult === true && !hoveringTest"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<XCircleIcon v-else-if="javaTestResult !== true && !hoveringTest" class="h-4 w-4" />
|
||||
<RefreshCwIcon v-else-if="overrideJavaInstall" class="h-4 w-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="overrideJavaInstall" class="flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="handleDetectJava">
|
||||
<SearchIcon />
|
||||
Detect
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleBrowseJava">
|
||||
<FolderSearchIcon />
|
||||
Browse
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="javaInstall && !overrideJavaInstall"
|
||||
class="p-4 bg-bg rounded-xl text-xs text-secondary leading-none font-mono"
|
||||
>
|
||||
{{ javaInstall.path }}
|
||||
</div>
|
||||
</template>
|
||||
<JavaSelector v-if="overrideJavaInstall || !javaInstall" v-model="javaInstall" />
|
||||
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
|
||||
</div>
|
||||
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||
{{ formatMessage(messages.javaMemory) }}
|
||||
</h2>
|
||||
<Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2.5" />
|
||||
<Checkbox
|
||||
v-model="overrideMemorySettings"
|
||||
:label="formatMessage(messages.customMemoryAllocation)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<Slider
|
||||
id="max-memory"
|
||||
v-model="memory.maximum"
|
||||
@@ -159,28 +286,36 @@ const messages = defineMessages({
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
|
||||
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||
{{ formatMessage(messages.javaArguments) }}
|
||||
</h2>
|
||||
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
|
||||
<Checkbox
|
||||
v-model="overrideJavaArgs"
|
||||
:label="formatMessage(messages.customJavaArguments)"
|
||||
class="my-2"
|
||||
/>
|
||||
<StyledInput
|
||||
id="java-args"
|
||||
v-model="javaArgs"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideJavaArgs"
|
||||
placeholder="Enter java arguments..."
|
||||
:placeholder="formatMessage(messages.enterJavaArguments)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
|
||||
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||
{{ formatMessage(messages.javaEnvironmentVariables) }}
|
||||
</h2>
|
||||
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2.5" />
|
||||
<Checkbox
|
||||
v-model="overrideEnvVars"
|
||||
:label="formatMessage(messages.customEnvironmentVariables)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<StyledInput
|
||||
id="env-vars"
|
||||
v-model="envVars"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideEnvVars"
|
||||
placeholder="Enter environmental variables..."
|
||||
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -45,12 +45,19 @@ const isMinecraftServer = ref(false)
|
||||
const handleUnlinked = () => emit('unlinked')
|
||||
|
||||
const instanceRef = computed(() => props.instance)
|
||||
const queryClient = useQueryClient()
|
||||
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
|
||||
function hide() {
|
||||
tabbedModal.value?.hide()
|
||||
}
|
||||
|
||||
provideInstanceSettings({
|
||||
instance: instanceRef,
|
||||
offline: props.offline,
|
||||
isMinecraftServer,
|
||||
onUnlinked: handleUnlinked,
|
||||
closeModal: hide,
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -113,9 +120,6 @@ const tabs = computed<TabbedModalTab[]>(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
|
||||
function show(tabIndex?: number) {
|
||||
if (props.instance.linked_data?.project_id) {
|
||||
queryClient.prefetchQuery({
|
||||
@@ -129,7 +133,7 @@ function show(tabIndex?: number) {
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
<template>
|
||||
<TabbedModal
|
||||
|
||||
@@ -160,7 +160,16 @@ watch(
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
|
||||
</div>
|
||||
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
|
||||
<Toggle
|
||||
id="hide-nametag-skins-page"
|
||||
:model-value="themeStore.hideNametagSkinsPage"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
themeStore.hideNametagSkinsPage = !!e
|
||||
settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
|
||||
|
||||
@@ -1,149 +1,242 @@
|
||||
<template>
|
||||
<UploadSkinModal ref="uploadModal" />
|
||||
<ModalWrapper ref="modal" @on-hide="resetState">
|
||||
<NewModal ref="modal" :on-hide="handleModalHide">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">
|
||||
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
|
||||
{{ formatMessage(mode === 'edit' ? messages.editSkinTitle : messages.addSkinTitle) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
|
||||
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
|
||||
<SkinPreviewRenderer
|
||||
:variant="variant"
|
||||
:texture-src="previewSkin || ''"
|
||||
:cape-src="selectedCapeTexture"
|
||||
:scale="1.4"
|
||||
:fov="50"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="h-[25rem] w-[16rem] min-w-[16rem] flex-shrink-0 md:self-center">
|
||||
<SkinPreviewRenderer
|
||||
:variant="variant"
|
||||
:texture-src="previewSkin || ''"
|
||||
:cape-src="selectedCapeTexture"
|
||||
framing="modal"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Texture</h2>
|
||||
<section v-if="mode === 'edit' && canEditTextureAndModel">
|
||||
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.textureSection) }}</h2>
|
||||
<ButtonStyled>
|
||||
<button @click="openUploadSkinModal"><UploadIcon /> Replace texture</button>
|
||||
<button class="!shadow-none" @click="openTextureFileBrowser">
|
||||
<UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<input
|
||||
ref="textureFileInput"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
class="hidden"
|
||||
@change="onTextureFileInputChange"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Arm style</h2>
|
||||
<section v-if="canEditTextureAndModel">
|
||||
<h2 class="text-base font-semibold mb-2">
|
||||
{{ formatMessage(messages.armStyleSection) }}
|
||||
</h2>
|
||||
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
|
||||
<template #default="{ item }">
|
||||
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
|
||||
{{
|
||||
formatMessage(item === 'CLASSIC' ? messages.wideArmStyle : messages.slimArmStyle)
|
||||
}}
|
||||
</template>
|
||||
</RadioButtons>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Cape</h2>
|
||||
<div class="flex gap-2">
|
||||
<CapeButton
|
||||
v-if="defaultCape"
|
||||
:id="defaultCape.id"
|
||||
:texture="defaultCape.texture"
|
||||
:name="undefined"
|
||||
:selected="!selectedCape"
|
||||
faded
|
||||
@select="selectCape(undefined)"
|
||||
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.capeSection) }}</h2>
|
||||
<div class="relative w-fit max-w-full">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-6"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-6"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<span>Use default cape</span>
|
||||
</CapeButton>
|
||||
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
|
||||
<span>Use default cape</span>
|
||||
</CapeLikeTextButton>
|
||||
<div
|
||||
v-if="showCapeTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-6 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<CapeButton
|
||||
v-for="cape in visibleCapeList"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:texture="cape.texture"
|
||||
:name="cape.name || 'Cape'"
|
||||
:selected="selectedCape?.id === cape.id"
|
||||
@select="selectCape(cape)"
|
||||
/>
|
||||
|
||||
<CapeLikeTextButton
|
||||
v-if="(capes?.length ?? 0) > 2"
|
||||
tooltip="View more capes"
|
||||
@mouseup="openSelectCapeModal"
|
||||
<div
|
||||
ref="capeListRef"
|
||||
class="grid grid-cols-[repeat(4,max-content)] auto-rows-max gap-2 overflow-y-auto pr-1"
|
||||
:style="{ maxHeight: capeListMaxHeight }"
|
||||
@scroll="checkCapeScrollState"
|
||||
>
|
||||
<template #icon><ChevronRightIcon /></template>
|
||||
<span>More</span>
|
||||
</CapeLikeTextButton>
|
||||
<CapeLikeTextButton
|
||||
:tooltip="formatMessage(messages.noCapeTooltip)"
|
||||
:highlighted="!selectedCape"
|
||||
@click="selectCape(undefined)"
|
||||
>
|
||||
<template #icon><XIcon /></template>
|
||||
<span>{{ formatMessage(messages.noneCapeOption) }}</span>
|
||||
</CapeLikeTextButton>
|
||||
|
||||
<CapeButton
|
||||
v-for="cape in sortedCapes"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:texture="cape.texture"
|
||||
:name="cape.name || formatMessage(messages.capeFallbackName)"
|
||||
:selected="selectedCape?.id === cape.id"
|
||||
@select="selectCape(cape)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-6"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-6"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showCapeBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-6 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-12">
|
||||
<ButtonStyled color="brand">
|
||||
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
|
||||
<SpinnerIcon v-if="isSaving" class="animate-spin" />
|
||||
<CheckIcon v-else-if="mode === 'new'" />
|
||||
<SaveIcon v-else />
|
||||
{{ mode === 'new' ? 'Add skin' : 'Save skin' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="isSaving" @click="hide"><XIcon />Cancel</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
|
||||
<SelectCapeModal
|
||||
ref="selectCapeModal"
|
||||
:capes="capes || []"
|
||||
@select="handleCapeSelected"
|
||||
@cancel="handleCapeCancel"
|
||||
/>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="isSaving" @click="hide">
|
||||
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
|
||||
<SpinnerIcon v-if="isSaving" class="animate-spin" />
|
||||
<CheckIcon v-else-if="mode === 'new'" />
|
||||
<SaveIcon v-else />
|
||||
{{ formatMessage(mode === 'new' ? messages.addSkinButton : messages.saveSkinButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
SaveIcon,
|
||||
SpinnerIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { CheckIcon, SaveIcon, SpinnerIcon, UploadIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
CapeButton,
|
||||
CapeLikeTextButton,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
RadioButtons,
|
||||
SkinPreviewRenderer,
|
||||
useScrollIndicator,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { arrayBufferToBase64 } from '@modrinth/utils'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
|
||||
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
|
||||
import {
|
||||
add_and_equip_custom_skin,
|
||||
type Cape,
|
||||
determineModelType,
|
||||
equip_skin,
|
||||
get_normalized_skin_texture,
|
||||
remove_custom_skin,
|
||||
normalize_skin_texture,
|
||||
save_custom_skin,
|
||||
type Skin,
|
||||
type SkinModel,
|
||||
type SkinTextureUrl,
|
||||
unequip_skin,
|
||||
} from '@/helpers/skins.ts'
|
||||
|
||||
const CAPE_LIST_MAX_HEIGHT = 334
|
||||
const messages = defineMessages({
|
||||
editSkinTitle: {
|
||||
id: 'app.skins.modal.edit-title',
|
||||
defaultMessage: 'Editing skin',
|
||||
},
|
||||
addSkinTitle: {
|
||||
id: 'app.skins.modal.add-title',
|
||||
defaultMessage: 'Adding a skin',
|
||||
},
|
||||
textureSection: {
|
||||
id: 'app.skins.modal.texture-section',
|
||||
defaultMessage: 'Texture',
|
||||
},
|
||||
replaceTextureButton: {
|
||||
id: 'app.skins.modal.replace-texture-button',
|
||||
defaultMessage: 'Replace texture',
|
||||
},
|
||||
armStyleSection: {
|
||||
id: 'app.skins.modal.arm-style-section',
|
||||
defaultMessage: 'Arm style',
|
||||
},
|
||||
wideArmStyle: {
|
||||
id: 'app.skins.modal.arm-style-wide',
|
||||
defaultMessage: 'Wide',
|
||||
},
|
||||
slimArmStyle: {
|
||||
id: 'app.skins.modal.arm-style-slim',
|
||||
defaultMessage: 'Slim',
|
||||
},
|
||||
capeSection: {
|
||||
id: 'app.skins.modal.cape-section',
|
||||
defaultMessage: 'Cape',
|
||||
},
|
||||
noCapeTooltip: {
|
||||
id: 'app.skins.modal.no-cape-tooltip',
|
||||
defaultMessage: 'No cape',
|
||||
},
|
||||
noneCapeOption: {
|
||||
id: 'app.skins.modal.none-cape-option',
|
||||
defaultMessage: 'None',
|
||||
},
|
||||
capeFallbackName: {
|
||||
id: 'app.skins.modal.cape-fallback-name',
|
||||
defaultMessage: 'Cape',
|
||||
},
|
||||
savingTooltip: {
|
||||
id: 'app.skins.modal.saving-tooltip',
|
||||
defaultMessage: 'Saving...',
|
||||
},
|
||||
uploadSkinFirstTooltip: {
|
||||
id: 'app.skins.modal.upload-skin-first-tooltip',
|
||||
defaultMessage: 'Upload a skin first!',
|
||||
},
|
||||
makeEditFirstTooltip: {
|
||||
id: 'app.skins.modal.make-edit-first-tooltip',
|
||||
defaultMessage: 'Make an edit to the skin first!',
|
||||
},
|
||||
addSkinButton: {
|
||||
id: 'app.skins.modal.add-skin-button',
|
||||
defaultMessage: 'Add skin',
|
||||
},
|
||||
saveSkinButton: {
|
||||
id: 'app.skins.modal.save-skin-button',
|
||||
defaultMessage: 'Save skin',
|
||||
},
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
const selectCapeModal = useTemplateRef('selectCapeModal')
|
||||
const textureFileInput = useTemplateRef<HTMLInputElement>('textureFileInput')
|
||||
const capeListRef = ref<HTMLElement | null>(null)
|
||||
const capeListMaxHeight = ref(`${CAPE_LIST_MAX_HEIGHT}px`)
|
||||
const mode = ref<'new' | 'edit'>('new')
|
||||
const currentSkin = ref<Skin | null>(null)
|
||||
const shouldRestoreModal = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const uploadedTextureUrl = ref<SkinTextureUrl | null>(null)
|
||||
@@ -151,10 +244,49 @@ const previewSkin = ref<string>('')
|
||||
|
||||
const variant = ref<SkinModel>('CLASSIC')
|
||||
const selectedCape = ref<Cape | undefined>(undefined)
|
||||
const props = defineProps<{ capes?: Cape[]; defaultCape?: Cape }>()
|
||||
const props = defineProps<{ capes?: Cape[] }>()
|
||||
|
||||
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
|
||||
const visibleCapeList = ref<Cape[]>([])
|
||||
const canEditTextureAndModel = computed(() => currentSkin.value?.source !== 'default')
|
||||
const {
|
||||
showTopFade: showCapeTopFade,
|
||||
showBottomFade: showCapeBottomFade,
|
||||
checkScrollState: checkCapeScrollState,
|
||||
forceCheck: forceCapeScrollCheck,
|
||||
} = useScrollIndicator(capeListRef)
|
||||
|
||||
let capeListLayoutFrame: number | null = null
|
||||
function updateCapeListLayout() {
|
||||
const capeList = capeListRef.value
|
||||
const modalContent = capeList?.closest('[data-modal-content]') as HTMLElement | null
|
||||
|
||||
if (!capeList || !modalContent) {
|
||||
capeListMaxHeight.value = `${CAPE_LIST_MAX_HEIGHT}px`
|
||||
forceCapeScrollCheck()
|
||||
return
|
||||
}
|
||||
|
||||
const availableHeight =
|
||||
modalContent.getBoundingClientRect().bottom - capeList.getBoundingClientRect().top
|
||||
|
||||
capeListMaxHeight.value = `${Math.min(
|
||||
CAPE_LIST_MAX_HEIGHT,
|
||||
Math.max(0, Math.floor(availableHeight)),
|
||||
)}px`
|
||||
|
||||
nextTick(() => forceCapeScrollCheck())
|
||||
}
|
||||
|
||||
function refreshCapeListLayout() {
|
||||
if (capeListLayoutFrame !== null) {
|
||||
cancelAnimationFrame(capeListLayoutFrame)
|
||||
}
|
||||
|
||||
capeListLayoutFrame = requestAnimationFrame(() => {
|
||||
capeListLayoutFrame = null
|
||||
updateCapeListLayout()
|
||||
})
|
||||
}
|
||||
|
||||
const sortedCapes = computed(() => {
|
||||
return [...(props.capes || [])].sort((a, b) => {
|
||||
@@ -164,32 +296,6 @@ const sortedCapes = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
function initVisibleCapeList() {
|
||||
if (!props.capes || props.capes.length === 0) {
|
||||
visibleCapeList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (visibleCapeList.value.length === 0) {
|
||||
if (selectedCape.value) {
|
||||
const otherCape = getSortedCapeExcluding(selectedCape.value.id)
|
||||
visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value]
|
||||
} else {
|
||||
visibleCapeList.value = getSortedCapes(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSortedCapes(count: number): Cape[] {
|
||||
if (!sortedCapes.value || sortedCapes.value.length === 0) return []
|
||||
return sortedCapes.value.slice(0, count)
|
||||
}
|
||||
|
||||
function getSortedCapeExcluding(excludeId: string): Cape | undefined {
|
||||
if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined
|
||||
return sortedCapes.value.find((cape) => cape.id !== excludeId)
|
||||
}
|
||||
|
||||
async function loadPreviewSkin() {
|
||||
if (uploadedTextureUrl.value) {
|
||||
previewSkin.value = uploadedTextureUrl.value.normalized
|
||||
@@ -221,9 +327,13 @@ const disableSave = computed(
|
||||
)
|
||||
|
||||
const saveTooltip = computed(() => {
|
||||
if (isSaving.value) return 'Saving...'
|
||||
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
|
||||
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
|
||||
if (isSaving.value) return formatMessage(messages.savingTooltip)
|
||||
if (mode.value === 'new' && !uploadedTextureUrl.value) {
|
||||
return formatMessage(messages.uploadSkinFirstTooltip)
|
||||
}
|
||||
if (mode.value === 'edit' && !hasEdits.value) {
|
||||
return formatMessage(messages.makeEditFirstTooltip)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -234,11 +344,13 @@ function resetState() {
|
||||
previewSkin.value = ''
|
||||
variant.value = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
shouldRestoreModal.value = false
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
function handleModalHide() {
|
||||
setTimeout(() => resetState(), 250)
|
||||
}
|
||||
|
||||
async function show(e: MouseEvent, skin?: Skin) {
|
||||
mode.value = skin ? 'edit' : 'new'
|
||||
currentSkin.value = skin ?? null
|
||||
@@ -249,12 +361,11 @@ async function show(e: MouseEvent, skin?: Skin) {
|
||||
variant.value = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
}
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
await loadPreviewSkin()
|
||||
|
||||
modal.value?.show(e)
|
||||
nextTick(() => refreshCapeListLayout())
|
||||
}
|
||||
|
||||
async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
|
||||
@@ -263,98 +374,54 @@ async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
variant.value = await determineModelType(skinTextureUrl.original)
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
await loadPreviewSkin()
|
||||
|
||||
modal.value?.show(e)
|
||||
nextTick(() => refreshCapeListLayout())
|
||||
}
|
||||
|
||||
async function restoreWithNewTexture(skinTextureUrl: SkinTextureUrl) {
|
||||
async function setUploadedTexture(skinTextureUrl: SkinTextureUrl) {
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
await loadPreviewSkin()
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
nextTick(() => refreshCapeListLayout())
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
setTimeout(() => resetState(), 250)
|
||||
}
|
||||
|
||||
function selectCape(cape: Cape | undefined) {
|
||||
if (cape && selectedCape.value?.id !== cape.id) {
|
||||
const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id)
|
||||
if (!isInVisibleList && visibleCapeList.value.length > 0) {
|
||||
visibleCapeList.value.splice(0, 1, cape)
|
||||
|
||||
if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) {
|
||||
const otherCape = getSortedCapeExcluding(cape.id)
|
||||
if (otherCape) {
|
||||
visibleCapeList.value.splice(1, 1, otherCape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedCape.value = cape
|
||||
}
|
||||
|
||||
function handleCapeSelected(cape: Cape | undefined) {
|
||||
selectCape(cape)
|
||||
function openTextureFileBrowser() {
|
||||
textureFileInput.value?.click()
|
||||
}
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
async function onTextureFileInputChange(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
const file = files?.[0]
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function handleCapeCancel() {
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectCapeModal(e: MouseEvent) {
|
||||
if (!selectCapeModal.value) return
|
||||
|
||||
shouldRestoreModal.value = true
|
||||
modal.value?.hide()
|
||||
|
||||
setTimeout(() => {
|
||||
selectCapeModal.value?.show(
|
||||
e,
|
||||
currentSkin.value?.texture_key,
|
||||
selectedCape.value,
|
||||
previewSkin.value,
|
||||
variant.value,
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function openUploadSkinModal(e: MouseEvent) {
|
||||
shouldRestoreModal.value = true
|
||||
modal.value?.hide()
|
||||
emit('open-upload-modal', e)
|
||||
}
|
||||
|
||||
function restoreModal() {
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
modal.value?.show(fakeEvent)
|
||||
shouldRestoreModal.value = false
|
||||
}, 500)
|
||||
try {
|
||||
const originalSkinTexUrl = `data:image/png;base64,${arrayBufferToBase64(
|
||||
await file.arrayBuffer(),
|
||||
)}`
|
||||
const skinTextureNormalized = await normalize_skin_texture(originalSkinTexUrl)
|
||||
await setUploadedTexture({
|
||||
original: originalSkinTexUrl,
|
||||
normalized: `data:image/png;base64,${arrayBufferToBase64(skinTextureNormalized)}`,
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
if (textureFileInput.value) {
|
||||
textureFileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,17 +437,32 @@ async function save() {
|
||||
textureUrl = currentSkin.value!.texture
|
||||
}
|
||||
|
||||
await unequip_skin()
|
||||
|
||||
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
|
||||
|
||||
if (mode.value === 'new') {
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
emit('saved')
|
||||
const addedSkin = await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
emit('saved', {
|
||||
applied: true,
|
||||
skin: addedSkin,
|
||||
})
|
||||
} else {
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
await remove_custom_skin(currentSkin.value!)
|
||||
emit('saved')
|
||||
const updatedSkin = await save_custom_skin(
|
||||
currentSkin.value!,
|
||||
bytes,
|
||||
variant.value,
|
||||
selectedCape.value,
|
||||
!!uploadedTextureUrl.value && textureUrl !== currentSkin.value?.texture,
|
||||
)
|
||||
|
||||
if (currentSkin.value?.is_equipped) {
|
||||
await equip_skin(updatedSkin)
|
||||
}
|
||||
|
||||
emit('saved', {
|
||||
applied: !!currentSkin.value?.is_equipped,
|
||||
skin: updatedSkin,
|
||||
previousSkin: currentSkin.value!,
|
||||
})
|
||||
}
|
||||
|
||||
hide()
|
||||
@@ -393,28 +475,53 @@ async function save() {
|
||||
|
||||
watch([uploadedTextureUrl, currentSkin], async () => {
|
||||
await loadPreviewSkin()
|
||||
refreshCapeListLayout()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.capes,
|
||||
() => {
|
||||
initVisibleCapeList()
|
||||
nextTick(() => refreshCapeListLayout())
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
capeListRef,
|
||||
(capeList, _, onCleanup) => {
|
||||
if (!capeList) return
|
||||
|
||||
const modalContent = capeList.closest('[data-modal-content]')
|
||||
const resizeObserver = new ResizeObserver(() => refreshCapeListLayout())
|
||||
|
||||
if (modalContent instanceof HTMLElement) {
|
||||
resizeObserver.observe(modalContent)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', refreshCapeListLayout, { passive: true })
|
||||
refreshCapeListLayout()
|
||||
|
||||
onCleanup(() => {
|
||||
resizeObserver.disconnect()
|
||||
window.removeEventListener('resize', refreshCapeListLayout)
|
||||
|
||||
if (capeListLayoutFrame !== null) {
|
||||
cancelAnimationFrame(capeListLayoutFrame)
|
||||
capeListLayoutFrame = null
|
||||
}
|
||||
})
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'saved'): void
|
||||
(event: 'saved', options: { applied: boolean; skin?: Skin; previousSkin?: Skin }): void
|
||||
(event: 'deleted', skin: Skin): void
|
||||
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
showNew,
|
||||
restoreWithNewTexture,
|
||||
hide,
|
||||
shouldRestoreModal,
|
||||
restoreModal,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
CapeButton,
|
||||
CapeLikeTextButton,
|
||||
ScrollablePanel,
|
||||
SkinPreviewRenderer,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import type { Cape, SkinModel } from '@/helpers/skins.ts'
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', cape: Cape | undefined): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
capes: Cape[]
|
||||
}>()
|
||||
|
||||
const sortedCapes = computed(() => {
|
||||
return [...props.capes].sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase()
|
||||
const nameB = (b.name || '').toLowerCase()
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
})
|
||||
|
||||
const currentSkinId = ref<string | undefined>()
|
||||
const currentSkinTexture = ref<string | undefined>()
|
||||
const currentSkinVariant = ref<SkinModel>('CLASSIC')
|
||||
const currentCapeTexture = computed<string | undefined>(() => currentCape.value?.texture)
|
||||
const currentCape = ref<Cape | undefined>()
|
||||
|
||||
function show(
|
||||
e: MouseEvent,
|
||||
skinId?: string,
|
||||
selected?: Cape,
|
||||
skinTexture?: string,
|
||||
variant?: SkinModel,
|
||||
) {
|
||||
currentSkinId.value = skinId
|
||||
currentSkinTexture.value = skinTexture
|
||||
currentSkinVariant.value = variant || 'CLASSIC'
|
||||
currentCape.value = selected
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function select() {
|
||||
emit('select', currentCape.value)
|
||||
hide()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function updateSelectedCape(cape: Cape | undefined) {
|
||||
currentCape.value = cape
|
||||
}
|
||||
|
||||
function onModalHide() {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @on-hide="onModalHide">
|
||||
<template #title>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg font-extrabold text-heading">Change cape</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
|
||||
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
|
||||
<SkinPreviewRenderer
|
||||
v-if="currentSkinTexture"
|
||||
:cape-src="currentCapeTexture"
|
||||
:texture-src="currentSkinTexture"
|
||||
:variant="currentSkinVariant"
|
||||
:scale="1.4"
|
||||
:fov="50"
|
||||
:initial-rotation="Math.PI + Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full my-auto">
|
||||
<ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full">
|
||||
<div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full">
|
||||
<CapeLikeTextButton
|
||||
tooltip="No Cape"
|
||||
:highlighted="!currentCape"
|
||||
@click="updateSelectedCape(undefined)"
|
||||
>
|
||||
<template #icon>
|
||||
<XIcon />
|
||||
</template>
|
||||
<span>None</span>
|
||||
</CapeLikeTextButton>
|
||||
<CapeButton
|
||||
v-for="cape in sortedCapes"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:name="cape.name"
|
||||
:texture="cape.texture"
|
||||
:selected="currentCape?.id === cape.id"
|
||||
@select="updateSelectedCape(cape)"
|
||||
/>
|
||||
</div>
|
||||
</ScrollablePanel>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="select">
|
||||
<CheckIcon />
|
||||
Select
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -1,141 +0,0 @@
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @on-hide="hide(true)">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast"> Upload skin texture </span>
|
||||
</template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2">
|
||||
<UploadIcon /> Select skin texture file
|
||||
</p>
|
||||
<p class="mx-auto mt-0 text-secondary text-sm text-center">
|
||||
Drag and drop or click here to browse
|
||||
</p>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
class="hidden"
|
||||
@change="handleInputFileChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadIcon } from '@modrinth/assets'
|
||||
import { injectNotificationManager } from '@modrinth/ui'
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_dragged_skin_data } from '@/helpers/skins'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const modal = ref()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const unlisten = ref<() => void>()
|
||||
const modalVisible = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'uploaded', data: ArrayBuffer): void
|
||||
(e: 'canceled'): void
|
||||
}>()
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
modal.value?.show(e)
|
||||
modalVisible.value = true
|
||||
setupDragDropListener()
|
||||
}
|
||||
|
||||
function hide(emitCanceled = false) {
|
||||
modal.value?.hide()
|
||||
modalVisible.value = false
|
||||
cleanupDragDropListener()
|
||||
resetState()
|
||||
if (emitCanceled) {
|
||||
emit('canceled')
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleInputFileChange(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
if (!files || files.length === 0) {
|
||||
return
|
||||
}
|
||||
const file = files[0]
|
||||
const buffer = await file.arrayBuffer()
|
||||
await processData(buffer)
|
||||
}
|
||||
|
||||
async function setupDragDropListener() {
|
||||
try {
|
||||
if (modalVisible.value) {
|
||||
await cleanupDragDropListener()
|
||||
unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
if (event.payload.type !== 'drop') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.payload.paths || event.payload.paths.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = event.payload.paths[0]
|
||||
|
||||
try {
|
||||
const data = await get_dragged_skin_data(filePath)
|
||||
await processData(data.buffer)
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
title: 'Error processing file',
|
||||
text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set up drag and drop listener:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupDragDropListener() {
|
||||
if (unlisten.value) {
|
||||
unlisten.value()
|
||||
unlisten.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function processData(buffer: ArrayBuffer) {
|
||||
emit('uploaded', buffer)
|
||||
hide()
|
||||
}
|
||||
|
||||
watch(modalVisible, (isVisible) => {
|
||||
if (isVisible) {
|
||||
setupDragDropListener()
|
||||
} else {
|
||||
cleanupDragDropListener()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDragDropListener()
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,410 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
SkinButton,
|
||||
SkinLikeTextButton,
|
||||
useScrollViewport,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import type { Skin } from '@/helpers/skins.ts'
|
||||
|
||||
type SkinSectionKind = 'saved' | 'default'
|
||||
type SkinLikeTextButtonExpose = {
|
||||
getRootElement: () => HTMLElement | null | undefined
|
||||
}
|
||||
type AddSkinButtonRef = SkinLikeTextButtonExpose | SkinLikeTextButtonExpose[]
|
||||
|
||||
interface DefaultSkinSection {
|
||||
title: string
|
||||
skins: Skin[]
|
||||
}
|
||||
|
||||
interface SkinSection {
|
||||
key: string
|
||||
title: string
|
||||
kind: SkinSectionKind
|
||||
skins: Skin[]
|
||||
}
|
||||
|
||||
interface VirtualSkinSection {
|
||||
section: SkinSection
|
||||
top: number
|
||||
index: number
|
||||
}
|
||||
|
||||
const SKIN_CARD_ASPECT_WIDTH = 31
|
||||
const SKIN_CARD_ASPECT_HEIGHT = 40
|
||||
const SKIN_GRID_GAP = 12
|
||||
const SKIN_SECTION_FIRST_SPACING = 4
|
||||
const SKIN_SECTION_SPACING = 24
|
||||
const SKIN_SECTION_HEADER_HEIGHT = 28
|
||||
const SKIN_SECTION_CONTENT_SPACING = 8
|
||||
const SKIN_SECTION_OVERSCAN = 900
|
||||
const FALLBACK_CARD_WIDTH = 220
|
||||
const messages = defineMessages({
|
||||
savedSkinsSection: {
|
||||
id: 'app.skins.section.saved-skins',
|
||||
defaultMessage: 'Saved skins',
|
||||
},
|
||||
addSkinButton: {
|
||||
id: 'app.skins.add-button',
|
||||
defaultMessage: 'Add skin',
|
||||
},
|
||||
dragAndDropSubtitle: {
|
||||
id: 'app.skins.add-button.drag-and-drop',
|
||||
defaultMessage: 'Drag and drop',
|
||||
},
|
||||
editSkinButton: {
|
||||
id: 'app.skins.edit-button',
|
||||
defaultMessage: 'Edit skin',
|
||||
},
|
||||
deleteSkinButton: {
|
||||
id: 'app.skins.delete-button',
|
||||
defaultMessage: 'Delete skin',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
savedSkins: Skin[]
|
||||
defaultSkinSections: DefaultSkinSection[]
|
||||
getBakedSkinTextures: (skin: Skin) => RenderResult | undefined
|
||||
isSkinSelected: (skin: Skin) => boolean
|
||||
isSkinActive: (skin: Skin) => boolean
|
||||
isAddSkinButtonDragActive: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [skin: Skin]
|
||||
edit: [skin: Skin, event: MouseEvent]
|
||||
delete: [skin: Skin]
|
||||
'add-skin': []
|
||||
'add-skin-dragenter': [event: DragEvent]
|
||||
'add-skin-dragover': [event: DragEvent]
|
||||
'add-skin-dragleave': [event: DragEvent]
|
||||
'add-skin-drop': [event: DragEvent]
|
||||
}>()
|
||||
|
||||
const addSkinButton = useTemplateRef<AddSkinButtonRef>('addSkinButton')
|
||||
const { formatMessage } = useVIntl()
|
||||
const { listContainer, relativeScrollTop, scrollContainer, viewportHeight } = useScrollViewport()
|
||||
const openSectionKeys = ref<Set<string>>(new Set())
|
||||
const hasSettledInitialLayout = ref(false)
|
||||
const knownSectionKeys = new Set<string>()
|
||||
let enableLayoutTransitionsFrame: number | null = null
|
||||
let isEnableLayoutTransitionsScheduled = false
|
||||
let isUnmounted = false
|
||||
|
||||
const { width: listWidth } = useElementSize(listContainer)
|
||||
const { width: windowWidth } = useWindowSize()
|
||||
|
||||
const columnCount = computed(() => {
|
||||
if (windowWidth.value >= 2050) {
|
||||
return 6
|
||||
}
|
||||
|
||||
if (windowWidth.value >= 1750) {
|
||||
return 5
|
||||
}
|
||||
|
||||
if (windowWidth.value >= 1300) {
|
||||
return 4
|
||||
}
|
||||
|
||||
return 3
|
||||
})
|
||||
|
||||
const cardWidth = computed(() => {
|
||||
if (listWidth.value <= 0) {
|
||||
return FALLBACK_CARD_WIDTH
|
||||
}
|
||||
|
||||
const gapsWidth = (columnCount.value - 1) * SKIN_GRID_GAP
|
||||
return Math.max(0, (listWidth.value - gapsWidth) / columnCount.value)
|
||||
})
|
||||
|
||||
const cardHeight = computed(
|
||||
() => (cardWidth.value * SKIN_CARD_ASPECT_HEIGHT) / SKIN_CARD_ASPECT_WIDTH,
|
||||
)
|
||||
|
||||
const sections = computed<SkinSection[]>(() => [
|
||||
{
|
||||
key: 'saved-skins',
|
||||
title: formatMessage(messages.savedSkinsSection),
|
||||
kind: 'saved',
|
||||
skins: props.savedSkins,
|
||||
},
|
||||
...props.defaultSkinSections.map((section) => ({
|
||||
key: defaultSkinSectionKey(section.title),
|
||||
title: section.title,
|
||||
kind: 'default' as const,
|
||||
skins: section.skins,
|
||||
})),
|
||||
])
|
||||
|
||||
const sectionLayouts = computed(() => {
|
||||
const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = []
|
||||
let top = 0
|
||||
|
||||
sections.value.forEach((section, index) => {
|
||||
const height = getSectionHeightEstimate(section, index)
|
||||
layouts.push({ section, top, height, index })
|
||||
top += height
|
||||
})
|
||||
|
||||
return layouts
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
const lastSection = sectionLayouts.value[sectionLayouts.value.length - 1]
|
||||
return lastSection ? lastSection.top + lastSection.height : 0
|
||||
})
|
||||
|
||||
const visibleSections = computed<VirtualSkinSection[]>(() => {
|
||||
if (!listContainer.value || !scrollContainer.value) {
|
||||
return sectionLayouts.value.slice(0, 4)
|
||||
}
|
||||
|
||||
const viewportStart = Math.max(0, relativeScrollTop.value - SKIN_SECTION_OVERSCAN)
|
||||
const viewportEnd = relativeScrollTop.value + viewportHeight.value + SKIN_SECTION_OVERSCAN
|
||||
|
||||
return sectionLayouts.value
|
||||
.filter((layout) => layout.top + layout.height >= viewportStart && layout.top <= viewportEnd)
|
||||
.map(({ section, top, index }) => ({ section, top, index }))
|
||||
})
|
||||
|
||||
watch(
|
||||
sections,
|
||||
(nextSections) => {
|
||||
const sectionKeys = new Set(nextSections.map((section) => section.key))
|
||||
const openKeys = new Set(openSectionKeys.value)
|
||||
|
||||
for (const section of nextSections) {
|
||||
if (!knownSectionKeys.has(section.key)) {
|
||||
knownSectionKeys.add(section.key)
|
||||
openKeys.add(section.key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of knownSectionKeys) {
|
||||
if (!sectionKeys.has(key)) {
|
||||
knownSectionKeys.delete(key)
|
||||
openKeys.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
openSectionKeys.value = openKeys
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
listWidth,
|
||||
(width) => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
width <= 0 ||
|
||||
hasSettledInitialLayout.value ||
|
||||
isEnableLayoutTransitionsScheduled
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
isEnableLayoutTransitionsScheduled = true
|
||||
void nextTick(() => {
|
||||
if (isUnmounted) return
|
||||
|
||||
enableLayoutTransitionsFrame = window.requestAnimationFrame(() => {
|
||||
if (isUnmounted) return
|
||||
|
||||
enableLayoutTransitionsFrame = window.requestAnimationFrame(() => {
|
||||
if (isUnmounted) return
|
||||
|
||||
hasSettledInitialLayout.value = true
|
||||
enableLayoutTransitionsFrame = null
|
||||
isEnableLayoutTransitionsScheduled = false
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
isUnmounted = true
|
||||
|
||||
if (enableLayoutTransitionsFrame !== null) {
|
||||
window.cancelAnimationFrame(enableLayoutTransitionsFrame)
|
||||
}
|
||||
})
|
||||
|
||||
function defaultSkinSectionKey(title: string) {
|
||||
return `default-skins-${title}`
|
||||
}
|
||||
|
||||
function skinKey(skin: Skin, prefix: string) {
|
||||
return `${prefix}-${skin.source}-${skin.texture_key}-${skin.variant}-${skin.cape_id ?? 'no-cape'}`
|
||||
}
|
||||
|
||||
function isSectionOpen(key: string) {
|
||||
return openSectionKeys.value.has(key)
|
||||
}
|
||||
|
||||
function setSectionOpen(key: string, open: boolean) {
|
||||
const openKeys = new Set(openSectionKeys.value)
|
||||
|
||||
if (open) {
|
||||
openKeys.add(key)
|
||||
} else {
|
||||
openKeys.delete(key)
|
||||
}
|
||||
|
||||
openSectionKeys.value = openKeys
|
||||
}
|
||||
|
||||
function getSectionHeightEstimate(section: SkinSection, index: number) {
|
||||
const spacing = index === 0 ? SKIN_SECTION_FIRST_SPACING : SKIN_SECTION_SPACING
|
||||
|
||||
if (!isSectionOpen(section.key)) {
|
||||
return spacing + SKIN_SECTION_HEADER_HEIGHT
|
||||
}
|
||||
|
||||
const cardCount = section.kind === 'saved' ? section.skins.length + 1 : section.skins.length
|
||||
const rowCount = Math.ceil(cardCount / columnCount.value)
|
||||
const gridHeight = rowCount * cardHeight.value + Math.max(0, rowCount - 1) * SKIN_GRID_GAP
|
||||
|
||||
return spacing + SKIN_SECTION_HEADER_HEIGHT + SKIN_SECTION_CONTENT_SPACING + gridHeight
|
||||
}
|
||||
|
||||
function getAddSkinButtonElement() {
|
||||
const button = Array.isArray(addSkinButton.value)
|
||||
? addSkinButton.value.find((candidate) => candidate.getRootElement())
|
||||
: addSkinButton.value
|
||||
|
||||
return button?.getRootElement()
|
||||
}
|
||||
|
||||
defineExpose({ getAddSkinButtonElement })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="listContainer"
|
||||
class="relative w-full"
|
||||
:style="{ height: `${totalHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div
|
||||
v-for="{ section, top, index } in visibleSections"
|
||||
:key="section.key"
|
||||
class="absolute inset-x-0"
|
||||
:class="[
|
||||
index === 0 ? 'pt-1' : 'pt-6',
|
||||
hasSettledInitialLayout
|
||||
? 'transition-transform duration-300 ease-in-out will-change-transform motion-reduce:transition-none'
|
||||
: '',
|
||||
]"
|
||||
:style="{ transform: `translateY(${top}px)` }"
|
||||
>
|
||||
<Accordion
|
||||
button-class="group flex w-full items-center gap-[6px] bg-transparent m-0 p-0 border-none cursor-pointer text-left"
|
||||
content-class="pt-2"
|
||||
:open-by-default="isSectionOpen(section.key)"
|
||||
@on-open="setSectionOpen(section.key, true)"
|
||||
@on-close="setSectionOpen(section.key, false)"
|
||||
>
|
||||
<template #title>
|
||||
{{ section.title }}
|
||||
</template>
|
||||
<template #button="{ open }">
|
||||
<DropdownIcon
|
||||
class="size-6 shrink-0 text-primary transition-transform duration-300"
|
||||
:class="{ 'rotate-180': open }"
|
||||
/>
|
||||
<span class="min-w-0 text-xl font-semibold leading-7 text-primary">
|
||||
{{ section.title }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="section.kind === 'saved'"
|
||||
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
|
||||
>
|
||||
<SkinLikeTextButton
|
||||
ref="addSkinButton"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
dropzone
|
||||
:drag-active="isAddSkinButtonDragActive"
|
||||
@click="emit('add-skin')"
|
||||
@dragenter="emit('add-skin-dragenter', $event)"
|
||||
@dragover="emit('add-skin-dragover', $event)"
|
||||
@dragleave="emit('add-skin-dragleave', $event)"
|
||||
@drop="emit('add-skin-drop', $event)"
|
||||
>
|
||||
<template #icon>
|
||||
<PlusIcon class="size-8" />
|
||||
</template>
|
||||
{{ formatMessage(messages.addSkinButton) }}
|
||||
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
|
||||
</SkinLikeTextButton>
|
||||
|
||||
<SkinButton
|
||||
v-for="skin in section.skins"
|
||||
:key="skinKey(skin, 'saved-skin')"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
|
||||
>
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.deleteSkinButton)"
|
||||
:aria-label="formatMessage(messages.deleteSkinButton)"
|
||||
class="!rounded-[100%] pointer-events-auto"
|
||||
@click.stop="emit('delete', skin)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
|
||||
>
|
||||
<SkinButton
|
||||
v-for="skin in section.skins"
|
||||
:key="skinKey(skin, section.key)"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:tooltip="skin.name"
|
||||
@select="emit('select', skin)"
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -244,7 +244,7 @@ const messages = defineMessages({
|
||||
</TagItem>
|
||||
<div
|
||||
v-if="world.type === 'singleplayer'"
|
||||
class="text-sm text-secondary flex items-center gap-1 font-semibold"
|
||||
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
|
||||
>
|
||||
<UserIcon
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
|
||||
interface ThemeStore {
|
||||
toggleSidebar: boolean
|
||||
$subscribe: (callback: () => void) => () => void
|
||||
}
|
||||
|
||||
interface IntercomBubblePosition {
|
||||
horizontalPadding: number
|
||||
verticalPadding: number
|
||||
}
|
||||
|
||||
const APP_LEFT_NAV_WIDTH = '4rem'
|
||||
const APP_SIDEBAR_WIDTH = 300
|
||||
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
|
||||
const INTERCOM_BUBBLE_WIDTH = 72
|
||||
const INTERCOM_BUBBLE_RIGHT_VAR = '--app-support-launcher-right'
|
||||
const INTERCOM_BUBBLE_BOTTOM_VAR = '--app-support-launcher-bottom'
|
||||
|
||||
export function useIntercomPositioning({
|
||||
route,
|
||||
themeStore,
|
||||
}: {
|
||||
route: RouteLocationNormalizedLoaded
|
||||
themeStore: ThemeStore
|
||||
}) {
|
||||
const sidebarToggled = ref(true)
|
||||
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
|
||||
sidebarToggled.value = !themeStore.toggleSidebar
|
||||
})
|
||||
|
||||
onUnmounted(unsubscribeSidebarToggle)
|
||||
|
||||
const forceSidebar = computed(
|
||||
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
|
||||
)
|
||||
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
|
||||
const defaultIntercomBubbleHorizontalPadding = computed(() =>
|
||||
sidebarVisible.value
|
||||
? APP_SIDEBAR_WIDTH + INTERCOM_BUBBLE_DEFAULT_PADDING
|
||||
: INTERCOM_BUBBLE_DEFAULT_PADDING,
|
||||
)
|
||||
const intercomBubbleRequestedHorizontalPadding = ref<number | null>(null)
|
||||
const intercomBubbleHorizontalPadding = computed(
|
||||
() =>
|
||||
intercomBubbleRequestedHorizontalPadding.value ??
|
||||
defaultIntercomBubbleHorizontalPadding.value,
|
||||
)
|
||||
const intercomBubbleVerticalClearance = ref<number | null>(null)
|
||||
const intercomBubblePosition = computed(() => ({
|
||||
horizontalPadding: intercomBubbleHorizontalPadding.value,
|
||||
verticalPadding: intercomBubbleVerticalClearance.value ?? INTERCOM_BUBBLE_DEFAULT_PADDING,
|
||||
}))
|
||||
const intercomBubbleHorizontalPaddingRequests = new Map<symbol, number>()
|
||||
const intercomBubbleClearanceRequests = new Map<symbol, number>()
|
||||
|
||||
function requestIntercomBubbleHorizontalPadding(id: symbol, padding: number | null) {
|
||||
if (padding === null) {
|
||||
intercomBubbleHorizontalPaddingRequests.delete(id)
|
||||
} else {
|
||||
intercomBubbleHorizontalPaddingRequests.set(id, padding)
|
||||
}
|
||||
|
||||
intercomBubbleRequestedHorizontalPadding.value =
|
||||
intercomBubbleHorizontalPaddingRequests.size > 0
|
||||
? Math.max(...intercomBubbleHorizontalPaddingRequests.values())
|
||||
: null
|
||||
}
|
||||
|
||||
function requestIntercomBubbleVerticalClearance(id: symbol, clearance: number | null) {
|
||||
if (clearance === null) {
|
||||
intercomBubbleClearanceRequests.delete(id)
|
||||
} else {
|
||||
intercomBubbleClearanceRequests.set(id, clearance)
|
||||
}
|
||||
|
||||
intercomBubbleVerticalClearance.value =
|
||||
intercomBubbleClearanceRequests.size > 0
|
||||
? Math.max(...intercomBubbleClearanceRequests.values())
|
||||
: null
|
||||
}
|
||||
|
||||
function updateIntercomBubbleStyles({
|
||||
horizontalPadding,
|
||||
verticalPadding,
|
||||
}: IntercomBubblePosition) {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
document.documentElement.style.setProperty(INTERCOM_BUBBLE_RIGHT_VAR, `${horizontalPadding}px`)
|
||||
document.documentElement.style.setProperty(INTERCOM_BUBBLE_BOTTOM_VAR, `${verticalPadding}px`)
|
||||
}
|
||||
|
||||
function clearIntercomBubbleStyles() {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
document.documentElement.style.removeProperty(INTERCOM_BUBBLE_RIGHT_VAR)
|
||||
document.documentElement.style.removeProperty(INTERCOM_BUBBLE_BOTTOM_VAR)
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarToggled,
|
||||
forceSidebar,
|
||||
sidebarVisible,
|
||||
intercomBubblePosition,
|
||||
updateIntercomBubbleStyles,
|
||||
clearIntercomBubbleStyles,
|
||||
pageContext: {
|
||||
floatingActionBarOffsets: {
|
||||
left: ref(APP_LEFT_NAV_WIDTH),
|
||||
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
|
||||
},
|
||||
intercomBubble: {
|
||||
width: ref(INTERCOM_BUBBLE_WIDTH),
|
||||
horizontalPadding: intercomBubbleHorizontalPadding,
|
||||
requestHorizontalPadding: requestIntercomBubbleHorizontalPadding,
|
||||
requestVerticalClearance: requestIntercomBubbleVerticalClearance,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ interface LogEntry {
|
||||
filename: string
|
||||
name?: string
|
||||
log_type: string
|
||||
stdout?: string
|
||||
output?: string | null
|
||||
age?: number
|
||||
live?: boolean
|
||||
}
|
||||
@@ -50,12 +50,12 @@ async function getHistoricalLogs(profilePathId: string, instancePath: string): P
|
||||
const entry = getOrCreate(profilePathId)
|
||||
if (entry.logList) return entry.logList
|
||||
|
||||
const logs: LogEntry[] = await get_logs(instancePath, false)
|
||||
const logs: LogEntry[] = await get_logs(instancePath, true)
|
||||
entry.logList = logs
|
||||
|
||||
for (const log of logs) {
|
||||
if (log.stdout && log.stdout !== '') {
|
||||
entry.historicalCache.set(log.filename, log.stdout)
|
||||
if (log.output) {
|
||||
entry.historicalCache.set(log.filename, log.output)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { test_jre } from '@/helpers/jre.js'
|
||||
|
||||
export default function useJavaTest() {
|
||||
const testingJava = ref(false)
|
||||
const javaTestResult = ref<boolean | null>(null)
|
||||
let testDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function runJavaTest(path: string, version: number, track = true) {
|
||||
if (testDebounceTimer) {
|
||||
clearTimeout(testDebounceTimer)
|
||||
testDebounceTimer = null
|
||||
}
|
||||
if (!path) {
|
||||
javaTestResult.value = null
|
||||
return
|
||||
}
|
||||
testingJava.value = true
|
||||
try {
|
||||
javaTestResult.value = await test_jre(path, version)
|
||||
} catch {
|
||||
javaTestResult.value = false
|
||||
}
|
||||
testingJava.value = false
|
||||
|
||||
if (track) {
|
||||
trackEvent('JavaTest', { path, success: javaTestResult.value })
|
||||
}
|
||||
}
|
||||
|
||||
function testJavaInstallationDebounced(path: string, version: number, delay = 600) {
|
||||
if (testDebounceTimer) clearTimeout(testDebounceTimer)
|
||||
if (!path) {
|
||||
javaTestResult.value = null
|
||||
return
|
||||
}
|
||||
testDebounceTimer = setTimeout(() => runJavaTest(path, version, false), delay)
|
||||
}
|
||||
|
||||
async function testJavaInstallation(path: string, version: number, track = false) {
|
||||
await runJavaTest(path, version, track)
|
||||
}
|
||||
|
||||
return {
|
||||
testingJava,
|
||||
javaTestResult,
|
||||
testJavaInstallationDebounced,
|
||||
testJavaInstallation,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
ContentItem,
|
||||
ContentModpackCardCategory,
|
||||
ContentModpackCardProject,
|
||||
ContentModpackCardVersion,
|
||||
ContentOwner,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
get_content_items,
|
||||
get_linked_modpack_info,
|
||||
type LinkedModpackInfo,
|
||||
} from '@/helpers/profile'
|
||||
import { get_categories } from '@/helpers/tags.js'
|
||||
import type { CacheBehaviour } from '@/helpers/types'
|
||||
|
||||
export type InstanceContentData = {
|
||||
path: string
|
||||
contentItems: ContentItem[] | null
|
||||
modpack: InstanceContentModpackData | null
|
||||
}
|
||||
|
||||
export type InstanceContentModpackData = {
|
||||
project: ContentModpackCardProject
|
||||
version: ContentModpackCardVersion
|
||||
owner: ContentOwner | null
|
||||
categories: ContentModpackCardCategory[]
|
||||
hasUpdate: boolean
|
||||
updateVersionId: string | null
|
||||
}
|
||||
|
||||
export async function loadInstanceContentData(
|
||||
path: string,
|
||||
cacheBehaviour?: CacheBehaviour,
|
||||
onError?: (error: Error) => unknown,
|
||||
): Promise<InstanceContentData> {
|
||||
const [contentItems, modpackInfo, allCategories] = await Promise.all([
|
||||
get_content_items(path, cacheBehaviour).catch((error) => handleLoadError(error, onError)),
|
||||
get_linked_modpack_info(path, cacheBehaviour).catch((error) => handleLoadError(error, onError)),
|
||||
get_categories().catch((error) => handleLoadError(error, onError)),
|
||||
])
|
||||
|
||||
return {
|
||||
path,
|
||||
contentItems: (contentItems as ContentItem[] | null | undefined) ?? null,
|
||||
modpack: normalizeLinkedModpackInfo(
|
||||
modpackInfo as LinkedModpackInfo | null | undefined,
|
||||
allCategories as ContentModpackCardCategory[] | null | undefined,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoadError(error: unknown, onError?: (error: Error) => unknown) {
|
||||
onError?.(error as Error)
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeLinkedModpackInfo(
|
||||
modpackInfo: LinkedModpackInfo | null | undefined,
|
||||
allCategories: ContentModpackCardCategory[] | null | undefined,
|
||||
): InstanceContentModpackData | null {
|
||||
if (!modpackInfo) return null
|
||||
|
||||
return {
|
||||
project: {
|
||||
...modpackInfo.project,
|
||||
slug: modpackInfo.project.slug ?? modpackInfo.project.id,
|
||||
icon_url: modpackInfo.project.icon_url ?? undefined,
|
||||
},
|
||||
version: {
|
||||
...modpackInfo.version,
|
||||
date_published: modpackInfo.version.date_published.toString(),
|
||||
},
|
||||
owner: modpackInfo.owner
|
||||
? {
|
||||
...modpackInfo.owner,
|
||||
avatar_url: modpackInfo.owner.avatar_url ?? undefined,
|
||||
}
|
||||
: null,
|
||||
categories: resolveLinkedModpackCategories(modpackInfo, allCategories),
|
||||
hasUpdate: modpackInfo.has_update,
|
||||
updateVersionId: modpackInfo.update_version_id,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLinkedModpackCategories(
|
||||
modpackInfo: LinkedModpackInfo,
|
||||
allCategories: ContentModpackCardCategory[] | null | undefined,
|
||||
) {
|
||||
if (!allCategories || !modpackInfo.project.categories) return []
|
||||
|
||||
const seen = new Set<string>()
|
||||
return allCategories.filter((category) => {
|
||||
if (modpackInfo.project.categories.includes(category.name) && !seen.has(category.name)) {
|
||||
seen.add(category.name)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
@@ -6,12 +6,11 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
/*
|
||||
A log is a struct containing the filename string, stdout, and stderr, as follows:
|
||||
A log is a struct containing the filename string and optional output, as follows:
|
||||
|
||||
pub struct Logs {
|
||||
pub filename: String,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub output: Option<String>,
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
@@ -147,9 +147,16 @@ export async function get_mod_full_path(path: string, projectPath: string): Prom
|
||||
return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
|
||||
}
|
||||
|
||||
export interface JavaVersion {
|
||||
parsed_version: number
|
||||
version: string
|
||||
architecture: string
|
||||
path: string
|
||||
}
|
||||
|
||||
// Get optimal java version from profile
|
||||
// Returns a java version
|
||||
export async function get_optimal_jre_key(path: string): Promise<string | null> {
|
||||
export async function get_optimal_jre_key(path: string): Promise<JavaVersion | null> {
|
||||
return await invoke('plugin:profile|profile_get_optimal_jre_key', { path })
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ import {
|
||||
applyCapeTexture,
|
||||
createTransparentTexture,
|
||||
disposeCaches,
|
||||
loadTexture,
|
||||
setupSkinModel,
|
||||
} from '@modrinth/utils'
|
||||
} from '@modrinth/ui'
|
||||
import * as THREE from 'three'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
@@ -29,6 +28,7 @@ class BatchSkinRenderer {
|
||||
private scene: THREE.Scene | null = null
|
||||
private camera: THREE.PerspectiveCamera | null = null
|
||||
private currentModel: THREE.Group | null = null
|
||||
private transparentTexture: THREE.Texture | null = null
|
||||
private readonly width: number
|
||||
private readonly height: number
|
||||
|
||||
@@ -52,6 +52,7 @@ class BatchSkinRenderer {
|
||||
})
|
||||
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
this.renderer.shadowMap.enabled = false
|
||||
this.renderer.toneMapping = THREE.NoToneMapping
|
||||
this.renderer.toneMappingExposure = 10.0
|
||||
this.renderer.setClearColor(0x000000, 0)
|
||||
@@ -62,7 +63,7 @@ class BatchSkinRenderer {
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||
directionalLight.castShadow = true
|
||||
directionalLight.castShadow = false
|
||||
directionalLight.position.set(2, 4, 3)
|
||||
this.scene.add(ambientLight)
|
||||
this.scene.add(directionalLight)
|
||||
@@ -112,9 +113,19 @@ class BatchSkinRenderer {
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
|
||||
const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
|
||||
const response = await fetch(dataUrl)
|
||||
return await response.blob()
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
this.renderer!.domElement.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Failed to create blob from rendered canvas'))
|
||||
}
|
||||
},
|
||||
'image/webp',
|
||||
0.9,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
|
||||
@@ -122,14 +133,10 @@ class BatchSkinRenderer {
|
||||
throw new Error('Renderer not initialized')
|
||||
}
|
||||
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl)
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl, capeUrl)
|
||||
|
||||
if (capeUrl) {
|
||||
const capeTexture = await loadTexture(capeUrl)
|
||||
applyCapeTexture(model, capeTexture)
|
||||
} else {
|
||||
const transparentTexture = createTransparentTexture()
|
||||
applyCapeTexture(model, null, transparentTexture)
|
||||
if (!capeUrl) {
|
||||
applyCapeTexture(model, null, this.getTransparentTexture())
|
||||
}
|
||||
|
||||
const group = new THREE.Group()
|
||||
@@ -141,39 +148,38 @@ class BatchSkinRenderer {
|
||||
this.currentModel = group
|
||||
}
|
||||
|
||||
private clearScene(): void {
|
||||
if (!this.scene) return
|
||||
|
||||
while (this.scene.children.length > 0) {
|
||||
const child = this.scene.children[0]
|
||||
this.scene.remove(child)
|
||||
|
||||
if (child instanceof THREE.Mesh) {
|
||||
if (child.geometry) child.geometry.dispose()
|
||||
if (child.material) {
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach((material) => material.dispose())
|
||||
} else {
|
||||
child.material.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
private getTransparentTexture(): THREE.Texture {
|
||||
if (!this.transparentTexture) {
|
||||
this.transparentTexture = createTransparentTexture()
|
||||
}
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||
directionalLight.castShadow = true
|
||||
directionalLight.position.set(2, 4, 3)
|
||||
this.scene.add(ambientLight)
|
||||
this.scene.add(directionalLight)
|
||||
return this.transparentTexture
|
||||
}
|
||||
|
||||
private clearScene(): void {
|
||||
if (!this.scene || !this.currentModel) return
|
||||
|
||||
this.scene.remove(this.currentModel)
|
||||
this.currentModel.clear()
|
||||
this.currentModel = null
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.clearScene()
|
||||
|
||||
if (this.transparentTexture) {
|
||||
this.transparentTexture.dispose()
|
||||
this.transparentTexture = null
|
||||
}
|
||||
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose()
|
||||
}
|
||||
|
||||
this.renderer = null
|
||||
this.scene = null
|
||||
this.camera = null
|
||||
|
||||
disposeCaches()
|
||||
}
|
||||
}
|
||||
@@ -194,6 +200,9 @@ export const headBlobUrlMap = reactive(new Map<string, string>())
|
||||
const DEBUG_MODE = false
|
||||
|
||||
let sharedRenderer: BatchSkinRenderer | null = null
|
||||
let latestPreviewGeneration = 0
|
||||
let previewGenerationQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
function getSharedRenderer(): BatchSkinRenderer {
|
||||
if (!sharedRenderer) {
|
||||
sharedRenderer = new BatchSkinRenderer()
|
||||
@@ -356,7 +365,27 @@ export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
|
||||
return await generateHeadRender(skin)
|
||||
}
|
||||
|
||||
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
|
||||
export function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
|
||||
const generation = ++latestPreviewGeneration
|
||||
const skinsSnapshot = [...skins]
|
||||
const capesSnapshot = [...capes]
|
||||
|
||||
const generationPromise = previewGenerationQueue.then(() =>
|
||||
generateSkinPreviewsForGeneration(skinsSnapshot, capesSnapshot, generation),
|
||||
)
|
||||
|
||||
previewGenerationQueue = generationPromise.catch(() => {})
|
||||
|
||||
return generationPromise
|
||||
}
|
||||
|
||||
async function generateSkinPreviewsForGeneration(
|
||||
skins: Skin[],
|
||||
capes: Cape[],
|
||||
generation: number,
|
||||
): Promise<void> {
|
||||
const isCurrentGeneration = () => generation === latestPreviewGeneration
|
||||
|
||||
try {
|
||||
const skinKeys = skins.map(
|
||||
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
|
||||
@@ -368,6 +397,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
|
||||
headStorage.batchRetrieve(headKeys),
|
||||
])
|
||||
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
for (let i = 0; i < skins.length; i++) {
|
||||
const skinKey = skinKeys[i]
|
||||
const headKey = headKeys[i]
|
||||
@@ -388,6 +419,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
|
||||
}
|
||||
|
||||
for (const skin of skins) {
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
|
||||
if (skinBlobUrlMap.has(key)) {
|
||||
@@ -419,6 +452,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
|
||||
cape?.texture,
|
||||
)
|
||||
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
const renderResult: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawRenderResult.forwards),
|
||||
backwards: URL.createObjectURL(rawRenderResult.backwards),
|
||||
@@ -439,9 +474,12 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
|
||||
}
|
||||
} finally {
|
||||
disposeSharedRenderer()
|
||||
await cleanupUnusedPreviews(skins)
|
||||
|
||||
await skinPreviewStorage.debugCalculateStorage()
|
||||
await headStorage.debugCalculateStorage()
|
||||
if (isCurrentGeneration()) {
|
||||
await cleanupUnusedPreviews(skins)
|
||||
|
||||
await skinPreviewStorage.debugCalculateStorage()
|
||||
await headStorage.debugCalculateStorage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export interface Cape {
|
||||
id: string
|
||||
name: string
|
||||
texture: string
|
||||
is_default: boolean
|
||||
is_equipped: boolean
|
||||
}
|
||||
|
||||
@@ -15,6 +14,7 @@ export type SkinSource = 'default' | 'custom_external' | 'custom'
|
||||
export interface Skin {
|
||||
texture_key: string
|
||||
name?: string
|
||||
section?: string
|
||||
variant: SkinModel
|
||||
cape_id?: string
|
||||
texture: string
|
||||
@@ -121,17 +121,11 @@ export async function get_available_skins(): Promise<Skin[]> {
|
||||
export async function add_and_equip_custom_skin(
|
||||
textureBlob: Uint8Array,
|
||||
variant: SkinModel,
|
||||
capeOverride?: Cape,
|
||||
): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
|
||||
cape?: Cape,
|
||||
): Promise<Skin> {
|
||||
return await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
|
||||
textureBlob,
|
||||
variant,
|
||||
capeOverride,
|
||||
})
|
||||
}
|
||||
|
||||
export async function set_default_cape(cape?: Cape): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|set_default_cape', {
|
||||
cape,
|
||||
})
|
||||
}
|
||||
@@ -148,6 +142,22 @@ export async function remove_custom_skin(skin: Skin): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function save_custom_skin(
|
||||
skin: Skin,
|
||||
textureBlob: Uint8Array,
|
||||
variant: SkinModel,
|
||||
cape: Cape | undefined,
|
||||
replaceTexture: boolean,
|
||||
): Promise<Skin> {
|
||||
return await invoke('plugin:minecraft-skins|save_custom_skin', {
|
||||
skin,
|
||||
textureBlob,
|
||||
variant,
|
||||
cape,
|
||||
replaceTexture,
|
||||
})
|
||||
}
|
||||
|
||||
export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
|
||||
const data = await normalize_skin_texture(skin.texture)
|
||||
const base64 = arrayBufferToBase64(data)
|
||||
@@ -162,6 +172,16 @@ export async function unequip_skin(): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|unequip_skin')
|
||||
}
|
||||
|
||||
export async function flush_pending_skin_change(): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|flush_pending_skin_change')
|
||||
}
|
||||
|
||||
export async function flush_pending_skin_change_for_profile(profileId: string): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|flush_pending_skin_change_for_profile', {
|
||||
profileId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function get_dragged_skin_data(path: string): Promise<Uint8Array> {
|
||||
const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
|
||||
return new Uint8Array(data)
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
"app.action-bar.downloads": {
|
||||
"message": "التنزيلات"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "غير متصل"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "صفحة الرئيسية"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.library": {
|
||||
"message": "مكتبة"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "قد تكون خوادم مصادقة ماينكرافت معطلة حاليًا. تحقق من اتصالك بالإنترنت وحاول مرة أخرى لاحقًا."
|
||||
},
|
||||
@@ -566,6 +575,12 @@
|
||||
"instance.worlds.world_in_use": {
|
||||
"message": "العالم قيد الاستخدام"
|
||||
},
|
||||
"minecraft-account.add-account": {
|
||||
"message": "إضافة حساب"
|
||||
},
|
||||
"minecraft-account.select-account": {
|
||||
"message": "اختيار الحساب"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "مقدَّم من النسخة"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,115 @@
|
||||
{
|
||||
"app.action-bar.downloading-java": {
|
||||
"message": "Stahování Javy {version}"
|
||||
},
|
||||
"app.action-bar.downloads": {
|
||||
"message": "Stažené Soubory"
|
||||
},
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "Skrýt více spuštěných instancí"
|
||||
},
|
||||
"app.action-bar.make-primary-instance": {
|
||||
"message": "Použít jako primární instanci"
|
||||
},
|
||||
"app.action-bar.no-instances-running": {
|
||||
"message": "Žádné spuštěné instance"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "Primární instance"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "Zobrazit další spuštěné instance"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Ukončit instanci"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "Zobrazit probíhající stahování"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "Zobrazit instanci"
|
||||
},
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "Zobrazit záznamy"
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.description": {
|
||||
"message": "Umožňuje pokročilé vykreslování, jako jsou efekty rozostření, které mohou způsobit problémy s výkonem bez hardwarového vykreslení."
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.title": {
|
||||
"message": "Pokročilé vykreslování"
|
||||
},
|
||||
"app.appearance-settings.color-theme.description": {
|
||||
"message": "Vyberte si váš preferovaný motiv pro aplikaci Modrinth."
|
||||
},
|
||||
"app.appearance-settings.color-theme.title": {
|
||||
"message": "Barevný motiv"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.description": {
|
||||
"message": "Změňte stránku, na které se spouštěč otevře."
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "Domov"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.library": {
|
||||
"message": "Knihovna"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.title": {
|
||||
"message": "Výchozí úvodní stránka"
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.description": {
|
||||
"message": "Zakáže zobrazení jmenovky nad vaší postavou na stránce skinů."
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.title": {
|
||||
"message": "Skrýt jmenovku"
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.description": {
|
||||
"message": "V sekci „Vrátit se do hry“ na úvodní stránce jsou uvedeny nejnovější světy."
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.title": {
|
||||
"message": "Skočit zpět do světů"
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.description": {
|
||||
"message": "Minimalizovat spouštěč při spuštění procesu Minecraftu."
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.title": {
|
||||
"message": "Minimalizovat spouštěč"
|
||||
},
|
||||
"app.appearance-settings.native-decorations.description": {
|
||||
"message": "Použít systémový rámeček okna (vyžaduje restart aplikace)."
|
||||
},
|
||||
"app.appearance-settings.native-decorations.title": {
|
||||
"message": "Nativní dekorace"
|
||||
},
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Vyberte možnost"
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.description": {
|
||||
"message": "Umožňí přepínat zobrazení postranního panelu."
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.title": {
|
||||
"message": "Přepnout postranní panel"
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.description": {
|
||||
"message": "Pokud se pokusíte nainstalovat soubor Modrinth Pack (.mrpack), který není hostován na Modrinth, zajistíme, abyste si před instalací uvědomili související rizika."
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.title": {
|
||||
"message": "Upozorni mě před instalací neznámých modpacků"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "Autořizační servery Minecraftu aktuálně nejsou k dispozici. Zkontrolujte si své připojení k internetu a zkuste to znovu později."
|
||||
},
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Připojení k autorizačním serverům se nezdařilo"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Přidat server do instance"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Přidat do instalace"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Přidat do instalace"
|
||||
},
|
||||
@@ -17,12 +122,33 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "Už přidáno"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Zpět k instanci"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Prozkoumat obsah"
|
||||
},
|
||||
"app.browse.discover-servers": {
|
||||
"message": "Prozkoumat servery"
|
||||
},
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "Skrýt již přidané servery"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Balíčky Modifikací"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Přidání obsahu může narušit kompatibilitu při připojení k serveru. Veškerý přidaný obsah se navíc ztratí při aktualizaci obsahu instance serveru."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Instalování"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.description": {
|
||||
"message": "{fileName}"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.title": {
|
||||
"message": "Probíhá instalace modpacku..."
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Přidej popis modpacku..."
|
||||
},
|
||||
@@ -32,12 +158,18 @@
|
||||
"app.export-modal.header": {
|
||||
"message": "Exportovat modpack"
|
||||
},
|
||||
"app.export-modal.include-file-accessibility-label": {
|
||||
"message": "Zahrnout \"{file}\"?"
|
||||
},
|
||||
"app.export-modal.modpack-name-label": {
|
||||
"message": "Jméno Modpacku"
|
||||
},
|
||||
"app.export-modal.modpack-name-placeholder": {
|
||||
"message": "Jméno modpacku"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "Nastavte, které soubory mají být do tohoto exportu zahrnuty"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "Číslo verze"
|
||||
},
|
||||
@@ -92,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Procházet servery"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "\"{name}\" bude **navždy smazáno** a nebude způsob, jak to obnovit."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Opravdu si jsi jistý, že chceš navždy smazat tenhle svět?"
|
||||
},
|
||||
@@ -116,8 +245,8 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Žádné servery nebo světy nebyly přidány"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "„{name}“ bude odstraněn z tvého seznamu, včetně hry, a nebude žádný způsob, jak ho obnovit."
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "'{name}' ({address}) bude odstraněn z vašeho seznamu, a to i ve hře, a nebude možné jej obnovit."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Opravdu chceš odstranit {name}?"
|
||||
@@ -128,6 +257,9 @@
|
||||
"app.instance.worlds.this-server": {
|
||||
"message": "tento server"
|
||||
},
|
||||
"app.modal.install-to-play.content-required": {
|
||||
"message": "Požadovaný obsah"
|
||||
},
|
||||
"app.modal.install-to-play.header": {
|
||||
"message": "Nainstaluj ke hraní"
|
||||
},
|
||||
@@ -137,12 +269,21 @@
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural,one {#mód} other {#módy}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Povinný modpack"
|
||||
},
|
||||
"app.modal.install-to-play.server-requires-mods": {
|
||||
"message": "Tento server k hraní vyžaduje módy. Klikni na instalovat pro získání potřebných módů z Modrinth."
|
||||
},
|
||||
"app.modal.install-to-play.shared-instance": {
|
||||
"message": "Sdílená instance"
|
||||
},
|
||||
"app.modal.install-to-play.shared-server-instance": {
|
||||
"message": "Sdílená serverová instance"
|
||||
},
|
||||
"app.modal.install-to-play.view-contents": {
|
||||
"message": "Zobrazit obsah"
|
||||
},
|
||||
"app.modal.update-to-play.header": {
|
||||
"message": "Updatuj ke hraní"
|
||||
},
|
||||
@@ -152,6 +293,15 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "Je nutná aktualizace ke hraní {name}. Prosím updatuj na nejnovější verzi k spuštění hry."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Tento projekt je již nainstalován"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Zpět na Prozkoumat"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Nainstalovat obsah do instnce"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Vývojářský režim povolen."
|
||||
},
|
||||
@@ -176,9 +326,33 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Správa zdrojů"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Aplikace Modrinth v{version} je připravena k instalaci! Nainstalujte aktualizaci nyní nebo automaticky po zavření aplikace Modrinth."
|
||||
},
|
||||
"app.update-popup.body.download-complete": {
|
||||
"message": "Stahování aplikace Modrinth v{version} bylo dokončeno. Nainstalujte aktualizaci nyní nebo automaticky po zavření aplikace Modrinth."
|
||||
},
|
||||
"app.update-popup.body.linux": {
|
||||
"message": "Aplikace Modrinth v{version} je k dispozici. Aktualizujte pomocí svého správce balíčků, abyste získali nejnovější funkce a opravy!"
|
||||
},
|
||||
"app.update-popup.body.metered": {
|
||||
"message": "Aplikace Modrinth v{version} je nyní k dispozici! Protože jste v měřené síti, nebyla stažena automaticky."
|
||||
},
|
||||
"app.update-popup.changelog": {
|
||||
"message": "Seznam změn"
|
||||
},
|
||||
"app.update-popup.download": {
|
||||
"message": "Stáhnout ({size})"
|
||||
},
|
||||
"app.update-popup.download-complete": {
|
||||
"message": "Stahování bylo dokončeno"
|
||||
},
|
||||
"app.update-popup.reload": {
|
||||
"message": "Načíst znovu"
|
||||
},
|
||||
"app.update-popup.title": {
|
||||
"message": "Je k dispozici aktualizace"
|
||||
},
|
||||
"app.update.complete-toast.text": {
|
||||
"message": "Kliknutím sem zobrazíte seznam změn."
|
||||
},
|
||||
@@ -194,6 +368,24 @@
|
||||
"app.update.reload-to-update": {
|
||||
"message": "Restartovat aplikaci pro nainstalování aktualizace"
|
||||
},
|
||||
"app.world.server-modal.placeholder-address": {
|
||||
"message": "priklad.modrinth.gg"
|
||||
},
|
||||
"app.world.server-modal.select-an-option": {
|
||||
"message": "Vyberte možnost"
|
||||
},
|
||||
"app.world.world-item.incompatible-version": {
|
||||
"message": "Nekompatibilní verze {version}"
|
||||
},
|
||||
"app.world.world-item.not-played-yet": {
|
||||
"message": "Ještě nehráno"
|
||||
},
|
||||
"app.world.world-item.offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.world.world-item.players-online": {
|
||||
"message": "{count} online"
|
||||
},
|
||||
"friends.action.add-friend": {
|
||||
"message": "Přidat přítele"
|
||||
},
|
||||
@@ -245,9 +437,6 @@
|
||||
"friends.heading.pending": {
|
||||
"message": "Čeká na vyřízení"
|
||||
},
|
||||
"friends.no-friends-match": {
|
||||
"message": "Žádní přátelé neodpovídají '{query}''"
|
||||
},
|
||||
"friends.search-friends-placeholder": {
|
||||
"message": "Hledat přátele..."
|
||||
},
|
||||
@@ -293,6 +482,12 @@
|
||||
"instance.edit-world.title": {
|
||||
"message": "Upravit svět"
|
||||
},
|
||||
"instance.files.adding-files": {
|
||||
"message": "Přidávání souborů ({completed}/{total})"
|
||||
},
|
||||
"instance.files.save-as": {
|
||||
"message": "Uložit jako..."
|
||||
},
|
||||
"instance.server-modal.address": {
|
||||
"message": "Adresa"
|
||||
},
|
||||
@@ -473,6 +668,9 @@
|
||||
"instance.worlds.incompatible_server": {
|
||||
"message": "Server je nekompatibilní"
|
||||
},
|
||||
"instance.worlds.linked_server": {
|
||||
"message": "Spravováno projektem serveru"
|
||||
},
|
||||
"instance.worlds.no_contact": {
|
||||
"message": "Server se nepodařilo spojit"
|
||||
},
|
||||
@@ -491,6 +689,24 @@
|
||||
"instance.worlds.world_in_use": {
|
||||
"message": "Svět je použit"
|
||||
},
|
||||
"minecraft-account.add-account": {
|
||||
"message": "Přidat účet"
|
||||
},
|
||||
"minecraft-account.label": {
|
||||
"message": "Účet Minecraft"
|
||||
},
|
||||
"minecraft-account.not-signed-in": {
|
||||
"message": "Nejste přihlášeni"
|
||||
},
|
||||
"minecraft-account.remove-account": {
|
||||
"message": "Odstranit účet"
|
||||
},
|
||||
"minecraft-account.select-account": {
|
||||
"message": "Vyberte účet"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "Přihlásit se do Minecraftu"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Poskytováno instancí"
|
||||
},
|
||||
@@ -503,6 +719,9 @@
|
||||
"search.filter.locked.instance.sync": {
|
||||
"message": "Synchronizováno z instancí"
|
||||
},
|
||||
"search.filter.locked.server": {
|
||||
"message": "Poskytováno serverem"
|
||||
},
|
||||
"search.filter.locked.server-environment.title": {
|
||||
"message": "Jen klientské mody mohou být přidány na server"
|
||||
},
|
||||
@@ -511,5 +730,26 @@
|
||||
},
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "Loader zprostředkovává server"
|
||||
},
|
||||
"unknown-pack-warning-modal.body": {
|
||||
"message": "Soubor je zkontrolován pouze v případě, že je nahrán na Modrinth, a to bez ohledu na jeho formát (včetně formátu .mrpack)."
|
||||
},
|
||||
"unknown-pack-warning-modal.dont-show-again": {
|
||||
"message": "Toto varování již nezobrazovat"
|
||||
},
|
||||
"unknown-pack-warning-modal.header": {
|
||||
"message": "Potvrdit instalaci"
|
||||
},
|
||||
"unknown-pack-warning-modal.install-anyway": {
|
||||
"message": "Přesto nainstalovat"
|
||||
},
|
||||
"unknown-pack-warning-modal.malware-statement": {
|
||||
"message": "Malware se často šíří prostřednictvím souborů s modpacky, které se sdílejí na platformách jako Discord."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.body": {
|
||||
"message": "Tento soubor jsme na Modrinthu nenašli. Důrazně doporučujeme instalovat pouze soubory z důvěryhodných zdrojů."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.title": {
|
||||
"message": "Upozornění na neznámý soubor"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,12 @@
|
||||
"app.browse.discover-servers": {
|
||||
"message": "Opdag servere"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpacks"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.description": {
|
||||
"message": "{fileName}"
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Indtast modpack beskrivelse..."
|
||||
},
|
||||
|
||||
@@ -224,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Server durchsuchen"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "„{name}“ wird **endgültig gelöscht**, und es gibt keine Möglichkeit, sie wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Möchtest du diese Welt wirklich endgültig löschen?"
|
||||
},
|
||||
@@ -248,12 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Keine Server oder Welten hinzugefügt"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "„{name}“ wird aus deiner Liste entfernt, auch im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "„{name}“ ({address}) wird aus deiner Liste entfernt, inklusive im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Möchtest du {name} wirklich entfernen?"
|
||||
},
|
||||
@@ -303,7 +294,7 @@
|
||||
"message": "Dieses Projekt ist bereits installiert"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Zurück zum Durchstöbern"
|
||||
"message": "Zurück zum Entdecken"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Inhalt in Instanz installieren"
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"message": "Farbschema"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.description": {
|
||||
"message": "Ändere die Seite, die beim Öffnen des Launchers angezeigt wird."
|
||||
"message": "Ändere die Kategorie, die beim Öffnen des Launchers angezeigt wird."
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "Start"
|
||||
@@ -137,6 +137,9 @@
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpacks"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Das Hinzufügen von Inhalten kann die Kompatibilität beim Beitreten zum Server beeinträchtigen. Außerdem gehen alle hinzugefügten Inhalte verloren, wenn du die Inhalte der Serverinstanz aktualisierst."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Wird installiert"
|
||||
},
|
||||
@@ -221,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Server durchsuchen"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "„{name}“ wird **endgültig gelöscht**, und es gibt keine Möglichkeit, sie wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Möchtest du diese Welt wirklich endgültig löschen?"
|
||||
},
|
||||
@@ -245,12 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Keine Server oder Welten hinzugefügt"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "„{name}“ wird aus deiner Liste entfernt, auch im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "„{name}“ ({address}) wird aus deiner Liste entfernt, inklusive im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Möchtest du {name} wirklich entfernen?"
|
||||
},
|
||||
@@ -299,6 +293,9 @@
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Dieses Projekt ist bereits installiert"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Zurück zum Entdecken"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Inhalt in Instanz installieren"
|
||||
},
|
||||
@@ -519,7 +516,7 @@
|
||||
"message": "Wird gelöscht..."
|
||||
},
|
||||
"instance.settings.tabs.general.duplicate-button": {
|
||||
"message": "Kopieren"
|
||||
"message": "Duplizieren"
|
||||
},
|
||||
"instance.settings.tabs.general.duplicate-button.tooltip.installing": {
|
||||
"message": "Kann während der Installation nicht dupliziert werden."
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
"message": "This project is already installed"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Back to browse"
|
||||
"message": "Back to discover"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Install content to instance"
|
||||
@@ -332,6 +332,138 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Resource management"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Add skin"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Drag and drop"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Apply"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Delete skin"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "This will permanently delete the selected skin. This action cannot be undone."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Are you sure you want to delete this skin?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Failed to read the dropped file."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Error processing file"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Edit skin"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Add skin"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Adding a skin"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Arm style"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Slim"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Wide"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Cape"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Cape"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Editing skin"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Make an edit to the skin first!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "No cape"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "None"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Replace texture"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Save skin"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Saving..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Texture"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Upload a skin first!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Edit skin"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Previewing"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Slow down!"
|
||||
},
|
||||
"app.skins.section.builders-and-biomes": {
|
||||
"message": "Builders & Biomes"
|
||||
},
|
||||
"app.skins.section.chase-the-skies": {
|
||||
"message": "Chase the Skies"
|
||||
},
|
||||
"app.skins.section.default-skins": {
|
||||
"message": "Default skins"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Mounts of Mayhem"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Saved skins"
|
||||
},
|
||||
"app.skins.section.striding-hero": {
|
||||
"message": "Striding Hero"
|
||||
},
|
||||
"app.skins.section.the-copper-age": {
|
||||
"message": "The Copper Age"
|
||||
},
|
||||
"app.skins.section.the-garden-awakens": {
|
||||
"message": "The Garden Awakens"
|
||||
},
|
||||
"app.skins.section.tiny-takeover": {
|
||||
"message": "Tiny Takeover"
|
||||
},
|
||||
"app.skins.sign-in.button": {
|
||||
"message": "Sign In"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Please sign into your Minecraft account to use the skin management features of the Modrinth app."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Excited Modrinth Bot"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Please sign in"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Skin selector"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App."
|
||||
},
|
||||
@@ -611,6 +743,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java and memory"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Custom environment variables"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Custom Java arguments"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Custom Java installation"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Custom memory allocation"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Enter environmental variables..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Enter Java arguments..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Environment variables"
|
||||
},
|
||||
@@ -626,6 +776,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Memory allocated"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/path/to/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Window"
|
||||
},
|
||||
|
||||
@@ -104,6 +104,12 @@
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "No se puede acceder a los servidores de autenticación"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Añadiendo servidor a instancia"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Añadir a una instancia"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Añadir a instancia"
|
||||
},
|
||||
@@ -116,6 +122,9 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "Ya se ha añadido"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Volver a instancia"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Descubrir contenido"
|
||||
},
|
||||
@@ -128,6 +137,9 @@
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpacks"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Añadir contenido puede romper la compatibilidad cuando te unes al servidor. Cualquier contenido añadido tambien se perderá cuando actualizes el contenido en la instancia del servidor."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Instalando"
|
||||
},
|
||||
@@ -212,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Explorar servidores"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "\"{name}\" se **eliminará permanentemente** y no habrá forma de recuperarlo."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "¿Estás seguro de que quieres eliminar este mundo de forma permanente?"
|
||||
},
|
||||
@@ -236,12 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "No hay servidores ni mundos añadidos"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "\"{name}\" se eliminará de tu lista, también dentro del juego, y no podrás recuperarlo."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "\"{name}\" ({address}) se eliminará de tu lista, también dentro del juego, y no podrás recuperarlo."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "¿Estás seguro de que quieres eliminar {name}?"
|
||||
},
|
||||
@@ -287,6 +290,15 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "Se requiere una actualización para jugar {name}. Por favor, actualiza a la versión más reciente para iniciar el juego."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Este proyecto está actualmente instalado"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Volver al explorador"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Instalar contenido a la instancia"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Modo desarrollador activado."
|
||||
},
|
||||
@@ -590,6 +602,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java y memoria"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Variables de entorno personalizadas"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Argumentos personalizados de Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Instalación personalizada de Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Asignación de memoria personalizada"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Introduce las variables de entorno..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Introduce los argumentos de Java..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Variables de entorno"
|
||||
},
|
||||
@@ -605,6 +635,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Memoria asignada"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/ruta/a/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Ventana"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "Instancia principal"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "Mostrar mas instancias en ejecución"
|
||||
"message": "Mostrar más instancias en ejecución"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Finalizar instancia"
|
||||
@@ -48,7 +48,7 @@
|
||||
"message": "Tema de color"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.description": {
|
||||
"message": "Cambia la página en la que el launcher se abre en."
|
||||
"message": "Cambia la página en la que el lanzador inicia."
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "Inicio"
|
||||
@@ -72,13 +72,13 @@
|
||||
"message": "Vuelve a jugar tus mundos"
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.description": {
|
||||
"message": "Minimiza el launcher cuando un proceso de Minecraft empieza."
|
||||
"message": "Minimiza el lanzador cuando un proceso de Minecraft empieza."
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.title": {
|
||||
"message": "Minimiza el launcher"
|
||||
"message": "Minimiza el lanzador"
|
||||
},
|
||||
"app.appearance-settings.native-decorations.description": {
|
||||
"message": "Usa los sistemas de frame de Windows (se requiere resetear la aplicación)."
|
||||
"message": "Usa los sistemas de marco de Windows (se requiere resetear la aplicación)."
|
||||
},
|
||||
"app.appearance-settings.native-decorations.title": {
|
||||
"message": "Decoraciones nativas"
|
||||
@@ -93,10 +93,10 @@
|
||||
"message": "Alternar barra lateral"
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.description": {
|
||||
"message": "Si intentas instalar un archivo de paquete de Modrinth (.mrpack) que no esté alojado en Modrinth, te advertiremos de los riesgos antes de instalarlo."
|
||||
"message": "Si intentas instalar un archivo de paquete de Modrinth (.mrpack) que no esté alojado en Modrinth, debes comprender los riesgos que esto conlleva antes de instalarlo."
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.title": {
|
||||
"message": "Adviérteme antes de instalarme modpacks desconocidos"
|
||||
"message": "Advertir antes de instalarme paquetes de mods desconocidos"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "Los servidores de autenticación de Minecraft pueden no estar funcionando en este momento. Verifica tu conexión a internet e inténtalo de nuevo más tarde."
|
||||
@@ -104,6 +104,12 @@
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "No se puede conectar con los servidores de autenticación"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Añadir servidor a instancia"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Añadir a una instancia"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Añadir a la instancia"
|
||||
},
|
||||
@@ -116,6 +122,9 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "Ya está añadido"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Volver a la instancia"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Descubrir contenido"
|
||||
},
|
||||
@@ -126,7 +135,10 @@
|
||||
"message": "Ocultar servidores ya añadidos"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpacks"
|
||||
"message": "Paquetes de Mods"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Añadir contenido puede comprometer la compatibilidad a la hora de unirse a un servidor. Cualquier contenido añadido se perderá cuando actualices la instancia del servidor."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Instalando"
|
||||
@@ -146,12 +158,18 @@
|
||||
"app.export-modal.header": {
|
||||
"message": "Exportar modpack"
|
||||
},
|
||||
"app.export-modal.include-file-accessibility-label": {
|
||||
"message": "¿Incluir \"{file}\"?"
|
||||
},
|
||||
"app.export-modal.modpack-name-label": {
|
||||
"message": "Nombre del modpack"
|
||||
},
|
||||
"app.export-modal.modpack-name-placeholder": {
|
||||
"message": "Nombre del modpack"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "Configura que archivos se incluirán en esta instancia"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "Número de versión"
|
||||
},
|
||||
@@ -281,6 +299,15 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "Una actualización es requerida para jugar {name}. Por favor actualízala a la versión más reciente para ejecutar el juego."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "El proyecto ya está instalado"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Volver a descubrir"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Instalar contenido"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Modo desarrollador activado."
|
||||
},
|
||||
@@ -584,6 +611,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java y memoria"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Variables de entorno personalizadas"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Argumentos personalizados de Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Instalación personalizada de Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Asignación de memoria personalizada"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Introduce las variables de entorno..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Introduce los argumentos de Java..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Variables de entorno"
|
||||
},
|
||||
@@ -599,6 +644,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Memoria asignada"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/ruta/a/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Ventana"
|
||||
},
|
||||
@@ -671,6 +719,24 @@
|
||||
"instance.worlds.world_in_use": {
|
||||
"message": "El mundo está en uso"
|
||||
},
|
||||
"minecraft-account.add-account": {
|
||||
"message": "Añadir cuenta"
|
||||
},
|
||||
"minecraft-account.label": {
|
||||
"message": "Cuenta de Minecraft"
|
||||
},
|
||||
"minecraft-account.not-signed-in": {
|
||||
"message": "Registrarse"
|
||||
},
|
||||
"minecraft-account.remove-account": {
|
||||
"message": "Eliminar cuenta"
|
||||
},
|
||||
"minecraft-account.select-account": {
|
||||
"message": "Seleccionar cuenta"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "Registrarse en Minecraft"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Proporcionado por la instancia"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,115 @@
|
||||
{
|
||||
"app.action-bar.downloading-java": {
|
||||
"message": "Ladataan Java-versiota {version}"
|
||||
},
|
||||
"app.action-bar.downloads": {
|
||||
"message": "Lataukset"
|
||||
},
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "Piilota enemmän käynnissä olevia instansseja"
|
||||
},
|
||||
"app.action-bar.make-primary-instance": {
|
||||
"message": "Aseta ensisijaiseksi instanssiksi"
|
||||
},
|
||||
"app.action-bar.no-instances-running": {
|
||||
"message": "Ei instansseja käynnissä"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "Offline-tilassa"
|
||||
},
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "Ensisijainen instanssi"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "Näytä enemmän käynnissä olevia instansseja"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Pysäytä instanssi"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "Näytä aktiiviset lataukset"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "Katso intanssia"
|
||||
},
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "Katso lokeja"
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.description": {
|
||||
"message": "Mahdollistaa edistyneen renderöinnin, kuten sumennus efektit, jotka voivat aiheuttaa suorituskykyongelmia ilman laitteistokiihdytettyä renderöintiä."
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.title": {
|
||||
"message": "Edistynyt renderointi"
|
||||
},
|
||||
"app.appearance-settings.color-theme.description": {
|
||||
"message": "Valitse haluamasi väriteema Modrinth Appille."
|
||||
},
|
||||
"app.appearance-settings.color-theme.title": {
|
||||
"message": "Väriteema"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.description": {
|
||||
"message": "Muuta sivua mille laukaisin avautuu."
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "Koti"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.library": {
|
||||
"message": "Kirjasto"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.title": {
|
||||
"message": "Oletus laskeutumissivu"
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.description": {
|
||||
"message": "Kytkee pois päältä nimikyltin pelaajasi päällä skinit sivulla."
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.title": {
|
||||
"message": "Piilota nimikyltti"
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.description": {
|
||||
"message": "Sisällyttää viimeaikaiset maailmat \"Hyppää takaisin sisään\" osiossa Koti sivulla."
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.title": {
|
||||
"message": "Hyppää takaisin sisään maailmoihin"
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.description": {
|
||||
"message": "Pienennä laukaisin kun Minecraft prosessi käynnistyy."
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.title": {
|
||||
"message": "Piilota käynnistysohjelma"
|
||||
},
|
||||
"app.appearance-settings.native-decorations.description": {
|
||||
"message": "Käytä järjestelmän ikkuna kehystä (Vaatii sovelluksen uudelleen käynnistämisen)."
|
||||
},
|
||||
"app.appearance-settings.native-decorations.title": {
|
||||
"message": "Natiivit koristukset"
|
||||
},
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Valitse vaihtoehto"
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.description": {
|
||||
"message": "Laittaa päälle mahdollisuuden kytkeä sivupalkkia."
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.title": {
|
||||
"message": "Kytke sivupalkki"
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.description": {
|
||||
"message": "Jos yrität asentaa Modrinth-pakettitiedoston (.mrpack), jota ei ole isännöity Modrinthissä, varmistamme, että ymmärrät riskit ennen asennusta."
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.title": {
|
||||
"message": "Varoita minua ennen tuntemattomien modipakettien asentamista"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "Minecraftin todennuspalvelimet eivät ehkä ole tällä hetkellä tavoitettavissa. Tarkista internetyhteytesi ja yritä myöhemmin uudelleen."
|
||||
},
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Todennuspalvelimiin ei saada yhteyttä"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Lisää palvelin instanssiin"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Lisää instanssiin"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Lisää instanssiin"
|
||||
},
|
||||
@@ -17,12 +122,33 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "On jo asennettu"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Takaisin instanssiin"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Löydä sisältöä"
|
||||
},
|
||||
"app.browse.discover-servers": {
|
||||
"message": "Löydä palvelimia"
|
||||
},
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "Piilota lisätyt palvelimet"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modipaketit"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Sisällön lisääminen voi rikkoa yhteensopivuuden palvelimelle liittyessä. Kaikki lisätty sisältö katoaa myös kun päivität palvelin instanssin sisällön."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Asennetaan"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.description": {
|
||||
"message": "{fileName}"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.title": {
|
||||
"message": "Asennetaan modipakettia..."
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Lisää modipaketin kuvaus..."
|
||||
},
|
||||
@@ -32,12 +158,18 @@
|
||||
"app.export-modal.header": {
|
||||
"message": "Vie modipaketti"
|
||||
},
|
||||
"app.export-modal.include-file-accessibility-label": {
|
||||
"message": "Sisällytä \"{file}\"?"
|
||||
},
|
||||
"app.export-modal.modpack-name-label": {
|
||||
"message": "Modipaketin nimi"
|
||||
},
|
||||
"app.export-modal.modpack-name-placeholder": {
|
||||
"message": "Modipaketin nimi"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "Muuta mitkä tiedostot sisältyvät tähän vientiin"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "Versio numero"
|
||||
},
|
||||
@@ -80,6 +212,9 @@
|
||||
"app.instance.mods.share-text": {
|
||||
"message": "Tsekkaa projektit joita käytän minun modipaketissani!"
|
||||
},
|
||||
"app.instance.mods.share-title": {
|
||||
"message": "Jaetaan modipaketti sisältöä"
|
||||
},
|
||||
"app.instance.mods.successfully-uploaded": {
|
||||
"message": "Onnistuneesti ladattu"
|
||||
},
|
||||
@@ -164,6 +299,15 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "Pävitys vaaditaan pelataksesi {name}. Päivitä viimeisimpään versioon pelataksesi."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Tämä projekti on jo asennettu"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Takaisin sisällön löytöön"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Asenna sisältöä instanssiin"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Kehittäjätila käytössä."
|
||||
},
|
||||
@@ -467,6 +611,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java ja muisti"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Mukautetut ympäristö muuttujat"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Mukautetut Java argumentit"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Mukautettu Java asennus"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Mukautettu muistin allokointi"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Syötä ympäristö muuttujat..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Syötä Java argumentit..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Ympäristö muuttujat"
|
||||
},
|
||||
@@ -482,6 +644,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Muisti allokoitu"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/javaan/johtava/polku/"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Ikkuna"
|
||||
},
|
||||
@@ -554,6 +719,24 @@
|
||||
"instance.worlds.world_in_use": {
|
||||
"message": "Maailma on käytössä"
|
||||
},
|
||||
"minecraft-account.add-account": {
|
||||
"message": "Lisää tili"
|
||||
},
|
||||
"minecraft-account.label": {
|
||||
"message": "Minecraft tili"
|
||||
},
|
||||
"minecraft-account.not-signed-in": {
|
||||
"message": "Ei kirjautuneena sisään"
|
||||
},
|
||||
"minecraft-account.remove-account": {
|
||||
"message": "Poista tili"
|
||||
},
|
||||
"minecraft-account.select-account": {
|
||||
"message": "Valitse tili"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "Kirjaudu sisään Minecraftiin"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Annettu instanssin toimesta"
|
||||
},
|
||||
@@ -577,5 +760,26 @@
|
||||
},
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "Modialusta on palvelimen tarjoama"
|
||||
},
|
||||
"unknown-pack-warning-modal.body": {
|
||||
"message": "Tiedosto tarkistetaan vain jos se on ladattu Modrinthiin, riippumatta tiedoston muodosta (mukaanlukien .mrpack)."
|
||||
},
|
||||
"unknown-pack-warning-modal.dont-show-again": {
|
||||
"message": "Älä näytä tätä varoitusta uudestaan"
|
||||
},
|
||||
"unknown-pack-warning-modal.header": {
|
||||
"message": "Vahvista asennus"
|
||||
},
|
||||
"unknown-pack-warning-modal.install-anyway": {
|
||||
"message": "Asenna jokatapauksessa"
|
||||
},
|
||||
"unknown-pack-warning-modal.malware-statement": {
|
||||
"message": "Haittaohjelmia levitetään usein modipaketti tiedostojen kautta jakamalla niitä alustoilla kuten Discordissa."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.body": {
|
||||
"message": "Emme löytäneet tätä tiedostoa Modrinthista. Suosittelemme vahvasti että asennat tiedostoja vain lähteistä joihin luotat."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.title": {
|
||||
"message": "Tuntematon tiedosto varoitus"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,7 +729,7 @@
|
||||
"message": "Kumpirmahin ang installation"
|
||||
},
|
||||
"unknown-pack-warning-modal.install-anyway": {
|
||||
"message": "I-install parin"
|
||||
"message": "I-install pa rin"
|
||||
},
|
||||
"unknown-pack-warning-modal.malware-statement": {
|
||||
"message": "Ang malware ay madalas naidadala sa mga modpack files sa pamamagitan ng pag-bigay ng mga ito sa mga platforms kagaya ng Discord."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app.action-bar.downloading-java": {
|
||||
"message": "Java version {version} en cours de téléchargement"
|
||||
"message": "Téléchargement de Java {version} en cours"
|
||||
},
|
||||
"app.action-bar.downloads": {
|
||||
"message": "Téléchargements"
|
||||
@@ -224,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Parcourir des serveurs"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "« {name} » sera supprimé **pour toujours**, et il sera impossible de le récupérer."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Êtes-vous sûr·e de vouloir supprimer ce monde pour toujours ?"
|
||||
},
|
||||
@@ -248,12 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Aucun serveur ou monde ajoutés"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "« {name} » sera retiré de votre liste, y compris en jeu, et il sera impossible de le récupérer."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "« {name} » ({address}) sera retiré de votre liste, y compris en jeu, et il sera impossible de le récupérer."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Êtes-vous sûr·e de vouloir retirer {name} ?"
|
||||
},
|
||||
@@ -303,7 +294,7 @@
|
||||
"message": "Ce projet a déjà été installé"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Retour à la navigation"
|
||||
"message": "Retour vers la page découvrir"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Installer du contenu à l'instance"
|
||||
@@ -389,9 +380,6 @@
|
||||
"app.world.world-item.offline": {
|
||||
"message": "Hors ligne"
|
||||
},
|
||||
"app.world.world-item.players-online": {
|
||||
"message": "{count, plural, =0 {Personne en ligne} one {# est en ligne} other {# sont en ligne}}"
|
||||
},
|
||||
"friends.action.add-friend": {
|
||||
"message": "Ajouter un ami"
|
||||
},
|
||||
@@ -611,6 +599,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java et mémoire"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Variables d'environnement personnalisé"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Arguments Java personnalisée"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Installation Java personnalisée"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Allocation de mémoire personnalisée"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Entrer une variable d'environnement..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Entrer un argument Java..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Variables d'environnement"
|
||||
},
|
||||
|
||||
@@ -254,9 +254,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "הוספת חבר"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count, plural, one {בקשת חברות אחת} other {# בקשות חברות}}"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "שליחת בקשת חברות"
|
||||
},
|
||||
|
||||
@@ -20,15 +20,78 @@
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "Elsődleges profil"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "További futó profilok megjelenítése"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Profil megállítása"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "Aktív letöltések megtekintése"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "Profil megtekintése"
|
||||
},
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "Naplók megtekintése"
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.description": {
|
||||
"message": "Lehetővé teszi olyan speciális megjelenítési effektusok használatát, mint például az elmosódás, amelyek hardveres gyorsítás nélkül teljesítménycsökkenést okozhatnak."
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.title": {
|
||||
"message": "Fejlett renderelés"
|
||||
},
|
||||
"app.appearance-settings.color-theme.description": {
|
||||
"message": "Válaszd ki a Modrinth Apphoz a kívánt színsémát."
|
||||
},
|
||||
"app.appearance-settings.color-theme.title": {
|
||||
"message": "Téma"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.description": {
|
||||
"message": "Állítsd be, hogy az indító melyik oldalon nyíljon meg."
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "Kezdőlap"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.library": {
|
||||
"message": "Könyvtár"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.title": {
|
||||
"message": "Alapértelmezett nyitóoldal"
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.description": {
|
||||
"message": "Letiltja a játékosod felett megjelenő névcímkét a Skinek oldalon."
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.title": {
|
||||
"message": "Névcímke eltüntetése"
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.description": {
|
||||
"message": "A kezdőlap „Folytasd itt” részében a legújabb világok is megtalálhatók."
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.title": {
|
||||
"message": "„Folytasd itt” a világokat is"
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.description": {
|
||||
"message": "Minimalizáld az indítót a Minecraft indításakor."
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.title": {
|
||||
"message": "Indító minimalizálása"
|
||||
},
|
||||
"app.appearance-settings.native-decorations.description": {
|
||||
"message": "A rendszer ablakkeret használata (az alkalmazás újraindítása szükséges)."
|
||||
},
|
||||
"app.appearance-settings.native-decorations.title": {
|
||||
"message": "Rendszer ablakkeret használata"
|
||||
},
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Válassz egy lehetőséget"
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.description": {
|
||||
"message": "Lehetővé teszi az oldalsáv be- és kikapcsolását."
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.title": {
|
||||
"message": "Oldalsáv megjelenítése/elrejtése"
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.description": {
|
||||
"message": "Ha olyan Modrinth Csomagfájlt (.mrpack) próbálsz telepíteni, amely nem a Modrinth szerverén található, a telepítés előtt gondoskodunk arról, hogy tisztában legyél a kockázatokkal."
|
||||
},
|
||||
@@ -41,6 +104,12 @@
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Nem lehet elérni a hitelesítési kiszolgálókat"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Szerver hozzáadása a profilhoz"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Hozzáadás egy profilhoz"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Hozzáadás a profilhoz"
|
||||
},
|
||||
@@ -68,6 +137,9 @@
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modcsomagok"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "A tartalom hozzáadása ronthatja a kompatibilitást a szerverhez való csatlakozáskor. A hozzáadott tartalom elveszik, ha frissíted a szerverprofil tartalmát."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Letöltés..."
|
||||
},
|
||||
@@ -86,12 +158,18 @@
|
||||
"app.export-modal.header": {
|
||||
"message": "Modcsomag exportálása"
|
||||
},
|
||||
"app.export-modal.include-file-accessibility-label": {
|
||||
"message": "Tartalmaz „{file}” fájlokat?"
|
||||
},
|
||||
"app.export-modal.modpack-name-label": {
|
||||
"message": "A modcsomag neve"
|
||||
},
|
||||
"app.export-modal.modpack-name-placeholder": {
|
||||
"message": "A modcsomag neve"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "Állítsd be, hogy mely fájlok kerüljenek bele az exportba"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "Verziószám"
|
||||
},
|
||||
@@ -194,9 +272,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Telepítés"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count} mod"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Szükséges modcsomag"
|
||||
},
|
||||
@@ -219,7 +294,10 @@
|
||||
"message": "Frissítés szükséges"
|
||||
},
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "\nFrissítés szükséges ehhez: {name}. Kérjük, frissíts a legújabb verzióra a játék elindításához"
|
||||
"message": "Frissítés szükséges ehhez: {name}. Kérlek, frissíts a legújabb verzióra a játék elindításához."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Ez a projekt már telepítve van"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Vissza a böngészéshez"
|
||||
@@ -314,9 +392,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Barát hozzáadása"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} barát {count, plural, other {kérelem}}"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Barátkérelem elküldése"
|
||||
},
|
||||
@@ -588,7 +663,7 @@
|
||||
"message": "Ne mutasd a kezdőlapon"
|
||||
},
|
||||
"instance.worlds.game_already_open": {
|
||||
"message": "A profil már meg van nyitva"
|
||||
"message": "A profil már el van indítva"
|
||||
},
|
||||
"instance.worlds.hardcore": {
|
||||
"message": "Hardcore mód"
|
||||
@@ -617,6 +692,24 @@
|
||||
"instance.worlds.world_in_use": {
|
||||
"message": "A világ használatban van"
|
||||
},
|
||||
"minecraft-account.add-account": {
|
||||
"message": "Fiók hozzáadása"
|
||||
},
|
||||
"minecraft-account.label": {
|
||||
"message": "Minecraft-fiók"
|
||||
},
|
||||
"minecraft-account.not-signed-in": {
|
||||
"message": "Nem vagy bejelentkezve"
|
||||
},
|
||||
"minecraft-account.remove-account": {
|
||||
"message": "Fiók törlése"
|
||||
},
|
||||
"minecraft-account.select-account": {
|
||||
"message": "Fiók kiválasztása"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "Bejelentkezés a Minecraftba"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "A profil által van megadva"
|
||||
},
|
||||
@@ -641,13 +734,25 @@
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "A betöltőt a szerver biztosítja"
|
||||
},
|
||||
"unknown-pack-warning-modal.body": {
|
||||
"message": "Egy fájlt csak akkor vizsgálunk meg, ha azt feltöltik a Modrinthra, függetlenül a fájlformátumtól (beleértve a .mrpack formátumot is)."
|
||||
},
|
||||
"unknown-pack-warning-modal.dont-show-again": {
|
||||
"message": "Ne mutasd újra ezt a figyelmeztetést"
|
||||
},
|
||||
"unknown-pack-warning-modal.header": {
|
||||
"message": "Telepítés megerősítése"
|
||||
},
|
||||
"unknown-pack-warning-modal.install-anyway": {
|
||||
"message": "Letöltés mindenképpen"
|
||||
},
|
||||
"unknown-pack-warning-modal.malware-statement": {
|
||||
"message": "A rosszindulatú programokat gyakran modcsomag-fájlokon keresztül terjesztik, például a Discordhoz hasonló platformokon történő megosztás révén."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.body": {
|
||||
"message": "Ezt a fájlt nem találtuk meg a Modrinthon. Határozottan javasoljuk, hogy kizárólag megbízható forrásokból származó fájlokat telepíts."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.title": {
|
||||
"message": "Figyelmeztetés ismeretlen fájlról"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Pilih opsi"
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.title": {
|
||||
"message": "Peringatkan saya sebelum memasang paket mod tidak dikenal"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "Server autentikasi Minecraft mungkin sedang tidak tersedia saat ini. Periksa koneksi internet Anda dan coba lagi nanti."
|
||||
},
|
||||
@@ -242,9 +245,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Pasang"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, other {# mod}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Paket mod yang diperlukan"
|
||||
},
|
||||
@@ -269,6 +269,9 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "{name} perlu diperbarui sebelum dimainkan. Mohon perbarui ke versi terkini untuk meluncurkan permainan."
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Pasang konten ke instans"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Mode pengembang dihidupkan."
|
||||
},
|
||||
@@ -356,9 +359,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Tambah teman"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} {count, plural, other {permintaan}} teman"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Kirim permintaan teman"
|
||||
},
|
||||
@@ -659,6 +659,21 @@
|
||||
"instance.worlds.world_in_use": {
|
||||
"message": "Dunia sedang dimainkan"
|
||||
},
|
||||
"minecraft-account.add-account": {
|
||||
"message": "Tambah akun"
|
||||
},
|
||||
"minecraft-account.label": {
|
||||
"message": "Akun Minecraft"
|
||||
},
|
||||
"minecraft-account.not-signed-in": {
|
||||
"message": "Tidak masuk"
|
||||
},
|
||||
"minecraft-account.remove-account": {
|
||||
"message": "Hapus akun"
|
||||
},
|
||||
"minecraft-account.select-account": {
|
||||
"message": "Pilih akun"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Disediakan oleh instans"
|
||||
},
|
||||
@@ -682,5 +697,20 @@
|
||||
},
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "Pemuat disediakan oleh server"
|
||||
},
|
||||
"unknown-pack-warning-modal.dont-show-again": {
|
||||
"message": "Jangan tampilkan peringatan ini lagi"
|
||||
},
|
||||
"unknown-pack-warning-modal.header": {
|
||||
"message": "Konfirmasi pemasangan"
|
||||
},
|
||||
"unknown-pack-warning-modal.install-anyway": {
|
||||
"message": "Tetap pasang"
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.body": {
|
||||
"message": "Kami tidak dapat menemukan berkas ini di Modrinth. Kami sangat menyarankan Anda untuk hanya memasang berkas dari sumber-sumber terpercaya."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.title": {
|
||||
"message": "Peringatan berkas tidak dikenal"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"message": "Decorazioni native"
|
||||
},
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Scegli una pagina"
|
||||
"message": "Seleziona"
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.description": {
|
||||
"message": "Scegli se mostrare o nascondere la barra laterale."
|
||||
@@ -93,7 +93,7 @@
|
||||
"message": "Abilita la barra laterale"
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.description": {
|
||||
"message": "Visti i rischi nell'installare un file Modrinth Pack (.mrpack) non proveniente da Modrinth, mostriamo un avvertimento quando tenti di fare ciò."
|
||||
"message": "Visti i rischi, apparirà un avviso prima di installare un file Modrinth Pack (.mrpack) non proveniente da Modrinth."
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.title": {
|
||||
"message": "Avvisami se installo pacchetti sconosciuti"
|
||||
@@ -224,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Esplora i server"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "\"{name}\" verrà **eliminato permanentemente** e non ci sarà modo di recuperarlo."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Vuoi davvero eliminare questo mondo per sempre?"
|
||||
},
|
||||
@@ -248,12 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Nessun server o mondo aggiunto"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "\"{name}\" sarà rimosso dalla tua lista, inclusa quella nel gioco, e non ci sarà modo di recuperarlo."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "\"{name}\" ({address}) sarà rimosso dalla tua lista, inclusa quella nel gioco, e non ci sarà modo di recuperarlo."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Vuoi davvero rimuovere {name}?"
|
||||
},
|
||||
@@ -272,9 +263,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Installa"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count} mod"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Pacchetto di mod richiesto"
|
||||
},
|
||||
@@ -303,7 +291,7 @@
|
||||
"message": "Questo progetto è già stato installato"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Torna su esplora"
|
||||
"message": "Torna a Esplora"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Installa il contenuto nell'istanza"
|
||||
@@ -611,6 +599,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java e memoria"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Variabili d'ambiente"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Argomenti JVM"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Eseguibile di Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Memoria allocata"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Inserisci le variabili d'ambiente..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Inserisci gli argomenti JVM..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Variabili d'ambiente"
|
||||
},
|
||||
@@ -626,6 +632,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Memoria allocata"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/percorso/di/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Finestra"
|
||||
},
|
||||
|
||||
@@ -254,9 +254,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "インストール"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, other {#個のMod}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "必須のModパック"
|
||||
},
|
||||
@@ -368,9 +365,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "フレンドを追加"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count}件の友達リクエスト"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "フレンド申請を送信"
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"message": "실행 중인 인스턴스 보이기"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "인스턴스 중지"
|
||||
"message": "인스턴스 멈추기"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "다운로드 목록 보기"
|
||||
@@ -104,6 +104,12 @@
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "인증 서버에 연결할 수 없습니다"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "인스턴스에 서버 추가"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "인스턴스에 추가"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "인스턴스에 추가"
|
||||
},
|
||||
@@ -116,6 +122,9 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "이미 추가됨"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "인스턴스로 돌아가기"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "콘텐츠 탐색하기"
|
||||
},
|
||||
@@ -128,6 +137,9 @@
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "모드팩"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "콘텐츠를 추가하면 서버 접속 시 호환이 안 될 수 있습니다. 또한 서버 인스턴스 콘텐츠를 업데이트하면 임의로 추가된 콘텐츠는 모두 손실됩니다."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "설치 중"
|
||||
},
|
||||
@@ -180,10 +192,10 @@
|
||||
"message": "이 모드팩은 이미 <bold>{instanceName}</bold> 인스턴스에 설치되어 있습니다. 정말로 복제하시겠습니까?"
|
||||
},
|
||||
"app.instance.modpack-already-installed.create": {
|
||||
"message": "생성"
|
||||
"message": "만들기"
|
||||
},
|
||||
"app.instance.modpack-already-installed.header": {
|
||||
"message": "모드팩 이미 설치됨"
|
||||
"message": "이미 설치된 모드팩"
|
||||
},
|
||||
"app.instance.modpack-already-installed.instance": {
|
||||
"message": "인스턴스"
|
||||
@@ -287,6 +299,15 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "{name}을(를) 플레이하려면 업데이트가 필요합니다. 게임을 실행하려면 최신 버전으로 업데이트해 주세요."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "이미 설치되어 있음"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "탐색으로 돌아가기"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "인스턴스에 콘텐츠 설치"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "개발자 모드가 활성화되었습니다."
|
||||
},
|
||||
@@ -374,9 +395,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "친구 추가"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count}건의 친구 요청"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "친구 요청 보내기"
|
||||
},
|
||||
@@ -590,6 +608,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java 및 메모리"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "사용자 지정 환경 변수"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "사용자 지정 Java 매개변수"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "사용자 지정 Java 설치"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "사용자 지정 메모리 할당"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "환경 변수를 입력하세요..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Java 인수를 입력하세요..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "환경 변수"
|
||||
},
|
||||
@@ -605,6 +641,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "할당된 메모리"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/path/to/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "창"
|
||||
},
|
||||
|
||||
@@ -272,9 +272,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Pasang"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, other {# mod}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Pek mod yang diperlukan"
|
||||
},
|
||||
@@ -302,9 +299,6 @@
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Projek ini sudah dipasang"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Kembali melayar"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Pasang kandungan ke dalam pemasangan"
|
||||
},
|
||||
@@ -395,9 +389,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Tambah rakan"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} {count, plural, other {permintaan}} rakan"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Hantar permintaan rakan"
|
||||
},
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
"message": "Verwijder instantie"
|
||||
},
|
||||
"app.instance.modpack-already-installed.body": {
|
||||
"message": "Deze modpakket is al in de <bold>{instanceName}<bold> instantie geïnstalleerd. Ben je zeker dat je het geen wil dupliceren?"
|
||||
"message": "Deze modpakket is al in de <bold>{instanceName}</bold> instantie geïnstalleerd. Ben je zeker dat je het geen wil dupliceren?"
|
||||
},
|
||||
"app.instance.modpack-already-installed.create": {
|
||||
"message": "Maak"
|
||||
@@ -224,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Zoek servers"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "{name} Zal **permanent verwijderd** worden en kan niet hersteld worden."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Weet je zeker dat je deze wereld definitief wilt verwijderen?"
|
||||
},
|
||||
@@ -257,9 +254,6 @@
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Ben je zeker dat je {name} wil verwijderen?"
|
||||
},
|
||||
"app.instance.worlds.search-worlds-placeholder": {
|
||||
"message": "Zoek werelden"
|
||||
},
|
||||
"app.instance.worlds.this-server": {
|
||||
"message": "deze server"
|
||||
},
|
||||
@@ -302,9 +296,6 @@
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Dit project is al geïnstalleerd"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Terug naar browsen"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Installeer content naar instantie"
|
||||
},
|
||||
|
||||
@@ -137,6 +137,9 @@
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Paczki modów"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Dodawanie zawartości może zepsuć kompatybilność podczas dołączania na serwer. Jakakolwiek dodana zawartość zostanie stracona, gdy zaktualizujesz zawartość instancji serwera."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Instalowanie"
|
||||
},
|
||||
@@ -155,12 +158,18 @@
|
||||
"app.export-modal.header": {
|
||||
"message": "Eksportuj paczkę modów"
|
||||
},
|
||||
"app.export-modal.include-file-accessibility-label": {
|
||||
"message": "Dołączyć \"{file}\"?"
|
||||
},
|
||||
"app.export-modal.modpack-name-label": {
|
||||
"message": "Nazwa paczki modów"
|
||||
},
|
||||
"app.export-modal.modpack-name-placeholder": {
|
||||
"message": "Nazwa paczki modów"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "Skonfiguruje jakie pliki zostaną załączone w tym eksporcie"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "Numer wersji"
|
||||
},
|
||||
@@ -168,7 +177,7 @@
|
||||
"message": "1.0.0"
|
||||
},
|
||||
"app.instance.confirm-delete.admonition-body": {
|
||||
"message": "Wszystkie dane z Twojej instancji zostaną trwale usunięte, w tym Twoje światy, pliki konfiguracji i wszystkie zainstalowane treści."
|
||||
"message": "Wszystkie dane z Twojej instancji zostaną trwale usunięte, w tym Twoje światy, pliki konfiguracji i jakakolwiek dodana zawartość."
|
||||
},
|
||||
"app.instance.confirm-delete.admonition-header": {
|
||||
"message": "Tej akcji nie można odwrócić"
|
||||
@@ -197,9 +206,6 @@
|
||||
"app.instance.mods.project-was-added": {
|
||||
"message": "Dodano \"{name}\""
|
||||
},
|
||||
"app.instance.mods.projects-were-added": {
|
||||
"message": "Dodano {count} {count, plural, one {projekt} few {projekty} other {projektów}}"
|
||||
},
|
||||
"app.instance.mods.share-text": {
|
||||
"message": "Sprawdź projekty, które używam w mojej paczce modów!"
|
||||
},
|
||||
@@ -263,9 +269,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Zainstaluj"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, one {# mod} few {# mody} other {# modów}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Wymagana paczka modów"
|
||||
},
|
||||
@@ -294,7 +297,7 @@
|
||||
"message": "Ten projekt jest już zainstalowany"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Powrót do przeglądania"
|
||||
"message": "Powróć do odkrywania"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Zainstaluj zasoby do instancji"
|
||||
@@ -386,9 +389,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Dodaj znajomego"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} {count, plural, one {zaproszenie} few {zaproszenia} other {zaproszeń}} do znajomych"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Wyślij zaproszenie"
|
||||
},
|
||||
@@ -695,6 +695,9 @@
|
||||
"minecraft-account.label": {
|
||||
"message": "Konto Minecraft"
|
||||
},
|
||||
"minecraft-account.not-signed-in": {
|
||||
"message": "Nie zalogowano"
|
||||
},
|
||||
"minecraft-account.remove-account": {
|
||||
"message": "Usuń konto"
|
||||
},
|
||||
|
||||
@@ -272,9 +272,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Instalar"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, =0 {Nenhum mod} one {# mod} other {# mods}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Pacote de mods necessário"
|
||||
},
|
||||
@@ -303,7 +300,7 @@
|
||||
"message": "Este projeto já foi instalado"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Voltar ao explorar"
|
||||
"message": "Voltar ao descobrir"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Instalar conteúdo para a instância"
|
||||
@@ -395,9 +392,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Adicionar um amigo"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count, plural, =0 {Nenhuma solicitação de amizade} one {{count} solicitação de amizade} other {{count} solicitações de amizade}}"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Enviar amizade"
|
||||
},
|
||||
@@ -611,6 +605,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java e memória"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Variáveis de ambiente personalizadas"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Argumentos Java personalizados"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Instalação personalizada do Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Alocação de memória personalizada"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Insira as variáveis de ambiente..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Insira os argumentos Java..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Variáveis de ambiente"
|
||||
},
|
||||
@@ -626,6 +638,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Memória alocada"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/caminho/ao/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Janela"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,61 @@
|
||||
{
|
||||
"app.action-bar.downloading-java": {
|
||||
"message": "A descarregar a versão do java {version}"
|
||||
},
|
||||
"app.action-bar.downloads": {
|
||||
"message": "Downloads"
|
||||
},
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "Esconder mais instâncias a correr"
|
||||
},
|
||||
"app.action-bar.make-primary-instance": {
|
||||
"message": "Transformar na instância principal"
|
||||
},
|
||||
"app.action-bar.no-instances-running": {
|
||||
"message": "Nenhuma instância a correr"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "Instância principal"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "Mostrar mais instâncias a correr"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Para instância"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "Ver instâncias ativas"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "Ver instância"
|
||||
},
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "Ver logs"
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.title": {
|
||||
"message": "Renderização avançada"
|
||||
},
|
||||
"app.appearance-settings.color-theme.description": {
|
||||
"message": "Seleciona a tua cor preferida para a Modrinth App."
|
||||
},
|
||||
"app.appearance-settings.color-theme.title": {
|
||||
"message": "Cor de tema"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.description": {
|
||||
"message": "Mudar a página em que o launcher abre."
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "Casa"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.library": {
|
||||
"message": "Livraria"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.title": {
|
||||
"message": "Página de carregamento principal"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "Os servidores de autenticação do Minecraft poderão estar em baixo de momento. Verifica a tua ligação à internet e tenta novamente mais tarde."
|
||||
},
|
||||
@@ -92,9 +149,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Procurar servidores"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "\"{name}\" vai ser **apagado permanentemente**, e não haverá maneira de o recuperar."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Tens a certeza que queres apagar permanentemente este mundo?"
|
||||
},
|
||||
@@ -116,12 +170,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Sem servidores ou mundos adicionados"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "\"{name}\" vai ser removido da tua lista, incluindo dentro do jogo, e não haverá maneira de o recuperar."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "\"{name}\" ({address}) vai ser removido da tua lista, incluindo dentro do jogo, e não haverá maneira de o recuperar."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Tens a certeza que queres remover {name}?"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"message": "Скачивание Java {version}"
|
||||
},
|
||||
"app.action-bar.downloads": {
|
||||
"message": "Скачивания"
|
||||
"message": "Скачивание"
|
||||
},
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "Скрыть другие активные сборки"
|
||||
@@ -206,9 +206,6 @@
|
||||
"app.instance.mods.project-was-added": {
|
||||
"message": "«{name}» добавлен"
|
||||
},
|
||||
"app.instance.mods.projects-were-added": {
|
||||
"message": "{count, plural, one {Добавлен # проект} few {Добавлено # проекта} other {Добавлено # проектов}}"
|
||||
},
|
||||
"app.instance.mods.share-text": {
|
||||
"message": "Что в моей сборке:"
|
||||
},
|
||||
@@ -224,9 +221,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Найти серверы"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "«{name}» будет **удалён навсегда**. Его невозможно восстановить."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Вы действительно хотите удалить этот мир?"
|
||||
},
|
||||
@@ -248,18 +242,9 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Нет серверов и миров"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "«{name}» будет удалён из списка — в том числе в игре. Его невозможно восстановить."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "«{name}» ({address}) будет удалён из списка — в том числе в игре. Его невозможно восстановить."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Вы действительно хотите удалить {name}?"
|
||||
},
|
||||
"app.instance.worlds.search-worlds-placeholder": {
|
||||
"message": "Поиск по {count, plural, one {# миру} other {# мирам}}..."
|
||||
},
|
||||
"app.instance.worlds.this-server": {
|
||||
"message": "этот сервер"
|
||||
},
|
||||
@@ -272,9 +257,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Установить"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, one {# мод} few {# мода} other {# модов}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Необходимая сборка"
|
||||
},
|
||||
@@ -360,7 +342,7 @@
|
||||
"message": "Доступно обновление"
|
||||
},
|
||||
"app.update.complete-toast.text": {
|
||||
"message": "Нажмите здесь, чтобы посмотреть изменения."
|
||||
"message": "Нажмите, чтобы посмотреть список изменений."
|
||||
},
|
||||
"app.update.complete-toast.title": {
|
||||
"message": "Версия {version} успешно установлена!"
|
||||
@@ -395,9 +377,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Добавить в друзья"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} {count, plural, one {запрос} few {запроса} other {запросов}} дружбы"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Отправить запрос дружбы"
|
||||
},
|
||||
@@ -491,9 +470,6 @@
|
||||
"instance.edit-world.title": {
|
||||
"message": "Настройка мира"
|
||||
},
|
||||
"instance.files.adding-files": {
|
||||
"message": "Добавление файлов ({completed, number}/{total, number})"
|
||||
},
|
||||
"instance.files.save-as": {
|
||||
"message": "Сохранить как..."
|
||||
},
|
||||
@@ -611,6 +587,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java и память"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Пользовательские настройки запуска"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Пользовательские аргументы Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Пользовательская установка Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Пользовательский объём памяти"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Введите дополнительные параметры системы..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Введите аргументы Java..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Переменные среды"
|
||||
},
|
||||
@@ -626,6 +620,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Выделенная память"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/path/to/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Окно"
|
||||
},
|
||||
@@ -741,7 +738,7 @@
|
||||
"message": "Загрузчик управляется сервером"
|
||||
},
|
||||
"unknown-pack-warning-modal.body": {
|
||||
"message": "Файлы любого формата проверены на безопасность, если они загружены на Modrinth (в том числе файлы⠀.mrpack)."
|
||||
"message": "Файл проверяется только в том случае, если он загружен на Modrinth, независимо от его формата (включая .mrpack)."
|
||||
},
|
||||
"unknown-pack-warning-modal.dont-show-again": {
|
||||
"message": "Больше не предупреждать"
|
||||
|
||||
@@ -5,12 +5,57 @@
|
||||
"app.action-bar.downloads": {
|
||||
"message": "Nedladdningar"
|
||||
},
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "Göm fler pågående instanser"
|
||||
},
|
||||
"app.action-bar.make-primary-instance": {
|
||||
"message": "Gör till primärinstans"
|
||||
},
|
||||
"app.action-bar.no-instances-running": {
|
||||
"message": "Ingen pågående instans"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "Primärinstans"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "Visa fler pågående instanser"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Avsluta instans"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "Visa aktiva nedladdningar"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "Visa instans"
|
||||
},
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "Visa loggar"
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.description": {
|
||||
"message": "Aktiverar avancerad rendering som till exempel oskärpa vilket kan leda till prestandaproblem utan hårdvaruaccelererad rendering."
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.title": {
|
||||
"message": "Avancerad rendering"
|
||||
},
|
||||
"app.appearance-settings.color-theme.description": {
|
||||
"message": "Välj färgtema för Modrinth-appen."
|
||||
},
|
||||
"app.appearance-settings.color-theme.title": {
|
||||
"message": "Färgtema"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "Hem"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.library": {
|
||||
"message": "Bibliotek"
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.title": {
|
||||
"message": "Dölj namnskylt"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "Minecrafts autentiseringsservrar kan vara nere just nu. Kontrollera din internetanslutning och försök igen senare."
|
||||
},
|
||||
|
||||
@@ -260,9 +260,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "ติดตั้ง"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, other {# ม็อด}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "มอดแพ็กที่จำเป็น"
|
||||
},
|
||||
@@ -374,9 +371,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "เพิ่มเพื่อน"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "คำขอเป็นเพื่อน {count} {count, plural, other {คน}}"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "ส่งคำขอเป็นเพื่อน"
|
||||
},
|
||||
|
||||
@@ -368,9 +368,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Bir arkadaş ekle"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} arkadaşlık isteği"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Arkadaşlık isteği gönder"
|
||||
},
|
||||
|
||||
@@ -6,49 +6,49 @@
|
||||
"message": "Завантаження"
|
||||
},
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "Приховати більше запущених екземплярів"
|
||||
"message": "Приховати більше запущених профілів"
|
||||
},
|
||||
"app.action-bar.make-primary-instance": {
|
||||
"message": "Зробити основним екземпляром"
|
||||
"message": "Зробити основним профілем"
|
||||
},
|
||||
"app.action-bar.no-instances-running": {
|
||||
"message": "Немає запущених екземплярів"
|
||||
"message": "Немає запущених профілів"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "Офлайн"
|
||||
"message": "Поза мережею"
|
||||
},
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "Основний екземпляр"
|
||||
"message": "Основний профіль"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "Показати більше запущених екземплярів"
|
||||
"message": "Показати більше запущених профілів"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Зупинити екземпляр"
|
||||
"message": "Зупинити профіль"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "Переглянути активні завантаження"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "Переглянути екземпляр"
|
||||
"message": "Переглянути профіль"
|
||||
},
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "Переглянути журнали"
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.description": {
|
||||
"message": "Дозволяє розширений рендеринг, такий як ефекти розмиття, які можуть спричиняти проблеми з продуктивністю без апаратно-прискореного рендерингу."
|
||||
"message": "Дозволяє розширене промальовування, як-от ефекти розмиття, які можуть спричиняти проблеми з продуктивністю без апаратно-прискореного промальовування."
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.title": {
|
||||
"message": "Розширений рендеринг"
|
||||
"message": "Розширене промальовування"
|
||||
},
|
||||
"app.appearance-settings.color-theme.description": {
|
||||
"message": "Виберіть бажану колірну тему для програми Modrinth App."
|
||||
"message": "Виберіть бажану колірну тему для Modrinth App."
|
||||
},
|
||||
"app.appearance-settings.color-theme.title": {
|
||||
"message": "Колірна тема"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.description": {
|
||||
"message": "Змініть сторінку, на якій відкривається панель запуску."
|
||||
"message": "Змініть сторінку, на якій відкривається запускач."
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "Головна"
|
||||
@@ -57,46 +57,46 @@
|
||||
"message": "Бібліотека"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.title": {
|
||||
"message": "Цільова сторінка за замовчуванням"
|
||||
"message": "Усталена цільова сторінка"
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.description": {
|
||||
"message": "Вимикає іменний бейдж над вашим гравцем на сторінці скінів."
|
||||
"message": "Вимикає ім'я над вашим гравцем на сторінці скінів."
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.title": {
|
||||
"message": "Приховати бейдж з іменем"
|
||||
"message": "Приховати ім'я"
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.description": {
|
||||
"message": "Включає нещодавні світи в розділі «Повернення» на головній сторінці."
|
||||
"message": "Уключає нещодавні світи в розділі «Повернутися» на головній сторінці."
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.title": {
|
||||
"message": "Стрибни назад у світи"
|
||||
"message": "Повернутися у світи"
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.description": {
|
||||
"message": "Згорнути лаунчер під час запуску процесу Minecraft."
|
||||
"message": "Згортає запускач під час запуску процесу Minecraft."
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.title": {
|
||||
"message": "Згорнути панель запуску"
|
||||
"message": "Мінімізувати запускач"
|
||||
},
|
||||
"app.appearance-settings.native-decorations.description": {
|
||||
"message": "Використовувати рамку системного вікна (потрібно перезапустити програму)."
|
||||
},
|
||||
"app.appearance-settings.native-decorations.title": {
|
||||
"message": "Рідні прикраси"
|
||||
"message": "Системне оформлення"
|
||||
},
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Виберіть опцію"
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.description": {
|
||||
"message": "Вмикає можливість перемикання бічної панелі."
|
||||
"message": "Умикає можливість перемикання бокової панелі."
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.title": {
|
||||
"message": "Перемикання бічної панелі"
|
||||
"message": "Перемикання бокової панелі"
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.description": {
|
||||
"message": "Якщо ви спробуєте встановити файл пакета Modrinth (.mrpack), який не розміщено на Modrinth, ми переконаємося, що ви розумієте ризики, перш ніж встановлювати його."
|
||||
"message": "Якщо ви спробуєте встановити файл пакета Modrinth (.mrpack), який не розміщено на Modrinth, ми переконаємося, що ви розумієте ризики, перш ніж установлювати його."
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.title": {
|
||||
"message": "Попереджати мене перед встановленням невідомих модпаків"
|
||||
"message": "Попереджати мене перед установленням невідомих збірок"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "Сервери автентифікації Minecraft можуть зараз не працювати. Перевірте з’єднання з інтернетом та спробуйте пізніше."
|
||||
@@ -104,6 +104,12 @@
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Не вдається зв’язатися зі серверами автентифікації"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Додавання сервера до профілю"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Додати до профілю"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Додати до профілю"
|
||||
},
|
||||
@@ -116,6 +122,9 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "Уже додано"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Назад до профілю"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Дослідити вміст"
|
||||
},
|
||||
@@ -123,22 +132,25 @@
|
||||
"message": "Дослідити сервера"
|
||||
},
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "Скрити наявні сервери"
|
||||
"message": "Сховати наявні сервери"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Модпаки"
|
||||
"message": "Збірки"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Додавання вмісту може порушити сумісність при приєднанні до сервера. Усі додані матеріали також будуть утрачені під час оновлення вмісту профілю сервера."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Встановлення"
|
||||
"message": "Установлення"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.description": {
|
||||
"message": "{fileName}"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.title": {
|
||||
"message": "Встановлення модпаку..."
|
||||
"message": "Установлення збірки…"
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Уведіть опис збірки..."
|
||||
"message": "Уведіть опис збірки…"
|
||||
},
|
||||
"app.export-modal.export-button": {
|
||||
"message": "Експортувати"
|
||||
@@ -146,12 +158,18 @@
|
||||
"app.export-modal.header": {
|
||||
"message": "Експортувати збірку"
|
||||
},
|
||||
"app.export-modal.include-file-accessibility-label": {
|
||||
"message": "Уключити «{file}»?"
|
||||
},
|
||||
"app.export-modal.modpack-name-label": {
|
||||
"message": "Назва збірки"
|
||||
},
|
||||
"app.export-modal.modpack-name-placeholder": {
|
||||
"message": "Назва збірки"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "Змініть файли, які додані до експорту"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "Номер версії"
|
||||
},
|
||||
@@ -159,7 +177,7 @@
|
||||
"message": "1.0.0"
|
||||
},
|
||||
"app.instance.confirm-delete.admonition-body": {
|
||||
"message": "Усі дані вашого профілю будуть видалені назавжди, включно з вашими світами, конфігураціями та усім встановленим контентом."
|
||||
"message": "Усі дані вашого профілю будуть видалені назавжди, включно з вашими світами, налаштуваннями та всім установленим умістом."
|
||||
},
|
||||
"app.instance.confirm-delete.admonition-header": {
|
||||
"message": "Ця дія є незворотною"
|
||||
@@ -204,16 +222,13 @@
|
||||
"message": "Додати сервер"
|
||||
},
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Переглянути сервера"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "«{name}» буде **назавжди видалено** й у вас не буде можливости відновлення."
|
||||
"message": "Переглянути сервери"
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Ви впевнені, що хочете назавжди видалити цей світ?"
|
||||
},
|
||||
"app.instance.worlds.filter-modded": {
|
||||
"message": "Модифікований"
|
||||
"message": "Модифіковано"
|
||||
},
|
||||
"app.instance.worlds.filter-offline": {
|
||||
"message": "Поза мережею"
|
||||
@@ -222,7 +237,7 @@
|
||||
"message": "У мережі"
|
||||
},
|
||||
"app.instance.worlds.filter-vanilla": {
|
||||
"message": "Ванільний"
|
||||
"message": "Стандартний"
|
||||
},
|
||||
"app.instance.worlds.no-worlds-description": {
|
||||
"message": "Додати сервер або переглянути, щоб почати"
|
||||
@@ -230,17 +245,11 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Сервера або світи не додано"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "«{name}» буде видалено з вашого списку, включно в грі, і без можливости відновлення."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "«{name}» ({address}) буде видалено з вашого списку, включно в грі, і без можливости відновлення."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Ви впевнені, що хочете видалити «{name}»?"
|
||||
},
|
||||
"app.instance.worlds.search-worlds-placeholder": {
|
||||
"message": "Пошук {count} світів..."
|
||||
"message": "Пошук {count} світів…"
|
||||
},
|
||||
"app.instance.worlds.this-server": {
|
||||
"message": "цей сервер"
|
||||
@@ -254,9 +263,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Установити"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, one {# мод} few {# мода} other {# модів}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Потрібна збірка"
|
||||
},
|
||||
@@ -281,6 +287,15 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "«{name}» потребує оновлення, щоб грати. Будь ласка, оновіть гру до останньої версії, щоб запустити її."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Проєкт уже встановлено"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Повернутися до дослідження"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Установити вміст до профілю"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Увімкнено режим розробника."
|
||||
},
|
||||
@@ -368,9 +383,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Додати друга"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} запит{count, plural, one { у} few {и в} many {ів у} other {у в}} друзі"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Надіслати запит у друзі"
|
||||
},
|
||||
@@ -420,7 +432,7 @@
|
||||
"message": "Немає друзів, які збігаються з «{query}»"
|
||||
},
|
||||
"friends.search-friends-placeholder": {
|
||||
"message": "Пошук друзів..."
|
||||
"message": "Пошук друзів…"
|
||||
},
|
||||
"friends.section.heading": {
|
||||
"message": "{title} — {count}"
|
||||
@@ -468,7 +480,7 @@
|
||||
"message": "Додавання файлів ({completed}/{total})"
|
||||
},
|
||||
"instance.files.save-as": {
|
||||
"message": "Зберегти як..."
|
||||
"message": "Зберегти як…"
|
||||
},
|
||||
"instance.server-modal.address": {
|
||||
"message": "Адреса"
|
||||
@@ -584,6 +596,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java та пам’ять"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Власні змінні середовища"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Власні аргументи Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Власна інсталяція Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Власний розподіл пам'яті"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Уведіть змінні середовища…"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Уведіть аргументи Java…"
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Змінні середовища"
|
||||
},
|
||||
@@ -599,6 +629,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Виділена пам’ять"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/шлях/до/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Вікно"
|
||||
},
|
||||
@@ -674,12 +707,21 @@
|
||||
"minecraft-account.add-account": {
|
||||
"message": "Додати обліковий запис"
|
||||
},
|
||||
"minecraft-account.label": {
|
||||
"message": "Майнкрафт аккаунт"
|
||||
},
|
||||
"minecraft-account.not-signed-in": {
|
||||
"message": "Не ввійдено в систему"
|
||||
},
|
||||
"minecraft-account.remove-account": {
|
||||
"message": "Видалити обліковий запис"
|
||||
},
|
||||
"minecraft-account.select-account": {
|
||||
"message": "Обрати обліковий запис"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "Увійти в Майнкрафт"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Надано профілем"
|
||||
},
|
||||
@@ -717,7 +759,7 @@
|
||||
"message": "Усе одно інсталювати"
|
||||
},
|
||||
"unknown-pack-warning-modal.malware-statement": {
|
||||
"message": "Шкідливе програмне забезпечення часто поширюють через файли модпаків, які публікуються на таких платформах, як Discord."
|
||||
"message": "Шкідливе програмне забезпечення часто поширюють через файли збірок, які публікуються на таких платформах, як Discord."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.body": {
|
||||
"message": "Ми не змогли знайти цей файл на Modrinth. Ми рекомендуємо встановлювати файли лише з тих джерел яким ви довіряєте."
|
||||
|
||||
@@ -1,10 +1,115 @@
|
||||
{
|
||||
"app.action-bar.downloading-java": {
|
||||
"message": "Đang tải xuống Java {version}"
|
||||
},
|
||||
"app.action-bar.downloads": {
|
||||
"message": "Tải xuống"
|
||||
},
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "Đóng các phiên bản đang chạy"
|
||||
},
|
||||
"app.action-bar.make-primary-instance": {
|
||||
"message": "Đặt làm bản instance chính"
|
||||
},
|
||||
"app.action-bar.no-instances-running": {
|
||||
"message": "Không có phiên bản nào đang chạy"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "Ngoại tuyến"
|
||||
},
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "Bản instance chính"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "Hiện thêm các phiên bản đang chạy"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Dừng instance"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "Xem các lượt tải xuống"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "Xem phiên bản"
|
||||
},
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "Xem logs"
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.description": {
|
||||
"message": "Kích hoạt các tính năng dựng hình nâng cao như hiệu ứng làm mờ, có thể gây giảm hiệu năng nếu không có chế độ tăng tốc phần cứng."
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.title": {
|
||||
"message": "Dựng hình nâng cao"
|
||||
},
|
||||
"app.appearance-settings.color-theme.description": {
|
||||
"message": "Chọn màu nền ưu thích của bạn cho Modrinth App."
|
||||
},
|
||||
"app.appearance-settings.color-theme.title": {
|
||||
"message": "Màu chủ đề"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.description": {
|
||||
"message": "Thay đổi trang hiển thị khi khởi chạy launcher."
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "Trang chủ"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.library": {
|
||||
"message": "Thư viện"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.title": {
|
||||
"message": "Trang mặc định"
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.description": {
|
||||
"message": "Tắt hiển thị thẻ tên phía trên người chơi ở trang skin."
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.title": {
|
||||
"message": "Ẩn thẻ tên"
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.description": {
|
||||
"message": "Hiển thị các thế giới gần đây trong mục \"Tiếp tục chơi\" ở Trang chủ."
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.title": {
|
||||
"message": "Tiếp tục chơi"
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.description": {
|
||||
"message": "Thu nhỏ launcher khi game Minecraft bắt đầu chạy."
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.title": {
|
||||
"message": "Thu nhỏ launcher"
|
||||
},
|
||||
"app.appearance-settings.native-decorations.description": {
|
||||
"message": "Sử dụng khung cửa sổ hệ thống (yêu cầu khởi động lại ứng dụng)."
|
||||
},
|
||||
"app.appearance-settings.native-decorations.title": {
|
||||
"message": "Giao diện cửa sổ hệ thống"
|
||||
},
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Chọn một tuỳ chọn"
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.description": {
|
||||
"message": "Bật tính năng ẩn hiển thị thanh menu bên."
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.title": {
|
||||
"message": "Ẩn/hiện thanh bên"
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.description": {
|
||||
"message": "Nếu bạn cố gắng cài đặt tệp Modrinth Pack (.mrpack) không được lưu trữ trên máy chủ Modrinth, chúng tôi sẽ đảm bảo bạn hiểu rõ các rủi ro trước khi cài đặt."
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.title": {
|
||||
"message": "Cảnh báo tôi trước khi cài đặt các modpacks không rõ nguồn gốc"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "Máy chủ xác thực của Minecraft có thể đang bị sập. Hãy kiểm tra kết nối Internet của bạn và thử lại sau."
|
||||
},
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Không thể kết nối đến máy chủ xác thực"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Thêm máy chủ vào phiên bản"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Thêm vào phiên bản"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Thêm vào hồ sơ"
|
||||
},
|
||||
@@ -17,12 +122,33 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "Đã được thêm"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Quay lại phiên bản"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Khám phá nội dung"
|
||||
},
|
||||
"app.browse.discover-servers": {
|
||||
"message": "Khám phá máy chủ"
|
||||
},
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "Ẩn các server đã thêm"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpack"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Thêm nội dung có thể gây lỗi tương thích khi tham gia máy chủ. Bất kỳ nội dung nào được thêm vào cũng sẽ bị mất khi bạn cập nhật nội dung của phiên bản máy chủ."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Đang cài đặt"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.description": {
|
||||
"message": "{fileName}"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.title": {
|
||||
"message": "Đang cài đặt modpack..."
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Thêm miêu tả cho gói modpack..."
|
||||
},
|
||||
@@ -32,12 +158,18 @@
|
||||
"app.export-modal.header": {
|
||||
"message": "Xuất modpack"
|
||||
},
|
||||
"app.export-modal.include-file-accessibility-label": {
|
||||
"message": "Bao gồm \"{file}\"?"
|
||||
},
|
||||
"app.export-modal.modpack-name-label": {
|
||||
"message": "Tên modpack"
|
||||
},
|
||||
"app.export-modal.modpack-name-placeholder": {
|
||||
"message": "Tên modpack"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "Cấu hình các tệp nào được bao gồm trong quá trình xuất phiên bản này"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "Phiên bản"
|
||||
},
|
||||
@@ -167,6 +299,15 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "Bạn cần cập nhật {name} để có thể chơi. Vui lòng cập nhật lên bản mới nhất để khởi chạy trò chơi."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Dự án này đã được cài đặt"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Quay lại trang Khám Phá"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Cài đặt nội dung vào phiên bản"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Chế độ nhà phát triển đã được bật."
|
||||
},
|
||||
@@ -470,6 +611,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java và bộ nhớ"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Tùy chọn biến môi trường"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Tùy chỉnh tham số Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Tùy chỉnh phiên bản cài đặt Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Tùy chỉnh phân bổ bộ nhớ"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Nhập biến môi trường..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Nhập tham số Java..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Biến môi trường"
|
||||
},
|
||||
@@ -485,6 +644,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Bộ nhớ phân bổ"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/đường_dẫn/tới/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Cửa sổ"
|
||||
},
|
||||
@@ -557,6 +719,24 @@
|
||||
"instance.worlds.world_in_use": {
|
||||
"message": "Thế giới đang mở"
|
||||
},
|
||||
"minecraft-account.add-account": {
|
||||
"message": "Thêm tài khoản"
|
||||
},
|
||||
"minecraft-account.label": {
|
||||
"message": "Tài khoản Minecraft"
|
||||
},
|
||||
"minecraft-account.not-signed-in": {
|
||||
"message": "Chưa đăng nhập"
|
||||
},
|
||||
"minecraft-account.remove-account": {
|
||||
"message": "Xoá tài khoản"
|
||||
},
|
||||
"minecraft-account.select-account": {
|
||||
"message": "Chọn tài khoản"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "Đăng nhập vào Minecraft"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Được cung cấp bởi phiên bản"
|
||||
},
|
||||
@@ -580,5 +760,26 @@
|
||||
},
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "Loader được cung cấp bởi máy chủ"
|
||||
},
|
||||
"unknown-pack-warning-modal.body": {
|
||||
"message": "Tệp chỉ được xem xét nếu nó được tải lên Modrinth, bất kể định dạng tệp của nó là gì (bao gồm cả .mrpack)."
|
||||
},
|
||||
"unknown-pack-warning-modal.dont-show-again": {
|
||||
"message": "Đừng hiển thị cảnh báo này nữa"
|
||||
},
|
||||
"unknown-pack-warning-modal.header": {
|
||||
"message": "Xác nhận cài đặt"
|
||||
},
|
||||
"unknown-pack-warning-modal.install-anyway": {
|
||||
"message": "Tiếp tục cài đặt"
|
||||
},
|
||||
"unknown-pack-warning-modal.malware-statement": {
|
||||
"message": "Phần mềm độc hại thường được phát tán thông qua các tệp modpack bằng cách chia sẻ chúng trên các nền tảng như Discord."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.body": {
|
||||
"message": "Chúng tôi không tìm thấy tập tin này trên Modrinth. Chúng tôi đặc biệt khuyên bạn chỉ nên cài đặt các tập tin từ các nguồn đáng tin cậy."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.title": {
|
||||
"message": "Cảnh báo tệp không xác định"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"message": "在皮肤页面中禁用你玩家头顶上的名牌。"
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.title": {
|
||||
"message": "隐藏名牌"
|
||||
"message": "隐藏名称标签"
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.description": {
|
||||
"message": "在主页的“快速回到”部分包含最近的世界。"
|
||||
@@ -224,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "浏览服务器"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "“{name}”将被**永久删除**,并且无法恢复。"
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "你确定要永久删除这个世界吗?"
|
||||
},
|
||||
@@ -248,12 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "未添加服务器或世界"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "将从你的列表中删除“{name}”,包括游戏内,并且无法恢复。"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "将从你的列表中删除“{name}”({address}),包括游戏内,并且无法恢复。"
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "你确定要删除 {name} 吗?"
|
||||
},
|
||||
@@ -272,9 +263,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "安装"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count} 个模组"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "需求整合包"
|
||||
},
|
||||
@@ -303,7 +291,7 @@
|
||||
"message": "此项目已安装"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "返回浏览器"
|
||||
"message": "返回发现页"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "将内容安装到实例中"
|
||||
@@ -395,9 +383,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "添加好友"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} 个好友请求"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "发送好友请求"
|
||||
},
|
||||
@@ -567,7 +552,7 @@
|
||||
"message": "启动 Hooks"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "自定义启动Hooks"
|
||||
"message": "自定义启动 Hooks"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Hooks允许高级用户在启动游戏前后运行特定的系统命令。"
|
||||
|
||||
@@ -147,10 +147,10 @@
|
||||
"message": "{fileName}"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.title": {
|
||||
"message": "正在安裝模組包..."
|
||||
"message": "正在安裝模組包……"
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "輸入模組包描述..."
|
||||
"message": "輸入模組包描述……"
|
||||
},
|
||||
"app.export-modal.export-button": {
|
||||
"message": "匯出"
|
||||
@@ -224,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "瀏覽伺服器"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "「{name}」將**永久刪除**,且無法還原。"
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "確定要永久刪除這個世界嗎?"
|
||||
},
|
||||
@@ -248,17 +245,11 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "未新增任何伺服器或世界"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "「{name}」將從你的清單中移除(包含遊戲內),且無法還原。"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "「{name}」({address}) 將從你的清單中移除(包含遊戲內),且無法還原。"
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "確定要移除「{name}」嗎?"
|
||||
},
|
||||
"app.instance.worlds.search-worlds-placeholder": {
|
||||
"message": "搜尋 {count} 個世界..."
|
||||
"message": "搜尋 {count} 個世界……"
|
||||
},
|
||||
"app.instance.worlds.this-server": {
|
||||
"message": "這個伺服器"
|
||||
@@ -272,9 +263,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "安裝"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count} 個模組"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "所需模組包"
|
||||
},
|
||||
@@ -303,7 +291,7 @@
|
||||
"message": "這個專案已安裝"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "回到瀏覽頁面"
|
||||
"message": "返回瀏覽"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "將內容安裝至實例"
|
||||
@@ -395,9 +383,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "新增好友"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} 個好友邀請"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "送出好友邀請"
|
||||
},
|
||||
@@ -611,6 +596,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java 和記憶體"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "自訂環境變數"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "自訂 Java 參數"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "自訂 Java 安裝"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "自訂記憶體分配"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "輸入環境變數……"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "輸入 Java 參數……"
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "環境變數"
|
||||
},
|
||||
@@ -626,6 +629,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "記憶體配置"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/path/to/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "視窗"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -218,7 +218,11 @@
|
||||
:key="instance.path"
|
||||
>
|
||||
<template v-if="Component">
|
||||
<Suspense :key="instance.path">
|
||||
<Suspense
|
||||
:key="instance.path"
|
||||
@pending="subpagePending = true"
|
||||
@resolve="subpagePending = false"
|
||||
>
|
||||
<component
|
||||
:is="Component"
|
||||
:instance="instance"
|
||||
@@ -228,6 +232,7 @@
|
||||
:installed="instance.install_stage !== 'installed'"
|
||||
:is-server-instance="isServerInstance"
|
||||
:open-settings="() => settingsModal?.show(1)"
|
||||
v-bind="contentSubpageProps"
|
||||
@play="updatePlayState"
|
||||
@stop="() => stopInstance('InstanceSubpage')"
|
||||
></component>
|
||||
@@ -295,6 +300,7 @@ import {
|
||||
ServerPing,
|
||||
ServerRecentPlays,
|
||||
ServerRegion,
|
||||
useLoadingBarToken,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
@@ -312,6 +318,7 @@ import { useInstanceConsole } from '@/composables/useInstanceConsole'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_v3 } from '@/helpers/cache.js'
|
||||
import { process_listener, profile_listener } from '@/helpers/events'
|
||||
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
|
||||
import { get_by_profile_path } from '@/helpers/process'
|
||||
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
@@ -331,6 +338,7 @@ const route = useRoute()
|
||||
|
||||
const router = useRouter()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const contentSubpageRouteNames = new Set(['Mods', 'ModsFilter'])
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
window.addEventListener('offline', () => {
|
||||
@@ -341,12 +349,16 @@ window.addEventListener('online', () => {
|
||||
})
|
||||
|
||||
const instance = ref<GameInstance>()
|
||||
const preloadedContent = ref<InstanceContentData | null>(null)
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const subpagePending = ref(false)
|
||||
const stopping = ref(false)
|
||||
const exportModal = ref<InstanceType<typeof ExportModal>>()
|
||||
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
|
||||
|
||||
useLoadingBarToken(subpagePending)
|
||||
|
||||
const isServerInstance = ref(false)
|
||||
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
|
||||
const selected = ref<unknown[]>([])
|
||||
@@ -361,36 +373,55 @@ const playersOnline = ref<number | undefined>(undefined)
|
||||
const ping = ref<number | undefined>(undefined)
|
||||
const loadingServerPing = ref(false)
|
||||
|
||||
function isContentSubpageRoute(routeName = route.name) {
|
||||
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
|
||||
}
|
||||
|
||||
async function fetchInstance() {
|
||||
isServerInstance.value = false
|
||||
linkedProjectV3.value = undefined
|
||||
preloadedContent.value = null
|
||||
ping.value = undefined
|
||||
playersOnline.value = undefined
|
||||
loadingServerPing.value = false
|
||||
|
||||
instance.value = await get(route.params.id as string).catch(handleError)
|
||||
const nextInstance = await get(route.params.id as string).catch(handleError)
|
||||
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
|
||||
let nextIsServerInstance = false
|
||||
|
||||
if (!offline.value && instance.value?.linked_data && instance.value.linked_data.project_id) {
|
||||
const contentPreloadPromise =
|
||||
nextInstance && isContentSubpageRoute()
|
||||
? loadInstanceContentData(nextInstance.path, undefined, handleError)
|
||||
: Promise.resolve(null)
|
||||
|
||||
if (!offline.value && nextInstance?.linked_data && nextInstance.linked_data.project_id) {
|
||||
try {
|
||||
linkedProjectV3.value = await get_project_v3(
|
||||
instance.value.linked_data.project_id,
|
||||
nextLinkedProjectV3 = await get_project_v3(
|
||||
nextInstance.linked_data.project_id,
|
||||
'must_revalidate',
|
||||
)
|
||||
|
||||
if (linkedProjectV3.value?.minecraft_server != null) {
|
||||
isServerInstance.value = true
|
||||
if (nextLinkedProjectV3?.minecraft_server != null) {
|
||||
nextIsServerInstance = true
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
const nextPreloadedContent = await contentPreloadPromise
|
||||
|
||||
instance.value = nextInstance ?? undefined
|
||||
linkedProjectV3.value = nextLinkedProjectV3
|
||||
isServerInstance.value = nextIsServerInstance
|
||||
preloadedContent.value = nextPreloadedContent
|
||||
|
||||
fetchDeferredData()
|
||||
|
||||
if (instance.value) {
|
||||
if (nextInstance) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['worlds', instance.value.path],
|
||||
queryFn: () => refreshWorlds(instance.value!.path),
|
||||
queryKey: ['worlds', nextInstance.path],
|
||||
queryFn: () => refreshWorlds(nextInstance.path),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -448,6 +479,9 @@ const renderMode = computed<'scroll' | 'fixed'>(() =>
|
||||
route.meta.renderMode === 'fixed' ? 'fixed' : 'scroll',
|
||||
)
|
||||
const isFixedRender = computed(() => renderMode.value === 'fixed')
|
||||
const contentSubpageProps = computed(() =>
|
||||
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
|
||||
)
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
|
||||
@@ -77,7 +77,7 @@ function buildLogList(rawLogs) {
|
||||
log.filename !== 'latest_stdout.log' &&
|
||||
log.filename !== 'latest_stdout' &&
|
||||
log.filename !== 'launcher_log.txt' &&
|
||||
log.stdout !== '' &&
|
||||
(log.output == null || log.output !== '') &&
|
||||
(log.filename.includes('.log') || log.filename.endsWith('.txt')),
|
||||
)
|
||||
.map((log) => ({
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
:modpack-name="linkedModpackProject?.title"
|
||||
:modpack-icon-url="linkedModpackProject?.icon_url ?? undefined"
|
||||
:enable-toggle="!props.isServerInstance"
|
||||
:busy="isBulkOperating"
|
||||
:get-overflow-options="getOverflowOptions"
|
||||
:switch-version="handleSwitchVersion"
|
||||
@update:enabled="handleModpackContentToggle"
|
||||
@@ -87,12 +88,12 @@ import {
|
||||
ReadyTransition,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
versionChangesGameVersion,
|
||||
} from '@modrinth/ui'
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import ExportModal from '@/components/ui/ExportModal.vue'
|
||||
@@ -100,22 +101,20 @@ import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_versions, get_version } from '@/helpers/cache.js'
|
||||
import { profile_listener } from '@/helpers/events.js'
|
||||
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
|
||||
import {
|
||||
add_project_from_path,
|
||||
add_project_from_version,
|
||||
duplicate,
|
||||
edit,
|
||||
get,
|
||||
get_content_items,
|
||||
get_linked_modpack_content,
|
||||
get_linked_modpack_info,
|
||||
list,
|
||||
remove_project,
|
||||
toggle_disable_project,
|
||||
update_managed_modrinth_version,
|
||||
update_project,
|
||||
} from '@/helpers/profile'
|
||||
import { get_categories } from '@/helpers/tags.js'
|
||||
import type { CacheBehaviour, GameInstance } from '@/helpers/types'
|
||||
import { highlightModInProfile } from '@/helpers/utils.js'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
@@ -160,6 +159,7 @@ const props = defineProps<{
|
||||
instance: GameInstance
|
||||
isServerInstance?: boolean
|
||||
openSettings?: () => void
|
||||
preloadedContent?: InstanceContentData | null
|
||||
}>()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -212,6 +212,7 @@ const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>
|
||||
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal> | null>()
|
||||
const modpackUpdateConfirmModal = ref<InstanceType<typeof ConfirmModpackUpdateModal> | null>()
|
||||
|
||||
// TODO: Extract content operation and updater modal state into composables; this page currently owns file mutations, dependency installs, busy flags, and version selection flow.
|
||||
const updatingProject = ref<ContentItem | null>(null)
|
||||
const updatingProjectVersions = ref<Labrinth.Versions.v2.Version[]>([])
|
||||
const loadingVersions = ref(false)
|
||||
@@ -219,6 +220,82 @@ const loadingChangelog = ref(false)
|
||||
const updatingModpack = ref(false)
|
||||
const pendingModpackUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
|
||||
const isModpackUpdateDowngrade = ref(false)
|
||||
const activeContentOperationKeys = ref(new Set<string>())
|
||||
|
||||
let activeContentOperationCount = 0
|
||||
let updateRequestId = 0
|
||||
const activeUpdateRequestId = ref(0)
|
||||
|
||||
function fileNameFromPath(path: string) {
|
||||
return path.split('/').pop() ?? path
|
||||
}
|
||||
|
||||
function getContentItemId(item: ContentItem | null | undefined) {
|
||||
return item?.file_path ?? item?.file_name ?? item?.id ?? ''
|
||||
}
|
||||
|
||||
function getContentOperationKeys(item: ContentItem) {
|
||||
return [getContentItemId(item), item.file_path, item.file_name].filter(
|
||||
(key): key is string => !!key,
|
||||
)
|
||||
}
|
||||
|
||||
function hasContentOperation(item: ContentItem) {
|
||||
const keys = getContentOperationKeys(item)
|
||||
return keys.some((key) => activeContentOperationKeys.value.has(key))
|
||||
}
|
||||
|
||||
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
|
||||
item.installing = busy
|
||||
modpackContentModal.value?.updateItem(originalFileName, {
|
||||
installing: busy,
|
||||
disabled: busy,
|
||||
})
|
||||
if (item.file_name !== originalFileName) {
|
||||
modpackContentModal.value?.updateItem(item.file_name, {
|
||||
installing: busy,
|
||||
disabled: busy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function beginContentOperation(item: ContentItem) {
|
||||
if (hasContentOperation(item)) return null
|
||||
|
||||
const keys = getContentOperationKeys(item)
|
||||
activeContentOperationKeys.value = new Set([...activeContentOperationKeys.value, ...keys])
|
||||
activeContentOperationCount++
|
||||
isBulkOperating.value = true
|
||||
setContentItemBusy(item, true)
|
||||
|
||||
return { keys, originalFileName: item.file_name }
|
||||
}
|
||||
|
||||
function finishContentOperation(
|
||||
item: ContentItem,
|
||||
operation: { keys: string[]; originalFileName: string },
|
||||
) {
|
||||
const nextKeys = new Set(activeContentOperationKeys.value)
|
||||
for (const key of operation.keys) {
|
||||
nextKeys.delete(key)
|
||||
}
|
||||
activeContentOperationKeys.value = nextKeys
|
||||
activeContentOperationCount = Math.max(0, activeContentOperationCount - 1)
|
||||
setContentItemBusy(item, false, operation.originalFileName)
|
||||
if (activeContentOperationCount === 0) {
|
||||
isBulkOperating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function beginUpdateRequest() {
|
||||
updateRequestId++
|
||||
activeUpdateRequestId.value = updateRequestId
|
||||
return updateRequestId
|
||||
}
|
||||
|
||||
function isActiveUpdateRequest(requestId: number) {
|
||||
return activeUpdateRequestId.value === requestId
|
||||
}
|
||||
|
||||
async function handleBrowseContent() {
|
||||
if (!props.instance) return
|
||||
@@ -265,9 +342,21 @@ async function handleUploadFiles() {
|
||||
}
|
||||
|
||||
async function toggleDisableMod(mod: ContentItem) {
|
||||
if (!mod.file_path) return
|
||||
const operation = beginContentOperation(mod)
|
||||
if (!operation) return
|
||||
|
||||
try {
|
||||
mod.file_path = await toggle_disable_project(props.instance.path, mod.file_path!)
|
||||
const newPath = await toggle_disable_project(props.instance.path, mod.file_path)
|
||||
const newFileName = fileNameFromPath(newPath)
|
||||
mod.file_path = newPath
|
||||
mod.file_name = newFileName
|
||||
mod.enabled = !mod.enabled
|
||||
modpackContentModal.value?.updateItem(operation.originalFileName, {
|
||||
file_path: newPath,
|
||||
file_name: newFileName,
|
||||
enabled: mod.enabled,
|
||||
})
|
||||
|
||||
trackEvent('InstanceProjectDisable', {
|
||||
loader: props.instance.loader,
|
||||
@@ -279,33 +368,48 @@ async function toggleDisableMod(mod: ContentItem) {
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
finishContentOperation(mod, operation)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDisableDebounced = useDebounceFn(toggleDisableMod, 20)
|
||||
const toggleDisableDebounced = toggleDisableMod
|
||||
|
||||
async function removeMod(mod: ContentItem) {
|
||||
await remove_project(props.instance.path, mod.file_path!).catch(handleError)
|
||||
projects.value = projects.value.filter((x) => mod.file_path !== x.file_path)
|
||||
if (!mod.file_path) return
|
||||
const operation = beginContentOperation(mod)
|
||||
if (!operation) return
|
||||
|
||||
trackEvent('InstanceProjectRemove', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
id: mod.project?.id,
|
||||
name: mod.project?.title ?? mod.file_name,
|
||||
project_type: mod.project_type,
|
||||
})
|
||||
try {
|
||||
const removedPath = mod.file_path
|
||||
await remove_project(props.instance.path, removedPath)
|
||||
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
|
||||
|
||||
trackEvent('InstanceProjectRemove', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
id: mod.project?.id,
|
||||
name: mod.project?.title ?? mod.file_name,
|
||||
project_type: mod.project_type,
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
finishContentOperation(mod, operation)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProject(mod: ContentItem) {
|
||||
try {
|
||||
const newPath = await update_project(props.instance.path, mod.file_path!)
|
||||
mod.file_path = newPath
|
||||
if (!mod.file_path) return
|
||||
const operation = beginContentOperation(mod)
|
||||
if (!operation) return
|
||||
|
||||
if (mod.update_version_id) {
|
||||
const versionData = await get_version(mod.update_version_id, 'must_revalidate').catch(
|
||||
handleError,
|
||||
)
|
||||
try {
|
||||
const updateVersionId = mod.update_version_id
|
||||
await update_project(props.instance.path, mod.file_path)
|
||||
|
||||
if (updateVersionId) {
|
||||
const versionData = await get_version(updateVersionId, 'must_revalidate').catch(handleError)
|
||||
|
||||
if (versionData) {
|
||||
const profile = await get(props.instance.path).catch(handleError)
|
||||
@@ -316,12 +420,6 @@ async function updateProject(mod: ContentItem) {
|
||||
}
|
||||
}
|
||||
|
||||
mod.has_update = false
|
||||
if (mod.version && mod.update_version_id) {
|
||||
mod.version.id = mod.update_version_id
|
||||
}
|
||||
mod.update_version_id = null
|
||||
|
||||
trackEvent('InstanceProjectUpdate', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
@@ -331,39 +429,65 @@ async function updateProject(mod: ContentItem) {
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
await refreshContentState('must_revalidate')
|
||||
finishContentOperation(mod, operation)
|
||||
}
|
||||
}
|
||||
|
||||
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
|
||||
isBulkOperating.value = true
|
||||
mod.installing = true
|
||||
if (mod.version) {
|
||||
mod.version.id = version.id
|
||||
mod.version.version_number = version.version_number
|
||||
}
|
||||
if (!mod.file_path) return
|
||||
const operation = beginContentOperation(mod)
|
||||
if (!operation) return
|
||||
|
||||
const oldPath = mod.file_path
|
||||
const wasDisabled = mod.enabled === false || oldPath.endsWith('.disabled')
|
||||
let newPath: string | null = null
|
||||
let shouldRemoveNewOnError = false
|
||||
|
||||
try {
|
||||
await remove_project(props.instance.path, mod.file_path!)
|
||||
const newPath = await add_project_from_version(props.instance.path, version.id, 'standalone')
|
||||
newPath = await add_project_from_version(props.instance.path, version.id, 'update')
|
||||
shouldRemoveNewOnError = newPath !== oldPath
|
||||
|
||||
if (wasDisabled) {
|
||||
newPath = await toggle_disable_project(props.instance.path, newPath)
|
||||
}
|
||||
|
||||
const profile = await get(props.instance.path).catch(handleError)
|
||||
if (profile) {
|
||||
await installVersionDependencies(profile, version, 'update').catch(handleError)
|
||||
}
|
||||
|
||||
mod.file_path = newPath
|
||||
shouldRemoveNewOnError = false
|
||||
if (newPath !== oldPath) {
|
||||
await remove_project(props.instance.path, oldPath)
|
||||
}
|
||||
|
||||
trackEvent('InstanceProjectUpdate', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
id: mod.project?.id,
|
||||
name: mod.project?.title ?? mod.file_name,
|
||||
project_type: mod.project_type,
|
||||
})
|
||||
} catch (err) {
|
||||
if (shouldRemoveNewOnError && newPath && newPath !== oldPath) {
|
||||
await remove_project(props.instance.path, newPath).catch(() => {})
|
||||
}
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
mod.installing = false
|
||||
isBulkOperating.value = false
|
||||
await initProjects()
|
||||
await refreshContentState('must_revalidate')
|
||||
finishContentOperation(mod, operation)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(id: string) {
|
||||
const item = projects.value.find((p) => p.id === id)
|
||||
const item = projects.value.find((p) => getContentItemId(p) === id)
|
||||
if (!item?.has_update || !item.project?.id || !item.version?.id) return
|
||||
|
||||
const requestId = beginUpdateRequest()
|
||||
const itemId = getContentItemId(item)
|
||||
|
||||
debug('handleUpdate triggered', {
|
||||
fileName: item.file_name,
|
||||
projectType: item.project_type,
|
||||
@@ -384,12 +508,48 @@ async function handleUpdate(id: string) {
|
||||
|
||||
await nextTick()
|
||||
|
||||
contentUpdaterModal.value?.show(item.update_version_id ?? undefined)
|
||||
const initialVersionId = item.update_version_id ?? undefined
|
||||
debug('handleUpdate: opening content updater modal', {
|
||||
type: 'content',
|
||||
initialVersionId,
|
||||
item: {
|
||||
id: item.id,
|
||||
fileName: item.file_name,
|
||||
projectType: item.project_type,
|
||||
projectId: item.project.id,
|
||||
projectTitle: item.project.title,
|
||||
currentVersionId: item.version.id,
|
||||
currentVersionNumber: item.version.version_number,
|
||||
updateVersionId: item.update_version_id,
|
||||
},
|
||||
instance: {
|
||||
path: props.instance.path,
|
||||
name: props.instance.name,
|
||||
gameVersion: props.instance.game_version,
|
||||
loader: props.instance.loader,
|
||||
linkedData: props.instance.linked_data,
|
||||
},
|
||||
modalStateBeforeFetch: {
|
||||
updatingModpack: updatingModpack.value,
|
||||
updatingProjectId: updatingProject.value?.id,
|
||||
updatingProjectVersions: updatingProjectVersions.value.map((version) => ({
|
||||
id: version.id,
|
||||
versionNumber: version.version_number,
|
||||
gameVersions: version.game_versions,
|
||||
loaders: version.loaders,
|
||||
datePublished: version.date_published,
|
||||
})),
|
||||
},
|
||||
})
|
||||
contentUpdaterModal.value?.show(initialVersionId)
|
||||
|
||||
const versions = (await get_project_versions(item.project.id).catch((e) => {
|
||||
return handleError(e)
|
||||
})) as Labrinth.Versions.v2.Version[] | null
|
||||
|
||||
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
|
||||
return
|
||||
|
||||
loadingVersions.value = false
|
||||
|
||||
if (!versions) {
|
||||
@@ -414,6 +574,25 @@ async function handleUpdate(id: string) {
|
||||
versions.sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
const preselectedVersion =
|
||||
versions.find((version) => version.id === initialVersionId) ?? versions[0] ?? null
|
||||
debug('handleUpdate: resolved content updater preselection', {
|
||||
type: 'content',
|
||||
initialVersionId,
|
||||
foundInitialVersion: versions.some((version) => version.id === initialVersionId),
|
||||
preselectedVersion: preselectedVersion
|
||||
? {
|
||||
id: preselectedVersion.id,
|
||||
versionNumber: preselectedVersion.version_number,
|
||||
gameVersions: preselectedVersion.game_versions,
|
||||
loaders: preselectedVersion.loaders,
|
||||
datePublished: preselectedVersion.date_published,
|
||||
}
|
||||
: null,
|
||||
versionCount: versions.length,
|
||||
currentVersionId: item.version.id,
|
||||
updateVersionId: item.update_version_id,
|
||||
})
|
||||
|
||||
updatingProjectVersions.value = versions
|
||||
}
|
||||
@@ -421,6 +600,9 @@ async function handleUpdate(id: string) {
|
||||
async function handleSwitchVersion(item: ContentItem) {
|
||||
if (!item.project?.id || !item.version?.id) return
|
||||
|
||||
const requestId = beginUpdateRequest()
|
||||
const itemId = getContentItemId(item)
|
||||
|
||||
updatingModpack.value = false
|
||||
updatingProject.value = item
|
||||
updatingProjectVersions.value = []
|
||||
@@ -435,6 +617,9 @@ async function handleSwitchVersion(item: ContentItem) {
|
||||
return handleError(e)
|
||||
})) as Labrinth.Versions.v2.Version[] | null
|
||||
|
||||
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
|
||||
return
|
||||
|
||||
loadingVersions.value = false
|
||||
|
||||
if (!versions) return
|
||||
@@ -468,9 +653,28 @@ async function handleModpackContent() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshModpackContentItems(cacheBehaviour?: CacheBehaviour) {
|
||||
if (!props.instance?.path) return
|
||||
|
||||
const contentItems = await get_linked_modpack_content(props.instance.path, cacheBehaviour).catch(
|
||||
handleError,
|
||||
)
|
||||
|
||||
if (contentItems) {
|
||||
modpackContentModal.value?.setItems(contentItems)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshContentState(cacheBehaviour?: CacheBehaviour) {
|
||||
await initProjects(cacheBehaviour)
|
||||
await refreshModpackContentItems(cacheBehaviour)
|
||||
}
|
||||
|
||||
async function handleModpackUpdate() {
|
||||
if (!props.instance?.linked_data?.project_id) return
|
||||
|
||||
const requestId = beginUpdateRequest()
|
||||
|
||||
updatingModpack.value = true
|
||||
updatingProject.value = null
|
||||
updatingProjectVersions.value = []
|
||||
@@ -479,14 +683,42 @@ async function handleModpackUpdate() {
|
||||
|
||||
await nextTick()
|
||||
|
||||
contentUpdaterModal.value?.show(
|
||||
linkedModpackUpdateVersionId.value ?? props.instance?.linked_data?.version_id ?? undefined,
|
||||
)
|
||||
const initialVersionId =
|
||||
linkedModpackUpdateVersionId.value ?? props.instance?.linked_data?.version_id ?? undefined
|
||||
debug('handleModpackUpdate: opening modpack updater modal', {
|
||||
type: 'modpack',
|
||||
initialVersionId,
|
||||
linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value,
|
||||
linkedModpackProject: linkedModpackProject.value,
|
||||
linkedModpackVersion: linkedModpackVersion.value,
|
||||
linkedModpackHasUpdate: linkedModpackHasUpdate.value,
|
||||
instance: {
|
||||
path: props.instance.path,
|
||||
name: props.instance.name,
|
||||
gameVersion: props.instance.game_version,
|
||||
loader: props.instance.loader,
|
||||
linkedData: props.instance.linked_data,
|
||||
},
|
||||
modalStateBeforeFetch: {
|
||||
updatingModpack: updatingModpack.value,
|
||||
updatingProjectId: updatingProject.value?.id,
|
||||
updatingProjectVersions: updatingProjectVersions.value.map((version) => ({
|
||||
id: version.id,
|
||||
versionNumber: version.version_number,
|
||||
gameVersions: version.game_versions,
|
||||
loaders: version.loaders,
|
||||
datePublished: version.date_published,
|
||||
})),
|
||||
},
|
||||
})
|
||||
contentUpdaterModal.value?.show(initialVersionId)
|
||||
|
||||
const versions = (await get_project_versions(props.instance.linked_data.project_id).catch(
|
||||
handleError,
|
||||
)) as Labrinth.Versions.v2.Version[] | null
|
||||
|
||||
if (!isActiveUpdateRequest(requestId) || !updatingModpack.value) return
|
||||
|
||||
loadingVersions.value = false
|
||||
|
||||
if (!versions) return
|
||||
@@ -494,6 +726,25 @@ async function handleModpackUpdate() {
|
||||
versions.sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
const preselectedVersion =
|
||||
versions.find((version) => version.id === initialVersionId) ?? versions[0] ?? null
|
||||
debug('handleModpackUpdate: resolved modpack updater preselection', {
|
||||
type: 'modpack',
|
||||
initialVersionId,
|
||||
foundInitialVersion: versions.some((version) => version.id === initialVersionId),
|
||||
preselectedVersion: preselectedVersion
|
||||
? {
|
||||
id: preselectedVersion.id,
|
||||
versionNumber: preselectedVersion.version_number,
|
||||
gameVersions: preselectedVersion.game_versions,
|
||||
loaders: preselectedVersion.loaders,
|
||||
datePublished: preselectedVersion.date_published,
|
||||
}
|
||||
: null,
|
||||
versionCount: versions.length,
|
||||
linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value,
|
||||
currentLinkedVersionId: props.instance.linked_data.version_id,
|
||||
})
|
||||
|
||||
updatingProjectVersions.value = versions
|
||||
}
|
||||
@@ -502,10 +753,12 @@ async function fetchAndSpliceVersion(
|
||||
versionId: string,
|
||||
cacheBehaviour?: Parameters<typeof get_version>[1],
|
||||
onError?: (err: unknown) => void,
|
||||
requestId = activeUpdateRequestId.value,
|
||||
) {
|
||||
const fullVersion = (await get_version(versionId, cacheBehaviour).catch(
|
||||
onError ?? (() => null),
|
||||
)) as Labrinth.Versions.v2.Version | null
|
||||
if (!isActiveUpdateRequest(requestId)) return
|
||||
if (!fullVersion) return
|
||||
const index = updatingProjectVersions.value.findIndex((v) => v.id === versionId)
|
||||
if (index !== -1) {
|
||||
@@ -517,17 +770,26 @@ async function fetchAndSpliceVersion(
|
||||
|
||||
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
if (version.changelog != null) return
|
||||
const requestId = activeUpdateRequestId.value
|
||||
loadingChangelog.value = true
|
||||
await fetchAndSpliceVersion(version.id, 'must_revalidate', handleError as (err: unknown) => void)
|
||||
loadingChangelog.value = false
|
||||
await fetchAndSpliceVersion(
|
||||
version.id,
|
||||
'must_revalidate',
|
||||
handleError as (err: unknown) => void,
|
||||
requestId,
|
||||
)
|
||||
if (isActiveUpdateRequest(requestId)) {
|
||||
loadingChangelog.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVersionHover(version: Labrinth.Versions.v2.Version) {
|
||||
if (version.changelog != null) return
|
||||
await fetchAndSpliceVersion(version.id)
|
||||
await fetchAndSpliceVersion(version.id, undefined, undefined, activeUpdateRequestId.value)
|
||||
}
|
||||
|
||||
function resetUpdateState() {
|
||||
activeUpdateRequestId.value = 0
|
||||
updatingModpack.value = false
|
||||
updatingProject.value = null
|
||||
updatingProjectVersions.value = []
|
||||
@@ -535,13 +797,23 @@ function resetUpdateState() {
|
||||
loadingChangelog.value = false
|
||||
}
|
||||
|
||||
function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
pendingModpackUpdateVersion.value = selectedVersion
|
||||
|
||||
const currentVersionId = props.instance?.linked_data?.version_id
|
||||
const currentVersion = updatingProjectVersions.value.find((v) => v.id === currentVersionId)
|
||||
isModpackUpdateDowngrade.value = currentVersion
|
||||
? new Date(selectedVersion.date_published) < new Date(currentVersion.date_published)
|
||||
: false
|
||||
const shouldShowWarning =
|
||||
isModpackUpdateDowngrade.value ||
|
||||
versionChangesGameVersion(selectedVersion, props.instance.game_version)
|
||||
|
||||
if (!shouldShowWarning) {
|
||||
await handleModpackUpdateConfirm()
|
||||
return
|
||||
}
|
||||
|
||||
modpackUpdateConfirmModal.value?.show()
|
||||
}
|
||||
|
||||
@@ -551,6 +823,7 @@ async function handleModpackUpdateConfirm() {
|
||||
const version = pendingModpackUpdateVersion.value
|
||||
pendingModpackUpdateVersion.value = null
|
||||
|
||||
contentUpdaterModal.value?.hide()
|
||||
isModpackUpdating.value = true
|
||||
try {
|
||||
await update_managed_modrinth_version(props.instance.path, version.id)
|
||||
@@ -574,7 +847,7 @@ async function handleModalUpdate(
|
||||
pendingModpackUpdateVersion.value = selectedVersion
|
||||
await handleModpackUpdateConfirm()
|
||||
} else {
|
||||
handleModpackUpdateRequest(selectedVersion)
|
||||
await handleModpackUpdateRequest(selectedVersion)
|
||||
}
|
||||
} else if (updatingProject.value) {
|
||||
const mod = updatingProject.value
|
||||
@@ -662,51 +935,31 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
|
||||
async function initProjects(cacheBehaviour?: CacheBehaviour) {
|
||||
if (!props.instance) return
|
||||
|
||||
const [contentItems, modpackInfo, allCategories] = await Promise.all([
|
||||
get_content_items(props.instance.path, cacheBehaviour).catch(handleError),
|
||||
get_linked_modpack_info(props.instance.path, cacheBehaviour).catch(handleError),
|
||||
get_categories().catch(handleError),
|
||||
])
|
||||
const contentData = await loadInstanceContentData(
|
||||
props.instance.path,
|
||||
cacheBehaviour,
|
||||
handleError,
|
||||
)
|
||||
applyContentData(contentData)
|
||||
}
|
||||
|
||||
if (!contentItems) {
|
||||
function applyContentData(contentData: InstanceContentData) {
|
||||
if (contentData.path !== props.instance.path) return false
|
||||
|
||||
if (!contentData.contentItems) {
|
||||
loading.value = false
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
projects.value = contentItems
|
||||
projects.value = contentData.contentItems
|
||||
|
||||
if (modpackInfo) {
|
||||
linkedModpackProject.value = {
|
||||
...modpackInfo.project,
|
||||
slug: modpackInfo.project.slug ?? modpackInfo.project.id,
|
||||
icon_url: modpackInfo.project.icon_url ?? undefined,
|
||||
}
|
||||
linkedModpackVersion.value = {
|
||||
...modpackInfo.version,
|
||||
date_published: modpackInfo.version.date_published.toString(),
|
||||
}
|
||||
linkedModpackOwner.value = modpackInfo.owner
|
||||
? {
|
||||
...modpackInfo.owner,
|
||||
avatar_url: modpackInfo.owner.avatar_url ?? undefined,
|
||||
}
|
||||
: null
|
||||
|
||||
linkedModpackHasUpdate.value = modpackInfo.has_update
|
||||
linkedModpackUpdateVersionId.value = modpackInfo.update_version_id
|
||||
|
||||
if (allCategories && modpackInfo.project.categories) {
|
||||
const seen = new Set<string>()
|
||||
linkedModpackCategories.value = allCategories.filter((cat: { name: string }) => {
|
||||
if (modpackInfo.project.categories.includes(cat.name) && !seen.has(cat.name)) {
|
||||
seen.add(cat.name)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
} else {
|
||||
linkedModpackCategories.value = []
|
||||
}
|
||||
if (contentData.modpack) {
|
||||
linkedModpackProject.value = contentData.modpack.project
|
||||
linkedModpackVersion.value = contentData.modpack.version
|
||||
linkedModpackOwner.value = contentData.modpack.owner
|
||||
linkedModpackCategories.value = contentData.modpack.categories
|
||||
linkedModpackHasUpdate.value = contentData.modpack.hasUpdate
|
||||
linkedModpackUpdateVersionId.value = contentData.modpack.updateVersionId
|
||||
} else {
|
||||
linkedModpackProject.value = null
|
||||
linkedModpackVersion.value = null
|
||||
@@ -717,6 +970,7 @@ async function initProjects(cacheBehaviour?: CacheBehaviour) {
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
return true
|
||||
}
|
||||
|
||||
provideAppBackup({
|
||||
@@ -809,8 +1063,9 @@ provideContentManager({
|
||||
showContentHint,
|
||||
dismissContentHint,
|
||||
shareItems: handleShareItems,
|
||||
getItemId: getContentItemId,
|
||||
mapToTableItem: (item: ContentItem) => ({
|
||||
id: item.id,
|
||||
id: getContentItemId(item),
|
||||
project: item.project ?? {
|
||||
id: item.file_name,
|
||||
slug: null,
|
||||
@@ -844,10 +1099,22 @@ provideContentManager({
|
||||
filterPersistKey: props.instance.path,
|
||||
})
|
||||
|
||||
await initProjects()
|
||||
type UnlistenFn = () => void
|
||||
|
||||
const initialContentReady = loadInitialContent()
|
||||
void initialContentReady.then(restoreModpackContentModalState).catch(handleError)
|
||||
|
||||
function loadInitialContent() {
|
||||
if (props.preloadedContent && applyContentData(props.preloadedContent)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return initProjects()
|
||||
}
|
||||
|
||||
async function restoreModpackContentModalState() {
|
||||
if (!savedModalState) return
|
||||
|
||||
// Restore modpack content modal state if returning via back navigation
|
||||
if (savedModalState) {
|
||||
const stateToRestore = savedModalState
|
||||
savedModalState = null
|
||||
await nextTick()
|
||||
@@ -860,18 +1127,32 @@ const removeBeforeEach = router.beforeEach(() => {
|
||||
savedModalState = state ?? null
|
||||
})
|
||||
|
||||
const unlisten = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
if (event.payload.type !== 'drop' || !props.instance) return
|
||||
let isUnmounted = false
|
||||
let unlistenDragDrop: UnlistenFn | null = null
|
||||
let unlistenProfiles: UnlistenFn | null = null
|
||||
|
||||
for (const file of event.payload.paths) {
|
||||
if (file.endsWith('.mrpack')) continue
|
||||
await add_project_from_path(props.instance.path, file).catch(handleError)
|
||||
}
|
||||
await initProjects()
|
||||
})
|
||||
onMounted(() => {
|
||||
void getCurrentWebview()
|
||||
.onDragDropEvent(async (event) => {
|
||||
if (event.payload.type !== 'drop' || !props.instance) return
|
||||
|
||||
const unlistenProfiles = await profile_listener(
|
||||
async (event: { event: string; profile_path_id: string }) => {
|
||||
for (const file of event.payload.paths) {
|
||||
if (file.endsWith('.mrpack')) continue
|
||||
await add_project_from_path(props.instance.path, file).catch(handleError)
|
||||
}
|
||||
await initProjects()
|
||||
})
|
||||
.then((unlisten) => {
|
||||
if (isUnmounted) {
|
||||
unlisten()
|
||||
return
|
||||
}
|
||||
|
||||
unlistenDragDrop = unlisten
|
||||
})
|
||||
.catch(handleError)
|
||||
|
||||
void profile_listener(async (event: { event: string; profile_path_id: string }) => {
|
||||
if (
|
||||
props.instance &&
|
||||
event.profile_path_id === props.instance.path &&
|
||||
@@ -881,8 +1162,17 @@ const unlistenProfiles = await profile_listener(
|
||||
) {
|
||||
await initProjects()
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
.then((unlisten) => {
|
||||
if (isUnmounted) {
|
||||
unlisten()
|
||||
return
|
||||
}
|
||||
|
||||
unlistenProfiles = unlisten
|
||||
})
|
||||
.catch(handleError)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.instance?.install_stage,
|
||||
@@ -905,8 +1195,9 @@ watch(
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
isUnmounted = true
|
||||
removeBeforeEach()
|
||||
unlisten()
|
||||
unlistenProfiles()
|
||||
unlistenDragDrop?.()
|
||||
unlistenProfiles?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -334,7 +334,7 @@ const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
backToBrowse: {
|
||||
id: 'app.project.install-context.back-to-browse',
|
||||
defaultMessage: 'Back to browse',
|
||||
defaultMessage: 'Back to discover',
|
||||
},
|
||||
installContentToInstance: {
|
||||
id: 'app.project.install-context.install-content-to-instance',
|
||||
@@ -415,7 +415,7 @@ const projectGalleryHref = computed(() => buildProjectHref(`/project/${route.par
|
||||
const projectBrowseBackUrl = computed(() => {
|
||||
const browsePath = route.query.b
|
||||
if (typeof browsePath === 'string' && browsePath.startsWith('/browse/')) return browsePath
|
||||
const type = data.value?.project_type ? `${data.value.project_type}s` : 'mods'
|
||||
const type = data.value?.project_type ? `${data.value.project_type}` : 'mod'
|
||||
return `/browse/${type}`
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface InstanceSettingsContext {
|
||||
offline?: boolean
|
||||
isMinecraftServer: Ref<boolean>
|
||||
onUnlinked: () => void
|
||||
closeModal?: () => void
|
||||
}
|
||||
|
||||
export const [injectInstanceSettings, provideInstanceSettings] =
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
writePendingServerContentInstallBaseline,
|
||||
writeStoredServerInstallQueue,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -208,6 +209,7 @@ export function createServerInstallContent(opts: {
|
||||
const router = useRouter()
|
||||
const client = injectModrinthClient()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const serverIdQuery = computed(() => readQueryString(route.query.sid))
|
||||
const worldIdQuery = computed(() => readQueryString(route.query.wid))
|
||||
@@ -471,6 +473,9 @@ export function createServerInstallContent(opts: {
|
||||
...serverContentInstallKeys.value,
|
||||
...result.flushedPlans.map((plan) => plan.projectId),
|
||||
])
|
||||
if (result.flushedPlans.length > 0) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] })
|
||||
}
|
||||
|
||||
return true
|
||||
} finally {
|
||||
@@ -491,6 +496,7 @@ export function createServerInstallContent(opts: {
|
||||
const plans = new Map(queuedServerInstalls.value)
|
||||
|
||||
if (sid && wid) {
|
||||
writeStoredServerInstallQueue(sid, wid, plans)
|
||||
writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value)
|
||||
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
|
||||
void getQueuedInstallPlaceholders(client, plans)
|
||||
|
||||
@@ -86,10 +86,10 @@ export default new createRouter({
|
||||
},
|
||||
{
|
||||
path: '/skins',
|
||||
name: 'Skins',
|
||||
name: 'Skin selector',
|
||||
component: Pages.Skins,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Skins' }],
|
||||
breadcrumb: [{ name: 'Skin selector' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ColorTheme = (typeof THEME_OPTIONS)[number]
|
||||
export type ThemeStore = {
|
||||
selectedTheme: ColorTheme
|
||||
advancedRendering: boolean
|
||||
hideNametagSkinsPage: boolean
|
||||
toggleSidebar: boolean
|
||||
|
||||
devMode: boolean
|
||||
@@ -30,6 +31,7 @@ export type ThemeStore = {
|
||||
export const DEFAULT_THEME_STORE: ThemeStore = {
|
||||
selectedTheme: 'dark',
|
||||
advancedRendering: true,
|
||||
hideNametagSkinsPage: false,
|
||||
toggleSidebar: false,
|
||||
|
||||
devMode: false,
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"types": ["vite/client"],
|
||||
|
||||
"paths": {
|
||||
"@modrinth/api-client": ["../../packages/api-client/src/index.ts"],
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import tauriConf from '../app/tauri.conf.json'
|
||||
|
||||
const projectRootDir = resolve(__dirname)
|
||||
const appLibEnvDir = resolve(projectRootDir, '../../packages/app-lib')
|
||||
const apiClientSource = resolve(projectRootDir, '../../packages/api-client/src/index.ts')
|
||||
|
||||
// Load .env from app-lib manually instead of using Vite's envDir, which would auto-load .env.local and override values
|
||||
const envFilePath = resolve(appLibEnvDir, '.env')
|
||||
@@ -37,6 +38,10 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: '@modrinth/api-client',
|
||||
replacement: apiClientSource,
|
||||
},
|
||||
{
|
||||
find: '@',
|
||||
replacement: resolve(projectRootDir, 'src'),
|
||||
|
||||
@@ -51,8 +51,21 @@ tauri-build = { workspace = true, features = ["codegen"] }
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
tauri-plugin-updater = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation.workspace = true
|
||||
core-graphics.workspace = true
|
||||
objc2-app-kit = { workspace = true, features = [
|
||||
"NSWindow",
|
||||
] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
webview2-com.workspace = true
|
||||
windows = { workspace = true, features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Dwm",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
windows-core.workspace = true
|
||||
|
||||
[features]
|
||||
|
||||
+3
-1
@@ -114,10 +114,12 @@ fn main() {
|
||||
"get_available_capes",
|
||||
"get_available_skins",
|
||||
"add_and_equip_custom_skin",
|
||||
"set_default_cape",
|
||||
"equip_skin",
|
||||
"remove_custom_skin",
|
||||
"save_custom_skin",
|
||||
"unequip_skin",
|
||||
"flush_pending_skin_change",
|
||||
"flush_pending_skin_change_for_profile",
|
||||
"normalize_skin_texture",
|
||||
"get_dragged_skin_data",
|
||||
])
|
||||
|
||||
+121
-21
@@ -11,19 +11,20 @@ use tokio::sync::RwLock;
|
||||
pub struct AdsState {
|
||||
pub shown: bool,
|
||||
pub modal_shown: bool,
|
||||
pub occluded: bool,
|
||||
pub last_click: Option<Instant>,
|
||||
pub malicious_origins: HashSet<String>,
|
||||
}
|
||||
|
||||
const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie";
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 0.5;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
const ADS_USER_AGENT: &str = concat!(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ",
|
||||
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 ",
|
||||
"ModrinthApp/",
|
||||
APP_VERSION,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (Modrinth App)",
|
||||
);
|
||||
|
||||
@@ -36,14 +37,14 @@ fn ads_user_agent_override_params() -> String {
|
||||
"brands": [
|
||||
{ "brand": "Chromium", "version": "128" },
|
||||
{ "brand": "Google Chrome", "version": "128" },
|
||||
{ "brand": "Modrinth App", "version": APP_VERSION },
|
||||
{ "brand": "Modrinth App", "version": env!("CARGO_PKG_VERSION") },
|
||||
{ "brand": "Not=A?Brand", "version": "99" },
|
||||
],
|
||||
"fullVersion": "128.0.0.0",
|
||||
"fullVersionList": [
|
||||
{ "brand": "Chromium", "version": "128.0.0.0" },
|
||||
{ "brand": "Google Chrome", "version": "128.0.0.0" },
|
||||
{ "brand": "Modrinth App", "version": APP_VERSION },
|
||||
{ "brand": "Modrinth App", "version": env!("CARGO_PKG_VERSION") },
|
||||
{ "brand": "Not=A?Brand", "version": "99.0.0.0" },
|
||||
],
|
||||
"platform": "Windows",
|
||||
@@ -62,8 +63,8 @@ fn configure_ads_cookie_settings(
|
||||
core_webview2: &webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2,
|
||||
) {
|
||||
use webview2_com::Microsoft::Web::WebView2::Win32::{
|
||||
COREWEBVIEW2_TRACKING_PREVENTION_LEVEL_NONE, ICoreWebView2,
|
||||
ICoreWebView2_13, ICoreWebView2Profile3,
|
||||
COREWEBVIEW2_TRACKING_PREVENTION_LEVEL_NONE, ICoreWebView2_13,
|
||||
ICoreWebView2Profile3,
|
||||
};
|
||||
use windows_core::Interface;
|
||||
|
||||
@@ -93,10 +94,12 @@ fn configure_ads_cookie_settings(
|
||||
}
|
||||
}
|
||||
|
||||
fn set_webview_visible<R: Runtime>(
|
||||
webview: &tauri::Webview<R>,
|
||||
_visible: bool,
|
||||
) {
|
||||
fn set_webview_visible<R: Runtime>(webview: &tauri::Webview<R>, visible: bool) {
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
{
|
||||
_ = visible;
|
||||
}
|
||||
|
||||
webview
|
||||
.with_webview(
|
||||
#[allow(unused_variables)]
|
||||
@@ -104,11 +107,18 @@ fn set_webview_visible<R: Runtime>(
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let controller = wv.controller();
|
||||
unsafe { controller.SetIsVisible(_visible) }.ok();
|
||||
unsafe { controller.SetIsVisible(visible) }.ok();
|
||||
}
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if visible {
|
||||
webview.show().ok();
|
||||
} else {
|
||||
webview.hide().ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn set_webview_visible_for_window<R: Runtime>(
|
||||
@@ -121,7 +131,76 @@ fn set_webview_visible_for_window<R: Runtime>(
|
||||
.and_then(|window| window.is_minimized().ok())
|
||||
.unwrap_or(false);
|
||||
|
||||
set_webview_visible(webview, visible && !is_minimized);
|
||||
let is_occluded = app
|
||||
.state::<RwLock<AdsState>>()
|
||||
.try_read()
|
||||
.map(|state| state.occluded)
|
||||
.unwrap_or(false);
|
||||
|
||||
set_webview_visible(webview, visible && !is_minimized && !is_occluded);
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
fn compute_ads_webview_occlusion<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
) -> Option<bool> {
|
||||
let main_window = app.get_window("main")?;
|
||||
let webviews = app.webviews();
|
||||
let webview = webviews.get("ads-window")?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let position = webview.position().ok()?;
|
||||
let size = webview.size().ok()?;
|
||||
|
||||
Some(
|
||||
crate::api::ads_occlusion_macos::is_ads_webview_occluded(
|
||||
&main_window,
|
||||
position.x,
|
||||
position.y,
|
||||
size.width,
|
||||
size.height,
|
||||
)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let position = webview.position().ok()?;
|
||||
let size = webview.size().ok()?;
|
||||
let hwnd = main_window.hwnd().ok()?;
|
||||
|
||||
Some(crate::api::ads_occlusion_windows::is_ads_webview_occluded(
|
||||
hwnd,
|
||||
position.x,
|
||||
position.y,
|
||||
size.width,
|
||||
size.height,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
async fn sync_ads_occlusion<R: Runtime>(app: &tauri::AppHandle<R>) {
|
||||
let Some(occluded) = compute_ads_webview_occlusion(app) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let mut state = state.write().await;
|
||||
|
||||
if state.occluded == occluded {
|
||||
return;
|
||||
}
|
||||
|
||||
state.occluded = occluded;
|
||||
let visible = state.shown && !state.modal_shown;
|
||||
drop(state);
|
||||
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
set_webview_visible_for_window(app, webview, visible);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_webview_visibility_for_main_window<R: Runtime>(
|
||||
@@ -142,7 +221,7 @@ fn sync_webview_visibility_for_main_window<R: Runtime>(
|
||||
false
|
||||
} else {
|
||||
match app.state::<RwLock<AdsState>>().try_read() {
|
||||
Ok(state) => state.shown && !state.modal_shown,
|
||||
Ok(state) => state.shown && !state.modal_shown && !state.occluded,
|
||||
Err(_) => false,
|
||||
}
|
||||
};
|
||||
@@ -175,20 +254,28 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
app.manage(RwLock::new(AdsState {
|
||||
shown: true,
|
||||
modal_shown: false,
|
||||
occluded: false,
|
||||
last_click: None,
|
||||
malicious_origins: HashSet::new(),
|
||||
}));
|
||||
|
||||
// We refresh the ads window every 5 minutes to mitigate memory leak issues.
|
||||
// While this loop doesn't include explicit checks to see if the window is still
|
||||
// visible when we refresh, the Aditude wrapper will not make any ad requests
|
||||
// unless Chromium reports the page as visible. The refresh does not reset the
|
||||
// visibility state.
|
||||
// We refresh the ads window periodically to mitigate memory leak issues.
|
||||
// Skip refreshes when app state has hidden the ads WebView. The refresh does
|
||||
// not reset the visibility state.
|
||||
let refresh_app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
if let Some(webview) =
|
||||
refresh_app.webviews().get_mut("ads-window")
|
||||
let should_refresh = refresh_app
|
||||
.state::<RwLock<AdsState>>()
|
||||
.try_read()
|
||||
.map(|state| {
|
||||
state.shown && !state.modal_shown && !state.occluded
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if should_refresh
|
||||
&& let Some(webview) =
|
||||
refresh_app.webviews().get_mut("ads-window")
|
||||
{
|
||||
let _ = webview.navigate(AD_LINK.parse().unwrap());
|
||||
}
|
||||
@@ -226,6 +313,19 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
{
|
||||
let app_handle = app.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
sync_ads_occlusion(&app_handle).await;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
use std::ptr::NonNull;
|
||||
|
||||
use core_foundation::array::CFArray;
|
||||
use core_foundation::base::{CFType, TCFType};
|
||||
use core_foundation::dictionary::CFDictionary;
|
||||
use core_foundation::number::CFNumber;
|
||||
use core_foundation::string::CFString;
|
||||
use core_graphics::display::CGDisplay;
|
||||
use core_graphics::geometry::{CGPoint, CGRect, CGSize};
|
||||
use core_graphics::window::{
|
||||
CGWindowID, kCGWindowAlpha, kCGWindowBounds, kCGWindowLayer,
|
||||
kCGWindowListExcludeDesktopElements,
|
||||
kCGWindowListOptionOnScreenAboveWindow, kCGWindowListOptionOnScreenOnly,
|
||||
kCGWindowNumber, kCGWindowOwnerName, kCGWindowOwnerPID,
|
||||
};
|
||||
use objc2_app_kit::NSWindow;
|
||||
|
||||
pub fn is_ads_webview_occluded<R: tauri::Runtime>(
|
||||
main_window: &tauri::Window<R>,
|
||||
ad_x: i32,
|
||||
ad_y: i32,
|
||||
ad_width: u32,
|
||||
ad_height: u32,
|
||||
) -> Option<bool> {
|
||||
let scale_factor = main_window.scale_factor().ok()?;
|
||||
let maybe_ns_window = main_window.ns_window().ok()?;
|
||||
let ns_window = NonNull::new(maybe_ns_window)?.cast::<NSWindow>();
|
||||
let ns_window = unsafe { ns_window.as_ref() };
|
||||
let window_number = ns_window.windowNumber();
|
||||
let main_window_id = if window_number > 0 {
|
||||
window_number as CGWindowID
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let window_infos = CGDisplay::window_list_info(
|
||||
kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
|
||||
None,
|
||||
)?;
|
||||
|
||||
let window_infos = unsafe {
|
||||
CFArray::<CFDictionary<CFString, CFType>>::wrap_under_get_rule(
|
||||
window_infos.as_concrete_TypeRef(),
|
||||
)
|
||||
};
|
||||
let main_window_rect = window_infos
|
||||
.iter()
|
||||
.find(|window_info| window_id(window_info) == Some(main_window_id))
|
||||
.and_then(|window_info| window_rect(&window_info))?;
|
||||
let ad_rect = ad_rect_from_main_window(
|
||||
&main_window_rect,
|
||||
ad_x,
|
||||
ad_y,
|
||||
ad_width,
|
||||
ad_height,
|
||||
scale_factor,
|
||||
);
|
||||
|
||||
if is_empty_rect(&ad_rect) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ad_area = rect_area(&ad_rect);
|
||||
|
||||
if ad_area == 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let app_process_id = std::process::id() as i32;
|
||||
let windows_above_main = CGDisplay::window_list_info(
|
||||
kCGWindowListOptionOnScreenAboveWindow
|
||||
| kCGWindowListExcludeDesktopElements,
|
||||
Some(main_window_id),
|
||||
)?;
|
||||
let windows_above_main = unsafe {
|
||||
CFArray::<CFDictionary<CFString, CFType>>::wrap_under_get_rule(
|
||||
windows_above_main.as_concrete_TypeRef(),
|
||||
)
|
||||
};
|
||||
let mut occluded_area = 0.0;
|
||||
|
||||
for window_info in windows_above_main.iter() {
|
||||
if window_id(&window_info) == Some(main_window_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if window_process_id(&window_info) == Some(app_process_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let owner_name = window_owner_name(&window_info);
|
||||
|
||||
if owner_name.as_deref().is_some_and(is_system_window_owner) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let layer = window_layer(&window_info);
|
||||
|
||||
if layer != Some(0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let alpha = window_alpha(&window_info);
|
||||
|
||||
if alpha.is_some_and(|alpha| alpha <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(rect) = window_rect(&window_info) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(intersection) = intersect_rects(&ad_rect, &rect) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
occluded_area += rect_area(&intersection);
|
||||
let occluded_ratio = occluded_area / ad_area;
|
||||
|
||||
if occluded_ratio >= super::ads::OCCLUDED_AREA_THRESHOLD {
|
||||
return Some(true);
|
||||
}
|
||||
}
|
||||
|
||||
Some(false)
|
||||
}
|
||||
|
||||
fn ad_rect_from_main_window(
|
||||
main_window_rect: &CGRect,
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
scale_factor: f64,
|
||||
) -> CGRect {
|
||||
let scale_factor = if scale_factor > 0.0 {
|
||||
scale_factor
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
CGRect::new(
|
||||
&CGPoint::new(
|
||||
main_window_rect.origin.x + x as f64 / scale_factor,
|
||||
main_window_rect.origin.y + y as f64 / scale_factor,
|
||||
),
|
||||
&CGSize::new(width as f64 / scale_factor, height as f64 / scale_factor),
|
||||
)
|
||||
}
|
||||
|
||||
fn window_id(
|
||||
window_info: &CFDictionary<CFString, CFType>,
|
||||
) -> Option<CGWindowID> {
|
||||
number_value(window_info, unsafe { kCGWindowNumber })
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
}
|
||||
|
||||
fn window_process_id(
|
||||
window_info: &CFDictionary<CFString, CFType>,
|
||||
) -> Option<i32> {
|
||||
number_value(window_info, unsafe { kCGWindowOwnerPID })
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
}
|
||||
|
||||
fn window_layer(window_info: &CFDictionary<CFString, CFType>) -> Option<i32> {
|
||||
number_value(window_info, unsafe { kCGWindowLayer })
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
}
|
||||
|
||||
fn window_alpha(window_info: &CFDictionary<CFString, CFType>) -> Option<f64> {
|
||||
let key = unsafe { CFString::wrap_under_get_rule(kCGWindowAlpha) };
|
||||
|
||||
window_info.find(&key)?.downcast::<CFNumber>()?.to_f64()
|
||||
}
|
||||
|
||||
fn window_owner_name(
|
||||
window_info: &CFDictionary<CFString, CFType>,
|
||||
) -> Option<String> {
|
||||
let key = unsafe { CFString::wrap_under_get_rule(kCGWindowOwnerName) };
|
||||
|
||||
Some(window_info.find(&key)?.downcast::<CFString>()?.to_string())
|
||||
}
|
||||
|
||||
fn is_system_window_owner(owner_name: &str) -> bool {
|
||||
matches!(owner_name, "WindowManager")
|
||||
}
|
||||
|
||||
fn number_value(
|
||||
window_info: &CFDictionary<CFString, CFType>,
|
||||
key: core_foundation::string::CFStringRef,
|
||||
) -> Option<i64> {
|
||||
let key = unsafe { CFString::wrap_under_get_rule(key) };
|
||||
|
||||
window_info.find(&key)?.downcast::<CFNumber>()?.to_i64()
|
||||
}
|
||||
|
||||
fn window_rect(window_info: &CFDictionary<CFString, CFType>) -> Option<CGRect> {
|
||||
let key = unsafe { CFString::wrap_under_get_rule(kCGWindowBounds) };
|
||||
let bounds = window_info.find(&key)?.downcast::<CFDictionary>()?;
|
||||
let rect = CGRect::from_dict_representation(&bounds)?;
|
||||
|
||||
if is_empty_rect(&rect) {
|
||||
None
|
||||
} else {
|
||||
Some(rect)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty_rect(rect: &CGRect) -> bool {
|
||||
rect.size.width <= 0.0 || rect.size.height <= 0.0
|
||||
}
|
||||
|
||||
fn rect_area(rect: &CGRect) -> f64 {
|
||||
if is_empty_rect(rect) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
rect.size.width * rect.size.height
|
||||
}
|
||||
|
||||
fn intersect_rects(a: &CGRect, b: &CGRect) -> Option<CGRect> {
|
||||
let left = a.origin.x.max(b.origin.x);
|
||||
let top = a.origin.y.max(b.origin.y);
|
||||
let right = (a.origin.x + a.size.width).min(b.origin.x + b.size.width);
|
||||
let bottom = (a.origin.y + a.size.height).min(b.origin.y + b.size.height);
|
||||
let rect = CGRect::new(
|
||||
&CGPoint::new(left, top),
|
||||
&CGSize::new(right - left, bottom - top),
|
||||
);
|
||||
|
||||
if is_empty_rect(&rect) {
|
||||
None
|
||||
} else {
|
||||
Some(rect)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
use windows::Win32::Foundation::{HWND, POINT, RECT};
|
||||
use windows::Win32::Graphics::Dwm::{
|
||||
DWMWA_CLOAKED, DWMWA_EXTENDED_FRAME_BOUNDS, DwmGetWindowAttribute,
|
||||
};
|
||||
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GA_ROOT, GW_HWNDNEXT, GetAncestor, GetTopWindow, GetWindow, GetWindowRect,
|
||||
GetWindowThreadProcessId, IsIconic, IsWindowVisible,
|
||||
};
|
||||
|
||||
pub fn is_ads_webview_occluded(
|
||||
main_hwnd: HWND,
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> bool {
|
||||
let Some(ad_rect) = ad_rect_in_screen(main_hwnd, x, y, width, height)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if is_empty_rect(&ad_rect) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let ad_area = rect_area(&ad_rect);
|
||||
if ad_area == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut occluded_area = 0u64;
|
||||
let app_root = unsafe { GetAncestor(main_hwnd, GA_ROOT) };
|
||||
let app_process_id = std::process::id();
|
||||
let mut hwnd = match unsafe { GetTopWindow(None) } {
|
||||
Ok(hwnd) => hwnd,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
while !hwnd.is_invalid() {
|
||||
let window_root = unsafe { GetAncestor(hwnd, GA_ROOT) };
|
||||
|
||||
if window_root == app_root {
|
||||
return false;
|
||||
}
|
||||
|
||||
if window_process_id(hwnd) == Some(app_process_id) {
|
||||
hwnd = match unsafe { GetWindow(hwnd, GW_HWNDNEXT) } {
|
||||
Ok(hwnd) => hwnd,
|
||||
Err(_) => break,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if window_counts_as_occluder(hwnd)
|
||||
&& let Some(occluder_rect) = window_rect(hwnd)
|
||||
&& let Some(intersection) =
|
||||
intersect_rects(&ad_rect, &occluder_rect)
|
||||
{
|
||||
occluded_area =
|
||||
occluded_area.saturating_add(rect_area(&intersection));
|
||||
|
||||
if (occluded_area as f64 / ad_area as f64)
|
||||
>= super::ads::OCCLUDED_AREA_THRESHOLD
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
hwnd = match unsafe { GetWindow(hwnd, GW_HWNDNEXT) } {
|
||||
Ok(hwnd) => hwnd,
|
||||
Err(_) => break,
|
||||
};
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn ad_rect_in_screen(
|
||||
main_hwnd: HWND,
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Option<RECT> {
|
||||
let mut origin = POINT { x: 0, y: 0 };
|
||||
|
||||
if !unsafe { ClientToScreen(main_hwnd, &mut origin).as_bool() } {
|
||||
return None;
|
||||
}
|
||||
|
||||
let left = origin.x.saturating_add(x);
|
||||
let top = origin.y.saturating_add(y);
|
||||
let right = left.saturating_add(width as i32);
|
||||
let bottom = top.saturating_add(height as i32);
|
||||
|
||||
Some(RECT {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
})
|
||||
}
|
||||
|
||||
fn window_counts_as_occluder(hwnd: HWND) -> bool {
|
||||
if !unsafe { IsWindowVisible(hwnd).as_bool() } {
|
||||
return false;
|
||||
}
|
||||
|
||||
if unsafe { IsIconic(hwnd).as_bool() } {
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_dwm_cloaked(hwnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn window_process_id(hwnd: HWND) -> Option<u32> {
|
||||
let mut process_id = 0u32;
|
||||
|
||||
unsafe {
|
||||
GetWindowThreadProcessId(hwnd, Some(&mut process_id));
|
||||
}
|
||||
|
||||
(process_id != 0).then_some(process_id)
|
||||
}
|
||||
|
||||
fn is_dwm_cloaked(hwnd: HWND) -> bool {
|
||||
let mut cloaked = 0u32;
|
||||
|
||||
unsafe {
|
||||
DwmGetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_CLOAKED,
|
||||
&mut cloaked as *mut u32 as *mut _,
|
||||
std::mem::size_of::<u32>() as u32,
|
||||
)
|
||||
}
|
||||
.is_ok()
|
||||
&& cloaked != 0
|
||||
}
|
||||
|
||||
fn window_rect(hwnd: HWND) -> Option<RECT> {
|
||||
let mut rect = RECT::default();
|
||||
|
||||
if unsafe {
|
||||
DwmGetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_EXTENDED_FRAME_BOUNDS,
|
||||
&mut rect as *mut RECT as *mut _,
|
||||
std::mem::size_of::<RECT>() as u32,
|
||||
)
|
||||
}
|
||||
.is_err()
|
||||
&& unsafe { GetWindowRect(hwnd, &mut rect) }.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if is_empty_rect(&rect) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(rect)
|
||||
}
|
||||
|
||||
fn is_empty_rect(rect: &RECT) -> bool {
|
||||
rect.right <= rect.left || rect.bottom <= rect.top
|
||||
}
|
||||
|
||||
fn rect_area(rect: &RECT) -> u64 {
|
||||
if is_empty_rect(rect) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
(rect.right - rect.left) as u64 * (rect.bottom - rect.top) as u64
|
||||
}
|
||||
|
||||
fn intersect_rects(a: &RECT, b: &RECT) -> Option<RECT> {
|
||||
let rect = RECT {
|
||||
left: a.left.max(b.left),
|
||||
top: a.top.max(b.top),
|
||||
right: a.right.min(b.right),
|
||||
bottom: a.bottom.min(b.bottom),
|
||||
};
|
||||
|
||||
(!is_empty_rect(&rect)).then_some(rect)
|
||||
}
|
||||
@@ -11,10 +11,12 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
get_available_capes,
|
||||
get_available_skins,
|
||||
add_and_equip_custom_skin,
|
||||
set_default_cape,
|
||||
equip_skin,
|
||||
remove_custom_skin,
|
||||
save_custom_skin,
|
||||
unequip_skin,
|
||||
flush_pending_skin_change,
|
||||
flush_pending_skin_change_for_profile,
|
||||
normalize_skin_texture,
|
||||
get_dragged_skin_data,
|
||||
])
|
||||
@@ -37,29 +39,19 @@ pub async fn get_available_skins() -> Result<Vec<Skin>> {
|
||||
Ok(minecraft_skins::get_available_skins().await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape_override)`
|
||||
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape)`
|
||||
///
|
||||
/// See also: [minecraft_skins::add_and_equip_custom_skin]
|
||||
#[tauri::command]
|
||||
pub async fn add_and_equip_custom_skin(
|
||||
texture_blob: Bytes,
|
||||
variant: MinecraftSkinVariant,
|
||||
cape_override: Option<Cape>,
|
||||
) -> Result<()> {
|
||||
Ok(minecraft_skins::add_and_equip_custom_skin(
|
||||
texture_blob,
|
||||
variant,
|
||||
cape_override,
|
||||
cape: Option<Cape>,
|
||||
) -> Result<Skin> {
|
||||
Ok(
|
||||
minecraft_skins::add_and_equip_custom_skin(texture_blob, variant, cape)
|
||||
.await?,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|set_default_cape', cape)`
|
||||
///
|
||||
/// See also: [minecraft_skins::set_default_cape]
|
||||
#[tauri::command]
|
||||
pub async fn set_default_cape(cape: Option<Cape>) -> Result<()> {
|
||||
Ok(minecraft_skins::set_default_cape(cape).await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|equip_skin', skin)`
|
||||
@@ -78,6 +70,27 @@ pub async fn remove_custom_skin(skin: Skin) -> Result<()> {
|
||||
Ok(minecraft_skins::remove_custom_skin(skin).await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|save_custom_skin', skin, texture_blob, variant, cape, replace_texture)`
|
||||
///
|
||||
/// See also: [minecraft_skins::save_custom_skin]
|
||||
#[tauri::command]
|
||||
pub async fn save_custom_skin(
|
||||
skin: Skin,
|
||||
texture_blob: Bytes,
|
||||
variant: MinecraftSkinVariant,
|
||||
cape: Option<Cape>,
|
||||
replace_texture: bool,
|
||||
) -> Result<Skin> {
|
||||
Ok(minecraft_skins::save_custom_skin(
|
||||
skin,
|
||||
texture_blob,
|
||||
variant,
|
||||
cape,
|
||||
replace_texture,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|unequip_skin')`
|
||||
///
|
||||
/// See also: [minecraft_skins::unequip_skin]
|
||||
@@ -86,6 +99,27 @@ pub async fn unequip_skin() -> Result<()> {
|
||||
Ok(minecraft_skins::unequip_skin().await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|flush_pending_skin_change')`
|
||||
///
|
||||
/// See also: [minecraft_skins::flush_pending_skin_change]
|
||||
#[tauri::command]
|
||||
pub async fn flush_pending_skin_change() -> Result<()> {
|
||||
Ok(minecraft_skins::flush_pending_skin_change().await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|flush_pending_skin_change_for_profile', profile_id)`
|
||||
///
|
||||
/// See also: [minecraft_skins::flush_pending_skin_change_for_profile]
|
||||
#[tauri::command]
|
||||
pub async fn flush_pending_skin_change_for_profile(
|
||||
profile_id: uuid::Uuid,
|
||||
) -> Result<()> {
|
||||
Ok(
|
||||
minecraft_skins::flush_pending_skin_change_for_profile(profile_id)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|normalize_skin_texture')`
|
||||
///
|
||||
/// See also: [minecraft_skins::normalize_skin_texture]
|
||||
|
||||
@@ -18,6 +18,10 @@ pub mod tags;
|
||||
pub mod utils;
|
||||
|
||||
pub mod ads;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod ads_occlusion_macos;
|
||||
#[cfg(windows)]
|
||||
mod ads_occlusion_windows;
|
||||
pub mod cache;
|
||||
pub mod files;
|
||||
pub mod friends;
|
||||
|
||||
+12
-2
@@ -270,10 +270,20 @@ fn main() {
|
||||
Ok(app) => {
|
||||
app.run(|app, event| {
|
||||
#[cfg(not(any(feature = "updater", target_os = "macos")))]
|
||||
drop((app, event));
|
||||
let _ = app;
|
||||
|
||||
if matches!(&event, tauri::RunEvent::ExitRequested { .. })
|
||||
&& let Err(error) = tauri::async_runtime::block_on(
|
||||
theseus::minecraft_skins::flush_pending_skin_change(),
|
||||
)
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to flush pending Minecraft skin change before exit: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
if matches!(event, tauri::RunEvent::Exit) {
|
||||
if matches!(&event, tauri::RunEvent::Exit) {
|
||||
let update_data = app.state::<PendingUpdateData>().inner();
|
||||
let should_restart = State::get_if_initialized()
|
||||
.map(|s| {
|
||||
|
||||
@@ -65,49 +65,65 @@ async fn fetch(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// We check Modrinth's fabric version manifest and compare if the fabric version exists in Modrinth's database
|
||||
// We also check intermediary versions that are newly added to query
|
||||
let (fetch_fabric_versions, fetch_intermediary_versions) =
|
||||
if let Some(modrinth_manifest) = modrinth_manifest {
|
||||
let (mut fetch_versions, mut fetch_intermediary_versions) =
|
||||
(Vec::new(), Vec::new());
|
||||
// We check Modrinth's manifest to find newly added loader versions,
|
||||
// intermediary/mapping artifacts, and game versions.
|
||||
let (
|
||||
fetch_fabric_versions,
|
||||
fetch_intermediary_versions,
|
||||
has_new_game_versions,
|
||||
) = if let Some(modrinth_manifest) = modrinth_manifest {
|
||||
let (mut fetch_versions, mut fetch_intermediary_versions) =
|
||||
(Vec::new(), Vec::new());
|
||||
|
||||
for version in &fabric_manifest.loader {
|
||||
if !modrinth_manifest
|
||||
.game_versions
|
||||
.iter()
|
||||
.any(|x| x.loaders.iter().any(|x| x.id == version.version))
|
||||
&& !skip_versions.contains(&&*version.version)
|
||||
{
|
||||
fetch_versions.push(version);
|
||||
}
|
||||
for version in &fabric_manifest.loader {
|
||||
if !modrinth_manifest
|
||||
.game_versions
|
||||
.iter()
|
||||
.any(|x| x.loaders.iter().any(|x| x.id == version.version))
|
||||
&& !skip_versions.contains(&&*version.version)
|
||||
{
|
||||
fetch_versions.push(version);
|
||||
}
|
||||
}
|
||||
|
||||
for version in &fabric_manifest.intermediary {
|
||||
if !modrinth_manifest
|
||||
for version in &fabric_manifest.intermediary {
|
||||
if !modrinth_manifest
|
||||
.game_versions
|
||||
.iter()
|
||||
.any(|x| x.id == version.version)
|
||||
&& fabric_manifest
|
||||
.game
|
||||
.iter()
|
||||
.any(|x| x.version == version.version)
|
||||
{
|
||||
fetch_intermediary_versions.push(version);
|
||||
}
|
||||
}
|
||||
|
||||
let has_new_game_versions =
|
||||
fabric_manifest.game.iter().any(|version| {
|
||||
!modrinth_manifest
|
||||
.game_versions
|
||||
.iter()
|
||||
.any(|x| x.id == version.version)
|
||||
&& fabric_manifest
|
||||
.game
|
||||
.iter()
|
||||
.any(|x| x.version == version.version)
|
||||
{
|
||||
fetch_intermediary_versions.push(version);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(fetch_versions, fetch_intermediary_versions)
|
||||
} else {
|
||||
(
|
||||
fabric_manifest
|
||||
.loader
|
||||
.iter()
|
||||
.filter(|x| !skip_versions.contains(&&*x.version))
|
||||
.collect(),
|
||||
fabric_manifest.intermediary.iter().collect(),
|
||||
)
|
||||
};
|
||||
(
|
||||
fetch_versions,
|
||||
fetch_intermediary_versions,
|
||||
has_new_game_versions,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
fabric_manifest
|
||||
.loader
|
||||
.iter()
|
||||
.filter(|x| !skip_versions.contains(&&*x.version))
|
||||
.collect(),
|
||||
fabric_manifest.intermediary.iter().collect(),
|
||||
true,
|
||||
)
|
||||
};
|
||||
|
||||
const DUMMY_GAME_VERSION: &str = "1.21";
|
||||
|
||||
@@ -216,6 +232,7 @@ async fn fetch(
|
||||
|
||||
if !fetch_fabric_versions.is_empty()
|
||||
|| !fetch_intermediary_versions.is_empty()
|
||||
|| has_new_game_versions
|
||||
{
|
||||
let fabric_manifest_path =
|
||||
format!("{mod_loader}/v{format_version}/manifest.json",);
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { GenericModrinthClient, type Labrinth } from '@modrinth/api-client'
|
||||
import serverSidedVue from '@vitejs/plugin-vue'
|
||||
import fs from 'fs/promises'
|
||||
import { defineNuxtConfig } from 'nuxt/config'
|
||||
import { fileURLToPath } from 'url'
|
||||
import svgLoader from 'vite-svg-loader'
|
||||
|
||||
import { GenericModrinthClient, type Labrinth } from '../../packages/api-client/src/index.ts'
|
||||
|
||||
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
|
||||
const API_CLIENT_SOURCE = fileURLToPath(
|
||||
new URL('../../packages/api-client/src/index.ts', import.meta.url),
|
||||
)
|
||||
|
||||
const preloadedFonts = [
|
||||
'inter/Inter-Regular.woff2',
|
||||
@@ -24,6 +29,9 @@ const STAGING_MODRINTH_URL = 'https://staging.modrinth.com'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
srcDir: 'src/',
|
||||
alias: {
|
||||
'@modrinth/api-client': API_CLIENT_SOURCE,
|
||||
},
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: {
|
||||
@@ -82,6 +90,9 @@ export default defineNuxtConfig({
|
||||
},
|
||||
cacheDir: '../../node_modules/.vite/apps/knossos',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@modrinth/api-client': API_CLIENT_SOURCE,
|
||||
},
|
||||
dedupe: ['vue'],
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"intl:extract": "formatjs extract \"src/{components,composables,layouts,middleware,modules,pages,plugins,utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/frontend",
|
||||
"cf-deploy": "pnpm run build && wrangler deploy --env staging",
|
||||
"cf-dev": "pnpm run build && wrangler dev --env staging",
|
||||
"cf-typegen": "wrangler types"
|
||||
@@ -38,7 +39,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.5.4",
|
||||
"@intercom/messenger-js-sdk": "^0.0.14",
|
||||
"@ltd/j-toml": "^1.38.0",
|
||||
"@modrinth/api-client": "workspace:*",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { BlueskyIcon, DiscordIcon, GithubIcon, MastodonIcon, TwitterIcon } from '@modrinth/assets'
|
||||
import {
|
||||
BlueskyIcon,
|
||||
DiscordIcon,
|
||||
GithubIcon,
|
||||
MastodonIcon,
|
||||
ToggleRightIcon,
|
||||
TwitterIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
AutoLink,
|
||||
ButtonStyled,
|
||||
@@ -10,6 +17,7 @@ import {
|
||||
type MessageDescriptor,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { commonSettingsMessages } from '@modrinth/ui/src/utils/common-messages.js'
|
||||
|
||||
import TextLogo from '~/components/brand/TextLogo.vue'
|
||||
|
||||
@@ -233,11 +241,21 @@ function developerModeIncrement() {
|
||||
role="region"
|
||||
:aria-label="formatMessage(messages.modrinthInformation)"
|
||||
>
|
||||
<TextLogo
|
||||
aria-hidden="true"
|
||||
class="text-logo button-base h-6 w-auto text-contrast lg:h-8"
|
||||
@click="developerModeIncrement()"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<TextLogo
|
||||
aria-hidden="true"
|
||||
class="text-logo button-base h-6 w-auto text-contrast lg:h-8"
|
||||
@click="developerModeIncrement()"
|
||||
/>
|
||||
<ButtonStyled v-if="flags.developerMode" circular type="transparent" color="brand">
|
||||
<nuxt-link
|
||||
v-tooltip="formatMessage(commonSettingsMessages.featureFlags)"
|
||||
to="/settings/flags"
|
||||
>
|
||||
<ToggleRightIcon />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center gap-px sm:-mx-2">
|
||||
<ButtonStyled
|
||||
v-for="(social, index) in socialLinks"
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||
import { XCircleIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
const tempIgnored = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
@@ -13,16 +17,34 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
"This deploy of Modrinth's frontend failed to generate state from the API. This may be due to an outage or an error in configuration. Rebuild when the API is available. Error codes: {errors}; Current API URL is: {url}",
|
||||
},
|
||||
ignoreErrors: {
|
||||
id: 'layout.banner.build-fail.ignore',
|
||||
defaultMessage: 'Ignore',
|
||||
},
|
||||
alwaysIgnore: {
|
||||
id: 'layout.banner.build-fail.always-ignore',
|
||||
defaultMessage: 'Always ignore',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
errors: any[] | undefined
|
||||
apiUrl: string
|
||||
}>()
|
||||
|
||||
function alwaysIgnoreBanner() {
|
||||
flags.value.alwaysIgnoreErrorBanner = true
|
||||
saveFeatureFlags()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagewideBanner v-if="errors?.length" variant="error">
|
||||
<PagewideBanner
|
||||
v-if="
|
||||
flags.showAllBanners || (errors?.length && !tempIgnored && !flags.alwaysIgnoreErrorBanner)
|
||||
"
|
||||
variant="error"
|
||||
>
|
||||
<template #title>
|
||||
<span>{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
@@ -34,5 +56,19 @@ defineProps<{
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template #actions_right>
|
||||
<ButtonStyled color="red" type="transparent" hover-color-fill="background">
|
||||
<button @click="alwaysIgnoreBanner">
|
||||
<XCircleIcon />
|
||||
{{ formatMessage(messages.alwaysIgnore) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="tempIgnored = true">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.ignoreErrors) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
normalizeChildren,
|
||||
PagewideBanner,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
const { formatMessage } = useVIntl()
|
||||
const flags = useFeatureFlags()
|
||||
const config = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
@@ -21,7 +21,7 @@ const messages = defineMessages({
|
||||
},
|
||||
description: {
|
||||
id: 'layout.banner.preview.description',
|
||||
defaultMessage: `If you meant to access the official Modrinth website, visit <link>https://modrinth.com</link>. This preview deploy is used by Modrinth staff for testing purposes. It was built using <branch-link>{owner}/{branch}</branch-link> @ {commit}.`,
|
||||
defaultMessage: `If you meant to access the official Modrinth website, visit {url}. This preview deploy is used by Modrinth staff for testing purposes. It was built using {ref}.`,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -29,39 +29,33 @@ function hidePreviewBanner() {
|
||||
flags.value.hidePreviewBanner = true
|
||||
saveFeatureFlags()
|
||||
}
|
||||
|
||||
const url = computed(() => `https://modrinth.com${route.fullPath}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagewideBanner v-if="!flags.hidePreviewBanner" variant="info">
|
||||
<PagewideBanner v-if="!flags.hidePreviewBanner || flags.showAllBanners" variant="info">
|
||||
<template #title>
|
||||
<span>{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
<span>
|
||||
<IntlFormatted
|
||||
:message-id="messages.description"
|
||||
:values="{
|
||||
owner: config.public.owner,
|
||||
branch: config.public.branch,
|
||||
}"
|
||||
>
|
||||
<template #link="{ children }">
|
||||
<a href="https://modrinth.com" target="_blank" rel="noopener" class="text-link">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
<IntlFormatted :message-id="messages.description">
|
||||
<template #url>
|
||||
<a :href="url" target="_blank" rel="noopener" class="text-link">
|
||||
{{ url }}
|
||||
</a>
|
||||
</template>
|
||||
<template #branch-link="{ children }">
|
||||
<template #ref>
|
||||
<a
|
||||
:href="`https://github.com/${config.public.owner}/code/tree/${config.public.branch}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="hover:underline"
|
||||
>
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
{{ config.public.owner }} / {{ config.public.branch }}
|
||||
</a>
|
||||
</template>
|
||||
<template #commit>
|
||||
<span v-if="config.public.hash === 'unknown'">unknown</span>
|
||||
@ <span v-if="config.public.hash === 'unknown'">unknown</span>
|
||||
<a
|
||||
v-else
|
||||
:href="`https://github.com/${config.public.owner}/code/commit/${config.public.hash}`"
|
||||
@@ -75,7 +69,7 @@ function hidePreviewBanner() {
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</template>
|
||||
<template #actions_right>
|
||||
<template #actions_top_right>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button :aria-label="formatMessage(commonMessages.closeButton)" @click="hidePreviewBanner">
|
||||
<XIcon aria-hidden="true" />
|
||||
|
||||
@@ -47,7 +47,7 @@ function hideRussiaCensorshipBanner() {
|
||||
<span class="text-xs font-medium">(Перевод на русский)</span>
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<ButtonStyled type="transparent" hover-color-fill="background">
|
||||
<nuxt-link to="/news/article/standing-by-our-values">
|
||||
<BookTextIcon /> Read our full statement
|
||||
<span class="text-xs font-medium">(English)</span>
|
||||
@@ -55,7 +55,7 @@ function hideRussiaCensorshipBanner() {
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions_right>
|
||||
<template #actions_top_right>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.closeButton)"
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const cosmetics = useCosmetics()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
@@ -29,14 +30,14 @@ function hideStagingBanner() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagewideBanner v-if="!cosmetics.hideStagingBanner" variant="warning">
|
||||
<PagewideBanner v-if="flags.showAllBanners || !cosmetics.hideStagingBanner" variant="warning">
|
||||
<template #title>
|
||||
<span>{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
{{ formatMessage(messages.description) }}
|
||||
</template>
|
||||
<template #actions_right>
|
||||
<template #actions_top_right>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button :aria-label="formatMessage(commonMessages.closeButton)" @click="hideStagingBanner">
|
||||
<XIcon aria-hidden="true" />
|
||||
|
||||
@@ -29,8 +29,8 @@ const messages = defineMessages({
|
||||
<template #description>
|
||||
<span>{{ formatMessage(messages.description) }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<ButtonStyled>
|
||||
<template #actions_right>
|
||||
<ButtonStyled color="red">
|
||||
<nuxt-link to="/settings/billing">
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.action) }}
|
||||
|
||||
@@ -55,7 +55,7 @@ function openTaxForm(e: MouseEvent) {
|
||||
formatMessage(messages.description, { threshold: formatMoney(taxThreshold) })
|
||||
}}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<template #actions_right>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="openTaxForm"><FileTextIcon /> {{ formatMessage(messages.action) }}</button>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -29,7 +29,7 @@ const messages = defineMessages({
|
||||
<template #description>
|
||||
<span>{{ formatMessage(messages.description) }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<template #actions_right>
|
||||
<div class="flex w-fit flex-row">
|
||||
<ButtonStyled color="red">
|
||||
<nuxt-link to="https://support.modrinth.com" target="_blank" rel="noopener">
|
||||
|
||||
@@ -96,14 +96,12 @@ async function handleResendEmailVerification() {
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<ButtonStyled v-if="hasEmail">
|
||||
<button @click="handleResendEmailVerification">
|
||||
<template #actions_right>
|
||||
<ButtonStyled color="orange">
|
||||
<button v-if="hasEmail" @click="handleResendEmailVerification">
|
||||
{{ formatMessage(verifyEmailBannerMessages.action) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<nuxt-link to="/settings/account">
|
||||
<nuxt-link v-else to="/settings/account">
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
{{ formatMessage(addEmailBannerMessages.action) }}
|
||||
</nuxt-link>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { PagewideBanner } from '@modrinth/ui'
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
const route = useRoute()
|
||||
|
||||
const url = computed(() => `https://modrinth.com${route.fullPath}`)
|
||||
|
||||
const bannerRoot = ref<HTMLElement | null>(null)
|
||||
|
||||
function onProdLinkClick(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
const el = bannerRoot.value
|
||||
if (el) {
|
||||
const { height } = el.getBoundingClientRect()
|
||||
window.scrollBy({ top: Math.ceil(height), behavior: 'auto' })
|
||||
}
|
||||
window.open(url.value, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="flags.showViewProdRouteBanner || flags.showAllBanners" ref="bannerRoot">
|
||||
<PagewideBanner variant="info" slim>
|
||||
<template #description>
|
||||
<span>
|
||||
View route on production:
|
||||
<a
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-link"
|
||||
@click="onProdLinkClick"
|
||||
>
|
||||
{{ url }}
|
||||
</a>
|
||||
</span>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
</div>
|
||||
</template>
|
||||
@@ -46,7 +46,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast"> Version changlog </span>
|
||||
<span class="font-semibold text-contrast"> Version changelog </span>
|
||||
|
||||
<div class="w-full">
|
||||
<MarkdownEditor
|
||||
|
||||
@@ -131,10 +131,9 @@
|
||||
class="h-10"
|
||||
/>
|
||||
|
||||
<StyledInput
|
||||
<DatePicker
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="formData[field.name]"
|
||||
type="date"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
@@ -230,6 +229,7 @@ import {
|
||||
Admonition,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
DatePicker,
|
||||
defineMessages,
|
||||
financialMessages,
|
||||
formFieldLabels,
|
||||
|
||||
@@ -95,11 +95,11 @@
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
<DatePicker
|
||||
v-model="formData.dateOfBirth"
|
||||
type="date"
|
||||
:max="maxDate"
|
||||
:max-date="maxDate"
|
||||
autocomplete="bday"
|
||||
placeholder="Select date of birth"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -213,6 +213,7 @@
|
||||
import {
|
||||
Chips,
|
||||
Combobox,
|
||||
DatePicker,
|
||||
defineMessages,
|
||||
formFieldLabels,
|
||||
formFieldPlaceholders,
|
||||
|
||||
@@ -233,7 +233,7 @@ const visibleNags = computed<Nag[]>(() => {
|
||||
link: {
|
||||
path: 'moderation',
|
||||
title: messages.visitModerationPage,
|
||||
shouldShow: () => props.routeName !== 'type-id-moderation',
|
||||
shouldShow: () => props.routeName !== 'type-project-moderation',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="shadow-card rounded-2xl border border-solid border-surface-5 bg-surface-3 p-4">
|
||||
<div class="shadow-card rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<NuxtLink
|
||||
@@ -48,6 +48,16 @@
|
||||
<span class="text-sm text-secondary">Requesting</span>
|
||||
<Badge :type="queueEntry.project.requested_status" class="text-sm" />
|
||||
</div>
|
||||
<div
|
||||
v-if="showExternalDependencies"
|
||||
v-tooltip="'External dependencies'"
|
||||
class="flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
|
||||
>
|
||||
<FileIcon aria-hidden="true" class="size-4 text-secondary" />
|
||||
<span class="text-sm font-medium text-secondary">
|
||||
{{ queueEntry.external_dependencies_count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="queueEntry.ownership?.kind === 'user'">
|
||||
<NuxtLink
|
||||
@@ -107,8 +117,8 @@
|
||||
<LinkIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-tooltip="'Begin review'" circular color="orange">
|
||||
<button @click="openProjectForReview">
|
||||
<ButtonStyled circular color="orange">
|
||||
<button v-tooltip="'Begin review'" @click="openProjectForReview">
|
||||
<ScaleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -119,7 +129,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClipboardCopyIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
|
||||
import { ClipboardCopyIcon, FileIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
@@ -151,6 +161,7 @@ const formatDateTimeFull = useFormatDateTime({
|
||||
|
||||
const props = defineProps<{
|
||||
queueEntry: ModerationProject
|
||||
showExternalDependencies?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
:expand-text="expandText"
|
||||
collapse-text="Collapse thread"
|
||||
>
|
||||
<div class="bg-surface-2 p-4 pt-2">
|
||||
<div class="bg-surface-2 pt-2">
|
||||
<ThreadView
|
||||
v-if="threadWithReportBody"
|
||||
ref="reportThread"
|
||||
|
||||
@@ -1075,6 +1075,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
mode="local"
|
||||
:links="navTabsLinks"
|
||||
:active-index="activeTabIndex"
|
||||
class="bg-surface-3! shadow-none!"
|
||||
@tab-click="handleTabClick"
|
||||
/>
|
||||
</div>
|
||||
@@ -1087,7 +1088,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
collapse-text="Collapse thread"
|
||||
class="border-x border-b border-solid border-surface-3"
|
||||
>
|
||||
<div class="bg-surface-2 p-4 pt-0">
|
||||
<div class="bg-surface-2 pt-0">
|
||||
<!-- DEV-531 -->
|
||||
<!-- @vue-expect-error TODO: will convert ThreadView to use api-client types at a later date -->
|
||||
<ThreadView
|
||||
|
||||
@@ -358,26 +358,44 @@
|
||||
|
||||
<div v-else-if="generatedMessage" class="flex items-center gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="goBackToStages">
|
||||
<button :disabled="loadingModerationDecision" @click="goBackToStages">
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
Edit
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="sendMessage('rejected')">
|
||||
<XIcon aria-hidden="true" />
|
||||
<button :disabled="loadingModerationDecision" @click="sendMessage('rejected')">
|
||||
<SpinnerIcon
|
||||
v-if="moderationDecision === 'rejected'"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<XIcon v-else aria-hidden="true" />
|
||||
Reject
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="sendMessage('withheld')">
|
||||
<EyeOffIcon aria-hidden="true" />
|
||||
<button :disabled="loadingModerationDecision" @click="sendMessage('withheld')">
|
||||
<SpinnerIcon
|
||||
v-if="moderationDecision === 'withheld'"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<LinkIcon v-else aria-hidden="true" />
|
||||
Withhold
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="green">
|
||||
<button @click="sendMessage(projectV2.requested_status ?? 'approved')">
|
||||
<CheckIcon aria-hidden="true" />
|
||||
<button
|
||||
:disabled="loadingModerationDecision"
|
||||
@click="sendMessage(approveSendStatus)"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="moderationDecision === approveSendStatus"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckIcon v-else aria-hidden="true" />
|
||||
Approve
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -428,14 +446,15 @@ import {
|
||||
BrushCleaningIcon,
|
||||
CheckIcon,
|
||||
DropdownIcon,
|
||||
EyeOffIcon,
|
||||
FileTextIcon,
|
||||
KeyboardIcon,
|
||||
LeftArrowIcon,
|
||||
LinkIcon,
|
||||
ListBulletedIcon,
|
||||
LockIcon,
|
||||
RightArrowIcon,
|
||||
ScaleIcon,
|
||||
SpinnerIcon,
|
||||
ToggleLeftIcon,
|
||||
ToggleRightIcon,
|
||||
XIcon,
|
||||
@@ -682,11 +701,11 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
|
||||
|
||||
// Quick re-check if close to expiry (last 5 seconds of TTL)
|
||||
if (now - next.validatedAt > PREFETCH_STALE_MS - 5000) {
|
||||
const recheck = await moderationQueue.checkLock(next.projectId)
|
||||
if (recheck.locked && !recheck.expired) {
|
||||
// Project got locked, remove from queue and try next
|
||||
const recheckResults = await batchCheckQueueCandidates([next.projectId])
|
||||
const recheck = recheckResults.get(next.projectId)
|
||||
if (!isEligibleQueueCandidate(recheck)) {
|
||||
prefetchQueue.value.shift()
|
||||
return navigateToNextUnlockedProject() // Recurse to try next
|
||||
return navigateToNextUnlockedProject()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,33 +717,14 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
|
||||
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
|
||||
)
|
||||
|
||||
if (next.skippedIds.length > 0) {
|
||||
addNotification({
|
||||
title: 'Skipped locked projects',
|
||||
text: `Skipped ${next.skippedIds.length} project(s) being moderated by others.`,
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
notifySkippedQueueProjects(next.skippedIds.length)
|
||||
|
||||
// Trigger prefetch replenishment in background (don't await)
|
||||
maintainPrefetchQueue()
|
||||
|
||||
// Navigate to canonical URL if we have metadata (avoids middleware redirect)
|
||||
if (next.slug && next.projectType) {
|
||||
const urlType = getProjectTypeForUrlShorthand(next.projectType, [], tags.value)
|
||||
|
||||
navigateTo({
|
||||
path: `/${urlType}/${next.slug}`,
|
||||
state: { showChecklist: true },
|
||||
})
|
||||
} else {
|
||||
// Fallback: use project ID (will trigger middleware redirect)
|
||||
navigateTo({
|
||||
name: 'type-id',
|
||||
params: { type: 'project', id: next.projectId },
|
||||
state: { showChecklist: true },
|
||||
})
|
||||
}
|
||||
navigateToQueueProject(
|
||||
{ slug: next.slug, projectType: next.projectType, locked: false, isProcessing: true },
|
||||
next.projectId,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -760,6 +760,12 @@ const message = ref(
|
||||
)
|
||||
const generatedMessage = ref(persistedGeneratedMessage.generated === true)
|
||||
const loadingMessage = ref(false)
|
||||
const moderationDecision = ref<ProjectStatus | null>(null)
|
||||
const loadingModerationDecision = computed(() => moderationDecision.value !== null)
|
||||
const approveSendStatus = computed<ProjectStatus>(() => {
|
||||
const requested = projectV2.value.requested_status
|
||||
return requested ?? 'approved'
|
||||
})
|
||||
const done = ref(false)
|
||||
|
||||
function persistGeneratedMessageState() {
|
||||
@@ -844,53 +850,108 @@ function reviewAnyway() {
|
||||
maintainPrefetchQueue()
|
||||
}
|
||||
|
||||
// Batch check locks and fetch project metadata in parallel
|
||||
interface LockCheckResult {
|
||||
// Batch check locks, processing status, and fetch project metadata in parallel
|
||||
interface QueueCandidateCheck {
|
||||
locked: boolean
|
||||
expired?: boolean
|
||||
isOwnLock?: boolean
|
||||
slug?: string
|
||||
projectType?: string
|
||||
status?: string
|
||||
isProcessing: boolean
|
||||
}
|
||||
|
||||
async function batchCheckLocksWithMetadata(
|
||||
function isEligibleQueueCandidate(result: QueueCandidateCheck | undefined): boolean {
|
||||
if (!result?.isProcessing) return false
|
||||
return !result.locked || !!result.expired || !!result.isOwnLock
|
||||
}
|
||||
|
||||
function notifySkippedQueueProjects(count: number) {
|
||||
if (count <= 0) return
|
||||
addNotification({
|
||||
title: 'Skipped projects',
|
||||
text: `Skipped ${count} project(s) already moderated or locked by others.`,
|
||||
type: 'info',
|
||||
autoCloseMs: 2000,
|
||||
})
|
||||
}
|
||||
|
||||
function navigateToQueueProject(result: QueueCandidateCheck, projectId: string) {
|
||||
if (result.slug && result.projectType) {
|
||||
const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value)
|
||||
navigateTo({
|
||||
path: `/${urlType}/${result.slug}`,
|
||||
state: { showChecklist: true },
|
||||
})
|
||||
} else {
|
||||
navigateTo({
|
||||
name: 'type-project',
|
||||
params: { type: 'project', project: projectId },
|
||||
state: { showChecklist: true },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function batchCheckQueueCandidates(
|
||||
projectIds: string[],
|
||||
): Promise<Map<string, LockCheckResult>> {
|
||||
const results = new Map<string, LockCheckResult>()
|
||||
): Promise<Map<string, QueueCandidateCheck>> {
|
||||
const results = new Map<string, QueueCandidateCheck>()
|
||||
|
||||
// Check locks and fetch minimal project data in parallel
|
||||
const checks = await Promise.allSettled(
|
||||
projectIds.map(async (id) => {
|
||||
// Parallel: check lock AND fetch project metadata
|
||||
const [lockResponse, projectData] = await Promise.all([
|
||||
moderationQueue.checkLock(id),
|
||||
useBaseFetch(`project/${id}`, { method: 'GET' }).catch(() => null),
|
||||
])
|
||||
|
||||
const status = (projectData as { status?: string } | null)?.status
|
||||
|
||||
return {
|
||||
id,
|
||||
locked: lockResponse.locked,
|
||||
expired: lockResponse.expired,
|
||||
isOwnLock: lockResponse.is_own_lock,
|
||||
slug: (projectData as { slug?: string })?.slug,
|
||||
projectType: (projectData as { project_type?: string })?.project_type,
|
||||
slug: (projectData as { slug?: string } | null)?.slug,
|
||||
projectType: (projectData as { project_type?: string } | null)?.project_type,
|
||||
status,
|
||||
isProcessing: projectData === null ? true : status === 'processing',
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Use forEach with index to avoid indexOf bug on PromiseSettledResult
|
||||
checks.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
results.set(result.value.id, result.value)
|
||||
} else {
|
||||
// On error, mark as needing fallback (no metadata)
|
||||
results.set(projectIds[index], { locked: false })
|
||||
results.set(projectIds[index], { locked: false, isProcessing: true })
|
||||
}
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function findNextEligibleQueueProject(candidateIds: string[]) {
|
||||
const skippedIds: string[] = []
|
||||
let checkedCount = 0
|
||||
|
||||
while (checkedCount < candidateIds.length) {
|
||||
const batch = candidateIds.slice(checkedCount, checkedCount + PREFETCH_BATCH_SIZE)
|
||||
checkedCount += batch.length
|
||||
|
||||
const results = await batchCheckQueueCandidates(batch)
|
||||
|
||||
for (const id of batch) {
|
||||
const result = results.get(id)
|
||||
if (isEligibleQueueCandidate(result)) {
|
||||
return { projectId: id, result: result!, skippedIds: [...skippedIds] }
|
||||
}
|
||||
skippedIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Maintain a queue of prefetched unlocked projects for instant navigation
|
||||
async function maintainPrefetchQueue() {
|
||||
if (isPrefetching.value) return
|
||||
@@ -922,13 +983,10 @@ async function maintainPrefetchQueue() {
|
||||
const remainingItems =
|
||||
currentIndex >= 0 ? queueItems.slice(currentIndex + 1) : queueItems.slice(1)
|
||||
|
||||
const candidateIds = remainingItems
|
||||
.filter((id) => !prefetchedIds.has(id))
|
||||
.slice(0, PREFETCH_BATCH_SIZE * 2) // Check up to 10 candidates
|
||||
const candidateIds = remainingItems.filter((id) => !prefetchedIds.has(id))
|
||||
|
||||
if (candidateIds.length === 0) return
|
||||
|
||||
// 5. Batch check locks AND fetch metadata in parallel
|
||||
const skippedIds: string[] = []
|
||||
let checkedCount = 0
|
||||
|
||||
@@ -939,31 +997,18 @@ async function maintainPrefetchQueue() {
|
||||
const batch = candidateIds.slice(checkedCount, checkedCount + PREFETCH_BATCH_SIZE)
|
||||
checkedCount += batch.length
|
||||
|
||||
const results = await batchCheckLocksWithMetadata(batch)
|
||||
const results = await batchCheckQueueCandidates(batch)
|
||||
|
||||
for (const id of batch) {
|
||||
const result = results.get(id)
|
||||
// Treat as unlocked if: not locked, OR expired, OR it's our own lock
|
||||
if (!result?.locked || result?.expired || result?.isOwnLock) {
|
||||
// Found unlocked project with metadata
|
||||
if (result?.slug && result?.projectType) {
|
||||
prefetchQueue.value.push({
|
||||
projectId: id,
|
||||
slug: result.slug,
|
||||
projectType: result.projectType,
|
||||
validatedAt: Date.now(),
|
||||
skippedIds: [...skippedIds],
|
||||
})
|
||||
} else {
|
||||
// No metadata - still add but will need fallback navigation
|
||||
prefetchQueue.value.push({
|
||||
projectId: id,
|
||||
slug: '', // Empty = use fallback
|
||||
projectType: '',
|
||||
validatedAt: Date.now(),
|
||||
skippedIds: [...skippedIds],
|
||||
})
|
||||
}
|
||||
if (isEligibleQueueCandidate(result)) {
|
||||
prefetchQueue.value.push({
|
||||
projectId: id,
|
||||
slug: result?.slug ?? '',
|
||||
projectType: result?.projectType ?? '',
|
||||
validatedAt: Date.now(),
|
||||
skippedIds: [...skippedIds],
|
||||
})
|
||||
|
||||
if (prefetchQueue.value.length >= PREFETCH_TARGET_COUNT) break
|
||||
} else {
|
||||
@@ -979,8 +1024,6 @@ async function maintainPrefetchQueue() {
|
||||
// Debounced prefetch to prevent spam from rapid stage changes
|
||||
const debouncedPrefetch = useDebounceFn(maintainPrefetchQueue, 300)
|
||||
|
||||
const MAX_SKIP_ATTEMPTS = 10
|
||||
|
||||
async function skipToNextProject() {
|
||||
// Skip the current project
|
||||
const currentProjectId = projectV2.value?.id
|
||||
@@ -1004,60 +1047,28 @@ async function skipToNextProject() {
|
||||
|
||||
debug('[skipToNextProject] No prefetch, entering fallback with batch checking')
|
||||
|
||||
// Fallback: batch check remaining projects with metadata (excluding current)
|
||||
const remainingIds: string[] = []
|
||||
const queueItems = moderationQueue.currentQueue.items
|
||||
|
||||
// Build list of remaining projects, excluding current
|
||||
for (const id of queueItems) {
|
||||
if (id === currentProjectId) continue
|
||||
if (remainingIds.length >= MAX_SKIP_ATTEMPTS) break
|
||||
remainingIds.push(id)
|
||||
}
|
||||
const remainingIds = moderationQueue.currentQueue.items.filter((id) => id !== currentProjectId)
|
||||
|
||||
if (remainingIds.length > 0) {
|
||||
const results = await batchCheckLocksWithMetadata(remainingIds)
|
||||
const next = await findNextEligibleQueueProject(remainingIds)
|
||||
|
||||
let skippedCount = 0
|
||||
for (const id of remainingIds) {
|
||||
const result = results.get(id)
|
||||
// Treat as unlocked if: not locked, OR expired, OR it's our own lock
|
||||
if (!result?.locked || result?.expired || result?.isOwnLock) {
|
||||
// Found unlocked - skip the locked ones before it
|
||||
if (skippedCount > 0) {
|
||||
addNotification({
|
||||
title: 'Skipped locked projects',
|
||||
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
|
||||
// Navigate to canonical URL if we have metadata
|
||||
if (result?.slug && result?.projectType) {
|
||||
const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value)
|
||||
navigateTo({
|
||||
path: `/${urlType}/${result.slug}`,
|
||||
state: { showChecklist: true },
|
||||
})
|
||||
} else {
|
||||
// Fallback: use project ID
|
||||
navigateTo({
|
||||
name: 'type-id',
|
||||
params: { type: 'project', id },
|
||||
state: { showChecklist: true },
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
await moderationQueue.completeCurrentProject(id, 'skipped')
|
||||
skippedCount++
|
||||
if (next) {
|
||||
await Promise.all(
|
||||
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
|
||||
)
|
||||
notifySkippedQueueProjects(next.skippedIds.length)
|
||||
navigateToQueueProject(next.result, next.projectId)
|
||||
return
|
||||
}
|
||||
|
||||
// All checked were locked
|
||||
debug('[skipToNextProject] All projects were locked, skippedCount:', skippedCount)
|
||||
await Promise.all(
|
||||
remainingIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
|
||||
)
|
||||
|
||||
debug('[skipToNextProject] No eligible projects in queue')
|
||||
addNotification({
|
||||
title: 'All projects locked',
|
||||
text: 'All remaining projects are currently being moderated by others.',
|
||||
title: 'No projects available',
|
||||
text: 'All remaining projects are already moderated or locked by others.',
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
@@ -1074,6 +1085,7 @@ function resetProgress() {
|
||||
done.value = false
|
||||
clearGeneratedMessageState()
|
||||
loadingMessage.value = false
|
||||
moderationDecision.value = null
|
||||
|
||||
localStorage.removeItem(`modpack-permissions-${projectV2.value.id}`)
|
||||
localStorage.removeItem(`modpack-permissions-index-${projectV2.value.id}`)
|
||||
@@ -1190,7 +1202,7 @@ function handleKeybinds(event: KeyboardEvent) {
|
||||
tryResetProgress: resetProgress,
|
||||
tryExitModeration: handleExit,
|
||||
|
||||
tryApprove: () => sendMessage(projectV2.value.requested_status ?? 'approved'),
|
||||
tryApprove: () => sendMessage(approveSendStatus.value),
|
||||
tryReject: () => sendMessage('rejected'),
|
||||
tryWithhold: () => sendMessage('withheld'),
|
||||
tryEditMessage: goBackToStages,
|
||||
@@ -1272,32 +1284,16 @@ onMounted(async () => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
notifications.setNotificationLocation('left')
|
||||
|
||||
// Check if project has already been reviewed (not in processing status)
|
||||
if (projectV2.value.status !== 'processing') {
|
||||
alreadyReviewed.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Try to acquire lock
|
||||
const result = await moderationQueue.acquireLock(projectV2.value.id)
|
||||
|
||||
if (result.success) {
|
||||
handleLockAcquired()
|
||||
} else if (result.locked_by) {
|
||||
// Actually locked by another moderator
|
||||
// In queue mode with more projects - auto-skip to next project
|
||||
if (moderationQueue.isQueueMode && moderationQueue.queueLength > 1) {
|
||||
addNotification({
|
||||
title: 'Project locked',
|
||||
text: `Skipped project locked by @${result.locked_by.username}.`,
|
||||
type: 'info',
|
||||
})
|
||||
// skipToNextProject already calls completeCurrentProject
|
||||
await skipToNextProject()
|
||||
return
|
||||
}
|
||||
|
||||
// Single project mode or last in queue - show locked UI
|
||||
lockStatus.value = {
|
||||
locked: true,
|
||||
lockedBy: result.locked_by,
|
||||
@@ -1977,6 +1973,7 @@ async function sendMessage(status: ProjectStatus) {
|
||||
return
|
||||
}
|
||||
|
||||
moderationDecision.value = status
|
||||
try {
|
||||
await useBaseFetch(`project/${projectId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -2026,6 +2023,8 @@ async function sendMessage(status: ProjectStatus) {
|
||||
text: 'Failed to submit moderation decision. Please try again.',
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
moderationDecision.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2056,72 +2055,36 @@ async function endChecklist(status?: string) {
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Use prefetched data if available for instant navigation
|
||||
if (!(await navigateToNextUnlockedProject())) {
|
||||
// Fallback: batch check remaining projects with metadata
|
||||
const remainingIds: string[] = []
|
||||
const currentProjectId = projectV2.value?.id
|
||||
const queueItems = moderationQueue.currentQueue.items
|
||||
const remainingIds = moderationQueue.currentQueue.items.filter(
|
||||
(id) => id !== currentProjectId,
|
||||
)
|
||||
|
||||
// Build list of remaining projects, excluding current
|
||||
for (const id of queueItems) {
|
||||
if (id === currentProjectId) continue
|
||||
if (remainingIds.length >= MAX_SKIP_ATTEMPTS) break
|
||||
remainingIds.push(id)
|
||||
}
|
||||
|
||||
let foundUnlocked = false
|
||||
let foundEligible = false
|
||||
if (remainingIds.length > 0) {
|
||||
const results = await batchCheckLocksWithMetadata(remainingIds)
|
||||
const next = await findNextEligibleQueueProject(remainingIds)
|
||||
|
||||
let skippedCount = 0
|
||||
for (const id of remainingIds) {
|
||||
const result = results.get(id)
|
||||
// Treat as unlocked if: not locked, OR expired, OR it's our own lock
|
||||
if (!result?.locked || result?.expired || result?.isOwnLock) {
|
||||
// Found unlocked - skip the locked ones before it
|
||||
if (skippedCount > 0) {
|
||||
addNotification({
|
||||
title: 'Skipped locked projects',
|
||||
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
|
||||
// Navigate to canonical URL if we have metadata
|
||||
if (result?.slug && result?.projectType) {
|
||||
const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value)
|
||||
navigateTo({
|
||||
path: `/${urlType}/${result.slug}`,
|
||||
state: { showChecklist: true },
|
||||
})
|
||||
} else {
|
||||
// Fallback: use project ID
|
||||
navigateTo({
|
||||
name: 'type-id',
|
||||
params: { type: 'project', id },
|
||||
state: { showChecklist: true },
|
||||
})
|
||||
}
|
||||
foundUnlocked = true
|
||||
break
|
||||
}
|
||||
await moderationQueue.completeCurrentProject(id, 'skipped')
|
||||
skippedCount++
|
||||
}
|
||||
|
||||
// If no unlocked projects found, show notification
|
||||
if (!foundUnlocked && skippedCount > 0) {
|
||||
if (next) {
|
||||
await Promise.all(
|
||||
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
|
||||
)
|
||||
notifySkippedQueueProjects(next.skippedIds.length)
|
||||
navigateToQueueProject(next.result, next.projectId)
|
||||
foundEligible = true
|
||||
} else {
|
||||
await Promise.all(
|
||||
remainingIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
|
||||
)
|
||||
addNotification({
|
||||
title: 'All projects locked',
|
||||
text: 'All remaining projects are currently being moderated by others.',
|
||||
title: 'No projects available',
|
||||
text: 'All remaining projects are already moderated or locked by others.',
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If no unlocked projects found, go back to moderation queue
|
||||
if (!foundUnlocked) {
|
||||
if (!foundEligible) {
|
||||
await navigateTo({
|
||||
name: 'moderation',
|
||||
})
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<section>
|
||||
<Breadcrumbs
|
||||
v-if="breadcrumbsStack"
|
||||
:current-title="`Report ${reportId}`"
|
||||
:link-stack="breadcrumbsStack"
|
||||
/>
|
||||
<h2>Report details</h2>
|
||||
<ReportInfo :report="report" :show-thread="false" :show-message="false" :auth="auth" />
|
||||
<ReportInfo
|
||||
:report="report"
|
||||
:show-thread="false"
|
||||
:show-message="false"
|
||||
:auth="auth"
|
||||
class="card-shadow mb-4 rounded-2xl border border-solid border-surface-4 bg-surface-2 p-4"
|
||||
/>
|
||||
</section>
|
||||
<section v-if="report && thread" class="universal-card">
|
||||
<h2>Messages</h2>
|
||||
<section
|
||||
v-if="report && thread"
|
||||
class="card-shadow rounded-2xl border border-solid border-surface-4 bg-surface-3"
|
||||
>
|
||||
<h2 class="m-4 mb-2 text-xl font-semibold text-contrast">Messages with the moderators</h2>
|
||||
<p class="mx-4 mt-0">
|
||||
Make sure to include evidence of all claims you make, or your report may be closed without
|
||||
action.
|
||||
</p>
|
||||
<ConversationThread
|
||||
class="overflow-clip rounded-b-2xl border-0 border-t border-solid border-surface-4 bg-surface-2"
|
||||
:thread="thread"
|
||||
:report="report"
|
||||
:auth="auth"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user