feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,452 @@
<script setup lang="ts">
import { type Component, computed, useId } from 'vue'
import {
lobeModelAvatarBrands,
lobeModelIconMappings,
openAIModelBackgrounds,
} from '@/data/lobeModelIcons'
import { lobeProviderIcons } from '@/data/lobeProviderIcons'
import CodeFlowLogo from './CodeFlowLogo.vue'
import LobeBrandCombine from './LobeBrandCombine.vue'
const props = withDefaults(
defineProps<{
kind: 'model' | 'provider-avatar' | 'provider-combine' | 'provider-wordmark'
value: string
size?: number
}>(),
{ size: 24 },
)
const componentModules = import.meta.glob(
'../../../../node_modules/@lobehub/icons-static-svg/icons/*.svg',
{
eager: true,
import: 'default',
query: '?component',
},
) as Record<string, Component>
const iconComponents = Object.fromEntries(
Object.entries(componentModules).map(([path, component]) => [
path.split('/').pop()?.replace('.svg', ''),
component,
]),
) as Record<string, Component>
const soraGradientId = useId()
const providerConfig = computed(() => lobeProviderIcons[props.value.toLocaleLowerCase()])
const isCodeFlow = computed(() => props.value.toLocaleLowerCase() === 'codeflow')
const avatarConfig = computed(() => providerConfig.value?.avatar)
const avatarComponent = computed(() => {
if (!providerConfig.value || !avatarConfig.value) return undefined
const suffix = avatarConfig.value.asset === 'color' ? '-color' : ''
return (
iconComponents[`${providerConfig.value.slug}${suffix}`] ??
iconComponents[providerConfig.value.slug]
)
})
const combineSize = computed(() => props.size * (providerConfig.value?.combine.multiple ?? 1))
const combineBrand = computed(
() => providerConfig.value?.combine.brand ?? providerConfig.value?.slug ?? props.value,
)
const specialAsset = computed(() => {
switch (providerConfig.value?.combine.kind) {
case 'google':
return iconComponents['google-brand-color']
case 'v0':
return iconComponents.v0
default:
return undefined
}
})
const modelMapping = computed(() => {
const model = props.value.toLocaleLowerCase()
return lobeModelIconMappings.find(({ keywords }) =>
keywords.some((keyword) => new RegExp(keyword, 'i').test(model)),
)
})
const modelAvatar = computed(() =>
modelMapping.value ? lobeModelAvatarBrands[modelMapping.value.slug] : undefined,
)
const modelBackground = computed(() => {
const openAIType = modelMapping.value?.openAIType
return openAIType ? openAIModelBackgrounds[openAIType] : modelAvatar.value?.background
})
const modelComponent = computed(() => {
if (!modelMapping.value || !modelAvatar.value) return undefined
if (
['aihubmix', 'dalle', 'lg', 'nanobanana', 'sora', 'stepfun'].includes(modelMapping.value.slug)
) {
return undefined
}
const suffix = modelAvatar.value.asset === 'color' ? '-color' : ''
return (
iconComponents[`${modelMapping.value.slug}${suffix}`] ?? iconComponents[modelMapping.value.slug]
)
})
const modelAvatarStyle = computed(() => ({
background: modelBackground.value ?? 'var(--color-button-bg)',
color: modelAvatar.value?.color ?? 'var(--color-secondary)',
height: `${props.size}px`,
width: `${props.size}px`,
}))
const avatarStyle = computed(() => ({
background: avatarConfig.value?.background,
borderRadius: `${Math.floor(props.size * 0.1)}px`,
color: avatarConfig.value?.color ?? (isCodeFlow.value ? 'var(--color-contrast)' : undefined),
height: `${props.size}px`,
width: `${props.size}px`,
}))
</script>
<template>
<span
v-if="kind === 'provider-avatar'"
class="lobe-provider-avatar inline-flex flex-none items-center justify-center overflow-hidden"
:class="{
'black-background': avatarConfig?.background === '#000',
'white-background': avatarConfig?.background === '#fff',
}"
:style="avatarStyle"
aria-hidden="true"
>
<svg
v-if="avatarConfig?.variant === 'ai302'"
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 24 24"
:style="{ transform: `scale(${avatarConfig.multiple})` }"
>
<path
d="M11.88 21.5a4.49 4.49 0 01-2.772-.959 4.516 4.516 0 01-1.71-3.024 4.513 4.513 0 01-.007-1.086 4.46 4.46 0 01-.859.078A4.537 4.537 0 012 11.975c0-2.5 2.036-4.54 4.532-4.54.356 0 .7.041 1.034.12A4.543 4.543 0 0112.07 2.5c2.497 0 4.525 2.04 4.525 4.54 0 .145-.005.286-.02.43a4.596 4.596 0 011.125-.056 4.542 4.542 0 014.18 4.864 4.507 4.507 0 01-1.562 3.103 4.484 4.484 0 01-3.287 1.085 4.54 4.54 0 01-.647-.091c0 .01.007.019.007.028a4.522 4.522 0 01-.922 3.349 4.496 4.496 0 01-3.019 1.713 4.53 4.53 0 01-.57.035zm-2.512-5.993a2.893 2.893 0 00-.366 1.812 2.906 2.906 0 003.244 2.538 2.899 2.899 0 001.943-1.1 2.89 2.89 0 00.59-2.15 2.905 2.905 0 00-.562-1.396 4.516 4.516 0 01-.542-.641.807.807 0 01.19-1.128.805.805 0 011.126.19c.061.085.122.163.19.24a.846.846 0 01.155.14c.028.034.05.067.077.1.474.429 1.08.692 1.731.74 1.605.12 3-1.09 3.118-2.693a2.913 2.913 0 00-2.681-3.124 2.884 2.884 0 00-1.739.423.804.804 0 01-.9.085.82.82 0 01-.324-1.107c.234-.425.359-.905.359-1.396a2.92 2.92 0 00-2.914-2.918A2.919 2.919 0 009.15 7.04c0 .576.168 1.13.485 1.608.016.024.03.053.043.077a4.52 4.52 0 011.379 3.25c0 1.426-.66 2.707-1.689 3.54v-.008zm-2.843-6.45a2.914 2.914 0 00-2.906 2.918c0 1.61 1.3 2.92 2.906 2.92a2.92 2.92 0 000-5.838z"
/>
</svg>
<svg
v-else-if="avatarConfig?.variant === 'aihubmix'"
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 24 24"
:style="{ transform: `scale(${avatarConfig.multiple})` }"
>
<path
clip-rule="evenodd"
d="M10.853 6.285c.141-.972.455-2.221.942-3.747l.205-.63.206.63c.486 1.526.8 2.775.942 3.748.108.713.108 1.62 0 2.72-.109 1.105-.109 2.019 0 2.741a4.218 4.218 0 001.452 2.635 4.224 4.224 0 002.855 1.07c1.2 0 2.224-.423 3.074-1.268.846-.845 1.273-1.865 1.282-3.06.005-1.058.114-2.225.326-3.5.104-.637.21-1.17.319-1.6l.142-.581.255.538A11.88 11.88 0 0124 10.883v.24c0 1.63-.314 3.186-.942 4.669a12.017 12.017 0 01-6.39 6.39 11.848 11.848 0 01-4.668.942c-1.629 0-3.185-.314-4.668-.942a12.016 12.016 0 01-6.39-6.39A11.848 11.848 0 010 11.124v-.241A11.881 11.881 0 011.148 5.98l.255-.538.141.58c.11.43.215.964.32 1.601.212 1.275.32 2.442.325 3.5.01 1.195.437 2.215 1.282 3.06.85.845 1.875 1.268 3.075 1.268a4.225 4.225 0 002.854-1.07 4.218 4.218 0 001.453-2.635c.108-.722.108-1.636 0-2.741-.109-1.1-.109-2.007 0-2.72zM12 20.936a9.651 9.651 0 004.661-1.176 9.643 9.643 0 002.677-2.113c.095-.107-.017-.27-.154-.232a6.574 6.574 0 01-1.73.227 6.402 6.402 0 01-3.293-.893c-.82-.478-1.5-1.099-2.04-1.862a.149.149 0 00-.242 0 6.427 6.427 0 01-2.04 1.862 6.402 6.402 0 01-3.293.893 6.574 6.574 0 01-1.73-.227c-.137-.037-.248.125-.154.232a9.643 9.643 0 002.677 2.113A9.651 9.651 0 0012 20.935z"
/>
</svg>
<svg
v-else-if="avatarConfig?.variant === 'stepfun'"
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 24 24"
:style="{ transform: `scale(${avatarConfig.multiple})` }"
>
<path
d="M1 23h6.335v-6.337H1V23zM8.832 23h6.336v-6.337H8.832V23zM8.832 15.17h6.336V8.835H8.832v6.337zM8.832 7.342h6.336V1.005H8.832v6.337zM16.665 7.337H23V1h-6.335v6.337z"
/>
</svg>
<component
:is="avatarComponent"
v-else-if="avatarComponent && avatarConfig"
:style="{
height: `${size}px`,
transform: `scale(${avatarConfig.multiple})`,
width: `${size}px`,
}"
/>
<CodeFlowLogo v-else-if="isCodeFlow" :size="size" />
<span v-else>{{ value.slice(0, 1).toLocaleUpperCase() }}</span>
</span>
<span
v-else-if="kind === 'provider-combine'"
class="inline-flex min-w-0 flex-none items-center justify-start text-contrast"
:style="{ gap: `${combineSize / 3}px`, height: `${size * 1.5}px` }"
aria-hidden="true"
>
<template v-if="providerConfig?.combine.kind === 'bedrock'">
<component
:is="iconComponents['aws-color']"
class="special-square"
:style="{ height: `${combineSize * 1.2}px`, width: `${combineSize * 1.2}px` }"
/>
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<LobeBrandCombine brand="bedrock" :size="combineSize" />
</template>
<template v-else-if="providerConfig?.combine.kind === 'google'">
<component :is="specialAsset" :style="{ height: `${combineSize * 0.95}px`, width: 'auto' }" />
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<LobeBrandCombine brand="gemini" :size="combineSize" />
</template>
<template v-else-if="providerConfig?.combine.kind === 'azure'">
<LobeBrandCombine brand="azure" :size="combineSize * 0.92" />
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<LobeBrandCombine brand="openai" :size="combineSize" />
</template>
<template v-else-if="providerConfig?.combine.kind === 'anthropic'">
<component
:is="iconComponents['anthropic-text']"
:style="{ height: `${combineSize * 0.75}px`, width: 'auto' }"
/>
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<LobeBrandCombine brand="claude" :size="combineSize" />
</template>
<template v-else-if="providerConfig?.combine.kind === 'qwen'">
<LobeBrandCombine brand="alibabacloud" :size="combineSize" />
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<LobeBrandCombine brand="qwen" :size="combineSize * 0.9" />
</template>
<template v-else-if="providerConfig?.combine.kind === 'wenxin'">
<LobeBrandCombine brand="baiducloud" :size="combineSize * 0.9" />
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<LobeBrandCombine brand="wenxin" extra="千帆" :size="combineSize" />
</template>
<template v-else-if="providerConfig?.combine.kind === 'cloudflare'">
<LobeBrandCombine brand="cloudflare" :size="combineSize * 1.1" />
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<LobeBrandCombine brand="workersai" :size="combineSize * 0.9" />
</template>
<template v-else-if="providerConfig?.combine.kind === 'v0'">
<LobeBrandCombine brand="vercel" :size="combineSize * 0.85" />
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<component
:is="specialAsset"
class="special-square"
:style="{ height: `${combineSize * 1.1}px`, width: `${combineSize * 1.1}px` }"
/>
</template>
<LobeBrandCombine
v-else-if="providerConfig?.combine.kind === 'ollamacloud'"
brand="ollama"
:extra-font-size="size * 0.78"
:extra-margin-left="size * 0.2"
extra="Cloud"
:size="size * 1.16"
/>
<CodeFlowLogo v-else-if="isCodeFlow" :size="combineSize" />
<LobeBrandCombine v-else :brand="combineBrand" :size="combineSize" />
</span>
<span
v-else-if="kind === 'provider-wordmark'"
class="lobe-provider-wordmark"
:style="{
color: 'var(--color-contrast)',
gap: `${Math.max(4, Math.round(size * 0.214))}px`,
}"
aria-hidden="true"
>
<CodeFlowLogo v-if="isCodeFlow" :size="size" />
<LobeBrandCombine v-else :brand="combineBrand" :size="combineSize" />
<span
v-if="isCodeFlow"
class="font-semibold"
:style="{ fontSize: `${Math.round(size * 0.536)}px`, letterSpacing: '-0.01em' }"
>CodeFlow</span
>
</span>
<span
v-else
class="model-icon inline-flex flex-none items-center justify-center overflow-hidden rounded-full"
:class="{
'black-background': modelBackground === '#000',
fallback: !modelMapping,
'white-background': modelBackground === '#fff',
}"
:style="modelAvatarStyle"
aria-hidden="true"
>
<svg
v-if="modelMapping?.slug === 'aihubmix'"
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 24 24"
:style="{ transform: `scale(${modelAvatar?.multiple})` }"
>
<path
clip-rule="evenodd"
d="M10.853 6.285c.141-.972.455-2.221.942-3.747l.205-.63.206.63c.486 1.526.8 2.775.942 3.748.108.713.108 1.62 0 2.72-.109 1.105-.109 2.019 0 2.741a4.218 4.218 0 001.452 2.635 4.224 4.224 0 002.855 1.07c1.2 0 2.224-.423 3.074-1.268.846-.845 1.273-1.865 1.282-3.06.005-1.058.114-2.225.326-3.5.104-.637.21-1.17.319-1.6l.142-.581.255.538A11.88 11.88 0 0124 10.883v.24c0 1.63-.314 3.186-.942 4.669a12.017 12.017 0 01-6.39 6.39 11.848 11.848 0 01-4.668.942c-1.629 0-3.185-.314-4.668-.942a12.016 12.016 0 01-6.39-6.39A11.848 11.848 0 010 11.124v-.241A11.881 11.881 0 011.148 5.98l.255-.538.141.58c.11.43.215.964.32 1.601.212 1.275.32 2.442.325 3.5.01 1.195.437 2.215 1.282 3.06.85.845 1.875 1.268 3.075 1.268a4.225 4.225 0 002.854-1.07 4.218 4.218 0 001.453-2.635c.108-.722.108-1.636 0-2.741-.109-1.1-.109-2.007 0-2.72zM12 20.936a9.651 9.651 0 004.661-1.176 9.643 9.643 0 002.677-2.113c.095-.107-.017-.27-.154-.232a6.574 6.574 0 01-1.73.227 6.402 6.402 0 01-3.293-.893c-.82-.478-1.5-1.099-2.04-1.862a.149.149 0 00-.242 0 6.427 6.427 0 01-2.04 1.862 6.402 6.402 0 01-3.293.893 6.574 6.574 0 01-1.73-.227c-.137-.037-.248.125-.154.232a9.643 9.643 0 002.677 2.113A9.651 9.651 0 0012 20.935z"
fill-rule="evenodd"
/>
</svg>
<svg
v-else-if="modelMapping?.slug === 'lg'"
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 24 24"
:style="{ transform: `scale(${modelAvatar?.multiple})` }"
>
<path
d="M19.167 19.18a10.082 10.082 0 002.97-7.169v-.549l-.498.003h-6.68v1.12h6.038l-.002.034a9.038 9.038 0 01-8.993 8.41 8.96 8.96 0 01-6.375-2.642 8.962 8.962 0 01-2.64-6.376c0-2.406.939-4.67 2.64-6.373A8.961 8.961 0 0112 2.998l.572.007V1.882l-.57-.007A10.15 10.15 0 001.864 12.011c0 2.708 1.055 5.253 2.97 7.17A10.079 10.079 0 0012 22.15a10.078 10.078 0 007.171-2.97m-6.6-2.942V6.656h-1.14v10.705h3.529V16.24H12.57zM9.703 8.183a1.533 1.533 0 10-3.066-.01 1.533 1.533 0 003.066.01z"
/>
</svg>
<svg
v-else-if="modelMapping?.slug === 'nanobanana'"
viewBox="0 0 24 24"
:style="{ transform: `scale(${modelAvatar?.multiple})` }"
>
<path
d="M12.453 1.026c.826-.118 1.574.17 2.207.684.625.508 1.157 1.25 1.596 2.102.797 1.548 1.332 3.555 1.535 5.487a3.689 3.689 0 013.263 1.107l1.634 1.704c.645.674.23 1.89-.775 1.89H19.88l.002.088v5.664l-.014.237c-.028.234-.1.457-.228.647-.177.263-.445.44-.769.485-.613.087-1.256-.302-1.815-.942l-.002-.002-1.387-1.602c-1.57 1.96-4.028 3.442-6.387 4.08-2.409.65-4.976.471-6.262-1.34H2.49c-.823 0-1.49-.668-1.491-1.49l.008-.153A1.492 1.492 0 012.27 18.35c.203-1.603 1.343-2.938 2.804-3.625l.326-.141A6.95 6.95 0 006.554 14H3.587c-1.004 0-1.42-1.218-.775-1.89l1.633-1.704a3.68 3.68 0 015.105-.241 8.88 8.88 0 00.4-1.615c.099-.696.112-1.431.11-2.532 0-.88-.037-2.013.215-2.952.13-.48.342-.946.702-1.319.366-.38.855-.631 1.476-.72z"
stroke="#451D1C"
/>
<path
d="M1.5 19.824c0-.548.444-.992.991-.992h.744a.991.991 0 010 1.983H2.49a.991.991 0 01-.991-.991z"
fill="#F3AD61"
/>
<path
d="M14.837 13.5h7.076c.522 0 .784-.657.413-1.044l-1.634-1.704a3.183 3.183 0 00-4.636 0l-1.633 1.704c-.37.385-.107 1.044.414 1.044zM3.587 13.5h7.076c.521 0 .784-.659.414-1.044l-1.635-1.704a3.183 3.183 0 00-4.636 0l-1.633 1.704c-.37.385-.107 1.044.414 1.044z"
fill="#F9C23C"
/>
<path
d="M12.525 1.521c3.69-.53 5.97 8.923 4.309 12.744-1.662 3.82-5.248 4.657-9.053 6.152a3.49 3.49 0 01-1.279.244c-1.443 0-2.227 1.187-2.774-.282-.707-1.9.22-4.031 2.069-4.757 2.014-.79 3.084-2.308 3.89-4.364.82-2.096.877-2.956.873-5.241-.003-1.827-.123-4.195 1.965-4.496z"
fill="#FEEFC2"
/>
<path
d="M16.834 14.264l-7.095-3.257c-.815 1.873-2.29 3.308-4.156 4.043-2.16.848-3.605 3.171-2.422 5.54 2.364 4.727 13.673-.05 13.673-6.325z"
fill="#FCD53F"
/>
<path
clip-rule="evenodd"
d="M13.68 12.362c.296.094.46.41.365.707-1.486 4.65-5.818 6.798-9.689 6.997a.562.562 0 11-.057-1.124c3.553-.182 7.372-2.138 8.674-6.216a.562.562 0 01.707-.364z"
fill="#F9C23C"
fill-rule="evenodd"
/>
<path
d="M17.43 19.85l-7.648-8.835h6.753c1.595.08 2.846 1.433 2.846 3.073v5.664c0 .997-.898 1.302-1.95.098z"
fill="#FFF478"
/>
</svg>
<svg
v-else-if="modelMapping?.slug === 'sora'"
viewBox="0 0 24 24"
:style="{ transform: `scale(${modelAvatar?.multiple})` }"
>
<path
d="M8.968 11.147a.408.408 0 110 .816.408.408 0 010-.816z"
:fill="`url(#${soraGradientId})`"
/>
<path
clip-rule="evenodd"
d="M7.21 8.748c.045-.012.087.003.128.044.195.2.39.398.587.596a.15.15 0 00.061.035l.81.209c.056.014.09.043.102.088.013.046-.002.09-.043.13l-.596.585a.139.139 0 00-.021.03.134.134 0 00-.015.033c-.07.27-.14.54-.208.81-.014.055-.044.09-.09.102-.045.012-.088-.003-.128-.045-.195-.199-.39-.397-.587-.595a.158.158 0 00-.062-.035l-.81-.209c-.056-.014-.09-.044-.103-.09-.011-.044.004-.087.045-.128.2-.194.398-.39.596-.585a.12.12 0 00.022-.03.134.134 0 00.014-.032c.07-.27.14-.54.208-.81.014-.056.044-.09.09-.103z"
:fill="`url(#${soraGradientId})`"
fill-rule="evenodd"
/>
<path
d="M15.827 9.31a.409.409 0 110 .817.409.409 0 010-.818z"
:fill="`url(#${soraGradientId})`"
/>
<path
clip-rule="evenodd"
d="M14.071 6.915c.046-.012.09.003.13.044.194.2.388.398.583.596a.155.155 0 00.062.036l.807.21c.056.014.09.045.103.09.012.045-.003.088-.045.128l-.596.583a.168.168 0 00-.036.061l-.21.808c-.014.056-.044.09-.09.103-.045.011-.088-.004-.128-.045-.194-.2-.389-.398-.583-.596a.12.12 0 00-.03-.022.12.12 0 00-.032-.014l-.808-.21c-.056-.014-.09-.044-.102-.09-.012-.045.003-.087.044-.128.2-.194.398-.388.596-.583a.119.119 0 00.022-.03.132.132 0 00.015-.032l.21-.806c.014-.057.043-.09.088-.103z"
:fill="`url(#${soraGradientId})`"
fill-rule="evenodd"
/>
<path
clip-rule="evenodd"
d="M8.086.457a6.102 6.102 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.116.116 0 00.107.029c1.409-.346 2.762-.224 4.062.366l.061.029.155.077c1.357.703 2.33 1.769 2.918 3.197.278.68.418 1.388.421 2.127a5.65 5.65 0 01-.18 1.631.164.164 0 00.04.154 5.98 5.98 0 011.577 2.892c.386 1.901-.008 3.614-1.182 5.14l-.181.22a6.062 6.062 0 01-2.936 1.85.16.16 0 00-.106.103c-.255.736-.512 1.364-.988 1.992-1.199 1.582-2.962 2.462-4.948 2.45-1.583-.007-2.986-.586-4.21-1.736a.142.142 0 00-.14-.031c-.518.167-1.04.191-1.605.185a5.923 5.923 0 01-2.594-.622 6.057 6.057 0 01-2.146-1.781c-.203-.27-.404-.522-.552-.821a7.742 7.742 0 01-.494-1.283 6.108 6.108 0 01-.017-3.065.163.163 0 00.007-.074.112.112 0 00-.036-.063 5.954 5.954 0 01-1.38-2.202 5.193 5.193 0 01-.333-1.59 6.911 6.911 0 01.188-2.13c.45-1.485 1.309-2.65 2.578-3.494.282-.188.549-.334.8-.439a8.21 8.21 0 01.862-.303.128.128 0 00.087-.087 6.014 6.014 0 011.104-2.155C6.315 1.463 7.132.846 8.086.457zm.965 7.647c-1.154-.82-2.73-.413-3.311.875-.301.666-.36 1.368-.178 2.106l.145.586.26.95c.105.533.31 1.02.612 1.462l.03.043c.16.189.335.362.524.518 1.386 1.139 3.275.379 3.652-1.323l.05-.213.012-.08c.06-.4.042-.792-.053-1.175a47.673 47.673 0 00-.546-2.024c-.217-.738-.616-1.313-1.197-1.725zm7.104-1.646c-.862-.802-2.191-.831-3.047-.026-.334.314-.566.736-.697 1.265a3.47 3.47 0 000 1.635l.014.054.055.18c.127.42.245.834.353 1.241.112.423.202.706.27.85.574 1.206 1.82 2.074 3.177 1.522 1.261-.514 1.641-2.01 1.355-3.22-.043-.183-.09-.365-.14-.546a34.426 34.426 0 00-.428-1.573c-.162-.508-.466-.968-.912-1.382z"
:fill="`url(#${soraGradientId})`"
fill-rule="evenodd"
/>
<defs>
<linearGradient
:id="soraGradientId"
gradientUnits="userSpaceOnUse"
x1="9.145"
x2="14.959"
y1="0"
y2="24.022"
>
<stop stop-color="#fff" />
<stop offset="1" stop-color="#6BB6FE" />
</linearGradient>
</defs>
</svg>
<svg
v-else-if="modelMapping?.slug === 'stepfun'"
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 24 24"
:style="{ transform: `scale(${modelAvatar?.multiple})` }"
>
<path
d="M1 23h6.335v-6.337H1V23zM8.832 23h6.336v-6.337H8.832V23zM8.832 15.17h6.336V8.835H8.832v6.337zM8.832 7.342h6.336V1.005H8.832v6.337zM16.665 7.337H23V1h-6.335v6.337z"
/>
</svg>
<component
:is="modelComponent"
v-else-if="modelComponent && modelAvatar"
:style="{
height: `${size}px`,
transform: `scale(${modelAvatar.multiple})`,
width: `${size}px`,
}"
/>
<svg
v-else-if="!modelMapping"
class="default-model-icon"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<path d="M12 18V5" />
<path d="M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4" />
<path d="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5" />
<path d="M17.997 5.125a4 4 0 0 1 2.526 5.77" />
<path d="M18 18a4 4 0 0 0 2-7.464" />
<path d="M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517" />
<path d="M6 18a4 4 0 0 1-2-7.464" />
<path d="M6.003 5.125a4 4 0 0 0-2.526 5.77" />
</svg>
</span>
</template>
<style scoped>
.lobe-provider-avatar.white-background,
.model-icon.white-background {
box-shadow: 0 0 0 1px rgb(0 0 0 / 5%) inset;
}
:global(html.dark-mode) .lobe-provider-avatar.black-background,
:global(html.dark-mode) .model-icon.black-background {
box-shadow: 0 0 0 1px rgb(255 255 255 / 10%) inset;
}
.lobe-provider-avatar > :deep(svg),
.lobe-provider-avatar > svg {
display: block;
flex: none;
height: 100%;
width: 100%;
}
.lobe-provider-wordmark {
display: inline-flex;
flex: none;
align-items: center;
justify-content: flex-start;
}
.special-square {
display: block;
flex: none;
object-fit: contain;
}
.model-icon > :deep(svg),
.model-icon > svg {
display: block;
flex: none;
width: 100%;
height: 100%;
}
.default-model-icon {
transform: scale(0.6);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,561 @@
<script setup lang="ts">
import {
CheckIcon,
ChevronDownIcon,
CopyIcon,
EditIcon,
ExternalIcon,
GithubIcon,
GlobeIcon,
HeartHandshakeIcon,
IssuesIcon,
ScaleIcon,
UsersIcon,
} from '@modrinth/assets'
import { Avatar, defineMessages, NewButton as Button, useVIntl } from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
import AfdianIcon from '@/assets/external/afdian.png'
import QqIcon from '@/assets/external/qq.svg?component'
import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue'
import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue'
import { AxolotlBrandConfig } from '@/config'
import { contributors, type TeamMember, teamMembers } from '@/data/about'
import AboutScene from '../AboutScene.vue'
import { type AboutMemberExperience, getAboutMemberExperience } from './about-member-experiences'
import QqChannelIcon from './QqChannelIcon.vue'
const { formatMessage } = useVIntl()
const version = await getVersion()
const copied = ref(false)
const experienceHost = ref<HTMLElement>()
const activeMemberExperience = shallowRef<AboutMemberExperience>()
const pressingMemberName = ref<string>()
let longPressTimer: ReturnType<typeof window.setTimeout> | undefined
let pressStart = { x: 0, y: 0 }
let suppressNextMemberClick = false
const replayOnboarding = inject<(mode: 'main' | 'instance') => Promise<void>>('replayOnboarding')
const licenseUrl = `${AxolotlBrandConfig.repositoryUrl}/blob/main/LICENSE`
const thirdPartyLicensesUrl = `${AxolotlBrandConfig.repositoryUrl}/tree/main/third-party/licenses`
async function copyQqGroupNumber() {
await navigator.clipboard.writeText(AxolotlBrandConfig.qqGroupNumber)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
function cancelMemberLongPress() {
if (longPressTimer) window.clearTimeout(longPressTimer)
longPressTimer = undefined
pressingMemberName.value = undefined
}
function startMemberLongPress(member: TeamMember, event: PointerEvent) {
const experience = getAboutMemberExperience(member.experience)
if (!experience || event.button !== 0) return
cancelMemberLongPress()
pressStart = { x: event.clientX, y: event.clientY }
pressingMemberName.value = member.name
longPressTimer = window.setTimeout(async () => {
activeMemberExperience.value = experience
suppressNextMemberClick = true
cancelMemberLongPress()
await nextTick()
experienceHost.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, experience.longPressDuration)
}
function moveMemberLongPress(event: PointerEvent) {
if (!longPressTimer) return
if (Math.hypot(event.clientX - pressStart.x, event.clientY - pressStart.y) > 8) {
cancelMemberLongPress()
}
}
function handleMemberClick(event: MouseEvent) {
if (!suppressNextMemberClick) return
suppressNextMemberClick = false
event.preventDefault()
event.stopPropagation()
}
function handleMemberContextMenu(member: TeamMember, event: MouseEvent) {
if (getAboutMemberExperience(member.experience)) event.preventDefault()
}
function closeMemberExperience() {
activeMemberExperience.value = undefined
}
const gameModal = ref<InstanceType<typeof EasterEggGameModal> | null>(null)
const contributorsModal = ref<InstanceType<typeof EasterEggContributorsModal> | null>(null)
let typedBuffer = ''
const secretCodes = ['cyf112233', 'cxkcxkckx']
const konamiSequence = [
'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown',
'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight',
'KeyB', 'KeyA',
]
let konamiIndex = 0
function handleEasterEggKeydown(event: KeyboardEvent) {
typedBuffer = (typedBuffer + event.key).toLowerCase()
const maxCodeLen = Math.max(...secretCodes.map((c) => c.length))
if (typedBuffer.length > maxCodeLen) {
typedBuffer = typedBuffer.slice(-maxCodeLen)
}
if (secretCodes.some((code) => typedBuffer.endsWith(code))) {
typedBuffer = ''
gameModal.value?.show()
return
}
const expected = konamiSequence[konamiIndex]
if (event.code === expected) {
konamiIndex++
if (konamiIndex === konamiSequence.length) {
konamiIndex = 0
contributorsModal.value?.show()
}
} else {
konamiIndex = event.code === konamiSequence[0] ? 1 : 0
}
}
function onEasterEggOpenGame() {
gameModal.value?.show()
}
document.addEventListener('keydown', handleEasterEggKeydown)
onScopeDispose(() => document.removeEventListener('keydown', handleEasterEggKeydown))
onScopeDispose(cancelMemberLongPress)
const messages = defineMessages({
productTitle: {
id: 'app.settings.about.product-title',
defaultMessage: 'About {productName}',
},
productDescription: {
id: 'app.settings.about.description',
defaultMessage: 'Your last launcher.',
},
version: {
id: 'app.settings.about.version',
defaultMessage: 'Version {version}',
},
replayOnboarding: {
id: 'app.settings.about.replay-onboarding',
defaultMessage: 'Replay tour',
},
developmentTeam: {
id: 'app.settings.about.development-team',
defaultMessage: 'Development team',
},
communitySupport: {
id: 'app.settings.about.community-support',
defaultMessage: 'Project & community',
},
projectWebsite: {
id: 'app.settings.about.project-website',
defaultMessage: 'Project website',
},
repository: {
id: 'app.settings.about.repository',
defaultMessage: 'Source code',
},
reportIssue: {
id: 'app.settings.about.report-issue',
defaultMessage: 'Issues & feedback',
},
qqGroup: {
id: 'app.settings.about.qq-group',
defaultMessage: 'Player QQ group',
},
qqChannel: {
id: 'app.settings.about.qq-channel',
defaultMessage: 'QQ channel',
},
copyQqGroup: {
id: 'app.settings.about.copy-qq-group',
defaultMessage: 'Copy group number',
},
copiedQqGroup: {
id: 'app.settings.about.copied-qq-group',
defaultMessage: 'Group number copied',
},
afdian: {
id: 'app.settings.about.afdian',
defaultMessage: 'Support on Afdian',
},
afdianDescription: {
id: 'app.settings.about.afdian-description',
defaultMessage: 'Help support continued development',
},
survey: {
id: 'app.settings.about.survey',
defaultMessage: 'Community survey',
},
surveyDescription: {
id: 'app.settings.about.survey-description',
defaultMessage: 'Help us improve Axolotl Launcher',
},
licenseAttribution: {
id: 'app.settings.about.license-attribution',
defaultMessage: 'License & attribution',
},
attribution: {
id: 'app.settings.about.attribution',
defaultMessage: 'Axolotl Launcher is a modified version of the open-source Modrinth codebase.',
},
notAffiliated: {
id: 'app.settings.about.not-affiliated',
defaultMessage:
'Modrinth is a trademark of Rinth, Inc. Axolotl Launcher is not affiliated with or endorsed by Rinth, Inc.',
},
originalSource: {
id: 'app.settings.about.original-source',
defaultMessage: 'Original Modrinth source',
},
projectLicense: {
id: 'app.settings.about.project-license',
defaultMessage: 'Project license (GPL-3.0)',
},
thirdPartyLicenses: {
id: 'app.settings.about.third-party-licenses',
defaultMessage: 'Third-party licenses',
},
contributors: {
id: 'app.settings.about.contributors',
defaultMessage: 'Contributors',
},
contributorsCount: {
id: 'app.settings.about.contributors-count',
defaultMessage: '{count, plural, one {# contributor} other {# contributors}}',
},
})
const projectLinks = [
{
href: AxolotlBrandConfig.website,
label: messages.projectWebsite,
icon: GlobeIcon,
},
{
href: AxolotlBrandConfig.repositoryUrl,
label: messages.repository,
icon: GithubIcon,
},
{
href: AxolotlBrandConfig.supportUrl,
label: messages.reportIssue,
icon: IssuesIcon,
},
{
href: AxolotlBrandConfig.qqChannelUrl,
label: messages.qqChannel,
icon: QqChannelIcon,
},
]
</script>
<template>
<div class="about-page flex flex-col gap-6">
<section id="settings-target-about-product" tabindex="-1" class="about-panel">
<div class="flex flex-col items-center gap-4">
<div
ref="experienceHost"
class="relative m-0 w-full overflow-hidden h-64 rounded-xl"
style="
mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
"
>
<AboutScene />
<component
:is="activeMemberExperience?.component"
v-if="activeMemberExperience"
@exit="closeMemberExperience"
/>
</div>
<div class="min-w-0 text-center">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{
formatMessage(messages.productTitle, {
productName: AxolotlBrandConfig.productName,
})
}}
</h2>
<p class="m-0 mt-1 text-secondary">
{{ formatMessage(messages.version, { version }) }}
</p>
</div>
</div>
<p class="m-0 mt-3 text-center text-primary">
{{ formatMessage(messages.productDescription) }}
</p>
</section>
<section>
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
<UsersIcon class="size-5 text-secondary" />
{{ formatMessage(messages.developmentTeam) }}
</h3>
<ul class="m-0 grid list-none grid-cols-2 gap-3 p-0 sm:grid-cols-3">
<li v-for="member in teamMembers" :key="member.name" class="min-w-0">
<component
:is="member.url ? 'a' : 'div'"
:href="member.url"
:target="member.url ? '_blank' : undefined"
:rel="member.url ? 'noopener noreferrer' : undefined"
class="flex min-w-0 select-none flex-col items-center gap-3 rounded-xl bg-surface-4 p-4"
:class="[
member.url ? 'transition-colors hover:bg-surface-5' : 'cursor-default',
pressingMemberName === member.name ? 'ring-4 ring-brand-shadow' : '',
]"
@pointerdown="startMemberLongPress(member, $event)"
@pointermove="moveMemberLongPress"
@pointerup="cancelMemberLongPress"
@pointercancel="cancelMemberLongPress"
@dragstart="cancelMemberLongPress"
@click="handleMemberClick"
@contextmenu="handleMemberContextMenu(member, $event)"
>
<Avatar :src="member.avatarUrl" :alt="member.name" size="4rem" circle no-shadow />
<span class="block truncate text-center font-semibold text-contrast">{{
member.name
}}</span>
</component>
</li>
</ul>
</section>
<section>
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
<HeartHandshakeIcon class="size-5 text-secondary" />
{{ formatMessage(messages.communitySupport) }}
</h3>
<div class="grid gap-3 sm:grid-cols-2">
<a
v-for="link in projectLinks"
:key="link.label"
:href="link.href"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-4 transition-colors hover:bg-surface-5"
>
<span
class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-contrast"
>
<component :is="link.icon" class="size-6" />
</span>
<span class="min-w-0 flex-1 font-semibold text-contrast">
{{ formatMessage(link.label) }}
</span>
<ExternalIcon class="size-5 shrink-0 text-secondary" />
</a>
<button
type="button"
:disabled="copied"
:aria-label="
copied ? formatMessage(messages.copiedQqGroup) : formatMessage(messages.copyQqGroup)
"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-4 text-left transition-colors hover:bg-surface-5 disabled:cursor-default"
@click="copyQqGroupNumber"
>
<span
class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-contrast"
>
<QqIcon class="size-6" />
</span>
<span class="min-w-0 flex-1">
<span class="block font-semibold text-contrast">
{{ formatMessage(messages.qqGroup) }}
</span>
<span class="block text-sm text-secondary">
{{ AxolotlBrandConfig.qqGroupNumber }}
</span>
</span>
<span class="shrink-0" aria-live="polite">
<CheckIcon v-if="copied" class="size-5 text-green" />
<CopyIcon v-else class="size-5 text-secondary" />
<span class="sr-only">
{{
copied ? formatMessage(messages.copiedQqGroup) : formatMessage(messages.copyQqGroup)
}}
</span>
</span>
</button>
<a
:href="AxolotlBrandConfig.sponsorUrl"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-4 transition-colors hover:bg-surface-5"
>
<span class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-surface-2">
<img :src="AfdianIcon" alt="" class="size-7 object-contain" />
</span>
<span class="min-w-0 flex-1">
<span class="block font-semibold text-contrast">
{{ formatMessage(messages.afdian) }}
</span>
<span class="block text-sm text-secondary">
{{ formatMessage(messages.afdianDescription) }}
</span>
</span>
<ExternalIcon class="size-5 shrink-0 text-secondary" />
</a>
<a
:href="AxolotlBrandConfig.surveyUrl"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-4 transition-colors hover:bg-surface-5 sm:col-span-2"
>
<span
class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-contrast"
>
<EditIcon class="size-6" />
</span>
<span class="min-w-0 flex-1">
<span class="block font-semibold text-contrast">
{{ formatMessage(messages.survey) }}
</span>
<span class="block text-sm text-secondary">
{{ formatMessage(messages.surveyDescription) }}
</span>
</span>
<ExternalIcon class="size-5 shrink-0 text-secondary" />
</a>
</div>
</section>
<section>
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
<ScaleIcon class="size-5 text-secondary" />
{{ formatMessage(messages.licenseAttribution) }}
</h3>
<div class="about-panel about-panel-compact">
<p class="m-0 text-primary">
{{ formatMessage(messages.attribution) }}
</p>
<p class="m-0 mt-2 text-sm text-secondary">
{{ formatMessage(messages.notAffiliated) }}
</p>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<a
:href="licenseUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg bg-surface-4 px-3 py-2 text-sm font-semibold text-contrast transition-colors hover:bg-surface-5"
>
{{ formatMessage(messages.projectLicense) }}
<ExternalIcon class="size-4 text-secondary" />
</a>
<a
:href="thirdPartyLicensesUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg bg-surface-4 px-3 py-2 text-sm font-semibold text-contrast transition-colors hover:bg-surface-5"
>
{{ formatMessage(messages.thirdPartyLicenses) }}
<ExternalIcon class="size-4 text-secondary" />
</a>
<a
href="https://github.com/modrinth/code"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg bg-surface-4 px-3 py-2 text-sm font-semibold text-contrast transition-colors hover:bg-surface-5"
>
{{ formatMessage(messages.originalSource) }}
<ExternalIcon class="size-4 text-secondary" />
</a>
</div>
</section>
<details class="group pt-4 about-settings-details">
<summary
class="flex cursor-pointer list-none items-center gap-2 text-base font-semibold text-contrast [&::-webkit-details-marker]:hidden"
>
<UsersIcon class="size-5 text-secondary" />
<span>{{ formatMessage(messages.contributors) }}</span>
<span class="rounded-full bg-surface-4 px-2 py-0.5 text-xs text-secondary">
{{ formatMessage(messages.contributorsCount, { count: contributors.length }) }}
</span>
<ChevronDownIcon
class="ml-auto size-5 text-secondary transition-transform group-open:rotate-180"
/>
</summary>
<div class="mt-3 flex flex-wrap gap-2">
<a
v-for="contributor in contributors"
:key="contributor.name"
:href="contributor.url"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-1.5 rounded-full bg-surface-4 py-1 pl-1 pr-2.5 transition-colors hover:bg-surface-5"
>
<Avatar
:src="contributor.avatarUrl"
:alt="contributor.name"
size="1.5rem"
circle
no-shadow
loading="lazy"
/>
<span class="truncate text-sm text-primary">{{ contributor.name }}</span>
</a>
</div>
</details>
<div id="settings-target-about-replay-tour" tabindex="-1" class="flex flex-wrap gap-2">
<Button type="base" @click="replayOnboarding?.('main')">
{{ formatMessage(messages.replayOnboarding) }}
</Button>
</div>
</div>
<EasterEggGameModal ref="gameModal" />
<EasterEggContributorsModal ref="contributorsModal" @open-game="onEasterEggOpenGame" />
</template>
<style scoped>
.about-settings-details {
border-top: 1px solid
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
}
.about-panel {
padding: 1.25rem;
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
border-radius: var(--radius-md);
background: var(--surface-2);
}
.about-panel-compact {
padding: var(--gap-lg);
}
.about-page :deep(.rounded-xl.bg-surface-4) {
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
border-radius: var(--radius-md);
background: var(--surface-2);
}
.about-page :deep(.rounded-xl.bg-surface-2) {
border-radius: var(--radius-sm);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,77 @@
<script setup lang="ts">
import { useId } from 'vue'
withDefaults(defineProps<{ size?: number }>(), { size: undefined })
const cutsArm1Id = useId()
const cutsArm2Id = useId()
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 64 64"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
:style="size ? { height: `${size}px`, width: `${size}px` } : undefined"
aria-hidden="true"
>
<defs>
<mask :id="cutsArm1Id" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64">
<rect width="64" height="64" fill="#fff" />
<path
fill="none"
stroke="#000"
stroke-width="8.6"
stroke-linecap="round"
stroke-linejoin="round"
d="M47.6 40.7c-3.4 5.8-10.7 7.9-16.3 4.7-3.7-2.1-5.5-5.8-5.4-10.6l.3-12"
/>
<path
fill="none"
stroke="#000"
stroke-width="8.6"
stroke-linecap="round"
stroke-linejoin="round"
d="M16.4 40.6c-3.4-5.8-1.3-13.2 4.4-16.4 3.7-2.1 7.8-1.7 11.9.8l10.4 6.3"
/>
</mask>
<mask :id="cutsArm2Id" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64">
<rect width="64" height="64" fill="#fff" />
<path
fill="none"
stroke="#000"
stroke-width="8.6"
stroke-linecap="round"
stroke-linejoin="round"
d="M16.4 40.6c-3.4-5.8-1.3-13.2 4.4-16.4 3.7-2.1 7.8-1.7 11.9.8l10.4 6.3"
/>
</mask>
</defs>
<g
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
transform="translate(-1.91 -1.11) scale(1.0875)"
>
<path
stroke="currentColor"
stroke-width="5.4"
:mask="`url(#${cutsArm1Id})`"
d="M32 14c6.8 0 12.2 5.2 12.2 11.7 0 4.1-2.2 7.6-6.3 10l-10.4 6.1"
/>
<path
stroke="currentColor"
stroke-width="5.4"
:mask="`url(#${cutsArm2Id})`"
d="M47.6 40.7c-3.4 5.8-10.7 7.9-16.3 4.7-3.7-2.1-5.5-5.8-5.4-10.6l.3-12"
/>
<path
stroke="currentColor"
stroke-width="5.4"
d="M16.4 40.6c-3.4-5.8-1.3-13.2 4.4-16.4 3.7-2.1 7.8-1.7 11.9.8l10.4 6.3"
/>
</g>
</svg>
</template>

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import AppearanceSettings from './AppearanceSettings.vue'
import ResourceManagementSettings from './ResourceManagementSettings.vue'
</script>
<template>
<div class="flex flex-col gap-6">
<AppearanceSettings scope="content-downloads" />
<ResourceManagementSettings scope="content-downloads" />
</div>
</template>

View File

@ -0,0 +1,152 @@
<script setup lang="ts">
import { Combobox, defineMessages, injectNotificationManager, Toggle, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import { type AIState, getAIState, sharedAIState } from '@/helpers/ai'
import { get_crash_analysis_ai_settings, update_crash_analysis_ai_settings } from '@/helpers/logs'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
type CrashAnalysisAISettings = {
enabled: boolean
provider_id: string
model_id: string
}
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const settings = ref<CrashAnalysisAISettings>({ enabled: false, provider_id: '', model_id: '' })
const loading = ref(true)
const saving = ref(false)
const messages = defineMessages({
title: { id: 'app.crash-analysis.ai.settings.title', defaultMessage: 'Crash AI explanation' },
description: {
id: 'app.crash-analysis.ai.settings.description',
defaultMessage:
'Optionally send a sanitized and shortened crash context to a model configured in AI Providers.',
},
enabled: {
id: 'app.crash-analysis.ai.settings.enabled',
defaultMessage: 'Enable AI explanations',
},
enabledDescription: {
id: 'app.crash-analysis.ai.settings.enabled-description',
defaultMessage: 'Local rule-based crash analysis remains available without AI.',
},
provider: { id: 'app.crash-analysis.ai.settings.provider', defaultMessage: 'AI provider' },
model: { id: 'app.crash-analysis.ai.settings.model', defaultMessage: 'Text model' },
noProviders: {
id: 'app.crash-analysis.ai.settings.no-providers',
defaultMessage: 'Enable a provider and a text model above before using AI crash explanations.',
},
})
const emptyState: AIState = { settings: { enabled: false }, catalog_source: '', providers: [] }
const aiState = computed(() => sharedAIState.value ?? emptyState)
const providers = computed(() =>
aiState.value.providers.filter(
(provider) => provider.enabled && provider.models.some((model) => model.enabled),
),
)
const providerOptions = computed(() =>
providers.value.map((provider) => ({
value: provider.provider_id,
label: provider.custom_name || provider.provider_id,
})),
)
const modelOptions = computed(
() =>
providers.value
.find((provider) => provider.provider_id === settings.value.provider_id)
?.models.filter((model) => model.enabled)
.map((model) => ({ value: model.id, label: model.name || model.id })) ?? [],
)
async function save(next: CrashAnalysisAISettings): Promise<void> {
saving.value = true
try {
await update_crash_analysis_ai_settings(next)
settings.value = next
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
function updateEnabled(enabled: boolean): void {
void save({ ...settings.value, enabled })
}
function updateProvider(providerId: string): void {
const provider = providers.value.find((item) => item.provider_id === providerId)
const modelId = provider?.models.find((model) => model.enabled)?.id ?? ''
void save({ ...settings.value, provider_id: providerId, model_id: modelId })
}
function updateModel(modelId: string): void {
void save({ ...settings.value, model_id: modelId })
}
onMounted(async () => {
try {
const [nextSettings] = await Promise.all([get_crash_analysis_ai_settings(), getAIState()])
settings.value = nextSettings
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
})
</script>
<template>
<SettingsSection
v-if="!loading"
id="settings-target-crash-analysis-ai"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
>
<SettingsRow>
<template #label>{{ formatMessage(messages.enabled) }}</template>
<template #description>{{ formatMessage(messages.enabledDescription) }}</template>
<template #control>
<Toggle
id="crash-analysis-ai-enabled"
:model-value="settings.enabled"
:disabled="saving || !aiState.settings.enabled || !providers.length"
@update:model-value="updateEnabled(!!$event)"
/>
</template>
</SettingsRow>
<template v-if="providers.length">
<SettingsRow>
<template #label>{{ formatMessage(messages.provider) }}</template>
<template #control>
<Combobox
id="crash-analysis-ai-provider"
:model-value="settings.provider_id"
:options="providerOptions"
:disabled="saving"
@update:model-value="updateProvider(String($event))"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.model) }}</template>
<template #control>
<Combobox
id="crash-analysis-ai-model"
:model-value="settings.model_id"
:options="modelOptions"
:disabled="saving || !settings.provider_id"
@update:model-value="updateModel(String($event))"
/>
</template>
</SettingsRow>
</template>
<p v-else class="m-0 p-4 text-sm text-secondary">{{ formatMessage(messages.noProviders) }}</p>
</SettingsSection>
</template>

View File

@ -0,0 +1,318 @@
<script setup lang="ts">
import { defineMessages, StyledInput, Toggle, useVIntl } from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import CrashAnalysisAISettings from './CrashAnalysisAISettings.vue'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
fullscreen: { id: 'app.settings.defaults.fullscreen', defaultMessage: 'Fullscreen' },
fullscreenDescription: {
id: 'app.settings.defaults.fullscreen-description',
defaultMessage: 'Overwrites the options.txt file to start in full screen when launched.',
},
maximizeWindow: {
id: 'app.settings.defaults.maximize-window',
defaultMessage: 'Maximize window',
},
maximizeWindowDescription: {
id: 'app.settings.defaults.maximize-window-description',
defaultMessage: 'Maximize the Minecraft window when it starts.',
},
maximizeWindowUnsupported: {
id: 'app.settings.defaults.maximize-window.unsupported',
defaultMessage: 'Not supported on this operating system.',
},
width: { id: 'app.settings.defaults.width', defaultMessage: 'Width' },
widthDescription: {
id: 'app.settings.defaults.width-description',
defaultMessage: 'The width of the game window when launched.',
},
widthPlaceholder: {
id: 'app.settings.defaults.width-placeholder',
defaultMessage: 'Enter width...',
},
height: { id: 'app.settings.defaults.height', defaultMessage: 'Height' },
heightDescription: {
id: 'app.settings.defaults.height-description',
defaultMessage: 'The height of the game window when launched.',
},
heightPlaceholder: {
id: 'app.settings.defaults.height-placeholder',
defaultMessage: 'Enter height...',
},
environmentVariables: {
id: 'app.settings.defaults.environment-variables',
defaultMessage: 'Environment variables',
},
environmentVariablesPlaceholder: {
id: 'app.settings.defaults.environment-variables-placeholder',
defaultMessage: 'Enter environment variables...',
},
preLaunchHook: {
id: 'app.settings.defaults.pre-launch-hook',
defaultMessage: 'Pre-launch hook',
},
preLaunchPlaceholder: {
id: 'app.settings.defaults.pre-launch-placeholder',
defaultMessage: 'Enter pre-launch command...',
},
preLaunchDescription: {
id: 'app.settings.defaults.pre-launch-description',
defaultMessage: 'Run before the instance is launched.',
},
wrapperHook: { id: 'app.settings.defaults.wrapper-hook', defaultMessage: 'Wrapper hook' },
wrapperPlaceholder: {
id: 'app.settings.defaults.wrapper-placeholder',
defaultMessage: 'Enter wrapper command...',
},
wrapperDescription: {
id: 'app.settings.defaults.wrapper-description',
defaultMessage: 'Wrapper command for launching Minecraft.',
},
postExitHook: { id: 'app.settings.defaults.post-exit-hook', defaultMessage: 'Post-exit hook' },
postExitPlaceholder: {
id: 'app.settings.defaults.post-exit-placeholder',
defaultMessage: 'Enter post-exit command...',
},
postExitDescription: {
id: 'app.settings.defaults.post-exit-description',
defaultMessage: 'Run after the game closes.',
},
lightweightMode: {
id: 'app.appearance-settings.lightweight-mode.title',
defaultMessage: 'Enter lightweight mode after launching a game',
},
lightweightModeDescription: {
id: 'app.appearance-settings.lightweight-mode.description',
defaultMessage:
'Closes the launcher webview after Minecraft starts to reduce memory use. Restore it from the system tray.',
},
minimizeLauncher: {
id: 'app.appearance-settings.minimize-launcher.title',
defaultMessage: 'Minimize launcher',
},
minimizeLauncherDescription: {
id: 'app.appearance-settings.minimize-launcher.description',
defaultMessage: 'Minimize the launcher when a Minecraft process starts.',
},
})
const fetchSettings = await get()
const supportsMaximizeWindow = (await platform()) === 'windows'
const settings = ref({
...fetchSettings,
envVars: fetchSettings.custom_env_vars.map((x) => x.join('=')).join(' '),
})
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
setSettings.custom_env_vars = setSettings.envVars
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x: string) => x.split('=').filter(Boolean))
if (!setSettings.hooks.pre_launch) {
setSettings.hooks.pre_launch = null
}
if (!setSettings.hooks.wrapper) {
setSettings.hooks.wrapper = null
}
if (!setSettings.hooks.post_exit) {
setSettings.hooks.post_exit = null
}
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsSection>
<SettingsRow>
<template #label>
<span id="settings-target-defaults-window" tabindex="-1">
{{ formatMessage(messages.fullscreen) }}
</span>
</template>
<template #description>{{ formatMessage(messages.fullscreenDescription) }}</template>
<template #control><Toggle id="fullscreen" v-model="settings.force_fullscreen" /></template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.maximizeWindow) }}</template>
<template #description>
<span :class="{ 'text-secondary': !supportsMaximizeWindow }">
{{
formatMessage(
supportsMaximizeWindow
? messages.maximizeWindowDescription
: messages.maximizeWindowUnsupported,
)
}}
</span>
</template>
<template #control>
<Toggle
id="maximize-window"
v-model="settings.maximize_window"
:disabled="settings.force_fullscreen || !supportsMaximizeWindow"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.width) }}</template>
<template #description>{{ formatMessage(messages.widthDescription) }}</template>
<template #control>
<StyledInput
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
:placeholder="formatMessage(messages.widthPlaceholder)"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.height) }}</template>
<template #description>{{ formatMessage(messages.heightDescription) }}</template>
<template #control>
<StyledInput
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
:placeholder="formatMessage(messages.heightPlaceholder)"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
<SettingsRow stacked>
<template #label>
<span id="settings-target-defaults-environment" tabindex="-1">
{{ formatMessage(messages.environmentVariables) }}
</span>
</template>
<template #control>
<StyledInput
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.environmentVariablesPlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
<SettingsRow stacked>
<template #label>
<span id="settings-target-defaults-launch-hooks" tabindex="-1">
{{ formatMessage(messages.preLaunchHook) }}
</span>
</template>
<template #description>{{ formatMessage(messages.preLaunchDescription) }}</template>
<template #control>
<StyledInput
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.preLaunchPlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>{{ formatMessage(messages.wrapperHook) }}</template>
<template #description>{{ formatMessage(messages.wrapperDescription) }}</template>
<template #control>
<StyledInput
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.wrapperPlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>{{ formatMessage(messages.postExitHook) }}</template>
<template #description>{{ formatMessage(messages.postExitDescription) }}</template>
<template #control>
<StyledInput
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.postExitPlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
<SettingsRow>
<template #label>
<span id="settings-target-launch-lightweight-mode" tabindex="-1">
{{ formatMessage(messages.lightweightMode) }}
</span>
</template>
<template #description>{{ formatMessage(messages.lightweightModeDescription) }}</template>
<template #control>
<Toggle
id="enter-lightweight-mode-on-game-launch"
:model-value="settings.enter_lightweight_mode_on_game_launch"
@update:model-value="
(value) => {
settings.enter_lightweight_mode_on_game_launch = !!value
if (value) settings.hide_on_process_start = false
}
"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-launch-minimize" tabindex="-1">
{{ formatMessage(messages.minimizeLauncher) }}
</span>
</template>
<template #description>{{ formatMessage(messages.minimizeLauncherDescription) }}</template>
<template #control>
<Toggle
id="minimize-launcher"
:model-value="settings.hide_on_process_start"
:disabled="settings.enter_lightweight_mode_on_game_launch"
@update:model-value="(value) => (settings.hide_on_process_start = !!value)"
/>
</template>
</SettingsRow>
</SettingsSection>
<CrashAnalysisAISettings />
</div>
</template>

View File

@ -0,0 +1,274 @@
<script setup>
import { ArrowLeftIcon, CoffeeIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
commonMessages,
defineMessages,
injectNotificationManager,
NewButton as Button,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
import AlibabaLogo from '@/assets/java-vendors/alibaba.png'
import AmazonLogo from '@/assets/java-vendors/amazon.png'
import AzulLogo from '@/assets/java-vendors/azul.png'
import BellSoftLogo from '@/assets/java-vendors/bellsoft.png'
import EclipseLogo from '@/assets/java-vendors/eclipse.png'
import GraalVmLogo from '@/assets/java-vendors/graalvm.png'
import IbmLogo from '@/assets/java-vendors/ibm.png'
import JetBrainsLogo from '@/assets/java-vendors/jetbrains.png'
import MicrosoftLogo from '@/assets/java-vendors/microsoft.png'
import OracleLogo from '@/assets/java-vendors/oracle.png'
import SapLogo from '@/assets/java-vendors/sap.png'
import { trackEvent } from '@/helpers/analytics'
import { download_java, list_java_feed_vendors, list_java_feed_versions } from '@/helpers/jre'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
downloadJava: { id: 'app.settings.java.download.title', defaultMessage: 'Download Java' },
selectVendor: {
id: 'app.settings.java.download.select-vendor',
defaultMessage: 'Choose a distribution:',
},
selectVersion: {
id: 'app.settings.java.download.select-version-feed',
defaultMessage: 'Select a version of {vendor}:',
},
back: { id: 'app.settings.java.download.back', defaultMessage: 'Back to distributions' },
loading: { id: 'app.settings.java.download.loading', defaultMessage: 'Loading...' },
noVendors: {
id: 'app.settings.java.download.no-vendors',
defaultMessage: 'No distributions available.',
},
noVersions: {
id: 'app.settings.java.download.no-versions',
defaultMessage: 'No versions available.',
},
versionLabel: {
id: 'app.settings.java.download.version-label',
defaultMessage: 'Java {version}',
},
})
const vendorBranding = {
Alibaba: { logo: AlibabaLogo, product: 'Dragonwell' },
Amazon: { logo: AmazonLogo, product: 'Corretto' },
Azul: { logo: AzulLogo, product: 'Zulu' },
BellSoft: { logo: BellSoftLogo, product: 'Liberica JDK' },
Eclipse: { logo: EclipseLogo, product: 'Temurin' },
GraalVM: { logo: GraalVmLogo, product: 'Community Edition' },
IBM: { logo: IbmLogo, product: 'Semeru' },
JetBrains: { logo: JetBrainsLogo, product: 'Runtime' },
Microsoft: { logo: MicrosoftLogo, product: 'OpenJDK' },
Oracle: { logo: OracleLogo, product: 'OpenJDK / GraalVM' },
SAP: { logo: SapLogo, product: 'SapMachine' },
}
const emit = defineEmits(['downloaded'])
const modal = ref(null)
const loading = ref(false)
const vendors = ref([])
const selectedVendor = ref(null)
const versions = ref([])
const downloading = ref(null)
let requestId = 0
async function show() {
const currentRequestId = ++requestId
selectedVendor.value = null
versions.value = []
downloading.value = null
loading.value = true
vendors.value = []
modal.value?.show()
const result = await list_java_feed_vendors().catch(handleError)
if (currentRequestId !== requestId) return
vendors.value = result || []
loading.value = false
}
defineExpose({ show })
async function selectVendor(vendor) {
const currentRequestId = ++requestId
selectedVendor.value = vendor
loading.value = true
versions.value = []
const result = await list_java_feed_versions(vendor).catch(handleError)
if (currentRequestId !== requestId || selectedVendor.value !== vendor) return
versions.value = result || []
loading.value = false
}
function backToVendors() {
requestId += 1
selectedVendor.value = null
versions.value = []
loading.value = false
}
async function downloadVersion(info) {
downloading.value = info.major_version
trackEvent('JavaDownload', { vendor: info.vendor, version: info.major_version })
modal.value?.hide()
const job = await download_java(info.vendor, info.major_version).catch(handleError)
downloading.value = null
if (job) {
emit('downloaded', job)
}
}
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.downloadJava)"
:closable="downloading === null"
max-width="720px"
width="min(720px, calc(100vw - 2rem))"
max-content-height="min(34rem, 70vh)"
scrollable
actions-divider
>
<div class="flex min-h-40 flex-col gap-4">
<template v-if="!selectedVendor">
<span class="font-semibold text-contrast">{{ formatMessage(messages.selectVendor) }}</span>
<div
v-if="loading"
class="flex min-h-32 items-center justify-center gap-2 text-sm text-secondary"
role="status"
>
<SpinnerIcon class="size-4 animate-spin" aria-hidden="true" />
{{ formatMessage(messages.loading) }}
</div>
<div
v-else-if="vendors.length === 0"
class="flex min-h-32 items-center justify-center text-sm text-secondary"
>
{{ formatMessage(messages.noVendors) }}
</div>
<div v-else class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<Button
v-for="vendor in vendors"
:key="vendor"
type="base"
class="!h-16 !w-full !min-w-0 !justify-start !gap-3 !rounded-lg !px-3 !py-2 !text-left !shadow-none"
native-type="button"
@click="selectVendor(vendor)"
>
<span
class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md p-1"
>
<img
v-if="vendorBranding[vendor]"
:src="vendorBranding[vendor].logo"
alt=""
class="size-full object-contain"
/>
<CoffeeIcon v-else class="size-5 text-secondary" aria-hidden="true" />
</span>
<span class="flex min-w-0 flex-1 flex-col items-start text-left leading-tight">
<span class="w-full truncate text-left text-sm font-semibold text-contrast">{{
vendor
}}</span>
<span
v-if="vendorBranding[vendor]"
class="w-full truncate text-left text-xs font-normal text-secondary"
>
{{ vendorBranding[vendor].product }}
</span>
</span>
</Button>
</div>
</template>
<template v-else>
<div class="flex items-center gap-3">
<span
class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md p-1"
>
<img
v-if="vendorBranding[selectedVendor]"
:src="vendorBranding[selectedVendor].logo"
alt=""
class="size-full object-contain"
/>
<CoffeeIcon v-else class="size-5 text-secondary" aria-hidden="true" />
</span>
<span class="min-w-0 font-semibold text-contrast">
{{ formatMessage(messages.selectVersion, { vendor: selectedVendor }) }}
</span>
</div>
<div
v-if="loading"
class="flex min-h-32 items-center justify-center gap-2 text-sm text-secondary"
role="status"
>
<SpinnerIcon class="size-4 animate-spin" aria-hidden="true" />
{{ formatMessage(messages.loading) }}
</div>
<div
v-else-if="versions.length === 0"
class="flex min-h-32 items-center justify-center text-sm text-secondary"
>
{{ formatMessage(messages.noVersions) }}
</div>
<div v-else class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<Button
v-for="info in versions"
:key="info.major_version"
type="base"
class="!h-12 !w-full !min-w-0 !rounded-lg !px-3 !shadow-none"
:disabled="downloading !== null"
native-type="button"
@click="downloadVersion(info)"
>
<SpinnerIcon
v-if="downloading === info.major_version"
class="animate-spin"
aria-hidden="true"
/>
<CoffeeIcon v-else aria-hidden="true" />
<span class="truncate text-sm font-semibold tabular-nums">
{{ formatMessage(messages.versionLabel, { version: info.major_version }) }}
</span>
</Button>
</div>
</template>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<Button
v-if="selectedVendor"
type="outlined"
:disabled="downloading !== null"
native-type="button"
@click="backToVendors"
>
<ArrowLeftIcon aria-hidden="true" />
{{ formatMessage(messages.back) }}
</Button>
<Button
type="outlined"
:disabled="downloading !== null"
native-type="button"
@click="modal?.hide()"
>
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
</div>
</template>
</NewModal>
</template>

View File

@ -0,0 +1,135 @@
<script setup lang="ts">
import { WrenchIcon } from '@modrinth/assets'
import {
defineMessages,
injectNotificationManager,
NewButton as Button,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { inject, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { isDev } from '@/helpers/utils'
import { handleSevereError } from '@/store/error.js'
import { useTheming } from '@/store/state'
import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const isDevEnvironment = await isDev()
const previewMinecraftCrashModal = inject<() => void>('previewMinecraftCrashModal')
const previewPrivacyConsentModal = inject<() => Promise<void>>('previewPrivacyConsentModal')
const messages = defineMessages({
resetToDefault: {
id: 'app.settings.feature-flags.reset-to-default',
defaultMessage: 'Reset to default',
},
developerTools: {
id: 'app.settings.about.developer-tools',
defaultMessage: 'Developer tools',
},
testError: {
id: 'app.settings.about.test-error',
defaultMessage: 'Trigger test error',
},
testErrorMessage: {
id: 'app.settings.about.test-error-message',
defaultMessage: 'Test error triggered from the development settings.',
},
testNotificationError: {
id: 'app.settings.about.test-notification-error',
defaultMessage: 'Trigger notification test error',
},
testNotificationErrorTitle: {
id: 'app.settings.about.test-notification-error-title',
defaultMessage: 'Test notification error',
},
previewMinecraftCrashModal: {
id: 'app.settings.about.preview-minecraft-crash-modal',
defaultMessage: 'Preview Minecraft crash window',
},
previewPrivacyConsentModal: {
id: 'app.settings.about.preview-privacy-consent-modal',
defaultMessage: 'Preview privacy & security modal',
},
})
const settings = ref(await getSettings())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
function setFeatureFlag(key: string, value: boolean) {
themeStore.featureFlags[key] = value
settings.value.feature_flags[key] = value
}
function triggerTestError() {
handleSevereError(new Error(formatMessage(messages.testErrorMessage)))
}
function triggerTestNotificationError() {
addNotification({
title: formatMessage(messages.testNotificationErrorTitle),
text: formatMessage(messages.testErrorMessage),
type: 'error',
})
}
watch(
settings,
async () => {
await setSettings(settings.value)
},
{ deep: true },
)
</script>
<template>
<SettingsSection>
<SettingsRow v-for="option in options" :key="option">
<template #label>{{ option.replaceAll('_', ' ') }}</template>
<template #control>
<div class="flex items-center gap-2">
<Button
type="quiet"
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
>
{{ formatMessage(messages.resetToDefault) }}
</Button>
<Toggle
:id="`feature-flag-${option}`"
:model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
/>
</div>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection v-if="isDevEnvironment">
<template #header>
<h2 class="m-0 flex items-center gap-2 text-lg font-semibold text-contrast">
<WrenchIcon class="size-5 text-secondary" />
{{ formatMessage(messages.developerTools) }}
</h2>
</template>
<div class="flex flex-wrap gap-2 p-4">
<Button type="base" @click="triggerTestError">
<WrenchIcon /> {{ formatMessage(messages.testError) }}
</Button>
<Button type="base" @click="triggerTestNotificationError">
<WrenchIcon /> {{ formatMessage(messages.testNotificationError) }}
</Button>
<Button type="base" @click="previewMinecraftCrashModal?.()">
<WrenchIcon /> {{ formatMessage(messages.previewMinecraftCrashModal) }}
</Button>
<Button type="base" @click="previewPrivacyConsentModal?.()">
<WrenchIcon /> {{ formatMessage(messages.previewPrivacyConsentModal) }}
</Button>
</div>
</SettingsSection>
</template>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import { useId } from 'vue'
const firstGradientId = useId()
const secondGradientId = useId()
</script>
<template>
<svg
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 84 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.798 11.785a3.883 3.883 0 01-.819.083h-2.42a6.522 6.522 0 01-3.366-.935 6.738 6.738 0 01-.51-.336 6.64 6.64 0 01-2.1-2.607 8.042 8.042 0 01-.227-.58 6.619 6.619 0 01-.336-2.084V0h2.683v5.326c0 .127.004.247.02.367.014.176.04.342.084.509a3.857 3.857 0 003.735 2.976h.02a3.342 3.342 0 013.24 2.607h-.004z"
:fill="`url(#${firstGradientId})`"
/>
<path
d="M4.683 11.206v6.645H2V7.734c.087.323.226.69.3.832a7.005 7.005 0 002.383 2.644v-.004z"
fill="#1E8CFE"
/>
<path
d="M15.08 12.524v5.327h-2.683v-5.327c0-.126-.004-.25-.02-.37a3.871 3.871 0 00-1.918-2.976 3.889 3.889 0 00-1.9-.51h-.02a3.346 3.346 0 01-3.24-2.6c.263-.056.536-.082.819-.082h2.42c1.225 0 2.38.342 3.366.935.176.103.346.216.51.336a6.578 6.578 0 012.097 2.607 6.487 6.487 0 01.57 2.66z"
:fill="`url(#${secondGradientId})`"
/>
<path
d="M35.635 17.585c0 .533-.017.999-.053 1.405a9.743 9.743 0 01-.143 1.069c-.187.849-.546 1.574-1.086 2.184C33.335 23.414 31.936 24 30.155 24c-1.505 0-2.74-.406-3.712-1.215-1.002-.832-1.581-1.984-1.734-3.455h2.613c.1.556.266.982.496 1.281.54.703 1.325 1.052 2.36 1.052 1.905 0 2.857-1.168 2.857-3.502V16.59c-1.032 1.055-2.224 1.584-3.572 1.584-1.535 0-2.79-.556-3.769-1.664-.985-1.126-1.481-2.534-1.481-4.222s.46-3.036 1.375-4.184c.985-1.219 2.29-1.828 3.908-1.828 1.418 0 2.597.53 3.539 1.585V6.589h2.6v10.996zm-2.497-5.34c0-1.095-.293-1.97-.879-2.623-.592-.67-1.351-1.006-2.277-1.006-.985 0-1.764.366-2.337 1.099-.516.656-.776 1.501-.776 2.543 0 1.042.26 1.865.776 2.52.563.716 1.342 1.076 2.337 1.076s1.781-.363 2.357-1.089c.533-.656.8-1.495.8-2.52zM38.917 6.588h2.603v1.006c.476-.5.903-.842 1.272-1.029.376-.193.826-.29 1.342-.29.686 0 1.401.224 2.15.67l-1.191 2.38c-.493-.353-.976-.533-1.445-.533-1.418 0-2.128 1.072-2.128 3.213v5.84h-2.603V6.587z"
/>
<path
d="M18.453 2.37c0-.456.166-.849.496-1.178.33-.333.73-.496 1.192-.496.463 0 .872.166 1.202.496.333.323.496.722.496 1.192s-.167.872-.496 1.202a1.59 1.59 0 01-1.192.496c-.473 0-.872-.167-1.202-.496a1.647 1.647 0 01-.496-1.216zm3.083 4.222V17.85h-2.777V6.588h2.777v.003z"
fill="#0418FF"
/>
<path
d="M57.867 12.831h-8.07c.07.925.37 1.661.902 2.207.533.54 1.215.81 2.048.81.645 0 1.181-.154 1.608-.464.3-.223.626-.582.865-1.072h2.68c-.173.82-.679 1.605-1.039 2.038-.359.433-.745.789-1.178 1.052-.433.266-.899.46-1.398.583a6.834 6.834 0 01-1.631.186c-1.682 0-3.03-.54-4.046-1.618-1.018-1.089-1.528-2.527-1.528-4.325 0-1.797.493-3.222 1.482-4.324.995-1.089 2.314-1.631 3.955-1.631s2.966.529 3.932 1.584c.955 1.049 1.435 2.5 1.435 4.358l-.014.613-.003.003zm-2.67-2.127c-.363-1.389-1.239-2.081-2.623-2.081-.317 0-.613.047-.89.143-.276.097-.529.236-.758.416-.227.18-.423.4-.583.653a2.942 2.942 0 00-.37.865H55.2l-.003.004zM65.501 18.154c-3 0-4.76-1.655-5.204-3.852h2.787c.38.846 1.328 1.425 2.524 1.425s1.907-.573 1.907-1.255c0-.546-.639-1.085-2.21-1.342-2.248-.37-3.263-.835-3.895-1.661-.563-.73-.693-1.628-.533-2.434.313-1.608 1.87-2.96 4.75-2.776 2.587.163 4.129 2.234 4.272 3.858h-2.554c-.22-.792-.785-1.355-1.884-1.521-.942-.143-1.92.153-1.974.949-.053.795.736 1.055 2.19 1.295 3.806.626 4.558 2.36 4.522 3.709-.06 2.197-2.038 3.612-4.701 3.612l.003-.007zM77.199 18.154c-3 0-4.76-1.655-5.204-3.852h2.787c.38.846 1.328 1.425 2.523 1.425s1.908-.573 1.908-1.255c0-.546-.64-1.085-2.21-1.342-2.248-.37-3.263-.835-3.896-1.661-.562-.73-.692-1.628-.532-2.434.313-1.608 1.87-2.96 4.75-2.776 2.587.163 4.129 2.234 4.272 3.858h-2.554c-.22-.792-.785-1.355-1.884-1.521-.942-.143-1.92.153-1.974.949-.054.795.736 1.055 2.19 1.295 3.806.626 4.558 2.36 4.521 3.709-.06 2.197-2.037 3.612-4.7 3.612l.003-.007z"
/>
<path
d="M12.397 6.645V0h2.683v10.117a5.284 5.284 0 00-.3-.832 7.006 7.006 0 00-2.383-2.644v.004z"
fill="#1E8CFE"
/>
<defs>
<linearGradient
:id="firstGradientId"
gradientUnits="userSpaceOnUse"
x1="2.02"
x2="11.798"
y1="5.933"
y2="5.933"
>
<stop offset="0" stop-color="#0418FF" />
<stop offset="1" stop-color="#1E8CFE" />
</linearGradient>
<linearGradient
:id="secondGradientId"
gradientUnits="userSpaceOnUse"
x1="15.08"
x2="5.299"
y1="11.918"
y2="11.918"
>
<stop offset="0" stop-color="#0418FF" />
<stop offset="1" stop-color="#1E8CFE" />
</linearGradient>
</defs>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import AppearanceSettings from './AppearanceSettings.vue'
</script>
<template>
<AppearanceSettings scope="home-navigation" />
</template>

View File

@ -0,0 +1,158 @@
<script setup>
import { SpinnerIcon, TrashIcon } from '@modrinth/assets'
import {
commonMessages,
defineMessages,
IconButton,
injectNotificationManager,
NewButton as Button,
NewModal,
Table,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { get_java_versions, remove_java_version } from '@/helpers/jre'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'app.settings.java.installed.title',
defaultMessage: 'Installed Java versions',
},
version: {
id: 'app.settings.java.table.version',
defaultMessage: 'Java Version',
},
distribution: {
id: 'app.settings.java.table.distribution',
defaultMessage: 'Distribution',
},
path: {
id: 'app.settings.java.table.path',
defaultMessage: 'Path',
},
actions: {
id: 'app.settings.java.table.actions',
defaultMessage: '',
},
loading: {
id: 'app.settings.java.installed.loading',
defaultMessage: 'Loading installed Java versions...',
},
empty: {
id: 'app.settings.java.installed.empty',
defaultMessage: 'No Java installations found.',
},
remove: {
id: 'app.settings.java.installed.remove',
defaultMessage: 'Remove Java installation',
},
})
const emit = defineEmits(['changed'])
const modal = ref(null)
const loading = ref(false)
const javaVersions = ref([])
const columns = [
{ key: 'parsed_version', label: formatMessage(messages.version), width: '8rem' },
{ key: 'distribution', label: formatMessage(messages.distribution) },
{ key: 'path', label: formatMessage(messages.path) },
{ key: 'actions', label: formatMessage(messages.actions), align: 'right', width: '3rem' },
]
const tableData = computed(() =>
javaVersions.value
.map((javaVersion) => ({
...javaVersion,
distribution: javaVersion.distribution || null,
}))
.sort((a, b) => b.parsed_version - a.parsed_version || a.path.localeCompare(b.path)),
)
async function reload() {
loading.value = true
const versions = await get_java_versions().catch(handleError)
if (versions) javaVersions.value = versions
loading.value = false
}
async function show() {
modal.value?.show()
await reload()
}
async function removeEntry(javaVersion) {
const removed = await remove_java_version(javaVersion.path)
.then(() => true)
.catch((error) => {
handleError(error)
return false
})
if (!removed) return
javaVersions.value = javaVersions.value.filter((item) => item.path !== javaVersion.path)
emit('changed')
}
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
max-width="860px"
width="min(860px, calc(100vw - 2rem))"
max-content-height="min(34rem, 70vh)"
scrollable
actions-divider
>
<div v-if="loading" class="flex min-h-40 items-center justify-center gap-2 text-secondary">
<SpinnerIcon class="size-4 animate-spin" aria-hidden="true" />
{{ formatMessage(messages.loading) }}
</div>
<Table v-else :columns="columns" :data="tableData" row-key="path">
<template #cell-parsed_version="{ value }">
<span class="font-semibold tabular-nums">Java {{ value }}</span>
</template>
<template #cell-distribution="{ value }">
<span class="text-sm">{{ value || '-' }}</span>
</template>
<template #cell-path="{ value }">
<span v-tooltip="value" class="block max-w-96 truncate font-mono text-xs">
{{ value }}
</span>
</template>
<template #cell-actions="{ row }">
<IconButton
v-tooltip="formatMessage(messages.remove)"
type="quiet"
color="red"
interaction="filled"
:label="formatMessage(messages.remove)"
native-type="button"
@click="removeEntry(row)"
>
<TrashIcon aria-hidden="true" />
</IconButton>
</template>
<template #empty-state>
<div class="py-8 text-center text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</div>
</template>
</Table>
<template #actions>
<div class="flex justify-end">
<Button type="outlined" native-type="button" @click="modal?.hide()">
{{ formatMessage(commonMessages.closeButton) }}
</Button>
</div>
</template>
</NewModal>
</template>

View File

@ -0,0 +1,491 @@
<script setup>
import { DownloadIcon, FolderSearchIcon, ListIcon, ScanEyeIcon, SearchIcon } from '@modrinth/assets'
import {
Checkbox,
defineMessages,
injectNotificationManager,
NewButton as Button,
Slider,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import { ref, watch } from 'vue'
import JavaArgumentsInput from '@/components/ui/JavaArgumentsInput.vue'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import MemoryAllocationDisplay from '@/components/ui/MemoryAllocationDisplay.vue'
import DownloadJavaModal from '@/components/ui/settings/DownloadJavaModal.vue'
import InstalledJavaModal from '@/components/ui/settings/InstalledJavaModal.vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { trackEvent } from '@/helpers/analytics'
import { collectGcContext } from '@/helpers/gc/context'
import { wait_for_install_job } from '@/helpers/install'
import { getJavaArgumentPresets } from '@/helpers/java-argument-presets'
import {
find_filtered_jres,
get_java_default_versions,
get_jre,
remove_java_default_version,
set_java_default_version,
set_java_version,
} from '@/helpers/jre'
import { get, set } from '@/helpers/settings.ts'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
javaLocation: {
id: 'app.settings.java.location',
defaultMessage: 'Java {version} location',
},
findJava: {
id: 'app.settings.java.find-java',
defaultMessage: 'Find Java',
},
deepScan: {
id: 'app.settings.java.deep-scan',
defaultMessage: 'Deep Scan',
},
manualAdd: {
id: 'app.settings.java.manual-add',
defaultMessage: 'Manual Add',
},
downloadJava: {
id: 'app.settings.java.download-java',
defaultMessage: 'Download Java',
},
viewInstalled: {
id: 'app.settings.java.view-installed',
defaultMessage: 'View installed Java',
},
autoHighPerformanceMode: {
id: 'app.settings.java.auto-high-performance-mode',
defaultMessage: 'Automatically use high-performance GPU for Java',
},
autoHighPerformanceModeDescription: {
id: 'app.settings.java.auto-high-performance-mode-description',
defaultMessage:
'Uses the high-performance GPU for Minecraft when it launches. Supported on Windows and Linux.',
},
scanning: {
id: 'app.settings.java.scanning',
defaultMessage: 'Scanning...',
},
deepScanConfirm: {
id: 'app.settings.java.deep-scan-confirm',
defaultMessage: 'This will scan ALL directories on ALL drives. May take several minutes.',
},
scanAnyway: {
id: 'app.settings.java.scan-anyway',
defaultMessage: 'Scan Anyway',
},
cancel: {
id: 'app.settings.java.cancel',
defaultMessage: 'Cancel',
},
memory: {
id: 'app.settings.defaults.memory',
defaultMessage: 'Memory allocated',
},
memoryDescription: {
id: 'app.settings.defaults.memory-description',
defaultMessage: 'The memory allocated to each instance when it is run.',
},
automaticMemory: {
id: 'app.settings.defaults.automatic-memory',
defaultMessage: 'Automatically allocate memory at launch',
},
automaticMemoryDescription: {
id: 'app.settings.defaults.automatic-memory-description',
defaultMessage: 'Adjusts memory for each launch based on available RAM and installed mods.',
},
optimizeMemoryBeforeLaunch: {
id: 'app.settings.defaults.optimize-memory-before-launch',
defaultMessage: 'Optimize memory before launching the game',
},
optimizeMemoryBeforeLaunchDescription: {
id: 'app.settings.defaults.optimize-memory-before-launch-description',
defaultMessage: 'Waits for Windows memory optimization to finish before starting the game.',
},
javaArguments: {
id: 'app.settings.defaults.java-arguments',
defaultMessage: 'Java arguments',
},
javaArgumentsPlaceholder: {
id: 'app.settings.defaults.java-arguments-placeholder',
defaultMessage: 'Enter Java arguments...',
},
})
const supportedJavaVersions = [25, 21, 17, 8]
const javaDefaults = ref({})
const scanning = ref(false)
const scanMode = ref('')
const showDeepScanConfirm = ref(false)
const downloadJavaModal = ref(null)
const installedJavaModal = ref(null)
const defaultSaveQueues = new Map()
const currentPlatform = await platform()
const supportsHighPerformanceMode = ['windows', 'linux'].includes(currentPlatform)
const supportsMemoryOptimization = currentPlatform === 'windows'
const settings = ref(await get().catch(handleError))
const autoHighPerformanceMode = ref(settings.value?.auto_set_java_high_performance_mode ?? false)
const javaArgs = ref((settings.value?.extra_launch_args ?? []).join(' '))
const memory = ref(
settings.value?.memory
? { optimize_before_launch: false, ...settings.value.memory }
: { maximum: 2048, automatic: true, optimize_before_launch: false },
)
let shouldApplyDefaultAuto = (settings.value?.extra_launch_args?.length ?? 0) === 0
const memorySlider = await useMemorySlider().catch(handleError)
const maxMemory = memorySlider?.maxMemory ?? 4096
const snapPoints = memorySlider?.snapPoints ?? []
const gcContext = ref(undefined)
async function updateGcContext() {
gcContext.value = await collectGcContext(memory.value.maximum, null, null, 0)
if (shouldApplyDefaultAuto) {
const autoPreset = getJavaArgumentPresets(gcContext.value).find(
(preset) => preset.id === 'gc-auto',
)
if (autoPreset) {
javaArgs.value = autoPreset.resolveArgs
? autoPreset.resolveArgs(gcContext.value)
: autoPreset.args
}
shouldApplyDefaultAuto = false
}
}
await updateGcContext()
watch(() => memory.value.maximum, updateGcContext)
watch(
[autoHighPerformanceMode, memory, javaArgs],
async () => {
if (!settings.value) return
settings.value = {
...settings.value,
auto_set_java_high_performance_mode: autoHighPerformanceMode.value,
memory: memory.value,
extra_launch_args: javaArgs.value.trim().split(/\s+/).filter(Boolean),
}
await set(settings.value).catch(handleError)
},
{ deep: true },
)
async function reloadDefaults() {
const defaults = await get_java_default_versions().catch(handleError)
if (!defaults) return
javaDefaults.value = Object.fromEntries(
defaults.map((javaVersion) => [javaVersion.parsed_version, javaVersion]),
)
}
await reloadDefaults()
async function persistDefault(majorVersion, javaVersion) {
const path = javaVersion?.path?.trim()
if (!path) {
const removed = await remove_java_default_version(majorVersion)
.then(() => true)
.catch((error) => {
handleError(error)
return false
})
if (removed) {
javaDefaults.value[majorVersion] = undefined
} else {
await reloadDefaults()
}
return
}
const validated = await set_java_default_version(majorVersion, path).catch((error) => {
handleError(error)
return null
})
if (validated) {
javaDefaults.value[majorVersion] = validated
} else {
await reloadDefaults()
}
}
function saveDefault(majorVersion, javaVersion) {
const previous = defaultSaveQueues.get(majorVersion) ?? Promise.resolve()
const operation = previous.then(() => persistDefault(majorVersion, javaVersion))
defaultSaveQueues.set(majorVersion, operation)
return operation.finally(() => {
if (defaultSaveQueues.get(majorVersion) === operation) {
defaultSaveQueues.delete(majorVersion)
}
})
}
async function runScan(exhaustive) {
if (exhaustive) {
showDeepScanConfirm.value = true
return
}
scanning.value = true
scanMode.value = 'quick'
trackEvent('JavaQuickScan', { source: 'settings' })
try {
await find_filtered_jres(null, false, true, false).catch(handleError)
} finally {
scanning.value = false
scanMode.value = ''
}
}
async function confirmDeepScan() {
showDeepScanConfirm.value = false
scanning.value = true
scanMode.value = 'deep'
trackEvent('JavaDeepScan', { source: 'settings' })
try {
await find_filtered_jres(null, true, true, true).catch(handleError)
} finally {
scanning.value = false
scanMode.value = ''
}
}
async function handleManualAdd() {
const result = await open({ multiple: false })
if (!result) return
const filePath = result.path ?? result
const javaInfo = await get_jre(filePath).catch(handleError)
if (!javaInfo) return
await set_java_version(javaInfo).catch(handleError)
trackEvent('JavaManualSelect', { path: filePath })
}
async function onJavaDownloaded(job) {
if (job?.job_id) {
await wait_for_install_job(job.job_id).catch(handleError)
}
await reloadDefaults()
}
</script>
<template>
<DownloadJavaModal ref="downloadJavaModal" @downloaded="onJavaDownloaded" />
<InstalledJavaModal ref="installedJavaModal" @changed="reloadDefaults" />
<div class="settings-page flex flex-col gap-6">
<div
v-for="(javaVersion, index) in supportedJavaVersions"
:key="`java-${javaVersion}`"
class="flex flex-col gap-2.5"
>
<h2 class="m-0 text-lg font-semibold text-contrast" :class="{ 'mt-2': index !== 0 }">
{{ formatMessage(messages.javaLocation, { version: javaVersion }) }}
</h2>
<JavaSelector
:id="`java-selector-${javaVersion}`"
v-model="javaDefaults[javaVersion]"
:version="javaVersion"
@commit="saveDefault(javaVersion, $event)"
/>
</div>
<div class="flex flex-wrap gap-2 border-0 border-t border-solid border-button-border pt-5">
<Button
type="base"
native-type="button"
class="!shadow-none"
:disabled="scanning"
@click="runScan(false)"
>
<SearchIcon aria-hidden="true" />
{{
scanning && scanMode === 'quick'
? formatMessage(messages.scanning)
: formatMessage(messages.findJava)
}}
</Button>
<Button
type="base"
native-type="button"
class="!shadow-none"
:disabled="scanning"
@click="runScan(true)"
>
<ScanEyeIcon aria-hidden="true" />
{{
scanning && scanMode === 'deep'
? formatMessage(messages.scanning)
: formatMessage(messages.deepScan)
}}
</Button>
<Button
type="base"
native-type="button"
class="!shadow-none"
:disabled="scanning"
@click="handleManualAdd"
>
<FolderSearchIcon aria-hidden="true" />
{{ formatMessage(messages.manualAdd) }}
</Button>
<Button
type="base"
native-type="button"
class="!shadow-none"
:disabled="scanning"
@click="downloadJavaModal?.show()"
>
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.downloadJava) }}
</Button>
<Button
type="base"
native-type="button"
class="!shadow-none"
@click="installedJavaModal?.show()"
>
<ListIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstalled) }}
</Button>
</div>
<div
v-if="showDeepScanConfirm"
class="flex flex-col gap-2 rounded-lg border border-warning bg-warning/10 p-3 text-sm"
>
<span>{{ formatMessage(messages.deepScanConfirm) }}</span>
<div class="flex flex-wrap gap-2">
<Button type="colored" color="red" native-type="button" @click="confirmDeepScan">
{{ formatMessage(messages.scanAnyway) }}
</Button>
<Button type="outlined" native-type="button" @click="showDeepScanConfirm = false">
{{ formatMessage(messages.cancel) }}
</Button>
</div>
</div>
<div
v-if="supportsHighPerformanceMode"
class="border-0 border-t border-solid border-button-border pt-5"
>
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.autoHighPerformanceMode) }}
</span>
<span class="text-xs text-secondary">
{{ formatMessage(messages.autoHighPerformanceModeDescription) }}
</span>
</div>
<Toggle id="auto-java-high-performance-mode" v-model="autoHighPerformanceMode" />
</div>
</div>
<div class="flex flex-col gap-6 border-0 border-t border-solid border-button-border pt-5">
<div class="flex flex-col gap-2.5">
<h2
id="settings-target-java-memory"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.memory) }}
</h2>
<Checkbox v-model="memory.automatic" :label="formatMessage(messages.automaticMemory)" />
<div v-if="supportsMemoryOptimization" class="flex flex-col gap-1">
<Checkbox
v-model="memory.optimize_before_launch"
:label="formatMessage(messages.optimizeMemoryBeforeLaunch)"
/>
<p class="m-0 text-xs leading-tight text-secondary">
{{ formatMessage(messages.optimizeMemoryBeforeLaunchDescription) }}
</p>
</div>
<Slider
id="max-memory"
v-model="memory.maximum"
:disabled="memory.automatic"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<p class="m-0 mt-1 leading-tight">
{{
formatMessage(
memory.automatic ? messages.automaticMemoryDescription : messages.memoryDescription,
)
}}
</p>
<MemoryAllocationDisplay :memory="memory" show-optimize-button />
</div>
<div class="flex flex-col gap-2.5">
<h2
id="settings-target-java-arguments"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.javaArguments) }}
</h2>
<JavaArgumentsInput
id="java-args"
v-model="javaArgs"
:gc-context="gcContext"
:placeholder="formatMessage(messages.javaArgumentsPlaceholder)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.settings-page > div {
padding: var(--gap-lg);
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
border-radius: var(--radius-md);
background: var(--surface-2);
}
.settings-page > div:has(.border-warning) {
padding: var(--gap-md);
background: var(--color-orange-bg);
}
@media (max-width: 700px) {
.settings-page :deep(.flex.items-center.justify-between) {
align-items: flex-start;
flex-direction: column;
}
}
</style>

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import {
Admonition,
AutoLink,
commonSettingsMessages,
IntlFormatted,
LanguageSelector,
languageSelectorMessages,
LOCALES,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import i18n from '@/i18n.config'
import SettingsSaveStatus from './SettingsSaveStatus.vue'
const { formatMessage } = useVIntl()
const platform = computed(() => formatMessage(languageSelectorMessages.platformApp))
const settings = ref(await get())
const $isChanging = ref(false)
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
const retryLocale = ref<string | null>(null)
async function onLocaleChange(newLocale: string) {
if (settings.value.locale === newLocale) return
const previousLocale = settings.value.locale
$isChanging.value = true
saveStatus.value = 'saving'
retryLocale.value = null
try {
i18n.global.locale.value = newLocale
settings.value.locale = newLocale
await set(settings.value)
saveStatus.value = 'saved'
} catch {
i18n.global.locale.value = previousLocale
settings.value.locale = previousLocale
retryLocale.value = newLocale
saveStatus.value = 'error'
} finally {
$isChanging.value = false
}
}
function retrySave() {
if (retryLocale.value) void onLocaleChange(retryLocale.value)
}
</script>
<template>
<div class="flex flex-col gap-3">
<header class="settings-page-header">
<h2 id="settings-target-language" tabindex="-1" class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(commonSettingsMessages.language) }}
</h2>
<SettingsSaveStatus :status="saveStatus" :retry="retrySave" />
</header>
<div class="settings-page-card">
<Admonition type="warning">
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
</Admonition>
<p class="settings-page-description">
<IntlFormatted
:message-id="languageSelectorMessages.languagesDescription"
:values="{ platform }"
>
<template #~crowdin-link="{ children }">
<AutoLink to="https://translate.modrinth.com">
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</p>
<LanguageSelector
:current-locale="settings.locale"
:locales="LOCALES"
:on-locale-change="onLocaleChange"
:is-changing="$isChanging"
/>
</div>
</div>
</template>
<style scoped>
.settings-page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap-md);
}
.settings-page-card {
display: flex;
flex-direction: column;
gap: var(--gap-lg);
padding: var(--gap-lg);
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
border-radius: var(--radius-md);
background: var(--surface-2);
}
.settings-page-description {
margin: 0;
color: var(--color-secondary);
font-size: 0.875rem;
line-height: 1.5;
}
</style>

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import LanguageSettings from './LanguageSettings.vue'
import TranslationSettings from './TranslationSettings.vue'
</script>
<template>
<div class="flex flex-col gap-6">
<LanguageSettings />
<TranslationSettings />
</div>
</template>

View File

@ -0,0 +1,136 @@
<script setup lang="ts">
import { type Component, computed } from 'vue'
import { lobeAvatarBrands, lobeCombineBrands } from '@/data/lobeProviderIcons'
import HigressTextColor from './HigressTextColor.vue'
const props = withDefaults(
defineProps<{
brand: string
extra?: string
extraFontSize?: number
extraMarginLeft?: number
size: number
}>(),
{ extra: '', extraFontSize: undefined, extraMarginLeft: undefined },
)
const iconModules = import.meta.glob(
'../../../../node_modules/@lobehub/icons-static-svg/icons/*.svg',
{
eager: true,
import: 'default',
query: '?component',
},
) as Record<string, Component>
const iconComponents = Object.fromEntries(
Object.entries(iconModules).map(([path, component]) => [
path.split('/').pop()?.replace('.svg', ''),
component,
]),
) as Record<string, Component>
const config = computed(() => lobeCombineBrands[props.brand])
const brandAvatar = computed(() => lobeAvatarBrands[props.brand])
const standaloneComponent = computed(() =>
config.value?.standalone ? iconComponents[config.value.standalone] : undefined,
)
const logoComponent = computed(() =>
config.value?.logo ? iconComponents[config.value.logo] : undefined,
)
const textComponent = computed(() =>
config.value?.text ? iconComponents[config.value.text] : undefined,
)
const avatarComponent = computed(() => {
if (!config.value?.avatar || !brandAvatar.value) return undefined
const suffix = brandAvatar.value.asset === 'color' ? '-color' : ''
return iconComponents[`${props.brand}${suffix}`] ?? iconComponents[props.brand]
})
const standaloneSize = computed(() => props.size * (config.value?.textMultiple ?? 1))
const textSize = computed(() => props.size * (config.value?.textMultiple ?? 1))
const logoMargin = computed(() => props.size * (config.value?.spaceMultiple ?? 1))
const extraStyle = computed(() => ({
fontSize: `${props.extraFontSize ?? textSize.value * 0.95}px`,
marginLeft: props.extraMarginLeft === undefined ? undefined : `${props.extraMarginLeft}px`,
}))
</script>
<template>
<span
v-if="config"
class="lobe-brand-combine inline-flex min-w-0 flex-none items-center justify-start"
:class="{ inverse: config.inverse }"
:style="{ color: config.color }"
>
<HigressTextColor
v-if="brand === 'higress'"
class="lobe-brand-standalone"
:style="{ height: `${standaloneSize}px` }"
/>
<component
:is="standaloneComponent"
v-else-if="standaloneComponent"
class="lobe-brand-standalone"
:style="{ height: `${standaloneSize}px` }"
/>
<template v-else>
<span
v-if="avatarComponent && brandAvatar"
class="lobe-brand-avatar inline-flex flex-none items-center justify-center overflow-hidden"
:style="{
background: brandAvatar.background,
borderRadius: `${Math.floor(size * 0.1)}px`,
color: brandAvatar.color,
height: `${size}px`,
marginLeft: config.inverse ? `${logoMargin}px` : undefined,
marginRight: config.inverse ? undefined : `${logoMargin}px`,
width: `${size}px`,
}"
>
<component
:is="avatarComponent"
:style="{
height: `${size}px`,
transform: `scale(${brandAvatar.multiple})`,
width: `${size}px`,
}"
/>
</span>
<component
:is="logoComponent"
v-else-if="logoComponent"
class="lobe-brand-logo object-contain"
:style="{
height: `${size}px`,
marginLeft: config.inverse ? `${logoMargin}px` : undefined,
marginRight: config.inverse ? undefined : `${logoMargin}px`,
width: `${size}px`,
}"
/>
<component
:is="textComponent"
v-if="textComponent"
class="lobe-brand-text"
:style="{ height: `${textSize}px` }"
/>
</template>
<span v-if="extra" class="flex-none leading-none" :style="extraStyle">{{ extra }}</span>
</span>
</template>
<style scoped>
.lobe-brand-combine.inverse {
flex-direction: row-reverse;
}
.lobe-brand-avatar > :deep(svg),
.lobe-brand-logo,
.lobe-brand-text,
.lobe-brand-standalone {
display: block;
flex: none;
width: auto;
}
</style>

View File

@ -0,0 +1,151 @@
<script setup lang="ts">
import { SaveIcon, SpinnerIcon } from '@modrinth/assets'
import {
defineMessages,
injectNotificationManager,
NewButton as Button,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings'
import { parseTerracottaPublicNodes } from '@/helpers/terracotta'
import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const { addNotification, handleError } = injectNotificationManager()
const messages = defineMessages({
publicNodes: {
id: 'app.multiplayer.terracotta.public-nodes',
defaultMessage: 'Terracotta public nodes',
},
publicNodesDescription: {
id: 'app.multiplayer.terracotta.public-nodes-description',
defaultMessage:
'Enter one node URI per line. Changes apply the next time you host or join a room. Leave empty to use only Terracotta defaults.',
},
publicNodesPlaceholder: {
id: 'app.multiplayer.terracotta.public-nodes-placeholder',
defaultMessage: 'wss://center.node.1tmc.top',
},
publicNodeInvalid: {
id: 'app.multiplayer.terracotta.public-node-invalid',
defaultMessage: 'Invalid node URI: {node}. Use http, https, tcp, tls, udp, ws, or wss.',
},
savePublicNodes: {
id: 'app.multiplayer.terracotta.save-public-nodes',
defaultMessage: 'Save nodes',
},
publicNodesSaved: {
id: 'app.multiplayer.terracotta.public-nodes-saved',
defaultMessage: 'Terracotta public nodes saved',
},
})
const initialSettings = await getSettings()
const savedPublicNodes = ref(initialSettings.terracotta_public_nodes)
const publicNodesInput = ref(savedPublicNodes.value.join('\n'))
const publicNodesTouched = ref(false)
const isSavingPublicNodes = ref(false)
const parsedPublicNodes = computed(() => parseTerracottaPublicNodes(publicNodesInput.value))
const publicNodesChanged = computed(
() => publicNodesInput.value !== savedPublicNodes.value.join('\n'),
)
const showPublicNodesError = computed(
() => publicNodesTouched.value && parsedPublicNodes.value.invalidNode !== null,
)
async function savePublicNodes() {
publicNodesTouched.value = true
if (parsedPublicNodes.value.invalidNode || isSavingPublicNodes.value) return
isSavingPublicNodes.value = true
try {
const settings = await getSettings()
settings.terracotta_public_nodes = parsedPublicNodes.value.nodes
await setSettings(settings)
savedPublicNodes.value = [...parsedPublicNodes.value.nodes]
publicNodesInput.value = savedPublicNodes.value.join('\n')
addNotification({
type: 'success',
title: formatMessage(messages.publicNodesSaved),
})
} catch (error) {
handleError(error)
} finally {
isSavingPublicNodes.value = false
}
}
</script>
<template>
<SettingsSection>
<template #header>
<h2
id="terracotta-public-nodes-title"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.publicNodes) }}
</h2>
<p
id="terracotta-public-nodes-description"
class="m-0 mt-1 text-sm leading-relaxed text-secondary"
>
{{ formatMessage(messages.publicNodesDescription) }}
</p>
</template>
<div class="flex flex-col gap-3 p-4">
<StyledInput
id="terracotta-public-nodes"
v-model="publicNodesInput"
multiline
resize="vertical"
:rows="5"
:error="showPublicNodesError"
:placeholder="formatMessage(messages.publicNodesPlaceholder)"
:input-attrs="{
'aria-labelledby': 'terracotta-public-nodes-title',
'aria-invalid': showPublicNodesError,
'aria-describedby': showPublicNodesError
? 'terracotta-public-nodes-description terracotta-public-nodes-error'
: 'terracotta-public-nodes-description',
}"
:spellcheck="false"
wrapper-class="w-full"
@input="publicNodesTouched = true"
/>
<p
v-if="showPublicNodesError"
id="terracotta-public-nodes-error"
class="m-0 text-sm text-red"
>
{{ formatMessage(messages.publicNodeInvalid, { node: parsedPublicNodes.invalidNode }) }}
</p>
<div class="settings-actions flex justify-end pt-3">
<Button
type="colored"
color="brand"
native-type="button"
:disabled="!publicNodesChanged || !!parsedPublicNodes.invalidNode || isSavingPublicNodes"
@click="savePublicNodes"
>
<SpinnerIcon v-if="isSavingPublicNodes" class="animate-spin" />
<SaveIcon v-else />
{{ formatMessage(messages.savePublicNodes) }}
</Button>
</div>
</div>
</SettingsSection>
</template>
<style scoped>
.settings-actions {
border-top: 1px solid
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
}
</style>

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import MultiplayerSettings from './MultiplayerSettings.vue'
import ResourceManagementSettings from './ResourceManagementSettings.vue'
</script>
<template>
<div class="flex flex-col gap-6">
<ResourceManagementSettings scope="network-multiplayer" />
<MultiplayerSettings />
</div>
</template>

View File

@ -0,0 +1,156 @@
<script setup lang="ts">
import { defineMessages, injectNotificationManager, Toggle, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { getPrivacySettings, setDiscordRpcEnabled, setTelemetryEnabled } from '@/helpers/settings'
import SettingsRow from './SettingsRow.vue'
import SettingsSaveStatus from './SettingsSaveStatus.vue'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const privacy = ref(await getPrivacySettings())
const telemetrySaving = ref(false)
const discordSaving = ref(false)
const lastSaveState = ref<'idle' | 'saved' | 'error'>('idle')
const retrySave = ref<(() => void) | undefined>()
const messages = defineMessages({
telemetry: {
id: 'app.settings.privacy.telemetry',
defaultMessage: 'Allow telemetry',
},
telemetryDescription: {
id: 'app.settings.privacy.telemetry-description',
defaultMessage:
'Send one anonymous daily activity signal to improve usage statistics. Minecraft logs and account credentials are never uploaded.',
},
discordRpc: {
id: 'app.settings.privacy.discord-rpc',
defaultMessage: 'Discord Rich Presence',
},
discordRpcDescription: {
id: 'app.settings.privacy.discord-rpc-description',
defaultMessage: 'Show your current launcher or game activity in Discord.',
},
dataHandling: {
id: 'app.settings.privacy.data-handling',
defaultMessage:
'Telemetry uses a random installation identifier and sends only a daily activity signal. Turning telemetry off clears pending data immediately.',
},
})
const saveStatus = computed(() => {
if (telemetrySaving.value || discordSaving.value) return 'saving'
return lastSaveState.value
})
async function updateTelemetry(value: boolean) {
if (telemetrySaving.value) return
const previous = privacy.value.telemetry
privacy.value.telemetry = value
telemetrySaving.value = true
lastSaveState.value = 'idle'
retrySave.value = undefined
try {
const saved = await setTelemetryEnabled(value)
privacy.value.telemetry = saved.telemetry
privacy.value.consent_version = saved.consent_version
lastSaveState.value = 'saved'
} catch (error) {
privacy.value.telemetry = previous
retrySave.value = () => void updateTelemetry(value)
lastSaveState.value = 'error'
handleError(error)
} finally {
telemetrySaving.value = false
}
}
async function updateDiscordRpc(value: boolean) {
if (discordSaving.value) return
const previous = privacy.value.discord_rpc
privacy.value.discord_rpc = value
discordSaving.value = true
lastSaveState.value = 'idle'
retrySave.value = undefined
try {
const saved = await setDiscordRpcEnabled(value)
privacy.value.discord_rpc = saved.discord_rpc
lastSaveState.value = 'saved'
} catch (error) {
privacy.value.discord_rpc = previous
retrySave.value = () => void updateDiscordRpc(value)
lastSaveState.value = 'error'
handleError(error)
} finally {
discordSaving.value = false
}
}
</script>
<template>
<div class="flex w-full flex-col gap-4">
<header class="settings-page-header">
<SettingsSaveStatus :status="saveStatus" :retry="retrySave" />
</header>
<div class="settings-page-card">
<SettingsRow>
<template #label>
<span id="settings-target-privacy-telemetry" tabindex="-1">
{{ formatMessage(messages.telemetry) }}
</span>
</template>
<template #description>{{ formatMessage(messages.telemetryDescription) }}</template>
<template #control>
<Toggle
id="privacy-telemetry"
:model-value="privacy.telemetry"
:disabled="telemetrySaving"
@update:model-value="(value) => updateTelemetry(!!value)"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-privacy-discord-rpc" tabindex="-1">
{{ formatMessage(messages.discordRpc) }}
</span>
</template>
<template #description>{{ formatMessage(messages.discordRpcDescription) }}</template>
<template #control>
<Toggle
id="privacy-discord-rpc"
:model-value="privacy.discord_rpc"
:disabled="discordSaving"
@update:model-value="(value) => updateDiscordRpc(!!value)"
/>
</template>
</SettingsRow>
</div>
<p class="settings-page-note">{{ formatMessage(messages.dataHandling) }}</p>
</div>
</template>
<style scoped>
.settings-page-card {
overflow: hidden;
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
border-radius: var(--radius-md);
background: var(--surface-2);
}
.settings-page-header {
display: flex;
min-height: 0;
justify-content: flex-end;
}
.settings-page-note {
margin: 0;
color: var(--color-secondary);
font-size: 0.8125rem;
line-height: 1.5;
}
</style>

View File

@ -0,0 +1,75 @@
<template>
<!-- QQ 频道官方图标pd.qq.com favicon.svg与官网一致 -->
<svg
class="qq-channel-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 128 128"
fill="none"
aria-hidden="true"
>
<path
class="qq-channel-glyph"
d="M105.534 43.3071H90.7396L92.1344 33.8696C92.2086 33.3651 91.8228 32.9199 91.3183 32.9199H81.7918C81.3763 32.9199 81.035 33.2167 80.9756 33.6322L79.5363 43.3071H54.696L56.0909 33.8696C56.1651 33.3651 55.7792 32.9199 55.2747 32.9199H45.763C45.3475 32.9199 45.0062 33.2167 44.9469 33.6322L43.5075 43.3071H28.4757C28.0603 43.3071 27.719 43.6039 27.6596 44.0194L26.3686 52.7446C26.2944 53.2492 26.6802 53.6943 27.1848 53.6943H41.9643L40.1984 65.5802C35.4055 64.6602 30.4642 64.126 25.4041 64.0815C24.9886 64.0815 24.6325 64.3931 24.5731 64.7938L23.2821 73.519C23.2079 74.0087 23.6086 74.4539 24.0983 74.4539C30.479 74.4984 34.7823 75.0326 38.67 75.8487L35.9397 94.2638C35.8655 94.7683 36.2513 95.2135 36.7558 95.2135H46.2675C46.683 95.2135 47.0243 94.9167 47.0837 94.5012L49.4134 78.8462C58.9844 82.3778 67.6058 87.8979 74.7433 94.9018C77.7704 97.8696 82.8898 96.1038 83.5131 91.9192L83.884 89.4115L84.5666 84.8263H99.5984C100.014 84.8263 100.355 84.5295 100.415 84.114L101.706 75.3887C101.78 74.8842 101.394 74.439 100.889 74.439H86.095L89.1815 53.6795H104.213C104.629 53.6795 104.97 53.3827 105.029 52.9672L106.32 44.242C106.395 43.7375 106.009 43.2923 105.504 43.2923L105.534 43.3071ZM77.7111 55.6234L74.402 77.8668C74.2091 79.1281 72.7697 79.7068 71.731 78.9797C65.7806 74.7803 59.2367 71.3674 52.2624 68.8745C51.5057 68.6074 51.0456 67.8506 51.1644 67.0493L52.9302 55.1337C53.0489 54.3027 53.7612 53.6943 54.607 53.6943H76.0343C77.073 53.6943 77.8595 54.6143 77.7111 55.6382V55.6234Z"
/>
<path
d="M126.783 45.5479C124.721 34.2258 120.269 28.1716 114.333 23.2451C108.249 18.4373 100.786 14.8314 86.7925 13.1695C80.4563 12.4127 72.7401 12.1901 64 12.1901C55.2599 12.1901 47.5437 12.4127 41.2075 13.1695C27.2293 14.8463 19.7505 18.4521 13.6666 23.2451C7.7459 28.1716 3.2794 34.2258 1.2168 45.5479C0.281954 50.6821 1.58835e-05 56.9293 1.52647e-05 64.0074C1.46459e-05 71.0855 0.281952 77.3475 1.2168 82.4669C3.2794 93.789 7.73106 99.8432 13.6666 104.77C19.7505 109.578 27.2145 113.183 41.2075 114.845C47.5437 115.602 55.2599 115.825 64 115.825C72.7401 115.825 80.4563 115.602 86.7925 114.845C100.771 113.169 108.249 109.563 114.333 104.77C120.254 99.8433 124.721 93.789 126.783 82.467C127.718 77.3327 128 71.0856 128 64.0074C128 56.9293 127.718 50.6673 126.783 45.5479ZM117.331 80.7605C115.728 89.5599 112.686 93.6406 108.294 97.3207C104.139 100.585 98.4855 103.79 85.6647 105.334C80.4266 105.957 73.5414 106.254 64.0148 106.254C54.4883 106.254 47.6031 105.957 42.365 105.334C29.5442 103.805 23.8906 100.585 19.7357 97.3206C15.3434 93.6406 12.3014 89.5599 10.6988 80.7605C9.97173 76.7837 9.63044 71.4714 9.63044 64.0222C9.63044 56.5731 9.97173 51.2608 10.6988 47.284C12.3014 38.4846 15.3434 34.4039 19.7357 30.7239C23.8906 27.4593 29.5442 24.2541 42.365 22.7109C47.6031 22.0876 54.4883 21.7909 64.0149 21.7909C73.5414 21.7909 80.4266 22.0876 85.6647 22.7109C98.4855 24.2393 104.139 27.4593 108.294 30.7239C112.686 34.4039 115.728 38.4846 117.331 47.284C118.058 51.2608 118.399 56.5731 118.399 64.0223C118.399 71.4714 118.058 76.7837 117.331 80.7605Z"
fill="#2B64F5"
/>
<path
d="M126.783 45.5479C124.721 34.2258 120.269 28.1716 114.333 23.2451C108.249 18.4373 100.786 14.8314 86.7925 13.1695C80.4563 12.4127 72.7401 12.1901 64 12.1901C55.2599 12.1901 47.5437 12.4127 41.2075 13.1695C27.2293 14.8463 19.7505 18.4521 13.6666 23.2451C7.7459 28.1716 3.2794 34.2258 1.2168 45.5479C0.281954 50.6821 1.58835e-05 56.9293 1.52647e-05 64.0074C1.46459e-05 71.0855 0.281952 77.3475 1.2168 82.4669C3.2794 93.789 7.73106 99.8432 13.6666 104.77C19.7505 109.578 27.2145 113.183 41.2075 114.845C47.5437 115.602 55.2599 115.825 64 115.825C72.7401 115.825 80.4563 115.602 86.7925 114.845C100.771 113.169 108.249 109.563 114.333 104.77C120.254 99.8433 124.721 93.789 126.783 82.467C127.718 77.3327 128 71.0856 128 64.0074C128 56.9293 127.718 50.6673 126.783 45.5479ZM117.331 80.7605C115.728 89.5599 112.686 93.6406 108.294 97.3207C104.139 100.585 98.4855 103.79 85.6647 105.334C80.4266 105.957 73.5414 106.254 64.0148 106.254C54.4883 106.254 47.6031 105.957 42.365 105.334C29.5442 103.805 23.8906 100.585 19.7357 97.3206C15.3434 93.6406 12.3014 89.5599 10.6988 80.7605C9.97173 76.7837 9.63044 71.4714 9.63044 64.0222C9.63044 56.5731 9.97173 51.2608 10.6988 47.284C12.3014 38.4846 15.3434 34.4039 19.7357 30.7239C23.8906 27.4593 29.5442 24.2541 42.365 22.7109C47.6031 22.0876 54.4883 21.7909 64.0149 21.7909C73.5414 21.7909 80.4266 22.0876 85.6647 22.7109C98.4855 24.2393 104.139 27.4593 108.294 30.7239C112.686 34.4039 115.728 38.4846 117.331 47.284C118.058 51.2608 118.399 56.5731 118.399 64.0223C118.399 71.4714 118.058 76.7837 117.331 80.7605Z"
fill="url(#qq-channel-glow-teal)"
/>
<path
d="M126.783 45.5479C124.721 34.2258 120.269 28.1716 114.333 23.2451C108.249 18.4373 100.786 14.8314 86.7925 13.1695C80.4563 12.4127 72.7401 12.1901 64 12.1901C55.2599 12.1901 47.5437 12.4127 41.2075 13.1695C27.2293 14.8463 19.7505 18.4521 13.6666 23.2451C7.7459 28.1716 3.2794 34.2258 1.2168 45.5479C0.281954 50.6821 1.58835e-05 56.9293 1.52647e-05 64.0074C1.46459e-05 71.0855 0.281952 77.3475 1.2168 82.4669C3.2794 93.789 7.73106 99.8432 13.6666 104.77C19.7505 109.578 27.2145 113.183 41.2075 114.845C47.5437 115.602 55.2599 115.825 64 115.825C72.7401 115.825 80.4563 115.602 86.7925 114.845C100.771 113.169 108.249 109.563 114.333 104.77C120.254 99.8433 124.721 93.789 126.783 82.467C127.718 77.3327 128 71.0856 128 64.0074C128 56.9293 127.718 50.6673 126.783 45.5479ZM117.331 80.7605C115.728 89.5599 112.686 93.6406 108.294 97.3207C104.139 100.585 98.4855 103.79 85.6647 105.334C80.4266 105.957 73.5414 106.254 64.0148 106.254C54.4883 106.254 47.6031 105.957 42.365 105.334C29.5442 103.805 23.8906 100.585 19.7357 97.3206C15.3434 93.6406 12.3014 89.5599 10.6988 80.7605C9.97173 76.7837 9.63044 71.4714 9.63044 64.0222C9.63044 56.5731 9.97173 51.2608 10.6988 47.284C12.3014 38.4846 15.3434 34.4039 19.7357 30.7239C23.8906 27.4593 29.5442 24.2541 42.365 22.7109C47.6031 22.0876 54.4883 21.7909 64.0149 21.7909C73.5414 21.7909 80.4266 22.0876 85.6647 22.7109C98.4855 24.2393 104.139 27.4593 108.294 30.7239C112.686 34.4039 115.728 38.4846 117.331 47.284C118.058 51.2608 118.399 56.5731 118.399 64.0223C118.399 71.4714 118.058 76.7837 117.331 80.7605Z"
fill="url(#qq-channel-glow-purple)"
/>
<path
d="M126.783 45.5479C124.721 34.2258 120.269 28.1716 114.333 23.2451C108.249 18.4373 100.786 14.8314 86.7925 13.1695C80.4563 12.4127 72.7401 12.1901 64 12.1901C55.2599 12.1901 47.5437 12.4127 41.2075 13.1695C27.2293 14.8463 19.7505 18.4521 13.6666 23.2451C7.7459 28.1716 3.2794 34.2258 1.2168 45.5479C0.281954 50.6821 1.58835e-05 56.9293 1.52647e-05 64.0074C1.46459e-05 71.0855 0.281952 77.3475 1.2168 82.4669C3.2794 93.789 7.73106 99.8432 13.6666 104.77C19.7505 109.578 27.2145 113.183 41.2075 114.845C47.5437 115.602 55.2599 115.825 64 115.825C72.7401 115.825 80.4563 115.602 86.7925 114.845C100.771 113.169 108.249 109.563 114.333 104.77C120.254 99.8433 124.721 93.789 126.783 82.467C127.718 77.3327 128 71.0856 128 64.0074C128 56.9293 127.718 50.6673 126.783 45.5479ZM117.331 80.7605C115.728 89.5599 112.686 93.6406 108.294 97.3207C104.139 100.585 98.4855 103.79 85.6647 105.334C80.4266 105.957 73.5414 106.254 64.0148 106.254C54.4883 106.254 47.6031 105.957 42.365 105.334C29.5442 103.805 23.8906 100.585 19.7357 97.3206C15.3434 93.6406 12.3014 89.5599 10.6988 80.7605C9.97173 76.7837 9.63044 71.4714 9.63044 64.0222C9.63044 56.5731 9.97173 51.2608 10.6988 47.284C12.3014 38.4846 15.3434 34.4039 19.7357 30.7239C23.8906 27.4593 29.5442 24.2541 42.365 22.7109C47.6031 22.0876 54.4883 21.7909 64.0149 21.7909C73.5414 21.7909 80.4266 22.0876 85.6647 22.7109C98.4855 24.2393 104.139 27.4593 108.294 30.7239C112.686 34.4039 115.728 38.4846 117.331 47.284C118.058 51.2608 118.399 56.5731 118.399 64.0223C118.399 71.4714 118.058 76.7837 117.331 80.7605Z"
fill="url(#qq-channel-glow-yellow)"
fill-opacity="0.8"
/>
<defs>
<radialGradient
id="qq-channel-glow-teal"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(95.7003 29.4625) rotate(105.19) scale(76.7612 86.9467)"
>
<stop stop-color="#49E0BC" />
<stop offset="0.71" stop-color="#49E0BC" stop-opacity="0.185" />
<stop offset="1" stop-color="#49E0BC" stop-opacity="0" />
</radialGradient>
<radialGradient
id="qq-channel-glow-purple"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-16.1165 148.834) rotate(-60.1482) scale(97.8038 238.027)"
>
<stop stop-color="#BA76FF" />
<stop offset="1" stop-color="#BA76FF" stop-opacity="0" />
</radialGradient>
<radialGradient
id="qq-channel-glow-yellow"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(103.892 4.32155) rotate(107.684) scale(86.8182 95.2138)"
>
<stop stop-color="#FFF15B" />
<stop offset="0.566778" stop-color="#FFF15B" stop-opacity="0.145" />
<stop offset="1" stop-color="#FFF15B" stop-opacity="0" />
</radialGradient>
</defs>
</svg>
</template>
<style scoped>
.qq-channel-glyph {
fill: currentColor;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
<script setup lang="ts">
withDefaults(
defineProps<{
compact?: boolean
stacked?: boolean
}>(),
{
compact: false,
stacked: false,
},
)
</script>
<template>
<div
class="settings-row"
:class="{ 'settings-row-compact': compact, 'settings-row-stacked': stacked }"
>
<div class="flex min-w-0 flex-col gap-1">
<div v-if="$slots.label" class="text-contrast text-base font-semibold">
<slot name="label" />
</div>
<div v-if="$slots.description" class="text-secondary text-sm leading-[1.45]">
<slot name="description" />
</div>
<slot name="copy" />
</div>
<div v-if="$slots.control" class="settings-row-control flex min-w-0 justify-end">
<slot name="control" />
</div>
</div>
</template>
<style scoped>
.settings-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(10rem, 12rem);
align-items: center;
gap: var(--gap-xl);
min-height: 4rem;
padding: var(--gap-md) var(--gap-lg);
border-bottom: 1px solid
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
}
.settings-row:last-child {
border-bottom: 0;
}
.settings-row-compact {
min-height: 3.5rem;
}
.settings-row-stacked {
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: var(--gap-md);
}
.settings-row-stacked .settings-row-control {
justify-content: flex-start;
width: 100%;
}
.settings-row-control :deep(.btn),
.settings-row-control :deep(input),
.settings-row-control :deep(select),
.settings-row-control :deep(.combobox) {
max-width: 100%;
}
@media (max-width: 700px) {
.settings-row {
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: var(--gap-md);
}
.settings-row-control {
justify-content: flex-start;
width: 100%;
}
}
</style>

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, NewButton as Button, useVIntl } from '@modrinth/ui'
const props = withDefaults(
defineProps<{
status?: 'idle' | 'saving' | 'saved' | 'error'
retry?: (() => void) | undefined
}>(),
{
status: 'idle',
retry: undefined,
},
)
const { formatMessage } = useVIntl()
const messages = defineMessages({
saving: { id: 'app.settings.save-status.saving', defaultMessage: 'Saving…' },
saved: { id: 'app.settings.save-status.saved', defaultMessage: 'Saved' },
error: { id: 'app.settings.save-status.error', defaultMessage: 'Could not save' },
retry: { id: 'app.settings.save-status.retry', defaultMessage: 'Retry' },
})
const statusMessage = {
saving: messages.saving,
saved: messages.saved,
error: messages.error,
} as const
</script>
<template>
<div
v-if="props.status !== 'idle'"
class="settings-save-status inline-flex items-center gap-1 text-xs text-secondary"
role="status"
>
<SpinnerIcon v-if="props.status === 'saving'" class="size-3.5 animate-spin" />
<span>{{ props.status === 'idle' ? '' : formatMessage(statusMessage[props.status]) }}</span>
<Button v-if="props.status === 'error' && props.retry" type="quiet" @click="props.retry">
{{ formatMessage(messages.retry) }}
</Button>
</div>
</template>
<style scoped>
.settings-save-status :deep(.btn) {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
</style>

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import { Card } from '@modrinth/ui'
withDefaults(
defineProps<{
title?: string
description?: string
}>(),
{
title: undefined,
description: undefined,
},
)
</script>
<template>
<section class="flex min-w-0 flex-col gap-3">
<header
v-if="title || description || $slots.header"
class="settings-section-header flex items-start justify-between gap-4"
>
<div class="min-w-0">
<h2 v-if="title" class="m-0 text-base font-semibold text-contrast">{{ title }}</h2>
<p v-if="description" class="m-0 mt-1 text-sm leading-relaxed text-secondary">
{{ description }}
</p>
<slot name="header" />
</div>
<slot name="extra" />
</header>
<Card class="settings-section-card">
<slot />
</Card>
</section>
</template>
<style scoped>
.settings-section-card {
margin: 0;
padding: 0;
background: var(--surface-2);
border-color: var(--surface-4);
border-radius: var(--radius-md);
}
@media (max-width: 700px) {
.settings-section-header {
flex-direction: column;
gap: var(--gap-sm);
}
}
</style>

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import ResourceManagementSettings from './ResourceManagementSettings.vue'
import StorageSettings from './StorageSettings.vue'
</script>
<template>
<div class="flex flex-col gap-6">
<ResourceManagementSettings scope="storage-backups" />
<StorageSettings />
</div>
</template>

View File

@ -0,0 +1,732 @@
<script setup lang="ts">
import { HelpCircleIcon, RefreshCwIcon, SpinnerIcon } from '@modrinth/assets'
import { useFormatBytes, useVIntl } from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { list as fetchInstances } from '@/helpers/instance'
import {
listenStorageScan,
openStoragePaths,
startStorageScan,
type StorageScanEvent,
} from '@/helpers/storage'
import type { StorageNode, StorageNodeType, StorageSize, StorageTree } from './storage/storageData'
import { sortStorageChildren } from './storage/storageData'
import { storageMessages } from './storage/storageMessages'
import StorageTreeNode from './storage/StorageTreeNode.vue'
type MessageDescriptor = (typeof storageMessages)['total']
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const router = useRouter()
const tree = ref<StorageTree | null>(null)
const loading = ref(true)
const lastUpdated = ref<Date | null>(null)
let listenerUnsubscribe: (() => void) | null = null
const categories = computed(() => tree.value?.categories ?? [])
const mainCategories = computed<StorageNode[]>(() => {
const list = categories.value.filter(
(category) => category.type !== 'other' && category.size.actual + category.size.symlink > 0,
)
const rootOther = tree.value?.rootOther
if (rootOther && rootOther.size.actual + rootOther.size.symlink > 0) {
list.push(rootOther)
}
return list
})
const instancesCategory = computed(
() => categories.value.find((category) => category.type === 'instances') ?? null,
)
const categoryLabels: Record<StorageNodeType, MessageDescriptor> = {
instances: storageMessages.instanceData,
cache: storageMessages.cacheData,
meta: storageMessages.metaData,
database: storageMessages.database,
other: storageMessages.other,
}
// 醒目高对比度色彩配置
const categoryColors: Record<StorageNodeType, { actual: string; symlink: string }> = {
instances: { actual: '#10b981', symlink: '#06b6d4' }, // 翡翠绿 / 亮青
cache: { actual: '#f59e0b', symlink: '#eab308' }, // 琥珀黄 / 明黄
meta: { actual: '#8b5cf6', symlink: '#ec4899' }, // 靛紫 / 靓粉
database: { actual: '#3b82f6', symlink: '#6366f1' }, // 靛蓝 / 靛青
other: { actual: '#6b7280', symlink: '#9ca3af' }, // 中灰 / 浅灰
}
const hoveredId = ref<string | null>(null)
const symlinkHelpTooltipOptions = computed(() => ({
content: formatMessage(storageMessages.symlinkHelpTooltip),
popperClass: 'storage-tooltip',
}))
function sizeTotal(size: StorageSize) {
return size.actual + size.symlink
}
function formatSize(size: StorageSize) {
const actual = formatBytes(size.actual)
if (size.symlink <= 0) return actual
return `${actual} + ${formatBytes(size.symlink)}`
}
function emptyTree(): StorageTree {
return {
total: { actual: 0, symlink: 0 },
categories: [],
rootOther: null,
}
}
function applyStorageEvent(event: StorageScanEvent) {
switch (event.kind) {
case 'started':
loading.value = true
break
case 'category': {
const category = event.payload.category
if (!tree.value) tree.value = emptyTree()
if (category.type === 'other') {
tree.value!.rootOther = category
} else {
const index = tree.value!.categories.findIndex((child) => child.id === category.id)
if (index >= 0) tree.value!.categories[index] = category
else tree.value!.categories.push(category)
}
break
}
case 'complete': {
tree.value = event.payload.tree
lastUpdated.value = tree.value.scannedAt ? new Date(tree.value.scannedAt) : new Date()
loading.value = false
break
}
case 'error': {
loading.value = false
console.warn('[storage] Scan failed:', event.payload.message)
break
}
}
}
async function loadStorage(force: boolean) {
loading.value = true
try {
await startStorageScan(force)
} catch (error) {
loading.value = false
console.warn('[storage] Failed to start storage scan', error)
}
}
async function updateStorage() {
await loadStorage(true)
}
onMounted(async () => {
try {
listenerUnsubscribe = await listenStorageScan(applyStorageEvent)
} catch (error) {
console.warn('[storage] Failed to subscribe to storage scan events', error)
}
await loadStorage(false)
})
onUnmounted(() => {
listenerUnsubscribe?.()
})
function findInstanceParent(target: StorageNode): StorageNode | null {
const roots: StorageNode[] = [...(tree.value?.categories ?? [])]
if (tree.value?.rootOther) roots.push(tree.value.rootOther)
const stack: { node: StorageNode; parentInstance: StorageNode | null }[] = roots.map((node) => ({
node,
parentInstance: null,
}))
while (stack.length > 0) {
const { node, parentInstance } = stack.pop()!
if (node === target) return node.type === 'instance' ? node : parentInstance
const nextInstance = node.type === 'instance' ? node : parentInstance
for (const child of node.children ?? []) {
stack.push({ node: child, parentInstance: nextInstance })
}
}
return null
}
async function resolveInstanceIdByName(instanceNode: StorageNode): Promise<string | null> {
const name = instanceNode.name?.trim() ?? ''
if (!name) return null
try {
const instances = await fetchInstances()
const normalizedName = name.toLowerCase()
return (
instances.find(
(instance) =>
instance.path === name || instance.name.trim().toLowerCase() === normalizedName,
)?.id ?? null
)
} catch (error) {
console.warn('[storage] Failed to resolve instance id, falling back to filesystem', error)
return null
}
}
async function resolveInstanceId(node: StorageNode): Promise<string | null> {
const instanceNode = findInstanceParent(node)
const directId = node.instance_id ?? instanceNode?.instance_id ?? null
if (directId) return directId
return instanceNode ? resolveInstanceIdByName(instanceNode) : null
}
function launcherRouteFor(node: StorageNode, instanceId: string): string | null {
const encodedId = encodeURIComponent(instanceId)
switch (node.type) {
case 'instance':
case 'mods':
return `/instance/${encodedId}`
case 'saves':
case 'world':
return `/instance/${encodedId}/worlds`
case 'screenshots':
return `/instance/${encodedId}/screenshots`
default:
return null
}
}
async function tryNavigate(path: string): Promise<boolean> {
try {
await router.push(path)
return true
} catch (error) {
console.warn('[storage] Launcher navigation failed, falling back to filesystem', error)
return false
}
}
async function openNodePaths(node: StorageNode) {
if (node.paths.length === 0) return
try {
const result = await openStoragePaths(node.paths)
for (const failure of result.failed) {
console.warn(`[storage] Failed to open path: ${failure.path}`, failure.reason)
}
} catch (error) {
console.warn('[storage] Failed to open storage paths', error)
}
}
async function handleAction(node: StorageNode) {
const instanceId = await resolveInstanceId(node)
const route = instanceId ? launcherRouteFor(node, instanceId) : null
if (route && (await tryNavigate(route))) return
await openNodePaths(node)
}
interface ChartItem {
id: string
category: StorageNode
label: string
sizeBytes: number
formattedSize: string
color: string
isSymlink: boolean
startAngle: number
endAngle: number
pathData: string
percentText: string
}
// 极其精准的 SVG Path 圆弧/环形生成算法(彻底解决 SVG circle dashoffset 错位乱套问题)
function getRingPath(
cx: number,
cy: number,
rInner: number,
rOuter: number,
startAngle: number,
endAngle: number,
) {
// 防止 100% 比例下起点终点重合导致无法绘制
const angleDiff = endAngle - startAngle
const safeEndAngle = angleDiff >= 360 ? startAngle + 359.999 : endAngle
const rad = (deg: number) => (deg - 90) * (Math.PI / 180)
const x1 = cx + rOuter * Math.cos(rad(startAngle))
const y1 = cy + rOuter * Math.sin(rad(startAngle))
const x2 = cx + rOuter * Math.cos(rad(safeEndAngle))
const y2 = cy + rOuter * Math.sin(rad(safeEndAngle))
const x3 = cx + rInner * Math.cos(rad(safeEndAngle))
const y3 = cy + rInner * Math.sin(rad(safeEndAngle))
const x4 = cx + rInner * Math.cos(rad(startAngle))
const y4 = cy + rInner * Math.sin(rad(startAngle))
const largeArc = angleDiff > 180 ? 1 : 0
return [
`M ${x1} ${y1}`,
`A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${x2} ${y2}`,
`L ${x3} ${y3}`,
`A ${rInner} ${rInner} 0 ${largeArc} 0 ${x4} ${y4}`,
'Z',
].join(' ')
}
const chartSlices = computed(() => {
const items: {
id: string
category: StorageNode
label: string
sizeBytes: number
formattedSize: string
color: string
isSymlink: boolean
}[] = []
// 拆分实体与软链接
for (const cat of mainCategories.value) {
const baseLabel = formatMessage(categoryLabels[cat.type])
if (cat.size.actual > 0) {
items.push({
id: `${cat.id}-actual`,
category: cat,
label: baseLabel,
sizeBytes: cat.size.actual,
formattedSize: formatBytes(cat.size.actual),
color: categoryColors[cat.type].actual,
isSymlink: false,
})
}
if (cat.size.symlink > 0) {
items.push({
id: `${cat.id}-symlink`,
category: cat,
label: `${baseLabel} (${formatMessage(storageMessages.symlinkLabel)})`,
sizeBytes: cat.size.symlink,
formattedSize: formatBytes(cat.size.symlink),
color: categoryColors[cat.type].symlink,
isSymlink: true,
})
}
}
const total = items.reduce((acc, item) => acc + item.sizeBytes, 0) || 1
let currentAngle = 0
return items.map((item) => {
const ratio = item.sizeBytes / total
const angle = ratio * 360
const startAngle = currentAngle
const endAngle = currentAngle + angle
currentAngle = endAngle
return {
...item,
startAngle,
endAngle,
percentText: `${(ratio * 100).toFixed(1)}%`,
pathData: getRingPath(50, 50, 26, 48, startAngle, endAngle), // 外径48内径26加粗环形
} as ChartItem
})
})
function formatDateTime(date: Date) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date)
}
</script>
<template>
<div id="settings-target-storage-overview" tabindex="-1" class="storage-page">
<div v-if="loading && !tree" class="storage-loading">
<SpinnerIcon class="size-8 animate-spin text-brand" />
<span>{{ formatMessage(storageMessages.scanning) }}</span>
</div>
<template v-else-if="tree">
<!-- 顶部面板总览 + 更新按钮 + 饼图 -->
<section class="storage-dashboard">
<div class="storage-total-card">
<h2 class="storage-total-title">
{{ formatMessage(storageMessages.total) }}
</h2>
<div class="storage-total-value">
<span class="text-3xl font-bold leading-[1.1] text-contrast">{{ formatBytes(tree.total.actual) }}</span>
<span v-if="tree.total.symlink > 0" class="total-symlink">
+ {{ formatBytes(tree.total.symlink) }} ({{
formatMessage(storageMessages.symlinkLabel)
}})
</span>
</div>
<div class="storage-actions">
<button class="btn min-w-max" :disabled="loading" @click="updateStorage">
<SpinnerIcon v-if="loading" class="size-4 animate-spin" />
<RefreshCwIcon v-else />
{{ formatMessage(loading ? storageMessages.updating : storageMessages.update) }}
</button>
<span v-if="lastUpdated" class="storage-last-updated">
<span>{{ formatMessage(storageMessages.lastUpdatedLabel) }}</span>
<span class="tabular-nums">{{ formatDateTime(lastUpdated) }}</span>
</span>
</div>
</div>
<div v-if="mainCategories.length > 0" class="storage-chart-section">
<div class="storage-pie-wrapper">
<svg class="storage-pie-svg" viewBox="0 0 100 100">
<path
v-for="slice in chartSlices"
:key="slice.id"
:d="slice.pathData"
:fill="slice.color"
class="pie-path"
:class="{
'is-hovered': hoveredId === slice.id,
'is-dimmed': hoveredId !== null && hoveredId !== slice.id,
}"
@mouseenter="hoveredId = slice.id"
@mouseleave="hoveredId = null"
@click="handleAction(slice.category)"
/>
</svg>
</div>
<div class="storage-legend">
<button
v-for="slice in chartSlices"
:key="slice.id"
type="button"
class="legend-item"
:class="{
'is-hovered': hoveredId === slice.id,
'is-dimmed': hoveredId !== null && hoveredId !== slice.id,
}"
@mouseenter="hoveredId = slice.id"
@mouseleave="hoveredId = null"
@click="handleAction(slice.category)"
>
<span
class="legend-dot mt-1 h-2.5 w-2.5 shrink-0 rounded-full"
:class="{ 'is-symlink-dot': slice.isSymlink }"
:style="{ backgroundColor: slice.color }"
/>
<div class="legend-info">
<span class="whitespace-nowrap text-[0.8125rem] font-semibold text-contrast">{{ slice.label }}</span>
<span class="legend-size">{{ slice.formattedSize }}</span>
<span class="legend-percent">{{ slice.percentText }}</span>
</div>
</button>
</div>
</div>
</section>
<!-- 实例树节点列表 -->
<section v-if="instancesCategory" class="mt-7">
<div v-tooltip="symlinkHelpTooltipOptions" class="instance-help">
<HelpCircleIcon class="instance-help-icon" aria-hidden="true" />
<span>{{ formatMessage(storageMessages.symlinkHelp) }}</span>
</div>
<div class="instance-heading">
<span class="storage-section-title">
{{ formatMessage(storageMessages.instanceData) }}
</span>
<span class="storage-section-size">
{{ formatSize(instancesCategory.size) }}
</span>
</div>
<div class="storage-tree">
<StorageTreeNode
v-for="child in sortStorageChildren(instancesCategory.children)"
:key="child.id"
:node="child"
:depth="0"
:parent-total="sizeTotal(instancesCategory.size)"
@action="handleAction"
/>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.storage-page {
display: flex;
flex-direction: column;
width: 100%;
color: var(--color-contrast);
}
.storage-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem 0;
color: var(--color-secondary);
}
/* 顶栏卡片布局 */
.storage-dashboard {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap-xl);
padding: var(--gap-lg);
border: 1px solid var(--surface-4);
border-radius: var(--radius-md);
background: var(--surface-2);
overflow: hidden;
}
/* 左侧总大小区域 */
.storage-total-card {
display: flex;
flex-direction: column;
gap: 0.375rem;
flex-shrink: 0;
}
.storage-total-title {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-secondary);
}
.storage-total-value {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-variant-numeric: tabular-nums;
}
.total-symlink {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-secondary);
}
.storage-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
margin-top: 0.5rem;
}
.storage-last-updated {
display: flex;
flex-direction: column;
font-size: 0.75rem;
color: var(--color-secondary);
}
/* 右侧核心区域:强制向右对齐 */
.storage-chart-section {
display: flex;
align-items: center;
gap: 1.5rem;
margin-left: auto;
}
/* SVG 饼图包裹层 (较大且加粗) */
.storage-pie-wrapper {
position: relative;
width: 130px;
height: 130px;
flex-shrink: 0;
}
.storage-pie-svg {
width: 100%;
height: 100%;
overflow: visible;
}
.pie-path {
cursor: pointer;
transition:
transform 150ms ease,
opacity 150ms ease,
filter 150ms ease;
transform-origin: 50px 50px;
}
.pie-path.is-hovered {
transform: scale(1.06);
opacity: 1;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.25));
}
.pie-path.is-dimmed {
opacity: 0.3;
}
/* 图例布局 */
.storage-legend {
display: grid;
grid-template-columns: repeat(2, auto);
gap: 0.625rem 1.25rem;
}
.legend-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border: 0;
border-radius: 0.375rem;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 120ms ease,
opacity 150ms ease;
}
.legend-item.is-hovered {
background: var(--surface-3);
}
.legend-item.is-dimmed {
opacity: 0.3;
}
.legend-item:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: -1px;
}
.legend-dot.is-symlink-dot {
border-radius: 2px;
}
.legend-info {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.legend-size {
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-secondary);
white-space: nowrap;
}
/* 百分比单独一行显示,避免超过卡片 */
.legend-percent {
display: block;
font-size: 0.71875rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--color-secondary);
opacity: 0.8;
}
/* 实例树样式 */
.instance-heading {
display: flex;
align-items: baseline;
gap: 0.75rem;
min-width: 0;
}
.storage-section-title {
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.375rem;
color: var(--color-contrast);
}
.storage-section-size {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-secondary);
}
.instance-help {
display: inline-flex;
align-items: center;
gap: 0.3125rem;
margin-bottom: 0.375rem;
font-size: 0.75rem;
line-height: 1.25rem;
color: var(--color-secondary);
cursor: help;
}
.instance-help-icon {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
}
.storage-tree {
display: flex;
flex-direction: column;
margin-top: 0.25rem;
}
/* 响应式支持 */
@media (max-width: 860px) {
.storage-dashboard {
flex-direction: column;
align-items: flex-start;
gap: 1.25rem;
}
.storage-chart-section {
margin-left: 0;
width: 100%;
justify-content: flex-start;
}
}
@media (max-width: 520px) {
.storage-chart-section {
flex-direction: column;
align-items: flex-start;
}
.storage-legend {
grid-template-columns: 1fr;
}
}
/* 存储页多行 tooltip内容换行并限制宽度 */
:global(.v-popper__popper.storage-tooltip .v-popper__inner) {
white-space: pre-line;
max-width: 22rem;
}
</style>

View File

@ -0,0 +1,816 @@
<script setup lang="ts">
import { PlugIcon, SpinnerIcon, TrashIcon } from '@modrinth/assets'
import {
Combobox,
defineMessages,
injectNotificationManager,
LOCALES,
NewButton as Button,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { type AIProviderDefinition, getAICatalog, getAIState, sharedAIState } from '@/helpers/ai'
import {
clearTranslationCache,
getGoogleIpPoolSize,
getTranslationErrorKind,
getTranslationSettings,
testTranslationProvider,
type TranslationProvider,
type TranslationSettings as TranslationSettingsState,
type TranslationStyle,
updateTranslationSettings,
} from '@/helpers/translation'
import AIIcon from './AIIcon.vue'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const settings = ref<TranslationSettingsState>({
provider: 'google',
target_language: '',
mode: 'bilingual',
auto_translate: false,
style: 'weakened',
ai_provider_id: '',
ai_model_id: '',
ai_system_prompt: '',
deepl_api_endpoint: 'https://api-free.deepl.com/v2/translate',
deepl_api_key: null,
})
// Debug logging helper
function debugLog(area: string, message: string, data?: unknown) {
const timestamp = new Date().toISOString()
const prefix = `[Translation Debug ${timestamp}] [${area}]`
if (data !== undefined) {
console.log(prefix, message, data)
} else {
console.log(prefix, message)
}
}
const aiCatalog = ref<AIProviderDefinition[]>([])
const loading = ref(true)
const cacheStatus = ref('')
const testing = ref(false)
const testStatus = ref('')
const googleIpPoolSize = ref(0)
let saveTimer: ReturnType<typeof setTimeout> | undefined
let poolTimer: ReturnType<typeof setInterval> | undefined
const messages = defineMessages({
title: { id: 'app.translation-settings.title', defaultMessage: 'Translation' },
description: {
id: 'app.translation-settings.description',
defaultMessage:
'Translate Modrinth project titles, summaries, and descriptions while browsing content.',
},
provider: { id: 'app.translation-settings.provider', defaultMessage: 'Translation service' },
google: {
id: 'app.translation-settings.provider.google',
defaultMessage: 'Google Translate (free)',
},
deepl: {
id: 'app.translation-settings.provider.deepl',
defaultMessage: 'DeepL',
},
deeplApiEndpoint: {
id: 'app.translation-settings.deepl-api-endpoint',
defaultMessage: 'API endpoint',
},
deeplApiEndpointPlaceholder: {
id: 'app.translation-settings.deepl-api-endpoint-placeholder',
defaultMessage: 'https://api-free.deepl.com/v2/translate',
},
deeplApiKey: {
id: 'app.translation-settings.deepl-api-key',
defaultMessage: 'API key',
},
deeplApiKeyPlaceholder: {
id: 'app.translation-settings.deepl-api-key-placeholder',
defaultMessage: 'Enter your DeepL API key',
},
deeplApiKeyHint: {
id: 'app.translation-settings.deepl-api-key-hint',
defaultMessage: 'Get a free key at deepl.com/pro-api',
},
googleIpPool: {
id: 'app.translation-settings.google-ip-pool',
defaultMessage: 'IP pool {count}',
},
ai: { id: 'app.translation-settings.provider.ai', defaultMessage: 'AI model' },
aiProvider: { id: 'app.translation-settings.ai-provider', defaultMessage: 'AI provider' },
aiModel: { id: 'app.translation-settings.ai-model', defaultMessage: 'Text model' },
targetLanguage: {
id: 'app.translation-settings.target-language',
defaultMessage: 'Target language',
},
followApp: {
id: 'app.translation-settings.target-language.follow-app',
defaultMessage: 'Follow launcher language',
},
displayMode: {
id: 'app.translation-settings.display-mode',
defaultMessage: 'Display mode',
},
bilingual: {
id: 'app.translation-settings.display-mode.bilingual',
defaultMessage: 'Original and translation',
},
translationOnly: {
id: 'app.translation-settings.display-mode.translation-only',
defaultMessage: 'Translation only',
},
autoTranslate: {
id: 'app.translation-settings.auto-translate',
defaultMessage: 'Translate project pages automatically',
},
autoTranslateDescription: {
id: 'app.translation-settings.auto-translate-description',
defaultMessage: 'Start translating as soon as a Modrinth project page is opened.',
},
style: { id: 'app.translation-settings.style', defaultMessage: 'Translation style' },
styleDefault: { id: 'app.translation-settings.style.default', defaultMessage: 'Default' },
styleBlur: { id: 'app.translation-settings.style.blur', defaultMessage: 'Blur' },
styleBlockquote: {
id: 'app.translation-settings.style.blockquote',
defaultMessage: 'Block quote',
},
styleWeakened: { id: 'app.translation-settings.style.weakened', defaultMessage: 'Muted' },
styleDashedLine: {
id: 'app.translation-settings.style.dashed-line',
defaultMessage: 'Dashed underline',
},
styleBorder: { id: 'app.translation-settings.style.border', defaultMessage: 'Border' },
styleTextColor: {
id: 'app.translation-settings.style.text-color',
defaultMessage: 'Text color',
},
styleBackground: {
id: 'app.translation-settings.style.background',
defaultMessage: 'Background',
},
stylePreview: { id: 'app.translation-settings.style.preview', defaultMessage: 'Preview' },
stylePreviewOriginalText: {
id: 'app.translation-settings.style.preview-original-text',
defaultMessage: 'Explore high-quality Minecraft content on Modrinth.',
},
stylePreviewText: {
id: 'app.translation-settings.style.preview-text',
defaultMessage: 'Discover high-quality Minecraft content on Modrinth.',
},
systemPrompt: {
id: 'app.translation-settings.system-prompt',
defaultMessage: 'Translation instructions',
},
systemPromptDescription: {
id: 'app.translation-settings.system-prompt-description',
defaultMessage:
'Optional feature-specific instructions. The launcher always appends its structured translation contract.',
},
test: { id: 'app.translation-settings.test', defaultMessage: 'Test service' },
testing: { id: 'app.translation-settings.testing', defaultMessage: 'Testing…' },
testSuccess: {
id: 'app.translation-settings.test-success',
defaultMessage: 'Connection succeeded: {translation}',
},
cache: { id: 'app.translation-settings.cache', defaultMessage: 'Translation cache' },
cacheDescription: {
id: 'app.translation-settings.cache-description',
defaultMessage: 'Successful translations are cached for seven days to reduce requests.',
},
clearCache: {
id: 'app.translation-settings.clear-cache',
defaultMessage: 'Clear translation cache',
},
cacheCleared: {
id: 'app.translation-settings.cache-cleared',
defaultMessage: 'Translation cache cleared.',
},
operationFailed: {
id: 'app.translation-settings.operation-failed',
defaultMessage: 'The translation operation failed. Check the configuration and try again.',
},
rateLimited: {
id: 'app.translation.error.rate-limited',
defaultMessage: 'The translation service is temporarily rate limited. Please try again later.',
},
authenticationFailed: {
id: 'app.translation.error.authentication',
defaultMessage: 'The translation service could not authenticate. Please try again later.',
},
contentTooLong: {
id: 'app.translation.error.content-too-long',
defaultMessage: 'This content is too long for the selected translation service.',
},
networkFailed: {
id: 'app.translation.error.network',
defaultMessage: 'The translation service could not be reached. Check your network or proxy.',
},
})
const configuredAIProviders = computed(() =>
(sharedAIState.value?.providers ?? []).filter(
(provider) => provider.enabled && provider.models.some((model) => model.enabled),
),
)
const aiAvailable = computed(
() => !!sharedAIState.value?.settings.enabled && configuredAIProviders.value.length > 0,
)
const modes = ['bilingual', 'translation-only'] as const
const styles: TranslationStyle[] = [
'default',
'blur',
'blockquote',
'weakened',
'dashed-line',
'border',
'text-color',
'background',
]
const languages = ['follow-app', ...LOCALES.map((locale) => locale.code)]
const targetLanguage = computed({
get: () => settings.value.target_language || 'follow-app',
set: (value: string) => {
settings.value.target_language = value === 'follow-app' ? '' : value
},
})
function providerName(provider: TranslationProvider) {
return formatMessage(
{ google: messages.google, deepl: messages.deepl, ai: messages.ai }[provider],
)
}
function languageName(code: string) {
if (code === 'follow-app') return formatMessage(messages.followApp)
const locale = LOCALES.find((item) => item.code === code)
return locale ? `${locale.name}${formatMessage(locale.translatedName)}` : code
}
function styleName(style: TranslationStyle) {
return formatMessage(
{
default: messages.styleDefault,
blur: messages.styleBlur,
blockquote: messages.styleBlockquote,
weakened: messages.styleWeakened,
'dashed-line': messages.styleDashedLine,
border: messages.styleBorder,
'text-color': messages.styleTextColor,
background: messages.styleBackground,
}[style],
)
}
const translationProviders = computed<TranslationProvider[]>(() => [
'google',
...(aiAvailable.value ? (['ai'] as const) : []),
'deepl',
])
const providerOptions = computed(() =>
translationProviders.value.map((provider) => ({
value: provider,
label: providerName(provider),
})),
)
const languageOptions = computed(() =>
languages.map((language) => ({ value: language, label: languageName(language) })),
)
const modeOptions = computed(() =>
modes.map((mode) => ({
value: mode,
label: formatMessage(mode === 'bilingual' ? messages.bilingual : messages.translationOnly),
})),
)
const styleOptions = computed(() =>
styles.map((style) => ({ value: style, label: styleName(style) })),
)
const aiProviderOptions = computed(() =>
configuredAIProviders.value.map((provider) => ({
value: provider.provider_id,
label:
provider.custom_name ||
aiCatalog.value.find((definition) => definition.id === provider.provider_id)?.name ||
provider.provider_id,
})),
)
const selectedAIProvider = computed({
get: () => settings.value.ai_provider_id,
set: (providerId: string) => {
settings.value.ai_provider_id = providerId
settings.value.ai_model_id =
configuredAIProviders.value
.find((provider) => provider.provider_id === providerId)
?.models.find((model) => model.enabled)?.id ?? ''
},
})
const aiModelOptions = computed(() =>
(
configuredAIProviders.value.find(
(provider) => provider.provider_id === settings.value.ai_provider_id,
)?.models ?? []
)
.filter((model) => model.enabled)
.map((model) => ({ value: model.id, label: model.name || model.id })),
)
const stylePreviewClass = computed(() => `translation-style-preview-${settings.value.style}`)
watch(
[aiAvailable, configuredAIProviders],
() => {
if (!aiAvailable.value) {
if (settings.value.provider === 'ai') settings.value.provider = 'google'
return
}
if (
!configuredAIProviders.value.some(
(provider) => provider.provider_id === settings.value.ai_provider_id,
)
) {
selectedAIProvider.value = configuredAIProviders.value[0]?.provider_id ?? ''
}
if (!aiModelOptions.value.some((model) => model.value === settings.value.ai_model_id)) {
settings.value.ai_model_id = aiModelOptions.value[0]?.value ?? ''
}
},
{ immediate: true, deep: true },
)
function reportOperationError(error?: unknown, context?: string) {
const errorKind = error ? getTranslationErrorKind(error) : 'provider'
const errorMessage = error instanceof Error ? error.message : String(error)
debugLog('Error', `Operation failed${context ? ` (${context})` : ''}`, {
kind: errorKind,
message: errorMessage,
provider: settings.value.provider,
deeplApiKeySet: !!settings.value.deepl_api_key?.trim(),
deeplEndpoint: settings.value.deepl_api_endpoint,
})
// Don't show error notifications for DeepL when API key is not configured
// This prevents spam when user is still configuring
if (
settings.value.provider === 'deepl' &&
!settings.value.deepl_api_key?.trim() &&
errorMessage.includes('DeepL API key is not configured')
) {
debugLog('Error', 'Suppressing DeepL API key not configured error - user is still configuring')
return
}
const message = error
? {
'rate-limited': messages.rateLimited,
authentication: messages.authenticationFailed,
'content-too-long': messages.contentTooLong,
network: messages.networkFailed,
provider: messages.operationFailed,
}[errorKind]
: messages.operationFailed
// Surface the underlying provider error (e.g. DeepL HTTP status, quota
// or endpoint mistakes) instead of a generic message, so users can fix
// the configuration themselves.
const displayMessage =
errorKind === 'provider' && errorMessage.includes('DeepL API error')
? errorMessage
: formatMessage(message)
handleError(new Error(displayMessage))
}
watch(
settings,
(newSettings) => {
if (loading.value) {
debugLog('Watch', 'Skipping save - still loading')
return
}
// Deep watchers receive the same object for old and new values when a
// nested field is mutated in place. Always schedule a save for a loaded
// settings change instead of comparing those references.
debugLog('Watch', 'Settings changed', {
provider: newSettings.provider,
mode: newSettings.mode,
style: newSettings.style,
autoTranslate: newSettings.auto_translate,
})
clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
saveTimer = undefined
debugLog('Save', 'Saving settings to backend', {
provider: newSettings.provider,
deeplApiKeySet: !!newSettings.deepl_api_key?.trim(),
deeplEndpoint: newSettings.deepl_api_endpoint,
aiProviderId: newSettings.ai_provider_id,
aiModelId: newSettings.ai_model_id,
})
// Only save settings, don't show error notifications
// Errors during save should be silent - only test button shows errors
void updateTranslationSettings({ ...settings.value })
.then(() => {
debugLog('Save', 'Settings saved successfully')
})
.catch((error) => {
// Log error but don't show notification to user
// Only the "Test" button should show errors
const errorMessage = error instanceof Error ? error.message : String(error)
debugLog('Save', 'Settings save failed (silent)', {
error: errorMessage,
provider: newSettings.provider,
})
})
}, 300)
},
{ deep: true },
)
async function refreshGoogleIpPool() {
try {
googleIpPoolSize.value = await getGoogleIpPoolSize()
} catch (error) {
reportOperationError(error)
}
}
watch(
() => settings.value.provider,
(provider) => {
clearInterval(poolTimer)
if (provider !== 'google') return
void refreshGoogleIpPool()
poolTimer = setInterval(() => void refreshGoogleIpPool(), 5000)
},
{ immediate: true },
)
onUnmounted(() => {
clearInterval(poolTimer)
if (loading.value || !saveTimer) return
clearTimeout(saveTimer)
saveTimer = undefined
void updateTranslationSettings({ ...settings.value }).catch(reportOperationError)
})
onMounted(async () => {
debugLog('Init', 'Loading translation settings...')
try {
const [loadedSettings, , loadedCatalog] = await Promise.all([
getTranslationSettings(),
getAIState(),
getAICatalog(),
])
debugLog('Init', 'Settings loaded from backend', {
provider: loadedSettings.provider,
deeplApiKeySet: !!loadedSettings.deepl_api_key?.trim(),
deeplEndpoint: loadedSettings.deepl_api_endpoint,
aiProviderId: loadedSettings.ai_provider_id,
aiModelId: loadedSettings.ai_model_id,
targetLanguage: loadedSettings.target_language,
mode: loadedSettings.mode,
autoTranslate: loadedSettings.auto_translate,
})
settings.value = loadedSettings
aiCatalog.value = loadedCatalog
} catch (error) {
debugLog('Init', 'Failed to load settings', error)
reportOperationError(error, 'load-settings')
} finally {
loading.value = false
debugLog('Init', 'Loading complete')
}
})
async function testProvider() {
debugLog('Test', 'Starting provider test', {
provider: settings.value.provider,
deeplApiKeySet: !!settings.value.deepl_api_key?.trim(),
deeplEndpoint: settings.value.deepl_api_endpoint,
aiProviderId: settings.value.ai_provider_id,
aiModelId: settings.value.ai_model_id,
})
testing.value = true
testStatus.value = ''
// Validate DeepL configuration before testing
if (settings.value.provider === 'deepl') {
if (!settings.value.deepl_api_key?.trim()) {
debugLog('Test', 'DeepL API key is not configured')
reportOperationError(
new Error('DeepL API key is not configured. Please enter your API key first.'),
'deepl-validation',
)
testing.value = false
return
}
if (!settings.value.deepl_api_endpoint?.trim()) {
debugLog('Test', 'DeepL API endpoint is not configured, using default')
settings.value.deepl_api_endpoint = 'https://api-free.deepl.com/v2/translate'
}
}
try {
debugLog('Test', 'Saving settings before test...')
await updateTranslationSettings({ ...settings.value })
debugLog('Test', 'Settings saved, now testing provider...')
const result = await testTranslationProvider(settings.value.provider)
debugLog('Test', 'Test succeeded', { result })
testStatus.value = formatMessage(messages.testSuccess, { translation: result })
} catch (error) {
debugLog('Test', 'Test failed', error)
reportOperationError(error, 'test-provider')
} finally {
testing.value = false
debugLog('Test', 'Test complete')
}
}
async function clearCache() {
try {
await clearTranslationCache()
cacheStatus.value = formatMessage(messages.cacheCleared)
} catch (error) {
reportOperationError(error)
}
}
</script>
<template>
<div v-if="loading" class="flex min-h-48 items-center justify-center">
<SpinnerIcon class="size-6 animate-spin text-secondary" />
</div>
<div v-else class="flex flex-col gap-6">
<SettingsSection>
<template #header>
<h2
id="settings-target-translation-service"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.title) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.description) }}
</p>
</template>
<template #extra>
<div class="flex flex-wrap items-center justify-end gap-2">
<span v-if="testStatus" class="text-sm text-secondary">{{ testStatus }}</span>
<Button type="base" :disabled="testing" @click="testProvider">
<PlugIcon />{{ formatMessage(testing ? messages.testing : messages.test) }}
</Button>
</div>
</template>
<SettingsRow>
<template #label>{{ formatMessage(messages.provider) }}</template>
<template #description>
<span v-if="settings.provider === 'google'">
{{ formatMessage(messages.googleIpPool, { count: googleIpPoolSize }) }}
</span>
</template>
<template #control>
<div class="w-full">
<Combobox v-model="settings.provider" :options="providerOptions" />
</div>
</template>
</SettingsRow>
<SettingsRow v-if="settings.provider === 'deepl'" stacked>
<template #label>{{ formatMessage(messages.deeplApiEndpoint) }}</template>
<template #control>
<StyledInput
v-model="settings.deepl_api_endpoint"
:placeholder="formatMessage(messages.deeplApiEndpointPlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
<SettingsRow v-if="settings.provider === 'deepl'" stacked>
<template #label>{{ formatMessage(messages.deeplApiKey) }}</template>
<template #description>{{ formatMessage(messages.deeplApiKeyHint) }}</template>
<template #control>
<StyledInput
v-model="settings.deepl_api_key"
type="password"
:placeholder="formatMessage(messages.deeplApiKeyPlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
<SettingsRow v-if="settings.provider === 'ai' && aiAvailable">
<template #label>{{ formatMessage(messages.aiProvider) }}</template>
<template #control>
<div class="w-full">
<Combobox v-model="selectedAIProvider" :options="aiProviderOptions">
<template #selected="{ label }">
<span class="inline-flex min-w-0 items-center gap-2">
<AIIcon kind="provider-avatar" :value="selectedAIProvider" :size="20" />
<span class="truncate">{{ label }}</span>
</span>
</template>
<template #option="{ item, isSelected }">
<div class="flex min-w-0 items-center gap-2.5">
<AIIcon kind="provider-avatar" :value="String(item.value)" :size="22" />
<span
class="truncate font-semibold leading-tight"
:class="isSelected ? 'text-brand' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
</template>
</Combobox>
</div>
</template>
</SettingsRow>
<SettingsRow v-if="settings.provider === 'ai' && aiAvailable">
<template #label>{{ formatMessage(messages.aiModel) }}</template>
<template #control>
<div
class="translation-model-combobox relative w-full"
:class="{ 'has-model-icon': settings.ai_model_id }"
>
<AIIcon
v-if="settings.ai_model_id"
class="pointer-events-none absolute left-3 top-1/2 z-[2] -translate-y-1/2"
kind="model"
:value="settings.ai_model_id"
:size="20"
/>
<Combobox v-model="settings.ai_model_id" :options="aiModelOptions" searchable>
<template #option="{ item, isSelected }">
<div class="flex min-w-0 items-center gap-2.5">
<AIIcon kind="model" :value="String(item.value)" :size="22" />
<span
class="truncate font-semibold leading-tight"
:class="isSelected ? 'text-brand' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
</template>
</Combobox>
</div>
</template>
</SettingsRow>
<SettingsRow v-if="settings.provider === 'ai' && aiAvailable" stacked>
<template #label>{{ formatMessage(messages.systemPrompt) }}</template>
<template #description>{{ formatMessage(messages.systemPromptDescription) }}</template>
<template #control>
<StyledInput
v-model="settings.ai_system_prompt"
multiline
:rows="3"
resize="vertical"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
<SettingsRow>
<template #label>{{ formatMessage(messages.targetLanguage) }}</template>
<template #control>
<div class="w-full">
<Combobox v-model="targetLanguage" :options="languageOptions" searchable />
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.displayMode) }}</template>
<template #control>
<div class="w-full"><Combobox v-model="settings.mode" :options="modeOptions" /></div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.style) }}</template>
<template #control>
<div class="w-full"><Combobox v-model="settings.style" :options="styleOptions" /></div>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>{{ formatMessage(messages.stylePreview) }}</template>
<template #control>
<div class="translation-style-preview-container">
<p v-if="settings.mode === 'bilingual'" class="translation-style-preview-original m-0">
{{ formatMessage(messages.stylePreviewOriginalText) }}
</p>
<p class="translation-style-preview m-0" :class="stylePreviewClass">
{{ formatMessage(messages.stylePreviewText) }}
</p>
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-translation-auto-translate" tabindex="-1">
{{ formatMessage(messages.autoTranslate) }}
</span>
</template>
<template #description>{{ formatMessage(messages.autoTranslateDescription) }}</template>
<template #control
><Toggle id="translation-auto" v-model="settings.auto_translate"
/></template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-translation-cache" tabindex="-1">
{{ formatMessage(messages.cache) }}
</span>
</template>
<template #description>{{ formatMessage(messages.cacheDescription) }}</template>
<template #control>
<div class="flex flex-wrap items-center justify-end gap-2">
<span v-if="cacheStatus" class="text-sm text-secondary">{{ cacheStatus }}</span>
<Button type="base" @click="clearCache">
<TrashIcon />{{ formatMessage(messages.clearCache) }}
</Button>
</div>
</template>
</SettingsRow>
</SettingsSection>
</div>
</template>
<style scoped>
.translation-model-combobox.has-model-icon :deep(input) {
padding-left: 2.75rem !important;
}
.translation-style-preview-container {
display: flex;
width: 100%;
min-height: 6.5rem;
flex-direction: column;
box-sizing: border-box;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--color-divider);
border-radius: var(--radius-lg);
}
.translation-style-preview-original,
.translation-style-preview {
font-weight: 400;
}
.translation-style-preview-original,
.translation-style-preview-default {
color: var(--color-text-primary);
}
.translation-style-preview-weakened {
color: var(--color-secondary) !important;
}
.translation-style-preview-blur {
filter: blur(4px);
opacity: 0.75;
transition:
filter 0.1s ease-in-out,
opacity 0.1s ease-in-out;
}
.translation-style-preview-blur:hover {
filter: blur(0);
opacity: 1;
}
.translation-style-preview-blockquote {
padding: 4px 0 4px 8px;
border-left: 4px solid var(--color-brand);
}
.translation-style-preview-dashed-line {
text-decoration: underline dashed var(--color-brand) !important;
text-underline-offset: 5px;
}
.translation-style-preview-border {
padding: 2px 4px;
border: 1px solid var(--color-brand);
border-radius: 4px;
}
.translation-style-preview-text-color {
color: oklch(0.693 0.17 162.48) !important;
}
.translation-style-preview-background {
padding: 2px 4px;
border-radius: 4px;
background-color: color-mix(in srgb, var(--color-brand) 15%, transparent);
}
</style>

View File

@ -0,0 +1,955 @@
<script setup lang="ts">
import { DatabaseIcon, RefreshCwIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
NewButton as Button,
NewModal,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { inject, nextTick, ref, watch } from 'vue'
import UpdateAnnouncementHistory from '@/components/ui/announcement/UpdateAnnouncementHistory.vue'
import {
betaDatabaseExists,
copyReleaseDatabaseToBeta,
copyDatabaseBetweenChannels,
getCurrentAppDatabasePath,
getUpdateChannel,
getUpdatePreferences,
setUpdateChannel,
setUpdatePreferences,
type UpdateChannel,
} from '@/helpers/settings.ts'
import { isDev, restartApp } from '@/helpers/utils.js'
import { type AppUpdateCheckResult, checkForAppUpdate } from '@/providers/app-update.ts'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const { addNotification, handleError } = injectNotificationManager()
const [
activeChannel,
initialUpdatePreferences,
currentVersion,
isDevEnvironment,
databasePath,
portable,
] = await Promise.all([
getUpdateChannel(),
getUpdatePreferences(),
getVersion(),
isDev(),
getCurrentAppDatabasePath().catch(() => ''),
invoke<boolean>('is_portable_mode').catch(() => false),
])
const selectedChannel = ref<UpdateChannel>(activeChannel)
const updatePreferences = ref(initialUpdatePreferences)
const checking = ref(false)
const checkResult = ref<AppUpdateCheckResult | 'failed' | 'portable' | null>(null)
const currentDatabasePath = ref(databasePath)
const latestChannelVersions = ref<Partial<Record<UpdateChannel, string>>>({})
const latestChannelVersionsLoaded = ref(false)
const isPortable = ref(portable)
const restartModal = ref<InstanceType<typeof NewModal>>()
const copyDatabaseModal = ref<InstanceType<typeof NewModal>>()
const databaseOperationModal = ref<InstanceType<typeof NewModal>>()
const pendingChannel = ref<UpdateChannel | null>(null)
const databaseOperation = ref<'release-to-beta' | 'beta-to-release' | ''>('')
const databaseOperationBusy = ref(false)
let restoringChannelSelection = false
const messages = defineMessages({
title: {
id: 'app.settings.updates.channel.title',
defaultMessage: 'Update channel',
},
preferencesTitle: {
id: 'app.settings.updates.preferences.title',
defaultMessage: 'Update behavior',
},
checkTitle: {
id: 'app.settings.updates.check.title',
defaultMessage: 'Check for updates',
},
description: {
id: 'app.settings.updates.channel.description',
defaultMessage: 'Choose which launcher versions Axolotl receives.',
},
channelLabel: {
id: 'app.settings.updates.channel.label',
defaultMessage: 'Channel',
},
release: {
id: 'app.settings.updates.channel.release',
defaultMessage: 'Release',
},
releaseDescription: {
id: 'app.settings.updates.channel.release-description',
defaultMessage: 'Stable, tested features and fixes. Updates arrive less often.',
},
beta: {
id: 'app.settings.updates.channel.beta',
defaultMessage: 'Beta',
},
betaDescription: {
id: 'app.settings.updates.channel.beta-description',
defaultMessage: 'New, less stable updates with the latest features. Updates arrive more often.',
},
betaImmediateFetch: {
id: 'app.settings.updates.immediate-fetch.beta-description',
defaultMessage: 'Beta updates are always available immediately.',
},
check: {
id: 'app.settings.updates.check',
defaultMessage: 'Check for updates',
},
checking: {
id: 'app.settings.updates.checking',
defaultMessage: 'Checking for updates…',
},
available: {
id: 'app.settings.updates.available',
defaultMessage: 'An update is available.',
},
upToDate: {
id: 'app.settings.updates.up-to-date',
defaultMessage: 'Axolotl is up to date.',
},
disabled: {
id: 'app.settings.updates.disabled',
defaultMessage: 'Updates are disabled in this build.',
},
offline: {
id: 'app.settings.updates.offline',
defaultMessage: 'Connect to the internet to check for updates.',
},
failed: {
id: 'app.settings.updates.failed',
defaultMessage: 'Could not check for updates.',
},
portable: {
id: 'app.settings.updates.portable',
defaultMessage:
'Portable mode cannot update automatically. Please download the latest version manually.',
},
security: {
id: 'app.settings.updates.security',
defaultMessage: 'Updates are installed only when their cryptographic signature is valid.',
},
currentVersion: {
id: 'app.settings.updates.current-version',
defaultMessage: 'Current version {version}',
},
latestVersion: {
id: 'app.settings.updates.latest-version',
defaultMessage: 'Latest {channel} version: {version}',
},
latestVersionUnavailable: {
id: 'app.settings.updates.latest-version-unavailable',
defaultMessage: 'Latest version unavailable',
},
latestVersionLoading: {
id: 'app.settings.updates.latest-version-loading',
defaultMessage: 'Fetching latest version…',
},
restartTitle: {
id: 'app.settings.updates.channel.restart-title',
defaultMessage: 'Restart required',
},
restartDescription: {
id: 'app.settings.updates.channel.restart-description',
defaultMessage:
'Restart Axolotl now to start using the new update channel, or restart manually later.',
},
restartDevelopmentDescription: {
id: 'app.settings.updates.channel.restart-development-description',
defaultMessage:
'The new update channel will be used after you manually restart the development session.',
},
restartNow: {
id: 'app.settings.updates.channel.restart-now',
defaultMessage: 'Restart now',
},
restartLater: {
id: 'app.settings.updates.channel.restart-later',
defaultMessage: 'Restart manually later',
},
immediateFetch: {
id: 'app.settings.updates.immediate-fetch',
defaultMessage: 'Get updates as soon as they are available',
},
immediateFetchDescription: {
id: 'app.settings.updates.immediate-fetch-description',
defaultMessage:
'After the latest stable changes and fixes are released, be among the first to receive them.',
},
pause: {
id: 'app.settings.updates.pause',
defaultMessage: 'Pause updates',
},
pauseDescription: {
id: 'app.settings.updates.pause-description',
defaultMessage:
'Stop automatic update checks, downloads, and update notifications until you resume updates.',
},
paused: {
id: 'app.settings.updates.paused',
defaultMessage: 'Updates are paused.',
},
copyDatabaseTitle: {
id: 'app.settings.updates.channel.copy-database-title',
defaultMessage: 'Copy Release data to Beta?',
},
copyDatabaseDescription: {
id: 'app.settings.updates.channel.copy-database-description',
defaultMessage:
'Would you like to copy your Release database into the Beta channel? This cannot be undone automatically.',
},
copyDatabase: {
id: 'app.settings.updates.channel.copy-database',
defaultMessage: 'Copy database',
},
startEmpty: {
id: 'app.settings.updates.channel.start-empty',
defaultMessage: 'Start with empty database',
},
databaseIsolationTitle: {
id: 'app.settings.updates.database-isolation.title',
defaultMessage: 'Database isolation',
},
currentDatabase: {
id: 'app.settings.updates.database-isolation.current-database',
defaultMessage: 'Database currently in use',
},
databaseIsolationDescription: {
id: 'app.settings.updates.database-isolation.description',
defaultMessage:
'Release and Beta use separate databases, so testing Beta does not change your Release data.',
},
releaseDatabase: {
id: 'app.settings.updates.database-isolation.release',
defaultMessage: 'Release database',
},
betaDatabase: {
id: 'app.settings.updates.database-isolation.beta',
defaultMessage: 'Beta database',
},
activeDatabase: {
id: 'app.settings.updates.database-isolation.active',
defaultMessage: 'Active',
},
databaseOperation: {
id: 'app.settings.updates.database-operation.label',
defaultMessage: 'Database operation',
},
releaseToBeta: {
id: 'app.settings.updates.database-operation.release-to-beta',
defaultMessage: 'Copy Release to Beta',
},
betaToRelease: {
id: 'app.settings.updates.database-operation.beta-to-release',
defaultMessage: 'Copy Beta to Release',
},
databaseOperationTitle: {
id: 'app.settings.updates.database-operation.confirm-title',
defaultMessage: 'Overwrite database?',
},
databaseOperationDescription: {
id: 'app.settings.updates.database-operation.confirm-description',
defaultMessage:
'This will completely replace the inactive {target} database with the contents of the {source} database. This cannot be undone.',
},
databaseOperationConfirm: {
id: 'app.settings.updates.database-operation.confirm',
defaultMessage: 'Overwrite database',
},
databaseOperationActiveTarget: {
id: 'app.settings.updates.database-operation.active-target',
defaultMessage:
'Cannot overwrite the database currently in use. Restart Axolotl and switch channels first.',
},
databaseOperationFailed: {
id: 'app.settings.updates.database-operation.failed',
defaultMessage:
'The database could not be copied. Please make sure Axolotl is not using the target database.',
},
databaseOperationSuccess: {
id: 'app.settings.updates.database-operation.success',
defaultMessage: '{source} database was copied to {target} successfully.',
},
cancel: {
id: 'app.settings.updates.database-operation.cancel',
defaultMessage: 'Cancel',
},
})
async function loadLatestChannelVersions() {
const versions = await Promise.all(
(['release', 'beta'] as const).map(async (channel) => {
try {
const response = await tauriFetch(`https://update.axlmc.org/latest?channel=${channel}`)
if (!response.ok) return [channel, undefined] as const
const payload = (await response.json()) as { version?: string }
return [channel, payload.version] as const
} catch {
return [channel, undefined] as const
}
}),
)
latestChannelVersions.value = Object.fromEntries(versions.filter(([, version]) => version))
latestChannelVersionsLoaded.value = true
}
void loadLatestChannelVersions()
const resultMessages: Record<AppUpdateCheckResult | 'failed' | 'portable', keyof typeof messages> =
{
available: 'available',
'up-to-date': 'upToDate',
disabled: 'disabled',
offline: 'offline',
failed: 'failed',
portable: 'portable',
paused: 'paused',
}
watch(selectedChannel, async (channel, previousChannel) => {
if (restoringChannelSelection) return
if (channel === 'beta') updatePreferences.value.immediateUpdateFetch = true
if (channel === 'beta' && previousChannel === 'release') {
try {
if (!(await betaDatabaseExists())) {
pendingChannel.value = channel
restoringChannelSelection = true
selectedChannel.value = previousChannel
await nextTick()
restoringChannelSelection = false
copyDatabaseModal.value?.show()
return
}
} catch (error) {
restoringChannelSelection = true
selectedChannel.value = previousChannel
await nextTick()
restoringChannelSelection = false
handleError(error)
return
}
}
await applyChannel(channel)
checkResult.value = null
})
async function applyChannel(channel: UpdateChannel, copyDatabase = false) {
try {
if (copyDatabase) await copyReleaseDatabaseToBeta()
await setUpdateChannel(channel)
restartModal.value?.show()
return true
} catch (error) {
handleError(error)
return false
}
}
async function chooseBetaDatabase(copyDatabase: boolean) {
copyDatabaseModal.value?.hide()
const channel = pendingChannel.value
pendingChannel.value = null
if (channel && (await applyChannel(channel, copyDatabase))) {
restoringChannelSelection = true
selectedChannel.value = channel
await nextTick()
restoringChannelSelection = false
}
}
async function restartForChannelChange() {
restartModal.value?.hide()
try {
await restartApp()
} catch (error) {
handleError(error)
}
}
async function saveUpdatePreferences() {
try {
await setUpdatePreferences(updatePreferences.value)
} catch (error) {
handleError(error)
}
}
async function checkForUpdates() {
checking.value = true
checkResult.value = null
if (isPortable.value) {
checkResult.value = 'portable'
checking.value = false
return
}
try {
checkResult.value = await checkForAppUpdate()
} catch (error) {
checkResult.value = 'failed'
handleError(error)
} finally {
checking.value = false
}
}
function requestDatabaseOperation() {
if (!databaseOperation.value) return
databaseOperationModal.value?.show()
}
function selectDatabaseOperation(operation: 'release-to-beta' | 'beta-to-release') {
databaseOperation.value = operation
requestDatabaseOperation()
}
async function confirmDatabaseOperation() {
if (!databaseOperation.value || databaseOperationBusy.value) return
const [sourceChannel, targetChannel] =
databaseOperation.value === 'release-to-beta'
? (['release', 'beta'] as const)
: (['beta', 'release'] as const)
databaseOperationBusy.value = true
databaseOperationModal.value?.hide()
try {
await copyDatabaseBetweenChannels(sourceChannel, targetChannel)
addNotification({
type: 'success',
title: formatMessage(messages.databaseOperationSuccess, {
source:
sourceChannel === 'release'
? formatMessage(messages.releaseDatabase)
: formatMessage(messages.betaDatabase),
target:
targetChannel === 'release'
? formatMessage(messages.releaseDatabase)
: formatMessage(messages.betaDatabase),
}),
})
} catch (error) {
handleError(
new Error(
targetChannel === activeChannel
? formatMessage(messages.databaseOperationActiveTarget)
: formatMessage(messages.databaseOperationFailed),
),
)
} finally {
databaseOperationBusy.value = false
}
}
function cancelDatabaseOperation() {
databaseOperationModal.value?.hide()
databaseOperation.value = ''
}
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsSection :title="formatMessage(messages.title)">
<div class="update-channel-panel">
<p id="settings-target-updates-channel" class="m-0 text-sm leading-[1.45] text-secondary">
{{ formatMessage(messages.description) }}
</p>
<div
class="update-channel-options"
role="radiogroup"
:aria-label="formatMessage(messages.channelLabel)"
>
<button
type="button"
class="update-channel-card"
:class="{ 'update-channel-card-selected': selectedChannel === 'release' }"
role="radio"
:aria-checked="selectedChannel === 'release'"
@click="selectedChannel = 'release'"
>
<span class="update-channel-card-title">{{ formatMessage(messages.release) }}</span>
<span class="update-channel-card-description">
{{ formatMessage(messages.releaseDescription) }}
</span>
<span class="update-channel-card-version">
{{
!latestChannelVersionsLoaded
? formatMessage(messages.latestVersionLoading)
: latestChannelVersions.release
? formatMessage(messages.latestVersion, {
channel: formatMessage(messages.release),
version: latestChannelVersions.release,
})
: formatMessage(messages.latestVersionUnavailable)
}}
</span>
</button>
<button
type="button"
class="update-channel-card"
:class="{ 'update-channel-card-selected': selectedChannel === 'beta' }"
role="radio"
:aria-checked="selectedChannel === 'beta'"
@click="selectedChannel = 'beta'"
>
<span class="update-channel-card-title">{{ formatMessage(messages.beta) }}</span>
<span class="update-channel-card-description">
{{ formatMessage(messages.betaDescription) }}
</span>
<span class="update-channel-card-version">
{{
!latestChannelVersionsLoaded
? formatMessage(messages.latestVersionLoading)
: latestChannelVersions.beta
? formatMessage(messages.latestVersion, {
channel: formatMessage(messages.beta),
version: latestChannelVersions.beta,
})
: formatMessage(messages.latestVersionUnavailable)
}}
</span>
</button>
</div>
</div>
<div class="database-isolation">
<div class="database-isolation-copy">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.databaseIsolationTitle) }}
</h3>
<p class="m-0 text-sm text-secondary">
{{ formatMessage(messages.databaseIsolationDescription) }}
</p>
<div class="database-path">
<span>{{ formatMessage(messages.currentDatabase) }}</span>
<code>{{ currentDatabasePath || '—' }}</code>
</div>
<div class="database-operation">
<span>{{ formatMessage(messages.databaseOperation) }}</span>
<div class="database-operation-buttons">
<ButtonStyled type="outlined" :disabled="activeChannel === 'beta'">
<button
type="button"
:disabled="activeChannel === 'beta'"
@click="selectDatabaseOperation('release-to-beta')"
>
<DatabaseIcon />
{{ formatMessage(messages.releaseToBeta) }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined" :disabled="activeChannel === 'release'">
<button
type="button"
:disabled="activeChannel === 'release'"
@click="selectDatabaseOperation('beta-to-release')"
>
<DatabaseIcon />
{{ formatMessage(messages.betaToRelease) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div
class="database-diagram"
:aria-label="formatMessage(messages.databaseIsolationDescription)"
>
<div class="database-channel database-channel-release">
<span>R</span>
<strong>{{ formatMessage(messages.releaseDatabase) }}</strong>
<small>{{
activeChannel === 'release' ? formatMessage(messages.activeDatabase) : '\u00a0'
}}</small>
</div>
<div class="database-branch" aria-hidden="true"></div>
<div class="database-channel database-channel-beta">
<span>B</span>
<strong>{{ formatMessage(messages.betaDatabase) }}</strong>
<small>{{
activeChannel === 'beta' ? formatMessage(messages.activeDatabase) : '\u00a0'
}}</small>
</div>
</div>
</div>
</SettingsSection>
<SettingsSection :title="formatMessage(messages.preferencesTitle)">
<SettingsRow>
<template #label>{{ formatMessage(messages.immediateFetch) }}</template>
<template #description>
{{
selectedChannel === 'beta'
? formatMessage(messages.betaImmediateFetch)
: formatMessage(messages.immediateFetchDescription)
}}
</template>
<template #control>
<Toggle
id="immediate-update-fetch"
v-model="updatePreferences.immediateUpdateFetch"
:disabled="selectedChannel === 'beta'"
@update:model-value="saveUpdatePreferences"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.pause) }}</template>
<template #description>{{ formatMessage(messages.pauseDescription) }}</template>
<template #control>
<Toggle
id="pause-updates"
v-model="updatePreferences.updatesPaused"
@update:model-value="saveUpdatePreferences"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection :title="formatMessage(messages.checkTitle)">
<div class="update-check-panel">
<div class="update-check-heading">
<p class="m-0 text-sm text-secondary">
{{ formatMessage(messages.currentVersion, { version: currentVersion }) }}
</p>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.security) }}</p>
</div>
<div class="flex flex-wrap gap-2">
<Button type="colored" color="brand" :disabled="checking" @click="checkForUpdates">
<RefreshCwIcon :class="{ 'animate-spin': checking }" />
{{ formatMessage(checking ? messages.checking : messages.check) }}
</Button>
</div>
<p
v-if="checkResult"
class="update-check-result"
:class="`update-check-result-${checkResult}`"
role="status"
>
{{ formatMessage(messages[resultMessages[checkResult]]) }}
</p>
</div>
</SettingsSection>
<UpdateAnnouncementHistory :current-version="currentVersion" />
</div>
<NewModal ref="restartModal" :header="formatMessage(messages.restartTitle)" :closable="false">
<p class="m-0">
{{
formatMessage(
isDevEnvironment ? messages.restartDevelopmentDescription : messages.restartDescription,
)
}}
</p>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button type="button" @click="restartModal?.hide()">
<XIcon />
{{ formatMessage(messages.restartLater) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="!isDevEnvironment" color="brand">
<button type="button" @click="restartForChannelChange">
<RefreshCwIcon />
{{ formatMessage(messages.restartNow) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<NewModal
ref="copyDatabaseModal"
:header="formatMessage(messages.copyDatabaseTitle)"
:closable="false"
>
<p class="m-0">{{ formatMessage(messages.copyDatabaseDescription) }}</p>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button type="button" @click="chooseBetaDatabase(false)">
<XIcon />
{{ formatMessage(messages.startEmpty) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" @click="chooseBetaDatabase(true)">
<RefreshCwIcon />
{{ formatMessage(messages.copyDatabase) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<NewModal
ref="databaseOperationModal"
:header="formatMessage(messages.databaseOperationTitle)"
:closable="false"
>
<p class="m-0">
{{
formatMessage(messages.databaseOperationDescription, {
source:
databaseOperation === 'release-to-beta'
? formatMessage(messages.releaseDatabase)
: formatMessage(messages.betaDatabase),
target:
databaseOperation === 'release-to-beta'
? formatMessage(messages.betaDatabase)
: formatMessage(messages.releaseDatabase),
})
}}
</p>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button type="button" :disabled="databaseOperationBusy" @click="cancelDatabaseOperation">
<XIcon />
{{ formatMessage(messages.cancel) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="databaseOperationBusy" @click="confirmDatabaseOperation">
<RefreshCwIcon :class="{ 'animate-spin': databaseOperationBusy }" />
{{ formatMessage(messages.databaseOperationConfirm) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<style scoped>
.update-check-panel {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--gap-md);
padding: var(--gap-lg);
}
.database-isolation {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(16rem, 0.8fr);
gap: var(--gap-lg);
margin: 0 var(--gap-lg) var(--gap-lg);
padding: var(--gap-md);
border: 1px solid var(--surface-4);
border-radius: var(--radius-md);
background: var(--surface-1);
}
.database-isolation-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--gap-sm);
}
.database-path {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--gap-xs);
color: var(--color-secondary);
font-size: 0.9375rem;
}
.database-path code {
overflow-wrap: anywhere;
color: var(--color-contrast);
font-family: var(--font-mono);
}
.database-operation {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
color: var(--color-secondary);
font-size: 0.9375rem;
}
.database-operation-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--gap-sm);
}
.database-diagram {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: var(--gap-sm);
min-width: 0;
}
.database-channel {
display: flex;
min-width: 0;
flex-direction: column;
align-items: center;
gap: 0.2rem;
padding: var(--gap-sm);
border: 1px solid var(--surface-4);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--color-contrast);
text-align: center;
}
.database-channel > span {
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border-radius: 50%;
background: var(--color-brand);
color: var(--color-button-text);
font-weight: 700;
}
.database-channel small {
color: var(--color-brand);
font-size: 0.7rem;
font-weight: 600;
}
.database-channel-beta > span {
background: var(--color-purple, var(--color-brand));
}
.database-branch {
width: 2.5rem;
height: 1px;
background: var(--surface-5);
}
.update-channel-panel {
display: flex;
flex-direction: column;
gap: var(--gap-md);
padding: var(--gap-lg);
}
.update-channel-options {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--gap-md);
width: 100%;
}
.update-channel-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
width: 100%;
min-width: 0;
gap: var(--gap-xs);
padding: var(--gap-md);
border: 1px solid var(--surface-4);
border-radius: var(--radius-md);
background: var(--surface-1);
color: var(--color-secondary);
text-align: left;
cursor: pointer;
transition:
border-color 150ms ease,
background-color 150ms ease,
color 150ms ease,
transform 150ms ease;
}
.update-channel-card:hover {
border-color: color-mix(in srgb, var(--color-brand) 55%, var(--surface-4));
background: var(--surface-2);
}
.update-channel-card:active {
transform: scale(0.98);
}
.update-channel-card:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.update-channel-card-selected {
border-color: var(--color-brand);
background: color-mix(in srgb, var(--color-brand) 12%, var(--surface-1));
}
.update-channel-card-title {
color: var(--color-contrast);
font-weight: 600;
}
.update-channel-card-description {
font-size: 0.875rem;
line-height: 1.4;
}
.update-channel-card-version {
grid-column: 2;
grid-row: 1 / span 2;
align-self: center;
color: var(--color-secondary);
font-size: 0.8125rem;
font-weight: 600;
}
.update-check-heading {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
}
.update-check-result {
margin: 0;
padding: var(--gap-sm) var(--gap-md);
border: 1px solid var(--surface-4);
border-radius: var(--radius-sm);
background: var(--surface-1);
color: var(--color-secondary);
font-size: 0.875rem;
line-height: 1.4;
}
.update-check-result-available {
border-color: color-mix(in srgb, var(--color-brand) 45%, var(--surface-4));
color: var(--color-brand);
}
.update-check-result-failed,
.update-check-result-offline {
border-color: color-mix(in srgb, var(--color-red) 45%, var(--surface-4));
color: var(--color-red);
}
.update-check-result-paused,
.update-check-result-portable,
.update-check-result-disabled {
border-color: color-mix(in srgb, var(--color-yellow) 45%, var(--surface-4));
color: var(--color-yellow);
}
@media (max-width: 640px) {
.database-isolation {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@ -0,0 +1,19 @@
import type { Component } from 'vue'
import AboutMergeGame from '../AboutMergeGame.vue'
export type AboutMemberExperience = {
component: Component
longPressDuration: number
}
const memberExperiences: Record<string, AboutMemberExperience> = {
'axolotl-merge': {
component: AboutMergeGame,
longPressDuration: 800,
},
}
export function getAboutMemberExperience(experience: unknown): AboutMemberExperience | undefined {
return typeof experience === 'string' ? memberExperiences[experience] : undefined
}

View File

@ -0,0 +1,142 @@
import { defineMessage, type MessageDescriptor } from '@modrinth/ui'
export type SettingsCategoryId =
| 'interface'
| 'home-navigation'
| 'language-translation'
| 'ai'
| 'java-performance'
| 'launch-defaults'
| 'content-downloads'
| 'network-multiplayer'
| 'storage-backups'
| 'privacy-data'
| 'updates'
| 'about'
| 'feature-flags'
export type SettingsGroupId = 'launcher' | 'game' | 'data-privacy' | 'support' | 'developer'
export interface SettingsCategoryDefinition {
id: SettingsCategoryId
name: MessageDescriptor
group: SettingsGroupId
flushContent?: boolean
developerOnly?: boolean
onboardingId?: string
}
export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
{
id: 'interface',
name: defineMessage({
id: 'app.settings.tabs.interface',
defaultMessage: 'Interface & appearance',
}),
group: 'launcher',
onboardingId: 'settings-tab-interface',
},
{
id: 'home-navigation',
name: defineMessage({
id: 'app.settings.tabs.home-navigation',
defaultMessage: 'Home & navigation',
}),
group: 'launcher',
onboardingId: 'settings-tab-home-navigation',
},
{
id: 'language-translation',
name: defineMessage({
id: 'app.settings.tabs.language-translation',
defaultMessage: 'Language & translation',
}),
group: 'launcher',
onboardingId: 'settings-tab-language-translation',
},
{
id: 'ai',
name: defineMessage({ id: 'app.settings.tabs.ai', defaultMessage: 'AI' }),
group: 'launcher',
flushContent: true,
onboardingId: 'settings-tab-ai',
},
{
id: 'java-performance',
name: defineMessage({
id: 'app.settings.tabs.java-performance',
defaultMessage: 'Java & performance',
}),
group: 'game',
onboardingId: 'settings-tab-java-performance',
},
{
id: 'launch-defaults',
name: defineMessage({
id: 'app.settings.tabs.launch-defaults',
defaultMessage: 'Launch & instance defaults',
}),
group: 'game',
onboardingId: 'settings-tab-launch-defaults',
},
{
id: 'content-downloads',
name: defineMessage({
id: 'app.settings.tabs.content-downloads',
defaultMessage: 'Content & downloads',
}),
group: 'game',
onboardingId: 'settings-tab-content-downloads',
},
{
id: 'network-multiplayer',
name: defineMessage({
id: 'app.settings.tabs.network-multiplayer',
defaultMessage: 'Network & multiplayer',
}),
group: 'game',
onboardingId: 'settings-tab-network-multiplayer',
},
{
id: 'storage-backups',
name: defineMessage({
id: 'app.settings.tabs.storage-backups',
defaultMessage: 'Storage & backups',
}),
group: 'data-privacy',
onboardingId: 'settings-tab-storage-backups',
},
{
id: 'privacy-data',
name: defineMessage({
id: 'app.settings.tabs.privacy-data',
defaultMessage: 'Privacy & data sharing',
}),
group: 'data-privacy',
onboardingId: 'settings-tab-privacy-data',
},
{
id: 'updates',
name: defineMessage({ id: 'app.settings.tabs.updates', defaultMessage: 'Updates' }),
group: 'support',
onboardingId: 'settings-tab-updates',
},
{
id: 'about',
name: defineMessage({ id: 'app.settings.tabs.about', defaultMessage: 'About' }),
group: 'support',
},
{
id: 'feature-flags',
name: defineMessage({
id: 'settings.feature-flags.title',
defaultMessage: 'Feature flags',
}),
group: 'developer',
developerOnly: true,
},
]
export function getVisibleSettingsCategoryDefinitions(developerMode: boolean) {
return settingsCategoryDefinitions.filter((category) => !category.developerOnly || developerMode)
}

View File

@ -0,0 +1,155 @@
import {
ArchiveIcon,
BotIcon,
CoffeeIcon,
CpuIcon,
GameIcon,
GaugeIcon,
InfoIcon,
LanguagesIcon,
LayoutTemplateIcon,
PaintbrushIcon,
RefreshCwIcon,
ShieldIcon,
ToggleRightIcon,
UsersIcon,
} from '@modrinth/assets'
import { commonMessages, defineMessages, type MessageDescriptor } from '@modrinth/ui'
import { defineAsyncComponent, type Component } from 'vue'
import {
getVisibleSettingsCategoryDefinitions,
type SettingsCategoryDefinition,
settingsCategoryDefinitions,
type SettingsCategoryId,
type SettingsGroupId,
} from './settings-category-definitions'
import { settingsSearchEntries, type SettingsSearchEntry } from './settings-search-index'
export interface SettingsCategory extends SettingsCategoryDefinition {
icon: Component
content: Component
entries: SettingsSearchEntry[]
}
export interface SettingsGroup {
id: SettingsGroupId
name: MessageDescriptor
icon: Component
categories: SettingsCategory[]
}
const categoryContent: Record<SettingsCategoryId, Pick<SettingsCategory, 'icon' | 'content'>> = {
interface: {
icon: PaintbrushIcon,
content: defineAsyncComponent(() => import('./AppearanceSettings.vue')),
},
'home-navigation': {
icon: LayoutTemplateIcon,
content: defineAsyncComponent(() => import('./HomeNavigationSettings.vue')),
},
'language-translation': {
icon: LanguagesIcon,
content: defineAsyncComponent(() => import('./LanguageTranslationSettings.vue')),
},
ai: { icon: BotIcon, content: defineAsyncComponent(() => import('./AISettings.vue')) },
'java-performance': {
icon: CoffeeIcon,
content: defineAsyncComponent(() => import('./JavaSettings.vue')),
},
'launch-defaults': {
icon: GameIcon,
content: defineAsyncComponent(() => import('./DefaultInstanceSettings.vue')),
},
'content-downloads': {
icon: GaugeIcon,
content: defineAsyncComponent(() => import('./ContentDownloadSettings.vue')),
},
'network-multiplayer': {
icon: UsersIcon,
content: defineAsyncComponent(() => import('./NetworkMultiplayerSettings.vue')),
},
'storage-backups': {
icon: ArchiveIcon,
content: defineAsyncComponent(() => import('./StorageBackupSettings.vue')),
},
'privacy-data': {
icon: ShieldIcon,
content: defineAsyncComponent(() => import('./PrivacySettings.vue')),
},
updates: {
icon: RefreshCwIcon,
content: defineAsyncComponent(() => import('./UpdateSettings.vue')),
},
about: { icon: InfoIcon, content: defineAsyncComponent(() => import('./AboutSettings.vue')) },
'feature-flags': {
icon: ToggleRightIcon,
content: defineAsyncComponent(() => import('./FeatureFlagSettings.vue')),
},
}
const messages = defineMessages({
launcher: { id: 'app.settings.groups.launcher', defaultMessage: 'Launcher' },
game: { id: 'app.settings.groups.game', defaultMessage: 'Game' },
dataPrivacy: { id: 'app.settings.groups.data-privacy', defaultMessage: 'Data & privacy' },
support: { id: 'app.settings.groups.support', defaultMessage: 'App & support' },
developer: { id: 'app.settings.groups.developer', defaultMessage: 'Developer' },
})
export const settingsCategories: SettingsCategory[] = settingsCategoryDefinitions.map(
(definition) => ({
...definition,
...categoryContent[definition.id],
entries: settingsSearchEntries.filter((entry) => entry.categoryId === definition.id),
}),
)
const settingsGroupDefinitions: Array<{
id: SettingsGroupId
name: MessageDescriptor
icon: Component
}> = [
{
id: 'launcher',
name: messages.launcher,
icon: GaugeIcon,
},
{
id: 'game',
name: messages.game,
icon: GameIcon,
},
{
id: 'data-privacy',
name: messages.dataPrivacy,
icon: ShieldIcon,
},
{
id: 'support',
name: messages.support,
icon: InfoIcon,
},
{
id: 'developer',
name: messages.developer,
icon: CpuIcon,
},
]
export function getVisibleSettingsCategories(developerMode: boolean): SettingsCategory[] {
const visibleIds = new Set(
getVisibleSettingsCategoryDefinitions(developerMode).map((category) => category.id),
)
return settingsCategories.filter((category) => visibleIds.has(category.id))
}
export function getVisibleSettingsGroups(developerMode: boolean): SettingsGroup[] {
const categories = getVisibleSettingsCategories(developerMode)
return settingsGroupDefinitions
.map((group) => ({
...group,
categories: categories.filter((category) => category.group === group.id),
}))
.filter((group) => group.categories.length > 0)
}
export const settingsPageTitle: MessageDescriptor = commonMessages.settingsLabel

View File

@ -0,0 +1,422 @@
import type { MessageDescriptor } from '@modrinth/ui'
import {
settingsCategoryDefinitions,
type SettingsCategoryId,
} from './settings-category-definitions.ts'
export interface SettingsSearchEntry {
id: string
categoryId: SettingsCategoryId
targetId?: string
label: MessageDescriptor
description?: MessageDescriptor
keywords?: MessageDescriptor[]
}
const message = (id: string, defaultMessage: string): MessageDescriptor => ({ id, defaultMessage })
export const settingsSearchEntries: SettingsSearchEntry[] = [
{
id: 'appearance-color-theme',
categoryId: 'interface',
targetId: 'settings-target-appearance-color-theme',
label: message('app.appearance-settings.color-theme.title', 'Color theme'),
description: message(
'app.appearance-settings.color-theme.description',
'Select your preferred color theme for Axolotl Launcher.',
),
},
{
id: 'appearance-accent-color',
categoryId: 'interface',
targetId: 'settings-target-appearance-accent-color',
label: message('app.appearance-settings.accent-color.title', 'Accent color'),
description: message(
'app.appearance-settings.accent-color.description',
'Choose the color used for buttons, selections, and highlights.',
),
},
{
id: 'appearance-launcher-background',
categoryId: 'interface',
targetId: 'settings-target-appearance-launcher-background',
label: message('app.appearance-settings.custom-background.title', 'Launcher background'),
description: message(
'app.appearance-settings.custom-background.description',
'Choose a custom image and fine-tune how it blends with the launcher interface.',
),
},
{
id: 'appearance-transparent-background',
categoryId: 'interface',
targetId: 'settings-target-appearance-transparent-background',
label: message(
'app.appearance-settings.transparent-background.title',
'Transparent background',
),
description: message(
'app.appearance-settings.transparent-background.description',
'Let your desktop show through the launcher window.',
),
},
{
id: 'appearance-advanced-rendering',
categoryId: 'interface',
targetId: 'settings-target-appearance-advanced-rendering',
label: message('app.appearance-settings.advanced-rendering.title', 'Advanced rendering'),
description: message(
'app.appearance-settings.advanced-rendering.description',
'Enable advanced visual effects that may affect performance.',
),
},
{
id: 'appearance-page-transitions',
categoryId: 'interface',
targetId: 'settings-target-appearance-page-transitions',
label: message('app.appearance-settings.page-transitions.title', 'Page transition animations'),
},
{
id: 'appearance-home-layout',
categoryId: 'home-navigation',
targetId: 'settings-target-appearance-home-layout',
label: message('app.appearance-settings.home-layout.title', 'Home layout'),
},
{
id: 'appearance-default-landing-page',
categoryId: 'home-navigation',
targetId: 'settings-target-appearance-default-landing-page',
label: message('app.appearance-settings.default-landing-page.title', 'Default landing page'),
},
{
id: 'appearance-sidebar-instance-limit',
categoryId: 'home-navigation',
targetId: 'settings-target-appearance-sidebar-instance-limit',
label: message(
'app.appearance-settings.sidebar-instance-count.title',
'Sidebar instance limit',
),
},
{
id: 'appearance-auto-hide-downloads',
categoryId: 'home-navigation',
targetId: 'settings-target-appearance-auto-hide-downloads',
label: message(
'app.appearance-settings.auto-hide-downloads-button.title',
'Auto-hide downloads button',
),
},
{
id: 'appearance-native-decorations',
categoryId: 'interface',
targetId: 'settings-target-appearance-native-decorations',
label: message('app.appearance-settings.native-decorations.title', 'Native decorations'),
},
{
id: 'appearance-close-behavior',
categoryId: 'interface',
targetId: 'settings-target-appearance-close-behavior',
label: message(
'app.appearance-settings.close-behavior.title',
'Choose how to close Axolotl Launcher',
),
keywords: [
message('app.appearance-settings.close-behavior.close', 'Close directly'),
message('app.appearance-settings.close-behavior.lightweight', 'Hide to tray'),
message('app.appearance-settings.lightweight-mode.title', 'Lightweight mode'),
],
},
{
id: 'launch-minimize-launcher',
categoryId: 'launch-defaults',
targetId: 'settings-target-launch-minimize',
label: message('app.appearance-settings.minimize-launcher.title', 'Minimize launcher'),
},
{
id: 'launch-lightweight-mode',
categoryId: 'launch-defaults',
targetId: 'settings-target-launch-lightweight-mode',
label: message(
'app.appearance-settings.lightweight-mode.title',
'Enter lightweight mode after launching a game',
),
},
{
id: 'appearance-show-play-time',
categoryId: 'home-navigation',
targetId: 'settings-target-appearance-show-play-time',
label: message('app.appearance-settings.show-play-time.title', 'Show play time'),
},
{
id: 'appearance-hide-nametag',
categoryId: 'interface',
targetId: 'settings-target-appearance-hide-nametag',
label: message('app.appearance-settings.hide-nametag.title', 'Hide nametag'),
},
{
id: 'appearance-unknown-pack-warning',
categoryId: 'content-downloads',
targetId: 'settings-target-appearance-unknown-pack-warning',
label: message(
'app.appearance-settings.unknown-pack-warning.title',
'Warn me before installing unknown modpacks',
),
},
{
id: 'content-auto-install-dependencies',
categoryId: 'content-downloads',
targetId: 'settings-target-content-auto-install-dependencies',
label: message(
'app.appearance-settings.auto-install-dependencies.title',
'Automatically install dependencies',
),
},
{
id: 'content-skip-nonessential-warnings',
categoryId: 'content-downloads',
targetId: 'settings-target-content-skip-nonessential-warnings',
label: message(
'app.appearance-settings.skip-non-essential-warnings.title',
'Skip non-essential warnings',
),
},
{
id: 'language-launcher-language',
categoryId: 'language-translation',
targetId: 'settings-target-language',
label: message('app.settings.tabs.language', 'Language'),
},
{
id: 'translation-service',
categoryId: 'language-translation',
targetId: 'settings-target-translation-service',
label: message('app.translation-settings.provider', 'Translation service'),
},
{
id: 'translation-auto-translate',
categoryId: 'language-translation',
targetId: 'settings-target-translation-auto-translate',
label: message(
'app.translation-settings.auto-translate',
'Translate project pages automatically',
),
},
{
id: 'translation-cache',
categoryId: 'language-translation',
targetId: 'settings-target-translation-cache',
label: message('app.translation-settings.cache', 'Translation cache'),
},
{
id: 'ai-providers',
categoryId: 'ai',
targetId: 'settings-target-ai-providers',
label: message('app.ai-settings.title', 'AI providers'),
keywords: [message('app.settings.tabs.ai', 'AI')],
},
{
id: 'crash-analysis-ai',
categoryId: 'launch-defaults',
targetId: 'settings-target-crash-analysis-ai',
label: message('app.crash-analysis.ai.settings.title', 'Crash AI explanation'),
keywords: [message('app.settings.tabs.launch-defaults', 'Launch & instance defaults')],
},
{
id: 'privacy-telemetry',
categoryId: 'privacy-data',
targetId: 'settings-target-privacy-telemetry',
label: message('app.settings.privacy.telemetry', 'Telemetry'),
},
{
id: 'privacy-discord-rpc',
categoryId: 'privacy-data',
targetId: 'settings-target-privacy-discord-rpc',
label: message('app.settings.privacy.discord-rpc', 'Discord rich presence'),
},
{
id: 'java-installations',
categoryId: 'java-performance',
label: message('app.settings.tabs.java-installations', 'Java installations'),
},
{
id: 'java-memory',
categoryId: 'java-performance',
targetId: 'settings-target-java-memory',
label: message('app.settings.defaults.memory', 'Memory allocated'),
},
{
id: 'java-arguments',
categoryId: 'java-performance',
targetId: 'settings-target-java-arguments',
label: message('app.settings.defaults.java-arguments', 'Java arguments'),
},
{
id: 'defaults-window',
categoryId: 'launch-defaults',
targetId: 'settings-target-defaults-window',
label: message('app.settings.defaults.fullscreen', 'Fullscreen'),
keywords: [
message('app.settings.defaults.width', 'Window width'),
message('app.settings.tabs.default-instance-options', 'Default instance options'),
],
},
{
id: 'defaults-environment',
categoryId: 'launch-defaults',
targetId: 'settings-target-defaults-environment',
label: message('app.settings.defaults.environment-variables', 'Environment variables'),
},
{
id: 'defaults-launch-hooks',
categoryId: 'launch-defaults',
targetId: 'settings-target-defaults-launch-hooks',
label: message('app.settings.defaults.pre-launch-hook', 'Pre-launch hook'),
keywords: [
message('app.settings.defaults.wrapper-hook', 'Wrapper hook'),
message('app.settings.defaults.post-exit-hook', 'Post-exit hook'),
],
},
{
id: 'resources-download-mirrors',
categoryId: 'content-downloads',
targetId: 'settings-target-resources-download-mirrors',
label: message('app.settings.resources.download-mirrors', 'Download mirrors'),
keywords: [message('app.settings.tabs.resource-management', 'Resource management')],
},
{
id: 'resources-download-engine',
categoryId: 'content-downloads',
targetId: 'settings-target-resources-download-engine',
label: message('app.settings.resources.download-engine', 'Download engine'),
},
{
id: 'resources-download-concurrency',
categoryId: 'content-downloads',
targetId: 'settings-target-resources-maximum-downloads',
label: message('app.settings.resources.maximum-downloads', 'Maximum concurrent downloads'),
keywords: [message('app.settings.resources.maximum-writes', 'Maximum concurrent writes')],
},
{
id: 'resources-proxy',
categoryId: 'network-multiplayer',
targetId: 'settings-target-resources-proxy',
label: message('app.settings.resources.proxy-settings', 'Proxy settings'),
keywords: [
message('app.settings.resources.proxy-mode', 'Proxy mode'),
message('app.settings.tabs.resource-management', 'Resource management'),
],
},
{
id: 'network-mojang-auth-source',
categoryId: 'network-multiplayer',
targetId: 'settings-target-network-mojang-auth-source',
label: message('app.settings.resources.mojang-auth-service', 'Mojang authentication service'),
},
{
id: 'resources-missing-content-import',
categoryId: 'content-downloads',
targetId: 'settings-target-resources-missing-content-import',
label: message(
'app.settings.resources.missing-content-auto-import',
'Automatically import missing modpack files',
),
},
{
id: 'resources-database-backups',
categoryId: 'storage-backups',
targetId: 'settings-target-resources-database-backups',
label: message('app.settings.resources.database-backups', 'App database backups'),
keywords: [message('app.settings.tabs.resource-management', 'Resource management')],
},
{
id: 'storage-app-directory',
categoryId: 'storage-backups',
targetId: 'settings-target-storage-app-directory',
label: message('app.settings.resources.axolotl-data-directory', 'Axolotl data directory'),
},
{
id: 'storage-minecraft-directories',
categoryId: 'storage-backups',
targetId: 'settings-target-storage-minecraft-directories',
label: message('app.settings.resources.minecraft-directories', 'Minecraft directories'),
},
{
id: 'storage-cache',
categoryId: 'storage-backups',
targetId: 'settings-target-storage-cache',
label: message('app.settings.resources.app-cache', 'App cache'),
},
{
id: 'multiplayer-public-nodes',
categoryId: 'network-multiplayer',
targetId: 'terracotta-public-nodes-title',
label: message('app.multiplayer.terracotta.public-nodes', 'Terracotta public nodes'),
},
{
id: 'storage-overview',
categoryId: 'storage-backups',
targetId: 'settings-target-storage-overview',
label: message('app.settings.storage.total', 'Storage usage'),
},
{
id: 'updates-source',
categoryId: 'updates',
targetId: 'settings-target-updates-source',
label: message('app.settings.updates.title', 'Update source'),
},
{
id: 'updates-history',
categoryId: 'updates',
label: message('app.settings.updates.announcements.history', 'Release history'),
},
{
id: 'about-product',
categoryId: 'about',
targetId: 'settings-target-about-product',
label: message('app.settings.tabs.about', 'About'),
},
{
id: 'about-replay-tour',
categoryId: 'about',
targetId: 'settings-target-about-replay-tour',
label: message('app.settings.about.replay-onboarding', 'Replay tour'),
},
{
id: 'feature-flags',
categoryId: 'feature-flags',
label: message('settings.feature-flags.title', 'Feature flags'),
},
]
export function validateSettingsSearchEntries(entries = settingsSearchEntries): string[] {
const seenIds = new Set<string>()
const errors: string[] = []
for (const entry of entries) {
if (seenIds.has(entry.id)) errors.push(`Duplicate settings search entry: ${entry.id}`)
seenIds.add(entry.id)
if (!entry.categoryId) errors.push(`Missing category for settings search entry: ${entry.id}`)
}
return errors
}
export function getSettingsSearchTargetId(entry: SettingsSearchEntry): string {
return entry.targetId ?? `settings-category-${entry.categoryId}`
}
export function validateSettingsSearchMappings(entries = settingsSearchEntries): string[] {
const categoryIds = new Set(settingsCategoryDefinitions.map((category) => category.id))
const errors: string[] = []
for (const entry of entries) {
if (!categoryIds.has(entry.categoryId)) {
errors.push(`Missing settings category for search entry: ${entry.id}`)
}
if (!getSettingsSearchTargetId(entry)) {
errors.push(`Missing settings search target for entry: ${entry.id}`)
}
}
return errors
}

View File

@ -0,0 +1,203 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import {
getVisibleSettingsCategoryDefinitions,
settingsCategoryDefinitions,
} from './settings-category-definitions.ts'
import {
filterSettingsSearchDocuments,
MAX_SETTINGS_SEARCH_RESULTS,
normalizeSettingsSearchText,
} from './settings-search.ts'
import {
getSettingsSearchTargetId,
settingsSearchEntries,
validateSettingsSearchEntries,
validateSettingsSearchMappings,
} from './settings-search-index.ts'
const settingsComponentFiles = {
interface: ['./AppearanceSettings.vue'],
'home-navigation': ['./AppearanceSettings.vue'],
'language-translation': ['./LanguageSettings.vue', './TranslationSettings.vue'],
ai: ['./AISettings.vue'],
'java-performance': ['./JavaSettings.vue'],
'launch-defaults': ['./DefaultInstanceSettings.vue', './CrashAnalysisAISettings.vue'],
'content-downloads': ['./AppearanceSettings.vue', './ResourceManagementSettings.vue'],
'network-multiplayer': ['./ResourceManagementSettings.vue', './MultiplayerSettings.vue'],
'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'],
'privacy-data': ['./PrivacySettings.vue'],
updates: ['./UpdateSettings.vue'],
about: ['./AboutSettings.vue'],
'feature-flags': ['./FeatureFlagSettings.vue'],
} as const
const chineseLocale = JSON.parse(
readFileSync(new URL('../../../locales/zh-CN/index.json', import.meta.url), 'utf8'),
) as Record<string, { message?: string }>
test('normalizes settings search text before matching', () => {
assert.equal(normalizeSettingsSearchText(' Proxy\nSettings '), 'proxy settings')
assert.deepEqual(
filterSettingsSearchDocuments('proxy settings', [
{ item: 'proxy', text: 'Resource management Proxy settings Custom proxy' },
{ item: 'theme', text: 'Appearance Color theme' },
]).map(({ item }) => item),
['proxy'],
)
})
test('returns no documents for an empty settings search', () => {
assert.deepEqual(filterSettingsSearchDocuments('', [{ item: 'theme', text: 'Color theme' }]), [])
})
test('matches setting categories, titles, descriptions, and static option keywords', () => {
const documents = [
{ item: 'category', text: 'Resource management' },
{ item: 'title', text: 'Appearance Color theme' },
{
item: 'description',
text: 'Resource management Proxy settings Connect through a network proxy',
},
{ item: 'keyword', text: 'Resource management Proxy settings SOCKS5' },
]
assert.deepEqual(
filterSettingsSearchDocuments('resource management', documents).map(({ item }) => item),
['category', 'keyword', 'description'],
)
assert.deepEqual(
filterSettingsSearchDocuments('color theme', documents).map(({ item }) => item),
['title'],
)
assert.deepEqual(
filterSettingsSearchDocuments('network proxy', documents).map(({ item }) => item),
['description'],
)
assert.deepEqual(
filterSettingsSearchDocuments('socks5', documents).map(({ item }) => item),
['keyword'],
)
})
test('tolerates small spelling errors and limits the result set', () => {
assert.deepEqual(
filterSettingsSearchDocuments('colur them', [
{ item: 'theme', text: 'Appearance Color theme' },
{ item: 'proxy', text: 'Network proxy' },
]).map(({ item }) => item),
['theme'],
)
const documents = Array.from({ length: MAX_SETTINGS_SEARCH_RESULTS + 3 }, (_, index) => ({
item: index,
text: `Setting ${index}`,
}))
assert.equal(
filterSettingsSearchDocuments('setting', documents).length,
MAX_SETTINGS_SEARCH_RESULTS,
)
})
test('settings search index has unique entries with categories', () => {
assert.deepEqual(validateSettingsSearchEntries(), [])
})
test('settings search keywords are valid message descriptors', () => {
for (const entry of settingsSearchEntries) {
for (const keyword of entry.keywords ?? []) {
assert.equal(typeof keyword.id, 'string', entry.id)
assert.equal(typeof keyword.defaultMessage, 'string', entry.id)
}
}
})
test('legacy category names remain searchable after the taxonomy change', () => {
const keywordText = settingsSearchEntries
.flatMap((entry) => entry.keywords ?? [])
.map((keyword) => keyword.defaultMessage)
.join(' ')
assert.equal(keywordText.includes('Resource management'), true)
assert.equal(keywordText.includes('Default instance options'), true)
})
test('developer-only settings stay out of the normal search categories', () => {
const visibleCategoryIds = new Set(
getVisibleSettingsCategoryDefinitions(false).map((category) => category.id),
)
const visibleEntries = settingsSearchEntries.filter((entry) =>
visibleCategoryIds.has(entry.categoryId),
)
assert.equal(visibleCategoryIds.has('feature-flags'), false)
assert.equal(
visibleEntries.some((entry) => entry.categoryId === 'feature-flags'),
false,
)
assert.equal(
settingsSearchEntries.some((entry) => entry.categoryId === 'feature-flags'),
true,
)
assert.equal(
getVisibleSettingsCategoryDefinitions(true).some((category) => category.id === 'feature-flags'),
true,
)
})
test('settings navigation groups preserve the intended Axolotl information architecture', () => {
const categoriesForGroup = (
group: 'launcher' | 'game' | 'data-privacy' | 'support' | 'developer',
developerMode = false,
) =>
getVisibleSettingsCategoryDefinitions(developerMode)
.filter((category) => category.group === group)
.map((category) => category.id)
assert.deepEqual(categoriesForGroup('launcher'), [
'interface',
'home-navigation',
'language-translation',
'ai',
])
assert.deepEqual(categoriesForGroup('game'), [
'java-performance',
'launch-defaults',
'content-downloads',
'network-multiplayer',
])
assert.deepEqual(categoriesForGroup('data-privacy'), ['storage-backups', 'privacy-data'])
assert.deepEqual(categoriesForGroup('support'), ['updates', 'about'])
assert.deepEqual(categoriesForGroup('developer'), [])
assert.deepEqual(categoriesForGroup('developer', true), ['feature-flags'])
})
test('Chinese contains every user-facing settings category label', () => {
for (const category of settingsCategoryDefinitions) {
if (category.id === 'feature-flags') continue
assert.equal(typeof chineseLocale[category.name.id]?.message, 'string', category.name.id)
}
})
test('every settings search result resolves to a category and a scroll target', () => {
assert.deepEqual(validateSettingsSearchMappings(), [])
const categoryIds = new Set(settingsCategoryDefinitions.map((category) => category.id))
for (const entry of settingsSearchEntries) {
assert.equal(categoryIds.has(entry.categoryId), true)
const targetId = getSettingsSearchTargetId(entry)
if (!entry.targetId) {
assert.equal(targetId, `settings-category-${entry.categoryId}`)
continue
}
const template = settingsComponentFiles[entry.categoryId]
.map((file) => readFileSync(new URL(file, import.meta.url), 'utf8'))
.join('\n')
assert.equal(template.includes(`id="${targetId}"`), true)
}
})

View File

@ -0,0 +1,30 @@
import Fuse from 'fuse.js'
export interface SettingsSearchDocument<T> {
item: T
text: string | string[]
}
export const MAX_SETTINGS_SEARCH_RESULTS = 20
export function normalizeSettingsSearchText(value: string): string {
return value.trim().toLocaleLowerCase().replace(/\s+/g, ' ')
}
export function filterSettingsSearchDocuments<T>(
query: string,
documents: SettingsSearchDocument<T>[],
): SettingsSearchDocument<T>[] {
const normalizedQuery = normalizeSettingsSearchText(query)
if (!normalizedQuery) return []
const search = new Fuse(documents, {
ignoreLocation: true,
keys: ['text'],
threshold: 0.35,
})
return search
.search(normalizedQuery, { limit: MAX_SETTINGS_SEARCH_RESULTS })
.map((result) => result.item)
}

View File

@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { StorageNode } from './storageData'
import { sortStorageChildren } from './storageData'
import StorageTreeRow from './StorageTreeRow.vue'
defineOptions({ name: 'StorageTreeNode' })
const props = defineProps<{
node: StorageNode
depth: number
parentTotal: number
}>()
const emit = defineEmits<{
action: [node: StorageNode]
}>()
const hasChildren = computed(() => (props.node.children?.length ?? 0) > 0)
const expanded = ref(false)
const totalSize = computed(() => props.node.size.actual + props.node.size.symlink)
const visibleChildren = computed(() => sortStorageChildren(props.node.children))
function onToggle(event: Event) {
expanded.value = (event.target as HTMLDetailsElement).open
}
</script>
<template>
<!-- 有子节点时使用原生 <details>/<summary> 管理展开收起 -->
<details v-if="hasChildren" class="flex flex-col" @toggle="onToggle">
<summary class="tree-row-reveal">
<StorageTreeRow
:node="node"
:depth="depth"
:parent-total="parentTotal"
:expanded="expanded"
@action="emit('action', $event)"
/>
</summary>
<div class="tree-children">
<StorageTreeNode
v-for="child in visibleChildren"
:key="child.id"
:node="child"
:depth="depth + 1"
:parent-total="totalSize"
@action="emit('action', $event)"
/>
</div>
</details>
<div v-else class="flex flex-col">
<!-- 叶子节点不需要展开直接渲染行 -->
<StorageTreeRow
:node="node"
:depth="depth"
:parent-total="parentTotal"
:expanded="false"
@action="emit('action', $event)"
/>
</div>
</template>
<style scoped>
/* 原生的 <summary> 需要去掉默认的展开三角形与缩进 */
.tree-row-reveal {
display: block;
list-style: none;
user-select: none;
cursor: pointer;
}
.tree-row-reveal::-webkit-details-marker {
display: none;
}
.tree-row-reveal::marker {
content: none;
}
</style>

View File

@ -0,0 +1,304 @@
<script setup lang="ts">
import { ChevronRightIcon, FileIcon, FolderIcon, FolderOpenIcon } from '@modrinth/assets'
import { useFormatBytes, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import type { StorageNode, StorageNodeType } from './storageData'
import { storageMessages } from './storageMessages'
type MessageDescriptor = (typeof storageMessages)['total']
defineOptions({ name: 'StorageTreeRow' })
const props = defineProps<{
node: StorageNode
depth: number
parentTotal: number
expanded: boolean
}>()
const emit = defineEmits<{
action: [node: StorageNode]
}>()
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const typeLabels: Record<StorageNodeType, MessageDescriptor> = {
instances: storageMessages.instanceData,
cache: storageMessages.cacheData,
meta: storageMessages.metaData,
database: storageMessages.database,
other: storageMessages.other,
instance: storageMessages.instance,
mods: storageMessages.mods,
replay: storageMessages.replay,
resourcepacks: storageMessages.resourcepacks,
saves: storageMessages.saves,
world: storageMessages.world,
schematics: storageMessages.schematics,
screenshots: storageMessages.screenshots,
shaderpacks: storageMessages.shaderpacks,
minimap: storageMessages.minimap,
'distant-horizons': storageMessages.distantHorizons,
'db-file': storageMessages.dbFile,
'db-backup': storageMessages.dbBackup,
}
const hasChildren = computed(() => (props.node.children?.length ?? 0) > 0)
const isDirectory = computed(() => (props.node.paths[0]?.kind ?? 'directory') !== 'file')
const actualShare = computed(() =>
props.parentTotal > 0 ? props.node.size.actual / props.parentTotal : 0,
)
const symShare = computed(() =>
props.parentTotal > 0 ? props.node.size.symlink / props.parentTotal : 0,
)
const totalShare = computed(() => Math.min(1, actualShare.value + symShare.value))
const percent = computed(() => Math.round(totalShare.value * 100))
const displayLabel = computed(() => props.node.name ?? formatMessage(typeLabels[props.node.type]))
const tooltipText = computed(() => props.node.paths.map((path) => path.path).join('\n'))
const nameTooltip = computed(() => ({
content: tooltipText.value,
popperClass: 'storage-tooltip',
}))
const actualSizeTooltip = computed(() =>
formatMessage(storageMessages.actualSizeTooltip, {
size: formatBytes(props.node.size.actual),
}),
)
const symlinkSizeTooltip = computed(() =>
formatMessage(storageMessages.symlinkSizeTooltip, {
size: formatBytes(props.node.size.symlink),
}),
)
const progressTitle = computed(() =>
props.node.size.symlink > 0
? `${actualSizeTooltip.value}\n${symlinkSizeTooltip.value}`
: actualSizeTooltip.value,
)
const progressTooltip = computed(() => ({
content: progressTitle.value,
popperClass: 'storage-tooltip',
}))
const progressLabel = computed(() =>
props.node.size.symlink > 0
? `${actualSizeTooltip.value} ${symlinkSizeTooltip.value}`
: actualSizeTooltip.value,
)
const actualSizeText = computed(() => formatBytes(props.node.size.actual))
const symlinkSizeText = computed(() => formatBytes(props.node.size.symlink))
const openActionLabel = computed(() => formatMessage(storageMessages.openAction))
</script>
<template>
<div class="tree-row hover:bg-surface-2" :class="{ clickable: hasChildren, 'is-expanded': expanded }">
<!-- 左侧树层级与名称部分 -->
<div class="flex h-full min-w-0 flex-1 items-center">
<!-- 根据 depth 生成层级缩进和导轨线 -->
<div class="indent-guides" aria-hidden="true">
<span v-for="i in depth" :key="i" class="guide-line" />
</div>
<!-- 展开/折叠 箭头装饰性由原生的 <summary> 负责展开 -->
<span
v-if="hasChildren"
class="chevron-icon inline-flex h-5 w-5 shrink-0 items-center justify-center p-0 text-secondary"
aria-hidden="true"
>
<ChevronRightIcon class="size-3.5" />
</span>
<span v-else class="chevron-placeholder" aria-hidden="true" />
<!-- 文件/文件夹按钮打开位置不展开树 -->
<button
v-tooltip="openActionLabel"
type="button"
class="node-type-btn mr-1.5 inline-flex cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-secondary hover:bg-surface-3 hover:text-contrast"
:aria-label="`${displayLabel}: ${openActionLabel}`"
@click.stop="emit('action', node)"
>
<FolderOpenIcon v-if="hasChildren && expanded" class="node-type-icon text-brand" />
<FolderIcon v-else-if="isDirectory" class="node-type-icon" />
<FileIcon v-else class="node-type-icon" />
</button>
<!-- 节点名称 -->
<span v-tooltip="nameTooltip" class="node-name">
{{ displayLabel }}
</span>
<span v-if="node.count !== undefined" class="count-badge">
{{ node.count }}
</span>
</div>
<!-- 右侧数据与进度列 -->
<div class="ml-4 flex shrink-0 items-center gap-4">
<div class="storage-size">
{{ actualSizeText }}
<span v-if="node.size.symlink > 0" class="text-secondary"> + {{ symlinkSizeText }} </span>
</div>
<div class="w-9 max-sm:hidden text-right text-xs tabular-nums text-secondary">{{ percent }}%</div>
<progress
v-tooltip="progressTooltip"
class="storage-progress h-1 w-24 shrink-0 appearance-none overflow-hidden rounded-full border-0 bg-surface-3"
:value="totalShare"
:max="1"
:aria-label="progressLabel"
/>
</div>
</div>
</template>
<style scoped>
/* 整体行布局:去卡片化、去分割线 */
.tree-row {
display: flex;
align-items: center;
justify-content: space-between;
height: 2rem;
padding: 0 0.5rem;
border: none;
background: transparent;
user-select: none;
transition: background-color 0.1s ease;
}
.tree-row.clickable {
cursor: pointer;
}
/* 竖向缩进线容器 */
.indent-guides {
display: flex;
height: 100%;
flex-shrink: 0;
}
/* 垂直导轨线:跟随主题 surface 描边色,浅色/深色模式都清晰可见 */
.guide-line {
display: block;
width: 1.25rem;
height: 100%;
position: relative;
}
.guide-line::before {
content: '';
position: absolute;
left: 0.5rem;
top: 0;
bottom: 0;
width: 1px;
background-color: var(--surface-5);
opacity: 1;
}
/* 展开状态下高亮当前父级的引导线,使用主题品牌色 */
.tree-row.is-expanded .indent-guides .guide-line:last-child::before {
background-color: var(--color-brand);
opacity: 1;
}
/* 折叠/展开 箭头图标 */
.chevron-icon {
transition: transform 0.15s ease;
}
.tree-row.is-expanded .chevron-icon {
transform: rotate(90deg);
}
.chevron-placeholder {
width: 1.25rem;
flex-shrink: 0;
}
/* 文件/文件夹打开按钮 */
.node-type-icon {
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
}
.node-type-btn:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: -1px;
}
.node-name {
min-width: 0;
overflow: hidden;
font-size: 0.8125rem;
font-weight: 400;
color: var(--color-contrast);
text-overflow: ellipsis;
white-space: nowrap;
}
.count-badge {
margin-left: 0.375rem;
padding: 0 0.35rem;
height: 1rem;
border-radius: 0.25rem;
background: var(--surface-3);
color: var(--color-secondary);
font-size: 0.6875rem;
line-height: 1rem;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.storage-size {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-primary);
white-space: nowrap;
text-align: right;
min-width: 5rem;
}
/* 原生 <progress> 作为共享占用进度条 */
.storage-progress::-webkit-progress-bar {
background: var(--surface-3);
border-radius: 9999px;
}
.storage-progress::-webkit-progress-value {
background: var(--color-brand);
border-radius: 9999px;
}
.storage-progress::-moz-progress-bar {
background: var(--color-brand);
border-radius: 9999px;
}
@media (max-width: 900px) {
.storage-progress {
display: none;
}
}
/* 存储页多行 tooltip内容换行并限制宽度 */
:global(.v-popper__popper.storage-tooltip .v-popper__inner) {
white-space: pre-line;
max-width: 22rem;
}
</style>

View File

@ -0,0 +1,522 @@
export type StorageNodeType =
| 'instances'
| 'cache'
| 'meta'
| 'database'
| 'other'
| 'instance'
| 'mods'
| 'replay'
| 'resourcepacks'
| 'saves'
| 'world'
| 'schematics'
| 'screenshots'
| 'shaderpacks'
| 'minimap'
| 'distant-horizons'
| 'db-file'
| 'db-backup'
export interface StoragePath {
path: string
kind: 'file' | 'directory'
}
export interface StorageSize {
actual: number
symlink: number
}
export interface StorageNode {
id: string
type: StorageNodeType
name?: string
instance_id?: string
size: StorageSize
count?: number
paths: StoragePath[]
children?: StorageNode[]
}
export interface StorageTree {
version?: number
scannedAt?: string
total: StorageSize
categories: StorageNode[]
rootOther: StorageNode | null
}
export function sortStorageChildren(children: StorageNode[] | undefined): StorageNode[] {
if (!children) return []
return [...children]
.filter((child) => child.size.actual + child.size.symlink > 0)
.sort((a, b) => {
const aIsOther = a.type === 'other' ? 1 : 0
const bIsOther = b.type === 'other' ? 1 : 0
if (aIsOther !== bIsOther) return aIsOther - bIsOther
const aTotal = a.size.actual + a.size.symlink
const bTotal = b.size.actual + b.size.symlink
return bTotal - aTotal
})
}
const gb = (value: number) => Math.round(value * 1024 ** 3)
const mb = (value: number) => Math.round(value * 1024 ** 2)
const appData = 'C:/Users/You/AppData/Roaming/red.ghs.axolotl'
const instanceAPath = `${appData}/profiles/红石生电优化【Redstone Survival Optimization】`
const instanceBPath = `${appData}/profiles/Fabric 1.21.11`
const instanceCPath = `${appData}/profiles/Vanilla 26.2`
const instanceDPath = `${appData}/profiles/CurseForge Pack`
const worldNode = (
id: string,
name: string,
actual: number,
symlink = 0,
parentPath: string,
): StorageNode => ({
id,
type: 'world',
name,
size: { actual, symlink },
paths: [{ path: `${parentPath}/saves/${name}`, kind: 'directory' }],
})
const instanceANode: StorageNode = {
id: 'instance-a',
type: 'instance',
name: '红石生电优化【Redstone Survival Optimization】',
size: { actual: gb(20), symlink: gb(1.5) },
paths: [{ path: instanceAPath, kind: 'directory' }],
children: [
{
id: 'instance-a-mods',
type: 'mods',
size: { actual: gb(1.6), symlink: gb(0.2) },
count: 42,
paths: [{ path: `${instanceAPath}/mods`, kind: 'directory' }],
},
{
id: 'instance-a-replay',
type: 'replay',
size: { actual: gb(5.2), symlink: 0 },
paths: [
{ path: `${instanceAPath}/flashback`, kind: 'directory' },
{ path: `${instanceAPath}/replay_recordings`, kind: 'directory' },
],
},
{
id: 'instance-a-resourcepacks',
type: 'resourcepacks',
size: { actual: gb(0.8), symlink: 0 },
count: 4,
paths: [{ path: `${instanceAPath}/resourcepacks`, kind: 'directory' }],
},
{
id: 'instance-a-saves',
type: 'saves',
size: { actual: gb(9.6), symlink: 0 },
count: 2,
paths: [{ path: `${instanceAPath}/saves`, kind: 'directory' }],
children: [
worldNode('instance-a-world1', 'world1', gb(8.4), 0, instanceAPath),
worldNode('instance-a-world2', 'world2', gb(1.2), 0, instanceAPath),
],
},
{
id: 'instance-a-schematics',
type: 'schematics',
size: { actual: gb(0.15), symlink: 0 },
count: 86,
paths: [{ path: `${instanceAPath}/schematics`, kind: 'directory' }],
},
{
id: 'instance-a-screenshots',
type: 'screenshots',
size: { actual: gb(0.6), symlink: 0 },
count: 214,
paths: [{ path: `${instanceAPath}/screenshots`, kind: 'directory' }],
},
{
id: 'instance-a-shaderpacks',
type: 'shaderpacks',
size: { actual: gb(0.9), symlink: gb(0.1) },
count: 3,
paths: [{ path: `${instanceAPath}/shaderpacks`, kind: 'directory' }],
},
{
id: 'instance-a-minimap',
type: 'minimap',
size: { actual: gb(0.05), symlink: gb(0.8) },
paths: [
{ path: `${instanceAPath}/voxelmap`, kind: 'directory' },
{ path: `${instanceAPath}/xaero`, kind: 'directory' },
{ path: `${instanceAPath}/XaeroWaypoints_BACKUP`, kind: 'directory' },
],
},
{
id: 'instance-a-distant-horizons',
type: 'distant-horizons',
size: { actual: gb(0.3), symlink: gb(0.4) },
paths: [
{ path: `${instanceAPath}/.voxy`, kind: 'directory' },
{ path: `${instanceAPath}/Distant_Horizons_server_data`, kind: 'directory' },
],
},
{
id: 'instance-a-other',
type: 'other',
size: { actual: gb(0.8), symlink: 0 },
paths: [{ path: instanceAPath, kind: 'directory' }],
},
],
}
const instanceBNode: StorageNode = {
id: 'instance-b',
type: 'instance',
name: 'Fabric 1.21.11',
size: { actual: gb(9.8), symlink: 0 },
paths: [{ path: instanceBPath, kind: 'directory' }],
children: [
{
id: 'instance-b-mods',
type: 'mods',
size: { actual: gb(2.4), symlink: 0 },
count: 57,
paths: [{ path: `${instanceBPath}/mods`, kind: 'directory' }],
},
{
id: 'instance-b-replay',
type: 'replay',
size: { actual: gb(0.1), symlink: 0 },
paths: [
{ path: `${instanceBPath}/flashback`, kind: 'directory' },
{ path: `${instanceBPath}/replay_recordings`, kind: 'directory' },
],
},
{
id: 'instance-b-resourcepacks',
type: 'resourcepacks',
size: { actual: gb(0.2), symlink: 0 },
count: 3,
paths: [{ path: `${instanceBPath}/resourcepacks`, kind: 'directory' }],
},
{
id: 'instance-b-saves',
type: 'saves',
size: { actual: gb(5.6), symlink: 0 },
count: 2,
paths: [{ path: `${instanceBPath}/saves`, kind: 'directory' }],
children: [
worldNode('instance-b-world1', 'world1', gb(3.2), 0, instanceBPath),
worldNode('instance-b-world2', 'world2', gb(2.4), 0, instanceBPath),
],
},
{
id: 'instance-b-schematics',
type: 'schematics',
size: { actual: gb(0.05), symlink: 0 },
count: 12,
paths: [{ path: `${instanceBPath}/schematics`, kind: 'directory' }],
},
{
id: 'instance-b-screenshots',
type: 'screenshots',
size: { actual: gb(0.3), symlink: 0 },
count: 76,
paths: [{ path: `${instanceBPath}/screenshots`, kind: 'directory' }],
},
{
id: 'instance-b-shaderpacks',
type: 'shaderpacks',
size: { actual: gb(0.4), symlink: 0 },
count: 2,
paths: [{ path: `${instanceBPath}/shaderpacks`, kind: 'directory' }],
},
{
id: 'instance-b-minimap',
type: 'minimap',
size: { actual: gb(0.02), symlink: 0 },
paths: [
{ path: `${instanceBPath}/voxelmap`, kind: 'directory' },
{ path: `${instanceBPath}/xaero`, kind: 'directory' },
{ path: `${instanceBPath}/XaeroWaypoints_BACKUP`, kind: 'directory' },
],
},
{
id: 'instance-b-distant-horizons',
type: 'distant-horizons',
size: { actual: gb(0.1), symlink: 0 },
paths: [
{ path: `${instanceBPath}/.voxy`, kind: 'directory' },
{ path: `${instanceBPath}/Distant_Horizons_server_data`, kind: 'directory' },
],
},
{
id: 'instance-b-other',
type: 'other',
size: { actual: gb(0.63), symlink: 0 },
paths: [{ path: instanceBPath, kind: 'directory' }],
},
],
}
const instanceCNode: StorageNode = {
id: 'instance-c',
type: 'instance',
name: 'Vanilla 26.2',
size: { actual: gb(0.5), symlink: gb(12) },
paths: [
{ path: `${appData}/profiles/Vanilla 26.2`, kind: 'directory' },
{ path: `${appData}/.minecraft/versions/Vanilla 26.2`, kind: 'directory' },
],
children: [
{
id: 'instance-c-mods',
type: 'mods',
size: { actual: gb(0.05), symlink: gb(0.5) },
count: 1,
paths: [{ path: `${instanceCPath}/mods`, kind: 'directory' }],
},
{
id: 'instance-c-replay',
type: 'replay',
size: { actual: 0, symlink: 0 },
paths: [
{ path: `${instanceCPath}/flashback`, kind: 'directory' },
{ path: `${instanceCPath}/replay_recordings`, kind: 'directory' },
],
},
{
id: 'instance-c-resourcepacks',
type: 'resourcepacks',
size: { actual: gb(0.02), symlink: gb(0.3) },
count: 1,
paths: [{ path: `${instanceCPath}/resourcepacks`, kind: 'directory' }],
},
{
id: 'instance-c-saves',
type: 'saves',
size: { actual: gb(0.4), symlink: gb(9) },
count: 1,
paths: [{ path: `${instanceCPath}/saves`, kind: 'directory' }],
children: [worldNode('instance-c-world1', 'world1', gb(0.4), gb(9), instanceCPath)],
},
{
id: 'instance-c-schematics',
type: 'schematics',
size: { actual: 0, symlink: 0 },
count: 0,
paths: [{ path: `${instanceCPath}/schematics`, kind: 'directory' }],
},
{
id: 'instance-c-screenshots',
type: 'screenshots',
size: { actual: 0, symlink: 0 },
count: 0,
paths: [{ path: `${instanceCPath}/screenshots`, kind: 'directory' }],
},
{
id: 'instance-c-shaderpacks',
type: 'shaderpacks',
size: { actual: gb(0.01), symlink: gb(2) },
count: 1,
paths: [{ path: `${instanceCPath}/shaderpacks`, kind: 'directory' }],
},
{
id: 'instance-c-minimap',
type: 'minimap',
size: { actual: gb(0.01), symlink: gb(0.1) },
paths: [
{ path: `${instanceCPath}/voxelmap`, kind: 'directory' },
{ path: `${instanceCPath}/xaero`, kind: 'directory' },
{ path: `${instanceCPath}/XaeroWaypoints_BACKUP`, kind: 'directory' },
],
},
{
id: 'instance-c-distant-horizons',
type: 'distant-horizons',
size: { actual: gb(0.01), symlink: gb(0.1) },
paths: [
{ path: `${instanceCPath}/.voxy`, kind: 'directory' },
{ path: `${instanceCPath}/Distant_Horizons_server_data`, kind: 'directory' },
],
},
{
id: 'instance-c-other',
type: 'other',
size: { actual: 0, symlink: 0 },
paths: [{ path: instanceCPath, kind: 'directory' }],
},
],
}
const instanceDNode: StorageNode = {
id: 'instance-d',
type: 'instance',
name: 'CurseForge Pack',
size: { actual: gb(4), symlink: gb(0.2) },
paths: [{ path: instanceDPath, kind: 'directory' }],
children: [
{
id: 'instance-d-mods',
type: 'mods',
size: { actual: gb(2), symlink: gb(0.1) },
count: 35,
paths: [{ path: `${instanceDPath}/mods`, kind: 'directory' }],
},
{
id: 'instance-d-replay',
type: 'replay',
size: { actual: gb(0.05), symlink: 0 },
paths: [
{ path: `${instanceDPath}/flashback`, kind: 'directory' },
{ path: `${instanceDPath}/replay_recordings`, kind: 'directory' },
],
},
{
id: 'instance-d-resourcepacks',
type: 'resourcepacks',
size: { actual: gb(0.3), symlink: 0 },
count: 5,
paths: [{ path: `${instanceDPath}/resourcepacks`, kind: 'directory' }],
},
{
id: 'instance-d-saves',
type: 'saves',
size: { actual: gb(1), symlink: 0 },
count: 2,
paths: [{ path: `${instanceDPath}/saves`, kind: 'directory' }],
children: [
worldNode('instance-d-world1', 'world1', gb(0.6), 0, instanceDPath),
worldNode('instance-d-world2', 'world2', gb(0.4), 0, instanceDPath),
],
},
{
id: 'instance-d-schematics',
type: 'schematics',
size: { actual: gb(0.1), symlink: 0 },
count: 24,
paths: [{ path: `${instanceDPath}/schematics`, kind: 'directory' }],
},
{
id: 'instance-d-screenshots',
type: 'screenshots',
size: { actual: gb(0.2), symlink: 0 },
count: 88,
paths: [{ path: `${instanceDPath}/screenshots`, kind: 'directory' }],
},
{
id: 'instance-d-shaderpacks',
type: 'shaderpacks',
size: { actual: gb(0.2), symlink: 0 },
count: 4,
paths: [{ path: `${instanceDPath}/shaderpacks`, kind: 'directory' }],
},
{
id: 'instance-d-minimap',
type: 'minimap',
size: { actual: gb(0.03), symlink: 0 },
paths: [
{ path: `${instanceDPath}/voxelmap`, kind: 'directory' },
{ path: `${instanceDPath}/xaero`, kind: 'directory' },
{ path: `${instanceDPath}/XaeroWaypoints_BACKUP`, kind: 'directory' },
],
},
{
id: 'instance-d-distant-horizons',
type: 'distant-horizons',
size: { actual: gb(0.02), symlink: 0 },
paths: [
{ path: `${instanceDPath}/.voxy`, kind: 'directory' },
{ path: `${instanceDPath}/Distant_Horizons_server_data`, kind: 'directory' },
],
},
{
id: 'instance-d-other',
type: 'other',
size: { actual: gb(0.1), symlink: gb(0.1) },
paths: [{ path: instanceDPath, kind: 'directory' }],
},
],
}
const instancesCategory: StorageNode = {
id: 'category-instances',
type: 'instances',
size: { actual: gb(34.7), symlink: gb(13.7) },
count: 4,
paths: [{ path: `${appData}/profiles`, kind: 'directory' }],
children: [
{
id: 'profiles-root-other',
type: 'other',
size: { actual: gb(0.4), symlink: 0 },
paths: [{ path: `${appData}/profiles`, kind: 'directory' }],
},
instanceANode,
instanceBNode,
instanceCNode,
instanceDNode,
],
}
const cacheCategory: StorageNode = {
id: 'category-cache',
type: 'cache',
size: { actual: gb(2.1), symlink: 0 },
count: 128,
paths: [{ path: `${appData}/caches`, kind: 'directory' }],
}
const metaCategory: StorageNode = {
id: 'category-meta',
type: 'meta',
size: { actual: gb(6.3), symlink: 0 },
count: 3421,
paths: [{ path: `${appData}/meta`, kind: 'directory' }],
}
const databaseCategory: StorageNode = {
id: 'category-database',
type: 'database',
size: { actual: gb(0.5), symlink: 0 },
count: 2,
paths: [{ path: `${appData}`, kind: 'directory' }],
children: [
{
id: 'database-app-db',
type: 'db-file',
size: { actual: mb(20), symlink: 0 },
paths: [{ path: `${appData}/app.db`, kind: 'file' }],
},
{
id: 'database-backups',
type: 'db-backup',
size: { actual: mb(480), symlink: 0 },
paths: [{ path: `${appData}/Backups/app-db`, kind: 'directory' }],
},
],
}
const rootOther: StorageNode = {
id: 'root-other',
type: 'other',
size: { actual: gb(0.3), symlink: 0 },
paths: [
{ path: `${appData}/launcher_logs`, kind: 'directory' },
{ path: `${appData}/app-window-state.json`, kind: 'file' },
{ path: `${appData}/download.log`, kind: 'file' },
{ path: `${appData}/download-reputation.json`, kind: 'file' },
],
}
export const storageTree: StorageTree = {
total: { actual: gb(43.9), symlink: gb(13.7) },
categories: [instancesCategory, cacheCategory, metaCategory, databaseCategory, rootOther],
rootOther,
}

View File

@ -0,0 +1,137 @@
import { defineMessages } from '@modrinth/ui'
export const storageMessages = defineMessages({
total: {
id: 'app.settings.storage.total',
defaultMessage: 'Total size',
},
totalDescription: {
id: 'app.settings.storage.total-description',
defaultMessage: 'Total size including symbolic links and junctions',
},
instanceData: {
id: 'app.settings.storage.instance-data',
defaultMessage: 'Instance data',
},
cacheData: {
id: 'app.settings.storage.cache-data',
defaultMessage: 'Cache data',
},
metaData: {
id: 'app.settings.storage.meta-data',
defaultMessage: 'Meta data',
},
database: {
id: 'app.settings.storage.database',
defaultMessage: 'Database',
},
other: {
id: 'app.settings.storage.other',
defaultMessage: 'Other',
},
instance: {
id: 'app.settings.storage.instance',
defaultMessage: 'Instance',
},
mods: {
id: 'app.settings.storage.mods',
defaultMessage: 'Mods',
},
replay: {
id: 'app.settings.storage.replay',
defaultMessage: 'Replay recordings',
},
resourcepacks: {
id: 'app.settings.storage.resourcepacks',
defaultMessage: 'Resource packs',
},
saves: {
id: 'app.settings.storage.saves',
defaultMessage: 'Saves',
},
world: {
id: 'app.settings.storage.world',
defaultMessage: 'World',
},
schematics: {
id: 'app.settings.storage.schematics',
defaultMessage: 'Schematics',
},
screenshots: {
id: 'app.settings.storage.screenshots',
defaultMessage: 'Screenshots',
},
shaderpacks: {
id: 'app.settings.storage.shaderpacks',
defaultMessage: 'Shader packs',
},
minimap: {
id: 'app.settings.storage.minimap',
defaultMessage: 'Minimap data',
},
distantHorizons: {
id: 'app.settings.storage.distant-horizons',
defaultMessage: 'Distant Horizons cache',
},
dbFile: {
id: 'app.settings.storage.db-file',
defaultMessage: 'App database',
},
dbBackup: {
id: 'app.settings.storage.db-backup',
defaultMessage: 'Database backups',
},
itemCount: {
id: 'app.settings.storage.item-count',
defaultMessage: '{count} items',
},
instanceCount: {
id: 'app.settings.storage.instance-count',
defaultMessage: '{count} instances',
},
actualSizeTooltip: {
id: 'app.settings.storage.actual-size-tooltip',
defaultMessage: 'Actual size: {size}',
},
symlinkSizeTooltip: {
id: 'app.settings.storage.symlink-size-tooltip',
defaultMessage: 'Symbolic link or junction referenced size: {size}',
},
openAction: {
id: 'app.settings.storage.open-action',
defaultMessage: 'Open in launcher or open location',
},
symlinkLabel: {
id: 'app.settings.storage.symlink-label',
defaultMessage: 'symlink',
},
symlinkHelp: {
id: 'app.settings.storage.symlink-help',
defaultMessage: 'What is a symlink?',
},
symlinkHelpTooltip: {
id: 'app.settings.storage.symlink-help-tooltip',
defaultMessage:
'Symbolic links reference Minecraft resources from other locations — these files may actually live in another launcher\u2019s directory.\nFor example, \u201c20MB + 1.2GB\u201d means the launcher folder contains 20MB of files and references an external 1.2GB of files.',
},
update: {
id: 'app.settings.storage.update',
defaultMessage: 'Update',
},
updating: {
id: 'app.settings.storage.updating',
defaultMessage: 'Updating…',
},
lastUpdatedLabel: {
id: 'app.settings.storage.last-updated-label',
defaultMessage: 'Last updated:',
},
scanning: {
id: 'app.settings.storage.scanning',
defaultMessage: 'Scanning storage…',
},
storageTab: {
id: 'app.settings.tabs.storage',
defaultMessage: 'Storage',
},
})