feat(instance-sync): screenshots sync (#7218)

* pnpm prepr

* feat(instance-sync): screenshots sync

* fix: prepr + fmt

* fix: qa

* feat: screenshit editor

* feat: screenshot* editor qa

* fix: qa

* fix: lint

* fix: aecsocket rev

* qa: a11y tab navigation focus is cut off

* qa: Change “Edited” badge to be bg-highlight-green

* qa: friends list input style wrong

* qa: toolbar width + labels

* qa: multiselect changes

* qa: anim changes

* qa: card hover colors

* qa: copy feedback

* qa: hide date

* qa: instance icon in overflow/contextmenu

* qa: spacing

* qa: group empty state text

* qa: drag preview badge

* qa: group deletion skip warning if no screenshots

* qa: redesign editor

* qa: remove screenshot parent/edited badge stuff

* qa: control font size consistency

* qa: viewer copy control

* qa: editor fixes

* qa: redirect on disable screenshot sync

* qa: margin police

* fix: crop

* fix: qa

* feat: initial start on basic instance file syncing (#7220)

* qa: final

* qa: final 2

* fix: lint + prepr

* fix: copy

* fix: screenshot editing outside of app causing ghost files in db + sync fail rollback impl

* chore: split up

* fix: fmt

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
Calum H.
2026-08-27 16:49:46 +00:00
committed by GitHub
co-authored by tdgao
parent 2bd108c278
commit 7c67cca7a7
217 changed files with 17139 additions and 1887 deletions
+1 -1
View File
@@ -1330,7 +1330,7 @@ provideBrowseManager({
</script>
<template>
<div class="flex flex-col gap-3 p-6">
<div class="flex flex-col gap-2 p-6">
<BrowsePageLayout>
<template #after>
<ContextMenu ref="contextMenuRef" :label="formatMessage(messages.projectActionsLabel)">
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { ImagesIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { onActivated } from 'vue'
import ScreenshotsPage from '@/components/ui/screenshots-page/index.vue'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
defineOptions({ name: 'ScreenshotsPage' })
const { formatMessage } = useVIntl()
const messages = defineMessages({
screenshots: { id: 'app.screenshots.heading', defaultMessage: 'Screenshots' },
})
const breadcrumb = useRootBreadcrumb({
slot: 'root',
id: 'screenshots',
label: formatMessage(messages.screenshots),
to: '/screenshots',
visual: { type: 'icon', component: ImagesIcon },
})
onActivated(breadcrumb.reset)
</script>
<template>
<div class="box-border h-full p-6">
<ScreenshotsPage show-heading />
</div>
</template>
+2 -1
View File
@@ -1,7 +1,8 @@
import Browse from './Browse.vue'
import Index from './Index.vue'
import Screenshots from './Screenshots.vue'
import Servers from './Servers.vue'
import Skins from './Skins.vue'
import User from './User.vue'
export { Browse, Index, Servers, Skins, User }
export { Browse, Index, Screenshots, Servers, Skins, User }
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Checkbox, defineMessages, injectNotificationManager, Input, useVIntl } from '@modrinth/ui'
import { defineMessages, injectNotificationManager, Input, Toggle, useVIntl } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { edit } from '@/helpers/instance'
@@ -7,6 +7,7 @@ import { get } from '@/helpers/settings.ts'
import type { AppSettings } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
import SettingsOptionsTransition from './settings-options-transition.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -15,12 +16,12 @@ const { instance } = injectInstanceSettings()
const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideHooks = ref(
const hasCustomHooks =
!!instance.value.hooks.pre_launch ||
!!instance.value.hooks.wrapper ||
!!instance.value.hooks.post_exit,
)
const hooksRaw = instance.value.hooks ?? globalSettings.hooks
!!instance.value.hooks.wrapper ||
!!instance.value.hooks.post_exit
const overrideHooks = ref(hasCustomHooks)
const hooksRaw = hasCustomHooks ? instance.value.hooks : globalSettings.hooks
const hooks = ref({
pre_launch: hooksRaw.pre_launch ?? '',
wrapper: hooksRaw.wrapper ?? '',
@@ -87,10 +88,6 @@ const messages = defineMessages({
id: 'instance.settings.tabs.hooks.variables.inst-java-args.description',
defaultMessage: '$INST_JAVA_ARGS: The JVM Arguments provided to the game',
},
customHooks: {
id: 'instance.settings.tabs.hooks.custom-hooks',
defaultMessage: 'Custom launch hooks',
},
preLaunch: {
id: 'instance.settings.tabs.hooks.pre-launch',
defaultMessage: 'Pre-launch',
@@ -132,69 +129,72 @@ const messages = defineMessages({
<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.preLaunch) }}
</h2>
<Input
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>
<Input
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>
<Input
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 class="m-0 mt-6">
{{ formatMessage(messages.hookVariablesDescription) }}
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.hooks) }}
</h2>
<p class="m-0">{{ formatMessage(messages.hooksDescription) }}</p>
</div>
<Toggle id="override-launch-hooks" v-model="overrideHooks" />
</div>
<ul class="m-0 mt-2">
<li>{{ formatMessage(messages.instanceNameDescription) }}</li>
<li>{{ formatMessage(messages.instanceIdDescription) }}</li>
<li>{{ formatMessage(messages.instanceDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceMcDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaArgsDescription) }}</li>
</ul>
<SettingsOptionsTransition :show="overrideHooks">
<div class="pt-6">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.preLaunch) }}
</h2>
<Input
id="pre-launch"
v-model="hooks.pre_launch"
autocomplete="off"
: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>
<Input
id="wrapper"
v-model="hooks.wrapper"
autocomplete="off"
: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>
<Input
id="post-exit"
v-model="hooks.post_exit"
autocomplete="off"
:placeholder="formatMessage(messages.postExitEnter)"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.postExitDescription) }}
</p>
<div class="m-0 mt-6">
{{ formatMessage(messages.hookVariablesDescription) }}
</div>
<ul class="m-0 mt-2">
<li>{{ formatMessage(messages.instanceNameDescription) }}</li>
<li>{{ formatMessage(messages.instanceIdDescription) }}</li>
<li>{{ formatMessage(messages.instanceDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceMcDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaArgsDescription) }}</li>
</ul>
</div>
</SettingsOptionsTransition>
</div>
</template>
@@ -1,14 +1,6 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
ChevronRightIcon,
CodeIcon,
CoffeeIcon,
InfoIcon,
MonitorIcon,
UsersIcon,
WrenchIcon,
} from '@modrinth/assets'
import { ChevronRightIcon, InfoIcon, Settings2Icon, UsersIcon, WrenchIcon } from '@modrinth/assets'
import {
Avatar,
commonMessages,
@@ -28,12 +20,10 @@ import { get_game_versions, get_loaders } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import GeneralSettings from './general-settings.vue'
import HooksSettings from './hooks-settings.vue'
import InstallationSettings from './installation-settings.vue'
import { provideInstanceSettings } from './instance-settings-context.ts'
import JavaSettings from './java-settings.vue'
import SharingSettings from './sharing-settings.vue'
import WindowSettings from './window-settings.vue'
import SyncedOptionsSettings from './synced-options-settings.vue'
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
@@ -98,6 +88,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: WrenchIcon,
content: InstallationSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.settings-overrides',
defaultMessage: 'Sync overrides',
}),
icon: Settings2Icon,
content: SyncedOptionsSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.sharing',
@@ -107,30 +105,6 @@ const tabs = computed<TabbedModalTab[]>(() => [
content: SharingSettings,
shown: props.instance.shared_instance?.role === 'owner' && !props.instance.quarantined,
},
{
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 hooks',
}),
icon: CodeIcon,
content: HooksSettings,
},
])
function getSupportedModpackLoaders() {
@@ -10,11 +10,11 @@ import {
} from '@modrinth/assets'
import {
Button,
Checkbox,
defineMessages,
injectNotificationManager,
Input,
Slider,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
@@ -28,6 +28,7 @@ import { get, parseEnvVars, serializeEnvVars } from '@/helpers/settings.ts'
import type { AppSettings } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
import SettingsOptionsTransition from './settings-options-transition.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -41,9 +42,7 @@ const optimalJava = readonly(await get_optimal_jre_key(instance.value.id).catch(
const overrideJavaInstall = ref(!!instance.value.java_path)
const javaPath = ref(instance.value.java_path ?? optimalJava?.path ?? '')
const activePath = computed(() =>
overrideJavaInstall.value ? javaPath.value : (optimalJava?.path ?? ''),
)
const activePath = computed(() => (overrideJavaInstall.value ? javaPath.value : ''))
watch(overrideJavaInstall, (enabled) => {
if (enabled && !javaPath.value) {
@@ -140,7 +139,7 @@ const messages = defineMessages({
},
customJavaInstallation: {
id: 'instance.settings.tabs.java.custom-java-installation',
defaultMessage: 'Custom Java installation',
defaultMessage: 'Use a custom Java installation for this instance.',
},
javaPathPlaceholder: {
id: 'instance.settings.tabs.java.java-path-placeholder',
@@ -152,7 +151,7 @@ const messages = defineMessages({
},
customMemoryAllocation: {
id: 'instance.settings.tabs.java.custom-memory-allocation',
defaultMessage: 'Custom memory allocation',
defaultMessage: 'Use a custom memory allocation for this instance.',
},
javaArguments: {
id: 'instance.settings.tabs.java.java-arguments',
@@ -160,7 +159,7 @@ const messages = defineMessages({
},
customJavaArguments: {
id: 'instance.settings.tabs.java.custom-java-arguments',
defaultMessage: 'Custom Java arguments',
defaultMessage: 'Use custom Java arguments for this instance.',
},
enterJavaArguments: {
id: 'instance.settings.tabs.java.enter-java-arguments',
@@ -172,7 +171,7 @@ const messages = defineMessages({
},
customEnvironmentVariables: {
id: 'instance.settings.tabs.java.custom-environment-variables',
defaultMessage: 'Custom environment variables',
defaultMessage: 'Use custom environment variables for this instance.',
},
enterEnvironmentVariables: {
id: 'instance.settings.tabs.java.enter-environment-variables',
@@ -186,144 +185,175 @@ const messages = defineMessages({
</script>
<template>
<div>
<div class="flex flex-col gap-6">
<JavaDetectionModal ref="javaDetectionModal" @submit="(val) => (javaPath = val.path)" />
<h2 class="m-0 mb-2 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaInstallation) }}
</h2>
<Checkbox
v-model="overrideJavaInstall"
:label="formatMessage(messages.customJavaInstallation)"
class="mb-2"
/>
<div class="flex gap-4 p-4 bg-bg rounded-2xl">
<div class="flex gap-3 items-start flex-1 min-w-0">
<div
class="w-10 h-10 flex items-center justify-center rounded-full bg-button-bg border-solid border-[1px] border-button-border p-2 mt-1 shrink-0 [&_svg]:h-full [&_svg]:w-full"
>
<CoffeeIcon />
<section class="flex flex-col">
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.javaInstallation) }}
</h2>
<p class="m-0">{{ formatMessage(messages.customJavaInstallation) }}</p>
</div>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<span class="font-semibold leading-none mt-2"
>Java {{ optimalJava?.parsed_version }}</span
>
<div class="flex gap-2 items-center">
<Input
:model-value="activePath"
:disabled="!overrideJavaInstall"
autocomplete="off"
:placeholder="formatMessage(messages.javaPathPlaceholder)"
wrapper-class="flex-1 min-w-0"
@update:model-value="(val) => (javaPath = String(val))"
/>
<Button
type="quiet"
:color="
!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: undefined
"
:disabled="!overrideJavaInstall || testingJava"
:style="{
'--legacy-button-color':
(!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard') &&
(!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard') !== 'standard'
? `var(--color-${
!hoveringTest && !testingJava
<Toggle id="override-java-installation" v-model="overrideJavaInstall" />
</div>
<SettingsOptionsTransition :show="overrideJavaInstall">
<div class="pt-3">
<div class="flex gap-4 rounded-2xl bg-bg p-4">
<div class="flex gap-3 items-start flex-1 min-w-0">
<div
class="w-10 h-10 flex items-center justify-center rounded-full bg-button-bg border-solid border-[1px] border-button-border p-2 mt-1 shrink-0 [&_svg]:h-full [&_svg]:w-full"
>
<CoffeeIcon />
</div>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<span class="font-semibold leading-none mt-2"
>Java {{ optimalJava?.parsed_version }}</span
>
<div class="flex gap-2 items-center">
<Input
:model-value="activePath"
autocomplete="off"
:placeholder="formatMessage(messages.javaPathPlaceholder)"
wrapper-class="flex-1 min-w-0"
@update:model-value="(val) => (javaPath = String(val))"
/>
<Button
type="quiet"
:color="
!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: undefined
"
:disabled="testingJava"
:style="{
'--legacy-button-color':
(!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard'
})`
: undefined,
}"
class="!text-[var(--legacy-button-color,var(--color-base))] [&>svg]:!text-[var(--legacy-button-color,var(--color-primary))]"
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
@mouseenter="overrideJavaInstall && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="javaTestResult === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="javaTestResult !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="overrideJavaInstall" class="h-4 w-4" />
</Button>
</div>
<div v-if="overrideJavaInstall" class="flex gap-2">
<Button @click="handleDetectJava">
<SearchIcon />
Detect
</Button>
<Button @click="handleBrowseJava">
<FolderSearchIcon />
Browse
</Button>
: 'standard') &&
(!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard') !== 'standard'
? `var(--color-${
!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard'
})`
: undefined,
}"
class="!text-[var(--legacy-button-color,var(--color-base))] [&>svg]:!text-[var(--legacy-button-color,var(--color-primary))]"
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
@mouseenter="hoveringTest = true"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="javaTestResult === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon
v-else-if="javaTestResult !== true && !hoveringTest"
class="h-4 w-4"
/>
<RefreshCwIcon v-else class="h-4 w-4" />
</Button>
</div>
<div class="flex gap-2">
<Button @click="handleDetectJava">
<SearchIcon />
Detect
</Button>
<Button @click="handleBrowseJava">
<FolderSearchIcon />
Browse
</Button>
</div>
</div>
</div>
</div>
</div>
</SettingsOptionsTransition>
</section>
<section class="flex flex-col">
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.javaMemory) }}
</h2>
<p class="m-0">{{ formatMessage(messages.customMemoryAllocation) }}</p>
</div>
<Toggle id="override-memory-allocation" v-model="overrideMemorySettings" />
</div>
</div>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaMemory) }}
</h2>
<Checkbox
v-model="overrideMemorySettings"
:label="formatMessage(messages.customMemoryAllocation)"
class="mb-2"
/>
<Slider
id="max-memory"
v-model="memory.maximum"
:disabled="!overrideMemorySettings"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaArguments) }}
</h2>
<Checkbox
v-model="overrideJavaArgs"
:label="formatMessage(messages.customJavaArguments)"
class="my-2"
/>
<Input
id="java-args"
v-model="javaArgs"
autocomplete="off"
:disabled="!overrideJavaArgs"
:placeholder="formatMessage(messages.enterJavaArguments)"
wrapper-class="w-full"
/>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaEnvironmentVariables) }}
</h2>
<Checkbox
v-model="overrideEnvVars"
:label="formatMessage(messages.customEnvironmentVariables)"
class="mb-2"
/>
<Input
id="env-vars"
v-model="envVars"
autocomplete="off"
:disabled="!overrideEnvVars"
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
wrapper-class="w-full"
/>
<SettingsOptionsTransition :show="overrideMemorySettings">
<div class="pt-3">
<Slider
id="max-memory"
v-model="memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
</div>
</SettingsOptionsTransition>
</section>
<section class="flex flex-col">
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.javaArguments) }}
</h2>
<p class="m-0">{{ formatMessage(messages.customJavaArguments) }}</p>
</div>
<Toggle id="override-java-arguments" v-model="overrideJavaArgs" />
</div>
<SettingsOptionsTransition :show="overrideJavaArgs">
<div class="pt-3">
<Input
id="java-args"
v-model="javaArgs"
autocomplete="off"
:placeholder="formatMessage(messages.enterJavaArguments)"
wrapper-class="w-full"
/>
</div>
</SettingsOptionsTransition>
</section>
<section class="flex flex-col">
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.javaEnvironmentVariables) }}
</h2>
<p class="m-0">{{ formatMessage(messages.customEnvironmentVariables) }}</p>
</div>
<Toggle id="override-environment-variables" v-model="overrideEnvVars" />
</div>
<SettingsOptionsTransition :show="overrideEnvVars">
<div class="pt-3">
<Input
id="env-vars"
v-model="envVars"
autocomplete="off"
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
wrapper-class="w-full"
/>
</div>
</SettingsOptionsTransition>
</section>
</div>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
defineProps<{
show: boolean
}>()
</script>
<template>
<Transition name="settings-options">
<div v-if="show" class="settings-options" :inert="!show">
<div>
<slot />
</div>
</div>
</Transition>
</template>
<style scoped>
.settings-options {
display: grid;
grid-template-rows: 1fr;
}
.settings-options-enter-active,
.settings-options-leave-active {
transition:
grid-template-rows 0.25s var(--ease-out-expo),
opacity 0.2s ease-out;
}
.settings-options-enter-from,
.settings-options-leave-to {
grid-template-rows: 0fr;
opacity: 0;
}
.settings-options > div {
min-height: 0;
}
.settings-options-enter-active > div,
.settings-options-leave-active > div {
overflow: hidden;
}
@media (prefers-reduced-motion) {
.settings-options-enter-active,
.settings-options-leave-active {
transition: none;
}
}
</style>
@@ -0,0 +1,376 @@
<script setup lang="ts">
import {
EditIcon,
RefreshCwIcon,
RotateCounterClockwiseIcon,
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, inject, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
get_synced_option_join_preview,
get_synced_options_overview,
set_synced_option,
type SyncedOption,
type SyncedOptionJoinResolution,
} from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { appSettingsModalOpenSyncedOptionsKey } from '@/providers/app-settings-modal'
import { instanceKeys, screenshotKeys } from '../../query-options'
import HooksSettings from './hooks-settings.vue'
import { injectInstanceSettings } from './instance-settings-context'
import JavaSettings from './java-settings.vue'
import WindowSettings from './window-settings.vue'
const { instance, closeModal } = injectInstanceSettings()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
const openAppSettingsSyncedOptions = inject(appSettingsModalOpenSyncedOptionsKey, () => {})
const messages = defineMessages({
sharedSettingsDescription: {
id: 'instance.settings.tabs.synced-options.shared-settings.description',
defaultMessage:
'Game settings can be shared between instances. Choose what to share in app settings.',
},
openSyncedOptions: {
id: 'instance.settings.tabs.synced-options.open-app-settings',
defaultMessage: 'Open synced settings',
},
multiplayerServers: {
id: 'instance.settings.tabs.synced-options.multiplayer-servers',
defaultMessage: 'Multiplayer servers',
},
multiplayerServersDescription: {
id: 'instance.settings.tabs.synced-options.multiplayer-servers.exclude-description',
defaultMessage: 'Exclude this instance from multiplayer server syncing.',
},
multiplayerServersDisabled: {
id: 'instance.settings.tabs.synced-options.multiplayer-servers.disabled-in-app',
defaultMessage: 'Multiplayer server syncing is turned off in app settings.',
},
commandHistory: {
id: 'instance.settings.tabs.synced-options.command-history',
defaultMessage: 'Command history',
},
commandHistoryDescription: {
id: 'instance.settings.tabs.synced-options.command-history.exclude-description',
defaultMessage: 'Exclude this instance from command history syncing.',
},
commandHistoryDisabled: {
id: 'instance.settings.tabs.synced-options.command-history.disabled-in-app',
defaultMessage: 'Command history syncing is turned off in app settings.',
},
creativeHotbars: {
id: 'instance.settings.tabs.synced-options.creative-hotbars',
defaultMessage: 'Saved creative hotbars',
},
creativeHotbarsDescription: {
id: 'instance.settings.tabs.synced-options.creative-hotbars.exclude-description',
defaultMessage: 'Exclude this instance from saved creative hotbar syncing.',
},
creativeHotbarsDisabled: {
id: 'instance.settings.tabs.synced-options.creative-hotbars.disabled-in-app',
defaultMessage: 'Saved creative hotbar syncing is turned off in app settings.',
},
screenshots: {
id: 'instance.settings.tabs.synced-options.screenshots',
defaultMessage: 'Screenshots',
},
screenshotsDescription: {
id: 'instance.settings.tabs.synced-options.screenshots.exclude-description',
defaultMessage: 'Exclude this instances screenshots from the Screenshots page.',
},
screenshotsDisabled: {
id: 'instance.settings.tabs.synced-options.screenshots.disabled-in-app',
defaultMessage: 'Screenshots are turned off in app settings.',
},
hotbarConflictTitle: {
id: 'instance.settings.tabs.synced-options.hotbars-conflict.title',
defaultMessage: 'Choose creative hotbars',
},
hotbarConflictDescription: {
id: 'instance.settings.tabs.synced-options.hotbars-conflict.description',
defaultMessage:
'{instance} and your synced version have different creative hotbars. Choose which one to use across your instances.',
},
hotbarBackupDescription: {
id: 'instance.settings.tabs.synced-options.hotbars-conflict.backup-description',
defaultMessage: 'The version being replaced will be backed up before anything changes.',
},
useSyncedHotbars: {
id: 'instance.settings.tabs.synced-options.hotbars-conflict.use-synced',
defaultMessage: 'Use synced',
},
useInstanceHotbars: {
id: 'instance.settings.tabs.synced-options.hotbars-conflict.use-instance',
defaultMessage: 'Overwrite others',
},
})
const globalDisabledMessages: Record<SyncedOption, keyof typeof messages> = {
multiplayer_servers: 'multiplayerServersDisabled',
command_history: 'commandHistoryDisabled',
creative_hotbars: 'creativeHotbarsDisabled',
screenshots: 'screenshotsDisabled',
}
const rows: Array<{
option: SyncedOption
title: keyof typeof messages
description?: keyof typeof messages
}> = [
{
option: 'multiplayer_servers',
title: 'multiplayerServers',
description: 'multiplayerServersDescription',
},
{
option: 'command_history',
title: 'commandHistory',
description: 'commandHistoryDescription',
},
{
option: 'creative_hotbars',
title: 'creativeHotbars',
description: 'creativeHotbarsDescription',
},
{
option: 'screenshots',
title: 'screenshots',
description: 'screenshotsDescription',
},
]
const overviewQuery = useQuery(
computed(() => ({
queryKey: ['instance-synced-options', instance.value.id],
queryFn: () => get_synced_options_overview(instance.value.id),
})),
)
const capabilities = computed(
() =>
new Map(
overviewQuery.data.value?.capabilities.map((capability) => [capability.option, capability]) ??
[],
),
)
const hotbarResolutionModal = ref<InstanceType<typeof NewModal> | null>(null)
const previewingOption = ref<SyncedOption | null>(null)
function excluded(option: SyncedOption): boolean {
return (
overviewQuery.data.value?.global_options[option] === true &&
!instance.value.synced_options[option]
)
}
function disabledReason(option: SyncedOption): string | undefined {
if (overviewQuery.data.value?.global_options[option] === false) {
return formatMessage(messages[globalDisabledMessages[option]])
}
return capabilities.value.get(option)?.disabled_reason ?? undefined
}
function showAppSyncedOptions(): void {
closeModal?.()
openAppSettingsSyncedOptions()
}
const mutation = useMutation({
mutationFn: ({
option,
enabled,
resolution,
}: {
option: SyncedOption
enabled: boolean
resolution?: SyncedOptionJoinResolution
}) => set_synced_option(instance.value.id, option, enabled, resolution),
onSuccess: async (updatedInstance, variables) => {
hotbarResolutionModal.value?.hide()
queryClient.setQueryData(instanceKeys.detail(updatedInstance.id), updatedInstance)
queryClient.setQueryData<GameInstance[]>(instanceKeys.list(), (instances) =>
instances?.map((candidate) =>
candidate.id === updatedInstance.id ? updatedInstance : candidate,
),
)
await queryClient.invalidateQueries({
queryKey: ['instance-synced-options', updatedInstance.id],
})
if (variables.option === 'multiplayer_servers') {
await queryClient.invalidateQueries({
queryKey: instanceKeys.worlds(updatedInstance.id),
})
}
if (variables.option === 'screenshots') {
await queryClient.invalidateQueries({ queryKey: screenshotKeys.all })
if (updatedInstance.synced_options.screenshots && route.name === 'InstanceScreenshots') {
await router.replace(`/instance/${encodeURIComponent(updatedInstance.id)}`)
} else if (!updatedInstance.synced_options.screenshots && route.name === 'Screenshots') {
await router.replace('/')
}
}
},
onError: handleError,
})
async function setExcluded(option: SyncedOption, nextExcluded: boolean) {
const enabled = !nextExcluded
if (!enabled || option !== 'creative_hotbars') {
mutation.mutate({ option, enabled })
return
}
previewingOption.value = option
try {
const preview = await get_synced_option_join_preview(instance.value.id, option)
if (preview.action === 'requires_resolution') {
hotbarResolutionModal.value?.show()
} else {
mutation.mutate({ option, enabled })
}
} catch (error) {
handleError(error)
} finally {
previewingOption.value = null
}
}
function resolveHotbars(resolution: SyncedOptionJoinResolution) {
mutation.mutate({
option: 'creative_hotbars',
enabled: true,
resolution,
})
}
</script>
<template>
<div class="flex flex-col gap-6">
<NewModal
ref="hotbarResolutionModal"
:header="formatMessage(messages.hotbarConflictTitle)"
fade="warning"
max-width="560px"
>
<div class="flex flex-col gap-3 text-primary">
<p class="m-0">
{{
formatMessage(messages.hotbarConflictDescription, {
instance: instance.name,
})
}}
</p>
<p class="m-0 text-secondary">
{{ formatMessage(messages.hotbarBackupDescription) }}
</p>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<Button
type="outlined"
:disabled="mutation.isPending.value"
@click="hotbarResolutionModal?.hide()"
>
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="orange"
:disabled="mutation.isPending.value"
@click="resolveHotbars('use_instance')"
>
<EditIcon aria-hidden="true" />
{{ formatMessage(messages.useInstanceHotbars) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="mutation.isPending.value"
@click="resolveHotbars('use_synced')"
>
<RotateCounterClockwiseIcon aria-hidden="true" />
{{ formatMessage(messages.useSyncedHotbars) }}
</Button>
</div>
</template>
</NewModal>
<div class="flex items-center justify-between gap-4">
<p class="m-0 text-secondary">
{{ formatMessage(messages.sharedSettingsDescription) }}
</p>
<Button class="shrink-0" @click="showAppSyncedOptions">
<RefreshCwIcon />
{{ formatMessage(messages.openSyncedOptions) }}
</Button>
</div>
<div class="flex flex-col gap-4">
<div v-for="row in rows" :key="row.option" class="flex items-center justify-between gap-6">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages[row.title]) }}
</h2>
<p v-if="row.description" class="m-0 text-secondary">
{{ formatMessage(messages[row.description]) }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<SpinnerIcon
v-if="
(mutation.isPending.value && mutation.variables.value?.option === row.option) ||
previewingOption === row.option
"
class="size-5 animate-spin"
/>
<span v-tooltip="disabledReason(row.option)" class="flex">
<Toggle
:id="`exclude-${row.option}`"
:model-value="excluded(row.option)"
:disabled="
mutation.isPending.value ||
previewingOption !== null ||
overviewQuery.isPending.value ||
!!disabledReason(row.option)
"
@update:model-value="(excluded) => setExcluded(row.option, excluded)"
/>
</span>
</div>
</div>
</div>
<hr class="m-0 h-px border-none bg-button-border" />
<WindowSettings />
<hr class="m-0 h-px border-none bg-button-border" />
<JavaSettings />
<hr class="m-0 h-px border-none bg-button-border" />
<HooksSettings />
</div>
</template>
@@ -1,12 +1,5 @@
<script setup lang="ts">
import {
Checkbox,
defineMessages,
injectNotificationManager,
Input,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { defineMessages, injectNotificationManager, Input, Toggle, useVIntl } from '@modrinth/ui'
import { computed, type Ref, ref, watch } from 'vue'
import { edit } from '@/helpers/instance'
@@ -14,6 +7,7 @@ import { get } from '@/helpers/settings.ts'
import type { AppSettings } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
import SettingsOptionsTransition from './settings-options-transition.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -54,9 +48,13 @@ watch(
)
const messages = defineMessages({
window: {
id: 'instance.settings.tabs.window',
defaultMessage: 'Window',
},
customWindowSettings: {
id: 'instance.settings.tabs.window.custom-window-settings',
defaultMessage: 'Custom window settings',
defaultMessage: 'Use custom window settings for this instance.',
},
fullscreen: {
id: 'instance.settings.tabs.window.fullscreen',
@@ -94,68 +92,68 @@ const messages = defineMessages({
</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">
<div class="flex flex-col">
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.fullscreen) }}
{{ formatMessage(messages.window) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.fullscreenDescription) }}
</p>
<p class="m-0">{{ formatMessage(messages.customWindowSettings) }}</p>
</div>
<Toggle
id="fullscreen"
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
:disabled="!overrideWindowSettings"
@update:model-value="
(e) => {
fullscreenSetting = e
}
"
/>
<Toggle id="override-window-settings" v-model="overrideWindowSettings" />
</div>
<SettingsOptionsTransition :show="overrideWindowSettings">
<div class="flex flex-col gap-6 pt-6">
<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">
{{ formatMessage(messages.fullscreenDescription) }}
</p>
</div>
<Toggle id="fullscreen" v-model="fullscreenSetting" />
</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>
<Input
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.width) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.widthDescription) }}
</p>
</div>
<Input
id="width"
v-model="resolution[0]"
autocomplete="off"
:disabled="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 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>
<Input
id="height"
v-model="resolution[1]"
autocomplete="off"
:disabled="fullscreenSetting"
type="number"
:placeholder="formatMessage(messages.enterHeight)"
/>
</div>
</div>
<Input
id="height"
v-model="resolution[1]"
autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting"
type="number"
:placeholder="formatMessage(messages.enterHeight)"
/>
</div>
</SettingsOptionsTransition>
</div>
</template>
@@ -2,7 +2,8 @@ import Content from './content/index.vue'
import Files from './files/index.vue'
import Index from './layout.vue'
import Logs from './logs/index.vue'
import Screenshots from './screenshots/index.vue'
import Share from './share/index.vue'
import Worlds from './worlds/index.vue'
export { Content, Files, Index, Logs, Share, Worlds }
export { Content, Files, Index, Logs, Screenshots, Share, Worlds }
@@ -92,6 +92,7 @@ import {
EditIcon,
FolderOpenIcon,
GlobeIcon,
ImagesIcon,
PlayIcon,
PlusIcon,
StopCircleIcon,
@@ -139,6 +140,7 @@ import {
} from '@/helpers/install'
import {
get_full_path,
get_global_synced_options,
getInstanceIconUrl,
kill,
refresh_content_updates,
@@ -187,6 +189,7 @@ const messages = defineMessages({
},
contentTab: { id: 'app.instance.tab.content', defaultMessage: 'Content' },
filesTab: { id: 'app.instance.tab.files', defaultMessage: 'Files' },
screenshotsTab: { id: 'app.instance.tab.screenshots', defaultMessage: 'Screenshots' },
worldsTab: { id: 'app.instance.tab.worlds', defaultMessage: 'Worlds' },
logsTab: { id: 'app.instance.tab.logs', defaultMessage: 'Logs' },
shareTab: { id: 'app.instance.tab.share', defaultMessage: 'Share' },
@@ -230,6 +233,10 @@ useQuery(
})),
)
const instance = computed(() => instanceQuery.data.value)
const globalSyncedOptionsQuery = useQuery({
queryKey: ['global-synced-options'],
queryFn: get_global_synced_options,
})
useQuery(
computed(() => ({
queryKey: instanceKeys.contentUpdateCheck(instanceId.value),
@@ -495,6 +502,17 @@ const tabs = computed(() => {
},
]
const screenshotsSynced =
globalSyncedOptionsQuery.data.value?.screenshots === true &&
instance.value?.synced_options.screenshots === true
if (!screenshotsSynced) {
instanceTabs.splice(2, 0, {
label: formatMessage(messages.screenshotsTab),
href: `${basePath.value}/screenshots`,
icon: ImagesIcon,
})
}
if (showShareTab.value) {
instanceTabs.push({
label: formatMessage(messages.shareTab),
@@ -1,13 +1,20 @@
import { queryOptions } from '@tanstack/vue-query'
import { get_project_v3 } from '@/helpers/cache.js'
import { get as getInstance } from '@/helpers/instance'
import {
get as getInstance,
list as listInstances,
list_instance_screenshots,
list_screenshot_groups,
list_synced_screenshots,
} from '@/helpers/instance'
import { loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_instance_id } from '@/helpers/process'
import { refreshWorlds } from '@/helpers/worlds'
export const instanceKeys = {
all: ['instances'] as const,
list: () => [...instanceKeys.all, 'list'] as const,
detail: (instanceId: string) => [...instanceKeys.all, 'summary', instanceId] as const,
processes: (instanceId: string) => [...instanceKeys.all, 'processes', instanceId] as const,
content: (instanceId: string) => [...instanceKeys.all, 'content', instanceId] as const,
@@ -30,6 +37,45 @@ export const instanceKeys = {
sharedMembers: (instanceId: string) => ['sharedInstanceUsers', instanceId] as const,
}
export const screenshotKeys = {
all: ['screenshots'] as const,
global: () => [...screenshotKeys.all, 'global'] as const,
instance: (instanceId: string) => [...screenshotKeys.all, 'instance', instanceId] as const,
groups: () => [...screenshotKeys.all, 'groups'] as const,
}
export function instanceListQueryOptions() {
return queryOptions({
queryKey: instanceKeys.list(),
queryFn: listInstances,
staleTime: 30_000,
})
}
export function syncedScreenshotsQueryOptions() {
return queryOptions({
queryKey: screenshotKeys.global(),
queryFn: list_synced_screenshots,
staleTime: 0,
})
}
export function instanceScreenshotsQueryOptions(instanceId: string) {
return queryOptions({
queryKey: screenshotKeys.instance(instanceId),
queryFn: () => list_instance_screenshots(instanceId),
staleTime: 0,
})
}
export function screenshotGroupsQueryOptions() {
return queryOptions({
queryKey: screenshotKeys.groups(),
queryFn: list_screenshot_groups,
staleTime: 0,
})
}
export function instanceDetailQueryOptions(instanceId: string) {
return queryOptions({
queryKey: instanceKeys.detail(instanceId),
@@ -0,0 +1,11 @@
<script setup lang="ts">
import ScreenshotsPage from '@/components/ui/screenshots-page/index.vue'
import { injectInstancePage } from '../instance-context'
const instancePage = injectInstancePage()
</script>
<template>
<ScreenshotsPage :instance-id="instancePage.instanceId.value" />
</template>
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<Input
v-model="search"
@@ -16,10 +16,12 @@
<ConfirmRemoveWorldModal
ref="removeWorldModal"
:world="worldToRemove"
:other-synced-instance-count="otherSyncedInstanceCount"
@confirm="proceedRemoveWorld"
/>
<DesyncServerModal ref="desyncServerModal" @confirm="confirmDesyncServer" />
<ReadyTransition :pending="worldsReadyPending">
<div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-4">
<div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<Input
v-model="searchFilter"
@@ -75,7 +77,7 @@
}}
</Button>
</div>
<div class="flex flex-col w-full gap-2">
<div class="mt-2 flex w-full flex-col gap-2">
<WorldItem
v-for="world in filteredWorlds"
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
@@ -108,6 +110,7 @@
: editServerModal?.show(world)
"
@delete="() => !isManagedServerWorld(world) && promptToRemoveWorld(world)"
@desync="() => world.type === 'server' && desyncServerModal?.show(world as ServerWorld)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.id, world.path)"
/>
</div>
@@ -153,6 +156,7 @@ import { useRoute } from 'vue-router'
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import ConfirmRemoveWorldModal from '@/components/ui/world/modal/ConfirmRemoveWorldModal.vue'
import DesyncServerModal from '@/components/ui/world/modal/DesyncServerModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
@@ -164,8 +168,9 @@ import { get_game_versions } from '@/helpers/tags'
import { ensureManagedServerWorldExists, getServerAddress } from '@/helpers/worlds'
import {
delete_world,
desync_server,
type DesyncServerMode,
get_instance_protocol_version,
getServerDomainKey,
getWorldIdentifier,
handleDefaultInstanceUpdateEvent,
hasServerQuickPlaySupport,
@@ -189,7 +194,11 @@ import {
import { injectServerInstall } from '@/providers/server-install'
import { injectInstancePage } from '../instance-context'
import { instanceKeys, instanceWorldsQueryOptions } from '../query-options'
import {
instanceKeys,
instanceListQueryOptions,
instanceWorldsQueryOptions,
} from '../query-options'
const messages = defineMessages({
searchWorldsPlaceholder: {
@@ -244,6 +253,7 @@ const addServerModal = ref<InstanceType<typeof AddServerModal>>()
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
const editWorldModal = ref<InstanceType<typeof EditWorldModal>>()
const removeWorldModal = ref<InstanceType<typeof ConfirmRemoveWorldModal>>()
const desyncServerModal = ref<InstanceType<typeof DesyncServerModal>>()
const worldToRemove = ref<World | null>(null)
@@ -283,6 +293,15 @@ function toggleFilter(id: string) {
const queryClient = useQueryClient()
const instanceListQuery = useQuery(instanceListQueryOptions())
const otherSyncedInstanceCount = computed(
() =>
instanceListQuery.data.value?.filter(
(candidate) =>
candidate.id !== instance.value.id && candidate.synced_options.multiplayer_servers,
).length ?? 0,
)
const refreshingAll = ref(false)
const hadNoWorlds = ref(true)
const startingInstance = ref(false)
@@ -507,6 +526,12 @@ async function removeServer(server: ServerWorld) {
}
}
async function confirmDesyncServer(server: ServerWorld, mode: DesyncServerMode) {
if (!server.server_id) return
await desync_server(instance.value.id, server.server_id, mode).catch(handleError)
await refreshAllWorlds()
}
async function editWorld(path: string, name: string, removeIcon: boolean) {
const world = worlds.value.find((world) => world.type === 'singleplayer' && world.path === path)
if (world) {
@@ -590,7 +615,7 @@ function worldsMatch(world: World, other: World | undefined) {
const dedupedWorlds = computed(() => {
const visibleWorlds: World[] = []
const serverIndexByDomain = new Map<string, number>()
const serverIndexByAddress = new Map<string, number>()
for (const world of worlds.value) {
if (world.type !== 'server') {
@@ -598,14 +623,11 @@ const dedupedWorlds = computed(() => {
continue
}
const domainKey =
getServerDomainKey(world.address) ||
normalizeServerAddress(world.address) ||
`server-${world.index}`
const existingIndex = serverIndexByDomain.get(domainKey)
const addressKey = normalizeServerAddress(world.address) || `server-${world.index}`
const existingIndex = serverIndexByAddress.get(addressKey)
if (existingIndex == null) {
serverIndexByDomain.set(domainKey, visibleWorlds.length)
serverIndexByAddress.set(addressKey, visibleWorlds.length)
visibleWorlds.push(world)
continue
}
+37 -283
View File
@@ -14,88 +14,31 @@
</span>
</Card>
</div>
<Teleport to="#teleports">
<div v-if="expandedGalleryItem" class="expanded-image-modal" @click="hideImage">
<div class="content">
<img
class="image"
:class="{ 'zoomed-in': zoomedIn }"
:src="
expandedGalleryItem.raw_url
? expandedGalleryItem.raw_url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
:alt="expandedGalleryItem.title ? expandedGalleryItem.title : 'gallery-image'"
@click.stop="() => {}"
/>
<div class="floating" @click.stop="() => {}">
<div class="text">
<h2 v-if="expandedGalleryItem.title">
{{ expandedGalleryItem.title }}
</h2>
<p v-if="expandedGalleryItem.description">
{{ expandedGalleryItem.description }}
</p>
</div>
<div class="controls">
<div class="buttons">
<IconButton label="Close" class="close" @click="hideImage">
<XIcon aria-hidden="true" />
</IconButton>
<ButtonLink
class="open btn icon-only !w-9 !px-0 !rounded-full"
target="_blank"
:href="
expandedGalleryItem.raw_url
? expandedGalleryItem.raw_url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
>
<ExternalIcon aria-hidden="true" />
</ButtonLink>
<IconButton label="Toggle zoom" @click="zoomedIn = !zoomedIn">
<ExpandIcon v-if="!zoomedIn" aria-hidden="true" />
<ContractIcon v-else aria-hidden="true" />
</IconButton>
<IconButton
v-if="filteredGallery.length > 1"
label="Previous image"
class="previous"
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</IconButton>
<IconButton
v-if="filteredGallery.length > 1"
label="Next image"
class="next"
@click="nextImage()"
>
<RightArrowIcon aria-hidden="true" />
</IconButton>
</div>
</div>
</div>
</div>
</div>
</Teleport>
<ImageViewerEditor
ref="galleryViewer"
:items="galleryViewerItems"
editor="disabled"
@navigate="trackGalleryNavigation"
>
<template #actions="{ item }">
<Button
type="quiet"
class="!w-9 !rounded-full !p-0"
aria-label="Open image in new tab"
@click="openUrl(item.src)"
>
<ExternalIcon aria-hidden="true" />
</Button>
</template>
</ImageViewerEditor>
</template>
<script setup>
import {
CalendarIcon,
ContractIcon,
ExpandIcon,
ExternalIcon,
LeftArrowIcon,
RightArrowIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonLink, Card, IconButton, useFormatDateTime } from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { CalendarIcon, ExternalIcon } from '@modrinth/assets'
import { Button, Card, ImageViewerEditor, useFormatDateTime } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref } from 'vue'
import { release_ads_window_hold, take_ads_window_hold } from '@/helpers/ads.js'
import { trackEvent } from '@/helpers/analytics'
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
@@ -117,84 +60,31 @@ const filteredGallery = computed(
() => props.project.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
)
const expandedGalleryItem = ref(null)
const expandedGalleryIndex = ref(0)
const zoomedIn = ref(false)
let adsWindowHold = false
const hideImage = () => {
expandedGalleryItem.value = null
if (adsWindowHold) {
adsWindowHold = false
release_ads_window_hold()
}
}
const nextImage = () => {
expandedGalleryIndex.value++
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
expandedGalleryIndex.value = 0
}
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
trackEvent('GalleryImageNext', {
project_id: props.project.id,
url: expandedGalleryItem.value.url,
})
}
const previousImage = () => {
expandedGalleryIndex.value--
if (expandedGalleryIndex.value < 0) {
expandedGalleryIndex.value = filteredGallery.value.length - 1
}
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
trackEvent('GalleryImagePrevious', {
project_id: props.project.id,
url: expandedGalleryItem.value,
})
}
const galleryViewer = ref()
const galleryViewerItems = computed(() =>
filteredGallery.value.map((image) => ({
id: image.url,
src: image.raw_url ?? 'https://cdn.modrinth.com/placeholder-banner.svg',
alt: image.title || 'Gallery image',
title: image.title,
description: image.description,
})),
)
const expandImage = (item, index) => {
if (!adsWindowHold) {
adsWindowHold = true
take_ads_window_hold()
}
expandedGalleryItem.value = item
expandedGalleryIndex.value = index
zoomedIn.value = false
galleryViewer.value?.show(index)
trackEvent('GalleryImageExpand', {
project_id: props.project.id,
url: item.url,
})
}
function keyListener(e) {
if (expandedGalleryItem.value) {
if (e.key === 'Escape') {
e.preventDefault()
hideImage()
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
previousImage()
} else if (e.key === 'ArrowRight') {
e.preventDefault()
nextImage()
}
}
function trackGalleryNavigation(item, _index, direction) {
trackEvent(direction === 'next' ? 'GalleryImageNext' : 'GalleryImagePrevious', {
project_id: props.project.id,
url: item.id,
})
}
onMounted(() => {
document.addEventListener('keydown', keyListener)
})
onUnmounted(() => {
document.removeEventListener('keydown', keyListener)
if (adsWindowHold) {
adsWindowHold = false
release_ads_window_hold()
}
})
</script>
<style scoped lang="scss">
@@ -229,140 +119,4 @@ onUnmounted(() => {
vertical-align: center;
}
}
.expanded-image-modal {
position: fixed;
z-index: 11;
overflow: auto;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #000000;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
.content {
position: relative;
width: calc(100vw - 2 * var(--gap-lg));
height: calc(100vh - 2 * var(--gap-lg));
.circle-button {
padding: 0.5rem;
line-height: 1;
display: flex;
max-width: 2rem;
color: var(--color-button-text);
background-color: var(--color-button-bg);
border-radius: var(--size-rounded-max);
margin: 0;
box-shadow: inset 0px -1px 1px rgb(17 24 39 / 10%);
&:not(:last-child) {
margin-right: 0.5rem;
}
&:hover {
background-color: var(--color-button-bg-hover) !important;
svg {
color: var(--color-button-text-hover) !important;
}
}
&:active {
background-color: var(--color-button-bg-active) !important;
svg {
color: var(--color-button-text-active) !important;
}
}
svg {
height: 1rem;
width: 1rem;
}
}
.image {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
max-width: calc(100vw - 2 * var(--gap-lg));
max-height: calc(100vh - 2 * var(--gap-lg));
border-radius: var(--radius-lg);
&.zoomed-in {
object-fit: cover;
width: auto;
height: calc(100vh - 2 * var(--gap-lg));
max-width: calc(100vw - 2 * var(--gap-lg));
}
}
.floating {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: var(--gap-md);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--gap-md);
transition: opacity 0.25s ease-in-out;
opacity: 1;
padding: 2rem 2rem 0 2rem;
&:not(&:hover) {
opacity: 0.4;
.text {
transform: translateY(2.5rem) scale(0.8);
opacity: 0;
}
.controls {
transform: translateY(0.25rem) scale(0.9);
}
}
.text {
display: flex;
flex-direction: column;
max-width: 40rem;
transition:
opacity 0.25s ease-in-out,
transform 0.25s ease-in-out;
text-shadow: 1px 1px 10px #000000d4;
margin-bottom: 0.25rem;
gap: 0.5rem;
h2 {
color: var(--dark-color-base);
font-size: 1.25rem;
text-align: center;
margin: 0;
}
p {
color: var(--dark-color-base);
margin: 0;
}
}
.controls {
background-color: var(--color-raised-bg);
padding: var(--gap-md);
border-radius: var(--radius-md);
transition:
opacity 0.25s ease-in-out,
transform 0.25s ease-in-out;
}
}
}
}
.buttons {
display: flex;
gap: 0.5rem;
}
</style>