fix: ux changes for sync settings/overrides (#7347)

* fix: ux changes for sync settings/overrides

* fix: screenshots page lag + better virtualization pass

* fix: gallery issues

* feat: further ux changes
This commit is contained in:
Calum H.
2026-08-31 23:06:16 +00:00
committed by GitHub
parent cf60f28406
commit 11e1de9962
40 changed files with 1761 additions and 1121 deletions
+21 -1
View File
@@ -125,6 +125,7 @@ import {
get as getInstance,
get_global_synced_options,
run,
set_global_synced_option,
} from '@/helpers/instance'
import {
get as getCreds,
@@ -151,7 +152,7 @@ import {
} from '@/helpers/utils.js'
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
import i18n from '@/i18n.config'
import { instanceKeys } from '@/pages/instance/query-options'
import { instanceKeys, screenshotKeys } from '@/pages/instance/query-options'
import {
appUpdateState,
downloadAvailableAppUpdate,
@@ -1116,6 +1117,25 @@ watch(
settings.hide_nametag_skins_page = behavior.hide_nametag
settingsChanged = true
}
const showAllScreenshots = behavior.show_all_screenshots
if (typeof showAllScreenshots === 'boolean') {
const globalSyncedOptions =
globalSyncedOptionsQuery.data.value ??
(await queryClient.fetchQuery({
queryKey: ['global-synced-options'],
queryFn: get_global_synced_options,
}))
if (globalSyncedOptions.screenshots !== showAllScreenshots) {
const updatedGlobalSyncedOptions = await set_global_synced_option(
'screenshots',
showAllScreenshots,
)
queryClient.setQueryData(['global-synced-options'], updatedGlobalSyncedOptions)
await queryClient.invalidateQueries({ queryKey: screenshotKeys.all })
}
}
for (const [flag, value] of Object.entries(behaviorFeatureFlags)) {
if (settings.feature_flags[flag] !== value) {
settings.feature_flags[flag] = value
@@ -3,11 +3,16 @@
<script setup lang="ts">
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
import { defineMessages, IconButton, useFormatDateTime, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import {
defineMessages,
IconButton,
useDebugLogger,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
const loadedScreenshotUrls = new Set<string>()
const props = defineProps<{
screenshot: InstanceScreenshot
@@ -29,9 +34,12 @@ const emit = defineEmits<{
const card = ref<HTMLElement>()
const image = ref<HTMLImageElement>()
const loaded = ref(loadedScreenshotUrls.has(props.screenshot.url))
const imageReady = ref(false)
const { formatMessage } = useVIntl()
const debugImage = useDebugLogger('Screenshots:Card')
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
let loadStartedAt = performance.now()
let loadGeneration = 0
const messages = defineMessages({
select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' },
deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' },
@@ -69,19 +77,86 @@ function activate(event: MouseEvent | KeyboardEvent) {
emit('activate', event)
}
function markImageLoaded() {
loadedScreenshotUrls.add(props.screenshot.url)
loaded.value = true
async function markImageLoaded() {
const loadedImage = image.value
const loadedUrl = props.screenshot.url
const generation = loadGeneration
if (!loadedImage) return
try {
await loadedImage.decode()
} catch {
if (!loadedImage.complete || loadedImage.naturalWidth === 0) return
}
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve()))
})
if (
generation !== loadGeneration ||
image.value !== loadedImage ||
props.screenshot.url !== loadedUrl
) {
return
}
const wasReady = imageReady.value
imageReady.value = true
if (!wasReady) {
debugImage('image loaded', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
naturalWidth: image.value?.naturalWidth,
naturalHeight: image.value?.naturalHeight,
})
}
}
function markImageFailed(event: Event) {
debugImage('image failed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
event,
})
}
onMounted(() => {
debugImage('mounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
complete: image.value?.complete,
})
if (image.value?.complete && image.value.naturalWidth > 0) markImageLoaded()
})
onBeforeUnmount(() => {
loadGeneration += 1
debugImage('unmounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loaded: imageReady.value,
})
})
watch(
() => props.screenshot.url,
(url) => {
loaded.value = loadedScreenshotUrls.has(url)
(url, previousUrl) => {
loadGeneration += 1
loadStartedAt = performance.now()
imageReady.value = false
debugImage('source changed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
previousUrl,
url,
})
},
)
</script>
@@ -91,7 +166,7 @@ watch(
ref="card"
role="button"
tabindex="0"
class="group relative aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
class="group relative isolate aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
:class="{
'!border-contrast brightness-110': selected,
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
@@ -115,7 +190,7 @@ watch(
>
<button
type="button"
class="selection-button group/selection absolute right-0.5 top-0 z-[2] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
class="selection-button group/selection absolute right-0.5 top-0 z-[3] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
:aria-label="
formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
@@ -134,19 +209,25 @@ watch(
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
</span>
</button>
<div v-if="!loaded" class="absolute inset-0 animate-pulse bg-surface-3" />
<img
ref="image"
:src="screenshot.url"
:alt="screenshot.file_name"
loading="lazy"
loading="eager"
decoding="async"
draggable="false"
class="h-full w-full object-cover transition duration-200"
:class="loaded ? 'opacity-100' : 'opacity-0'"
class="screenshot-card-fade absolute inset-0 z-[1] h-full w-full object-cover"
:class="imageReady ? 'opacity-100' : 'opacity-0'"
@load="markImageLoaded"
@error="markImageFailed"
/>
<div
class="absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-contrast opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
aria-hidden="true"
class="pointer-events-none absolute inset-0 bg-button-bg"
:class="{ 'animate-pulse': !imageReady }"
/>
<div
class="absolute inset-x-0 bottom-0 z-[2] flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-contrast opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
>
<div class="min-w-0">
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
@@ -193,3 +274,9 @@ watch(
</div>
</article>
</template>
<style scoped>
.screenshot-card-fade {
transition: opacity 350ms ease-in-out;
}
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
@@ -27,7 +27,6 @@ const props = defineProps<{
highlightedScreenshotId?: string
copiedScreenshotIds: ReadonlySet<string>
forceOpen: boolean
animateEntry: boolean
hideHeader?: boolean
editableTitle?: boolean
startEditingTitle?: boolean
@@ -39,6 +38,7 @@ const props = defineProps<{
const collapsed = defineModel<boolean>('collapsed', { required: true })
const dropTarget = ref<HTMLElement>()
const { formatMessage } = useVIntl()
const debugLayout = useDebugLogger('Screenshots:Group')
const messages = defineMessages({
emptyGroup: {
id: 'app.screenshots.group.empty',
@@ -60,14 +60,42 @@ const visibleGridStyle = computed(() =>
: { transform: `translateY(${props.virtualGridTop}px)` },
)
let unmountGridTimeout: ReturnType<typeof setTimeout> | undefined
let resizeObserver: ResizeObserver | undefined
async function logGeometry(reason: string) {
await nextTick()
const rect = dropTarget.value?.getBoundingClientRect()
debugLayout(reason, {
id: props.id,
title: props.title,
collapsed: collapsed.value,
shouldShowGrid: shouldShowGrid.value,
renderGrid: renderGrid.value,
screenshotCount: props.screenshots.length,
renderedCount: visibleScreenshots.value.length,
firstScreenshotId: visibleScreenshots.value.at(0)?.id,
lastScreenshotId: visibleScreenshots.value.at(-1)?.id,
virtualGridHeight: props.virtualGridHeight,
virtualGridTop: props.virtualGridTop,
actual: rect
? { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
: undefined,
})
}
watch(
() => props.renderedScreenshots ?? props.screenshots,
(screenshots) => {
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
void logGeometry('rendered screenshots changed')
},
)
watch(
() => [props.virtualGridHeight, props.virtualGridTop] as const,
() => void logGeometry('virtual grid geometry changed'),
)
watch(
shouldShowGrid,
(showGrid, previouslyShown) => {
@@ -75,6 +103,7 @@ watch(
if (showGrid) {
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
renderGrid.value = true
void logGeometry('grid shown')
return
}
if (!previouslyShown) {
@@ -83,13 +112,41 @@ watch(
}
unmountGridTimeout = setTimeout(() => {
renderGrid.value = false
void logGeometry('grid unmounted after collapse')
unmountGridTimeout = undefined
}, 300)
},
{ flush: 'post' },
)
onMounted(() => {
debugLayout('mounted', { id: props.id, title: props.title })
void logGeometry('mounted geometry')
if (dropTarget.value) {
resizeObserver = new ResizeObserver(([entry]) => {
const rect = entry?.target.getBoundingClientRect()
debugLayout('actual size changed', {
id: props.id,
title: props.title,
width: rect?.width,
height: rect?.height,
expectedHeight:
(props.hideHeader ? 0 : 40) +
(shouldShowGrid.value ? 10 + (props.virtualGridHeight ?? 0) : 0) +
12,
})
})
resizeObserver.observe(dropTarget.value)
}
})
onBeforeUnmount(() => {
debugLayout('unmounted', {
id: props.id,
title: props.title,
renderedCount: visibleScreenshots.value.length,
})
resizeObserver?.disconnect()
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
})
@@ -149,19 +206,10 @@ function getSelectionKey(screenshot: InstanceScreenshot) {
<slot name="actions" :start-editing="startEditing" />
</template>
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
<TransitionGroup
tag="div"
<div
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
:class="{ 'absolute inset-x-0 top-0': virtualGridHeight !== undefined }"
:style="visibleGridStyle"
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
:enter-active-class="
animateEntry
? 'transition-[opacity,transform] duration-[150ms] ease-out motion-reduce:transition-none'
: ''
"
:enter-from-class="animateEntry ? 'opacity-0' : ''"
enter-to-class="opacity-100 scale-100"
>
<ScreenshotCard
v-for="screenshot in visibleScreenshots"
@@ -188,7 +236,7 @@ function getSelectionKey(screenshot: InstanceScreenshot) {
>
{{ formatMessage(messages.emptyGroup) }}
</p>
</TransitionGroup>
</div>
</div>
</ScreenshotSection>
</div>
@@ -34,6 +34,7 @@ import {
type ImageViewerEditorSavePayload,
injectNotificationManager,
ReadyTransition,
useDebugLogger,
useFormatDateTime,
useReadyState,
useScrollViewport,
@@ -109,6 +110,7 @@ type ScreenshotDropData = {
}
type ScreenshotGroupLayout = {
id: string
group: ScreenshotGroupData
top: number
height: number
@@ -117,7 +119,7 @@ type ScreenshotGroupLayout = {
gridTop: number
}
type VisibleScreenshotGroupLayout = ScreenshotGroupLayout & {
type VirtualizedScreenshotGroupLayout = ScreenshotGroupLayout & {
renderedScreenshots: InstanceScreenshot[]
virtualGridTop: number
}
@@ -126,7 +128,7 @@ const SCREENSHOT_GRID_GAP = 12
const SCREENSHOT_GROUP_SPACING = 12
const SCREENSHOT_GROUP_HEADER_HEIGHT = 40
const SCREENSHOT_GROUP_CONTENT_SPACING = 10
const SCREENSHOT_GROUP_OVERSCAN = 900
const SCREENSHOT_GROUP_OVERSCAN = 2000
const SCREENSHOT_GRID_MIN_HEIGHT = 45
const FALLBACK_SCREENSHOT_CARD_WIDTH = 320
@@ -173,8 +175,6 @@ const selectedKeys = ref(new Set<string>())
const copiedScreenshotIds = ref(new Set<string>())
const copiedResetTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const screenshotsPage = ref<HTMLElement>()
const regrouping = ref(false)
const screenshotsScrolling = ref(false)
const screenshotToDelete = ref<InstanceScreenshot | null>(null)
const deleteFromPreview = ref(false)
const activeDrag = ref<ActiveScreenshotDrag | null>(null)
@@ -197,7 +197,8 @@ const route = useRoute()
const router = useRouter()
const { formatMessage } = useVIntl()
const { addNotification, handleError } = injectNotificationManager()
let screenshotsScrollIdleTimeout: ReturnType<typeof setTimeout> | undefined
const debugLayout = useDebugLogger('Screenshots:Layout')
let screenshotsScrollLogFrame: number | undefined
const {
listContainer: screenshotListContainer,
containerOffset: screenshotListOffset,
@@ -206,12 +207,18 @@ const {
viewportHeight: screenshotViewportHeight,
} = useScrollViewport({
onScroll: () => {
screenshotsScrolling.value = true
if (screenshotsScrollIdleTimeout) clearTimeout(screenshotsScrollIdleTimeout)
screenshotsScrollIdleTimeout = setTimeout(() => {
screenshotsScrolling.value = false
screenshotsScrollIdleTimeout = undefined
}, 120)
if (screenshotsScrollLogFrame === undefined) {
screenshotsScrollLogFrame = requestAnimationFrame(() => {
screenshotsScrollLogFrame = undefined
debugLayout('scroll', {
groupBy: groupBy.value,
relativeScrollTop: screenshotListScrollTop.value,
listOffset: screenshotListOffset.value,
viewportHeight: screenshotViewportHeight.value,
listWidth: screenshotListWidth.value,
})
})
}
},
})
const { width: screenshotListWidth } = useElementSize(screenshotListContainer)
@@ -429,10 +436,10 @@ const groupedScreenshots = computed((): ScreenshotGroupData[] => {
screenshotGroups.set(screenshot.instance_id, group)
}
const syncedInstances = (instancesQuery.data.value ?? [])
.filter((instance) => instance.synced_options.screenshots)
.sort((a, b) => a.name.localeCompare(b.name))
const groups = syncedInstances.flatMap((instance) => {
const instances = [...(instancesQuery.data.value ?? [])].sort((a, b) =>
a.name.localeCompare(b.name),
)
const groups = instances.flatMap((instance) => {
const instanceScreenshots = screenshotGroups.get(instance.id)
return instanceScreenshots
? [
@@ -546,19 +553,14 @@ const screenshotGroupLayouts = computed<ScreenshotGroupLayout[]>(() => {
(isOpen ? SCREENSHOT_GROUP_CONTENT_SPACING + gridHeight : 0) +
SCREENSHOT_GROUP_SPACING
layouts.push({ group, top, height, isOpen, gridHeight, gridTop })
layouts.push({ id: group.id, group, top, height, isOpen, gridHeight, gridTop })
top += height
}
return layouts
})
const screenshotListHeight = computed(() => {
const lastGroup = screenshotGroupLayouts.value[screenshotGroupLayouts.value.length - 1]
return lastGroup ? lastGroup.top + lastGroup.height : 0
})
const visibleScreenshotGroups = computed<VisibleScreenshotGroupLayout[]>(() => {
const virtualizedScreenshotGroups = computed<VirtualizedScreenshotGroupLayout[]>(() => {
const hasViewport = Boolean(screenshotListContainer.value && screenshotScrollContainer.value)
const viewportStart = hasViewport
? Math.max(0, screenshotListScrollTop.value - SCREENSHOT_GROUP_OVERSCAN)
@@ -567,36 +569,90 @@ const visibleScreenshotGroups = computed<VisibleScreenshotGroupLayout[]>(() => {
? screenshotListScrollTop.value + screenshotViewportHeight.value + SCREENSHOT_GROUP_OVERSCAN
: SCREENSHOT_GROUP_OVERSCAN
return screenshotGroupLayouts.value
.filter((layout) => layout.top + layout.height >= viewportStart && layout.top <= viewportEnd)
.map((layout) => {
if (!layout.isOpen || layout.group.screenshots.length === 0) {
return { ...layout, renderedScreenshots: [], virtualGridTop: 0 }
}
return screenshotGroupLayouts.value.map((layout) => {
const isInRenderRange = layout.top + layout.height >= viewportStart && layout.top <= viewportEnd
if (!layout.isOpen || layout.group.screenshots.length === 0) {
return { ...layout, renderedScreenshots: [], virtualGridTop: 0 }
}
if (!isInRenderRange) {
return { ...layout, renderedScreenshots: [], virtualGridTop: 0 }
}
const rowCount = Math.ceil(layout.group.screenshots.length / screenshotColumnCount.value)
const firstRow = Math.min(
rowCount,
Math.max(0, Math.floor((viewportStart - layout.gridTop) / screenshotRowHeight.value)),
)
const lastRow = Math.min(
rowCount,
Math.max(
firstRow,
Math.ceil(
(viewportEnd - layout.gridTop + SCREENSHOT_GRID_GAP) / screenshotRowHeight.value,
),
),
)
const firstScreenshot = firstRow * screenshotColumnCount.value
const lastScreenshot = lastRow * screenshotColumnCount.value
const rowCount = Math.ceil(layout.group.screenshots.length / screenshotColumnCount.value)
const firstRow = Math.min(
rowCount,
Math.max(0, Math.floor((viewportStart - layout.gridTop) / screenshotRowHeight.value)),
)
const lastRow = Math.min(
rowCount,
Math.max(
firstRow,
Math.ceil((viewportEnd - layout.gridTop + SCREENSHOT_GRID_GAP) / screenshotRowHeight.value),
),
)
const firstScreenshot = firstRow * screenshotColumnCount.value
const lastScreenshot = lastRow * screenshotColumnCount.value
return {
...layout,
renderedScreenshots: layout.group.screenshots.slice(firstScreenshot, lastScreenshot),
virtualGridTop: firstRow * screenshotRowHeight.value,
}
return {
...layout,
renderedScreenshots: layout.group.screenshots.slice(firstScreenshot, lastScreenshot),
virtualGridTop: firstRow * screenshotRowHeight.value,
}
})
})
watch(
[groupBy, screenshotListWidth, windowWidth, screenshotColumnCount, screenshotCardHeight],
([currentGroupBy, listWidth, currentWindowWidth, columns, cardHeight], previous) => {
debugLayout('responsive geometry changed', {
groupBy: currentGroupBy,
listWidth,
windowWidth: currentWindowWidth,
columns,
cardHeight,
previous,
})
},
{ immediate: true },
)
watch(
screenshotGroupLayouts,
(layouts) => {
debugLayout('group layouts changed', {
groupBy: groupBy.value,
groupCount: layouts.length,
totalHeight: layouts.at(-1) ? layouts.at(-1)!.top + layouts.at(-1)!.height : 0,
layouts: layouts.map((layout) => ({
id: layout.id,
top: layout.top,
height: layout.height,
gridTop: layout.gridTop,
gridHeight: layout.gridHeight,
isOpen: layout.isOpen,
screenshotCount: layout.group.screenshots.length,
})),
})
},
{ immediate: true },
)
watch(virtualizedScreenshotGroups, (groups) => {
debugLayout('render window changed', {
groupBy: groupBy.value,
relativeScrollTop: screenshotListScrollTop.value,
viewportHeight: screenshotViewportHeight.value,
renderedGroups: groups
.filter((group) => group.renderedScreenshots.length > 0)
.map((group) => ({
id: group.id,
top: group.top,
virtualGridTop: group.virtualGridTop,
renderedCount: group.renderedScreenshots.length,
firstScreenshotId: group.renderedScreenshots.at(0)?.id,
lastScreenshotId: group.renderedScreenshots.at(-1)?.id,
})),
})
})
const previewItems = computed(() =>
@@ -685,10 +741,8 @@ watch(groupBy, async (currentGroupBy, previousGroupBy) => {
if (currentGroupBy === previousGroupBy) return
const previousPositions = getScreenshotCardPositions()
regrouping.value = true
await nextTick()
animateScreenshotCardsFrom(previousPositions)
regrouping.value = false
})
let handledFocus: string | undefined
@@ -1335,7 +1389,7 @@ watch(activeDropGroupId, (groupId) => {
onBeforeUnmount(() => {
clearGroupHoverOpenTimeout()
if (revealTimeout) clearTimeout(revealTimeout)
if (screenshotsScrollIdleTimeout) clearTimeout(screenshotsScrollIdleTimeout)
if (screenshotsScrollLogFrame !== undefined) cancelAnimationFrame(screenshotsScrollLogFrame)
for (const timeout of copiedResetTimeouts.values()) clearTimeout(timeout)
copiedResetTimeouts.clear()
})
@@ -1479,20 +1533,21 @@ onBeforeUnmount(() => {
>
<div
ref="screenshotListContainer"
class="relative w-full"
:style="{ height: `${screenshotListHeight}px`, overflowAnchor: 'none' }"
class="w-full"
:style="{
overflowAnchor: 'none',
visibility: screenshotListWidth > 0 ? 'visible' : 'hidden',
}"
>
<div
v-for="{
id,
group,
top,
gridHeight,
renderedScreenshots,
virtualGridTop,
} in visibleScreenshotGroups"
:key="group.id"
class="absolute inset-x-0 transition-transform duration-300 ease-in-out will-change-transform motion-reduce:transition-none"
:style="{ transform: `translateY(${top}px)` }"
} in virtualizedScreenshotGroups"
:key="id"
>
<ScreenshotGroupSection
:id="group.id"
@@ -1514,7 +1569,6 @@ onBeforeUnmount(() => {
:show-instance-name="isGlobal && groupBy !== 'instance'"
:highlighted-screenshot-id="highlightedScreenshotId"
:copied-screenshot-ids="copiedScreenshotIds"
:animate-entry="!regrouping && !screenshotsScrolling"
:force-open="search.length > 0"
:hide-header="groupBy === 'none'"
:editable-title="Boolean(group.customGroupId)"
@@ -6,9 +6,10 @@ import {
defineMessages,
InlineEditableText,
TagItem,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { nextTick, ref, watch } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
@@ -37,6 +38,7 @@ const emit = defineEmits<{
}>()
const { formatMessage } = useVIntl()
const debugLayout = useDebugLogger('Screenshots:Section')
const accordion = ref<InstanceType<typeof Accordion>>()
const titleInput = ref<InstanceType<typeof InlineEditableText>>()
const titleModel = ref(props.title)
@@ -46,6 +48,11 @@ const messages = defineMessages({
})
function toggle() {
debugLayout('toggle requested', {
title: props.title,
count: props.count,
isOpen: accordion.value?.isOpen,
})
if (accordion.value?.isOpen) {
accordion.value.close()
} else {
@@ -53,6 +60,30 @@ function toggle() {
}
}
function handleOpen() {
debugLayout('opened', { title: props.title, count: props.count })
emit('update:collapsed', false)
}
function handleClose() {
debugLayout('closed', { title: props.title, count: props.count })
emit('update:collapsed', true)
}
onMounted(() => {
debugLayout('mounted', {
title: props.title,
count: props.count,
collapsed: props.collapsed,
hideHeader: props.hideHeader,
forceOpen: props.forceOpen,
})
})
onBeforeUnmount(() => {
debugLayout('unmounted', { title: props.title, count: props.count })
})
async function startTitleEditing() {
if (!props.editable) return
await titleInput.value?.startEditing()
@@ -127,8 +158,8 @@ watch(
:force-open="forceOpen"
overflow-visible
class="w-full"
@on-open="emit('update:collapsed', false)"
@on-close="emit('update:collapsed', true)"
@on-open="handleOpen"
@on-close="handleClose"
>
<div class="mt-2.5">
<slot />
@@ -7,6 +7,7 @@ import {
useSavable,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
import {
@@ -14,7 +15,13 @@ import {
type FeatureFlag,
useAppSettings,
} from '@/composables/use-app-settings.ts'
import {
get_global_synced_options,
type GlobalSyncedOptions,
set_global_synced_option,
} from '@/helpers/instance.ts'
import { type AppSettings, get, set } from '@/helpers/settings.ts'
import { screenshotKeys } from '@/pages/instance/query-options.ts'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const appSettings = useAppSettings()
@@ -22,6 +29,7 @@ const { formatMessage } = useVIntl()
const auth = injectAuth()
const { updatePreferences } = injectUserPreferences()
const settingsModal = inject(appSettingsModalContextKey, null)
const queryClient = useQueryClient()
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const compactInstanceCardsFlag: FeatureFlag = 'compact_instance_cards'
@@ -51,6 +59,14 @@ const messages = defineMessages({
id: 'app.behavior-settings.content.title',
defaultMessage: 'Home and content',
},
showAllScreenshotsTitle: {
id: 'app.behavior-settings.show-all-screenshots.title',
defaultMessage: 'Show all screenshots together',
},
showAllScreenshotsDescription: {
id: 'app.behavior-settings.show-all-screenshots.description',
defaultMessage: 'View screenshots from all your instances on the Screenshots page.',
},
confirmationsTitle: {
id: 'app.behavior-settings.confirmations.title',
defaultMessage: 'Confirmations',
@@ -137,6 +153,7 @@ type BehaviorSettingsState = {
minimizeApp: boolean
hideRightSidebar: boolean
showJumpIn: boolean
showAllScreenshots: boolean
compactInstanceCards: boolean
showPlayTime: boolean
hideNametag: boolean
@@ -144,14 +161,23 @@ type BehaviorSettingsState = {
skipNonEssentialWarnings: boolean
}
const persistedSettings = ref(await get())
const [initialSettings, initialGlobalSyncedOptions] = await Promise.all([
get(),
get_global_synced_options(),
])
const persistedSettings = ref(initialSettings)
const persistedGlobalSyncedOptions = ref(initialGlobalSyncedOptions)
function getBehaviorSettingsState(settings: AppSettings): BehaviorSettingsState {
function getBehaviorSettingsState(
settings: AppSettings,
globalSyncedOptions: GlobalSyncedOptions,
): BehaviorSettingsState {
return {
syncBehaviorAcrossDevices: settings.sync_behavior_across_devices,
minimizeApp: settings.hide_on_process_start,
hideRightSidebar: settings.toggle_sidebar,
showJumpIn: settings.feature_flags[worldsInHomeFlag] ?? DEFAULT_FEATURE_FLAGS[worldsInHomeFlag],
showAllScreenshots: globalSyncedOptions.screenshots,
compactInstanceCards:
settings.feature_flags[compactInstanceCardsFlag] ??
DEFAULT_FEATURE_FLAGS[compactInstanceCardsFlag],
@@ -169,7 +195,7 @@ function getBehaviorSettingsState(settings: AppSettings): BehaviorSettingsState
}
const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
() => getBehaviorSettingsState(persistedSettings.value),
() => getBehaviorSettingsState(persistedSettings.value, persistedGlobalSyncedOptions.value),
async () => {
const value = current.value
@@ -182,6 +208,7 @@ const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
compact_instance_cards: value.compactInstanceCards,
show_play_time: value.showPlayTime,
hide_nametag: value.hideNametag,
show_all_screenshots: value.showAllScreenshots,
warn_on_unknown_modpacks: value.warnOnUnknownModpacks,
skip_non_essential_warnings: value.skipNonEssentialWarnings,
},
@@ -204,8 +231,20 @@ const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
},
}
await set(nextSettings)
const screenshotsChanged =
value.showAllScreenshots !== persistedGlobalSyncedOptions.value.screenshots
const [, updatedGlobalSyncedOptions] = await Promise.all([
set(nextSettings),
screenshotsChanged
? set_global_synced_option('screenshots', value.showAllScreenshots)
: Promise.resolve(persistedGlobalSyncedOptions.value),
])
persistedSettings.value = nextSettings
persistedGlobalSyncedOptions.value = updatedGlobalSyncedOptions
queryClient.setQueryData(['global-synced-options'], updatedGlobalSyncedOptions)
if (screenshotsChanged) {
await queryClient.invalidateQueries({ queryKey: screenshotKeys.all })
}
appSettings.setBehaviorSyncAcrossDevices(value.syncBehaviorAcrossDevices)
appSettings.toggleSidebar = value.hideRightSidebar
appSettings.hideNametagSkinsPage = value.hideNametag
@@ -302,6 +341,18 @@ onBeforeUnmount(() => {
{{ formatMessage(messages.contentTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.showAllScreenshotsTitle) }}
</h3>
<p class="m-0 mt-1">
{{ formatMessage(messages.showAllScreenshotsDescription) }}
</p>
</div>
<Toggle id="show-all-screenshots" v-model="current.showAllScreenshots" />
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
@@ -2,11 +2,15 @@
import {
EditIcon,
// FolderOpenIcon,
RefreshCwIcon,
SaveIcon,
SearchIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
Button,
CheckCircleButton,
commonMessages,
defineMessages,
IconButton,
@@ -26,7 +30,9 @@ import useMemorySlider from '@/composables/useMemorySlider'
import {
get_command_history,
get_global_synced_options,
getInstanceIconUrl,
type GlobalSyncedOptions,
list as listInstances,
list_synced_servers,
// open_synced_options_folder,
remove_synced_server,
@@ -43,7 +49,7 @@ import {
type ServerData,
type ServerWorld,
} from '@/helpers/worlds.ts'
import { instanceKeys, screenshotKeys } from '@/pages/instance/query-options'
import { instanceKeys } from '@/pages/instance/query-options'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -61,35 +67,55 @@ const messages = defineMessages({
// },
multiplayerServers: {
id: 'app.settings.synced-options.multiplayer-servers',
defaultMessage: 'Multiplayer servers',
defaultMessage: 'Sync multiplayer servers',
},
multiplayerServersDescription: {
id: 'app.settings.synced-options.multiplayer-servers.description',
defaultMessage: 'Sync multiplayer servers across your instances.',
defaultMessage: 'Use the same multiplayer servers across your instances.',
},
commandHistory: {
id: 'app.settings.synced-options.command-history',
defaultMessage: 'Command history',
defaultMessage: 'Sync command history',
},
commandHistoryDescription: {
id: 'app.settings.synced-options.command-history.description',
defaultMessage: 'Sync command history across your instances.',
defaultMessage: 'Use the same command history across your instances.',
},
creativeHotbars: {
id: 'app.settings.synced-options.creative-hotbars',
defaultMessage: 'Saved creative hotbars',
defaultMessage: 'Sync saved creative hotbars',
},
creativeHotbarsDescription: {
id: 'app.settings.synced-options.creative-hotbars.description',
defaultMessage: 'Sync saved creative hotbars across your instances.',
defaultMessage: 'Use the same saved creative hotbars across your instances.',
},
screenshots: {
id: 'app.settings.synced-options.screenshots',
defaultMessage: 'Screenshots',
chooseSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.title',
defaultMessage: 'Choose a sync source',
},
screenshotsDescription: {
id: 'app.settings.synced-options.screenshots.description',
defaultMessage: 'View screenshots from your instances in one place.',
multiplayerServersSyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.multiplayer-servers-description',
defaultMessage: 'Pick the instance whose multiplayer servers become the shared copy.',
},
commandHistorySyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.command-history-description',
defaultMessage: 'Pick the instance whose command history becomes the shared copy.',
},
creativeHotbarsSyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.creative-hotbars-description',
defaultMessage: 'Pick the instance whose saved creative hotbars become the shared copy.',
},
searchInstance: {
id: 'app.settings.synced-options.choose-sync-source.search-placeholder',
defaultMessage: 'Search instance',
},
noInstancesFound: {
id: 'app.settings.synced-options.choose-sync-source.no-instances-found',
defaultMessage: 'No instances found',
},
syncButton: {
id: 'app.settings.synced-options.choose-sync-source.sync',
defaultMessage: 'Sync',
},
commandHistoryEditorTitle: {
id: 'app.settings.synced-options.command-history.editor-title',
@@ -285,11 +311,6 @@ const globalRows: Array<{
title: 'creativeHotbars',
description: 'creativeHotbarsDescription',
},
{
option: 'screenshots',
title: 'screenshots',
description: 'screenshotsDescription',
},
]
const globalSyncedOptionsQueryKey = ['global-synced-options'] as const
@@ -306,6 +327,11 @@ const globalOptionsQuery = useQuery({
queryFn: get_global_synced_options,
})
const globalOptions = computed(() => globalOptionsQuery.data.value ?? defaultGlobalOptions)
const instances = ref(await listInstances().catch(() => []))
const baseOption = ref<SyncedOption | null>(null)
const baseInstanceId = ref(instances.value[0]?.id ?? '')
const baseInstanceSearch = ref('')
const baseModal = ref<InstanceType<typeof NewModal> | null>(null)
const commandHistoryModal = ref<InstanceType<typeof NewModal> | null>(null)
const serverEditorModal = ref<InstanceType<typeof NewModal> | null>(null)
const editServerModal = ref<InstanceType<typeof NewModal> | null>(null)
@@ -340,24 +366,43 @@ const syncedServerCards = computed(() =>
})),
)
const baseInstanceDescription = computed(() => {
switch (baseOption.value) {
case 'multiplayer_servers':
return formatMessage(messages.multiplayerServersSyncSourceDescription)
case 'command_history':
return formatMessage(messages.commandHistorySyncSourceDescription)
case 'creative_hotbars':
return formatMessage(messages.creativeHotbarsSyncSourceDescription)
default:
return ''
}
})
const filteredBaseInstances = computed(() => {
const search = baseInstanceSearch.value.trim().toLowerCase()
if (!search) return instances.value
return instances.value.filter((instance) => instance.name.toLowerCase().includes(search))
})
async function invalidateSyncedOptions() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: instanceKeys.all }),
queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }),
queryClient.invalidateQueries({ queryKey: globalSyncedOptionsQueryKey }),
queryClient.invalidateQueries({ queryKey: screenshotKeys.all }),
])
}
type GlobalOptionMutationVariables = {
option: SyncedOption
enabled: boolean
baseInstanceId?: string
}
const globalOptionMutation = useMutation({
mutationKey: globalSyncedOptionsMutationKey,
mutationFn: ({ option, enabled }: GlobalOptionMutationVariables) =>
set_global_synced_option(option, enabled),
mutationFn: ({ option, enabled, baseInstanceId }: GlobalOptionMutationVariables) =>
set_global_synced_option(option, enabled, baseInstanceId),
onMutate: async ({ option, enabled }) => {
await queryClient.cancelQueries({ queryKey: globalSyncedOptionsQueryKey })
const previous = globalOptions.value[option]
@@ -376,6 +421,15 @@ const globalOptionMutation = useMutation({
}))
handleError(error)
},
onSuccess: async (_options, { option, enabled }) => {
if (enabled) baseModal.value?.hide()
if (enabled && option === 'multiplayer_servers') {
syncedServers.value = await list_synced_servers().catch((error) => {
handleError(error)
return []
})
}
},
onSettled: async () => {
if (queryClient.isMutating({ mutationKey: globalSyncedOptionsMutationKey }) === 1) {
await invalidateSyncedOptions()
@@ -383,12 +437,25 @@ const globalOptionMutation = useMutation({
},
})
function applyGlobalOption(option: SyncedOption, enabled: boolean) {
globalOptionMutation.mutate({ option, enabled })
function applyGlobalOption(option: SyncedOption, enabled: boolean, baseInstanceId?: string) {
globalOptionMutation.mutate({ option, enabled, baseInstanceId })
}
function toggleGlobalOption(option: SyncedOption, enabled: boolean) {
applyGlobalOption(option, enabled)
if (!enabled) {
applyGlobalOption(option, false)
return
}
baseOption.value = option
baseInstanceId.value = instances.value[0]?.id ?? ''
baseInstanceSearch.value = ''
baseModal.value?.show()
}
function confirmBaseInstance() {
if (!baseOption.value || !baseInstanceId.value) return
applyGlobalOption(baseOption.value, true, baseInstanceId.value)
}
async function openCommandHistoryEditor() {
@@ -502,6 +569,79 @@ watch(
<template>
<div>
<NewModal
ref="baseModal"
:header="formatMessage(messages.chooseSyncSourceTitle)"
no-padding
actions-divider
max-width="560px"
width="560px"
>
<p class="m-0 border-0 border-b border-solid border-surface-5 p-6 text-primary">
{{ baseInstanceDescription }}
</p>
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 px-6 py-4">
<Input
v-model="baseInstanceSearch"
:icon="SearchIcon"
type="search"
autocomplete="off"
:placeholder="formatMessage(messages.searchInstance)"
class="shrink-0"
/>
<div
v-if="filteredBaseInstances.length === 0"
class="flex flex-1 items-center justify-center text-secondary"
>
{{ formatMessage(messages.noInstancesFound) }}
</div>
<div
v-else
role="radiogroup"
:aria-label="formatMessage(messages.chooseSyncSourceTitle)"
class="flex flex-col gap-1"
>
<CheckCircleButton
v-for="instance in filteredBaseInstances"
:key="instance.id"
:checked="baseInstanceId === instance.id"
class="h-10"
@click="baseInstanceId = instance.id"
>
<span class="size-5 shrink-0 overflow-hidden rounded-[6px]">
<Avatar
:src="getInstanceIconUrl(instance.icon_path)"
:alt="instance.name"
:tint-by="instance.id"
size="1.25rem"
no-shadow
/>
</span>
<span class="truncate">{{ instance.name }}</span>
</CheckCircleButton>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2 p-2">
<Button type="outlined" @click="baseModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="!baseInstanceId || globalOptionMutation.isPending.value"
@click="confirmBaseInstance"
>
<RefreshCwIcon />
{{ formatMessage(messages.syncButton) }}
</Button>
</div>
</template>
</NewModal>
<NewModal
ref="commandHistoryModal"
:header="formatMessage(messages.commandHistoryEditorTitle)"
@@ -338,10 +338,12 @@ export async function get_global_synced_options(): Promise<GlobalSyncedOptions>
export async function set_global_synced_option(
option: SyncedOption,
enabled: boolean,
baseInstanceId?: string,
): Promise<GlobalSyncedOptions> {
return await invoke('plugin:instance|instance_set_global_synced_option', {
option,
enabled,
baseInstanceId,
})
}
+5
View File
@@ -43,6 +43,11 @@ export type GameInstance = {
force_fullscreen?: boolean
game_resolution?: [number, number]
hooks: Hooks
visible_tabs: {
files: boolean
worlds: boolean
screenshots: boolean
}
}
export type IconBackground =
+74 -44
View File
@@ -215,6 +215,12 @@
"app.behavior-settings.content.title": {
"message": "Home and content"
},
"app.behavior-settings.show-all-screenshots.description": {
"message": "View screenshots from all your instances on the Screenshots page."
},
"app.behavior-settings.show-all-screenshots.title": {
"message": "Show all screenshots together"
},
"app.behavior-settings.startup-and-navigation.title": {
"message": "Startup and navigation"
},
@@ -1691,29 +1697,50 @@
"app.settings.sidebar.label.instances": {
"message": "Instances"
},
"app.settings.synced-options.choose-sync-source.command-history-description": {
"message": "Pick the instance whose command history becomes the shared copy."
},
"app.settings.synced-options.choose-sync-source.creative-hotbars-description": {
"message": "Pick the instance whose saved creative hotbars become the shared copy."
},
"app.settings.synced-options.choose-sync-source.multiplayer-servers-description": {
"message": "Pick the instance whose multiplayer servers become the shared copy."
},
"app.settings.synced-options.choose-sync-source.no-instances-found": {
"message": "No instances found"
},
"app.settings.synced-options.choose-sync-source.search-placeholder": {
"message": "Search instance"
},
"app.settings.synced-options.choose-sync-source.sync": {
"message": "Sync"
},
"app.settings.synced-options.choose-sync-source.title": {
"message": "Choose a sync source"
},
"app.settings.synced-options.command-history": {
"message": "Command history"
"message": "Sync command history"
},
"app.settings.synced-options.command-history.description": {
"message": "Sync command history across your instances."
"message": "Use the same command history across your instances."
},
"app.settings.synced-options.command-history.editor-title": {
"message": "Edit command history"
},
"app.settings.synced-options.creative-hotbars": {
"message": "Saved creative hotbars"
"message": "Sync saved creative hotbars"
},
"app.settings.synced-options.creative-hotbars.description": {
"message": "Sync saved creative hotbars across your instances."
"message": "Use the same saved creative hotbars across your instances."
},
"app.settings.synced-options.multiplayer-servers": {
"message": "Multiplayer servers"
"message": "Sync multiplayer servers"
},
"app.settings.synced-options.multiplayer-servers.address": {
"message": "Server address"
},
"app.settings.synced-options.multiplayer-servers.description": {
"message": "Sync multiplayer servers across your instances."
"message": "Use the same multiplayer servers across your instances."
},
"app.settings.synced-options.multiplayer-servers.editor-title": {
"message": "Edit synced servers"
@@ -1727,12 +1754,6 @@
"app.settings.synced-options.multiplayer-servers.none-synced-yet": {
"message": "No servers synced yet"
},
"app.settings.synced-options.screenshots": {
"message": "Screenshots"
},
"app.settings.synced-options.screenshots.description": {
"message": "View screenshots from your instances in one place."
},
"app.settings.tabs.appearance": {
"message": "Appearance"
},
@@ -2492,6 +2513,21 @@
"instance.settings.sharing.revoke-invite.header": {
"message": "Revoke invite"
},
"instance.settings.tabs.behavior.description": {
"message": "Choose which tabs appear on this instance."
},
"instance.settings.tabs.behavior.files": {
"message": "Show Files tab"
},
"instance.settings.tabs.behavior.screenshots": {
"message": "Show Screenshots tab"
},
"instance.settings.tabs.behavior.screenshots.required": {
"message": "The Screenshots tab cannot be hidden while the global Screenshots page is turned off."
},
"instance.settings.tabs.behavior.worlds": {
"message": "Show Worlds tab"
},
"instance.settings.tabs.general": {
"message": "General"
},
@@ -2568,7 +2604,7 @@
"message": "Select update channel"
},
"instance.settings.tabs.hooks.description": {
"message": "Hooks allow advanced users to run certain system commands before and after launching the game."
"message": "Run instance-specific system commands before and after launching the game."
},
"instance.settings.tabs.hooks.post-exit": {
"message": "Post-exit"
@@ -2589,7 +2625,7 @@
"message": "Enter pre-launch command..."
},
"instance.settings.tabs.hooks.title": {
"message": "Game launch hooks"
"message": "Custom game launch hooks"
},
"instance.settings.tabs.hooks.variables.description": {
"message": "Hooks run in the working directory of the instance, with the following variables:"
@@ -2631,16 +2667,16 @@
"message": "Installation settings are unavailable while this instance is locked."
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Use custom environment variables for this instance."
"message": "Set environment variables separately for this instance."
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Use custom Java arguments for this instance."
"message": "Set Java arguments separately for this instance."
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Use a custom Java installation for this instance."
"message": "Choose a different Java installation for this instance."
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Use a custom memory allocation for this instance."
"message": "Set the memory allocation separately for this instance."
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Enter environmental variables..."
@@ -2649,19 +2685,19 @@
"message": "Enter Java arguments..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Environment variables"
"message": "Custom environment variables"
},
"instance.settings.tabs.java.hooks": {
"message": "Hooks"
},
"instance.settings.tabs.java.java-arguments": {
"message": "Java arguments"
"message": "Custom Java arguments"
},
"instance.settings.tabs.java.java-installation": {
"message": "Java installation"
"message": "Custom Java installation"
},
"instance.settings.tabs.java.java-memory": {
"message": "Memory allocated"
"message": "Custom memory allocation"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/path/to/java"
@@ -2673,22 +2709,22 @@
"message": "Sharing"
},
"instance.settings.tabs.synced-options.command-history": {
"message": "Command history"
"message": "Unsync command history"
},
"instance.settings.tabs.synced-options.command-history.disabled-in-app": {
"message": "Command history syncing is turned off in app settings."
},
"instance.settings.tabs.synced-options.command-history.exclude-description": {
"message": "Exclude this instance from command history syncing."
"instance.settings.tabs.synced-options.command-history.override-description": {
"message": "Keep this instance's command history separate from synced command history."
},
"instance.settings.tabs.synced-options.creative-hotbars": {
"message": "Saved creative hotbars"
"message": "Unsync saved creative hotbars"
},
"instance.settings.tabs.synced-options.creative-hotbars.disabled-in-app": {
"message": "Saved creative hotbar syncing is turned off in app settings."
},
"instance.settings.tabs.synced-options.creative-hotbars.exclude-description": {
"message": "Exclude this instance from saved creative hotbar syncing."
"instance.settings.tabs.synced-options.creative-hotbars.override-description": {
"message": "Keep this instance's saved creative hotbars separate from synced hotbars."
},
"instance.settings.tabs.synced-options.hotbars-conflict.backup-description": {
"message": "The version being replaced will be backed up before anything changes."
@@ -2706,34 +2742,28 @@
"message": "Use synced"
},
"instance.settings.tabs.synced-options.multiplayer-servers": {
"message": "Multiplayer servers"
"message": "Unsync multiplayer servers"
},
"instance.settings.tabs.synced-options.multiplayer-servers.disabled-in-app": {
"message": "Multiplayer server syncing is turned off in app settings."
},
"instance.settings.tabs.synced-options.multiplayer-servers.exclude-description": {
"message": "Exclude this instance from multiplayer server syncing."
"instance.settings.tabs.synced-options.multiplayer-servers.override-description": {
"message": "Keep this instance's multiplayer servers separate from synced servers."
},
"instance.settings.tabs.synced-options.open-app-settings": {
"message": "Open synced settings"
},
"instance.settings.tabs.synced-options.screenshots": {
"message": "Screenshots"
},
"instance.settings.tabs.synced-options.screenshots.disabled-in-app": {
"message": "Screenshots are turned off in app settings."
},
"instance.settings.tabs.synced-options.screenshots.exclude-description": {
"message": "Exclude this instances screenshots from the Screenshots page."
"message": "Manage synced settings"
},
"instance.settings.tabs.synced-options.shared-settings.description": {
"message": "Game settings can be shared between instances. Choose what to share in app settings."
"message": "Enable an override to keep a synced setting separate for this instance."
},
"instance.settings.tabs.tab-visibility": {
"message": "Tabs"
},
"instance.settings.tabs.window": {
"message": "Window"
"message": "Custom window settings"
},
"instance.settings.tabs.window.custom-window-settings": {
"message": "Use custom window settings for this instance."
"message": "Configure fullscreen and launch resolution separately for this instance."
},
"instance.settings.tabs.window.fullscreen": {
"message": "Fullscreen"
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { defineMessages, injectNotificationManager, Toggle, useVIntl } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { edit, get_global_synced_options } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { instanceKeys } from '../../query-options'
import { injectInstanceSettings } from './instance-settings-context'
const { instance } = injectInstanceSettings()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const messages = defineMessages({
intro: {
id: 'instance.settings.tabs.behavior.description',
defaultMessage: 'Choose which tabs appear on this instance.',
},
files: {
id: 'instance.settings.tabs.behavior.files',
defaultMessage: 'Show Files tab',
},
worlds: {
id: 'instance.settings.tabs.behavior.worlds',
defaultMessage: 'Show Worlds tab',
},
screenshots: {
id: 'instance.settings.tabs.behavior.screenshots',
defaultMessage: 'Show Screenshots tab',
},
screenshotsRequired: {
id: 'instance.settings.tabs.behavior.screenshots.required',
defaultMessage:
'The Screenshots tab cannot be hidden while the global Screenshots page is turned off.',
},
})
type InstanceTab = keyof GameInstance['visible_tabs']
const rows: Array<{
tab: InstanceTab
title: keyof typeof messages
}> = [
{ tab: 'files', title: 'files' },
{ tab: 'worlds', title: 'worlds' },
{ tab: 'screenshots', title: 'screenshots' },
]
const globalSyncedOptionsQuery = useQuery({
queryKey: ['global-synced-options'],
queryFn: get_global_synced_options,
})
const globalScreenshotsEnabled = computed(() => globalSyncedOptionsQuery.data.value?.screenshots)
const saving = ref(false)
function isTabVisible(tab: InstanceTab) {
if (tab === 'screenshots' && globalScreenshotsEnabled.value === false) return true
return instance.value.visible_tabs[tab]
}
function disabledReason(tab: InstanceTab) {
if (tab === 'screenshots' && globalScreenshotsEnabled.value === false) {
return formatMessage(messages.screenshotsRequired)
}
return undefined
}
async function setTabVisible(tab: InstanceTab, visible: boolean) {
if (disabledReason(tab) || saving.value) return
const instanceId = instance.value.id
const detailKey = instanceKeys.detail(instanceId)
const listKey = instanceKeys.list()
const previousTabs = instance.value.visible_tabs
const visibleTabs = { ...previousTabs, [tab]: visible }
const applyTabs = (current: GameInstance): GameInstance => ({
...current,
visible_tabs: visibleTabs,
})
saving.value = true
await Promise.all([
queryClient.cancelQueries({ queryKey: detailKey }),
queryClient.cancelQueries({ queryKey: listKey }),
])
queryClient.setQueryData<GameInstance>(detailKey, (current) =>
applyTabs(current ?? instance.value),
)
queryClient.setQueryData<GameInstance[]>(listKey, (instances) =>
instances?.map((candidate) => (candidate.id === instanceId ? applyTabs(candidate) : candidate)),
)
try {
await edit(instanceId, { visible_tabs: visibleTabs })
} catch (error) {
const rollbackTabs = (current: GameInstance): GameInstance => ({
...current,
visible_tabs: previousTabs,
})
queryClient.setQueryData<GameInstance>(detailKey, (current) =>
current ? rollbackTabs(current) : current,
)
queryClient.setQueryData<GameInstance[]>(listKey, (instances) =>
instances?.map((candidate) =>
candidate.id === instanceId ? rollbackTabs(candidate) : candidate,
),
)
handleError(error)
} finally {
saving.value = false
await Promise.all([
queryClient.invalidateQueries({ queryKey: detailKey }),
queryClient.invalidateQueries({ queryKey: listKey }),
])
}
}
</script>
<template>
<div class="flex flex-col gap-6">
<p class="m-0 text-secondary">
{{ formatMessage(messages.intro) }}
</p>
<div class="flex flex-col gap-4">
<div v-for="row in rows" :key="row.tab" class="flex items-center justify-between gap-6">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages[row.title]) }}
</h2>
<span v-tooltip="disabledReason(row.tab)" class="flex shrink-0">
<Toggle
:id="`show-${row.tab}-tab`"
:model-value="isTabVisible(row.tab)"
:disabled="saving || !!disabledReason(row.tab)"
@update:model-value="(visible) => setTabVisible(row.tab, visible)"
/>
</span>
</div>
</div>
</div>
</template>
@@ -7,7 +7,6 @@ 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()
@@ -28,6 +27,16 @@ const hooks = ref({
post_exit: hooksRaw.post_exit ?? '',
})
watch(overrideHooks, (enabled) => {
if (!enabled) {
hooks.value = {
pre_launch: globalSettings.hooks.pre_launch ?? '',
wrapper: globalSettings.hooks.wrapper ?? '',
post_exit: globalSettings.hooks.post_exit ?? '',
}
}
})
const editInstanceObject = computed(() => ({
hooks: overrideHooks.value
? {
@@ -52,12 +61,11 @@ watch(
const messages = defineMessages({
hooks: {
id: 'instance.settings.tabs.hooks.title',
defaultMessage: 'Game launch hooks',
defaultMessage: 'Custom game launch hooks',
},
hooksDescription: {
id: 'instance.settings.tabs.hooks.description',
defaultMessage:
'Hooks allow advanced users to run certain system commands before and after launching the game.',
defaultMessage: 'Run instance-specific system commands before and after launching the game.',
},
hookVariablesDescription: {
id: 'instance.settings.tabs.hooks.variables.description',
@@ -139,62 +147,63 @@ const messages = defineMessages({
<Toggle id="override-launch-hooks" v-model="overrideHooks" />
</div>
<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>
<div class="pt-6" :class="{ 'opacity-50': !overrideHooks }">
<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"
: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"
: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.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"
:placeholder="formatMessage(messages.postExitEnter)"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.postExitDescription) }}
</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>
<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 class="m-0 mt-6">
{{ formatMessage(messages.hookVariablesDescription) }}
</div>
</SettingsOptionsTransition>
<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>
</div>
</template>
@@ -1,6 +1,13 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ChevronRightIcon, InfoIcon, Settings2Icon, UsersIcon, WrenchIcon } from '@modrinth/assets'
import {
ChevronRightIcon,
EyeIcon,
InfoIcon,
Settings2Icon,
UsersIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
Avatar,
commonMessages,
@@ -19,6 +26,7 @@ import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import BehaviorSettings from './behavior-settings.vue'
import GeneralSettings from './general-settings.vue'
import InstallationSettings from './installation-settings.vue'
import { provideInstanceSettings } from './instance-settings-context.ts'
@@ -88,6 +96,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: WrenchIcon,
content: InstallationSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.tab-visibility',
defaultMessage: 'Tabs',
}),
icon: EyeIcon,
content: BehaviorSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.settings-overrides',
@@ -28,7 +28,6 @@ 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()
@@ -42,11 +41,14 @@ 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 : ''))
const activePath = computed(() => javaPath.value)
const javaTestPath = computed(() => (overrideJavaInstall.value ? javaPath.value : ''))
watch(overrideJavaInstall, (enabled) => {
if (enabled && !javaPath.value) {
javaPath.value = optimalJava?.path ?? ''
} else if (!enabled) {
javaPath.value = optimalJava?.path ?? ''
}
})
@@ -57,7 +59,7 @@ const hoveringTest = ref(false)
let hasInitialized = false
watch(
activePath,
javaTestPath,
(newPath) => {
if (newPath && optimalJava?.parsed_version) {
if (!hasInitialized) {
@@ -95,12 +97,30 @@ const envVars = ref(
)
const overrideMemorySettings = ref(!!instance.value.memory)
const memory = ref(instance.value.memory ?? globalSettings.memory)
const memory = ref(instance.value.memory ?? { ...globalSettings.memory })
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
}
watch(overrideJavaArgs, (enabled) => {
if (!enabled) {
javaArgs.value = globalSettings.extra_launch_args.join(' ')
}
})
watch(overrideEnvVars, (enabled) => {
if (!enabled) {
envVars.value = serializeEnvVars(globalSettings.custom_env_vars)
}
})
watch(overrideMemorySettings, (enabled) => {
if (!enabled) {
memory.value = { ...globalSettings.memory }
}
})
const editInstanceObject = computed(() => {
return {
java_path:
@@ -135,11 +155,11 @@ watch(
const messages = defineMessages({
javaInstallation: {
id: 'instance.settings.tabs.java.java-installation',
defaultMessage: 'Java installation',
defaultMessage: 'Custom Java installation',
},
customJavaInstallation: {
id: 'instance.settings.tabs.java.custom-java-installation',
defaultMessage: 'Use a custom Java installation for this instance.',
defaultMessage: 'Choose a different Java installation for this instance.',
},
javaPathPlaceholder: {
id: 'instance.settings.tabs.java.java-path-placeholder',
@@ -147,19 +167,19 @@ const messages = defineMessages({
},
javaMemory: {
id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated',
defaultMessage: 'Custom memory allocation',
},
customMemoryAllocation: {
id: 'instance.settings.tabs.java.custom-memory-allocation',
defaultMessage: 'Use a custom memory allocation for this instance.',
defaultMessage: 'Set the memory allocation separately for this instance.',
},
javaArguments: {
id: 'instance.settings.tabs.java.java-arguments',
defaultMessage: 'Java arguments',
defaultMessage: 'Custom Java arguments',
},
customJavaArguments: {
id: 'instance.settings.tabs.java.custom-java-arguments',
defaultMessage: 'Use custom Java arguments for this instance.',
defaultMessage: 'Set Java arguments separately for this instance.',
},
enterJavaArguments: {
id: 'instance.settings.tabs.java.enter-java-arguments',
@@ -167,11 +187,11 @@ const messages = defineMessages({
},
javaEnvironmentVariables: {
id: 'instance.settings.tabs.java.environment-variables',
defaultMessage: 'Environment variables',
defaultMessage: 'Custom environment variables',
},
customEnvironmentVariables: {
id: 'instance.settings.tabs.java.custom-environment-variables',
defaultMessage: 'Use custom environment variables for this instance.',
defaultMessage: 'Set environment variables separately for this instance.',
},
enterEnvironmentVariables: {
id: 'instance.settings.tabs.java.enter-environment-variables',
@@ -198,90 +218,89 @@ const messages = defineMessages({
</div>
<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"
<div class="pt-3" :class="{ 'opacity-50': !overrideJavaInstall }">
<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
>
<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
<div class="flex gap-2 items-center">
<Input
:model-value="activePath"
autocomplete="off"
:disabled="!overrideJavaInstall"
:placeholder="formatMessage(messages.javaPathPlaceholder)"
wrapper-class="flex-1 min-w-0"
@update:model-value="(val) => (javaPath = String(val))"
/>
<Button
type="quiet"
:color="
overrideJavaInstall && !hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: undefined
"
:disabled="!overrideJavaInstall || testingJava"
:style="{
'--legacy-button-color':
(overrideJavaInstall && !hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: undefined
"
:disabled="testingJava"
:style="{
'--legacy-button-color':
(!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: '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>
: 'standard') &&
(overrideJavaInstall && !hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard') !== 'standard'
? `var(--color-${
overrideJavaInstall && !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="overrideJavaInstall && javaTestResult === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon
v-else-if="overrideJavaInstall && javaTestResult !== true && !hoveringTest"
class="h-4 w-4"
/>
<RefreshCwIcon v-else class="h-4 w-4" />
</Button>
</div>
<div class="flex gap-2">
<Button :disabled="!overrideJavaInstall" @click="handleDetectJava">
<SearchIcon />
Detect
</Button>
<Button :disabled="!overrideJavaInstall" @click="handleBrowseJava">
<FolderSearchIcon />
Browse
</Button>
</div>
</div>
</div>
</div>
</SettingsOptionsTransition>
</div>
</section>
<section class="flex flex-col">
@@ -294,20 +313,19 @@ const messages = defineMessages({
</div>
<Toggle id="override-memory-allocation" v-model="overrideMemorySettings" />
</div>
<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>
<div class="pt-3">
<Slider
id="max-memory"
v-model="memory.maximum"
:disabled="!overrideMemorySettings"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
</div>
</section>
<section class="flex flex-col">
@@ -320,17 +338,16 @@ const messages = defineMessages({
</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>
<div class="pt-3" :class="{ 'opacity-50': !overrideJavaArgs }">
<Input
id="java-args"
v-model="javaArgs"
autocomplete="off"
:disabled="!overrideJavaArgs"
:placeholder="formatMessage(messages.enterJavaArguments)"
wrapper-class="w-full"
/>
</div>
</section>
<section class="flex flex-col">
@@ -343,17 +360,16 @@ const messages = defineMessages({
</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>
<div class="pt-3" :class="{ 'opacity-50': !overrideEnvVars }">
<Input
id="env-vars"
v-model="envVars"
autocomplete="off"
:disabled="!overrideEnvVars"
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
wrapper-class="w-full"
/>
</div>
</section>
</div>
</template>
@@ -1,11 +1,5 @@
<script setup lang="ts">
import {
EditIcon,
RefreshCwIcon,
RotateCounterClockwiseIcon,
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { EditIcon, RefreshCwIcon, RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
@@ -17,7 +11,6 @@ import {
} 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,
@@ -29,7 +22,7 @@ import {
import type { GameInstance } from '@/helpers/types'
import { appSettingsModalOpenSyncedOptionsKey } from '@/providers/app-settings-modal'
import { instanceKeys, screenshotKeys } from '../../query-options'
import { instanceKeys } from '../../query-options'
import HooksSettings from './hooks-settings.vue'
import { injectInstanceSettings } from './instance-settings-context'
import JavaSettings from './java-settings.vue'
@@ -39,27 +32,24 @@ 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.',
defaultMessage: 'Enable an override to keep a synced setting separate for this instance.',
},
openSyncedOptions: {
id: 'instance.settings.tabs.synced-options.open-app-settings',
defaultMessage: 'Open synced settings',
defaultMessage: 'Manage synced settings',
},
multiplayerServers: {
id: 'instance.settings.tabs.synced-options.multiplayer-servers',
defaultMessage: 'Multiplayer servers',
defaultMessage: 'Unsync multiplayer servers',
},
multiplayerServersDescription: {
id: 'instance.settings.tabs.synced-options.multiplayer-servers.exclude-description',
defaultMessage: 'Exclude this instance from multiplayer server syncing.',
id: 'instance.settings.tabs.synced-options.multiplayer-servers.override-description',
defaultMessage: "Keep this instance's multiplayer servers separate from synced servers.",
},
multiplayerServersDisabled: {
id: 'instance.settings.tabs.synced-options.multiplayer-servers.disabled-in-app',
@@ -67,11 +57,11 @@ const messages = defineMessages({
},
commandHistory: {
id: 'instance.settings.tabs.synced-options.command-history',
defaultMessage: 'Command history',
defaultMessage: 'Unsync command history',
},
commandHistoryDescription: {
id: 'instance.settings.tabs.synced-options.command-history.exclude-description',
defaultMessage: 'Exclude this instance from command history syncing.',
id: 'instance.settings.tabs.synced-options.command-history.override-description',
defaultMessage: "Keep this instance's command history separate from synced command history.",
},
commandHistoryDisabled: {
id: 'instance.settings.tabs.synced-options.command-history.disabled-in-app',
@@ -79,28 +69,16 @@ const messages = defineMessages({
},
creativeHotbars: {
id: 'instance.settings.tabs.synced-options.creative-hotbars',
defaultMessage: 'Saved creative hotbars',
defaultMessage: 'Unsync saved creative hotbars',
},
creativeHotbarsDescription: {
id: 'instance.settings.tabs.synced-options.creative-hotbars.exclude-description',
defaultMessage: 'Exclude this instance from saved creative hotbar syncing.',
id: 'instance.settings.tabs.synced-options.creative-hotbars.override-description',
defaultMessage: "Keep this instance's saved creative hotbars separate from synced hotbars.",
},
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',
@@ -124,15 +102,16 @@ const messages = defineMessages({
},
})
const globalDisabledMessages: Record<SyncedOption, keyof typeof messages> = {
type InstanceSyncedOption = Exclude<SyncedOption, 'screenshots'>
const globalDisabledMessages: Record<InstanceSyncedOption, keyof typeof messages> = {
multiplayer_servers: 'multiplayerServersDisabled',
command_history: 'commandHistoryDisabled',
creative_hotbars: 'creativeHotbarsDisabled',
screenshots: 'screenshotsDisabled',
}
const rows: Array<{
option: SyncedOption
option: InstanceSyncedOption
title: keyof typeof messages
description?: keyof typeof messages
}> = [
@@ -151,11 +130,6 @@ const rows: Array<{
title: 'creativeHotbars',
description: 'creativeHotbarsDescription',
},
{
option: 'screenshots',
title: 'screenshots',
description: 'screenshotsDescription',
},
]
const overviewQuery = useQuery(
@@ -173,16 +147,29 @@ const capabilities = computed(
),
)
const hotbarResolutionModal = ref<InstanceType<typeof NewModal> | null>(null)
const previewingOption = ref<SyncedOption | null>(null)
const previewingOption = ref<InstanceSyncedOption | null>(null)
const previewExcluded = ref<Partial<Record<InstanceSyncedOption, boolean>>>({})
function excluded(option: InstanceSyncedOption): boolean {
const preview = previewExcluded.value[option]
if (preview !== undefined) return preview
function excluded(option: SyncedOption): boolean {
return (
overviewQuery.data.value?.global_options[option] === true &&
!instance.value.synced_options[option]
)
}
function disabledReason(option: SyncedOption): string | undefined {
function setPreviewExcluded(option: InstanceSyncedOption, value?: boolean) {
if (value === undefined) {
const { [option]: _, ...next } = previewExcluded.value
previewExcluded.value = next
} else {
previewExcluded.value = { ...previewExcluded.value, [option]: value }
}
}
function disabledReason(option: InstanceSyncedOption): string | undefined {
if (overviewQuery.data.value?.global_options[option] === false) {
return formatMessage(messages[globalDisabledMessages[option]])
}
@@ -194,52 +181,97 @@ function showAppSyncedOptions(): void {
openAppSettingsSyncedOptions()
}
type SyncedOptionMutationVariables = {
option: InstanceSyncedOption
enabled: boolean
resolution?: SyncedOptionJoinResolution
}
const mutationKey = ['instance-synced-options', 'set', instance.value.id] as const
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) =>
mutationKey,
mutationFn: ({ option, enabled, resolution }: SyncedOptionMutationVariables) =>
set_synced_option(instance.value.id, option, enabled, resolution),
onMutate: async ({ option, enabled }) => {
const instanceId = instance.value.id
const detailKey = instanceKeys.detail(instanceId)
const listKey = instanceKeys.list()
await Promise.all([
queryClient.cancelQueries({ queryKey: detailKey }),
queryClient.cancelQueries({ queryKey: listKey }),
])
const previousEnabled = instance.value.synced_options[option]
const applyOption = (current: GameInstance): GameInstance => ({
...current,
synced_options: {
...current.synced_options,
[option]: enabled,
},
})
queryClient.setQueryData<GameInstance>(detailKey, (current) =>
applyOption(current ?? instance.value),
)
queryClient.setQueryData<GameInstance[]>(listKey, (instances) =>
instances?.map((candidate) =>
candidate.id === updatedInstance.id ? updatedInstance : candidate,
candidate.id === instanceId ? applyOption(candidate) : candidate,
),
)
await queryClient.invalidateQueries({
queryKey: ['instance-synced-options', updatedInstance.id],
})
setPreviewExcluded(option)
return { instanceId, previousEnabled }
},
onSuccess: () => {
hotbarResolutionModal.value?.hide()
},
onError: (error, { option }, context) => {
if (context) {
const rollbackOption = (current: GameInstance): GameInstance => ({
...current,
synced_options: {
...current.synced_options,
[option]: context.previousEnabled,
},
})
queryClient.setQueryData<GameInstance>(instanceKeys.detail(context.instanceId), (current) =>
current ? rollbackOption(current) : current,
)
queryClient.setQueryData<GameInstance[]>(instanceKeys.list(), (instances) =>
instances?.map((candidate) =>
candidate.id === context.instanceId ? rollbackOption(candidate) : candidate,
),
)
}
setPreviewExcluded(option)
handleError(error)
},
onSettled: async (_data, _error, variables) => {
if (variables.option === 'multiplayer_servers') {
await queryClient.invalidateQueries({
queryKey: instanceKeys.worlds(updatedInstance.id),
queryKey: instanceKeys.worlds(instance.value.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('/')
}
if (queryClient.isMutating({ mutationKey }) === 1) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: instanceKeys.detail(instance.value.id) }),
queryClient.invalidateQueries({ queryKey: instanceKeys.list() }),
queryClient.invalidateQueries({
queryKey: ['instance-synced-options', instance.value.id],
}),
])
}
},
onError: handleError,
})
async function setExcluded(option: SyncedOption, nextExcluded: boolean) {
async function setExcluded(option: InstanceSyncedOption, nextExcluded: boolean) {
const enabled = !nextExcluded
if (!enabled || option !== 'creative_hotbars') {
mutation.mutate({ option, enabled })
return
}
setPreviewExcluded(option, nextExcluded)
previewingOption.value = option
try {
const preview = await get_synced_option_join_preview(instance.value.id, option)
@@ -249,12 +281,18 @@ async function setExcluded(option: SyncedOption, nextExcluded: boolean) {
mutation.mutate({ option, enabled })
}
} catch (error) {
setPreviewExcluded(option)
handleError(error)
} finally {
previewingOption.value = null
}
}
function cancelHotbarResolution() {
setPreviewExcluded('creative_hotbars')
hotbarResolutionModal.value?.hide()
}
function resolveHotbars(resolution: SyncedOptionJoinResolution) {
mutation.mutate({
option: 'creative_hotbars',
@@ -271,6 +309,7 @@ function resolveHotbars(resolution: SyncedOptionJoinResolution) {
:header="formatMessage(messages.hotbarConflictTitle)"
fade="warning"
max-width="560px"
@hide="setPreviewExcluded('creative_hotbars')"
>
<div class="flex flex-col gap-3 text-primary">
<p class="m-0">
@@ -289,7 +328,7 @@ function resolveHotbars(resolution: SyncedOptionJoinResolution) {
<Button
type="outlined"
:disabled="mutation.isPending.value"
@click="hotbarResolutionModal?.hide()"
@click="cancelHotbarResolution"
>
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
@@ -336,20 +375,12 @@ function resolveHotbars(resolution: SyncedOptionJoinResolution) {
{{ 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"
/>
<div class="flex shrink-0 items-center">
<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)
@@ -7,7 +7,6 @@ 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()
@@ -26,6 +25,13 @@ const fullscreenSetting: Ref<boolean> = ref(
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
)
watch(overrideWindowSettings, (enabled) => {
if (!enabled) {
resolution.value = globalSettings.game_resolution.slice() as [number, number]
fullscreenSetting.value = globalSettings.force_fullscreen
}
})
const editInstanceObject = computed(() => {
if (!overrideWindowSettings.value) {
return {
@@ -50,11 +56,11 @@ watch(
const messages = defineMessages({
window: {
id: 'instance.settings.tabs.window',
defaultMessage: 'Window',
defaultMessage: 'Custom window settings',
},
customWindowSettings: {
id: 'instance.settings.tabs.window.custom-window-settings',
defaultMessage: 'Use custom window settings for this instance.',
defaultMessage: 'Configure fullscreen and launch resolution separately for this instance.',
},
fullscreen: {
id: 'instance.settings.tabs.window.fullscreen',
@@ -102,58 +108,56 @@ const messages = defineMessages({
</div>
<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="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>
<Input
id="height"
v-model="resolution[1]"
autocomplete="off"
:disabled="fullscreenSetting"
type="number"
:placeholder="formatMessage(messages.enterHeight)"
/>
<div class="flex flex-col gap-6 pt-6" :class="{ 'opacity-50': !overrideWindowSettings }">
<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" :disabled="!overrideWindowSettings" />
</div>
</SettingsOptionsTransition>
<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.height) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.heightDescription) }}
</p>
</div>
<Input
id="height"
v-model="resolution[1]"
autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting"
type="number"
:placeholder="formatMessage(messages.enterHeight)"
/>
</div>
</div>
</div>
</template>
+23 -18
View File
@@ -485,34 +485,39 @@ const tabs = computed(() => {
href: `${basePath.value}`,
icon: BoxesIcon,
},
{
]
if (instance.value?.visible_tabs.files !== false) {
instanceTabs.push({
label: formatMessage(messages.filesTab),
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
},
{
label: formatMessage(messages.worldsTab),
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
},
{
label: formatMessage(messages.logsTab),
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
},
]
})
}
const screenshotsSynced =
globalSyncedOptionsQuery.data.value?.screenshots === true &&
instance.value?.synced_options.screenshots === true
if (!screenshotsSynced) {
instanceTabs.splice(2, 0, {
const screenshotsGloballyAvailable = globalSyncedOptionsQuery.data.value?.screenshots === true
if (!screenshotsGloballyAvailable || instance.value?.visible_tabs.screenshots !== false) {
instanceTabs.push({
label: formatMessage(messages.screenshotsTab),
href: `${basePath.value}/screenshots`,
icon: ImagesIcon,
})
}
if (instance.value?.visible_tabs.worlds !== false) {
instanceTabs.push({
label: formatMessage(messages.worldsTab),
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
})
}
instanceTabs.push({
label: formatMessage(messages.logsTab),
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
})
if (showShareTab.value) {
instanceTabs.push({
label: formatMessage(messages.shareTab),
+1
View File
@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
+37 -8
View File
@@ -2,7 +2,7 @@ use crate::api::Result;
use dashmap::DashMap;
use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Runtime};
use tauri_plugin_fs::FsExt;
@@ -12,7 +12,8 @@ use theseus::data::{
AppliedContentSetPatch, ContentItem, Dependency,
EditInstance as CoreEditInstance, InstanceInstallCandidate,
InstanceInstallTarget, InstanceLaunchOverridesPatch,
InstanceLink as CoreInstanceLink, InstanceMetadata, LinkedModpackInfo,
InstanceLink as CoreInstanceLink, InstanceMetadata, InstanceTabVisibility,
LinkedModpackInfo,
SharedInstanceAttachment as CoreSharedInstanceAttachment,
SharedInstanceRole,
};
@@ -140,6 +141,7 @@ pub struct Instance {
pub force_fullscreen: Option<bool>,
pub game_resolution: Option<WindowSize>,
pub hooks: Hooks,
pub visible_tabs: InstanceTabVisibility,
}
#[derive(Serialize, Debug, Clone)]
@@ -280,6 +282,7 @@ pub struct EditInstance {
)]
pub game_resolution: Option<Option<WindowSize>>,
pub hooks: Option<Hooks>,
pub visible_tabs: Option<InstanceTabVisibility>,
}
impl From<InstanceMetadata> for Instance {
@@ -318,6 +321,7 @@ impl From<InstanceMetadata> for Instance {
force_fullscreen: metadata.launch_overrides.force_fullscreen,
game_resolution: metadata.launch_overrides.game_resolution,
hooks: metadata.launch_overrides.hooks,
visible_tabs: metadata.launch_overrides.visible_tabs,
}
}
}
@@ -486,6 +490,7 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
force_fullscreen: edit_instance.force_fullscreen,
game_resolution: edit_instance.game_resolution,
hooks: edit_instance.hooks,
visible_tabs: edit_instance.visible_tabs,
}),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
@@ -839,8 +844,14 @@ pub async fn instance_get_global_synced_options()
pub async fn instance_set_global_synced_option(
option: InstanceSyncedOption,
enabled: bool,
base_instance_id: Option<String>,
) -> Result<theseus::instance::GlobalSyncedOptions> {
Ok(theseus::instance::set_global_synced_option(option, enabled).await?)
Ok(theseus::instance::set_global_synced_option(
option,
enabled,
base_instance_id.as_deref(),
)
.await?)
}
#[tauri::command]
@@ -900,13 +911,25 @@ fn serialize_screenshots<R: Runtime>(
app_handle: &AppHandle<R>,
screenshots: Vec<theseus::instance::InstanceScreenshot>,
) -> Result<Vec<InstanceScreenshot>> {
let mut result = Vec::with_capacity(screenshots.len());
for screenshot in screenshots {
result.push(serialize_screenshot(app_handle, screenshot)?);
let screenshot_directories = screenshots
.iter()
.filter_map(|screenshot| screenshot.path.parent())
.collect::<HashSet<_>>();
for directory in screenshot_directories {
app_handle
.asset_protocol_scope()
.allow_directory(directory, false)
.map_err(|error| std::io::Error::other(error.to_string()))?;
app_handle
.fs_scope()
.allow_directory(directory, false)
.map_err(|error| std::io::Error::other(error.to_string()))?;
}
Ok(result)
screenshots
.into_iter()
.map(serialize_screenshot_data)
.collect()
}
fn serialize_screenshot<R: Runtime>(
@@ -921,6 +944,12 @@ fn serialize_screenshot<R: Runtime>(
.fs_scope()
.allow_file(&screenshot.path)
.map_err(|error| std::io::Error::other(error.to_string()))?;
serialize_screenshot_data(screenshot)
}
fn serialize_screenshot_data(
screenshot: theseus::instance::InstanceScreenshot,
) -> Result<InstanceScreenshot> {
let mut url = super::utils::tauri_convert_file_src(&screenshot.path)?;
url.query_pairs_mut()
.append_pair("revision", &screenshot.modified_at.to_string());
@@ -44,6 +44,7 @@ pub struct BehaviorPreferences {
pub compact_instance_cards: bool,
pub show_play_time: bool,
pub hide_nametag: bool,
pub show_all_screenshots: bool,
pub warn_on_unknown_modpacks: bool,
pub skip_non_essential_warnings: bool,
}
@@ -57,6 +58,7 @@ impl Default for BehaviorPreferences {
compact_instance_cards: false,
show_play_time: true,
hide_nametag: false,
show_all_screenshots: true,
warn_on_unknown_modpacks: true,
skip_non_essential_warnings: false,
}