mirror of
https://github.com/modrinth/code.git
synced 2026-08-29 02:54:51 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bee2f83d8 | ||
|
|
7823f83d04 | ||
|
|
a6a9139019 | ||
|
|
b9c3b363e8 | ||
|
|
0ab9100c46 | ||
|
|
a1b73d089e | ||
|
|
c2cbf16434 | ||
|
|
ff4c9be910 | ||
|
|
98f8bbcad9 |
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Build frontend
|
||||
run: pnpm web:build
|
||||
env:
|
||||
NITRO_PRESET: node-server
|
||||
NITRO_PRESET: node_cluster
|
||||
BUILD_ENV: ${{ steps.meta.outputs.env }}
|
||||
CF_PAGES_BRANCH: ${{ github.ref_name }}
|
||||
CF_PAGES_COMMIT_SHA: ${{ github.sha }}
|
||||
|
||||
@@ -3,16 +3,11 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
|
||||
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
|
||||
import {
|
||||
defineMessages,
|
||||
IconButton,
|
||||
useDebugLogger,
|
||||
useFormatDateTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { defineMessages, IconButton, useFormatDateTime, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
const loadedScreenshotUrls = new Set<string>()
|
||||
|
||||
const props = defineProps<{
|
||||
screenshot: InstanceScreenshot
|
||||
@@ -34,12 +29,9 @@ const emit = defineEmits<{
|
||||
|
||||
const card = ref<HTMLElement>()
|
||||
const image = ref<HTMLImageElement>()
|
||||
const imageReady = ref(false)
|
||||
const loaded = ref(loadedScreenshotUrls.has(props.screenshot.url))
|
||||
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}' },
|
||||
@@ -77,86 +69,19 @@ function activate(event: MouseEvent | KeyboardEvent) {
|
||||
emit('activate', event)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
function markImageLoaded() {
|
||||
loadedScreenshotUrls.add(props.screenshot.url)
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
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, previousUrl) => {
|
||||
loadGeneration += 1
|
||||
loadStartedAt = performance.now()
|
||||
imageReady.value = false
|
||||
debugImage('source changed', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
previousUrl,
|
||||
url,
|
||||
})
|
||||
(url) => {
|
||||
loaded.value = loadedScreenshotUrls.has(url)
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -166,7 +91,7 @@ watch(
|
||||
ref="card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
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="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="{
|
||||
'!border-contrast brightness-110': selected,
|
||||
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
|
||||
@@ -190,7 +115,7 @@ watch(
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
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"
|
||||
:aria-label="
|
||||
formatMessage(selected ? messages.deselect : messages.select, {
|
||||
name: screenshot.file_name,
|
||||
@@ -209,25 +134,19 @@ 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="eager"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
draggable="false"
|
||||
class="screenshot-card-fade absolute inset-0 z-[1] h-full w-full object-cover"
|
||||
:class="imageReady ? 'opacity-100' : 'opacity-0'"
|
||||
class="h-full w-full object-cover transition duration-200"
|
||||
:class="loaded ? 'opacity-100' : 'opacity-0'"
|
||||
@load="markImageLoaded"
|
||||
@error="markImageFailed"
|
||||
/>
|
||||
<div
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
|
||||
@@ -274,9 +193,3 @@ 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, useDebugLogger, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
|
||||
@@ -27,6 +27,7 @@ const props = defineProps<{
|
||||
highlightedScreenshotId?: string
|
||||
copiedScreenshotIds: ReadonlySet<string>
|
||||
forceOpen: boolean
|
||||
animateEntry: boolean
|
||||
hideHeader?: boolean
|
||||
editableTitle?: boolean
|
||||
startEditingTitle?: boolean
|
||||
@@ -38,7 +39,6 @@ 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,42 +60,14 @@ 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) => {
|
||||
@@ -103,7 +75,6 @@ watch(
|
||||
if (showGrid) {
|
||||
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
|
||||
renderGrid.value = true
|
||||
void logGeometry('grid shown')
|
||||
return
|
||||
}
|
||||
if (!previouslyShown) {
|
||||
@@ -112,41 +83,13 @@ 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)
|
||||
})
|
||||
|
||||
@@ -206,10 +149,19 @@ function getSelectionKey(screenshot: InstanceScreenshot) {
|
||||
<slot name="actions" :start-editing="startEditing" />
|
||||
</template>
|
||||
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
|
||||
<div
|
||||
<TransitionGroup
|
||||
tag="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"
|
||||
@@ -236,7 +188,7 @@ function getSelectionKey(screenshot: InstanceScreenshot) {
|
||||
>
|
||||
{{ formatMessage(messages.emptyGroup) }}
|
||||
</p>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</ScreenshotSection>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
type ImageViewerEditorSavePayload,
|
||||
injectNotificationManager,
|
||||
ReadyTransition,
|
||||
useDebugLogger,
|
||||
useFormatDateTime,
|
||||
useReadyState,
|
||||
useScrollViewport,
|
||||
@@ -110,7 +109,6 @@ type ScreenshotDropData = {
|
||||
}
|
||||
|
||||
type ScreenshotGroupLayout = {
|
||||
id: string
|
||||
group: ScreenshotGroupData
|
||||
top: number
|
||||
height: number
|
||||
@@ -119,7 +117,7 @@ type ScreenshotGroupLayout = {
|
||||
gridTop: number
|
||||
}
|
||||
|
||||
type VirtualizedScreenshotGroupLayout = ScreenshotGroupLayout & {
|
||||
type VisibleScreenshotGroupLayout = ScreenshotGroupLayout & {
|
||||
renderedScreenshots: InstanceScreenshot[]
|
||||
virtualGridTop: number
|
||||
}
|
||||
@@ -128,7 +126,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 = 2000
|
||||
const SCREENSHOT_GROUP_OVERSCAN = 900
|
||||
const SCREENSHOT_GRID_MIN_HEIGHT = 45
|
||||
const FALLBACK_SCREENSHOT_CARD_WIDTH = 320
|
||||
|
||||
@@ -175,6 +173,8 @@ 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,8 +197,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const debugLayout = useDebugLogger('Screenshots:Layout')
|
||||
let screenshotsScrollLogFrame: number | undefined
|
||||
let screenshotsScrollIdleTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
const {
|
||||
listContainer: screenshotListContainer,
|
||||
containerOffset: screenshotListOffset,
|
||||
@@ -207,18 +206,12 @@ const {
|
||||
viewportHeight: screenshotViewportHeight,
|
||||
} = useScrollViewport({
|
||||
onScroll: () => {
|
||||
if (screenshotsScrollLogFrame === undefined) {
|
||||
screenshotsScrollLogFrame = requestAnimationFrame(() => {
|
||||
screenshotsScrollLogFrame = undefined
|
||||
debugLayout('scroll', {
|
||||
groupBy: groupBy.value,
|
||||
relativeScrollTop: screenshotListScrollTop.value,
|
||||
listOffset: screenshotListOffset.value,
|
||||
viewportHeight: screenshotViewportHeight.value,
|
||||
listWidth: screenshotListWidth.value,
|
||||
})
|
||||
})
|
||||
}
|
||||
screenshotsScrolling.value = true
|
||||
if (screenshotsScrollIdleTimeout) clearTimeout(screenshotsScrollIdleTimeout)
|
||||
screenshotsScrollIdleTimeout = setTimeout(() => {
|
||||
screenshotsScrolling.value = false
|
||||
screenshotsScrollIdleTimeout = undefined
|
||||
}, 120)
|
||||
},
|
||||
})
|
||||
const { width: screenshotListWidth } = useElementSize(screenshotListContainer)
|
||||
@@ -436,10 +429,10 @@ const groupedScreenshots = computed((): ScreenshotGroupData[] => {
|
||||
screenshotGroups.set(screenshot.instance_id, group)
|
||||
}
|
||||
|
||||
const instances = [...(instancesQuery.data.value ?? [])].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
const groups = instances.flatMap((instance) => {
|
||||
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 instanceScreenshots = screenshotGroups.get(instance.id)
|
||||
return instanceScreenshots
|
||||
? [
|
||||
@@ -553,14 +546,19 @@ const screenshotGroupLayouts = computed<ScreenshotGroupLayout[]>(() => {
|
||||
(isOpen ? SCREENSHOT_GROUP_CONTENT_SPACING + gridHeight : 0) +
|
||||
SCREENSHOT_GROUP_SPACING
|
||||
|
||||
layouts.push({ id: group.id, group, top, height, isOpen, gridHeight, gridTop })
|
||||
layouts.push({ group, top, height, isOpen, gridHeight, gridTop })
|
||||
top += height
|
||||
}
|
||||
|
||||
return layouts
|
||||
})
|
||||
|
||||
const virtualizedScreenshotGroups = computed<VirtualizedScreenshotGroupLayout[]>(() => {
|
||||
const screenshotListHeight = computed(() => {
|
||||
const lastGroup = screenshotGroupLayouts.value[screenshotGroupLayouts.value.length - 1]
|
||||
return lastGroup ? lastGroup.top + lastGroup.height : 0
|
||||
})
|
||||
|
||||
const visibleScreenshotGroups = computed<VisibleScreenshotGroupLayout[]>(() => {
|
||||
const hasViewport = Boolean(screenshotListContainer.value && screenshotScrollContainer.value)
|
||||
const viewportStart = hasViewport
|
||||
? Math.max(0, screenshotListScrollTop.value - SCREENSHOT_GROUP_OVERSCAN)
|
||||
@@ -569,90 +567,36 @@ const virtualizedScreenshotGroups = computed<VirtualizedScreenshotGroupLayout[]>
|
||||
? screenshotListScrollTop.value + screenshotViewportHeight.value + SCREENSHOT_GROUP_OVERSCAN
|
||||
: SCREENSHOT_GROUP_OVERSCAN
|
||||
|
||||
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 }
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
[groupBy, screenshotListWidth, windowWidth, screenshotColumnCount, screenshotCardHeight],
|
||||
([currentGroupBy, listWidth, currentWindowWidth, columns, cardHeight], previous) => {
|
||||
debugLayout('responsive geometry changed', {
|
||||
groupBy: currentGroupBy,
|
||||
listWidth,
|
||||
windowWidth: currentWindowWidth,
|
||||
columns,
|
||||
cardHeight,
|
||||
previous,
|
||||
return {
|
||||
...layout,
|
||||
renderedScreenshots: layout.group.screenshots.slice(firstScreenshot, lastScreenshot),
|
||||
virtualGridTop: firstRow * screenshotRowHeight.value,
|
||||
}
|
||||
})
|
||||
},
|
||||
{ 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(() =>
|
||||
@@ -741,8 +685,10 @@ 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
|
||||
@@ -1389,7 +1335,7 @@ watch(activeDropGroupId, (groupId) => {
|
||||
onBeforeUnmount(() => {
|
||||
clearGroupHoverOpenTimeout()
|
||||
if (revealTimeout) clearTimeout(revealTimeout)
|
||||
if (screenshotsScrollLogFrame !== undefined) cancelAnimationFrame(screenshotsScrollLogFrame)
|
||||
if (screenshotsScrollIdleTimeout) clearTimeout(screenshotsScrollIdleTimeout)
|
||||
for (const timeout of copiedResetTimeouts.values()) clearTimeout(timeout)
|
||||
copiedResetTimeouts.clear()
|
||||
})
|
||||
@@ -1533,21 +1479,20 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div
|
||||
ref="screenshotListContainer"
|
||||
class="w-full"
|
||||
:style="{
|
||||
overflowAnchor: 'none',
|
||||
visibility: screenshotListWidth > 0 ? 'visible' : 'hidden',
|
||||
}"
|
||||
class="relative w-full"
|
||||
:style="{ height: `${screenshotListHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div
|
||||
v-for="{
|
||||
id,
|
||||
group,
|
||||
top,
|
||||
gridHeight,
|
||||
renderedScreenshots,
|
||||
virtualGridTop,
|
||||
} in virtualizedScreenshotGroups"
|
||||
:key="id"
|
||||
} 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)` }"
|
||||
>
|
||||
<ScreenshotGroupSection
|
||||
:id="group.id"
|
||||
@@ -1569,6 +1514,7 @@ 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,10 +6,9 @@ import {
|
||||
defineMessages,
|
||||
InlineEditableText,
|
||||
TagItem,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -38,7 +37,6 @@ 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)
|
||||
@@ -48,11 +46,6 @@ 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 {
|
||||
@@ -60,30 +53,6 @@ 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()
|
||||
@@ -158,8 +127,8 @@ watch(
|
||||
:force-open="forceOpen"
|
||||
overflow-visible
|
||||
class="w-full"
|
||||
@on-open="handleOpen"
|
||||
@on-close="handleClose"
|
||||
@on-open="emit('update:collapsed', false)"
|
||||
@on-close="emit('update:collapsed', true)"
|
||||
>
|
||||
<div class="mt-2.5">
|
||||
<slot />
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useSavable,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
@@ -15,13 +14,7 @@ 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()
|
||||
@@ -29,7 +22,6 @@ 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'
|
||||
@@ -59,14 +51,6 @@ 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',
|
||||
@@ -153,7 +137,6 @@ type BehaviorSettingsState = {
|
||||
minimizeApp: boolean
|
||||
hideRightSidebar: boolean
|
||||
showJumpIn: boolean
|
||||
showAllScreenshots: boolean
|
||||
compactInstanceCards: boolean
|
||||
showPlayTime: boolean
|
||||
hideNametag: boolean
|
||||
@@ -161,23 +144,14 @@ type BehaviorSettingsState = {
|
||||
skipNonEssentialWarnings: boolean
|
||||
}
|
||||
|
||||
const [initialSettings, initialGlobalSyncedOptions] = await Promise.all([
|
||||
get(),
|
||||
get_global_synced_options(),
|
||||
])
|
||||
const persistedSettings = ref(initialSettings)
|
||||
const persistedGlobalSyncedOptions = ref(initialGlobalSyncedOptions)
|
||||
const persistedSettings = ref(await get())
|
||||
|
||||
function getBehaviorSettingsState(
|
||||
settings: AppSettings,
|
||||
globalSyncedOptions: GlobalSyncedOptions,
|
||||
): BehaviorSettingsState {
|
||||
function getBehaviorSettingsState(settings: AppSettings): 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],
|
||||
@@ -195,7 +169,7 @@ function getBehaviorSettingsState(
|
||||
}
|
||||
|
||||
const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
|
||||
() => getBehaviorSettingsState(persistedSettings.value, persistedGlobalSyncedOptions.value),
|
||||
() => getBehaviorSettingsState(persistedSettings.value),
|
||||
async () => {
|
||||
const value = current.value
|
||||
|
||||
@@ -230,20 +204,8 @@ const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
|
||||
},
|
||||
}
|
||||
|
||||
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),
|
||||
])
|
||||
await set(nextSettings)
|
||||
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
|
||||
@@ -340,18 +302,6 @@ 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">
|
||||
|
||||
+18
-158
@@ -2,15 +2,11 @@
|
||||
import {
|
||||
EditIcon,
|
||||
// FolderOpenIcon,
|
||||
RefreshCwIcon,
|
||||
SaveIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
CheckCircleButton,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IconButton,
|
||||
@@ -30,9 +26,7 @@ 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,
|
||||
@@ -49,7 +43,7 @@ import {
|
||||
type ServerData,
|
||||
type ServerWorld,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { instanceKeys } from '@/pages/instance/query-options'
|
||||
import { instanceKeys, screenshotKeys } from '@/pages/instance/query-options'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -89,33 +83,13 @@ const messages = defineMessages({
|
||||
id: 'app.settings.synced-options.creative-hotbars.description',
|
||||
defaultMessage: 'Sync saved creative hotbars across your instances.',
|
||||
},
|
||||
chooseSyncSourceTitle: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.title',
|
||||
defaultMessage: 'Choose a sync source',
|
||||
screenshots: {
|
||||
id: 'app.settings.synced-options.screenshots',
|
||||
defaultMessage: 'Screenshots',
|
||||
},
|
||||
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',
|
||||
screenshotsDescription: {
|
||||
id: 'app.settings.synced-options.screenshots.description',
|
||||
defaultMessage: 'View screenshots from your instances in one place.',
|
||||
},
|
||||
commandHistoryEditorTitle: {
|
||||
id: 'app.settings.synced-options.command-history.editor-title',
|
||||
@@ -311,6 +285,11 @@ const globalRows: Array<{
|
||||
title: 'creativeHotbars',
|
||||
description: 'creativeHotbarsDescription',
|
||||
},
|
||||
{
|
||||
option: 'screenshots',
|
||||
title: 'screenshots',
|
||||
description: 'screenshotsDescription',
|
||||
},
|
||||
]
|
||||
|
||||
const globalSyncedOptionsQueryKey = ['global-synced-options'] as const
|
||||
@@ -327,11 +306,6 @@ 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)
|
||||
@@ -366,43 +340,24 @@ 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, baseInstanceId }: GlobalOptionMutationVariables) =>
|
||||
set_global_synced_option(option, enabled, baseInstanceId),
|
||||
mutationFn: ({ option, enabled }: GlobalOptionMutationVariables) =>
|
||||
set_global_synced_option(option, enabled),
|
||||
onMutate: async ({ option, enabled }) => {
|
||||
await queryClient.cancelQueries({ queryKey: globalSyncedOptionsQueryKey })
|
||||
const previous = globalOptions.value[option]
|
||||
@@ -421,15 +376,6 @@ 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()
|
||||
@@ -437,25 +383,12 @@ const globalOptionMutation = useMutation({
|
||||
},
|
||||
})
|
||||
|
||||
function applyGlobalOption(option: SyncedOption, enabled: boolean, baseInstanceId?: string) {
|
||||
globalOptionMutation.mutate({ option, enabled, baseInstanceId })
|
||||
function applyGlobalOption(option: SyncedOption, enabled: boolean) {
|
||||
globalOptionMutation.mutate({ option, enabled })
|
||||
}
|
||||
|
||||
function toggleGlobalOption(option: SyncedOption, enabled: boolean) {
|
||||
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)
|
||||
applyGlobalOption(option, enabled)
|
||||
}
|
||||
|
||||
async function openCommandHistoryEditor() {
|
||||
@@ -569,79 +502,6 @@ 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,12 +338,10 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -215,12 +215,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -1697,27 +1691,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -1754,6 +1727,12 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2699,8 +2678,8 @@
|
||||
"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.override-description": {
|
||||
"message": "Keep this instance's command history separate from synced command history."
|
||||
"instance.settings.tabs.synced-options.command-history.exclude-description": {
|
||||
"message": "Exclude this instance from command history syncing."
|
||||
},
|
||||
"instance.settings.tabs.synced-options.creative-hotbars": {
|
||||
"message": "Saved creative hotbars"
|
||||
@@ -2708,8 +2687,8 @@
|
||||
"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.override-description": {
|
||||
"message": "Keep this instance's saved creative hotbars separate from synced hotbars."
|
||||
"instance.settings.tabs.synced-options.creative-hotbars.exclude-description": {
|
||||
"message": "Exclude this instance from saved creative hotbar syncing."
|
||||
},
|
||||
"instance.settings.tabs.synced-options.hotbars-conflict.backup-description": {
|
||||
"message": "The version being replaced will be backed up before anything changes."
|
||||
@@ -2732,14 +2711,23 @@
|
||||
"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.override-description": {
|
||||
"message": "Keep this instance's multiplayer servers separate from synced servers."
|
||||
"instance.settings.tabs.synced-options.multiplayer-servers.exclude-description": {
|
||||
"message": "Exclude this instance from multiplayer server syncing."
|
||||
},
|
||||
"instance.settings.tabs.synced-options.open-app-settings": {
|
||||
"message": "Manage synced 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 instance’s screenshots from the Screenshots page."
|
||||
},
|
||||
"instance.settings.tabs.synced-options.shared-settings.description": {
|
||||
"message": "Enable an override to keep a synced setting separate for this instance."
|
||||
"message": "Game settings can be shared between instances. Choose what to share in app settings."
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Window"
|
||||
|
||||
@@ -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()
|
||||
@@ -27,16 +28,6 @@ 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
|
||||
? {
|
||||
@@ -148,63 +139,62 @@ const messages = defineMessages({
|
||||
<Toggle id="override-launch-hooks" v-model="overrideHooks" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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"
|
||||
: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.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"
|
||||
:disabled="!overrideHooks"
|
||||
: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"
|
||||
: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="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>
|
||||
<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>
|
||||
|
||||
+118
-134
@@ -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,14 +42,11 @@ 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(() => javaPath.value)
|
||||
const javaTestPath = computed(() => (overrideJavaInstall.value ? javaPath.value : ''))
|
||||
const activePath = computed(() => (overrideJavaInstall.value ? javaPath.value : ''))
|
||||
|
||||
watch(overrideJavaInstall, (enabled) => {
|
||||
if (enabled && !javaPath.value) {
|
||||
javaPath.value = optimalJava?.path ?? ''
|
||||
} else if (!enabled) {
|
||||
javaPath.value = optimalJava?.path ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -59,7 +57,7 @@ const hoveringTest = ref(false)
|
||||
let hasInitialized = false
|
||||
|
||||
watch(
|
||||
javaTestPath,
|
||||
activePath,
|
||||
(newPath) => {
|
||||
if (newPath && optimalJava?.parsed_version) {
|
||||
if (!hasInitialized) {
|
||||
@@ -97,30 +95,12 @@ 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:
|
||||
@@ -218,89 +198,90 @@ const messages = defineMessages({
|
||||
</div>
|
||||
<Toggle id="override-java-installation" v-model="overrideJavaInstall" />
|
||||
</div>
|
||||
<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
|
||||
<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="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'
|
||||
: '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>
|
||||
<CoffeeIcon />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button :disabled="!overrideJavaInstall" @click="handleDetectJava">
|
||||
<SearchIcon />
|
||||
Detect
|
||||
</Button>
|
||||
<Button :disabled="!overrideJavaInstall" @click="handleBrowseJava">
|
||||
<FolderSearchIcon />
|
||||
Browse
|
||||
</Button>
|
||||
<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') &&
|
||||
(!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>
|
||||
</div>
|
||||
</SettingsOptionsTransition>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col">
|
||||
@@ -313,19 +294,20 @@ const messages = defineMessages({
|
||||
</div>
|
||||
<Toggle id="override-memory-allocation" v-model="overrideMemorySettings" />
|
||||
</div>
|
||||
<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>
|
||||
<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">
|
||||
@@ -338,16 +320,17 @@ const messages = defineMessages({
|
||||
</div>
|
||||
<Toggle id="override-java-arguments" v-model="overrideJavaArgs" />
|
||||
</div>
|
||||
<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>
|
||||
<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">
|
||||
@@ -360,16 +343,17 @@ const messages = defineMessages({
|
||||
</div>
|
||||
<Toggle id="override-environment-variables" v-model="overrideEnvVars" />
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
+81
-112
@@ -1,5 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, RefreshCwIcon, RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
EditIcon,
|
||||
RefreshCwIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
commonMessages,
|
||||
@@ -11,6 +17,7 @@ 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,
|
||||
@@ -22,7 +29,7 @@ import {
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { appSettingsModalOpenSyncedOptionsKey } from '@/providers/app-settings-modal'
|
||||
|
||||
import { instanceKeys } from '../../query-options'
|
||||
import { instanceKeys, screenshotKeys } from '../../query-options'
|
||||
import HooksSettings from './hooks-settings.vue'
|
||||
import { injectInstanceSettings } from './instance-settings-context'
|
||||
import JavaSettings from './java-settings.vue'
|
||||
@@ -32,24 +39,27 @@ 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: 'Enable an override to keep a synced setting separate for this instance.',
|
||||
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: 'Manage synced 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.override-description',
|
||||
defaultMessage: "Keep this instance's multiplayer servers separate from synced servers.",
|
||||
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',
|
||||
@@ -60,8 +70,8 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Command history',
|
||||
},
|
||||
commandHistoryDescription: {
|
||||
id: 'instance.settings.tabs.synced-options.command-history.override-description',
|
||||
defaultMessage: "Keep this instance's command history separate from synced command history.",
|
||||
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',
|
||||
@@ -72,13 +82,25 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Saved creative hotbars',
|
||||
},
|
||||
creativeHotbarsDescription: {
|
||||
id: 'instance.settings.tabs.synced-options.creative-hotbars.override-description',
|
||||
defaultMessage: "Keep this instance's saved creative hotbars separate from synced hotbars.",
|
||||
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',
|
||||
@@ -102,16 +124,15 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
type InstanceSyncedOption = Exclude<SyncedOption, 'screenshots'>
|
||||
|
||||
const globalDisabledMessages: Record<InstanceSyncedOption, keyof typeof messages> = {
|
||||
const globalDisabledMessages: Record<SyncedOption, keyof typeof messages> = {
|
||||
multiplayer_servers: 'multiplayerServersDisabled',
|
||||
command_history: 'commandHistoryDisabled',
|
||||
creative_hotbars: 'creativeHotbarsDisabled',
|
||||
screenshots: 'screenshotsDisabled',
|
||||
}
|
||||
|
||||
const rows: Array<{
|
||||
option: InstanceSyncedOption
|
||||
option: SyncedOption
|
||||
title: keyof typeof messages
|
||||
description?: keyof typeof messages
|
||||
}> = [
|
||||
@@ -130,6 +151,11 @@ const rows: Array<{
|
||||
title: 'creativeHotbars',
|
||||
description: 'creativeHotbarsDescription',
|
||||
},
|
||||
{
|
||||
option: 'screenshots',
|
||||
title: 'screenshots',
|
||||
description: 'screenshotsDescription',
|
||||
},
|
||||
]
|
||||
|
||||
const overviewQuery = useQuery(
|
||||
@@ -147,29 +173,16 @@ const capabilities = computed(
|
||||
),
|
||||
)
|
||||
const hotbarResolutionModal = ref<InstanceType<typeof NewModal> | 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
|
||||
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 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 {
|
||||
function disabledReason(option: SyncedOption): string | undefined {
|
||||
if (overviewQuery.data.value?.global_options[option] === false) {
|
||||
return formatMessage(messages[globalDisabledMessages[option]])
|
||||
}
|
||||
@@ -181,97 +194,52 @@ 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({
|
||||
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) =>
|
||||
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 === instanceId ? applyOption(candidate) : candidate,
|
||||
candidate.id === updatedInstance.id ? updatedInstance : candidate,
|
||||
),
|
||||
)
|
||||
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) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['instance-synced-options', updatedInstance.id],
|
||||
})
|
||||
if (variables.option === 'multiplayer_servers') {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: instanceKeys.worlds(instance.value.id),
|
||||
queryKey: instanceKeys.worlds(updatedInstance.id),
|
||||
})
|
||||
}
|
||||
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],
|
||||
}),
|
||||
])
|
||||
|
||||
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: InstanceSyncedOption, nextExcluded: boolean) {
|
||||
async function setExcluded(option: SyncedOption, 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)
|
||||
@@ -281,18 +249,12 @@ async function setExcluded(option: InstanceSyncedOption, 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',
|
||||
@@ -309,7 +271,6 @@ 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">
|
||||
@@ -328,7 +289,7 @@ function resolveHotbars(resolution: SyncedOptionJoinResolution) {
|
||||
<Button
|
||||
type="outlined"
|
||||
:disabled="mutation.isPending.value"
|
||||
@click="cancelHotbarResolution"
|
||||
@click="hotbarResolutionModal?.hide()"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
@@ -375,12 +336,20 @@ function resolveHotbars(resolution: SyncedOptionJoinResolution) {
|
||||
{{ formatMessage(messages[row.description]) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<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)
|
||||
|
||||
+48
-52
@@ -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()
|
||||
@@ -25,13 +26,6 @@ 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 {
|
||||
@@ -108,56 +102,58 @@ const messages = defineMessages({
|
||||
</div>
|
||||
<Toggle id="override-window-settings" v-model="overrideWindowSettings" />
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<Toggle id="fullscreen" v-model="fullscreenSetting" :disabled="!overrideWindowSettings" />
|
||||
</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 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>
|
||||
<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 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>
|
||||
<Input
|
||||
id="height"
|
||||
v-model="resolution[1]"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideWindowSettings || fullscreenSetting"
|
||||
type="number"
|
||||
:placeholder="formatMessage(messages.enterHeight)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsOptionsTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -502,7 +502,9 @@ const tabs = computed(() => {
|
||||
},
|
||||
]
|
||||
|
||||
const screenshotsSynced = globalSyncedOptionsQuery.data.value?.screenshots === true
|
||||
const screenshotsSynced =
|
||||
globalSyncedOptionsQuery.data.value?.screenshots === true &&
|
||||
instance.value?.synced_options.screenshots === true
|
||||
if (!screenshotsSynced) {
|
||||
instanceTabs.splice(2, 0, {
|
||||
label: formatMessage(messages.screenshotsTab),
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::api::Result;
|
||||
use dashmap::DashMap;
|
||||
use path_util::SafeRelativeUtf8UnixPathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tauri_plugin_fs::FsExt;
|
||||
@@ -839,14 +839,8 @@ 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,
|
||||
base_instance_id.as_deref(),
|
||||
)
|
||||
.await?)
|
||||
Ok(theseus::instance::set_global_synced_option(option, enabled).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -906,25 +900,13 @@ fn serialize_screenshots<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
screenshots: Vec<theseus::instance::InstanceScreenshot>,
|
||||
) -> Result<Vec<InstanceScreenshot>> {
|
||||
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()))?;
|
||||
let mut result = Vec::with_capacity(screenshots.len());
|
||||
|
||||
for screenshot in screenshots {
|
||||
result.push(serialize_screenshot(app_handle, screenshot)?);
|
||||
}
|
||||
|
||||
screenshots
|
||||
.into_iter()
|
||||
.map(serialize_screenshot_data)
|
||||
.collect()
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn serialize_screenshot<R: Runtime>(
|
||||
@@ -939,12 +921,6 @@ 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());
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:24-slim
|
||||
FROM docker.io/tailscale/tailscale:latest AS tailscale
|
||||
|
||||
FROM node:24-trixie-slim
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/modrinth/code
|
||||
LABEL org.opencontainers.image.title=frontend
|
||||
@@ -11,6 +13,67 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends dumb-init \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Debug
|
||||
COPY --from=tailscale /usr/local/bin/tailscale /usr/local/bin/tailscaled /usr/local/bin/
|
||||
|
||||
COPY --chmod=0755 <<'EOF' /usr/local/bin/entrypoint.sh
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
TAILSCALE_DIR=/tmp/tailscale
|
||||
TAILSCALE_SOCKET="$TAILSCALE_DIR/tailscaled.sock"
|
||||
|
||||
start_tailscale() {
|
||||
echo "entrypoint: starting tailscale as euid=$(id -u) groups=$(id -G)" >&2
|
||||
mkdir -p "$TAILSCALE_DIR"
|
||||
|
||||
# Userspace networking needs no TUN device or extra capabilities.
|
||||
# --state=mem: registers an ephemeral node; --statedir gives the SSH host keys
|
||||
# somewhere writable to live, which mem: on its own does not.
|
||||
tailscaled \
|
||||
--tun=userspace-networking \
|
||||
--state=mem: \
|
||||
--statedir="$TAILSCALE_DIR" \
|
||||
--socket="$TAILSCALE_SOCKET" &
|
||||
|
||||
waited=0
|
||||
while [ ! -S "$TAILSCALE_SOCKET" ]; do
|
||||
if [ "$waited" -ge 50 ]; then
|
||||
echo "entrypoint: tailscaled socket never appeared" >&2
|
||||
return 1
|
||||
fi
|
||||
waited=$((waited + 1))
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
if [ -n "${TAILSCALE_HOSTNAME:-}" ]; then
|
||||
node_hostname="$TAILSCALE_HOSTNAME"
|
||||
elif [ -n "${BUNNYNET_MC_PODID:-}" ] && [ -n "${BUNNYNET_MC_REGION:-}" ]; then
|
||||
node_hostname="frontend-$BUNNYNET_MC_PODID-$BUNNYNET_MC_REGION"
|
||||
else
|
||||
node_hostname="frontend-$(hostname)"
|
||||
fi
|
||||
|
||||
tailscale --socket="$TAILSCALE_SOCKET" up \
|
||||
--authkey="$TAILSCALE_AUTH_KEY" \
|
||||
--hostname="$node_hostname" \
|
||||
--accept-dns=false \
|
||||
--timeout=30s \
|
||||
--ssh
|
||||
}
|
||||
|
||||
if [ -n "${TAILSCALE_AUTH_KEY:-}" ]; then
|
||||
start_tailscale || echo "entrypoint: tailscale setup failed, serving without SSH" >&2
|
||||
fi
|
||||
|
||||
# Only drop if we actually started as root; some runtimes pin their own uid.
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
exec setpriv --reuid=node --regid=node --init-groups -- "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
EOF
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
@@ -20,11 +83,9 @@ WORKDIR /app
|
||||
# Nitro bundles every runtime dependency into .output, so no node_modules is needed.
|
||||
COPY --chown=node:node .output ./.output
|
||||
|
||||
USER node
|
||||
# Stays root so tailscaled can setgroups/setuid for SSH sessions; the entrypoint
|
||||
# drops the server itself to the node user.
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/robots.txt').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
ENTRYPOINT ["dumb-init", "--", "/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["node", "/app/.output/server/index.mjs"]
|
||||
|
||||
@@ -970,13 +970,10 @@ const showTinMismatchBanner = computed(() => {
|
||||
|
||||
const PRIDE_COLLECTION_ID = 'M4c3ITvd'
|
||||
const PRIDE_ARTICLE_SLUGS = ['pride-campaign-2025', 'pride-campaign-2026', 'proud-of-you-2026']
|
||||
const PRIDE_CACHE_TIME = 1000 * 60 * 60 * 24
|
||||
|
||||
const { data: prideCollection } = useQuery({
|
||||
queryKey: computed(() => ['collection', PRIDE_COLLECTION_ID]),
|
||||
queryFn: () => client.labrinth.collections.get(PRIDE_COLLECTION_ID),
|
||||
staleTime: PRIDE_CACHE_TIME,
|
||||
gcTime: PRIDE_CACHE_TIME,
|
||||
})
|
||||
|
||||
const prideProjectIds = computed(() => new Set(prideCollection.value?.projects ?? []))
|
||||
|
||||
@@ -29,6 +29,9 @@ export default defineNuxtPlugin((nuxt) => {
|
||||
if (import.meta.server) {
|
||||
nuxt.hooks.hook('app:rendered', () => {
|
||||
vueQueryState.value = dehydrate(queryClient)
|
||||
|
||||
// Hack to prevent memory leak when gcTime is being set on the server side.
|
||||
queryClient.clear()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('error', async (error, { event }) => {
|
||||
const statusCode = (error as { statusCode?: number }).statusCode ?? 500
|
||||
if (statusCode < 500) return
|
||||
|
||||
console.error(`[Context Error] at ${event?.path}:`, error)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
UPDATE sync_feature_settings
|
||||
SET globally_enabled = 0, new_instance_default = 1
|
||||
WHERE feature IN (
|
||||
'command_history',
|
||||
'multiplayer_servers',
|
||||
'creative_hotbars'
|
||||
);
|
||||
|
||||
UPDATE instance_sync_preferences
|
||||
SET enabled = 1
|
||||
WHERE feature IN (
|
||||
'command_history',
|
||||
'multiplayer_servers',
|
||||
'creative_hotbars'
|
||||
);
|
||||
@@ -67,14 +67,9 @@ pub async fn list_screenshots(
|
||||
|
||||
pub async fn list_synced_screenshots() -> crate::Result<Vec<InstanceScreenshot>>
|
||||
{
|
||||
if !super::super::synced_options::get_global_options()
|
||||
.await?
|
||||
.screenshots
|
||||
{
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let state = State::get().await?;
|
||||
let sources = instance_rows::list_screenshot_sources(&state.pool).await?;
|
||||
let sources =
|
||||
instance_rows::list_synced_screenshot_sources(&state.pool).await?;
|
||||
list_source_screenshot_sets(&state, sources).await
|
||||
}
|
||||
|
||||
|
||||
@@ -8,15 +8,12 @@ use crate::state::instances::adapters::sqlite::{
|
||||
screenshot_rows::{self, ScreenshotRow},
|
||||
};
|
||||
use crate::util::fetch::sha1_file_async;
|
||||
use crate::util::io::IOError;
|
||||
use crate::util::io::{self, IOError};
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::stream::{self, StreamExt, TryStreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
const SCREENSHOT_HASH_CONCURRENCY: usize = 8;
|
||||
|
||||
pub(super) struct ScannedScreenshot {
|
||||
file_name: String,
|
||||
created_at: DateTime<Utc>,
|
||||
@@ -112,14 +109,7 @@ pub(super) async fn scan_source_screenshots(
|
||||
}
|
||||
|
||||
let screenshots_dir = source_screenshots_dir(state, source).await?;
|
||||
tokio::task::spawn_blocking(move || scan_screenshots_dir(&screenshots_dir))
|
||||
.await?
|
||||
}
|
||||
|
||||
fn scan_screenshots_dir(
|
||||
screenshots_dir: &Path,
|
||||
) -> crate::Result<Vec<ScannedScreenshot>> {
|
||||
let entries = match std::fs::read_dir(screenshots_dir) {
|
||||
let mut entries = match io::read_dir(&screenshots_dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Vec::new());
|
||||
@@ -128,11 +118,14 @@ fn scan_screenshots_dir(
|
||||
};
|
||||
let mut screenshots = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry
|
||||
.map_err(|error| IOError::with_path(error, screenshots_dir))?;
|
||||
while let Some(entry) = entries
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|error| IOError::with_path(error, &screenshots_dir))?
|
||||
{
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.await
|
||||
.map_err(|error| IOError::with_path(error, entry.path()))?;
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
@@ -149,6 +142,7 @@ fn scan_screenshots_dir(
|
||||
};
|
||||
let metadata = entry
|
||||
.metadata()
|
||||
.await
|
||||
.map_err(|error| IOError::with_path(error, &path))?;
|
||||
let created_at = metadata
|
||||
.created()
|
||||
@@ -185,34 +179,53 @@ pub(super) async fn reconcile_source_screenshots(
|
||||
) -> crate::Result<Vec<InstanceScreenshot>> {
|
||||
let existing =
|
||||
screenshot_rows::list_screenshots(&source.id, &state.pool).await?;
|
||||
let mut unmatched_by_name = existing
|
||||
.into_iter()
|
||||
.map(|row| (row.file_name.clone(), row))
|
||||
let existing_by_name = existing
|
||||
.iter()
|
||||
.map(|row| (row.file_name.as_str(), row))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let metadata_matches = existing.len() == scanned.len()
|
||||
&& scanned.iter().all(|scanned| {
|
||||
existing_by_name
|
||||
.get(scanned.file_name.as_str())
|
||||
.is_some_and(|row| {
|
||||
row.file_size == scanned.file_size
|
||||
&& row.modified_at == scanned.modified_at
|
||||
&& row.created_at
|
||||
== scanned.created_at.timestamp_millis()
|
||||
})
|
||||
});
|
||||
let mut unmatched_by_hash =
|
||||
HashMap::<(String, i64), Vec<ScreenshotRow>>::new();
|
||||
let mut resolved = Vec::with_capacity(scanned.len());
|
||||
let mut needs_hash = Vec::new();
|
||||
|
||||
for scanned in scanned {
|
||||
match unmatched_by_name.remove(&scanned.file_name) {
|
||||
Some(row)
|
||||
if row.file_size == scanned.file_size
|
||||
&& row.modified_at == scanned.modified_at
|
||||
&& row.created_at
|
||||
== scanned.created_at.timestamp_millis() =>
|
||||
{
|
||||
resolved.push(ResolvedScreenshot {
|
||||
scanned,
|
||||
row,
|
||||
is_new: false,
|
||||
changed: false,
|
||||
});
|
||||
}
|
||||
matched => needs_hash.push((scanned, matched)),
|
||||
if metadata_matches {
|
||||
let mut existing_by_name = existing
|
||||
.into_iter()
|
||||
.map(|row| (row.file_name.clone(), row))
|
||||
.collect::<HashMap<_, _>>();
|
||||
for scanned in scanned {
|
||||
let row = existing_by_name.remove(&scanned.file_name).ok_or_else(
|
||||
|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Screenshot index changed during reconciliation"
|
||||
.to_string(),
|
||||
)
|
||||
},
|
||||
)?;
|
||||
resolved.push(ResolvedScreenshot {
|
||||
scanned,
|
||||
row,
|
||||
is_new: false,
|
||||
changed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let hashed = stream::iter(needs_hash.into_iter().map(
|
||||
|(mut scanned, matched)| async move {
|
||||
} else {
|
||||
let mut unmatched_by_name = existing
|
||||
.into_iter()
|
||||
.map(|row| (row.file_name.clone(), row))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut hashed = Vec::with_capacity(scanned.len());
|
||||
for mut scanned in scanned {
|
||||
let (file_size, content_hash) =
|
||||
sha1_file_async(&scanned.path).await?;
|
||||
scanned.file_size = i64::try_from(file_size).map_err(|_| {
|
||||
@@ -220,54 +233,54 @@ pub(super) async fn reconcile_source_screenshots(
|
||||
"Screenshot is too large to index".to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok::<_, crate::Error>((scanned, content_hash, matched))
|
||||
},
|
||||
))
|
||||
.buffer_unordered(SCREENSHOT_HASH_CONCURRENCY)
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
hashed.push((scanned, content_hash));
|
||||
}
|
||||
|
||||
let mut unmatched_by_hash =
|
||||
HashMap::<(String, i64), Vec<ScreenshotRow>>::new();
|
||||
for row in unmatched_by_name.into_values() {
|
||||
unmatched_by_hash
|
||||
.entry((row.content_hash.clone(), row.file_size))
|
||||
.or_default()
|
||||
.push(row);
|
||||
}
|
||||
let mut renamed_or_new = Vec::new();
|
||||
for (scanned, content_hash) in hashed {
|
||||
if let Some(row) = unmatched_by_name.remove(&scanned.file_name) {
|
||||
resolved.push(resolve_scanned_screenshot(
|
||||
source,
|
||||
scanned,
|
||||
content_hash,
|
||||
Some(row),
|
||||
));
|
||||
} else {
|
||||
renamed_or_new.push((scanned, content_hash));
|
||||
}
|
||||
}
|
||||
|
||||
for (scanned, content_hash, matched_by_name) in hashed {
|
||||
if let Some(row) = matched_by_name {
|
||||
for row in unmatched_by_name.into_values() {
|
||||
unmatched_by_hash
|
||||
.entry((row.content_hash.clone(), row.file_size))
|
||||
.or_default()
|
||||
.push(row);
|
||||
}
|
||||
|
||||
for (scanned, content_hash) in renamed_or_new {
|
||||
let hash_key = (content_hash.clone(), scanned.file_size);
|
||||
let matched =
|
||||
unmatched_by_hash.get_mut(&hash_key).and_then(|rows| {
|
||||
if rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let created_at = scanned.created_at.timestamp_millis();
|
||||
let index = rows
|
||||
.iter()
|
||||
.position(|row| {
|
||||
row.modified_at == scanned.modified_at
|
||||
&& row.created_at == created_at
|
||||
})
|
||||
.unwrap_or(rows.len() - 1);
|
||||
Some(rows.swap_remove(index))
|
||||
});
|
||||
resolved.push(resolve_scanned_screenshot(
|
||||
source,
|
||||
scanned,
|
||||
content_hash,
|
||||
Some(row),
|
||||
matched,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let hash_key = (content_hash.clone(), scanned.file_size);
|
||||
let matched = unmatched_by_hash.get_mut(&hash_key).and_then(|rows| {
|
||||
if rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let created_at = scanned.created_at.timestamp_millis();
|
||||
let index = rows
|
||||
.iter()
|
||||
.position(|row| {
|
||||
row.modified_at == scanned.modified_at
|
||||
&& row.created_at == created_at
|
||||
})
|
||||
.unwrap_or(rows.len() - 1);
|
||||
Some(rows.swap_remove(index))
|
||||
});
|
||||
resolved.push(resolve_scanned_screenshot(
|
||||
source,
|
||||
scanned,
|
||||
content_hash,
|
||||
matched,
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = state.pool.begin().await?;
|
||||
|
||||
@@ -288,28 +288,47 @@ async fn version_capability(
|
||||
pub async fn set_global_option(
|
||||
option: SyncedOption,
|
||||
enabled: bool,
|
||||
base_instance_id: Option<&str>,
|
||||
) -> crate::Result<GlobalSyncedOptions> {
|
||||
let state = State::get().await?;
|
||||
let _guard = state.lock_synced_options().await;
|
||||
let reset_participation =
|
||||
enabled && !canonical_exists(option, &state).await?;
|
||||
|
||||
if enabled && option != SyncedOption::Screenshots {
|
||||
let base_instance_id = base_instance_id.ok_or_else(|| {
|
||||
ErrorKind::InputError(
|
||||
"Choose an instance to use as the sync source.".to_string(),
|
||||
)
|
||||
})?;
|
||||
return enable_global_option_from_base(
|
||||
option,
|
||||
base_instance_id,
|
||||
&state,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
set_global_option_enabled(option, enabled, &state).await?;
|
||||
let option_name = option.as_str();
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO sync_feature_settings
|
||||
(feature, globally_enabled, new_instance_default)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(feature) DO UPDATE SET
|
||||
globally_enabled = excluded.globally_enabled
|
||||
",
|
||||
option_name,
|
||||
enabled,
|
||||
)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let instances = crate::state::list_instances(&state.pool).await?;
|
||||
if reset_participation {
|
||||
for metadata in instances {
|
||||
if instance_option_enabled(&metadata, option) {
|
||||
instance_rows::set_instance_sync_preference(
|
||||
&metadata.instance.id,
|
||||
option,
|
||||
false,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if !sync_files_are_protected(&metadata)
|
||||
&& !instance_is_running(&metadata, &state).await?
|
||||
{
|
||||
detach_option(&metadata, option, &state).await?;
|
||||
}
|
||||
}
|
||||
return get_global_options_with_state(&state).await;
|
||||
}
|
||||
for metadata in instances {
|
||||
if sync_files_are_protected(&metadata)
|
||||
|| instance_is_running(&metadata, &state).await?
|
||||
@@ -334,112 +353,6 @@ pub async fn set_global_option(
|
||||
get_global_options_with_state(&state).await
|
||||
}
|
||||
|
||||
async fn set_global_option_enabled(
|
||||
option: SyncedOption,
|
||||
enabled: bool,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let option_name = option.as_str();
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO sync_feature_settings
|
||||
(feature, globally_enabled, new_instance_default)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(feature) DO UPDATE SET
|
||||
globally_enabled = excluded.globally_enabled
|
||||
",
|
||||
option_name,
|
||||
enabled,
|
||||
)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn enable_global_option_from_base(
|
||||
option: SyncedOption,
|
||||
base_instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<GlobalSyncedOptions> {
|
||||
let source = crate::state::get_instance(base_instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ErrorKind::InputError("Unknown sync source instance.".to_string())
|
||||
})?;
|
||||
if sync_files_are_protected(&source)
|
||||
|| instance_is_running(&source, state).await?
|
||||
{
|
||||
return Err(ErrorKind::InputError(
|
||||
"Close the source instance before using it for syncing."
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
match capability_status(&source, option, true, state).await {
|
||||
CapabilityStatus::Supported => {}
|
||||
CapabilityStatus::Unsupported(reason)
|
||||
| CapabilityStatus::Indeterminate(reason) => {
|
||||
return Err(ErrorKind::InputError(reason).into());
|
||||
}
|
||||
}
|
||||
|
||||
let instances = crate::state::list_instances(&state.pool).await?;
|
||||
for metadata in &instances {
|
||||
if instance_option_enabled(metadata, option)
|
||||
&& (sync_files_are_protected(metadata)
|
||||
|| instance_is_running(metadata, state).await?)
|
||||
{
|
||||
return Err(ErrorKind::InputError(
|
||||
"Close all instances using this synced setting before choosing a new sync source."
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
for metadata in &instances {
|
||||
if instance_option_enabled(metadata, option) {
|
||||
detach_option(metadata, option, state).await?;
|
||||
}
|
||||
}
|
||||
if !instance_option_enabled(&source, option) {
|
||||
detach_option(&source, option, state).await?;
|
||||
}
|
||||
|
||||
seed_from_instance(&source, option, state).await?;
|
||||
instance_rows::set_instance_sync_preference(
|
||||
base_instance_id,
|
||||
option,
|
||||
true,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
set_global_option_enabled(option, true, state).await?;
|
||||
|
||||
for metadata in crate::state::list_instances(&state.pool).await? {
|
||||
if sync_files_are_protected(&metadata)
|
||||
|| instance_is_running(&metadata, state).await?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !instance_option_enabled(&metadata, option) {
|
||||
detach_option(&metadata, option, state).await?;
|
||||
continue;
|
||||
}
|
||||
match capability_status(&metadata, option, true, state).await {
|
||||
CapabilityStatus::Supported => {
|
||||
ensure_option(&metadata, option, state).await?
|
||||
}
|
||||
CapabilityStatus::Unsupported(_) => {
|
||||
detach_option(&metadata, option, state).await?
|
||||
}
|
||||
CapabilityStatus::Indeterminate(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
get_global_options_with_state(state).await
|
||||
}
|
||||
|
||||
pub async fn set_instance_option(
|
||||
instance_id: &str,
|
||||
option: SyncedOption,
|
||||
|
||||
@@ -172,48 +172,7 @@ pub(in crate::api::instance) async fn detach_servers(
|
||||
) -> crate::Result<()> {
|
||||
let generated = generated_path(state, &metadata.instance.id);
|
||||
let local = instance_dir(metadata, state).join(SERVERS_FILE);
|
||||
let Some(current_checkpoint) = checkpoint(
|
||||
&metadata.instance.id,
|
||||
SyncedOption::MultiplayerServers,
|
||||
"default",
|
||||
state,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return detach_link(&generated, &local).await;
|
||||
};
|
||||
let linked_to_generated = tokio::fs::symlink_metadata(&local)
|
||||
.await
|
||||
.is_ok_and(|metadata| metadata.file_type().is_symlink())
|
||||
&& tokio::fs::read_link(&local)
|
||||
.await
|
||||
.is_ok_and(|target| target == generated);
|
||||
let matches_checkpoint = local.exists()
|
||||
&& sha1_file(&local).await? == current_checkpoint.expected_sha1;
|
||||
if current_checkpoint.status != CheckpointStatus::Ready
|
||||
|| (!linked_to_generated && !matches_checkpoint)
|
||||
{
|
||||
return detach_link(&generated, &local).await;
|
||||
}
|
||||
|
||||
let current = read_servers(&local).await?;
|
||||
let projections =
|
||||
load_projection_entries(&metadata.instance.id, state).await?;
|
||||
let projection_matches = match_projection_entries(¤t, &projections);
|
||||
let instance_servers = current
|
||||
.into_iter()
|
||||
.zip(projection_matches)
|
||||
.filter_map(|(server, projection)| {
|
||||
projection
|
||||
.is_none_or(|projection| {
|
||||
projection.owner == ProjectionOwner::Instance
|
||||
})
|
||||
.then_some(server)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
detach_link(&generated, &local).await?;
|
||||
write_servers(&local, &instance_servers).await
|
||||
detach_link(&generated, &local).await
|
||||
}
|
||||
|
||||
pub(in crate::api::instance) async fn reconcile_servers(
|
||||
|
||||
@@ -494,6 +494,32 @@ pub(crate) async fn get_instance_screenshot_source(
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_synced_screenshot_sources(
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<InstanceScreenshotSource>> {
|
||||
let sources = sqlx::query_as!(
|
||||
InstanceScreenshotSource,
|
||||
"
|
||||
SELECT instances.id, instances.name, instances.path
|
||||
FROM instances
|
||||
INNER JOIN instance_sync_preferences preferences
|
||||
ON preferences.instance_id = instances.id
|
||||
WHERE preferences.feature = 'screenshots'
|
||||
AND preferences.enabled = 1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_feature_settings
|
||||
WHERE feature = 'screenshots' AND globally_enabled = 1
|
||||
)
|
||||
ORDER BY instances.name, instances.id
|
||||
",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_screenshot_sources(
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<InstanceScreenshotSource>> {
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<span class="shrink-0 whitespace-nowrap py-2 text-sm leading-5 text-secondary">
|
||||
{{ min }}
|
||||
</span>
|
||||
|
||||
<div class="relative h-10 min-w-0 flex-1" :class="disabled ? 'opacity-50' : ''">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-surface-5"
|
||||
>
|
||||
<div class="h-full rounded-full bg-brand" :style="{ width: `${currentPercentage}%` }" />
|
||||
<div class="flex flex-row items-center w-full">
|
||||
<div class="w-full relative">
|
||||
<div class="absolute top-0 h-1/2 w-full">
|
||||
<div
|
||||
class="relative inline-block align-middle w-[calc(100%-0.75rem)] h-3 left-[calc(0.75rem/2)]"
|
||||
>
|
||||
<div
|
||||
v-for="snapPoint in snapPoints"
|
||||
:key="snapPoint"
|
||||
class="absolute inline-block w-1 h-full rounded-sm -translate-x-1/2"
|
||||
:class="{
|
||||
'opacity-0': disabled,
|
||||
}"
|
||||
:style="{
|
||||
left: ((snapPoint - min) / (max - min)) * 100 + '%',
|
||||
backgroundColor:
|
||||
snapPoint <= currentValue ? 'var(--color-brand)' : 'var(--color-base)',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="visibleSnapPoints.length"
|
||||
class="pointer-events-none absolute inset-x-0 top-1/2 h-6 -translate-y-1/2"
|
||||
>
|
||||
<span
|
||||
v-for="snapPoint in visibleSnapPoints"
|
||||
:key="snapPoint"
|
||||
class="absolute top-0 h-6 w-1 -translate-x-1/2 rounded-full"
|
||||
:class="snapPoint <= currentValue ? 'bg-brand' : 'bg-surface-5'"
|
||||
:style="{ left: `${getPercentage(snapPoint)}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="input"
|
||||
v-model="currentValue"
|
||||
@@ -31,24 +27,27 @@
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="slider absolute top-0 h-10 min-h-0 appearance-none border-0 bg-transparent p-0 shadow-none outline-none"
|
||||
:class="disabled ? 'cursor-not-allowed' : 'cursor-pointer'"
|
||||
class="slider relative rounded-sm h-1 w-full p-0 min-h-0 shadow-none outline-none align-middle appearance-none"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed': disabled,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
:style="{
|
||||
'--current-value': currentValue,
|
||||
'--min-value': min,
|
||||
'--max-value': max,
|
||||
}"
|
||||
@input="onInputWithSnap(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div class="flex flex-row justify-between text-xs m-0">
|
||||
<span> {{ min }} {{ unit }} </span>
|
||||
<span> {{ max }} {{ unit }} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="shrink-0 whitespace-nowrap py-2 text-sm leading-5 text-secondary">
|
||||
{{ formatValue(max) }}
|
||||
</span>
|
||||
|
||||
<Input
|
||||
:model-value="String(currentValue)"
|
||||
type="number"
|
||||
size="medium"
|
||||
wrapper-class="slider-value shrink-0"
|
||||
input-class="!font-semibold"
|
||||
:style="{ width: valueInputWidth }"
|
||||
class="w-24 ml-3"
|
||||
:disabled="disabled"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@@ -59,7 +58,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import Input from './inputs/Input.vue'
|
||||
|
||||
@@ -89,133 +88,104 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
unit: '',
|
||||
})
|
||||
|
||||
const currentValue = ref(clampValue(props.modelValue))
|
||||
const currentPercentage = computed(() => getPercentage(currentValue.value))
|
||||
const valueInputWidth = computed(
|
||||
() => `calc(${Math.max(String(currentValue.value).length, 1)}ch + 2.125rem)`,
|
||||
)
|
||||
const visibleSnapPoints = computed(() =>
|
||||
props.snapPoints.filter((snapPoint) => snapPoint >= props.min && snapPoint <= props.max),
|
||||
)
|
||||
const currentValue = ref(Math.max(props.min, props.modelValue))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
currentValue.value = clampValue(newValue ?? props.min)
|
||||
currentValue.value = Math.max(props.min, newValue ?? props.min)
|
||||
},
|
||||
)
|
||||
|
||||
function clampValue(value: number) {
|
||||
return Math.max(props.min, Math.min(value, props.max))
|
||||
}
|
||||
const inputValueValid = (inputValue: number) => {
|
||||
let newValue = inputValue || props.min
|
||||
|
||||
function getPercentage(value: number) {
|
||||
const range = props.max - props.min
|
||||
if (range <= 0) return 0
|
||||
|
||||
return Math.max(0, Math.min(((value - props.min) / range) * 100, 100))
|
||||
}
|
||||
|
||||
function formatValue(value: number) {
|
||||
return props.unit ? `${value} ${props.unit}` : String(value)
|
||||
}
|
||||
|
||||
function inputValueValid(inputValue: number) {
|
||||
if (Number.isNaN(inputValue)) return
|
||||
|
||||
let newValue = inputValue
|
||||
if (props.forceStep && props.step > 0) {
|
||||
if (props.forceStep) {
|
||||
newValue -= newValue % props.step
|
||||
}
|
||||
newValue = Math.max(props.min, Math.min(newValue, props.max))
|
||||
|
||||
currentValue.value = clampValue(newValue)
|
||||
currentValue.value = newValue
|
||||
emit('update:modelValue', currentValue.value)
|
||||
}
|
||||
|
||||
function onInputWithSnap(value: string) {
|
||||
let parsedValue = Number.parseFloat(value)
|
||||
const onInputWithSnap = (value: string) => {
|
||||
let parsedValue = parseInt(value)
|
||||
|
||||
for (const snapPoint of props.snapPoints) {
|
||||
const distance = Math.abs(snapPoint - parsedValue)
|
||||
if (distance < props.snapRange) parsedValue = snapPoint
|
||||
|
||||
if (distance < props.snapRange) {
|
||||
parsedValue = snapPoint
|
||||
}
|
||||
}
|
||||
|
||||
inputValueValid(parsedValue)
|
||||
}
|
||||
|
||||
function onInput(value: string) {
|
||||
inputValueValid(Number.parseFloat(value))
|
||||
const onInput = (value: string) => {
|
||||
inputValueValid(parseInt(value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slider {
|
||||
left: -0.625rem;
|
||||
width: calc(100% + 1.25rem);
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
height: 0.25rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-moz-range-track,
|
||||
&::-moz-range-progress {
|
||||
height: 0.25rem;
|
||||
background: transparent;
|
||||
}
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-brand) 0%,
|
||||
var(--color-brand)
|
||||
calc(
|
||||
(var(--current-value) - var(--min-value)) / (var(--max-value) - var(--min-value)) * 100%
|
||||
),
|
||||
var(--color-base)
|
||||
calc(
|
||||
(var(--current-value) - var(--min-value)) / (var(--max-value) - var(--min-value)) * 100%
|
||||
),
|
||||
var(--color-base) 100%
|
||||
)
|
||||
100% 100% no-repeat;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-top: -0.5rem;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-text-default);
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface-3),
|
||||
0 0 0 4px var(--color-brand);
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--color-brand);
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
width 0.2s,
|
||||
height 0.2s;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-text-default);
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface-3),
|
||||
0 0 0 4px var(--color-brand);
|
||||
border: none;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--color-brand);
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
width 0.2s,
|
||||
height 0.2s;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible::-webkit-slider-thumb {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface-3),
|
||||
0 0 0 4px var(--color-brand),
|
||||
0 0 0 8px var(--color-brand-highlight);
|
||||
}
|
||||
|
||||
&:focus-visible::-moz-range-thumb {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface-3),
|
||||
0 0 0 4px var(--color-brand),
|
||||
0 0 0 8px var(--color-brand-highlight);
|
||||
&:hover:not(:disabled)::-webkit-slider-thumb,
|
||||
&:hover:not(:disabled)::-moz-range-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-value :deep(input[type='number']) {
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user