mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf1d948030 | ||
|
|
fe8fa4b6f7 | ||
|
|
2c62cf1d12 | ||
|
|
84b91f32f8 | ||
|
|
64edf2ddeb | ||
|
|
2f248027d6 | ||
|
|
f96520638b | ||
|
|
9e5d29ced6 | ||
|
|
3e15d0b287 | ||
|
|
bcce7e28fd | ||
|
|
3889d0f5ec | ||
|
|
6f44c5b039 | ||
|
|
ea967845d9 |
@@ -330,6 +330,7 @@ async function setupApp() {
|
||||
locale,
|
||||
telemetry,
|
||||
collapsed_navigation,
|
||||
hide_nametag_skins_page,
|
||||
advanced_rendering,
|
||||
onboarded,
|
||||
default_page,
|
||||
@@ -360,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
|
||||
@@ -1227,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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -302,6 +302,9 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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..."
|
||||
},
|
||||
|
||||
@@ -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 +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"
|
||||
},
|
||||
@@ -626,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"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -611,6 +611,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"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
@@ -269,6 +272,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."
|
||||
},
|
||||
@@ -659,6 +665,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 +703,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."
|
||||
@@ -611,6 +611,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 +644,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"
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"message": "실행 중인 인스턴스 보이기"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "인스턴스 중지"
|
||||
"message": "인스턴스 멈추기"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "다운로드 목록 보기"
|
||||
@@ -192,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": "인스턴스"
|
||||
@@ -611,6 +611,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 +644,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "할당된 메모리"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/path/to/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "창"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -611,6 +611,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 +644,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"
|
||||
},
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
"message": "Доступно обновление"
|
||||
},
|
||||
"app.update.complete-toast.text": {
|
||||
"message": "Нажмите здесь, чтобы посмотреть изменения."
|
||||
"message": "Нажмите, чтобы посмотреть список изменений."
|
||||
},
|
||||
"app.update.complete-toast.title": {
|
||||
"message": "Версия {version} успешно установлена!"
|
||||
@@ -611,6 +611,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 +644,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 +762,7 @@
|
||||
"message": "Загрузчик управляется сервером"
|
||||
},
|
||||
"unknown-pack-warning-modal.body": {
|
||||
"message": "Файлы любого формата проверены на безопасность, если они загружены на Modrinth (в том числе файлы .mrpack)."
|
||||
"message": "Файл проверяется только в том случае, если он загружен на Modrinth, независимо от его формата (включая .mrpack)."
|
||||
},
|
||||
"unknown-pack-warning-modal.dont-show-again": {
|
||||
"message": "Больше не предупреждать"
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
"message": "Назва збірки"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "Змінити які файли додані до експорту"
|
||||
"message": "Змініть файли, які додані до експорту"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "Номер версії"
|
||||
@@ -222,7 +222,7 @@
|
||||
"message": "Додати сервер"
|
||||
},
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Переглянути сервера"
|
||||
"message": "Переглянути сервери"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "«{name}» буде **назавжди видалено** й у вас не буде можливости відновлення."
|
||||
@@ -611,6 +611,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 +644,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Виділена пам’ять"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/шлях/до/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Вікно"
|
||||
},
|
||||
|
||||
@@ -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": "在主页的“快速回到”部分包含最近的世界。"
|
||||
|
||||
@@ -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": "匯出"
|
||||
@@ -252,13 +252,13 @@
|
||||
"message": "「{name}」將從你的清單中移除(包含遊戲內),且無法還原。"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "「{name}」({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": "這個伺服器"
|
||||
@@ -302,6 +302,9 @@
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "這個專案已安裝"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "返回瀏覽"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "將內容安裝至實例"
|
||||
},
|
||||
@@ -608,6 +611,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": "環境變數"
|
||||
},
|
||||
@@ -623,6 +644,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
@@ -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,
|
||||
|
||||
+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",
|
||||
])
|
||||
|
||||
@@ -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]
|
||||
|
||||
+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",);
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
normalizeChildren,
|
||||
PagewideBanner,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
@@ -22,7 +21,7 @@ const messages = defineMessages({
|
||||
},
|
||||
description: {
|
||||
id: 'layout.banner.preview.description',
|
||||
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 <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}.`,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -41,30 +40,22 @@ const url = computed(() => `https://modrinth.com${route.fullPath}`)
|
||||
</template>
|
||||
<template #description>
|
||||
<span>
|
||||
<IntlFormatted
|
||||
:message-id="messages.description"
|
||||
:values="{
|
||||
owner: config.public.owner,
|
||||
branch: config.public.branch,
|
||||
}"
|
||||
>
|
||||
<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}`"
|
||||
|
||||
@@ -872,6 +872,7 @@ function notifySkippedQueueProjects(count: number) {
|
||||
title: 'Skipped projects',
|
||||
text: `Skipped ${count} project(s) already moderated or locked by others.`,
|
||||
type: 'info',
|
||||
autoCloseMs: 2000,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,11 @@ export function useCdnDownloadContext() {
|
||||
})
|
||||
|
||||
function createProjectDownloadUrl(originalUrl: string, context?: DownloadContext): string {
|
||||
if (!originalUrl.startsWith('https://cdn.modrinth.com')) {
|
||||
if (
|
||||
typeof originalUrl !== 'string' ||
|
||||
!originalUrl ||
|
||||
!originalUrl.startsWith('https://cdn.modrinth.com')
|
||||
) {
|
||||
return originalUrl
|
||||
}
|
||||
|
||||
|
||||
@@ -942,7 +942,7 @@
|
||||
"message": "موعد"
|
||||
},
|
||||
"dashboard.withdraw.completion.email-confirmation": {
|
||||
"message": "ستتلقى رسالة بريد إلكتروني في <b>{email}<b> مع تعليمات لاسترداد المبلغ المسحوب. "
|
||||
"message": "ستتلقى رسالة بريد إلكتروني في <b>{email}</b> مع تعليمات لاسترداد المبلغ المسحوب. "
|
||||
},
|
||||
"dashboard.withdraw.completion.exchange-rate": {
|
||||
"message": "سعر الصرف "
|
||||
|
||||
@@ -107,6 +107,9 @@
|
||||
"app-marketing.features.performance.cpu-percent": {
|
||||
"message": "% CPU"
|
||||
},
|
||||
"app-marketing.features.performance.description": {
|
||||
"message": "Aplikace Modrinth je výkonnější než mnoho předních správců modů a využívá pouze 150MB paměti RAM!"
|
||||
},
|
||||
"app-marketing.features.performance.discord": {
|
||||
"message": "Discord"
|
||||
},
|
||||
@@ -401,6 +404,48 @@
|
||||
"collection.title": {
|
||||
"message": "{name} - Kolekce"
|
||||
},
|
||||
"conversation-thread.action.add-private-note": {
|
||||
"message": "Přidat soukromou poznámku"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Uzavřít vlákno"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Uzavřít s odpovědí"
|
||||
},
|
||||
"conversation-thread.action.reject": {
|
||||
"message": "Zamítnout"
|
||||
},
|
||||
"conversation-thread.action.reject-with-reply": {
|
||||
"message": "Zamítnout s odpovědí"
|
||||
},
|
||||
"conversation-thread.action.reopen-thread": {
|
||||
"message": "Znovuotevřít vlákno"
|
||||
},
|
||||
"conversation-thread.action.reply": {
|
||||
"message": "Odpovědět"
|
||||
},
|
||||
"conversation-thread.action.reply-to-thread": {
|
||||
"message": "Odpovědět vláknu"
|
||||
},
|
||||
"conversation-thread.action.send": {
|
||||
"message": "Odeslat"
|
||||
},
|
||||
"conversation-thread.action.send-to-review": {
|
||||
"message": "Odeslat ke kontrole"
|
||||
},
|
||||
"conversation-thread.action.send-to-review-with-reply": {
|
||||
"message": "Odeslat ke kontrole s odpovědí"
|
||||
},
|
||||
"conversation-thread.reply-editor.placeholder.reply": {
|
||||
"message": "Odpovědět vláknu..."
|
||||
},
|
||||
"conversation-thread.reply-editor.placeholder.send": {
|
||||
"message": "Odeslat zprávu..."
|
||||
},
|
||||
"conversation-thread.reply-modal.header": {
|
||||
"message": "Odpovědět vláknu"
|
||||
},
|
||||
"create-project-version.create-modal.stage.add-files.admonition": {
|
||||
"message": "Doplňkové soubory slouží jako podpůrné materiály (např. zdrojový kód), ne jako alternativní verze či varianty."
|
||||
},
|
||||
@@ -497,6 +542,9 @@
|
||||
"create.project.name-placeholder": {
|
||||
"message": "Zadejte název projektu..."
|
||||
},
|
||||
"create.project.owner-label": {
|
||||
"message": "Vlastník"
|
||||
},
|
||||
"create.project.summary-description": {
|
||||
"message": "Popiště vaši organizace dvěma nebo jednou větou."
|
||||
},
|
||||
@@ -548,9 +596,18 @@
|
||||
"dashboard.affiliate-links.revoke-confirm.title": {
|
||||
"message": "Jste si jistý, že chcete zrušit svůj affiliate odkaz „{title}“?"
|
||||
},
|
||||
"dashboard.analytics.total-downloads": {
|
||||
"message": "Celková stažení"
|
||||
},
|
||||
"dashboard.analytics.total-followers": {
|
||||
"message": "Celkoví sledující"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Vytvořit novou"
|
||||
},
|
||||
"dashboard.collections.empty.get-started-hint": {
|
||||
"message": "Vytvořte svou první kolekci a začněte!"
|
||||
},
|
||||
"dashboard.collections.label.projects-count": {
|
||||
"message": "{count} {countPlural, plural, one {project} other {projects}}"
|
||||
},
|
||||
@@ -560,6 +617,15 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"message": "Vaše kolekce"
|
||||
},
|
||||
"dashboard.collections.sort.name-ascending": {
|
||||
"message": "Jméno (A-Z)"
|
||||
},
|
||||
"dashboard.collections.sort.recently-created": {
|
||||
"message": "Nedávno vytvořeno"
|
||||
},
|
||||
"dashboard.collections.sort.recently-updated": {
|
||||
"message": "Nedávno aktualizováno"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.title": {
|
||||
"message": "Hotovo! 🎉"
|
||||
},
|
||||
@@ -575,9 +641,18 @@
|
||||
"dashboard.creator-withdraw-modal.details-label": {
|
||||
"message": "Detaily"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.fee-breakdown-amount": {
|
||||
"message": "Množství"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.fee-breakdown-fee": {
|
||||
"message": "Poplatek"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.fee-breakdown-gift-card-value": {
|
||||
"message": "Hodnota dárkové karty"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.fee-breakdown-usd-equivalent": {
|
||||
"message": "Ekvivalent americkému dolaru"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.method-selection.country-placeholder": {
|
||||
"message": "Zvolte vaši zemi"
|
||||
},
|
||||
@@ -626,9 +701,18 @@
|
||||
"dashboard.creator-withdraw-modal.stage.tax-form": {
|
||||
"message": ""
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.tremendous-details.enter-denomination-placeholder": {
|
||||
"message": "Zadejte množství"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.tremendous-details.payment-method": {
|
||||
"message": "Platební metoda"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.tremendous-details.reward": {
|
||||
"message": "Odměna"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.tremendous-details.reward-placeholder": {
|
||||
"message": "Vyberte odměnu"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.tremendous-details.reward-plural": {
|
||||
"message": "Odměny"
|
||||
},
|
||||
@@ -638,9 +722,57 @@
|
||||
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
|
||||
"message": "Vyčerpali jste svůj limit pro výběr <b>{withdrawLimit}</b>. Pro vyšší výběr musíte vyplnit daňový formulář."
|
||||
},
|
||||
"dashboard.organizations.button.create": {
|
||||
"message": "Vytvořit organizaci"
|
||||
},
|
||||
"dashboard.organizations.empty.cta": {
|
||||
"message": "Vytvořte organizaci!"
|
||||
},
|
||||
"dashboard.organizations.error.fetch": {
|
||||
"message": "Nepodařilo se načíst organizace"
|
||||
},
|
||||
"dashboard.overview.notifications.button.mark-all-as-read": {
|
||||
"message": "Označit vše jako přečtené"
|
||||
},
|
||||
"dashboard.overview.notifications.button.view-history": {
|
||||
"message": "Ukázat historii"
|
||||
},
|
||||
"dashboard.overview.notifications.history.label": {
|
||||
"message": "Historie"
|
||||
},
|
||||
"dashboard.overview.notifications.history.title": {
|
||||
"message": "Historie oznámení"
|
||||
},
|
||||
"dashboard.overview.notifications.loading": {
|
||||
"message": "Načítání oznámení..."
|
||||
},
|
||||
"dashboard.projects.head-title": {
|
||||
"message": "Projekty"
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.label": {
|
||||
"message": "Discord pozvánka"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-url": {
|
||||
"message": "Zadejte platnou URL"
|
||||
},
|
||||
"dashboard.projects.sort.option.name": {
|
||||
"message": "Jméno"
|
||||
},
|
||||
"dashboard.projects.sort.option.status": {
|
||||
"message": "Stav"
|
||||
},
|
||||
"dashboard.projects.table.name": {
|
||||
"message": "Jméno"
|
||||
},
|
||||
"dashboard.projects.table.status": {
|
||||
"message": "Stav"
|
||||
},
|
||||
"dashboard.revenue.available-now": {
|
||||
"message": "K dispozici nyní"
|
||||
},
|
||||
"dashboard.revenue.balance": {
|
||||
"message": "Zůstatek"
|
||||
},
|
||||
"dashboard.revenue.estimated-with-date": {
|
||||
"message": "Odhadováno {date}"
|
||||
},
|
||||
@@ -650,18 +782,39 @@
|
||||
"dashboard.revenue.transactions.btn.download-csv": {
|
||||
"message": "Stáhnout jako CSV"
|
||||
},
|
||||
"dashboard.sidebar.label.creators": {
|
||||
"message": "Tvůrci"
|
||||
},
|
||||
"dashboard.sidebar.label.notifications": {
|
||||
"message": "Oznámení"
|
||||
},
|
||||
"dashboard.sidebar.label.organizations": {
|
||||
"message": "Organizace"
|
||||
},
|
||||
"dashboard.sidebar.label.projects": {
|
||||
"message": "Projekty"
|
||||
},
|
||||
"dashboard.withdraw.completion.account": {
|
||||
"message": "Účet"
|
||||
},
|
||||
"dashboard.withdraw.completion.amount": {
|
||||
"message": "Množství"
|
||||
},
|
||||
"dashboard.withdraw.completion.date": {
|
||||
"message": "Datum"
|
||||
},
|
||||
"dashboard.withdraw.completion.fee": {
|
||||
"message": "Poplatek"
|
||||
},
|
||||
"dashboard.withdraw.completion.transactions-button": {
|
||||
"message": "Transakce"
|
||||
},
|
||||
"dashboard.withdraw.completion.wallet": {
|
||||
"message": "Peněženka"
|
||||
},
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Zpět na server"
|
||||
},
|
||||
"error.collection.404.list_item.1": {
|
||||
"message": "Možná jste zadali URL adresu kolekce špatně."
|
||||
},
|
||||
@@ -752,6 +905,9 @@
|
||||
"frog.title": {
|
||||
"message": "Žába"
|
||||
},
|
||||
"hosting-marketing.included.custom-url": {
|
||||
"message": "Vlastní URL"
|
||||
},
|
||||
"landing.creator.feature.monetization.title": {
|
||||
"message": "Monetizace"
|
||||
},
|
||||
|
||||
@@ -1013,9 +1013,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "Hvor er Modrinth Hosting servere placeret? Kan jeg vælge et område?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Vi har servere tilgængelig i Nord Amerika, Europa, og Sydøst Asien på nuværende tidspunkt som du kan vælge imellem under købet. Flere områder kommer i fremtiden! Hvis du gerne vil skifte til dit område, venligst kontakt support."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "Hvilken Minecraft version og loaders kan blive brugt?"
|
||||
},
|
||||
|
||||
@@ -1469,9 +1469,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "Wo befinden sich Server von Modrinth Hosting? Kann ich eine Region auswählen?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Derzeit sind Server in Nordamerika, Europa und Südostasien verfügbar, die du beim Kauf auswählen kannst. Weitere Regionen werden in Zukunft dazukommen! Falls du deine Region wechseln möchtest, wende dich bitte an den Support."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "Welche Minecraft-Versionen und Loader können verwendet werden?"
|
||||
},
|
||||
@@ -1814,9 +1811,6 @@
|
||||
"layout.banner.build-fail.title": {
|
||||
"message": "Fehler beim generieren des Status von der API beim erstellen."
|
||||
},
|
||||
"layout.banner.preview.description": {
|
||||
"message": "Falls du die offizielle Modrinth-Website aufrufen wolltest, besuche {url}. Diese Vorschauversion wird von Modrinth-Mitarbeitern zu Testzwecken genutzt. Sie wurde unter Verwendung von <branch-link>{owner}/{branch}</branch-link> @ {commit} erstellt."
|
||||
},
|
||||
"layout.banner.preview.title": {
|
||||
"message": "Dies ist eine Vorschauversion der Modrinth-Webseite."
|
||||
},
|
||||
|
||||
@@ -1469,9 +1469,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "Wo befinden sich Server von Modrinth Hosting? Kann ich eine Region auswählen?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Derzeit sind Server in Nordamerika, Europa und Südostasien verfügbar, die du beim Kauf auswählen kannst. Weitere Regionen werden in Zukunft dazukommen! Falls du deine Region wechseln möchtest, wende dich bitte an den Support."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "Welche Minecraft-Versionen und Loader können verwendet werden?"
|
||||
},
|
||||
@@ -1814,9 +1811,6 @@
|
||||
"layout.banner.build-fail.title": {
|
||||
"message": "Fehler beim Generieren des Status aus der API beim Erstellen."
|
||||
},
|
||||
"layout.banner.preview.description": {
|
||||
"message": "Falls du die offizielle Modrinth-Website aufrufen wolltest, besuche {url}. Diese Vorschauversion wird von Modrinth-Mitarbeitern zu Testzwecken genutzt. Sie wurde unter Verwendung von <branch-link>{owner}/{branch}</branch-link> @ {commit} erstellt."
|
||||
},
|
||||
"layout.banner.preview.title": {
|
||||
"message": "Dies ist eine Vorschauversion der Modrinth-Website."
|
||||
},
|
||||
|
||||
@@ -1881,7 +1881,7 @@
|
||||
"message": "Error generating state from API when building."
|
||||
},
|
||||
"layout.banner.preview.description": {
|
||||
"message": "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 <branch-link>{owner}/{branch}</branch-link> @ {commit}."
|
||||
"message": "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}."
|
||||
},
|
||||
"layout.banner.preview.title": {
|
||||
"message": "This is a preview deploy of the Modrinth website."
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"message": "Al descargar la Modrinth App, aceptas nuestros <terms-link>Términos</terms-link> y <privacy-link>Política de Privacidad</privacy-link>."
|
||||
},
|
||||
"app-marketing.download.title": {
|
||||
"message": "Descargar la Modrinth App (Beta)"
|
||||
"message": "Descarga Modrinth App (Beta)"
|
||||
},
|
||||
"app-marketing.download.windows": {
|
||||
"message": "Windows"
|
||||
@@ -497,6 +497,27 @@
|
||||
"conversation-thread.reply-modal.help-center-note": {
|
||||
"message": "Si necesitas ponerte en contacto con el equipo de moderación, por favor utiliza el <help-center-link>Centro de ayuda de Modrinth</help-center-link> y haz clic en la burbuja azúl en la esquina inferior derecha para contactar soporte."
|
||||
},
|
||||
"conversation-thread.resubmit-modal.confirmation.description": {
|
||||
"message": "Confirma que has atendido las observaciones de los moderadores"
|
||||
},
|
||||
"conversation-thread.resubmit-modal.confirmation.label": {
|
||||
"message": "Confirmo que he atendido correctamente las observaciones de los moderadores."
|
||||
},
|
||||
"conversation-thread.resubmit-modal.description": {
|
||||
"message": "Estás enviando <project-title>{projectTitle}</project-title> para ser revisado de nuevo por los moderadores."
|
||||
},
|
||||
"conversation-thread.resubmit-modal.header.resubmitting": {
|
||||
"message": "Enviando de nuevo para revisión"
|
||||
},
|
||||
"conversation-thread.resubmit-modal.header.submitting": {
|
||||
"message": "Enviando para revisión"
|
||||
},
|
||||
"conversation-thread.resubmit-modal.reminder": {
|
||||
"message": "Asegúrate de haber leído y resuelto todas las observaciones del equipo de moderación."
|
||||
},
|
||||
"conversation-thread.resubmit-modal.warning": {
|
||||
"message": "Envíos repetidos sin atender las observaciones de los moderadores pueden resultar en la suspensión de la cuenta."
|
||||
},
|
||||
"create-project-version.create-modal.stage.add-files.admonition": {
|
||||
"message": "Los archivos complementarios sirven para recursos de apoyo, como código fuente, no para versiones o variantes alternativas."
|
||||
},
|
||||
@@ -965,6 +986,9 @@
|
||||
"dashboard.head-title": {
|
||||
"message": "Panel de control"
|
||||
},
|
||||
"dashboard.notifications.empty.no-unread": {
|
||||
"message": "No tienes notificaciones sin leer."
|
||||
},
|
||||
"dashboard.notifications.link.see-all": {
|
||||
"message": "Ver todas"
|
||||
},
|
||||
@@ -995,6 +1019,9 @@
|
||||
"dashboard.overview.notifications.button.view-history": {
|
||||
"message": "Ver historial"
|
||||
},
|
||||
"dashboard.overview.notifications.empty.no-unread": {
|
||||
"message": "No tienes notificaciones sin leer."
|
||||
},
|
||||
"dashboard.overview.notifications.error.loading": {
|
||||
"message": "Error cargando notificaciones:"
|
||||
},
|
||||
@@ -1335,13 +1362,13 @@
|
||||
"message": "Seleccionando modpack para instalar antes del reinicio"
|
||||
},
|
||||
"discover.seo.description": {
|
||||
"message": "Busca y navega miles de Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {packs de recursos} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} en Modrinth con resultados de busqueda instantáneos y precisos. Nuestros filtradores te ayudan a encontrar rápidamente lo mejor de Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {packs de recursos} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}."
|
||||
"message": "Busca y navega miles de {projectType, select, mod {mods} modpack {modpacks} resourcepack {packs de recursos} shader {shaders} plugin {plugins} datapack {datapacks} other {proyectos}} para Minecraft en Modrinth con resultados de busqueda instantáneos y precisos. Nuestros filtradores te ayudarán a encontrar rápidamente los mejores {projectType, select, mod {mods} modpack {modpacks} resourcepack {packs de recursos} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} para Minecraft."
|
||||
},
|
||||
"discover.seo.title": {
|
||||
"message": "Buscar {projectType, select, mod {mods} modpack {modpacks} resourcepack {packs de recursos} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}"
|
||||
"message": "Buscar {projectType, select, mod {mods} modpack {modpacks} resourcepack {paquetes de recursos} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}"
|
||||
},
|
||||
"discover.seo.title-with-query": {
|
||||
"message": "Buscar {projectType, select, mod {mods} modpack {modpacks} resourcepack {packs de recursos} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} | {query}"
|
||||
"message": "Buscar {projectType, select, mod {mods} modpack {modpacks} resourcepack {paquetes de recursos} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} | {query}"
|
||||
},
|
||||
"error.collection.404.list_item.1": {
|
||||
"message": "Puede que hayas escrito mal la URL de la colección."
|
||||
@@ -1509,7 +1536,7 @@
|
||||
"message": "¿Dónde se encuentran los servidores de Modrinth Hosting? ¿Puedo elegir una región?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Actualmente disponemos de servidores en Norteamérica, Europa y el Sudeste Asiático entre los que puede elegir al realizar la compra. ¡En el futuro añadiremos más regiones! Si deseas cambiar de región, ponte en contacto con soporte."
|
||||
"message": "Tenemos servidores disponibles a través de Norteamérica, Europa y el sudeste asiático que puedes seleccionar en el momento de tu compra. ¡Más regiones en el futuro! Si deseas cambiar tu región, por favor contacta con soporte."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "¿Qué versiones y loaders de Minecraft se pueden utilizar?"
|
||||
@@ -2681,6 +2708,81 @@
|
||||
"project.license.title": {
|
||||
"message": "Licencia"
|
||||
},
|
||||
"project.moderation.admonition.approved.body.private": {
|
||||
"message": "Tu proyecto es privado, lo que significa que solo pueden acceder tú y las personas a las que invites."
|
||||
},
|
||||
"project.moderation.admonition.approved.body.public": {
|
||||
"message": "Tu proyecto está publicado y aparece en las búsquedas de Modrinth."
|
||||
},
|
||||
"project.moderation.admonition.approved.body.unlisted": {
|
||||
"message": "Tu proyecto no está listado, lo que significa que solo se puede acceder a él a través de un link directo y no aparece en las búsquedas de Modrinth."
|
||||
},
|
||||
"project.moderation.admonition.approved.body.visibility-message": {
|
||||
"message": "Puedes cambiar la visibilidad de tu proyecto en la <visibility-settings-link>configuración de visibilidad</visibility-settings-link> de tu proyecto."
|
||||
},
|
||||
"project.moderation.admonition.approved.header": {
|
||||
"message": "Proyecto aprobado"
|
||||
},
|
||||
"project.moderation.admonition.draft.body": {
|
||||
"message": "Este es un borrador de tu proyecto que nadie puede ver hasta que sea enviado para ser revisado y aprobado por el equipo de moderación de Modrinth."
|
||||
},
|
||||
"project.moderation.admonition.draft.header": {
|
||||
"message": "Borrador"
|
||||
},
|
||||
"project.moderation.admonition.draft.submit-for-review": {
|
||||
"message": "Una vez que hayas completado todos los pasos necesarios y te hayas asegurado de que tu proyecto cumple con las <rules-link>Reglas de contenido</rules-link> de Modrinth, puedes enviar tu proyecto a revisión."
|
||||
},
|
||||
"project.moderation.admonition.rejected.address-all-concerns": {
|
||||
"message": "Por favor resuelve todas las observaciones de moderación, incluyendo cualquiera listada abajo, antes de enviar de nuevo el proyecto."
|
||||
},
|
||||
"project.moderation.admonition.rejected.header": {
|
||||
"message": "Cambios solicitados"
|
||||
},
|
||||
"project.moderation.admonition.rejected.spam-notice": {
|
||||
"message": "Enviar en repetidas ocasiones tu proyecto sin atender antes todas las observaciones de moderación puede resultar en la suspensión de la cuenta."
|
||||
},
|
||||
"project.moderation.admonition.under-review.body.1": {
|
||||
"message": "Tu proyecto está en lista de espera para ser revisado por el equipo de moderación de Modrinth."
|
||||
},
|
||||
"project.moderation.admonition.under-review.body.2": {
|
||||
"message": "Tu proyecto será escaneado y después revisado por moderadores humanos para asegurarnos de que esté de acuerdo con las <rules-link>Reglas de contenido</rules-link> y los <terms-link>Términos de uso</terms-link> de Modrinth."
|
||||
},
|
||||
"project.moderation.admonition.under-review.body.3": {
|
||||
"message": "Aún puedes modificar tu proyecto, no afectará tu posición en la cola."
|
||||
},
|
||||
"project.moderation.admonition.under-review.body.4": {
|
||||
"message": "Buscamos revisar envíos en un plazo de 24-48 horas, pero algunos proyectos pueden sufrir retrasos. Esto no indica ningún problema con tu envío."
|
||||
},
|
||||
"project.moderation.admonition.under-review.body.5": {
|
||||
"message": "<emphasis>¡Apreciamos tu paciencia mientras nuestros moderadores trabajan duro para mantener Modrinth seguro y estaremos encantados de poder ayudarte a compartir tu contenido! 💚</emphasis>"
|
||||
},
|
||||
"project.moderation.admonition.under-review.header": {
|
||||
"message": "Proyecto bajo revisión"
|
||||
},
|
||||
"project.moderation.admonition.withheld.body": {
|
||||
"message": "Tu proyecto no aparecerá públicamente y solo se podrá acceder a él por un enlace directo.{requestedStatus, select,unlisted { Según tu <visibility-settings-link>configuración de visibilidad</visibility-settings-link>, es muy probable que no se necesite ninguna acción adicional.} other { Por favor resuelve todas las observaciones de moderación, incluyendo las listadas abajo, antes de enviar de nuevo el proyecto.}}"
|
||||
},
|
||||
"project.moderation.admonition.withheld.header": {
|
||||
"message": "Deslistado por el staff"
|
||||
},
|
||||
"project.moderation.error.unauthorized": {
|
||||
"message": "Sin autorización"
|
||||
},
|
||||
"project.moderation.thread.approved-warning": {
|
||||
"message": "Este hilo no esta siendo monitorizado de manera activa, pero puede ser revisado para más información sobre tu proyecto de ser necesario."
|
||||
},
|
||||
"project.moderation.thread.help-center-note.1": {
|
||||
"message": "Los moderadores de contenido no pueden dar soporte a la mayoría de los problemas y los mensajes de este hilo no notifican al staff."
|
||||
},
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "Si necesitas ayuda o tienes consultas adicionales, por favor visita el <help-center-link>Centro de ayuda de Modrinth</help-center-link> y haz click en la burbuja azúl para contactar con soporte."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Mostrar vista de miembro"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "Este es un hilo de conversación con los moderadores de Modrinth. Podrían enviarte un mensaje con observaciones relacionadas a este proyecto."
|
||||
},
|
||||
"project.moderation.thread.title": {
|
||||
"message": "Mensajes de moredación"
|
||||
},
|
||||
@@ -3684,7 +3786,7 @@
|
||||
"message": "Error al renovar la suscripción"
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.request-submitted.text": {
|
||||
"message": "Si el servidor se encuentra cancelado, puede tomar 10-15 minutos en preparar el servidor"
|
||||
"message": "Si el servidor se encuentra cancelado, puede tomar de 10 a 15 minutos en preparar el servidor."
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.request-submitted.title": {
|
||||
"message": "Solicitud de renovación de suscripción enviada"
|
||||
|
||||
@@ -1367,9 +1367,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "¿Dónde están localizados los servidores de Modrinth Hosting? ¿Puedo elegir la región?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Tenemos servidores disponibles en América del Norte, Europa y Sureste de Asia por el momento, que puedes elegir al momento de comprarlo. ¡Más regiones estarán disponibles en el futuro! Si te gustaría cambiar tu región por favor contáctate con soporte."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "¿Que versiones de Minecraft y loaders se pueden usar?"
|
||||
},
|
||||
|
||||
@@ -191,9 +191,21 @@
|
||||
"app-marketing.hero.download-modrinth-app-for-os": {
|
||||
"message": "Lataa Modrinth App alustalle {os}"
|
||||
},
|
||||
"app-marketing.hero.minecraft-screenshot-alt": {
|
||||
"message": "Kuvakaappaus Cobblemon instanssin pää valikko näytöstä."
|
||||
},
|
||||
"app-marketing.hero.more-download-options": {
|
||||
"message": "Lisää lataus vaihtoehtoja"
|
||||
},
|
||||
"app-marketing.hide-other-packages": {
|
||||
"message": "Piilota muut paketit"
|
||||
},
|
||||
"app-marketing.not-recommended": {
|
||||
"message": "Emme suosittele että käytät näitä ellet tiedä mitä teet."
|
||||
},
|
||||
"app-marketing.show-other-packages": {
|
||||
"message": "Näytä muut paketit"
|
||||
},
|
||||
"auth.authorize.action.authorize": {
|
||||
"message": "Valtuuta"
|
||||
},
|
||||
@@ -335,36 +347,171 @@
|
||||
"collection.button.edit-icon": {
|
||||
"message": "Muokkaa kuvaketta"
|
||||
},
|
||||
"collection.button.remove-icon": {
|
||||
"message": "Poista kuvake"
|
||||
},
|
||||
"collection.button.remove-project": {
|
||||
"message": "Poista projekti"
|
||||
},
|
||||
"collection.button.replace-icon": {
|
||||
"message": "Korvaa kuvake"
|
||||
},
|
||||
"collection.button.select-icon": {
|
||||
"message": "Valitse kuvake"
|
||||
},
|
||||
"collection.button.unfollow-project": {
|
||||
"message": "Lopeta projektin seuraaminen"
|
||||
},
|
||||
"collection.delete-modal.description": {
|
||||
"message": "Tämä poistaa pysyvästi tämän kokoelman. Tätä toimea ei voida peruuttaa."
|
||||
},
|
||||
"collection.delete-modal.title": {
|
||||
"message": "Oletko varma että haluat poistaa tämän kokoelman?"
|
||||
},
|
||||
"collection.editing": {
|
||||
"message": "Muokataan kokoelmaa"
|
||||
},
|
||||
"collection.error.not-found": {
|
||||
"message": "Kokoelmaa ei löydy"
|
||||
},
|
||||
"collection.label.created-at": {
|
||||
"message": "Luotu {ago}"
|
||||
},
|
||||
"collection.label.curated-by": {
|
||||
"message": "Kuratoinut"
|
||||
},
|
||||
"collection.label.no-projects": {
|
||||
"message": "Ei vielä projekteja kokelmassa"
|
||||
},
|
||||
"collection.label.projects-count": {
|
||||
"message": "{count, plural, =0 {No projects yet} other {<stat>{count}</stat> {type}}}"
|
||||
},
|
||||
"collection.label.updated-at": {
|
||||
"message": "Päivitetty {ago}"
|
||||
},
|
||||
"collection.return-link.dashboard-collections": {
|
||||
"message": "Kokoelmasi"
|
||||
},
|
||||
"collection.return-link.user": {
|
||||
"message": "{user}:n profiili"
|
||||
},
|
||||
"collection.title": {
|
||||
"message": "{name} - kokoelma"
|
||||
},
|
||||
"conversation-thread.action.add-private-note": {
|
||||
"message": "Lisää yksityinen muistiinpano"
|
||||
},
|
||||
"conversation-thread.action.approve": {
|
||||
"message": "Hyväksy"
|
||||
},
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Hyväksy vastauksella"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Sulje keskustelu"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Sulje vastauksella"
|
||||
},
|
||||
"conversation-thread.action.reject": {
|
||||
"message": "Hylkää"
|
||||
},
|
||||
"conversation-thread.action.reject-with-reply": {
|
||||
"message": "Hylkää vastauksella"
|
||||
},
|
||||
"conversation-thread.action.reopen-thread": {
|
||||
"message": "Avaa keskustelu uudelleen"
|
||||
},
|
||||
"conversation-thread.action.reply": {
|
||||
"message": "Vastaa"
|
||||
},
|
||||
"conversation-thread.action.reply-to-thread": {
|
||||
"message": "Vastaa keskusteluun"
|
||||
},
|
||||
"conversation-thread.action.resubmit-for-review": {
|
||||
"message": "Lähetä uudelleen arvioitavaksi"
|
||||
},
|
||||
"conversation-thread.action.resubmit-for-review-with-reply": {
|
||||
"message": "Lähetä uudelleen arvioitavaksi vastauksella"
|
||||
},
|
||||
"conversation-thread.action.send": {
|
||||
"message": "Lähetä"
|
||||
},
|
||||
"conversation-thread.action.send-to-review": {
|
||||
"message": "Lähetä arvioitavaksi"
|
||||
},
|
||||
"conversation-thread.action.send-to-review-with-reply": {
|
||||
"message": "Lähetä arvioitavaksi vastauksella"
|
||||
},
|
||||
"conversation-thread.reply-editor.placeholder.reply": {
|
||||
"message": "Vastaa keskusteluun..."
|
||||
},
|
||||
"conversation-thread.reply-editor.placeholder.send": {
|
||||
"message": "Lähetä viesti..."
|
||||
},
|
||||
"conversation-thread.reply-modal.header": {
|
||||
"message": "Vastaa keskusteluun"
|
||||
},
|
||||
"create.collection.collection-info": {
|
||||
"message": "Sinun uusi kokoelma luodaan julkiseksi kokoelmaksi {count, plural, =0 {no projects} one {# project} other {# projects}}:lla. "
|
||||
},
|
||||
"create.collection.create-collection": {
|
||||
"message": "Luo kokoelma"
|
||||
},
|
||||
"create.collection.name-label": {
|
||||
"message": "Nimi"
|
||||
},
|
||||
"create.collection.name-placeholder": {
|
||||
"message": "Syötä kokoelman nimi..."
|
||||
},
|
||||
"create.collection.summary-label": {
|
||||
"message": "Yhteenveto"
|
||||
},
|
||||
"create.collection.title": {
|
||||
"message": "Luodaan kokoelmaa"
|
||||
},
|
||||
"create.limit-alert.contact-support": {
|
||||
"message": "Ota yhteyttä tukeen"
|
||||
},
|
||||
"create.limit-alert.type-collection": {
|
||||
"message": "kokoelma"
|
||||
},
|
||||
"create.limit-alert.type-organization": {
|
||||
"message": "organisaatio"
|
||||
},
|
||||
"create.limit-alert.type-plural-collection": {
|
||||
"message": "kokoelmat"
|
||||
},
|
||||
"create.limit-alert.type-plural-organization": {
|
||||
"message": "organisaatiot"
|
||||
},
|
||||
"create.limit-alert.type-plural-project": {
|
||||
"message": "projektit"
|
||||
},
|
||||
"create.limit-alert.type-project": {
|
||||
"message": "projekti"
|
||||
},
|
||||
"create.organization.create-organization": {
|
||||
"message": "Luo organisaatio"
|
||||
},
|
||||
"create.organization.name-label": {
|
||||
"message": "Nimi"
|
||||
},
|
||||
"create.organization.name-placeholder": {
|
||||
"message": "Syötä organisaation nimi..."
|
||||
},
|
||||
"create.organization.summary-label": {
|
||||
"message": "Yhteenveto"
|
||||
},
|
||||
"create.project.visibility-private": {
|
||||
"message": "Yksityinen"
|
||||
},
|
||||
"create.project.visibility-public": {
|
||||
"message": "Julkinen"
|
||||
},
|
||||
"create.project.visibility-unlisted": {
|
||||
"message": "Piilotettu"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Luo uusi"
|
||||
},
|
||||
@@ -374,18 +521,165 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"message": "Kokoelmasi"
|
||||
},
|
||||
"dashboard.collections.sort.name-ascending": {
|
||||
"message": "Nimi (A-Z)"
|
||||
},
|
||||
"dashboard.collections.sort.recently-created": {
|
||||
"message": "Äskettäin luotu"
|
||||
},
|
||||
"dashboard.collections.sort.recently-updated": {
|
||||
"message": "Äskettäin päivitetty"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.download-button": {
|
||||
"message": "Lataa {formType}"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.title": {
|
||||
"message": "Kaikki valmista! 🎉"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.entity.private-individual": {
|
||||
"message": "Yksityishenkilö"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.header": {
|
||||
"message": "Vero lomake"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.us-citizen.question": {
|
||||
"message": "Oletko Yhdysvaltain kansalainen?"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.details-label": {
|
||||
"message": "Lisätiedot"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.fee-breakdown-amount": {
|
||||
"message": "Määrä"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.kyc.private-individual": {
|
||||
"message": "Yksityishenkilö"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.muralpay-details.crypto-warning-header": {
|
||||
"message": "Vahvista lompakkosi osoite"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.paypal-details.payment-method": {
|
||||
"message": "Maksutapa"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.paypal-details.paypal-account": {
|
||||
"message": "PayPal-tili"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.tremendous-details.unverified-email-header": {
|
||||
"message": "Vavhistamaton sähköposti"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.tremendous-details.usd-paypal-warning-header": {
|
||||
"message": "Matalammat maksut saatavilla"
|
||||
},
|
||||
"dashboard.head-title": {
|
||||
"message": "Hallintapaneeli"
|
||||
},
|
||||
"dashboard.notifications.empty.no-unread": {
|
||||
"message": "Sinulla ei ole lukemattomia ilmoituksia."
|
||||
},
|
||||
"dashboard.notifications.link.see-all": {
|
||||
"message": "Näytä kaikki"
|
||||
},
|
||||
"dashboard.notifications.link.view-history": {
|
||||
"message": "Näytä ilmoitushistoria"
|
||||
},
|
||||
"dashboard.organizations.button.create": {
|
||||
"message": "Luo organisaatio"
|
||||
},
|
||||
"dashboard.organizations.empty.cta": {
|
||||
"message": "Tee organisaatio!"
|
||||
},
|
||||
"dashboard.organizations.error.fetch": {
|
||||
"message": "Organisaatioiden haku epäonnistui"
|
||||
},
|
||||
"dashboard.organizations.member-count": {
|
||||
"message": "{count} {count, plural, one {jäsen} other {jäsentä}}"
|
||||
},
|
||||
"dashboard.projects.head-title": {
|
||||
"message": "Projektit"
|
||||
},
|
||||
"dashboard.projects.links.and-more": {
|
||||
"message": "ja {count} lisää..."
|
||||
},
|
||||
"dashboard.projects.links.button.clear-link": {
|
||||
"message": "Tyhjennä linkki"
|
||||
},
|
||||
"dashboard.projects.links.button.edit": {
|
||||
"message": "Muokkaa linkkejä"
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.description": {
|
||||
"message": "Kutsulinkki sinun Discord-palvelimellesi."
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.label": {
|
||||
"message": "Discord-kutsu"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.cleared": {
|
||||
"message": "Olemassa oleva linkki tyhjennetään"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-discord-url": {
|
||||
"message": "Syötä kelvollinen Discord-kutsu URL"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-url": {
|
||||
"message": "Syötä kelvollinen URL"
|
||||
},
|
||||
"dashboard.projects.links.show-all-projects": {
|
||||
"message": "Näytä kaikki projektit"
|
||||
},
|
||||
"dashboard.projects.links.source-code.label": {
|
||||
"message": "Lähdekoodi"
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.label": {
|
||||
"message": "Wikisivu"
|
||||
},
|
||||
"dashboard.projects.project.icon-alt": {
|
||||
"message": "Kuvake projektille {title}"
|
||||
},
|
||||
"dashboard.projects.sort.ascending": {
|
||||
"message": "Nouseva"
|
||||
},
|
||||
"dashboard.projects.sort.descending": {
|
||||
"message": "Laskeva"
|
||||
},
|
||||
"dashboard.projects.sort.option.name": {
|
||||
"message": "Nimi"
|
||||
},
|
||||
"dashboard.projects.sort.option.status": {
|
||||
"message": "Tila"
|
||||
},
|
||||
"dashboard.projects.sort.option.type": {
|
||||
"message": "Tyyppi"
|
||||
},
|
||||
"dashboard.projects.table.icon": {
|
||||
"message": "Kuvake"
|
||||
},
|
||||
"dashboard.projects.table.id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"dashboard.projects.table.name": {
|
||||
"message": "Nimi"
|
||||
},
|
||||
"dashboard.projects.table.status": {
|
||||
"message": "Tila"
|
||||
},
|
||||
"dashboard.projects.table.type": {
|
||||
"message": "Tyyppi"
|
||||
},
|
||||
"dashboard.report.title": {
|
||||
"message": "Raportti {id}"
|
||||
},
|
||||
"dashboard.reports.active-title": {
|
||||
"message": "Aktiiviset raportit"
|
||||
},
|
||||
"dashboard.reports.title": {
|
||||
"message": "Raportit"
|
||||
},
|
||||
"dashboard.revenue.available-now": {
|
||||
"message": "Saatavilla nyt"
|
||||
},
|
||||
"dashboard.revenue.processing": {
|
||||
"message": "Prosessoidaan"
|
||||
},
|
||||
"dashboard.revenue.transactions.btn.download-csv": {
|
||||
"message": "Lataa CSV-tiedostona"
|
||||
},
|
||||
"error.collection.404.list_item.1": {
|
||||
"message": "Saatoit kirjoittaa kokoelman URL osoitteen väärin."
|
||||
},
|
||||
@@ -419,12 +713,51 @@
|
||||
"error.project.404.list_title": {
|
||||
"message": "Miksi?"
|
||||
},
|
||||
"error.project.404.title": {
|
||||
"message": "Projektia ei löytynyt"
|
||||
},
|
||||
"error.user.404.list_title": {
|
||||
"message": "Miksi?"
|
||||
},
|
||||
"error.user.404.title": {
|
||||
"message": "Käyttäjää ei löytynyt"
|
||||
},
|
||||
"frog": {
|
||||
"message": "Löysit sammakon! 🐸"
|
||||
},
|
||||
"frog.title": {
|
||||
"message": "Sammakko"
|
||||
},
|
||||
"hosting-marketing.included.backups-included": {
|
||||
"message": "Varmuuskopiot sisällyttäen"
|
||||
},
|
||||
"hosting-marketing.included.file-manager": {
|
||||
"message": "Helppokäyttöinen tiedostoselain"
|
||||
},
|
||||
"hosting-marketing.included.file-manager.description": {
|
||||
"message": "Etsi, hallinnoi, muokkaa ja lataa tiedostoja suoraan palvelimellesi helposti."
|
||||
},
|
||||
"hosting-marketing.included.help": {
|
||||
"message": "Apua kun sitä tarvitset"
|
||||
},
|
||||
"hosting-marketing.included.help.description": {
|
||||
"message": "Ota yhteyttä Modrinthin tiimiin milloin tahansa palvelinasioissa."
|
||||
},
|
||||
"hosting-marketing.included.sftp-access": {
|
||||
"message": "SFTP pääsy"
|
||||
},
|
||||
"hosting-marketing.know-what-you-need": {
|
||||
"message": "Tiedät jo mitä tarvitset?"
|
||||
},
|
||||
"hosting-marketing.medal.info": {
|
||||
"message": "Kokeile ilmaista <orange>3Gt palvelinta</orange> 5 päivän ajan, jonka tarjoaa <orange>Medal</orange>"
|
||||
},
|
||||
"hosting-marketing.medal.learn-more": {
|
||||
"message": "Lue lisää"
|
||||
},
|
||||
"hosting.plan.out-of-stock": {
|
||||
"message": "Loppuunmyyty"
|
||||
},
|
||||
"landing.button.discover-mods": {
|
||||
"message": "Löydä modeja"
|
||||
},
|
||||
@@ -512,6 +845,9 @@
|
||||
"layout.action.new-project": {
|
||||
"message": "Uusi projekti"
|
||||
},
|
||||
"layout.action.publish": {
|
||||
"message": "Julkaise"
|
||||
},
|
||||
"layout.avatar.alt": {
|
||||
"message": "Avatarisi"
|
||||
},
|
||||
|
||||
@@ -1235,9 +1235,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "Nasaan ang mga server ng Modrinth Hosting? Makapipili ba ako ng rehiyon?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Mayroon kaming mga server na magagamit sa Hilagang Amerika, Yuropa, at Timog-silangang Asya sa kasalukuyang sandali na maaari mong piliin pagkatapos bumili. Dadating ang higit pang rehiyon sa hinaharap! Kung nais mong magpalit ng rehiyon, mangyaring abutin ang suporta."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "Ano bang bersiyon ng Minecraft at loader ang magagamit?"
|
||||
},
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
"message": "Moniteur d'activité"
|
||||
},
|
||||
"app-marketing.features.performance.cpu-percent": {
|
||||
"message": "Pourcentage du processeur"
|
||||
"message": "% Processeur"
|
||||
},
|
||||
"app-marketing.features.performance.description": {
|
||||
"message": "Modrinth App est plus performante que la plupart des autres gestionnaires de mods, tout en n'utilisant que 150 Mo de mémoire vive !"
|
||||
@@ -126,7 +126,7 @@
|
||||
"message": "∞ * ∞ Mo"
|
||||
},
|
||||
"app-marketing.features.performance.less-than-150mb": {
|
||||
"message": "moins de 150 Mo"
|
||||
"message": "< 150 Mo"
|
||||
},
|
||||
"app-marketing.features.performance.modrinth-app": {
|
||||
"message": "Modrinth App"
|
||||
@@ -165,7 +165,7 @@
|
||||
"message": "Partagez des modpacks"
|
||||
},
|
||||
"app-marketing.features.unlike-any-launcher": {
|
||||
"message": "Un launcher comme"
|
||||
"message": "Contrairement à tous les autres launchers"
|
||||
},
|
||||
"app-marketing.features.website.description": {
|
||||
"message": "Modrinth App est entièrement intégrée au site Web, vous pouvez donc accéder à tous vos projets préférés depuis l'application !"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Intégration au site Web"
|
||||
},
|
||||
"app-marketing.features.youve-used-before": {
|
||||
"message": "vous n'en avez jamais vu"
|
||||
"message": "vous avez utilisé avant"
|
||||
},
|
||||
"app-marketing.hero.app-screenshot-alt": {
|
||||
"message": "Capture d'écran de Modrinth App avec une instance Cobblemon ouverte sur la page « Contenu »."
|
||||
@@ -1014,7 +1014,7 @@
|
||||
"message": "Organisations"
|
||||
},
|
||||
"dashboard.overview.notifications.button.mark-all-as-read": {
|
||||
"message": "Mettre tout comme étant lu."
|
||||
"message": "Marquer tout comme lu"
|
||||
},
|
||||
"dashboard.overview.notifications.button.view-history": {
|
||||
"message": "Voire l'historique"
|
||||
@@ -1536,7 +1536,7 @@
|
||||
"message": "Où se trouvent les serveurs de Modrinth Hosting ? Puis-je choisir une région ?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Nous avons des serveurs disponibles en Amérique du Nord, en Europe et en Asie du Sud-Est en ce moment-même que vous pouvez choisir dès l'achat. Plus de régions à venir dans le futur ! Si vous souhaitez changer de région, veuillez contacter le support."
|
||||
"message": "Nous avons des serveurs disponibles en Amérique du Nord, en Europe et en Asie du Sud-Est pour le moment que vous pouvez choisir lors de l'achat. Plus de régions sont à venir ! Si vous voulez changer de région, contactez le support."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "Quelles versions de Minecraft et loaders peuvent-être utilisés ?"
|
||||
@@ -1881,7 +1881,7 @@
|
||||
"message": "Erreur lors de la génération de l’état à partir de l’API pendant la construction."
|
||||
},
|
||||
"layout.banner.preview.description": {
|
||||
"message": "Si vous voulez accéder au site officiel de Modrinth, visitez {url}. Cet aperçu\nde déploiement est utilisée par le staff de Modrinth pour des raisons d'essai.\nIl a été construit en utilisant <branch-link>{owner}/{branch}</branch-link> @ {commit}."
|
||||
"message": "Si vous souhaitez accéder au site web officiel de Modrinth, rendez-vous sûr {url}. Cette version anticipée est utilisée par l'équipe de Modrinth à des fins de test. Elle a été créée avec {ref}."
|
||||
},
|
||||
"layout.banner.preview.title": {
|
||||
"message": "Ceci est un déploiement de prévisualisation du site Modrinth."
|
||||
|
||||
@@ -1163,9 +1163,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "היכן ממוקמים שרתי האחסון של Modrinth? האם ניתן לבחור אזור?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "כרגע זמינים עבורכם שרתים בצפון אמריקה, אירופה ודרום-מזרח אסיה, אותם ניתן לבחור בעמד הרכישה. אזורים נוספים יתווספו בעתיד! אם תרצו להחליף את האזור שלכם, אנא צרו קשר עם התמיכה."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "באילו גרסאות של מיינקראפט ובאילו Loaders ניתן להשתמש?"
|
||||
},
|
||||
|
||||
@@ -291,7 +291,7 @@
|
||||
"message": "Már van fiókod?"
|
||||
},
|
||||
"auth.sign-up.subscribe.label": {
|
||||
"message": "Iratkozzon fel a Modrinth frissítéseiről szóló értesítésekre"
|
||||
"message": "Feliratkozás a Modrinth híreire és frissítéseire"
|
||||
},
|
||||
"auth.sign-up.title": {
|
||||
"message": "Regisztráció"
|
||||
@@ -333,7 +333,7 @@
|
||||
"message": "Email-cím hitelesítése"
|
||||
},
|
||||
"auth.welcome.checkbox.subscribe": {
|
||||
"message": "Iratkozzon fel a Modrinth frissítéseiről szóló értesítésekre"
|
||||
"message": "Feliratkozás a Modrinth híreire és frissítéseire"
|
||||
},
|
||||
"auth.welcome.description": {
|
||||
"message": "Most már része vagy annak a fantasztikus fejlesztők és felfedezők közösségének, akik már építenek, letöltenek és naprakészek maradnak a csodálatos modokkal kapcsolatban."
|
||||
@@ -416,6 +416,9 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Jóváhagyás és válasz"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Gondolatmenet lezárása"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Bezárás és válasz"
|
||||
},
|
||||
@@ -950,6 +953,9 @@
|
||||
"dashboard.head-title": {
|
||||
"message": "Irányítópult"
|
||||
},
|
||||
"dashboard.notifications.empty.no-unread": {
|
||||
"message": "Nincsenek olvasatlan értesítéseid."
|
||||
},
|
||||
"dashboard.notifications.link.see-all": {
|
||||
"message": "Összes megtekintése"
|
||||
},
|
||||
@@ -1478,9 +1484,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "Hol találhatók a Modrinth Hosting szerverei? Kiválaszthatom a régiót?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Jelenleg Észak-Amerikában, Európában és Délkelet-Ázsiában állnak rendelkezésre szerverek, amelyek közül vásárláskor választhatsz. A jövőben további régiók is elérhetővé válnak! Ha szeretnéd megváltoztatni a régiót, vedd fel a kapcsolatot az ügyfélszolgálattal."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "Melyik Minecraft verziók és betöltők használhatók?"
|
||||
},
|
||||
@@ -1749,7 +1752,7 @@
|
||||
"message": "Játékosoknak"
|
||||
},
|
||||
"landing.section.for-players.tagline": {
|
||||
"message": "Fedez fel több mint {count, number} alkotást"
|
||||
"message": "Fedezz fel több mint {count, number} alkotást"
|
||||
},
|
||||
"landing.subheading": {
|
||||
"message": "Fedezz fel, játssz és ossz meg Minecraft-tartalmakat a közösség számára létrehozott nyílt forráskódú platformunkon."
|
||||
@@ -1815,7 +1818,7 @@
|
||||
"message": "Mindig mellőzd"
|
||||
},
|
||||
"layout.banner.build-fail.description": {
|
||||
"message": "A Modrinth felhasználói felületének jelenlegi telepítése nem tudta az API-ból lekérni az állapotadatokat. Ennek oka lehet egy szolgáltatáskimaradás vagy konfigurációs hiba. Kérünk, próbáld meg újra, amikor az API újra elérhetővé válik. Hibakódok: {errors}; Az API jelenlegi linkje: {url}"
|
||||
"message": "A Modrinth felhasználói felületének jelenlegi telepítése nem tudta az API-ból lekérni az állapotadatokat. Ennek oka lehet egy szolgáltatáskimaradás vagy konfigurációs hiba. Kérlek próbáld meg újra, amikor az API újra elérhetővé válik. Hibakódok: {errors}; Az API jelenlegi linkje: {url}"
|
||||
},
|
||||
"layout.banner.build-fail.ignore": {
|
||||
"message": "Mellőz"
|
||||
@@ -1823,11 +1826,8 @@
|
||||
"layout.banner.build-fail.title": {
|
||||
"message": "Hiba történt az API állapotának generálása közben a fordítás során."
|
||||
},
|
||||
"layout.banner.preview.description": {
|
||||
"message": "Ha a hivatalos Modrinth weboldalt szeretnéd elérni, látogass el a {url} oldalra. Ezt az előzetes verziót a Modrinth munkatársai tesztelési célokra használják. A <branch-link>{owner}/{branch</branch-link> @ {commit} használatával készült."
|
||||
},
|
||||
"layout.banner.preview.title": {
|
||||
"message": "Ez a Modrinth weboldal előzetes telepítése."
|
||||
"message": "Ez a Modrinth weboldal előzetes verziója."
|
||||
},
|
||||
"layout.banner.staging.description": {
|
||||
"message": "A tesztelési környezet teljesen elkülönül az éles Modrinth adatbázistól. Ezt tesztelési és hibakeresési célokra használják, és a Modrinth backend vagy frontend fejlesztés alatt álló, az éles példánynál újabb verzióit futtathatja."
|
||||
@@ -2628,10 +2628,10 @@
|
||||
"message": "Átdolgoztuk a Modrinth Környezetek rendszerét, és új lehetőségek érhetők el. Kérjük, ellenőrizd, hogy a metaadatok helyesek-e."
|
||||
},
|
||||
"project.environment.migration.review-button": {
|
||||
"message": "Környezet beállítások Átnézése"
|
||||
"message": "Környezeti beállítások áttekintése"
|
||||
},
|
||||
"project.environment.migration.title": {
|
||||
"message": "Kérjük, tekintse át a környezet metaadatait"
|
||||
"message": "Kérlek tekintsd át a környezeti metaadatokat"
|
||||
},
|
||||
"project.error.loading": {
|
||||
"message": "Hiba a projektadatok {message} betöltése közben"
|
||||
@@ -2669,12 +2669,18 @@
|
||||
"project.moderation.admonition.draft.submit-for-review": {
|
||||
"message": "Miután elvégezted az összes szükséges lépést, és meggyőződtél arról, hogy a projekted megfelel a Modrinth <rules-link>Tartalmi szabályainak</rules-link>, benyújthatod a projektedet felülvizsgálatra."
|
||||
},
|
||||
"project.moderation.admonition.rejected.header": {
|
||||
"message": "Változtatások kérve lettek"
|
||||
},
|
||||
"project.moderation.admonition.under-review.body.5": {
|
||||
"message": "<emphasis>Köszönjük türelmedet, amíg moderátoraink keményen dolgoznak a Modrinth biztonságának fenntartásán, és alig várjuk, hogy segíthessünk tartalmaid megosztásában! 💚</emphasis>"
|
||||
},
|
||||
"project.moderation.admonition.under-review.header": {
|
||||
"message": "Projekt áttekintés alatt"
|
||||
},
|
||||
"project.moderation.thread.title": {
|
||||
"message": "Moderációs üzenetek"
|
||||
},
|
||||
"project.moderation.title": {
|
||||
"message": "Moderáció"
|
||||
},
|
||||
@@ -3489,7 +3495,7 @@
|
||||
"message": "Amikor engedélyezel egy alkalmazást a Modrinth fiókoddal, hozzáférést biztosítasz számára a fiókodhoz. A fiókodhoz való hozzáférést bármikor kezelheted és ellenőrizheted itt."
|
||||
},
|
||||
"settings.authorizations.empty-state": {
|
||||
"message": "Jelenleg nem tudjuk megjeleníteni a jogosult alkalmazásait, dolgozunk a probléma megoldásán. Kérjük, látogassa meg ezt az oldalt később!"
|
||||
"message": "Jelenleg nem tudjuk megjeleníteni az azonosított alkalmazásokat, dolgozunk a probléma megoldásán. Kérlek látogasd meg ezt az oldalt később!"
|
||||
},
|
||||
"settings.authorizations.head-title": {
|
||||
"message": "Engedélyek"
|
||||
|
||||
@@ -1163,9 +1163,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "Di mana server Modrinth Hosting terletak? Dapatkah saya memilih wilayah?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Server kami tersedia di Amerika Utara, Eropa, dan Asia Tenggara dan Anda dapat memilihnya saat membeli. Wilayah-wilayah lain akan segera hadir di masa mendatang! Bila Anda ingin mengganti wilayah Anda, mohon hubungi dukungan."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "Versi dan pemuat Minecraft apa sajakah yang dapat digunakan?"
|
||||
},
|
||||
|
||||
@@ -1881,7 +1881,7 @@
|
||||
"message": "Errore nella generazione dello stato da API durante il build."
|
||||
},
|
||||
"layout.banner.preview.description": {
|
||||
"message": "Se volevi accedere al sito ufficiale, visita {url}. Questa versione serve allo staff di Modrinth per condurre test; basata su <branch-link>{owner}/{branch}</branch-link> @ {commit}."
|
||||
"message": "Se volevi accedere al sito ufficiale, visita {url}. Questa versione serve allo staff di Modrinth per condurre test; basata su {ref}."
|
||||
},
|
||||
"layout.banner.preview.title": {
|
||||
"message": "Questa versione del sito di Modrinth è un'anteprima."
|
||||
|
||||
@@ -1373,9 +1373,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "Modrinthでホストするサーバーはどこにありますか?地域を選択できますか?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "現時点では、北アメリカ、ヨーロッパ、東南アジアのサーバーを購入時に選択できます。今後、より多くの地域に対応予定です!地域を変更したい場合はサポートまでお問い合わせください。"
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "どのMinecraftのバージョンとローダーが使えますか?"
|
||||
},
|
||||
|
||||
@@ -687,7 +687,7 @@
|
||||
"message": "제휴 링크 검색..."
|
||||
},
|
||||
"dashboard.analytics.from-projects": {
|
||||
"message": "프로젝트 {count}개"
|
||||
"message": "{count} {count, plural, one {프로젝트} other {프로젝트}}로"
|
||||
},
|
||||
"dashboard.analytics.total-downloads": {
|
||||
"message": "다운로드 수"
|
||||
@@ -1536,7 +1536,7 @@
|
||||
"message": "Modrinth 호스팅 서버는 어디에 위치해 있나요? 지역을 선택할 수 있나요?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "현재 서버는 구매 시 북미, 유럽, 동남아 지역 중에서 선택할 수 있습니다. 향후 더 많은 지역에서 서비스를 제공할 예정입니다! 지역을 변경이 필요하면 지원팀에 문의해 주세요."
|
||||
"message": "현재 북미, 유럽, 동남아시아 전역에서 구매 시 선택할 수 있는 서버가 있습니다. 앞으로 더 많은 지역이 추가될 예정입니다! 지역을 변경하고 싶으시면 지원팀에 문의해 주세요."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "어떤 마인크래프트 버전과 로더를 사용할 수 있나요?"
|
||||
@@ -1880,9 +1880,6 @@
|
||||
"layout.banner.build-fail.title": {
|
||||
"message": "빌드 중 API에서 상태 생성 오류가 발생했습니다."
|
||||
},
|
||||
"layout.banner.preview.description": {
|
||||
"message": "공식 Modrinth 웹사이트에 접속하려는 경우, {url}을(를) 방문하세요. 이 미리보기 배포판은 Modrinth 운영진이 테스트 목적으로 사용하며, 이는 <branch-link>{owner}/{branch}</branch-link> @ {commit} 기반으로 빌드되었습니다."
|
||||
},
|
||||
"layout.banner.preview.title": {
|
||||
"message": "이 웹사이트는 Modrinth의 미리보기입니다."
|
||||
},
|
||||
|
||||
@@ -449,6 +449,27 @@
|
||||
"conversation-thread.action.send-to-review-with-reply": {
|
||||
"message": "Hantar untuk semakan dengan balasan"
|
||||
},
|
||||
"conversation-thread.error.closing-report": {
|
||||
"message": "Ralat semasa menutup laporan"
|
||||
},
|
||||
"conversation-thread.error.reopening-report": {
|
||||
"message": "Ralat semasa membuka semula laporan"
|
||||
},
|
||||
"conversation-thread.error.sending-message": {
|
||||
"message": "Ralat semasa menghantar mesej"
|
||||
},
|
||||
"conversation-thread.reply-editor.placeholder.reply": {
|
||||
"message": "Balas bebenang..."
|
||||
},
|
||||
"conversation-thread.reply-editor.placeholder.send": {
|
||||
"message": "Hantar mesej..."
|
||||
},
|
||||
"conversation-thread.reply-modal.confirmation.description": {
|
||||
"message": "Sahkan bahawa penyederhana tidak memantau bebenang ini secara aktif"
|
||||
},
|
||||
"conversation-thread.reply-modal.confirmation.label": {
|
||||
"message": "Saya akui bahawa penyederhana tidak memantau bebenang ini secara aktif."
|
||||
},
|
||||
"conversation-thread.resubmit-modal.description": {
|
||||
"message": "Anda sedang menghantar <project-title>{projectTitle}</project-title> untuk disemak semula oleh penyederhana."
|
||||
},
|
||||
@@ -1373,9 +1394,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "Di manakah pelayan Modrinth Hosting terletak? Bolehkah saya memilih rantau?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Kami mempunyai pelayan yang tersedia di Amerika Utara, Eropah dan Asia Tenggara pada masa ini yang boleh anda pilih semasa pembelian. Lebih banyak rantau akan datang pada masa hadapan! Jika anda ingin menukar rantau anda, sila hubungi sokongan."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "Versi Minecraft dan pemuat apa yang boleh digunakan?"
|
||||
},
|
||||
@@ -1709,9 +1727,6 @@
|
||||
"layout.banner.build-fail.title": {
|
||||
"message": "Ralat menjana keadaan daripada API semasa membina."
|
||||
},
|
||||
"layout.banner.preview.description": {
|
||||
"message": "Jika anda bermaksud untuk mengakses laman sesawang rasmi Modrinth, kunjungi {url}. Pralihat pelaksanaan ini digunakan oleh kakitangan Modrinth untuk tujuan pengujian. Ia dibina menggunakan <branch-link>{owner}/{branch}</branch-link> @ {commit}."
|
||||
},
|
||||
"layout.banner.preview.title": {
|
||||
"message": "Ini ialah pengaturan pralihat laman sesawang Modrinth."
|
||||
},
|
||||
|
||||
@@ -407,11 +407,20 @@
|
||||
"collection.title": {
|
||||
"message": "{name} - Collectie"
|
||||
},
|
||||
"conversation-thread.reply-modal.description": {
|
||||
"message": "Je project is al goedgekeurd. Daarom houdt het moderatieteam deze thread niet actief in de gaten. Mocht er echter een probleem met je project zijn, dan kunnen zij je bericht wel zien."
|
||||
},
|
||||
"conversation-thread.reply-modal.help-center-note": {
|
||||
"message": "Als je contact wilt opnemen met het moderatorteam, ga dan naar het <help-center-link>Modrinth Helpcentrum</help-center-link> en klik op het blauwe ballonnetje rechtsonder om contact op te nemen met de ondersteuning."
|
||||
},
|
||||
"conversation-thread.resubmit-modal.warning": {
|
||||
"message": "Herhaaldelijk berichten plaatsen zonder in te gaan op de opmerkingen van de moderators kan leiden tot een opschorting van je account."
|
||||
},
|
||||
"create-project-version.create-modal.stage.add-files.admonition": {
|
||||
"message": "Aanvullende bestanden zijn bedoeld voor ondersteunende bronnen zoals source code, niet voor alternatieve versies of varianten."
|
||||
},
|
||||
"create.collection.collection-info": {
|
||||
"message": "Je nieuwe collectie wordt gemaakt als een publieke collectie met {{count, plural,=0 {geen projecten}one {# project}other {# projecten}}."
|
||||
"message": "Je nieuwe collectie wordt gemaakt als een publieke collectie met {count, plural,=0 {geen projecten}one {# project}other {# projecten}}."
|
||||
},
|
||||
"create.collection.create-collection": {
|
||||
"message": "Collectie aanmaken"
|
||||
@@ -572,6 +581,9 @@
|
||||
"dashboard.affiliate-links.search": {
|
||||
"message": "Zoek affiliatelinks..."
|
||||
},
|
||||
"dashboard.analytics.from-projects": {
|
||||
"message": "van {count} {count, plural, one {project} other {projects}}"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Maak nieuwe"
|
||||
},
|
||||
@@ -848,6 +860,18 @@
|
||||
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
|
||||
"message": "Je hebt je opnamelimiet <b>{withdrawLimit}</b> bereikt. Je moet een belastingformulier invullen om meer geld op te nemen."
|
||||
},
|
||||
"dashboard.notifications.link.view-more": {
|
||||
"message": "Bekijk {extraNotifs} meer {extraNotifs, plural, one {notification} other {notifications}}"
|
||||
},
|
||||
"dashboard.organizations.member-count": {
|
||||
"message": "{count} {count, plural, one {member} other {members}}"
|
||||
},
|
||||
"dashboard.projects.links.changes-applied": {
|
||||
"message": "De wijzigingen worden toegepast op <strong>{count}</strong> {count, plural, one {project} other {projects}}."
|
||||
},
|
||||
"dashboard.projects.links.description": {
|
||||
"message": "Alle links die u hieronder opgeeft, worden in elk van de geselecteerde projecten overschreven. Links die u leeg laat, worden genegeerd. U kunt een link uit alle geselecteerde projecten verwijderen met de prullenbakknop."
|
||||
},
|
||||
"dashboard.revenue.available-now": {
|
||||
"message": "Nu beschikbaar"
|
||||
},
|
||||
@@ -1004,6 +1028,15 @@
|
||||
"dashboard.withdraw.error.tax-form.title": {
|
||||
"message": "Gelieve het belastingformulier in te vullen"
|
||||
},
|
||||
"discover.seo.description": {
|
||||
"message": "Zoek en blader door duizenden Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} op Modrinth met directe, nauwkeurige zoekresultaten. Onze filters helpen je snel de beste Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}."
|
||||
},
|
||||
"discover.seo.title": {
|
||||
"message": "Zoek {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}"
|
||||
},
|
||||
"discover.seo.title-with-query": {
|
||||
"message": "Zoek {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} | {query}"
|
||||
},
|
||||
"error.collection.404.list_item.1": {
|
||||
"message": "U heeft mogelijk de URL van de collectie fout ingetypt."
|
||||
},
|
||||
@@ -1164,7 +1197,7 @@
|
||||
"message": "Waar bevinden de servers van Modrinth Hosting zich? Kan ik een regio kiezen?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "We hebben momenteel servers beschikbaar in Noord-Amerika, Europa en Zuidoost-Azië, waaruit u bij aankoop kunt kiezen. In de toekomst zullen er meer regio's volgen! Als u uw regio wilt wijzigen, neem dan contact op met de klantenservice."
|
||||
"message": "Op dit moment hebben we servers beschikbaar in Noord-Amerika, Europa en Zuidoost-Azië, waaruit u bij aankoop kunt kiezen. In de toekomst komen er nog meer regio’s bij! Als u van regio wilt wisselen, neem dan contact op met de klantenservice."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "Welke Minecraft-versies en loaders kunnen worden gebruikt?"
|
||||
|
||||
@@ -1238,9 +1238,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "Hvor oppholder Modrinth Hosting-serverne seg? Kan jeg velge en region?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Akkurat nå, så har tilgjengelige servere i Nord-Amerika, Europa, og Sørøst-Asia som du kan velge etter kjøpet ditt. Det kommer flere regioner i framtida! Viss du vil bytte regionen din, vær så snill å ta kontakt med støtte."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "Hvilke Minecraft-versjoner-og-loadere kan brukes?"
|
||||
},
|
||||
|
||||
@@ -1880,9 +1880,6 @@
|
||||
"layout.banner.build-fail.title": {
|
||||
"message": "Błąd podczas generowania stanu z API przy kompilacji."
|
||||
},
|
||||
"layout.banner.preview.description": {
|
||||
"message": "Jeśli chciałeś/aś wejść na oficjalną stronę Modrinth, odwiedź {url}. To wydanie poglądowe jest używane przez administrację Modrinth do testów. Zostało utworzone z <branch-link>{owner}/{branch}</branch-link> @ {commit}."
|
||||
},
|
||||
"layout.banner.preview.title": {
|
||||
"message": "To jest wydanie poglądowe strony Modrinth."
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"message": "Mac"
|
||||
},
|
||||
"app-marketing.download.options-title": {
|
||||
"message": "Opções de transferência"
|
||||
"message": "Opções de download"
|
||||
},
|
||||
"app-marketing.download.terms": {
|
||||
"message": "Ao baixar o Modrinth App, você concorda com nossos <terms-link>Termos</terms-link> e <privacy-link>Política de Privacidade</privacy-link>."
|
||||
@@ -1362,13 +1362,13 @@
|
||||
"message": "Selecionando o pacote de mods para instalar após a redefinição"
|
||||
},
|
||||
"discover.seo.description": {
|
||||
"message": "Busque e explore milhares de {projectType, select, mod {mods} modpack {pacotes de mods} resourcepack {pacotes de recursos} shader {shaders} plugin {plugins} datapack {pacotes de dados} other {projetos}} de Minecraft no Modrinth com resultados de busca instantâneos e precisos. Nossos filtros ajudam você a encontrar rapidamente os melhores {projectType, select, mod {mods} modpack {pacotes de mods} resourcepack {pacotes de recursos} shader {shaders} plugin {plugins} datapack {pacotes de dados} other {projetos}} de Minecraft."
|
||||
"message": "Busque e explore milhares de {projectType, select, mod {mods} modpack {pacotes de mods (modpacks)} resourcepack {pacotes de recursos (resourcepacks)} shader {sombreadores (shaders)} plugin {plugins} datapack {pacotes de dados (datapacks)} other {projetos}} de Minecraft no Modrinth com resultados de busca instantâneos e precisos. Nossos filtros ajudam você a encontrar rapidamente os melhores {projectType, select, mod {mods} modpack {pacotes de mods (modpacks)} resourcepack {pacotes de recursos (resourcepacks)} shader {sombreadores (shaders)} plugin {plugins} datapack {pacotes de dados (datapacks)} other {projetos}} de Minecraft."
|
||||
},
|
||||
"discover.seo.title": {
|
||||
"message": "Buscar {projectType, select, mod {mods} modpack {pacotes de mods} resourcepack {pacotes de recursos} shader {shaders} plugin {plugins} datapack {pacotes de dados} other {projetos}}"
|
||||
"message": "Buscar {projectType, select, mod {mods} modpack {pacotes de mods (modpacks)} resourcepack {pacotes de recursos (resourcepacks)} shader {sombreadores (shaders)} plugin {plugins} datapack {pacotes de dados (datapacks)} other {projetos}}"
|
||||
},
|
||||
"discover.seo.title-with-query": {
|
||||
"message": "Buscar {projectType, select, mod {mods} modpack {pacotes de mods} resourcepack {pacotes de recursos} shader {shaders} plugin {plugins} datapack {pacotes de dados} other {projetos}} | {query}"
|
||||
"message": "Buscar {projectType, select, mod {mods} modpack {pacotes de mods (modpacks)} resourcepack {pacotes de recursos (resourcepacks)} shader {sombreadores (shaders)} plugin {plugins} datapack {pacotes de dados (datapacks)} other {projetos}} | {query}"
|
||||
},
|
||||
"error.collection.404.list_item.1": {
|
||||
"message": "Você pode ter digitado incorretamente o URL da coleção."
|
||||
@@ -1881,7 +1881,7 @@
|
||||
"message": "Erro ao gerar o estado da API durante a compilação."
|
||||
},
|
||||
"layout.banner.preview.description": {
|
||||
"message": "Se você pretendia acessar o site oficial do Modrinth, visite {url}. Essa versão de pré-lançamento é usada pela equipe da Modrinth para fins de teste. Ela foi construída com base em <branch-link>{owner}/{branch}</branch-link> @ {commit}."
|
||||
"message": "Se você pretendia acessar o site oficial do Modrinth, visite {url}. Essa versão de pré-lançamento é usada pela equipe da Modrinth para fins de teste. Ela foi criada usando {ref}."
|
||||
},
|
||||
"layout.banner.preview.title": {
|
||||
"message": "Esta é uma versão de pré-lançamento do site Modrinth."
|
||||
@@ -2565,10 +2565,10 @@
|
||||
"message": "Servidor"
|
||||
},
|
||||
"project-type.shader.plural": {
|
||||
"message": "Shaders"
|
||||
"message": "Sombreadores"
|
||||
},
|
||||
"project-type.shader.singular": {
|
||||
"message": "Shader"
|
||||
"message": "Sombreador"
|
||||
},
|
||||
"project.about.details.created": {
|
||||
"message": "Criado {date}"
|
||||
|
||||
@@ -1229,9 +1229,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "Onde estão localizados os servidores Modrinth Hosting? Posso escolher uma região?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Temos servidores disponíveis na América do Norte, Europa e Ásia Sudeste, que podes escolher no momento da compra. Mais regiões serão adicionadas no futuro! Se quiseres trocar a tua região, entra em contacto com o suporte."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "Que versões de Minecraft e loaders podem ser utilizados?"
|
||||
},
|
||||
|
||||
@@ -408,25 +408,25 @@
|
||||
"message": "{name} — Коллекция"
|
||||
},
|
||||
"conversation-thread.action.add-private-note": {
|
||||
"message": "Добавить примечание для себя"
|
||||
"message": "Добавить приватную заметку"
|
||||
},
|
||||
"conversation-thread.action.approve": {
|
||||
"message": "Одобрить"
|
||||
},
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Одобрить с ответом"
|
||||
"message": "Одобрить с ответом"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Закрыть ветку"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Закрыть с ответом"
|
||||
"message": "Закрыть с ответом"
|
||||
},
|
||||
"conversation-thread.action.reject": {
|
||||
"message": "Отклонить"
|
||||
},
|
||||
"conversation-thread.action.reject-with-reply": {
|
||||
"message": "Отклонить с ответом"
|
||||
"message": "Отклонить с ответом"
|
||||
},
|
||||
"conversation-thread.action.reopen-thread": {
|
||||
"message": "Возобновить ветку"
|
||||
@@ -435,34 +435,34 @@
|
||||
"message": "Ответить"
|
||||
},
|
||||
"conversation-thread.action.reply-to-thread": {
|
||||
"message": "Ответить на ветку"
|
||||
"message": "Ответить в ветке"
|
||||
},
|
||||
"conversation-thread.action.resubmit-for-review": {
|
||||
"message": "Запросить перепроверку"
|
||||
"message": "Отправить на перепроверку"
|
||||
},
|
||||
"conversation-thread.action.resubmit-for-review-with-reply": {
|
||||
"message": "Запросить перепроверку с ответом"
|
||||
"message": "Отправить на перепроверку с ответом"
|
||||
},
|
||||
"conversation-thread.action.send": {
|
||||
"message": "Отправить"
|
||||
},
|
||||
"conversation-thread.action.send-to-review": {
|
||||
"message": "Отправить на проверку"
|
||||
"message": "Отправить на проверку"
|
||||
},
|
||||
"conversation-thread.action.send-to-review-with-reply": {
|
||||
"message": "Отправить на проверку с ответом"
|
||||
"message": "Отправить на проверку с ответом"
|
||||
},
|
||||
"conversation-thread.action.set-to-draft": {
|
||||
"message": "Установить в черновик"
|
||||
"message": "Установить в черновик"
|
||||
},
|
||||
"conversation-thread.action.set-to-draft-with-reply": {
|
||||
"message": "Установить в черновик с ответом"
|
||||
"message": "Установить в черновик с ответом"
|
||||
},
|
||||
"conversation-thread.action.withhold": {
|
||||
"message": "Удержать"
|
||||
},
|
||||
"conversation-thread.action.withhold-with-reply": {
|
||||
"message": "Удержать с ответом"
|
||||
"message": "Удержать с ответом"
|
||||
},
|
||||
"conversation-thread.closed-thread.description": {
|
||||
"message": "Эта ветка закрыта, новые сообщения в неё нельзя отправить."
|
||||
@@ -477,10 +477,10 @@
|
||||
"message": "Ошибка отправки сообщения"
|
||||
},
|
||||
"conversation-thread.reply-editor.placeholder.reply": {
|
||||
"message": "Ответить в ветку..."
|
||||
"message": "Ответьте в ветке..."
|
||||
},
|
||||
"conversation-thread.reply-editor.placeholder.send": {
|
||||
"message": "Отправить сообщение..."
|
||||
"message": "Отправьте сообщение..."
|
||||
},
|
||||
"conversation-thread.reply-modal.confirmation.description": {
|
||||
"message": "Подтвердить, что модераторы не проводят активный просмотр"
|
||||
@@ -492,7 +492,7 @@
|
||||
"message": "Ваш проект уже одобрен. Из-за этого модерация не проводит активный просмотр этой ветки. Однако, если возникнет проблема с вашим проектом, она сможет увидеть ваше сообщение."
|
||||
},
|
||||
"conversation-thread.reply-modal.header": {
|
||||
"message": "Ответить на ветку"
|
||||
"message": "Ответ в ветке"
|
||||
},
|
||||
"conversation-thread.reply-modal.help-center-note": {
|
||||
"message": "Если вам нужно выйти на контакт с командой модерации, используйте <help-center-link>справочный центр Modrinth</help-center-link> и нажмите на синий значок в правом нижнем углу для связи с поддержкой."
|
||||
@@ -1881,7 +1881,7 @@
|
||||
"message": "Ошибка генерации состояния от API во время сборки."
|
||||
},
|
||||
"layout.banner.preview.description": {
|
||||
"message": "Официальный сайт Modrinth доступен по адресу {url}. Текущая предварительная версия предназначена для тестирования командой Modrinth и собрана из <branch-link>{owner}/{branch}</branch-link> @ {commit}."
|
||||
"message": "Официальный сайт Modrinth доступен по адресу {url}. Текущая предварительная версия предназначена для тестирования командой Modrinth и собрана из {ref}."
|
||||
},
|
||||
"layout.banner.preview.title": {
|
||||
"message": "Это предварительная версия сайта Modrinth."
|
||||
@@ -2496,7 +2496,7 @@
|
||||
"message": "Запросите проверку"
|
||||
},
|
||||
"project-moderation-nags.submit-for-review-button": {
|
||||
"message": "Отправить на проверку"
|
||||
"message": "Отправить на проверку"
|
||||
},
|
||||
"project-moderation-nags.submit-for-review-desc": {
|
||||
"message": "Проект сейчас виден только участникам. Для публикации он должен пройти проверку модераторами."
|
||||
|
||||
@@ -1184,9 +1184,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "Var finns Modrinth Hostings servrar? Kan jag välja region?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Vi har för närvarande servrar tillgängliga i Nordamerika, Europa och Sydostasien som du kan välja mellan vid köpet. Fler regioner kommer att läggas till i framtiden! Om du vill byta region, vänligen kontakta supporten."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "Vilka Minecraft versioner och loaders kan användas?"
|
||||
},
|
||||
|
||||
@@ -407,6 +407,108 @@
|
||||
"collection.title": {
|
||||
"message": "{name} - Koleksiyon"
|
||||
},
|
||||
"conversation-thread.action.add-private-note": {
|
||||
"message": "Özel not ekle"
|
||||
},
|
||||
"conversation-thread.action.approve": {
|
||||
"message": "Onaylandı"
|
||||
},
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Onaylandı, işlem tamamlandı"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Konuyu kapat"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Cevapla ve kapat"
|
||||
},
|
||||
"conversation-thread.action.reject": {
|
||||
"message": "Reddet"
|
||||
},
|
||||
"conversation-thread.action.reject-with-reply": {
|
||||
"message": "Cevap vererek reddet"
|
||||
},
|
||||
"conversation-thread.action.reopen-thread": {
|
||||
"message": "Konuyu yeniden aç"
|
||||
},
|
||||
"conversation-thread.action.reply": {
|
||||
"message": "Yanıtla"
|
||||
},
|
||||
"conversation-thread.action.reply-to-thread": {
|
||||
"message": "Konuya yanıt ver"
|
||||
},
|
||||
"conversation-thread.action.resubmit-for-review": {
|
||||
"message": "İnceleme için yeniden gönder"
|
||||
},
|
||||
"conversation-thread.action.resubmit-for-review-with-reply": {
|
||||
"message": "Yanıtla birlikte yeniden incelemeye gönder"
|
||||
},
|
||||
"conversation-thread.action.send": {
|
||||
"message": "Gönder"
|
||||
},
|
||||
"conversation-thread.action.send-to-review": {
|
||||
"message": "İncelemeye gönder"
|
||||
},
|
||||
"conversation-thread.action.send-to-review-with-reply": {
|
||||
"message": "Yanıtla birlikte incelemeye gönder"
|
||||
},
|
||||
"conversation-thread.action.withhold": {
|
||||
"message": "Yanıtla birlikte taslağa çevir"
|
||||
},
|
||||
"conversation-thread.action.withhold-with-reply": {
|
||||
"message": "Yanıtla birlikte beklet"
|
||||
},
|
||||
"conversation-thread.closed-thread.description": {
|
||||
"message": "Bu konu kapatılmıştır ve yeni mesaj gönderilemez."
|
||||
},
|
||||
"conversation-thread.error.closing-report": {
|
||||
"message": "Rapor kapatılırken hata oluştu"
|
||||
},
|
||||
"conversation-thread.error.sending-message": {
|
||||
"message": "Mesaj gönderilirken hata oluştu"
|
||||
},
|
||||
"conversation-thread.reply-editor.placeholder.reply": {
|
||||
"message": "Konuya yanıt ver..."
|
||||
},
|
||||
"conversation-thread.reply-editor.placeholder.send": {
|
||||
"message": "Mesaj gönder..."
|
||||
},
|
||||
"conversation-thread.reply-modal.confirmation.description": {
|
||||
"message": "Moderatörlerin bunu aktif olarak izlemediğini onayla"
|
||||
},
|
||||
"conversation-thread.reply-modal.confirmation.label": {
|
||||
"message": "Moderatörlerin bu konuyu aktif olarak denetlemediğini kabul ediyorum."
|
||||
},
|
||||
"conversation-thread.reply-modal.description": {
|
||||
"message": "Projeniz zaten onaylanmış durumda. Bu nedenle moderasyon ekibi bu konuyu aktif olarak takip etmez. Ancak projenizle ilgili bir sorun oluşursa mesajınızı yine de görebilirler."
|
||||
},
|
||||
"conversation-thread.reply-modal.header": {
|
||||
"message": "Konuyu yanıtla"
|
||||
},
|
||||
"conversation-thread.reply-modal.help-center-note": {
|
||||
"message": "Moderasyon ekibiyle iletişime geçmeniz gerekiyorsa, lütfen <help-center-link>Modrinth Yardım Merkezi</help-center-link> adresini kullanın ve destekle iletişime geçmek için sağ alt köşedeki mavi sohbet simgesine tıklayın."
|
||||
},
|
||||
"conversation-thread.resubmit-modal.confirmation.description": {
|
||||
"message": "Moderatörlerin mesajlarını yanıtladığımı onaylıyorum"
|
||||
},
|
||||
"conversation-thread.resubmit-modal.confirmation.label": {
|
||||
"message": "Moderatörlerin yorumlarını uygun şekilde yanıtladığımı onaylıyorum."
|
||||
},
|
||||
"conversation-thread.resubmit-modal.description": {
|
||||
"message": "<project-title>{projectTitle}</project-title> projesini moderatörler tarafından yeniden incelenmek üzere gönderiyorsunuz."
|
||||
},
|
||||
"conversation-thread.resubmit-modal.header.resubmitting": {
|
||||
"message": "İnceleme için yeniden gönderiliyor"
|
||||
},
|
||||
"conversation-thread.resubmit-modal.header.submitting": {
|
||||
"message": "İncelemeye gönderiliyor"
|
||||
},
|
||||
"conversation-thread.resubmit-modal.reminder": {
|
||||
"message": "Moderatör ekibinin tüm yorumlarını ele aldığınızdan emin olun."
|
||||
},
|
||||
"conversation-thread.resubmit-modal.warning": {
|
||||
"message": "Moderatörlerin yorumlarını ele almadan tekrar tekrar gönderim yapmak, hesabınızın askıya alınmasına neden olabilir."
|
||||
},
|
||||
"create-project-version.create-modal.stage.add-files.admonition": {
|
||||
"message": "Ek dosyalar, alternatif sürümler veya varyantlar için değil, kaynak kodu gibi destekleyici kaynaklar içindir."
|
||||
},
|
||||
@@ -875,6 +977,9 @@
|
||||
"dashboard.head-title": {
|
||||
"message": "Kontrol Paneli"
|
||||
},
|
||||
"dashboard.notifications.empty.no-unread": {
|
||||
"message": "Okunmamış bildiriminiz yok."
|
||||
},
|
||||
"dashboard.notifications.link.see-all": {
|
||||
"message": "Hepsi"
|
||||
},
|
||||
@@ -899,6 +1004,27 @@
|
||||
"dashboard.organizations.title": {
|
||||
"message": "Organizasyonlar"
|
||||
},
|
||||
"dashboard.overview.notifications.button.mark-all-as-read": {
|
||||
"message": "Tümünü okundu olarak işaretle"
|
||||
},
|
||||
"dashboard.overview.notifications.button.view-history": {
|
||||
"message": "Geçmişi görüntüle"
|
||||
},
|
||||
"dashboard.overview.notifications.empty.no-unread": {
|
||||
"message": "Okunmamış bildiriminiz yok."
|
||||
},
|
||||
"dashboard.overview.notifications.error.loading": {
|
||||
"message": "Bildirimler yüklenirken hata oluştu:"
|
||||
},
|
||||
"dashboard.overview.notifications.history.label": {
|
||||
"message": "Geçmiş"
|
||||
},
|
||||
"dashboard.overview.notifications.history.title": {
|
||||
"message": "Bildirim geçmişi"
|
||||
},
|
||||
"dashboard.overview.notifications.loading": {
|
||||
"message": "Bildirimler yükleniyor..."
|
||||
},
|
||||
"dashboard.projects.bulk-edit-hint": {
|
||||
"message": "Aşağıdan seçerek birden fazla projeyi düzenleyebilirsiniz."
|
||||
},
|
||||
@@ -1208,6 +1334,33 @@
|
||||
"dashboard.withdraw.error.tax-form.title": {
|
||||
"message": "Lütfen vergi formunu tamamlayınız"
|
||||
},
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Sunucuya geri dön"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Sunucuya geri dön"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Sıfırlamayı iptal et"
|
||||
},
|
||||
"discover.install.error.no-server-world": {
|
||||
"message": "Yüklenebilecek hiçbir sunucu dünyası mevcut değil."
|
||||
},
|
||||
"discover.install.error.unsupported-content-type": {
|
||||
"message": "Bu içerik türü, tarayıcıdan bir sunucuya yüklenemez."
|
||||
},
|
||||
"discover.install.heading.reset-modpack": {
|
||||
"message": "Sıfırlama sonrası yüklenecek mod paketi seçiliyor"
|
||||
},
|
||||
"discover.seo.description": {
|
||||
"message": "Modrinth üzerinde anında ve doğru arama sonuçlarıyla binlerce Minecraft {projectType, select, mod {mod} modpack {modpack} resourcepack {kaynak paketi} shader {shader} plugin {eklenti} datapack {veri paketi} other {proje}} arasında arama yap ve göz at. Filtrelerimiz en iyi Minecraft {projectType, select, mod {modları} modpack {mod paketlerini} resourcepack {kaynak paketlerini} shader {shaderları} plugin {eklentileri} datapack {veri paketlerini} other {projeleri}} hızlıca bulmana yardımcı olur."
|
||||
},
|
||||
"discover.seo.title": {
|
||||
"message": "Ara {projectType, select, mod {modları} modpack {mod paketleri} resourcepack {kaynak paketleri} shader {shaderlar} plugin {eklentiler} datapack {veri paketleri} other {projeler}}"
|
||||
},
|
||||
"discover.seo.title-with-query": {
|
||||
"message": "Ara {projectType, select, mod {modları} modpack {mod paketleri} resourcepack {kaynak paketleri} shader {shaderlar} plugin {eklentiler} datapack {veri paketleri} other {projeler}} | {query}"
|
||||
},
|
||||
"error.collection.404.list_item.1": {
|
||||
"message": "Koleksiyonun URL'ini yanlış yazmış olabilirsin."
|
||||
},
|
||||
@@ -1223,6 +1376,12 @@
|
||||
"error.collection.404.title": {
|
||||
"message": "Koleksiyon bulunamadı"
|
||||
},
|
||||
"error.generic.401.signed-in-as": {
|
||||
"message": "Şu anda şu hesapla oturum açtınız:"
|
||||
},
|
||||
"error.generic.401.title": {
|
||||
"message": "Bu sayfaya erişiminiz yok"
|
||||
},
|
||||
"error.generic.404.subtitle": {
|
||||
"message": "Aradığın sayfa yokmuş gibi duruyor."
|
||||
},
|
||||
@@ -1368,7 +1527,7 @@
|
||||
"message": "Modrinth Hosting sunucuları nerede bulunuyor? Bölge seçebilir miyim?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Şu anda Kuzey Amerika, Avrupa ve Güneydoğu Asya'da satın alabileceğiniz sunucularımız bulunmaktadır. Gelecekte daha fazla bölge eklenecektir! Bölgenizi değiştirmek isterseniz, lütfen destek ekibiyle iletişime geçin."
|
||||
"message": "Şu anda satın alma sırasında seçebileceğiniz sunucular Kuzey Amerika, Avrupa ve Güneydoğu Asya bölgelerinde mevcuttur. Yakında daha fazla bölge eklenecektir. Bölgenizi değiştirmek isterseniz lütfen destek ekibiyle iletişime geçin."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "Hangi Minecraft sürümleri ve yükleyiciler kullanılabilir?"
|
||||
@@ -1649,6 +1808,9 @@
|
||||
"layout.action.create-new": {
|
||||
"message": "Yeni oluştur..."
|
||||
},
|
||||
"layout.action.external-projects": {
|
||||
"message": "Harici projeler"
|
||||
},
|
||||
"layout.action.file-lookup": {
|
||||
"message": "Dosya bulma"
|
||||
},
|
||||
@@ -1697,9 +1859,15 @@
|
||||
"layout.banner.add-email.description": {
|
||||
"message": "Güvenlik sebebiyle Modrinth hesabınıza bir e-posta adresi kaydetmeniz gerek."
|
||||
},
|
||||
"layout.banner.build-fail.always-ignore": {
|
||||
"message": "Her zaman yok say"
|
||||
},
|
||||
"layout.banner.build-fail.description": {
|
||||
"message": "Modrinth'in önucunun bu dağıtımı API'den durum oluşturamadı. Bu bir kesintiden veya yapılandırmadaki bir hatadan kaynaklı olabilir. API mevcutken tekrar derleyin. Hata kodları: {errors}; şu anki API adresi: {url}"
|
||||
},
|
||||
"layout.banner.build-fail.ignore": {
|
||||
"message": "Yok say"
|
||||
},
|
||||
"layout.banner.build-fail.title": {
|
||||
"message": "Derleme esnasında API'den durum oluştururken hata."
|
||||
},
|
||||
@@ -1898,6 +2066,9 @@
|
||||
"moderation.moderate": {
|
||||
"message": "Yönet"
|
||||
},
|
||||
"moderation.page.external-projects": {
|
||||
"message": "Harici projeler"
|
||||
},
|
||||
"moderation.page.projects": {
|
||||
"message": "Projeler"
|
||||
},
|
||||
@@ -2525,6 +2696,9 @@
|
||||
"project.license.title": {
|
||||
"message": "Lisans"
|
||||
},
|
||||
"project.moderation.admonition.approved.header": {
|
||||
"message": "Proje onaylandı"
|
||||
},
|
||||
"project.moderation.title": {
|
||||
"message": "Moderasyon"
|
||||
},
|
||||
@@ -2588,6 +2762,12 @@
|
||||
"project.settings.general.url.title": {
|
||||
"message": "URL"
|
||||
},
|
||||
"project.settings.permissions.empty-state.heading": {
|
||||
"message": "Hazırsınız!"
|
||||
},
|
||||
"project.settings.permissions.learn-more": {
|
||||
"message": "Daha Fazla Bilgi edinin"
|
||||
},
|
||||
"project.settings.title": {
|
||||
"message": "Ayarlar"
|
||||
},
|
||||
@@ -3332,6 +3512,9 @@
|
||||
"settings.billing.charges.description": {
|
||||
"message": "Modrinth hesabınıza geçmişte yapılan tüm ödemeleriniz burada listelenir:"
|
||||
},
|
||||
"settings.billing.charges.product.pyro": {
|
||||
"message": "Modrinth Barındırma"
|
||||
},
|
||||
"settings.billing.charges.title": {
|
||||
"message": "Past charges"
|
||||
},
|
||||
@@ -3440,6 +3623,9 @@
|
||||
"settings.billing.pyro.ram": {
|
||||
"message": "{gb} GB RAM"
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.error.text": {
|
||||
"message": "Modrinth sunucuna yeniden abone olurken hata oluştu."
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.error.title": {
|
||||
"message": "Yeniden abone olurken hata"
|
||||
},
|
||||
|
||||
@@ -1881,7 +1881,7 @@
|
||||
"message": "Помилка під час створення стану з API під час побудови."
|
||||
},
|
||||
"layout.banner.preview.description": {
|
||||
"message": "Якщо ви хочете отримати доступ до офіційного сайту Modrinth, відвідайте {url}. Це розгортання попереднього перегляду використовується персоналом Modrinth для тестування. Він був створений за допомогою <branch-link>{owner}/{branch}</branch-link> @ {commit}."
|
||||
"message": "Якщо ви хотіли отримати доступ до офіційного сайту Modrinth, відвідайте {url}. Це розгортання попереднього перегляду використовується персоналом Modrinth для тестування. Воно було створено за допомогою {ref}."
|
||||
},
|
||||
"layout.banner.preview.title": {
|
||||
"message": "Це попередній перегляд розгортки вебсайту Modrinth."
|
||||
|
||||
@@ -407,6 +407,81 @@
|
||||
"collection.title": {
|
||||
"message": "{name} - Bộ sưu tập"
|
||||
},
|
||||
"conversation-thread.action.add-private-note": {
|
||||
"message": "Thêm ghi chú bí mật"
|
||||
},
|
||||
"conversation-thread.action.approve": {
|
||||
"message": "Chấp thuận"
|
||||
},
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Chấp thuận với phản hồi"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Đóng luồng"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Đóng với phản hồi"
|
||||
},
|
||||
"conversation-thread.action.reject": {
|
||||
"message": "Từ chối"
|
||||
},
|
||||
"conversation-thread.action.reject-with-reply": {
|
||||
"message": "Từ chối với phản hồi"
|
||||
},
|
||||
"conversation-thread.action.reopen-thread": {
|
||||
"message": "Mở lại luồng"
|
||||
},
|
||||
"conversation-thread.action.reply": {
|
||||
"message": "Phản hồi"
|
||||
},
|
||||
"conversation-thread.action.reply-to-thread": {
|
||||
"message": "Phản hồi đến luồng"
|
||||
},
|
||||
"conversation-thread.action.resubmit-for-review": {
|
||||
"message": "Gửi lại để xét duyệt"
|
||||
},
|
||||
"conversation-thread.action.resubmit-for-review-with-reply": {
|
||||
"message": "Gửi lại để xét duyệt kèm phản hồi"
|
||||
},
|
||||
"conversation-thread.action.send": {
|
||||
"message": "Gửi"
|
||||
},
|
||||
"conversation-thread.action.send-to-review": {
|
||||
"message": "Gửi để xem xét"
|
||||
},
|
||||
"conversation-thread.action.send-to-review-with-reply": {
|
||||
"message": "Gửi để xem xét với phản hồi"
|
||||
},
|
||||
"conversation-thread.action.set-to-draft": {
|
||||
"message": "Chuyển sang bản nháp"
|
||||
},
|
||||
"conversation-thread.action.set-to-draft-with-reply": {
|
||||
"message": "Chuyển sang bản nháp kèm phản hồi"
|
||||
},
|
||||
"conversation-thread.action.withhold": {
|
||||
"message": "Thu hồi"
|
||||
},
|
||||
"conversation-thread.action.withhold-with-reply": {
|
||||
"message": "Thu hồi với phản hồi"
|
||||
},
|
||||
"conversation-thread.closed-thread.description": {
|
||||
"message": "Luồng này đã bị đóng và tin nhắn mới không thể gửi được vào."
|
||||
},
|
||||
"conversation-thread.error.closing-report": {
|
||||
"message": "Lỗi khi đóng báo cáo"
|
||||
},
|
||||
"conversation-thread.error.reopening-report": {
|
||||
"message": "Lỗi khi mở lại báo cáo"
|
||||
},
|
||||
"conversation-thread.error.sending-message": {
|
||||
"message": "Lỗi khi gửi tin nhắn"
|
||||
},
|
||||
"conversation-thread.reply-editor.placeholder.reply": {
|
||||
"message": "Đang phản hồi vào luồng..."
|
||||
},
|
||||
"conversation-thread.reply-editor.placeholder.send": {
|
||||
"message": "Gửi một tin nhắn..."
|
||||
},
|
||||
"create-project-version.create-modal.stage.add-files.admonition": {
|
||||
"message": "Các tệp bổ trợ dùng cho các tài nguyên hỗ trợ như mã nguồn, không dùng cho các phiên bản hoặc biến thể thay thế."
|
||||
},
|
||||
@@ -768,7 +843,7 @@
|
||||
"message": "Mạng"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.nearing-threshold": {
|
||||
"message": "Bạn sắp chạm đến ngưỡng rút tiền. Bạn có thể rút <b>{amountRemaining}<b> ngay bây giờ, nhưng cần có biểu mẫu thuế để rút thêm."
|
||||
"message": "Bạn sắp chạm đến ngưỡng rút tiền. Bạn có thể rút <b>{amountRemaining}</b> ngay bây giờ, nhưng cần có biểu mẫu thuế để rút thêm."
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.paypal-details.account": {
|
||||
"message": "Tài khoản"
|
||||
@@ -1199,9 +1274,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "Những máy chủ Modrinth Hosting được nằm ở đâu? Tôi có thể chọn khu vực không?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "Chúng tôi có các máy chủ có sẵn ở Bắc Mỹ, Châu Âu và Đông Nam Á tại thời điểm này mà bạn có thể chọn khi mua. Nhiều khu vực sẽ đến trong tương lai! Nếu bạn muốn chuyển đổi khu vực của mình, vui lòng liên hệ với bộ phận hỗ trợ."
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "Những phiên bản Minecraft và modloader nào có thể được sử dụng?"
|
||||
},
|
||||
|
||||
@@ -1880,9 +1880,6 @@
|
||||
"layout.banner.build-fail.title": {
|
||||
"message": "构建期间从 API 生成状态时发生错误。"
|
||||
},
|
||||
"layout.banner.preview.description": {
|
||||
"message": "如果你是想前往 Modrinth 的官方网站,请前往 {url}。这个预览部署版本仅供 Modrinth 工作人员测试使用。它是基于 <branch-link>{owner}/{branch}</branch-link> @ {commit} 构建而成。"
|
||||
},
|
||||
"layout.banner.preview.title": {
|
||||
"message": "这是 Modrinth 网站的预览部署版本。"
|
||||
},
|
||||
|
||||
@@ -1391,9 +1391,6 @@
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "Modrinth Hosting 伺服器位於哪裡?我可以選擇區域嗎?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "我們目前在北美、歐洲與東南亞設有伺服器,你可以在購買時選擇。未來還會推出更多區域!如果你想切換區域,請聯絡支援團隊。"
|
||||
},
|
||||
"hosting-marketing.faq.versions-loaders": {
|
||||
"message": "可以使用哪些 Minecraft 版本與載入器?"
|
||||
},
|
||||
@@ -3056,6 +3053,9 @@
|
||||
"search.filter.locked.server.sync": {
|
||||
"message": "與伺服器同步"
|
||||
},
|
||||
"servers.manage.content.title": {
|
||||
"message": "內容 - {serverName} - Modrinth"
|
||||
},
|
||||
"servers.notice.actions": {
|
||||
"message": "動作"
|
||||
},
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-else class="input-group mt-2">
|
||||
<ButtonStyled v-if="primaryFile && !currentMember" color="brand">
|
||||
<ButtonStyled v-if="primaryFile?.url && !currentMember" color="brand">
|
||||
<a
|
||||
v-tooltip="primaryFile.filename + ' (' + formatBytes(primaryFile.size) + ')'"
|
||||
:href="decoratedPrimaryFileUrl"
|
||||
|
||||
@@ -515,6 +515,16 @@ function goToPage(page: number) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
function notifySkippedProjects(skippedCount: number) {
|
||||
if (skippedCount <= 0) return
|
||||
addNotification({
|
||||
title: 'Skipped projects',
|
||||
text: `Skipped ${skippedCount} project(s) already moderated or locked by others.`,
|
||||
type: 'info',
|
||||
autoCloseMs: 2000,
|
||||
})
|
||||
}
|
||||
|
||||
async function findFirstEligibleProject(): Promise<ModerationProject | null> {
|
||||
let skippedCount = 0
|
||||
|
||||
@@ -539,13 +549,7 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
|
||||
const lockStatus = await moderationQueue.checkLock(currentId)
|
||||
|
||||
if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) {
|
||||
if (skippedCount > 0) {
|
||||
addNotification({
|
||||
title: 'Skipped projects',
|
||||
text: `Skipped ${skippedCount} project(s) already moderated or locked by others.`,
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
notifySkippedProjects(skippedCount)
|
||||
return project
|
||||
}
|
||||
|
||||
@@ -556,13 +560,7 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
|
||||
}
|
||||
}
|
||||
|
||||
if (skippedCount > 0) {
|
||||
addNotification({
|
||||
title: 'Skipped projects',
|
||||
text: `Skipped ${skippedCount} project(s) already moderated or locked by others.`,
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
notifySkippedProjects(skippedCount)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id\n FROM versions\n WHERE mod_id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "12fa322e09465aab925ac33f8b2a1371fb63be744f1d87c68229342ffadbe998"
|
||||
}
|
||||
Generated
-37
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n WIDTH_BUCKET(\n EXTRACT(EPOCH FROM created)::bigint,\n EXTRACT(EPOCH FROM $1::timestamp with time zone AT TIME ZONE 'UTC')::bigint,\n EXTRACT(EPOCH FROM $2::timestamp with time zone AT TIME ZONE 'UTC')::bigint,\n $3::integer\n ) AS bucket,\n mod_id,\n SUM(amount) amount_sum\n FROM payouts_values\n WHERE\n -- only project revenue is counted here\n -- for affiliate code revenue, see `affiliate_code_revenue`\n payouts_values.mod_id IS NOT NULL\n AND payouts_values.mod_id = ANY($4)\n AND created BETWEEN $1 AND $2\n GROUP BY bucket, mod_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "bucket",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "mod_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "amount_sum",
|
||||
"type_info": "Numeric"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Int4",
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8d38218e5a0c9297be7c6c77acf40a2339b12ff15f1f9e53a27a1c599a33e43b"
|
||||
}
|
||||
Generated
-38
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n WIDTH_BUCKET(\n EXTRACT(EPOCH FROM usa.created_at)::bigint,\n EXTRACT(EPOCH FROM $1::timestamp with time zone AT TIME ZONE 'UTC')::bigint,\n EXTRACT(EPOCH FROM $2::timestamp with time zone AT TIME ZONE 'UTC')::bigint,\n $3::integer\n ) AS bucket,\n CASE WHEN $5 THEN affiliate_code ELSE 0 END AS affiliate_code,\n COUNT(*) AS conversions\n FROM users_subscriptions_affiliations usa\n INNER JOIN affiliate_codes ac ON ac.id = usa.affiliate_code\n INNER JOIN users_subscriptions us ON us.id = usa.subscription_id\n INNER JOIN charges c ON c.subscription_id = us.id\n WHERE\n ac.affiliate = $4\n AND usa.created_at BETWEEN $1 AND $2\n AND c.status = 'succeeded'\n GROUP BY bucket, affiliate_code",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "bucket",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "affiliate_code",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "conversions",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Int4",
|
||||
"Int8",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9152c0d7e7f508491b601c16c6eed05e2333475e96007180acda6086ee2825c0"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT m.id\n FROM mods m\n WHERE m.organization_id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9cabb8fd373e6ebf76e6d6a6711e83b71b766d64e05cecbfab58194eff89ec08"
|
||||
}
|
||||
Generated
-38
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n WIDTH_BUCKET(\n EXTRACT(EPOCH FROM created)::bigint,\n EXTRACT(EPOCH FROM $1::timestamp with time zone AT TIME ZONE 'UTC')::bigint,\n EXTRACT(EPOCH FROM $2::timestamp with time zone AT TIME ZONE 'UTC')::bigint,\n $3::integer\n ) AS bucket,\n CASE WHEN $5 THEN affiliate_code_source ELSE 0 END AS affiliate_code_source,\n SUM(amount) amount_sum\n FROM payouts_values\n WHERE\n user_id = $4\n AND payouts_values.affiliate_code_source IS NOT NULL\n AND created BETWEEN $1 AND $2\n GROUP BY bucket, affiliate_code_source",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "bucket",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "affiliate_code_source",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "amount_sum",
|
||||
"type_info": "Numeric"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Int4",
|
||||
"Int8",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "eeea6cad39d645d3f5a0a4115c8350e08b7850a09a86c62d0de371a1caed7c07"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use actix_web::{HttpRequest, post, web};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{DownloadSource, GetRequest, normalize_download_source};
|
||||
use crate::{
|
||||
auth::get_user_from_headers,
|
||||
database::{
|
||||
PgPool,
|
||||
models::{DBProjectId, DBUser, DBVersionId},
|
||||
redis::RedisPool,
|
||||
},
|
||||
models::{ids::VersionId, pats::Scopes, v3::analytics::DownloadReason},
|
||||
queue::session::AuthQueue,
|
||||
routes::ApiError,
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(fetch_facets);
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct FacetsResponse {
|
||||
pub facets: AnalyticsFacets,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
|
||||
pub struct AnalyticsFacets {
|
||||
pub project_views: ProjectViewsFacets,
|
||||
pub project_downloads: ProjectDownloadsFacets,
|
||||
pub project_playtime: ProjectPlaytimeFacets,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
|
||||
pub struct ProjectViewsFacets {
|
||||
pub domain: Vec<String>,
|
||||
pub site_path: Vec<String>,
|
||||
pub monetized: Vec<bool>,
|
||||
pub country: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
|
||||
pub struct ProjectDownloadsFacets {
|
||||
pub domain: Vec<String>,
|
||||
pub user_agent: Vec<DownloadSource>,
|
||||
pub version_id: Vec<VersionId>,
|
||||
pub monetized: Vec<bool>,
|
||||
pub country: Vec<String>,
|
||||
pub reason: Vec<DownloadReason>,
|
||||
pub game_version: Vec<String>,
|
||||
pub loader: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
|
||||
pub struct ProjectPlaytimeFacets {
|
||||
pub version_id: Vec<VersionId>,
|
||||
pub loader: Vec<String>,
|
||||
pub game_version: Vec<String>,
|
||||
pub country: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, clickhouse::Row, serde::Deserialize)]
|
||||
struct StringFacetRow {
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, clickhouse::Row, serde::Deserialize)]
|
||||
struct VersionFacetRow {
|
||||
value: DBVersionId,
|
||||
}
|
||||
|
||||
#[derive(Debug, clickhouse::Row, serde::Deserialize)]
|
||||
struct BoolFacetRow {
|
||||
value: bool,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
responses((status = OK, body = inline(FacetsResponse))),
|
||||
)]
|
||||
#[post("/facets")]
|
||||
pub async fn fetch_facets(
|
||||
http_req: HttpRequest,
|
||||
req: web::Json<GetRequest>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
clickhouse: web::Data<clickhouse::Client>,
|
||||
) -> Result<web::Json<FacetsResponse>, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&http_req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::ANALYTICS,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let project_ids = if req.project_ids.is_empty() {
|
||||
DBUser::get_projects(user.id.into(), &**pool, &redis).await?
|
||||
} else {
|
||||
req.project_ids
|
||||
.iter()
|
||||
.map(|id| DBProjectId::from(*id))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let project_ids =
|
||||
super::filter_allowed_project_ids(&project_ids, &user, &pool, &redis)
|
||||
.await?;
|
||||
|
||||
let parent_version_ids =
|
||||
fetch_project_version_ids(&project_ids, &pool).await?;
|
||||
|
||||
Ok(web::Json(FacetsResponse {
|
||||
facets: AnalyticsFacets {
|
||||
project_views: fetch_project_views_facets(
|
||||
&clickhouse,
|
||||
&project_ids,
|
||||
)
|
||||
.await?,
|
||||
project_downloads: fetch_project_downloads_facets(
|
||||
&clickhouse,
|
||||
&project_ids,
|
||||
)
|
||||
.await?,
|
||||
project_playtime: fetch_project_playtime_facets(
|
||||
&clickhouse,
|
||||
&project_ids,
|
||||
&parent_version_ids,
|
||||
)
|
||||
.await?,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async fn fetch_project_version_ids(
|
||||
project_ids: &[DBProjectId],
|
||||
pool: &PgPool,
|
||||
) -> Result<Vec<DBVersionId>, ApiError> {
|
||||
let project_id_values =
|
||||
project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
|
||||
Ok(sqlx::query!(
|
||||
"
|
||||
SELECT id
|
||||
FROM versions
|
||||
WHERE mod_id = ANY($1)
|
||||
",
|
||||
&project_id_values,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| DBVersionId(row.id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn fetch_project_views_facets(
|
||||
clickhouse: &clickhouse::Client,
|
||||
project_ids: &[DBProjectId],
|
||||
) -> Result<ProjectViewsFacets, ApiError> {
|
||||
Ok(ProjectViewsFacets {
|
||||
domain: fetch_string_facet(
|
||||
clickhouse,
|
||||
"SELECT DISTINCT domain AS value FROM views WHERE project_id IN {project_ids: Array(UInt64)} AND domain != '' ORDER BY value",
|
||||
project_ids,
|
||||
)
|
||||
.await?,
|
||||
site_path: fetch_string_facet(
|
||||
clickhouse,
|
||||
"SELECT DISTINCT site_path AS value FROM views WHERE project_id IN {project_ids: Array(UInt64)} AND site_path != '' ORDER BY value",
|
||||
project_ids,
|
||||
)
|
||||
.await?,
|
||||
monetized: fetch_bool_facet(
|
||||
clickhouse,
|
||||
"SELECT DISTINCT monetized AS value FROM views WHERE project_id IN {project_ids: Array(UInt64)} ORDER BY value",
|
||||
project_ids,
|
||||
)
|
||||
.await?,
|
||||
country: fetch_string_facet(
|
||||
clickhouse,
|
||||
"SELECT DISTINCT country AS value FROM views WHERE project_id IN {project_ids: Array(UInt64)} AND country != '' ORDER BY value",
|
||||
project_ids,
|
||||
)
|
||||
.await?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_project_downloads_facets(
|
||||
clickhouse: &clickhouse::Client,
|
||||
project_ids: &[DBProjectId],
|
||||
) -> Result<ProjectDownloadsFacets, ApiError> {
|
||||
let user_agents = fetch_string_facet(
|
||||
clickhouse,
|
||||
"SELECT DISTINCT user_agent AS value FROM downloads WHERE project_id IN {project_ids: Array(UInt64)} AND user_agent != ''",
|
||||
project_ids,
|
||||
)
|
||||
.await?;
|
||||
let user_agent = normalize_download_source_facets(&user_agents);
|
||||
|
||||
Ok(ProjectDownloadsFacets {
|
||||
domain: fetch_string_facet(
|
||||
clickhouse,
|
||||
"SELECT DISTINCT domain AS value FROM downloads WHERE project_id IN {project_ids: Array(UInt64)} AND domain != '' ORDER BY value",
|
||||
project_ids,
|
||||
)
|
||||
.await?,
|
||||
user_agent,
|
||||
version_id: fetch_version_facet(
|
||||
clickhouse,
|
||||
"SELECT DISTINCT version_id AS value FROM downloads WHERE project_id IN {project_ids: Array(UInt64)} AND version_id != 0 ORDER BY value",
|
||||
project_ids,
|
||||
)
|
||||
.await?,
|
||||
monetized: fetch_bool_facet(
|
||||
clickhouse,
|
||||
"SELECT DISTINCT user_id != 0 AS value FROM downloads WHERE project_id IN {project_ids: Array(UInt64)} ORDER BY value",
|
||||
project_ids,
|
||||
)
|
||||
.await?,
|
||||
country: fetch_string_facet(
|
||||
clickhouse,
|
||||
"SELECT DISTINCT country AS value FROM downloads WHERE project_id IN {project_ids: Array(UInt64)} AND country != '' ORDER BY value",
|
||||
project_ids,
|
||||
)
|
||||
.await?,
|
||||
reason: fetch_string_facet(
|
||||
clickhouse,
|
||||
"SELECT DISTINCT reason AS value FROM downloads WHERE project_id IN {project_ids: Array(UInt64)} AND reason != '' ORDER BY value",
|
||||
project_ids,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|reason| reason.parse().ok())
|
||||
.collect(),
|
||||
game_version: fetch_string_facet(
|
||||
clickhouse,
|
||||
"SELECT DISTINCT game_version AS value FROM downloads WHERE project_id IN {project_ids: Array(UInt64)} AND game_version != '' ORDER BY value",
|
||||
project_ids,
|
||||
)
|
||||
.await?,
|
||||
loader: fetch_string_facet(
|
||||
clickhouse,
|
||||
"SELECT DISTINCT loader AS value FROM downloads WHERE project_id IN {project_ids: Array(UInt64)} AND loader != '' ORDER BY value",
|
||||
project_ids,
|
||||
)
|
||||
.await?,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_download_source_facets(
|
||||
user_agents: &[String],
|
||||
) -> Vec<DownloadSource> {
|
||||
user_agents
|
||||
.iter()
|
||||
.filter_map(|user_agent| normalize_download_source(user_agent))
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn fetch_project_playtime_facets(
|
||||
clickhouse: &clickhouse::Client,
|
||||
project_ids: &[DBProjectId],
|
||||
parent_version_ids: &[DBVersionId],
|
||||
) -> Result<ProjectPlaytimeFacets, ApiError> {
|
||||
Ok(ProjectPlaytimeFacets {
|
||||
version_id: fetch_playtime_version_facet(
|
||||
clickhouse,
|
||||
project_ids,
|
||||
parent_version_ids,
|
||||
)
|
||||
.await?,
|
||||
loader: fetch_playtime_string_facet(
|
||||
clickhouse,
|
||||
"loader",
|
||||
project_ids,
|
||||
parent_version_ids,
|
||||
)
|
||||
.await?,
|
||||
game_version: fetch_playtime_string_facet(
|
||||
clickhouse,
|
||||
"game_version",
|
||||
project_ids,
|
||||
parent_version_ids,
|
||||
)
|
||||
.await?,
|
||||
country: fetch_playtime_string_facet(
|
||||
clickhouse,
|
||||
"country",
|
||||
project_ids,
|
||||
parent_version_ids,
|
||||
)
|
||||
.await?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_string_facet(
|
||||
clickhouse: &clickhouse::Client,
|
||||
query: &str,
|
||||
project_ids: &[DBProjectId],
|
||||
) -> Result<Vec<String>, ApiError> {
|
||||
let mut rows = clickhouse
|
||||
.query(query)
|
||||
.param("project_ids", project_ids)
|
||||
.fetch::<StringFacetRow>()?;
|
||||
let mut values = Vec::new();
|
||||
while let Some(row) = rows.next().await? {
|
||||
values.push(row.value);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
async fn fetch_version_facet(
|
||||
clickhouse: &clickhouse::Client,
|
||||
query: &str,
|
||||
project_ids: &[DBProjectId],
|
||||
) -> Result<Vec<VersionId>, ApiError> {
|
||||
let mut rows = clickhouse
|
||||
.query(query)
|
||||
.param("project_ids", project_ids)
|
||||
.fetch::<VersionFacetRow>()?;
|
||||
let mut values = Vec::new();
|
||||
while let Some(row) = rows.next().await? {
|
||||
values.push(row.value.into());
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
async fn fetch_bool_facet(
|
||||
clickhouse: &clickhouse::Client,
|
||||
query: &str,
|
||||
project_ids: &[DBProjectId],
|
||||
) -> Result<Vec<bool>, ApiError> {
|
||||
let mut rows = clickhouse
|
||||
.query(query)
|
||||
.param("project_ids", project_ids)
|
||||
.fetch::<BoolFacetRow>()?;
|
||||
let mut values = Vec::new();
|
||||
while let Some(row) = rows.next().await? {
|
||||
values.push(row.value);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
async fn fetch_playtime_string_facet(
|
||||
clickhouse: &clickhouse::Client,
|
||||
column: &str,
|
||||
project_ids: &[DBProjectId],
|
||||
parent_version_ids: &[DBVersionId],
|
||||
) -> Result<Vec<String>, ApiError> {
|
||||
let query = format!(
|
||||
"SELECT DISTINCT {column} AS value
|
||||
FROM playtime
|
||||
WHERE (project_id IN {{project_ids: Array(UInt64)}} OR parent IN {{parent_version_ids: Array(UInt64)}})
|
||||
AND {column} != ''
|
||||
ORDER BY value"
|
||||
);
|
||||
let mut rows = clickhouse
|
||||
.query(&query)
|
||||
.param("project_ids", project_ids)
|
||||
.param("parent_version_ids", parent_version_ids)
|
||||
.fetch::<StringFacetRow>()?;
|
||||
let mut values = Vec::new();
|
||||
while let Some(row) = rows.next().await? {
|
||||
values.push(row.value);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
async fn fetch_playtime_version_facet(
|
||||
clickhouse: &clickhouse::Client,
|
||||
project_ids: &[DBProjectId],
|
||||
parent_version_ids: &[DBVersionId],
|
||||
) -> Result<Vec<VersionId>, ApiError> {
|
||||
let mut rows = clickhouse
|
||||
.query(
|
||||
"SELECT DISTINCT version_id AS value
|
||||
FROM playtime
|
||||
WHERE (project_id IN {project_ids: Array(UInt64)} OR parent IN {parent_version_ids: Array(UInt64)})
|
||||
AND version_id != 0
|
||||
ORDER BY value",
|
||||
)
|
||||
.param("project_ids", project_ids)
|
||||
.param("parent_version_ids", parent_version_ids)
|
||||
.fetch::<VersionFacetRow>()?;
|
||||
let mut values = Vec::new();
|
||||
while let Some(row) = rows.next().await? {
|
||||
values.push(row.value.into());
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn user_agent_facets_use_normalized_sources() {
|
||||
let user_agents = vec![
|
||||
"MultiMC/5.0".to_string(),
|
||||
"MultiMC/6.0".to_string(),
|
||||
"PrismLauncher/6.1".to_string(),
|
||||
"curl/8.7.1".to_string(),
|
||||
"Mozilla/5.0 AppleWebKit/537.36".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
normalize_download_source_facets(&user_agents),
|
||||
vec![
|
||||
DownloadSource::Named("MultiMC".into()),
|
||||
DownloadSource::Named("Prism Launcher".into()),
|
||||
DownloadSource::Website,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use const_format::formatcp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
database::models::DBAffiliateCodeId, models::ids::AffiliateCodeId,
|
||||
routes::ApiError,
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
ClickhouseFilterParam, ClickhouseQueryParams, QueryClickhouseContext,
|
||||
query_clickhouse,
|
||||
};
|
||||
use super::{
|
||||
AffiliateCodeAnalytics, AffiliateCodeMetrics, AnalyticsData, Metrics,
|
||||
};
|
||||
|
||||
const TIME_RANGE_START: &str = "{time_range_start: UInt64}";
|
||||
const TIME_RANGE_END: &str = "{time_range_end: UInt64}";
|
||||
const TIME_SLICES: &str = "{time_slices: UInt64}";
|
||||
|
||||
/// Fields for [`super::ReturnMetrics::affiliate_code_clicks`].
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AffiliateCodeClicksField {
|
||||
/// Affiliate code ID.
|
||||
AffiliateCodeId,
|
||||
}
|
||||
|
||||
/// Filters for [`super::ReturnMetrics::affiliate_code_clicks`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AffiliateCodeClicksFilters {
|
||||
/// Affiliate code IDs to include.
|
||||
#[serde(default)]
|
||||
pub affiliate_code_id: Vec<AffiliateCodeId>,
|
||||
}
|
||||
|
||||
/// [`super::ReturnMetrics::affiliate_code_clicks`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AffiliateCodeClicks {
|
||||
/// Total clicks for this bucket.
|
||||
pub clicks: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, clickhouse::Row, serde::Deserialize)]
|
||||
struct AffiliateCodeClickRow {
|
||||
bucket: u64,
|
||||
affiliate_code_id: DBAffiliateCodeId,
|
||||
clicks: u64,
|
||||
}
|
||||
|
||||
const AFFILIATE_CODE_CLICKS: &str = {
|
||||
const USE_AFFILIATE_CODE_ID: &str = "{use_affiliate_code_id: Bool}";
|
||||
const FILTER_AFFILIATE_CODE_ID: &str =
|
||||
"{filter_affiliate_code_id: Array(UInt64)}";
|
||||
|
||||
formatcp!(
|
||||
"SELECT
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
if({USE_AFFILIATE_CODE_ID}, affiliate_code_id, 0) AS affiliate_code_id,
|
||||
COUNT(*) AS clicks
|
||||
FROM affiliate_code_clicks
|
||||
WHERE
|
||||
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
|
||||
-- make sure that the REAL affiliate code id is included,
|
||||
-- not the possibly-zero one,
|
||||
-- by using `affiliate_code_clicks.affiliate_code_id` instead of `project_id`
|
||||
AND (empty({FILTER_AFFILIATE_CODE_ID}) OR affiliate_code_id IN {FILTER_AFFILIATE_CODE_ID})
|
||||
GROUP BY bucket, affiliate_code_id"
|
||||
)
|
||||
};
|
||||
|
||||
pub(crate) async fn fetch(
|
||||
cx: &mut QueryClickhouseContext<'_>,
|
||||
metrics: &Metrics<AffiliateCodeClicksField, AffiliateCodeClicksFilters>,
|
||||
) -> Result<(), ApiError> {
|
||||
use AffiliateCodeClicksField as F;
|
||||
let uses = |field| metrics.bucket_by.contains(&field);
|
||||
|
||||
query_clickhouse::<AffiliateCodeClickRow>(
|
||||
cx,
|
||||
AFFILIATE_CODE_CLICKS,
|
||||
ClickhouseQueryParams::empty(),
|
||||
&[("use_affiliate_code_id", uses(F::AffiliateCodeId))],
|
||||
vec![ClickhouseFilterParam::AffiliateCodeId(
|
||||
"filter_affiliate_code_id",
|
||||
&metrics.filter_by.affiliate_code_id,
|
||||
)],
|
||||
|_| true,
|
||||
|row| row.bucket,
|
||||
|row| {
|
||||
AnalyticsData::AffiliateCode(AffiliateCodeAnalytics {
|
||||
source_affiliate_code: row.affiliate_code_id.into(),
|
||||
metrics: AffiliateCodeMetrics::Clicks(AffiliateCodeClicks {
|
||||
clicks: row.clicks,
|
||||
}),
|
||||
})
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::{
|
||||
database::{
|
||||
PgPool,
|
||||
models::{DBAffiliateCodeId, DBUserId},
|
||||
},
|
||||
models::ids::AffiliateCodeId,
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
use super::super::{TimeSlice, add_to_time_slice};
|
||||
use super::{
|
||||
AffiliateCodeAnalytics, AffiliateCodeMetrics, AnalyticsData, Metrics,
|
||||
};
|
||||
|
||||
/// Fields for [`super::ReturnMetrics::affiliate_code_conversions`].
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AffiliateCodeConversionsField {
|
||||
/// Affiliate code ID.
|
||||
AffiliateCodeId,
|
||||
}
|
||||
|
||||
/// Filters for [`super::ReturnMetrics::affiliate_code_conversions`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AffiliateCodeConversionsFilters {
|
||||
/// Affiliate code IDs to include.
|
||||
#[serde(default)]
|
||||
pub affiliate_code_id: Vec<AffiliateCodeId>,
|
||||
}
|
||||
|
||||
/// [`super::ReturnMetrics::affiliate_code_conversions`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AffiliateCodeConversions {
|
||||
/// Total conversions for this bucket.
|
||||
pub conversions: u64,
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch(
|
||||
pool: &PgPool,
|
||||
time_slices: &mut [TimeSlice],
|
||||
req: &super::super::GetRequest,
|
||||
user_id: DBUserId,
|
||||
num_time_slices: usize,
|
||||
metrics: &Metrics<
|
||||
AffiliateCodeConversionsField,
|
||||
AffiliateCodeConversionsFilters,
|
||||
>,
|
||||
) -> Result<(), ApiError> {
|
||||
let filter_affiliate_code_ids = metrics
|
||||
.filter_by
|
||||
.affiliate_code_id
|
||||
.iter()
|
||||
.map(|id| DBAffiliateCodeId::from(*id).0)
|
||||
.collect::<Vec<_>>();
|
||||
let mut rows = sqlx::query(
|
||||
"SELECT
|
||||
WIDTH_BUCKET(
|
||||
EXTRACT(EPOCH FROM usa.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM $1::timestamp with time zone AT TIME ZONE 'UTC')::bigint,
|
||||
EXTRACT(EPOCH FROM $2::timestamp with time zone AT TIME ZONE 'UTC')::bigint,
|
||||
$3::integer
|
||||
) AS bucket,
|
||||
CASE WHEN $5 THEN affiliate_code ELSE 0 END AS affiliate_code,
|
||||
COUNT(*) AS conversions
|
||||
FROM users_subscriptions_affiliations usa
|
||||
INNER JOIN affiliate_codes ac ON ac.id = usa.affiliate_code
|
||||
INNER JOIN users_subscriptions us ON us.id = usa.subscription_id
|
||||
INNER JOIN charges c ON c.subscription_id = us.id
|
||||
WHERE
|
||||
ac.affiliate = $4
|
||||
AND usa.created_at BETWEEN $1 AND $2
|
||||
AND c.status = 'succeeded'
|
||||
AND (cardinality($6::bigint[]) = 0 OR affiliate_code = ANY($6))
|
||||
GROUP BY bucket, affiliate_code",
|
||||
)
|
||||
.bind(req.time_range.start)
|
||||
.bind(req.time_range.end)
|
||||
.bind(num_time_slices as i64)
|
||||
.bind(user_id as DBUserId)
|
||||
.bind(
|
||||
metrics
|
||||
.bucket_by
|
||||
.contains(&AffiliateCodeConversionsField::AffiliateCodeId),
|
||||
)
|
||||
.bind(&filter_affiliate_code_ids)
|
||||
.fetch(pool);
|
||||
while let Some(row) = rows.next().await.transpose()? {
|
||||
let bucket = row
|
||||
.try_get::<Option<i32>, _>("bucket")?
|
||||
.wrap_internal_err("bucket should be non-null - query bug!")?;
|
||||
let bucket = usize::try_from(bucket).wrap_internal_err_with(|| {
|
||||
eyre::eyre!(
|
||||
"bucket value {bucket} does not fit into `usize` - query bug!"
|
||||
)
|
||||
})?;
|
||||
|
||||
let affiliate_code = row.try_get::<Option<i64>, _>("affiliate_code")?;
|
||||
let conversion_count = row.try_get::<Option<i64>, _>("conversions")?;
|
||||
let source_affiliate_code = AffiliateCodeId::from(DBAffiliateCodeId(
|
||||
affiliate_code.unwrap_or_default(),
|
||||
));
|
||||
let conversions = u64::try_from(conversion_count.unwrap_or_default())
|
||||
.unwrap_or(u64::MAX);
|
||||
|
||||
add_to_time_slice(
|
||||
time_slices,
|
||||
bucket,
|
||||
AnalyticsData::AffiliateCode(AffiliateCodeAnalytics {
|
||||
source_affiliate_code,
|
||||
metrics: AffiliateCodeMetrics::Conversions(
|
||||
AffiliateCodeConversions { conversions },
|
||||
),
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use futures::StreamExt;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::{
|
||||
database::{
|
||||
PgPool,
|
||||
models::{DBAffiliateCodeId, DBUserId},
|
||||
},
|
||||
models::ids::AffiliateCodeId,
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
use super::super::{TimeSlice, add_to_time_slice};
|
||||
use super::{
|
||||
AffiliateCodeAnalytics, AffiliateCodeMetrics, AnalyticsData, Metrics,
|
||||
};
|
||||
|
||||
/// Fields for [`super::ReturnMetrics::affiliate_code_revenue`].
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AffiliateCodeRevenueField {
|
||||
/// Affiliate code ID.
|
||||
AffiliateCodeId,
|
||||
}
|
||||
|
||||
/// Filters for [`super::ReturnMetrics::affiliate_code_revenue`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AffiliateCodeRevenueFilters {
|
||||
/// Affiliate code IDs to include.
|
||||
#[serde(default)]
|
||||
pub affiliate_code_id: Vec<AffiliateCodeId>,
|
||||
}
|
||||
|
||||
/// [`super::ReturnMetrics::affiliate_code_revenue`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AffiliateCodeRevenue {
|
||||
/// Total revenue for this bucket.
|
||||
pub revenue: Decimal,
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch(
|
||||
pool: &PgPool,
|
||||
time_slices: &mut [TimeSlice],
|
||||
req: &super::super::GetRequest,
|
||||
user_id: DBUserId,
|
||||
num_time_slices: usize,
|
||||
metrics: &Metrics<AffiliateCodeRevenueField, AffiliateCodeRevenueFilters>,
|
||||
) -> Result<(), ApiError> {
|
||||
let filter_affiliate_code_ids = metrics
|
||||
.filter_by
|
||||
.affiliate_code_id
|
||||
.iter()
|
||||
.map(|id| DBAffiliateCodeId::from(*id).0)
|
||||
.collect::<Vec<_>>();
|
||||
let mut rows = sqlx::query(
|
||||
"SELECT
|
||||
WIDTH_BUCKET(
|
||||
EXTRACT(EPOCH FROM created)::bigint,
|
||||
EXTRACT(EPOCH FROM $1::timestamp with time zone AT TIME ZONE 'UTC')::bigint,
|
||||
EXTRACT(EPOCH FROM $2::timestamp with time zone AT TIME ZONE 'UTC')::bigint,
|
||||
$3::integer
|
||||
) AS bucket,
|
||||
CASE WHEN $5 THEN affiliate_code_source ELSE 0 END AS affiliate_code_source,
|
||||
SUM(amount) amount_sum
|
||||
FROM payouts_values
|
||||
WHERE
|
||||
user_id = $4
|
||||
AND payouts_values.affiliate_code_source IS NOT NULL
|
||||
AND created BETWEEN $1 AND $2
|
||||
AND (cardinality($6::bigint[]) = 0 OR affiliate_code_source = ANY($6))
|
||||
GROUP BY bucket, affiliate_code_source",
|
||||
)
|
||||
.bind(req.time_range.start)
|
||||
.bind(req.time_range.end)
|
||||
.bind(num_time_slices as i64)
|
||||
.bind(user_id as DBUserId)
|
||||
.bind(
|
||||
metrics
|
||||
.bucket_by
|
||||
.contains(&AffiliateCodeRevenueField::AffiliateCodeId),
|
||||
)
|
||||
.bind(&filter_affiliate_code_ids)
|
||||
.fetch(pool);
|
||||
while let Some(row) = rows.next().await.transpose()? {
|
||||
let bucket = row
|
||||
.try_get::<Option<i32>, _>("bucket")?
|
||||
.wrap_internal_err("bucket should be non-null - query bug!")?;
|
||||
let bucket = usize::try_from(bucket).wrap_internal_err_with(|| {
|
||||
eyre::eyre!(
|
||||
"bucket value {bucket} does not fit into `usize` - query bug!"
|
||||
)
|
||||
})?;
|
||||
|
||||
let affiliate_code_source =
|
||||
row.try_get::<Option<i64>, _>("affiliate_code_source")?;
|
||||
let source_affiliate_code = AffiliateCodeId::from(DBAffiliateCodeId(
|
||||
affiliate_code_source.unwrap_or_default(),
|
||||
));
|
||||
let revenue = row
|
||||
.try_get::<Option<Decimal>, _>("amount_sum")?
|
||||
.unwrap_or_default();
|
||||
|
||||
add_to_time_slice(
|
||||
time_slices,
|
||||
bucket,
|
||||
AnalyticsData::AffiliateCode(AffiliateCodeAnalytics {
|
||||
source_affiliate_code,
|
||||
metrics: AffiliateCodeMetrics::Revenue(AffiliateCodeRevenue {
|
||||
revenue,
|
||||
}),
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
mod affiliate_code_clicks;
|
||||
mod affiliate_code_conversions;
|
||||
mod affiliate_code_revenue;
|
||||
mod project_downloads;
|
||||
mod project_playtime;
|
||||
mod project_revenue;
|
||||
mod project_views;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::ids::{AffiliateCodeId, ProjectId};
|
||||
|
||||
pub(crate) use affiliate_code_clicks::fetch as fetch_affiliate_code_clicks;
|
||||
pub use affiliate_code_clicks::{
|
||||
AffiliateCodeClicks, AffiliateCodeClicksField, AffiliateCodeClicksFilters,
|
||||
};
|
||||
pub(crate) use affiliate_code_conversions::fetch as fetch_affiliate_code_conversions;
|
||||
pub use affiliate_code_conversions::{
|
||||
AffiliateCodeConversions, AffiliateCodeConversionsField,
|
||||
AffiliateCodeConversionsFilters,
|
||||
};
|
||||
pub(crate) use affiliate_code_revenue::fetch as fetch_affiliate_code_revenue;
|
||||
pub use affiliate_code_revenue::{
|
||||
AffiliateCodeRevenue, AffiliateCodeRevenueField,
|
||||
AffiliateCodeRevenueFilters,
|
||||
};
|
||||
pub use project_downloads::{
|
||||
DownloadSource, ProjectDownloads, ProjectDownloadsField,
|
||||
ProjectDownloadsFilters,
|
||||
};
|
||||
pub(crate) use project_downloads::{
|
||||
fetch as fetch_project_downloads, normalize_download_source,
|
||||
};
|
||||
pub(crate) use project_playtime::fetch as fetch_project_playtime;
|
||||
pub use project_playtime::{
|
||||
ProjectPlaytime, ProjectPlaytimeField, ProjectPlaytimeFilters,
|
||||
};
|
||||
pub(crate) use project_revenue::fetch as fetch_project_revenue;
|
||||
pub use project_revenue::{
|
||||
ProjectRevenue, ProjectRevenueField, ProjectRevenueFilters,
|
||||
};
|
||||
pub(crate) use project_views::fetch as fetch_project_views;
|
||||
pub use project_views::{ProjectViews, ProjectViewsField, ProjectViewsFilters};
|
||||
|
||||
/// What metrics the caller would like to receive from this analytics get
|
||||
/// request.
|
||||
#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ReturnMetrics {
|
||||
/// How many times a project page has been viewed.
|
||||
pub project_views: Option<Metrics<ProjectViewsField, ProjectViewsFilters>>,
|
||||
/// How many times a project has been downloaded.
|
||||
pub project_downloads:
|
||||
Option<Metrics<ProjectDownloadsField, ProjectDownloadsFilters>>,
|
||||
/// How long users have been playing a project.
|
||||
pub project_playtime:
|
||||
Option<Metrics<ProjectPlaytimeField, ProjectPlaytimeFilters>>,
|
||||
/// How much payout revenue a project has generated.
|
||||
pub project_revenue:
|
||||
Option<Metrics<ProjectRevenueField, ProjectRevenueFilters>>,
|
||||
/// How many times an affiliate code has been clicked.
|
||||
pub affiliate_code_clicks:
|
||||
Option<Metrics<AffiliateCodeClicksField, AffiliateCodeClicksFilters>>,
|
||||
/// How many times a product has been purchased with an affiliate code.
|
||||
pub affiliate_code_conversions: Option<
|
||||
Metrics<AffiliateCodeConversionsField, AffiliateCodeConversionsFilters>,
|
||||
>,
|
||||
/// How much payout revenue an affiliate code has generated.
|
||||
pub affiliate_code_revenue:
|
||||
Option<Metrics<AffiliateCodeRevenueField, AffiliateCodeRevenueFilters>>,
|
||||
}
|
||||
|
||||
/// See [`ReturnMetrics`].
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct Metrics<BucketBy, FilterBy> {
|
||||
/// When collecting metrics, what fields do we want to group the results by?
|
||||
///
|
||||
/// For example, if we have two views entries:
|
||||
/// - `{ "project_id": "abcdefgh", "domain": "youtube.com", "count": 5 }`
|
||||
/// - `{ "project_id": "abcdefgh", "domain": "discord.com", "count": 3 }`
|
||||
///
|
||||
/// If we bucket by `domain`, then we will get two results:
|
||||
/// - `{ "project_id": "abcdefgh", "domain": "youtube.com", "count": 5 }`
|
||||
/// - `{ "project_id": "abcdefgh", "domain": "discord.com", "count": 3 }`
|
||||
///
|
||||
/// If we do not bucket by `domain`, we will only get one, which is an
|
||||
/// aggregate of the two rows:
|
||||
/// - `{ "project_id": "abcdefgh", "count": 8 }`
|
||||
#[serde(default = "Vec::default")]
|
||||
pub bucket_by: Vec<BucketBy>,
|
||||
/// Filters to apply before aggregating this metric.
|
||||
///
|
||||
/// Values within one field are ORed together. Different fields are AND'ed
|
||||
/// together. An empty list means that field is not filtered.
|
||||
#[serde(default)]
|
||||
pub filter_by: FilterBy,
|
||||
}
|
||||
|
||||
/// Metrics collected in a single time slice.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[serde(untagged)] // the presence of `source_project`, `source_affiliate_code` determines the kind
|
||||
pub enum AnalyticsData {
|
||||
/// Project metrics.
|
||||
Project(ProjectAnalytics),
|
||||
AffiliateCode(AffiliateCodeAnalytics),
|
||||
}
|
||||
|
||||
/// Project metrics.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProjectAnalytics {
|
||||
/// What project these metrics are for.
|
||||
pub source_project: ProjectId,
|
||||
/// Metrics collected.
|
||||
#[serde(flatten)]
|
||||
pub metrics: ProjectMetrics,
|
||||
}
|
||||
|
||||
/// Project metrics of a specific kind.
|
||||
///
|
||||
/// If a field is not included in [`Metrics::bucket_by`], it will be [`None`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "snake_case", tag = "metric_kind")]
|
||||
pub enum ProjectMetrics {
|
||||
/// [`ReturnMetrics::project_views`].
|
||||
Views(ProjectViews),
|
||||
/// [`ReturnMetrics::project_downloads`].
|
||||
Downloads(ProjectDownloads),
|
||||
/// [`ReturnMetrics::project_playtime`].
|
||||
Playtime(ProjectPlaytime),
|
||||
/// [`ReturnMetrics::project_revenue`].
|
||||
Revenue(ProjectRevenue),
|
||||
}
|
||||
|
||||
/// Affiliate code metrics.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AffiliateCodeAnalytics {
|
||||
/// What affiliate code these metrics are for.
|
||||
pub source_affiliate_code: AffiliateCodeId,
|
||||
/// Metrics collected.
|
||||
#[serde(flatten)]
|
||||
pub metrics: AffiliateCodeMetrics,
|
||||
}
|
||||
|
||||
/// Affiliate code metrics of a specific kind.
|
||||
///
|
||||
/// If a field is not included in [`Metrics::bucket_by`], it will be [`None`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "snake_case", tag = "metric_kind")]
|
||||
pub enum AffiliateCodeMetrics {
|
||||
Clicks(AffiliateCodeClicks),
|
||||
Conversions(AffiliateCodeConversions),
|
||||
Revenue(AffiliateCodeRevenue),
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
LazyLock,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use const_format::formatcp;
|
||||
use dashmap::DashMap;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
|
||||
use crate::{
|
||||
database::models::{DBProjectId, DBVersionId},
|
||||
models::{ids::VersionId, v3::analytics::DownloadReason},
|
||||
routes::ApiError,
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
ClickhouseFilterParam, QueryClickhouseContext, add_to_time_slice,
|
||||
condense_country, none_if_empty, none_if_zero_version_id,
|
||||
};
|
||||
use super::{AnalyticsData, Metrics, ProjectAnalytics, ProjectMetrics};
|
||||
|
||||
const TIME_RANGE_START: &str = "{time_range_start: UInt64}";
|
||||
const TIME_RANGE_END: &str = "{time_range_end: UInt64}";
|
||||
const TIME_SLICES: &str = "{time_slices: UInt64}";
|
||||
const PROJECT_IDS: &str = "{project_ids: Array(UInt64)}";
|
||||
|
||||
/// Fields for [`super::ReturnMetrics::project_downloads`].
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProjectDownloadsField {
|
||||
/// Project ID.
|
||||
ProjectId,
|
||||
/// Version ID of this project.
|
||||
VersionId,
|
||||
/// Referrer domain which linked to this project.
|
||||
Domain,
|
||||
/// Normalized user agent used to download this project.
|
||||
UserAgent,
|
||||
/// Whether these downloads were monetized or not.
|
||||
Monetized,
|
||||
/// What country these downloads came from.
|
||||
///
|
||||
/// To anonymize the data, the country may be reported as `XX`.
|
||||
Country,
|
||||
/// Download reason.
|
||||
Reason,
|
||||
/// Game version used for this download.
|
||||
GameVersion,
|
||||
/// Mod loader used for this download.
|
||||
Loader,
|
||||
}
|
||||
|
||||
/// Filters for [`super::ReturnMetrics::project_downloads`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProjectDownloadsFilters {
|
||||
/// Version IDs to include.
|
||||
#[serde(default)]
|
||||
pub version_id: Vec<VersionId>,
|
||||
/// Referrer domains to include.
|
||||
#[serde(default)]
|
||||
pub domain: Vec<String>,
|
||||
/// Normalized download sources to include.
|
||||
#[serde(default)]
|
||||
pub user_agent: Vec<DownloadSource>,
|
||||
/// Monetization states to include.
|
||||
#[serde(default)]
|
||||
pub monetized: Vec<bool>,
|
||||
/// Country codes to include.
|
||||
#[serde(default)]
|
||||
pub country: Vec<String>,
|
||||
/// Download reasons to include.
|
||||
#[serde(default)]
|
||||
pub reason: Vec<DownloadReason>,
|
||||
/// Game versions to include.
|
||||
#[serde(default)]
|
||||
pub game_version: Vec<String>,
|
||||
/// Loaders to include.
|
||||
#[serde(default)]
|
||||
pub loader: Vec<String>,
|
||||
}
|
||||
|
||||
/// [`super::ReturnMetrics::project_downloads`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProjectDownloads {
|
||||
/// [`ProjectDownloadsField::Domain`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) domain: Option<String>,
|
||||
/// [`ProjectDownloadsField::UserAgent`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) user_agent: Option<DownloadSource>,
|
||||
/// [`ProjectDownloadsField::VersionId`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) version_id: Option<VersionId>,
|
||||
/// [`ProjectDownloadsField::Monetized`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) monetized: Option<bool>,
|
||||
/// [`ProjectDownloadsField::Country`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) country: Option<String>,
|
||||
/// [`ProjectDownloadsField::Reason`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) reason: Option<DownloadReason>,
|
||||
/// [`ProjectDownloadsField::GameVersion`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) game_version: Option<String>,
|
||||
/// [`ProjectDownloadsField::Loader`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) loader: Option<String>,
|
||||
/// Total number of downloads for this bucket.
|
||||
pub(crate) downloads: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, utoipa::ToSchema)]
|
||||
pub enum DownloadSource {
|
||||
Website,
|
||||
ModrinthApp,
|
||||
ModrinthHosting,
|
||||
ModrinthMaven,
|
||||
Other,
|
||||
Named(String),
|
||||
}
|
||||
|
||||
impl Serialize for DownloadSource {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match self {
|
||||
Self::Named(name) => serializer.serialize_str(name),
|
||||
Self::Website => serializer.serialize_str("website"),
|
||||
Self::ModrinthApp => serializer.serialize_str("modrinth_app"),
|
||||
Self::ModrinthHosting => {
|
||||
serializer.serialize_str("modrinth_hosting")
|
||||
}
|
||||
Self::ModrinthMaven => serializer.serialize_str("modrinth_maven"),
|
||||
Self::Other => serializer.serialize_str("other"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DownloadSource {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let source = String::deserialize(deserializer)?;
|
||||
Ok(match source.as_str() {
|
||||
"website" => Self::Website,
|
||||
"modrinth_app" => Self::ModrinthApp,
|
||||
"modrinth_hosting" => Self::ModrinthHosting,
|
||||
"modrinth_maven" => Self::ModrinthMaven,
|
||||
"other" => Self::Other,
|
||||
_ if !source.is_empty() => Self::Named(source),
|
||||
_ => {
|
||||
return Err(D::Error::custom(
|
||||
"download source cannot be empty",
|
||||
));
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, clickhouse::Row, serde::Deserialize)]
|
||||
struct DownloadRow {
|
||||
bucket: u64,
|
||||
project_id: DBProjectId,
|
||||
domain: String,
|
||||
user_agent: String,
|
||||
version_id: DBVersionId,
|
||||
monetized: i8,
|
||||
country: String,
|
||||
reason: String,
|
||||
game_version: String,
|
||||
loader: String,
|
||||
downloads: u64,
|
||||
}
|
||||
|
||||
const DOWNLOADS: &str = {
|
||||
const USE_PROJECT_ID: &str = "{use_project_id: Bool}";
|
||||
const USE_DOMAIN: &str = "{use_domain: Bool}";
|
||||
const USE_USER_AGENT: &str = "{use_user_agent: Bool}";
|
||||
const USE_VERSION_ID: &str = "{use_version_id: Bool}";
|
||||
const USE_MONETIZED: &str = "{use_monetized: Bool}";
|
||||
const USE_COUNTRY: &str = "{use_country: Bool}";
|
||||
const USE_REASON: &str = "{use_reason: Bool}";
|
||||
const USE_GAME_VERSION: &str = "{use_game_version: Bool}";
|
||||
const USE_LOADER: &str = "{use_loader: Bool}";
|
||||
const FILTER_DOMAIN: &str = "{filter_domain: Array(String)}";
|
||||
const FILTER_VERSION_ID: &str = "{filter_version_id: Array(UInt64)}";
|
||||
const FILTER_MONETIZED: &str = "{filter_monetized: UInt8}";
|
||||
const FILTER_COUNTRY: &str = "{filter_country: Array(String)}";
|
||||
const FILTER_REASON: &str = "{filter_reason: Array(String)}";
|
||||
const FILTER_GAME_VERSION: &str = "{filter_game_version: Array(String)}";
|
||||
const FILTER_LOADER: &str = "{filter_loader: Array(String)}";
|
||||
|
||||
formatcp!(
|
||||
"SELECT
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
if({USE_PROJECT_ID}, project_id, 0) AS project_id,
|
||||
if({USE_DOMAIN}, domain, '') AS domain,
|
||||
if({USE_USER_AGENT}, user_agent, '') AS user_agent,
|
||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||
if({USE_MONETIZED}, CAST(user_id != 0 AS Int8), -1) AS monetized,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
if({USE_REASON}, reason, '') AS reason,
|
||||
if({USE_GAME_VERSION}, game_version, '') AS game_version,
|
||||
if({USE_LOADER}, loader, '') AS loader,
|
||||
COUNT(*) AS downloads
|
||||
FROM downloads
|
||||
WHERE
|
||||
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
|
||||
-- make sure that the REAL project id is included,
|
||||
-- not the possibly-zero one,
|
||||
-- by using `downloads.project_id` instead of `project_id`
|
||||
AND downloads.project_id IN {PROJECT_IDS}
|
||||
AND (empty({FILTER_DOMAIN}) OR downloads.domain IN {FILTER_DOMAIN})
|
||||
AND (empty({FILTER_VERSION_ID}) OR downloads.version_id IN {FILTER_VERSION_ID})
|
||||
AND ({FILTER_MONETIZED} = 2 OR CAST(downloads.user_id != 0 AS UInt8) = {FILTER_MONETIZED})
|
||||
AND (empty({FILTER_COUNTRY}) OR downloads.country IN {FILTER_COUNTRY})
|
||||
AND (empty({FILTER_REASON}) OR downloads.reason IN {FILTER_REASON})
|
||||
AND (empty({FILTER_GAME_VERSION}) OR downloads.game_version IN {FILTER_GAME_VERSION})
|
||||
AND (empty({FILTER_LOADER}) OR downloads.loader IN {FILTER_LOADER})
|
||||
GROUP BY bucket, project_id, domain, user_agent, version_id, monetized, country, reason, game_version, loader"
|
||||
)
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct DownloadBucket {
|
||||
bucket: u64,
|
||||
project_id: DBProjectId,
|
||||
domain: Option<String>,
|
||||
user_agent: Option<DownloadSource>,
|
||||
version_id: Option<DBVersionId>,
|
||||
monetized: Option<bool>,
|
||||
country: Option<String>,
|
||||
reason: Option<DownloadReason>,
|
||||
game_version: Option<String>,
|
||||
loader: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch(
|
||||
cx: &mut QueryClickhouseContext<'_>,
|
||||
metrics: &Metrics<ProjectDownloadsField, ProjectDownloadsFilters>,
|
||||
) -> Result<(), ApiError> {
|
||||
use ProjectDownloadsField as F;
|
||||
let uses = |field| metrics.bucket_by.contains(&field);
|
||||
let use_columns = &[
|
||||
("use_project_id", uses(F::ProjectId)),
|
||||
("use_domain", uses(F::Domain)),
|
||||
(
|
||||
"use_user_agent",
|
||||
uses(F::UserAgent) || !metrics.filter_by.user_agent.is_empty(),
|
||||
),
|
||||
("use_version_id", uses(F::VersionId)),
|
||||
("use_monetized", uses(F::Monetized)),
|
||||
("use_country", uses(F::Country)),
|
||||
("use_reason", uses(F::Reason)),
|
||||
("use_game_version", uses(F::GameVersion)),
|
||||
("use_loader", uses(F::Loader)),
|
||||
];
|
||||
|
||||
let mut query = cx
|
||||
.clickhouse
|
||||
.query(DOWNLOADS)
|
||||
.param("time_range_start", cx.req.time_range.start.timestamp())
|
||||
.param("time_range_end", cx.req.time_range.end.timestamp())
|
||||
.param("time_slices", cx.time_slices.len())
|
||||
.param("project_ids", cx.project_ids);
|
||||
for (param_name, used) in use_columns {
|
||||
query = query.param(param_name, used)
|
||||
}
|
||||
for filter_param in [
|
||||
ClickhouseFilterParam::String(
|
||||
"filter_domain",
|
||||
&metrics.filter_by.domain,
|
||||
),
|
||||
ClickhouseFilterParam::VersionId(
|
||||
"filter_version_id",
|
||||
&metrics.filter_by.version_id,
|
||||
),
|
||||
ClickhouseFilterParam::Bool(
|
||||
"filter_monetized",
|
||||
&metrics.filter_by.monetized,
|
||||
),
|
||||
ClickhouseFilterParam::String(
|
||||
"filter_country",
|
||||
&metrics.filter_by.country,
|
||||
),
|
||||
ClickhouseFilterParam::DownloadReason(
|
||||
"filter_reason",
|
||||
&metrics.filter_by.reason,
|
||||
),
|
||||
ClickhouseFilterParam::String(
|
||||
"filter_game_version",
|
||||
&metrics.filter_by.game_version,
|
||||
),
|
||||
ClickhouseFilterParam::String(
|
||||
"filter_loader",
|
||||
&metrics.filter_by.loader,
|
||||
),
|
||||
] {
|
||||
query = filter_param.bind(query);
|
||||
}
|
||||
|
||||
let uses_column = |name| {
|
||||
use_columns
|
||||
.iter()
|
||||
.any(|(column_name, used)| *column_name == name && *used)
|
||||
};
|
||||
let mut cursor = query.fetch::<DownloadRow>()?;
|
||||
let mut buckets = HashMap::<DownloadBucket, u64>::new();
|
||||
|
||||
while let Some(row) = cursor.next().await? {
|
||||
let normalized_source = normalize_download_source(&row.user_agent);
|
||||
if !metrics.filter_by.user_agent.is_empty()
|
||||
&& !normalized_source.as_ref().is_some_and(|source| {
|
||||
metrics.filter_by.user_agent.contains(source)
|
||||
})
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let key = DownloadBucket {
|
||||
bucket: row.bucket,
|
||||
project_id: row.project_id,
|
||||
domain: uses_column("use_domain").then(|| row.domain.clone()),
|
||||
user_agent: uses(F::UserAgent)
|
||||
.then_some(normalized_source)
|
||||
.flatten(),
|
||||
version_id: uses_column("use_version_id").then_some(row.version_id),
|
||||
monetized: if uses_column("use_monetized") {
|
||||
match row.monetized {
|
||||
0 => Some(false),
|
||||
1 => Some(true),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
},
|
||||
country: uses_column("use_country").then(|| row.country.clone()),
|
||||
reason: if uses_column("use_reason") {
|
||||
none_if_empty(row.reason.clone()).and_then(|s| s.parse().ok())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
game_version: uses_column("use_game_version")
|
||||
.then(|| row.game_version.clone()),
|
||||
loader: uses_column("use_loader").then(|| row.loader.clone()),
|
||||
};
|
||||
|
||||
*buckets.entry(key).or_default() += row.downloads;
|
||||
}
|
||||
|
||||
for (key, downloads) in buckets {
|
||||
add_to_time_slice(
|
||||
cx.time_slices,
|
||||
key.bucket as usize,
|
||||
AnalyticsData::Project(ProjectAnalytics {
|
||||
source_project: key.project_id.into(),
|
||||
metrics: ProjectMetrics::Downloads(ProjectDownloads {
|
||||
domain: key.domain.and_then(none_if_empty),
|
||||
user_agent: key.user_agent,
|
||||
version_id: key
|
||||
.version_id
|
||||
.and_then(none_if_zero_version_id),
|
||||
monetized: key.monetized,
|
||||
country: key
|
||||
.country
|
||||
.map(|country| condense_country(country, downloads)),
|
||||
reason: key.reason,
|
||||
game_version: key.game_version.and_then(none_if_empty),
|
||||
loader: key.loader.and_then(none_if_empty),
|
||||
downloads,
|
||||
}),
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum DownloadSourcePattern {
|
||||
Named(&'static str),
|
||||
Website,
|
||||
ModrinthApp,
|
||||
ModrinthHosting,
|
||||
ModrinthMaven,
|
||||
}
|
||||
|
||||
impl DownloadSourcePattern {
|
||||
fn into_source(self) -> DownloadSource {
|
||||
match self {
|
||||
Self::Named(name) => DownloadSource::Named(name.into()),
|
||||
Self::Website => DownloadSource::Website,
|
||||
Self::ModrinthApp => DownloadSource::ModrinthApp,
|
||||
Self::ModrinthHosting => DownloadSource::ModrinthHosting,
|
||||
Self::ModrinthMaven => DownloadSource::ModrinthMaven,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static DOWNLOAD_SOURCE_PATTERNS: LazyLock<Vec<(Regex, DownloadSourcePattern)>> =
|
||||
LazyLock::new(|| {
|
||||
use DownloadSourcePattern as P;
|
||||
|
||||
[
|
||||
(r"^modrinth/kyros/", P::ModrinthHosting),
|
||||
(r"^modrinth/theseus/", P::ModrinthApp),
|
||||
(r"^(Gradle/|Apache-Maven/)", P::ModrinthMaven),
|
||||
(r"^MultiMC/", P::Named("MultiMC")),
|
||||
(r"^PrismLauncher/", P::Named("Prism Launcher")),
|
||||
(r"^PolyMC/", P::Named("PolyMC")),
|
||||
(r"^FCL/", P::Named("FCL")),
|
||||
(r"^PCL2/", P::Named("PCL2")),
|
||||
(r"^HMCL/", P::Named("HMCL")),
|
||||
(r"^Lunar Client Launcher", P::Named("Lunar Client")),
|
||||
(r"^PojavLauncher", P::Named("PojavLauncher")),
|
||||
(r"^ATLauncher/", P::Named("ATLauncher")),
|
||||
(r"FeatherLauncher/", P::Named("Feather Client")),
|
||||
(
|
||||
r"^FeatherMC/Feather Client Rust Launcher/",
|
||||
P::Named("Feather Client"),
|
||||
),
|
||||
(r"Feather/[0-9A-Za-z]+", P::Named("Feather Client")),
|
||||
(r"^PandoraLauncher/", P::Named("Pandora Launcher")),
|
||||
(r"^unsup", P::Named("unsup")),
|
||||
(r"nothub/mrpack-install", P::Named("mrpack-install")),
|
||||
(r"^(packwiz-installer|packwiz/)", P::Named("Packwiz")),
|
||||
(
|
||||
r"^(Mozilla/|Chrome/|Chromium/|Firefox/|Safari/|AppleWebKit/|Edg/|OPR/)",
|
||||
P::Website,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(pattern, source)| {
|
||||
(
|
||||
Regex::new(pattern)
|
||||
.expect("download source regex should be valid"),
|
||||
source,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
const MAX_DOWNLOAD_SOURCE_CACHE_BYTES: usize = 100 * 1024 * 1024;
|
||||
|
||||
static DOWNLOAD_SOURCE_CACHE: LazyLock<
|
||||
DashMap<String, Option<DownloadSource>>,
|
||||
> = LazyLock::new(DashMap::new);
|
||||
|
||||
static DOWNLOAD_SOURCE_CACHE_BYTES: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub(crate) fn normalize_download_source(
|
||||
user_agent: &str,
|
||||
) -> Option<DownloadSource> {
|
||||
if let Some(source) = DOWNLOAD_SOURCE_CACHE.get(user_agent) {
|
||||
return source.clone();
|
||||
}
|
||||
|
||||
let source = normalize_download_source_uncached(user_agent);
|
||||
|
||||
let key_bytes = user_agent.len();
|
||||
let previous_bytes =
|
||||
DOWNLOAD_SOURCE_CACHE_BYTES.fetch_add(key_bytes, Ordering::Relaxed);
|
||||
if previous_bytes + key_bytes <= MAX_DOWNLOAD_SOURCE_CACHE_BYTES {
|
||||
DOWNLOAD_SOURCE_CACHE.insert(user_agent.to_owned(), source.clone());
|
||||
} else {
|
||||
DOWNLOAD_SOURCE_CACHE_BYTES.fetch_sub(key_bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
source
|
||||
}
|
||||
|
||||
fn normalize_download_source_uncached(
|
||||
user_agent: &str,
|
||||
) -> Option<DownloadSource> {
|
||||
DOWNLOAD_SOURCE_PATTERNS.iter().find_map(|(regex, source)| {
|
||||
regex.is_match(user_agent).then(|| source.into_source())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use const_format::formatcp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
database::models::{DBProjectId, DBVersionId},
|
||||
models::ids::VersionId,
|
||||
routes::ApiError,
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
ClickhouseFilterParam, QueryClickhouseContext, add_to_time_slice,
|
||||
condense_country, none_if_empty, none_if_zero_version_id,
|
||||
};
|
||||
use super::{AnalyticsData, Metrics, ProjectAnalytics, ProjectMetrics};
|
||||
|
||||
const TIME_RANGE_START: &str = "{time_range_start: UInt64}";
|
||||
const TIME_RANGE_END: &str = "{time_range_end: UInt64}";
|
||||
const TIME_SLICES: &str = "{time_slices: UInt64}";
|
||||
const PROJECT_IDS: &str = "{project_ids: Array(UInt64)}";
|
||||
|
||||
/// Fields for [`super::ReturnMetrics::project_playtime`].
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProjectPlaytimeField {
|
||||
/// Project ID.
|
||||
ProjectId,
|
||||
/// Version ID of this project.
|
||||
VersionId,
|
||||
/// Game mod loader which was used to count this playtime, e.g. Fabric.
|
||||
Loader,
|
||||
/// Game version which this project was played on.
|
||||
GameVersion,
|
||||
/// What country this playtime came from.
|
||||
///
|
||||
/// To anonymize the data, the country may be reported as `XX`.
|
||||
Country,
|
||||
}
|
||||
|
||||
/// Filters for [`super::ReturnMetrics::project_playtime`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProjectPlaytimeFilters {
|
||||
/// Version IDs to include.
|
||||
#[serde(default)]
|
||||
pub version_id: Vec<VersionId>,
|
||||
/// Loaders to include.
|
||||
#[serde(default)]
|
||||
pub loader: Vec<String>,
|
||||
/// Game versions to include.
|
||||
#[serde(default)]
|
||||
pub game_version: Vec<String>,
|
||||
/// Country codes to include.
|
||||
#[serde(default)]
|
||||
pub country: Vec<String>,
|
||||
}
|
||||
|
||||
/// [`super::ReturnMetrics::project_playtime`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProjectPlaytime {
|
||||
/// [`ProjectPlaytimeField::VersionId`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) version_id: Option<VersionId>,
|
||||
/// [`ProjectPlaytimeField::Loader`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) loader: Option<String>,
|
||||
/// [`ProjectPlaytimeField::GameVersion`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) game_version: Option<String>,
|
||||
/// [`ProjectPlaytimeField::Country`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) country: Option<String>,
|
||||
/// Total number of seconds of playtime for this bucket.
|
||||
pub(crate) seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, clickhouse::Row, serde::Deserialize)]
|
||||
struct PlaytimeRow {
|
||||
bucket: u64,
|
||||
project_id: DBProjectId,
|
||||
parent_version_id: DBVersionId,
|
||||
version_id: DBVersionId,
|
||||
loader: String,
|
||||
game_version: String,
|
||||
country: String,
|
||||
seconds: u64,
|
||||
}
|
||||
|
||||
const PLAYTIME: &str = {
|
||||
const USE_PROJECT_ID: &str = "{use_project_id: Bool}";
|
||||
const USE_VERSION_ID: &str = "{use_version_id: Bool}";
|
||||
const USE_LOADER: &str = "{use_loader: Bool}";
|
||||
const USE_GAME_VERSION: &str = "{use_game_version: Bool}";
|
||||
const USE_COUNTRY: &str = "{use_country: Bool}";
|
||||
const PARENT_VERSION_IDS: &str = "{parent_version_ids: Array(UInt64)}";
|
||||
const FILTER_VERSION_ID: &str = "{filter_version_id: Array(UInt64)}";
|
||||
const FILTER_LOADER: &str = "{filter_loader: Array(String)}";
|
||||
const FILTER_GAME_VERSION: &str = "{filter_game_version: Array(String)}";
|
||||
const FILTER_COUNTRY: &str = "{filter_country: Array(String)}";
|
||||
|
||||
formatcp!(
|
||||
"SELECT
|
||||
bucket,
|
||||
if({USE_PROJECT_ID}, source_project_id, 0) AS project_id,
|
||||
parent_version_id,
|
||||
version_id,
|
||||
loader,
|
||||
game_version,
|
||||
country,
|
||||
SUM(seconds) AS seconds
|
||||
FROM (
|
||||
SELECT
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
project_id AS source_project_id,
|
||||
0 AS parent_version_id,
|
||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||
if({USE_LOADER}, loader, '') AS loader,
|
||||
if({USE_GAME_VERSION}, game_version, '') AS game_version,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
seconds
|
||||
FROM playtime
|
||||
WHERE
|
||||
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
|
||||
AND playtime.project_id IN {PROJECT_IDS}
|
||||
AND (empty({FILTER_VERSION_ID}) OR playtime.version_id IN {FILTER_VERSION_ID})
|
||||
AND (empty({FILTER_LOADER}) OR playtime.loader IN {FILTER_LOADER})
|
||||
AND (empty({FILTER_GAME_VERSION}) OR playtime.game_version IN {FILTER_GAME_VERSION})
|
||||
AND (empty({FILTER_COUNTRY}) OR playtime.country IN {FILTER_COUNTRY})
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
0 AS source_project_id,
|
||||
parent AS parent_version_id,
|
||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||
if({USE_LOADER}, loader, '') AS loader,
|
||||
if({USE_GAME_VERSION}, game_version, '') AS game_version,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
seconds
|
||||
FROM playtime
|
||||
WHERE
|
||||
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
|
||||
AND parent IN {PARENT_VERSION_IDS}
|
||||
AND (empty({FILTER_VERSION_ID}) OR playtime.version_id IN {FILTER_VERSION_ID})
|
||||
AND (empty({FILTER_LOADER}) OR playtime.loader IN {FILTER_LOADER})
|
||||
AND (empty({FILTER_GAME_VERSION}) OR playtime.game_version IN {FILTER_GAME_VERSION})
|
||||
AND (empty({FILTER_COUNTRY}) OR playtime.country IN {FILTER_COUNTRY})
|
||||
)
|
||||
GROUP BY bucket, project_id, parent_version_id, version_id, loader, game_version, country"
|
||||
)
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct PlaytimeBucket {
|
||||
bucket: u64,
|
||||
project_id: DBProjectId,
|
||||
version_id: Option<DBVersionId>,
|
||||
loader: Option<String>,
|
||||
game_version: Option<String>,
|
||||
country: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch(
|
||||
cx: &mut QueryClickhouseContext<'_>,
|
||||
parent_version_projects: &HashMap<DBVersionId, DBProjectId>,
|
||||
metrics: &Metrics<ProjectPlaytimeField, ProjectPlaytimeFilters>,
|
||||
) -> Result<(), ApiError> {
|
||||
use ProjectPlaytimeField as F;
|
||||
let uses = |field| metrics.bucket_by.contains(&field);
|
||||
let use_columns = &[
|
||||
("use_project_id", uses(F::ProjectId)),
|
||||
("use_version_id", uses(F::VersionId)),
|
||||
("use_loader", uses(F::Loader)),
|
||||
("use_game_version", uses(F::GameVersion)),
|
||||
("use_country", uses(F::Country)),
|
||||
];
|
||||
let uses_column = |name| {
|
||||
use_columns
|
||||
.iter()
|
||||
.any(|(column_name, used)| *column_name == name && *used)
|
||||
};
|
||||
|
||||
let mut query = cx
|
||||
.clickhouse
|
||||
.query(PLAYTIME)
|
||||
.param("time_range_start", cx.req.time_range.start.timestamp())
|
||||
.param("time_range_end", cx.req.time_range.end.timestamp())
|
||||
.param("time_slices", cx.time_slices.len())
|
||||
.param("project_ids", cx.project_ids)
|
||||
.param("parent_version_ids", cx.parent_version_ids);
|
||||
for (param_name, used) in use_columns {
|
||||
query = query.param(param_name, used)
|
||||
}
|
||||
for filter_param in [
|
||||
ClickhouseFilterParam::VersionId(
|
||||
"filter_version_id",
|
||||
&metrics.filter_by.version_id,
|
||||
),
|
||||
ClickhouseFilterParam::String(
|
||||
"filter_loader",
|
||||
&metrics.filter_by.loader,
|
||||
),
|
||||
ClickhouseFilterParam::String(
|
||||
"filter_game_version",
|
||||
&metrics.filter_by.game_version,
|
||||
),
|
||||
ClickhouseFilterParam::String(
|
||||
"filter_country",
|
||||
&metrics.filter_by.country,
|
||||
),
|
||||
] {
|
||||
query = filter_param.bind(query);
|
||||
}
|
||||
|
||||
let mut cursor = query.fetch::<PlaytimeRow>()?;
|
||||
let mut buckets = HashMap::<PlaytimeBucket, u64>::new();
|
||||
|
||||
while let Some(row) = cursor.next().await? {
|
||||
let project_id =
|
||||
if uses_column("use_project_id") && row.project_id.0 == 0 {
|
||||
parent_version_projects
|
||||
.get(&row.parent_version_id)
|
||||
.copied()
|
||||
.unwrap_or(row.project_id)
|
||||
} else {
|
||||
row.project_id
|
||||
};
|
||||
let key = PlaytimeBucket {
|
||||
bucket: row.bucket,
|
||||
project_id,
|
||||
version_id: uses_column("use_version_id").then_some(row.version_id),
|
||||
loader: uses_column("use_loader").then(|| row.loader.clone()),
|
||||
game_version: uses_column("use_game_version")
|
||||
.then(|| row.game_version.clone()),
|
||||
country: uses_column("use_country").then(|| row.country.clone()),
|
||||
};
|
||||
|
||||
*buckets.entry(key).or_default() += row.seconds;
|
||||
}
|
||||
|
||||
for (key, seconds) in buckets {
|
||||
add_to_time_slice(
|
||||
cx.time_slices,
|
||||
key.bucket as usize,
|
||||
AnalyticsData::Project(ProjectAnalytics {
|
||||
source_project: key.project_id.into(),
|
||||
metrics: ProjectMetrics::Playtime(ProjectPlaytime {
|
||||
version_id: key
|
||||
.version_id
|
||||
.and_then(none_if_zero_version_id),
|
||||
loader: key.loader.and_then(none_if_empty),
|
||||
game_version: key.game_version.and_then(none_if_empty),
|
||||
country: key
|
||||
.country
|
||||
.map(|country| condense_country(country, seconds)),
|
||||
seconds,
|
||||
}),
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use futures::StreamExt;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::{
|
||||
database::{PgPool, models::DBProjectId},
|
||||
models::ids::ProjectId,
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
use super::super::{TimeSlice, add_to_time_slice};
|
||||
use super::{AnalyticsData, ProjectAnalytics, ProjectMetrics};
|
||||
|
||||
/// Fields for [`super::ReturnMetrics::project_revenue`].
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProjectRevenueField {
|
||||
/// Project ID.
|
||||
ProjectId,
|
||||
}
|
||||
|
||||
/// Filters for [`super::ReturnMetrics::project_revenue`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProjectRevenueFilters {}
|
||||
|
||||
/// [`super::ReturnMetrics::project_revenue`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProjectRevenue {
|
||||
/// Total revenue for this bucket.
|
||||
pub(crate) revenue: Decimal,
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch(
|
||||
pool: &PgPool,
|
||||
time_slices: &mut [TimeSlice],
|
||||
req: &super::super::GetRequest,
|
||||
num_time_slices: usize,
|
||||
project_id_values: &[i64],
|
||||
) -> Result<(), ApiError> {
|
||||
let mut rows = sqlx::query(
|
||||
"SELECT
|
||||
WIDTH_BUCKET(
|
||||
EXTRACT(EPOCH FROM created)::bigint,
|
||||
EXTRACT(EPOCH FROM $1::timestamp with time zone AT TIME ZONE 'UTC')::bigint,
|
||||
EXTRACT(EPOCH FROM $2::timestamp with time zone AT TIME ZONE 'UTC')::bigint,
|
||||
$3::integer
|
||||
) AS bucket,
|
||||
mod_id,
|
||||
SUM(amount) amount_sum
|
||||
FROM payouts_values
|
||||
WHERE
|
||||
-- only project revenue is counted here
|
||||
-- for affiliate code revenue, see `affiliate_code_revenue`
|
||||
payouts_values.mod_id IS NOT NULL
|
||||
AND payouts_values.mod_id = ANY($4)
|
||||
AND created BETWEEN $1 AND $2
|
||||
GROUP BY bucket, mod_id",
|
||||
)
|
||||
.bind(req.time_range.start)
|
||||
.bind(req.time_range.end)
|
||||
.bind(num_time_slices as i64)
|
||||
.bind(project_id_values)
|
||||
.fetch(pool);
|
||||
while let Some(row) = rows.next().await.transpose()? {
|
||||
let bucket = row
|
||||
.try_get::<Option<i32>, _>("bucket")?
|
||||
.wrap_internal_err("bucket should be non-null - query bug!")?;
|
||||
let bucket = usize::try_from(bucket).wrap_internal_err_with(|| {
|
||||
eyre::eyre!(
|
||||
"bucket value {bucket} does not fit into `usize` - query bug!"
|
||||
)
|
||||
})?;
|
||||
|
||||
let mod_id = row.try_get::<Option<i64>, _>("mod_id")?;
|
||||
let amount_sum = row.try_get::<Option<Decimal>, _>("amount_sum")?;
|
||||
if let Some(source_project) =
|
||||
mod_id.map(DBProjectId).map(ProjectId::from)
|
||||
&& let Some(revenue) = amount_sum
|
||||
{
|
||||
add_to_time_slice(
|
||||
time_slices,
|
||||
bucket,
|
||||
AnalyticsData::Project(ProjectAnalytics {
|
||||
source_project,
|
||||
metrics: ProjectMetrics::Revenue(ProjectRevenue {
|
||||
revenue,
|
||||
}),
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use const_format::formatcp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{database::models::DBProjectId, routes::ApiError};
|
||||
|
||||
use super::super::{
|
||||
ClickhouseFilterParam, ClickhouseQueryParams, QueryClickhouseContext,
|
||||
condense_country, none_if_empty, query_clickhouse,
|
||||
};
|
||||
use super::{AnalyticsData, Metrics, ProjectAnalytics, ProjectMetrics};
|
||||
|
||||
const TIME_RANGE_START: &str = "{time_range_start: UInt64}";
|
||||
const TIME_RANGE_END: &str = "{time_range_end: UInt64}";
|
||||
const TIME_SLICES: &str = "{time_slices: UInt64}";
|
||||
const PROJECT_IDS: &str = "{project_ids: Array(UInt64)}";
|
||||
|
||||
/// Fields for [`super::ReturnMetrics::project_views`].
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProjectViewsField {
|
||||
/// Project ID.
|
||||
ProjectId,
|
||||
/// Referrer domain which linked to this project.
|
||||
Domain,
|
||||
/// Modrinth site path which was visited, e.g. `/mod/foo`.
|
||||
SitePath,
|
||||
/// Whether these views were monetized or not.
|
||||
Monetized,
|
||||
/// What country these views came from.
|
||||
///
|
||||
/// To anonymize the data, the country may be reported as `XX`.
|
||||
Country,
|
||||
}
|
||||
|
||||
/// Filters for [`super::ReturnMetrics::project_views`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProjectViewsFilters {
|
||||
/// Referrer domains to include.
|
||||
#[serde(default)]
|
||||
pub domain: Vec<String>,
|
||||
/// Modrinth site paths to include.
|
||||
#[serde(default)]
|
||||
pub site_path: Vec<String>,
|
||||
/// Monetization states to include.
|
||||
#[serde(default)]
|
||||
pub monetized: Vec<bool>,
|
||||
/// Country codes to include.
|
||||
#[serde(default)]
|
||||
pub country: Vec<String>,
|
||||
}
|
||||
|
||||
/// [`super::ReturnMetrics::project_views`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProjectViews {
|
||||
/// [`ProjectViewsField::Domain`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain: Option<String>,
|
||||
/// [`ProjectViewsField::SitePath`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub site_path: Option<String>,
|
||||
/// [`ProjectViewsField::Monetized`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub monetized: Option<bool>,
|
||||
/// [`ProjectViewsField::Country`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub country: Option<String>,
|
||||
/// Total number of views for this bucket.
|
||||
pub views: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, clickhouse::Row, serde::Deserialize)]
|
||||
struct ViewRow {
|
||||
bucket: u64,
|
||||
project_id: DBProjectId,
|
||||
domain: String,
|
||||
site_path: String,
|
||||
monetized: i8,
|
||||
country: String,
|
||||
views: u64,
|
||||
}
|
||||
|
||||
const VIEWS: &str = {
|
||||
const USE_PROJECT_ID: &str = "{use_project_id: Bool}";
|
||||
const USE_DOMAIN: &str = "{use_domain: Bool}";
|
||||
const USE_SITE_PATH: &str = "{use_site_path: Bool}";
|
||||
const USE_MONETIZED: &str = "{use_monetized: Bool}";
|
||||
const USE_COUNTRY: &str = "{use_country: Bool}";
|
||||
const FILTER_DOMAIN: &str = "{filter_domain: Array(String)}";
|
||||
const FILTER_SITE_PATH: &str = "{filter_site_path: Array(String)}";
|
||||
const FILTER_MONETIZED: &str = "{filter_monetized: UInt8}";
|
||||
const FILTER_COUNTRY: &str = "{filter_country: Array(String)}";
|
||||
|
||||
formatcp!(
|
||||
"SELECT
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
if({USE_PROJECT_ID}, project_id, 0) AS project_id,
|
||||
if({USE_DOMAIN}, domain, '') AS domain,
|
||||
if({USE_SITE_PATH}, site_path, '') AS site_path,
|
||||
if({USE_MONETIZED}, CAST(monetized AS Int8), -1) AS monetized,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
COUNT(*) AS views
|
||||
FROM views
|
||||
WHERE
|
||||
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
|
||||
-- make sure that the REAL project id is included,
|
||||
-- not the possibly-zero one,
|
||||
-- by using `views.project_id` instead of `project_id`
|
||||
AND views.project_id IN {PROJECT_IDS}
|
||||
AND (empty({FILTER_DOMAIN}) OR views.domain IN {FILTER_DOMAIN})
|
||||
AND (empty({FILTER_SITE_PATH}) OR views.site_path IN {FILTER_SITE_PATH})
|
||||
AND ({FILTER_MONETIZED} = 2 OR CAST(views.monetized AS UInt8) = {FILTER_MONETIZED})
|
||||
AND (empty({FILTER_COUNTRY}) OR views.country IN {FILTER_COUNTRY})
|
||||
GROUP BY bucket, project_id, domain, site_path, monetized, country
|
||||
"
|
||||
)
|
||||
};
|
||||
|
||||
pub(crate) async fn fetch(
|
||||
cx: &mut QueryClickhouseContext<'_>,
|
||||
metrics: &Metrics<ProjectViewsField, ProjectViewsFilters>,
|
||||
) -> Result<(), ApiError> {
|
||||
use ProjectViewsField as F;
|
||||
let uses = |field| metrics.bucket_by.contains(&field);
|
||||
|
||||
query_clickhouse::<ViewRow>(
|
||||
cx,
|
||||
VIEWS,
|
||||
ClickhouseQueryParams::PROJECT_IDS,
|
||||
&[
|
||||
("use_project_id", uses(F::ProjectId)),
|
||||
("use_domain", uses(F::Domain)),
|
||||
("use_site_path", uses(F::SitePath)),
|
||||
("use_monetized", uses(F::Monetized)),
|
||||
("use_country", uses(F::Country)),
|
||||
],
|
||||
vec![
|
||||
ClickhouseFilterParam::String(
|
||||
"filter_domain",
|
||||
&metrics.filter_by.domain,
|
||||
),
|
||||
ClickhouseFilterParam::String(
|
||||
"filter_site_path",
|
||||
&metrics.filter_by.site_path,
|
||||
),
|
||||
ClickhouseFilterParam::Bool(
|
||||
"filter_monetized",
|
||||
&metrics.filter_by.monetized,
|
||||
),
|
||||
ClickhouseFilterParam::String(
|
||||
"filter_country",
|
||||
&metrics.filter_by.country,
|
||||
),
|
||||
],
|
||||
|_| true,
|
||||
|row| row.bucket,
|
||||
|row| {
|
||||
let country = if uses(F::Country) {
|
||||
Some(condense_country(row.country, row.views))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
AnalyticsData::Project(ProjectAnalytics {
|
||||
source_project: row.project_id.into(),
|
||||
metrics: ProjectMetrics::Views(ProjectViews {
|
||||
domain: none_if_empty(row.domain),
|
||||
site_path: none_if_empty(row.site_path),
|
||||
monetized: match row.monetized {
|
||||
0 => Some(false),
|
||||
1 => Some(true),
|
||||
_ => None,
|
||||
},
|
||||
country,
|
||||
views: row.views,
|
||||
}),
|
||||
})
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,813 @@
|
||||
//! # Design rationale
|
||||
//!
|
||||
//! - different metrics require different scopes
|
||||
//! - views, downloads, playtime requires `Scopes::ANALYTICS`
|
||||
//! - revenue requires `Scopes::PAYOUTS_READ`
|
||||
//! - each request returns an array of N elements; if you have to make multiple
|
||||
//! requests, you have to zip together M arrays of N elements
|
||||
//! - this makes it inconvenient to have separate endpoints
|
||||
|
||||
mod facets;
|
||||
mod metrics;
|
||||
mod old;
|
||||
|
||||
use std::{collections::HashMap, num::NonZeroU64};
|
||||
|
||||
use crate::database::PgPool;
|
||||
use actix_web::{HttpRequest, post, web};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use eyre::eyre;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
AuthenticationError, checks::filter_visible_version_ids,
|
||||
get_user_from_headers,
|
||||
},
|
||||
database::{
|
||||
self, DBProject,
|
||||
models::{
|
||||
DBAffiliateCode, DBAffiliateCodeId, DBProjectId, DBUser, DBVersion,
|
||||
DBVersionId,
|
||||
},
|
||||
redis::RedisPool,
|
||||
},
|
||||
models::{
|
||||
ids::{AffiliateCodeId, ProjectId, VersionId},
|
||||
pats::Scopes,
|
||||
projects::ProjectStatus,
|
||||
teams::ProjectPermissions,
|
||||
threads::MessageBody,
|
||||
v3::analytics::DownloadReason,
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
routes::ApiError,
|
||||
};
|
||||
|
||||
pub(crate) use metrics::normalize_download_source;
|
||||
pub use metrics::*;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(fetch_analytics);
|
||||
cfg.configure(facets::config);
|
||||
cfg.configure(old::config);
|
||||
}
|
||||
|
||||
// request
|
||||
|
||||
/// Requests analytics data, aggregating over all possible analytics sources
|
||||
/// like projects and affiliate codes, returning the data in a list of time
|
||||
/// slices.
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct GetRequest {
|
||||
/// What time range to return statistics for.
|
||||
pub time_range: TimeRange,
|
||||
/// What analytics metrics to return data for.
|
||||
#[serde(default)]
|
||||
pub return_metrics: ReturnMetrics,
|
||||
/// What project IDs to return data for.
|
||||
///
|
||||
/// If this is empty, all of the user's projects will be included.
|
||||
#[serde(default)]
|
||||
pub project_ids: Vec<ProjectId>,
|
||||
}
|
||||
|
||||
/// Time range for fetching analytics.
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct TimeRange {
|
||||
/// When to start including data.
|
||||
pub start: DateTime<Utc>,
|
||||
/// When to stop including data.
|
||||
pub end: DateTime<Utc>,
|
||||
/// Determines how many time slices between the start and end will be
|
||||
/// included, and how fine-grained those time slices will be.
|
||||
///
|
||||
/// This must fall within the bounds of [`MIN_RESOLUTION`] and
|
||||
/// [`MAX_TIME_SLICES`].
|
||||
pub resolution: TimeRangeResolution,
|
||||
}
|
||||
|
||||
/// Determines how many time slices between the start and end will be
|
||||
/// included, and how fine-grained those time slices will be.
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TimeRangeResolution {
|
||||
/// Use a set number of time slices, with the resolution being determined
|
||||
/// automatically.
|
||||
#[schema(value_type = u64)]
|
||||
Slices(NonZeroU64),
|
||||
/// Each time slice will be a set number of minutes long, and the number of
|
||||
/// slices is determined automatically.
|
||||
#[schema(value_type = u64)]
|
||||
Minutes(NonZeroU64),
|
||||
}
|
||||
|
||||
/// Minimum width of a [`TimeSlice`], controlled by [`TimeRange::resolution`].
|
||||
pub const MIN_RESOLUTION: TimeDelta = TimeDelta::minutes(60);
|
||||
|
||||
/// Maximum number of [`TimeSlice`]s in a [`GetResponse`], controlled by
|
||||
/// [`TimeRange::resolution`].
|
||||
pub const MAX_TIME_SLICES: usize = 1024;
|
||||
|
||||
// response
|
||||
|
||||
/// Response for a [`GetRequest`].
|
||||
#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct GetResponse {
|
||||
/// List of N [`TimeSlice`]s, where each slice represents an equal
|
||||
/// time interval of metrics collection. The number of slices is determined
|
||||
/// by [`GetRequest::time_range`].
|
||||
pub metrics: Vec<TimeSlice>,
|
||||
/// List of events associated with projects that were requested.
|
||||
pub project_events: Vec<ProjectAnalyticsEvent>,
|
||||
}
|
||||
|
||||
/// Single time interval of metrics collection.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct TimeSlice(pub Vec<AnalyticsData>);
|
||||
|
||||
/// Notable update to a project which may reflect in analytics metrics.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProjectAnalyticsEvent {
|
||||
/// ID of the event's project.
|
||||
pub project_id: ProjectId,
|
||||
/// When the event occurred.
|
||||
pub timestamp: DateTime<Utc>,
|
||||
#[serde(flatten)]
|
||||
pub kind: ProjectAnalyticsEventKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ProjectAnalyticsEventKind {
|
||||
/// New version of this project was uploaded.
|
||||
VersionUploaded {
|
||||
version_id: VersionId,
|
||||
version_name: String,
|
||||
version_number: String,
|
||||
},
|
||||
/// Project changed status.
|
||||
StatusChanged {
|
||||
status_from: ProjectStatus,
|
||||
status_to: ProjectStatus,
|
||||
},
|
||||
}
|
||||
|
||||
// logic
|
||||
|
||||
/// Fetches analytics data for the authorized user's projects.
|
||||
#[utoipa::path(
|
||||
responses((status = OK, body = inline(GetResponse))),
|
||||
)]
|
||||
#[post("")]
|
||||
pub async fn fetch_analytics(
|
||||
http_req: HttpRequest,
|
||||
req: web::Json<GetRequest>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
clickhouse: web::Data<clickhouse::Client>,
|
||||
) -> Result<web::Json<GetResponse>, ApiError> {
|
||||
let (scopes, user) = get_user_from_headers(
|
||||
&http_req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::ANALYTICS,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let full_time_range = req.time_range.end - req.time_range.start;
|
||||
if full_time_range < TimeDelta::zero() {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"End date must be after start date".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let (num_time_slices, resolution) = match req.time_range.resolution {
|
||||
TimeRangeResolution::Slices(slices) => {
|
||||
let slices = i32::try_from(slices.get()).map_err(|_| {
|
||||
ApiError::InvalidInput(
|
||||
"Number of slices must fit into an `i32`".into(),
|
||||
)
|
||||
})?;
|
||||
let resolution = full_time_range / slices;
|
||||
(slices as usize, resolution)
|
||||
}
|
||||
TimeRangeResolution::Minutes(resolution_minutes) => {
|
||||
let resolution_minutes = i64::try_from(resolution_minutes.get())
|
||||
.map_err(|_| {
|
||||
ApiError::InvalidInput(
|
||||
"Resolution must fit into a `i64`".into(),
|
||||
)
|
||||
})?;
|
||||
let resolution = TimeDelta::try_minutes(resolution_minutes)
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput("Resolution overflow".into())
|
||||
})?;
|
||||
|
||||
let num_slices =
|
||||
full_time_range.as_seconds_f64() / resolution.as_seconds_f64();
|
||||
|
||||
(num_slices as usize, resolution)
|
||||
}
|
||||
};
|
||||
|
||||
if num_time_slices > MAX_TIME_SLICES {
|
||||
return Err(ApiError::Request(eyre!(
|
||||
"Resolution is too fine or range is too large - maximum of {MAX_TIME_SLICES} time slices, was {num_time_slices}"
|
||||
)));
|
||||
}
|
||||
if resolution < MIN_RESOLUTION {
|
||||
return Err(ApiError::Request(eyre!(
|
||||
"Resolution must be at least {MIN_RESOLUTION}, was {resolution}",
|
||||
)));
|
||||
}
|
||||
|
||||
let mut time_slices = vec![TimeSlice::default(); num_time_slices];
|
||||
|
||||
let project_ids = {
|
||||
if req.project_ids.is_empty() {
|
||||
DBUser::get_projects(user.id.into(), &**pool, &redis).await?
|
||||
} else {
|
||||
req.project_ids
|
||||
.iter()
|
||||
.map(|id| DBProjectId::from(*id))
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
};
|
||||
|
||||
let project_ids =
|
||||
filter_allowed_project_ids(&project_ids, &user, &pool, &redis).await?;
|
||||
|
||||
let project_id_values =
|
||||
project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
|
||||
let parent_versions = sqlx::query!(
|
||||
"
|
||||
SELECT id, mod_id
|
||||
FROM versions
|
||||
WHERE mod_id = ANY($1)
|
||||
",
|
||||
&project_id_values,
|
||||
)
|
||||
.fetch_all(&**pool)
|
||||
.await?;
|
||||
let parent_version_ids = parent_versions
|
||||
.iter()
|
||||
.map(|version| DBVersionId(version.id))
|
||||
.collect::<Vec<_>>();
|
||||
let parent_version_projects = parent_versions
|
||||
.iter()
|
||||
.map(|version| (DBVersionId(version.id), DBProjectId(version.mod_id)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let parent_version_data =
|
||||
DBVersion::get_many(&parent_version_ids, &**pool, &redis).await?;
|
||||
let visible_version_ids = filter_visible_version_ids(
|
||||
parent_version_data
|
||||
.iter()
|
||||
.map(|version| &version.inner)
|
||||
.collect(),
|
||||
&Some(user.clone()),
|
||||
&pool,
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
let mut project_events = parent_version_data
|
||||
.iter()
|
||||
.filter(|version| {
|
||||
visible_version_ids.contains(&version.inner.id)
|
||||
&& version.inner.date_published >= req.time_range.start
|
||||
&& version.inner.date_published <= req.time_range.end
|
||||
})
|
||||
.map(|version| ProjectAnalyticsEvent {
|
||||
project_id: version.inner.project_id.into(),
|
||||
timestamp: version.inner.date_published,
|
||||
kind: ProjectAnalyticsEventKind::VersionUploaded {
|
||||
version_id: version.inner.id.into(),
|
||||
version_name: version.inner.name.clone(),
|
||||
version_number: version.inner.version_number.clone(),
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
project_events.extend(
|
||||
fetch_project_status_change_events(
|
||||
&project_ids,
|
||||
&req.time_range,
|
||||
&pool,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
project_events.sort_by_key(|event| event.timestamp);
|
||||
|
||||
let affiliate_code_ids =
|
||||
DBAffiliateCode::get_by_affiliate(user.id.into(), &**pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|code| code.id)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut query_clickhouse_cx = QueryClickhouseContext {
|
||||
clickhouse: &clickhouse,
|
||||
req: &req,
|
||||
time_slices: &mut time_slices,
|
||||
project_ids: &project_ids,
|
||||
parent_version_ids: &parent_version_ids,
|
||||
affiliate_code_ids: &affiliate_code_ids,
|
||||
};
|
||||
|
||||
if let Some(metrics) = &req.return_metrics.project_views {
|
||||
metrics::fetch_project_views(&mut query_clickhouse_cx, metrics).await?;
|
||||
}
|
||||
|
||||
if let Some(metrics) = &req.return_metrics.project_downloads {
|
||||
metrics::fetch_project_downloads(&mut query_clickhouse_cx, metrics)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(metrics) = &req.return_metrics.project_playtime {
|
||||
metrics::fetch_project_playtime(
|
||||
&mut query_clickhouse_cx,
|
||||
&parent_version_projects,
|
||||
metrics,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(metrics) = &req.return_metrics.affiliate_code_clicks {
|
||||
metrics::fetch_affiliate_code_clicks(&mut query_clickhouse_cx, metrics)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if req.return_metrics.project_revenue.is_some() {
|
||||
if !scopes.contains(Scopes::PAYOUTS_READ) {
|
||||
return Err(AuthenticationError::InvalidCredentials.into());
|
||||
}
|
||||
|
||||
metrics::fetch_project_revenue(
|
||||
&pool,
|
||||
&mut time_slices,
|
||||
&req,
|
||||
num_time_slices,
|
||||
&project_id_values,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(metrics) = &req.return_metrics.affiliate_code_conversions {
|
||||
metrics::fetch_affiliate_code_conversions(
|
||||
&pool,
|
||||
&mut time_slices,
|
||||
&req,
|
||||
user.id.into(),
|
||||
num_time_slices,
|
||||
metrics,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(metrics) = &req.return_metrics.affiliate_code_revenue {
|
||||
if !scopes.contains(Scopes::PAYOUTS_READ) {
|
||||
return Err(AuthenticationError::InvalidCredentials.into());
|
||||
}
|
||||
|
||||
metrics::fetch_affiliate_code_revenue(
|
||||
&pool,
|
||||
&mut time_slices,
|
||||
&req,
|
||||
user.id.into(),
|
||||
num_time_slices,
|
||||
metrics,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(web::Json(GetResponse {
|
||||
metrics: time_slices,
|
||||
project_events,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn none_if_empty(s: String) -> Option<String> {
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
}
|
||||
|
||||
pub(crate) fn none_if_zero_version_id(v: DBVersionId) -> Option<VersionId> {
|
||||
if v.0 == 0 { None } else { Some(v.into()) }
|
||||
}
|
||||
|
||||
pub(crate) fn condense_country(country: String, count: u64) -> String {
|
||||
// Every country under '50' (view or downloads) should be condensed into 'XX'
|
||||
if count < 50 {
|
||||
"XX".to_string()
|
||||
} else {
|
||||
country
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_project_status_change_events(
|
||||
project_ids: &[DBProjectId],
|
||||
time_range: &TimeRange,
|
||||
pool: &PgPool,
|
||||
) -> Result<Vec<ProjectAnalyticsEvent>, ApiError> {
|
||||
let project_id_values =
|
||||
project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
t.mod_id AS "project_id!",
|
||||
tm.created,
|
||||
tm.body AS "body: sqlx::types::Json<MessageBody>"
|
||||
FROM threads_messages tm
|
||||
INNER JOIN threads t ON t.id = tm.thread_id
|
||||
WHERE
|
||||
t.mod_id = ANY($1)
|
||||
AND tm.body->>'type' = 'status_change'
|
||||
AND tm.created BETWEEN $2 AND $3
|
||||
"#,
|
||||
&project_id_values,
|
||||
time_range.start,
|
||||
time_range.end,
|
||||
)
|
||||
.fetch_all(&**pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let MessageBody::StatusChange {
|
||||
old_status,
|
||||
new_status,
|
||||
} = row.body.0
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(ProjectAnalyticsEvent {
|
||||
project_id: DBProjectId(row.project_id).into(),
|
||||
timestamp: row.created,
|
||||
kind: ProjectAnalyticsEventKind::StatusChanged {
|
||||
status_from: old_status,
|
||||
status_to: new_status,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) struct QueryClickhouseContext<'a> {
|
||||
pub(crate) clickhouse: &'a clickhouse::Client,
|
||||
pub(crate) req: &'a GetRequest,
|
||||
pub(crate) time_slices: &'a mut [TimeSlice],
|
||||
pub(crate) project_ids: &'a [DBProjectId],
|
||||
pub(crate) parent_version_ids: &'a [DBVersionId],
|
||||
pub(crate) affiliate_code_ids: &'a [DBAffiliateCodeId],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub(crate) struct ClickhouseQueryParams {
|
||||
pub(crate) project_ids: bool,
|
||||
pub(crate) parent_version_ids: bool,
|
||||
pub(crate) affiliate_code_ids: bool,
|
||||
}
|
||||
|
||||
pub(crate) enum ClickhouseFilterParam<'a> {
|
||||
String(&'static str, &'a [String]),
|
||||
Bool(&'static str, &'a [bool]),
|
||||
VersionId(&'static str, &'a [VersionId]),
|
||||
AffiliateCodeId(&'static str, &'a [AffiliateCodeId]),
|
||||
DownloadReason(&'static str, &'a [DownloadReason]),
|
||||
}
|
||||
|
||||
impl ClickhouseFilterParam<'_> {
|
||||
pub(crate) fn bind(
|
||||
self,
|
||||
query: clickhouse::query::Query,
|
||||
) -> clickhouse::query::Query {
|
||||
match self {
|
||||
Self::String(name, values) => query.param(name, values),
|
||||
Self::Bool(name, values) => {
|
||||
let value = match values {
|
||||
[false] => 0,
|
||||
[true] => 1,
|
||||
_ => 2,
|
||||
};
|
||||
query.param(name, value)
|
||||
}
|
||||
Self::VersionId(name, values) => {
|
||||
let values = values
|
||||
.iter()
|
||||
.map(|id| DBVersionId::from(*id))
|
||||
.collect::<Vec<_>>();
|
||||
query.param(name, values)
|
||||
}
|
||||
Self::AffiliateCodeId(name, values) => {
|
||||
let values = values
|
||||
.iter()
|
||||
.map(|id| DBAffiliateCodeId::from(*id))
|
||||
.collect::<Vec<_>>();
|
||||
query.param(name, values)
|
||||
}
|
||||
Self::DownloadReason(name, values) => {
|
||||
let values =
|
||||
values.iter().map(ToString::to_string).collect::<Vec<_>>();
|
||||
query.param(name, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClickhouseQueryParams {
|
||||
pub(crate) const PROJECT_IDS: Self = Self {
|
||||
project_ids: true,
|
||||
parent_version_ids: false,
|
||||
affiliate_code_ids: false,
|
||||
};
|
||||
|
||||
pub(crate) const fn empty() -> Self {
|
||||
Self {
|
||||
project_ids: false,
|
||||
parent_version_ids: false,
|
||||
affiliate_code_ids: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::BitOr for ClickhouseQueryParams {
|
||||
type Output = Self;
|
||||
|
||||
fn bitor(self, rhs: Self) -> Self::Output {
|
||||
Self {
|
||||
project_ids: self.project_ids || rhs.project_ids,
|
||||
parent_version_ids: self.parent_version_ids
|
||||
|| rhs.parent_version_ids,
|
||||
affiliate_code_ids: self.affiliate_code_ids
|
||||
|| rhs.affiliate_code_ids,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn query_clickhouse<Row>(
|
||||
cx: &mut QueryClickhouseContext<'_>,
|
||||
query: &str,
|
||||
params: ClickhouseQueryParams,
|
||||
use_columns: &[(&str, bool)],
|
||||
filter_params: Vec<ClickhouseFilterParam<'_>>,
|
||||
row_filter: impl Fn(&Row::Value<'_>) -> bool,
|
||||
// I hate using the hidden type Row::Value here, but it's what next() returns, so I see no other option
|
||||
row_get_bucket: impl Fn(&Row::Value<'_>) -> u64,
|
||||
row_to_analytics: impl Fn(Row::Value<'_>) -> AnalyticsData,
|
||||
) -> Result<(), ApiError>
|
||||
where
|
||||
Row: clickhouse::RowRead + serde::de::DeserializeOwned + std::fmt::Debug,
|
||||
{
|
||||
let mut query = cx
|
||||
.clickhouse
|
||||
.query(query)
|
||||
.param("time_range_start", cx.req.time_range.start.timestamp())
|
||||
.param("time_range_end", cx.req.time_range.end.timestamp())
|
||||
.param("time_slices", cx.time_slices.len());
|
||||
if params.project_ids {
|
||||
query = query.param("project_ids", cx.project_ids);
|
||||
}
|
||||
if params.parent_version_ids {
|
||||
query = query.param("parent_version_ids", cx.parent_version_ids);
|
||||
}
|
||||
if params.affiliate_code_ids {
|
||||
query = query.param("affiliate_code_ids", cx.affiliate_code_ids);
|
||||
}
|
||||
for (param_name, used) in use_columns {
|
||||
query = query.param(param_name, used)
|
||||
}
|
||||
for filter_param in filter_params {
|
||||
query = filter_param.bind(query);
|
||||
}
|
||||
let mut cursor = query.fetch::<Row>()?;
|
||||
|
||||
while let Some(row) = cursor.next().await? {
|
||||
if !row_filter(&row) {
|
||||
continue;
|
||||
}
|
||||
let bucket = row_get_bucket(&row) as usize;
|
||||
add_to_time_slice(cx.time_slices, bucket, row_to_analytics(row))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn add_to_time_slice(
|
||||
time_slices: &mut [TimeSlice],
|
||||
bucket: usize,
|
||||
data: AnalyticsData,
|
||||
) -> Result<(), ApiError> {
|
||||
// row.recorded < time_range_start => bucket = 0
|
||||
// row.recorded >= time_range_end => bucket = num_time_slices
|
||||
// (note: this is out of range of `time_slices`!)
|
||||
let Some(bucket) = bucket.checked_sub(1) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let num_time_slices = time_slices.len();
|
||||
let slice = time_slices.get_mut(bucket).ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
format!("bucket {bucket} returned by query out of range for {num_time_slices} - query bug!")
|
||||
)
|
||||
})?;
|
||||
|
||||
slice.0.push(data);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn filter_allowed_project_ids(
|
||||
project_ids: &[DBProjectId],
|
||||
user: &crate::models::users::User,
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Vec<DBProjectId>, ApiError> {
|
||||
let projects = DBProject::get_many_ids(project_ids, pool, redis).await?;
|
||||
|
||||
let team_ids = projects
|
||||
.iter()
|
||||
.map(|x| x.inner.team_id)
|
||||
.collect::<Vec<database::models::DBTeamId>>();
|
||||
let team_members = database::models::DBTeamMember::get_from_team_full_many(
|
||||
&team_ids, pool, redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let organization_ids = projects
|
||||
.iter()
|
||||
.filter_map(|x| x.inner.organization_id)
|
||||
.collect::<Vec<database::models::DBOrganizationId>>();
|
||||
let organizations = database::models::DBOrganization::get_many_ids(
|
||||
&organization_ids,
|
||||
pool,
|
||||
redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let organization_team_ids = organizations
|
||||
.iter()
|
||||
.map(|x| x.team_id)
|
||||
.collect::<Vec<database::models::DBTeamId>>();
|
||||
let organization_team_members =
|
||||
database::models::DBTeamMember::get_from_team_full_many(
|
||||
&organization_team_ids,
|
||||
pool,
|
||||
redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(projects
|
||||
.into_iter()
|
||||
.filter(|project| {
|
||||
let team_member = team_members.iter().find(|x| {
|
||||
x.team_id == project.inner.team_id
|
||||
&& x.user_id == user.id.into()
|
||||
});
|
||||
|
||||
let organization = project
|
||||
.inner
|
||||
.organization_id
|
||||
.and_then(|oid| organizations.iter().find(|x| x.id == oid));
|
||||
|
||||
let organization_team_member =
|
||||
if let Some(organization) = organization {
|
||||
organization_team_members.iter().find(|x| {
|
||||
x.team_id == organization.team_id
|
||||
&& x.user_id == user.id.into()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let permissions = ProjectPermissions::get_permissions_by_role(
|
||||
&user.role,
|
||||
&team_member.cloned(),
|
||||
&organization_team_member.cloned(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
permissions.contains(ProjectPermissions::VIEW_ANALYTICS)
|
||||
})
|
||||
.map(|project| project.inner.id)
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_download_sources() {
|
||||
let cases = [
|
||||
("MultiMC/5.0", Some(DownloadSource::Named("MultiMC".into()))),
|
||||
(
|
||||
"PrismLauncher/6.1",
|
||||
Some(DownloadSource::Named("Prism Launcher".into())),
|
||||
),
|
||||
(
|
||||
"modrinth/theseus/0.8.6 (support@modrinth.com)",
|
||||
Some(DownloadSource::ModrinthApp),
|
||||
),
|
||||
(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
|
||||
Some(DownloadSource::Website),
|
||||
),
|
||||
("curl/8.7.1", None),
|
||||
];
|
||||
|
||||
for (user_agent, source) in cases {
|
||||
assert_eq!(normalize_download_source(user_agent), source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_source_serializes_as_raw_string() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(DownloadSource::Named("MultiMC".into()))
|
||||
.unwrap(),
|
||||
json!("MultiMC")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(DownloadSource::Website).unwrap(),
|
||||
json!("website")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(DownloadSource::ModrinthApp).unwrap(),
|
||||
json!("modrinth_app")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(DownloadSource::Other).unwrap(),
|
||||
json!("other")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_format() {
|
||||
let test_project_1 = ProjectId(123);
|
||||
let test_project_2 = ProjectId(456);
|
||||
let test_project_3 = ProjectId(789);
|
||||
|
||||
let src = GetResponse {
|
||||
metrics: vec![
|
||||
TimeSlice(vec![
|
||||
AnalyticsData::Project(ProjectAnalytics {
|
||||
source_project: test_project_1,
|
||||
metrics: ProjectMetrics::Views(ProjectViews {
|
||||
domain: Some("youtube.com".into()),
|
||||
views: 100,
|
||||
..Default::default()
|
||||
}),
|
||||
}),
|
||||
AnalyticsData::Project(ProjectAnalytics {
|
||||
source_project: test_project_2,
|
||||
metrics: ProjectMetrics::Downloads(ProjectDownloads {
|
||||
domain: Some("discord.com".into()),
|
||||
downloads: 150,
|
||||
..Default::default()
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
TimeSlice(vec![AnalyticsData::Project(ProjectAnalytics {
|
||||
source_project: test_project_3,
|
||||
metrics: ProjectMetrics::Revenue(ProjectRevenue {
|
||||
revenue: Decimal::new(20000, 2),
|
||||
}),
|
||||
})]),
|
||||
],
|
||||
project_events: vec![],
|
||||
};
|
||||
let target = json!({
|
||||
"metrics": [
|
||||
[
|
||||
{
|
||||
"source_project": test_project_1.to_string(),
|
||||
"metric_kind": "views",
|
||||
"domain": "youtube.com",
|
||||
"views": 100,
|
||||
},
|
||||
{
|
||||
"source_project": test_project_2.to_string(),
|
||||
"metric_kind": "downloads",
|
||||
"domain": "discord.com",
|
||||
"downloads": 150,
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"source_project": test_project_3.to_string(),
|
||||
"metric_kind": "revenue",
|
||||
"revenue": "200.00",
|
||||
}
|
||||
]
|
||||
],
|
||||
"project_events": []
|
||||
});
|
||||
|
||||
assert_eq!(serde_json::to_value(src).unwrap(), target);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use super::{ApiError, oauth_clients::get_user_clients};
|
||||
use crate::database::PgPool;
|
||||
@@ -10,12 +13,14 @@ use crate::{
|
||||
get_user_from_headers,
|
||||
},
|
||||
database::{
|
||||
models::{DBModerationNote, DBUser},
|
||||
models::{DBModerationNote, DBOrganization, DBProjectId, DBUser},
|
||||
redis::RedisPool,
|
||||
},
|
||||
file_hosting::{FileHost, FileHostPublicity},
|
||||
models::{
|
||||
ids::OrganizationId,
|
||||
notifications::Notification,
|
||||
organizations::Organization,
|
||||
pats::Scopes,
|
||||
projects::Project,
|
||||
users::{Badges, Role},
|
||||
@@ -35,6 +40,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.route("user", web::get().to(user_auth_get));
|
||||
cfg.route("users", web::get().to(users_get));
|
||||
cfg.route("user_email", web::get().to(admin_user_email));
|
||||
cfg.route("all-projects", web::get().to(all_projects));
|
||||
|
||||
cfg.service(
|
||||
web::scope("user")
|
||||
@@ -53,11 +59,135 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AllProjectsResponse {
|
||||
pub projects: Vec<Project>,
|
||||
pub organizations: HashMap<OrganizationId, Organization>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UserEmailQuery {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
pub async fn all_projects(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<AllProjectsResponse>, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_READ | Scopes::ORGANIZATION_READ,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let user_project_ids =
|
||||
DBUser::get_projects(user.id.into(), &**pool, &redis).await?;
|
||||
let organization_ids =
|
||||
DBUser::get_organizations(user.id.into(), &**pool).await?;
|
||||
let organizations_data =
|
||||
DBOrganization::get_many_ids(&organization_ids, &**pool, &redis)
|
||||
.await?;
|
||||
|
||||
let team_ids = organizations_data
|
||||
.iter()
|
||||
.map(|organization| organization.team_id)
|
||||
.collect::<Vec<_>>();
|
||||
let teams_data =
|
||||
crate::database::models::DBTeamMember::get_from_team_full_many(
|
||||
&team_ids, &**pool, &redis,
|
||||
)
|
||||
.await?;
|
||||
let users = DBUser::get_many_ids(
|
||||
&teams_data
|
||||
.iter()
|
||||
.map(|member| member.user_id)
|
||||
.collect::<Vec<_>>(),
|
||||
&**pool,
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut team_groups = HashMap::new();
|
||||
for member in teams_data {
|
||||
team_groups
|
||||
.entry(member.team_id)
|
||||
.or_insert(vec![])
|
||||
.push(member);
|
||||
}
|
||||
|
||||
let mut organizations = HashMap::new();
|
||||
let mut visible_organization_ids = Vec::new();
|
||||
for data in organizations_data {
|
||||
if !is_visible_organization(&data, &Some(user.clone()), &pool, &redis)
|
||||
.await?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
visible_organization_ids.push(data.id);
|
||||
let members_data = team_groups.remove(&data.team_id).unwrap_or(vec![]);
|
||||
let team_members = members_data
|
||||
.into_iter()
|
||||
.filter_map(|data| {
|
||||
users.iter().find(|x| x.id == data.user_id).map(|member| {
|
||||
crate::models::teams::TeamMember::from(
|
||||
data,
|
||||
member.clone(),
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
organizations.insert(
|
||||
OrganizationId::from(data.id),
|
||||
Organization::from(data, team_members),
|
||||
);
|
||||
}
|
||||
|
||||
let organization_id_values = visible_organization_ids
|
||||
.iter()
|
||||
.map(|id| id.0)
|
||||
.collect::<Vec<_>>();
|
||||
let organization_project_ids = sqlx::query!(
|
||||
"
|
||||
SELECT m.id
|
||||
FROM mods m
|
||||
WHERE m.organization_id = ANY($1)
|
||||
",
|
||||
&organization_id_values,
|
||||
)
|
||||
.fetch_all(&**pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| DBProjectId(row.id))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let project_ids = user_project_ids
|
||||
.into_iter()
|
||||
.chain(organization_project_ids)
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let projects_data =
|
||||
crate::database::DBProject::get_many_ids(&project_ids, &**pool, &redis)
|
||||
.await?;
|
||||
let projects =
|
||||
filter_visible_projects(projects_data, &Some(user), &pool, true)
|
||||
.await?;
|
||||
|
||||
Ok(web::Json(AllProjectsResponse {
|
||||
projects,
|
||||
organizations,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn admin_user_email(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id IS ?",
|
||||
"query": "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20"
|
||||
"hash": "08908e54884b79705500501389344f3dc52fc81d34b0e9a44f5b9bede487cfa6"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944"
|
||||
}
|
||||
Generated
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id: Hyphenated",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246"
|
||||
}
|
||||
Generated
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "texture_key",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "variant: MinecraftSkinVariant",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "cape_id: Hyphenated",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a0b0ff0ae4b88d5df9d15d3427ab4e9a6ff21cffdc9c2f3d6860e245949d313d"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
DROP TABLE IF EXISTS default_minecraft_capes;
|
||||
|
||||
-- Keep only one saved skin per Minecraft account and texture.
|
||||
-- variant and cape_id are settings on that saved skin, not part of the skin identity.
|
||||
DELETE FROM custom_minecraft_skins
|
||||
WHERE rowid NOT IN (
|
||||
SELECT MAX(rowid)
|
||||
FROM custom_minecraft_skins
|
||||
GROUP BY minecraft_user_uuid, texture_key
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX custom_minecraft_skins_one_per_texture
|
||||
ON custom_minecraft_skins (minecraft_user_uuid, texture_key);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,16 @@ use crate::{minecraft_skins::SkinSource, state::MinecraftSkinVariant};
|
||||
|
||||
use super::super::super::Skin;
|
||||
|
||||
const DEFAULT_SKINS_SECTION: &str = "Default skins";
|
||||
const MINECON_EARTH_2017_SKIN_PACK_SECTION: &str = "MINECON Earth 2017";
|
||||
const BUILDERS_AND_BIOMES_SKIN_PACK_SECTION: &str = "Builders & Biomes";
|
||||
const STRIDING_HERO_SKIN_PACK_SECTION: &str = "Striding Hero";
|
||||
const THE_GARDEN_AWAKENS_SKIN_PACK_SECTION: &str = "The Garden Awakens";
|
||||
const CHASE_THE_SKIES_SKIN_PACK_SECTION: &str = "Chase the Skies";
|
||||
const THE_COPPER_AGE_SKIN_PACK_SECTION: &str = "The Copper Age";
|
||||
const MOUNTS_OF_MAYHEM_SKIN_PACK_SECTION: &str = "Mounts of Mayhem";
|
||||
const TINY_TAKEOVER_SKIN_PACK_SECTION: &str = "Tiny Takeover";
|
||||
|
||||
/// A list of default Minecraft skins to make available to the user, created by Mojang.
|
||||
pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
//
|
||||
@@ -16,6 +26,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
vec![Skin {
|
||||
texture_key: Arc::from("46acd06e8483b176e8ea39fc12fe105eb3a2a4970f5100057e9d84d4b60bdfa7"),
|
||||
name: Some(Arc::from("Alex")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -27,6 +38,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("1abc803022d8300ab7578b189294cce39622d9a404cdc00d3feacfdf45be6981"),
|
||||
name: Some(Arc::from("Alex")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -38,6 +50,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("6ac6ca262d67bcfb3dbc924ba8215a18195497c780058a5749de674217721892"),
|
||||
name: Some(Arc::from("Ari")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -49,6 +62,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("4c05ab9e07b3505dc3ec11370c3bdce5570ad2fb2b562e9b9dd9cf271f81aa44"),
|
||||
name: Some(Arc::from("Ari")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -60,6 +74,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("fece7017b1bb13926d1158864b283b8b930271f80a90482f174cca6a17e88236"),
|
||||
name: Some(Arc::from("Efe")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -71,6 +86,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("daf3d88ccb38f11f74814e92053d92f7728ddb1a7955652a60e30cb27ae6659f"),
|
||||
name: Some(Arc::from("Efe")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -82,6 +98,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("226c617fde5b1ba569aa08bd2cb6fd84c93337532a872b3eb7bf66bdd5b395f8"),
|
||||
name: Some(Arc::from("Kai")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -93,6 +110,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("e5cdc3243b2153ab28a159861be643a4fc1e3c17d291cdd3e57a7f370ad676f3"),
|
||||
name: Some(Arc::from("Kai")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -104,6 +122,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("7cb3ba52ddd5cc82c0b050c3f920f87da36add80165846f479079663805433db"),
|
||||
name: Some(Arc::from("Makena")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -115,6 +134,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("dc0fcfaf2aa040a83dc0de4e56058d1bbb2ea40157501f3e7d15dc245e493095"),
|
||||
name: Some(Arc::from("Makena")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -126,6 +146,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("6c160fbd16adbc4bff2409e70180d911002aebcfa811eb6ec3d1040761aea6dd"),
|
||||
name: Some(Arc::from("Noor")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -137,6 +158,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("90e75cd429ba6331cd210b9bd19399527ee3bab467b5a9f61cb8a27b177f6789"),
|
||||
name: Some(Arc::from("Noor")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -148,6 +170,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("d5c4ee5ce20aed9e33e866c66caa37178606234b3721084bf01d13320fb2eb3f"),
|
||||
name: Some(Arc::from("Steve")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -159,6 +182,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("31f477eb1a7beee631c2ca64d06f8f68fa93a3386d04452ab27f43acdf1b60cb"),
|
||||
name: Some(Arc::from("Steve")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -170,6 +194,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("b66bc80f002b10371e2fa23de6f230dd5e2f3affc2e15786f65bc9be4c6eb71a"),
|
||||
name: Some(Arc::from("Sunny")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -181,6 +206,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("a3bd16079f764cd541e072e888fe43885e711f98658323db0f9a6045da91ee7a"),
|
||||
name: Some(Arc::from("Sunny")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -192,6 +218,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("eee522611005acf256dbd152e992c60c0bb7978cb0f3127807700e478ad97664"),
|
||||
name: Some(Arc::from("Zuri")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -203,6 +230,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("f5dddb41dcafef616e959c2817808e0be741c89ffbfed39134a13e75b811863d"),
|
||||
name: Some(Arc::from("Zuri")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -220,6 +248,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("6c25523e7dabfcaf0dbe32d90fd0c001d5d57ac66206a0595defe9be5947ff08"),
|
||||
name: Some(Arc::from("Globe Alex")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -231,6 +260,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("66206c8f51d13d2d31c54696a58a3e8bcd1e5e7db9888d331d0753129324e4f1"),
|
||||
name: Some(Arc::from("Party Alex")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -242,6 +272,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("6acf91326bd116ce889e461ddb57e92ace07a8367dbd2d191075078fccc3c727"),
|
||||
name: Some(Arc::from("Cardboard Cosplayer")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -253,6 +284,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("b9f7facdca2bf4772fa168e1c3cf7b020124eb1fc82118307d426da1b88c32c5"),
|
||||
name: Some(Arc::from("Creeper Cosplayer")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -264,6 +296,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("b7393199a84eb9e932efa8dda6829423875eb65af76cb82912ade62f93996b9c"),
|
||||
name: Some(Arc::from("Creeper Piñata Cosplayer")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -275,6 +308,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("7cbe449d9d37c111a07a902e322d3869d98790c48f1fa16a24bcbe2d8d73808b"),
|
||||
name: Some(Arc::from("Sheep Cosplayer")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -286,6 +320,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("b182ad5783a343be3e202ac35902270a8d31042fdfd48b849fc99a55a1b60a91"),
|
||||
name: Some(Arc::from("Cake Steve")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -297,6 +332,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("c05e396bbf744082122f77b7277af390d11d2d4e93dd2f8c67942ca9626db24d"),
|
||||
name: Some(Arc::from("Party Steve")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -311,6 +347,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("2007b66a99ae905c81f339e2a0a4bf4b99e9454a485d5164e3e1051c3036ad70"),
|
||||
name: Some(Arc::from("Barn Builder")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -322,6 +359,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("59f2872323bf515aa8d84c00931fbf8170b2cec5138961527c09ffcd06ca4ab2"),
|
||||
name: Some(Arc::from("Bee-Friender")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -333,6 +371,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("7cd85127cbc710a1c9a53c6bb3474f59995c222b9d8c57b293993cc2d8a225aa"),
|
||||
name: Some(Arc::from("Bee-Friender (Alternate)")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -344,6 +383,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("5e4e09eccbce11e701c51bb64b102d688a6ac4018c725dd2b780210aee101b31"),
|
||||
name: Some(Arc::from("Buff Butcher")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -355,6 +395,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("d66ed86ce96a1b63c30f1baac762f638717930866474ac4fce697cdbd0bd6fbb"),
|
||||
name: Some(Arc::from("Buff Butcher (Alternate)")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -366,6 +407,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("b9e9d1b51b4be289b9525d4decd798cb7912e920bac8846a2df70e9ff4f0b1d8"),
|
||||
name: Some(Arc::from("Homestead Healer")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -377,6 +419,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("83e283ab33558baa2cd0184d2e85f090c795a797bdbcb2cc47230c27f23fe9b1"),
|
||||
name: Some(Arc::from("Pig Whisperer")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -388,6 +431,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("e1fc44f1d69fd2864df7b80618a38af4170d4800f2df4fbde81c17b74b2a818b"),
|
||||
name: Some(Arc::from("Pig Whisperer (Alternate)")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -399,6 +443,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("25dc6421d47cad8e2bdf93f56fae9ab06fcfe218c8645c1775ae2e4563c065ad"),
|
||||
name: Some(Arc::from("Ranch Ranger")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -413,6 +458,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("721c05483a435d4362047ccb62e075ef5f001aa63a7e0e2afe03e60759bab91d"),
|
||||
name: Some(Arc::from("Snowfeather")),
|
||||
section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -424,6 +470,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("b914cf5106aaa82409fdd9213fbdb1479b4d65aecc5d5e22b1f25e5744c4c4f7"),
|
||||
name: Some(Arc::from("Stray")),
|
||||
section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -435,6 +482,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("5eb077c54ecfc7e760c36add887b68859d7a3160d331580ff859f7353d959151"),
|
||||
name: Some(Arc::from("Strider")),
|
||||
section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -446,6 +494,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("b271a744ef479018927575952621b110b9c11f62730a95729af7e8591cf8dbf6"),
|
||||
name: Some(Arc::from("Villager 1")),
|
||||
section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -457,6 +506,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("748923629fed7c6ec9462016b4480fa3cff8c16e82ee6fe26d4b707f4de10060"),
|
||||
name: Some(Arc::from("Villager 2")),
|
||||
section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -468,6 +518,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("3d996abc69ea70a20442855e429bf44b45111f9818d0f8c46272e12d12bec218"),
|
||||
name: Some(Arc::from("Wither Skeleton")),
|
||||
section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -482,6 +533,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("6f8fc677cdcd4c6eed67d90c08d23162abc3a3a85357c7636fdf80d874aa857f"),
|
||||
name: Some(Arc::from("Pale Lumberjack")),
|
||||
section: Some(Arc::from(THE_GARDEN_AWAKENS_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -493,6 +545,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("9a0af2b1fd9659480d43132db95cd7d459d1a66480fe42150e132d03b9731573"),
|
||||
name: Some(Arc::from("Creaking")),
|
||||
section: Some(Arc::from(THE_GARDEN_AWAKENS_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -507,6 +560,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("8409954698b6c7741460fdd85d6ec6a5e0a9ad04ade7e2c72c913f02936a607d"),
|
||||
name: Some(Arc::from("Ghast Pilot")),
|
||||
section: Some(Arc::from(CHASE_THE_SKIES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -518,6 +572,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("e12d98dab548e92cad7ac80f92d8fefbb9ca7a1af94aa4f428daf6ef723aa8e0"),
|
||||
name: Some(Arc::from("Ghast Swimmer")),
|
||||
section: Some(Arc::from(CHASE_THE_SKIES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -533,6 +588,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("33aef79a4ca986a2971057d35046e71dce326b645353e6f92d56e1c4bb3b0073"),
|
||||
name: Some(Arc::from("Copper Chemist")),
|
||||
section: Some(Arc::from(THE_COPPER_AGE_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -544,6 +600,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("514b10ff7bc50dd01b5438632815b9e27cfe54064d1a28ef6014f3309d278b38"),
|
||||
name: Some(Arc::from("Copper Welder")),
|
||||
section: Some(Arc::from(THE_COPPER_AGE_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -558,6 +615,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("e0bae80c765f9ef3c3050dd72ea4d4bc53ae00e39c8a376a886d419abdc5dd84"),
|
||||
name: Some(Arc::from("Zombie Horse Onesie")),
|
||||
section: Some(Arc::from(MOUNTS_OF_MAYHEM_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -565,5 +623,43 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
).unwrap()),
|
||||
source: SkinSource::Default,
|
||||
is_equipped: false,
|
||||
},
|
||||
// Tiny Takeover skin pack
|
||||
Skin {
|
||||
texture_key: Arc::from("890044fb07cbca79bb9ffec4d2f15cdd1053e4b554e9a02469e9d0b271f3fdfa"),
|
||||
name: Some(Arc::from("Baby Bee")),
|
||||
section: Some(Arc::from(TINY_TAKEOVER_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAMiklEQVR4nNVbfWwUxxV/e16fP87fNingAAaHr5oA5usPEj4shBJBlFKUtOKPtklVQaUWqUqrVopUihKJP1BLK/FPG7WiTVRFqqqkjQpSShEBDBU1BAMxUMDGGAwE7Duf8fns8/m2+s3s25vb292789kJ/UnreTPzbnbem7czb96MNcqAwQPzDa96fVYxxe+MuNaVvnpZozxgPHjZIH+TzMQ6yKLNvDb9o7za1zMxnDp827O+rT9BUwoIHOug2PB1ojAKrhNVEvlLF5jK+IimVAHrts7xrG/JYAF7X71M+SIpvIkwUYyukz/vlun/wAJg9qrwDJSV5t+8/sRbgDDz6+nllfTFfAKnngQLgLCqFfAcQE+IBVy8M9exbtmsW5NiAWs29tF/jgSlEiB81ddp+doT1H6Gpl4BTwogNFU5WEee0LHOw8wx0vY0G+D7b6Krab9FGt86hw5v8nv6EVv+/GJypiebadvWfFc/wQbHtmz1XKfTJAKTnhNUZaqOE5TEnbV3EPBbdE1KuURNiqBwiKAMt/ZYYPCNdi0SfEXzrmmeCuDOenl7KfyzSzJOpveicsC++UqDrDC/a3QGWePBAsEg89cInb147YwWG15klaMMcwLTLDSn3NZoFxmqFVjKMd8J+HjU7KOnCq0K7zbKbmCBkcISvvPrZuodTpYVNV/T1BleHcH6+q8a615bKNLd8eNWOeh563y0Zv3XDKQpCDs7UWq74p0mfDxq9tFzG02vUXZTUvFz1ZbQKrhM7ZAqRH3jfOrtvCHSwy2brHLQap36u5S2TP609pU63Wm0shXMCWjDUqrCX1+q0YF3bxG9e0vQrm0pQkDAuvIqKy1qPiPK1TKkboIiP3pBfjqq0CqvrgqOEUHHOI+RA86fni5/dcd8SOZXPvdApPGeaEobqiI4j28ebWeyIFWI3t4rWq9Jd11YZBQ1Sxpzgqi3fpNFe3brMKGj8+gkUuvFDuaqYufBo/TO7s3JRkxBuQ17m/t2fUr5wtGMJ+H3mr3gvfWFxrLnK+liq7SXzkGDesJSIbMrNdrbHvfsyKnXyowlG+pooFOjqkb5O6Y/O9FH3V2jFq/6Hqa/dXLMs/3IB8+kjI4+1/yKzVmdV4BsodsL0BFOTxztp+1baul2x5Aom9NURnvb+zI2OhaK0t27EWo9KYVtmFdEgZpASvv291n0ycztq9DCprxOO8aJKOBiazilUx8cGUhawO2BjA0uXBqg/16KiJGesel5UdZ9rNWq+/hvwTQlqO/OFUalaRDOTU7MAk4c7Rf0hs21Is3FAh511lHTxhLq7uIpSqJpYx3db4MVBNPMnumJIG8LOLhaS5vxGmYWUfc9ab7o4EA0LuiB0LiYI9TOL10uVwqtLEHGkI9o3PuFdn58HlBOPBilgmcX0Kl5tw1Yil5TQh2f9NGKl80VyAVjwTHPOSKw/abnnOBjoqpEp790GfSD3/XR/tYRoQTgnY4xUaeiqrrAqiuoJxocHCZfpWyqosrdZZ6xOpLGXxOYY1nOtMY+8ZmABqCYqYYPQkPA/e1jNL+iwHqgBKC2yGcpQaTVsg6KAl/o6jBVLy4VKRBoHqfIjQJ6YVsN3T/WKp6t360XZRhlO/+Mloiog/CYO/BAUcw/1dCFsO1JMwqOGlRTpAnh9vwxKLWkafT7K3GhjD+0xUQdg4VBCkQuFNDgQDFNqx2iFc+aG5GyiCib0TxO1WOGKz+sIRi5TVRGFv+UK+DGYPIlSdpH/aMJGjWjXVGzODouP69+cymHQiBM5cxikVZUlFJgxTgFKEL32+poVmOMPjcGSO+popmrIlTQU5iR32+UkN5TaPHr2woo3h4nfbku07m6mPjE7F9JoqywplDMBeCxoOz4vODDn8ay1B0VhM8G4IMQ+J6Rzm4uJT1YSOGbGvnnPaT+YJz0UBmN0CAN9Y6KfK78EOTfl2eR1qNJAeEqnJguZ/+wdISQhxJU4a3VIZMC5lcUUOdQQihBnQPwzCzxiafGr4mH8yoPJjWMJlKhlGCcBgcjgh4OlZJePEKjwz4KPhynaH00Z34IEigvF0Keem+GUATnUafWi1E3hbfyGaCtqyVjwJwCqgqlWbMFsCGUFGgUjBlUUkBCAaqF7GwqtJZETJCNL8rA3fn3+2nljlqRAqDHriboUnsoJ/5HtU1CQEbk8eOUvIqVK26IFMKDB3n/xqtaTo5QvykcFHEvmqAiHwnhGVzGgBAwaV4a+89Kpwn5xGBCpPAfQGP5y5U/0JAqrJvwQomfythAoFzNX828CgRHpYCY/VMhJfWZvlK1X0vjm7u2go7/9RG1vDJNCjKaoIEiH8HduXA4RM1bpePDNHK58D+mqYX+zwc5LDWR9G1yy+GQcJkhFHwFYd7/CgnvEUJCEKbBA3jxczmEB71qB00p9O8t1Gn7liqx6VFTwF6GFFDrAWHSdm+xRKdEOOFIe/FzOfN/KYjY/OlcETrUYHCq0vZ6L9oNxpVdhnHzF4ZIr+wyEBVWn1z7qjt1PvTZAN19q854ek/fhKIwiAcA/zjUm1YG5artR+4MCRp11a93Z3zfkd2HqLRExhOHoyO0+eeNNOm4+1adwZ3KFQ8PfEX8jneNKs11PNLqO0BnY3mWBZhWkK8FpEE1w4l+CuiIXQHcObf2s33XskVrjeMvVYgHdOyTxSlPrn312TuVLewdztZi2MzBz3v1seOLjUh3csGDpaAMNEYZ54ucomzj/h3iAY6+3UmFT6230tjZNdb8ADpTf3z2An1JcqcXC8tAiBe4o4FZZVYZ3FUWhM0egBU48Rsa0VNvfK4xP2hue+zxhZT3tf0p7kgLfpMX8wSerPG4zQzfZllur1MtiE0dgsD08dhNU+VnhaiKUvkxinuX6wanO6/fFzQe0GodUpx2g4a1ZLrhZq0C5as7HWdft3K1DsLArJGWNZRQ4byk780h7m/MNgwWdqg7Sin8Lem+ujFb9hv80dMhEZQdMdOPWzbRL38sNy+Hf7WJXvAn65ACb3zb+cLGlOGu8v2rkxFStgh1hs6Vn799p7sGXnVfGEIOzo59SWJTnwj/VEK/9GadMWu+nJDu3Bgi0GoKqPWcZx5ANWk7WBCYutMnkInf6ROZdIQONRhQBGhO1dFBmZPLyryqSWNyHO6Qlxwen3Ne53Pln3KEHPwAFs4pb69z66xano2D5cZveXrKUTdoL88v5VjcAxoLtHTfxPx+dBoODXcY5o0y+BB2GiYdC41RLvyJKhn8tMBhLnvA06nc5Ug8TQH5Qt04sULstMqTC78YZY7wqpHeMFH8VqojxIFRjhhnc1rsy1t6jOKSqozeo8qTKz9HeFMivZUkosQQGpFhpBBc+BA5HDNqXpXqiGQLfE58nojIT8rpchb3C9LO/znW73INxj4P+Ec2EPmnE8Xk7ZVY8QnP3+tOneBZOlfhAZws86nyRO8XqOB9Rbb1R97OLV6gT7YFqEizgCzuF7i5xW4xfqvexJaDr0sLAGIP0iwgKwU8vadPw/ocWJ278DhVZiv4Mixg+fcv02+eeV/QP7q5hNp+6/17PZtOuM3UoDvPDVgXKvhIXQUrA0oAjTkCNGL/iAg3rrLfgM7t/B8xAGx9MfJyC7zKihXQ9ssiTsB1SGNny43C8mZr+6y7vVjdCaoztdusLY7YzSN1kcfJT4kuJkMcpQM/fb44hZ4MZIoXHH0znZdjBZv3Lc9uGVSDJPaACQTiuwM4TcKDwxa+S4D7B+qdA/v9g8kA/mkDwiH9YfXfBY0HtFoH8JYZ+ZHToewUoFqDSkMZEBh3B3CHQAXKP7wkL10w7DTfP8gHaiwAKa7RgsYDWq1j4XFnmWMGWr4dsB+u2sGHreqhK5fjnPHU+UXpZ/zmCS8cG3h71t0A1SdQPD3EArYei1mp+n6vuknxBCGIV56FZiXYywEIjxNdcelRPd42vT3M9LgjwEsZ3w1gsGBOAnrVOcYDAHtMwCteADPG3QH18BSC8gEqX7lh1BQlPwNxKBtOHmdrYem9WfmeB1IR5qkw6g0yRNn6DZLOFxr+YGcGYXhHyHE+3iWyktQy5sn0CbBCnG6doKj1w8XWsbYd9rsAyENw8DOd6fw/E3z4owrPeYDL1DqmmYfBVsBwM3d7vZvwTncBkE/eAUjSKtziBuqeQa3XKEu4xQy2zfQZoZj7/QEv4BP42U9eonywouV6TvEBe5me7YvcAibnQgnP+wNTDUyIdm9RTKZK3CAtlmCWa2Et//8am4z7BfkAS2UhyViAWDptmyNeTq2rdYxKGTj5H1CWJkjdsN/kAAAAAElFTkSuQmCC"
|
||||
).unwrap()),
|
||||
source: SkinSource::Default,
|
||||
is_equipped: false,
|
||||
},
|
||||
Skin {
|
||||
texture_key: Arc::from("8d0484011053097a9809f14c0301166981369b3a660150afea1e753ae7e54685"),
|
||||
name: Some(Arc::from("Baby Axolotl")),
|
||||
section: Some(Arc::from(TINY_TAKEOVER_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAOKElEQVR4nGL8//8/Az7wh4EBruDESwhtIY7Q4TnhFl79uwvUGPEqGEjAwMAAAAAA//9iIcV+FYHB7BUyAAMDAwAAAP//IikA8IHXEx1QpEXzDwyYp4gGDAwMAAAAAP//IikA7nyA0CJIWQAGmBk44ey/DN/p5gGKAAMDAwAAAP//YqKGIeixDwqMdxM96e0X0gEDAwMAAAD//yIpAK48gmB0gBz7QwowMDAAAAAA//8iWAsceYmoBWCFICwrgEC+hTKczc3DDKa/fvkLFzt7/y7FtcCD+En/FRbmoZiDTYxkwMDAAAAAAP//IlgGrL/6Bkyr8oqgxP7tzxBxEAB5nI2NkYGNHZKgQOxfv/6jBAQl4JKJC4MCmgHYxEgGDAwMAAAAAP//YvyNIwmA6nyY52EBgAyQAyBQWwSlbUAKYGFgwBmLyLH84ifDfwl2iFpkNkUpgYGBAQAAAP//gpcBb34iBJHZ2DyPTQyXfnSzcOnBBkAe2zT52n9QY0yEHdIoQ2aD5CjKBgwMDAAAAAD//2Ii1rHEAmzmYTMXJgaKTZBnQDQyGyQHil2/XC1Ge8ZcsNrnqw+CMQiAxEByIDX4zEHmI2Ow+E+G/wAAAAD//2J8/gOSBUAFG3ohh63ExwZ05CCiyPpxsYlVZyMOSeKgWBYK08JohYLUvFt1DRwIID6osCbZ/g8MDAAAAAD//2I8/AJ7GbDp7DeGzz//MXz5+Y/hdIwQWExMUwlMv7p+D0w7roaYyMvOxOBnzEVcaBEJYAFALECurYgGDAwMAAAAAP//Yunc8Jjhx/fvDByciLocxFeRl2KQ5GNheP7pD8Ppd28YopZ/Zvg+ywVc0vNPvc+wLJKXIWvbPwYediZwQM3c/wnMfvLqPdwMdDNBfHR6c7osY/G+N/97nURQaBtxzHIHHwAV2OhmgGi8mhgYGAAAAAD//2J06b8JDjlkxwnw84M9AwKgQDhUpg9mw6q5Xz//gWnVmrPgFAJSe+fhMwwPIgN0MRgf1FuEleroNCkBQJYZDAwMAAAAAP//gtcCyI578eIF2GMgAEoBoDodBu6dug0JBKgYDyskm4BSDDazsHkeXc239MlYaVIAWWYwMDAAAAAA///CaAkauZ9BFfjUg8I9d3wF3lB1nXALb158e/QTAwM/AwPDR5j5DAwMsgwMDI8ZGBj4GBjOrTYhyfy3K+tIch8KYGBgAAAAAP//ItgS5OPjI8U8MPj99Q+UhrQEWbkhTWRWbhaIh0EYGYA8DwLo4kQAkPt+/iSz/mZgYAAAAAD//yIYAJ8+keEqqOd/LA+AcCI3wAMBHtvYAEiORECu+8CAgYEBAAAA///CHgCgSIeaS04KAMU0CP+A8rnE2PHaAQb4AgYJwFIX3BhKUgADAwMAAAD//2I0dDv9H+4YdBoXQJIX9sQdQLAxAaH87WQ7EAbQPY4LgLMZEsA7JsnAwAAAAAD//2Ji/LEd7Jl/2usYGF9tZ0DmIwOw/A+IPEgdujw2B4tLsoExJZ779uonWB7dYxB9f+HlDAiA1CCbBdKLFzAwMAAAAAD//wKbCvKM9vs/DAxKnxlu3OOF82+AAgM0xGX8HUUexGc+C2rkgOTD4Z6AORLEBjsubCPEQWhyMIeiOxjdHGSPYvM8MhtUxiB7GJseDMDAwAAAAAD//2LSUPrM8OPrF4anzz4wfPr8lwHGh7GR+cjyUqIfwGz0Eh/meRgbEVOobOyegqiBxToiZpmxephYeZyAgYEBAAAA//9iAXlc8OdPhhfq/AwMDz4zfP4M6uryMtwW/s2g+paB4fPnz2D52+r8DBIPfoDlEfzPDL8lkS2GeAzZQeh8bIEFkUcPSGzqMcVwxTZRKYCBgQEAAAD//wK3BNnZUUtpkKdB4M2bN1hLWGT1IIfDkh66Q3HRuDyGS55mgIGBAQAAAP//Ame2FwocDII3PzLwioiAPQ/ig2L7E9Tz3/TEGLguvWL4CfX4e2hqQHUsat5GF8elDkH/Zfj+6icDCzczirjQrQUU+r8XtxQDAwMAAAD//wK77MeXLwwiUM+DYvzHlz8MP38iQh8kD+rswlIDiA+SB+n5BlXzByP2/jAwsTJhiCMXfCAaJI8sBlOPrg8GYG4EpUJeXl6cHoOpwwsYGBgAAAAA//9iARkidO8pw2UlBgbJN3/BBgvd+8Tw3VKJgek4pN8vdO8bwzslLjANApJP/zI8l2ZmYH/zGexQUKzBAMxDEHGE9SA+pxg7ikdh+mABgawWRoOyIchN6J5B5qPLYVOPFTAwMAAAAAD//2IChRRyaw8Wuh9evgDTyPkdxocZjm4JzOFwTz77C27dgcU/4vcoPoDNMyAxUDMYRMMwrgDBCRgYGAAAAAD//2KBtaWFPjEwwLSBYltAXILh5z1ICgDL3/sGl0fnwzwI6+X9YYDyYa1JaM8PxcNI6vHRsAhBp0GRJqsogdVvj++/AAcOeuGOARgYGAAAAAD//wKXASDDQBpgtMIbFlAVAGkdo8nB1IMAmA/txsI9C6JBHkBvSsPEkQMGJg4DMDk0/egBAIvhqxchU/PYAogYzzMwMDAAAAAA//9iAI0HoGP9wsr/2MSHHf7/nwEAAAD//6La9Dg6yFz74f+rd5BoFBPiY5geLECdhRKXZBADInpPKDOTgYEBAAAA///CCACDoiqwBTD6Ql8bWZaAPL82VQ6sN3j2o/8MDINwdQUDAwMAAAD//8KYHQZ5GOZpcj0PAqBYx8YeVICBgQEAAAD//4IHACzGQcCh9RrBMXZk9dgALPmjswkBSGpBgOe9jP/ByR6G0eUoAQwMDAAAAAD//8K6PuBANWS2BVeAYAsgmMNhNL4UgO5JZICabRgYJIv/M1rrB4KVvD9oC8YgABIDyWEzE5/5KICBgQEAAAD//6JKyR406yHWWiNjzfv/uOSQxXGpAeFnPQz/X61z/P/tkAEKBomB5Ajpx4v//2cAAAAA//+iSdX2MGgihoOwiRETiCB8bZb8/5TaaSjyIDEQjS6OzEeXw8D//zMAAAAA//+iaI2QUQraHAIUyK3NY3wUDJm1BQEQGySGzyxY0gcB5CQ8OZb/v7iDCIPe/QqG1LrpYHGYGIj+8eo5Q2xG3X+YHIgPAiAxGBsnYGBgAAAAAP//wrpEBuSxc3OwT1Dgk4N7dlIGdrm8GeDAIegqJADyiNnXiQynuPPhohxikuAAQRZDBiB5mOcXz2jCbR8DAwMAAAD//0JJFobJp6nSArRkyPn//8nP/6BkD076T37+B4uRYIZ+UypcfUx67X8QJpePE///zwAAAAD//6JJ/v+14c7//+ffoWCQGK5yAORZ+5arKHLIAYCM0dVRhP//ZwAAAAD//6KaQbg8QKyDkdXj8jy5bsGJ//9nAAAAAP//YsJVkJEKPrxdglULqE1h0JyGYge+RhShNggxAN0MnICBgQEAAAD//6JK7KP3HrHFPnqsIushJQtQNVv8/88AAAAA//+iTW/wPWR1B0pMQMVwqT/QhxZr+NSjAZJiHBkwMDAAAAAA///CWg2Ckii5HSHw+gJYyxc8FU7Z+gJK5//xAgYGBgAAAAD//0JJAYQ6OEQDpL4PObPLyAB9TJKqgIGBAQAAAP//QmkJIneDqRUYoGEzZEwqQB70JFb/hs0M/2EYr0IGBgYAAAAA///CWQZQMhaAPM3OB88PxAH0yVKapgAGBgYAAAAA//9iBJXGyJ4FxbyAcAy8YEEvD9D58DyPbX0BrnUGyAsw7IhbX4htehwEsM3/I8d8gC+elWIMDAwAAAAA///CaiquUhVXnfxPdh1kuhw6W/ZX+zsD09Ug8DoCMN8YwgcB0PoCuPyP7Qys3OE41xTBAGzmCCYPW3GCbV3B9Vad/wx6V7AGhvolHQbN6isIvzEwMAAAAAD//yK4X4AQiE4sAxtwVZAFumYAGgjQNQSgKXRc8qA1Bw8kYzFmj7EFAjZ5kNjRNl1GUO8RuTfpwCrwv2DdB4YnK56C+TIR0gwTggQYDvz+gNLjXJsqxwgAAAD//6J4ywxs3QBozQBsPQFo7QAIgNggOdAUPAjA1hTA5EFy2DyHLkZIHuQRWBcalALce54x/HjLwCDiKg3GIDZIDJw6kPQwMDAwAAAAAP//ojgAQFNroLUEoNll2CILEF/20U8wHzbbzHnuKZgNEgPJw/gwgL7gAeZJbNkCPXWAxgJgHur7mc2wZl0vQ0QCA+Pee9PBNAiDxEByMPVgBgMDAwAAAP//osqmKRgAVVWgyUxYwCAD9BIcnU9KjKPLg/r+ME+dO/iGwccri8HYvhnuSRAbJAaSQ1HPwMAAAAAA//8iugzANRBi6RD/H+Rx2BoCEAA1fmBrDECzz6BAQV9TAFuTAJpipwQIiCBNQUPBteusDEb2IvBBEdAACcjzWpq/4XywHAMDAwAAAP//wkgBuHqHuEaBkNcMIItB1hD8BKcEkCdh8qAAQZbHBkB6QIGGnopAAF0cecQHxAZhkEdBHoTxQWyQGDIfrH5GEyMAAAD//8KbAogZGjOyjPiPvHYABP5B1xbAFjGC1hKA5KWlpcGOfyDyB7zGADaRCQMwNq5lOcjiMP7xAwtJbrCBqnNwVc/AwAAAAAD//8JbBuAb+4PJYVs/AFtbgOxgUGAgr9pA9jx6cxfXegB8fGyexCUHb+cwMDAAAAAA//+iuBAEOQQW+zBPgNYWgGIdxgfJw/oD6OpVNOQZtPXVwBjGhnWg0BdioAceSD0uDyN7EmdgMDAwAAAAAP//osp4APoaAoGrbxgEGFiIWl9w58ZDlLl9EMDGxiYGWx+AnKSxAWxyYD3VWowAAAAA//8DAMBt24LWZpwjAAAAAElFTkSuQmCC"
|
||||
).unwrap()),
|
||||
source: SkinSource::Default,
|
||||
is_equipped: false,
|
||||
},
|
||||
// Dandelion Onesie skin pack
|
||||
Skin {
|
||||
texture_key: Arc::from("b240795e214270b5b864cea3cbbcbac2fae60abed5de10229a7567510713355b"),
|
||||
name: Some(Arc::from("Dandelion Onesie")),
|
||||
section: Some(Arc::from(TINY_TAKEOVER_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAANB0lEQVR4nORbf2yVVxl+2xVabm9puaW0ILbABmOMAVN0ijDnjJrgdG7J3KY2bIkhGINLFiOEP4z+Y9gfLjFoJMYESRcnYCBMXDKJ7AdjmWIEtwls/GztpKWlv3vb0m7X+5zvPt99v3O/XxeuyZY9Sznn+875zn2f57znfOc771mFROC1n41m9HVVqkzG+zJu2nG2K/T5R3bdXCYlxJV/fDMzZ/XekrVZHqcSyNopyGtc7Rz2pKXG+5ku84OlJA/EEoA9rq+B9PCESW3ySEstRHlZUwFxeIPcIGJ7gO5xipGoqTRE6+fXFDyDe6UQgT3vd68U3hApgO3+gPYIP/JAkDBFG2j1PMj/cPktcqVv0w33PlARVcF2f0BPhBp/PbnOpF9cdbQk5P0AQUB+TmpnSeaC2HOATgE9LErV23FRKvJApAf8Z3Ktk7k4lR302erdIsMXuqSmabYMd/XKUN97WTVETr0z5T7Tnb5XJnojmy4acP/e/p8ECnCm7U7TI0tbT8QWqAzvebzLmxc3FRSC/J5fngttYP36pCy71aljt6Ov9XpB11mzrbpMv9tBUo97kpYLfSKLUuZe35//JqnbFpu8ngjtNYJuC8Nm+7pnZOvR73gENN00u2mWLwH0NLBgTkY2bnV+sLdjQmY3V8pvtp+VS1fyQuOVWJ1MuPnern5PmygbHUm7dfRv0WgYyQmORhryUjjjn2mTDEUgPGJYbeHPCJRrl+IYAfA600IQHeec9zzIg3hP+7hbhnvbnjxn6iyY59SrnzvTXRvYhFEml52U3mCvIm0jgWNb/yJtB3rkwYaazP6eYXMfefx26wODEgS/tvyEdidBGEMhSKIu5Yzj1Ffvkv2/73AbRx73dB0+13H+vy5hkDfEc2XMU2gtuN+7HQbetq5WQJzkze/nrj+3cbU77u127LZscbbuXm/Schh25uRFSY+OmRSAEBSBaGmu9s0TYyN58khJGCmFYZtoH+QpuO4R3SbGehj6Tp/1XAe1A9gLKopTDsMT1TMcw3IpDR7om3IN+eTaevdh5Gkc6qAN/TzTq5eHDEktDACh8QwF0T2iDcZsfvposJujTM/4fu34CaOX0GYIwFVhNFLtxs23OD2ECQ8TH9yxoaXKnQQBDoGe7quedkAQ99CeFkZfUzjdI9pgwM/NSQL2aMEK2skRtYXRdSpgZENjvTtZ0SMwJMw7PgvM9ph0vMiLjDaYoi0C7aA9tk1heE0j7bHKCezp1XvNNWb7AxsHXcLoee3+fm8Pm2jQfFBw89nW867a18anZPdLP3bL0lPD8nr/86GLjD9tvhy6Rh8ZSIcVy6Nt4fsHL24ZdtsfSY/Ibw9vM/mqqiqT7jn566JWib7LtY4LzhBomjdHescuS7Ho6xrKEh3zfBsk62ZIqsl5C0BYlGugfHpV9OpxWnW5TI6+b9Jk9r/u7m63rLa2VopFRaHx+U9YGDl7xlz3Gh4QBZIHsIDiYkkTtsnzHkSIAy1CY2OjuUcPKBYFAqSaaiQlzocNeqpYD0Avs6dzc6k0L210yzEE8BtsH4jT8zZAHrhRDyj71ZdOZmAQep6GEbj32ftbpf2NUWlZ4bz733z5jx6Dk3WJXN0hl3jHmW5ZuW6x/P3VRfLqibQZAloEXffa+KRJuy71SdOCVIGBjR+vd3sb+NfR/OSH3+jpvMNTv6LymCQTSTn1z/Oy7BM3yxeeqgmdE8pB2ozJ9LBLOk/OccmLl0aNCFoYDRCmWyNPQxcuqM4uVxtkouwrsusPS6RyxjRThrqsl0rNMuQpAq+RavIkTpGQ9vcMmfzRY1fMn4afmL4CgAx6NJmocYkhpXumB6cMkYaWSpMnUdZFD1Io5NnT6B0IB8PgBY8/8o5bh+IiD3KaFK85ztGTSHEPKURBHgKxLWDmzOlu+3g7oDyWALbba5AsiPS0e5fGzqw9zc1TBLgfZv/9exwhMAmCPHp8WlllQX2SgnBIARivXR0i4B5FQR4kifkfq5aVd9RJdV2FazPK8VykAPgHvYkh4JLIpnqmpgckais8BOi6ui5+FIRBHM8BdHdtkDYUZCEcyAHaKwDT+5ns0nlkzPGIbJ69D9JME7U3icNnyLUzlgCmcsLxBHiEGRLqYXhAevA9MwR0w/QeXRc9ibEOETguOSz0RMhnUJcEQQ4kIUjNrIQRB2XIA2dPdHpSikihYSPuzV1Yb9rlRBsqgJ7QOLtzoQKvQKPA6MCU8wPZe+dOtZtyzhManOjQo3DNtXcm5MUji82wYJnGxNikSxBkbZIoQx1c67kCww82oNfRQQDyKEN9bUuoAOwJEAMhMxxy5DW0i8FbwhY2MBa91/nuqJkAMRw2PX7RU8d+a1AIegnsAgHcp5eANMvh5rR9+apaYxuGKNpFGWygEJECmL9E3p3xh2UwgJ5nyonQlGcN0nOGJk9i8ADg29/4tzFGG6SfxX3UZzmXzbwPgDzyZpZXgsMr3zo5aFLsWvmJG4aK5Z+/32ROvTIoy+72rqRwj4CbYawtuX2Dp95bLx90JyS+Ermsncg9jsmSPYc6GGpO6oxTGooU9fRrdeU9j5p8y4oqWXJX3jZsz3F4asxd+DXXUw0OS7gAGzZzrZ9N9zm5lpYWk7a3t3trH3eS2kPOWBwc65Mn7nPIgzQmxfwEOVMOvuR8+WnyAFedek1A0SgMxICX7dh3j4yPj5u1PlNgYGBABgfzHbT3eN5MLInr6upyV6clVAD8k54ckcS0pEmBnned1xuvWYbUD3oy1D0M1+c9vWjhM3lPqPCsDehJfMZPABKECPggwjcBUtTJk49GSUPNH0bECo19EFGq4Oj/1QOevD2ZwStQb2l/0PCh9IBS9T4QKsD1nsDgDi3XAXHa8TsI4WtTLsYXJUJc28v9foD5oJ3UsB/XO7TcD4g6yWHv2wdtgxubAvb+bcQ9PVKulUI+Sl3W8TPSNpD7AVGwSelgh99+f6SgRXiu78GjqB8A+Tgx+Ke/3JjBtwAmQTvsHQXagYnUDmnHbiPGSZKCIUDyQT0MkHyYqwL8TA2L2dnP2Hb4hcwkJuKIVkYDSn3+DtCvwWI8K8hbgg45eOoUycXMAds3PB/a49cL7gcAcYwC+YJApp6UrbnCz+ZiO7IcD+Cwgx7TccQIq0OjuR8QBntYFAQyLbKaYNg8FHetUG43hB9C5PfAxkWBDYTV0e9prgPCQIJRr196h8SAa0OMt0HFvQ0Pm0qbHzguOw+tkSe2iGy6b1Ce2rdUViTnZx5b87bngd+9dqs89oOkbHloUHYc+FT2zgWxCfFoCt4CuBf11tCi2WP74VXfM23sOJIRktr8XWf3eOCKs2uFz3Kgdobzmd66fETanlsvrV+fLlEoW5G8O1QlPwE03hh5JZAYzvIgjfMtYE9eFI0CgGzbc9cMqbo5zu5VkAAoRxnSqGjxR/5zuOSnGXc/9HZGxw5/cWizJCrywZcjPXsizxdgR5nY+cKPTIqNDuDgmV3R3lTEUdrSH+eU/PmC5kXzzE4Sd5biAOR1+Nve+oqDYlaNJRdA79hi7w/baNoDJEILTR7A9hb+6AHSL7EQ1wsqStGIhrOl7RCGAMV6AEDywPV4ABDX7shXk1mUWCe43Hd3dubufN3ZdeUZAp4fsEPoWhSdn7fwQU+9hvlvmtAYYvtaCICxQx1j/P7hVUV/0OkldeCGiLsis8jrBQbLQJ7hKZswUh11InGAu8A6vo+YP8PjEIKkkacgDJEFBT/1wWstSgE3nBSVEPgdX7NFQcAUkWN8+TF4CrL4c4OnCW/AVYfWAcb2XfFUeNw+H8ByHZnyg/1NUcCFJ0X9Htbr/IK1uo9rIWRGDzBxxdxE6MYac3FGe/8f9RBzRGyfy2bGAhhMYW+DtHZ9huaDEHT61OWV8whfAbhsDVpT62sEJOkBNuw3AqDPFTjP3+SJ8aMMr1EGRBEwZegc4PkAlPmdNvO4esDxeV3mK0DB/puP+7AO3B4xOnsOQG/r0yd21IciML5HAbWHMGyOIKkOjbPMbwjEOSGu75f7kdf7A/b6nCnqoC4IMIKsg5XscYrAN4N9SJKBTAiIPMVhpJgRYswZnDtY5ucBhoM1bP24BArgtz/ARngPKeqgrnZhY5Q6V8DJUAuigboYQmgDMX6AngHSw/3OXoIOuLLs8sWrEkTeHrZ6SOt6vgLoBzR5+/ufdTAB6jMENFi7J88esEyf/0FMX8f4+RwPRICsPurCUHpQ79vD1t5RskVyv9kJfO5y+co9gsnJSZN3vv+dvQPsF2CF99Nvdbrjn+PY75yBfY/36T0QgF4Aj8CxfA39v+sAHG4/f+bTvp/DAPcQIAj3EAD9ae0rQDHwC5kHni/IgYbS8IJyFd/3C40bErklMtoKEsBvD8F9PreXcMP7AZ+ZtT5jny/gAWueM7bPF2gBdGwfqfdwg0MU10w9JLL3Xrj07A1x+B8AAAD//25uor4AAAAGSURBVAMAAbiJK4NueeEAAAAASUVORK5CYII="
|
||||
).unwrap()),
|
||||
source: SkinSource::Default,
|
||||
is_equipped: false,
|
||||
}]
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ pub async fn url_to_data_stream(
|
||||
let response = INSECURE_REQWEST_CLIENT
|
||||
.get(url.as_str())
|
||||
.header("Accept", "image/png")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.and_then(|response| response.error_for_status())?;
|
||||
|
||||
@@ -917,6 +917,8 @@ async fn run_credentials(
|
||||
}
|
||||
}
|
||||
|
||||
crate::minecraft_skins::flush_pending_skin_change().await?;
|
||||
|
||||
crate::launcher::launch_minecraft(
|
||||
&java_args,
|
||||
&env_args,
|
||||
|
||||
@@ -48,11 +48,6 @@ pub(crate) async fn connect(
|
||||
async fn stale_data_cleanup(pool: &Pool<Sqlite>) -> crate::Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)"
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)"
|
||||
)
|
||||
|
||||
@@ -20,7 +20,6 @@ use serde_json::json;
|
||||
use sha2::Digest;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::future::Future;
|
||||
use std::hash::{BuildHasherDefault, DefaultHasher};
|
||||
use std::io;
|
||||
@@ -217,6 +216,34 @@ pub(super) static PROFILE_CACHE: Mutex<
|
||||
HashMap<Uuid, ProfileCacheEntry, BuildHasherDefault<DefaultHasher>>,
|
||||
> = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new()));
|
||||
|
||||
const ONLINE_PROFILE_CACHE_MAX_AGE: std::time::Duration =
|
||||
std::time::Duration::from_secs(60);
|
||||
const ONLINE_PROFILE_LIVE_STATE_MAX_AGE: std::time::Duration =
|
||||
std::time::Duration::from_secs(5);
|
||||
const ONLINE_PROFILE_AUTH_ERROR_BACKOFF: std::time::Duration =
|
||||
std::time::Duration::from_secs(60);
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum OnlineProfileCacheIntent {
|
||||
NormalRead,
|
||||
LiveStateRead,
|
||||
RefreshFromMojang,
|
||||
}
|
||||
|
||||
impl OnlineProfileCacheIntent {
|
||||
fn max_age(self) -> std::time::Duration {
|
||||
match self {
|
||||
Self::NormalRead => ONLINE_PROFILE_CACHE_MAX_AGE,
|
||||
Self::LiveStateRead => ONLINE_PROFILE_LIVE_STATE_MAX_AGE,
|
||||
Self::RefreshFromMojang => std::time::Duration::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
fn can_use_stale_on_fetch_error(self) -> bool {
|
||||
matches!(self, Self::LiveStateRead)
|
||||
}
|
||||
}
|
||||
|
||||
impl Credentials {
|
||||
/// Refreshes the authentication tokens for this user if they are expired, or
|
||||
/// very close to expiration.
|
||||
@@ -268,92 +295,133 @@ impl Credentials {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns online profile data when the cached copy is still recent enough.
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn online_profile(&self) -> Option<Arc<MinecraftProfile>> {
|
||||
let mut profile_cache = PROFILE_CACHE.lock().await;
|
||||
self.online_profile_with_cache_intent(
|
||||
OnlineProfileCacheIntent::NormalRead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
loop {
|
||||
match profile_cache.entry(self.offline_profile.id) {
|
||||
Entry::Occupied(entry) => {
|
||||
match entry.get() {
|
||||
ProfileCacheEntry::Hit(profile)
|
||||
if profile.is_fresh() =>
|
||||
{
|
||||
return Some(Arc::clone(profile));
|
||||
}
|
||||
ProfileCacheEntry::Hit(_) => {
|
||||
// The profile is stale, so remove it and try again
|
||||
entry.remove();
|
||||
continue;
|
||||
}
|
||||
// Auth errors must be handled with a backoff strategy because it
|
||||
// has been experimentally found that Mojang quickly rate limits
|
||||
// the profile data endpoint on repeated attempts with bad auth
|
||||
ProfileCacheEntry::AuthErrorBackoff {
|
||||
likely_expired_token,
|
||||
last_attempt,
|
||||
} if &self.access_token != likely_expired_token
|
||||
|| Instant::now()
|
||||
.saturating_duration_since(*last_attempt)
|
||||
> std::time::Duration::from_secs(60) =>
|
||||
{
|
||||
entry.remove();
|
||||
continue;
|
||||
}
|
||||
ProfileCacheEntry::AuthErrorBackoff { .. } => {
|
||||
return None;
|
||||
}
|
||||
/// Returns profile data recent enough for skin and cape state.
|
||||
///
|
||||
/// Reuses a profile read from the last few seconds so opening the skins page
|
||||
/// does not send several identical Mojang requests.
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn online_profile_fresh(&self) -> Option<Arc<MinecraftProfile>> {
|
||||
self.online_profile_with_cache_intent(
|
||||
OnlineProfileCacheIntent::LiveStateRead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetches the online profile from Mojang after a skin or cape change.
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn refresh_online_profile(
|
||||
&self,
|
||||
) -> Option<Arc<MinecraftProfile>> {
|
||||
self.online_profile_with_cache_intent(
|
||||
OnlineProfileCacheIntent::RefreshFromMojang,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn online_profile_with_cache_intent(
|
||||
&self,
|
||||
cache_intent: OnlineProfileCacheIntent,
|
||||
) -> Option<Arc<MinecraftProfile>> {
|
||||
let max_age = cache_intent.max_age();
|
||||
let stale_profile = {
|
||||
let mut profile_cache = PROFILE_CACHE.lock().await;
|
||||
let mut remove_cached_entry = false;
|
||||
|
||||
let stale_profile = if let Some(cache_entry) =
|
||||
profile_cache.get(&self.offline_profile.id)
|
||||
{
|
||||
match cache_entry {
|
||||
ProfileCacheEntry::Hit(profile)
|
||||
if profile.is_fresh(max_age) =>
|
||||
{
|
||||
return Some(Arc::clone(profile));
|
||||
}
|
||||
ProfileCacheEntry::Hit(profile) => {
|
||||
Some(Arc::clone(profile))
|
||||
}
|
||||
// Auth errors must be handled with a backoff strategy because it
|
||||
// has been experimentally found that Mojang quickly rate limits
|
||||
// the profile data endpoint on repeated attempts with bad auth
|
||||
ProfileCacheEntry::AuthErrorBackoff {
|
||||
likely_expired_token,
|
||||
last_attempt,
|
||||
} if &self.access_token != likely_expired_token
|
||||
|| Instant::now()
|
||||
.saturating_duration_since(*last_attempt)
|
||||
> ONLINE_PROFILE_AUTH_ERROR_BACKOFF =>
|
||||
{
|
||||
remove_cached_entry = true;
|
||||
None
|
||||
}
|
||||
ProfileCacheEntry::AuthErrorBackoff { .. } => {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
match minecraft_profile(&self.access_token).await {
|
||||
Ok(profile) => {
|
||||
let profile = Arc::new(profile);
|
||||
let cache_entry =
|
||||
ProfileCacheEntry::Hit(Arc::clone(&profile));
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// When fetching a profile for the first time, the player UUID may
|
||||
// be unknown (i.e., set to a dummy value), so make sure we don't
|
||||
// cache it in the wrong place
|
||||
if entry.key() != &profile.id {
|
||||
profile_cache.insert(profile.id, cache_entry);
|
||||
} else {
|
||||
entry.insert(cache_entry);
|
||||
}
|
||||
if remove_cached_entry {
|
||||
profile_cache.remove(&self.offline_profile.id);
|
||||
}
|
||||
|
||||
return Some(profile);
|
||||
}
|
||||
Err(
|
||||
err @ MinecraftAuthenticationError::DeserializeResponse {
|
||||
status_code: StatusCode::UNAUTHORIZED,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
tracing::warn!(
|
||||
"Failed to fetch online profile for UUID {} likely due to stale credentials, backing off: {err}",
|
||||
self.offline_profile.id
|
||||
);
|
||||
stale_profile
|
||||
};
|
||||
|
||||
// We have to assume the player UUID key we have is correct here, which
|
||||
// should always be the case assuming a non-adversarial server. In any
|
||||
// case, any cache poisoning is inconsequential due to the entry expiration
|
||||
// and the fact that we use at most one single dummy UUID
|
||||
entry.insert(ProfileCacheEntry::AuthErrorBackoff {
|
||||
likely_expired_token: self.access_token.clone(),
|
||||
last_attempt: Instant::now(),
|
||||
});
|
||||
match minecraft_profile(&self.access_token).await {
|
||||
Ok(profile) => {
|
||||
let profile = Arc::new(profile);
|
||||
let cache_entry = ProfileCacheEntry::Hit(Arc::clone(&profile));
|
||||
|
||||
return None;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Failed to fetch online profile for UUID {}: {err}",
|
||||
self.offline_profile.id
|
||||
);
|
||||
let mut profile_cache = PROFILE_CACHE.lock().await;
|
||||
if self.offline_profile.id != profile.id {
|
||||
profile_cache.remove(&self.offline_profile.id);
|
||||
}
|
||||
profile_cache.insert(profile.id, cache_entry);
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(profile)
|
||||
}
|
||||
Err(
|
||||
err @ MinecraftAuthenticationError::DeserializeResponse {
|
||||
status_code: StatusCode::UNAUTHORIZED,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
tracing::warn!(
|
||||
"Failed to fetch online profile for UUID {} likely due to stale credentials, backing off: {err}",
|
||||
self.offline_profile.id
|
||||
);
|
||||
|
||||
let mut profile_cache = PROFILE_CACHE.lock().await;
|
||||
profile_cache.insert(
|
||||
self.offline_profile.id,
|
||||
ProfileCacheEntry::AuthErrorBackoff {
|
||||
likely_expired_token: self.access_token.clone(),
|
||||
last_attempt: Instant::now(),
|
||||
},
|
||||
);
|
||||
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Failed to fetch online profile for UUID {}: {err}",
|
||||
self.offline_profile.id
|
||||
);
|
||||
|
||||
if cache_intent.can_use_stale_on_fetch_error() {
|
||||
stale_profile
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -717,6 +785,8 @@ impl DeviceTokenPair {
|
||||
const MICROSOFT_CLIENT_ID: &str = "00000000402b5328";
|
||||
const AUTH_REPLY_URL: &str = "https://login.live.com/oauth20_desktop.srf";
|
||||
const REQUESTED_SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
|
||||
pub const MINECRAFT_SERVICES_USER_AGENT: &str =
|
||||
"Modrinth App (support@modrinth.com; https://modrinth.com/app)";
|
||||
|
||||
pub struct RequestWithDate<T> {
|
||||
pub date: DateTime<Utc>,
|
||||
@@ -1051,6 +1121,7 @@ async fn minecraft_token(
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.post("https://api.minecraftservices.com/launcher/login")
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
|
||||
.json(&json!({
|
||||
"platform": "PC_LAUNCHER",
|
||||
"xtoken": format!("XBL3.0 x={uhs};{token}"),
|
||||
@@ -1224,10 +1295,10 @@ impl MinecraftProfile {
|
||||
/// from the Mojang API: the vanilla launcher was seen refreshing profile
|
||||
/// data every 60 seconds when re-entering the skin selection screen, and
|
||||
/// external applications may change this data at any time.
|
||||
fn is_fresh(&self) -> bool {
|
||||
fn is_fresh(&self, max_age: std::time::Duration) -> bool {
|
||||
self.fetch_time.is_some_and(|last_profile_fetch_time| {
|
||||
Instant::now().saturating_duration_since(last_profile_fetch_time)
|
||||
< std::time::Duration::from_secs(60)
|
||||
< max_age
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1279,6 +1350,7 @@ async fn minecraft_profile(
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.get("https://api.minecraftservices.com/minecraft/profile")
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
|
||||
.bearer_auth(token)
|
||||
// Profiles may be refreshed periodically in response to user actions,
|
||||
// so we want each refresh to be fast
|
||||
@@ -1327,12 +1399,13 @@ async fn minecraft_entitlements(
|
||||
token: &str,
|
||||
) -> Result<MinecraftEntitlements, MinecraftAuthenticationError> {
|
||||
let res = auth_retry(|| {
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.get(format!("https://api.minecraftservices.com/entitlements/license?requestId={}", Uuid::new_v4()))
|
||||
.header("Accept", "application/json")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
})
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.get(format!("https://api.minecraftservices.com/entitlements/license?requestId={}", Uuid::new_v4()))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
})
|
||||
.await.map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements })?;
|
||||
|
||||
let status = res.status();
|
||||
|
||||
@@ -5,81 +5,31 @@ use super::MinecraftSkinVariant;
|
||||
|
||||
pub mod mojang_api;
|
||||
|
||||
/// Represents the default cape for a Minecraft player.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DefaultMinecraftCape {
|
||||
/// The UUID of a cape for a Minecraft player, which comes from its profile.
|
||||
///
|
||||
/// This UUID may or may not be different for every player, even if they refer to the same cape.
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
impl DefaultMinecraftCape {
|
||||
pub async fn set(
|
||||
minecraft_user_id: Uuid,
|
||||
cape_id: Uuid,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
let cape_id = cape_id.as_hyphenated();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)",
|
||||
minecraft_user_id, cape_id
|
||||
)
|
||||
.execute(&mut *db.acquire().await?)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
minecraft_user_id: Uuid,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Option<Self>> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?",
|
||||
minecraft_user_id
|
||||
)
|
||||
.fetch_optional(&mut *db.acquire().await?)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
minecraft_user_id: Uuid,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?",
|
||||
minecraft_user_id
|
||||
)
|
||||
.execute(&mut *db.acquire().await?)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a custom skin for a Minecraft player.
|
||||
/// Represents a saved skin row for a Minecraft player.
|
||||
///
|
||||
/// The same player and `texture_key` always point to the same saved skin.
|
||||
/// Changing the model variant or cape updates that saved skin instead of
|
||||
/// creating a second copy. Bundled default skins with a cape are also stored
|
||||
/// here so the cape can stay associated with the default skin card.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CustomMinecraftSkin {
|
||||
/// The key for the texture skin, which is akin to a hash that identifies it.
|
||||
/// The key for the skin texture, which is akin to a hash that identifies it.
|
||||
pub texture_key: String,
|
||||
/// The variant of the skin model.
|
||||
pub variant: MinecraftSkinVariant,
|
||||
/// The UUID of the cape that this skin uses, which should match one of the
|
||||
/// cape UUIDs the player has in its profile.
|
||||
///
|
||||
/// If `None`, the skin does not have an explicit cape set, and the default
|
||||
/// cape for this player, if any, should be used.
|
||||
/// If `None`, the skin is saved without a cape.
|
||||
pub cape_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
struct CustomMinecraftSkinRow {
|
||||
texture_key: String,
|
||||
variant: MinecraftSkinVariant,
|
||||
cape_id: Option<Hyphenated>,
|
||||
}
|
||||
|
||||
impl CustomMinecraftSkin {
|
||||
pub async fn add(
|
||||
minecraft_user_id: Uuid,
|
||||
@@ -95,24 +45,59 @@ impl CustomMinecraftSkin {
|
||||
let mut transaction = db.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)",
|
||||
texture_key, texture
|
||||
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
texture_key
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)",
|
||||
minecraft_user_id, texture_key, variant, cape_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
"INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)",
|
||||
texture_key, texture
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)",
|
||||
minecraft_user_id, texture_key, variant, cape_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_by_texture(
|
||||
minecraft_user_id: Uuid,
|
||||
texture_key: &str,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Option<Self>> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
sqlx::query_as!(
|
||||
CustomMinecraftSkinRow,
|
||||
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \
|
||||
FROM custom_minecraft_skins \
|
||||
WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
texture_key
|
||||
)
|
||||
.fetch_optional(&mut *db.acquire().await?)
|
||||
.await?
|
||||
.map(|row| {
|
||||
Ok(Self {
|
||||
texture_key: row.texture_key,
|
||||
variant: row.variant,
|
||||
cape_id: row.cape_id.map(Uuid::from),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn get_many(
|
||||
minecraft_user_id: Uuid,
|
||||
offset: u32,
|
||||
@@ -165,12 +150,11 @@ impl CustomMinecraftSkin {
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
let cape_id = self.cape_id.map(|id| id.hyphenated());
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM custom_minecraft_skins \
|
||||
WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id IS ?",
|
||||
minecraft_user_id, self.texture_key, self.variant, cape_id
|
||||
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
self.texture_key
|
||||
)
|
||||
.execute(&mut *db.acquire().await?)
|
||||
.await?;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user