mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
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:
@@ -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>
|
||||
@@ -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() {
|
||||
|
||||
+169
-139
@@ -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>
|
||||
|
||||
+51
@@ -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>
|
||||
+376
@@ -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 instance’s 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>
|
||||
+63
-65
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user