Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 23 additions & 1 deletion apps/app-frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { useAppEvent } from '@/composables/use-app-event'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { useError } from '@/composables/use-error.js'
import { useInstanceMetadataRefresh } from '@/composables/use-instance-metadata-refresh'
import { isDarkTheme, useTheme } from '@/composables/use-theme.ts'
import { config } from '@/config'
import { getAccountAppearance, rememberAccountAppearance } from '@/helpers/account-appearance.ts'
Expand All @@ -125,6 +126,7 @@ import {
get as getInstance,
get_global_synced_options,
run,
set_global_synced_option,
} from '@/helpers/instance'
import {
get as getCreds,
Expand All @@ -151,7 +153,7 @@ import {
} from '@/helpers/utils.js'
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
import i18n from '@/i18n.config'
import { instanceKeys } from '@/pages/instance/query-options'
import { instanceKeys, screenshotKeys } from '@/pages/instance/query-options'
import {
appUpdateState,
downloadAvailableAppUpdate,
Expand Down Expand Up @@ -190,6 +192,7 @@ const appTheme = useTheme()
const router = useRouter()
const route = useRoute()
const { channel: appEventChannel, events: appEvents } = setupAppEventsProvider()
useInstanceMetadataRefresh(appEvents)
const breadcrumbManager = createBreadcrumbManager()
provideBreadcrumbManager(breadcrumbManager)
const canNavigateBack = ref(false)
Expand Down Expand Up @@ -1116,6 +1119,25 @@ watch(
settings.hide_nametag_skins_page = behavior.hide_nametag
settingsChanged = true
}

const showAllScreenshots = behavior.show_all_screenshots
if (typeof showAllScreenshots === 'boolean') {
const globalSyncedOptions =
globalSyncedOptionsQuery.data.value ??
(await queryClient.fetchQuery({
queryKey: ['global-synced-options'],
queryFn: get_global_synced_options,
}))
if (globalSyncedOptions.screenshots !== showAllScreenshots) {
const updatedGlobalSyncedOptions = await set_global_synced_option(
'screenshots',
showAllScreenshots,
)
queryClient.setQueryData(['global-synced-options'], updatedGlobalSyncedOptions)
await queryClient.invalidateQueries({ queryKey: screenshotKeys.all })
}
}

for (const [flag, value] of Object.entries(behaviorFeatureFlags)) {
if (settings.feature_flags[flag] !== value) {
settings.feature_flags[flag] = value
Expand Down
60 changes: 23 additions & 37 deletions apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
Expand All @@ -17,25 +17,42 @@ import NavButton from '@/components/ui/NavButton.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { getInstanceIconUrl, kill, list, run } from '@/helpers/instance'
import { getInstanceIconUrl, kill, run } from '@/helpers/instance'
import { get_all } from '@/helpers/process'
import { showInstanceInFolder } from '@/helpers/utils'
import { instanceKeys } from '@/pages/instance/query-options'
import { instanceListQueryOptions } from '@/pages/instance/query-options'

const ITEM_SIZE = 52
const APPROX_USED_VERTICAL_SPACE = 475 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
const STORAGE_KEY = 'modrinth-quick-instance-count'

const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const instancesQuery = useQuery(instanceListQueryOptions())
const router = useRouter()
const instanceOptions = ref()
const runningInstances = ref([])

const { formatMessage } = useVIntl()

const maxAuto = ref(0)
const allInstances = ref([])
const allInstances = computed(() =>
(instancesQuery.data.value ?? []).slice().sort((a, b) => {
const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)

const dateBCreated = dayjs(b.created)
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)

const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed

if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}

return dateB - dateA
}),
)
const dragging = ref(false)

const stored = localStorage.getItem(STORAGE_KEY)
Expand Down Expand Up @@ -134,40 +151,9 @@ const onDividerPointerUp = (event) => {
endDrag(event)
}

const getInstances = async () => {
const instances = await list().catch(handleError)

for (const instance of instances) {
queryClient.setQueryData(instanceKeys.detail(instance.id), instance)
}

allInstances.value = instances.sort((a, b) => {
const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)

const dateBCreated = dayjs(b.created)
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)

const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed

if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}

return dateB - dateA
})
}

await getInstances()
await instancesQuery.suspense().catch(handleError)
updateMaxAuto()

useAppEvent('instance', async (event) => {
if (event.event !== 'synced') {
await getInstances()
}
})

useAppEvent('process', checkProcesses)

onMounted(() => {
Expand Down
119 changes: 103 additions & 16 deletions apps/app-frontend/src/components/ui/screenshots-page/card.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
<script setup lang="ts">
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
import { defineMessages, IconButton, useFormatDateTime, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import {
defineMessages,
IconButton,
useDebugLogger,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'

import type { InstanceScreenshot } from '@/helpers/instance'
const loadedScreenshotUrls = new Set<string>()

const props = defineProps<{
screenshot: InstanceScreenshot
Expand All @@ -29,9 +34,12 @@ const emit = defineEmits<{

const card = ref<HTMLElement>()
const image = ref<HTMLImageElement>()
const loaded = ref(loadedScreenshotUrls.has(props.screenshot.url))
const imageReady = ref(false)
const { formatMessage } = useVIntl()
const debugImage = useDebugLogger('Screenshots:Card')
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
let loadStartedAt = performance.now()
let loadGeneration = 0
const messages = defineMessages({
select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' },
deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' },
Expand Down Expand Up @@ -69,19 +77,86 @@ function activate(event: MouseEvent | KeyboardEvent) {
emit('activate', event)
}

function markImageLoaded() {
loadedScreenshotUrls.add(props.screenshot.url)
loaded.value = true
async function markImageLoaded() {
const loadedImage = image.value
const loadedUrl = props.screenshot.url
const generation = loadGeneration
if (!loadedImage) return

try {
await loadedImage.decode()
} catch {
if (!loadedImage.complete || loadedImage.naturalWidth === 0) return
}

await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve()))
})

if (
generation !== loadGeneration ||
image.value !== loadedImage ||
props.screenshot.url !== loadedUrl
) {
return
}

const wasReady = imageReady.value
imageReady.value = true
if (!wasReady) {
debugImage('image loaded', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
naturalWidth: image.value?.naturalWidth,
naturalHeight: image.value?.naturalHeight,
})
}
}

function markImageFailed(event: Event) {
debugImage('image failed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
event,
})
}

onMounted(() => {
debugImage('mounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
complete: image.value?.complete,
})
if (image.value?.complete && image.value.naturalWidth > 0) markImageLoaded()
})

onBeforeUnmount(() => {
loadGeneration += 1
debugImage('unmounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loaded: imageReady.value,
})
})

watch(
() => props.screenshot.url,
(url) => {
loaded.value = loadedScreenshotUrls.has(url)
(url, previousUrl) => {
loadGeneration += 1
loadStartedAt = performance.now()
imageReady.value = false
debugImage('source changed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
previousUrl,
url,
})
},
)
</script>
Expand All @@ -91,7 +166,7 @@ watch(
ref="card"
role="button"
tabindex="0"
class="group relative aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
class="group relative isolate aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
:class="{
'!border-contrast brightness-110': selected,
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
Expand All @@ -115,7 +190,7 @@ watch(
>
<button
type="button"
class="selection-button group/selection absolute right-0.5 top-0 z-[2] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
class="selection-button group/selection absolute right-0.5 top-0 z-[3] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
:aria-label="
formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
Expand All @@ -134,19 +209,25 @@ watch(
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
</span>
</button>
<div v-if="!loaded" class="absolute inset-0 animate-pulse bg-surface-3" />
<img
ref="image"
:src="screenshot.url"
:alt="screenshot.file_name"
loading="lazy"
loading="eager"
decoding="async"
draggable="false"
class="h-full w-full object-cover transition duration-200"
:class="loaded ? 'opacity-100' : 'opacity-0'"
class="screenshot-card-fade absolute inset-0 z-[1] h-full w-full object-cover"
:class="imageReady ? 'opacity-100' : 'opacity-0'"
@load="markImageLoaded"
@error="markImageFailed"
/>
<div
class="absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-contrast opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
aria-hidden="true"
class="pointer-events-none absolute inset-0 bg-button-bg"
:class="{ 'animate-pulse': !imageReady }"
/>
<div
class="absolute inset-x-0 bottom-0 z-[2] flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-contrast opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
>
<div class="min-w-0">
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
Expand Down Expand Up @@ -193,3 +274,9 @@ watch(
</div>
</article>
</template>

<style scoped>
.screenshot-card-fade {
transition: opacity 350ms ease-in-out;
}
</style>
Loading
Loading