feat:移除了弹窗,服务器添加sls
This commit is contained in:
1017
apps/app-frontend/src/components/ui/AboutMergeGame.vue
Normal file
1017
apps/app-frontend/src/components/ui/AboutMergeGame.vue
Normal file
File diff suppressed because it is too large
Load Diff
360
apps/app-frontend/src/components/ui/AboutScene.vue
Normal file
360
apps/app-frontend/src/components/ui/AboutScene.vue
Normal file
@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<canvas id="about_scene" class="size-full" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as THREE from 'three'
|
||||
import { type GLTF, GLTFLoader } from 'three/examples/jsm/Addons.js'
|
||||
import { onMounted, onScopeDispose, useTemplateRef } from 'vue'
|
||||
|
||||
import { useTheming } from '@/store/theme'
|
||||
|
||||
const themeStore = useTheming()
|
||||
function isDarkMode() {
|
||||
if (themeStore.selectedTheme == 'system') {
|
||||
return matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
return ['dark', 'oled'].includes(themeStore.selectedTheme)
|
||||
}
|
||||
|
||||
function loadGLTF(url: string): Promise<GLTF> {
|
||||
return new Promise((res, rej) => {
|
||||
const loader = new GLTFLoader()
|
||||
loader.load(
|
||||
url,
|
||||
(data) => {
|
||||
res(data)
|
||||
},
|
||||
undefined,
|
||||
rej,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function createTip(position: THREE.Vector3, color: THREE.ColorRepresentation = 0x00ff00) {
|
||||
const tipGeometry = new THREE.SphereGeometry(2)
|
||||
const tipMaterial = new THREE.MeshBasicMaterial({ color })
|
||||
const tipMesh = new THREE.Mesh(tipGeometry, tipMaterial)
|
||||
tipMesh.position.copy(position)
|
||||
return tipMesh
|
||||
}
|
||||
|
||||
function createWaterMaterial(): THREE.ShaderMaterial {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
time: { value: 0 },
|
||||
seed: { value: Math.random() * 83 + 17 },
|
||||
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
|
||||
},
|
||||
transparent: true,
|
||||
vertexShader: `#define WATER_VERT
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `#define WATER_FRAG
|
||||
uniform float time;
|
||||
uniform float seed;
|
||||
uniform vec3 color;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec2 randomGradient(vec2 p) {
|
||||
float n = sin(dot(p, vec2(127.1, 311.7)));
|
||||
float angle = fract(n * 43758.5453123) * 6.28318530718 * seed;
|
||||
return vec2(cos(angle), sin(angle));
|
||||
}
|
||||
|
||||
float perlinNoise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
|
||||
vec2 g1 = randomGradient(i);
|
||||
vec2 g2 = randomGradient(i + vec2(1.0, 0.0));
|
||||
vec2 g3 = randomGradient(i + vec2(0.0, 1.0));
|
||||
vec2 g4 = randomGradient(i + vec2(1.0, 1.0));
|
||||
|
||||
vec2 d1 = f;
|
||||
vec2 d2 = f - vec2(1.0, 0.0);
|
||||
vec2 d3 = f - vec2(0.0, 1.0);
|
||||
vec2 d4 = f - vec2(1.0, 1.0);
|
||||
|
||||
float v1 = dot(g1, d1);
|
||||
float v2 = dot(g2, d2);
|
||||
float v3 = dot(g3, d3);
|
||||
float v4 = dot(g4, d4);
|
||||
|
||||
return mix(mix(v1, v2, u.x), mix(v3, v4, u.x), u.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float height = 0.0;
|
||||
height += perlinNoise(vec2(vUv.x * 10.0, time * 0.8)) * 0.3;
|
||||
height += perlinNoise(vec2(vUv.x * 5.0, time * 0.4)) * 0.35;
|
||||
height += perlinNoise(vec2(vUv.x * 2.5, time * 0.2)) * 0.15;
|
||||
height += perlinNoise(vec2(vUv.x * 2.0, time * 0.2)) * 0.2;
|
||||
height = clamp(height, -1.0, 1.0);
|
||||
height = height * 0.8 + 0.6;
|
||||
|
||||
float thickness = 0.008;
|
||||
if(vUv.y < height - thickness) {
|
||||
float scalar = 1.0 - height + vUv.y;
|
||||
scalar = scalar * scalar * scalar * 0.6;
|
||||
gl_FragColor = vec4(color, scalar);
|
||||
} else if(vUv.y > height + thickness) {
|
||||
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
} else {
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
}`,
|
||||
})
|
||||
}
|
||||
|
||||
function createWater(material: THREE.ShaderMaterial, position: THREE.Vector3) {
|
||||
const geometry = new THREE.PlaneGeometry(120, 16)
|
||||
const waterMesh = new THREE.Mesh(geometry, material)
|
||||
waterMesh.position.copy(position)
|
||||
return waterMesh
|
||||
}
|
||||
|
||||
function createCircleMaterial(): THREE.ShaderMaterial {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
|
||||
},
|
||||
transparent: true,
|
||||
vertexShader: `#define CIRCLE_VERT
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `#define CIRCLE_FRAG
|
||||
varying vec2 vUv;
|
||||
uniform vec3 color;
|
||||
float remap(float v, float inMin, float inMax, float outMin, float outMax) {
|
||||
float t = (v - inMin) / (inMax - inMin);
|
||||
return outMin + (outMax - outMin) * t;
|
||||
}
|
||||
void main() {
|
||||
float dis = distance(vUv, vec2(0.5));
|
||||
float thickness = 0.05;
|
||||
|
||||
gl_FragColor = vec4(0.0);
|
||||
if(dis <= 0.35 && dis >= 0.35 - thickness) {
|
||||
gl_FragColor = vec4(color, 0.8);
|
||||
} else {
|
||||
// emissive
|
||||
float scalar = 0.0;
|
||||
if(dis >= 0.35) {
|
||||
scalar = clamp(0.5 - dis, 0.0, 0.15);
|
||||
scalar = remap(scalar, 0.0, 0.15, 0.0, 1.0);
|
||||
} else {
|
||||
scalar = clamp(0.35 - dis, 0.0, 0.5);
|
||||
scalar = remap(scalar, 0.0, 0.35, 1.0, 0.0);
|
||||
}
|
||||
scalar = clamp(scalar * scalar * scalar, 0.0, 1.0);
|
||||
gl_FragColor = vec4(color, scalar);
|
||||
}
|
||||
}`,
|
||||
})
|
||||
}
|
||||
|
||||
function createCircle(material: THREE.ShaderMaterial, position: THREE.Vector3) {
|
||||
const geometry = new THREE.PlaneGeometry(0.6, 0.6)
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
mesh.position.copy(position)
|
||||
return mesh
|
||||
}
|
||||
|
||||
function main() {
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('#about_scene')
|
||||
if (!canvas) return console.error('No canvas')
|
||||
|
||||
let isUpdating = true
|
||||
|
||||
const canvasSize = new THREE.Vector2(
|
||||
canvas.getBoundingClientRect().width,
|
||||
canvas.getBoundingClientRect().height,
|
||||
)
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
canvas,
|
||||
})
|
||||
renderer.setPixelRatio(devicePixelRatio)
|
||||
renderer.setSize(canvasSize.x, canvasSize.y)
|
||||
|
||||
const deltaClock = new THREE.Clock()
|
||||
const elapseClock = new THREE.Clock()
|
||||
deltaClock.start()
|
||||
elapseClock.start()
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(30, canvasSize.x / canvasSize.y, 1, 3000)
|
||||
camera.fov *= 0.7
|
||||
camera.position.set(-10, 5, 30)
|
||||
camera.lookAt(0, 0, 0)
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff)
|
||||
scene.add(ambientLight)
|
||||
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 4.0)
|
||||
dirLight.position.set(-30, 30, 28)
|
||||
scene.add(dirLight)
|
||||
|
||||
scene.add(createTip(dirLight.position, 0xffff00))
|
||||
scene.add(createTip(camera.position))
|
||||
|
||||
const accentColor =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-brand').trim() || '#4444ff'
|
||||
|
||||
const waterMaterial = createWaterMaterial()
|
||||
waterMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
|
||||
isDarkMode() ? 0.6 : 2.4,
|
||||
)
|
||||
// .multiplyScalar(0.6)
|
||||
// .multiplyScalar(2.4)
|
||||
|
||||
scene.add(createWater(waterMaterial, new THREE.Vector3(0, -6.5, 4)))
|
||||
scene.add(createWater(waterMaterial, new THREE.Vector3(2, -8, -10)))
|
||||
scene.add(createWater(waterMaterial, new THREE.Vector3(16, -8, -26)))
|
||||
|
||||
async function load() {
|
||||
const axlGLTF = await loadGLTF('/models/axolotl.gltf')
|
||||
|
||||
const axlModel = axlGLTF.scene
|
||||
axlModel.scale.multiplyScalar(5)
|
||||
axlModel.rotateY(Math.PI / 2)
|
||||
axlModel.position.add(new THREE.Vector3(0, -2.5, 0))
|
||||
scene.add(axlModel)
|
||||
|
||||
const mixer = new THREE.AnimationMixer(axlModel)
|
||||
const axlSwimAnim = axlGLTF.animations.filter((a) => a.name === 'swim')[0]
|
||||
if (!axlSwimAnim) return console.error('Missing animation swim')
|
||||
mixer.clipAction(axlSwimAnim).play()
|
||||
|
||||
// // Axl Label
|
||||
// const axlLabelGLTF = await loadGLTF('/models/axl_label.glb')
|
||||
// const axlLabel = axlLabelGLTF.scene
|
||||
// axlLabel.scale.multiplyScalar(8)
|
||||
// axlLabel.rotateY(-Math.PI / 2)
|
||||
// axlLabel.position.set(0, 5.2, 0)
|
||||
// scene.add(axlLabel)
|
||||
|
||||
const originAxlModelPosition = axlModel.position.clone()
|
||||
return function (deltaTime: number, elapsedTime: number) {
|
||||
axlModel.position.set(
|
||||
originAxlModelPosition.x,
|
||||
originAxlModelPosition.y + Math.sin(elapsedTime),
|
||||
originAxlModelPosition.z,
|
||||
)
|
||||
axlModel.rotation.y = Math.sin(elapsedTime * 0.3) * 0.2 + (Math.PI * 100) / 180
|
||||
mixer.update(deltaTime)
|
||||
}
|
||||
}
|
||||
let updateGLTF = (_deltaTime: number, _elapsedTime: number) => {}
|
||||
load().then((updateFn) => {
|
||||
if (updateFn) updateGLTF = updateFn
|
||||
})
|
||||
|
||||
const circleMaterial = createCircleMaterial()
|
||||
circleMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
|
||||
isDarkMode() ? 1.2 : 3,
|
||||
)
|
||||
// .multiplyScalar(1.2)
|
||||
// .multiplyScalar(3)
|
||||
|
||||
let circleMeshList: THREE.Mesh[] = []
|
||||
let nextCircleCreateTime = 0.0
|
||||
function updateCircle(deltaTime: number, elapsedTime: number) {
|
||||
circleMeshList = circleMeshList.filter((m) => {
|
||||
m.position.y += deltaTime * 2.0
|
||||
if (m.position.y >= 32) {
|
||||
scene.remove(m)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (elapsedTime >= nextCircleCreateTime) {
|
||||
nextCircleCreateTime = elapsedTime + Math.random() * 0.8
|
||||
const circle = createCircle(
|
||||
circleMaterial,
|
||||
new THREE.Vector3(Math.random() * 64 - 32 - 12, -20, Math.random() * 6 + 1),
|
||||
)
|
||||
scene.add(circle)
|
||||
circleMeshList.push(circle)
|
||||
}
|
||||
}
|
||||
|
||||
function animate(_time: number) {
|
||||
if (isUpdating === false) return
|
||||
requestAnimationFrame(animate)
|
||||
|
||||
const deltaTime = deltaClock.getDelta()
|
||||
const elapsedTime = elapseClock.getElapsedTime()
|
||||
|
||||
updateGLTF(deltaTime, elapsedTime)
|
||||
waterMaterial.uniforms.time.value = elapsedTime
|
||||
|
||||
updateCircle(deltaTime, elapsedTime)
|
||||
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
animate(Date.now())
|
||||
|
||||
const originCameraPosition = camera.position.clone()
|
||||
function onMouseMove(event: MouseEvent) {
|
||||
const mouseXOffsetRatio = ((event.clientX - innerWidth / 2) / innerWidth) * 2
|
||||
const mouseYOffsetRatio = ((event.clientY - innerHeight / 2) / innerHeight) * 2
|
||||
const newPosition = new THREE.Vector3(
|
||||
originCameraPosition.x + mouseXOffsetRatio,
|
||||
originCameraPosition.y + mouseYOffsetRatio * 0.5,
|
||||
originCameraPosition.z,
|
||||
)
|
||||
camera.position.copy(newPosition)
|
||||
}
|
||||
|
||||
function updateSize() {
|
||||
if (!isUpdating) return
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const w = rect.width
|
||||
const h = rect.height
|
||||
if (w > 0 && h > 0) {
|
||||
renderer.setSize(w, h)
|
||||
camera.aspect = w / h
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateSize)
|
||||
resizeObserver.observe(canvas)
|
||||
|
||||
addEventListener('mousemove', onMouseMove)
|
||||
onScopeDispose(() => {
|
||||
isUpdating = false
|
||||
removeEventListener('mousemove', onMouseMove)
|
||||
resizeObserver.disconnect()
|
||||
deltaClock.stop()
|
||||
elapseClock.stop()
|
||||
renderer.dispose()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(main)
|
||||
</script>
|
||||
<style>
|
||||
#about_scene {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--color-brand) 36%, var(--surface-1) 100%),
|
||||
#00000000 40%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
1237
apps/app-frontend/src/components/ui/AccountsCard.vue
Normal file
1237
apps/app-frontend/src/components/ui/AccountsCard.vue
Normal file
File diff suppressed because it is too large
Load Diff
73
apps/app-frontend/src/components/ui/AddContentButton.vue
Normal file
73
apps/app-frontend/src/components/ui/AddContentButton.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon, FolderOpenIcon, PlusIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { add_project_from_path } from '@/helpers/instance'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
installContent: { id: 'app.content.install-content', defaultMessage: 'Install content' },
|
||||
addFromFile: { id: 'app.content.add-from-file', defaultMessage: 'Add from file' },
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleAddContentFromFile = async () => {
|
||||
const newProject = await open({ multiple: true })
|
||||
if (!newProject) return
|
||||
|
||||
for (const project of newProject) {
|
||||
await add_project_from_path(props.instance.id, project.path ?? project).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchContent = async () => {
|
||||
await router.push({
|
||||
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
|
||||
query: { i: props.instance.id },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="joined-buttons">
|
||||
<ButtonStyled>
|
||||
<button @click="handleSearchContent">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.installContent) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'from_file',
|
||||
action: handleAddContentFromFile,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<DropdownIcon />
|
||||
<template #from_file>
|
||||
<FolderOpenIcon />
|
||||
<span class="whitespace-nowrap">{{ formatMessage(messages.addFromFile) }}</span>
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
787
apps/app-frontend/src/components/ui/AppActionBar.vue
Normal file
787
apps/app-frontend/src/components/ui/AppActionBar.vue
Normal file
@ -0,0 +1,787 @@
|
||||
<template>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Dropdown
|
||||
v-model:shown="notificationCenterShown"
|
||||
placement="bottom-end"
|
||||
:triggers="['click']"
|
||||
:hide-triggers="['click']"
|
||||
>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.notifications)"
|
||||
:aria-label="formatMessage(messages.notifications)"
|
||||
class="relative"
|
||||
>
|
||||
<BellIcon />
|
||||
<span
|
||||
v-if="hasUnreadNotifications"
|
||||
class="absolute right-0 top-0 size-2 rounded-full bg-red ring-2 ring-bg-raised"
|
||||
/>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template #popper>
|
||||
<div class="w-[22rem] max-w-[calc(100vw-2rem)] p-2">
|
||||
<div class="mb-2 flex items-center justify-between px-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.notifications)
|
||||
}}</span>
|
||||
<button
|
||||
v-if="notificationHistory.length"
|
||||
class="text-xs text-secondary hover:text-contrast"
|
||||
@click="clearNotificationHistory"
|
||||
>
|
||||
{{ formatMessage(messages.clearNotifications) }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!notificationHistory.length"
|
||||
class="px-2 py-4 text-center text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noNotifications) }}
|
||||
</div>
|
||||
<div v-else class="flex max-h-[22rem] flex-col gap-1 overflow-auto">
|
||||
<div
|
||||
v-for="item in notificationHistory"
|
||||
:key="item.key"
|
||||
class="flex items-start gap-2 rounded-lg p-2 hover:bg-button-bg"
|
||||
>
|
||||
<div
|
||||
class="mt-1 size-2 shrink-0 rounded-full"
|
||||
:class="notificationDotClass(item.type)"
|
||||
/>
|
||||
<button class="min-w-0 flex-1 text-left" @click="openNotification(item)">
|
||||
<div class="truncate text-sm font-medium text-contrast">{{ item.title }}</div>
|
||||
<div v-if="item.text" class="line-clamp-2 text-xs text-secondary">
|
||||
{{ item.text }}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.dismissNotification)"
|
||||
class="shrink-0 text-secondary hover:text-contrast"
|
||||
@click="dismissNotification(item)"
|
||||
>
|
||||
<XIcon class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<ButtonStyled
|
||||
v-if="!isDownloadsPage && hasActiveDownloads && !hasVisibleActiveDownloadToasts"
|
||||
color="brand"
|
||||
type="transparent"
|
||||
circular
|
||||
>
|
||||
<button v-tooltip="formatMessage(messages.viewActiveDownloads)" @click="goToDownloads">
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div v-if="offline" class="flex items-center gap-1">
|
||||
<UnplugIcon class="text-secondary" />
|
||||
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
|
||||
</div>
|
||||
<AppUpdateButton />
|
||||
<div
|
||||
class="flex border-solid border-surface-5 text-sm items-center gap-2 py-1.5 px-3 rounded-xl border"
|
||||
>
|
||||
<template v-if="selectedProcess">
|
||||
<OnlineIndicatorIcon />
|
||||
<div class="text-contrast flex items-center gap-2">
|
||||
<router-link
|
||||
v-tooltip="formatMessage(messages.viewInstance)"
|
||||
:to="`/instance/${encodeURIComponent(selectedProcess.instance.id)}`"
|
||||
class="hover:underline"
|
||||
>
|
||||
{{ selectedProcess.instance.name }}
|
||||
</router-link>
|
||||
<Dropdown
|
||||
v-if="currentProcesses.length > 1"
|
||||
placement="bottom"
|
||||
:triggers="['click']"
|
||||
:hide-triggers="['click']"
|
||||
@show="showInstances = true"
|
||||
@hide="showInstances = false"
|
||||
>
|
||||
<ButtonStyled type="transparent" circular size="small">
|
||||
<button
|
||||
v-tooltip="
|
||||
showInstances
|
||||
? formatMessage(messages.hideMoreRunningInstances)
|
||||
: formatMessage(messages.showMoreRunningInstances)
|
||||
"
|
||||
>
|
||||
<DropdownIcon :class="{ 'rotate-180': !!showInstances }" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template #popper>
|
||||
<div class="flex w-[20rem] max-h-[24rem] flex-col gap-2 overflow-auto">
|
||||
<div
|
||||
v-for="process in currentProcesses"
|
||||
:key="process.uuid"
|
||||
class="flex w-full items-center gap-2 rounded-xl bg-surface-4 p-2 text-sm"
|
||||
>
|
||||
<button
|
||||
v-tooltip.left="
|
||||
process.uuid === selectedProcess.uuid
|
||||
? formatMessage(messages.primaryInstance)
|
||||
: formatMessage(messages.makePrimaryInstance)
|
||||
"
|
||||
class="flex flex-grow items-center gap-2"
|
||||
:class="{
|
||||
'active:scale-95 transition-transform': process.uuid !== selectedProcess.uuid,
|
||||
}"
|
||||
:disabled="process.uuid === selectedProcess.uuid"
|
||||
@click="selectProcess(process)"
|
||||
>
|
||||
<OnlineIndicatorIcon />
|
||||
<span class="mr-auto text-contrast flex items-center gap-2">
|
||||
{{ process.instance.name }}
|
||||
<StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.stopInstance)"
|
||||
class="active:scale-95 flex"
|
||||
@click.stop="stop(process)"
|
||||
>
|
||||
<StopCircleIcon class="text-red size-5" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.viewLogs)"
|
||||
class="active:scale-95 flex"
|
||||
@click.stop="goToTerminal(process.instance.id)"
|
||||
>
|
||||
<TerminalSquareIcon class="text-secondary size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.stopInstance)"
|
||||
class="active:scale-95 flex"
|
||||
@click="stop(selectedProcess)"
|
||||
>
|
||||
<StopCircleIcon class="text-red size-5" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.viewLogs)"
|
||||
class="active:scale-95 flex"
|
||||
@click="goToTerminal()"
|
||||
>
|
||||
<TerminalSquareIcon class="text-secondary size-5" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="size-2 rounded-full bg-secondary" />
|
||||
<span class="text-secondary"> {{ formatMessage(messages.noInstancesRunning) }} </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BellIcon,
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
OnlineIndicatorIcon,
|
||||
StarIcon,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
UnplugIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
type PopupNotification,
|
||||
type PopupNotificationProgressItem,
|
||||
useVIntl,
|
||||
type WebNotification,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
|
||||
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { loading_listener, process_listener } from '@/helpers/events'
|
||||
import { get_many as getInstances } from '@/helpers/instance'
|
||||
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
|
||||
import type { LoadingBar } from '@/helpers/state'
|
||||
import { progress_bars_list } from '@/helpers/state'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { downloadBarTypes, injectDownloadManager } from '@/providers/download-manager'
|
||||
|
||||
const notificationManager = injectNotificationManager()
|
||||
const { handleError } = notificationManager
|
||||
const popupNotificationManager = injectPopupNotificationManager()
|
||||
const downloadManager = injectDownloadManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
type NotificationHistoryItem = {
|
||||
key: string
|
||||
createdAt?: number
|
||||
title: string
|
||||
text?: string
|
||||
type?: 'error' | 'warning' | 'success' | 'info' | 'download'
|
||||
collapsed?: boolean
|
||||
expand: () => void
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
const notificationHistory = computed<NotificationHistoryItem[]>(() =>
|
||||
[
|
||||
...notificationManager.getNotifications().map((item: WebNotification) => ({
|
||||
key: `web-${item.id}`,
|
||||
createdAt: item.createdAt,
|
||||
title: item.title ?? formatMessage(messages.notifications),
|
||||
text: item.text,
|
||||
type: item.type,
|
||||
collapsed: item.collapsed,
|
||||
expand: () => notificationManager.expandNotification(item.id),
|
||||
dismiss: () => notificationManager.removeNotification(item.id),
|
||||
})),
|
||||
...popupNotificationManager.getNotifications().map((item: PopupNotification) => ({
|
||||
key: `popup-${item.id}`,
|
||||
createdAt: item.createdAt,
|
||||
title: item.title,
|
||||
text:
|
||||
item.text ??
|
||||
(item.progressItems
|
||||
?.filter((progressItem) => progressItem.text)
|
||||
.map((progressItem) => `${progressItem.title}: ${progressItem.text}`)
|
||||
.join('\n') ||
|
||||
undefined),
|
||||
type: item.type,
|
||||
collapsed: item.collapsed,
|
||||
expand: () => popupNotificationManager.expandNotification(item.id),
|
||||
dismiss: () => popupNotificationManager.removeNotification(item.id),
|
||||
})),
|
||||
].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)),
|
||||
)
|
||||
|
||||
const hasUnreadNotifications = computed(() =>
|
||||
notificationHistory.value.some(
|
||||
(item) => !item.collapsed && ['error', 'warning'].includes(item.type ?? ''),
|
||||
),
|
||||
)
|
||||
|
||||
function notificationDotClass(type?: NotificationHistoryItem['type']): string {
|
||||
if (type === 'error') return 'bg-red'
|
||||
if (type === 'warning') return 'bg-orange'
|
||||
if (type === 'success') return 'bg-green'
|
||||
if (type === 'download') return 'bg-green'
|
||||
return 'bg-blue'
|
||||
}
|
||||
|
||||
function dismissNotification(item: NotificationHistoryItem) {
|
||||
item.dismiss()
|
||||
}
|
||||
|
||||
async function openNotification(item: NotificationHistoryItem) {
|
||||
item.expand()
|
||||
notificationCenterShown.value = false
|
||||
}
|
||||
|
||||
function clearNotificationHistory() {
|
||||
notificationManager.clearAllNotifications()
|
||||
popupNotificationManager.clearAllNotifications()
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const isDownloadsPage = computed(
|
||||
() => route.path === '/downloads' || route.path.startsWith('/downloads/'),
|
||||
)
|
||||
|
||||
const showInstances = ref(false)
|
||||
const notificationCenterShown = ref(false)
|
||||
|
||||
interface RunningProcess {
|
||||
uuid: string
|
||||
instance_id: string
|
||||
instance: GameInstance
|
||||
}
|
||||
|
||||
interface LoadingEventPayload {
|
||||
event: LoadingBar['bar_type']
|
||||
loader_uuid: string
|
||||
fraction: number | null
|
||||
message: string
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
offline: {
|
||||
id: 'app.action-bar.offline',
|
||||
defaultMessage: 'Offline',
|
||||
},
|
||||
viewInstance: {
|
||||
id: 'app.action-bar.view-instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
showMoreRunningInstances: {
|
||||
id: 'app.action-bar.show-more-running-instances',
|
||||
defaultMessage: 'Show more running instances',
|
||||
},
|
||||
hideMoreRunningInstances: {
|
||||
id: 'app.action-bar.hide-more-running-instances',
|
||||
defaultMessage: 'Hide more running instances',
|
||||
},
|
||||
primaryInstance: {
|
||||
id: 'app.action-bar.primary-instance',
|
||||
defaultMessage: 'Primary instance',
|
||||
},
|
||||
makePrimaryInstance: {
|
||||
id: 'app.action-bar.make-primary-instance',
|
||||
defaultMessage: 'Make primary instance',
|
||||
},
|
||||
stopInstance: {
|
||||
id: 'app.action-bar.stop-instance',
|
||||
defaultMessage: 'Stop instance',
|
||||
},
|
||||
viewLogs: {
|
||||
id: 'app.action-bar.view-logs',
|
||||
defaultMessage: 'View logs',
|
||||
},
|
||||
noInstancesRunning: {
|
||||
id: 'app.action-bar.no-instances-running',
|
||||
defaultMessage: 'No instances running',
|
||||
},
|
||||
notifications: {
|
||||
id: 'app.action-bar.notifications',
|
||||
defaultMessage: 'Notifications',
|
||||
},
|
||||
clearNotifications: {
|
||||
id: 'app.action-bar.notifications.clear',
|
||||
defaultMessage: 'Clear all',
|
||||
},
|
||||
noNotifications: {
|
||||
id: 'app.action-bar.notifications.empty',
|
||||
defaultMessage: 'No notifications',
|
||||
},
|
||||
dismissNotification: {
|
||||
id: 'app.action-bar.notifications.dismiss',
|
||||
defaultMessage: 'Dismiss notification',
|
||||
},
|
||||
downloadingJava: {
|
||||
id: 'app.action-bar.downloading-java',
|
||||
defaultMessage: 'Downloading Java {version}',
|
||||
},
|
||||
downloadingModpack: {
|
||||
id: 'app.downloads.phase.downloading-pack-file',
|
||||
defaultMessage: 'Downloading modpack',
|
||||
},
|
||||
downloads: {
|
||||
id: 'app.action-bar.downloads',
|
||||
defaultMessage: 'Downloads',
|
||||
},
|
||||
viewActiveDownloads: {
|
||||
id: 'app.action-bar.view-active-downloads',
|
||||
defaultMessage: 'View active downloads',
|
||||
},
|
||||
exportingModpack: {
|
||||
id: 'app.action-bar.exporting-modpack',
|
||||
defaultMessage: 'Exporting modpack',
|
||||
},
|
||||
})
|
||||
|
||||
const currentProcesses = ref<RunningProcess[]>([])
|
||||
const selectedProcess = ref<RunningProcess | undefined>()
|
||||
|
||||
const refresh = async () => {
|
||||
const processes = ((await getRunningProcesses().catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})) ?? []) as Array<{ uuid: string; instance_id: string }>
|
||||
const instanceIds = processes.map((process) => process.instance_id)
|
||||
const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
|
||||
currentProcesses.value = processes
|
||||
.map((process) => {
|
||||
const instance = instances.find((item) => process.instance_id === item.id)
|
||||
if (!instance) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...process,
|
||||
instance,
|
||||
}
|
||||
})
|
||||
.filter((process): process is RunningProcess => process !== null)
|
||||
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) {
|
||||
selectedProcess.value = currentProcesses.value[0]
|
||||
}
|
||||
}
|
||||
|
||||
await refresh()
|
||||
|
||||
const { offline } = useNetworkStatus()
|
||||
|
||||
const unlistenProcess = await process_listener(async () => {
|
||||
await refresh()
|
||||
})
|
||||
|
||||
const stop = async (process: RunningProcess) => {
|
||||
try {
|
||||
await killProcess(process.uuid).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: process.instance.loader,
|
||||
game_version: process.instance.game_version,
|
||||
source: 'AppBar',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function goToTerminal(instanceId?: string) {
|
||||
const selectedInstanceId = instanceId ?? selectedProcess.value?.instance.id
|
||||
if (!selectedInstanceId) {
|
||||
return
|
||||
}
|
||||
router.push(`/instance/${encodeURIComponent(selectedInstanceId)}/logs`)
|
||||
}
|
||||
|
||||
const currentLoadingBars = ref<LoadingBar[]>([])
|
||||
const currentLoadingBarIconUrls = ref<Record<string, string | null>>({})
|
||||
const notificationId = ref<string | number | null>(null)
|
||||
const dismissed = ref(false)
|
||||
|
||||
function getLoadingBarKey(loadingBar: LoadingBar): string {
|
||||
return `${loadingBar.loading_bar_uuid ?? loadingBar.id}`
|
||||
}
|
||||
|
||||
function getLoadingProgress(loadingBar: LoadingBar): number {
|
||||
if (!loadingBar.total || loadingBar.total <= 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.max(0, Math.min(1, (loadingBar.current ?? 0) / (loadingBar.total ?? 0)))
|
||||
}
|
||||
|
||||
function getLoadingText(loadingBar: LoadingBar): string {
|
||||
return loadingBar.message ?? ''
|
||||
}
|
||||
|
||||
function getDisplayIconUrl(icon: string | null | undefined): string | null {
|
||||
if (!icon) {
|
||||
return null
|
||||
}
|
||||
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) {
|
||||
return icon
|
||||
}
|
||||
return convertFileSrc(icon)
|
||||
}
|
||||
|
||||
function getNotification(): PopupNotification | null {
|
||||
if (!notificationId.value) {
|
||||
return null
|
||||
}
|
||||
const notification = popupNotificationManager
|
||||
.getNotifications()
|
||||
.find((notification) => notification.id === notificationId.value)
|
||||
return notification ?? null
|
||||
}
|
||||
|
||||
function collapseNotification(): void {
|
||||
if (!notificationId.value) {
|
||||
return
|
||||
}
|
||||
popupNotificationManager.collapseNotification(notificationId.value)
|
||||
}
|
||||
|
||||
function removeNotification(): void {
|
||||
if (!notificationId.value) {
|
||||
return
|
||||
}
|
||||
popupNotificationManager.removeNotification(notificationId.value)
|
||||
notificationId.value = null
|
||||
}
|
||||
|
||||
function buildDownloadItems(): PopupNotificationProgressItem[] {
|
||||
return [
|
||||
...installJobNotifications.progressItems.value,
|
||||
...currentLoadingBars.value.map((bar) => {
|
||||
const isPackDownload = bar.bar_type?.type === 'pack_download'
|
||||
return {
|
||||
id: getLoadingBarKey(bar),
|
||||
title: bar.title ?? '',
|
||||
text: getLoadingText(bar),
|
||||
iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null,
|
||||
progress: getLoadingProgress(bar),
|
||||
waiting: !bar.total || bar.total <= 0,
|
||||
// Pack downloads report file counts, so prefer count UI over raw percentage.
|
||||
progressType: isPackDownload ? 'count' : 'percentage',
|
||||
progressCurrent: bar.current,
|
||||
progressTotal: bar.total,
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
const hasVisibleActiveDownloadToasts = computed(() => {
|
||||
const notification = getNotification()
|
||||
return !!notification && !notification.collapsed
|
||||
})
|
||||
const hasActiveDownloads = computed(
|
||||
() =>
|
||||
installJobNotifications.active.value ||
|
||||
currentLoadingBars.value.some((bar) => downloadBarTypes.has(bar.bar_type?.type ?? '')),
|
||||
)
|
||||
const hasDownloadNotificationItems = computed(
|
||||
() => installJobNotifications.hasItems.value || currentLoadingBars.value.length > 0,
|
||||
)
|
||||
|
||||
function updateNotification(resummon = false): void {
|
||||
const shouldResummon = resummon && !isDownloadsPage.value
|
||||
if (shouldResummon) {
|
||||
dismissed.value = false
|
||||
}
|
||||
|
||||
if (!hasDownloadNotificationItems.value) {
|
||||
removeNotification()
|
||||
dismissed.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (notificationId.value && !getNotification()) {
|
||||
notificationId.value = null
|
||||
dismissed.value = true
|
||||
}
|
||||
|
||||
if (dismissed.value && !shouldResummon) {
|
||||
return
|
||||
}
|
||||
|
||||
let notif = getNotification()
|
||||
if (notif?.collapsed && shouldResummon) {
|
||||
notif.collapsed = false
|
||||
}
|
||||
const progressItems = buildDownloadItems()
|
||||
|
||||
if (notif) {
|
||||
notif.title = installJobNotifications.hasItems.value
|
||||
? installJobNotifications.title.value
|
||||
: formatMessage(messages.downloads)
|
||||
notif.text = undefined
|
||||
notif.progressItems = progressItems
|
||||
notif.buttons = installJobNotifications.buttons.value
|
||||
notif.onClick = hasDownloadNotificationItems.value ? goToDownloads : undefined
|
||||
notif.progress = undefined
|
||||
notif.waiting = undefined
|
||||
notif.autoCloseMs =
|
||||
progressItems.length > 0 && progressItems.every((item) => item.showProgress === false)
|
||||
? 30 * 1000
|
||||
: null
|
||||
if (!notif.collapsed) popupNotificationManager.setNotificationTimer(notif)
|
||||
} else {
|
||||
notif = popupNotificationManager.addPopupNotification({
|
||||
title: installJobNotifications.hasItems.value
|
||||
? installJobNotifications.title.value
|
||||
: formatMessage(messages.downloads),
|
||||
type: 'download',
|
||||
autoCloseMs: null,
|
||||
progressItems,
|
||||
buttons: installJobNotifications.buttons.value,
|
||||
onClick: hasDownloadNotificationItems.value ? goToDownloads : undefined,
|
||||
})
|
||||
notificationId.value = notif.id
|
||||
if (isDownloadsPage.value) {
|
||||
popupNotificationManager.collapseNotification(notif.id)
|
||||
}
|
||||
if (progressItems.length > 0 && progressItems.every((item) => item.showProgress === false)) {
|
||||
notif.autoCloseMs = 30 * 1000
|
||||
popupNotificationManager.setNotificationTimer(notif)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatLoadingBars(loadingBar: LoadingBar): LoadingBar {
|
||||
const formatted = { ...loadingBar }
|
||||
if (formatted.bar_type?.type === 'java_download') {
|
||||
formatted.title = formatMessage(messages.downloadingJava, {
|
||||
version: formatted.bar_type.version,
|
||||
})
|
||||
}
|
||||
if (formatted.bar_type?.type === 'pack_file_download') {
|
||||
formatted.message = formatMessage(messages.downloadingModpack)
|
||||
}
|
||||
if (formatted.bar_type?.instance_id) {
|
||||
formatted.title = formatted.bar_type.instance_name ?? formatted.bar_type.instance_id
|
||||
}
|
||||
if (formatted.bar_type?.type === 'zip_extract') {
|
||||
formatted.title = formatMessage(messages.exportingModpack)
|
||||
}
|
||||
if (formatted.bar_type?.pack_name) {
|
||||
formatted.title = formatted.bar_type.pack_name
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
function isVisibleLoadingBar(loadingBar: LoadingBar): boolean {
|
||||
return (
|
||||
loadingBar.bar_type?.type !== 'launcher_update' &&
|
||||
[
|
||||
'java_download',
|
||||
'pack_file_download',
|
||||
'pack_download',
|
||||
'minecraft_download',
|
||||
'copy_instance',
|
||||
'zip_extract',
|
||||
].includes(loadingBar.bar_type?.type ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
function applyLoadingEvent(payload: LoadingEventPayload): boolean {
|
||||
const key = payload.loader_uuid
|
||||
const index = currentLoadingBars.value.findIndex((bar) => getLoadingBarKey(bar) === key)
|
||||
|
||||
if (payload.fraction === null) {
|
||||
if (index >= 0) {
|
||||
currentLoadingBars.value.splice(index, 1)
|
||||
const { [key]: _removedIcon, ...remainingIcons } = currentLoadingBarIconUrls.value
|
||||
currentLoadingBarIconUrls.value = remainingIcons
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const loadingBar = formatLoadingBars({
|
||||
loading_bar_uuid: payload.loader_uuid,
|
||||
message: payload.message,
|
||||
current: payload.fraction,
|
||||
total: 1,
|
||||
bar_type: payload.event,
|
||||
})
|
||||
if (!isVisibleLoadingBar(loadingBar)) return false
|
||||
|
||||
if (index >= 0) {
|
||||
currentLoadingBars.value.splice(index, 1, loadingBar)
|
||||
} else {
|
||||
currentLoadingBars.value.push(loadingBar)
|
||||
}
|
||||
currentLoadingBarIconUrls.value[key] = getDisplayIconUrl(payload.event?.icon)
|
||||
return index < 0
|
||||
}
|
||||
|
||||
async function refreshLoadingBars() {
|
||||
const bars: Record<string, LoadingBar> = await progress_bars_list().catch((error) => {
|
||||
handleError(error)
|
||||
return {}
|
||||
})
|
||||
|
||||
currentLoadingBars.value = Object.values(bars).map(formatLoadingBars).filter(isVisibleLoadingBar)
|
||||
|
||||
const instanceIds = Array.from(
|
||||
new Set(
|
||||
currentLoadingBars.value
|
||||
.map((bar) => bar.bar_type?.instance_id)
|
||||
.filter((instanceId): instanceId is string => !!instanceId),
|
||||
),
|
||||
)
|
||||
const instances = instanceIds.length
|
||||
? await getInstances(instanceIds).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
: []
|
||||
const instanceIconUrls = new Map(
|
||||
instances.map((instance) => [instance.id, getDisplayIconUrl(instance.icon_path)]),
|
||||
)
|
||||
currentLoadingBarIconUrls.value = Object.fromEntries(
|
||||
currentLoadingBars.value.map((bar) => {
|
||||
const barIconUrl = getDisplayIconUrl(bar.bar_type?.icon)
|
||||
const instanceIconUrl = bar.bar_type?.instance_id
|
||||
? instanceIconUrls.get(bar.bar_type.instance_id)
|
||||
: null
|
||||
return [getLoadingBarKey(bar), barIconUrl ?? instanceIconUrl ?? null]
|
||||
}),
|
||||
)
|
||||
|
||||
currentLoadingBars.value.sort((a, b) => {
|
||||
const aKey = `${a.loading_bar_uuid ?? a.id ?? ''}`
|
||||
const bKey = `${b.loading_bar_uuid ?? b.id ?? ''}`
|
||||
return aKey.localeCompare(bKey)
|
||||
})
|
||||
|
||||
updateNotification()
|
||||
}
|
||||
|
||||
const installJobNotifications = await useInstallJobNotifications({
|
||||
router,
|
||||
manager: downloadManager,
|
||||
handleError,
|
||||
onChange: updateNotification,
|
||||
})
|
||||
|
||||
await refreshLoadingBars()
|
||||
|
||||
let newBarDuringWindow = false
|
||||
let loadingNotificationTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const unlistenLoading = await loading_listener((payload: LoadingEventPayload) => {
|
||||
const isNewBar = applyLoadingEvent(payload)
|
||||
if (isNewBar) {
|
||||
newBarDuringWindow = true
|
||||
}
|
||||
if (loadingNotificationTimer !== null) {
|
||||
return
|
||||
}
|
||||
loadingNotificationTimer = setTimeout(() => {
|
||||
loadingNotificationTimer = null
|
||||
if (newBarDuringWindow) {
|
||||
newBarDuringWindow = false
|
||||
if (isDownloadsPage.value) {
|
||||
updateNotification()
|
||||
} else {
|
||||
removeNotification()
|
||||
updateNotification(true)
|
||||
}
|
||||
} else {
|
||||
updateNotification()
|
||||
}
|
||||
}, 250)
|
||||
})
|
||||
|
||||
function goToDownloads() {
|
||||
router.push('/downloads')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
if (isDownloadsPage.value) {
|
||||
collapseNotification()
|
||||
}
|
||||
updateNotification()
|
||||
},
|
||||
)
|
||||
|
||||
function selectProcess(process: RunningProcess) {
|
||||
selectedProcess.value = process
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (loadingNotificationTimer !== null) {
|
||||
clearTimeout(loadingNotificationTimer)
|
||||
loadingNotificationTimer = null
|
||||
}
|
||||
removeNotification()
|
||||
dismissed.value = false
|
||||
unlistenProcess()
|
||||
unlistenLoading()
|
||||
installJobNotifications.dispose()
|
||||
})
|
||||
</script>
|
||||
14
apps/app-frontend/src/components/ui/AxolotlLogo.vue
Normal file
14
apps/app-frontend/src/components/ui/AxolotlLogo.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div aria-label="Axolotl" class="flex h-full items-center gap-2 font-extrabold text-contrast">
|
||||
<img aria-hidden="true" class="aspect-square h-full object-contain" :src="axolotlVisual" />
|
||||
<span v-if="!iconOnly" class="hidden text-sm tracking-wide xl:inline">Axolotl</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import axolotlVisual from '@modrinth/assets/branding/axolotl.png'
|
||||
|
||||
defineProps<{
|
||||
iconOnly?: boolean
|
||||
}>()
|
||||
</script>
|
||||
343
apps/app-frontend/src/components/ui/Breadcrumbs.vue
Normal file
343
apps/app-frontend/src/components/ui/Breadcrumbs.vue
Normal file
@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<div
|
||||
ref="outerRef"
|
||||
data-tauri-drag-region
|
||||
class="min-w-0 overflow-hidden pl-3"
|
||||
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
|
||||
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<div
|
||||
ref="innerRef"
|
||||
data-tauri-drag-region
|
||||
class="flex w-fit items-center gap-1"
|
||||
:class="{ 'breadcrumbs-scroll': isAnimating }"
|
||||
@animationiteration="onAnimationIteration"
|
||||
>
|
||||
<template v-for="(breadcrumb, index) in breadcrumbs" :key="breadcrumb.name">
|
||||
<router-link
|
||||
v-if="breadcrumb.link"
|
||||
:to="{
|
||||
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
|
||||
query: breadcrumb.query,
|
||||
}"
|
||||
class="flex shrink-0 items-center gap-1 whitespace-nowrap text-primary"
|
||||
>
|
||||
<Avatar
|
||||
v-if="resolveIconUrl(breadcrumb)"
|
||||
:src="resolveIconUrl(breadcrumb)"
|
||||
:alt="resolveLabel(breadcrumb.name)"
|
||||
size="20px"
|
||||
no-shadow
|
||||
raised
|
||||
class="shrink-0 !rounded-md"
|
||||
/>
|
||||
<component
|
||||
:is="resolveIcon(breadcrumb)"
|
||||
v-else-if="resolveIcon(breadcrumb)"
|
||||
class="size-5 shrink-0 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ resolveLabel(breadcrumb.name) }}
|
||||
</router-link>
|
||||
<span
|
||||
v-else
|
||||
data-tauri-drag-region
|
||||
class="flex shrink-0 items-center gap-1 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
|
||||
>
|
||||
<Avatar
|
||||
v-if="resolveIconUrl(breadcrumb)"
|
||||
:src="resolveIconUrl(breadcrumb)"
|
||||
:alt="resolveLabel(breadcrumb.name)"
|
||||
size="20px"
|
||||
no-shadow
|
||||
raised
|
||||
class="shrink-0 !rounded-md"
|
||||
/>
|
||||
<component
|
||||
:is="resolveIcon(breadcrumb)"
|
||||
v-else-if="resolveIcon(breadcrumb)"
|
||||
class="size-5 shrink-0 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ resolveLabel(breadcrumb.name) }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="index < breadcrumbs.length - 1"
|
||||
data-tauri-drag-region
|
||||
class="w-5 h-5 shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
ChangeSkinIcon,
|
||||
ChevronRightIcon,
|
||||
CodeIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
FlaskConicalIcon,
|
||||
FolderIcon,
|
||||
GlobeIcon,
|
||||
HeartIcon,
|
||||
HomeIcon,
|
||||
ImagesIcon,
|
||||
LibraryIcon,
|
||||
MapIcon,
|
||||
PackageIcon,
|
||||
PencilIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { type Component, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { resolveBreadcrumbLabel } from '@/helpers/breadcrumb-label'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
|
||||
interface Breadcrumb {
|
||||
name: string
|
||||
link?: string
|
||||
query?: Record<string, string>
|
||||
iconUrl?: string | null
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const breadcrumbData = useBreadcrumbs()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
home: { id: 'app.navigation.home', defaultMessage: 'Home' },
|
||||
worlds: { id: 'app.navigation.worlds', defaultMessage: 'Worlds' },
|
||||
discoverContent: {
|
||||
id: 'app.navigation.discover-content',
|
||||
defaultMessage: 'Discover content',
|
||||
},
|
||||
skinSelector: { id: 'app.navigation.skin-selector', defaultMessage: 'Skin selector' },
|
||||
multiplayer: { id: 'app.navigation.multiplayer', defaultMessage: 'Multiplayer' },
|
||||
library: { id: 'app.navigation.library', defaultMessage: 'Library' },
|
||||
downloads: { id: 'app.navigation.downloads', defaultMessage: 'Downloads' },
|
||||
lab: { id: 'app.navigation.lab', defaultMessage: 'Lab' },
|
||||
gradientText: {
|
||||
id: 'app.lab.gradient-text.title',
|
||||
defaultMessage: 'Gradient text generator',
|
||||
},
|
||||
seedMap: { id: 'app.lab.seed-map.title', defaultMessage: 'Seed map' },
|
||||
schematicWorkshop: {
|
||||
id: 'app.lab.schematic-preview.title',
|
||||
defaultMessage: 'Schematic workshop',
|
||||
},
|
||||
modTranslation: {
|
||||
id: 'app.lab.mod-translation.title',
|
||||
defaultMessage: 'Mod translation',
|
||||
},
|
||||
skinEditor: { id: 'app.lab.skin-editor.title', defaultMessage: 'Skin editor' },
|
||||
content: { id: 'app.instance.tabs.content', defaultMessage: 'Content' },
|
||||
files: { id: 'app.instance.tabs.files', defaultMessage: 'Files' },
|
||||
studio: { id: 'instance.files.studio.title', defaultMessage: 'Studio' },
|
||||
logs: { id: 'app.instance.tabs.logs', defaultMessage: 'Logs' },
|
||||
editWorld: { id: 'app.navigation.edit-world', defaultMessage: 'Edit world' },
|
||||
upgradeInstance: { id: 'app.instance.upgrade-instance', defaultMessage: 'Upgrade instance' },
|
||||
})
|
||||
|
||||
const staticLabels = {
|
||||
Home: messages.home,
|
||||
Worlds: messages.worlds,
|
||||
'Discover content': messages.discoverContent,
|
||||
'Skin selector': messages.skinSelector,
|
||||
Multiplayer: messages.multiplayer,
|
||||
Library: messages.library,
|
||||
Downloads: messages.downloads,
|
||||
Settings: commonMessages.settingsLabel,
|
||||
Lab: messages.lab,
|
||||
'Gradient text generator': messages.gradientText,
|
||||
'Seed map': messages.seedMap,
|
||||
'Schematic workshop': messages.schematicWorkshop,
|
||||
'Mod translation': messages.modTranslation,
|
||||
'Skin editor': messages.skinEditor,
|
||||
Content: messages.content,
|
||||
Files: messages.files,
|
||||
Studio: messages.studio,
|
||||
Logs: messages.logs,
|
||||
'Edit world': messages.editWorld,
|
||||
Upgrade: messages.upgradeInstance,
|
||||
}
|
||||
|
||||
const staticIcons: Record<string, Component> = {
|
||||
Home: HomeIcon,
|
||||
Worlds: GlobeIcon,
|
||||
'Discover content': CompassIcon,
|
||||
'Skin selector': ChangeSkinIcon,
|
||||
Multiplayer: ServerIcon,
|
||||
Library: LibraryIcon,
|
||||
Downloads: DownloadIcon,
|
||||
Settings: SettingsIcon,
|
||||
Lab: FlaskConicalIcon,
|
||||
'Gradient text generator': FlaskConicalIcon,
|
||||
'Seed map': MapIcon,
|
||||
'Schematic workshop': CodeIcon,
|
||||
'Mod translation': CodeIcon,
|
||||
'Skin editor': PencilIcon,
|
||||
Content: PackageIcon,
|
||||
Files: FolderIcon,
|
||||
Studio: CodeIcon,
|
||||
Logs: FileTextIcon,
|
||||
'Edit world': PencilIcon,
|
||||
Upgrade: ArrowBigUpDashIcon,
|
||||
Favorites: HeartIcon,
|
||||
Versions: PackageIcon,
|
||||
Gallery: ImagesIcon,
|
||||
Screenshots: ImagesIcon,
|
||||
'Drop help': FileTextIcon,
|
||||
'Recipe generator': FlaskConicalIcon,
|
||||
Downloaded: DownloadIcon,
|
||||
Modpacks: PackageIcon,
|
||||
LibraryServers: ServerIcon,
|
||||
Custom: PackageIcon,
|
||||
Shared: PackageIcon,
|
||||
Saved: HeartIcon,
|
||||
}
|
||||
|
||||
const breadcrumbs = computed<Breadcrumb[]>(() => {
|
||||
const additionalContext =
|
||||
route.meta.useContext === true
|
||||
? breadcrumbData.context
|
||||
: route.meta.useRootContext === true
|
||||
? breadcrumbData.rootContext
|
||||
: null
|
||||
const crumbs = (route.meta.breadcrumb ?? []) as Breadcrumb[]
|
||||
if (
|
||||
additionalContext?.name.startsWith('?') &&
|
||||
crumbs.some((crumb) => crumb.name === additionalContext.name)
|
||||
) {
|
||||
return crumbs
|
||||
}
|
||||
return additionalContext ? [additionalContext as Breadcrumb, ...crumbs] : crumbs
|
||||
})
|
||||
|
||||
function resolveLabel(name: string): string {
|
||||
return resolveBreadcrumbLabel(
|
||||
name,
|
||||
(key) => breadcrumbData.getName(key),
|
||||
staticLabels,
|
||||
(message) => formatMessage(message),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveIcon(breadcrumb: Breadcrumb): Component | undefined {
|
||||
if (breadcrumb.iconUrl || breadcrumbData.getIcon(breadcrumb.name.slice(1))) return undefined
|
||||
const dynamicIcons: Record<string, Component> = {
|
||||
'?Project': PackageIcon,
|
||||
'?Version': PackageIcon,
|
||||
'?BrowseTitle': CompassIcon,
|
||||
'?FavoritesTitle': HeartIcon,
|
||||
}
|
||||
if (dynamicIcons[breadcrumb.name]) return dynamicIcons[breadcrumb.name]
|
||||
const key = breadcrumb.name.startsWith('?') ? resolveLabel(breadcrumb.name) : breadcrumb.name
|
||||
return staticIcons[key]
|
||||
}
|
||||
|
||||
function resolveIconUrl(breadcrumb: Breadcrumb): string | null {
|
||||
return (
|
||||
breadcrumb.iconUrl ??
|
||||
(breadcrumb.name.startsWith('?') ? breadcrumbData.getIcon(breadcrumb.name.slice(1)) : null)
|
||||
)
|
||||
}
|
||||
|
||||
// Overflow detection
|
||||
const outerRef = ref<HTMLDivElement | null>(null)
|
||||
const innerRef = ref<HTMLDivElement | null>(null)
|
||||
const isOverflowing = ref(false)
|
||||
const isAnimating = ref(false)
|
||||
const overflowAmount = ref(0)
|
||||
|
||||
let hovered = false
|
||||
let stopping = false
|
||||
|
||||
function checkOverflow() {
|
||||
if (!outerRef.value || !innerRef.value) return
|
||||
const overflow = innerRef.value.scrollWidth - outerRef.value.clientWidth
|
||||
isOverflowing.value = overflow > 0
|
||||
overflowAmount.value = overflow + 12
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
hovered = true
|
||||
stopping = false
|
||||
if (isOverflowing.value) {
|
||||
isAnimating.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
hovered = false
|
||||
if (isAnimating.value) {
|
||||
stopping = true
|
||||
}
|
||||
}
|
||||
|
||||
function onAnimationIteration() {
|
||||
if (stopping && !hovered) {
|
||||
isAnimating.value = false
|
||||
stopping = false
|
||||
}
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
checkOverflow()
|
||||
resizeObserver = new ResizeObserver(checkOverflow)
|
||||
if (outerRef.value) resizeObserver.observe(outerRef.value)
|
||||
if (innerRef.value) resizeObserver.observe(innerRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
watch(
|
||||
breadcrumbs,
|
||||
() => {
|
||||
breadcrumbData.resetToNames(breadcrumbs.value)
|
||||
requestAnimationFrame(checkOverflow)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumb-fade-mask {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 12px,
|
||||
black calc(100% - 12px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.breadcrumbs-scroll {
|
||||
animation: breadcrumb-scroll 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes breadcrumb-scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
35%,
|
||||
65% {
|
||||
transform: translateX(var(--scroll-distance));
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,725 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, XIcon } from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled, Checkbox, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { getActiveDependencyConflictIdentities } from '@/providers/content-selection-logic'
|
||||
|
||||
export interface ContentInstallPreviewDependency {
|
||||
id: string
|
||||
title: string
|
||||
iconUrl?: string | null
|
||||
versionNumber?: string
|
||||
fileName?: string
|
||||
description?: string
|
||||
projectUrl?: string
|
||||
requiredBy: string[]
|
||||
alreadyInstalled: boolean
|
||||
status?: 'installed' | 'included'
|
||||
versionMismatch?: boolean
|
||||
selectionReason?: string
|
||||
required?: boolean
|
||||
requiredByKeys?: string[]
|
||||
}
|
||||
|
||||
export interface ContentInstallPreviewSkipped {
|
||||
id: string
|
||||
title: string
|
||||
reason: string
|
||||
requiredByKeys?: string[]
|
||||
}
|
||||
|
||||
export interface ContentInstallPreviewData {
|
||||
primary?: ContentInstallPreviewPrimary
|
||||
primaries?: ContentInstallPreviewPrimary[]
|
||||
instanceName: string
|
||||
installDependencies: boolean
|
||||
dependencies: ContentInstallPreviewDependency[]
|
||||
skipped: ContentInstallPreviewSkipped[]
|
||||
}
|
||||
|
||||
export interface ContentInstallPreviewPrimary {
|
||||
key?: string
|
||||
title: string
|
||||
iconUrl?: string | null
|
||||
versionNumber?: string
|
||||
provider?: string
|
||||
contentType?: string
|
||||
error?: string
|
||||
conflictIdentities?: string[]
|
||||
removable?: boolean
|
||||
}
|
||||
|
||||
export interface ContentInstallBatchPreviewResult {
|
||||
approvedIds: string[]
|
||||
primaryKeys: string[]
|
||||
}
|
||||
|
||||
export interface ContentInstallConflictPrompt {
|
||||
candidate: {
|
||||
title: string
|
||||
provider: string
|
||||
contentType: string
|
||||
iconUrl?: string | null
|
||||
}
|
||||
existing: Array<{
|
||||
title: string
|
||||
provider: string
|
||||
fileName?: string
|
||||
}>
|
||||
source: 'heuristic'
|
||||
confidence: 'high' | 'possible'
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.content-install.preview.header',
|
||||
defaultMessage: 'Confirm installation',
|
||||
},
|
||||
description: {
|
||||
id: 'app.content-install.preview.description',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# dependency will be installed automatically} other {# dependencies will be installed automatically}} for {project} in {instance}.',
|
||||
},
|
||||
batchDescription: {
|
||||
id: 'app.content-install.preview.batch-description',
|
||||
defaultMessage:
|
||||
'Review {projectCount, plural, one {# project} other {# projects}} and {dependencyCount, plural, one {# dependency} other {# dependencies}} for {instance}.',
|
||||
},
|
||||
selectedContentHeader: {
|
||||
id: 'app.content-install.preview.selected-content-header',
|
||||
defaultMessage: 'Selected content',
|
||||
},
|
||||
removeProject: {
|
||||
id: 'app.content-install.preview.remove-project',
|
||||
defaultMessage: 'Remove {project} from this installation',
|
||||
},
|
||||
dependenciesHeader: {
|
||||
id: 'app.content-install.preview.dependencies-header',
|
||||
defaultMessage: 'Dependencies',
|
||||
},
|
||||
dependenciesCount: {
|
||||
id: 'app.content-install.preview.dependencies-count',
|
||||
defaultMessage: '{count, plural, one {# dependency} other {# dependencies}}',
|
||||
},
|
||||
requiredDependenciesHeader: {
|
||||
id: 'app.content-install.preview.required-dependencies-header',
|
||||
defaultMessage: 'Required dependencies',
|
||||
},
|
||||
optionalDependenciesHeader: {
|
||||
id: 'app.content-install.preview.optional-dependencies-header',
|
||||
defaultMessage: 'Optional dependencies',
|
||||
},
|
||||
requiredBy: {
|
||||
id: 'app.content-install.preview.required-by',
|
||||
defaultMessage: 'Required by {projects}',
|
||||
},
|
||||
alreadyInstalled: {
|
||||
id: 'app.content-install.preview.already-installed',
|
||||
defaultMessage: 'Already installed',
|
||||
},
|
||||
alreadyIncluded: {
|
||||
id: 'app.content-install.preview.already-included',
|
||||
defaultMessage: 'Already included',
|
||||
},
|
||||
versionMismatch: {
|
||||
id: 'app.content-install.preview.version-mismatch',
|
||||
defaultMessage: 'Version may not match this instance',
|
||||
},
|
||||
skippedHeader: {
|
||||
id: 'app.content-install.preview.skipped-header',
|
||||
defaultMessage: 'Skipped',
|
||||
},
|
||||
onlyChecked: {
|
||||
id: 'app.content-install.preview.only-checked',
|
||||
defaultMessage: 'Only checked dependencies will be installed.',
|
||||
},
|
||||
selectAll: {
|
||||
id: 'app.content-install.preview.select-all',
|
||||
defaultMessage: 'Select all',
|
||||
},
|
||||
clearAll: {
|
||||
id: 'app.content-install.preview.clear-all',
|
||||
defaultMessage: 'Clear all',
|
||||
},
|
||||
cancel: {
|
||||
id: 'app.content-install.preview.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
viewDetails: {
|
||||
id: 'app.content-install.preview.view-details',
|
||||
defaultMessage: 'View details',
|
||||
},
|
||||
openProjectPage: {
|
||||
id: 'app.content-install.preview.open-project-page',
|
||||
defaultMessage: 'Open project page',
|
||||
},
|
||||
descriptionUnavailable: {
|
||||
id: 'app.content-install.preview.description-unavailable',
|
||||
defaultMessage: 'No description available.',
|
||||
},
|
||||
install: {
|
||||
id: 'app.content-install.preview.install',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
installResolved: {
|
||||
id: 'app.content-install.preview.install-resolved',
|
||||
defaultMessage: 'Install resolved content',
|
||||
},
|
||||
conflictHeader: {
|
||||
id: 'app.content-install.preview.conflict-header',
|
||||
defaultMessage: 'Possible duplicate content',
|
||||
},
|
||||
conflictDescription: {
|
||||
id: 'app.content-install.preview.conflict-description',
|
||||
defaultMessage:
|
||||
'{candidate} may be the same content as an installed or selected project. Continue anyway?',
|
||||
},
|
||||
continueAnyway: {
|
||||
id: 'app.content-install.preview.continue-anyway',
|
||||
defaultMessage: 'Install anyway',
|
||||
},
|
||||
existingContent: {
|
||||
id: 'app.content-install.preview.existing-content',
|
||||
defaultMessage: 'Existing content',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const data = ref<ContentInstallPreviewData | null>(null)
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
const expandedDependencyIds = ref<Set<string>>(new Set())
|
||||
const removedPrimaryKeys = ref<Set<string>>(new Set())
|
||||
let settled = false
|
||||
let batchMode = false
|
||||
let conflictMode = false
|
||||
const conflictPrompt = ref<ContentInstallConflictPrompt | null>(null)
|
||||
let resolveShow:
|
||||
| ((result: string[] | ContentInstallBatchPreviewResult | boolean | null) => void)
|
||||
| null = null
|
||||
|
||||
const primaries = computed(() => {
|
||||
if (!data.value) return []
|
||||
if (data.value.primaries?.length) return data.value.primaries
|
||||
return data.value.primary ? [data.value.primary] : []
|
||||
})
|
||||
const visiblePrimaries = computed(() =>
|
||||
primaries.value.filter((primary) => !primary.key || !removedPrimaryKeys.value.has(primary.key)),
|
||||
)
|
||||
const visiblePrimaryKeys = computed(() =>
|
||||
visiblePrimaries.value.map((primary) => primary.key).filter((key): key is string => !!key),
|
||||
)
|
||||
const visiblePrimaryKeySet = computed(() => new Set(visiblePrimaryKeys.value))
|
||||
const visibleDependencies = computed(
|
||||
() =>
|
||||
data.value?.dependencies.filter(
|
||||
(dependency) =>
|
||||
!dependency.requiredByKeys?.length ||
|
||||
dependency.requiredByKeys.some((key) => visiblePrimaryKeySet.value.has(key)),
|
||||
) ?? [],
|
||||
)
|
||||
const activeConflictIdentities = computed(() =>
|
||||
getActiveDependencyConflictIdentities(data.value?.dependencies ?? [], visiblePrimaryKeySet.value),
|
||||
)
|
||||
function primaryError(primary: ContentInstallPreviewPrimary) {
|
||||
if (!primary.error) return null
|
||||
if (!primary.conflictIdentities?.length) return primary.error
|
||||
return primary.conflictIdentities.some((identity) => activeConflictIdentities.value.has(identity))
|
||||
? primary.error
|
||||
: null
|
||||
}
|
||||
const visibleSkipped = computed(
|
||||
() =>
|
||||
data.value?.skipped.filter(
|
||||
(skipped) =>
|
||||
!skipped.requiredByKeys?.length ||
|
||||
skipped.requiredByKeys.some((key) => visiblePrimaryKeySet.value.has(key)),
|
||||
) ?? [],
|
||||
)
|
||||
const hasBlockingPrimary = computed(() =>
|
||||
visiblePrimaries.value.some((primary) => !!primaryError(primary)),
|
||||
)
|
||||
|
||||
const installableDependencies = computed(() => visibleDependencies.value)
|
||||
const dependencyGroups = computed(() =>
|
||||
[
|
||||
{
|
||||
id: 'required',
|
||||
header: messages.requiredDependenciesHeader,
|
||||
dependencies: visibleDependencies.value.filter((dependency) => dependency.required !== false),
|
||||
},
|
||||
{
|
||||
id: 'optional',
|
||||
header: messages.optionalDependenciesHeader,
|
||||
dependencies: visibleDependencies.value.filter((dependency) => dependency.required === false),
|
||||
},
|
||||
].filter((group) => group.dependencies.length > 0),
|
||||
)
|
||||
const selectedInstallableCount = computed(
|
||||
() =>
|
||||
installableDependencies.value.filter((dependency) => selectedIds.value.has(dependency.id))
|
||||
.length,
|
||||
)
|
||||
const hasUnresolvedDependencies = computed(() => visibleSkipped.value.length > 0)
|
||||
|
||||
function toggleDependency(id: string, value: boolean) {
|
||||
const next = new Set(selectedIds.value)
|
||||
if (value) next.add(id)
|
||||
else next.delete(id)
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
function toggleAll(value: boolean) {
|
||||
const next = new Set(selectedIds.value)
|
||||
for (const dependency of installableDependencies.value) {
|
||||
if (value) next.add(dependency.id)
|
||||
else next.delete(dependency.id)
|
||||
}
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
function hasDependencyDetails(dependency: ContentInstallPreviewDependency) {
|
||||
return !!dependency.description || !!dependency.projectUrl
|
||||
}
|
||||
|
||||
function toggleDependencyDetails(id: string) {
|
||||
const next = new Set(expandedDependencyIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
expandedDependencyIds.value = next
|
||||
}
|
||||
|
||||
async function openDependencyPage(dependency: ContentInstallPreviewDependency) {
|
||||
if (!dependency.projectUrl) return
|
||||
await openUrl(dependency.projectUrl)
|
||||
}
|
||||
|
||||
function initialSelectedIds(value: ContentInstallPreviewData) {
|
||||
if (!value.installDependencies) return new Set<string>()
|
||||
return new Set(
|
||||
value.dependencies
|
||||
.filter((dependency) => !dependency.alreadyInstalled && dependency.required !== false)
|
||||
.map((dependency) => dependency.id),
|
||||
)
|
||||
}
|
||||
|
||||
function finish(result: string[] | ContentInstallBatchPreviewResult | boolean | null) {
|
||||
if (settled) return
|
||||
settled = true
|
||||
const resolve = resolveShow
|
||||
resolveShow = null
|
||||
if (resolve) resolve(result)
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (conflictMode) {
|
||||
finish(true)
|
||||
return
|
||||
}
|
||||
if (hasBlockingPrimary.value || visiblePrimaries.value.length === 0) return
|
||||
const approvedIds = visibleDependencies.value
|
||||
.filter((dependency) => selectedIds.value.has(dependency.id))
|
||||
.map((dependency) => dependency.id)
|
||||
finish(batchMode ? { approvedIds, primaryKeys: visiblePrimaryKeys.value } : approvedIds)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
finish(null)
|
||||
}
|
||||
|
||||
function show(value: ContentInstallPreviewData): Promise<string[] | null> {
|
||||
resolveShow?.(null)
|
||||
resolveShow = null
|
||||
data.value = value
|
||||
batchMode = false
|
||||
conflictMode = false
|
||||
conflictPrompt.value = null
|
||||
removedPrimaryKeys.value = new Set()
|
||||
selectedIds.value = initialSelectedIds(value)
|
||||
expandedDependencyIds.value = new Set()
|
||||
settled = false
|
||||
modal.value?.show()
|
||||
return new Promise<string[] | null>((resolve) => {
|
||||
resolveShow = (result) => resolve(Array.isArray(result) ? result : null)
|
||||
})
|
||||
}
|
||||
|
||||
function showBatch(
|
||||
value: ContentInstallPreviewData,
|
||||
): Promise<ContentInstallBatchPreviewResult | null> {
|
||||
resolveShow?.(null)
|
||||
resolveShow = null
|
||||
data.value = value
|
||||
batchMode = true
|
||||
conflictMode = false
|
||||
conflictPrompt.value = null
|
||||
removedPrimaryKeys.value = new Set()
|
||||
selectedIds.value = initialSelectedIds(value)
|
||||
expandedDependencyIds.value = new Set()
|
||||
settled = false
|
||||
modal.value?.show()
|
||||
return new Promise<ContentInstallBatchPreviewResult | null>((resolve) => {
|
||||
resolveShow = (result) =>
|
||||
resolve(result && !Array.isArray(result) && typeof result !== 'boolean' ? result : null)
|
||||
})
|
||||
}
|
||||
|
||||
function showConflict(value: ContentInstallConflictPrompt): Promise<boolean> {
|
||||
resolveShow?.(null)
|
||||
resolveShow = null
|
||||
data.value = null
|
||||
batchMode = false
|
||||
conflictMode = true
|
||||
conflictPrompt.value = value
|
||||
settled = false
|
||||
modal.value?.show()
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveShow = (result) => resolve(result === true)
|
||||
})
|
||||
}
|
||||
|
||||
function removePrimary(key: string) {
|
||||
removedPrimaryKeys.value = new Set([...removedPrimaryKeys.value, key])
|
||||
}
|
||||
|
||||
defineExpose({ show, showBatch, showConflict })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(conflictMode ? messages.conflictHeader : messages.header)"
|
||||
scrollable
|
||||
max-content-height="70vh"
|
||||
width="40rem"
|
||||
max-width="40rem"
|
||||
:on-hide="hide"
|
||||
>
|
||||
<div v-if="conflictPrompt" class="flex flex-col gap-4">
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border border-solid border-warning bg-warning-bg p-3"
|
||||
>
|
||||
<Avatar
|
||||
:src="conflictPrompt.candidate.iconUrl"
|
||||
:alt="conflictPrompt.candidate.title"
|
||||
size="2.5rem"
|
||||
:tint-by="conflictPrompt.candidate.title"
|
||||
no-shadow
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block truncate font-semibold text-contrast">{{
|
||||
conflictPrompt.candidate.title
|
||||
}}</span>
|
||||
<span class="block truncate text-sm text-secondary">
|
||||
{{
|
||||
[conflictPrompt.candidate.provider, conflictPrompt.candidate.contentType].join(' · ')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0 text-primary">
|
||||
{{
|
||||
formatMessage(messages.conflictDescription, {
|
||||
candidate: conflictPrompt.candidate.title,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.existingContent)
|
||||
}}</span>
|
||||
<div
|
||||
v-for="item in conflictPrompt.existing"
|
||||
:key="`${item.provider}:${item.title}:${item.fileName ?? ''}`"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-solid border-surface-4 bg-surface-2 px-3 py-2"
|
||||
>
|
||||
<span class="min-w-0 truncate font-medium text-contrast">{{ item.title }}</span>
|
||||
<span class="shrink-0 text-sm text-secondary">{{ item.provider }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="data" class="flex min-w-0 flex-col gap-4">
|
||||
<div v-if="batchMode" class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.selectedContentHeader)
|
||||
}}</span>
|
||||
<div
|
||||
v-for="primary in visiblePrimaries"
|
||||
:key="primary.key ?? primary.title"
|
||||
class="flex items-center gap-3 rounded-lg border border-solid border-surface-4 bg-surface-2 p-3"
|
||||
>
|
||||
<Avatar
|
||||
:src="primary.iconUrl"
|
||||
:alt="primary.title"
|
||||
size="2.5rem"
|
||||
:tint-by="primary.title"
|
||||
no-shadow
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="truncate font-semibold text-contrast">{{ primary.title }}</span>
|
||||
<span class="truncate text-sm text-secondary">
|
||||
{{
|
||||
[primary.versionNumber, primary.provider, primary.contentType]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}}
|
||||
</span>
|
||||
<span v-if="primaryError(primary)" class="text-sm text-red">
|
||||
{{ primaryError(primary) }}
|
||||
</span>
|
||||
</div>
|
||||
<ButtonStyled v-if="primary.removable && primary.key" circular type="transparent">
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.removeProject, { project: primary.title })"
|
||||
@click="removePrimary(primary.key)"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="visiblePrimaries[0]"
|
||||
class="flex items-center gap-3 rounded-lg border border-solid border-surface-4 bg-surface-2 p-3"
|
||||
>
|
||||
<Avatar
|
||||
:src="visiblePrimaries[0].iconUrl"
|
||||
:alt="visiblePrimaries[0].title"
|
||||
size="2.5rem"
|
||||
:tint-by="visiblePrimaries[0].title"
|
||||
no-shadow
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="truncate font-semibold text-contrast">{{ visiblePrimaries[0].title }}</span>
|
||||
<span v-if="visiblePrimaries[0].versionNumber" class="truncate text-sm text-secondary">
|
||||
{{ visiblePrimaries[0].versionNumber }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-primary">
|
||||
{{
|
||||
batchMode
|
||||
? formatMessage(messages.batchDescription, {
|
||||
projectCount: visiblePrimaries.length,
|
||||
dependencyCount: visibleDependencies.length,
|
||||
instance: data.instanceName,
|
||||
})
|
||||
: formatMessage(messages.description, {
|
||||
count: selectedInstallableCount,
|
||||
project: visiblePrimaries[0]?.title ?? '',
|
||||
instance: data.instanceName,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div v-if="visibleDependencies.length > 0" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center gap-2 font-semibold text-contrast">
|
||||
{{ formatMessage(messages.dependenciesHeader) }}
|
||||
<span
|
||||
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium tabular-nums text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.dependenciesCount, { count: visibleDependencies.length }) }}
|
||||
</span>
|
||||
</span>
|
||||
<ButtonStyled v-if="installableDependencies.length > 1" size="small" type="transparent">
|
||||
<button @click="toggleAll(selectedInstallableCount !== installableDependencies.length)">
|
||||
{{
|
||||
selectedInstallableCount === installableDependencies.length
|
||||
? formatMessage(messages.clearAll)
|
||||
: formatMessage(messages.selectAll)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-3"
|
||||
:class="{ 'sm:grid-cols-2': dependencyGroups.length > 1 }"
|
||||
>
|
||||
<div
|
||||
v-for="group in dependencyGroups"
|
||||
:key="group.id"
|
||||
class="flex min-w-0 flex-col gap-2"
|
||||
>
|
||||
<span class="flex items-center gap-2 font-semibold text-contrast">
|
||||
{{ formatMessage(group.header) }}
|
||||
<span
|
||||
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium tabular-nums text-secondary"
|
||||
>
|
||||
{{ group.dependencies.length }}
|
||||
</span>
|
||||
</span>
|
||||
<div
|
||||
v-for="dependency in group.dependencies"
|
||||
:key="dependency.id"
|
||||
class="flex w-full min-w-0 flex-col overflow-hidden rounded-xl border border-solid border-surface-4 bg-surface-2"
|
||||
:class="{ 'opacity-60': dependency.alreadyInstalled }"
|
||||
>
|
||||
<div class="flex items-start gap-3 p-3">
|
||||
<Checkbox
|
||||
:model-value="selectedIds.has(dependency.id)"
|
||||
class="mt-2 shrink-0"
|
||||
@update:model-value="(value) => toggleDependency(dependency.id, value)"
|
||||
/>
|
||||
<Avatar
|
||||
:src="dependency.iconUrl"
|
||||
:alt="dependency.title"
|
||||
size="2.5rem"
|
||||
:tint-by="dependency.title"
|
||||
no-shadow
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<button
|
||||
v-if="hasDependencyDetails(dependency)"
|
||||
type="button"
|
||||
class="group flex w-full min-w-0 cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left"
|
||||
:aria-expanded="expandedDependencyIds.has(dependency.id)"
|
||||
:aria-label="formatMessage(messages.viewDetails, { project: dependency.title })"
|
||||
@click="toggleDependencyDetails(dependency.id)"
|
||||
>
|
||||
<span
|
||||
v-tooltip="
|
||||
dependency.description
|
||||
? {
|
||||
content: dependency.description,
|
||||
placement: 'top',
|
||||
popperClass: 'preview-dependency-tooltip',
|
||||
}
|
||||
: null
|
||||
"
|
||||
class="min-w-0 truncate font-semibold text-contrast group-hover:underline"
|
||||
>
|
||||
{{ dependency.title }}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
class="shrink-0 text-secondary transition-transform duration-150"
|
||||
:class="{
|
||||
'rotate-180': expandedDependencyIds.has(dependency.id),
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<span v-else class="truncate font-semibold text-contrast">
|
||||
{{ dependency.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="dependency.versionNumber"
|
||||
class="min-w-0 truncate text-sm text-secondary"
|
||||
>
|
||||
{{ dependency.versionNumber }}
|
||||
</span>
|
||||
<span
|
||||
v-if="dependency.requiredBy.length > 0"
|
||||
class="min-w-0 truncate text-sm text-secondary"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.requiredBy, {
|
||||
projects: dependency.requiredBy.join(', '),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-1 pt-1">
|
||||
<span
|
||||
v-if="dependency.versionMismatch"
|
||||
class="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-medium text-warning-text"
|
||||
>
|
||||
{{ formatMessage(messages.versionMismatch) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="dependency.selectionReason"
|
||||
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium text-secondary"
|
||||
>
|
||||
{{ dependency.selectionReason }}
|
||||
</span>
|
||||
<span
|
||||
v-if="dependency.alreadyInstalled"
|
||||
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium text-secondary"
|
||||
>
|
||||
{{
|
||||
formatMessage(
|
||||
dependency.status === 'included'
|
||||
? messages.alreadyIncluded
|
||||
: messages.alreadyInstalled,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="expandedDependencyIds.has(dependency.id)"
|
||||
class="mb-3 flex w-auto min-w-0 flex-col gap-2.5 rounded-lg bg-surface-1 px-3 py-2.5 mx-3"
|
||||
>
|
||||
<p
|
||||
v-if="dependency.description"
|
||||
class="m-0 w-full min-w-0 text-sm leading-relaxed text-secondary [overflow-wrap:anywhere]"
|
||||
>
|
||||
{{ dependency.description }}
|
||||
</p>
|
||||
<p v-else class="m-0 w-full min-w-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.descriptionUnavailable) }}
|
||||
</p>
|
||||
<ButtonStyled v-if="dependency.projectUrl" class="self-start" type="outlined">
|
||||
<button type="button" @click="openDependencyPage(dependency)">
|
||||
{{ formatMessage(messages.openProjectPage) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-secondary">{{ formatMessage(messages.onlyChecked) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleSkipped.length > 0" class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.skippedHeader) }}</span>
|
||||
<div
|
||||
v-for="skipped in visibleSkipped"
|
||||
:key="skipped.id"
|
||||
class="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-solid border-surface-4 bg-surface-2 px-3 py-2"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate font-medium text-contrast">
|
||||
{{ skipped.title }}
|
||||
</span>
|
||||
<span class="shrink-0 text-sm text-secondary">{{ skipped.reason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="hide">{{ formatMessage(messages.cancel) }}</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="!conflictMode && (hasBlockingPrimary || visiblePrimaries.length === 0)"
|
||||
@click="confirm"
|
||||
>
|
||||
{{
|
||||
conflictMode
|
||||
? formatMessage(messages.continueAnyway)
|
||||
: hasUnresolvedDependencies
|
||||
? formatMessage(messages.installResolved)
|
||||
: formatMessage(messages.install)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.preview-dependency-tooltip.v-popper--theme-tooltip .v-popper__inner {
|
||||
max-width: 22rem;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
181
apps/app-frontend/src/components/ui/ContextMenu.vue
Normal file
181
apps/app-frontend/src/components/ui/ContextMenu.vue
Normal file
@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-show="shown"
|
||||
ref="contextMenu"
|
||||
class="context-menu"
|
||||
:style="{
|
||||
left: left,
|
||||
top: top,
|
||||
}"
|
||||
>
|
||||
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
|
||||
<hr v-if="option.type === 'divider'" class="divider" />
|
||||
<div
|
||||
v-else-if="!(isInstanceLink(item) && option.name === `add_content`)"
|
||||
class="item clickable"
|
||||
:class="[option.color ?? 'base']"
|
||||
>
|
||||
<slot :name="option.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['menu-closed', 'option-clicked'])
|
||||
|
||||
const item = ref(null)
|
||||
const contextMenu = ref(null)
|
||||
const options = ref([])
|
||||
const left = ref('0px')
|
||||
const top = ref('0px')
|
||||
const shown = ref(false)
|
||||
|
||||
defineExpose({
|
||||
showMenu: (event, passedItem, passedOptions) => {
|
||||
item.value = passedItem
|
||||
options.value = passedOptions
|
||||
|
||||
// show to get dimensions
|
||||
shown.value = true
|
||||
|
||||
// then, adjust position if overflowing
|
||||
nextTick(() => {
|
||||
const menuWidth = contextMenu.value?.clientWidth || 200
|
||||
const menuHeight = contextMenu.value?.clientHeight || 100
|
||||
const minFromEdge = 10
|
||||
|
||||
if (event.pageX + menuWidth + minFromEdge >= window.innerWidth) {
|
||||
left.value = Math.max(minFromEdge, event.pageX - menuWidth - minFromEdge) + 'px'
|
||||
} else {
|
||||
left.value = event.pageX + minFromEdge + 'px'
|
||||
}
|
||||
|
||||
if (event.pageY + menuHeight + minFromEdge >= window.innerHeight) {
|
||||
top.value = Math.max(minFromEdge, event.pageY - menuHeight - minFromEdge) + 'px'
|
||||
} else {
|
||||
top.value = event.pageY + minFromEdge + 'px'
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const isInstanceLink = (item) => {
|
||||
if (item.instance != undefined && item.instance.link) {
|
||||
return true
|
||||
} else if (item != undefined && item.link) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const hideContextMenu = () => {
|
||||
shown.value = false
|
||||
emit('menu-closed')
|
||||
}
|
||||
|
||||
const optionClicked = (option) => {
|
||||
emit('option-clicked', {
|
||||
item: item.value,
|
||||
option: option,
|
||||
})
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
const onEscKeyRelease = (event) => {
|
||||
if (event.keyCode === 27) {
|
||||
hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
const elements = document.elementsFromPoint(event.clientX, event.clientY)
|
||||
if (
|
||||
contextMenu.value &&
|
||||
contextMenu.value.$el !== event.target &&
|
||||
!elements.includes(contextMenu.value.$el)
|
||||
) {
|
||||
hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
document.body.addEventListener('keyup', onEscKeyRelease)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
document.body.removeEventListener('keyup', onEscKeyRelease)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.context-menu {
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-floating);
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
z-index: 1000000;
|
||||
overflow: hidden;
|
||||
padding: var(--gap-sm);
|
||||
|
||||
.item {
|
||||
align-items: center;
|
||||
color: var(--color-base);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
padding: var(--gap-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
&.base {
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background-color: var(--color-red);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.contrast {
|
||||
background-color: var(--color-orange);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: var(--gap-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { renderHighlightedString } from '@modrinth/utils/highlightjs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { explain_crash_with_ai } from '@/helpers/logs'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const loading = ref(false)
|
||||
const output = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.crash-analysis.ai.title', defaultMessage: 'AI crash explanation' },
|
||||
disclaimer: {
|
||||
id: 'app.crash-analysis.ai.disclaimer',
|
||||
defaultMessage:
|
||||
'A sanitized and shortened crash context is sent directly to the AI provider configured in this launcher. AI output may be inaccurate.',
|
||||
},
|
||||
analyzing: { id: 'app.crash-analysis.ai.analyzing', defaultMessage: 'Explaining the crash...' },
|
||||
error: { id: 'app.crash-analysis.ai.error', defaultMessage: 'AI explanation failed: {message}' },
|
||||
copy: { id: 'app.crash-analysis.ai.copy', defaultMessage: 'Copy explanation' },
|
||||
copied: {
|
||||
id: 'app.crash-analysis.ai.copied',
|
||||
defaultMessage: 'AI explanation copied to your clipboard',
|
||||
},
|
||||
close: { id: 'app.crash-analysis.ai.close', defaultMessage: 'Close' },
|
||||
})
|
||||
|
||||
const renderedOutput = computed(() => renderHighlightedString(output.value))
|
||||
|
||||
async function show(instanceId: string): Promise<void> {
|
||||
output.value = ''
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
modal.value?.show()
|
||||
try {
|
||||
const result = await explain_crash_with_ai(instanceId)
|
||||
output.value = result.content
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(output.value)
|
||||
addNotification({ title: formatMessage(messages.copied), type: 'success' })
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" max-width="720px">
|
||||
<div class="flex flex-col gap-4">
|
||||
<Admonition type="warning" :header="formatMessage(messages.title)">
|
||||
{{ formatMessage(messages.disclaimer) }}
|
||||
</Admonition>
|
||||
<div v-if="loading" class="text-secondary">{{ formatMessage(messages.analyzing) }}</div>
|
||||
<div v-else-if="errorMessage" class="rounded-lg bg-red-500/10 p-3 text-secondary">
|
||||
{{ formatMessage(messages.error, { message: errorMessage }) }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="output"
|
||||
class="markdown-body max-h-[55vh] overflow-y-auto rounded-lg bg-surface-2 p-4"
|
||||
v-html="renderedOutput"
|
||||
/>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<ButtonStyled v-if="output" type="outlined">
|
||||
<button @click="copy">{{ formatMessage(messages.copy) }}</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="loading" @click="modal?.hide()">
|
||||
{{ formatMessage(messages.close) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
167
apps/app-frontend/src/components/ui/CrashModChangesModal.vue
Normal file
167
apps/app-frontend/src/components/ui/CrashModChangesModal.vue
Normal file
@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { CrashAnalysisResult } from '@/composables/useCrashAnalysis'
|
||||
import { refresh_content } from '@/helpers/instance'
|
||||
import { undo_added_mod } from '@/helpers/logs'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const analysis = ref<CrashAnalysisResult | null>(null)
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const busy = ref<string | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.title',
|
||||
defaultMessage: 'Mod changes since the last successful launch',
|
||||
},
|
||||
description: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.description',
|
||||
defaultMessage: 'This comparison does not restore or modify any files.',
|
||||
},
|
||||
added: { id: 'app.minecraft-crash.mod-changes-modal.added', defaultMessage: 'Added ({count})' },
|
||||
removed: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.removed',
|
||||
defaultMessage: 'Removed ({count})',
|
||||
},
|
||||
modified: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.modified',
|
||||
defaultMessage: 'Modified ({count})',
|
||||
},
|
||||
empty: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.empty',
|
||||
defaultMessage: 'No Mod file changes were detected.',
|
||||
},
|
||||
close: { id: 'app.minecraft-crash.mod-changes-modal.close', defaultMessage: 'Close' },
|
||||
undo: { id: 'app.minecraft-crash.mod-changes-modal.undo', defaultMessage: 'Undo added Mod' },
|
||||
undone: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.undone',
|
||||
defaultMessage: 'Added Mod removed',
|
||||
},
|
||||
undoConfirm: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.undo-confirm',
|
||||
defaultMessage: 'Remove {name}? Only this unchanged file will be deleted.',
|
||||
},
|
||||
undoFailed: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.undo-failed',
|
||||
defaultMessage: 'Could not undo this Mod change',
|
||||
},
|
||||
refreshFailed: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.refresh-failed',
|
||||
defaultMessage: 'Mod removed, but the content list could not be refreshed.',
|
||||
},
|
||||
})
|
||||
|
||||
const groups = computed(() =>
|
||||
(['added', 'removed', 'modified'] as const).map((kind) => ({
|
||||
kind,
|
||||
items: (analysis.value?.mod_changes ?? []).filter((change) => change.kind === kind),
|
||||
})),
|
||||
)
|
||||
const groupMessages = {
|
||||
added: messages.added,
|
||||
removed: messages.removed,
|
||||
modified: messages.modified,
|
||||
} as const
|
||||
|
||||
function show(nextAnalysis: CrashAnalysisResult): void {
|
||||
analysis.value = nextAnalysis
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
async function undo(change: (typeof groups.value)[number]['items'][number]): Promise<void> {
|
||||
if (change.kind !== 'added' || busy.value) return
|
||||
const currentAnalysis = analysis.value
|
||||
if (!currentAnalysis || !change.current_sha256) return
|
||||
const name = change.project_title || change.filename
|
||||
if (!window.confirm(formatMessage(messages.undoConfirm, { name }))) return
|
||||
busy.value = change.filename
|
||||
let removed = false
|
||||
try {
|
||||
await undo_added_mod(currentAnalysis.instance_id, change.filename, change.current_sha256)
|
||||
removed = true
|
||||
currentAnalysis.mod_changes = currentAnalysis.mod_changes.filter(
|
||||
(item) => item.filename !== change.filename,
|
||||
)
|
||||
addNotification({ title: formatMessage(messages.undone), type: 'success' })
|
||||
} catch {
|
||||
addNotification({ title: formatMessage(messages.undoFailed), type: 'error' })
|
||||
} finally {
|
||||
busy.value = null
|
||||
}
|
||||
if (!removed) return
|
||||
try {
|
||||
await refresh_content(currentAnalysis.instance_id)
|
||||
} catch {
|
||||
addNotification({ title: formatMessage(messages.refreshFailed), type: 'warning' })
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" max-width="680px">
|
||||
<div class="flex max-h-[65vh] flex-col gap-4 overflow-y-auto">
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.description) }}</p>
|
||||
<p v-if="!analysis?.mod_changes.length" class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<section v-for="group in groups" v-else :key="group.kind" class="flex flex-col gap-2">
|
||||
<h3 class="m-0 text-sm font-semibold text-contrast">
|
||||
{{ formatMessage(groupMessages[group.kind], { count: group.items.length }) }}
|
||||
</h3>
|
||||
<ul v-if="group.items.length" class="m-0 flex list-none flex-col gap-1 p-0">
|
||||
<li
|
||||
v-for="change in group.items"
|
||||
:key="`${group.kind}:${change.filename}`"
|
||||
class="rounded-md bg-surface-2 px-3 py-2 text-sm text-secondary"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="size-8 shrink-0 overflow-hidden rounded bg-surface-3">
|
||||
<img
|
||||
v-if="change.icon_url"
|
||||
:src="change.icon_url"
|
||||
:alt="change.project_title || ''"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
v-if="change.project_title && change.project_title !== change.filename"
|
||||
class="truncate font-sans text-sm text-contrast"
|
||||
>
|
||||
{{ change.project_title || change.filename }}
|
||||
</div>
|
||||
<div class="truncate text-xs text-secondary">
|
||||
{{ change.version_number ? `v${change.version_number} · ` : ''
|
||||
}}{{ change.filename }}
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled v-if="change.kind === 'added'" type="outlined">
|
||||
<button :disabled="busy === change.filename" @click="undo(change)">
|
||||
{{ formatMessage(messages.undo) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-end">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="modal?.hide()">{{ formatMessage(messages.close) }}</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
507
apps/app-frontend/src/components/ui/ErrorModal.vue
Normal file
507
apps/app-frontend/src/components/ui/ErrorModal.vue
Normal file
@ -0,0 +1,507 @@
|
||||
<script setup>
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
HammerIcon,
|
||||
LogInIcon,
|
||||
UpdatedIcon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Collapsible,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { ChatIcon } from '@/assets/icons'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { AxolotlBrandConfig } from '@/config'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { install_existing_instance } from '@/helpers/install'
|
||||
import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||
import { exportErrorLogs } from '@/helpers/utils'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
genericTitle: { id: 'app.error.generic-title', defaultMessage: 'An error occurred' },
|
||||
minecraftAuthTitle: {
|
||||
id: 'app.error.minecraft-auth-title',
|
||||
defaultMessage: 'Unable to sign in to Minecraft',
|
||||
},
|
||||
minecraftSignInTitle: {
|
||||
id: 'app.error.minecraft-sign-in-title',
|
||||
defaultMessage: 'Sign in to Minecraft',
|
||||
},
|
||||
directoryTitle: {
|
||||
id: 'app.error.directory-title',
|
||||
defaultMessage: 'Could not change app directory',
|
||||
},
|
||||
loaderTitle: { id: 'app.error.loader-title', defaultMessage: 'No loader selected' },
|
||||
stateTitle: {
|
||||
id: 'app.error.state-title',
|
||||
defaultMessage: 'Error initializing Axolotl Launcher',
|
||||
},
|
||||
networkIssues: { id: 'app.error.network-issues', defaultMessage: 'Network issues' },
|
||||
networkDescription: {
|
||||
id: 'app.error.network-description',
|
||||
defaultMessage:
|
||||
'Axolotl Launcher had trouble connecting to Microsoft services. This is often caused by a poor connection. Try again, and use our support article if the issue persists.',
|
||||
},
|
||||
hostsDescription: {
|
||||
id: 'app.error.hosts-description',
|
||||
defaultMessage:
|
||||
'The connection to Microsoft, Xbox, or Minecraft services was rejected. These services may be blocked by your hosts file. See our support article for steps to fix the issue.',
|
||||
},
|
||||
supportArticle: { id: 'app.error.support-article', defaultMessage: 'Support article' },
|
||||
tryAnotherAccount: {
|
||||
id: 'app.error.try-another-account',
|
||||
defaultMessage: 'Try another Microsoft account',
|
||||
},
|
||||
accountDescription: {
|
||||
id: 'app.error.account-description',
|
||||
defaultMessage:
|
||||
'Check that you signed in with the correct account. You may own Minecraft on another Microsoft account.',
|
||||
},
|
||||
tryAnotherAccountButton: {
|
||||
id: 'app.error.try-another-account-button',
|
||||
defaultMessage: 'Try another account',
|
||||
},
|
||||
officialLauncherTitle: {
|
||||
id: 'app.error.official-launcher-title',
|
||||
defaultMessage: 'Using PC Game Pass, coming from Bedrock, or just bought the game?',
|
||||
},
|
||||
officialLauncherBefore: {
|
||||
id: 'app.error.official-launcher-before',
|
||||
defaultMessage: 'Try signing in with the',
|
||||
},
|
||||
officialLauncher: {
|
||||
id: 'app.error.official-launcher',
|
||||
defaultMessage: 'official Minecraft Launcher',
|
||||
},
|
||||
officialLauncherAfter: {
|
||||
id: 'app.error.official-launcher-after',
|
||||
defaultMessage: 'first. When that is complete, return here and sign in.',
|
||||
},
|
||||
tryAgain: { id: 'app.error.try-sign-in-again', defaultMessage: 'Try signing in again' },
|
||||
permissionsTitle: {
|
||||
id: 'app.error.permissions-title',
|
||||
defaultMessage: 'Change directory permissions',
|
||||
},
|
||||
permissionsDescription: {
|
||||
id: 'app.error.permissions-description',
|
||||
defaultMessage:
|
||||
'Axolotl Launcher cannot write to the selected directory. Adjust its permissions and try again, or cancel the directory change.',
|
||||
},
|
||||
spaceTitle: { id: 'app.error.space-title', defaultMessage: 'Not enough space' },
|
||||
spaceDescription: {
|
||||
id: 'app.error.space-description',
|
||||
defaultMessage:
|
||||
'The disk containing the selected directory does not have enough free space. Free some space and try again, or cancel the directory change.',
|
||||
},
|
||||
directoryDescription: {
|
||||
id: 'app.error.directory-description',
|
||||
defaultMessage:
|
||||
'Axolotl Launcher cannot migrate to the selected directory. Contact support for help or cancel the directory change.',
|
||||
},
|
||||
retryDirectory: {
|
||||
id: 'app.error.retry-directory',
|
||||
defaultMessage: 'Retry directory change',
|
||||
},
|
||||
cancelDirectory: {
|
||||
id: 'app.error.cancel-directory',
|
||||
defaultMessage: 'Cancel directory change',
|
||||
},
|
||||
minecraftRequired: {
|
||||
id: 'app.error.minecraft-required',
|
||||
defaultMessage:
|
||||
'You are not logged in to any account. Please log in below. If you do not have a licensed account, you can create an offline account or log in with a third-party service in the sidebar.',
|
||||
},
|
||||
stateDescription: {
|
||||
id: 'app.error.state-description',
|
||||
defaultMessage:
|
||||
'Axolotl Launcher failed to load correctly. A file may be corrupted or an essential file may be missing.',
|
||||
},
|
||||
stateFixIntro: {
|
||||
id: 'app.error.state-fix-intro',
|
||||
defaultMessage: 'Try one of the following:',
|
||||
},
|
||||
stateFixInternet: {
|
||||
id: 'app.error.state-fix-internet',
|
||||
defaultMessage: 'Check your internet connection, then restart the app.',
|
||||
},
|
||||
stateFixRedownload: {
|
||||
id: 'app.error.state-fix-redownload',
|
||||
defaultMessage: 'Download and install the app again.',
|
||||
},
|
||||
loaderDescription: {
|
||||
id: 'app.error.loader-description',
|
||||
defaultMessage: 'Axolotl Launcher could not find a loader version for this instance.',
|
||||
},
|
||||
loaderFix: {
|
||||
id: 'app.error.loader-fix',
|
||||
defaultMessage: 'Repair the instance using the button below.',
|
||||
},
|
||||
repairInstance: { id: 'app.error.repair-instance', defaultMessage: 'Repair instance' },
|
||||
supportDescription: {
|
||||
id: 'app.error.support-description',
|
||||
defaultMessage:
|
||||
'If you still need help, visit our support page and provide the following debug information.',
|
||||
},
|
||||
getSupport: { id: 'app.error.get-support', defaultMessage: 'Get support' },
|
||||
debugInformation: { id: 'app.error.debug-information', defaultMessage: 'Debug information' },
|
||||
copyDebugInfo: { id: 'app.error.copy-debug-info', defaultMessage: 'Copy debug information' },
|
||||
exportLogs: { id: 'app.error.export-logs', defaultMessage: 'Export error logs' },
|
||||
noErrorMessage: { id: 'app.error.no-error-message', defaultMessage: 'No error message.' },
|
||||
})
|
||||
|
||||
const errorModal = ref()
|
||||
const error = ref()
|
||||
const closable = ref(true)
|
||||
const errorCollapsed = ref(false)
|
||||
|
||||
const title = ref(formatMessage(messages.genericTitle))
|
||||
const errorType = ref('unknown')
|
||||
const supportLink = ref(AxolotlBrandConfig.supportUrl)
|
||||
const metadata = ref({})
|
||||
|
||||
defineExpose({
|
||||
async show(errorVal, context, canClose = true, source = null) {
|
||||
console.log(errorVal, context, canClose, source)
|
||||
closable.value = canClose
|
||||
|
||||
if (errorVal.message && errorVal.message.includes('Minecraft authentication error:')) {
|
||||
title.value = formatMessage(messages.minecraftAuthTitle)
|
||||
errorType.value = 'minecraft_auth'
|
||||
supportLink.value = AxolotlBrandConfig.supportUrl
|
||||
|
||||
if (
|
||||
errorVal.message.includes('existing connection was forcibly closed') ||
|
||||
errorVal.message.includes('error sending request for url')
|
||||
) {
|
||||
metadata.value.network = true
|
||||
}
|
||||
if (errorVal.message.includes('because the target machine actively refused it')) {
|
||||
metadata.value.hostsFile = true
|
||||
}
|
||||
} else if (errorVal.message && errorVal.message.includes('User is not logged in')) {
|
||||
title.value = formatMessage(messages.minecraftSignInTitle)
|
||||
errorType.value = 'minecraft_sign_in'
|
||||
supportLink.value = AxolotlBrandConfig.supportUrl
|
||||
} else if (errorVal.message && errorVal.message.includes('Move directory error:')) {
|
||||
title.value = formatMessage(messages.directoryTitle)
|
||||
errorType.value = 'directory_move'
|
||||
supportLink.value = AxolotlBrandConfig.supportUrl
|
||||
|
||||
if (errorVal.message.includes('directory is not writable')) {
|
||||
metadata.value.readOnly = true
|
||||
}
|
||||
|
||||
if (errorVal.message.includes('Not enough space')) {
|
||||
metadata.value.notEnoughSpace = true
|
||||
}
|
||||
} else if (errorVal.message && errorVal.message.includes('No loader version selected for')) {
|
||||
title.value = formatMessage(messages.loaderTitle)
|
||||
errorType.value = 'no_loader_version'
|
||||
supportLink.value = AxolotlBrandConfig.supportUrl
|
||||
metadata.value.instanceId = context.instanceId
|
||||
} else if (source === 'state_init') {
|
||||
title.value = formatMessage(messages.stateTitle)
|
||||
errorType.value = 'state_init'
|
||||
supportLink.value = AxolotlBrandConfig.supportUrl
|
||||
} else {
|
||||
title.value = formatMessage(messages.genericTitle)
|
||||
errorType.value = 'unknown'
|
||||
supportLink.value = AxolotlBrandConfig.supportUrl
|
||||
metadata.value = {}
|
||||
}
|
||||
|
||||
error.value = errorVal
|
||||
errorModal.value.show()
|
||||
},
|
||||
})
|
||||
|
||||
const loadingMinecraft = ref(false)
|
||||
async function loginMinecraft() {
|
||||
try {
|
||||
loadingMinecraft.value = true
|
||||
const loggedIn = await login_flow()
|
||||
|
||||
if (loggedIn) {
|
||||
await set_default_user(loggedIn.profile.id).catch(handleError)
|
||||
}
|
||||
|
||||
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
||||
loadingMinecraft.value = false
|
||||
errorModal.value.hide()
|
||||
} catch (err) {
|
||||
loadingMinecraft.value = false
|
||||
handleSevereError(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelDirectoryChange() {
|
||||
try {
|
||||
await cancel_directory_change()
|
||||
window.location.reload()
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
}
|
||||
}
|
||||
|
||||
function retryDirectoryChange() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const loadingRepair = ref(false)
|
||||
async function repairInstance() {
|
||||
loadingRepair.value = true
|
||||
try {
|
||||
await install_existing_instance(metadata.value.instanceId, false)
|
||||
errorModal.value.hide()
|
||||
} catch (err) {
|
||||
handleSevereError(err)
|
||||
}
|
||||
loadingRepair.value = false
|
||||
}
|
||||
|
||||
const hasDebugInfo = computed(
|
||||
() =>
|
||||
errorType.value === 'directory_move' ||
|
||||
errorType.value === 'minecraft_auth' ||
|
||||
errorType.value === 'state_init' ||
|
||||
errorType.value === 'no_loader_version',
|
||||
)
|
||||
|
||||
const debugInfo = computed(
|
||||
() => error.value.message ?? error.value ?? formatMessage(messages.noErrorMessage),
|
||||
)
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const exportingLogs = ref(false)
|
||||
|
||||
async function exportLogs() {
|
||||
exportingLogs.value = true
|
||||
try {
|
||||
await exportErrorLogs(debugInfo.value)
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
} finally {
|
||||
exportingLogs.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="errorModal" :header="title" :closable="closable">
|
||||
<div class="modal-body flex flex-col gap-3 max-w-[550px]">
|
||||
<div class="markdown-body">
|
||||
<template v-if="errorType === 'minecraft_auth'">
|
||||
<template v-if="metadata.network">
|
||||
<h3>{{ formatMessage(messages.networkIssues) }}</h3>
|
||||
<p>
|
||||
{{ formatMessage(messages.networkDescription) }}
|
||||
<a :href="AxolotlBrandConfig.supportUrl">
|
||||
{{ formatMessage(messages.supportArticle) }}
|
||||
</a>
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="metadata.hostsFile">
|
||||
<h3>{{ formatMessage(messages.networkIssues) }}</h3>
|
||||
<p>
|
||||
{{ formatMessage(messages.hostsDescription) }}
|
||||
<a :href="AxolotlBrandConfig.supportUrl">
|
||||
{{ formatMessage(messages.supportArticle) }}
|
||||
</a>
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h3>{{ formatMessage(messages.tryAnotherAccount) }}</h3>
|
||||
<p>
|
||||
{{ formatMessage(messages.accountDescription) }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center p-2 gap-2">
|
||||
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
|
||||
<LogInIcon /> {{ formatMessage(messages.tryAnotherAccountButton) }}
|
||||
</button>
|
||||
</div>
|
||||
<h3>{{ formatMessage(messages.officialLauncherTitle) }}</h3>
|
||||
<p>
|
||||
{{ formatMessage(messages.officialLauncherBefore) }}
|
||||
<a href="https://www.minecraft.net/en-us/download">
|
||||
{{ formatMessage(messages.officialLauncher) }}
|
||||
</a>
|
||||
{{ formatMessage(messages.officialLauncherAfter) }}
|
||||
</p>
|
||||
</template>
|
||||
<div class="flex items-center justify-center p-2 gap-2">
|
||||
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
|
||||
<LogInIcon /> {{ formatMessage(messages.tryAgain) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="errorType === 'directory_move'">
|
||||
<template v-if="metadata.readOnly">
|
||||
<h3>{{ formatMessage(messages.permissionsTitle) }}</h3>
|
||||
<p>
|
||||
{{ formatMessage(messages.permissionsDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="metadata.notEnoughSpace">
|
||||
<h3>{{ formatMessage(messages.spaceTitle) }}</h3>
|
||||
<p>
|
||||
{{ formatMessage(messages.spaceDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>
|
||||
{{ formatMessage(messages.directoryDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center justify-center p-2 gap-2">
|
||||
<button class="btn" @click="retryDirectoryChange">
|
||||
<UpdatedIcon /> {{ formatMessage(messages.retryDirectory) }}
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="cancelDirectoryChange">
|
||||
<XIcon /> {{ formatMessage(messages.cancelDirectory) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="errorType === 'minecraft_sign_in'">
|
||||
<p>
|
||||
{{ formatMessage(messages.minecraftRequired) }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center p-2 gap-2">
|
||||
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
|
||||
<LogInIcon /> {{ formatMessage(messages.minecraftSignInTitle) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else-if="errorType === 'state_init'">
|
||||
<p>
|
||||
{{ formatMessage(messages.stateDescription) }}
|
||||
</p>
|
||||
<p>{{ formatMessage(messages.stateFixIntro) }}</p>
|
||||
<ul>
|
||||
<li>{{ formatMessage(messages.stateFixInternet) }}</li>
|
||||
<li>{{ formatMessage(messages.stateFixRedownload) }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template v-else-if="errorType === 'no_loader_version'">
|
||||
<p>{{ formatMessage(messages.loaderDescription) }}</p>
|
||||
<p>{{ formatMessage(messages.loaderFix) }}</p>
|
||||
<div class="flex items-center justify-center p-2 gap-2">
|
||||
<button class="btn btn-primary" :disabled="loadingRepair" @click="repairInstance">
|
||||
<HammerIcon /> {{ formatMessage(messages.repairInstance) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ debugInfo }}
|
||||
</template>
|
||||
<template v-if="hasDebugInfo">
|
||||
<div class="w-full h-[1px] bg-surface-5 mb-3"></div>
|
||||
<p>
|
||||
{{ formatMessage(messages.supportDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled>
|
||||
<a :href="supportLink" @click="errorModal.hide()">
|
||||
<ChatIcon /> {{ formatMessage(messages.getSupport) }}
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="exportingLogs" @click="exportLogs">
|
||||
<DownloadIcon /> {{ formatMessage(messages.exportLogs) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="closable">
|
||||
<button @click="errorModal.hide()">
|
||||
<XIcon /> {{ formatMessage(commonMessages.closeButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<template v-if="hasDebugInfo">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="w-full h-[1px] bg-surface-5"></div>
|
||||
|
||||
<div class="overflow-clip">
|
||||
<button
|
||||
class="flex items-center justify-between w-full bg-transparent border-0 py-4 cursor-pointer"
|
||||
@click="errorCollapsed = !errorCollapsed"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-contrast font-extrabold m-0">
|
||||
<WrenchIcon class="h-4 w-4" />
|
||||
{{ formatMessage(messages.debugInformation) }}
|
||||
</span>
|
||||
<DropdownIcon
|
||||
class="h-5 w-5 text-secondary transition-transform"
|
||||
:class="{ 'rotate-180': !errorCollapsed }"
|
||||
/>
|
||||
</button>
|
||||
<Collapsible :collapsed="errorCollapsed">
|
||||
<div
|
||||
class="p-3 bg-surface-2 rounded-2xl text-xs grid grid-cols-[1fr_auto] max-w-full items-start"
|
||||
>
|
||||
<div
|
||||
class="m-0 p-0 rounded-none bg-transparent text-sm font-mono break-words overflow-auto"
|
||||
>
|
||||
{{ debugInfo }}
|
||||
</div>
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.copyDebugInfo)"
|
||||
:disabled="copied"
|
||||
@click="copyToClipboard(debugInfo)"
|
||||
>
|
||||
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
|
||||
<template v-else> <CopyIcon /> </template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.light-mode {
|
||||
--color-orange-bg: rgba(255, 163, 71, 0.2);
|
||||
}
|
||||
|
||||
.dark-mode,
|
||||
.oled-mode {
|
||||
--color-orange-bg: rgba(224, 131, 37, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.markdown-body {
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
335
apps/app-frontend/src/components/ui/ExportModal.vue
Normal file
335
apps/app-frontend/src/components/ui/ExportModal.vue
Normal file
@ -0,0 +1,335 @@
|
||||
<script setup>
|
||||
import { FolderOpenIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FileTreeSelect,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import { join } from '@tauri-apps/api/path'
|
||||
import { readDir, stat } from '@tauri-apps/plugin-fs'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { PackageIcon } from '@/assets/icons'
|
||||
import {
|
||||
export_instance_mrpack,
|
||||
get_full_path,
|
||||
get_pack_export_candidates,
|
||||
} from '@/helpers/instance'
|
||||
import { highlightInFolder } from '@/helpers/utils'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const popupNotificationManager = injectPopupNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: { id: 'app.export-modal.header', defaultMessage: 'Export modpack' },
|
||||
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack name' },
|
||||
modpackNamePlaceholder: {
|
||||
id: 'app.export-modal.modpack-name-placeholder',
|
||||
defaultMessage: 'Modpack name',
|
||||
},
|
||||
versionNumberLabel: {
|
||||
id: 'app.export-modal.version-number-label',
|
||||
defaultMessage: 'Version number',
|
||||
},
|
||||
versionNumberPlaceholder: {
|
||||
id: 'app.export-modal.version-number-placeholder',
|
||||
defaultMessage: '1.0.0',
|
||||
},
|
||||
descriptionPlaceholder: {
|
||||
id: 'app.export-modal.description-placeholder',
|
||||
defaultMessage: 'Enter modpack description...',
|
||||
},
|
||||
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
|
||||
exportComplete: {
|
||||
id: 'app.export-modal.export-complete',
|
||||
defaultMessage: 'Export complete',
|
||||
},
|
||||
exportCompleteDescription: {
|
||||
id: 'app.export-modal.export-complete-description',
|
||||
defaultMessage: '{name} was exported successfully.',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
resetExportState()
|
||||
exportModal.value.show()
|
||||
void initFiles().catch(handleError)
|
||||
},
|
||||
})
|
||||
|
||||
const exportModal = ref(null)
|
||||
const nameInput = ref(props.instance.name)
|
||||
const exportDescription = ref('')
|
||||
const versionInput = ref('1.0.0')
|
||||
const files = ref([])
|
||||
const selectedFilePaths = ref([])
|
||||
const fileTreeKey = ref(0)
|
||||
const filesLoadId = ref(0)
|
||||
const instanceRoot = ref('')
|
||||
const loadedDirectories = ref(new Set())
|
||||
|
||||
async function initFiles() {
|
||||
const loadId = ++filesLoadId.value
|
||||
const [filePaths, root] = await Promise.all([
|
||||
get_pack_export_candidates(props.instance.id),
|
||||
get_full_path(props.instance.id),
|
||||
])
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
instanceRoot.value = root
|
||||
const exportCandidates = await Promise.all(
|
||||
filePaths.map((path) => buildExportCandidateItem(root, path)),
|
||||
)
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
files.value = exportCandidates
|
||||
selectedFilePaths.value = files.value
|
||||
.filter((file) => !file.disabled && isDefaultSelectedExportCandidate(file.path))
|
||||
.map((file) => file.path)
|
||||
}
|
||||
|
||||
const exportPack = async () => {
|
||||
const outputPath = await save({
|
||||
defaultPath: `${nameInput.value} ${versionInput.value}.mrpack`,
|
||||
filters: [
|
||||
{
|
||||
name: 'Modrinth Modpack',
|
||||
extensions: ['mrpack'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (outputPath) {
|
||||
exportModal.value.hide()
|
||||
|
||||
try {
|
||||
await export_instance_mrpack(
|
||||
props.instance.id,
|
||||
outputPath,
|
||||
selectedFilePaths.value,
|
||||
versionInput.value,
|
||||
exportDescription.value,
|
||||
nameInput.value,
|
||||
)
|
||||
|
||||
const fileName = outputPath.split(/[\\/]/).pop() ?? outputPath
|
||||
popupNotificationManager.addPopupNotification({
|
||||
title: formatMessage(messages.exportComplete),
|
||||
text: formatMessage(messages.exportCompleteDescription, { name: fileName }),
|
||||
type: 'success',
|
||||
buttons: [
|
||||
{
|
||||
label: formatMessage(commonMessages.openInFolderButton),
|
||||
icon: FolderOpenIcon,
|
||||
action: () => highlightInFolder(outputPath).catch(handleError),
|
||||
},
|
||||
],
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetExportState() {
|
||||
nameInput.value = props.instance.name
|
||||
exportDescription.value = ''
|
||||
versionInput.value = '1.0.0'
|
||||
files.value = []
|
||||
selectedFilePaths.value = []
|
||||
fileTreeKey.value += 1
|
||||
instanceRoot.value = ''
|
||||
loadedDirectories.value = new Set()
|
||||
}
|
||||
|
||||
async function loadExportDirectory(path) {
|
||||
if (!path || !instanceRoot.value || loadedDirectories.value.has(path)) return
|
||||
|
||||
const loadId = filesLoadId.value
|
||||
loadedDirectories.value.add(path)
|
||||
|
||||
try {
|
||||
const entries = await readDir(await join(instanceRoot.value, ...path.split('/')))
|
||||
const childItems = await Promise.all(
|
||||
entries.map((entry) => buildExportDirectoryChildItem(instanceRoot.value, path, entry)),
|
||||
)
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
appendExportItems(childItems)
|
||||
} catch {
|
||||
loadedDirectories.value.delete(path)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildExportCandidateItem(instanceRoot, path) {
|
||||
try {
|
||||
const entries = await readDir(await join(instanceRoot, ...path.split('/')))
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, path)
|
||||
return {
|
||||
path,
|
||||
type: 'directory',
|
||||
disabled: isExportCandidateDisabled(path),
|
||||
modified: metadata.modified,
|
||||
count: entries.length,
|
||||
}
|
||||
} catch {
|
||||
return buildExportFileItem(instanceRoot, path)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildExportDirectoryChildItem(instanceRoot, parentPath, entry) {
|
||||
const path = `${parentPath}/${entry.name}`
|
||||
if (entry.isDirectory) {
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, path)
|
||||
return {
|
||||
path,
|
||||
type: 'directory',
|
||||
disabled: isExportCandidateDisabled(path),
|
||||
modified: metadata.modified,
|
||||
}
|
||||
}
|
||||
|
||||
return buildExportFileItem(instanceRoot, path)
|
||||
}
|
||||
|
||||
async function buildExportFileItem(instanceRoot, path) {
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, path)
|
||||
return {
|
||||
path,
|
||||
type: 'file',
|
||||
disabled: isExportCandidateDisabled(path),
|
||||
size: metadata.size,
|
||||
modified: metadata.modified,
|
||||
}
|
||||
}
|
||||
|
||||
function appendExportItems(items) {
|
||||
const nextFiles = new Map(files.value.map((file) => [normalizeExportPath(file.path), file]))
|
||||
for (const item of items) {
|
||||
nextFiles.set(normalizeExportPath(item.path), item)
|
||||
}
|
||||
files.value = [...nextFiles.values()]
|
||||
}
|
||||
|
||||
async function getExportCandidateMetadata(instanceRoot, path) {
|
||||
try {
|
||||
const metadata = await stat(await join(instanceRoot, ...path.split('/')))
|
||||
return {
|
||||
size: metadata.size,
|
||||
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : undefined,
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExportPath(path) {
|
||||
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
|
||||
}
|
||||
|
||||
function isDefaultSelectedExportCandidate(path) {
|
||||
return (
|
||||
path.startsWith('mods') ||
|
||||
path.startsWith('datapacks') ||
|
||||
path.startsWith('resourcepacks') ||
|
||||
path.startsWith('shaderpacks') ||
|
||||
path.startsWith('config')
|
||||
)
|
||||
}
|
||||
|
||||
function isExportCandidateDisabled(path) {
|
||||
return (
|
||||
path === 'profile.json' ||
|
||||
path.startsWith('modrinth_logs') ||
|
||||
path.startsWith('.fabric') ||
|
||||
path.startsWith('__MACOSX')
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="exportModal"
|
||||
:header="formatMessage(messages.header)"
|
||||
scrollable
|
||||
width="46rem"
|
||||
max-width="calc(100vw - 2rem)"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="labeled_input w-full">
|
||||
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
|
||||
<StyledInput
|
||||
v-model="nameInput"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="labeled_input w-full">
|
||||
<p class="text-contrast font-semibold">
|
||||
{{ formatMessage(messages.versionNumberLabel) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
v-model="versionInput"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 min-w-0">
|
||||
<p class="m-0 text-contrast font-semibold">
|
||||
{{ formatMessage(commonMessages.descriptionLabel) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
v-model="exportDescription"
|
||||
multiline
|
||||
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<FileTreeSelect
|
||||
:key="fileTreeKey"
|
||||
v-model="selectedFilePaths"
|
||||
class="min-w-0"
|
||||
:items="files"
|
||||
@navigate="loadExportDirectory"
|
||||
/>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="exportModal.hide">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="exportPack">
|
||||
<PackageIcon />
|
||||
{{ formatMessage(messages.exportButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
443
apps/app-frontend/src/components/ui/Instance.vue
Normal file
443
apps/app-frontend/src/components/ui/Instance.vue
Normal file
@ -0,0 +1,443 @@
|
||||
<script setup>
|
||||
import {
|
||||
DownloadIcon,
|
||||
GameIcon,
|
||||
PlayIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const { formatMessage } = useVIntl()
|
||||
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
const messages = defineMessages({
|
||||
loading: { id: 'app.instance.loading', defaultMessage: 'Instance is loading...' },
|
||||
played: { id: 'app.instance.played', defaultMessage: 'Played {time}' },
|
||||
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
|
||||
offlineInstalledOnly: {
|
||||
id: 'app.instance.offline-installed-only',
|
||||
defaultMessage: 'Offline mode can only launch fully downloaded instances.',
|
||||
},
|
||||
})
|
||||
|
||||
const { offline } = useNetworkStatus()
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
flat: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'standard',
|
||||
},
|
||||
playing: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
first: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const internalPlaying = ref(false)
|
||||
const isPlaying = computed(() => props.playing ?? internalPlaying.value)
|
||||
const displayIcon = computed(() =>
|
||||
getDisplayInstanceIcon(props.instance.icon_path, props.instance.loader),
|
||||
)
|
||||
const loading = ref(false)
|
||||
const modLoading = computed(
|
||||
() =>
|
||||
loading.value ||
|
||||
currentEvent.value === 'installing' ||
|
||||
(currentEvent.value === 'launched' && !isPlaying.value),
|
||||
)
|
||||
const installing = computed(() => props.instance.install_stage.includes('installing'))
|
||||
const installed = computed(() => props.instance.install_stage === 'installed')
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const seeInstance = async () => {
|
||||
await router.push(`/instance/${encodeURIComponent(props.instance.id)}`)
|
||||
}
|
||||
|
||||
const checkProcess = async () => {
|
||||
if (props.playing !== undefined) return
|
||||
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
|
||||
|
||||
internalPlaying.value = runningProcesses.length > 0
|
||||
}
|
||||
|
||||
const play = async (e, context) => {
|
||||
e?.stopPropagation()
|
||||
loading.value = true
|
||||
await run(props.instance.id)
|
||||
.catch(async (err) => {
|
||||
const handled = await handleMinecraftLaunchError(err, {
|
||||
instance_id: props.instance.id,
|
||||
instance_name: props.instance.name,
|
||||
})
|
||||
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
||||
})
|
||||
.finally(() => {
|
||||
trackEvent('InstanceStart', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: context,
|
||||
})
|
||||
})
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const stop = async (e, context) => {
|
||||
e?.stopPropagation()
|
||||
internalPlaying.value = false
|
||||
|
||||
await kill(props.instance.id).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: context,
|
||||
})
|
||||
}
|
||||
|
||||
const repair = async (e) => {
|
||||
e?.stopPropagation()
|
||||
|
||||
if (
|
||||
props.instance.install_stage !== 'pack_installed' &&
|
||||
(props.instance.link?.type === 'modrinth_modpack' ||
|
||||
props.instance.link?.type === 'server_project_modpack')
|
||||
) {
|
||||
await install_pack_to_existing_instance(props.instance.id, {
|
||||
type: 'fromVersionId',
|
||||
project_id: props.instance.link.project_id ?? props.instance.link.server_project_id ?? '',
|
||||
version_id: props.instance.link.version_id ?? props.instance.link.content_version_id ?? '',
|
||||
title: props.instance.name,
|
||||
}).catch(handleError)
|
||||
} else {
|
||||
await install_existing_instance(props.instance.id, false).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
const openFolder = async () => {
|
||||
await showInstanceInFolder(props.instance.id)
|
||||
}
|
||||
|
||||
const addContent = async () => {
|
||||
await router.push({
|
||||
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
query: { i: props.instance.id },
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
play,
|
||||
stop,
|
||||
seeInstance,
|
||||
openFolder,
|
||||
addContent,
|
||||
instance: props.instance,
|
||||
})
|
||||
|
||||
const currentEvent = ref(null)
|
||||
|
||||
const unlisten =
|
||||
props.playing === undefined
|
||||
? await process_listener((e) => {
|
||||
if (e.instance_id === props.instance.id) {
|
||||
currentEvent.value = e.event
|
||||
if (e.event === 'finished') {
|
||||
internalPlaying.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
: () => undefined
|
||||
|
||||
onMounted(() => checkProcess())
|
||||
onUnmounted(() => unlisten())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="compact">
|
||||
<div
|
||||
class="grid cursor-pointer grid-cols-[auto_1fr_auto] items-center gap-2 rounded-lg transition-colors"
|
||||
:class="
|
||||
flat
|
||||
? 'px-2 py-2 hover:bg-button-bg'
|
||||
: 'card-shadow bg-bg-raised p-3 pl-4 hover:brightness-90'
|
||||
"
|
||||
@click="seeInstance"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
<InstanceIcon
|
||||
size="48px"
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
:alt="instance.name"
|
||||
/>
|
||||
<div class="h-full flex items-center font-bold text-contrast leading-normal">
|
||||
<span class="line-clamp-2">{{ instance.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<ButtonStyled v-if="isPlaying" color="red" circular @mousehover="checkProcess">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.stopButton)"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="modLoading" color="standard" circular>
|
||||
<button v-tooltip="formatMessage(messages.loading)" disabled>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else :color="first ? 'brand' : 'standard'" circular>
|
||||
<button
|
||||
v-tooltip="
|
||||
offline && !installed
|
||||
? formatMessage(messages.offlineInstalledOnly)
|
||||
: formatMessage(commonMessages.playButton)
|
||||
"
|
||||
:disabled="offline && !installed"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<!-- Translate for optical centering -->
|
||||
<PlayIcon class="translate-x-[1px]" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
|
||||
<TimerIcon />
|
||||
<span class="text-sm">
|
||||
<template v-if="instance.last_played">
|
||||
{{
|
||||
formatMessage(messages.played, {
|
||||
time: formatRelativeTime(dayjs(instance.last_played).toISOString()),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="variant === 'library'">
|
||||
<div
|
||||
class="group relative flex w-full cursor-pointer select-none flex-col items-start justify-end gap-3 overflow-clip rounded-[20px] border border-solid border-surface-4 bg-surface-3 p-3 text-left transition-[border-color,filter,transform] hover:border-surface-5 hover:brightness-110 active:scale-[0.98]"
|
||||
@click="seeInstance"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
<div
|
||||
class="relative flex aspect-square w-full shrink-0 items-center overflow-clip rounded-2xl"
|
||||
>
|
||||
<Avatar
|
||||
size="100%"
|
||||
:src="displayIcon.url"
|
||||
:tint-by="instance.id"
|
||||
:class="[
|
||||
'pointer-events-none !rounded-2xl outline-none',
|
||||
{ '!border-0 !bg-transparent !shadow-none': displayIcon.frameless },
|
||||
]"
|
||||
:alt="instance.name"
|
||||
/>
|
||||
<div
|
||||
v-if="modLoading || installing"
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-surface-1/30"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-tooltip="
|
||||
modLoading
|
||||
? formatMessage(messages.loading)
|
||||
: formatMessage(commonMessages.installingLabel)
|
||||
"
|
||||
class="size-[30%] animate-spin text-contrast"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute bottom-1.5 right-1.5 flex size-12 items-center justify-center">
|
||||
<ButtonStyled v-if="isPlaying" size="large" color="red" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.stopButton)"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="!modLoading && !installing && !installed"
|
||||
size="large"
|
||||
color="brand"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
offline
|
||||
? formatMessage(messages.offlineInstalledOnly)
|
||||
: formatMessage(commonMessages.repairButton)
|
||||
"
|
||||
:disabled="offline"
|
||||
:class="{
|
||||
'pointer-events-none scale-75 opacity-0': disabled,
|
||||
'scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100':
|
||||
!disabled,
|
||||
}"
|
||||
@click="(e) => repair(e)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="!modLoading && !installing" size="large" color="brand" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.playButton)"
|
||||
:class="{
|
||||
'pointer-events-none scale-75 opacity-0': disabled,
|
||||
'scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100':
|
||||
!disabled,
|
||||
}"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<PlayIcon class="translate-x-[1px]" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col items-start justify-center gap-1 px-0.5">
|
||||
<p class="m-0 w-full truncate text-base font-semibold leading-5 text-contrast">
|
||||
{{ instance.name }}
|
||||
</p>
|
||||
<p class="m-0 w-full truncate text-sm font-medium capitalize leading-[18px] text-primary">
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
class="button-base flex gap-3 group"
|
||||
:class="
|
||||
flat
|
||||
? 'rounded-lg bg-transparent px-2 py-2 hover:bg-button-bg'
|
||||
: 'rounded-xl bg-bg-raised p-4'
|
||||
"
|
||||
@click="seeInstance"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
<div class="relative flex items-center justify-center">
|
||||
<InstanceIcon
|
||||
size="48px"
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
:alt="instance.name"
|
||||
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<ButtonStyled v-if="isPlaying" size="large" color="red" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.stopButton)"
|
||||
:class="{ 'scale-100 opacity-100': isPlaying }"
|
||||
class="transition-all origin-bottom opacity-0 card-shadow"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<SpinnerIcon
|
||||
v-else-if="modLoading || installing"
|
||||
v-tooltip="
|
||||
modLoading
|
||||
? formatMessage(messages.loading)
|
||||
: formatMessage(commonMessages.installingLabel)
|
||||
"
|
||||
class="animate-spin w-8 h-8"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
|
||||
<button
|
||||
v-tooltip="
|
||||
offline
|
||||
? formatMessage(messages.offlineInstalledOnly)
|
||||
: formatMessage(commonMessages.repairButton)
|
||||
"
|
||||
:disabled="offline"
|
||||
:class="`transition-all scale-75 origin-bottom card-shadow ${disabled ? 'opacity-0 scale-75' : 'opacity-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100'}`"
|
||||
@click="(e) => repair(e)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else size="large" color="brand" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.playButton)"
|
||||
:class="`transition-all scale-75 origin-bottom card-shadow ${disabled ? 'opacity-0 scale-75' : 'opacity-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100'}`"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<PlayIcon class="translate-x-[2px]" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1">
|
||||
{{ instance.name }}
|
||||
</p>
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
|
||||
<GameIcon class="shrink-0" />
|
||||
<span class="text-sm capitalize">
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
29
apps/app-frontend/src/components/ui/InstanceIcon.vue
Normal file
29
apps/app-frontend/src/components/ui/InstanceIcon.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { Avatar } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
iconPath?: string | null
|
||||
instanceId?: string | null
|
||||
loader?: string | null
|
||||
}>(),
|
||||
{
|
||||
iconPath: null,
|
||||
instanceId: null,
|
||||
loader: null,
|
||||
},
|
||||
)
|
||||
|
||||
const displayIcon = computed(() => getDisplayInstanceIcon(props.iconPath, props.loader))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Avatar
|
||||
:src="displayIcon.url"
|
||||
:tint-by="instanceId"
|
||||
:class="{ '!border-0 !rounded-none !bg-transparent !shadow-none': displayIcon.frameless }"
|
||||
/>
|
||||
</template>
|
||||
66
apps/app-frontend/src/components/ui/InstanceIndicator.vue
Normal file
66
apps/app-frontend/src/components/ui/InstanceIndicator.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, FormattedTag, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
|
||||
type Instance = {
|
||||
game_version: string
|
||||
loader: string
|
||||
path: string
|
||||
install_stage: string
|
||||
icon_path?: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
instance: Instance
|
||||
backTab?: string
|
||||
}>(),
|
||||
{ backTab: undefined },
|
||||
)
|
||||
|
||||
const instanceLink = computed(() => {
|
||||
const base = `/instance/${encodeURIComponent(props.instance.id)}`
|
||||
return props.backTab ? `${base}/${props.backTab}` : base
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
backToInstance: { id: 'app.instance.back', defaultMessage: 'Back to instance' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
|
||||
<router-link :to="instanceLink" tabindex="-1" class="flex flex-col gap-4 text-primary">
|
||||
<span class="flex items-center gap-2">
|
||||
<InstanceIcon
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
:alt="instance.name"
|
||||
size="48px"
|
||||
/>
|
||||
<span class="flex flex-col gap-2">
|
||||
<span class="font-extrabold bold text-contrast">
|
||||
{{ instance.name }}
|
||||
</span>
|
||||
<span class="text-secondary flex items-center gap-2 font-semibold">
|
||||
<GameIcon class="h-5 w-5 text-secondary" />
|
||||
<FormattedTag :tag="instance.loader" enforce-type="loader" />
|
||||
{{ instance.game_version }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</router-link>
|
||||
<ButtonStyled>
|
||||
<router-link :to="instanceLink">
|
||||
<LeftArrowIcon /> {{ formatMessage(messages.backToInstance) }}
|
||||
</router-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
497
apps/app-frontend/src/components/ui/JavaArgumentsInput.vue
Normal file
497
apps/app-frontend/src/components/ui/JavaArgumentsInput.vue
Normal file
@ -0,0 +1,497 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DropdownIcon,
|
||||
ExternalIcon,
|
||||
GlobeIcon,
|
||||
SparklesIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
AutoLink,
|
||||
ButtonStyled,
|
||||
Collapsible,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
TagItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { resolveAutoGcArgs } from '@/helpers/gc/auto-selector'
|
||||
import type { GcContext } from '@/helpers/gc/types'
|
||||
import { lastGcLaunchReport } from '@/helpers/gc-notice'
|
||||
import {
|
||||
getJavaArgumentPresets,
|
||||
getPresetsByGroup,
|
||||
type JavaArgumentPreset,
|
||||
} from '@/helpers/java-argument-presets'
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
gcContext?: GcContext
|
||||
showAutoDetails?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: undefined,
|
||||
placeholder: undefined,
|
||||
disabled: false,
|
||||
gcContext: undefined,
|
||||
showAutoDetails: false,
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
presetsButton: {
|
||||
id: 'app.java-arguments.presets.button',
|
||||
defaultMessage: 'Argument presets',
|
||||
},
|
||||
presetsModalTitle: {
|
||||
id: 'app.java-arguments.presets.modal-title',
|
||||
defaultMessage: 'Java argument presets',
|
||||
},
|
||||
usePreset: {
|
||||
id: 'app.java-arguments.presets.use',
|
||||
defaultMessage: 'Use preset',
|
||||
},
|
||||
presetApplied: {
|
||||
id: 'app.java-arguments.presets.applied',
|
||||
defaultMessage: 'Applied',
|
||||
},
|
||||
removePreset: {
|
||||
id: 'app.java-arguments.presets.remove',
|
||||
defaultMessage: 'Remove preset',
|
||||
},
|
||||
presetArguments: {
|
||||
id: 'app.java-arguments.presets.arguments',
|
||||
defaultMessage: 'Arguments',
|
||||
},
|
||||
collapseGroup: {
|
||||
id: 'app.java-arguments.presets.collapse-group',
|
||||
defaultMessage: 'Collapse group',
|
||||
},
|
||||
expandGroup: {
|
||||
id: 'app.java-arguments.presets.expand-group',
|
||||
defaultMessage: 'Expand group',
|
||||
},
|
||||
autoResolved: {
|
||||
id: 'app.java-arguments.presets.gc.auto.resolved',
|
||||
defaultMessage: 'Resolved → {strategy}',
|
||||
},
|
||||
autoReasonChain: {
|
||||
id: 'app.java-arguments.presets.gc.auto.reason-chain',
|
||||
defaultMessage: 'Decision path: {chain}',
|
||||
},
|
||||
autoVerified: {
|
||||
id: 'app.java-arguments.presets.gc.auto.verified',
|
||||
defaultMessage: 'Last launch: {chosen}',
|
||||
},
|
||||
})
|
||||
|
||||
const presets = computed(() => getJavaArgumentPresets(props.gcContext))
|
||||
const groupedPresets = computed(() => getPresetsByGroup(presets.value))
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const expandedPresetIds = ref(new Set<string>())
|
||||
const expandedGroupIds = ref(new Set<string>())
|
||||
const copiedPresetId = ref<string | null>(null)
|
||||
|
||||
function getPresetArgs(preset: JavaArgumentPreset): string {
|
||||
return preset.resolveArgs ? preset.resolveArgs(props.gcContext) : preset.args
|
||||
}
|
||||
|
||||
function getDisplayArgs(preset: JavaArgumentPreset): string {
|
||||
if (preset.id === 'gc-auto' && props.showAutoDetails && props.gcContext) {
|
||||
return resolveAutoGcArgs(props.gcContext)
|
||||
}
|
||||
return getPresetArgs(preset)
|
||||
}
|
||||
|
||||
function getActivePresets(value: string): JavaArgumentPreset[] {
|
||||
const trimmed = value.trimStart()
|
||||
const matches = presets.value
|
||||
.filter((preset) => (preset.detect ? preset.detect(trimmed) : trimmed.startsWith(preset.args)))
|
||||
.sort((a, b) => {
|
||||
const aIsAuto = a.id === 'gc-auto' ? 1 : 0
|
||||
const bIsAuto = b.id === 'gc-auto' ? 1 : 0
|
||||
return aIsAuto - bIsAuto || presets.value.indexOf(a) - presets.value.indexOf(b)
|
||||
})
|
||||
const result: JavaArgumentPreset[] = []
|
||||
const seenGroups = new Set<string>()
|
||||
for (const preset of matches) {
|
||||
if (seenGroups.has(preset.group)) continue
|
||||
seenGroups.add(preset.group)
|
||||
result.push(preset)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function removePresetArgs(preset: JavaArgumentPreset, args: string): string {
|
||||
const presetArgs = getPresetArgs(preset)
|
||||
if (args.startsWith(presetArgs)) {
|
||||
return args.slice(presetArgs.length).trimStart()
|
||||
}
|
||||
if (preset.detect && preset.detect(args)) {
|
||||
return args.replace(presetArgs, '').trimStart()
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
const split = computed(() => {
|
||||
const trimmed = model.value.trimStart()
|
||||
const active = getActivePresets(trimmed)
|
||||
let rest = trimmed
|
||||
for (const preset of active) {
|
||||
rest = removePresetArgs(preset, rest)
|
||||
}
|
||||
return { active, rest }
|
||||
})
|
||||
|
||||
const activePresets = computed(() => split.value.active)
|
||||
|
||||
const rest = computed<string>({
|
||||
get: () => split.value.rest,
|
||||
set: (value) => {
|
||||
const argsToJoin = activePresets.value.map(getPresetArgs)
|
||||
model.value = argsToJoin.length ? argsToJoin.join(' ') + (value ? ` ${value}` : '') : value
|
||||
},
|
||||
})
|
||||
|
||||
function onInput(event: Event) {
|
||||
rest.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
function removeOtherGroupPresets(preset: JavaArgumentPreset, currentArgs: string): string {
|
||||
const groupPresets = presets.value.filter((p) => p.group === preset.group && p.id !== preset.id)
|
||||
let result = currentArgs
|
||||
for (const other of groupPresets) {
|
||||
result = removePresetArgs(other, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function applyPreset(preset: JavaArgumentPreset) {
|
||||
const argsToApply = getPresetArgs(preset)
|
||||
const cleanedRest = removeOtherGroupPresets(preset, split.value.rest)
|
||||
model.value = argsToApply + (cleanedRest ? ` ${cleanedRest}` : '')
|
||||
}
|
||||
|
||||
function removePreset(preset: JavaArgumentPreset) {
|
||||
model.value = removePresetArgs(preset, model.value.trimStart()).trimStart()
|
||||
}
|
||||
|
||||
function showPresets() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
async function copyPresetArgs(preset: JavaArgumentPreset) {
|
||||
const argsToCopy = getDisplayArgs(preset)
|
||||
await navigator.clipboard.writeText(argsToCopy)
|
||||
copiedPresetId.value = preset.id
|
||||
setTimeout(() => {
|
||||
if (copiedPresetId.value === preset.id) {
|
||||
copiedPresetId.value = null
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
function isPresetCollapsed(preset: JavaArgumentPreset) {
|
||||
return !expandedPresetIds.value.has(preset.id)
|
||||
}
|
||||
|
||||
function togglePresetCollapsed(preset: JavaArgumentPreset) {
|
||||
const next = new Set(expandedPresetIds.value)
|
||||
if (next.has(preset.id)) {
|
||||
next.delete(preset.id)
|
||||
} else {
|
||||
next.add(preset.id)
|
||||
}
|
||||
expandedPresetIds.value = next
|
||||
}
|
||||
|
||||
function isPresetActive(preset: JavaArgumentPreset) {
|
||||
return activePresets.value.some((p) => p.id === preset.id)
|
||||
}
|
||||
|
||||
function isGroupCollapsed(group: string) {
|
||||
return !expandedGroupIds.value.has(group)
|
||||
}
|
||||
|
||||
function toggleGroupCollapsed(group: string) {
|
||||
const next = new Set(expandedGroupIds.value)
|
||||
if (next.has(group)) {
|
||||
next.delete(group)
|
||||
} else {
|
||||
next.add(group)
|
||||
}
|
||||
expandedGroupIds.value = next
|
||||
}
|
||||
|
||||
function getAutoResolvedLabel(preset: JavaArgumentPreset): string | null {
|
||||
if (preset.id !== 'gc-auto' || !preset.autoResolvedName) return null
|
||||
return formatMessage(messages.autoResolved, { strategy: preset.autoResolvedName })
|
||||
}
|
||||
|
||||
function getAutoReasonChainText(preset: JavaArgumentPreset): string | null {
|
||||
if (preset.id !== 'gc-auto' || !preset.autoReasonChain) return null
|
||||
return formatMessage(messages.autoReasonChain, { chain: preset.autoReasonChain.join(' → ') })
|
||||
}
|
||||
|
||||
function getAutoVerifiedLabel(preset: JavaArgumentPreset): string | null {
|
||||
if (preset.id !== 'gc-auto') return null
|
||||
const notice = lastGcLaunchReport.value
|
||||
if (!notice) return null
|
||||
let chosen = notice.chosen_strategy || 'JVM default GC'
|
||||
if (notice.pruned_args.length > 0) {
|
||||
chosen += ` (${notice.pruned_args.length} pruned)`
|
||||
}
|
||||
return formatMessage(messages.autoVerified, { chosen })
|
||||
}
|
||||
|
||||
const tagsScrollRef = ref<HTMLElement | null>(null)
|
||||
const showTagsFade = ref(false)
|
||||
let tagsResizeObserver: ResizeObserver | null = null
|
||||
|
||||
function updateTagsFade() {
|
||||
const el = tagsScrollRef.value
|
||||
if (!el) return
|
||||
showTagsFade.value = el.scrollWidth > el.clientWidth + 1
|
||||
}
|
||||
|
||||
watch(tagsScrollRef, (el) => {
|
||||
if (el) {
|
||||
updateTagsFade()
|
||||
tagsResizeObserver?.disconnect()
|
||||
tagsResizeObserver = new ResizeObserver(updateTagsFade)
|
||||
tagsResizeObserver.observe(el)
|
||||
} else {
|
||||
tagsResizeObserver?.disconnect()
|
||||
tagsResizeObserver = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(activePresets, () => {
|
||||
nextTick(updateTagsFade)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
tagsResizeObserver?.disconnect()
|
||||
tagsResizeObserver = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex min-w-0 flex-1 items-center gap-2 rounded-xl bg-surface-4 px-3 transition-[box-shadow,color] focus-within:ring-4 focus-within:ring-brand-shadow"
|
||||
:class="props.disabled ? 'cursor-not-allowed opacity-50' : ''"
|
||||
>
|
||||
<div
|
||||
v-if="activePresets.length"
|
||||
ref="tagsScrollRef"
|
||||
class="tags-scroll flex min-w-0 max-w-[50%] shrink-0 items-center gap-1 overflow-x-auto"
|
||||
:class="{ 'tags-fade-right': showTagsFade }"
|
||||
@scroll="updateTagsFade"
|
||||
>
|
||||
<TagItem
|
||||
v-for="preset in activePresets"
|
||||
:key="preset.id"
|
||||
class="shrink-0"
|
||||
:action="props.disabled ? undefined : () => removePreset(preset)"
|
||||
:aria-label="formatMessage(messages.removePreset)"
|
||||
>
|
||||
{{ formatMessage(preset.title) }}
|
||||
<XIcon aria-hidden="true" />
|
||||
</TagItem>
|
||||
</div>
|
||||
<input
|
||||
:id="props.id"
|
||||
:value="rest"
|
||||
:disabled="props.disabled"
|
||||
:placeholder="props.placeholder"
|
||||
class="h-9 min-w-0 flex-1 bg-transparent px-0 py-2 text-base font-medium text-primary placeholder:text-secondary focus:text-contrast focus:shadow-none focus:outline-none"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
@input="onInput"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled type="outlined" class="shrink-0">
|
||||
<button type="button" :disabled="props.disabled" @click="showPresets">
|
||||
<SparklesIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.presetsButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.presetsModalTitle)"
|
||||
width="min(640px, calc(100vw - 2rem))"
|
||||
max-width="640px"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
v-for="groupEntry in groupedPresets"
|
||||
:key="groupEntry.group"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="flex w-full cursor-pointer select-none items-center justify-between gap-2"
|
||||
:aria-expanded="!isGroupCollapsed(groupEntry.group)"
|
||||
:aria-label="
|
||||
formatMessage(
|
||||
isGroupCollapsed(groupEntry.group) ? messages.expandGroup : messages.collapseGroup,
|
||||
)
|
||||
"
|
||||
@click="toggleGroupCollapsed(groupEntry.group)"
|
||||
@keydown.enter="toggleGroupCollapsed(groupEntry.group)"
|
||||
@keydown.space.prevent="toggleGroupCollapsed(groupEntry.group)"
|
||||
>
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(groupEntry.title) }}
|
||||
</h3>
|
||||
<DropdownIcon
|
||||
class="size-4 shrink-0 text-secondary transition-transform"
|
||||
:class="{ 'rotate-180': !isGroupCollapsed(groupEntry.group) }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Collapsible :collapsed="isGroupCollapsed(groupEntry.group)">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="preset in groupEntry.presets"
|
||||
:key="preset.id"
|
||||
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-4 bg-surface-2 p-4"
|
||||
>
|
||||
<div class="flex items-start gap-3 text-left">
|
||||
<GlobeIcon class="mt-0.5 size-6 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(preset.title) }}
|
||||
</p>
|
||||
<AutoLink
|
||||
:to="preset.link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="inline-flex items-start gap-1 text-sm text-secondary hover:text-brand hover:underline"
|
||||
>
|
||||
<span class="min-w-0">{{ formatMessage(preset.description) }}</span>
|
||||
<ExternalIcon class="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
|
||||
</AutoLink>
|
||||
<p
|
||||
v-if="showAutoDetails && getAutoResolvedLabel(preset)"
|
||||
class="m-0 mt-2 text-sm font-medium text-brand"
|
||||
>
|
||||
{{ getAutoResolvedLabel(preset) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="showAutoDetails && getAutoReasonChainText(preset)"
|
||||
class="m-0 mt-1 text-xs text-secondary"
|
||||
>
|
||||
{{ getAutoReasonChainText(preset) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="showAutoDetails && getAutoVerifiedLabel(preset)"
|
||||
class="m-0 mt-1 text-xs text-warning-text"
|
||||
>
|
||||
{{ getAutoVerifiedLabel(preset) }}
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
:type="isPresetActive(preset) ? 'standard' : 'outlined'"
|
||||
color="brand"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isPresetActive(preset)"
|
||||
@click="applyPreset(preset)"
|
||||
>
|
||||
<CheckIcon v-if="isPresetActive(preset)" aria-hidden="true" />
|
||||
{{
|
||||
formatMessage(
|
||||
isPresetActive(preset) ? messages.presetApplied : messages.usePreset,
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<template v-if="preset.id !== 'gc-auto' || showAutoDetails">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-px min-w-0 flex-1 bg-surface-4" />
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.presetArguments)"
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.presetArguments)"
|
||||
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-secondary transition-colors hover:bg-surface-5 hover:text-contrast"
|
||||
@click="togglePresetCollapsed(preset)"
|
||||
>
|
||||
<DropdownIcon
|
||||
class="size-4 transition-transform"
|
||||
:class="{ 'rotate-180': !isPresetCollapsed(preset) }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<Collapsible :collapsed="isPresetCollapsed(preset)">
|
||||
<div class="flex items-start gap-2">
|
||||
<code
|
||||
class="min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-all text-left font-mono text-xs leading-relaxed text-primary"
|
||||
>
|
||||
{{ getDisplayArgs(preset) }}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.presetArguments)"
|
||||
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-secondary transition-colors hover:bg-surface-5 hover:text-contrast"
|
||||
@click="copyPresetArgs(preset)"
|
||||
>
|
||||
<CheckIcon
|
||||
v-if="copiedPresetId === preset.id"
|
||||
class="size-4 text-green"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CopyIcon v-else class="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tags-scroll {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tags-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tags-fade-right {
|
||||
mask-image: linear-gradient(to right, black 0%, black calc(100% - 1.25rem), transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
black 0%,
|
||||
black calc(100% - 1.25rem),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
139
apps/app-frontend/src/components/ui/JavaDetectionModal.vue
Normal file
139
apps/app-frontend/src/components/ui/JavaDetectionModal.vue
Normal file
@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<ModalWrapper
|
||||
ref="detectJavaModal"
|
||||
:header="formatMessage(messages.selectJavaVersion)"
|
||||
:show-ad-on-close="false"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Table :columns="javaInstallColumns" :data="chosenInstallOptions" row-key="path">
|
||||
<template #cell-version="{ value }">
|
||||
<span class="font-semibold text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-path="{ value }">
|
||||
<span v-tooltip="value" class="block truncate font-mono text-xs">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center justify-end">
|
||||
<ButtonStyled v-if="currentSelected.path === row.path">
|
||||
<button class="!shadow-none" disabled>
|
||||
<CheckIcon /> {{ formatMessage(commonMessages.selectedLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button class="!shadow-none" @click="setJavaInstall(row)">
|
||||
<PlusIcon /> {{ formatMessage(messages.select) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<div class="p-4 text-secondary">
|
||||
{{ formatMessage(messages.noneFound) }}
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
<div class="flex justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!shadow-none !border-surface-4 !border"
|
||||
@click="$refs.detectJavaModal.hide()"
|
||||
>
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
<script setup>
|
||||
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
Table,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { java_discovery_listener } from '@/helpers/events'
|
||||
import { find_filtered_jres } from '@/helpers/jre.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
selectJavaVersion: {
|
||||
id: 'app.java.select-version',
|
||||
defaultMessage: 'Select Java version',
|
||||
},
|
||||
select: { id: 'app.java.select', defaultMessage: 'Select' },
|
||||
noneFound: {
|
||||
id: 'app.java.none-found',
|
||||
defaultMessage: 'No Java installations found!',
|
||||
},
|
||||
version: { id: 'app.java.table.version', defaultMessage: 'Version' },
|
||||
path: { id: 'app.java.table.path', defaultMessage: 'Path' },
|
||||
actions: { id: 'app.java.table.actions', defaultMessage: 'Actions' },
|
||||
})
|
||||
|
||||
const chosenInstallOptions = ref([])
|
||||
const detectJavaModal = ref(null)
|
||||
const currentSelected = ref({})
|
||||
const javaInstallColumns = [
|
||||
{ key: 'version', label: formatMessage(messages.version), width: '9rem' },
|
||||
{ key: 'path', label: formatMessage(messages.path) },
|
||||
{ key: 'actions', label: formatMessage(messages.actions), align: 'right', width: '10rem' },
|
||||
]
|
||||
|
||||
const lastRequestedVersion = ref(null)
|
||||
let unlistenJavaDiscovery = null
|
||||
|
||||
defineExpose({
|
||||
show: async (version, currentSelectedJava) => {
|
||||
lastRequestedVersion.value = version ?? null
|
||||
chosenInstallOptions.value = await find_filtered_jres(version, false, false).catch(handleError)
|
||||
|
||||
currentSelected.value = currentSelectedJava
|
||||
if (!currentSelected.value) {
|
||||
currentSelected.value = { path: '', version: '' }
|
||||
}
|
||||
|
||||
if (!unlistenJavaDiscovery) {
|
||||
unlistenJavaDiscovery = await java_discovery_listener(refreshInstallOptions)
|
||||
}
|
||||
|
||||
detectJavaModal.value.show()
|
||||
},
|
||||
})
|
||||
|
||||
async function refreshInstallOptions() {
|
||||
const updated = await find_filtered_jres(lastRequestedVersion.value, false, false).catch(
|
||||
() => null,
|
||||
)
|
||||
if (updated) {
|
||||
chosenInstallOptions.value = updated
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unlistenJavaDiscovery) {
|
||||
unlistenJavaDiscovery()
|
||||
unlistenJavaDiscovery = null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit'])
|
||||
|
||||
function setJavaInstall(javaInstall) {
|
||||
emit('submit', javaInstall)
|
||||
detectJavaModal.value.hide()
|
||||
trackEvent('JavaAutoDetect', {
|
||||
path: javaInstall.path,
|
||||
version: javaInstall.version,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
292
apps/app-frontend/src/components/ui/JavaSelector.vue
Normal file
292
apps/app-frontend/src/components/ui/JavaSelector.vue
Normal file
@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<JavaDetectionModal ref="detectJavaModal" @submit="commitSelection" />
|
||||
<div :id="props.id" class="flex flex-wrap justify-between items-center gap-2" :class="{ compact }">
|
||||
<div class="flex items-center gap-2 w-full min-w-0">
|
||||
<StyledInput
|
||||
autocomplete="off"
|
||||
:disabled="props.disabled"
|
||||
:model-value="props.modelValue ? props.modelValue.path : ''"
|
||||
:placeholder="placeholder ?? '/path/to/java'"
|
||||
wrapper-class="flex-1 min-w-0"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
path: val,
|
||||
})
|
||||
}
|
||||
"
|
||||
@focusout="emit('commit', props.modelValue)"
|
||||
/>
|
||||
<ButtonStyled
|
||||
:color="
|
||||
!hoveringTest && !testingJava
|
||||
? testingJavaSuccess === true
|
||||
? 'green'
|
||||
: 'red'
|
||||
: 'standard'
|
||||
"
|
||||
color-fill="text"
|
||||
>
|
||||
<button
|
||||
class="!shadow-none"
|
||||
:aria-label="formatMessage(messages.testInstallation)"
|
||||
:disabled="testingJava || props.disabled"
|
||||
@click="runTest(props.modelValue?.path)"
|
||||
@mouseenter="!props.disabled && (hoveringTest = true)"
|
||||
@mouseleave="hoveringTest = false"
|
||||
>
|
||||
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
|
||||
<CheckCircleIcon
|
||||
v-else-if="testingJavaSuccess === true && !hoveringTest"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<XCircleIcon v-else-if="testingJavaSuccess !== true && !hoveringTest" class="h-4 w-4" />
|
||||
<RefreshCwIcon v-else-if="!props.disabled" class="h-4 w-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="flex items-center gap-2 m-0">
|
||||
<ButtonStyled v-if="props.version">
|
||||
<button
|
||||
v-tooltip="recommendedInstalled ? formatMessage(messages.alreadyInstalled) : undefined"
|
||||
class="!shadow-none"
|
||||
:aria-label="formatMessage(messages.installRecommended)"
|
||||
:disabled="props.disabled || installingJava || recommendedInstalled"
|
||||
@click="reinstallJava"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{
|
||||
installingJava
|
||||
? formatMessage(commonMessages.installingLabel)
|
||||
: formatMessage(messages.installRecommended)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="!shadow-none"
|
||||
:aria-label="formatMessage(props.selectAllVersions ? messages.select : messages.detect)"
|
||||
:disabled="props.disabled"
|
||||
@click="autoDetect"
|
||||
>
|
||||
<SearchIcon />
|
||||
{{ formatMessage(props.selectAllVersions ? messages.select : messages.detect) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="!shadow-none"
|
||||
:aria-label="formatMessage(messages.browseForExecutable)"
|
||||
:disabled="props.disabled"
|
||||
@click="handleJavaFileInput()"
|
||||
>
|
||||
<FolderSearchIcon />
|
||||
{{ formatMessage(messages.browse) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
DownloadIcon,
|
||||
FolderSearchIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
|
||||
import useJavaTest from '@/composables/useJavaTest'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
alreadyInstalled: { id: 'app.java.already-installed', defaultMessage: 'Already installed' },
|
||||
installRecommended: {
|
||||
id: 'app.java.install-recommended',
|
||||
defaultMessage: 'Install recommended',
|
||||
},
|
||||
detect: { id: 'app.java.detect', defaultMessage: 'Detect' },
|
||||
select: { id: 'app.java.select', defaultMessage: 'Select' },
|
||||
browse: { id: 'app.java.browse', defaultMessage: 'Browse' },
|
||||
browseForExecutable: {
|
||||
id: 'app.java.browse-for-executable',
|
||||
defaultMessage: 'Browse for Java executable',
|
||||
},
|
||||
testInstallation: {
|
||||
id: 'app.java.test-installation',
|
||||
defaultMessage: 'Test Java installation',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
version: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
path: '',
|
||||
version: '',
|
||||
}),
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectAllVersions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'commit'])
|
||||
|
||||
const {
|
||||
testingJava,
|
||||
javaTestResult: testingJavaSuccess,
|
||||
testJavaInstallationDebounced,
|
||||
testJavaInstallation,
|
||||
} = useJavaTest()
|
||||
const recommendedJavaTest = useJavaTest()
|
||||
|
||||
const installingJava = ref(false)
|
||||
const hoveringTest = ref(false)
|
||||
const testVersion = computed(() => (props.selectAllVersions ? null : props.version))
|
||||
const recommendedInstalled = computed(() => {
|
||||
if (props.version == null) return false
|
||||
if (props.selectAllVersions) return recommendedJavaTest.javaTestResult.value === true
|
||||
return testingJavaSuccess.value === true
|
||||
})
|
||||
let hasInitialized = false
|
||||
|
||||
async function runTest(path) {
|
||||
await testJavaInstallation(path, testVersion.value, true)
|
||||
if (props.version != null) {
|
||||
await recommendedJavaTest.testJavaInstallation(path, props.version, false)
|
||||
}
|
||||
}
|
||||
|
||||
function commitSelection(javaVersion) {
|
||||
emit('update:modelValue', javaVersion)
|
||||
emit('commit', javaVersion)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue?.path,
|
||||
(newPath) => {
|
||||
if (newPath) {
|
||||
if (!hasInitialized) {
|
||||
testJavaInstallation(newPath, testVersion.value, false)
|
||||
if (props.version != null) {
|
||||
recommendedJavaTest.testJavaInstallation(newPath, props.version, false)
|
||||
}
|
||||
hasInitialized = true
|
||||
} else {
|
||||
testJavaInstallationDebounced(newPath, testVersion.value)
|
||||
if (props.version != null) {
|
||||
recommendedJavaTest.testJavaInstallationDebounced(newPath, props.version)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function handleJavaFileInput() {
|
||||
const filePath = await open()
|
||||
|
||||
if (filePath) {
|
||||
let result = await get_jre(filePath.path ?? filePath).catch(handleError)
|
||||
if (!result) {
|
||||
result = {
|
||||
path: filePath.path ?? filePath,
|
||||
version: props.version?.toString() ?? '',
|
||||
parsed_version: props.version ?? 0,
|
||||
architecture: 'x86',
|
||||
}
|
||||
}
|
||||
|
||||
trackEvent('JavaManualSelect', {
|
||||
version: props.version,
|
||||
})
|
||||
|
||||
commitSelection(result)
|
||||
}
|
||||
}
|
||||
|
||||
const detectJavaModal = ref(null)
|
||||
async function autoDetect() {
|
||||
const filterVersion = props.selectAllVersions ? null : props.version
|
||||
if (!props.compact) {
|
||||
detectJavaModal.value.show(filterVersion, props.modelValue)
|
||||
} else {
|
||||
const versions = await find_filtered_jres(filterVersion, false, false).catch(handleError)
|
||||
if (versions?.length > 0) {
|
||||
commitSelection(versions[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function reinstallJava() {
|
||||
installingJava.value = true
|
||||
try {
|
||||
const path = await auto_install_java(props.version).catch(handleError)
|
||||
if (!path) return
|
||||
|
||||
let result = await get_jre(path).catch(handleError)
|
||||
if (!result) {
|
||||
result = {
|
||||
path: path,
|
||||
version: props.version?.toString() ?? '',
|
||||
parsed_version: props.version ?? 0,
|
||||
architecture: 'x86',
|
||||
}
|
||||
}
|
||||
|
||||
trackEvent('JavaReInstall', { path: path, version: props.version })
|
||||
commitSelection(result)
|
||||
runTest(result.path)
|
||||
} finally {
|
||||
installingJava.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
119
apps/app-frontend/src/components/ui/LegacyProjectCard.vue
Normal file
119
apps/app-frontend/src/components/ui/LegacyProjectCard.vue
Normal file
@ -0,0 +1,119 @@
|
||||
<script setup>
|
||||
import { DownloadIcon, HeartIcon, TagIcon } from '@modrinth/assets'
|
||||
import { Avatar, FormattedTag, TagItem, useCompactNumber } from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const { formatCompactNumber } = useCompactNumber()
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const featuredCategory = computed(() => {
|
||||
if (props.project.display_categories.includes('optimization')) {
|
||||
return 'optimization'
|
||||
}
|
||||
|
||||
return props.project.display_categories[0] ?? props.project.categories[0]
|
||||
})
|
||||
|
||||
const toColor = computed(() => {
|
||||
let color = props.project.color
|
||||
|
||||
color >>>= 0
|
||||
const b = color & 0xff
|
||||
const g = (color >>> 8) & 0xff
|
||||
const r = (color >>> 16) & 0xff
|
||||
return 'rgba(' + [r, g, b, 1].join(',') + ')'
|
||||
})
|
||||
|
||||
const toTransparent = computed(() => {
|
||||
let color = props.project.color
|
||||
|
||||
color >>>= 0
|
||||
const b = color & 0xff
|
||||
const g = (color >>> 8) & 0xff
|
||||
const r = (color >>> 16) & 0xff
|
||||
return (
|
||||
'linear-gradient(rgba(' +
|
||||
[r, g, b, 0.03].join(',') +
|
||||
'), 65%, rgba(' +
|
||||
[r, g, b, 0.3].join(',') +
|
||||
'))'
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="card-shadow bg-bg-raised rounded-xl overflow-clip cursor-pointer hover:brightness-90 transition-all"
|
||||
@click="router.push(`/project/${project.slug}`)"
|
||||
>
|
||||
<div
|
||||
class="w-full aspect-[2/1] bg-cover bg-center bg-no-repeat"
|
||||
:style="{
|
||||
'background-color': (project.featured_gallery ?? project.gallery[0]) ? null : toColor,
|
||||
'background-image': project.featured_gallery
|
||||
? `url(${project.featured_gallery})`
|
||||
: project.gallery[0]
|
||||
? `url(${project.gallery[0]})`
|
||||
: null,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="badges-wrapper"
|
||||
:class="{
|
||||
'no-image': !project.featured_gallery && !project.gallery[0],
|
||||
}"
|
||||
:style="{
|
||||
background: !project.featured_gallery && !project.gallery[0] ? toTransparent : null,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center gap-2 px-4 py-3">
|
||||
<div class="flex gap-2 items-center">
|
||||
<Avatar size="48px" :src="project.icon_url" />
|
||||
<div class="h-full flex items-center font-bold text-contrast leading-normal">
|
||||
<span class="line-clamp-2">{{ project.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0 text-sm font-medium line-clamp-3 leading-tight h-[3.25rem]">
|
||||
{{ project.description }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-sm text-secondary font-semibold mt-auto">
|
||||
<div
|
||||
class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ formatCompactNumber(project.downloads) }}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border"
|
||||
>
|
||||
<HeartIcon />
|
||||
{{ formatCompactNumber(project.follows) }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 pr-2">
|
||||
<TagIcon />
|
||||
<TagItem>
|
||||
<FormattedTag :tag="featuredCategory" />
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
191
apps/app-frontend/src/components/ui/MemoryAllocationDisplay.vue
Normal file
191
apps/app-frontend/src/components/ui/MemoryAllocationDisplay.vue
Normal file
@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { SparklesIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { get_memory_status, optimize_memory } from '@/helpers/jre.js'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
instanceId?: string
|
||||
|
||||
memory: { maximum: number; automatic: boolean; optimize_before_launch?: boolean }
|
||||
showOptimizeButton?: boolean
|
||||
}>(),
|
||||
{ instanceId: undefined, showOptimizeButton: false },
|
||||
)
|
||||
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
used: { id: 'app.memory-display.used', defaultMessage: 'Memory in use' },
|
||||
game: { id: 'app.memory-display.game', defaultMessage: 'Game allocation' },
|
||||
remaining: { id: 'app.memory-display.remaining', defaultMessage: 'Remaining after allocation' },
|
||||
available: { id: 'app.memory-display.available', defaultMessage: '{memory} available' },
|
||||
optimize: { id: 'app.memory-display.optimize', defaultMessage: 'Optimize memory' },
|
||||
optimizing: { id: 'app.memory-display.optimizing', defaultMessage: 'Optimizing...' },
|
||||
optimized: { id: 'app.memory-display.optimized', defaultMessage: 'Memory optimization complete' },
|
||||
optimizationDescription: {
|
||||
id: 'app.memory-display.optimization-description',
|
||||
defaultMessage: 'Free unused Windows working sets and standby memory.',
|
||||
},
|
||||
unsupported: {
|
||||
id: 'app.memory-display.unsupported',
|
||||
defaultMessage: 'Memory optimization is only available on Windows.',
|
||||
},
|
||||
reclaimed: { id: 'app.memory-display.reclaimed', defaultMessage: 'Freed {memory} of memory.' },
|
||||
})
|
||||
|
||||
type MemoryStatus = {
|
||||
total_bytes: number
|
||||
available_bytes: number
|
||||
allocated_mb: number
|
||||
optimization_supported: boolean
|
||||
}
|
||||
|
||||
const status = ref<MemoryStatus | null>(null)
|
||||
const optimizing = ref(false)
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const totalGiB = computed(() => (status.value?.total_bytes ?? 0) / 1024 ** 3)
|
||||
const availableGiB = computed(() => (status.value?.available_bytes ?? 0) / 1024 ** 3)
|
||||
const usedGiB = computed(() => Math.max(totalGiB.value - availableGiB.value, 0))
|
||||
const allocatedGiB = computed(() => (status.value?.allocated_mb ?? props.memory.maximum) / 1024)
|
||||
const allocatedAvailableGiB = computed(() => Math.min(allocatedGiB.value, availableGiB.value))
|
||||
const remainingGiB = computed(() => Math.max(availableGiB.value - allocatedGiB.value, 0))
|
||||
const allocationLimited = computed(() => allocatedGiB.value > availableGiB.value)
|
||||
const optimizationSupported = computed(() => status.value?.optimization_supported ?? false)
|
||||
|
||||
function percentage(value: number) {
|
||||
return totalGiB.value > 0
|
||||
? `${Math.max(0, Math.min(100, (value / totalGiB.value) * 100))}%`
|
||||
: '0%'
|
||||
}
|
||||
|
||||
function formatGiB(value: number) {
|
||||
return `${value.toFixed(1)} GB`
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
status.value = await get_memory_status(
|
||||
props.instanceId ?? null,
|
||||
props.memory.maximum,
|
||||
props.memory.automatic,
|
||||
)
|
||||
} catch {
|
||||
// The display retries on the next interval while the backend is starting.
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOptimize() {
|
||||
if (optimizing.value || !optimizationSupported.value) return
|
||||
optimizing.value = true
|
||||
try {
|
||||
const result = await optimize_memory()
|
||||
if (result?.supported) {
|
||||
await refresh()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.optimized),
|
||||
text: formatMessage(messages.reclaimed, {
|
||||
memory: formatGiB(result.reclaimed_bytes / 1024 ** 3),
|
||||
}),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
optimizing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.instanceId, props.memory.maximum, props.memory.automatic],
|
||||
() => void refresh(),
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void refresh()
|
||||
timer = setInterval(() => void refresh(), 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-2 min-w-0">
|
||||
<div v-if="!status" class="h-10 animate-pulse rounded-lg bg-button-bg" />
|
||||
<template v-else>
|
||||
<div
|
||||
class="flex h-2 w-full overflow-hidden rounded-full bg-bg-gray"
|
||||
role="meter"
|
||||
:aria-label="formatMessage(messages.used)"
|
||||
:aria-valuenow="usedGiB + allocatedAvailableGiB"
|
||||
aria-valuemin="0"
|
||||
:aria-valuemax="totalGiB"
|
||||
>
|
||||
<span
|
||||
class="bg-gray transition-[width] duration-500"
|
||||
:style="{ width: percentage(usedGiB) }"
|
||||
/>
|
||||
<span
|
||||
class="bg-brand transition-[width] duration-500"
|
||||
:style="{ width: percentage(allocatedAvailableGiB) }"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="size-2 shrink-0 rounded-full bg-gray" />
|
||||
<span class="text-secondary">{{ formatMessage(messages.used) }}</span>
|
||||
<span class="whitespace-nowrap font-semibold tabular-nums text-contrast">
|
||||
{{ formatGiB(usedGiB) }} / {{ formatGiB(totalGiB) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="size-2 shrink-0 rounded-full bg-brand" />
|
||||
<span class="text-secondary">{{ formatMessage(messages.game) }}</span>
|
||||
<span class="whitespace-nowrap font-semibold tabular-nums text-contrast">
|
||||
{{ formatGiB(allocatedGiB) }}
|
||||
<span v-if="allocationLimited"
|
||||
>({{ formatMessage(messages.available, { memory: formatGiB(availableGiB) }) }})</span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="size-2 shrink-0 rounded-full bg-bg-gray ring-1 ring-inset ring-divider" />
|
||||
<span class="text-secondary">{{ formatMessage(messages.remaining) }}</span>
|
||||
<span class="whitespace-nowrap font-semibold tabular-nums text-contrast">
|
||||
{{ formatGiB(remainingGiB) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="showOptimizeButton"
|
||||
class="mt-3 flex flex-col items-start justify-between gap-3 border-t border-divider pt-3 sm:flex-row sm:items-center"
|
||||
>
|
||||
<p class="m-0 min-w-0 flex-1 text-xs leading-tight text-secondary">
|
||||
{{
|
||||
formatMessage(
|
||||
optimizationSupported ? messages.optimizationDescription : messages.unsupported,
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="optimizing || !optimizationSupported"
|
||||
@click="handleOptimize"
|
||||
>
|
||||
<SpinnerIcon v-if="optimizing" class="animate-spin" />
|
||||
<SparklesIcon v-else />
|
||||
{{ formatMessage(optimizing ? messages.optimizing : messages.optimize) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
664
apps/app-frontend/src/components/ui/MinecraftCrashModal.vue
Normal file
664
apps/app-frontend/src/components/ui/MinecraftCrashModal.vue
Normal file
@ -0,0 +1,664 @@
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
shareLogs,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import CrashAIExplanationModal from '@/components/ui/CrashAIExplanationModal.vue'
|
||||
import CrashModChangesModal from '@/components/ui/CrashModChangesModal.vue'
|
||||
import {
|
||||
clearCrashAnalysis,
|
||||
type CrashAnalysisResult,
|
||||
refreshCrashAnalysis,
|
||||
} from '@/composables/useCrashAnalysis'
|
||||
import type { MinecraftLaunchErrorPayload } from '@/composables/useMinecraftLaunchError'
|
||||
import { getAIState } from '@/helpers/ai'
|
||||
import { process_listener } from '@/helpers/events.js'
|
||||
import { get as getInstance } from '@/helpers/instance'
|
||||
import { get_crash_analysis_ai_settings } from '@/helpers/logs.js'
|
||||
import { shouldShowMinecraftCrash } from '@/helpers/process.js'
|
||||
|
||||
interface CrashModalPayload extends MinecraftLaunchErrorPayload {
|
||||
title?: string
|
||||
summary?: string
|
||||
body?: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
interface ProcessEvent {
|
||||
instance_id: string
|
||||
uuid: string
|
||||
event: 'launched' | 'finished'
|
||||
crashed?: boolean
|
||||
}
|
||||
|
||||
interface CrashWarningPayload extends MinecraftLaunchErrorPayload {
|
||||
kind: 'minecraft_crash'
|
||||
}
|
||||
|
||||
type Unlisten = () => void
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const aiModal = ref<InstanceType<typeof CrashAIExplanationModal>>()
|
||||
const modChangesModal = ref<InstanceType<typeof CrashModChangesModal>>()
|
||||
const payload = ref<Partial<CrashModalPayload>>({})
|
||||
const sharing = ref(false)
|
||||
let lastAnalysis: CrashAnalysisResult | null = null
|
||||
const modChangesAvailable = ref(false)
|
||||
const activeRuns = new Map<string, string>()
|
||||
const lastShownAt = new Map<string, number>()
|
||||
let unlistenProcess: Unlisten | undefined
|
||||
let mounted = false
|
||||
let analysisVersion = 0
|
||||
const aiAvailable = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.minecraft-crash.title',
|
||||
defaultMessage: '{instanceName} crashed',
|
||||
},
|
||||
body: {
|
||||
id: 'app.minecraft-crash.body',
|
||||
defaultMessage:
|
||||
'Do not send a screenshot of this window when asking for help. Export the error report instead so the crash report, game logs, debug log, and JVM details can be checked together.',
|
||||
},
|
||||
summary: {
|
||||
id: 'app.minecraft-crash.summary',
|
||||
defaultMessage: 'Minecraft stopped unexpectedly.',
|
||||
},
|
||||
supportHint: {
|
||||
id: 'app.minecraft-crash.support-hint',
|
||||
defaultMessage:
|
||||
'When asking for help, send the exported ZIP. Do not send only a screenshot of this window because it does not contain the diagnostic evidence.',
|
||||
},
|
||||
previewInstance: {
|
||||
id: 'app.minecraft-crash.preview-instance',
|
||||
defaultMessage: 'Minecraft test instance',
|
||||
},
|
||||
launchFailedTitle: {
|
||||
id: 'app.minecraft-crash.launch-failed-title',
|
||||
defaultMessage: '{instanceName} could not start',
|
||||
},
|
||||
launchFailedSummary: {
|
||||
id: 'app.minecraft-crash.launch-failed-summary',
|
||||
defaultMessage: 'Minecraft failed during launch preparation.',
|
||||
},
|
||||
exitedBeforeInitialization: {
|
||||
id: 'app.minecraft-crash.exited-before-initialization',
|
||||
defaultMessage:
|
||||
'The Java process exited before it could connect to the launcher. The selected Java version is probably incompatible with this Minecraft or Mod loader version. Select the Java version required by the instance, then try again.',
|
||||
},
|
||||
initializationTimedOut: {
|
||||
id: 'app.minecraft-crash.initialization-timed-out',
|
||||
defaultMessage:
|
||||
'The Java process started but did not connect to the launcher within 15 seconds. Check the selected Java version and any wrapper command, then try again.',
|
||||
},
|
||||
preparationTimedOut: {
|
||||
id: 'app.minecraft-crash.preparation-timed-out',
|
||||
defaultMessage:
|
||||
'Launch preparation did not finish within 60 seconds and was cancelled. Check the Java path, launch hooks, wrapper command, and network connection, then try again.',
|
||||
},
|
||||
launchFailureHint: {
|
||||
id: 'app.minecraft-crash.launch-failure-hint',
|
||||
defaultMessage:
|
||||
'Open the Minecraft logs to view the captured Java output. When asking for help, export and send the complete Minecraft diagnostic package.',
|
||||
},
|
||||
analyzing: {
|
||||
id: 'app.minecraft-crash.analyzing',
|
||||
defaultMessage: 'Analyzing the logs from this launch...',
|
||||
},
|
||||
evidence: {
|
||||
id: 'app.minecraft-crash.evidence',
|
||||
defaultMessage: 'Reference evidence: {evidence}',
|
||||
},
|
||||
viewModChanges: {
|
||||
id: 'app.minecraft-crash.view-mod-changes',
|
||||
defaultMessage: 'View Mod changes',
|
||||
},
|
||||
modChangesTitle: {
|
||||
id: 'app.minecraft-crash.mod-changes-title',
|
||||
defaultMessage: 'Possible issue: Mod files changed since the last successful launch',
|
||||
},
|
||||
modChangesAction: {
|
||||
id: 'app.minecraft-crash.mod-changes-action',
|
||||
defaultMessage:
|
||||
'Review the changed Mod files and restore the previous setup manually if the crash started after those changes.',
|
||||
},
|
||||
jvmArgumentsTitle: {
|
||||
id: 'app.minecraft-crash.diagnosis.jvm-arguments.title',
|
||||
defaultMessage: 'Possible issue: the JVM arguments are invalid',
|
||||
},
|
||||
jvmArgumentsAction: {
|
||||
id: 'app.minecraft-crash.diagnosis.jvm-arguments.action',
|
||||
defaultMessage:
|
||||
'You can try removing the JVM argument shown below from the instance settings, then launch again.',
|
||||
},
|
||||
javaTooNewTitle: {
|
||||
id: 'app.minecraft-crash.diagnosis.java-too-new.title',
|
||||
defaultMessage: 'Possible issue: the selected Java version is too new',
|
||||
},
|
||||
javaTooNewAction: {
|
||||
id: 'app.minecraft-crash.diagnosis.java-too-new.action',
|
||||
defaultMessage:
|
||||
'You can try selecting the Java major version required by this Minecraft and Mod loader version, then launch again.',
|
||||
},
|
||||
javaIncompatibleTitle: {
|
||||
id: 'app.minecraft-crash.diagnosis.java-incompatible.title',
|
||||
defaultMessage: 'Possible issue: the Java version is incompatible',
|
||||
},
|
||||
javaIncompatibleAction: {
|
||||
id: 'app.minecraft-crash.diagnosis.java-incompatible.action',
|
||||
defaultMessage:
|
||||
'You can try selecting the Java version requested in the error below, or using a compatible build of the affected Mod.',
|
||||
},
|
||||
java32BitTitle: {
|
||||
id: 'app.minecraft-crash.diagnosis.java-32bit.title',
|
||||
defaultMessage: 'Possible issue: 32-bit Java cannot allocate enough memory',
|
||||
},
|
||||
java32BitAction: {
|
||||
id: 'app.minecraft-crash.diagnosis.java-32bit.action',
|
||||
defaultMessage:
|
||||
'You can try installing and selecting a 64-bit Java runtime, then launch again.',
|
||||
},
|
||||
java11RequiredTitle: {
|
||||
id: 'app.minecraft-crash.diagnosis.java-11-required.title',
|
||||
defaultMessage: 'Possible issue: a Mod requires Java 11',
|
||||
},
|
||||
java11RequiredAction: {
|
||||
id: 'app.minecraft-crash.diagnosis.java-11-required.action',
|
||||
defaultMessage:
|
||||
'You can try selecting Java 11, or installing a build of the affected Mod that supports the current Java version.',
|
||||
},
|
||||
openJ9Title: {
|
||||
id: 'app.minecraft-crash.diagnosis.openj9.title',
|
||||
defaultMessage: 'Possible issue: OpenJ9 is not compatible with this instance',
|
||||
},
|
||||
openJ9Action: {
|
||||
id: 'app.minecraft-crash.diagnosis.openj9.action',
|
||||
defaultMessage:
|
||||
'You can try selecting a HotSpot-based Java runtime, such as the bundled Minecraft runtime or Eclipse Temurin.',
|
||||
},
|
||||
jdkRuntimeTitle: {
|
||||
id: 'app.minecraft-crash.diagnosis.jdk-runtime.title',
|
||||
defaultMessage: 'Possible issue: the selected JDK is not compatible',
|
||||
},
|
||||
jdkRuntimeAction: {
|
||||
id: 'app.minecraft-crash.diagnosis.jdk-runtime.action',
|
||||
defaultMessage:
|
||||
'You can try selecting a standard HotSpot Java runtime for this Minecraft version.',
|
||||
},
|
||||
forgeJavaTitle: {
|
||||
id: 'app.minecraft-crash.diagnosis.forge-java.title',
|
||||
defaultMessage: 'Possible issue: Forge is not compatible with the selected Java version',
|
||||
},
|
||||
forgeJavaAction: {
|
||||
id: 'app.minecraft-crash.diagnosis.forge-java.action',
|
||||
defaultMessage:
|
||||
'You can try using the Java version expected by this Forge release, or updating Forge.',
|
||||
},
|
||||
outOfMemoryTitle: {
|
||||
id: 'app.minecraft-crash.diagnosis.out-of-memory.title',
|
||||
defaultMessage: 'Possible issue: Minecraft ran out of memory',
|
||||
},
|
||||
outOfMemoryAction: {
|
||||
id: 'app.minecraft-crash.diagnosis.out-of-memory.action',
|
||||
defaultMessage:
|
||||
'You can try increasing the instance memory allocation, or removing memory-heavy Mods and resource packs.',
|
||||
},
|
||||
diskSpaceTitle: {
|
||||
id: 'app.minecraft-crash.diagnosis.disk-space.title',
|
||||
defaultMessage: 'Possible issue: the disk ran out of free space',
|
||||
},
|
||||
diskSpaceAction: {
|
||||
id: 'app.minecraft-crash.diagnosis.disk-space.action',
|
||||
defaultMessage:
|
||||
'Free space on the drive containing this instance, then launch Minecraft again.',
|
||||
},
|
||||
fileInUseTitle: {
|
||||
id: 'app.minecraft-crash.diagnosis.file-in-use.title',
|
||||
defaultMessage: 'Possible issue: another process is using a required file',
|
||||
},
|
||||
fileInUseAction: {
|
||||
id: 'app.minecraft-crash.diagnosis.file-in-use.action',
|
||||
defaultMessage:
|
||||
'Close the program named in the log, including other launchers, backup tools, or antivirus scans, then launch again.',
|
||||
},
|
||||
knownFailureTitle: {
|
||||
id: 'app.minecraft-crash.diagnosis.known-failure.title',
|
||||
defaultMessage: 'Possible issue: a specific launch problem was detected',
|
||||
},
|
||||
knownFailureAction: {
|
||||
id: 'app.minecraft-crash.diagnosis.known-failure.action',
|
||||
defaultMessage:
|
||||
'This is an automatic guess, not a guaranteed diagnosis. Open the log analysis for the full context before applying the suggested fix.',
|
||||
},
|
||||
shareDiagnostic: {
|
||||
id: 'app.minecraft-crash.share-diagnostic',
|
||||
defaultMessage: 'Share diagnostic',
|
||||
},
|
||||
sharingDiagnostic: {
|
||||
id: 'app.minecraft-crash.sharing-diagnostic',
|
||||
defaultMessage: 'Sharing diagnostic...',
|
||||
},
|
||||
shareFailed: {
|
||||
id: 'app.minecraft-crash.share-failed',
|
||||
defaultMessage: 'Failed to share the diagnostic',
|
||||
},
|
||||
shareTruncated: {
|
||||
id: 'app.minecraft-crash.share-truncated',
|
||||
defaultMessage: 'The diagnostic log is too large, so only the last 9 MB was uploaded.',
|
||||
},
|
||||
shareCopied: {
|
||||
id: 'app.minecraft-crash.share-copied',
|
||||
defaultMessage: 'Diagnostic link copied to your clipboard',
|
||||
},
|
||||
shareReady: {
|
||||
id: 'app.minecraft-crash.share-ready',
|
||||
defaultMessage: 'Diagnostic link is ready to share',
|
||||
},
|
||||
copyLink: {
|
||||
id: 'app.minecraft-crash.copy-link',
|
||||
defaultMessage: 'Copy link',
|
||||
},
|
||||
aiAnalyze: {
|
||||
id: 'app.crash-analysis.ai.action',
|
||||
defaultMessage: 'Use AI to explain',
|
||||
},
|
||||
noLogContent: {
|
||||
id: 'app.minecraft-crash.no-log-content',
|
||||
defaultMessage:
|
||||
'No log content was found to share or analyze. Make sure the instance has logs generated in the last few minutes.',
|
||||
},
|
||||
})
|
||||
|
||||
const diagnosisMessages = {
|
||||
jvm_arguments: [messages.jvmArgumentsTitle, messages.jvmArgumentsAction],
|
||||
java_too_new: [messages.javaTooNewTitle, messages.javaTooNewAction],
|
||||
java_incompatible: [messages.javaIncompatibleTitle, messages.javaIncompatibleAction],
|
||||
java_32bit: [messages.java32BitTitle, messages.java32BitAction],
|
||||
java_11_required: [messages.java11RequiredTitle, messages.java11RequiredAction],
|
||||
openj9: [messages.openJ9Title, messages.openJ9Action],
|
||||
jdk_runtime: [messages.jdkRuntimeTitle, messages.jdkRuntimeAction],
|
||||
forge_java_incompatible: [messages.forgeJavaTitle, messages.forgeJavaAction],
|
||||
out_of_memory: [messages.outOfMemoryTitle, messages.outOfMemoryAction],
|
||||
disk_space: [messages.diskSpaceTitle, messages.diskSpaceAction],
|
||||
file_in_use: [messages.fileInUseTitle, messages.fileInUseAction],
|
||||
} as const
|
||||
|
||||
const title = computed(
|
||||
() =>
|
||||
payload.value.title ||
|
||||
formatMessage(messages.title, {
|
||||
instanceName: payload.value.instance_name || 'Minecraft',
|
||||
}),
|
||||
)
|
||||
const summary = computed(() => payload.value.summary || formatMessage(messages.summary))
|
||||
const body = computed(() => payload.value.body || formatMessage(messages.body))
|
||||
const hint = computed(() => payload.value.hint || formatMessage(messages.supportHint))
|
||||
const showSupportHint = computed(() => hint.value !== formatMessage(messages.supportHint))
|
||||
|
||||
function applyAnalysis(
|
||||
modalPayload: CrashModalPayload,
|
||||
analysis: CrashAnalysisResult | null,
|
||||
): CrashModalPayload {
|
||||
const finding = analysis?.findings[0]
|
||||
const modChanges = analysis?.mod_changes ?? []
|
||||
if (!finding && modChanges.length === 0) return modalPayload
|
||||
|
||||
const diagnosis = finding
|
||||
? diagnosisMessages[finding.id as keyof typeof diagnosisMessages]
|
||||
: undefined
|
||||
const [titleMessage, actionMessage] = diagnosis ?? [
|
||||
messages.knownFailureTitle,
|
||||
messages.knownFailureAction,
|
||||
]
|
||||
const resolvedTitleMessage = finding ? titleMessage : messages.modChangesTitle
|
||||
const resolvedActionMessage = finding ? actionMessage : messages.modChangesAction
|
||||
const evidence = finding?.evidence[0]
|
||||
return {
|
||||
...modalPayload,
|
||||
summary: formatMessage(resolvedTitleMessage),
|
||||
body: formatMessage(resolvedActionMessage),
|
||||
hint: evidence
|
||||
? formatMessage(messages.evidence, {
|
||||
evidence: `${evidence.filename}:${evidence.line} - ${evidence.text}`,
|
||||
})
|
||||
: modalPayload.hint,
|
||||
/*
|
||||
...(false
|
||||
? {
|
||||
hint: `${formatMessage(messages.modChanges, {
|
||||
changes: modChanges.map((change) => `${change.kind}: ${change.filename}`).join('; '),
|
||||
})}${
|
||||
evidence
|
||||
? ` ${formatMessage(messages.evidence, {
|
||||
evidence: `${evidence.filename}:${evidence.line} - ${evidence.text}`,
|
||||
})}`
|
||||
: ''
|
||||
}`,
|
||||
}
|
||||
: {}),
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
function show(modalPayload: CrashModalPayload, isPreview = false): boolean {
|
||||
if (!isPreview) {
|
||||
const now = Date.now()
|
||||
const lastShown = lastShownAt.get(modalPayload.instance_id) ?? 0
|
||||
if (now - lastShown < 5000) return false
|
||||
lastShownAt.set(modalPayload.instance_id, now)
|
||||
}
|
||||
analysisVersion += 1
|
||||
payload.value = modalPayload
|
||||
modal.value?.show()
|
||||
return true
|
||||
}
|
||||
|
||||
function openModChanges(): void {
|
||||
if (lastAnalysis?.mod_changes.length) modChangesModal.value?.show(lastAnalysis)
|
||||
}
|
||||
|
||||
function launchErrorText(error: unknown): string {
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object') {
|
||||
const record = error as Record<string, unknown>
|
||||
const values = [record.message, record.error, record.cause]
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.join('\n')
|
||||
if (values) return values
|
||||
try {
|
||||
return JSON.stringify(error)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function launchFailureBody(error: unknown): string | null {
|
||||
const errorText = launchErrorText(error)
|
||||
if (errorText.includes('Minecraft exited before launcher initialization completed')) {
|
||||
return formatMessage(messages.exitedBeforeInitialization)
|
||||
}
|
||||
if (errorText.includes('Minecraft launcher initialization did not respond')) {
|
||||
return formatMessage(messages.initializationTimedOut)
|
||||
}
|
||||
if (errorText.includes('Minecraft launch preparation timed out')) {
|
||||
return formatMessage(messages.preparationTimedOut)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isLaunchFailure(error: unknown): boolean {
|
||||
return launchFailureBody(error) !== null
|
||||
}
|
||||
|
||||
async function analyzeAndUpdate(
|
||||
modalPayload: CrashModalPayload,
|
||||
fallbackHint?: string,
|
||||
): Promise<CrashAnalysisResult | null> {
|
||||
const version = analysisVersion
|
||||
const analysis = await refreshCrashAnalysis(modalPayload.instance_id).catch((error) => {
|
||||
console.error('Failed to analyze Minecraft crash', error)
|
||||
return null
|
||||
})
|
||||
lastAnalysis = analysis
|
||||
modChangesAvailable.value = !!analysis?.mod_changes.length
|
||||
if (mounted && version === analysisVersion) {
|
||||
payload.value = applyAnalysis(modalPayload, analysis)
|
||||
if (!analysis?.findings.length && fallbackHint) payload.value.hint = fallbackHint
|
||||
}
|
||||
return analysis
|
||||
}
|
||||
|
||||
async function handleLaunchError(
|
||||
error: unknown,
|
||||
launchPayload: MinecraftLaunchErrorPayload,
|
||||
): Promise<boolean> {
|
||||
const failureBody = launchFailureBody(error)
|
||||
if (!failureBody) return false
|
||||
|
||||
const instanceName = launchPayload.instance_name || 'Minecraft'
|
||||
const modalPayload: CrashModalPayload = {
|
||||
...launchPayload,
|
||||
title: formatMessage(messages.launchFailedTitle, { instanceName }),
|
||||
summary: formatMessage(messages.launchFailedSummary),
|
||||
body: failureBody,
|
||||
hint: formatMessage(messages.analyzing),
|
||||
}
|
||||
if (!show(modalPayload)) return true
|
||||
await analyzeAndUpdate(modalPayload, formatMessage(messages.launchFailureHint))
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleWarning(warning: CrashWarningPayload): Promise<void> {
|
||||
const modalPayload = { ...warning, hint: formatMessage(messages.analyzing) }
|
||||
if (!show(modalPayload)) return
|
||||
await analyzeAndUpdate(modalPayload)
|
||||
}
|
||||
|
||||
function showPreview(): void {
|
||||
show(
|
||||
{
|
||||
instance_id: 'preview',
|
||||
instance_name: formatMessage(messages.previewInstance),
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
const shareUrl = ref('')
|
||||
|
||||
function notifyNoLogContent(): void {
|
||||
addNotification({
|
||||
title: formatMessage(messages.noLogContent),
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
|
||||
async function shareDiagnostic(): Promise<void> {
|
||||
if (sharing.value) return
|
||||
if (!lastAnalysis?.combined_log) {
|
||||
notifyNoLogContent()
|
||||
return
|
||||
}
|
||||
sharing.value = true
|
||||
shareUrl.value = ''
|
||||
try {
|
||||
const result = await shareLogs(client, lastAnalysis.combined_log)
|
||||
if (result.truncated) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.shareTruncated),
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
shareUrl.value = result.url
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.url)
|
||||
addNotification({
|
||||
title: formatMessage(messages.shareCopied),
|
||||
type: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to copy shared diagnostic URL', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.shareReady),
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to share crash diagnostic', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.shareFailed),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
sharing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyShareUrl(): Promise<void> {
|
||||
if (!shareUrl.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl.value)
|
||||
addNotification({
|
||||
title: formatMessage(messages.shareCopied),
|
||||
type: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to copy share URL', error)
|
||||
}
|
||||
}
|
||||
|
||||
function _openAIAnalysis(): void {
|
||||
if (!lastAnalysis?.combined_log) {
|
||||
notifyNoLogContent()
|
||||
return
|
||||
}
|
||||
aiModal.value?.show(payload.value.instance_id!)
|
||||
}
|
||||
|
||||
async function refreshAIAvailability(): Promise<void> {
|
||||
try {
|
||||
const [settings, state] = await Promise.all([get_crash_analysis_ai_settings(), getAIState()])
|
||||
const provider = state.providers.find((item) => item.provider_id === settings.provider_id)
|
||||
aiAvailable.value =
|
||||
settings.enabled &&
|
||||
state.settings.enabled &&
|
||||
!!provider?.enabled &&
|
||||
provider.models.some((model) => model.id === settings.model_id && model.enabled)
|
||||
} catch {
|
||||
aiAvailable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProcessEvent(event: ProcessEvent): Promise<void> {
|
||||
if (event.event === 'launched') {
|
||||
activeRuns.set(event.instance_id, event.uuid)
|
||||
clearCrashAnalysis(event.instance_id)
|
||||
modChangesAvailable.value = false
|
||||
return
|
||||
}
|
||||
if (event.event !== 'finished' || activeRuns.get(event.instance_id) !== event.uuid) return
|
||||
if (!shouldShowMinecraftCrash(event.crashed)) {
|
||||
activeRuns.delete(event.instance_id)
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
if (!mounted || activeRuns.get(event.instance_id) !== event.uuid) return
|
||||
|
||||
try {
|
||||
const analysis = await refreshCrashAnalysis(event.instance_id).catch((error) => {
|
||||
console.error('Failed to analyze finished Minecraft process', error)
|
||||
return null
|
||||
})
|
||||
lastAnalysis = analysis
|
||||
modChangesAvailable.value = !!analysis?.mod_changes.length
|
||||
if (!mounted) return
|
||||
|
||||
const instance = await getInstance(event.instance_id).catch(() => null)
|
||||
if (!mounted) return
|
||||
show(
|
||||
applyAnalysis(
|
||||
{
|
||||
instance_id: event.instance_id,
|
||||
instance_name: instance?.name || 'Minecraft',
|
||||
},
|
||||
analysis,
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
if (activeRuns.get(event.instance_id) === event.uuid) activeRuns.delete(event.instance_id)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
mounted = true
|
||||
void refreshAIAvailability()
|
||||
const unlisten = await process_listener((event: ProcessEvent) => void handleProcessEvent(event))
|
||||
if (!mounted) {
|
||||
unlisten()
|
||||
return
|
||||
}
|
||||
unlistenProcess = unlisten
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mounted = false
|
||||
analysisVersion += 1
|
||||
activeRuns.clear()
|
||||
unlistenProcess?.()
|
||||
})
|
||||
|
||||
defineExpose({ handleLaunchError, handleWarning, isLaunchFailure, showPreview, openAIAnalysis: _openAIAnalysis })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="title" fade="danger" max-width="560px">
|
||||
<div class="flex flex-col gap-4">
|
||||
<Admonition type="critical" :header="summary">
|
||||
{{ body }}
|
||||
</Admonition>
|
||||
<p class="m-0 text-secondary">
|
||||
{{ hint }}
|
||||
</p>
|
||||
<p v-if="showSupportHint" class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.supportHint) }}
|
||||
</p>
|
||||
<div v-if="shareUrl" class="flex items-center gap-2 rounded-lg bg-surface-2 p-3">
|
||||
<ExternalIcon class="h-4 w-4 shrink-0 text-secondary" />
|
||||
<a
|
||||
:href="shareUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="min-w-0 flex-1 truncate text-primary underline"
|
||||
>
|
||||
{{ shareUrl }}
|
||||
</a>
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="copyShareUrl">
|
||||
{{ formatMessage(messages.copyLink) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="sharing" @click="shareDiagnostic">
|
||||
{{
|
||||
sharing
|
||||
? formatMessage(messages.sharingDiagnostic)
|
||||
: formatMessage(messages.shareDiagnostic)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="aiAvailable" color="brand">
|
||||
<button @click="openAIAnalysis">
|
||||
{{ formatMessage(messages.aiAnalyze) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="modChangesAvailable" type="outlined">
|
||||
<button @click="openModChanges">
|
||||
{{ formatMessage(messages.viewModChanges) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
<CrashAIExplanationModal ref="aiModal" />
|
||||
<CrashModChangesModal ref="modChangesModal" />
|
||||
</template>
|
||||
164
apps/app-frontend/src/components/ui/MinecraftLoginModal.vue
Normal file
164
apps/app-frontend/src/components/ui/MinecraftLoginModal.vue
Normal file
@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { begin_device_login, poll_device_login } from '@/helpers/auth'
|
||||
|
||||
type MinecraftCredential = {
|
||||
account_type: 'microsoft'
|
||||
profile: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
type DeviceLoginFlow = {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
type DeviceLoginPoll =
|
||||
| { status: 'pending'; slow_down: boolean }
|
||||
| { status: 'complete'; credentials: MinecraftCredential }
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: [credentials: MinecraftCredential]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof ModalWrapper> | null>(null)
|
||||
const busy = ref(false)
|
||||
const deviceFlow = ref<DeviceLoginFlow | null>(null)
|
||||
const deviceError = ref<string | null>(null)
|
||||
let devicePollTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let deviceExpiresAt = 0
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'minecraft-login.title', defaultMessage: 'Sign in to Minecraft' },
|
||||
deviceStarting: {
|
||||
id: 'minecraft-login.device-starting',
|
||||
defaultMessage: 'Requesting a device code...',
|
||||
},
|
||||
deviceDescription: {
|
||||
id: 'minecraft-login.device-description',
|
||||
defaultMessage: 'Open the page below on any device, then enter this code.',
|
||||
},
|
||||
deviceExpired: {
|
||||
id: 'minecraft-login.device-expired',
|
||||
defaultMessage: 'This device code expired. Start again to receive a new code.',
|
||||
},
|
||||
openVerification: {
|
||||
id: 'minecraft-login.open-verification',
|
||||
defaultMessage: 'Open verification page',
|
||||
},
|
||||
})
|
||||
|
||||
const verificationUrl = computed(() => deviceFlow.value?.verification_uri ?? '')
|
||||
|
||||
function clearDevicePolling() {
|
||||
if (devicePollTimer !== undefined) {
|
||||
clearTimeout(devicePollTimer)
|
||||
devicePollTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function resetDeviceLogin() {
|
||||
clearDevicePolling()
|
||||
deviceFlow.value = null
|
||||
deviceError.value = null
|
||||
deviceExpiresAt = 0
|
||||
}
|
||||
|
||||
function hide() {
|
||||
resetDeviceLogin()
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function showDeviceLogin() {
|
||||
resetDeviceLogin()
|
||||
modal.value?.show()
|
||||
void startDeviceLogin()
|
||||
}
|
||||
|
||||
async function finishLogin(credentials: MinecraftCredential) {
|
||||
emit('complete', credentials)
|
||||
hide()
|
||||
}
|
||||
|
||||
async function pollDeviceLogin(delay: number) {
|
||||
const deviceCode = deviceFlow.value?.device_code
|
||||
if (!deviceCode || Date.now() >= deviceExpiresAt) {
|
||||
deviceError.value = formatMessage(messages.deviceExpired)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = (await poll_device_login(deviceCode)) as DeviceLoginPoll
|
||||
if (deviceFlow.value?.device_code !== deviceCode) return
|
||||
if (result.status === 'complete') {
|
||||
await finishLogin(result.credentials)
|
||||
return
|
||||
}
|
||||
const nextDelay = result.slow_down ? delay + 5000 : delay
|
||||
devicePollTimer = setTimeout(() => void pollDeviceLogin(nextDelay), nextDelay)
|
||||
} catch (error) {
|
||||
deviceError.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function openDeviceVerification() {
|
||||
if (!verificationUrl.value) return
|
||||
await openUrl(verificationUrl.value)
|
||||
}
|
||||
|
||||
async function startDeviceLogin() {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
deviceError.value = null
|
||||
try {
|
||||
const flow = (await begin_device_login()) as DeviceLoginFlow
|
||||
deviceFlow.value = flow
|
||||
deviceExpiresAt = Date.now() + flow.expires_in * 1000
|
||||
void pollDeviceLogin(Math.max(flow.interval, 1) * 1000)
|
||||
} catch (error) {
|
||||
deviceError.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(clearDevicePolling)
|
||||
|
||||
defineExpose({ showDeviceLogin, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="modal" :header="formatMessage(messages.title)" :on-hide="resetDeviceLogin">
|
||||
<div class="flex min-w-[24rem] flex-col gap-4">
|
||||
<template v-if="deviceFlow">
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.deviceDescription) }}</p>
|
||||
<ButtonStyled>
|
||||
<button @click="openDeviceVerification">
|
||||
<ExternalIcon /> {{ formatMessage(messages.openVerification) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<code
|
||||
class="rounded-xl bg-surface-3 px-4 py-3 text-center text-xl font-bold tracking-[0.18em] text-contrast"
|
||||
>
|
||||
{{ deviceFlow.user_code }}
|
||||
</code>
|
||||
<p v-if="deviceError" class="m-0 text-sm text-red">{{ deviceError }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p v-if="busy" class="m-0 text-secondary">{{ formatMessage(messages.deviceStarting) }}</p>
|
||||
<p v-if="deviceError" class="m-0 text-sm text-red">{{ deviceError }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
179
apps/app-frontend/src/components/ui/ModpackVersionModal.vue
Normal file
179
apps/app-frontend/src/components/ui/ModpackVersionModal.vue
Normal file
@ -0,0 +1,179 @@
|
||||
<script setup>
|
||||
import { CheckIcon } from '@modrinth/assets'
|
||||
import { Badge, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { SwapIcon } from '@/assets/icons/index.js'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
||||
import { update_managed_modrinth_version } from '@/helpers/instance'
|
||||
import { releaseColor } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps({
|
||||
versions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
instance: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
changeVersion: {
|
||||
id: 'app.modpack.change-version',
|
||||
defaultMessage: 'Change modpack version',
|
||||
},
|
||||
name: { id: 'app.modpack.version-name', defaultMessage: 'Name' },
|
||||
supports: { id: 'app.modpack.version-supports', defaultMessage: 'Supports' },
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
modpackVersionModal.value.show()
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['finish-install'])
|
||||
|
||||
const filteredVersions = computed(() => {
|
||||
return props.versions
|
||||
})
|
||||
|
||||
const modpackVersionModal = ref(null)
|
||||
const installedVersion = computed(() => props.instance?.link?.version_id)
|
||||
const installing = computed(() => props.instance.install_stage !== 'installed')
|
||||
const inProgress = ref(false)
|
||||
|
||||
const switchVersion = async (versionId) => {
|
||||
modpackVersionModal.value.hide()
|
||||
inProgress.value = true
|
||||
await update_managed_modrinth_version(props.instance.id, versionId)
|
||||
inProgress.value = false
|
||||
emit('finish-install')
|
||||
}
|
||||
|
||||
const onHide = () => {
|
||||
if (!inProgress.value) {
|
||||
emit('finish-install')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper
|
||||
ref="modpackVersionModal"
|
||||
class="modpack-version-modal"
|
||||
:header="formatMessage(messages.changeVersion)"
|
||||
:on-hide="onHide"
|
||||
>
|
||||
<div class="modal-body flex flex-col gap-3">
|
||||
<SymlinkInstanceWarning
|
||||
v-if="instance?.symlink_target"
|
||||
:symlink-target="instance.symlink_target"
|
||||
/>
|
||||
<div v-if="instance.link" class="mod-card">
|
||||
<div class="table border border-bg">
|
||||
<div class="table-row grid-cols-[min-content_1fr_1fr] table-head">
|
||||
<div class="table-cell table-text w-16 p-4" />
|
||||
<div class="name-cell table-cell table-text">
|
||||
{{ formatMessage(messages.name) }}
|
||||
</div>
|
||||
<div class="table-cell table-text">{{ formatMessage(messages.supports) }}</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto max-h-[25rem]">
|
||||
<div
|
||||
v-for="version in filteredVersions"
|
||||
:key="version.id"
|
||||
class="table-row grid-cols-[min-content_1fr_1fr] selectable"
|
||||
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
|
||||
>
|
||||
<div class="table-cell table-text">
|
||||
<ButtonStyled
|
||||
circular
|
||||
:color="version.id === installedVersion ? 'standard' : 'brand'"
|
||||
>
|
||||
<button
|
||||
:disabled="inProgress || installing || version.id === installedVersion"
|
||||
@click.stop="() => switchVersion(version.id)"
|
||||
>
|
||||
<SwapIcon v-if="version.id !== installedVersion" />
|
||||
<CheckIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="name-cell table-cell table-text">
|
||||
<div class="version-link">
|
||||
{{ version.name.charAt(0).toUpperCase() + version.name.slice(1) }}
|
||||
<div class="version-badge">
|
||||
<div class="channel-indicator mr-2">
|
||||
<Badge
|
||||
:color="releaseColor(version.version_type)"
|
||||
:type="
|
||||
version.version_type.charAt(0).toUpperCase() +
|
||||
version.version_type.slice(1)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{{ version.version_number }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-cell table-text stacked-text">
|
||||
<span>
|
||||
{{
|
||||
version.loaders
|
||||
.map((str) => str.charAt(0).toUpperCase() + str.slice(1))
|
||||
.join(', ')
|
||||
}}
|
||||
</span>
|
||||
<span>
|
||||
{{ version.game_versions.join(', ') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
.mod-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.version-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
.version-badge {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.stacked-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
</style>
|
||||
88
apps/app-frontend/src/components/ui/NavButton.vue
Normal file
88
apps/app-frontend/src/components/ui/NavButton.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<RouterLink
|
||||
v-if="typeof to === 'string' && !disabled"
|
||||
:to="to"
|
||||
v-bind="$attrs"
|
||||
:active-class="isSubpage ? '' : undefined"
|
||||
:class="{
|
||||
'router-link-active': isPrimary && isPrimary(route),
|
||||
'subpage-active': isSubpage && isSubpage(route),
|
||||
}"
|
||||
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
|
||||
>
|
||||
<slot />
|
||||
</RouterLink>
|
||||
<button
|
||||
v-else-if="typeof to === 'string'"
|
||||
v-bind="$attrs"
|
||||
type="button"
|
||||
aria-disabled="true"
|
||||
tabindex="-1"
|
||||
:class="{
|
||||
'router-link-active': isPrimary && isPrimary(route),
|
||||
'subpage-active': isSubpage && isSubpage(route),
|
||||
}"
|
||||
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
|
||||
@click.prevent
|
||||
@keydown.enter.prevent
|
||||
@keyup.enter.prevent
|
||||
@keydown.space.prevent
|
||||
@keyup.space.prevent
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
v-bind="$attrs"
|
||||
class="button-animation border-none text-primary cursor-pointer w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
|
||||
:disabled="disabled"
|
||||
@click="to"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
type RouteFunction = (route: RouteLocationNormalizedLoaded) => boolean
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
to: (() => void) | string
|
||||
isPrimary?: RouteFunction
|
||||
isSubpage?: RouteFunction
|
||||
highlightOverride?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
isPrimary: undefined,
|
||||
isSubpage: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.router-link-active,
|
||||
.subpage-active {
|
||||
svg {
|
||||
filter: drop-shadow(0 0 0.5rem black);
|
||||
}
|
||||
}
|
||||
|
||||
.router-link-active {
|
||||
@apply text-[--color-button-text-selected] bg-[--color-button-bg-selected];
|
||||
}
|
||||
|
||||
.subpage-active {
|
||||
@apply text-contrast bg-button-bg;
|
||||
}
|
||||
</style>
|
||||
116
apps/app-frontend/src/components/ui/NavRail.vue
Normal file
116
apps/app-frontend/src/components/ui/NavRail.vue
Normal file
@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div ref="rail" class="nav-rail relative flex flex-col gap-[0.5rem]">
|
||||
<slot />
|
||||
<div
|
||||
class="nav-rail-slider pointer-events-none absolute rounded-full"
|
||||
:class="[
|
||||
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
|
||||
transitionsEnabled ? 'nav-rail-slider-transition' : '',
|
||||
]"
|
||||
:style="sliderStyle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const rail = ref<HTMLElement | null>(null)
|
||||
|
||||
const hasActive = ref(false)
|
||||
const subpageSelected = ref(false)
|
||||
const sliderReady = ref(false)
|
||||
const transitionsEnabled = ref(false)
|
||||
|
||||
const top = ref(0)
|
||||
const bottom = ref(0)
|
||||
const left = ref(0)
|
||||
const width = ref(0)
|
||||
|
||||
const topDelay = ref('0ms')
|
||||
const bottomDelay = ref('0ms')
|
||||
|
||||
const STAGGER_DELAY = '120ms'
|
||||
|
||||
const sliderStyle = computed(() => ({
|
||||
top: `${top.value}px`,
|
||||
bottom: `${bottom.value}px`,
|
||||
left: `${left.value}px`,
|
||||
width: `${width.value}px`,
|
||||
opacity: sliderReady.value && hasActive.value ? 1 : 0,
|
||||
}))
|
||||
|
||||
function positionSlider() {
|
||||
const container = rail.value
|
||||
if (!container) return
|
||||
|
||||
const el = container.querySelector<HTMLElement>('.router-link-active, .subpage-active')
|
||||
if (!el?.offsetParent || container.offsetHeight === 0) {
|
||||
hasActive.value = false
|
||||
return
|
||||
}
|
||||
|
||||
subpageSelected.value = el.classList.contains('subpage-active')
|
||||
|
||||
const newTop = el.offsetTop
|
||||
const newBottom = container.offsetHeight - el.offsetTop - el.offsetHeight
|
||||
const movingDown = newTop > top.value
|
||||
|
||||
topDelay.value = movingDown ? STAGGER_DELAY : '0ms'
|
||||
bottomDelay.value = movingDown ? '0ms' : STAGGER_DELAY
|
||||
|
||||
top.value = newTop
|
||||
bottom.value = newBottom
|
||||
left.value = el.offsetLeft
|
||||
width.value = el.offsetWidth
|
||||
hasActive.value = true
|
||||
|
||||
if (!sliderReady.value) {
|
||||
sliderReady.value = true
|
||||
requestAnimationFrame(() => {
|
||||
transitionsEnabled.value = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSlider() {
|
||||
await nextTick()
|
||||
positionSlider()
|
||||
}
|
||||
|
||||
onMounted(updateSlider)
|
||||
|
||||
watch(() => [route.path, route.query], updateSlider)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-rail :deep(a),
|
||||
.nav-rail :deep(button) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.nav-rail :deep(a.router-link-active),
|
||||
.nav-rail :deep(a.subpage-active),
|
||||
.nav-rail :deep(button.router-link-active),
|
||||
.nav-rail :deep(button.subpage-active) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.nav-rail-slider {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.nav-rail-slider-transition {
|
||||
transition:
|
||||
top 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(topDelay),
|
||||
bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay),
|
||||
left 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
width 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||
}
|
||||
</style>
|
||||
33
apps/app-frontend/src/components/ui/ProgressBar.vue
Normal file
33
apps/app-frontend/src/components/ui/ProgressBar.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="w-full h-2 bg-button-bg rounded-[var(--radius-lg)] overflow-hidden">
|
||||
<div
|
||||
class="progress-bar__fill h-full"
|
||||
:style="{
|
||||
width: `${progress}%`,
|
||||
'background-color': error ? 'var(--color-red)' : 'var(--color-brand)',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
progress: {
|
||||
type: Number,
|
||||
required: true,
|
||||
validator(value) {
|
||||
return value >= 0 && value <= 100
|
||||
},
|
||||
},
|
||||
error: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.progress-bar__fill {
|
||||
transition: width 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { injectNotificationManager } from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import { instance_listener } from '@/helpers/events.js'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
const fullInstanceList = ref([])
|
||||
const instanceCount = computed(() => themeStore.sidebarInstanceCount)
|
||||
|
||||
const recentInstances = computed(() => {
|
||||
if (instanceCount.value > 0) {
|
||||
return fullInstanceList.value.slice(0, instanceCount.value)
|
||||
}
|
||||
return fullInstanceList.value
|
||||
})
|
||||
|
||||
const getInstances = async () => {
|
||||
const instances = await list().catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
|
||||
fullInstanceList.value = instances.sort((a, b) => {
|
||||
const dateACreated = dayjs(a.created)
|
||||
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
|
||||
|
||||
const dateBCreated = dayjs(b.created)
|
||||
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
|
||||
|
||||
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
|
||||
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
|
||||
|
||||
if (dateA.isSame(dateB)) {
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
return dateB - dateA
|
||||
})
|
||||
}
|
||||
|
||||
await getInstances()
|
||||
|
||||
const unlistenInstance = await instance_listener(async (event) => {
|
||||
if (event.event !== 'synced') {
|
||||
await getInstances()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenInstance()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="instance in recentInstances" :key="instance.id" v-tooltip.right="instance.name">
|
||||
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
|
||||
<InstanceIcon
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
size="28px"
|
||||
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
|
||||
/>
|
||||
<div
|
||||
v-if="instance.install_stage !== 'installed'"
|
||||
class="absolute inset-0 flex items-center justify-center z-10 pointer-events-none"
|
||||
>
|
||||
<SpinnerIcon class="animate-spin w-4 h-4" />
|
||||
</div>
|
||||
</NavButton>
|
||||
</div>
|
||||
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-divider"></div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
136
apps/app-frontend/src/components/ui/SplashScreen.vue
Normal file
136
apps/app-frontend/src/components/ui/SplashScreen.vue
Normal file
@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<Transition name="splash-fade" @after-leave="onAfterLeave">
|
||||
<div v-if="!doneLoading" class="fixed inset-0 z-[10000] dark">
|
||||
<div class="absolute h-screen w-full flex flex-col justify-center items-center gap-4 z-[9998]" data-tauri-drag-region>
|
||||
<img class="app-logo" src="@/assets/axolotl.png" alt="Axolotl Launcher" />
|
||||
<ProgressBar class="max-w-xs" :progress="Math.min(loadingProgress, 100)" />
|
||||
<span v-if="message">{{ message }}</span>
|
||||
</div>
|
||||
<div class="gradient-bg" data-tauri-drag-region></div>
|
||||
<div class="cube-bg"></div>
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-bg z-[9995]"></div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineMessages, injectLoadingState, useVIntl } from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
import { loading_listener } from '@/helpers/events.js'
|
||||
|
||||
const doneLoading = ref(false)
|
||||
const loadingProgress = ref(0)
|
||||
const message = ref()
|
||||
|
||||
const MIN_DISPLAY_MS = 500
|
||||
const mountedAt = Date.now()
|
||||
|
||||
const loading = injectLoadingState()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
updatingAppDirectory: {
|
||||
id: 'app.splash.updating-app-directory',
|
||||
defaultMessage: 'Updating app directory...',
|
||||
},
|
||||
checkingForUpdates: {
|
||||
id: 'app.splash.checking-for-updates',
|
||||
defaultMessage: 'Checking for updates...',
|
||||
},
|
||||
})
|
||||
|
||||
function onAfterLeave() {
|
||||
loading.setEnabled(true)
|
||||
}
|
||||
|
||||
watch(
|
||||
[loading.barEnabled, loading.pending],
|
||||
([barEnabled, pending]) => {
|
||||
if (barEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (pending) {
|
||||
loadingProgress.value = 0
|
||||
fakeLoadingIncrease()
|
||||
return
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - mountedAt
|
||||
const delay = Math.max(0, MIN_DISPLAY_MS - elapsed)
|
||||
|
||||
setTimeout(() => {
|
||||
if (loading.pending.value) {
|
||||
return
|
||||
}
|
||||
doneLoading.value = true
|
||||
}, delay)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function fakeLoadingIncrease() {
|
||||
if (loadingProgress.value < 95) {
|
||||
setTimeout(() => {
|
||||
loadingProgress.value += 2
|
||||
fakeLoadingIncrease()
|
||||
}, 5)
|
||||
}
|
||||
}
|
||||
|
||||
loading_listener(async (e) => {
|
||||
if (e.event.type === 'directory_move') {
|
||||
loadingProgress.value = 100 * (e.fraction ?? 1)
|
||||
message.value = formatMessage(messages.updatingAppDirectory)
|
||||
} else if (e.event.type === 'checking_for_updates') {
|
||||
loadingProgress.value = 100 * (e.fraction ?? 1)
|
||||
message.value = formatMessage(messages.checkingForUpdates)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.splash-fade-leave-active {
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.splash-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
height: min(18rem, 45vh);
|
||||
width: min(18rem, 45vw);
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 2rem rgba(255, 77, 157, 0.35));
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
position: absolute;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 77, 157, 0.24) 0%, rgba(48, 16, 40, 0.56) 97.29%),
|
||||
linear-gradient(0deg, rgba(22, 18, 28, 0.68), rgba(22, 18, 28, 0.68));
|
||||
z-index: 9997;
|
||||
}
|
||||
|
||||
.cube-bg {
|
||||
position: absolute;
|
||||
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
width: 180vw;
|
||||
height: 180vh;
|
||||
opacity: 0.8;
|
||||
background: #16181c url('@/assets/loading/cube.png') center no-repeat;
|
||||
background-size: contain;
|
||||
|
||||
z-index: 9996;
|
||||
}
|
||||
|
||||
</style>
|
||||
134
apps/app-frontend/src/components/ui/SymlinkInstanceWarning.vue
Normal file
134
apps/app-frontend/src/components/ui/SymlinkInstanceWarning.vue
Normal file
@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { LinkIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { injectSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
symlinkTarget: string
|
||||
variant?: 'write' | 'delete'
|
||||
dismissible?: boolean
|
||||
badgeOnly?: boolean
|
||||
}>(),
|
||||
{
|
||||
variant: 'write',
|
||||
dismissible: false,
|
||||
badgeOnly: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
dismiss: []
|
||||
dismissPermanently: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const dismissState = injectSymlinkWarningDismiss()
|
||||
|
||||
const effectivelyHidden = computed(() => {
|
||||
if (props.variant === 'delete') return false
|
||||
return dismissState?.isHidden.value ?? false
|
||||
})
|
||||
|
||||
const showBadge = computed(() => props.badgeOnly || effectivelyHidden.value)
|
||||
|
||||
const effectivelyDismissible = computed(() => {
|
||||
if (props.variant === 'delete') return false
|
||||
return props.dismissible || !!dismissState
|
||||
})
|
||||
|
||||
function handleDismiss() {
|
||||
if (dismissState) {
|
||||
dismissState.dismissTemp()
|
||||
}
|
||||
emit('dismiss')
|
||||
}
|
||||
|
||||
function handleDismissPermanently() {
|
||||
if (dismissState) {
|
||||
dismissState.dismissPermanently()
|
||||
}
|
||||
emit('dismissPermanently')
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
writeHeader: {
|
||||
id: 'app.symlink-warning.write.header',
|
||||
defaultMessage: 'Shared instance',
|
||||
},
|
||||
writeBody: {
|
||||
id: 'app.symlink-warning.write.body',
|
||||
defaultMessage:
|
||||
'This instance is linked to "{path}". Changes will also affect the original files.',
|
||||
},
|
||||
deleteHeader: {
|
||||
id: 'app.symlink-warning.delete.header',
|
||||
defaultMessage: 'Delete shared instance',
|
||||
},
|
||||
deleteBody: {
|
||||
id: 'app.symlink-warning.delete.body',
|
||||
defaultMessage:
|
||||
'Deleting this instance removes only the launcher entry. The original files at "{path}" will remain.',
|
||||
},
|
||||
dismissPermanently: {
|
||||
id: 'app.symlink-warning.dismiss-permanently',
|
||||
defaultMessage: "Don't show again for this instance",
|
||||
},
|
||||
sharedBadge: {
|
||||
id: 'app.symlink-warning.shared-badge',
|
||||
defaultMessage: 'Shared',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$attrs.class">
|
||||
<!-- Collapsed: small badge -->
|
||||
<div v-if="showBadge" class="flex">
|
||||
<span
|
||||
v-tooltip="formatMessage(messages.writeBody, { path: props.symlinkTarget })"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-bg-orange px-2 py-0.5 text-xs font-medium text-brand-orange"
|
||||
>
|
||||
<LinkIcon class="size-3" />
|
||||
{{ formatMessage(messages.sharedBadge) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Expanded: full warning -->
|
||||
<template v-else>
|
||||
<Admonition
|
||||
v-if="props.variant === 'delete'"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.deleteHeader)"
|
||||
>
|
||||
{{ formatMessage(messages.deleteBody, { path: props.symlinkTarget }) }}
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-else
|
||||
type="warning"
|
||||
:header="formatMessage(messages.writeHeader)"
|
||||
:dismissible="effectivelyDismissible"
|
||||
@dismiss="handleDismiss"
|
||||
>
|
||||
<span>
|
||||
{{ formatMessage(messages.writeBody, { path: props.symlinkTarget }) }}
|
||||
<template v-if="effectivelyDismissible">
|
||||
{{ ' ' }}
|
||||
<ButtonStyled
|
||||
size="small"
|
||||
type="transparent"
|
||||
color="orange"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button type="button" @click="handleDismissPermanently">
|
||||
{{ formatMessage(messages.dismissPermanently) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</span>
|
||||
</Admonition>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { configuredXss, renderHighlightedString } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
prepareDescription,
|
||||
renderTranslatedDescription,
|
||||
type TranslationMode,
|
||||
type TranslationStyle,
|
||||
} from '@/helpers/translation'
|
||||
|
||||
const props = defineProps<{
|
||||
description: string
|
||||
active: boolean
|
||||
translations: Record<string, string>
|
||||
mode: TranslationMode
|
||||
style: TranslationStyle
|
||||
format?: 'markdown' | 'html'
|
||||
}>()
|
||||
|
||||
const renderedDescription = computed(() => {
|
||||
if (!props.active) {
|
||||
return props.format === 'html'
|
||||
? configuredXss.process(props.description ?? '')
|
||||
: renderHighlightedString(props.description ?? '')
|
||||
}
|
||||
return renderTranslatedDescription(
|
||||
prepareDescription(props.description, props.format),
|
||||
props.translations,
|
||||
props.mode,
|
||||
props.style,
|
||||
)
|
||||
})
|
||||
|
||||
const translationOnlyClass = computed(() =>
|
||||
props.active && props.mode === 'translation-only'
|
||||
? ['ax-translation-only', `ax-translation-style-${props.style}`]
|
||||
: [],
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="markdown-body" :class="translationOnlyClass" v-html="renderedDescription" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ax-translation-block) {
|
||||
margin-block: 0.5rem 1rem;
|
||||
animation: translation-float-in 0.5s ease-out both;
|
||||
}
|
||||
|
||||
:deep(.ax-translation-block > :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:deep(.ax-translation-block > :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.ax-translation-style-weakened) {
|
||||
color: var(--color-secondary) !important;
|
||||
}
|
||||
|
||||
:deep(.ax-translation-style-blur) {
|
||||
filter: blur(4px);
|
||||
opacity: 0.75;
|
||||
transition:
|
||||
filter 0.1s ease-in-out,
|
||||
opacity 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
:deep(.ax-translation-style-blur:hover) {
|
||||
filter: blur(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:deep(.ax-translation-style-blockquote) {
|
||||
padding: 4px 0 4px 8px;
|
||||
border-left: 4px solid var(--color-brand);
|
||||
}
|
||||
|
||||
:deep(.ax-translation-style-dashed-line) {
|
||||
text-decoration: underline dashed var(--color-brand) !important;
|
||||
text-underline-offset: 5px;
|
||||
}
|
||||
|
||||
:deep(.ax-translation-style-border) {
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--color-brand);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(.ax-translation-style-text-color) {
|
||||
color: oklch(0.693 0.17 162.48) !important;
|
||||
}
|
||||
|
||||
:deep(.ax-translation-style-background) {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background-color: color-mix(in srgb, var(--color-brand) 15%, transparent);
|
||||
}
|
||||
|
||||
.ax-translation-only.ax-translation-style-weakened {
|
||||
color: var(--color-secondary) !important;
|
||||
}
|
||||
|
||||
.ax-translation-only.ax-translation-style-blur {
|
||||
filter: blur(4px);
|
||||
opacity: 0.75;
|
||||
transition:
|
||||
filter 0.1s ease-in-out,
|
||||
opacity 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.ax-translation-only.ax-translation-style-blur:hover {
|
||||
filter: blur(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ax-translation-only.ax-translation-style-blockquote {
|
||||
padding: 4px 0 4px 8px;
|
||||
border-left: 4px solid var(--color-brand);
|
||||
}
|
||||
|
||||
.ax-translation-only.ax-translation-style-dashed-line {
|
||||
text-decoration: underline dashed var(--color-brand) !important;
|
||||
text-underline-offset: 5px;
|
||||
}
|
||||
|
||||
.ax-translation-only.ax-translation-style-border {
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--color-brand);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ax-translation-only.ax-translation-style-text-color {
|
||||
color: oklch(0.693 0.17 162.48) !important;
|
||||
}
|
||||
|
||||
.ax-translation-only.ax-translation-style-background {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background-color: color-mix(in srgb, var(--color-brand) 15%, transparent);
|
||||
}
|
||||
|
||||
@keyframes translation-float-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
105
apps/app-frontend/src/components/ui/URLConfirmModal.vue
Normal file
105
apps/app-frontend/src/components/ui/URLConfirmModal.vue
Normal file
@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
ProjectCard,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_project_v3, get_version } from '@/helpers/cache.js'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { install: installVersion } = injectContentInstall()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
installProject: { id: 'app.url-install.title', defaultMessage: 'Install {project}' },
|
||||
installingVersion: {
|
||||
id: 'app.url-install.version',
|
||||
defaultMessage: 'Installing version {version}',
|
||||
},
|
||||
})
|
||||
|
||||
const confirmModal = ref(null)
|
||||
const project = ref(null)
|
||||
const version = ref(null)
|
||||
|
||||
defineExpose({
|
||||
async show(event) {
|
||||
if (event.event === 'InstallVersion') {
|
||||
version.value = await get_version(event.id, 'must_revalidate').catch(handleError)
|
||||
project.value = await get_project_v3(version.value.project_id, 'must_revalidate').catch(
|
||||
handleError,
|
||||
)
|
||||
} else {
|
||||
project.value = await get_project_v3(event.id, 'must_revalidate').catch(handleError)
|
||||
version.value = await get_version(
|
||||
project.value.versions[project.value.versions.length - 1],
|
||||
'must_revalidate',
|
||||
).catch(handleError)
|
||||
}
|
||||
confirmModal.value.show()
|
||||
},
|
||||
})
|
||||
|
||||
async function install() {
|
||||
confirmModal.value.hide()
|
||||
await installVersion(
|
||||
project.value.id,
|
||||
version.value.id,
|
||||
null,
|
||||
'URLConfirmModal',
|
||||
() => {},
|
||||
() => {},
|
||||
).catch(handleError)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper
|
||||
ref="confirmModal"
|
||||
:header="formatMessage(messages.installProject, { project: project?.name })"
|
||||
>
|
||||
<div class="modal-body flex flex-col items-center justify-center gap-3">
|
||||
<ProjectCard
|
||||
:title="project.name"
|
||||
:link="() => confirmModal.hide()"
|
||||
:icon-url="project.icon_url"
|
||||
:summary="project.summary"
|
||||
:tags="project.display_categories"
|
||||
:all-tags="project.categories"
|
||||
:downloads="project.downloads"
|
||||
:date-updated="project.date_modified"
|
||||
:banner="project.featured_gallery ?? undefined"
|
||||
:color="project.color ?? undefined"
|
||||
layout="list"
|
||||
class="project-card bg-bg w-full"
|
||||
/>
|
||||
<div class="flex w-full flex-row justify-between items-center gap-3">
|
||||
<div class="markdown-body">
|
||||
<p>
|
||||
{{ formatMessage(messages.installingVersion, { version: version.id }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="install">{{ formatMessage(commonMessages.installButton) }}</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.project-card {
|
||||
:deep(.badge) {
|
||||
border: 1px solid var(--color-raised-bg);
|
||||
background-color: var(--color-accent-contrast);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
107
apps/app-frontend/src/components/ui/WindowControls.vue
Normal file
107
apps/app-frontend/src/components/ui/WindowControls.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<section
|
||||
v-if="showControls"
|
||||
class="flex items-center gap-2 mr-1.5"
|
||||
data-tauri-drag-region-exclude
|
||||
>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button class="relative expanded-button" @click="() => getCurrentWindow().minimize()">
|
||||
<MinimizeIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button class="relative expanded-button" @click="() => getCurrentWindow().toggleMaximize()">
|
||||
<RestoreIcon v-if="isMaximized" />
|
||||
<MaximizeIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
color="red"
|
||||
color-fill="none"
|
||||
hover-color-fill="background"
|
||||
circular
|
||||
>
|
||||
<button class="relative expanded-button close-button" @click="handleClose">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { MaximizeIcon, MinimizeIcon, RestoreIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { get as getSettings } from '@/helpers/settings.ts'
|
||||
import { getOS } from '@/helpers/utils.js'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
const nativeDecorations = ref(true)
|
||||
const isMaximized = ref(false)
|
||||
const os = ref('')
|
||||
const unlistenResize = ref(null)
|
||||
let resizeTimer
|
||||
|
||||
const alwaysShowAppControls = computed(() => themeStore.getFeatureFlag('always_show_app_controls'))
|
||||
|
||||
const showControls = computed(
|
||||
() =>
|
||||
alwaysShowAppControls.value ||
|
||||
(!nativeDecorations.value && (os.value === 'Windows' || os.value === 'Linux')),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
os.value = await getOS()
|
||||
|
||||
const settings = await getSettings()
|
||||
nativeDecorations.value = settings.native_decorations
|
||||
|
||||
if (os.value !== 'MacOS') {
|
||||
await getCurrentWindow().setDecorations(nativeDecorations.value)
|
||||
}
|
||||
|
||||
isMaximized.value = await getCurrentWindow().isMaximized()
|
||||
|
||||
unlistenResize.value = await getCurrentWindow().onResized(() => {
|
||||
// Windows emits a burst of resize events while a game changes display mode.
|
||||
if (resizeTimer) clearTimeout(resizeTimer)
|
||||
resizeTimer = setTimeout(async () => {
|
||||
resizeTimer = undefined
|
||||
try {
|
||||
isMaximized.value = await getCurrentWindow().isMaximized()
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh maximized state after resize', error)
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeTimer) clearTimeout(resizeTimer)
|
||||
if (unlistenResize.value) {
|
||||
unlistenResize.value()
|
||||
}
|
||||
})
|
||||
|
||||
const handleClose = async () => {
|
||||
await saveWindowState(StateFlags.ALL)
|
||||
await getCurrentWindow().close()
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.expanded-button::before {
|
||||
inset: -9px -6px;
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.expanded-button.close-button::before {
|
||||
inset: -9px -9px -9px -6px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon } from '@modrinth/assets'
|
||||
import { Admonition, BulletDivider, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
ANNOUNCEMENT_CHANGE_TYPES,
|
||||
type AnnouncementChangeType,
|
||||
getLocalizedAnnouncementText,
|
||||
type LauncherAnnouncement,
|
||||
} from '@/announcements/catalog'
|
||||
import i18n from '@/i18n.config'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
announcement?: LauncherAnnouncement
|
||||
version?: string | null
|
||||
externalUrl?: string
|
||||
showHeader?: boolean
|
||||
}>(),
|
||||
{
|
||||
announcement: undefined,
|
||||
version: null,
|
||||
externalUrl: undefined,
|
||||
showHeader: true,
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
unknownTitle: {
|
||||
id: 'app.update-announcement.unknown-title',
|
||||
defaultMessage: 'Axolotl Launcher was updated',
|
||||
},
|
||||
unknownBody: {
|
||||
id: 'app.update-announcement.unknown-body',
|
||||
defaultMessage:
|
||||
'This version does not have a bundled announcement yet. Visit the website for the full changelog.',
|
||||
},
|
||||
version: {
|
||||
id: 'app.update-announcement.version',
|
||||
defaultMessage: 'Version {version}',
|
||||
},
|
||||
openChangelog: {
|
||||
id: 'app.update-announcement.open-changelog',
|
||||
defaultMessage: 'Open full changelog',
|
||||
},
|
||||
notes: {
|
||||
id: 'app.update-announcement.notes',
|
||||
defaultMessage: 'Notes',
|
||||
},
|
||||
added: {
|
||||
id: 'app.update-announcement.category.added',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
changed: {
|
||||
id: 'app.update-announcement.category.changed',
|
||||
defaultMessage: 'Changed',
|
||||
},
|
||||
deprecated: {
|
||||
id: 'app.update-announcement.category.deprecated',
|
||||
defaultMessage: 'Deprecated',
|
||||
},
|
||||
removed: {
|
||||
id: 'app.update-announcement.category.removed',
|
||||
defaultMessage: 'Removed',
|
||||
},
|
||||
fixed: {
|
||||
id: 'app.update-announcement.category.fixed',
|
||||
defaultMessage: 'Fixed',
|
||||
},
|
||||
security: {
|
||||
id: 'app.update-announcement.category.security',
|
||||
defaultMessage: 'Security',
|
||||
},
|
||||
})
|
||||
|
||||
const categoryClasses: Record<AnnouncementChangeType, string> = {
|
||||
added: 'bg-brand-green',
|
||||
changed: 'bg-brand-blue',
|
||||
deprecated: 'bg-brand-orange',
|
||||
removed: 'bg-brand-red',
|
||||
fixed: 'bg-brand-purple',
|
||||
security: 'bg-brand-orange',
|
||||
}
|
||||
|
||||
const locale = computed(() => i18n.global.locale.value)
|
||||
const title = computed(() =>
|
||||
props.announcement
|
||||
? getLocalizedAnnouncementText(props.announcement.title, locale.value)
|
||||
: formatMessage(messages.unknownTitle),
|
||||
)
|
||||
const versionLabel = computed(() =>
|
||||
formatMessage(messages.version, { version: props.announcement?.version ?? props.version ?? '—' }),
|
||||
)
|
||||
const categoryRows = computed(() =>
|
||||
ANNOUNCEMENT_CHANGE_TYPES.flatMap((type) => {
|
||||
const changes = props.announcement?.changes[type]
|
||||
if (!changes?.length) return []
|
||||
return [
|
||||
{
|
||||
type,
|
||||
label: formatMessage(messages[type]),
|
||||
className: categoryClasses[type],
|
||||
changes: changes.map((change) => getLocalizedAnnouncementText(change, locale.value)),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
|
||||
async function openChangelog() {
|
||||
if (props.externalUrl) await openUrl(props.externalUrl)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-5 text-primary">
|
||||
<header v-if="showHeader" class="flex min-w-0 flex-col gap-2">
|
||||
<h2 class="m-0 break-words text-xl font-semibold text-contrast">{{ title }}</h2>
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm text-secondary">
|
||||
<span>{{ versionLabel }}</span>
|
||||
<BulletDivider v-if="announcement?.publishedAt" />
|
||||
<time v-if="announcement?.publishedAt" :datetime="announcement.publishedAt">
|
||||
{{ announcement.publishedAt }}
|
||||
</time>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="categoryRows.length" class="flex flex-col">
|
||||
<section
|
||||
v-for="(category, index) in categoryRows"
|
||||
:key="category.type"
|
||||
class="announcement-category grid grid-cols-1 gap-2 py-4 sm:grid-cols-[7rem_minmax(0,1fr)] sm:gap-5"
|
||||
:class="{ 'border-t-0 pt-0': index === 0 }"
|
||||
>
|
||||
<h3 class="m-0 flex items-center gap-2 text-sm font-semibold text-secondary">
|
||||
<span
|
||||
class="size-2 shrink-0 rounded-full"
|
||||
:class="category.className"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ category.label }}
|
||||
</h3>
|
||||
<ul class="m-0 flex list-disc flex-col gap-2 pl-5 leading-relaxed text-primary">
|
||||
<li v-for="change in category.changes" :key="change">{{ change }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<Admonition v-else type="info" :body="formatMessage(messages.unknownBody)" />
|
||||
|
||||
<div v-if="announcement?.notes" class="announcement-notes">
|
||||
<h3 class="m-0 mb-2 text-sm font-semibold text-secondary">
|
||||
{{ formatMessage(messages.notes) }}
|
||||
</h3>
|
||||
<p class="m-0 leading-relaxed text-primary">
|
||||
{{ getLocalizedAnnouncementText(announcement.notes, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ButtonStyled v-if="externalUrl" color="brand" type="outlined" class="self-start">
|
||||
<button type="button" @click="openChangelog">
|
||||
<ExternalIcon />
|
||||
{{ formatMessage(messages.openChangelog) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.announcement-category {
|
||||
border-top: 1px solid
|
||||
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
|
||||
}
|
||||
|
||||
.announcement-notes {
|
||||
padding-top: var(--gap-md);
|
||||
border-top: 1px solid
|
||||
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { CalendarIcon, HistoryIcon } from '@modrinth/assets'
|
||||
import { Accordion, defineMessages, TagItem, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
getAnnouncementByVersion,
|
||||
getAnnouncements,
|
||||
getLocalizedAnnouncementText,
|
||||
} from '@/announcements/catalog'
|
||||
import { AxolotlBrandConfig } from '@/config'
|
||||
import i18n from '@/i18n.config'
|
||||
|
||||
import UpdateAnnouncementContent from './UpdateAnnouncementContent.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
currentVersion: string
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.settings.updates.announcements.title',
|
||||
defaultMessage: 'Update announcements',
|
||||
},
|
||||
description: {
|
||||
id: 'app.settings.updates.announcements.description',
|
||||
defaultMessage: 'See what changed in this version and browse previous releases.',
|
||||
},
|
||||
history: {
|
||||
id: 'app.settings.updates.announcements.history',
|
||||
defaultMessage: 'Version history',
|
||||
},
|
||||
empty: {
|
||||
id: 'app.settings.updates.announcements.empty',
|
||||
defaultMessage: 'No bundled update announcements are available.',
|
||||
},
|
||||
})
|
||||
|
||||
const locale = computed(() => i18n.global.locale.value)
|
||||
const launcherAnnouncements = getAnnouncements()
|
||||
const currentAnnouncement = computed(() => getAnnouncementByVersion(props.currentVersion))
|
||||
const historyAnnouncements = computed(() =>
|
||||
launcherAnnouncements.filter((announcement) => announcement.id !== currentAnnouncement.value?.id),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="update-announcement-history">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.title) }}
|
||||
</h2>
|
||||
<p class="m-0 leading-relaxed text-secondary">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<UpdateAnnouncementContent
|
||||
:announcement="currentAnnouncement"
|
||||
:version="currentVersion"
|
||||
:external-url="currentAnnouncement?.externalUrl ?? AxolotlBrandConfig.website"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-3">
|
||||
<h3 class="m-0 flex items-center gap-2 text-base font-semibold text-contrast">
|
||||
<HistoryIcon aria-hidden="true" class="size-4 text-secondary" />
|
||||
{{ formatMessage(messages.history) }}
|
||||
</h3>
|
||||
<p v-if="historyAnnouncements.length === 0" class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<div v-else class="flex min-w-0 flex-col gap-2">
|
||||
<Accordion
|
||||
v-for="announcement in historyAnnouncements"
|
||||
:key="announcement.id"
|
||||
class="update-announcement-history-item hover:border-surface-4 focus-within:border-surface-4"
|
||||
button-class="group flex w-full cursor-pointer items-center gap-3 border-0 bg-transparent px-4 py-3 text-left"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span
|
||||
class="truncate font-semibold text-primary transition-colors group-hover:text-contrast"
|
||||
>
|
||||
{{ getLocalizedAnnouncementText(announcement.title, locale) }}
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-secondary">
|
||||
<TagItem class="px-1.5 py-0.5 text-xs">v{{ announcement.version }}</TagItem>
|
||||
<span class="flex items-center gap-1">
|
||||
<CalendarIcon aria-hidden="true" class="size-3.5" />
|
||||
<time :datetime="announcement.publishedAt">{{ announcement.publishedAt }}</time>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="update-announcement-history-item-content">
|
||||
<UpdateAnnouncementContent
|
||||
:announcement="announcement"
|
||||
:show-header="false"
|
||||
:external-url="announcement.externalUrl"
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.update-announcement-history {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xl);
|
||||
padding: var(--gap-xl);
|
||||
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);
|
||||
}
|
||||
|
||||
.update-announcement-history-item {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid
|
||||
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-3);
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
|
||||
.update-announcement-history-item-content {
|
||||
padding: var(--gap-lg);
|
||||
border-top: 1px solid
|
||||
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
|
||||
}
|
||||
</style>
|
||||
124
apps/app-frontend/src/components/ui/app-update-button/index.vue
Normal file
124
apps/app-frontend/src/components/ui/app-update-button/index.vue
Normal file
@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
|
||||
<button
|
||||
v-if="showUpdatePill"
|
||||
type="button"
|
||||
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out"
|
||||
:class="{
|
||||
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
|
||||
'opacity-100 scale-100': finishedDownloading && animateReadyPill,
|
||||
}"
|
||||
:disabled="isUpdateDownloading"
|
||||
:aria-busy="isUpdateDownloading"
|
||||
@click="handleUpdateClick"
|
||||
>
|
||||
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
|
||||
<DownloadIcon v-else />
|
||||
<span v-if="isUpdateDownloading">
|
||||
{{ formatMessage(messages.downloadingUpdate) }}
|
||||
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
|
||||
</span>
|
||||
<span v-else>{{ updateLabel }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, RefreshCwIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
appUpdateState,
|
||||
downloadAvailableAppUpdate,
|
||||
installAvailableAppUpdate,
|
||||
} from '@/providers/app-update'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
update: {
|
||||
id: 'app.action-bar.update',
|
||||
defaultMessage: 'Update',
|
||||
},
|
||||
downloadingUpdate: {
|
||||
id: 'app.action-bar.downloading-update',
|
||||
defaultMessage: 'Downloading update',
|
||||
},
|
||||
reloadToUpdate: {
|
||||
id: 'app.action-bar.reload-to-update',
|
||||
defaultMessage: 'Reload to update',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
downloading,
|
||||
downloadPercent,
|
||||
downloadProgress,
|
||||
finishedDownloading,
|
||||
isVisible: isUpdateVisible,
|
||||
metered,
|
||||
restarting,
|
||||
} = appUpdateState
|
||||
|
||||
const isUpdateDownloading = computed(
|
||||
() =>
|
||||
downloading.value ||
|
||||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
|
||||
)
|
||||
const showUpdatePill = computed(
|
||||
() => isUpdateVisible.value && (finishedDownloading.value || metered.value),
|
||||
)
|
||||
const animateReadyPill = ref(false)
|
||||
const updateLabel = computed(() => {
|
||||
if (isUpdateDownloading.value) {
|
||||
return formatMessage(messages.downloadingUpdate)
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
return formatMessage(messages.reloadToUpdate)
|
||||
}
|
||||
|
||||
return formatMessage(messages.update)
|
||||
})
|
||||
let readyPillAnimationFrame: number | null = null
|
||||
watch([showUpdatePill, finishedDownloading], async ([show, ready], [wasShown, wasReady]) => {
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
readyPillAnimationFrame = null
|
||||
}
|
||||
|
||||
if (!show || !ready) {
|
||||
animateReadyPill.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (wasShown && wasReady) {
|
||||
return
|
||||
}
|
||||
|
||||
animateReadyPill.value = false
|
||||
await nextTick()
|
||||
readyPillAnimationFrame = requestAnimationFrame(() => {
|
||||
animateReadyPill.value = true
|
||||
readyPillAnimationFrame = null
|
||||
})
|
||||
})
|
||||
async function handleUpdateClick() {
|
||||
if (isUpdateDownloading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
await installAvailableAppUpdate()
|
||||
} else {
|
||||
await downloadAvailableAppUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
70
apps/app-frontend/src/components/ui/breadcrumbs.test.ts
Normal file
70
apps/app-frontend/src/components/ui/breadcrumbs.test.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { resolveBreadcrumbLabel } from '../../helpers/breadcrumb-label.ts'
|
||||
|
||||
const localeFiles = {
|
||||
'en-US': '../../locales/en-US/index.json',
|
||||
'zh-CN': '../../locales/zh-CN/index.json',
|
||||
'zh-TW': '../../locales/zh-TW/index.json',
|
||||
} as const
|
||||
|
||||
const localeMessages = Object.fromEntries(
|
||||
Object.entries(localeFiles).map(([locale, path]) => [
|
||||
locale,
|
||||
JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8')) as Record<
|
||||
string,
|
||||
{ message: string }
|
||||
>,
|
||||
]),
|
||||
) as Record<keyof typeof localeFiles, Record<string, { message: string }>>
|
||||
|
||||
test('upgrade breadcrumb reuses localized Upgrade instance message at runtime', () => {
|
||||
let locale: keyof typeof localeFiles = 'en-US'
|
||||
const labels = { Upgrade: 'app.instance.upgrade-instance' }
|
||||
const resolve = () =>
|
||||
resolveBreadcrumbLabel(
|
||||
'Upgrade',
|
||||
() => '',
|
||||
labels,
|
||||
(messageId) => localeMessages[locale][messageId].message,
|
||||
)
|
||||
|
||||
assert.equal(resolve(), 'Upgrade instance')
|
||||
locale = 'zh-CN'
|
||||
assert.equal(resolve(), '升级实例')
|
||||
locale = 'zh-TW'
|
||||
assert.equal(resolve(), '升級實例')
|
||||
})
|
||||
|
||||
test('upgrade route paths, internal names, and breadcrumb depth stay unchanged', () => {
|
||||
const routes = readFileSync(new URL('../../routes.js', import.meta.url), 'utf8')
|
||||
assert.match(routes, /useRootContext: true,[\s\S]*?breadcrumb: \[\{ name: 'Upgrade' \}\]/)
|
||||
assert.doesNotMatch(routes, /breadcrumb: \[\{ name: '\?Instance'[^\]]*\{ name: 'Upgrade' \}\]/)
|
||||
for (const [path, name] of [
|
||||
['', 'InstanceUpgrade'],
|
||||
['compatibility', 'InstanceUpgradeCompatibility'],
|
||||
['customize', 'InstanceUpgradeCustomize'],
|
||||
['confirm', 'InstanceUpgradeConfirm'],
|
||||
['progress', 'InstanceUpgradeProgress'],
|
||||
['result', 'InstanceUpgradeResult'],
|
||||
] as const) {
|
||||
assert.match(routes, new RegExp(`path: '${path}',\\s+name: '${name}'`))
|
||||
}
|
||||
})
|
||||
|
||||
test('breadcrumb component resolves Upgrade through formatMessage on each render', () => {
|
||||
const source = readFileSync(new URL('./Breadcrumbs.vue', import.meta.url), 'utf8')
|
||||
assert.match(source, /Upgrade: messages\.upgradeInstance/)
|
||||
assert.match(source, /Upgrade: ArrowBigUpDashIcon/)
|
||||
assert.match(source, /id: 'app\.instance\.upgrade-instance'/)
|
||||
assert.match(source, /resolveBreadcrumbLabel\([\s\S]*?\(message\) => formatMessage\(message\)/)
|
||||
})
|
||||
|
||||
test('breadcrumb separators render only between visible breadcrumb items', () => {
|
||||
const source = readFileSync(new URL('./Breadcrumbs.vue', import.meta.url), 'utf8')
|
||||
assert.match(source, /v-for="\(breadcrumb, index\) in breadcrumbs"/)
|
||||
assert.match(source, /v-if="index < breadcrumbs\.length - 1"/)
|
||||
assert.doesNotMatch(source, /ChevronRightIcon v-if="breadcrumb\.link"/)
|
||||
})
|
||||
@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
test('context menu removes global listeners from their registration targets', () => {
|
||||
const source = readFileSync(new URL('./ContextMenu.vue', import.meta.url), 'utf8')
|
||||
|
||||
assert.match(source, /window\.addEventListener\('click', handleClickOutside\)/)
|
||||
assert.match(source, /window\.removeEventListener\('click', handleClickOutside\)/)
|
||||
assert.match(source, /document\.body\.addEventListener\('keyup', onEscKeyRelease\)/)
|
||||
assert.match(source, /document\.body\.removeEventListener\('keyup', onEscKeyRelease\)/)
|
||||
})
|
||||
@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { Avatar, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
openGame: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.settings.about.easteregg.contributors-title',
|
||||
defaultMessage: '贡献者彩蛋',
|
||||
},
|
||||
clickHint: {
|
||||
id: 'app.settings.about.easteregg.click-hint',
|
||||
defaultMessage: 'Click to open a hidden Mini Game',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
|
||||
const contributors = [
|
||||
{
|
||||
name: 'cyf112233',
|
||||
avatarUrl: `${window.location.origin}/easteregg/avatars/cyf112233.jpg`,
|
||||
},
|
||||
]
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function selectContributor() {
|
||||
modal.value?.hide()
|
||||
emit('openGame')
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
width="min(480px, calc(100vw - 2rem))"
|
||||
max-width="480px"
|
||||
>
|
||||
<ul class="m-0 list-none p-0">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-xl bg-surface-4 p-4 text-left transition-colors hover:bg-surface-5"
|
||||
@click="selectContributor"
|
||||
>
|
||||
<Avatar
|
||||
:src="contributors[0].avatarUrl"
|
||||
:alt="contributors[0].name"
|
||||
size="4rem"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0">
|
||||
<span class="block font-semibold text-contrast">{{ contributors[0].name }}</span>
|
||||
<span class="block text-sm text-secondary">
|
||||
{{ formatMessage(messages.clickHint) }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</NewModal>
|
||||
</template>
|
||||
@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.settings.about.easteregg.game-title',
|
||||
defaultMessage: 'Mini Game',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const gameVisible = ref(false)
|
||||
|
||||
const gameUrl = `${window.location.origin}/easteregg/games/game.html`
|
||||
|
||||
function show() {
|
||||
gameVisible.value = true
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function onHide() {
|
||||
gameVisible.value = false
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
width="min(832px, calc(100vw - 2rem))"
|
||||
max-width="832px"
|
||||
noblur
|
||||
:on-hide="onHide"
|
||||
>
|
||||
<div class="flex flex-col items-center py-2">
|
||||
<iframe
|
||||
v-if="gameVisible"
|
||||
:src="gameUrl"
|
||||
title="Mini Game"
|
||||
class="h-[600px] w-[800px] max-w-full rounded-xl border-none bg-black"
|
||||
/>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
422
apps/app-frontend/src/components/ui/friends/FriendsList.vue
Normal file
422
apps/app-frontend/src/components/ui/friends/FriendsList.vue
Normal file
@ -0,0 +1,422 @@
|
||||
<script setup lang="ts">
|
||||
import { MailIcon, SearchIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
StyledInput,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { friend_listener } from '@/helpers/events'
|
||||
import {
|
||||
add_friend,
|
||||
friends,
|
||||
type FriendWithUserData,
|
||||
remove_friend,
|
||||
transformFriends,
|
||||
} from '@/helpers/friends.ts'
|
||||
import type { ModrinthCredentials } from '@/helpers/mr_auth'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const props = defineProps<{
|
||||
credentials: ModrinthCredentials | null
|
||||
signIn: () => void
|
||||
}>()
|
||||
|
||||
const userCredentials = computed(() => props.credentials)
|
||||
|
||||
const search = ref('')
|
||||
const friendInvitesModal = ref()
|
||||
|
||||
const username = ref('')
|
||||
const addFriendModal = ref()
|
||||
async function addFriendFromModal() {
|
||||
addFriendModal.value.hide()
|
||||
await add_friend(username.value).catch(handleError)
|
||||
username.value = ''
|
||||
await loadFriends()
|
||||
}
|
||||
|
||||
async function addFriend(friend: FriendWithUserData) {
|
||||
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
|
||||
if (id) {
|
||||
await add_friend(id).catch(handleError)
|
||||
await loadFriends()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFriend(friend: FriendWithUserData) {
|
||||
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
|
||||
if (id) {
|
||||
await remove_friend(id).catch(handleError)
|
||||
await loadFriends()
|
||||
}
|
||||
}
|
||||
|
||||
const userFriends = ref<FriendWithUserData[]>([])
|
||||
const sortedFriends = computed<FriendWithUserData[]>(() =>
|
||||
userFriends.value.slice().sort((a, b) => {
|
||||
if (a.last_updated === null && b.last_updated === null) {
|
||||
return 0 // Both are null, equal in sorting
|
||||
}
|
||||
if (a.last_updated === null) {
|
||||
return 1 // `a` is null, move it after `b`
|
||||
}
|
||||
if (b.last_updated === null) {
|
||||
return -1 // `b` is null, move it after `a`
|
||||
}
|
||||
// Both are non-null, sort by date
|
||||
return b.last_updated.diff(a.last_updated)
|
||||
}),
|
||||
)
|
||||
const filteredFriends = computed<FriendWithUserData[]>(() =>
|
||||
sortedFriends.value.filter((x) =>
|
||||
x.username.trim().toLowerCase().includes(search.value.trim().toLowerCase()),
|
||||
),
|
||||
)
|
||||
|
||||
const activeFriends = computed<FriendWithUserData[]>(() =>
|
||||
filteredFriends.value.filter((x) => !!x.status && x.online && x.accepted),
|
||||
)
|
||||
const onlineFriends = computed<FriendWithUserData[]>(() =>
|
||||
filteredFriends.value.filter((x) => x.online && !x.status && x.accepted),
|
||||
)
|
||||
const offlineFriends = computed<FriendWithUserData[]>(() =>
|
||||
filteredFriends.value.filter((x) => !x.online && x.accepted),
|
||||
)
|
||||
const pendingFriends = computed(() =>
|
||||
filteredFriends.value
|
||||
.filter((x) => !x.accepted && x.id !== userCredentials.value?.user_id)
|
||||
.slice()
|
||||
.sort((a, b) => b.created.diff(a.created)),
|
||||
)
|
||||
const incomingRequests = computed(() =>
|
||||
userFriends.value
|
||||
.filter((x) => !x.accepted && x.id === userCredentials.value?.user_id)
|
||||
.slice()
|
||||
.sort((a, b) => b.created.diff(a.created)),
|
||||
)
|
||||
|
||||
const loading = ref(true)
|
||||
async function loadFriends(timeout = false) {
|
||||
loading.value = timeout
|
||||
|
||||
try {
|
||||
const friendsList = await friends()
|
||||
userFriends.value = await transformFriends(friendsList, userCredentials.value)
|
||||
loading.value = false
|
||||
} catch (e) {
|
||||
console.error('Error loading friends', e)
|
||||
if (timeout) {
|
||||
setTimeout(() => loadFriends(), 15 * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
userCredentials,
|
||||
() => {
|
||||
if (userCredentials.value === undefined) {
|
||||
userFriends.value = []
|
||||
loading.value = false
|
||||
} else if (userCredentials.value === null) {
|
||||
userFriends.value = []
|
||||
loading.value = false
|
||||
} else {
|
||||
loadFriends(true)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const unlisten = await friend_listener(() => loadFriends())
|
||||
onUnmounted(() => {
|
||||
unlisten()
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
addFriend: {
|
||||
id: 'friends.action.add-friend',
|
||||
defaultMessage: 'Add a friend',
|
||||
},
|
||||
addingAFriend: {
|
||||
id: 'friends.add-friend.title',
|
||||
defaultMessage: 'Adding a friend',
|
||||
},
|
||||
usernameTitle: {
|
||||
id: 'friends.add-friend.username.title',
|
||||
defaultMessage: "What's your friend's Modrinth username?",
|
||||
},
|
||||
usernameDescription: {
|
||||
id: 'friends.add-friend.username.description',
|
||||
defaultMessage: 'It may be different from their Minecraft username!',
|
||||
},
|
||||
usernamePlaceholder: {
|
||||
id: 'friends.add-friend.username.placeholder',
|
||||
defaultMessage: 'Enter Modrinth username...',
|
||||
},
|
||||
sendFriendRequest: {
|
||||
id: 'friends.add-friend.submit',
|
||||
defaultMessage: 'Send friend request',
|
||||
},
|
||||
viewFriendRequests: {
|
||||
id: 'friends.action.view-friend-requests',
|
||||
defaultMessage: '{count} friend {count, plural, one {request} other {requests}}',
|
||||
},
|
||||
searchFriends: {
|
||||
id: 'friends.search-friends-placeholder',
|
||||
defaultMessage: 'Search friends...',
|
||||
},
|
||||
friends: {
|
||||
id: 'friends.heading',
|
||||
defaultMessage: 'Friends',
|
||||
},
|
||||
pending: {
|
||||
id: 'friends.heading.pending',
|
||||
defaultMessage: 'Pending',
|
||||
},
|
||||
active: {
|
||||
id: 'friends.heading.active',
|
||||
defaultMessage: 'Active',
|
||||
},
|
||||
online: {
|
||||
id: 'friends.heading.online',
|
||||
defaultMessage: 'Online',
|
||||
},
|
||||
offline: {
|
||||
id: 'friends.heading.offline',
|
||||
defaultMessage: 'Offline',
|
||||
},
|
||||
noFriendsMatch: {
|
||||
id: 'friends.no-friends-match',
|
||||
defaultMessage: `No friends matching ''{query}''`,
|
||||
},
|
||||
signInToAddFriends: {
|
||||
id: 'friends.sign-in-to-add-friends',
|
||||
defaultMessage:
|
||||
"<link>Sign in to a Modrinth account</link> to add friends and see what they're playing!",
|
||||
},
|
||||
addFriendsToShare: {
|
||||
id: 'friends.add-friends-to-share',
|
||||
defaultMessage: "<link>Add friends</link> to see what they're playing!",
|
||||
},
|
||||
viewRequests: { id: 'friends.requests.title', defaultMessage: 'View friend requests' },
|
||||
noPendingRequests: {
|
||||
id: 'friends.requests.none',
|
||||
defaultMessage: 'You have no pending friend requests :C',
|
||||
},
|
||||
incomingRequest: {
|
||||
id: 'friends.requests.incoming',
|
||||
defaultMessage: '{username} sent you a friend request',
|
||||
},
|
||||
outgoingRequest: {
|
||||
id: 'friends.requests.outgoing',
|
||||
defaultMessage: 'You sent {username} a friend request',
|
||||
},
|
||||
accept: { id: 'friends.requests.accept', defaultMessage: 'Accept' },
|
||||
ignore: { id: 'friends.requests.ignore', defaultMessage: 'Ignore' },
|
||||
cancel: { id: 'friends.requests.cancel', defaultMessage: 'Cancel' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="friendInvitesModal" :header="formatMessage(messages.viewRequests)">
|
||||
<p v-if="incomingRequests.length === 0">{{ formatMessage(messages.noPendingRequests) }}</p>
|
||||
<div v-else class="flex flex-col gap-4 min-w-[40rem]">
|
||||
<div v-for="friend in incomingRequests" :key="friend.username" class="flex gap-2">
|
||||
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
|
||||
<div class="grid grid-cols-[1fr_auto] w-full gap-4">
|
||||
<div>
|
||||
<p class="m-0">
|
||||
<template v-if="friend.id === userCredentials?.user_id">
|
||||
{{ formatMessage(messages.incomingRequest, { username: friend.username }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatMessage(messages.outgoingRequest, { username: friend.username }) }}
|
||||
</template>
|
||||
</p>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ formatRelativeTime(friend.created.toISOString()) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<template v-if="friend.id === userCredentials?.user_id">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="addFriend(friend)">
|
||||
<UserPlusIcon />
|
||||
{{ formatMessage(messages.accept) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="removeFriend(friend)">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.ignore) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ButtonStyled>
|
||||
<button @click="removeFriend(friend)">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="addFriendModal" :header="formatMessage(messages.addingAFriend)">
|
||||
<div class="min-w-[30rem]">
|
||||
<h2 class="m-0 text-base font-medium text-primary">
|
||||
{{ formatMessage(messages.usernameTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1 text-sm text-secondary leading-tight">
|
||||
{{ formatMessage(messages.usernameDescription) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<StyledInput
|
||||
v-model="username"
|
||||
:icon="UserIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.usernamePlaceholder)"
|
||||
wrapper-class="flex-1"
|
||||
@keyup.enter="addFriendFromModal"
|
||||
/>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="username.length === 0" @click="addFriendFromModal">
|
||||
<SendIcon />
|
||||
{{ formatMessage(messages.sendFriendRequest) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 -ml-1">
|
||||
<template v-if="sortedFriends.length > 0">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.addFriend)"
|
||||
:aria-label="formatMessage(messages.addFriend)"
|
||||
@click="addFriendModal.show"
|
||||
>
|
||||
<UserPlusIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.searchFriends)"
|
||||
clearable
|
||||
input-class="!bg-transparent !border !border-solid !border-button-bg !text-primary !placeholder:text-primary"
|
||||
wrapper-class="flex-1 [&>svg]:!text-primary [&>svg]:!opacity-100"
|
||||
@keyup.esc="search = ''"
|
||||
/>
|
||||
</template>
|
||||
<h3 v-else class="w-full text-base text-primary font-medium m-0">
|
||||
{{ formatMessage(messages.friends) }}
|
||||
</h3>
|
||||
<ButtonStyled v-if="incomingRequests.length > 0" circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
|
||||
class="relative"
|
||||
:aria-label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
|
||||
@click="friendInvitesModal.show"
|
||||
>
|
||||
<MailIcon />
|
||||
<span
|
||||
v-if="incomingRequests.length > 0"
|
||||
aria-hidden="true"
|
||||
class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
|
||||
>
|
||||
{{ incomingRequests.length }}
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 v-if="loading" class="text-base text-primary font-medium m-0">
|
||||
{{ formatMessage(messages.friends) }}
|
||||
</h3>
|
||||
<template v-if="loading">
|
||||
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse">
|
||||
<div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div>
|
||||
<div class="h-2.5 bg-button-bg rounded-full w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="sortedFriends.length === 0">
|
||||
<div class="text-sm">
|
||||
<div v-if="!userCredentials">
|
||||
<IntlFormatted :message-id="messages.signInToAddFriends">
|
||||
<template #link="{ children }">
|
||||
<span class="font-semibold text-brand cursor-pointer" @click="signIn">
|
||||
<component :is="() => children" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
<div v-else>
|
||||
<IntlFormatted :message-id="messages.addFriendsToShare">
|
||||
<template #link="{ children }">
|
||||
<span class="font-semibold text-brand cursor-pointer" @click="addFriendModal.show">
|
||||
<component :is="() => children" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<FriendsSection
|
||||
v-if="activeFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
open-by-default
|
||||
:friends="activeFriends"
|
||||
:heading="formatMessage(messages.active)"
|
||||
:remove-friend="removeFriend"
|
||||
/>
|
||||
<FriendsSection
|
||||
v-if="onlineFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
open-by-default
|
||||
:friends="onlineFriends"
|
||||
:heading="formatMessage(messages.online)"
|
||||
:remove-friend="removeFriend"
|
||||
/>
|
||||
<FriendsSection
|
||||
v-if="offlineFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
:open-by-default="activeFriends.length + onlineFriends.length < 3"
|
||||
:friends="offlineFriends"
|
||||
:heading="formatMessage(messages.offline)"
|
||||
:remove-friend="removeFriend"
|
||||
/>
|
||||
<FriendsSection
|
||||
v-if="pendingFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
:friends="pendingFriends"
|
||||
:heading="formatMessage(messages.pending)"
|
||||
:remove-friend="removeFriend"
|
||||
/>
|
||||
<p v-if="filteredFriends.length === 0 && search" class="text-sm text-secondary my-1 mx-4">
|
||||
{{ formatMessage(messages.noFriendsMatch, { query: search }) }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
191
apps/app-frontend/src/components/ui/friends/FriendsSection.vue
Normal file
191
apps/app-frontend/src/components/ui/friends/FriendsSection.vue
Normal file
@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
OverflowMenu,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { FriendWithUserData } from '@/helpers/friends.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
friends: FriendWithUserData[]
|
||||
heading: string
|
||||
removeFriend: (friend: FriendWithUserData) => Promise<void>
|
||||
isSearching?: boolean
|
||||
openByDefault?: boolean
|
||||
}>(),
|
||||
{
|
||||
isSearching: false,
|
||||
openByDefault: false,
|
||||
},
|
||||
)
|
||||
|
||||
function createContextMenuOptions(friend: FriendWithUserData) {
|
||||
if (friend.accepted) {
|
||||
return [
|
||||
{
|
||||
name: 'view-profile',
|
||||
},
|
||||
{
|
||||
name: 'remove-friend',
|
||||
color: 'danger',
|
||||
},
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
name: 'view-profile',
|
||||
},
|
||||
{
|
||||
name: 'cancel-request',
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function openProfile(username: string) {
|
||||
openUrl('https://modrinth.com/user/' + username)
|
||||
}
|
||||
|
||||
const friendOptions = useTemplateRef('friendOptions')
|
||||
async function handleFriendOptions(args: { item: FriendWithUserData; option: string }) {
|
||||
switch (args.option) {
|
||||
case 'remove-friend':
|
||||
case 'cancel-request':
|
||||
await props.removeFriend(args.item)
|
||||
break
|
||||
case 'view-profile':
|
||||
openProfile(args.item.username)
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
removeFriend: {
|
||||
id: 'friends.friend.remove-friend',
|
||||
defaultMessage: 'Remove friend',
|
||||
},
|
||||
heading: {
|
||||
id: 'friends.section.heading',
|
||||
defaultMessage: '{title} - {count}',
|
||||
},
|
||||
friendRequestSent: {
|
||||
id: 'friends.friend.request-sent',
|
||||
defaultMessage: 'Friend request sent',
|
||||
},
|
||||
cancelRequest: {
|
||||
id: 'friends.friend.cancel-request',
|
||||
defaultMessage: 'Cancel request',
|
||||
},
|
||||
viewProfile: {
|
||||
id: 'friends.friend.view-profile',
|
||||
defaultMessage: 'View profile',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
|
||||
<template #view-profile>
|
||||
<UserIcon />
|
||||
{{ formatMessage(messages.viewProfile) }}
|
||||
</template>
|
||||
<template #remove-friend> <TrashIcon /> {{ formatMessage(messages.removeFriend) }} </template>
|
||||
<template #cancel-request> <XIcon /> {{ formatMessage(messages.cancelRequest) }} </template>
|
||||
</ContextMenu>
|
||||
<Accordion
|
||||
:open-by-default="openByDefault"
|
||||
:force-open="isSearching"
|
||||
:button-class="
|
||||
'flex w-full items-center bg-transparent border-0 p-0' +
|
||||
(isSearching
|
||||
? ''
|
||||
: ' cursor-pointer hover:brightness-[--hover-brightness] active:scale-[0.98] transition-all')
|
||||
"
|
||||
>
|
||||
<template #title>
|
||||
<h3 class="text-base text-primary font-medium m-0">
|
||||
{{ formatMessage(messages.heading, { title: heading, count: friends.length }) }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="pt-3 flex flex-col gap-1">
|
||||
<div
|
||||
v-for="friend in friends"
|
||||
:key="friend.username"
|
||||
class="group grid items-center grid-cols-[auto_1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
|
||||
@contextmenu.prevent.stop="
|
||||
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
|
||||
"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="friend.avatar"
|
||||
:class="{ grayscale: !friend.online && friend.accepted }"
|
||||
class="w-12 h-12 rounded-full"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<span
|
||||
v-if="friend.online"
|
||||
aria-hidden="true"
|
||||
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-sm m-0"
|
||||
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ friend.username }}
|
||||
</span>
|
||||
<span v-if="!friend.accepted" class="m-0 text-xs">
|
||||
{{ formatMessage(messages.friendRequestSent) }}
|
||||
</span>
|
||||
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
|
||||
</div>
|
||||
<ButtonStyled v-if="friend.accepted" circular type="transparent">
|
||||
<OverflowMenu
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:options="[
|
||||
{
|
||||
id: 'view-profile',
|
||||
action: () => openProfile(friend.username),
|
||||
},
|
||||
{
|
||||
id: 'remove-friend',
|
||||
action: () => removeFriend(friend),
|
||||
color: 'red',
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #view-profile>
|
||||
<UserIcon />
|
||||
{{ formatMessage(messages.viewProfile) }}
|
||||
</template>
|
||||
<template #remove-friend>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.removeFriend) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else type="transparent" circular>
|
||||
<button v-tooltip="formatMessage(messages.cancelRequest)" @click="removeFriend(friend)">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Accordion>
|
||||
</template>
|
||||
@ -0,0 +1,176 @@
|
||||
<script setup>
|
||||
import { CheckIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
addServer: { id: 'app.server.add-to-instance', defaultMessage: 'Add server to instance' },
|
||||
compatibilityWarning: {
|
||||
id: 'app.server.compatibility-warning',
|
||||
defaultMessage: 'This server may not be compatible with all instances.',
|
||||
},
|
||||
searchInstance: {
|
||||
id: 'app.server.search-instance',
|
||||
defaultMessage: 'Search for an instance',
|
||||
},
|
||||
adding: { id: 'app.server.adding', defaultMessage: 'Adding...' },
|
||||
added: { id: 'app.server.added', defaultMessage: 'Added' },
|
||||
add: { id: 'app.server.add', defaultMessage: 'Add' },
|
||||
symlinkWarningHeader: {
|
||||
id: 'app.symlink-warning.write.header',
|
||||
defaultMessage: 'Shared instance',
|
||||
},
|
||||
symlinkWarningBody: {
|
||||
id: 'app.symlink-warning.write.body',
|
||||
defaultMessage:
|
||||
'This instance is linked to "{path}". Changes will also affect the original files.',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps({
|
||||
symlinkTarget: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref()
|
||||
const searchFilter = ref('')
|
||||
const instances = ref([])
|
||||
|
||||
const serverName = ref('')
|
||||
const serverAddress = ref('')
|
||||
|
||||
const shownInstances = computed(() =>
|
||||
instances.value.filter((instance) => {
|
||||
return instance.name.toLowerCase().includes(searchFilter.value.toLowerCase())
|
||||
}),
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
show: async (name, address) => {
|
||||
serverName.value = name
|
||||
serverAddress.value = address
|
||||
searchFilter.value = ''
|
||||
|
||||
const instanceValues = await list().catch(handleError)
|
||||
await Promise.allSettled(
|
||||
instanceValues.map(async (instance) => {
|
||||
instance.adding = false
|
||||
instance.added = false
|
||||
|
||||
try {
|
||||
const worlds = await get_instance_worlds(instance.id)
|
||||
instance.added = worlds.some(
|
||||
(w) => w.type === 'server' && w.address === serverAddress.value,
|
||||
)
|
||||
} catch {
|
||||
// Ignore - will show as not added
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
instances.value = instanceValues
|
||||
modal.value.show()
|
||||
|
||||
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
|
||||
},
|
||||
})
|
||||
|
||||
async function addServer(instance) {
|
||||
instance.adding = true
|
||||
try {
|
||||
await add_server_to_instance(instance.id, serverName.value, serverAddress.value, 'prompt')
|
||||
instance.added = true
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
|
||||
|
||||
trackEvent('AddServerToInstance', {
|
||||
server_name: serverName.value,
|
||||
instance_name: instance.name,
|
||||
source: 'AddServerToInstanceModal',
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
}
|
||||
instance.adding = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="modal" :header="formatMessage(messages.addServer)">
|
||||
<div class="flex flex-col gap-4 min-w-[350px]">
|
||||
<Admonition
|
||||
v-if="symlinkTarget"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.symlinkWarningHeader)"
|
||||
>
|
||||
{{ formatMessage(messages.symlinkWarningBody, { path: symlinkTarget }) }}
|
||||
</Admonition>
|
||||
<Admonition type="warning" :body="formatMessage(messages.compatibilityWarning)" />
|
||||
<StyledInput
|
||||
v-model="searchFilter"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
:placeholder="formatMessage(messages.searchInstance)"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="max-h-[21rem] overflow-y-auto">
|
||||
<div
|
||||
v-for="instance in shownInstances"
|
||||
:key="instance.id"
|
||||
class="flex w-full items-center justify-between gap-2 bg-bg-raised text-icon shadow-none"
|
||||
>
|
||||
<router-link
|
||||
class="btn btn-transparent p-2 text-left"
|
||||
:to="`/instance/${encodeURIComponent(instance.id)}`"
|
||||
@click="modal.hide()"
|
||||
>
|
||||
<InstanceIcon
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
class="mr-2 [--size:2rem]"
|
||||
/>
|
||||
{{ instance.name }}
|
||||
</router-link>
|
||||
<ButtonStyled>
|
||||
<button :disabled="instance.added || instance.adding" @click="addServer(instance)">
|
||||
<PlusIcon v-if="!instance.added && !instance.adding" />
|
||||
<CheckIcon v-else-if="instance.added" />
|
||||
{{
|
||||
instance.adding
|
||||
? formatMessage(messages.adding)
|
||||
: instance.added
|
||||
? formatMessage(messages.added)
|
||||
: formatMessage(messages.add)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group push-right">
|
||||
<ButtonStyled>
|
||||
<button @click="modal.hide()">{{ formatMessage(commonMessages.cancelButton) }}</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" :on-hide="reset">
|
||||
<div class="max-w-[31rem] flex flex-col gap-6">
|
||||
<Admonition
|
||||
type="warning"
|
||||
:header="formatMessage(messages.warningTitle)"
|
||||
:body="formatMessage(messages.warningBody)"
|
||||
/>
|
||||
<div v-if="fileName" class="overflow-x-auto whitespace-nowrap text-sm text-secondary">
|
||||
{{ fileName }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="mt-0 leading-tight">
|
||||
{{ formatMessage(messages.body) }}
|
||||
</p>
|
||||
<p class="text-orange font-semibold mb-0 leading-tight">
|
||||
{{ formatMessage(messages.malwareStatement) }}
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox v-model="dontShowAgain" :label="formatMessage(messages.dontShowAgain)" />
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="cancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="isProceeding" @click="proceed">
|
||||
<SpinnerIcon v-if="isProceeding" class="animate-spin" />
|
||||
<CircleArrowRightIcon v-else />
|
||||
{{ formatMessage(messages.installAnyway) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CircleArrowRightIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings'
|
||||
import { useTheming } from '@/store/state'
|
||||
import type { FeatureFlag } from '@/store/theme.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const themeStore = useTheming()
|
||||
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
|
||||
|
||||
const dontShowAgain = ref(false)
|
||||
const modal = useTemplateRef('modal')
|
||||
const onProceed = ref<() => Promise<void>>()
|
||||
const isProceeding = ref(false)
|
||||
const fileName = ref('')
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'unknown-pack-warning-modal.header',
|
||||
defaultMessage: 'Confirm installation',
|
||||
},
|
||||
warningTitle: {
|
||||
id: 'unknown-pack-warning-modal.warning.title',
|
||||
defaultMessage: 'Unknown file warning',
|
||||
},
|
||||
warningBody: {
|
||||
id: 'unknown-pack-warning-modal.warning.body',
|
||||
defaultMessage: `We couldn't find this file on Modrinth. We strongly recommend only installing files from sources you trust.`,
|
||||
},
|
||||
body: {
|
||||
id: 'unknown-pack-warning-modal.body',
|
||||
defaultMessage: `A file is only reviewed if it’s uploaded to Modrinth, regardless of its file format (including .mrpack).`,
|
||||
},
|
||||
malwareStatement: {
|
||||
id: 'unknown-pack-warning-modal.malware-statement',
|
||||
defaultMessage: `Malware is often distributed through modpack files by sharing them on platforms like Discord.`,
|
||||
},
|
||||
dontShowAgain: {
|
||||
id: 'unknown-pack-warning-modal.dont-show-again',
|
||||
defaultMessage: `Don't show this warning again`,
|
||||
},
|
||||
installAnyway: {
|
||||
id: 'unknown-pack-warning-modal.install-anyway',
|
||||
defaultMessage: `Install anyway`,
|
||||
},
|
||||
})
|
||||
|
||||
function show(createInstance: () => Promise<void>, selectedFileName = '') {
|
||||
onProceed.value = createInstance
|
||||
fileName.value = selectedFileName
|
||||
dontShowAgain.value = false
|
||||
|
||||
if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
|
||||
// noinspection ES6MissingAwait
|
||||
createInstance()
|
||||
return
|
||||
}
|
||||
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
onProceed.value = undefined
|
||||
fileName.value = ''
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
async function proceed() {
|
||||
if (!onProceed.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (dontShowAgain.value) {
|
||||
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = true
|
||||
const settings = await getSettings()
|
||||
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = true
|
||||
await setSettings(settings)
|
||||
}
|
||||
|
||||
const createInstance = onProceed.value
|
||||
modal.value?.hide()
|
||||
// noinspection ES6MissingAwait
|
||||
createInstance()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { SearchIcon } from '@modrinth/assets'
|
||||
import { EmptyState, StyledInput, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const props = defineProps<{
|
||||
instances: GameInstance[]
|
||||
searchPlaceholder: string
|
||||
noInstancesMessage: string
|
||||
noMatchesMessage: string
|
||||
selectLabel: (instance: GameInstance) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [instance: GameInstance]
|
||||
}>()
|
||||
|
||||
const { locale } = useVIntl()
|
||||
const searchInput = ref<InstanceType<typeof StyledInput>>()
|
||||
const searchQuery = ref('')
|
||||
|
||||
const visibleInstances = computed(() => {
|
||||
const query = searchQuery.value.trim().toLocaleLowerCase(locale.value)
|
||||
return props.instances
|
||||
.filter((instance) => {
|
||||
if (!query) return true
|
||||
return [instance.name, instance.loader, instance.game_version].some((value) =>
|
||||
value.toLocaleLowerCase(locale.value).includes(query),
|
||||
)
|
||||
})
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name, locale.value, { sensitivity: 'base' }))
|
||||
})
|
||||
|
||||
function reset() {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
function focus() {
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ reset, focus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StyledInput
|
||||
v-if="instances.length > 0"
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="searchPlaceholder"
|
||||
wrapper-class="w-full"
|
||||
clearable
|
||||
/>
|
||||
<ul v-if="visibleInstances.length > 0" class="m-0 flex list-none flex-col gap-1 p-0">
|
||||
<li v-for="instance in visibleInstances" :key="instance.id" class="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-16 w-full cursor-pointer items-center gap-3 rounded-lg border-0 bg-transparent px-3 py-2 text-left text-primary transition-colors hover:bg-button-bg focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
:aria-label="selectLabel(instance)"
|
||||
@click="emit('select', instance)"
|
||||
>
|
||||
<InstanceIcon
|
||||
class="size-10 shrink-0"
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
|
||||
<span class="truncate text-sm capitalize text-secondary">
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</span>
|
||||
</span>
|
||||
<slot name="action" :instance="instance" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<EmptyState
|
||||
v-else
|
||||
type="empty-inbox"
|
||||
:heading="instances.length === 0 ? noInstancesMessage : noMatchesMessage"
|
||||
/>
|
||||
</template>
|
||||
@ -0,0 +1,434 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
FileArchiveIcon,
|
||||
PlusIcon,
|
||||
RestoreIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
add_core_jar_mod,
|
||||
import_mcarchive_modloader,
|
||||
install_mcarchive_modloader,
|
||||
list_core_components,
|
||||
move_core_component,
|
||||
preview_core_jar,
|
||||
remove_core_component,
|
||||
replace_core_jar,
|
||||
restore_core_component,
|
||||
set_core_component_enabled,
|
||||
} from '@/helpers/instance'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const busy = ref(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: computed(() => ['core-components', instance.value.id]),
|
||||
queryFn: () => list_core_components(instance.value.id),
|
||||
})
|
||||
|
||||
const activeComponents = computed(() =>
|
||||
(query.data.value ?? []).filter((component) => !component.removed),
|
||||
)
|
||||
const deletedComponents = computed(() =>
|
||||
(query.data.value ?? []).filter((component) => component.removed),
|
||||
)
|
||||
const canInstallModLoader = computed(() => {
|
||||
const version = instance.value.game_version.trim().replace(/^v/, '')
|
||||
if (/^(?:a|b|inf-)/.test(version)) return true
|
||||
const match = /^1\.(\d+)(?:\.(\d+))?$/.exec(version)
|
||||
if (!match) return false
|
||||
const minor = Number(match[1])
|
||||
const patch = Number(match[2] ?? 0)
|
||||
return minor <= 6 && (minor < 6 || patch <= 2)
|
||||
})
|
||||
const manualModLoader = ref<{
|
||||
fileName: string
|
||||
pageUrl: string | null
|
||||
expectedSha256: string | null
|
||||
} | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'instance.settings.tabs.core-components.title',
|
||||
defaultMessage: 'Core components',
|
||||
},
|
||||
add: {
|
||||
id: 'instance.settings.tabs.core-components.add',
|
||||
defaultMessage: 'Add to Minecraft.jar',
|
||||
},
|
||||
replace: {
|
||||
id: 'instance.settings.tabs.core-components.replace',
|
||||
defaultMessage: 'Replace Minecraft.jar',
|
||||
},
|
||||
pickJar: {
|
||||
id: 'instance.settings.tabs.core-components.pick-jar',
|
||||
defaultMessage: 'Choose core archive',
|
||||
},
|
||||
jarFilter: {
|
||||
id: 'instance.settings.tabs.core-components.jar-filter',
|
||||
defaultMessage: 'Minecraft archives',
|
||||
},
|
||||
moveUp: {
|
||||
id: 'instance.settings.tabs.core-components.move-up',
|
||||
defaultMessage: 'Move up',
|
||||
},
|
||||
moveDown: {
|
||||
id: 'instance.settings.tabs.core-components.move-down',
|
||||
defaultMessage: 'Move down',
|
||||
},
|
||||
remove: {
|
||||
id: 'instance.settings.tabs.core-components.remove',
|
||||
defaultMessage: 'Remove',
|
||||
},
|
||||
restore: {
|
||||
id: 'instance.settings.tabs.core-components.restore',
|
||||
defaultMessage: 'Restore',
|
||||
},
|
||||
jarMod: {
|
||||
id: 'instance.settings.tabs.core-components.jar-mod',
|
||||
defaultMessage: 'JAR mod',
|
||||
},
|
||||
replacement: {
|
||||
id: 'instance.settings.tabs.core-components.replacement',
|
||||
defaultMessage: 'Replacement JAR',
|
||||
},
|
||||
sha256: {
|
||||
id: 'instance.settings.tabs.core-components.sha256',
|
||||
defaultMessage: 'SHA-256',
|
||||
},
|
||||
failure: {
|
||||
id: 'instance.settings.tabs.core-components.failure',
|
||||
defaultMessage: 'Failure',
|
||||
},
|
||||
sha1: {
|
||||
id: 'instance.settings.tabs.core-components.sha1',
|
||||
defaultMessage: 'SHA-1',
|
||||
},
|
||||
source: {
|
||||
id: 'instance.settings.tabs.core-components.source',
|
||||
defaultMessage: 'Source',
|
||||
},
|
||||
targetVersion: {
|
||||
id: 'instance.settings.tabs.core-components.target-version',
|
||||
defaultMessage: 'Target Minecraft version',
|
||||
},
|
||||
preview: {
|
||||
id: 'instance.settings.tabs.core-components.preview',
|
||||
defaultMessage: 'Preview assembled JAR',
|
||||
},
|
||||
previewed: {
|
||||
id: 'instance.settings.tabs.core-components.previewed',
|
||||
defaultMessage: 'Assembled {components} components into {entries} entries',
|
||||
},
|
||||
modLoader: {
|
||||
id: 'instance.settings.tabs.core-components.modloader',
|
||||
defaultMessage: 'Install ModLoader',
|
||||
},
|
||||
modLoaderInstalled: {
|
||||
id: 'instance.settings.tabs.core-components.modloader-installed',
|
||||
defaultMessage: 'Installed ModLoader {fileName}',
|
||||
},
|
||||
modLoaderManual: {
|
||||
id: 'instance.settings.tabs.core-components.modloader-manual',
|
||||
defaultMessage:
|
||||
'{fileName} needs a manual download. Download it from the source page, then import the verified archive here.',
|
||||
},
|
||||
openSource: {
|
||||
id: 'instance.settings.tabs.core-components.open-source',
|
||||
defaultMessage: 'Open source page',
|
||||
},
|
||||
importModLoader: {
|
||||
id: 'instance.settings.tabs.core-components.import-modloader',
|
||||
defaultMessage: 'Import verified ModLoader archive',
|
||||
},
|
||||
pickModLoader: {
|
||||
id: 'instance.settings.tabs.core-components.pick-modloader',
|
||||
defaultMessage: 'Choose downloaded ModLoader archive',
|
||||
},
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
await queryClient.invalidateQueries({ queryKey: ['core-components', instance.value.id] })
|
||||
}
|
||||
|
||||
async function pick(kind: 'jar_mod' | 'replacement_jar') {
|
||||
const path = await open({
|
||||
multiple: false,
|
||||
title: formatMessage(messages.pickJar),
|
||||
filters: [{ name: formatMessage(messages.jarFilter), extensions: ['jar', 'zip'] }],
|
||||
})
|
||||
if (!path || Array.isArray(path)) return
|
||||
busy.value = true
|
||||
try {
|
||||
if (kind === 'jar_mod') {
|
||||
await add_core_jar_mod(instance.value.id, path, instance.value.game_version)
|
||||
} else {
|
||||
await replace_core_jar(instance.value.id, path, instance.value.game_version)
|
||||
}
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function run(action: () => Promise<unknown>) {
|
||||
busy.value = true
|
||||
try {
|
||||
await action()
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await preview_core_jar(instance.value.id)
|
||||
if (result) {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.previewed, {
|
||||
components: result.componentCount,
|
||||
entries: result.entries,
|
||||
}),
|
||||
})
|
||||
}
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function installModLoader() {
|
||||
busy.value = true
|
||||
manualModLoader.value = null
|
||||
try {
|
||||
const result = await install_mcarchive_modloader(instance.value.id, instance.value.game_version)
|
||||
if (result.state === 'manual_download') {
|
||||
manualModLoader.value = result
|
||||
return
|
||||
}
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.modLoaderInstalled, {
|
||||
fileName: result.component.fileName,
|
||||
}),
|
||||
})
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function importModLoader() {
|
||||
if (!manualModLoader.value) return
|
||||
const path = await open({
|
||||
multiple: false,
|
||||
title: formatMessage(messages.pickModLoader),
|
||||
filters: [{ name: formatMessage(messages.jarFilter), extensions: ['jar', 'zip'] }],
|
||||
})
|
||||
if (!path || Array.isArray(path)) return
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await import_mcarchive_modloader(
|
||||
instance.value.id,
|
||||
instance.value.game_version,
|
||||
path,
|
||||
)
|
||||
if (result.state === 'manual_download') {
|
||||
manualModLoader.value = result
|
||||
return
|
||||
}
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.modLoaderInstalled, {
|
||||
fileName: result.component.fileName,
|
||||
}),
|
||||
})
|
||||
manualModLoader.value = null
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled>
|
||||
<button :disabled="busy" @click="pick('jar_mod')">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.add) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="busy" @click="pick('replacement_jar')">
|
||||
<FileArchiveIcon />
|
||||
{{ formatMessage(messages.replace) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="canInstallModLoader" type="outlined">
|
||||
<button :disabled="busy" @click="installModLoader">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.modLoader) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button :disabled="busy" @click="preview">
|
||||
<EyeIcon />
|
||||
{{ formatMessage(messages.preview) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="manualModLoader"
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-y border-surface-4 py-3"
|
||||
>
|
||||
<p class="m-0 min-w-0 flex-1 text-sm text-secondary">
|
||||
{{ formatMessage(messages.modLoaderManual, { fileName: manualModLoader.fileName }) }}
|
||||
</p>
|
||||
<ButtonStyled v-if="manualModLoader.pageUrl" type="outlined">
|
||||
<button @click="openUrl(manualModLoader!.pageUrl!)">
|
||||
<ExternalIcon />
|
||||
{{ formatMessage(messages.openSource) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="busy" @click="importModLoader">
|
||||
<FileArchiveIcon />
|
||||
{{ formatMessage(messages.importModLoader) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-surface-4">
|
||||
<div
|
||||
v-for="(component, index) in activeComponents"
|
||||
:key="component.id"
|
||||
class="flex items-center gap-3 border-b border-surface-4 px-3 py-3 last:border-b-0"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="component.enabled"
|
||||
:disabled="busy"
|
||||
@update:model-value="
|
||||
(enabled) => run(() => set_core_component_enabled(instance.id, component.id, enabled))
|
||||
"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-medium text-contrast">{{ component.fileName }}</div>
|
||||
<div class="text-xs text-secondary">
|
||||
{{
|
||||
formatMessage(
|
||||
component.kind === 'replacement_jar' ? messages.replacement : messages.jarMod,
|
||||
)
|
||||
}}
|
||||
<span v-if="component.targetGameVersion">
|
||||
· {{ formatMessage(messages.targetVersion) }} {{ component.targetGameVersion }}
|
||||
</span>
|
||||
<span v-if="component.source">
|
||||
· {{ formatMessage(messages.source) }} {{ component.source.provider }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="component.sha256 || component.sha1" class="truncate text-xs text-secondary">
|
||||
<span v-if="component.sha256"
|
||||
>{{ formatMessage(messages.sha256) }} {{ component.sha256 }}</span
|
||||
>
|
||||
<span v-if="component.sha256 && component.sha1"> · </span>
|
||||
<span v-if="component.sha1"
|
||||
>{{ formatMessage(messages.sha1) }} {{ component.sha1 }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="component.failureReason" class="text-xs text-red">
|
||||
{{ formatMessage(messages.failure) }}: {{ component.failureReason }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<ButtonStyled circular size="small" type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.moveUp)"
|
||||
:aria-label="formatMessage(messages.moveUp)"
|
||||
:disabled="busy || index === 0"
|
||||
@click="run(() => move_core_component(instance.id, component.id, -1))"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular size="small" type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.moveDown)"
|
||||
:aria-label="formatMessage(messages.moveDown)"
|
||||
:disabled="busy || index === activeComponents.length - 1"
|
||||
@click="run(() => move_core_component(instance.id, component.id, 1))"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular color="red" size="small" type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.remove)"
|
||||
:aria-label="formatMessage(messages.remove)"
|
||||
:disabled="busy"
|
||||
@click="run(() => remove_core_component(instance.id, component.id))"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="deletedComponents.length" class="overflow-hidden rounded-lg border border-surface-4">
|
||||
<div
|
||||
v-for="component in deletedComponents"
|
||||
:key="component.id"
|
||||
class="flex items-center gap-3 px-3 py-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1 truncate text-secondary">{{ component.fileName }}</div>
|
||||
<ButtonStyled circular size="small" type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.restore)"
|
||||
:aria-label="formatMessage(messages.restore)"
|
||||
:disabled="busy"
|
||||
@click="run(() => restore_core_component(instance.id, component.id))"
|
||||
>
|
||||
<RestoreIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,553 @@
|
||||
<script setup lang="ts">
|
||||
import { CopyIcon, EditIcon, SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
defineMessages,
|
||||
injectFilePicker,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
RadioButtons,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { basename, dirname, join } from '@tauri-apps/api/path'
|
||||
import { computed, type Ref, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { GameInstance } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const filePicker = injectFilePicker()
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const deleteConfirmModal = ref()
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
type ReleaseChannel = GameInstance['update_channel']
|
||||
const releaseChannelOptions: ReleaseChannel[] = ['release', 'beta', 'alpha']
|
||||
|
||||
const title = ref(instance.value.name)
|
||||
const icon: Ref<string | undefined> = ref(instance.value.icon_path)
|
||||
const savingReleaseChannel = ref(false)
|
||||
const selectedReleaseChannel = ref<ReleaseChannel>(instance.value.update_channel)
|
||||
const releaseChannelDisabledItems = computed<ReleaseChannel[]>(() =>
|
||||
savingReleaseChannel.value ? [...releaseChannelOptions] : [],
|
||||
)
|
||||
|
||||
const installing = computed(() => instance.value.install_stage !== 'installed')
|
||||
|
||||
async function duplicateInstance() {
|
||||
await install_duplicate_instance(instance.value.id).catch(handleError)
|
||||
trackEvent('InstanceDuplicate', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
|
||||
function formatReleaseChannelLabel(channel: ReleaseChannel) {
|
||||
switch (channel) {
|
||||
case 'release':
|
||||
return formatMessage(messages.updateChannelRelease)
|
||||
case 'beta':
|
||||
return formatMessage(messages.updateChannelBeta)
|
||||
case 'alpha':
|
||||
return formatMessage(messages.updateChannelAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
function formatReleaseChannelDescription(channel: ReleaseChannel) {
|
||||
switch (channel) {
|
||||
case 'release':
|
||||
return formatMessage(messages.updateChannelReleaseDescription)
|
||||
case 'beta':
|
||||
return formatMessage(messages.updateChannelBetaDescription)
|
||||
case 'alpha':
|
||||
return formatMessage(messages.updateChannelAlphaDescription)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [instance.value.id, instance.value.update_channel] as const,
|
||||
() => {
|
||||
if (!savingReleaseChannel.value) {
|
||||
selectedReleaseChannel.value = instance.value.update_channel
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(selectedReleaseChannel, async (channel, previousChannel) => {
|
||||
const previousReleaseChannel = previousChannel ?? instance.value.update_channel
|
||||
if (channel === instance.value.update_channel) return
|
||||
|
||||
savingReleaseChannel.value = true
|
||||
const instanceId = instance.value.id
|
||||
await edit(instanceId, { update_channel: channel })
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instanceId] }))
|
||||
.catch((error) => {
|
||||
selectedReleaseChannel.value = previousReleaseChannel
|
||||
handleError(error)
|
||||
})
|
||||
savingReleaseChannel.value = false
|
||||
})
|
||||
|
||||
async function resetIcon() {
|
||||
icon.value = undefined
|
||||
await edit_icon(instance.value.id, null).catch(handleError)
|
||||
trackEvent('InstanceRemoveIcon')
|
||||
}
|
||||
|
||||
async function setIcon() {
|
||||
try {
|
||||
const picked = await (filePicker.pickInstanceIcon?.() ?? filePicker.pickImage())
|
||||
if (!picked?.path) return
|
||||
|
||||
const previousIcon = icon.value
|
||||
icon.value = picked.path
|
||||
try {
|
||||
await edit_icon(instance.value.id, picked.path)
|
||||
trackEvent('InstanceSetIcon')
|
||||
} catch (error) {
|
||||
icon.value = previousIcon
|
||||
handleError(error)
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const gameDirOverride = ref(instance.value.game_dir_override)
|
||||
const savingGameDir = ref(false)
|
||||
const isDirectLinked = computed(() => !!instance.value.linked_dot_minecraft)
|
||||
const configuredExternalGameDir = computed(
|
||||
() => gameDirOverride.value ?? instance.value.linked_dot_minecraft ?? null,
|
||||
)
|
||||
const resolvedExternalGameDir = ref<string | null>(null)
|
||||
const externalGameDir = computed(
|
||||
() => resolvedExternalGameDir.value ?? configuredExternalGameDir.value,
|
||||
)
|
||||
|
||||
let pathResolutionRevision = 0
|
||||
async function refreshExternalGameDir() {
|
||||
const revision = ++pathResolutionRevision
|
||||
if (!configuredExternalGameDir.value) {
|
||||
resolvedExternalGameDir.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const path = await get_full_path(instance.value.id)
|
||||
if (revision === pathResolutionRevision) resolvedExternalGameDir.value = path
|
||||
} catch {
|
||||
// Keep the configured path visible while the external chain is unavailable.
|
||||
if (revision === pathResolutionRevision) resolvedExternalGameDir.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => instance.value.game_dir_override,
|
||||
(path) => {
|
||||
gameDirOverride.value = path
|
||||
void refreshExternalGameDir()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [instance.value.id, instance.value.linked_dot_minecraft] as const,
|
||||
() => void refreshExternalGameDir(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// An external game dir is stored as a single path. Whether it is version
|
||||
// isolated is encoded in the path: `<root>/versions/<name>` vs the `.minecraft`
|
||||
// root itself. `isExternal` is false for built-in (managed) instances, which
|
||||
// expose no isolation option.
|
||||
const isExternal = computed(() => !!externalGameDir.value)
|
||||
|
||||
const gameDirInfo = ref<{ isolated: boolean; baseRoot: string | null }>({
|
||||
isolated: false,
|
||||
baseRoot: null,
|
||||
})
|
||||
|
||||
async function refreshGameDirInfo() {
|
||||
const path = configuredExternalGameDir.value
|
||||
if (!path || isDirectLinked.value) {
|
||||
gameDirInfo.value = { isolated: false, baseRoot: null }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parent = await dirname(path)
|
||||
if ((await basename(parent)).toLowerCase() === 'versions') {
|
||||
gameDirInfo.value = { isolated: true, baseRoot: await dirname(parent) }
|
||||
} else {
|
||||
gameDirInfo.value = { isolated: false, baseRoot: path }
|
||||
}
|
||||
} catch {
|
||||
gameDirInfo.value = { isolated: false, baseRoot: path }
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [configuredExternalGameDir.value, isDirectLinked.value] as const,
|
||||
() => void refreshGameDirInfo(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
type GameDirMode = 'isolated' | 'not-isolated'
|
||||
const gameDirMode = computed<GameDirMode>({
|
||||
get: () => (gameDirInfo.value.isolated ? 'isolated' : 'not-isolated'),
|
||||
set: (mode) => void setGameDirMode(mode),
|
||||
})
|
||||
const gameDirModeItems: GameDirMode[] = ['isolated', 'not-isolated']
|
||||
|
||||
function gameDirModeLabel(mode: GameDirMode) {
|
||||
return mode === 'isolated' ? messages.gameDirIsolated : messages.gameDirNotIsolated
|
||||
}
|
||||
|
||||
async function setGameDirMode(mode: GameDirMode) {
|
||||
const baseRoot = gameDirInfo.value.baseRoot
|
||||
if (!baseRoot) return
|
||||
const nextPath =
|
||||
mode === 'isolated' ? await join(baseRoot, 'versions', instance.value.name) : baseRoot
|
||||
if (nextPath === gameDirOverride.value) return
|
||||
|
||||
// The launcher only records the new override path; the user is responsible
|
||||
// for actually moving the mods/saves/config folders to match.
|
||||
const previous = gameDirOverride.value
|
||||
gameDirOverride.value = nextPath
|
||||
savingGameDir.value = true
|
||||
try {
|
||||
await edit(instance.value.id, { game_dir_override: nextPath })
|
||||
} catch (error) {
|
||||
gameDirOverride.value = previous
|
||||
handleError(error)
|
||||
} finally {
|
||||
savingGameDir.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editInstanceObject = computed(() => ({
|
||||
name: title.value.trim().substring(0, 32) ?? 'Instance',
|
||||
}))
|
||||
|
||||
watch(
|
||||
title,
|
||||
async () => {
|
||||
if (removing.value) return
|
||||
await edit(instance.value.id, editInstanceObject.value).catch(handleError)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const removing = ref(false)
|
||||
async function removeInstance() {
|
||||
removing.value = true
|
||||
const path = instance.value.id
|
||||
|
||||
trackEvent('InstanceRemove', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
|
||||
await router.push({ path: '/' })
|
||||
await remove(path).catch(handleError)
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
icon: {
|
||||
id: 'instance.settings.tabs.general.icon',
|
||||
defaultMessage: 'Icon',
|
||||
},
|
||||
name: {
|
||||
id: 'instance.settings.tabs.general.name',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
editIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon',
|
||||
defaultMessage: 'Edit icon',
|
||||
},
|
||||
selectIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.select',
|
||||
defaultMessage: 'Select icon',
|
||||
},
|
||||
replaceIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.replace',
|
||||
defaultMessage: 'Replace icon',
|
||||
},
|
||||
removeIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.remove',
|
||||
defaultMessage: 'Remove icon',
|
||||
},
|
||||
duplicateInstance: {
|
||||
id: 'instance.settings.tabs.general.duplicate-instance',
|
||||
defaultMessage: 'Duplicate instance',
|
||||
},
|
||||
duplicateInstanceDescription: {
|
||||
id: 'instance.settings.tabs.general.duplicate-instance.description',
|
||||
defaultMessage: 'Creates a copy of this instance, including worlds, configs, mods, etc.',
|
||||
},
|
||||
duplicateButtonTooltipInstalling: {
|
||||
id: 'instance.settings.tabs.general.duplicate-button.tooltip.installing',
|
||||
defaultMessage: 'Cannot duplicate while installing.',
|
||||
},
|
||||
duplicateButton: {
|
||||
id: 'instance.settings.tabs.general.duplicate-button',
|
||||
defaultMessage: 'Duplicate',
|
||||
},
|
||||
gameDir: {
|
||||
id: 'instance.settings.tabs.general.game-dir',
|
||||
defaultMessage: 'Game directory',
|
||||
},
|
||||
gameDirDescription: {
|
||||
id: 'instance.settings.tabs.general.game-dir.description',
|
||||
defaultMessage:
|
||||
'Uses a separate folder as the working directory for this instance. The game reads mods, saves, configs, and resource packs from that folder instead of the managed instance folder.',
|
||||
},
|
||||
gameDirCurrent: {
|
||||
id: 'instance.settings.tabs.general.game-dir.current',
|
||||
defaultMessage: 'Current directory',
|
||||
},
|
||||
gameDirIsolated: {
|
||||
id: 'instance.settings.tabs.general.game-dir.isolated',
|
||||
defaultMessage: 'Version isolated (stored in versions/)',
|
||||
},
|
||||
gameDirNotIsolated: {
|
||||
id: 'instance.settings.tabs.general.game-dir.not-isolated',
|
||||
defaultMessage: 'Version shared (.minecraft/)',
|
||||
},
|
||||
gameDirMoveNote: {
|
||||
id: 'instance.settings.tabs.general.game-dir.move-note',
|
||||
defaultMessage:
|
||||
'Switching isolation only updates the launcher path. Move the mods, saves, and config folders yourself to match.',
|
||||
},
|
||||
gameDirManagedNote: {
|
||||
id: 'instance.settings.tabs.general.game-dir.managed-note',
|
||||
defaultMessage: 'This instance uses the Axolotl-managed folder.',
|
||||
},
|
||||
gameDirExternalNote: {
|
||||
id: 'instance.settings.tabs.general.game-dir.external-note',
|
||||
defaultMessage: 'This instance uses an external .minecraft folder managed in place.',
|
||||
},
|
||||
updateChannel: {
|
||||
id: 'instance.settings.tabs.general.update-channel',
|
||||
defaultMessage: 'Update channel',
|
||||
},
|
||||
updateChannelReleaseDescription: {
|
||||
id: 'instance.settings.tabs.general.update-channel.release.description',
|
||||
defaultMessage: 'Only release versions will be shown as available updates.',
|
||||
},
|
||||
updateChannelBetaDescription: {
|
||||
id: 'instance.settings.tabs.general.update-channel.beta.description',
|
||||
defaultMessage: 'Release and beta versions will be shown as available updates.',
|
||||
},
|
||||
updateChannelAlphaDescription: {
|
||||
id: 'instance.settings.tabs.general.update-channel.alpha.description',
|
||||
defaultMessage: 'Release, beta, and alpha versions will be shown as available updates.',
|
||||
},
|
||||
updateChannelRelease: {
|
||||
id: 'instance.settings.tabs.general.update-channel.release',
|
||||
defaultMessage: 'Release',
|
||||
},
|
||||
updateChannelBeta: {
|
||||
id: 'instance.settings.tabs.general.update-channel.beta',
|
||||
defaultMessage: 'Beta',
|
||||
},
|
||||
updateChannelAlpha: {
|
||||
id: 'instance.settings.tabs.general.update-channel.alpha',
|
||||
defaultMessage: 'Alpha',
|
||||
},
|
||||
selectUpdateChannelAriaLabel: {
|
||||
id: 'instance.settings.tabs.general.update-channel.select',
|
||||
defaultMessage: 'Select update channel',
|
||||
},
|
||||
deleteInstance: {
|
||||
id: 'instance.settings.tabs.general.delete',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
deleteInstanceDescription: {
|
||||
id: 'instance.settings.tabs.general.delete.description',
|
||||
defaultMessage:
|
||||
'Permanently deletes an instance from your device, including your worlds, configs, and all installed content. Be careful, as once you delete a instance there is no way to recover it.',
|
||||
},
|
||||
deleteInstanceButton: {
|
||||
id: 'instance.settings.tabs.general.delete.button',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
deletingInstanceButton: {
|
||||
id: 'instance.settings.tabs.general.deleting.button',
|
||||
defaultMessage: 'Deleting...',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDeleteInstanceModal
|
||||
ref="deleteConfirmModal"
|
||||
:symlink-target="instance.symlink_target"
|
||||
@delete="removeInstance"
|
||||
/>
|
||||
<div class="block">
|
||||
<div class="float-end ml-10 relative group w-fit">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.icon) }}
|
||||
</span>
|
||||
<div class="group relative w-fit">
|
||||
<OverflowMenu
|
||||
v-tooltip="formatMessage(messages.editIcon)"
|
||||
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
|
||||
:options="[
|
||||
{
|
||||
id: 'select',
|
||||
action: () => setIcon(),
|
||||
},
|
||||
{
|
||||
id: 'remove',
|
||||
color: 'danger',
|
||||
action: () => resetIcon(),
|
||||
shown: !!icon,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<InstanceIcon
|
||||
:icon-path="icon"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
size="108px"
|
||||
class="transition-[filter] group-hover:brightness-75"
|
||||
no-shadow
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 h-full w-full flex items-center justify-center opacity-0 transition-all group-hover:opacity-100"
|
||||
>
|
||||
<EditIcon aria-hidden="true" class="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<template #select>
|
||||
<UploadIcon />
|
||||
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
|
||||
</template>
|
||||
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label for="instance-name" class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.name) }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<StyledInput
|
||||
id="instance-name"
|
||||
v-model="title"
|
||||
autocomplete="off"
|
||||
:maxlength="80"
|
||||
wrapper-class="flex-grow"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="instance.install_stage == 'installed'">
|
||||
<div class="flex flex-col gap-2.5 mt-6">
|
||||
<h2 id="duplicate-instance-label" class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.duplicateInstance) }}
|
||||
</h2>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
|
||||
aria-labelledby="duplicate-instance-label"
|
||||
:disabled="installing"
|
||||
class="w-max !shadow-none"
|
||||
@click="duplicateInstance"
|
||||
>
|
||||
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.duplicateInstanceDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-2.5 mt-6">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.gameDir) }}
|
||||
</h2>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.gameDirDescription) }}
|
||||
</p>
|
||||
<template v-if="isExternal">
|
||||
<div v-if="!isDirectLinked" class="flex flex-col gap-1.5">
|
||||
<RadioButtons v-model="gameDirMode" :items="gameDirModeItems" force-selection>
|
||||
<template #default="{ item }">
|
||||
{{ formatMessage(gameDirModeLabel(item)) }}
|
||||
</template>
|
||||
</RadioButtons>
|
||||
</div>
|
||||
<p v-if="externalGameDir" class="m-0 text-secondary break-all">
|
||||
{{ formatMessage(messages.gameDirCurrent) }}:
|
||||
<code>{{ externalGameDir }}</code>
|
||||
</p>
|
||||
<p v-if="!isDirectLinked" class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.gameDirMoveNote) }}
|
||||
</p>
|
||||
<p v-else class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.gameDirExternalNote) }}
|
||||
</p>
|
||||
</template>
|
||||
<p v-else class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.gameDirManagedNote) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5 mt-6">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.updateChannel) }}
|
||||
</h2>
|
||||
<Chips
|
||||
v-model="selectedReleaseChannel"
|
||||
:items="releaseChannelOptions"
|
||||
:format-label="formatReleaseChannelLabel"
|
||||
:capitalize="false"
|
||||
:disabled-items="releaseChannelDisabledItems"
|
||||
:aria-label="formatMessage(messages.selectUpdateChannelAriaLabel)"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatReleaseChannelDescription(selectedReleaseChannel) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5 mt-6">
|
||||
<h2 id="delete-instance-label" class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.deleteInstance) }}
|
||||
</h2>
|
||||
<ButtonStyled color="red">
|
||||
<button
|
||||
aria-labelledby="delete-instance-label"
|
||||
:disabled="removing"
|
||||
class="w-fit !shadow-none"
|
||||
@click="deleteConfirmModal.show()"
|
||||
>
|
||||
<SpinnerIcon v-if="removing" class="animate-spin" />
|
||||
<TrashIcon v-else />
|
||||
{{
|
||||
removing
|
||||
? formatMessage(messages.deletingInstanceButton)
|
||||
: formatMessage(messages.deleteInstanceButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.deleteInstanceDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.hovering-icon-shadow {
|
||||
box-shadow: var(--shadow-inset-sm), var(--shadow-raised);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { edit } from '@/helpers/instance'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { AppSettings, Hooks } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
|
||||
const globalSettings = (await get().catch(handleError)) as AppSettings
|
||||
|
||||
const overrideHooks = ref(
|
||||
!!instance.value.hooks.pre_launch ||
|
||||
!!instance.value.hooks.wrapper ||
|
||||
!!instance.value.hooks.post_exit,
|
||||
)
|
||||
const hooks = ref(instance.value.hooks ?? globalSettings.hooks)
|
||||
const overrideLaunchPreparationTimeout = ref(instance.value.launch_preparation_timeout != null)
|
||||
const launchPreparationTimeout = ref(
|
||||
Math.min(600, Math.max(30, instance.value.launch_preparation_timeout ?? 60)),
|
||||
)
|
||||
|
||||
const editInstanceObject = computed(() => {
|
||||
const editInstancePatch: {
|
||||
hooks?: Hooks
|
||||
launch_preparation_timeout?: number | null
|
||||
} = {}
|
||||
|
||||
// When hooks are not overridden per-instance, we want to clear them
|
||||
editInstancePatch.hooks = overrideHooks.value ? hooks.value : {}
|
||||
editInstancePatch.launch_preparation_timeout = overrideLaunchPreparationTimeout.value
|
||||
? Math.min(600, Math.max(30, Math.round(launchPreparationTimeout.value || 60)))
|
||||
: null
|
||||
|
||||
return editInstancePatch
|
||||
})
|
||||
|
||||
watch(
|
||||
[overrideHooks, hooks, overrideLaunchPreparationTimeout, launchPreparationTimeout],
|
||||
async () => {
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
const messages = defineMessages({
|
||||
hooks: {
|
||||
id: 'instance.settings.tabs.hooks.title',
|
||||
defaultMessage: 'Launch preparation',
|
||||
},
|
||||
hooksDescription: {
|
||||
id: 'instance.settings.tabs.hooks.description',
|
||||
defaultMessage:
|
||||
'Configure the time allowed for launch preparation and optional commands that run before and after the game.',
|
||||
},
|
||||
launchPreparationTimeout: {
|
||||
id: 'instance.settings.tabs.hooks.launch-preparation-timeout',
|
||||
defaultMessage: 'Launch preparation timeout',
|
||||
},
|
||||
launchPreparationTimeoutDescription: {
|
||||
id: 'instance.settings.tabs.hooks.launch-preparation-timeout.description',
|
||||
defaultMessage: 'Maximum time to wait for launch preparation to finish, in seconds (30–600).',
|
||||
},
|
||||
customLaunchPreparationTimeout: {
|
||||
id: 'instance.settings.tabs.hooks.custom-launch-preparation-timeout',
|
||||
defaultMessage: 'Use a custom launch preparation timeout',
|
||||
},
|
||||
customHooks: {
|
||||
id: 'instance.settings.tabs.hooks.custom-hooks',
|
||||
defaultMessage: 'Custom launch hooks',
|
||||
},
|
||||
preLaunch: {
|
||||
id: 'instance.settings.tabs.hooks.pre-launch',
|
||||
defaultMessage: 'Pre-launch',
|
||||
},
|
||||
preLaunchDescription: {
|
||||
id: 'instance.settings.tabs.hooks.pre-launch.description',
|
||||
defaultMessage: 'Ran before the instance is launched.',
|
||||
},
|
||||
preLaunchEnter: {
|
||||
id: 'instance.settings.tabs.hooks.pre-launch.enter',
|
||||
defaultMessage: 'Enter pre-launch command...',
|
||||
},
|
||||
wrapper: {
|
||||
id: 'instance.settings.tabs.hooks.wrapper',
|
||||
defaultMessage: 'Wrapper',
|
||||
},
|
||||
wrapperDescription: {
|
||||
id: 'instance.settings.tabs.hooks.wrapper.description',
|
||||
defaultMessage: 'Wrapper command for launching Minecraft.',
|
||||
},
|
||||
wrapperEnter: {
|
||||
id: 'instance.settings.tabs.hooks.wrapper.enter',
|
||||
defaultMessage: 'Enter wrapper command...',
|
||||
},
|
||||
postExit: {
|
||||
id: 'instance.settings.tabs.hooks.post-exit',
|
||||
defaultMessage: 'Post-exit',
|
||||
},
|
||||
postExitDescription: {
|
||||
id: 'instance.settings.tabs.hooks.post-exit.description',
|
||||
defaultMessage: 'Ran after the game closes.',
|
||||
},
|
||||
postExitEnter: {
|
||||
id: 'instance.settings.tabs.hooks.post-exit.enter',
|
||||
defaultMessage: 'Enter post-exit command...',
|
||||
},
|
||||
})
|
||||
|
||||
function normalizeLaunchPreparationTimeout() {
|
||||
launchPreparationTimeout.value = Math.min(
|
||||
600,
|
||||
Math.max(30, Math.round(Number(launchPreparationTimeout.value) || 60)),
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="m-0 m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.hooks) }}
|
||||
</h2>
|
||||
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="my-2.5" />
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.hooksDescription) }}
|
||||
</p>
|
||||
|
||||
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.launchPreparationTimeout) }}
|
||||
</h2>
|
||||
<Checkbox
|
||||
v-model="overrideLaunchPreparationTimeout"
|
||||
:label="formatMessage(messages.customLaunchPreparationTimeout)"
|
||||
class="my-2.5"
|
||||
/>
|
||||
<StyledInput
|
||||
id="launch-preparation-timeout"
|
||||
v-model="launchPreparationTimeout"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideLaunchPreparationTimeout"
|
||||
type="number"
|
||||
min="30"
|
||||
max="600"
|
||||
step="1"
|
||||
wrapper-class="w-full my-2.5"
|
||||
@blur="normalizeLaunchPreparationTimeout"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.launchPreparationTimeoutDescription) }}
|
||||
</p>
|
||||
|
||||
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.preLaunch) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
id="pre-launch"
|
||||
v-model="hooks.pre_launch"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideHooks"
|
||||
:placeholder="formatMessage(messages.preLaunchEnter)"
|
||||
wrapper-class="w-full my-2.5"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.preLaunchDescription) }}
|
||||
</p>
|
||||
|
||||
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.wrapper) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
id="wrapper"
|
||||
v-model="hooks.wrapper"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideHooks"
|
||||
:placeholder="formatMessage(messages.wrapperEnter)"
|
||||
wrapper-class="w-full my-2.5"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.wrapperDescription) }}
|
||||
</p>
|
||||
|
||||
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.postExit) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
id="post-exit"
|
||||
v-model="hooks.post_exit"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideHooks"
|
||||
:placeholder="formatMessage(messages.postExitEnter)"
|
||||
wrapper-class="w-full my-2.5"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.postExitDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,589 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
formatLoaderLabel,
|
||||
injectFilePicker,
|
||||
injectNotificationManager,
|
||||
InstallationSettingsLayout,
|
||||
instanceInstallablePlatforms,
|
||||
type LoaderMetadataStatus,
|
||||
loaderSupportState,
|
||||
loaderVersionsForGameVersion,
|
||||
provideAppBackup,
|
||||
provideInstallationSettings,
|
||||
scopedLoaderMetadataQueryKey,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { GameVersionTag } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_versions, get_version } from '@/helpers/cache'
|
||||
import { type CurseForgeFile, updateManagedCurseForgeModpack } from '@/helpers/curseforge'
|
||||
import {
|
||||
install_duplicate_instance,
|
||||
install_existing_instance,
|
||||
install_pack_to_existing_instance,
|
||||
installJobInstanceId,
|
||||
wait_for_install_job,
|
||||
} from '@/helpers/install'
|
||||
import {
|
||||
edit,
|
||||
get_linked_modpack_info,
|
||||
list,
|
||||
update_managed_modrinth_version,
|
||||
update_repair_modrinth,
|
||||
} from '@/helpers/instance'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import { get_game_versions } from '@/helpers/tags'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
import type { Manifest } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const filePicker = injectFilePicker()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const debug = useDebugLogger('AppInstallationSettings')
|
||||
const themeStore = useTheming()
|
||||
|
||||
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
|
||||
const skipNonEssentialWarnings = computed(() =>
|
||||
themeStore.getFeatureFlag('skip_non_essential_warnings'),
|
||||
)
|
||||
|
||||
debug('metadata load: start', {
|
||||
instanceId: instance.value.id,
|
||||
loader: instance.value.loader,
|
||||
gameVersion: instance.value.game_version,
|
||||
installStage: instance.value.install_stage,
|
||||
})
|
||||
|
||||
const gameVersionsQuery = useQuery({
|
||||
queryKey: ['instance-settings', 'game-versions'],
|
||||
queryFn: () => get_game_versions() as Promise<GameVersionTag[]>,
|
||||
})
|
||||
|
||||
const editingPlatform = ref(instance.value.loader)
|
||||
const editingGameVersion = ref(instance.value.game_version)
|
||||
const scopedLoader = computed(() =>
|
||||
editingPlatform.value === 'neoforge' ? 'neo' : editingPlatform.value,
|
||||
)
|
||||
const scopedLoaderQueryEnabled = computed(
|
||||
() => editingPlatform.value !== 'vanilla' && !!editingGameVersion.value,
|
||||
)
|
||||
const scopedLoaderVersionsQuery = useQuery({
|
||||
queryKey: computed(() =>
|
||||
scopedLoaderMetadataQueryKey('instance-settings', scopedLoader.value, editingGameVersion.value),
|
||||
),
|
||||
queryFn: ({ queryKey }) => get_loader_versions(queryKey[2], queryKey[3]) as Promise<Manifest>,
|
||||
enabled: scopedLoaderQueryEnabled,
|
||||
})
|
||||
const scopedLoaderMetadataStatus = computed<LoaderMetadataStatus>(() => {
|
||||
if (!scopedLoaderQueryEnabled.value) return 'unknown'
|
||||
if (scopedLoaderVersionsQuery.isPending.value || scopedLoaderVersionsQuery.isFetching.value) {
|
||||
return 'loading'
|
||||
}
|
||||
if (scopedLoaderVersionsQuery.isError.value) return 'error'
|
||||
return 'success'
|
||||
})
|
||||
const scopedLoaderVersionState = computed(() =>
|
||||
loaderSupportState(
|
||||
scopedLoaderMetadataStatus.value,
|
||||
scopedLoaderVersionsQuery.data.value,
|
||||
editingGameVersion.value,
|
||||
),
|
||||
)
|
||||
const metadataLoading = computed(() => gameVersionsQuery.isLoading.value)
|
||||
|
||||
debug('metadata queries configured', {
|
||||
instanceId: instance.value.id,
|
||||
loader: instance.value.loader,
|
||||
gameVersion: instance.value.game_version,
|
||||
})
|
||||
|
||||
const isModrinthLinkedModpack = computed(
|
||||
() =>
|
||||
instance.value.link?.type === 'modrinth_modpack' ||
|
||||
instance.value.link?.type === 'server_project_modpack',
|
||||
)
|
||||
const isCurseForgeLinkedModpack = computed(() => instance.value.link?.type === 'curseforge_modpack')
|
||||
const isLinkedManagedModpack = computed(
|
||||
() => isModrinthLinkedModpack.value || isCurseForgeLinkedModpack.value,
|
||||
)
|
||||
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
|
||||
|
||||
const modpackInfoQuery = useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
|
||||
queryFn: () => get_linked_modpack_info(instance.value.id, 'must_revalidate'),
|
||||
enabled: computed(
|
||||
() => instance.value.install_stage === 'installed' && isLinkedManagedModpack.value && !offline,
|
||||
),
|
||||
})
|
||||
const modpackInfo = modpackInfoQuery.data
|
||||
|
||||
const repairing = ref(false)
|
||||
const reinstalling = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
loaderVersion: {
|
||||
id: 'instance.settings.tabs.installation.loader-version',
|
||||
defaultMessage: '{loader} version',
|
||||
},
|
||||
})
|
||||
|
||||
async function installLocalModpackFromPicker() {
|
||||
const picked = await filePicker.pickModpackFile({ readFile: false })
|
||||
if (!picked?.path) return false
|
||||
|
||||
const job = await install_pack_to_existing_instance(instance.value.id, {
|
||||
type: 'fromFile',
|
||||
path: picked.path,
|
||||
}).catch(handleError)
|
||||
if (!job) return false
|
||||
|
||||
const completed = await wait_for_install_job(job.job_id).catch(handleError)
|
||||
return !!completed
|
||||
}
|
||||
|
||||
provideAppBackup({
|
||||
async createBackup() {
|
||||
debug('createBackup: start', {
|
||||
instanceId: instance.value.id,
|
||||
instanceName: instance.value.name,
|
||||
})
|
||||
const allInstances = await list()
|
||||
const prefix = `${instance.value.name} - Backup #`
|
||||
const existingNums = allInstances
|
||||
.filter((p) => p.name.startsWith(prefix))
|
||||
.map((p) => parseInt(p.name.slice(prefix.length), 10))
|
||||
.filter((n) => !isNaN(n))
|
||||
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
|
||||
const job = await install_duplicate_instance(instance.value.id)
|
||||
const newInstanceId = installJobInstanceId(job)
|
||||
if (newInstanceId) {
|
||||
await edit(newInstanceId, { name: `${prefix}${nextNum}` })
|
||||
}
|
||||
debug('createBackup: done', { newInstanceId, backupName: `${prefix}${nextNum}` })
|
||||
},
|
||||
})
|
||||
|
||||
provideInstallationSettings({
|
||||
closeSettings: closeModal,
|
||||
loading: computed(() => metadataLoading.value || modpackInfoQuery.isLoading.value),
|
||||
installationInfo: computed(() => {
|
||||
const rows = [
|
||||
{
|
||||
label: formatMessage(commonMessages.platformLabel),
|
||||
value: formatLoaderLabel(instance.value.loader),
|
||||
},
|
||||
{
|
||||
label: formatMessage(commonMessages.gameVersionLabel),
|
||||
value: instance.value.game_version,
|
||||
},
|
||||
]
|
||||
if ((instance.value.loader_components ?? []).length > 1) {
|
||||
for (const component of instance.value.loader_components ?? []) {
|
||||
rows.push({
|
||||
label: formatMessage(messages.loaderVersion, {
|
||||
loader: formatLoaderLabel(component.kind),
|
||||
}),
|
||||
value: component.version ?? formatLoaderLabel(component.kind),
|
||||
})
|
||||
}
|
||||
} else if (instance.value.loader !== 'vanilla' && instance.value.loader_version) {
|
||||
rows.push({
|
||||
label: formatMessage(messages.loaderVersion, {
|
||||
loader: formatLoaderLabel(instance.value.loader),
|
||||
}),
|
||||
value: instance.value.loader_version,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}),
|
||||
isLinked: computed(() => isLinkedManagedModpack.value || isImportedModpack.value),
|
||||
isBusy: computed(
|
||||
() =>
|
||||
instance.value.install_stage !== 'installed' ||
|
||||
repairing.value ||
|
||||
reinstalling.value ||
|
||||
!!offline,
|
||||
),
|
||||
skipNonEssentialWarnings,
|
||||
modpack: computed(() => {
|
||||
if (isImportedModpack.value && instance.value.link?.type === 'imported_modpack') {
|
||||
return {
|
||||
iconUrl: instance.value.icon_path,
|
||||
title: instance.value.link.name ?? instance.value.name,
|
||||
versionNumber: instance.value.link.version_number ?? undefined,
|
||||
filename: instance.value.link.filename ?? undefined,
|
||||
}
|
||||
}
|
||||
if (modpackInfo.value) {
|
||||
return {
|
||||
iconUrl: modpackInfo.value.project.icon_url,
|
||||
title: modpackInfo.value.project.title,
|
||||
link: isCurseForgeLinkedModpack.value
|
||||
? `/project/curseforge/${String(modpackInfo.value.project.id).replace(/^curseforge:/, '')}`
|
||||
: `/project/${modpackInfo.value.project.slug ?? modpackInfo.value.project.id}`,
|
||||
versionNumber: modpackInfo.value.version?.version_number,
|
||||
}
|
||||
}
|
||||
// Fallback when linked metadata is temporarily unavailable so the
|
||||
// association controls still match Modrinth-linked packs.
|
||||
if (isCurseForgeLinkedModpack.value && instance.value.link?.type === 'curseforge_modpack') {
|
||||
return {
|
||||
iconUrl: instance.value.icon_path,
|
||||
title: instance.value.name,
|
||||
link: `/project/curseforge/${instance.value.link.project_id}`,
|
||||
versionNumber: instance.value.link.version_id,
|
||||
}
|
||||
}
|
||||
if (isModrinthLinkedModpack.value && instance.value.link) {
|
||||
const projectId =
|
||||
instance.value.link.type === 'server_project_modpack'
|
||||
? (instance.value.link.content_project_id ?? instance.value.link.project_id)
|
||||
: instance.value.link.project_id
|
||||
const versionId =
|
||||
instance.value.link.type === 'server_project_modpack'
|
||||
? instance.value.link.content_version_id
|
||||
: instance.value.link.version_id
|
||||
if (!projectId) return null
|
||||
return {
|
||||
iconUrl: instance.value.icon_path,
|
||||
title: instance.value.name,
|
||||
link: `/project/${projectId}`,
|
||||
versionNumber: versionId ?? undefined,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}),
|
||||
currentPlatform: computed(() => instance.value.loader),
|
||||
currentGameVersion: computed(() => instance.value.game_version),
|
||||
currentLoaderVersion: computed(() => instance.value.loader_version ?? ''),
|
||||
availablePlatforms: computed(() => [...instanceInstallablePlatforms]),
|
||||
editingPlatformRef: editingPlatform,
|
||||
editingGameVersionRef: editingGameVersion,
|
||||
loaderVersionState: scopedLoaderVersionState,
|
||||
|
||||
resolveGameVersions(loader, showSnapshots) {
|
||||
const versions = gameVersionsQuery.data.value ?? []
|
||||
const result = (
|
||||
showSnapshots ? versions : versions.filter((x) => x.version_type === 'release')
|
||||
).map((x) => ({ value: x.version, label: x.version }))
|
||||
debug('resolveGameVersions:', {
|
||||
loader,
|
||||
showSnapshots,
|
||||
totalVersions: versions.length,
|
||||
resultVersions: result.length,
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
||||
resolveLoaderVersions(loader, gameVersion) {
|
||||
if (loader === 'vanilla' || !gameVersion) {
|
||||
debug('resolveLoaderVersions: skipped', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
if (loader !== editingPlatform.value || gameVersion !== editingGameVersion.value) {
|
||||
debug('resolveLoaderVersions: stale selection', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
if (scopedLoaderVersionState.value !== 'supported') return []
|
||||
const result = loaderVersionsForGameVersion(scopedLoaderVersionsQuery.data.value, gameVersion)
|
||||
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
|
||||
return result
|
||||
},
|
||||
|
||||
resolveHasSnapshots(loader) {
|
||||
const versions = gameVersionsQuery.data.value ?? []
|
||||
const result = versions.some((x) => x.version_type !== 'release')
|
||||
debug('resolveHasSnapshots:', {
|
||||
loader,
|
||||
totalVersions: versions.length,
|
||||
result,
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
||||
async save(platform, gameVersion, loaderVersionId) {
|
||||
debug('save: called', {
|
||||
instanceId: instance.value.id,
|
||||
platform,
|
||||
gameVersion,
|
||||
loaderVersionId,
|
||||
})
|
||||
const editInstancePatch: Record<string, string | undefined> = {
|
||||
loader: platform,
|
||||
game_version: gameVersion,
|
||||
}
|
||||
if (platform !== 'vanilla' && loaderVersionId) {
|
||||
editInstancePatch.loader_version = loaderVersionId
|
||||
}
|
||||
await edit(instance.value.id, editInstancePatch).catch(handleError)
|
||||
debug('save: edit complete', { editInstancePatch })
|
||||
},
|
||||
|
||||
afterSave: async () => {
|
||||
debug('afterSave: installing', { instanceId: instance.value.id })
|
||||
await install_existing_instance(instance.value.id, false).catch(handleError)
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('afterSave: done')
|
||||
},
|
||||
|
||||
async repair() {
|
||||
debug('repair: called', { instanceId: instance.value.id })
|
||||
repairing.value = true
|
||||
await install_existing_instance(instance.value.id, true).catch(handleError)
|
||||
repairing.value = false
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('repair: done')
|
||||
},
|
||||
|
||||
async reinstallModpack() {
|
||||
debug('reinstallModpack: called', { instanceId: instance.value.id })
|
||||
reinstalling.value = true
|
||||
let shouldTrack = false
|
||||
try {
|
||||
if (isImportedModpack.value) {
|
||||
shouldTrack = await installLocalModpackFromPicker()
|
||||
} else if (isCurseForgeLinkedModpack.value) {
|
||||
const fileId = Number(instance.value.link?.version_id)
|
||||
if (!Number.isFinite(fileId)) {
|
||||
throw new Error('Invalid CurseForge file ID')
|
||||
}
|
||||
await updateManagedCurseForgeModpack(instance.value.id, fileId).catch(handleError)
|
||||
shouldTrack = true
|
||||
} else {
|
||||
await update_repair_modrinth(instance.value.id).catch(handleError)
|
||||
shouldTrack = true
|
||||
}
|
||||
} finally {
|
||||
reinstalling.value = false
|
||||
}
|
||||
if (shouldTrack) {
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
debug('reinstallModpack: done')
|
||||
},
|
||||
|
||||
async swapModpack() {
|
||||
debug('swapModpack: called', { instanceId: instance.value.id })
|
||||
reinstalling.value = true
|
||||
try {
|
||||
const installed = await installLocalModpackFromPicker()
|
||||
if (installed) {
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
reinstalling.value = false
|
||||
}
|
||||
debug('swapModpack: done')
|
||||
},
|
||||
|
||||
async unlinkModpack() {
|
||||
debug('unlinkModpack: called', { instanceId: instance.value.id })
|
||||
await edit(instance.value.id, {
|
||||
link: null as unknown as undefined,
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.id],
|
||||
})
|
||||
onUnlinked()
|
||||
debug('unlinkModpack: done')
|
||||
},
|
||||
|
||||
getCachedModpackVersions: () => null,
|
||||
async fetchModpackVersions() {
|
||||
debug('fetchModpackVersions: called', {
|
||||
projectId: instance.value.link?.project_id,
|
||||
})
|
||||
if (isCurseForgeLinkedModpack.value) {
|
||||
const rawProjectId = instance.value.link?.project_id
|
||||
if (!rawProjectId) return []
|
||||
const projectId = Number(
|
||||
rawProjectId.startsWith('curseforge:')
|
||||
? rawProjectId.slice('curseforge:'.length)
|
||||
: rawProjectId,
|
||||
)
|
||||
if (!Number.isFinite(projectId)) return []
|
||||
const { getCurseForgeFile, getCurseForgeFiles } = await import('@/helpers/curseforge')
|
||||
const files: CurseForgeFile[] = []
|
||||
let index = 0
|
||||
while (true) {
|
||||
const response = await getCurseForgeFiles(projectId, {
|
||||
index,
|
||||
pageSize: 50,
|
||||
}).catch(handleError)
|
||||
if (!response) break
|
||||
files.push(...response.files)
|
||||
index += response.files.length
|
||||
if (
|
||||
response.files.length === 0 ||
|
||||
index >= (response.pagination?.totalCount ?? response.files.length)
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
const installedFileId = Number(instance.value.link?.version_id)
|
||||
if (Number.isFinite(installedFileId) && !files.some((file) => file.id === installedFileId)) {
|
||||
const installedFile = await getCurseForgeFile(projectId, installedFileId).catch(() => null)
|
||||
if (installedFile?.isAvailable) {
|
||||
files.push(installedFile)
|
||||
}
|
||||
}
|
||||
const versions = files
|
||||
.filter((file) => file.isAvailable)
|
||||
.map((file) => {
|
||||
const loaders = [
|
||||
...new Set(
|
||||
file.gameVersions
|
||||
.map((value) => {
|
||||
switch (value.toLowerCase().replaceAll(' ', '')) {
|
||||
case 'forge':
|
||||
return 'forge'
|
||||
case 'fabric':
|
||||
case 'fabricloader':
|
||||
return 'fabric'
|
||||
case 'quilt':
|
||||
return 'quilt'
|
||||
case 'neoforge':
|
||||
return 'neoforge'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter(Boolean),
|
||||
),
|
||||
] as string[]
|
||||
const gameVersions = file.gameVersions.filter((value) => {
|
||||
const normalized = value.toLowerCase().replaceAll(' ', '')
|
||||
return !['forge', 'fabric', 'fabricloader', 'quilt', 'neoforge'].includes(normalized)
|
||||
})
|
||||
return {
|
||||
id: file.id.toString(),
|
||||
project_id: `curseforge:${projectId}`,
|
||||
name: file.displayName,
|
||||
version_number: file.displayName,
|
||||
game_versions: gameVersions,
|
||||
loaders: loaders.length > 0 ? loaders : ['minecraft'],
|
||||
date_published: file.fileDate,
|
||||
version_type:
|
||||
file.releaseType === 1 ? 'release' : file.releaseType === 2 ? 'beta' : 'alpha',
|
||||
files: [
|
||||
{
|
||||
filename: file.fileName,
|
||||
url: file.downloadUrl ?? '',
|
||||
primary: true,
|
||||
size: file.fileLength,
|
||||
hashes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as Labrinth.Versions.v2.Version
|
||||
})
|
||||
debug('fetchModpackVersions: done', { count: versions.length })
|
||||
return versions
|
||||
}
|
||||
const versions = await get_project_versions(instance.value.link!.project_id!).catch(handleError)
|
||||
debug('fetchModpackVersions: done', { count: versions?.length ?? 0 })
|
||||
return (versions ?? []) as Labrinth.Versions.v2.Version[]
|
||||
},
|
||||
|
||||
async getVersionChangelog(versionId: string) {
|
||||
debug('getVersionChangelog: called', { versionId })
|
||||
if (isCurseForgeLinkedModpack.value) {
|
||||
const rawProjectId = instance.value.link?.project_id
|
||||
const fileId = Number(versionId)
|
||||
const projectId = rawProjectId
|
||||
? Number(
|
||||
rawProjectId.startsWith('curseforge:')
|
||||
? rawProjectId.slice('curseforge:'.length)
|
||||
: rawProjectId,
|
||||
)
|
||||
: NaN
|
||||
if (!Number.isFinite(projectId) || !Number.isFinite(fileId)) return null
|
||||
const { getCurseForgeChangelog } = await import('@/helpers/curseforge')
|
||||
const changelog = await getCurseForgeChangelog(projectId, fileId).catch(() => null)
|
||||
if (changelog == null) return null
|
||||
return { id: versionId, changelog } as unknown as Labrinth.Versions.v2.Version
|
||||
}
|
||||
return (await get_version(versionId, 'must_revalidate').catch(
|
||||
() => null,
|
||||
)) as Labrinth.Versions.v2.Version | null
|
||||
},
|
||||
|
||||
async onModpackVersionConfirm(version) {
|
||||
debug('onModpackVersionConfirm: called', {
|
||||
versionId: version.id,
|
||||
instanceId: instance.value.id,
|
||||
})
|
||||
try {
|
||||
if (isCurseForgeLinkedModpack.value) {
|
||||
const fileId = Number(version.id)
|
||||
if (!Number.isFinite(fileId)) {
|
||||
throw new Error('Invalid CurseForge file ID')
|
||||
}
|
||||
await updateManagedCurseForgeModpack(instance.value.id, fileId)
|
||||
} else {
|
||||
await update_managed_modrinth_version(instance.value.id, version.id)
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.id],
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
}
|
||||
debug('onModpackVersionConfirm: done')
|
||||
},
|
||||
|
||||
updaterModalProps: computed(() => ({
|
||||
isApp: true,
|
||||
currentVersionId:
|
||||
modpackInfo.value?.update?.provider === 'modrinth'
|
||||
? modpackInfo.value.update.target_version_id
|
||||
: modpackInfo.value?.update?.provider === 'curseforge'
|
||||
? String(modpackInfo.value.update.target_file_id)
|
||||
: (instance.value.link?.version_id ?? ''),
|
||||
projectIconUrl: modpackInfo.value?.project?.icon_url,
|
||||
projectName: modpackInfo.value?.project?.title ?? 'Modpack',
|
||||
currentGameVersion: instance.value.game_version,
|
||||
currentLoader: instance.value.loader,
|
||||
})),
|
||||
|
||||
isServer: false,
|
||||
isApp: true,
|
||||
symlinkTarget: computed(() => instance.value.symlink_target),
|
||||
showModpackVersionActions: computed(
|
||||
() => isLinkedManagedModpack.value && !isMinecraftServer.value,
|
||||
),
|
||||
isLocalFile: isImportedModpack,
|
||||
repairing,
|
||||
reinstalling,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SymlinkInstanceWarning
|
||||
v-if="instance?.symlink_target"
|
||||
:symlink-target="instance.symlink_target"
|
||||
/>
|
||||
<InstallationSettingsLayout />
|
||||
</template>
|
||||
@ -0,0 +1,291 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
Slider,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { computed, readonly, 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 useMemorySlider from '@/composables/useMemorySlider'
|
||||
import { collectGcContext, extractJavaMajorVersion } from '@/helpers/gc/context'
|
||||
import type { GcContext } from '@/helpers/gc/types'
|
||||
import { edit, get_content_snapshot, get_optimal_jre_key } from '@/helpers/instance'
|
||||
import { get } from '@/helpers/settings'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { AppSettings } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
javaInstallation: {
|
||||
id: 'instance.settings.tabs.java.java-installation',
|
||||
defaultMessage: 'Java installation',
|
||||
},
|
||||
customJavaInstallation: {
|
||||
id: 'instance.settings.tabs.java.custom-java-installation',
|
||||
defaultMessage: 'Use a custom Java installation for this instance',
|
||||
},
|
||||
javaPathPlaceholder: {
|
||||
id: 'instance.settings.tabs.java.java-path-placeholder',
|
||||
defaultMessage: '/path/to/java',
|
||||
},
|
||||
javaMemory: {
|
||||
id: 'instance.settings.tabs.java.java-memory',
|
||||
defaultMessage: 'Memory allocated',
|
||||
},
|
||||
customMemoryAllocation: {
|
||||
id: 'instance.settings.tabs.java.custom-memory-allocation',
|
||||
defaultMessage: 'Custom memory allocation',
|
||||
},
|
||||
automaticMemory: {
|
||||
id: 'instance.settings.tabs.java.automatic-memory',
|
||||
defaultMessage: 'Automatically allocate memory at launch',
|
||||
},
|
||||
optimizeMemoryBeforeLaunch: {
|
||||
id: 'instance.settings.tabs.java.optimize-memory-before-launch',
|
||||
defaultMessage: 'Optimize memory before launching the game',
|
||||
},
|
||||
optimizeMemoryBeforeLaunchDescription: {
|
||||
id: 'instance.settings.tabs.java.optimize-memory-before-launch-description',
|
||||
defaultMessage: 'Waits for Windows memory optimization to finish before starting the game.',
|
||||
},
|
||||
javaArguments: {
|
||||
id: 'instance.settings.tabs.java.java-arguments',
|
||||
defaultMessage: 'Java arguments',
|
||||
},
|
||||
customJavaArguments: {
|
||||
id: 'instance.settings.tabs.java.custom-java-arguments',
|
||||
defaultMessage: 'Custom Java arguments',
|
||||
},
|
||||
enterJavaArguments: {
|
||||
id: 'instance.settings.tabs.java.enter-java-arguments',
|
||||
defaultMessage: 'Enter Java arguments...',
|
||||
},
|
||||
javaEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.environment-variables',
|
||||
defaultMessage: 'Environment variables',
|
||||
},
|
||||
customEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.custom-environment-variables',
|
||||
defaultMessage: 'Custom environment variables',
|
||||
},
|
||||
enterEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.enter-environment-variables',
|
||||
defaultMessage: 'Enter environmental variables...',
|
||||
},
|
||||
})
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
const supportsMemoryOptimization = (await platform()) === 'windows'
|
||||
|
||||
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
|
||||
const optimalJava = readonly(await get_optimal_jre_key(instance.value.id).catch(handleError))
|
||||
const requiredJavaVersion = optimalJava?.parsed_version ?? null
|
||||
|
||||
const overrideJavaInstall = ref(!!instance.value.java_path)
|
||||
const overrideJava = ref({
|
||||
...(optimalJava ?? {}),
|
||||
path: instance.value.java_path ?? optimalJava?.path ?? '',
|
||||
})
|
||||
const displayedJava = computed({
|
||||
get: () => (overrideJavaInstall.value ? overrideJava.value : (optimalJava ?? overrideJava.value)),
|
||||
set: (value) => {
|
||||
overrideJava.value = value
|
||||
},
|
||||
})
|
||||
|
||||
watch(overrideJavaInstall, (enabled) => {
|
||||
if (enabled && !overrideJava.value.path) {
|
||||
overrideJava.value = { ...(optimalJava ?? {}), path: optimalJava?.path ?? '' }
|
||||
}
|
||||
})
|
||||
|
||||
const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0)
|
||||
const javaArgs = ref(
|
||||
(instance.value.extra_launch_args ?? globalSettings?.extra_launch_args ?? []).join(' '),
|
||||
)
|
||||
|
||||
const overrideEnvVars = ref((instance.value.custom_env_vars?.length ?? 0) > 0)
|
||||
const envVars = ref(
|
||||
(instance.value.custom_env_vars ?? globalSettings?.custom_env_vars ?? [])
|
||||
.map((x: string[]) => x.join('='))
|
||||
.join(' '),
|
||||
)
|
||||
|
||||
const defaultMemory = { maximum: 2048, automatic: true, optimize_before_launch: false }
|
||||
const overrideMemorySettings = ref(!!instance.value.memory)
|
||||
const memory = ref({
|
||||
...defaultMemory,
|
||||
...(instance.value.memory ?? globalSettings?.memory),
|
||||
})
|
||||
const effectiveMemory = computed(() =>
|
||||
overrideMemorySettings.value ? memory.value : { ...defaultMemory, ...globalSettings?.memory },
|
||||
)
|
||||
const memData = await useMemorySlider().catch(() => ({
|
||||
maxMemory: ref(4096),
|
||||
snapPoints: computed(() => []),
|
||||
}))
|
||||
const maxMemory = memData.maxMemory
|
||||
const snapPoints = memData.snapPoints
|
||||
|
||||
const gcContext = ref<GcContext | null>(null)
|
||||
|
||||
async function updateGcContext() {
|
||||
const javaMajorVersion = extractJavaMajorVersion(displayedJava.value?.parsed_version)
|
||||
let modCount = 0
|
||||
try {
|
||||
const snapshot = await get_content_snapshot(instance.value.id)
|
||||
modCount = snapshot.items.filter(
|
||||
(item) => item.projectType === 'mod' && item.materializationState === 'present',
|
||||
).length
|
||||
} catch {
|
||||
modCount = 0
|
||||
}
|
||||
gcContext.value = await collectGcContext(
|
||||
memory.value.maximum,
|
||||
instance.value.loader,
|
||||
javaMajorVersion,
|
||||
modCount,
|
||||
)
|
||||
}
|
||||
|
||||
await updateGcContext()
|
||||
|
||||
watch([memory, displayedJava, () => instance.value.loader], updateGcContext)
|
||||
|
||||
const editInstanceObject = computed(() => ({
|
||||
java_path:
|
||||
overrideJavaInstall.value && overrideJava.value.path
|
||||
? overrideJava.value.path.replace('java.exe', 'javaw.exe')
|
||||
: null,
|
||||
extra_launch_args: overrideJavaArgs.value
|
||||
? javaArgs.value.trim().split(/\s+/).filter(Boolean)
|
||||
: null,
|
||||
custom_env_vars: overrideEnvVars.value
|
||||
? envVars.value
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((x: string) => x.split('=').filter(Boolean))
|
||||
: null,
|
||||
memory: overrideMemorySettings.value ? memory.value : null,
|
||||
}))
|
||||
|
||||
watch(
|
||||
[
|
||||
overrideJavaInstall,
|
||||
overrideJava,
|
||||
overrideJavaArgs,
|
||||
javaArgs,
|
||||
overrideEnvVars,
|
||||
envVars,
|
||||
overrideMemorySettings,
|
||||
memory,
|
||||
],
|
||||
async () => {
|
||||
await edit(instance.value.id, editInstanceObject.value).catch(handleError)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="m-0 mb-2 block text-base font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.javaInstallation) }}
|
||||
</h2>
|
||||
<Checkbox
|
||||
v-model="overrideJavaInstall"
|
||||
:label="formatMessage(messages.customJavaInstallation)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<JavaSelector
|
||||
v-model="displayedJava"
|
||||
:disabled="!overrideJavaInstall"
|
||||
:placeholder="formatMessage(messages.javaPathPlaceholder)"
|
||||
:version="requiredJavaVersion"
|
||||
select-all-versions
|
||||
/>
|
||||
|
||||
<h2 class="mb-1 mt-4 block text-base font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.javaMemory) }}
|
||||
</h2>
|
||||
<Checkbox
|
||||
v-model="overrideMemorySettings"
|
||||
:label="formatMessage(messages.customMemoryAllocation)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<Checkbox
|
||||
v-if="overrideMemorySettings"
|
||||
v-model="memory.automatic"
|
||||
:label="formatMessage(messages.automaticMemory)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<div
|
||||
v-if="supportsMemoryOptimization && overrideMemorySettings"
|
||||
class="mb-2 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="!overrideMemorySettings || memory.automatic"
|
||||
:min="512"
|
||||
:max="maxMemory"
|
||||
:step="64"
|
||||
:snap-points="snapPoints"
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
<MemoryAllocationDisplay :instance-id="instance.id" :memory="effectiveMemory" />
|
||||
|
||||
<h2 class="mb-1 mt-4 block text-base font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.javaArguments) }}
|
||||
</h2>
|
||||
<Checkbox
|
||||
v-model="overrideJavaArgs"
|
||||
:label="formatMessage(messages.customJavaArguments)"
|
||||
class="my-1"
|
||||
/>
|
||||
<JavaArgumentsInput
|
||||
id="java-args"
|
||||
v-model="javaArgs"
|
||||
:disabled="!overrideJavaArgs"
|
||||
:gc-context="gcContext"
|
||||
:show-auto-details="true"
|
||||
:placeholder="formatMessage(messages.enterJavaArguments)"
|
||||
/>
|
||||
|
||||
<h2 class="mb-1 mt-4 block text-base font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.javaEnvironmentVariables) }}
|
||||
</h2>
|
||||
<Checkbox
|
||||
v-model="overrideEnvVars"
|
||||
:label="formatMessage(messages.customEnvironmentVariables)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<StyledInput
|
||||
id="env-vars"
|
||||
v-model="envVars"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideEnvVars"
|
||||
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { computed, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import { edit } from '@/helpers/instance'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { AppSettings } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
const supportsMaximizeWindow = (await platform()) === 'windows'
|
||||
|
||||
const globalSettings = (await get().catch(handleError)) as AppSettings
|
||||
|
||||
const overrideWindowSettings = ref(
|
||||
!!instance.value.game_resolution ||
|
||||
!!instance.value.force_fullscreen ||
|
||||
!!instance.value.maximize_window,
|
||||
)
|
||||
const resolution: Ref<[number, number]> = ref(
|
||||
instance.value.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
|
||||
)
|
||||
const fullscreenSetting: Ref<boolean> = ref(
|
||||
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
|
||||
)
|
||||
const maximizeWindowSetting = ref(instance.value.maximize_window ?? globalSettings.maximize_window)
|
||||
|
||||
const editInstanceObject = computed(() => {
|
||||
if (!overrideWindowSettings.value) {
|
||||
return {
|
||||
force_fullscreen: null,
|
||||
maximize_window: null,
|
||||
game_resolution: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
force_fullscreen: fullscreenSetting.value,
|
||||
maximize_window: maximizeWindowSetting.value,
|
||||
game_resolution: fullscreenSetting.value ? null : resolution.value,
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[overrideWindowSettings, resolution, fullscreenSetting, maximizeWindowSetting],
|
||||
async () => {
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
customWindowSettings: {
|
||||
id: 'instance.settings.tabs.window.custom-window-settings',
|
||||
defaultMessage: 'Custom window settings',
|
||||
},
|
||||
fullscreen: {
|
||||
id: 'instance.settings.tabs.window.fullscreen',
|
||||
defaultMessage: 'Fullscreen',
|
||||
},
|
||||
fullscreenDescription: {
|
||||
id: 'instance.settings.tabs.window.fullscreen.description',
|
||||
defaultMessage: 'Make the game start in full screen when launched (using options.txt).',
|
||||
},
|
||||
maximizeWindow: {
|
||||
id: 'instance.settings.tabs.window.maximize-window',
|
||||
defaultMessage: 'Maximize window',
|
||||
},
|
||||
maximizeWindowDescription: {
|
||||
id: 'instance.settings.tabs.window.maximize-window.description',
|
||||
defaultMessage: 'Maximize the Minecraft window when launched.',
|
||||
},
|
||||
maximizeWindowUnsupported: {
|
||||
id: 'instance.settings.tabs.window.maximize-window.unsupported',
|
||||
defaultMessage: 'Not supported on this operating system.',
|
||||
},
|
||||
width: {
|
||||
id: 'instance.settings.tabs.window.width',
|
||||
defaultMessage: 'Width',
|
||||
},
|
||||
widthDescription: {
|
||||
id: 'instance.settings.tabs.window.width.description',
|
||||
defaultMessage: 'The width of the game window when launched.',
|
||||
},
|
||||
enterWidth: {
|
||||
id: 'instance.settings.tabs.window.width.enter',
|
||||
defaultMessage: 'Enter width...',
|
||||
},
|
||||
height: {
|
||||
id: 'instance.settings.tabs.window.height',
|
||||
defaultMessage: 'Height',
|
||||
},
|
||||
heightDescription: {
|
||||
id: 'instance.settings.tabs.window.height.description',
|
||||
defaultMessage: 'The height of the game window when launched.',
|
||||
},
|
||||
enterHeight: {
|
||||
id: 'instance.settings.tabs.window.height.enter',
|
||||
defaultMessage: 'Enter height...',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Checkbox
|
||||
v-model="overrideWindowSettings"
|
||||
:label="formatMessage(messages.customWindowSettings)"
|
||||
/>
|
||||
<div class="flex items-center gap-4 justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.fullscreen) }}
|
||||
</h2>
|
||||
<p class="m-0" :class="{ 'text-secondary': !supportsMaximizeWindow }">
|
||||
{{ formatMessage(messages.fullscreenDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="fullscreen"
|
||||
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
|
||||
:disabled="!overrideWindowSettings"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
fullscreenSetting = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.maximizeWindow) }}
|
||||
</h2>
|
||||
<p class="m-0">
|
||||
{{
|
||||
formatMessage(
|
||||
supportsMaximizeWindow
|
||||
? messages.maximizeWindowDescription
|
||||
: messages.maximizeWindowUnsupported,
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="maximize-window"
|
||||
:model-value="
|
||||
overrideWindowSettings ? maximizeWindowSetting : globalSettings.maximize_window
|
||||
"
|
||||
:disabled="!overrideWindowSettings || fullscreenSetting || !supportsMaximizeWindow"
|
||||
@update:model-value="(value) => (maximizeWindowSetting = value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.width) }}
|
||||
</h2>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.widthDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="width"
|
||||
v-model="resolution[0]"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideWindowSettings || fullscreenSetting"
|
||||
type="number"
|
||||
:placeholder="formatMessage(messages.enterWidth)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.height) }}
|
||||
</h2>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.heightDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="height"
|
||||
v-model="resolution[1]"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideWindowSettings || fullscreenSetting"
|
||||
type="number"
|
||||
:placeholder="formatMessage(messages.enterHeight)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DropdownIcon,
|
||||
LogInIcon,
|
||||
MessagesSquareIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Collapsible,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { AxolotlBrandConfig } from '@/config'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import i18n from '@/i18n.config'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
import { findMinecraftAuthError, type MinecraftAuthError } from './minecraft-auth-errors'
|
||||
import { translateMinecraftAuthErrorText } from './minecraft-auth-errors-zh'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const rawError = ref<string>('')
|
||||
const matchedError = ref<MinecraftAuthError | null>(null)
|
||||
const debugCollapsed = ref(true)
|
||||
const copied = ref(false)
|
||||
const loadingSignIn = ref(false)
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.minecraft-auth.title', defaultMessage: 'Sign in failed' },
|
||||
warning: {
|
||||
id: 'app.minecraft-auth.warning',
|
||||
defaultMessage:
|
||||
"We couldn't sign you in to your Microsoft account. This may be due to account restrictions or regional limitations.",
|
||||
},
|
||||
whatHappened: {
|
||||
id: 'app.minecraft-auth.what-happened',
|
||||
defaultMessage: 'What we think happened',
|
||||
},
|
||||
howToFix: { id: 'app.minecraft-auth.how-to-fix', defaultMessage: 'How to fix it' },
|
||||
unknownError: { id: 'app.minecraft-auth.unknown-error', defaultMessage: 'Unknown error' },
|
||||
unknownDescription: {
|
||||
id: 'app.minecraft-auth.unknown-description',
|
||||
defaultMessage:
|
||||
"We don't recognize this error and can't recommend specific steps to resolve it.",
|
||||
},
|
||||
tryMinecraftLoginBefore: {
|
||||
id: 'app.minecraft-auth.try-login-before',
|
||||
defaultMessage: 'Try visiting',
|
||||
},
|
||||
minecraftLogin: { id: 'app.minecraft-auth.minecraft-login', defaultMessage: 'Minecraft Login' },
|
||||
tryMinecraftLoginAfter: {
|
||||
id: 'app.minecraft-auth.try-login-after',
|
||||
defaultMessage:
|
||||
'and signing in, as it may prompt you with the necessary steps. You can also contact support and we can investigate further.',
|
||||
},
|
||||
contactSupport: {
|
||||
id: 'app.minecraft-auth.contact-support',
|
||||
defaultMessage: 'Contact support',
|
||||
},
|
||||
signInAgain: { id: 'app.minecraft-auth.sign-in-again', defaultMessage: 'Sign in again' },
|
||||
debugInformation: {
|
||||
id: 'app.minecraft-auth.debug-information',
|
||||
defaultMessage: 'Debug information',
|
||||
},
|
||||
copyDebugInfo: {
|
||||
id: 'app.minecraft-auth.copy-debug-info',
|
||||
defaultMessage: 'Copy debug information',
|
||||
},
|
||||
noErrorMessage: {
|
||||
id: 'app.minecraft-auth.no-error-message',
|
||||
defaultMessage: 'No error message.',
|
||||
},
|
||||
})
|
||||
|
||||
const localizedMatchedError = computed(() => {
|
||||
if (!matchedError.value) return null
|
||||
const locale = i18n.global.locale.value
|
||||
return {
|
||||
whatHappened: translateMinecraftAuthErrorText(matchedError.value.whatHappened, locale),
|
||||
stepsToFix: matchedError.value.stepsToFix.map((step) =>
|
||||
translateMinecraftAuthErrorText(step, locale),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
function show(errorVal: { message?: string }) {
|
||||
rawError.value = errorVal?.message ?? String(errorVal)
|
||||
|
||||
matchedError.value = findMinecraftAuthError(rawError.value)
|
||||
|
||||
debugCollapsed.value = true
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
|
||||
async function signInAgain() {
|
||||
try {
|
||||
loadingSignIn.value = true
|
||||
const loggedIn = await login_flow()
|
||||
if (loggedIn) {
|
||||
await set_default_user(loggedIn.profile.id)
|
||||
}
|
||||
loadingSignIn.value = false
|
||||
modal.value?.hide()
|
||||
} catch (err) {
|
||||
loadingSignIn.value = false
|
||||
handleSevereError(err)
|
||||
}
|
||||
}
|
||||
|
||||
const debugInfo = computed(() => rawError.value || formatMessage(messages.noErrorMessage))
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" :max-width="'548px'">
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="warning" :body="formatMessage(messages.warning)"> </Admonition>
|
||||
|
||||
<!-- Matched error details -->
|
||||
<div class="bg-surface-2 rounded-2xl p-4 px-5 flex flex-col gap-3">
|
||||
<template v-if="localizedMatchedError">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<h3 class="text-base font-bold m-0">
|
||||
{{ formatMessage(messages.whatHappened) }}
|
||||
</h3>
|
||||
<p class="text-sm text-secondary m-0">
|
||||
{{ localizedMatchedError.whatHappened }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<h3 class="text-base font-bold m-0">
|
||||
{{ formatMessage(messages.howToFix) }}
|
||||
</h3>
|
||||
<ol class="list-none flex flex-col gap-2 m-0 pl-0">
|
||||
<li
|
||||
v-for="(step, index) in localizedMatchedError.stepsToFix"
|
||||
:key="index"
|
||||
class="flex items-baseline gap-2"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center justify-center shrink-0 w-5 h-5 rounded-full bg-surface-4 border border-solid border-surface-5 text-xs font-medium"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<span
|
||||
class="text-sm [&_a]:text-info [&_a]:font-medium [&_a]:underline"
|
||||
v-html="step"
|
||||
/>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<h3 class="text-base font-bold m-0">
|
||||
{{ formatMessage(messages.unknownError) }}
|
||||
</h3>
|
||||
<p class="text-sm text-secondary m-0">
|
||||
{{ formatMessage(messages.unknownDescription) }}
|
||||
</p>
|
||||
<p class="text-sm text-secondary m-0">
|
||||
{{ formatMessage(messages.tryMinecraftLoginBefore) }}
|
||||
<a
|
||||
class="text-info font-medium underline hover:underline"
|
||||
href="https://www.minecraft.net/en-us/login"
|
||||
>{{ formatMessage(messages.minecraftLogin) }}</a
|
||||
>
|
||||
{{ formatMessage(messages.tryMinecraftLoginAfter) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled>
|
||||
<a :href="AxolotlBrandConfig.supportUrl" class="!w-full" @click="modal?.hide()">
|
||||
<MessagesSquareIcon /> {{ formatMessage(messages.contactSupport) }}
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="loadingSignIn" class="!w-full" @click="signInAgain">
|
||||
<LogInIcon /> {{ formatMessage(messages.signInAgain) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="w-full h-[1px] bg-surface-5"></div>
|
||||
|
||||
<!-- Debug info -->
|
||||
<div class="overflow-clip">
|
||||
<button
|
||||
class="flex items-center justify-between w-full bg-transparent border-0 py-4 cursor-pointer"
|
||||
@click="debugCollapsed = !debugCollapsed"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-contrast font-extrabold m-0">
|
||||
<WrenchIcon class="h-4 w-4" />
|
||||
{{ formatMessage(messages.debugInformation) }}
|
||||
</span>
|
||||
<DropdownIcon
|
||||
class="h-5 w-5 text-secondary transition-transform"
|
||||
:class="{ 'rotate-180': !debugCollapsed }"
|
||||
/>
|
||||
</button>
|
||||
<Collapsible :collapsed="debugCollapsed">
|
||||
<div
|
||||
class="p-3 bg-surface-2 rounded-2xl text-xs grid grid-cols-[1fr_auto] max-w-full items-start"
|
||||
>
|
||||
<div
|
||||
class="m-0 p-0 rounded-none bg-transparent text-sm font-mono break-words overflow-auto"
|
||||
>
|
||||
{{ debugInfo }}
|
||||
</div>
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.copyDebugInfo)"
|
||||
:disabled="copied"
|
||||
@click="copyToClipboard(debugInfo)"
|
||||
>
|
||||
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
|
||||
<template v-else> <CopyIcon /> </template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
@ -0,0 +1,108 @@
|
||||
const translations: Record<string, string> = {
|
||||
'Your saved Microsoft sign-in token has expired or was revoked, so Axolotl Launcher cannot refresh your Minecraft session.':
|
||||
'已保存的 Microsoft 登录令牌已过期或被撤销,因此 Axolotl Launcher 无法刷新你的 Minecraft 会话。',
|
||||
'Sign out of the affected Minecraft account in Axolotl Launcher':
|
||||
'在 Axolotl Launcher 中退出受影响的 Minecraft 账号',
|
||||
'Sign in to the account again': '重新登录该账号',
|
||||
'Once the new sign-in finishes, try launching Minecraft again':
|
||||
'完成重新登录后,再次尝试启动 Minecraft',
|
||||
'Xbox services rejected the first sign-in response. This is most often caused by your system clock or time zone being out of sync.':
|
||||
'Xbox 服务拒绝了首次登录响应。这通常是因为系统时间或时区不同步。',
|
||||
'Open your system date and time settings': '打开系统的日期和时间设置',
|
||||
'Turn on automatic time zone and automatic time, if available':
|
||||
'如果系统支持,请开启自动设置时区和自动设置时间',
|
||||
'Use the sync option in your system settings to synchronize the clock':
|
||||
'使用系统设置中的同步选项校准时钟',
|
||||
'Restart Axolotl Launcher': '重启 Axolotl Launcher',
|
||||
'Try signing in again': '再次尝试登录',
|
||||
'Microsoft or Minecraft temporarily blocked the sign-in request because there were too many recent attempts.':
|
||||
'由于短时间内尝试次数过多,Microsoft 或 Minecraft 暂时限制了此次登录请求。',
|
||||
'Wait about an hour before trying again': '等待约一小时后再试',
|
||||
'Restart Axolotl Launcher after waiting': '等待后重启 Axolotl Launcher',
|
||||
'Try signing in once more': '再次尝试登录',
|
||||
'If the same message appears, wait longer before retrying so the temporary limit can clear':
|
||||
'如果仍出现相同提示,请延长等待时间后再试,以便临时限制解除',
|
||||
"Minecraft's authentication service is returning a server error, so Axolotl Launcher cannot finish signing you in right now.":
|
||||
'Minecraft 身份验证服务返回了服务器错误,Axolotl Launcher 目前无法完成登录。',
|
||||
'Wait a few minutes and try signing in again': '等待几分钟后再次尝试登录',
|
||||
'Check <a href="https://support.xbox.com/xbox-live-status">Xbox Status</a> for current service issues':
|
||||
'在 <a href="https://support.xbox.com/xbox-live-status">Xbox 服务状态</a>中查看当前是否存在服务故障',
|
||||
'Try signing in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a> to confirm whether Minecraft sign-in is also affected there':
|
||||
'尝试使用<a href="https://www.minecraft.net/en-us/download">官方 Minecraft 启动器</a>登录,确认官方启动器是否也受到影响',
|
||||
'If the service is healthy and this keeps happening, contact support with the debug information below':
|
||||
'如果服务状态正常但问题持续存在,请携带下方调试信息联系支持',
|
||||
'Minecraft services could not return a Java Edition profile for this account. This most often happens when the game was purchased recently, the Java profile has not finished being created, or the wrong Microsoft account is being used.':
|
||||
'Minecraft 服务无法返回此账号的 Java 版档案。常见原因是刚购买游戏、Java 版档案尚未创建完成,或登录了错误的 Microsoft 账号。',
|
||||
'Sign in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>':
|
||||
'使用<a href="https://www.minecraft.net/en-us/download">官方 Minecraft 启动器</a>登录',
|
||||
'Launch Minecraft: Java Edition once from the official launcher':
|
||||
'通过官方启动器至少启动一次 Minecraft:Java 版',
|
||||
'Wait up to an hour if the purchase or profile setup was recent':
|
||||
'如果刚购买游戏或刚设置档案,请等待最多一小时',
|
||||
'Make sure you are using the Microsoft account that owns Minecraft. Visit <a href="https://github.com/Mystic-Stars/Axolotl/issues">Axolotl support</a> for help':
|
||||
'确认当前使用的是拥有 Minecraft 的 Microsoft 账号。如需帮助,请访问 <a href="https://github.com/Mystic-Stars/Axolotl/issues">Axolotl 支持</a>',
|
||||
'Try signing in to Axolotl Launcher again': '再次尝试登录 Axolotl Launcher',
|
||||
'Axolotl Launcher could not connect to a Microsoft, Xbox, or Minecraft service needed for sign-in. This is usually caused by a local network, DNS, proxy, firewall, hosts file, VPN, or antivirus issue.':
|
||||
'Axolotl Launcher 无法连接登录所需的 Microsoft、Xbox 或 Minecraft 服务。通常是本地网络、DNS、代理、防火墙、hosts 文件、VPN 或杀毒软件导致的。',
|
||||
'Restart Axolotl Launcher and try signing in again': '重启 Axolotl Launcher,然后再次尝试登录',
|
||||
'Check that your internet connection is working': '检查网络连接是否正常',
|
||||
'Allow Axolotl Launcher through your firewall, antivirus, proxy, VPN, and hosts file rules':
|
||||
'在防火墙、杀毒软件、代理、VPN 和 hosts 文件规则中允许 Axolotl Launcher 通行',
|
||||
'Try a different network or temporarily disable VPN/proxy software if you use one':
|
||||
'尝试更换网络;如果正在使用 VPN 或代理软件,请暂时关闭后再试',
|
||||
'If routing or DNS is the issue, a service like Cloudflare WARP can sometimes help':
|
||||
'如果问题来自路由或 DNS,可以尝试使用 Cloudflare WARP 等服务',
|
||||
'Your Minecraft/Xbox Live account requires age verification to comply with UK regulations. You must complete this before signing in.':
|
||||
'根据英国法规,你的 Minecraft/Xbox Live 账号需要完成年龄验证,验证完成后才能登录。',
|
||||
'Go to the <a href="https://www.minecraft.net/en-us/login">Minecraft Login</a> page and sign in':
|
||||
'前往 <a href="https://www.minecraft.net/en-us/login">Minecraft 登录</a>页面并登录',
|
||||
'Follow the instructions to verify your age': '按照页面提示完成年龄验证',
|
||||
'Once verified, try signing in again': '验证完成后再次尝试登录',
|
||||
'For additional help, visit <a href="https://support.xbox.com/en-GB/help/family-online-safety/online-safety/UK-age-verification">UK age verification on Xbox</a>':
|
||||
'如需更多帮助,请参阅 <a href="https://support.xbox.com/en-GB/help/family-online-safety/online-safety/UK-age-verification">Xbox 英国年龄验证</a>',
|
||||
"This account doesn't have an Xbox profile set up or doesn't own Minecraft.":
|
||||
'此账号尚未设置 Xbox 档案,或未拥有 Minecraft。',
|
||||
'Make sure Minecraft is purchased on this account': '确认此账号已经购买 Minecraft',
|
||||
'Visit <a href="https://www.minecraft.net/en-us/login">Minecraft Login</a> and sign in':
|
||||
'访问 <a href="https://www.minecraft.net/en-us/login">Minecraft 登录</a>页面并登录',
|
||||
'Complete Xbox profile setup if prompted': '如果出现提示,请完成 Xbox 档案设置',
|
||||
'Once finished, try signing in again': '完成后再次尝试登录',
|
||||
"Xbox Live isn't available in your region, so sign-in is blocked.":
|
||||
'你所在的地区不支持 Xbox Live,因此登录被阻止。',
|
||||
'Xbox services must be supported in your country before you can sign in':
|
||||
'只有所在国家或地区支持 Xbox 服务时才能登录',
|
||||
'Check <a href="https://www.xbox.com/en-US/regions">Xbox Availability</a> for supported regions':
|
||||
'在 <a href="https://www.xbox.com/en-US/regions">Xbox 可用地区</a>中查看支持范围',
|
||||
'This account requires adult verification under South Korean regulations.':
|
||||
'根据韩国法规,此账号需要完成成年人验证。',
|
||||
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in':
|
||||
'访问 <a href="https://www.xbox.com">Xbox</a> 并登录',
|
||||
'Complete the identity verification process': '完成身份验证流程',
|
||||
'This account is underage and not linked to a Microsoft family group.':
|
||||
'此账号为未成年账号,且尚未加入 Microsoft 家庭组。',
|
||||
'Review the <a href="https://help.minecraft.net/hc/en-us/articles/4408968616077">Family Setup Guide</a>':
|
||||
'查看<a href="https://help.minecraft.net/hc/en-us/articles/4408968616077">家庭组设置指南</a>',
|
||||
'Join or create a family group as instructed': '按照指南加入或创建家庭组',
|
||||
'This account was suspended for violating Xbox Community Standards.':
|
||||
'此账号因违反 Xbox 社区准则而被暂停。',
|
||||
'Visit <a href="https://support.xbox.com">Xbox Support</a> and review the enforcement details':
|
||||
'访问 <a href="https://support.xbox.com">Xbox 支持</a>并查看处罚详情',
|
||||
'Submit an appeal if one is available': '如果可以申诉,请提交申诉',
|
||||
"This account is restricted and doesn't have permission to play online.":
|
||||
'此账号受到限制,没有进行在线游戏的权限。',
|
||||
'Have a guardian sign in to <a href="https://account.microsoft.com/family/">Microsoft Family</a>':
|
||||
'请监护人登录 <a href="https://account.microsoft.com/family/">Microsoft 家庭</a>',
|
||||
'Update online play permissions': '更新在线游戏权限',
|
||||
"This account hasn't accepted Xbox's Terms of Service.": '此账号尚未接受 Xbox 服务条款。',
|
||||
'Accept the Terms if prompted': '如果出现提示,请接受相关条款',
|
||||
'Xbox services rejected the request to authorize this account for Minecraft services, but did not return a specific account restriction that Axolotl Launcher recognizes.':
|
||||
'Xbox 服务拒绝授权此账号访问 Minecraft 服务,但未返回 Axolotl Launcher 能识别的具体账号限制。',
|
||||
'Complete any prompts shown by Microsoft, Xbox, or Minecraft':
|
||||
'完成 Microsoft、Xbox 或 Minecraft 显示的所有提示步骤',
|
||||
'If the official launcher also fails, follow the error shown there or contact Xbox Support':
|
||||
'如果官方启动器也无法登录,请按照其中显示的错误处理,或联系 Xbox 支持',
|
||||
}
|
||||
|
||||
export function translateMinecraftAuthErrorText(text: string, locale: string): string {
|
||||
return locale === 'zh-CN' ? (translations[text] ?? text) : text
|
||||
}
|
||||
@ -0,0 +1,200 @@
|
||||
export interface MinecraftAuthError {
|
||||
errorCode?: string
|
||||
errorMatchers?: string[]
|
||||
matches?: (message: string) => boolean
|
||||
whatHappened: string
|
||||
stepsToFix: string[]
|
||||
}
|
||||
|
||||
export const minecraftAuthErrors: MinecraftAuthError[] = [
|
||||
{
|
||||
errorMatchers: ['Failed to deserialize response to JSON during step RefreshOAuthToken:'],
|
||||
whatHappened:
|
||||
'Your saved Microsoft sign-in token has expired or was revoked, so Axolotl Launcher cannot refresh your Minecraft session.',
|
||||
stepsToFix: [
|
||||
'Sign out of the affected Minecraft account in Axolotl Launcher',
|
||||
'Sign in to the account again',
|
||||
'Once the new sign-in finishes, try launching Minecraft again',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorMatchers: ['Failed to deserialize response to JSON during step SisuAuthenticate:'],
|
||||
whatHappened:
|
||||
'Xbox services rejected the first sign-in response. This is most often caused by your system clock or time zone being out of sync.',
|
||||
stepsToFix: [
|
||||
'Open your system date and time settings',
|
||||
'Turn on automatic time zone and automatic time, if available',
|
||||
'Use the sync option in your system settings to synchronize the clock',
|
||||
'Restart Axolotl Launcher',
|
||||
'Try signing in again',
|
||||
],
|
||||
},
|
||||
{
|
||||
matches: (message) =>
|
||||
message.includes('Failed to deserialize response to JSON during step MinecraftToken:') &&
|
||||
message.includes('429 Too Many Requests'),
|
||||
whatHappened:
|
||||
'Microsoft or Minecraft temporarily blocked the sign-in request because there were too many recent attempts.',
|
||||
stepsToFix: [
|
||||
'Wait about an hour before trying again',
|
||||
'Restart Axolotl Launcher after waiting',
|
||||
'Try signing in once more',
|
||||
'If the same message appears, wait longer before retrying so the temporary limit can clear',
|
||||
],
|
||||
},
|
||||
{
|
||||
matches: (message) =>
|
||||
message.includes('Failed to deserialize response to JSON during step MinecraftToken:') &&
|
||||
/Status Code: 5\d\d/.test(message),
|
||||
whatHappened:
|
||||
"Minecraft's authentication service is returning a server error, so Axolotl Launcher cannot finish signing you in right now.",
|
||||
stepsToFix: [
|
||||
'Wait a few minutes and try signing in again',
|
||||
'Check <a href="https://support.xbox.com/xbox-live-status">Xbox Status</a> for current service issues',
|
||||
'Try signing in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a> to confirm whether Minecraft sign-in is also affected there',
|
||||
'If the service is healthy and this keeps happening, contact support with the debug information below',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorMatchers: ['Failed to fetch player profile'],
|
||||
whatHappened:
|
||||
'Minecraft services could not return a Java Edition profile for this account. This most often happens when the game was purchased recently, the Java profile has not finished being created, or the wrong Microsoft account is being used.',
|
||||
stepsToFix: [
|
||||
'Sign in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>',
|
||||
'Launch Minecraft: Java Edition once from the official launcher',
|
||||
'Wait up to an hour if the purchase or profile setup was recent',
|
||||
'Make sure you are using the Microsoft account that owns Minecraft. Visit <a href="https://github.com/Mystic-Stars/Axolotl/issues">Axolotl support</a> for help',
|
||||
'Try signing in to Axolotl Launcher again',
|
||||
],
|
||||
},
|
||||
{
|
||||
matches: (message) =>
|
||||
message.includes('error sending request for url (') &&
|
||||
[
|
||||
'minecraft.net',
|
||||
'minecraftservices.com',
|
||||
'mojang.com',
|
||||
'xbox.com',
|
||||
'xboxlive.com',
|
||||
'live.com',
|
||||
].some((domain) => message.includes(domain)),
|
||||
whatHappened:
|
||||
'Axolotl Launcher could not connect to a Microsoft, Xbox, or Minecraft service needed for sign-in. This is usually caused by a local network, DNS, proxy, firewall, hosts file, VPN, or antivirus issue.',
|
||||
stepsToFix: [
|
||||
'Restart Axolotl Launcher and try signing in again',
|
||||
'Check that your internet connection is working',
|
||||
'Allow Axolotl Launcher through your firewall, antivirus, proxy, VPN, and hosts file rules',
|
||||
'Try a different network or temporarily disable VPN/proxy software if you use one',
|
||||
'If routing or DNS is the issue, a service like Cloudflare WARP can sometimes help',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorCode: '2148916222',
|
||||
whatHappened:
|
||||
'Your Minecraft/Xbox Live account requires age verification to comply with UK regulations. You must complete this before signing in.',
|
||||
stepsToFix: [
|
||||
'Go to the <a href="https://www.minecraft.net/en-us/login">Minecraft Login</a> page and sign in',
|
||||
'Follow the instructions to verify your age',
|
||||
'Once verified, try signing in again',
|
||||
'For additional help, visit <a href="https://support.xbox.com/en-GB/help/family-online-safety/online-safety/UK-age-verification">UK age verification on Xbox</a>',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorCode: '2148916233',
|
||||
whatHappened: "This account doesn't have an Xbox profile set up or doesn't own Minecraft.",
|
||||
stepsToFix: [
|
||||
'Make sure Minecraft is purchased on this account',
|
||||
'Visit <a href="https://www.minecraft.net/en-us/login">Minecraft Login</a> and sign in',
|
||||
'Complete Xbox profile setup if prompted',
|
||||
'Once finished, try signing in again',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorCode: '2148916235',
|
||||
whatHappened: "Xbox Live isn't available in your region, so sign-in is blocked.",
|
||||
stepsToFix: [
|
||||
'Xbox services must be supported in your country before you can sign in',
|
||||
'Check <a href="https://www.xbox.com/en-US/regions">Xbox Availability</a> for supported regions',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorCode: '2148916236',
|
||||
whatHappened: 'This account requires adult verification under South Korean regulations.',
|
||||
stepsToFix: [
|
||||
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
|
||||
'Complete the identity verification process',
|
||||
'Once finished, try signing in again',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorCode: '2148916237',
|
||||
whatHappened: 'This account requires adult verification under South Korean regulations.',
|
||||
stepsToFix: [
|
||||
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
|
||||
'Complete the identity verification process',
|
||||
'Once finished, try signing in again',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorCode: '2148916238',
|
||||
whatHappened: 'This account is underage and not linked to a Microsoft family group.',
|
||||
stepsToFix: [
|
||||
'Review the <a href="https://help.minecraft.net/hc/en-us/articles/4408968616077">Family Setup Guide</a>',
|
||||
'Join or create a family group as instructed',
|
||||
'Once finished, try signing in again',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorCode: '2148916227',
|
||||
whatHappened: 'This account was suspended for violating Xbox Community Standards.',
|
||||
stepsToFix: [
|
||||
'Visit <a href="https://support.xbox.com">Xbox Support</a> and review the enforcement details',
|
||||
'Submit an appeal if one is available',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorCode: '2148916229',
|
||||
whatHappened: "This account is restricted and doesn't have permission to play online.",
|
||||
stepsToFix: [
|
||||
'Have a guardian sign in to <a href="https://account.microsoft.com/family/">Microsoft Family</a>',
|
||||
'Update online play permissions',
|
||||
'Once finished, try signing in again',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorCode: '2148916234',
|
||||
whatHappened: "This account hasn't accepted Xbox's Terms of Service.",
|
||||
stepsToFix: [
|
||||
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
|
||||
'Accept the Terms if prompted',
|
||||
'Once finished, try signing in again',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorMatchers: ['Failed to deserialize response to JSON during step XstsAuthorize:'],
|
||||
whatHappened:
|
||||
'Xbox services rejected the request to authorize this account for Minecraft services, but did not return a specific account restriction that Axolotl Launcher recognizes.',
|
||||
stepsToFix: [
|
||||
'Sign in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>',
|
||||
'Complete any prompts shown by Microsoft, Xbox, or Minecraft',
|
||||
'Try signing in to Axolotl Launcher again',
|
||||
'If the official launcher also fails, follow the error shown there or contact Xbox Support',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function findMinecraftAuthError(message: string): MinecraftAuthError | null {
|
||||
return (
|
||||
minecraftAuthErrors.find((error) => {
|
||||
if (error.errorCode && message.includes(error.errorCode)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (error.errorMatchers?.some((matcher) => message.includes(matcher))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return error.matches?.(message) ?? false
|
||||
}) ?? null
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
defineProps({
|
||||
onFlowCancel: {
|
||||
type: Function,
|
||||
default() {
|
||||
return async () => {}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
signInBrowser: {
|
||||
id: 'app.auth.sign-in-browser',
|
||||
defaultMessage: 'Please sign in in the browser window that just opened to continue.',
|
||||
},
|
||||
})
|
||||
|
||||
function show() {
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @hide="onFlowCancel">
|
||||
<template #title>
|
||||
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
|
||||
<LogInIcon /> {{ formatMessage(commonMessages.signInButton) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-center gap-2">
|
||||
<SpinnerIcon class="w-12 h-12 animate-spin" />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.signInBrowser) }}
|
||||
</p>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="standard" max-width="500px">
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.description, { count: instanceIds.length }) }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-3 mt-4">
|
||||
<RadioButtons v-model="selectedGroup" :items="groupOptions">
|
||||
<template #default="{ item }">
|
||||
<span class="flex items-center justify-between flex-1 leading-none">
|
||||
<span>{{ item || formatMessage(messages.noGroup) }}</span>
|
||||
<button
|
||||
v-if="item"
|
||||
class="bg-transparent border-none cursor-pointer text-secondary hover:text-red rounded flex items-center justify-center w-6 h-6"
|
||||
@click.stop="deleteGroup(item)"
|
||||
>
|
||||
<TrashIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<span v-else class="w-6 h-6" />
|
||||
</span>
|
||||
</template>
|
||||
</RadioButtons>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<StyledInput
|
||||
v-model="newGroupInput"
|
||||
:placeholder="formatMessage(messages.enterGroupName)"
|
||||
class="w-full max-w-[300px]"
|
||||
@submit="addNewGroup"
|
||||
/>
|
||||
<ButtonStyled>
|
||||
<button class="w-fit !shadow-none" @click="addNewGroup">
|
||||
<PlusIcon /> {{ formatMessage(messages.createGroup) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="confirm">
|
||||
<CheckIcon />
|
||||
{{ formatMessage(messages.applyButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, PlusIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
RadioButtons,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { edit, list } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
instanceIds: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'applied'): void
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.instances.batch-edit-groups.header',
|
||||
defaultMessage: 'Edit groups',
|
||||
},
|
||||
description: {
|
||||
id: 'app.instances.batch-edit-groups.description',
|
||||
defaultMessage: 'Select groups to apply to {count} instance(s).',
|
||||
},
|
||||
enterGroupName: {
|
||||
id: 'app.instances.batch-edit-groups.enter-group-name',
|
||||
defaultMessage: 'Enter group name',
|
||||
},
|
||||
createGroup: {
|
||||
id: 'app.instances.batch-edit-groups.create-group',
|
||||
defaultMessage: 'Create new group',
|
||||
},
|
||||
applyButton: {
|
||||
id: 'app.instances.batch-edit-groups.apply',
|
||||
defaultMessage: 'Apply',
|
||||
},
|
||||
noGroup: {
|
||||
id: 'app.instances.group.ungrouped',
|
||||
defaultMessage: 'No group',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const selectedGroup = ref('')
|
||||
const newGroupInput = ref('')
|
||||
const allInstances = ref<GameInstance[]>([])
|
||||
|
||||
const availableGroups = computed(() => {
|
||||
const groups = new Set<string>()
|
||||
for (const instance of allInstances.value) {
|
||||
for (const group of instance.groups) {
|
||||
groups.add(group)
|
||||
}
|
||||
}
|
||||
return [...groups]
|
||||
})
|
||||
|
||||
const groupOptions = computed(() => ['', ...availableGroups.value])
|
||||
|
||||
function show() {
|
||||
selectedGroup.value = ''
|
||||
newGroupInput.value = ''
|
||||
list().then((instances) => {
|
||||
allInstances.value = instances as GameInstance[]
|
||||
})
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function addNewGroup() {
|
||||
const text = newGroupInput.value.trim()
|
||||
if (text.length > 0) {
|
||||
const groupName = text.substring(0, 32)
|
||||
allInstances.value.push({ groups: [groupName] } as GameInstance)
|
||||
selectedGroup.value = groupName
|
||||
newGroupInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGroup(group: string) {
|
||||
for (const instance of allInstances.value) {
|
||||
if (instance.groups.includes(group)) {
|
||||
const newGroups = instance.groups.filter((g) => g !== group)
|
||||
await edit(instance.id, { groups: newGroups }).catch(() => {})
|
||||
instance.groups = newGroups
|
||||
}
|
||||
}
|
||||
if (selectedGroup.value === group) {
|
||||
selectedGroup.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (newGroupInput.value.trim().length > 0) {
|
||||
addNewGroup()
|
||||
}
|
||||
|
||||
modal.value?.hide()
|
||||
|
||||
const groups = selectedGroup.value ? [selectedGroup.value.trim().substring(0, 32)] : []
|
||||
|
||||
for (const instanceId of props.instanceIds) {
|
||||
await edit(instanceId, { groups }).catch(() => {})
|
||||
}
|
||||
|
||||
emit('applied')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(count > 1 ? messages.batchHeader : messages.header)"
|
||||
fade="danger"
|
||||
max-width="500px"
|
||||
>
|
||||
<Admonition
|
||||
v-if="!symlinkTarget && count <= 1"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.admonitionHeader)"
|
||||
>
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-else-if="!symlinkTarget"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.admonitionHeader)"
|
||||
>
|
||||
{{ formatMessage(messages.batchAdmonitionBody, { count }) }}
|
||||
</Admonition>
|
||||
<Admonition v-else type="critical">
|
||||
{{ formatMessage(messages.symlinkDeleteWarning, { path: symlinkTarget }) }}
|
||||
</Admonition>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="confirm">
|
||||
<TrashIcon />
|
||||
{{
|
||||
formatMessage(count > 1 ? messages.batchDeleteButton : messages.deleteButton, {
|
||||
count,
|
||||
})
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
symlinkTarget?: string | null
|
||||
count?: number
|
||||
}>(),
|
||||
{
|
||||
count: 1,
|
||||
},
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.instance.confirm-delete.header',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
batchHeader: {
|
||||
id: 'app.instance.confirm-delete.batch-header',
|
||||
defaultMessage: 'Delete instances',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'app.instance.confirm-delete.admonition-header',
|
||||
defaultMessage: 'This action cannot be undone',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'app.instance.confirm-delete.admonition-body',
|
||||
defaultMessage:
|
||||
'All data for your instance will be permanently deleted, including your worlds, configs, and all installed content.',
|
||||
},
|
||||
batchAdmonitionBody: {
|
||||
id: 'app.instance.confirm-delete.batch-admonition-body',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# instance} other {# instances}} will be permanently deleted, including worlds, configs, and all installed content.',
|
||||
},
|
||||
symlinkDeleteWarning: {
|
||||
id: 'app.instance.confirm-delete.symlink-warning',
|
||||
defaultMessage:
|
||||
'This is a shared instance linked to "{path}". Only the link will be removed; the original files will not be deleted.',
|
||||
},
|
||||
deleteButton: {
|
||||
id: 'app.instance.confirm-delete.delete-button',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
batchDeleteButton: {
|
||||
id: 'app.instance.confirm-delete.batch-delete-button',
|
||||
defaultMessage: 'Delete {count, plural, one {# instance} other {# instances}}',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
modal.value?.hide()
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,78 @@
|
||||
<!-- @deprecated Use ConfirmModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
|
||||
<script setup lang="ts">
|
||||
import { ConfirmModal } from '@modrinth/ui'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
defineProps({
|
||||
confirmationText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasToType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'No title defined',
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'No description defined',
|
||||
required: true,
|
||||
},
|
||||
proceedIcon: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
proceedLabel: {
|
||||
type: String,
|
||||
default: 'Proceed',
|
||||
},
|
||||
danger: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
/** @deprecated No longer used — ads are handled by provideModalBehavior */
|
||||
showAdOnClose: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
markdown: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['proceed'])
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
modal.value?.show()
|
||||
},
|
||||
hide: () => {
|
||||
modal.value?.hide()
|
||||
},
|
||||
})
|
||||
|
||||
function proceed() {
|
||||
emit('proceed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmModal
|
||||
ref="modal"
|
||||
:confirmation-text="confirmationText"
|
||||
:has-to-type="hasToType"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:proceed-icon="proceedIcon"
|
||||
:proceed-label="proceedLabel"
|
||||
:danger="danger"
|
||||
:markdown="markdown"
|
||||
@proceed="proceed"
|
||||
/>
|
||||
</template>
|
||||
@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
MinecraftFormattedText,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { autoCleanToText } from '@sfirew/minecraft-motd-parser'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface ContentToggleDependencyItem {
|
||||
title: string
|
||||
iconUrl?: string | null
|
||||
versionNumber?: string
|
||||
}
|
||||
|
||||
export interface ContentToggleDependenciesData {
|
||||
enabling: boolean
|
||||
bulk: boolean
|
||||
primaryTitle: string
|
||||
related: ContentToggleDependencyItem[]
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.instance.mods.toggle-dependencies.title',
|
||||
defaultMessage: 'Confirm toggle',
|
||||
},
|
||||
singleBody: {
|
||||
id: 'app.instance.mods.toggle-dependencies.single-body',
|
||||
defaultMessage:
|
||||
'Toggling {project} will affect {count, plural, one {# other item} other {# other items}}.',
|
||||
},
|
||||
bulkBody: {
|
||||
id: 'app.instance.mods.toggle-dependencies.bulk-body',
|
||||
defaultMessage:
|
||||
'Toggling the selected content will affect {count, plural, one {# other item} other {# other items}}.',
|
||||
},
|
||||
willEnable: {
|
||||
id: 'app.instance.mods.toggle-dependencies.will-enable',
|
||||
defaultMessage: 'The following content will be enabled:',
|
||||
},
|
||||
willDisable: {
|
||||
id: 'app.instance.mods.toggle-dependencies.will-disable',
|
||||
defaultMessage: 'The following content will be disabled:',
|
||||
},
|
||||
warning: {
|
||||
id: 'app.instance.mods.toggle-dependencies.warning',
|
||||
defaultMessage:
|
||||
'Do you want to apply these related changes automatically? Ignoring them may break the game.',
|
||||
},
|
||||
apply: {
|
||||
id: 'app.instance.mods.toggle-dependencies.apply',
|
||||
defaultMessage: 'Toggle related content',
|
||||
},
|
||||
selectedOnly: {
|
||||
id: 'app.instance.mods.toggle-dependencies.selected-only',
|
||||
defaultMessage: 'Only toggle selected',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const data = ref<ContentToggleDependenciesData | null>(null)
|
||||
let settled = false
|
||||
let resolveShow: ((choice: 'apply' | 'selected' | 'cancel') => void) | null = null
|
||||
|
||||
function finish(choice: 'apply' | 'selected' | 'cancel') {
|
||||
if (settled) return
|
||||
settled = true
|
||||
const resolve = resolveShow
|
||||
resolveShow = null
|
||||
if (resolve) resolve(choice)
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function show(value: ContentToggleDependenciesData): Promise<'apply' | 'selected' | 'cancel'> {
|
||||
data.value = value
|
||||
settled = false
|
||||
modal.value?.show()
|
||||
return new Promise((resolve) => {
|
||||
resolveShow = resolve
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
scrollable
|
||||
max-content-height="70vh"
|
||||
max-width="36rem"
|
||||
:on-hide="() => finish('cancel')"
|
||||
>
|
||||
<div v-if="data" class="flex flex-col gap-4">
|
||||
<p class="m-0 text-primary">
|
||||
<IntlFormatted
|
||||
:message-id="data.bulk ? messages.bulkBody : messages.singleBody"
|
||||
:values="{ count: data.related.length }"
|
||||
>
|
||||
<template #project>
|
||||
<MinecraftFormattedText :text="data.primaryTitle" />
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
|
||||
<div v-if="data.related.length > 0" class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(data.enabling ? messages.willEnable : messages.willDisable) }}
|
||||
</span>
|
||||
<div
|
||||
v-for="item in data.related"
|
||||
:key="item.title"
|
||||
class="flex items-center gap-3 rounded-xl border border-solid border-surface-4 bg-surface-2 p-3"
|
||||
>
|
||||
<Avatar
|
||||
:src="item.iconUrl"
|
||||
:alt="autoCleanToText(item.title)"
|
||||
size="2.5rem"
|
||||
:tint-by="item.title"
|
||||
no-shadow
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<MinecraftFormattedText
|
||||
:text="item.title"
|
||||
class="truncate font-semibold text-contrast"
|
||||
/>
|
||||
<span v-if="item.versionNumber" class="truncate text-sm text-secondary">
|
||||
{{ item.versionNumber }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.warning) }}</p>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="finish('cancel')">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="finish('apply')">{{ formatMessage(messages.apply) }}</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="finish('selected')">{{ formatMessage(messages.selectedOnly) }}</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@ -0,0 +1,535 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
:fade="remainingCount > 0 ? 'warning' : 'standard'"
|
||||
:on-hide="stopScanning"
|
||||
max-width="680px"
|
||||
scrollable
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="m-0 text-secondary">
|
||||
{{
|
||||
installed == null
|
||||
? formatMessage(messages.existingBody, { manual: remainingCount })
|
||||
: formatMessage(messages.body, {
|
||||
installed,
|
||||
manual: remainingCount,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<Admonition
|
||||
:type="remainingCount === 0 ? 'success' : 'info'"
|
||||
:header="
|
||||
formatMessage(remainingCount === 0 ? messages.allImported : messages.automaticImport)
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckIcon v-if="remainingCount === 0" class="size-5 shrink-0" aria-hidden="true" />
|
||||
<SpinnerIcon
|
||||
v-else-if="scanning"
|
||||
class="size-5 shrink-0 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<FolderSearchIcon v-else class="size-5 shrink-0" aria-hidden="true" />
|
||||
</template>
|
||||
<span class="min-w-0 break-words text-secondary">{{ scannerStatus }}</span>
|
||||
</Admonition>
|
||||
|
||||
<div class="max-h-72 overflow-y-auto rounded-lg border border-surface-5 bg-surface-2">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="itemKey(item)"
|
||||
class="flex min-h-16 items-center justify-between gap-3 border-0 border-b border-solid border-surface-5 px-3 py-2 last:border-b-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center rounded-full bg-surface-4">
|
||||
<CheckIcon v-if="isImported(item)" class="size-5 text-green" aria-hidden="true" />
|
||||
<FolderSearchIcon v-else class="size-5 text-secondary" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="min-w-0 flex flex-col gap-0.5">
|
||||
<span class="truncate font-medium text-contrast">{{ item.fileName }}</span>
|
||||
<span class="truncate text-sm text-secondary">
|
||||
{{
|
||||
formatMessage(messages.projectFile, {
|
||||
projectId: item.projectId,
|
||||
fileId: item.fileId,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="text-sm" :class="isImported(item) ? 'text-green' : 'text-secondary'">
|
||||
{{ itemStatus(item) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isImported(item)" class="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button :disabled="busyKeys.has(itemKey(item))" @click="openOne(item)">
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.open) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button :disabled="busyKeys.has(itemKey(item))" @click="chooseLocalFile(item)">
|
||||
<SpinnerIcon v-if="busyKeys.has(itemKey(item))" class="animate-spin" />
|
||||
<UploadIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.chooseFile) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="hide">
|
||||
{{ formatMessage(commonMessages.closeButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="instanceId" type="outlined">
|
||||
<button @click="goToInstance">
|
||||
{{ formatMessage(messages.viewInstance) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="remainingCount > 0" color="orange">
|
||||
<button @click="openAll">
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.openAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
ExternalIcon,
|
||||
FolderSearchIcon,
|
||||
SpinnerIcon,
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
classifyCurseForgeManualDownloadImportError,
|
||||
type CurseForgeManualDownloadImport,
|
||||
type CurseForgeManualDownloadScanResult,
|
||||
importCurseForgeManualDownloads,
|
||||
importPendingCurseForgeManualDownloadFile,
|
||||
listPendingCurseForgeManualDownloads,
|
||||
} from '@/helpers/curseforge'
|
||||
import {
|
||||
type CurseForgeManualDownloadItem,
|
||||
getCurseForgeManualDownloadUrl,
|
||||
} from '@/helpers/curseforge-manual'
|
||||
import { getMissingContentScannerSettings } from '@/helpers/downloads-scanner'
|
||||
import { instance_listener } from '@/helpers/events.js'
|
||||
import { get_content_snapshot } from '@/helpers/instance'
|
||||
import { get_instance_worlds } from '@/helpers/worlds'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.curseforge.manual-downloads.header',
|
||||
defaultMessage: 'Complete CurseForge downloads',
|
||||
},
|
||||
body: {
|
||||
id: 'app.curseforge.manual-downloads.body',
|
||||
defaultMessage:
|
||||
'Installed {installed, number} files automatically. {manual, number} files require browser download.',
|
||||
},
|
||||
existingBody: {
|
||||
id: 'app.curseforge.manual-downloads.existing-body',
|
||||
defaultMessage:
|
||||
'{manual, number} files still require browser download. Downloaded files will be verified and imported automatically.',
|
||||
},
|
||||
projectFile: {
|
||||
id: 'app.curseforge.manual-downloads.project-file',
|
||||
defaultMessage: 'Project {projectId} / File {fileId}',
|
||||
},
|
||||
open: {
|
||||
id: 'app.curseforge.manual-downloads.open',
|
||||
defaultMessage: 'Download',
|
||||
},
|
||||
openAll: {
|
||||
id: 'app.curseforge.manual-downloads.open-all',
|
||||
defaultMessage: 'Open missing',
|
||||
},
|
||||
chooseFile: {
|
||||
id: 'app.curseforge.manual-downloads.choose-file',
|
||||
defaultMessage: 'Choose local file',
|
||||
},
|
||||
viewInstance: {
|
||||
id: 'app.curseforge.manual-downloads.view-instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
automaticImport: {
|
||||
id: 'app.curseforge.manual-downloads.automatic-import',
|
||||
defaultMessage: 'Automatic import',
|
||||
},
|
||||
allImported: {
|
||||
id: 'app.curseforge.manual-downloads.all-imported',
|
||||
defaultMessage: 'All files imported',
|
||||
},
|
||||
checkingDownloads: {
|
||||
id: 'app.curseforge.manual-downloads.checking-downloads',
|
||||
defaultMessage: 'Checking the monitored import folder...',
|
||||
},
|
||||
watchingDownloads: {
|
||||
id: 'app.curseforge.manual-downloads.watching-downloads',
|
||||
defaultMessage: 'Watching {path}. Files are verified before import.',
|
||||
},
|
||||
downloadsUnavailable: {
|
||||
id: 'app.curseforge.manual-downloads.downloads-unavailable',
|
||||
defaultMessage: 'The monitored import folder is unavailable.',
|
||||
},
|
||||
automaticImportDisabled: {
|
||||
id: 'app.curseforge.manual-downloads.automatic-import-disabled',
|
||||
defaultMessage: 'Automatic import is disabled in Resource Management settings.',
|
||||
},
|
||||
scannerFailed: {
|
||||
id: 'app.curseforge.manual-downloads.scanner-failed',
|
||||
defaultMessage: 'Automatic import check failed; retrying.',
|
||||
},
|
||||
importComplete: {
|
||||
id: 'app.curseforge.manual-downloads.import-complete',
|
||||
defaultMessage: 'Imported into the instance',
|
||||
},
|
||||
waiting: {
|
||||
id: 'app.curseforge.manual-downloads.waiting',
|
||||
defaultMessage: 'Waiting for download',
|
||||
},
|
||||
retrying: {
|
||||
id: 'app.curseforge.manual-downloads.retrying',
|
||||
defaultMessage: 'File verification failed. Choose the required file.',
|
||||
},
|
||||
stateChanged: {
|
||||
id: 'app.curseforge.manual-downloads.state-changed',
|
||||
defaultMessage: 'Download state changed. Waiting for synchronization.',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'view-instance', instanceId: string): void
|
||||
(e: 'imported', instanceId: string, imports: CurseForgeManualDownloadImport[]): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const items = ref<CurseForgeManualDownloadItem[]>([])
|
||||
const candidateItems = ref<CurseForgeManualDownloadItem[]>([])
|
||||
const installed = ref<number | null>(null)
|
||||
const instanceId = ref<string | null>(null)
|
||||
const scanning = ref(false)
|
||||
const scanError = ref(false)
|
||||
const downloadDirectory = ref<string | null>(null)
|
||||
const scannerEnabled = ref(true)
|
||||
const scanDirectory = ref<string | null>(null)
|
||||
const importedKeys = ref(new Set<string>())
|
||||
const inconsistentKeys = ref(new Set<string>())
|
||||
const errorKeys = ref(new Set<string>())
|
||||
const busyKeys = ref(new Set<string>())
|
||||
let scannerActive = false
|
||||
let scanGeneration = 0
|
||||
let reconciliationGeneration = 0
|
||||
let scanInFlight: Promise<CurseForgeManualDownloadScanResult> | undefined
|
||||
let scanInterval: ReturnType<typeof setInterval> | null = null
|
||||
let unlistenInstances: (() => void) | null = null
|
||||
|
||||
const remainingCount = computed(
|
||||
() => items.value.filter((item) => !importedKeys.value.has(itemKey(item))).length,
|
||||
)
|
||||
const scannerStatus = computed(() => {
|
||||
if (remainingCount.value === 0) return formatMessage(messages.importComplete)
|
||||
if (!scannerEnabled.value) return formatMessage(messages.automaticImportDisabled)
|
||||
if (scanError.value) return formatMessage(messages.scannerFailed)
|
||||
if (downloadDirectory.value) {
|
||||
return formatMessage(messages.watchingDownloads, { path: downloadDirectory.value })
|
||||
}
|
||||
if (scanning.value) return formatMessage(messages.checkingDownloads)
|
||||
return formatMessage(messages.downloadsUnavailable)
|
||||
})
|
||||
|
||||
function itemKey(item: Pick<CurseForgeManualDownloadItem, 'projectId' | 'fileId'>) {
|
||||
return `${item.projectId}:${item.fileId}`
|
||||
}
|
||||
|
||||
function isImported(item: CurseForgeManualDownloadItem) {
|
||||
return importedKeys.value.has(itemKey(item))
|
||||
}
|
||||
|
||||
function itemStatus(item: CurseForgeManualDownloadItem) {
|
||||
if (isImported(item)) return formatMessage(messages.importComplete)
|
||||
if (errorKeys.value.has(itemKey(item))) return formatMessage(messages.retrying)
|
||||
if (inconsistentKeys.value.has(itemKey(item))) return formatMessage(messages.stateChanged)
|
||||
return formatMessage(messages.waiting)
|
||||
}
|
||||
|
||||
function show(payload: {
|
||||
items: CurseForgeManualDownloadItem[]
|
||||
installed?: number
|
||||
instanceId?: string | null
|
||||
}) {
|
||||
stopScanning()
|
||||
scannerActive = true
|
||||
const seededItems = [...new Map(payload.items.map((item) => [itemKey(item), item])).values()]
|
||||
candidateItems.value = seededItems
|
||||
items.value = seededItems
|
||||
installed.value = payload.installed ?? null
|
||||
instanceId.value = payload.instanceId ?? null
|
||||
downloadDirectory.value = null
|
||||
scanError.value = false
|
||||
importedKeys.value = new Set()
|
||||
inconsistentKeys.value = new Set()
|
||||
errorKeys.value = new Set()
|
||||
busyKeys.value = new Set()
|
||||
const scannerSettings = getMissingContentScannerSettings()
|
||||
scannerEnabled.value = scannerSettings.enabled
|
||||
scanDirectory.value = scannerSettings.directory
|
||||
modal.value?.show()
|
||||
void reconcileManualDownloadState().catch(handleError)
|
||||
if (scannerEnabled.value) {
|
||||
void scanDownloads()
|
||||
scanInterval = setInterval(() => {
|
||||
if (scannerActive) void scanDownloads()
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileManualDownloadState() {
|
||||
const currentInstanceId = instanceId.value
|
||||
if (!scannerActive || !currentInstanceId) return
|
||||
const generation = ++reconciliationGeneration
|
||||
const hasWorldCandidates = candidateItems.value.some((item) => item.projectType === 'world')
|
||||
const [pending, snapshot, worlds] = await Promise.all([
|
||||
listPendingCurseForgeManualDownloads(currentInstanceId),
|
||||
get_content_snapshot(currentInstanceId),
|
||||
hasWorldCandidates ? get_instance_worlds(currentInstanceId) : Promise.resolve([]),
|
||||
])
|
||||
if (
|
||||
!scannerActive ||
|
||||
currentInstanceId !== instanceId.value ||
|
||||
generation !== reconciliationGeneration
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const pendingByKey = new Map(pending.map((item) => [itemKey(item), item]))
|
||||
const candidateByKey = new Map(candidateItems.value.map((item) => [itemKey(item), item]))
|
||||
for (const [key, item] of pendingByKey) candidateByKey.set(key, item)
|
||||
const nextCandidates = [...candidateByKey.values()]
|
||||
const nextItems = nextCandidates.map((item) => pendingByKey.get(itemKey(item)) ?? item)
|
||||
const materializedByKey = new Map<string, string>()
|
||||
for (const item of snapshot.items) {
|
||||
if (
|
||||
item.provider === 'curseforge' &&
|
||||
item.providerProjectId != null &&
|
||||
item.providerReleaseId != null &&
|
||||
item.materializationState === 'present' &&
|
||||
item.content != null
|
||||
) {
|
||||
materializedByKey.set(
|
||||
`${item.providerProjectId}:${item.providerReleaseId}`,
|
||||
item.expectedRelativePath,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const nextImported = new Set<string>()
|
||||
const nextInconsistent = new Set<string>()
|
||||
const newlyImported: CurseForgeManualDownloadImport[] = []
|
||||
for (const item of nextItems) {
|
||||
const key = itemKey(item)
|
||||
if (pendingByKey.has(key)) continue
|
||||
if (item.projectType === 'world') {
|
||||
const worldName = item.fileName.replace(/\.zip$/i, '')
|
||||
const importedWorld = worlds.some(
|
||||
(world) => world.type === 'singleplayer' && world.path === worldName,
|
||||
)
|
||||
if (importedWorld) {
|
||||
nextImported.add(key)
|
||||
if (!importedKeys.value.has(key)) {
|
||||
newlyImported.push({
|
||||
projectId: item.projectId,
|
||||
fileId: item.fileId,
|
||||
relativePath: `saves/${worldName}`,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
const relativePath = materializedByKey.get(key)
|
||||
if (relativePath == null) {
|
||||
nextInconsistent.add(key)
|
||||
continue
|
||||
}
|
||||
nextImported.add(key)
|
||||
if (!importedKeys.value.has(key)) {
|
||||
newlyImported.push({ projectId: item.projectId, fileId: item.fileId, relativePath })
|
||||
}
|
||||
}
|
||||
|
||||
candidateItems.value = nextCandidates
|
||||
items.value = nextItems
|
||||
importedKeys.value = nextImported
|
||||
inconsistentKeys.value = nextInconsistent
|
||||
errorKeys.value = new Set([...errorKeys.value].filter((key) => pendingByKey.has(key)))
|
||||
if (newlyImported.length > 0) emit('imported', currentInstanceId, newlyImported)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function stopScanning() {
|
||||
scannerActive = false
|
||||
scanGeneration += 1
|
||||
reconciliationGeneration += 1
|
||||
if (scanInterval != null) {
|
||||
clearInterval(scanInterval)
|
||||
scanInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
async function scanDownloads(): Promise<void> {
|
||||
if (!scannerEnabled.value) return
|
||||
if (scanInFlight) {
|
||||
await scanInFlight.catch(() => undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const currentInstanceId = instanceId.value
|
||||
if (!currentInstanceId) return
|
||||
const generation = scanGeneration
|
||||
if (remainingCount.value === 0) return
|
||||
|
||||
scanning.value = true
|
||||
const operation = importCurseForgeManualDownloads(currentInstanceId, scanDirectory.value)
|
||||
scanInFlight = operation
|
||||
try {
|
||||
const result = await operation
|
||||
if (generation !== scanGeneration) return
|
||||
scanError.value = false
|
||||
downloadDirectory.value = result.downloadDirectory ?? null
|
||||
errorKeys.value = new Set(result.errors.map((item) => `${item.projectId}:${item.fileId}`))
|
||||
await reconcileManualDownloadState().catch(handleError)
|
||||
} catch {
|
||||
if (generation !== scanGeneration) return
|
||||
scanError.value = true
|
||||
errorKeys.value = new Set(
|
||||
items.value
|
||||
.filter((item) => !isImported(item) && !inconsistentKeys.value.has(itemKey(item)))
|
||||
.map((item) => itemKey(item)),
|
||||
)
|
||||
} finally {
|
||||
if (scanInFlight === operation) scanInFlight = undefined
|
||||
if (generation === scanGeneration) {
|
||||
scanning.value = false
|
||||
} else if (!scanInFlight) {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openOne(item: CurseForgeManualDownloadItem) {
|
||||
await openUrl(getCurseForgeManualDownloadUrl(item))
|
||||
}
|
||||
|
||||
async function chooseLocalFile(item: CurseForgeManualDownloadItem) {
|
||||
const currentInstanceId = instanceId.value
|
||||
const key = itemKey(item)
|
||||
if (!currentInstanceId || busyKeys.value.has(key)) return
|
||||
const selected = await open({ multiple: false })
|
||||
const sourcePath = typeof selected === 'string' ? selected : null
|
||||
if (!sourcePath) return
|
||||
busyKeys.value = new Set(busyKeys.value).add(key)
|
||||
try {
|
||||
await importPendingCurseForgeManualDownloadFile(
|
||||
currentInstanceId,
|
||||
item.projectId,
|
||||
item.fileId,
|
||||
sourcePath,
|
||||
)
|
||||
await reconcileManualDownloadState().catch(handleError)
|
||||
} catch (error) {
|
||||
const errorKind = classifyCurseForgeManualDownloadImportError(error)
|
||||
if (errorKind === 'verification_failed') {
|
||||
const nextErrors = new Set(errorKeys.value)
|
||||
nextErrors.add(key)
|
||||
errorKeys.value = nextErrors
|
||||
const nextInconsistent = new Set(inconsistentKeys.value)
|
||||
nextInconsistent.delete(key)
|
||||
inconsistentKeys.value = nextInconsistent
|
||||
} else if (errorKind === 'not_pending') {
|
||||
const nextErrors = new Set(errorKeys.value)
|
||||
nextErrors.delete(key)
|
||||
errorKeys.value = nextErrors
|
||||
await reconcileManualDownloadState().catch(handleError)
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
} finally {
|
||||
const nextBusy = new Set(busyKeys.value)
|
||||
nextBusy.delete(key)
|
||||
busyKeys.value = nextBusy
|
||||
}
|
||||
}
|
||||
|
||||
async function openAll() {
|
||||
for (const item of items.value) {
|
||||
if (isImported(item)) continue
|
||||
await openOne(item)
|
||||
}
|
||||
}
|
||||
|
||||
function goToInstance() {
|
||||
if (!instanceId.value) return
|
||||
hide()
|
||||
emit('view-instance', instanceId.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void instance_listener(async (event: { event: string; instance_id: string }) => {
|
||||
if (
|
||||
event.event === 'content_changed' &&
|
||||
event.instance_id === instanceId.value &&
|
||||
scannerActive
|
||||
) {
|
||||
await reconcileManualDownloadState().catch(() => {
|
||||
scanError.value = true
|
||||
})
|
||||
}
|
||||
})
|
||||
.then((unlisten) => {
|
||||
unlistenInstances = unlisten
|
||||
})
|
||||
.catch(() => undefined)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopScanning()
|
||||
unlistenInstances?.()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
274
apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue
Normal file
274
apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue
Normal file
@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
|
||||
<div v-if="requiredContentProject" class="flex flex-col gap-6 max-w-[500px]">
|
||||
<Admonition type="info" :header="formatMessage(messages.contentRequired)">
|
||||
{{ formatMessage(messages.serverRequiresMods) }}
|
||||
</Admonition>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.requiredModpack)
|
||||
}}</span>
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="openViewContents">
|
||||
<EyeIcon />
|
||||
{{ formatMessage(messages.viewContents) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
|
||||
<Avatar
|
||||
:src="requiredContentProject.icon_url"
|
||||
:alt="requiredContentProject.title"
|
||||
size="48px"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
<template v-if="usingCustomModpack && modpackVersion">
|
||||
{{ modpackVersion.name }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ requiredContentProject.title }}
|
||||
</template>
|
||||
</span>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
|
||||
<template v-if="modCount">
|
||||
· {{ formatMessage(messages.modCount, { count: modCount }) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="handleDecline">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleAccept">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.installButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
|
||||
<ModpackContentModal
|
||||
ref="modpackContentModal"
|
||||
:modpack-name="project?.name ?? ''"
|
||||
:modpack-icon-url="project?.icon_url ?? undefined"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
formatLoader,
|
||||
ModpackContentModal,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const modpackVersionId = ref<string | null>(null)
|
||||
const modpackVersion = ref<Labrinth.Versions.v2.Version | null>(null)
|
||||
const project = ref<Labrinth.Projects.v3.Project | null>(null)
|
||||
const requiredContentProject = ref<Labrinth.Projects.v2.Project | null>(null)
|
||||
const onInstallComplete = ref<() => void>(() => {})
|
||||
const { formatMessage } = useVIntl()
|
||||
const { installServerProject, startInstallingServer, stopInstallingServer } = injectServerInstall()
|
||||
|
||||
const usingCustomModpack = computed(() => {
|
||||
return requiredContentProject.value?.id === project.value?.id
|
||||
})
|
||||
|
||||
const loaderDisplay = computed(() => {
|
||||
const loader = requiredContentProject.value?.loaders?.[0]
|
||||
if (!loader) return ''
|
||||
return formatLoader(formatMessage, loader)
|
||||
})
|
||||
|
||||
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
|
||||
|
||||
async function fetchData(versionId: string) {
|
||||
// cache is making version null for some reason so bypassing for now
|
||||
modpackVersion.value = await get_version(versionId, 'bypass')
|
||||
|
||||
if (modpackVersion.value?.project_id) {
|
||||
requiredContentProject.value = await get_project(modpackVersion.value.project_id, 'bypass')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAccept() {
|
||||
hide()
|
||||
const serverProjectId = project.value?.id
|
||||
startInstallingServer(serverProjectId)
|
||||
try {
|
||||
await installServerProject(serverProjectId)
|
||||
onInstallComplete.value()
|
||||
} catch (error) {
|
||||
console.error('Failed to install server project from InstallToPlayModal:', error)
|
||||
} finally {
|
||||
stopInstallingServer(serverProjectId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDecline() {
|
||||
hide()
|
||||
}
|
||||
|
||||
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
|
||||
|
||||
async function openViewContents() {
|
||||
modpackContentModal.value?.showLoading()
|
||||
try {
|
||||
// Ensure version data is available — the useQuery may not have resolved yet
|
||||
const versionId = modpackVersionId.value
|
||||
const version =
|
||||
modpackVersion.value ?? (versionId ? await get_version(versionId, 'must_revalidate') : null)
|
||||
|
||||
const deps = version?.dependencies ?? []
|
||||
|
||||
const projectIds = deps
|
||||
.map((d: { project_id?: string }) => d.project_id)
|
||||
.filter((id: string | undefined): id is string => !!id)
|
||||
|
||||
const versionIds = deps
|
||||
.map((d: { version_id?: string }) => d.version_id)
|
||||
.filter((id: string | undefined): id is string => !!id)
|
||||
|
||||
const projects: Labrinth.Projects.v2.Project[] =
|
||||
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
|
||||
|
||||
const versions: Labrinth.Versions.v2.Version[] =
|
||||
versionIds.length > 0 ? await get_version_many(versionIds, 'must_revalidate') : []
|
||||
|
||||
const projectMap = new Map(projects.map((p: Labrinth.Projects.v2.Project) => [p.id, p]))
|
||||
|
||||
const contentItems: ContentItem[] = deps.map(
|
||||
(dep: Labrinth.Versions.v2.Dependency): ContentItem => {
|
||||
const depProject = dep.project_id ? projectMap.get(dep.project_id) : null
|
||||
// @ts-expect-error - version_id is missing from the type for some reason
|
||||
const depVersion = dep.version_id
|
||||
? // @ts-expect-error - version_id is missing from the type for some reason
|
||||
versions.find((v: Labrinth.Versions.v2.Version) => v.id === dep.version_id)
|
||||
: null
|
||||
|
||||
return {
|
||||
id: dep.file_name ?? dep.project_id ?? 'unknown',
|
||||
file_name: dep.file_name ?? depProject?.title ?? 'Unknown',
|
||||
project_type: depProject?.project_type ?? 'mod',
|
||||
update: null,
|
||||
origin_provider: null,
|
||||
provider_refs: [],
|
||||
enabled: true,
|
||||
project: {
|
||||
id: depProject?.id ?? dep.project_id ?? dep.file_name ?? 'unknown',
|
||||
slug: depProject?.slug ?? dep.project_id ?? 'unknown',
|
||||
title: depProject?.title ?? dep.file_name ?? 'Unknown',
|
||||
icon_url: depProject?.icon_url ?? undefined,
|
||||
},
|
||||
...(depVersion
|
||||
? {
|
||||
version: {
|
||||
id: depVersion.id,
|
||||
file_name: depVersion.files?.[0]?.filename ?? dep.file_name,
|
||||
version_number: depVersion.version_number ?? undefined,
|
||||
date_published: depVersion.date_published ?? undefined,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
},
|
||||
)
|
||||
modpackContentModal.value?.show(contentItems)
|
||||
} catch (err) {
|
||||
console.error('Failed to load modpack contents:', err)
|
||||
modpackContentModal.value?.show([])
|
||||
}
|
||||
}
|
||||
|
||||
async function show(
|
||||
projectVal: Labrinth.Projects.v3.Project,
|
||||
modpackVersionIdVal: string | null = null,
|
||||
callback: () => void = () => {},
|
||||
e?: MouseEvent,
|
||||
) {
|
||||
project.value = projectVal
|
||||
modpackVersionId.value = modpackVersionIdVal
|
||||
modpackVersion.value = null
|
||||
requiredContentProject.value = null
|
||||
onInstallComplete.value = callback
|
||||
|
||||
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
|
||||
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
installToPlay: {
|
||||
id: 'app.modal.install-to-play.header',
|
||||
defaultMessage: 'Install to play',
|
||||
},
|
||||
sharedServerInstance: {
|
||||
id: 'app.modal.install-to-play.shared-server-instance',
|
||||
defaultMessage: 'Shared server instance',
|
||||
},
|
||||
contentRequired: {
|
||||
id: 'app.modal.install-to-play.content-required',
|
||||
defaultMessage: 'Content required',
|
||||
},
|
||||
serverRequiresMods: {
|
||||
id: 'app.modal.install-to-play.server-requires-mods',
|
||||
defaultMessage:
|
||||
'This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server.',
|
||||
},
|
||||
requiredModpack: {
|
||||
id: 'app.modal.install-to-play.required-modpack',
|
||||
defaultMessage: 'Required modpack',
|
||||
},
|
||||
sharedInstance: {
|
||||
id: 'app.modal.install-to-play.shared-instance',
|
||||
defaultMessage: 'Shared instance',
|
||||
},
|
||||
modCount: {
|
||||
id: 'app.modal.install-to-play.mod-count',
|
||||
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
|
||||
},
|
||||
installButton: {
|
||||
id: 'app.modal.install-to-play.install-button',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
viewContents: {
|
||||
id: 'app.modal.install-to-play.view-contents',
|
||||
defaultMessage: 'View contents',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
:on-hide="handleHide"
|
||||
width="min(760px, calc(100vw - 2rem))"
|
||||
max-width="760px"
|
||||
scrollable
|
||||
>
|
||||
<div class="grid gap-6 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div
|
||||
class="flex aspect-square w-44 items-center justify-center overflow-hidden rounded-3xl shadow-lg"
|
||||
:style="selectedBackground.style"
|
||||
>
|
||||
<img :src="selectedIcon.url" alt="" class="h-[72%] w-[72%] object-contain" />
|
||||
</div>
|
||||
<p class="m-0 text-center text-sm text-secondary">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="saving" @click="surpriseMe">
|
||||
<RefreshCwIcon />
|
||||
{{ formatMessage(messages.surpriseMe) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-5">
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<h2 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.background) }}
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="background in backgrounds"
|
||||
:key="background.id"
|
||||
type="button"
|
||||
class="h-10 w-10 cursor-pointer rounded-xl border-2 border-solid transition-transform hover:scale-105 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-wait disabled:opacity-60"
|
||||
:class="
|
||||
background.id === selectedBackgroundId
|
||||
? 'border-contrast shadow-md'
|
||||
: 'border-transparent'
|
||||
"
|
||||
:style="background.style"
|
||||
:aria-label="formatMessage(background.name)"
|
||||
:aria-pressed="background.id === selectedBackgroundId"
|
||||
:disabled="saving"
|
||||
@click="selectedBackgroundId = background.id"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-for="group in iconGroups" :key="group.id" class="flex min-w-0 flex-col gap-2.5">
|
||||
<h2 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(group.name) }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-4 gap-2 sm:grid-cols-6">
|
||||
<button
|
||||
v-for="icon in group.icons"
|
||||
:key="icon.id"
|
||||
type="button"
|
||||
class="group flex min-w-0 cursor-pointer flex-col items-center gap-1.5 rounded-xl border border-solid bg-surface-2 p-2 text-secondary transition-colors hover:border-brand hover:bg-brand-highlight hover:text-contrast focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-wait disabled:opacity-60"
|
||||
:class="
|
||||
icon.id === selectedIconId
|
||||
? 'border-brand bg-brand-highlight text-contrast'
|
||||
: 'border-surface-5'
|
||||
"
|
||||
:aria-label="formatMessage(icon.name)"
|
||||
:aria-pressed="icon.id === selectedIconId"
|
||||
:disabled="saving"
|
||||
@click="selectedIconId = icon.id"
|
||||
>
|
||||
<img :src="icon.url" alt="" class="aspect-square w-full object-contain" />
|
||||
<span class="w-full truncate text-center text-xs font-semibold">
|
||||
{{ formatMessage(icon.name) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="saving" @click="selectUploadedIcon">
|
||||
<UploadIcon />
|
||||
{{ formatMessage(messages.upload) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="saving" @click="saveGeneratedIcon">
|
||||
<SpinnerIcon v-if="saving" class="animate-spin" />
|
||||
<SaveIcon v-else />
|
||||
{{ formatMessage(saving ? messages.saving : messages.useIcon) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RefreshCwIcon, SaveIcon, SpinnerIcon, UploadIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessage,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
type MessageDescriptor,
|
||||
NewModal,
|
||||
type PickedFile,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { cache_icon } from '@/helpers/instance'
|
||||
import { builtInInstanceIcons, modrinth3DInstanceIcons } from '@/helpers/instance-icons'
|
||||
import { pickImage } from '@/providers/setup/file-picker'
|
||||
|
||||
interface IconBackground {
|
||||
id: string
|
||||
name: MessageDescriptor
|
||||
colors: [string, string] | null
|
||||
style: Record<string, string>
|
||||
}
|
||||
|
||||
function background(
|
||||
id: string,
|
||||
name: MessageDescriptor,
|
||||
top: string,
|
||||
bottom: string,
|
||||
): IconBackground {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
colors: [top, bottom],
|
||||
style: { backgroundImage: `linear-gradient(145deg, ${top}, ${bottom})` },
|
||||
}
|
||||
}
|
||||
|
||||
const backgroundNames = defineMessages({
|
||||
transparent: {
|
||||
id: 'app.instance.icon-picker.background.transparent',
|
||||
defaultMessage: 'Transparent',
|
||||
},
|
||||
grass: { id: 'app.instance.icon-picker.background.grass', defaultMessage: 'Grass' },
|
||||
ocean: { id: 'app.instance.icon-picker.background.ocean', defaultMessage: 'Ocean' },
|
||||
amethyst: { id: 'app.instance.icon-picker.background.amethyst', defaultMessage: 'Amethyst' },
|
||||
sunset: { id: 'app.instance.icon-picker.background.sunset', defaultMessage: 'Sunset' },
|
||||
cherry: { id: 'app.instance.icon-picker.background.cherry', defaultMessage: 'Cherry' },
|
||||
nether: { id: 'app.instance.icon-picker.background.nether', defaultMessage: 'Nether' },
|
||||
slime: { id: 'app.instance.icon-picker.background.slime', defaultMessage: 'Slime' },
|
||||
deepDark: { id: 'app.instance.icon-picker.background.deep-dark', defaultMessage: 'Deep Dark' },
|
||||
stone: { id: 'app.instance.icon-picker.background.stone', defaultMessage: 'Stone' },
|
||||
midnight: { id: 'app.instance.icon-picker.background.midnight', defaultMessage: 'Midnight' },
|
||||
})
|
||||
|
||||
const backgrounds = [
|
||||
{
|
||||
id: 'transparent',
|
||||
name: backgroundNames.transparent,
|
||||
colors: null,
|
||||
style: {
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundImage:
|
||||
'linear-gradient(45deg, #d7d9de 25%, transparent 25%), linear-gradient(-45deg, #d7d9de 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d7d9de 75%), linear-gradient(-45deg, transparent 75%, #d7d9de 75%)',
|
||||
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0',
|
||||
backgroundSize: '16px 16px',
|
||||
},
|
||||
} satisfies IconBackground,
|
||||
background('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'),
|
||||
background('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'),
|
||||
background('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'),
|
||||
background('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'),
|
||||
background('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'),
|
||||
background('nether', backgroundNames.nether, '#ef5a46', '#6d1717'),
|
||||
background('slime', backgroundNames.slime, '#b6ee55', '#44972f'),
|
||||
background('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'),
|
||||
background('stone', backgroundNames.stone, '#aeb5bd', '#525b66'),
|
||||
background('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'),
|
||||
]
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
const iconOptions = [...builtInInstanceIcons, ...modrinth3DInstanceIcons]
|
||||
const iconGroups = [
|
||||
{
|
||||
id: 'original',
|
||||
name: defineMessage({
|
||||
id: 'app.instance.icon-picker.group.original',
|
||||
defaultMessage: 'Original icons',
|
||||
}),
|
||||
icons: builtInInstanceIcons,
|
||||
},
|
||||
{
|
||||
id: 'modrinth-3d',
|
||||
name: defineMessage({
|
||||
id: 'app.instance.icon-picker.group.modrinth-3d',
|
||||
defaultMessage: 'Modrinth 3D icons',
|
||||
}),
|
||||
icons: modrinth3DInstanceIcons,
|
||||
},
|
||||
]
|
||||
|
||||
const selectedBackgroundId = ref(backgrounds[0].id)
|
||||
const selectedIconId = ref(builtInInstanceIcons[0].id)
|
||||
const saving = ref(false)
|
||||
let resolveSelection: ((selection: PickedFile | null) => void) | null = null
|
||||
|
||||
const selectedBackground = computed(
|
||||
() => backgrounds.find((item) => item.id === selectedBackgroundId.value) ?? backgrounds[0],
|
||||
)
|
||||
const selectedIcon = computed(
|
||||
() => iconOptions.find((icon) => icon.id === selectedIconId.value) ?? builtInInstanceIcons[0],
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.instance.icon-picker.title',
|
||||
defaultMessage: 'Create an instance icon',
|
||||
},
|
||||
description: {
|
||||
id: 'app.instance.icon-picker.description',
|
||||
defaultMessage: 'Combine a background and a Minecraft element, or upload your own image.',
|
||||
},
|
||||
background: {
|
||||
id: 'app.instance.icon-picker.background',
|
||||
defaultMessage: 'Background',
|
||||
},
|
||||
surpriseMe: {
|
||||
id: 'app.instance.icon-picker.surprise-me',
|
||||
defaultMessage: 'Surprise me',
|
||||
},
|
||||
upload: {
|
||||
id: 'app.instance.icon-picker.upload',
|
||||
defaultMessage: 'Upload image',
|
||||
},
|
||||
useIcon: {
|
||||
id: 'app.instance.icon-picker.use-icon',
|
||||
defaultMessage: 'Use this icon',
|
||||
},
|
||||
saving: {
|
||||
id: 'app.instance.icon-picker.saving',
|
||||
defaultMessage: 'Saving...',
|
||||
},
|
||||
loadError: {
|
||||
id: 'app.instance.icon-picker.load-error',
|
||||
defaultMessage: 'Failed to load the bundled icon.',
|
||||
},
|
||||
})
|
||||
|
||||
function finish(selection: PickedFile | null) {
|
||||
const resolve = resolveSelection
|
||||
resolveSelection = null
|
||||
modal.value?.hide()
|
||||
resolve?.(selection)
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
saving.value = false
|
||||
if (resolveSelection) {
|
||||
resolveSelection(null)
|
||||
resolveSelection = null
|
||||
}
|
||||
}
|
||||
|
||||
function surpriseMe() {
|
||||
if (backgrounds.length > 1) {
|
||||
const candidates = backgrounds.filter((item) => item.id !== selectedBackgroundId.value)
|
||||
selectedBackgroundId.value = candidates[Math.floor(Math.random() * candidates.length)].id
|
||||
}
|
||||
if (iconOptions.length > 1) {
|
||||
const candidates = iconOptions.filter((icon) => icon.id !== selectedIconId.value)
|
||||
selectedIconId.value = candidates[Math.floor(Math.random() * candidates.length)].id
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = () => reject(new Error(formatMessage(messages.loadError)))
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) resolve(blob)
|
||||
else reject(new Error(formatMessage(messages.loadError)))
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
async function renderGeneratedIcon(): Promise<Blob> {
|
||||
if (!selectedBackground.value.colors) {
|
||||
const response = await fetch(selectedIcon.value.url)
|
||||
if (!response.ok) throw new Error(formatMessage(messages.loadError))
|
||||
return await response.blob()
|
||||
}
|
||||
|
||||
const size = 512
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error(formatMessage(messages.loadError))
|
||||
|
||||
if (selectedBackground.value.colors) {
|
||||
const gradient = context.createLinearGradient(0, 0, size, size)
|
||||
gradient.addColorStop(0, selectedBackground.value.colors[0])
|
||||
gradient.addColorStop(1, selectedBackground.value.colors[1])
|
||||
context.fillStyle = gradient
|
||||
context.fillRect(0, 0, size, size)
|
||||
}
|
||||
|
||||
const symbol = await loadImage(selectedIcon.value.url)
|
||||
const maxSymbolSize = size * 0.72
|
||||
const scale = Math.min(maxSymbolSize / symbol.naturalWidth, maxSymbolSize / symbol.naturalHeight)
|
||||
const width = symbol.naturalWidth * scale
|
||||
const height = symbol.naturalHeight * scale
|
||||
context.drawImage(symbol, (size - width) / 2, (size - height) / 2, width, height)
|
||||
|
||||
return await canvasToBlob(canvas)
|
||||
}
|
||||
|
||||
async function saveGeneratedIcon() {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const blob = await renderGeneratedIcon()
|
||||
const fileName = `generated-${selectedBackgroundId.value}-${selectedIconId.value}.png`
|
||||
const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()))
|
||||
const path = await cache_icon(fileName, bytes)
|
||||
finish({
|
||||
file: new File([blob], fileName, { type: 'image/png' }),
|
||||
path,
|
||||
previewUrl: convertFileSrc(path),
|
||||
frameless: selectedBackgroundId.value === 'transparent',
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectUploadedIcon() {
|
||||
try {
|
||||
const selection = await pickImage()
|
||||
if (selection) finish(selection)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function show(): Promise<PickedFile | null> {
|
||||
resolveSelection?.(null)
|
||||
const modalInstance = modal.value
|
||||
if (!modalInstance) return Promise.resolve(null)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolveSelection = resolve
|
||||
modalInstance.show()
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
|
||||
<InstanceIcon
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
size="24px"
|
||||
/>
|
||||
{{ instance.name }} <ChevronRightIcon />
|
||||
</span>
|
||||
</template>
|
||||
@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
CodeIcon,
|
||||
CoffeeIcon,
|
||||
FileArchiveIcon,
|
||||
InfoIcon,
|
||||
MonitorIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
commonMessages,
|
||||
defineMessage,
|
||||
TabbedModal,
|
||||
type TabbedModalTab,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { PlatformTag } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue'
|
||||
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
|
||||
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
|
||||
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
|
||||
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
|
||||
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { get_project_v3 } from '@/helpers/cache'
|
||||
import { get_linked_modpack_info } from '@/helpers/instance'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { provideInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { GameInstance } from '../../../helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
offline?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
unlinked: []
|
||||
}>()
|
||||
|
||||
const isMinecraftServer = ref(false)
|
||||
const handleUnlinked = () => emit('unlinked')
|
||||
let serverMetadataGeneration = 0
|
||||
|
||||
const instanceRef = computed(() => props.instance)
|
||||
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
|
||||
function hide() {
|
||||
tabbedModal.value?.hide()
|
||||
}
|
||||
|
||||
provideInstanceSettings({
|
||||
instance: instanceRef,
|
||||
offline: props.offline,
|
||||
isMinecraftServer,
|
||||
onUnlinked: handleUnlinked,
|
||||
closeModal: hide,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.instance,
|
||||
(instance) => {
|
||||
const generation = ++serverMetadataGeneration
|
||||
isMinecraftServer.value = false
|
||||
if (instance.install_stage === 'installed' && instance.link?.project_id) {
|
||||
const instanceId = instance.id
|
||||
get_project_v3(instance.link.project_id, 'must_revalidate')
|
||||
.then((project: Labrinth.Projects.v3.Project | undefined) => {
|
||||
if (
|
||||
generation === serverMetadataGeneration &&
|
||||
props.instance.id === instanceId &&
|
||||
props.instance.install_stage === 'installed' &&
|
||||
project?.minecraft_server != null
|
||||
) {
|
||||
isMinecraftServer.value = true
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const tabs = computed<TabbedModalTab[]>(() => [
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.general',
|
||||
defaultMessage: 'General',
|
||||
}),
|
||||
icon: InfoIcon,
|
||||
content: GeneralSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.installation',
|
||||
defaultMessage: 'Installation',
|
||||
}),
|
||||
icon: WrenchIcon,
|
||||
content: InstallationSettings,
|
||||
},
|
||||
{
|
||||
// Core component editing is instance-specific and advanced, so it is intentionally excluded from first-run onboarding.
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.core-components',
|
||||
defaultMessage: 'Core components',
|
||||
}),
|
||||
icon: FileArchiveIcon,
|
||||
content: CoreComponentsSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.window',
|
||||
defaultMessage: 'Window',
|
||||
}),
|
||||
icon: MonitorIcon,
|
||||
content: WindowSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.java',
|
||||
defaultMessage: 'Java and memory',
|
||||
}),
|
||||
icon: CoffeeIcon,
|
||||
content: JavaSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.hooks',
|
||||
defaultMessage: 'Launch preparation',
|
||||
}),
|
||||
icon: CodeIcon,
|
||||
content: HooksSettings,
|
||||
},
|
||||
])
|
||||
|
||||
function getSupportedModpackLoaders() {
|
||||
return get_loaders().then((value: PlatformTag[]) =>
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack') || item.name === 'vanilla')
|
||||
.sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)),
|
||||
)
|
||||
}
|
||||
|
||||
// Preload metadata that is not scoped to an editable Minecraft version.
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'game-versions'],
|
||||
queryFn: get_game_versions,
|
||||
})
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'loaders', 'modpack'],
|
||||
queryFn: getSupportedModpackLoaders,
|
||||
})
|
||||
useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', props.instance.id]),
|
||||
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
|
||||
enabled: computed(
|
||||
() =>
|
||||
props.instance.install_stage === 'installed' &&
|
||||
!!props.instance.link?.project_id &&
|
||||
!props.offline,
|
||||
),
|
||||
})
|
||||
|
||||
function show(tabIndex?: number) {
|
||||
if (props.instance.install_stage === 'installed' && props.instance.link?.project_id) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['linkedModpackInfo', props.instance.id],
|
||||
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
|
||||
})
|
||||
}
|
||||
tabbedModal.value?.show()
|
||||
if (tabIndex !== undefined) {
|
||||
nextTick(() => tabbedModal.value?.setTab(tabIndex))
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
<template>
|
||||
<TabbedModal
|
||||
ref="tabbedModal"
|
||||
:tabs="tabs"
|
||||
:max-width="'min(928px, calc(95vw - 10rem))'"
|
||||
:width="'min(928px, calc(95vw - 10rem))'"
|
||||
>
|
||||
<template #title>
|
||||
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
|
||||
<InstanceIcon
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="props.instance.id"
|
||||
:loader="instance.loader"
|
||||
size="24px"
|
||||
/>
|
||||
{{ instance.name }} <ChevronRightIcon />
|
||||
<span class="font-extrabold text-contrast">{{
|
||||
formatMessage(commonMessages.settingsLabel)
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</TabbedModal>
|
||||
</template>
|
||||
@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title, { version })"
|
||||
:on-hide="handleHide"
|
||||
:disable-close="responding"
|
||||
fade="warning"
|
||||
max-width="520px"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="m-0 leading-relaxed text-contrast">
|
||||
{{ formatMessage(messages.body, { version }) }}
|
||||
</p>
|
||||
<p class="m-0 leading-relaxed text-secondary">
|
||||
{{ formatMessage(messages.laterDescription, { version }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" :disabled="responding" @click="modal?.hide()">
|
||||
<ClockIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.setUpLater) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" :disabled="responding" @click="confirmDownload">
|
||||
<SpinnerIcon v-if="responding" class="animate-spin" aria-hidden="true" />
|
||||
<DownloadIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.download) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClockIcon, DownloadIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { onUnmounted, ref, useTemplateRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { respond_to_java_download_confirmation } from '@/helpers/jre'
|
||||
|
||||
interface JavaDownloadConfirmationRequest {
|
||||
requestId: string
|
||||
version: number
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const router = useRouter()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.java-download-confirmation.title',
|
||||
defaultMessage: 'Download Java {version}?',
|
||||
},
|
||||
body: {
|
||||
id: 'app.java-download-confirmation.body',
|
||||
defaultMessage:
|
||||
'No compatible Java {version} installation was found. You can download it now or postpone Java setup while the remaining game resources continue downloading.',
|
||||
},
|
||||
laterDescription: {
|
||||
id: 'app.java-download-confirmation.later-description',
|
||||
defaultMessage:
|
||||
'The instance will finish installing, but Java {version} must be configured before you can play.',
|
||||
},
|
||||
setUpLater: {
|
||||
id: 'app.java-download-confirmation.set-up-later',
|
||||
defaultMessage: 'Set up later',
|
||||
},
|
||||
download: {
|
||||
id: 'app.java-download-confirmation.download',
|
||||
defaultMessage: 'Download Java',
|
||||
},
|
||||
postponedTitle: {
|
||||
id: 'app.java-download-confirmation.postponed-title',
|
||||
defaultMessage: 'Java setup postponed',
|
||||
},
|
||||
postponedBody: {
|
||||
id: 'app.java-download-confirmation.postponed-body',
|
||||
defaultMessage:
|
||||
'The instance will finish installing. Configure Java {version} before launching it.',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
const request = ref<JavaDownloadConfirmationRequest | null>(null)
|
||||
const version = ref(0)
|
||||
const responding = ref(false)
|
||||
let decisionSent = false
|
||||
|
||||
function show(payload: JavaDownloadConfirmationRequest) {
|
||||
request.value = payload
|
||||
version.value = payload.version
|
||||
responding.value = false
|
||||
decisionSent = false
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
const pendingRequest = request.value
|
||||
request.value = null
|
||||
responding.value = false
|
||||
|
||||
if (!pendingRequest || decisionSent) return
|
||||
|
||||
decisionSent = true
|
||||
void respond_to_java_download_confirmation(pendingRequest.requestId, false)
|
||||
addNotification({
|
||||
title: formatMessage(messages.postponedTitle),
|
||||
text: formatMessage(messages.postponedBody, { version: pendingRequest.version }),
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmDownload() {
|
||||
const pendingRequest = request.value
|
||||
if (!pendingRequest || responding.value) return
|
||||
|
||||
responding.value = true
|
||||
decisionSent = true
|
||||
const response = respond_to_java_download_confirmation(pendingRequest.requestId, true)
|
||||
modal.value?.hide()
|
||||
await router.push('/downloads')
|
||||
await response
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
const pendingRequest = request.value
|
||||
if (!pendingRequest || decisionSent) return
|
||||
|
||||
decisionSent = true
|
||||
void respond_to_java_download_confirmation(pendingRequest.requestId, false)
|
||||
})
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@ -0,0 +1,602 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
:fade="remaining > 0 ? 'warning' : 'standard'"
|
||||
:on-hide="stopScanning"
|
||||
max-width="760px"
|
||||
scrollable
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Admonition
|
||||
:type="continuing || remaining === 0 ? 'success' : 'warning'"
|
||||
:header="
|
||||
continuing
|
||||
? formatMessage(messages.continuing)
|
||||
: formatMessage(messages.remaining, { count: remaining })
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<SpinnerIcon v-if="loading || continuing" class="size-5 animate-spin" />
|
||||
<CheckIcon v-else-if="remaining === 0" class="size-5 text-green" />
|
||||
<DownloadIcon v-else class="size-5" />
|
||||
</template>
|
||||
{{ continuing ? formatMessage(messages.continuingBody) : formatMessage(messages.body) }}
|
||||
</Admonition>
|
||||
|
||||
<Admonition
|
||||
v-if="!continuing && remaining > 0"
|
||||
:type="scannerPresentation.phase === 'rejected' ? 'warning' : 'info'"
|
||||
:header="scannerHeader"
|
||||
>
|
||||
<template #icon>
|
||||
<SpinnerIcon
|
||||
v-if="
|
||||
scannerPresentation.phase === 'importing' ||
|
||||
scannerPresentation.phase === 'verifying' ||
|
||||
scannerPresentation.phase === 'waiting_for_stability'
|
||||
"
|
||||
class="size-5 animate-spin"
|
||||
/>
|
||||
<FolderSearchIcon v-else class="size-5" />
|
||||
</template>
|
||||
{{ scannerStatus }}
|
||||
</Admonition>
|
||||
|
||||
<div
|
||||
v-if="files.length"
|
||||
class="flex flex-col overflow-hidden rounded-lg border border-surface-5"
|
||||
>
|
||||
<div
|
||||
v-for="file in files"
|
||||
:key="file.itemId"
|
||||
class="flex flex-col gap-3 border-0 border-b border-solid border-surface-5 bg-surface-2 p-4 last:border-b-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="break-all font-medium text-contrast">{{ file.path }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-2 text-sm text-secondary">
|
||||
<span>{{
|
||||
formatMessage(messages.expectedSize, { size: formatBytes(file.expectedSize) })
|
||||
}}</span>
|
||||
<span>·</span>
|
||||
<span>{{ attemptText(file) }}</span>
|
||||
<span v-if="file.browserUrls.length > 1">·</span>
|
||||
<span v-if="file.browserUrls.length > 1">
|
||||
{{ formatMessage(messages.fallbacks, { count: file.browserUrls.length - 1 }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="file.browserUrls[0]" class="mt-1 truncate text-xs text-secondary">
|
||||
<code v-tooltip="file.browserUrls[0]">{{ file.browserUrls[0] }}</code>
|
||||
</div>
|
||||
<div v-if="file.lastError" class="mt-1 text-sm text-red">
|
||||
{{ file.lastError }}
|
||||
</div>
|
||||
<div v-if="scannerItemStatus(file.itemId)" class="mt-1 text-sm text-orange">
|
||||
{{ scannerItemStatus(file.itemId) }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge :color="statusColor(file.status)" :type="statusLabel(file.status)" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled color="brand" size="small">
|
||||
<button :disabled="isBusy(file.itemId)" @click="retryOne(file.itemId)">
|
||||
<SpinnerIcon v-if="isBusy(file.itemId)" class="animate-spin" />
|
||||
<RefreshCwIcon v-else />
|
||||
{{ formatMessage(messages.retry) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="file.browserUrls.length" type="outlined" size="small">
|
||||
<button :disabled="isBusy(file.itemId)" @click="openBrowser(file.browserUrls[0])">
|
||||
<ExternalIcon />{{ formatMessage(messages.browserDownload) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button :disabled="isBusy(file.itemId)" @click="selectLocal(file.itemId)">
|
||||
<UploadIcon />{{ formatMessage(messages.chooseFile) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">{{ formatMessage(commonMessages.closeButton) }}</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="remaining > 0" color="brand">
|
||||
<button :disabled="loading || busy.size > 0" @click="retryAll">
|
||||
<RefreshCwIcon />{{ formatMessage(messages.retryAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
FolderSearchIcon,
|
||||
RefreshCwIcon,
|
||||
SpinnerIcon,
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Badge,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useFormatBytes,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
createDownloadsScanLoop,
|
||||
createDownloadsScannerPresentationState,
|
||||
getMissingContentScannerSettings,
|
||||
reduceDownloadsScannerPresentation,
|
||||
} from '@/helpers/downloads-scanner'
|
||||
import { install_job_listener } from '@/helpers/events'
|
||||
import {
|
||||
install_job_get,
|
||||
install_job_import_missing_file,
|
||||
install_job_missing_files,
|
||||
install_job_resume,
|
||||
install_job_retry_missing_file,
|
||||
install_job_scan_missing_files,
|
||||
type InstallJobSnapshot,
|
||||
type MissingModpackContentView,
|
||||
} from '@/helpers/install'
|
||||
|
||||
type MissingFile = MissingModpackContentView['files'][number]
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatBytes = useFormatBytes()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const jobId = ref<string | null>(null)
|
||||
const content = ref<MissingModpackContentView>({ remaining: 0, files: [] })
|
||||
const loading = ref(false)
|
||||
const continuing = ref(false)
|
||||
const busy = ref(new Set<string>())
|
||||
const scannerPresentation = ref(createDownloadsScannerPresentationState())
|
||||
const scannerErrors = ref(new Map<string, string>())
|
||||
const scannerEnabled = ref(true)
|
||||
const scanDirectory = ref<string | null>(null)
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
const files = computed(() => content.value.files)
|
||||
const remaining = computed(() => content.value.remaining)
|
||||
const scannerHeader = computed(() => {
|
||||
if (!scannerEnabled.value) return formatMessage(messages.automaticImportDisabled)
|
||||
if (scannerPresentation.value.phase === 'rejected') {
|
||||
return formatMessage(messages.fileMismatchTitle)
|
||||
}
|
||||
if (scannerPresentation.value.phase === 'verifying') {
|
||||
return formatMessage(messages.verifyingCandidate)
|
||||
}
|
||||
if (scannerPresentation.value.phase === 'importing') {
|
||||
return formatMessage(messages.importingCandidate)
|
||||
}
|
||||
if (
|
||||
scannerPresentation.value.phase === 'monitoring' ||
|
||||
scannerPresentation.value.phase === 'idle'
|
||||
) {
|
||||
return formatMessage(messages.watchingDownloadsTitle)
|
||||
}
|
||||
return formatMessage(messages.automaticImport)
|
||||
})
|
||||
const scannerStatus = computed(() => {
|
||||
if (!scannerEnabled.value) return formatMessage(messages.automaticImportDisabledBody)
|
||||
const state = scannerPresentation.value
|
||||
if (state.phase === 'importing') return formatMessage(messages.importingCandidateBody)
|
||||
if (state.phase === 'verifying') return formatMessage(messages.verifyingCandidateBody)
|
||||
if (state.phase === 'waiting_for_stability') return formatMessage(messages.waitingForCompletion)
|
||||
if (state.phase === 'rejected') return formatMessage(messages.fileMismatchBody)
|
||||
if (state.phase === 'imported') {
|
||||
return formatMessage(messages.importedAutomatically, {
|
||||
count: state.importedCount,
|
||||
remaining: remaining.value,
|
||||
})
|
||||
}
|
||||
if (state.phase === 'error') return formatMessage(messages.scannerFailed)
|
||||
if (state.phase === 'monitoring' && state.downloadDirectory) {
|
||||
return formatMessage(messages.watchingDirectory, { path: state.downloadDirectory })
|
||||
}
|
||||
if (state.phase === 'idle') {
|
||||
return formatMessage(messages.watchingDownloadsBody)
|
||||
}
|
||||
return formatMessage(messages.downloadsUnavailable)
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
header: { id: 'app.downloads.missing-content.header', defaultMessage: 'Complete missing files' },
|
||||
body: {
|
||||
id: 'app.downloads.missing-content.body',
|
||||
defaultMessage: 'Each local file is verified before it can replace the required instance file.',
|
||||
},
|
||||
remaining: {
|
||||
id: 'app.downloads.missing-content.remaining',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# file still needs to be completed} other {# files still need to be completed}}',
|
||||
},
|
||||
continuing: {
|
||||
id: 'app.downloads.missing-content.continuing',
|
||||
defaultMessage: 'Continuing installation',
|
||||
},
|
||||
continuingBody: {
|
||||
id: 'app.downloads.missing-content.continuing-body',
|
||||
defaultMessage:
|
||||
'All required files are ready. The launcher is verifying them again and continuing installation.',
|
||||
},
|
||||
expectedSize: {
|
||||
id: 'app.downloads.missing-content.expected-size',
|
||||
defaultMessage: 'Expected size: {size}',
|
||||
},
|
||||
fallbacks: {
|
||||
id: 'app.downloads.missing-content.fallbacks',
|
||||
defaultMessage: '{count} fallback links',
|
||||
},
|
||||
retry: { id: 'app.downloads.missing-content.retry', defaultMessage: 'Retry download' },
|
||||
retryAll: {
|
||||
id: 'app.downloads.missing-content.retry-all',
|
||||
defaultMessage: 'Retry all missing files',
|
||||
},
|
||||
browserDownload: {
|
||||
id: 'app.downloads.missing-content.browser-download',
|
||||
defaultMessage: 'Browser download',
|
||||
},
|
||||
chooseFile: {
|
||||
id: 'app.downloads.missing-content.choose-file',
|
||||
defaultMessage: 'Choose local file',
|
||||
},
|
||||
attempts: {
|
||||
id: 'app.downloads.missing-content.attempts',
|
||||
defaultMessage: 'Attempt {attempt}/{max}',
|
||||
},
|
||||
noAttempts: {
|
||||
id: 'app.downloads.missing-content.no-attempts',
|
||||
defaultMessage: 'No attempt information',
|
||||
},
|
||||
automaticImport: {
|
||||
id: 'app.downloads.missing-content.automatic-import',
|
||||
defaultMessage: 'Automatic import from monitored folder',
|
||||
},
|
||||
automaticImportDisabled: {
|
||||
id: 'app.downloads.missing-content.automatic-import-disabled',
|
||||
defaultMessage: 'Automatic import is disabled',
|
||||
},
|
||||
automaticImportDisabledBody: {
|
||||
id: 'app.downloads.missing-content.automatic-import-disabled-body',
|
||||
defaultMessage: 'Retry downloading or choose each missing file manually.',
|
||||
},
|
||||
verifyingCandidate: {
|
||||
id: 'app.downloads.missing-content.verifying-candidate',
|
||||
defaultMessage: 'Verifying downloaded file',
|
||||
},
|
||||
verifyingCandidateBody: {
|
||||
id: 'app.downloads.missing-content.verifying-candidate-body',
|
||||
defaultMessage: 'Checking that the candidate matches the file required by the modpack.',
|
||||
},
|
||||
importingCandidate: {
|
||||
id: 'app.downloads.missing-content.importing-candidate',
|
||||
defaultMessage: 'Importing verified file',
|
||||
},
|
||||
importingCandidateBody: {
|
||||
id: 'app.downloads.missing-content.importing-candidate-body',
|
||||
defaultMessage: 'Adding the verified file to the instance...',
|
||||
},
|
||||
watchingDownloadsTitle: {
|
||||
id: 'app.downloads.missing-content.watching-downloads-title',
|
||||
defaultMessage: 'Watching the import folder',
|
||||
},
|
||||
watchingDownloadsBody: {
|
||||
id: 'app.downloads.missing-content.watching-downloads-body',
|
||||
defaultMessage: 'Completed downloads will be verified and imported automatically.',
|
||||
},
|
||||
watchingDirectory: {
|
||||
id: 'app.downloads.missing-content.watching-downloads',
|
||||
defaultMessage: 'Watching {path}. Matching files are verified before import.',
|
||||
},
|
||||
waitingForCompletion: {
|
||||
id: 'app.downloads.missing-content.waiting-for-completion',
|
||||
defaultMessage: 'Waiting for a browser download to finish writing...',
|
||||
},
|
||||
downloadsUnavailable: {
|
||||
id: 'app.downloads.missing-content.downloads-unavailable',
|
||||
defaultMessage: 'The monitored folder is unavailable. Choose a local file instead.',
|
||||
},
|
||||
scannerFailed: {
|
||||
id: 'app.downloads.missing-content.scanner-failed',
|
||||
defaultMessage: 'Automatic checking failed. Manual file selection is still available.',
|
||||
},
|
||||
fileMismatch: {
|
||||
id: 'app.downloads.missing-content.file-mismatch',
|
||||
defaultMessage: 'A similarly named file was found, but it did not match this required file.',
|
||||
},
|
||||
fileMismatchTitle: {
|
||||
id: 'app.downloads.missing-content.file-mismatch-title',
|
||||
defaultMessage: 'A same-named file was found, but file verification failed',
|
||||
},
|
||||
fileMismatchBody: {
|
||||
id: 'app.downloads.missing-content.file-mismatch-body',
|
||||
defaultMessage:
|
||||
'The launcher will keep waiting for the correct file. You can also choose a local file manually.',
|
||||
},
|
||||
importedAutomatically: {
|
||||
id: 'app.downloads.missing-content.imported-automatically',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# file imported automatically} other {# files imported automatically}}. {remaining, plural, one {# file still needs to be completed} other {# files still need to be completed}}.',
|
||||
},
|
||||
})
|
||||
|
||||
const statusMessages = defineMessages({
|
||||
failed: { id: 'app.downloads.item-status.failed', defaultMessage: 'Failed' },
|
||||
worker_started: { id: 'app.downloads.item-status.worker-started', defaultMessage: 'Starting' },
|
||||
connecting: { id: 'app.downloads.item-status.connecting', defaultMessage: 'Connecting' },
|
||||
verifying: { id: 'app.downloads.item-status.verifying', defaultMessage: 'Verifying' },
|
||||
writing: { id: 'app.downloads.item-status.writing', defaultMessage: 'Writing' },
|
||||
finalizing: { id: 'app.downloads.item-status.finalizing', defaultMessage: 'Finalizing' },
|
||||
metadata: { id: 'app.downloads.item-status.metadata', defaultMessage: 'Loading metadata' },
|
||||
waiting_for_database: {
|
||||
id: 'app.downloads.item-status.waiting-for-database',
|
||||
defaultMessage: 'Waiting for database',
|
||||
},
|
||||
completed: { id: 'app.downloads.item-status.completed', defaultMessage: 'Completed' },
|
||||
downloading: { id: 'app.downloads.item-status.downloading', defaultMessage: 'Downloading' },
|
||||
waiting_for_resource: {
|
||||
id: 'app.downloads.item-status.waiting-for-resource',
|
||||
defaultMessage: 'Waiting for download resources',
|
||||
},
|
||||
queued: { id: 'app.downloads.status.queued', defaultMessage: 'Queued' },
|
||||
})
|
||||
|
||||
const scanner = createDownloadsScanLoop({
|
||||
scan: async () => {
|
||||
if (!jobId.value) throw new Error('Missing install job ID')
|
||||
return await install_job_scan_missing_files(jobId.value, scanDirectory.value)
|
||||
},
|
||||
onResult: (result) => {
|
||||
content.value = result.content
|
||||
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
|
||||
type: 'scan_result',
|
||||
downloadDirectory: result.downloadDirectory ?? null,
|
||||
importedItemIds: result.importedItemIds,
|
||||
rejectedItemIds: result.rejectedItemIds,
|
||||
pendingCandidates: result.pendingCandidates,
|
||||
hasErrors: result.errors.length > 0,
|
||||
items: result.content.files.map((file) => ({ id: file.itemId, status: file.status })),
|
||||
})
|
||||
scannerErrors.value = new Map(result.errors.map((error) => [error.itemId, error.message]))
|
||||
for (const itemId of result.importedItemIds) {
|
||||
scannerErrors.value.delete(itemId)
|
||||
}
|
||||
if (result.job.status !== 'waiting_for_user') {
|
||||
continuing.value = true
|
||||
stopScanning()
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
|
||||
type: 'scan_failed',
|
||||
})
|
||||
},
|
||||
intervalMs: 3000,
|
||||
})
|
||||
|
||||
async function show(job: InstallJobSnapshot) {
|
||||
stopScanning()
|
||||
const scannerSettings = getMissingContentScannerSettings()
|
||||
scannerEnabled.value = scannerSettings.enabled
|
||||
scanDirectory.value = scannerSettings.directory
|
||||
jobId.value = job.job_id
|
||||
continuing.value = false
|
||||
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
|
||||
type: 'reset',
|
||||
})
|
||||
scannerErrors.value = new Map()
|
||||
content.value = {
|
||||
remaining: job.items.filter((item) => item.status === 'failed').length,
|
||||
files: [],
|
||||
}
|
||||
modal.value?.show()
|
||||
await refresh()
|
||||
if (!unlisten) {
|
||||
unlisten = await install_job_listener((update: InstallJobSnapshot) => {
|
||||
if (update.job_id !== jobId.value) return
|
||||
if (update.status === 'waiting_for_user') {
|
||||
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
|
||||
type: 'items_updated',
|
||||
items: update.items,
|
||||
})
|
||||
void refresh()
|
||||
} else if (update.status === 'queued' || update.status === 'running') {
|
||||
continuing.value = true
|
||||
stopScanning()
|
||||
}
|
||||
})
|
||||
}
|
||||
if (scannerEnabled.value) scanner.start()
|
||||
}
|
||||
|
||||
function stopScanning() {
|
||||
scanner.stop()
|
||||
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
|
||||
type: 'reset',
|
||||
})
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!jobId.value || continuing.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const nextContent = await install_job_missing_files(jobId.value)
|
||||
content.value = nextContent
|
||||
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
|
||||
type: 'items_updated',
|
||||
items: nextContent.files.map((file) => ({ id: file.itemId, status: file.status })),
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runItem(itemId: string, action: () => Promise<InstallJobSnapshot>) {
|
||||
busy.value = new Set([...busy.value, itemId])
|
||||
try {
|
||||
const job = await action()
|
||||
await applyItemResult(itemId, job)
|
||||
} catch (error) {
|
||||
const latest = jobId.value ? await install_job_get(jobId.value).catch(() => null) : null
|
||||
if (
|
||||
latest &&
|
||||
(isContinuingStatus(latest.status) ||
|
||||
latest.items.some(
|
||||
(item) => item.id === itemId && ['completed', 'skipped'].includes(item.status),
|
||||
))
|
||||
) {
|
||||
await applyItemResult(itemId, latest)
|
||||
} else {
|
||||
handleError(error)
|
||||
await refresh()
|
||||
}
|
||||
} finally {
|
||||
const next = new Set(busy.value)
|
||||
next.delete(itemId)
|
||||
busy.value = next
|
||||
}
|
||||
}
|
||||
|
||||
async function applyItemResult(itemId: string, job: InstallJobSnapshot) {
|
||||
if (
|
||||
job.status !== 'waiting_for_user' ||
|
||||
job.items.some((item) => item.id === itemId && ['completed', 'skipped'].includes(item.status))
|
||||
) {
|
||||
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
|
||||
type: 'items_resolved',
|
||||
itemIds: [itemId],
|
||||
})
|
||||
scannerErrors.value.delete(itemId)
|
||||
}
|
||||
if (job.status === 'waiting_for_user') await refresh()
|
||||
else if (isContinuingStatus(job.status)) {
|
||||
continuing.value = true
|
||||
stopScanning()
|
||||
}
|
||||
}
|
||||
|
||||
function isContinuingStatus(status: InstallJobSnapshot['status']) {
|
||||
return status === 'queued' || status === 'running' || status === 'succeeded'
|
||||
}
|
||||
|
||||
async function retryOne(itemId: string) {
|
||||
if (!jobId.value) return
|
||||
await runItem(itemId, () => install_job_retry_missing_file(jobId.value!, itemId))
|
||||
}
|
||||
|
||||
async function selectLocal(itemId: string) {
|
||||
if (!jobId.value) return
|
||||
const selected = await open({ multiple: false })
|
||||
const path = selectedPath(selected)
|
||||
if (!path) return
|
||||
await runItem(itemId, () => install_job_import_missing_file(jobId.value!, itemId, path))
|
||||
}
|
||||
|
||||
function selectedPath(selected: unknown) {
|
||||
if (typeof selected === 'string') return selected
|
||||
if (
|
||||
selected &&
|
||||
typeof selected === 'object' &&
|
||||
'path' in selected &&
|
||||
typeof selected.path === 'string'
|
||||
) {
|
||||
return selected.path
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function retryAll() {
|
||||
if (!jobId.value) return
|
||||
loading.value = true
|
||||
stopScanning()
|
||||
try {
|
||||
await install_job_resume(jobId.value)
|
||||
continuing.value = true
|
||||
} catch (error) {
|
||||
const latest = await install_job_get(jobId.value).catch(() => null)
|
||||
if (latest && isContinuingStatus(latest.status)) {
|
||||
continuing.value = true
|
||||
} else {
|
||||
handleError(error)
|
||||
if (latest?.status === 'waiting_for_user' && scannerEnabled.value) scanner.start()
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openBrowser(url: string) {
|
||||
try {
|
||||
await openUrl(url)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function isBusy(itemId: string) {
|
||||
return busy.value.has(itemId)
|
||||
}
|
||||
|
||||
function scannerItemStatus(itemId: string) {
|
||||
if (scannerErrors.value.has(itemId)) return scannerErrors.value.get(itemId)
|
||||
if (scannerPresentation.value.rejectedItemIds.includes(itemId)) {
|
||||
return formatMessage(messages.fileMismatch)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function attemptText(file: MissingFile) {
|
||||
if (file.attempt == null || file.maxAttempts == null) return formatMessage(messages.noAttempts)
|
||||
return formatMessage(messages.attempts, { attempt: file.attempt, max: file.maxAttempts })
|
||||
}
|
||||
|
||||
function statusLabel(status: MissingFile['status']) {
|
||||
return status in statusMessages
|
||||
? formatMessage(statusMessages[status as keyof typeof statusMessages])
|
||||
: status
|
||||
}
|
||||
|
||||
function statusColor(status: MissingFile['status']): 'green' | 'red' | 'orange' | 'blue' | 'gray' {
|
||||
if (status === 'completed') return 'green'
|
||||
if (status === 'failed') return 'red'
|
||||
if (
|
||||
status === 'verifying' ||
|
||||
status === 'writing' ||
|
||||
status === 'finalizing' ||
|
||||
status === 'metadata' ||
|
||||
status === 'waiting_for_database'
|
||||
)
|
||||
return 'orange'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopScanning()
|
||||
unlisten?.()
|
||||
})
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
56
apps/app-frontend/src/components/ui/modal/ModalWrapper.vue
Normal file
56
apps/app-frontend/src/components/ui/modal/ModalWrapper.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<!-- @deprecated Use NewModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
|
||||
<script setup lang="ts">
|
||||
import { NewModal as Modal } from '@modrinth/ui'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
hideHeader: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
onHide: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
/** @deprecated No longer used — ads are handled by provideModalBehavior */
|
||||
showAdOnClose: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
defineExpose({
|
||||
show: (e?: MouseEvent) => {
|
||||
modal.value?.show(e)
|
||||
},
|
||||
hide: () => {
|
||||
modal.value?.hide()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
ref="modal"
|
||||
:header="header"
|
||||
:closable="closable"
|
||||
:hide-header="hideHeader"
|
||||
:on-hide="() => props.onHide?.()"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="title" />
|
||||
</template>
|
||||
<slot />
|
||||
</Modal>
|
||||
</template>
|
||||
@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
:on-hide="handleHide"
|
||||
>
|
||||
<p class="m-0 text-secondary">
|
||||
<IntlFormatted :message-id="messages.body" :values="{ instanceName }">
|
||||
<template #bold="{ children }">
|
||||
<span class="font-medium text-contrast"><component :is="() => children" /></span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleGoToInstance">
|
||||
{{ formatMessage(messages.instance) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="handleCreateAnyway">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.create) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.instance.modpack-already-installed.header',
|
||||
defaultMessage: 'Modpack already installed',
|
||||
},
|
||||
body: {
|
||||
id: 'app.instance.modpack-already-installed.body',
|
||||
defaultMessage:
|
||||
'This modpack is already installed in the <bold>{instanceName}</bold> instance. Are you sure you want to duplicate it?',
|
||||
},
|
||||
instance: {
|
||||
id: 'app.instance.modpack-already-installed.instance',
|
||||
defaultMessage: 'Instance',
|
||||
},
|
||||
create: {
|
||||
id: 'app.instance.modpack-already-installed.create',
|
||||
defaultMessage: 'Create',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'go-to-instance', instanceId: string): void
|
||||
(e: 'create-anyway' | 'cancel'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const instanceName = ref('')
|
||||
const instanceId = ref('')
|
||||
const accepted = ref(false)
|
||||
|
||||
function show(name: string, id: string) {
|
||||
instanceName.value = name
|
||||
instanceId.value = id
|
||||
accepted.value = false
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleGoToInstance() {
|
||||
accepted.value = true
|
||||
modal.value?.hide()
|
||||
emit('go-to-instance', instanceId.value)
|
||||
}
|
||||
|
||||
function handleCreateAnyway() {
|
||||
accepted.value = true
|
||||
modal.value?.hide()
|
||||
emit('create-anyway')
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
if (!accepted.value) emit('cancel')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { CheckIcon, DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { releaseColor } from '@/helpers/utils'
|
||||
|
||||
export interface ModpackInstallExistingInstance {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ModpackInstallModalData {
|
||||
project: Pick<Labrinth.Projects.v2.Project, 'id' | 'title' | 'icon_url'>
|
||||
versions: Labrinth.Versions.v2.Version[]
|
||||
initialVersionId?: string | null
|
||||
existingInstancesByVersion: Record<string, ModpackInstallExistingInstance[]>
|
||||
instancePaths: string[]
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
install: [versionId: string, name: string]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const data = ref<ModpackInstallModalData | null>(null)
|
||||
const selectedVersionId = ref('')
|
||||
const instanceName = ref('')
|
||||
const submitting = ref(false)
|
||||
const submitted = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.modpack-install.title',
|
||||
defaultMessage: 'Install modpack',
|
||||
},
|
||||
version: {
|
||||
id: 'app.modpack-install.version',
|
||||
defaultMessage: 'Version',
|
||||
},
|
||||
instanceName: {
|
||||
id: 'app.modpack-install.instance-name',
|
||||
defaultMessage: 'Instance name',
|
||||
},
|
||||
instanceNamePlaceholder: {
|
||||
id: 'app.modpack-install.instance-name-placeholder',
|
||||
defaultMessage: 'My modpack instance',
|
||||
},
|
||||
installed: {
|
||||
id: 'app.modpack-install.already-installed',
|
||||
defaultMessage: 'This version is already installed',
|
||||
},
|
||||
installedDescription: {
|
||||
id: 'app.modpack-install.already-installed-description',
|
||||
defaultMessage: 'Creating another instance will not change these existing instances: {names}',
|
||||
},
|
||||
install: {
|
||||
id: 'app.modpack-install.install',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
selectVersion: {
|
||||
id: 'app.modpack-install.select-version',
|
||||
defaultMessage: 'Select a version',
|
||||
},
|
||||
folderName: {
|
||||
id: 'app.modpack-install.folder-name',
|
||||
defaultMessage: 'Instance folder: {name}',
|
||||
},
|
||||
folderNameConflict: {
|
||||
id: 'app.modpack-install.folder-name-conflict',
|
||||
defaultMessage: 'A folder with this name already exists. The instance folder will be: {name}',
|
||||
},
|
||||
})
|
||||
|
||||
const selectedVersion = computed(() =>
|
||||
data.value?.versions.find((version) => version.id === selectedVersionId.value),
|
||||
)
|
||||
|
||||
const versionOptions = computed(() =>
|
||||
(data.value?.versions ?? []).map((version) => ({
|
||||
value: version.id,
|
||||
label: versionLabel(version),
|
||||
})),
|
||||
)
|
||||
|
||||
const existingInstances = computed(
|
||||
() => data.value?.existingInstancesByVersion[selectedVersionId.value] ?? [],
|
||||
)
|
||||
|
||||
const folderName = computed(() => {
|
||||
const baseName = instanceName.value.trim().replace(/[\\/?*:'"|<>!]/g, '_')
|
||||
let candidate = baseName
|
||||
let index = 1
|
||||
while (candidate && data.value?.instancePaths.includes(candidate)) {
|
||||
candidate = `${baseName} (${index++})`
|
||||
}
|
||||
return candidate
|
||||
})
|
||||
|
||||
const hasFolderNameConflict = computed(() => folderName.value !== instanceName.value.trim())
|
||||
|
||||
const canInstall = computed(
|
||||
() => !!selectedVersion.value && instanceName.value.trim().length > 0 && !submitting.value,
|
||||
)
|
||||
|
||||
function versionLabel(version: Labrinth.Versions.v2.Version) {
|
||||
return [version.version_number || version.name, version.game_versions.join(', ')].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
function show(nextData: ModpackInstallModalData) {
|
||||
data.value = nextData
|
||||
selectedVersionId.value =
|
||||
nextData.versions.find((version) => version.id === nextData.initialVersionId)?.id ??
|
||||
nextData.versions[0]?.id ??
|
||||
''
|
||||
instanceName.value = nextData.project.title
|
||||
submitting.value = false
|
||||
submitted.value = false
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!canInstall.value) return
|
||||
submitting.value = true
|
||||
submitted.value = true
|
||||
emit('install', selectedVersionId.value, instanceName.value.trim())
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
if (!submitted.value) emit('cancel')
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
width="min(36rem, calc(95vw - 10rem))"
|
||||
max-width="36rem"
|
||||
:on-hide="handleHide"
|
||||
>
|
||||
<div v-if="data" class="flex min-w-0 flex-col gap-4">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<Avatar
|
||||
:src="data.project.icon_url"
|
||||
:alt="data.project.title"
|
||||
size="3rem"
|
||||
:tint-by="data.project.title"
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate text-lg font-semibold text-contrast">{{ data.project.title }}</span>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.version) }}</span>
|
||||
<Combobox
|
||||
v-model="selectedVersionId"
|
||||
:options="versionOptions"
|
||||
:name="formatMessage(messages.version)"
|
||||
:display-value="selectedVersion ? versionLabel(selectedVersion) : formatMessage(messages.selectVersion)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-if="selectedVersion" class="flex flex-wrap items-center gap-2 text-sm text-secondary">
|
||||
<Badge :color="releaseColor(selectedVersion.version_type)" :type="selectedVersion.version_type" />
|
||||
<span v-if="selectedVersion.loaders.length">{{ selectedVersion.loaders.join(', ') }}</span>
|
||||
<span v-if="selectedVersion.game_versions.length">{{ selectedVersion.game_versions.join(', ') }}</span>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.instanceName) }}</span>
|
||||
<StyledInput
|
||||
v-model="instanceName"
|
||||
:placeholder="formatMessage(messages.instanceNamePlaceholder)"
|
||||
autocomplete="off"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p v-if="folderName" class="m-0 text-sm text-secondary">
|
||||
{{
|
||||
formatMessage(hasFolderNameConflict ? messages.folderNameConflict : messages.folderName, {
|
||||
name: folderName,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="existingInstances.length"
|
||||
class="flex items-start gap-2 rounded-lg border border-warning bg-warning-bg p-3"
|
||||
>
|
||||
<CheckIcon class="mt-0.5 shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 font-semibold text-contrast">{{ formatMessage(messages.installed) }}</p>
|
||||
<p class="mt-1 mb-0 text-sm text-secondary">
|
||||
{{
|
||||
formatMessage(messages.installedDescription, {
|
||||
names: existingInstances.map((instance) => instance.name).join(', '),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="submitting" @click="hide">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canInstall" @click="submit">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.install) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon, ShieldIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { type PrivacySettings, savePrivacySettings } from '@/helpers/settings'
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [privacy: PrivacySettings]
|
||||
}>()
|
||||
|
||||
const CONSENT_VERSION = 1
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const telemetry = ref(true)
|
||||
const discordRpc = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.privacy-consent.title',
|
||||
defaultMessage: 'Privacy & security',
|
||||
},
|
||||
intro: {
|
||||
id: 'app.privacy-consent.intro',
|
||||
defaultMessage:
|
||||
'Choose what Axolotl may send or display. Nothing is sent until you confirm these choices.',
|
||||
},
|
||||
telemetry: {
|
||||
id: 'app.privacy-consent.telemetry',
|
||||
defaultMessage: 'Allow anonymous telemetry',
|
||||
},
|
||||
telemetryDescription: {
|
||||
id: 'app.privacy-consent.telemetry-description',
|
||||
defaultMessage:
|
||||
'Helps count opted-in installations and daily active users. Full Minecraft logs and account credentials are never uploaded.',
|
||||
},
|
||||
discordRpc: {
|
||||
id: 'app.privacy-consent.discord-rpc',
|
||||
defaultMessage: 'Discord Rich Presence',
|
||||
},
|
||||
discordRpcDescription: {
|
||||
id: 'app.privacy-consent.discord-rpc-description',
|
||||
defaultMessage:
|
||||
'Shows your current launcher or game activity in Discord when Discord is running.',
|
||||
},
|
||||
privacyPolicy: {
|
||||
id: 'app.privacy-consent.privacy-policy',
|
||||
defaultMessage: 'Read the privacy policy',
|
||||
},
|
||||
continue: {
|
||||
id: 'app.privacy-consent.continue',
|
||||
defaultMessage: 'Save and continue',
|
||||
},
|
||||
})
|
||||
|
||||
function show(current: PrivacySettings) {
|
||||
telemetry.value = true
|
||||
discordRpc.value = current.discord_rpc
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const privacy = await savePrivacySettings({
|
||||
telemetry: telemetry.value,
|
||||
discord_rpc: discordRpc.value,
|
||||
consent_version: CONSENT_VERSION,
|
||||
})
|
||||
modal.value?.hide()
|
||||
emit('saved', privacy)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" :closable="false" max-width="600px">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<ShieldIcon class="mt-0.5 size-6 shrink-0 text-brand" />
|
||||
<p class="m-0 leading-relaxed text-primary">
|
||||
{{ formatMessage(messages.intro) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-5">
|
||||
<div class="min-w-0">
|
||||
<label for="consent-telemetry" class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.telemetry) }}
|
||||
</label>
|
||||
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
|
||||
{{ formatMessage(messages.telemetryDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle id="consent-telemetry" v-model="telemetry" :disabled="saving" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-5">
|
||||
<div class="min-w-0">
|
||||
<label for="consent-discord-rpc" class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.discordRpc) }}
|
||||
</label>
|
||||
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
|
||||
{{ formatMessage(messages.discordRpcDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle id="consent-discord-rpc" v-model="discordRpc" :disabled="saving" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<ButtonStyled type="transparent">
|
||||
<button type="button" :disabled="saving" @click="openUrl('https://axlmc.org/privacy')">
|
||||
<ExternalIcon />
|
||||
{{ formatMessage(messages.privacyPolicy) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" :disabled="saving" @click="save">
|
||||
<SpinnerIcon v-if="saving" class="animate-spin" />
|
||||
{{ formatMessage(messages.continue) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@ -0,0 +1,50 @@
|
||||
<!-- @deprecated Use ShareModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
|
||||
<script setup lang="ts">
|
||||
import { ShareModal } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: 'Share',
|
||||
},
|
||||
shareTitle: {
|
||||
type: String,
|
||||
default: 'Modrinth',
|
||||
},
|
||||
shareText: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
link: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
openInNewTab: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref(null)
|
||||
|
||||
defineExpose({
|
||||
show: (passedContent) => {
|
||||
modal.value.show(passedContent)
|
||||
},
|
||||
hide: () => {
|
||||
modal.value.hide()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ShareModal
|
||||
ref="modal"
|
||||
:header="header"
|
||||
:share-title="shareTitle"
|
||||
:share-text="shareText"
|
||||
:link="link"
|
||||
:open-in-new-tab="openInNewTab"
|
||||
/>
|
||||
</template>
|
||||
314
apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
Normal file
314
apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
Normal file
@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<SymlinkInstanceWarning
|
||||
v-if="instance?.symlink_target"
|
||||
:symlink-target="instance.symlink_target"
|
||||
/>
|
||||
<ContentDiffModal
|
||||
ref="diffModal"
|
||||
:header="formatMessage(messages.updateToPlay)"
|
||||
:admonition-header="formatMessage(messages.updateRequired)"
|
||||
:description="
|
||||
instance ? formatMessage(messages.updateRequiredDescription, { name: instance.name }) : ''
|
||||
"
|
||||
:diffs="normalizedDiffs"
|
||||
:confirm-label="formatMessage(commonMessages.updateButton)"
|
||||
:confirm-icon="DownloadIcon"
|
||||
:show-report-button="true"
|
||||
@confirm="handleUpdate"
|
||||
@cancel="handleDecline"
|
||||
@report="handleReport"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { DownloadIcon } from '@modrinth/assets'
|
||||
import {
|
||||
commonMessages,
|
||||
type ContentDiffItem,
|
||||
ContentDiffModal,
|
||||
defineMessages,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
||||
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import { wait_for_install_job } from '@/helpers/install'
|
||||
import { update_managed_modrinth_version } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
type Dependency = Labrinth.Versions.v3.Dependency
|
||||
type Version = Labrinth.Versions.v2.Version
|
||||
|
||||
interface BaseDiff {
|
||||
project_id: string
|
||||
project?: {
|
||||
title: string
|
||||
icon_url?: string
|
||||
slug: string
|
||||
}
|
||||
currentVersionId?: string
|
||||
newVersionId?: string
|
||||
currentVersion?: Version
|
||||
newVersion?: Version
|
||||
fileName?: string
|
||||
}
|
||||
interface AddedDiff extends BaseDiff {
|
||||
type: 'added'
|
||||
newVersionId: string
|
||||
}
|
||||
interface RemovedDiff extends BaseDiff {
|
||||
type: 'removed'
|
||||
}
|
||||
interface UpdatedDiff extends BaseDiff {
|
||||
type: 'updated'
|
||||
currentVersionId: string
|
||||
newVersionId: string
|
||||
}
|
||||
type DependencyDiff = AddedDiff | RemovedDiff | UpdatedDiff
|
||||
|
||||
type ProjectInfo = {
|
||||
id: string
|
||||
title: string
|
||||
icon_url?: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
|
||||
type UpdateCompleteCallback = () => void | Promise<void>
|
||||
|
||||
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
|
||||
const instance = ref<GameInstance | null>(null)
|
||||
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
|
||||
const diffs = ref<DependencyDiff[]>([])
|
||||
const modpackVersionId = ref<string | null>(null)
|
||||
const modpackVersion = ref<Version | null>(null)
|
||||
|
||||
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
|
||||
diffs.value.map((diff) => ({
|
||||
type: diff.type,
|
||||
projectName: diff.project?.title,
|
||||
fileName: diff.fileName,
|
||||
currentVersionName: diff.currentVersion?.version_number,
|
||||
newVersionName: diff.newVersion?.version_number,
|
||||
})),
|
||||
)
|
||||
|
||||
async function computeDependencyDiffs(
|
||||
currentDeps: Dependency[],
|
||||
latestDeps: Dependency[],
|
||||
): Promise<DependencyDiff[]> {
|
||||
console.log('Computing dependency diffs', { currentDeps, latestDeps })
|
||||
|
||||
// Separate deps with project_id from file_name-only deps
|
||||
const currentWithProject = currentDeps.filter((d) => d.project_id)
|
||||
const latestWithProject = latestDeps.filter((d) => d.project_id)
|
||||
const currentFileOnly = currentDeps.filter((d) => !d.project_id && d.file_name)
|
||||
const latestFileOnly = latestDeps.filter((d) => !d.project_id && d.file_name)
|
||||
|
||||
const currentByProject = new Map<string, Dependency>(
|
||||
currentWithProject.map((d) => [d.project_id!, d]),
|
||||
)
|
||||
const latestByProject = new Map<string, Dependency>(
|
||||
latestWithProject.map((d) => [d.project_id!, d]),
|
||||
)
|
||||
const currentFilenames = new Set(currentFileOnly.map((d) => d.file_name!))
|
||||
const latestFilenames = new Set(latestFileOnly.map((d) => d.file_name!))
|
||||
|
||||
const diffs: DependencyDiff[] = []
|
||||
|
||||
// Find added and updated dependencies (by project_id)
|
||||
latestByProject.forEach((latestDep, projectId) => {
|
||||
const currentDep = currentByProject.get(projectId)
|
||||
if (!currentDep && latestDep.version_id) {
|
||||
diffs.push({ type: 'added', project_id: projectId, newVersionId: latestDep.version_id })
|
||||
} else if (
|
||||
currentDep?.version_id &&
|
||||
latestDep?.version_id &&
|
||||
currentDep?.version_id !== latestDep.version_id
|
||||
) {
|
||||
diffs.push({
|
||||
type: 'updated',
|
||||
project_id: projectId,
|
||||
currentVersionId: currentDep.version_id,
|
||||
newVersionId: latestDep.version_id,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Find removed dependencies (by project_id)
|
||||
currentByProject.forEach((currentDep, projectId) => {
|
||||
if (!latestByProject.has(projectId)) {
|
||||
diffs.push({
|
||||
type: 'removed',
|
||||
project_id: projectId,
|
||||
currentVersionId: currentDep.version_id,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Find added/removed file_name-only dependencies
|
||||
// ideally in future, this should use the hash of the file instead of filename, but since version dependencies don't include file hashes, we'll use filename as a best effort approach
|
||||
for (const fileName of latestFilenames) {
|
||||
if (!currentFilenames.has(fileName)) {
|
||||
diffs.push({ type: 'added', project_id: '', newVersionId: '' as string, fileName })
|
||||
}
|
||||
}
|
||||
for (const fileName of currentFilenames) {
|
||||
if (!latestFilenames.has(fileName)) {
|
||||
diffs.push({ type: 'removed', project_id: '', fileName })
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch projects and versions of diffs
|
||||
const allProjectIds = [...new Set(diffs.map((d) => d.project_id).filter(Boolean))]
|
||||
const allVersionIds = [
|
||||
...new Set(
|
||||
[...diffs.map((d) => d.newVersionId), ...diffs.map((d) => d.currentVersionId)].filter(
|
||||
Boolean,
|
||||
),
|
||||
),
|
||||
] as string[]
|
||||
const [projects, versions] = await Promise.all([
|
||||
get_project_many(allProjectIds, 'bypass'),
|
||||
get_version_many(allVersionIds, 'bypass'),
|
||||
])
|
||||
|
||||
const projectMap = new Map<string, ProjectInfo>(projects.map((p: ProjectInfo) => [p.id, p]))
|
||||
const versionMap = new Map<string, Version>(versions.map((v: Version) => [v.id, v]))
|
||||
|
||||
const mappedDiffs = diffs
|
||||
.map((diff) => {
|
||||
const project = projectMap.get(diff.project_id)
|
||||
return {
|
||||
...diff,
|
||||
project: project
|
||||
? { title: project.title, icon_url: project.icon_url, slug: project.slug }
|
||||
: undefined,
|
||||
currentVersion: diff.currentVersionId ? versionMap.get(diff.currentVersionId) : undefined,
|
||||
newVersion: diff.newVersionId ? versionMap.get(diff.newVersionId) : undefined,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const typeOrder = { added: 0, updated: 1, removed: 2 }
|
||||
const typeCompare = typeOrder[a.type] - typeOrder[b.type]
|
||||
if (typeCompare !== 0) return typeCompare
|
||||
|
||||
const aDate = a.newVersion?.date_published || a.currentVersion?.date_published || ''
|
||||
const bDate = b.newVersion?.date_published || b.currentVersion?.date_published || ''
|
||||
return dayjs(bDate).valueOf() - dayjs(aDate).valueOf()
|
||||
})
|
||||
.filter((d) => d.project || d.fileName) // filter out any diffs that couldn't be matched to a project or file
|
||||
return mappedDiffs
|
||||
}
|
||||
|
||||
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
|
||||
if (!inst.link) return null
|
||||
if (!modpackVersionId.value || !inst.link.version_id) return null
|
||||
|
||||
try {
|
||||
// For server projects, link.project_id is the server project but
|
||||
// link.version_id references a content modpack version from a different project.
|
||||
// Detect this by comparing the version's project_id with link.project_id.
|
||||
modpackVersion.value = await get_version(modpackVersionId.value, 'bypass')
|
||||
const instanceModpackVersion = await get_version(inst.link.version_id, 'bypass')
|
||||
|
||||
// Compute dependency diffs between current and latest version
|
||||
if (instanceModpackVersion && modpackVersion.value) {
|
||||
return await computeDependencyDiffs(
|
||||
instanceModpackVersion.dependencies || [],
|
||||
modpackVersion.value.dependencies || [],
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error)
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => instance.value,
|
||||
async (newInstance) => {
|
||||
if (!newInstance) return
|
||||
const result = await checkUpdateAvailable(newInstance)
|
||||
diffs.value = result || []
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
async function handleUpdate() {
|
||||
hide()
|
||||
const serverProjectId = instance.value?.link?.project_id
|
||||
if (serverProjectId) startInstallingServer(serverProjectId)
|
||||
try {
|
||||
if (modpackVersionId.value && instance.value) {
|
||||
const job = await update_managed_modrinth_version(instance.value.id, modpackVersionId.value)
|
||||
await wait_for_install_job(job.job_id)
|
||||
await onUpdateComplete.value()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating instance:', error)
|
||||
} finally {
|
||||
if (serverProjectId) stopInstallingServer(serverProjectId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReport() {
|
||||
if (instance.value?.link?.project_id) {
|
||||
openUrl(`https://modrinth.com/report?item=project&itemID=${instance.value.link.project_id}`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDecline() {
|
||||
hide()
|
||||
}
|
||||
|
||||
function show(
|
||||
instanceVal: GameInstance,
|
||||
modpackVersionIdVal: string | null = null,
|
||||
callback: UpdateCompleteCallback = () => {},
|
||||
e?: MouseEvent,
|
||||
) {
|
||||
instance.value = instanceVal
|
||||
modpackVersionId.value = modpackVersionIdVal
|
||||
onUpdateComplete.value = callback
|
||||
diffModal.value?.show(e)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
diffModal.value?.hide()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
updateToPlay: {
|
||||
id: 'app.modal.update-to-play.header',
|
||||
defaultMessage: 'Update to play',
|
||||
},
|
||||
updateRequired: {
|
||||
id: 'app.modal.update-to-play.update-required',
|
||||
defaultMessage: 'Update required',
|
||||
},
|
||||
updateRequiredDescription: {
|
||||
id: 'app.modal.update-to-play.update-required-description',
|
||||
defaultMessage:
|
||||
'An update is required to play {name}. Please update to the latest version to launch the game.',
|
||||
},
|
||||
})
|
||||
|
||||
const hasUpdate = computed(() => {
|
||||
if (!instance.value?.link) return false
|
||||
return modpackVersionId.value != null && modpackVersionId.value !== instance.value.link.version_id
|
||||
})
|
||||
|
||||
defineExpose({ show, hide, hasUpdate })
|
||||
</script>
|
||||
@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { onboardingMessages, type OnboardingStep } from './onboardingConfig'
|
||||
import OnboardingMascotStage from './OnboardingMascotStage.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
step: OnboardingStep
|
||||
current: number
|
||||
total: number
|
||||
docked: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
advance: []
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const progressWidth = computed(() => `${Math.min(100, (props.current / props.total) * 100)}%`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="onboarding-dialogue-layout" :class="{ 'onboarding-dialogue-layout-docked': docked }">
|
||||
<div v-if="docked" class="onboarding-dialogue-progress" aria-hidden="true">
|
||||
<span :style="{ width: progressWidth }"></span>
|
||||
</div>
|
||||
<OnboardingMascotStage :alt="formatMessage(onboardingMessages.mascotAlt)" />
|
||||
<div class="onboarding-dialogue-copy">
|
||||
<div class="onboarding-dialogue-header">
|
||||
<div class="onboarding-dialogue-heading">
|
||||
<p class="m-0 mb-1.5 text-[0.8125rem] font-bold text-brand">{{ current }} / {{ total }}</p>
|
||||
<h2 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h2>
|
||||
</div>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button :aria-label="formatMessage(onboardingMessages.skip)" @click.stop="$emit('skip')">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="onboarding-dialogue-body">
|
||||
<p :id="`onboarding-description-${step.id}`">
|
||||
{{ formatMessage(step.description) }}
|
||||
</p>
|
||||
<ButtonStyled v-if="step.interaction === 'manual'" color="brand">
|
||||
<button @click="$emit('advance')">
|
||||
{{ formatMessage(step.action) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p v-else class="onboarding-action-hint">
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
{{ formatMessage(step.action) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.onboarding-dialogue-layout {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-layout > :deep(.onboarding-mascot) {
|
||||
width: 6rem;
|
||||
align-self: center;
|
||||
transform: scale(1.35);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-layout-docked {
|
||||
width: min(72rem, 100%);
|
||||
margin-inline: auto;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-progress {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-progress span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--color-brand);
|
||||
transition: width 280ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.onboarding-dialogue-layout-docked > :deep(.onboarding-mascot) {
|
||||
width: 8rem;
|
||||
align-self: end;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-copy {
|
||||
min-width: 0;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.onboarding-dialogue-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-heading h2,
|
||||
.onboarding-dialogue-body p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-heading h2 {
|
||||
color: var(--color-contrast);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-body > p:not(.onboarding-action-hint) {
|
||||
max-width: 46ch;
|
||||
color: var(--color-contrast);
|
||||
font-size: 1rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-layout-docked .onboarding-dialogue-body > p:not(.onboarding-action-hint) {
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.onboarding-progress,
|
||||
.onboarding-action-hint {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.onboarding-progress {
|
||||
margin-bottom: 0.375rem;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.onboarding-action-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.625rem !important;
|
||||
color: var(--color-brand);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.onboarding-action-hint :deep(svg) {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-body :deep(.button-outer) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-header :deep(.button-outer) {
|
||||
flex: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-copy :deep(svg) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.onboarding-dialogue-layout > :deep(.onboarding-mascot) {
|
||||
width: 4.5rem;
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
.onboarding-dialogue-layout-docked {
|
||||
width: 100%;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-layout-docked > :deep(.onboarding-mascot) {
|
||||
width: 5rem;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onboarding-dialogue-progress span {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import searchingVisual from '@/assets/axo-searching.svg?url'
|
||||
import teachingVisual from '@/assets/axo-teaching.svg?url'
|
||||
|
||||
defineProps<{
|
||||
alt: string
|
||||
}>()
|
||||
|
||||
const mascotVisuals = [teachingVisual, searchingVisual]
|
||||
const mascotVisual = mascotVisuals[Math.floor(Math.random() * mascotVisuals.length)]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img class="onboarding-mascot" :src="mascotVisual" :alt="alt" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.onboarding-mascot {
|
||||
width: clamp(6rem, 11vw, 9rem);
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { toRef } from 'vue'
|
||||
|
||||
import type { OnboardingMode } from './onboardingConfig'
|
||||
import OnboardingDialogue from './OnboardingDialogue.vue'
|
||||
import OnboardingWelcome from './OnboardingWelcome.vue'
|
||||
import { useOnboardingTour } from './useOnboardingTour'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
mode: OnboardingMode
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
skip: []
|
||||
requestCloseSettings: []
|
||||
}>()
|
||||
|
||||
const {
|
||||
bubbleElement,
|
||||
bubblePlacement,
|
||||
controlSpotlightStyle,
|
||||
handleManualClick,
|
||||
isDialogueStep,
|
||||
isWelcomeStep,
|
||||
step,
|
||||
stepIndex,
|
||||
steps,
|
||||
targetRect,
|
||||
advance,
|
||||
} = useOnboardingTour(toRef(props, 'visible'), toRef(props, 'mode'), {
|
||||
complete: () => emit('complete'),
|
||||
skip: () => emit('skip'),
|
||||
closeSettings: () => emit('requestCloseSettings'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="fixed inset-0 z-[10001] overflow-hidden pointer-events-none" aria-live="polite">
|
||||
<div
|
||||
v-if="targetRect && step.spotlight === 'control'"
|
||||
class="onboarding-spotlight"
|
||||
:style="controlSpotlightStyle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="onboarding-corner onboarding-corner-top-left"></span>
|
||||
<span class="onboarding-corner onboarding-corner-top-right"></span>
|
||||
<span class="onboarding-corner onboarding-corner-bottom-right"></span>
|
||||
<span class="onboarding-corner onboarding-corner-bottom-left"></span>
|
||||
</div>
|
||||
|
||||
<section
|
||||
ref="bubbleElement"
|
||||
data-onboarding-overlay-ui
|
||||
class="onboarding-surface"
|
||||
:class="[
|
||||
`onboarding-surface-${bubblePlacement.direction}`,
|
||||
{
|
||||
'onboarding-surface-centered': !targetRect,
|
||||
'onboarding-surface-dialogue': isDialogueStep,
|
||||
'onboarding-surface-welcome': isWelcomeStep,
|
||||
},
|
||||
]"
|
||||
:style="bubblePlacement.style"
|
||||
:aria-labelledby="`onboarding-title-${step.id}`"
|
||||
:aria-describedby="`onboarding-description-${step.id}`"
|
||||
@click="step.interaction === 'inspect' ? advance() : undefined"
|
||||
>
|
||||
<OnboardingWelcome
|
||||
v-if="isWelcomeStep"
|
||||
:step="step"
|
||||
@start="handleManualClick"
|
||||
@skip="emit('skip')"
|
||||
/>
|
||||
<OnboardingDialogue
|
||||
v-else
|
||||
:key="`${mode}-${step.id}`"
|
||||
:step="step"
|
||||
:current="stepIndex + 1"
|
||||
:total="steps.length"
|
||||
:docked="isDialogueStep"
|
||||
@advance="handleManualClick"
|
||||
@skip="emit('skip')"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.onboarding-spotlight {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
transform-origin: center;
|
||||
animation: onboarding-focus 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.onboarding-corner {
|
||||
position: absolute;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-color: var(--color-brand);
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.onboarding-corner-top-left {
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-top-width: 2px;
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
.onboarding-corner-top-right {
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-top-width: 2px;
|
||||
border-right-width: 2px;
|
||||
}
|
||||
|
||||
.onboarding-corner-bottom-right {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-right-width: 2px;
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
|
||||
.onboarding-corner-bottom-left {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-bottom-width: 2px;
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
.onboarding-surface {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
width: min(32rem, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - 4rem);
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-super-raised-bg);
|
||||
padding: 1.25rem;
|
||||
box-sizing: border-box;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.onboarding-surface-centered {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.onboarding-surface-welcome {
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
width: auto;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
transform: none;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.onboarding-surface-dialogue {
|
||||
top: auto !important;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0 !important;
|
||||
z-index: 2;
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
max-height: min(15rem, 40vh);
|
||||
transform: none;
|
||||
overflow-y: auto;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-divider);
|
||||
border-radius: 0;
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
:global(body.onboarding-reserve-dialogue-space .modal-container) {
|
||||
height: calc(100% - var(--onboarding-dialogue-reserved-space, 0px));
|
||||
}
|
||||
|
||||
@keyframes onboarding-focus {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.015);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onboarding-spotlight {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.onboarding-surface {
|
||||
top: auto !important;
|
||||
bottom: 1rem;
|
||||
left: 1rem !important;
|
||||
width: calc(100vw - 2rem);
|
||||
max-height: min(22rem, calc(100vh - 4rem));
|
||||
transform: none;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.onboarding-surface-dialogue {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0 !important;
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
max-height: min(18rem, 52vh);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.onboarding-surface-welcome {
|
||||
inset: 0 !important;
|
||||
width: auto;
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,457 @@
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import AxolotlLogo from '@/components/ui/AxolotlLogo.vue'
|
||||
|
||||
import { onboardingMessages, type OnboardingStep } from './onboardingConfig'
|
||||
|
||||
defineProps<{
|
||||
step: OnboardingStep
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
start: []
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="onboarding-welcome-content">
|
||||
<div class="onboarding-welcome-brand-stage">
|
||||
<div class="onboarding-welcome-brand">
|
||||
<div class="onboarding-welcome-logo">
|
||||
<AxolotlLogo icon-only />
|
||||
</div>
|
||||
<div class="onboarding-welcome-wordmark" aria-label="Axolotl Launcher">
|
||||
<span class="onboarding-welcome-wordmark-core" data-wordmark="Axolotl"> Axolotl </span>
|
||||
<span class="onboarding-welcome-wordmark-suffix" data-wordmark="Launcher">
|
||||
Launcher
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="onboarding-welcome-panel">
|
||||
<div class="onboarding-welcome-panel-inner">
|
||||
<div class="onboarding-welcome-copy">
|
||||
<h1 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h1>
|
||||
<p :id="`onboarding-description-${step.id}`">
|
||||
{{ formatMessage(step.description) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-welcome-actions">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="$emit('start')">
|
||||
{{ formatMessage(step.action) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="onboarding-welcome-secondary-action">
|
||||
<span>{{ formatMessage(onboardingMessages.welcomeFooter) }}</span>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="$emit('skip')">
|
||||
{{ formatMessage(onboardingMessages.skip) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.onboarding-welcome-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-bg);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.onboarding-welcome-brand-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.onboarding-welcome-brand {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: clamp(0.75rem, 2vw, 1.5rem);
|
||||
transform: translate(-50%, -50%);
|
||||
animation: onboarding-welcome-brand-lift 3200ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.onboarding-welcome-logo {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex: none;
|
||||
width: clamp(7rem, 15vw, 11rem);
|
||||
height: clamp(7rem, 15vw, 11rem);
|
||||
animation: onboarding-welcome-logo-reveal 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35em;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-contrast);
|
||||
font-size: 4.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark span {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
transform: translateX(-4rem);
|
||||
animation: onboarding-welcome-wordmark-flight 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark-core {
|
||||
background-image: linear-gradient(90deg, var(--color-contrast), var(--color-base));
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark-suffix {
|
||||
color: var(--color-secondary);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark span::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: var(--color-brand);
|
||||
content: attr(data-wordmark);
|
||||
animation: onboarding-welcome-brand-scan 700ms 2050ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark-suffix::after {
|
||||
animation-delay: 2400ms;
|
||||
}
|
||||
|
||||
.onboarding-welcome-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
border-top: 1px solid var(--color-divider);
|
||||
background: var(--color-raised-bg);
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
animation: onboarding-welcome-panel-enter 650ms 3200ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.onboarding-welcome-panel::before {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 0;
|
||||
width: clamp(4rem, 12vw, 10rem);
|
||||
height: 2px;
|
||||
background: var(--color-brand);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.onboarding-welcome-panel-inner {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: clamp(2rem, 6vw, 6rem);
|
||||
width: min(80rem, 100%);
|
||||
min-height: clamp(11rem, 27vh, 15rem);
|
||||
margin-inline: auto;
|
||||
padding: 1.5rem clamp(1.5rem, 5vw, 4rem);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy h1,
|
||||
.onboarding-welcome-copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy h1 {
|
||||
color: var(--color-contrast);
|
||||
font-size: 2.25rem;
|
||||
font-weight: 750;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy p {
|
||||
max-width: 46rem;
|
||||
margin-top: 0.75rem;
|
||||
color: var(--color-secondary);
|
||||
font-size: 1rem;
|
||||
line-height: 1.55;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.onboarding-welcome-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
min-width: 15rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-actions :deep(.button-outer),
|
||||
.onboarding-welcome-actions :deep(button) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.onboarding-welcome-secondary-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-secondary-action :deep(.button-outer),
|
||||
.onboarding-welcome-secondary-action :deep(button) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@keyframes onboarding-welcome-logo-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.72);
|
||||
}
|
||||
65% {
|
||||
opacity: 1;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes onboarding-welcome-wordmark-reveal {
|
||||
from {
|
||||
max-width: 0;
|
||||
}
|
||||
to {
|
||||
max-width: 42rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes onboarding-welcome-wordmark-flight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-4rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes onboarding-welcome-brand-scan {
|
||||
0%,
|
||||
12% {
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
100% {
|
||||
clip-path: inset(0 0 0 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes onboarding-welcome-brand-lift {
|
||||
0%,
|
||||
70% {
|
||||
top: 50%;
|
||||
}
|
||||
100% {
|
||||
top: clamp(9rem, 33vh, 18rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes onboarding-welcome-panel-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onboarding-welcome-brand,
|
||||
.onboarding-welcome-logo,
|
||||
.onboarding-welcome-wordmark,
|
||||
.onboarding-welcome-wordmark span,
|
||||
.onboarding-welcome-panel {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.onboarding-welcome-brand {
|
||||
top: clamp(9rem, 33vh, 18rem);
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark {
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-logo,
|
||||
.onboarding-welcome-wordmark span,
|
||||
.onboarding-welcome-panel {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark span::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.onboarding-welcome-brand {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-logo {
|
||||
width: 6.5rem;
|
||||
height: 6.5rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.1rem;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-panel-inner {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
min-height: min(19rem, 46vh);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy p {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.onboarding-welcome-actions {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.onboarding-welcome-wordmark {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-secondary-action > span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 701px) and (max-width: 1000px) {
|
||||
.onboarding-welcome-wordmark {
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 680px) {
|
||||
.onboarding-welcome-brand {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-logo {
|
||||
width: 6.5rem;
|
||||
height: 6.5rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-panel-inner {
|
||||
min-height: min(10rem, 32vh);
|
||||
padding-block: 1rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy p {
|
||||
margin-top: 0.375rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) and (max-height: 680px) {
|
||||
.onboarding-welcome-brand {
|
||||
animation-name: onboarding-welcome-brand-lift-compact;
|
||||
}
|
||||
|
||||
.onboarding-welcome-panel-inner {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 1rem;
|
||||
min-height: min(9.5rem, 40vh);
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy p {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.onboarding-welcome-actions {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-secondary-action > span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes onboarding-welcome-brand-lift-compact {
|
||||
0%,
|
||||
70% {
|
||||
top: 50%;
|
||||
}
|
||||
100% {
|
||||
top: 29%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,767 @@
|
||||
import { defineMessages, type MessageDescriptor } from '@modrinth/ui'
|
||||
|
||||
export type OnboardingInteraction = 'manual' | 'navigate' | 'activate' | 'inspect'
|
||||
export type OnboardingMode = 'main' | 'instance'
|
||||
export type CreationPath = 'custom'
|
||||
export type StepDestination = string | 'complete'
|
||||
|
||||
export type OnboardingStep = {
|
||||
id: string
|
||||
targetId?: string
|
||||
interaction: OnboardingInteraction
|
||||
title: MessageDescriptor
|
||||
description: MessageDescriptor
|
||||
action: MessageDescriptor
|
||||
spotlight?: 'control'
|
||||
expectedPath?: string
|
||||
next?: StepDestination
|
||||
closeSettingsAfter?: boolean
|
||||
nextByCreationPath?: Partial<Record<CreationPath, StepDestination>>
|
||||
branchByTarget?: Record<
|
||||
string,
|
||||
{
|
||||
creationPath?: CreationPath
|
||||
next: StepDestination
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export const onboardingMessages = defineMessages({
|
||||
welcomeTitle: {
|
||||
id: 'app.onboarding.welcome.title',
|
||||
defaultMessage: 'Everything is ready',
|
||||
},
|
||||
welcomeDescription: {
|
||||
id: 'app.onboarding.welcome.description',
|
||||
defaultMessage:
|
||||
'Your instances, content, worlds, and downloads now have one home. Let us take a quick lap before you settle in.',
|
||||
},
|
||||
welcomeFooter: {
|
||||
id: 'app.onboarding.welcome.footer',
|
||||
defaultMessage: 'Your last next launcher.',
|
||||
},
|
||||
start: { id: 'app.onboarding.action.start', defaultMessage: 'Take the tour' },
|
||||
homeWidgetsTitle: {
|
||||
id: 'app.onboarding.home-widgets.title',
|
||||
defaultMessage: 'Your Home, your layout',
|
||||
},
|
||||
homeWidgetsDescription: {
|
||||
id: 'app.onboarding.home-widgets.description',
|
||||
defaultMessage:
|
||||
'Information Home is built from widgets for recent activity, playtime, instances, worlds, and servers. The grid reflows as the window or account sidebar changes.',
|
||||
},
|
||||
homeCustomizeTitle: {
|
||||
id: 'app.onboarding.home-customize.title',
|
||||
defaultMessage: 'Arrange it your way',
|
||||
},
|
||||
homeCustomizeDescription: {
|
||||
id: 'app.onboarding.home-customize.description',
|
||||
defaultMessage:
|
||||
'Use the bottom-right edit control to add, resize, and configure widgets. While editing, switch between an automatically packed grid and a free grid that preserves empty cells.',
|
||||
},
|
||||
discoverTitle: { id: 'app.onboarding.discover.title', defaultMessage: 'Find something new' },
|
||||
discoverDescription: {
|
||||
id: 'app.onboarding.discover.description',
|
||||
defaultMessage:
|
||||
'Modpacks, mods, plugins, resource packs, shaders: the good kind of rabbit hole.',
|
||||
},
|
||||
clickDiscover: {
|
||||
id: 'app.onboarding.action.click-discover',
|
||||
defaultMessage: 'Click Discover content to continue',
|
||||
},
|
||||
browseTitle: { id: 'app.onboarding.browse.title', defaultMessage: 'Search with receipts' },
|
||||
browseDescription: {
|
||||
id: 'app.onboarding.browse.description',
|
||||
defaultMessage:
|
||||
'Use types, search, and filters to narrow things down. Project pages keep versions, changelogs, galleries, and install options in one place.',
|
||||
},
|
||||
favoritesTitle: {
|
||||
id: 'app.onboarding.favorites.title',
|
||||
defaultMessage: 'Keep a short list',
|
||||
},
|
||||
favoritesDescription: {
|
||||
id: 'app.onboarding.favorites.description',
|
||||
defaultMessage:
|
||||
'Open Favorites to revisit saved mods, resource packs, data packs, and shaders. The launcher refreshes their project details, then you can choose an instance and add mixed sources to the same install cart.',
|
||||
},
|
||||
clickFavorites: {
|
||||
id: 'app.onboarding.action.click-favorites',
|
||||
defaultMessage: 'Click Favorites to continue',
|
||||
},
|
||||
homeLayoutTitle: {
|
||||
id: 'app.onboarding.home-layout.title',
|
||||
defaultMessage: 'Change the amount of detail',
|
||||
},
|
||||
homeLayoutDescription: {
|
||||
id: 'app.onboarding.home-layout.description',
|
||||
defaultMessage:
|
||||
'Use the bottom-right control to switch between Information Home and Minimal Home. Widget editing stays with Information Home.',
|
||||
},
|
||||
continueArea: {
|
||||
id: 'app.onboarding.action.continue-area',
|
||||
defaultMessage: 'Click anywhere for the next bit',
|
||||
},
|
||||
skinsTitle: { id: 'app.onboarding.skins.title', defaultMessage: 'A new look, maybe' },
|
||||
skinsDescription: {
|
||||
id: 'app.onboarding.skins.description',
|
||||
defaultMessage:
|
||||
'Keep your Minecraft skins together. Signing in can wait until you feel like it.',
|
||||
},
|
||||
clickSkins: {
|
||||
id: 'app.onboarding.action.click-skins',
|
||||
defaultMessage: 'Click Skin selector to continue',
|
||||
},
|
||||
skinsPageTitle: { id: 'app.onboarding.skins-page.title', defaultMessage: 'Your skin drawer' },
|
||||
skinsPageDescription: {
|
||||
id: 'app.onboarding.skins-page.description',
|
||||
defaultMessage: 'Add, preview, sort, and apply skins here. No pressure to sign in just yet.',
|
||||
},
|
||||
accountTitle: {
|
||||
id: 'app.onboarding.account.title',
|
||||
defaultMessage: 'Accounts, on your schedule',
|
||||
},
|
||||
accountDescription: {
|
||||
id: 'app.onboarding.account.description',
|
||||
defaultMessage:
|
||||
'When you are ready, sign in, switch accounts, or open your profile here. No deadline.',
|
||||
},
|
||||
downloadsTitle: { id: 'app.onboarding.downloads.title', defaultMessage: 'Download control room' },
|
||||
downloadsDescription: {
|
||||
id: 'app.onboarding.downloads.description',
|
||||
defaultMessage:
|
||||
'Installations and content downloads report in here, so nothing has to disappear mysteriously.',
|
||||
},
|
||||
clickDownloads: {
|
||||
id: 'app.onboarding.action.click-downloads',
|
||||
defaultMessage: 'Click Downloads to continue',
|
||||
},
|
||||
downloadsPageTitle: {
|
||||
id: 'app.onboarding.downloads-page.title',
|
||||
defaultMessage: 'Nothing gets lost',
|
||||
},
|
||||
downloadsPageDescription: {
|
||||
id: 'app.onboarding.downloads-page.description',
|
||||
defaultMessage:
|
||||
'Active work, history, errors, retries, cancellations, and diagnostics all leave a paper trail here.',
|
||||
},
|
||||
settingsTitle: { id: 'app.onboarding.settings.title', defaultMessage: 'Make it yours' },
|
||||
settingsDescription: {
|
||||
id: 'app.onboarding.settings.description',
|
||||
defaultMessage:
|
||||
'The useful controls live here: launcher preferences, game launch behavior, content downloads, and privacy.',
|
||||
},
|
||||
clickSettings: {
|
||||
id: 'app.onboarding.action.click-settings',
|
||||
defaultMessage: 'Click Settings to continue',
|
||||
},
|
||||
appearanceTitle: { id: 'app.onboarding.appearance.title', defaultMessage: 'Set the vibe' },
|
||||
appearanceDescription: {
|
||||
id: 'app.onboarding.appearance.description',
|
||||
defaultMessage:
|
||||
'Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar.',
|
||||
},
|
||||
languageTitle: { id: 'app.onboarding.language.title', defaultMessage: 'Speak your language' },
|
||||
languageDescription: {
|
||||
id: 'app.onboarding.language.description',
|
||||
defaultMessage: 'Pick the launcher language and manage translations. No decoder ring required.',
|
||||
},
|
||||
translationTitle: {
|
||||
id: 'app.onboarding.translation.title',
|
||||
defaultMessage: 'Translation, the Axolotl way',
|
||||
},
|
||||
translationDescription: {
|
||||
id: 'app.onboarding.translation.description',
|
||||
defaultMessage:
|
||||
'Translate Modrinth project titles, summaries, and descriptions while you browse. Keep the original, show both, or make the translation the main character.',
|
||||
},
|
||||
aiTitle: {
|
||||
id: 'app.onboarding.ai.title',
|
||||
defaultMessage: 'Bring your own AI provider',
|
||||
},
|
||||
aiDescription: {
|
||||
id: 'app.onboarding.ai.description',
|
||||
defaultMessage:
|
||||
'Connect text-model providers once, choose the models you want available, or switch every AI feature off in one place.',
|
||||
},
|
||||
javaTitle: { id: 'app.onboarding.java.title', defaultMessage: 'Java, under the hood' },
|
||||
javaDescription: {
|
||||
id: 'app.onboarding.java.description',
|
||||
defaultMessage:
|
||||
'The Java runtimes that start Minecraft live here. Technical, but well-behaved.',
|
||||
},
|
||||
defaultsTitle: { id: 'app.onboarding.defaults.title', defaultMessage: 'Start ahead' },
|
||||
defaultsDescription: {
|
||||
id: 'app.onboarding.defaults.description',
|
||||
defaultMessage:
|
||||
'New instances inherit these choices, so you do not have to repeat the homework.',
|
||||
},
|
||||
resourcesTitle: {
|
||||
id: 'app.onboarding.resources.title',
|
||||
defaultMessage: 'Do not cook the computer',
|
||||
},
|
||||
resourcesDescription: {
|
||||
id: 'app.onboarding.resources.description',
|
||||
defaultMessage:
|
||||
'Choose how content downloads and installs, from download sources to safety checks.',
|
||||
},
|
||||
privacyTitle: {
|
||||
id: 'app.onboarding.privacy.title',
|
||||
defaultMessage: 'Your data, your call',
|
||||
},
|
||||
privacyDescription: {
|
||||
id: 'app.onboarding.privacy.description',
|
||||
defaultMessage:
|
||||
'Manage anonymous telemetry, Discord Rich Presence, and the Minecraft log analysis service whenever you need to.',
|
||||
},
|
||||
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
|
||||
updatesDescription: {
|
||||
id: 'app.onboarding.updates.description',
|
||||
defaultMessage: 'Choose when Axolotl checks for updates and whether it installs them for you.',
|
||||
},
|
||||
clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' },
|
||||
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
|
||||
libraryDescription: {
|
||||
id: 'app.onboarding.library.description',
|
||||
defaultMessage:
|
||||
'Instances keep versions, loaders, content, and saves separate. No accidental mod soup.',
|
||||
},
|
||||
clickLibrary: {
|
||||
id: 'app.onboarding.action.click-library',
|
||||
defaultMessage: 'Click Library to continue',
|
||||
},
|
||||
libraryPageTitle: {
|
||||
id: 'app.onboarding.library-page.title',
|
||||
defaultMessage: 'Everything, in its place',
|
||||
},
|
||||
libraryPageDescription: {
|
||||
id: 'app.onboarding.library-page.description',
|
||||
defaultMessage:
|
||||
'Filter by modpack, server, or custom setup, then open any instance to manage it.',
|
||||
},
|
||||
createTitle: { id: 'app.onboarding.create.title', defaultMessage: 'Make a fresh start' },
|
||||
createDescription: {
|
||||
id: 'app.onboarding.create.description',
|
||||
defaultMessage: 'Start from scratch or bring in an existing instance or modpack. Your call.',
|
||||
},
|
||||
clickCreate: {
|
||||
id: 'app.onboarding.action.click-create',
|
||||
defaultMessage: 'Click Create new instance to continue',
|
||||
},
|
||||
creationTitle: { id: 'app.onboarding.creation.title', defaultMessage: 'Pick your route' },
|
||||
creationDescription: {
|
||||
id: 'app.onboarding.creation.description',
|
||||
defaultMessage:
|
||||
'Start fresh with a custom setup, or import an existing instance or modpack. Your call.',
|
||||
},
|
||||
clickCreationMethod: {
|
||||
id: 'app.onboarding.action.click-creation-method',
|
||||
defaultMessage: 'Choose a route to continue',
|
||||
},
|
||||
creationNameTitle: {
|
||||
id: 'app.onboarding.creation-name.title',
|
||||
defaultMessage: 'Give it a memorable name',
|
||||
},
|
||||
creationNameDescription: {
|
||||
id: 'app.onboarding.creation-name.description',
|
||||
defaultMessage: 'Pick a name your future self will recognize at a glance.',
|
||||
},
|
||||
creationLoaderTitle: {
|
||||
id: 'app.onboarding.creation-loader.title',
|
||||
defaultMessage: 'Choose the engine',
|
||||
},
|
||||
creationLoaderDescription: {
|
||||
id: 'app.onboarding.creation-loader.description',
|
||||
defaultMessage: 'Vanilla, Fabric, Forge, NeoForge, and friends. Pick what your content needs.',
|
||||
},
|
||||
creationVersionTitle: {
|
||||
id: 'app.onboarding.creation-version.title',
|
||||
defaultMessage: 'Set the game version',
|
||||
},
|
||||
creationVersionDescription: {
|
||||
id: 'app.onboarding.creation-version.description',
|
||||
defaultMessage:
|
||||
'Choose the Minecraft version for this instance. Compatibility likes specifics.',
|
||||
},
|
||||
creationConfirmTitle: {
|
||||
id: 'app.onboarding.creation-confirm.title',
|
||||
defaultMessage: 'One last look',
|
||||
},
|
||||
creationConfirmDescription: {
|
||||
id: 'app.onboarding.creation-confirm.description',
|
||||
defaultMessage:
|
||||
'This creates the instance with your choices. I keep a strict hands-off policy.',
|
||||
},
|
||||
finishArea: {
|
||||
id: 'app.onboarding.action.finish-area',
|
||||
defaultMessage: 'Click anywhere and you are all set',
|
||||
},
|
||||
instanceActionsTitle: {
|
||||
id: 'app.onboarding.instance-actions.title',
|
||||
defaultMessage: 'The main controls',
|
||||
},
|
||||
instanceActionsDescription: {
|
||||
id: 'app.onboarding.instance-actions.description',
|
||||
defaultMessage:
|
||||
'Launch, stop, repair, configure, export, or open the instance from its header.',
|
||||
},
|
||||
instanceTabsTitle: {
|
||||
id: 'app.onboarding.instance-tabs.title',
|
||||
defaultMessage: 'The rest of the workshop',
|
||||
},
|
||||
instanceTabsDescription: {
|
||||
id: 'app.onboarding.instance-tabs.description',
|
||||
defaultMessage: 'Use these tabs for content, files, screenshots, worlds, and logs. Tidy chaos.',
|
||||
},
|
||||
labTitle: {
|
||||
id: 'app.onboarding.lab.title',
|
||||
defaultMessage: 'Useful tools, built in',
|
||||
},
|
||||
labDescription: {
|
||||
id: 'app.onboarding.lab.description',
|
||||
defaultMessage:
|
||||
'The Lab keeps local Minecraft tools inside the launcher, without another website or account.',
|
||||
},
|
||||
clickLab: {
|
||||
id: 'app.onboarding.action.click-lab',
|
||||
defaultMessage: 'Click Lab to continue',
|
||||
},
|
||||
labToolsTitle: {
|
||||
id: 'app.onboarding.lab-tools.title',
|
||||
defaultMessage: 'Local tools for Minecraft',
|
||||
},
|
||||
labToolsDescription: {
|
||||
id: 'app.onboarding.lab-tools.description',
|
||||
defaultMessage:
|
||||
'Create formatted text and recipe data packs, explore Java worlds, and inspect schematic builds without leaving the launcher.',
|
||||
},
|
||||
openGradientText: {
|
||||
id: 'app.onboarding.action.open-gradient-text',
|
||||
defaultMessage: 'Open Gradient text generator to continue',
|
||||
},
|
||||
labEditorTitle: {
|
||||
id: 'app.onboarding.lab-editor.title',
|
||||
defaultMessage: 'Build and copy in one place',
|
||||
},
|
||||
labEditorDescription: {
|
||||
id: 'app.onboarding.lab-editor.description',
|
||||
defaultMessage:
|
||||
'Edit text, choose colors, preview the result, and copy the format your Minecraft setup expects.',
|
||||
},
|
||||
labSeedMapTitle: {
|
||||
id: 'app.onboarding.lab-seed-map.title',
|
||||
defaultMessage: 'Find a world before you load it',
|
||||
},
|
||||
labSeedMapDescription: {
|
||||
id: 'app.onboarding.lab-seed-map.description',
|
||||
defaultMessage:
|
||||
'Enter a seed or load one from an instance, then inspect biomes, structures, and ore layers on the local map.',
|
||||
},
|
||||
returnToLab: {
|
||||
id: 'app.onboarding.action.return-lab',
|
||||
defaultMessage: 'Click Lab to continue',
|
||||
},
|
||||
openSeedMap: {
|
||||
id: 'app.onboarding.action.open-seed-map',
|
||||
defaultMessage: 'Open Seed map to continue',
|
||||
},
|
||||
openSchematicWorkshop: {
|
||||
id: 'app.onboarding.action.open-schematic-workshop',
|
||||
defaultMessage: 'Open Schematic workshop to continue',
|
||||
},
|
||||
openRecipeGenerator: {
|
||||
id: 'app.onboarding.action.open-recipe-generator',
|
||||
defaultMessage: 'Open Recipe generator to continue',
|
||||
},
|
||||
labRecipeGeneratorTitle: {
|
||||
id: 'app.onboarding.lab-recipe-generator.title',
|
||||
defaultMessage: 'Craft data pack recipes',
|
||||
},
|
||||
labRecipeGeneratorDescription: {
|
||||
id: 'app.onboarding.lab-recipe-generator.description',
|
||||
defaultMessage:
|
||||
'Pick a Java version, fill the recipe slots, and copy or export the JSON locally.',
|
||||
},
|
||||
labSchematicTitle: {
|
||||
id: 'app.onboarding.lab-schematic.title',
|
||||
defaultMessage: 'Inspect a build before placing it',
|
||||
},
|
||||
labSchematicDescription: {
|
||||
id: 'app.onboarding.lab-schematic.description',
|
||||
defaultMessage:
|
||||
'Open a local .litematic or .schem file, or choose one from an installed instance. The 3D workspace keeps viewing, measurement, layer controls, materials, and local edits together.',
|
||||
},
|
||||
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
|
||||
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Axolotl guide' },
|
||||
})
|
||||
|
||||
const step = (
|
||||
id: string,
|
||||
interaction: OnboardingInteraction,
|
||||
copy: {
|
||||
title: MessageDescriptor
|
||||
description: MessageDescriptor
|
||||
action: MessageDescriptor
|
||||
},
|
||||
options: Omit<OnboardingStep, 'id' | 'interaction' | 'title' | 'description' | 'action'> = {},
|
||||
): OnboardingStep => ({ id, interaction, ...copy, ...options })
|
||||
|
||||
const copy = (
|
||||
title: MessageDescriptor,
|
||||
description: MessageDescriptor,
|
||||
action: MessageDescriptor,
|
||||
) => ({ title, description, action })
|
||||
|
||||
const control = (targetId: string, expectedPath?: string) => ({
|
||||
targetId,
|
||||
spotlight: 'control' as const,
|
||||
...(expectedPath ? { expectedPath } : {}),
|
||||
})
|
||||
|
||||
const inspect = (
|
||||
id: string,
|
||||
targetId: string,
|
||||
title: MessageDescriptor,
|
||||
description: MessageDescriptor,
|
||||
) => step(id, 'inspect', copy(title, description, onboardingMessages.continueArea), { targetId })
|
||||
|
||||
const settingsTourSteps: Array<[string, string, MessageDescriptor, MessageDescriptor]> = [
|
||||
[
|
||||
'settings-interface',
|
||||
'settings-tab-interface',
|
||||
onboardingMessages.appearanceTitle,
|
||||
onboardingMessages.appearanceDescription,
|
||||
],
|
||||
[
|
||||
'settings-launch-defaults',
|
||||
'settings-tab-launch-defaults',
|
||||
onboardingMessages.defaultsTitle,
|
||||
onboardingMessages.defaultsDescription,
|
||||
],
|
||||
[
|
||||
'settings-content-downloads',
|
||||
'settings-tab-content-downloads',
|
||||
onboardingMessages.resourcesTitle,
|
||||
onboardingMessages.resourcesDescription,
|
||||
],
|
||||
]
|
||||
|
||||
export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
main: [
|
||||
step(
|
||||
'welcome',
|
||||
'manual',
|
||||
copy(
|
||||
onboardingMessages.welcomeTitle,
|
||||
onboardingMessages.welcomeDescription,
|
||||
onboardingMessages.start,
|
||||
),
|
||||
),
|
||||
inspect(
|
||||
'home-widget-grid',
|
||||
'home-widget-grid',
|
||||
onboardingMessages.homeWidgetsTitle,
|
||||
onboardingMessages.homeWidgetsDescription,
|
||||
),
|
||||
inspect(
|
||||
'home-widget-customize',
|
||||
'home-widget-customize',
|
||||
onboardingMessages.homeCustomizeTitle,
|
||||
onboardingMessages.homeCustomizeDescription,
|
||||
),
|
||||
inspect(
|
||||
'home-layout-switch',
|
||||
'home-layout-switch',
|
||||
onboardingMessages.homeLayoutTitle,
|
||||
onboardingMessages.homeLayoutDescription,
|
||||
),
|
||||
step(
|
||||
'discover-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.discoverTitle,
|
||||
onboardingMessages.discoverDescription,
|
||||
onboardingMessages.clickDiscover,
|
||||
),
|
||||
control('nav-discover', '/browse/modpack'),
|
||||
),
|
||||
inspect(
|
||||
'discover-content',
|
||||
'browse-content',
|
||||
onboardingMessages.browseTitle,
|
||||
onboardingMessages.browseDescription,
|
||||
),
|
||||
step(
|
||||
'discover-favorites-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.favoritesTitle,
|
||||
onboardingMessages.favoritesDescription,
|
||||
onboardingMessages.clickFavorites,
|
||||
),
|
||||
control('browse-favorites-tab', '/browse/favorites'),
|
||||
),
|
||||
inspect(
|
||||
'discover-favorites-content',
|
||||
'browse-favorites-content',
|
||||
onboardingMessages.favoritesTitle,
|
||||
onboardingMessages.favoritesDescription,
|
||||
),
|
||||
step(
|
||||
'skins-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.skinsTitle,
|
||||
onboardingMessages.skinsDescription,
|
||||
onboardingMessages.clickSkins,
|
||||
),
|
||||
control('nav-skins', '/skins'),
|
||||
),
|
||||
inspect(
|
||||
'skins-page',
|
||||
'skins-page',
|
||||
onboardingMessages.skinsPageTitle,
|
||||
onboardingMessages.skinsPageDescription,
|
||||
),
|
||||
step(
|
||||
'account',
|
||||
'inspect',
|
||||
copy(
|
||||
onboardingMessages.accountTitle,
|
||||
onboardingMessages.accountDescription,
|
||||
onboardingMessages.continueArea,
|
||||
),
|
||||
control('account-entry'),
|
||||
),
|
||||
step(
|
||||
'lab-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labTitle,
|
||||
onboardingMessages.labDescription,
|
||||
onboardingMessages.clickLab,
|
||||
),
|
||||
control('nav-lab', '/lab'),
|
||||
),
|
||||
inspect(
|
||||
'lab-tools',
|
||||
'lab-tools',
|
||||
onboardingMessages.labToolsTitle,
|
||||
onboardingMessages.labToolsDescription,
|
||||
),
|
||||
step(
|
||||
'lab-gradient-text-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labEditorTitle,
|
||||
onboardingMessages.labEditorDescription,
|
||||
onboardingMessages.openGradientText,
|
||||
),
|
||||
control('lab-gradient-text-card', '/lab/gradient-text'),
|
||||
),
|
||||
inspect(
|
||||
'lab-gradient-text-editor',
|
||||
'lab-gradient-text-editor',
|
||||
onboardingMessages.labEditorTitle,
|
||||
onboardingMessages.labEditorDescription,
|
||||
),
|
||||
step(
|
||||
'lab-return-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSeedMapTitle,
|
||||
onboardingMessages.labSeedMapDescription,
|
||||
onboardingMessages.returnToLab,
|
||||
),
|
||||
control('nav-lab', '/lab'),
|
||||
),
|
||||
step(
|
||||
'lab-seed-map-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSeedMapTitle,
|
||||
onboardingMessages.labSeedMapDescription,
|
||||
onboardingMessages.openSeedMap,
|
||||
),
|
||||
control('lab-seed-map-card', '/lab/seed-map'),
|
||||
),
|
||||
inspect(
|
||||
'lab-seed-map-workspace',
|
||||
'seed-map-workspace',
|
||||
onboardingMessages.labSeedMapTitle,
|
||||
onboardingMessages.labSeedMapDescription,
|
||||
),
|
||||
step(
|
||||
'lab-return-schematic-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSchematicTitle,
|
||||
onboardingMessages.labSchematicDescription,
|
||||
onboardingMessages.returnToLab,
|
||||
),
|
||||
control('nav-lab', '/lab'),
|
||||
),
|
||||
step(
|
||||
'lab-schematic-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSchematicTitle,
|
||||
onboardingMessages.labSchematicDescription,
|
||||
onboardingMessages.openSchematicWorkshop,
|
||||
),
|
||||
control('lab-schematic-preview-card', '/lab/schematic-preview'),
|
||||
),
|
||||
inspect(
|
||||
'lab-schematic-workspace',
|
||||
'schematic-preview-workspace',
|
||||
onboardingMessages.labSchematicTitle,
|
||||
onboardingMessages.labSchematicDescription,
|
||||
),
|
||||
step(
|
||||
'lab-recipe-generator-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labRecipeGeneratorTitle,
|
||||
onboardingMessages.labRecipeGeneratorDescription,
|
||||
onboardingMessages.openRecipeGenerator,
|
||||
),
|
||||
control('lab-recipe-generator-card', '/lab/recipe-generator'),
|
||||
),
|
||||
inspect(
|
||||
'lab-recipe-generator-workspace',
|
||||
'recipe-generator-workspace',
|
||||
onboardingMessages.labRecipeGeneratorTitle,
|
||||
onboardingMessages.labRecipeGeneratorDescription,
|
||||
),
|
||||
step(
|
||||
'downloads-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.downloadsTitle,
|
||||
onboardingMessages.downloadsDescription,
|
||||
onboardingMessages.clickDownloads,
|
||||
),
|
||||
control('nav-downloads', '/downloads'),
|
||||
),
|
||||
inspect(
|
||||
'downloads-tabs',
|
||||
'downloads-tabs',
|
||||
onboardingMessages.downloadsPageTitle,
|
||||
onboardingMessages.downloadsPageDescription,
|
||||
),
|
||||
step(
|
||||
'settings-navigation',
|
||||
'activate',
|
||||
copy(
|
||||
onboardingMessages.settingsTitle,
|
||||
onboardingMessages.settingsDescription,
|
||||
onboardingMessages.clickSettings,
|
||||
),
|
||||
control('nav-settings', '/settings'),
|
||||
),
|
||||
...settingsTourSteps.map(([id, targetId, title, description], index) =>
|
||||
step(id, 'activate', copy(title, description, onboardingMessages.clickTab), {
|
||||
...control(targetId),
|
||||
closeSettingsAfter: index === settingsTourSteps.length - 1,
|
||||
}),
|
||||
),
|
||||
step(
|
||||
'library-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.libraryTitle,
|
||||
onboardingMessages.libraryDescription,
|
||||
onboardingMessages.clickLibrary,
|
||||
),
|
||||
control('nav-library', '/library'),
|
||||
),
|
||||
inspect(
|
||||
'library-content',
|
||||
'library-content',
|
||||
onboardingMessages.libraryPageTitle,
|
||||
onboardingMessages.libraryPageDescription,
|
||||
),
|
||||
step(
|
||||
'create-instance',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.createTitle,
|
||||
onboardingMessages.createDescription,
|
||||
onboardingMessages.clickCreate,
|
||||
),
|
||||
control('create-instance', '/create'),
|
||||
),
|
||||
step(
|
||||
'creation-flow',
|
||||
'activate',
|
||||
copy(
|
||||
onboardingMessages.creationTitle,
|
||||
onboardingMessages.creationDescription,
|
||||
onboardingMessages.clickCreationMethod,
|
||||
),
|
||||
{
|
||||
targetId: 'creation-methods',
|
||||
branchByTarget: {
|
||||
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
|
||||
'creation-method-import': { next: 'complete' },
|
||||
},
|
||||
},
|
||||
),
|
||||
inspect(
|
||||
'creation-name',
|
||||
'creation-name',
|
||||
onboardingMessages.creationNameTitle,
|
||||
onboardingMessages.creationNameDescription,
|
||||
),
|
||||
inspect(
|
||||
'creation-loader',
|
||||
'creation-loader',
|
||||
onboardingMessages.creationLoaderTitle,
|
||||
onboardingMessages.creationLoaderDescription,
|
||||
),
|
||||
step(
|
||||
'creation-version',
|
||||
'inspect',
|
||||
copy(
|
||||
onboardingMessages.creationVersionTitle,
|
||||
onboardingMessages.creationVersionDescription,
|
||||
onboardingMessages.continueArea,
|
||||
),
|
||||
{
|
||||
targetId: 'creation-game-version',
|
||||
nextByCreationPath: { custom: 'creation-confirm' },
|
||||
},
|
||||
),
|
||||
step(
|
||||
'creation-confirm',
|
||||
'inspect',
|
||||
copy(
|
||||
onboardingMessages.creationConfirmTitle,
|
||||
onboardingMessages.creationConfirmDescription,
|
||||
onboardingMessages.finishArea,
|
||||
),
|
||||
{ targetId: 'creation-confirm' },
|
||||
),
|
||||
],
|
||||
instance: [
|
||||
inspect(
|
||||
'instance-actions',
|
||||
'instance-actions',
|
||||
onboardingMessages.instanceActionsTitle,
|
||||
onboardingMessages.instanceActionsDescription,
|
||||
),
|
||||
step(
|
||||
'instance-tabs',
|
||||
'inspect',
|
||||
copy(
|
||||
onboardingMessages.instanceTabsTitle,
|
||||
onboardingMessages.instanceTabsDescription,
|
||||
onboardingMessages.finishArea,
|
||||
),
|
||||
{ targetId: 'instance-tabs' },
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export function onboardingTargetSelector(targetId: string) {
|
||||
return `[data-onboarding-id="${targetId}"]`
|
||||
}
|
||||
@ -0,0 +1,372 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, type Ref, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import {
|
||||
type CreationPath,
|
||||
type OnboardingMode,
|
||||
onboardingTargetSelector,
|
||||
onboardingTours,
|
||||
type StepDestination,
|
||||
} from './onboardingConfig'
|
||||
|
||||
type TourEvents = {
|
||||
complete: () => void
|
||||
skip: () => void
|
||||
closeSettings: () => void
|
||||
}
|
||||
|
||||
const controlSpotlightPadding = 3
|
||||
const missingTargetRetryLimit = 8
|
||||
const missingTargetRetryDelay = 250
|
||||
|
||||
export function useOnboardingTour(
|
||||
visible: Ref<boolean>,
|
||||
mode: Ref<OnboardingMode>,
|
||||
events: TourEvents,
|
||||
) {
|
||||
const route = useRoute()
|
||||
const stepIndex = ref(0)
|
||||
const targetRect = ref<DOMRect | null>(null)
|
||||
const bubbleElement = ref<HTMLElement>()
|
||||
const bubbleSize = ref({ width: 512, height: 160 })
|
||||
const waitingForRoute = ref(false)
|
||||
const creationPath = ref<CreationPath>()
|
||||
const targetElement = ref<HTMLElement>()
|
||||
let targetObserver: ResizeObserver | undefined
|
||||
let bubbleObserver: ResizeObserver | undefined
|
||||
let targetRetryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let advanceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let unlockTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let targetRetryCount = 0
|
||||
let transitionLocked = false
|
||||
|
||||
const steps = computed(() => onboardingTours[mode.value])
|
||||
const step = computed(() => steps.value[stepIndex.value])
|
||||
const isWelcomeStep = computed(() => step.value.id === 'welcome')
|
||||
const isDialogueStep = computed(
|
||||
() => !!step.value.targetId && (step.value.spotlight !== 'control' || !targetRect.value),
|
||||
)
|
||||
const controlSpotlightStyle = computed(() => {
|
||||
if (!targetRect.value) return {}
|
||||
const rect = targetRect.value
|
||||
return {
|
||||
left: `${Math.max(0, rect.left - controlSpotlightPadding)}px`,
|
||||
top: `${Math.max(0, rect.top - controlSpotlightPadding)}px`,
|
||||
width: `${rect.width + controlSpotlightPadding * 2}px`,
|
||||
height: `${rect.height + controlSpotlightPadding * 2}px`,
|
||||
}
|
||||
})
|
||||
const bubblePlacement = computed(() => {
|
||||
if (!targetRect.value || isDialogueStep.value) return { direction: 'center', style: {} }
|
||||
|
||||
const rect = targetRect.value
|
||||
const safeInset = 16
|
||||
const safeTop = 48
|
||||
const bubbleWidth = Math.min(bubbleSize.value.width, window.innerWidth - safeInset * 2)
|
||||
const bubbleHeight = Math.min(bubbleSize.value.height, window.innerHeight - safeTop - safeInset)
|
||||
const gap = 20
|
||||
const positions = [
|
||||
{
|
||||
direction: 'right',
|
||||
left: rect.right + gap,
|
||||
top: rect.top + rect.height / 2 - bubbleHeight / 2,
|
||||
},
|
||||
{
|
||||
direction: 'bottom',
|
||||
left: rect.left + rect.width / 2 - bubbleWidth / 2,
|
||||
top: rect.bottom + gap,
|
||||
},
|
||||
{
|
||||
direction: 'left',
|
||||
left: rect.left - bubbleWidth - gap,
|
||||
top: rect.top + rect.height / 2 - bubbleHeight / 2,
|
||||
},
|
||||
{
|
||||
direction: 'top',
|
||||
left: rect.left + rect.width / 2 - bubbleWidth / 2,
|
||||
top: rect.top - bubbleHeight - gap,
|
||||
},
|
||||
]
|
||||
const position =
|
||||
positions.find(
|
||||
(candidate) =>
|
||||
candidate.left >= safeInset &&
|
||||
candidate.top >= safeTop &&
|
||||
candidate.left + bubbleWidth <= window.innerWidth - safeInset &&
|
||||
candidate.top + bubbleHeight <= window.innerHeight - safeInset,
|
||||
) ?? positions[0]
|
||||
|
||||
return {
|
||||
direction: position.direction,
|
||||
style: {
|
||||
left: `${Math.min(
|
||||
Math.max(safeInset, position.left),
|
||||
window.innerWidth - bubbleWidth - safeInset,
|
||||
)}px`,
|
||||
top: `${Math.min(
|
||||
Math.max(safeTop, position.top),
|
||||
window.innerHeight - bubbleHeight - safeInset,
|
||||
)}px`,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function clearTargetTracking() {
|
||||
targetObserver?.disconnect()
|
||||
targetObserver = undefined
|
||||
if (targetRetryTimer) clearTimeout(targetRetryTimer)
|
||||
targetRetryTimer = undefined
|
||||
}
|
||||
|
||||
function clearModalReservation() {
|
||||
document.body.classList.remove('onboarding-reserve-dialogue-space')
|
||||
document.body.style.removeProperty('--onboarding-dialogue-reserved-space')
|
||||
}
|
||||
|
||||
function updateModalReservation() {
|
||||
const targetIsInModal = !!targetElement.value?.closest('[role="dialog"]')
|
||||
if (!visible.value || !isDialogueStep.value || !targetIsInModal) {
|
||||
clearModalReservation()
|
||||
return
|
||||
}
|
||||
|
||||
document.body.classList.add('onboarding-reserve-dialogue-space')
|
||||
document.body.style.setProperty(
|
||||
'--onboarding-dialogue-reserved-space',
|
||||
`${Math.ceil(bubbleSize.value.height)}px`,
|
||||
)
|
||||
}
|
||||
|
||||
function updateBubbleSize() {
|
||||
if (!bubbleElement.value) return
|
||||
const { width, height } = bubbleElement.value.getBoundingClientRect()
|
||||
bubbleSize.value = { width, height }
|
||||
updateModalReservation()
|
||||
}
|
||||
|
||||
function scheduleMissingTargetRetry(stepId: string) {
|
||||
if (targetRetryCount >= missingTargetRetryLimit) {
|
||||
void advance()
|
||||
return
|
||||
}
|
||||
|
||||
targetRetryCount++
|
||||
targetRetryTimer = setTimeout(() => {
|
||||
if (visible.value && step.value.id === stepId && !targetRect.value) updateTarget()
|
||||
}, missingTargetRetryDelay)
|
||||
}
|
||||
|
||||
function updateTarget() {
|
||||
clearTargetTracking()
|
||||
if (!visible.value || !step.value.targetId) {
|
||||
targetElement.value = undefined
|
||||
targetRect.value = null
|
||||
clearModalReservation()
|
||||
return
|
||||
}
|
||||
|
||||
const target = document.querySelector<HTMLElement>(
|
||||
onboardingTargetSelector(step.value.targetId),
|
||||
)
|
||||
const rect = target?.getBoundingClientRect()
|
||||
if (!target || !rect || rect.width < 1 || rect.height < 1) {
|
||||
targetElement.value = undefined
|
||||
targetRect.value = null
|
||||
clearModalReservation()
|
||||
scheduleMissingTargetRetry(step.value.id)
|
||||
return
|
||||
}
|
||||
|
||||
targetRetryCount = 0
|
||||
targetElement.value = target
|
||||
const updateRect = () => {
|
||||
targetRect.value = target.getBoundingClientRect()
|
||||
}
|
||||
updateRect()
|
||||
targetObserver = new ResizeObserver(updateRect)
|
||||
targetObserver.observe(target)
|
||||
updateModalReservation()
|
||||
requestAnimationFrame(updateRect)
|
||||
}
|
||||
|
||||
function goTo(destination: StepDestination) {
|
||||
if (destination === 'complete') {
|
||||
events.complete()
|
||||
return
|
||||
}
|
||||
|
||||
const destinationIndex = steps.value.findIndex((candidate) => candidate.id === destination)
|
||||
if (destinationIndex === -1) {
|
||||
events.complete()
|
||||
return
|
||||
}
|
||||
|
||||
stepIndex.value = destinationIndex
|
||||
}
|
||||
|
||||
async function advance() {
|
||||
if (transitionLocked) return
|
||||
transitionLocked = true
|
||||
|
||||
const destination = creationPath.value
|
||||
? step.value.nextByCreationPath?.[creationPath.value]
|
||||
: step.value.next
|
||||
if (destination) {
|
||||
goTo(destination)
|
||||
scheduleUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
if (stepIndex.value === steps.value.length - 1) {
|
||||
events.complete()
|
||||
scheduleUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
if (step.value.closeSettingsAfter) events.closeSettings()
|
||||
stepIndex.value++
|
||||
await nextTick()
|
||||
updateTarget()
|
||||
scheduleUnlock()
|
||||
}
|
||||
|
||||
function scheduleUnlock() {
|
||||
if (unlockTimer) clearTimeout(unlockTimer)
|
||||
unlockTimer = setTimeout(() => {
|
||||
transitionLocked = false
|
||||
unlockTimer = undefined
|
||||
}, 180)
|
||||
}
|
||||
|
||||
function scheduleDestination(destination?: StepDestination) {
|
||||
if (advanceTimer || transitionLocked) return
|
||||
if (destination) transitionLocked = true
|
||||
advanceTimer = setTimeout(() => {
|
||||
advanceTimer = undefined
|
||||
if (destination) {
|
||||
goTo(destination)
|
||||
scheduleUnlock()
|
||||
} else {
|
||||
void advance()
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
|
||||
function handleManualClick() {
|
||||
if (step.value.interaction === 'manual') void advance()
|
||||
}
|
||||
|
||||
function handleBranchClick(target: Element) {
|
||||
if (!step.value.branchByTarget) return false
|
||||
|
||||
for (const [targetId, branch] of Object.entries(step.value.branchByTarget)) {
|
||||
if (!target.closest(onboardingTargetSelector(targetId))) continue
|
||||
creationPath.value = branch.creationPath
|
||||
scheduleDestination(branch.next)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function handleDocumentClick(event: MouseEvent) {
|
||||
if (!visible.value) return
|
||||
const clickedElement = event.target instanceof Element ? event.target : null
|
||||
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
|
||||
|
||||
if (step.value.interaction === 'inspect') {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
void advance()
|
||||
return
|
||||
}
|
||||
|
||||
if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return
|
||||
if (clickedElement && handleBranchClick(clickedElement)) return
|
||||
if (!targetElement.value?.contains(event.target as Node)) return
|
||||
|
||||
if (step.value.expectedPath && route.path !== step.value.expectedPath) {
|
||||
waitingForRoute.value = true
|
||||
return
|
||||
}
|
||||
scheduleDestination()
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!visible.value || event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
events.skip()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
if (!waitingForRoute.value || !step.value.expectedPath) return
|
||||
if (path !== step.value.expectedPath) return
|
||||
waitingForRoute.value = false
|
||||
void advance()
|
||||
},
|
||||
)
|
||||
|
||||
watch(visible, async (isVisible) => {
|
||||
if (isVisible) {
|
||||
stepIndex.value = 0
|
||||
waitingForRoute.value = false
|
||||
creationPath.value = undefined
|
||||
targetRetryCount = 0
|
||||
await nextTick()
|
||||
updateBubbleSize()
|
||||
if (bubbleElement.value) bubbleObserver?.observe(bubbleElement.value)
|
||||
} else {
|
||||
bubbleObserver?.disconnect()
|
||||
clearModalReservation()
|
||||
}
|
||||
updateTarget()
|
||||
})
|
||||
|
||||
watch(step, async () => {
|
||||
if (!visible.value) return
|
||||
targetRetryCount = 0
|
||||
await nextTick()
|
||||
updateBubbleSize()
|
||||
updateTarget()
|
||||
})
|
||||
|
||||
watch(mode, () => {
|
||||
stepIndex.value = 0
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleDocumentClick, true)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('resize', updateTarget)
|
||||
window.addEventListener('scroll', updateTarget, true)
|
||||
bubbleObserver = new ResizeObserver(updateBubbleSize)
|
||||
if (bubbleElement.value) bubbleObserver.observe(bubbleElement.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTargetTracking()
|
||||
clearModalReservation()
|
||||
bubbleObserver?.disconnect()
|
||||
if (advanceTimer) clearTimeout(advanceTimer)
|
||||
if (unlockTimer) clearTimeout(unlockTimer)
|
||||
document.removeEventListener('click', handleDocumentClick, true)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('resize', updateTarget)
|
||||
window.removeEventListener('scroll', updateTarget, true)
|
||||
})
|
||||
|
||||
return {
|
||||
advance,
|
||||
bubbleElement,
|
||||
bubblePlacement,
|
||||
controlSpotlightStyle,
|
||||
handleManualClick,
|
||||
isDialogueStep,
|
||||
isWelcomeStep,
|
||||
step,
|
||||
stepIndex,
|
||||
steps,
|
||||
targetRect,
|
||||
}
|
||||
}
|
||||
452
apps/app-frontend/src/components/ui/settings/AIIcon.vue
Normal file
452
apps/app-frontend/src/components/ui/settings/AIIcon.vue
Normal 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>
|
||||
1694
apps/app-frontend/src/components/ui/settings/AISettings.vue
Normal file
1694
apps/app-frontend/src/components/ui/settings/AISettings.vue
Normal file
File diff suppressed because it is too large
Load Diff
561
apps/app-frontend/src/components/ui/settings/AboutSettings.vue
Normal file
561
apps/app-frontend/src/components/ui/settings/AboutSettings.vue
Normal 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>
|
||||
1221
apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue
Normal file
1221
apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppearanceSettings from './AppearanceSettings.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppearanceSettings scope="home-navigation" />
|
||||
</template>
|
||||
@ -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>
|
||||
491
apps/app-frontend/src/components/ui/settings/JavaSettings.vue
Normal file
491
apps/app-frontend/src/components/ui/settings/JavaSettings.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
156
apps/app-frontend/src/components/ui/settings/PrivacySettings.vue
Normal file
156
apps/app-frontend/src/components/ui/settings/PrivacySettings.vue
Normal 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>
|
||||
@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user