mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 19:46:33 +00:00
feat: screenshots tab draft
This commit is contained in:
@@ -0,0 +1,269 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="activeItem"
|
||||||
|
class="expanded-image-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="activeItem.title || activeItem.alt"
|
||||||
|
@click="hide"
|
||||||
|
>
|
||||||
|
<div class="content">
|
||||||
|
<img
|
||||||
|
class="image"
|
||||||
|
:class="{ 'zoomed-in': zoomedIn }"
|
||||||
|
:src="activeItem.src"
|
||||||
|
:alt="activeItem.alt"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="floating" @click.stop>
|
||||||
|
<div v-if="activeItem.title || activeItem.description" class="text">
|
||||||
|
<h2 v-if="activeItem.title">{{ activeItem.title }}</h2>
|
||||||
|
<p v-if="activeItem.description">{{ activeItem.description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<div class="buttons">
|
||||||
|
<IconButton label="Close" class="close" @click="hide">
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
</IconButton>
|
||||||
|
<slot name="actions" :item="activeItem" :index="activeIndex" :hide="hide" />
|
||||||
|
<IconButton label="Toggle zoom" @click="zoomedIn = !zoomedIn">
|
||||||
|
<ExpandIcon v-if="!zoomedIn" aria-hidden="true" />
|
||||||
|
<ContractIcon v-else aria-hidden="true" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
v-if="items.length > 1"
|
||||||
|
label="Previous image"
|
||||||
|
class="previous"
|
||||||
|
@click="previous"
|
||||||
|
>
|
||||||
|
<LeftArrowIcon aria-hidden="true" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
v-if="items.length > 1"
|
||||||
|
label="Next image"
|
||||||
|
class="next"
|
||||||
|
@click="next"
|
||||||
|
>
|
||||||
|
<RightArrowIcon aria-hidden="true" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ContractIcon,
|
||||||
|
ExpandIcon,
|
||||||
|
LeftArrowIcon,
|
||||||
|
RightArrowIcon,
|
||||||
|
XIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import { IconButton } from '@modrinth/ui'
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { release_ads_window_hold, take_ads_window_hold } from '@/helpers/ads.js'
|
||||||
|
|
||||||
|
type ImagePreviewItem = {
|
||||||
|
id: string
|
||||||
|
src: string
|
||||||
|
alt: string
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: ImagePreviewItem[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
show: [item: ImagePreviewItem, index: number]
|
||||||
|
hide: []
|
||||||
|
navigate: [item: ImagePreviewItem, index: number, direction: 'next' | 'previous']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const activeId = ref<string | null>(null)
|
||||||
|
const activeIndex = computed(() => props.items.findIndex((item) => item.id === activeId.value))
|
||||||
|
const activeItem = computed(() => props.items[activeIndex.value] ?? null)
|
||||||
|
const zoomedIn = ref(false)
|
||||||
|
let adsWindowHold = false
|
||||||
|
|
||||||
|
function show(index: number) {
|
||||||
|
const item = props.items[index]
|
||||||
|
if (!item) return
|
||||||
|
|
||||||
|
if (!adsWindowHold) {
|
||||||
|
adsWindowHold = true
|
||||||
|
take_ads_window_hold()
|
||||||
|
}
|
||||||
|
activeId.value = item.id
|
||||||
|
zoomedIn.value = false
|
||||||
|
emit('show', item, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
if (activeId.value === null) return
|
||||||
|
activeId.value = null
|
||||||
|
zoomedIn.value = false
|
||||||
|
if (adsWindowHold) {
|
||||||
|
adsWindowHold = false
|
||||||
|
release_ads_window_hold()
|
||||||
|
}
|
||||||
|
emit('hide')
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(offset: number, direction: 'next' | 'previous') {
|
||||||
|
if (props.items.length < 2) return
|
||||||
|
const index = (activeIndex.value + offset + props.items.length) % props.items.length
|
||||||
|
activeId.value = props.items[index].id
|
||||||
|
zoomedIn.value = false
|
||||||
|
emit('navigate', props.items[index], index, direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
navigate(1, 'next')
|
||||||
|
}
|
||||||
|
|
||||||
|
function previous() {
|
||||||
|
navigate(-1, 'previous')
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyListener(event: KeyboardEvent) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
if (document.querySelector('.modal-root')) return
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
hide()
|
||||||
|
} else if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault()
|
||||||
|
previous()
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault()
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('keydown', keyListener))
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', keyListener)
|
||||||
|
if (adsWindowHold) release_ads_window_hold()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(activeItem, (item, previousItem) => {
|
||||||
|
if (!item && previousItem) hide()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ show, hide, next, previous })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.expanded-image-modal {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 110;
|
||||||
|
overflow: auto;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgb(0 0 0 / 70%);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
--controls-safe-area: 5rem;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
width: calc(100% - 2 * var(--gap-lg));
|
||||||
|
height: calc(100% - 2 * var(--gap-lg));
|
||||||
|
|
||||||
|
.image {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
|
||||||
|
&.zoomed-in {
|
||||||
|
object-fit: cover;
|
||||||
|
top: calc((100% - var(--controls-safe-area)) / 2);
|
||||||
|
width: auto;
|
||||||
|
height: calc(100% - var(--controls-safe-area));
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: var(--gap-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gap-md);
|
||||||
|
transition: opacity 0.25s ease-in-out;
|
||||||
|
opacity: 1;
|
||||||
|
padding: 2rem 2rem 0;
|
||||||
|
|
||||||
|
&:not(&:hover) {
|
||||||
|
opacity: 0.4;
|
||||||
|
|
||||||
|
.text {
|
||||||
|
transform: translateY(2.5rem) scale(0.8);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
transform: translateY(0.25rem) scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 40rem;
|
||||||
|
transition:
|
||||||
|
opacity 0.25s ease-in-out,
|
||||||
|
transform 0.25s ease-in-out;
|
||||||
|
text-shadow: 1px 1px 10px #000000d4;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: var(--dark-color-base);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--dark-color-base);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
background-color: var(--color-raised-bg);
|
||||||
|
padding: var(--gap-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition:
|
||||||
|
opacity 0.25s ease-in-out,
|
||||||
|
transform 0.25s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -136,6 +136,37 @@ export async function get_mod_full_path(instanceId: string, projectPath: string)
|
|||||||
return await invoke('plugin:instance|instance_get_mod_full_path', { instanceId, projectPath })
|
return await invoke('plugin:instance|instance_get_mod_full_path', { instanceId, projectPath })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InstanceScreenshot {
|
||||||
|
file_name: string
|
||||||
|
created_at: string
|
||||||
|
path: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list_screenshots(instanceId: string): Promise<InstanceScreenshot[]> {
|
||||||
|
return await invoke('plugin:instance|instance_list_screenshots', { instanceId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function delete_screenshot(instanceId: string, fileName: string): Promise<void> {
|
||||||
|
return await invoke('plugin:instance|instance_delete_screenshot', { instanceId, fileName })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function export_screenshots(
|
||||||
|
instanceId: string,
|
||||||
|
fileNames: string[],
|
||||||
|
exportPath: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return await invoke('plugin:instance|instance_export_screenshots', {
|
||||||
|
instanceId,
|
||||||
|
fileNames,
|
||||||
|
exportPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function open_screenshot(instanceId: string, fileName: string): Promise<void> {
|
||||||
|
return await invoke('plugin:instance|instance_open_screenshot', { instanceId, fileName })
|
||||||
|
}
|
||||||
|
|
||||||
export interface JavaVersion {
|
export interface JavaVersion {
|
||||||
parsed_version: number
|
parsed_version: number
|
||||||
version: string
|
version: string
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import Content from './content/index.vue'
|
|||||||
import Files from './files/index.vue'
|
import Files from './files/index.vue'
|
||||||
import Index from './layout.vue'
|
import Index from './layout.vue'
|
||||||
import Logs from './logs/index.vue'
|
import Logs from './logs/index.vue'
|
||||||
|
import Screenshots from './screenshots/index.vue'
|
||||||
import Share from './share/index.vue'
|
import Share from './share/index.vue'
|
||||||
import Worlds from './worlds/index.vue'
|
import Worlds from './worlds/index.vue'
|
||||||
|
|
||||||
export { Content, Files, Index, Logs, Share, Worlds }
|
export { Content, Files, Index, Logs, Screenshots, Share, Worlds }
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import {
|
|||||||
EditIcon,
|
EditIcon,
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
|
ImagesIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
StopCircleIcon,
|
StopCircleIcon,
|
||||||
@@ -449,6 +450,11 @@ const tabs = computed(() => {
|
|||||||
href: `${basePath.value}/files`,
|
href: `${basePath.value}/files`,
|
||||||
icon: FolderOpenIcon,
|
icon: FolderOpenIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Screenshots',
|
||||||
|
href: `${basePath.value}/screenshots`,
|
||||||
|
icon: ImagesIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Worlds',
|
label: 'Worlds',
|
||||||
href: `${basePath.value}/worlds`,
|
href: `${basePath.value}/worlds`,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { queryOptions } from '@tanstack/vue-query'
|
import { queryOptions } from '@tanstack/vue-query'
|
||||||
|
|
||||||
import { get_project_v3 } from '@/helpers/cache.js'
|
import { get_project_v3 } from '@/helpers/cache.js'
|
||||||
import { get as getInstance } from '@/helpers/instance'
|
import { get as getInstance, list_screenshots } from '@/helpers/instance'
|
||||||
import { loadInstanceContentData } from '@/helpers/instance-content'
|
import { loadInstanceContentData } from '@/helpers/instance-content'
|
||||||
import { get_by_instance_id } from '@/helpers/process'
|
import { get_by_instance_id } from '@/helpers/process'
|
||||||
import { refreshWorlds } from '@/helpers/worlds'
|
import { refreshWorlds } from '@/helpers/worlds'
|
||||||
@@ -22,6 +22,8 @@ export const instanceKeys = {
|
|||||||
[...instanceKeys.detail(instanceId), 'installed-project-ids', source] as const,
|
[...instanceKeys.detail(instanceId), 'installed-project-ids', source] as const,
|
||||||
linkedContent: (instanceId: string) => ['linkedModpackContent', instanceId] as const,
|
linkedContent: (instanceId: string) => ['linkedModpackContent', instanceId] as const,
|
||||||
worlds: (instanceId: string) => ['worlds', instanceId] as const,
|
worlds: (instanceId: string) => ['worlds', instanceId] as const,
|
||||||
|
screenshots: (instanceId: string) =>
|
||||||
|
[...instanceKeys.detail(instanceId), 'screenshots'] as const,
|
||||||
linkedProject: (projectId: string) => ['project', 'v3', projectId] as const,
|
linkedProject: (projectId: string) => ['project', 'v3', projectId] as const,
|
||||||
sharedEligibility: (userId: string | null | undefined) =>
|
sharedEligibility: (userId: string | null | undefined) =>
|
||||||
['shared-instance-eligibility', userId] as const,
|
['shared-instance-eligibility', userId] as const,
|
||||||
@@ -79,3 +81,11 @@ export function instanceWorldsQueryOptions(instanceId: string) {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function instanceScreenshotsQueryOptions(instanceId: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: instanceKeys.screenshots(instanceId),
|
||||||
|
queryFn: () => list_screenshots(instanceId),
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,650 @@
|
|||||||
|
<template>
|
||||||
|
<ConfirmModal
|
||||||
|
ref="deleteModal"
|
||||||
|
:title="formatMessage(messages.deleteTitle)"
|
||||||
|
:description="
|
||||||
|
formatMessage(messages.deleteDescription, {
|
||||||
|
name: screenshotToDelete?.file_name ?? '',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:proceed-label="formatMessage(commonMessages.deleteLabel)"
|
||||||
|
:markdown="false"
|
||||||
|
@proceed="confirmDelete"
|
||||||
|
/>
|
||||||
|
<ConfirmModal
|
||||||
|
ref="bulkDeleteModal"
|
||||||
|
:title="formatMessage(messages.bulkDeleteTitle)"
|
||||||
|
:description="
|
||||||
|
formatMessage(messages.bulkDeleteDescription, {
|
||||||
|
count: selectedScreenshotNames.size,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:proceed-label="formatMessage(commonMessages.deleteLabel)"
|
||||||
|
:markdown="false"
|
||||||
|
@proceed="deleteSelectedScreenshots"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImagePreviewModal ref="previewModal" :items="previewItems">
|
||||||
|
<template #actions="{ item }">
|
||||||
|
<IconButton
|
||||||
|
v-tooltip="formatMessage(messages.copy)"
|
||||||
|
:label="formatMessage(messages.copy)"
|
||||||
|
@click="copyScreenshotByFileName(item.id)"
|
||||||
|
>
|
||||||
|
<ClipboardCopyIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
v-tooltip="formatMessage(messages.showInFolder)"
|
||||||
|
:label="formatMessage(messages.showInFolder)"
|
||||||
|
@click="openScreenshotByFileName(item.id)"
|
||||||
|
>
|
||||||
|
<ExternalIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
v-tooltip="formatMessage(commonMessages.deleteLabel)"
|
||||||
|
:label="formatMessage(commonMessages.deleteLabel)"
|
||||||
|
@click="requestPreviewDelete(item.id)"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</IconButton>
|
||||||
|
</template>
|
||||||
|
</ImagePreviewModal>
|
||||||
|
|
||||||
|
<ReadyTransition :pending="screenshotsReadyPending">
|
||||||
|
<EmptyState
|
||||||
|
v-if="screenshotsError"
|
||||||
|
type="error"
|
||||||
|
:heading="formatMessage(messages.errorHeading)"
|
||||||
|
:description="screenshotsError.message"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<Button type="outlined" @click="screenshotsQuery.refetch()">
|
||||||
|
{{ formatMessage(commonMessages.retryButton) }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
v-else-if="screenshots.length === 0"
|
||||||
|
type="no-images"
|
||||||
|
:heading="formatMessage(messages.emptyHeading)"
|
||||||
|
:description="formatMessage(messages.emptyDescription)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col gap-3">
|
||||||
|
<div
|
||||||
|
v-for="(group, groupIndex) in groupedScreenshots"
|
||||||
|
:key="group.label"
|
||||||
|
class="relative flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute left-2.5 top-5 w-px bg-surface-5"
|
||||||
|
:class="groupIndex === groupedScreenshots.length - 1 ? 'bottom-0' : '-bottom-3'"
|
||||||
|
/>
|
||||||
|
<div class="relative flex items-center gap-2">
|
||||||
|
<div class="flex w-5 shrink-0 items-center justify-center">
|
||||||
|
<CalendarIcon class="size-5" />
|
||||||
|
</div>
|
||||||
|
<span class="text-lg font-semibold leading-5 text-contrast">{{ group.label }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3 pl-7 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="screenshot in group.screenshots"
|
||||||
|
:key="screenshot.file_name"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="group relative aspect-video cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition hover:border-brand focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
|
||||||
|
:class="{ '!border-contrast': selectedScreenshotNames.has(screenshot.file_name) }"
|
||||||
|
:aria-label="
|
||||||
|
selectionActive
|
||||||
|
? formatMessage(
|
||||||
|
selectedScreenshotNames.has(screenshot.file_name)
|
||||||
|
? messages.deselectScreenshot
|
||||||
|
: messages.selectScreenshot,
|
||||||
|
{ name: screenshot.file_name },
|
||||||
|
)
|
||||||
|
: screenshot.file_name
|
||||||
|
"
|
||||||
|
:aria-pressed="
|
||||||
|
selectionActive ? selectedScreenshotNames.has(screenshot.file_name) : undefined
|
||||||
|
"
|
||||||
|
@click="activateScreenshot(screenshot, $event)"
|
||||||
|
@keydown="handleScreenshotKeydown($event, screenshot)"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
:aria-label="
|
||||||
|
formatMessage(
|
||||||
|
selectedScreenshotNames.has(screenshot.file_name)
|
||||||
|
? messages.deselectScreenshot
|
||||||
|
: messages.selectScreenshot,
|
||||||
|
{ name: screenshot.file_name },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-pressed="selectedScreenshotNames.has(screenshot.file_name)"
|
||||||
|
@click.stop="toggleScreenshotSelection(screenshot.file_name)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="relative flex size-6 items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-hover/selection:brightness-125"
|
||||||
|
:class="
|
||||||
|
selectedScreenshotNames.has(screenshot.file_name)
|
||||||
|
? 'border-0 !opacity-100'
|
||||||
|
: 'border-2 border-solid border-primary bg-transparent'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="selectedScreenshotNames.has(screenshot.file_name)"
|
||||||
|
class="absolute inset-0 rounded-full bg-contrast"
|
||||||
|
/>
|
||||||
|
<CheckIcon
|
||||||
|
v-if="selectedScreenshotNames.has(screenshot.file_name)"
|
||||||
|
class="relative size-4 invert [stroke-width:3]"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="!loadedScreenshots.has(screenshot.file_name)"
|
||||||
|
class="absolute inset-0 animate-pulse bg-surface-3"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
:src="screenshot.url"
|
||||||
|
:alt="screenshot.file_name"
|
||||||
|
loading="lazy"
|
||||||
|
class="h-full w-full object-cover transition duration-200 group-hover:scale-[1.02]"
|
||||||
|
:class="loadedScreenshots.has(screenshot.file_name) ? 'opacity-100' : 'opacity-0'"
|
||||||
|
@load="markLoaded(screenshot.file_name)"
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
|
||||||
|
{{ screenshot.file_name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-secondary">
|
||||||
|
{{ formatScreenshotTime(screenshot.created_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!selectionActive"
|
||||||
|
class="flex shrink-0 translate-y-1 gap-1 opacity-0 transition group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
v-tooltip="formatMessage(messages.copy)"
|
||||||
|
:label="formatMessage(messages.copy)"
|
||||||
|
type="quiet"
|
||||||
|
class="bg-surface-2 text-contrast hover:bg-surface-3"
|
||||||
|
@click="copyScreenshot(screenshot)"
|
||||||
|
>
|
||||||
|
<ClipboardCopyIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
v-tooltip="formatMessage(messages.showInFolder)"
|
||||||
|
:label="formatMessage(messages.showInFolder)"
|
||||||
|
type="quiet"
|
||||||
|
class="bg-surface-2 text-contrast hover:bg-surface-3"
|
||||||
|
@click="openScreenshot(screenshot)"
|
||||||
|
>
|
||||||
|
<ExternalIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
v-tooltip="formatMessage(commonMessages.deleteLabel)"
|
||||||
|
:label="formatMessage(commonMessages.deleteLabel)"
|
||||||
|
type="quiet"
|
||||||
|
class="bg-surface-2 text-contrast hover:bg-surface-3"
|
||||||
|
@click="requestDelete(screenshot)"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FloatingActionBar
|
||||||
|
:shown="selectionActive"
|
||||||
|
:aria-label="formatMessage(messages.selectionAriaLabel)"
|
||||||
|
hide-when-modal-open
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-0.5">
|
||||||
|
<span class="px-4 py-2.5 text-base font-semibold tabular-nums text-contrast">
|
||||||
|
{{ formatMessage(messages.selectedCount, { count: selectedScreenshotNames.size }) }}
|
||||||
|
</span>
|
||||||
|
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||||
|
<Button type="quiet" :disabled="bulkBusy" @click="clearScreenshotSelection">
|
||||||
|
{{ formatMessage(commonMessages.clearButton) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto flex items-center gap-0.5">
|
||||||
|
<Button type="quiet" :disabled="bulkBusy" @click="exportSelectedScreenshots">
|
||||||
|
<FileArchiveIcon />
|
||||||
|
<span class="bar-label">{{ formatMessage(messages.exportZip) }}</span>
|
||||||
|
</Button>
|
||||||
|
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||||
|
<Button
|
||||||
|
type="quiet"
|
||||||
|
color="red"
|
||||||
|
interaction="filled"
|
||||||
|
:disabled="bulkBusy"
|
||||||
|
@click="bulkDeleteModal?.show()"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FloatingActionBar>
|
||||||
|
</ReadyTransition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
CalendarIcon,
|
||||||
|
CheckIcon,
|
||||||
|
ClipboardCopyIcon,
|
||||||
|
ExternalIcon,
|
||||||
|
FileArchiveIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
commonMessages,
|
||||||
|
ConfirmModal,
|
||||||
|
defineMessages,
|
||||||
|
EmptyState,
|
||||||
|
FloatingActionBar,
|
||||||
|
IconButton,
|
||||||
|
injectNotificationManager,
|
||||||
|
ReadyTransition,
|
||||||
|
useFormatDateTime,
|
||||||
|
useReadyState,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { save } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { readFile } from '@tauri-apps/plugin-fs'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import ImagePreviewModal from '@/components/ui/ImagePreviewModal.vue'
|
||||||
|
import { instance_listener } from '@/helpers/events.js'
|
||||||
|
import {
|
||||||
|
delete_screenshot,
|
||||||
|
export_screenshots,
|
||||||
|
open_screenshot,
|
||||||
|
type InstanceScreenshot,
|
||||||
|
} from '@/helpers/instance'
|
||||||
|
|
||||||
|
import { injectInstancePage } from '../instance-context'
|
||||||
|
import { instanceKeys, instanceScreenshotsQueryOptions } from '../query-options'
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
emptyHeading: {
|
||||||
|
id: 'app.instance.screenshots.empty-heading',
|
||||||
|
defaultMessage: 'No screenshots yet',
|
||||||
|
},
|
||||||
|
emptyDescription: {
|
||||||
|
id: 'app.instance.screenshots.empty-description',
|
||||||
|
defaultMessage: 'Screenshots you take in-game will appear here.',
|
||||||
|
},
|
||||||
|
errorHeading: {
|
||||||
|
id: 'app.instance.screenshots.error-heading',
|
||||||
|
defaultMessage: 'Failed to load screenshots',
|
||||||
|
},
|
||||||
|
copy: {
|
||||||
|
id: 'app.instance.screenshots.copy',
|
||||||
|
defaultMessage: 'Copy image',
|
||||||
|
},
|
||||||
|
showInFolder: {
|
||||||
|
id: 'app.instance.screenshots.show-in-folder',
|
||||||
|
defaultMessage: 'Show in folder',
|
||||||
|
},
|
||||||
|
deleteTitle: {
|
||||||
|
id: 'app.instance.screenshots.delete-title',
|
||||||
|
defaultMessage: 'Delete screenshot',
|
||||||
|
},
|
||||||
|
deleteDescription: {
|
||||||
|
id: 'app.instance.screenshots.delete-description',
|
||||||
|
defaultMessage: 'Permanently delete {name}? This action cannot be undone.',
|
||||||
|
},
|
||||||
|
deleteSuccess: {
|
||||||
|
id: 'app.instance.screenshots.delete-success',
|
||||||
|
defaultMessage: 'Screenshot deleted',
|
||||||
|
},
|
||||||
|
copySuccess: {
|
||||||
|
id: 'app.instance.screenshots.copy-success',
|
||||||
|
defaultMessage: 'Screenshot copied to clipboard',
|
||||||
|
},
|
||||||
|
selectionAriaLabel: {
|
||||||
|
id: 'app.instance.screenshots.selection.aria-label',
|
||||||
|
defaultMessage: 'Selected screenshots',
|
||||||
|
},
|
||||||
|
selectedCount: {
|
||||||
|
id: 'app.instance.screenshots.selection.selected-count',
|
||||||
|
defaultMessage: '{count} selected',
|
||||||
|
},
|
||||||
|
selectScreenshot: {
|
||||||
|
id: 'app.instance.screenshots.selection.select',
|
||||||
|
defaultMessage: 'Select {name}',
|
||||||
|
},
|
||||||
|
deselectScreenshot: {
|
||||||
|
id: 'app.instance.screenshots.selection.deselect',
|
||||||
|
defaultMessage: 'Deselect {name}',
|
||||||
|
},
|
||||||
|
exportZip: {
|
||||||
|
id: 'app.instance.screenshots.selection.export-zip',
|
||||||
|
defaultMessage: 'Export ZIP',
|
||||||
|
},
|
||||||
|
zipArchive: {
|
||||||
|
id: 'app.instance.screenshots.selection.zip-archive',
|
||||||
|
defaultMessage: 'ZIP archive',
|
||||||
|
},
|
||||||
|
exportSuccess: {
|
||||||
|
id: 'app.instance.screenshots.selection.export-success',
|
||||||
|
defaultMessage: 'Screenshots exported',
|
||||||
|
},
|
||||||
|
bulkDeleteTitle: {
|
||||||
|
id: 'app.instance.screenshots.selection.delete-title',
|
||||||
|
defaultMessage: 'Delete selected screenshots',
|
||||||
|
},
|
||||||
|
bulkDeleteDescription: {
|
||||||
|
id: 'app.instance.screenshots.selection.delete-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Delete {count, plural, one {# screenshot} other {# screenshots}}? This action cannot be undone.',
|
||||||
|
},
|
||||||
|
bulkDeleteSuccess: {
|
||||||
|
id: 'app.instance.screenshots.selection.delete-success',
|
||||||
|
defaultMessage: '{count, plural, one {# screenshot deleted} other {# screenshots deleted}}',
|
||||||
|
},
|
||||||
|
today: {
|
||||||
|
id: 'app.instance.screenshots.group.today',
|
||||||
|
defaultMessage: 'Today',
|
||||||
|
},
|
||||||
|
yesterday: {
|
||||||
|
id: 'app.instance.screenshots.group.yesterday',
|
||||||
|
defaultMessage: 'Yesterday',
|
||||||
|
},
|
||||||
|
thisWeek: {
|
||||||
|
id: 'app.instance.screenshots.group.this-week',
|
||||||
|
defaultMessage: 'This week',
|
||||||
|
},
|
||||||
|
thisMonth: {
|
||||||
|
id: 'app.instance.screenshots.group.this-month',
|
||||||
|
defaultMessage: 'This month',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type ScreenshotGroup = {
|
||||||
|
label: string
|
||||||
|
screenshots: InstanceScreenshot[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const { addNotification, handleError } = injectNotificationManager()
|
||||||
|
const instancePage = injectInstancePage()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const instanceId = instancePage.instanceId
|
||||||
|
const previewModal = ref<InstanceType<typeof ImagePreviewModal>>()
|
||||||
|
const deleteModal = ref<InstanceType<typeof ConfirmModal>>()
|
||||||
|
const bulkDeleteModal = ref<InstanceType<typeof ConfirmModal>>()
|
||||||
|
const screenshotToDelete = ref<InstanceScreenshot | null>(null)
|
||||||
|
const deleteFromPreview = ref(false)
|
||||||
|
const loadedScreenshots = ref(new Set<string>())
|
||||||
|
const selectedScreenshotNames = ref(new Set<string>())
|
||||||
|
const bulkDeleting = ref(false)
|
||||||
|
const bulkExporting = ref(false)
|
||||||
|
|
||||||
|
const screenshotsQuery = useQuery(computed(() => instanceScreenshotsQueryOptions(instanceId.value)))
|
||||||
|
const screenshotsReadyPending = useReadyState(screenshotsQuery)
|
||||||
|
const screenshots = computed(() => screenshotsQuery.data.value ?? [])
|
||||||
|
const selectionActive = computed(() => selectedScreenshotNames.value.size > 0)
|
||||||
|
const bulkBusy = computed(() => bulkDeleting.value || bulkExporting.value)
|
||||||
|
const selectedScreenshots = computed(() =>
|
||||||
|
screenshots.value.filter((screenshot) =>
|
||||||
|
selectedScreenshotNames.value.has(screenshot.file_name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const screenshotsError = computed(() => {
|
||||||
|
const error = screenshotsQuery.error.value
|
||||||
|
return error instanceof Error ? error : error ? new Error(String(error)) : null
|
||||||
|
})
|
||||||
|
const formatScreenshotDate = useFormatDateTime({ dateStyle: 'long', timeStyle: 'short' })
|
||||||
|
const formatScreenshotTime = useFormatDateTime({ hour: 'numeric', minute: '2-digit' })
|
||||||
|
const formatScreenshotMonth = useFormatDateTime({ month: 'long', year: 'numeric' })
|
||||||
|
const previewItems = computed(() =>
|
||||||
|
screenshots.value.map((screenshot) => ({
|
||||||
|
id: screenshot.file_name,
|
||||||
|
src: screenshot.url,
|
||||||
|
alt: screenshot.file_name,
|
||||||
|
title: screenshot.file_name,
|
||||||
|
description: formatScreenshotDate(screenshot.created_at),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const groupedScreenshots = computed((): ScreenshotGroup[] => {
|
||||||
|
const now = dayjs()
|
||||||
|
const groups: ScreenshotGroup[] = []
|
||||||
|
|
||||||
|
function addToGroup(label: string, screenshot: InstanceScreenshot) {
|
||||||
|
let group = groups.find((candidate) => candidate.label === label)
|
||||||
|
if (!group) {
|
||||||
|
group = { label, screenshots: [] }
|
||||||
|
groups.push(group)
|
||||||
|
}
|
||||||
|
group.screenshots.push(screenshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const screenshot of screenshots.value) {
|
||||||
|
const created = dayjs(screenshot.created_at)
|
||||||
|
const isToday = created.isSame(now, 'day')
|
||||||
|
const isYesterday = created.isSame(now.subtract(1, 'day'), 'day')
|
||||||
|
|
||||||
|
if (isToday) {
|
||||||
|
addToGroup(formatMessage(messages.today), screenshot)
|
||||||
|
} else if (isYesterday) {
|
||||||
|
addToGroup(formatMessage(messages.yesterday), screenshot)
|
||||||
|
} else if (created.isSame(now, 'week')) {
|
||||||
|
addToGroup(formatMessage(messages.thisWeek), screenshot)
|
||||||
|
} else if (created.isSame(now, 'month')) {
|
||||||
|
addToGroup(formatMessage(messages.thisMonth), screenshot)
|
||||||
|
} else {
|
||||||
|
addToGroup(formatScreenshotMonth(created.toDate()), screenshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(screenshots, (currentScreenshots) => {
|
||||||
|
const currentNames = new Set(currentScreenshots.map((screenshot) => screenshot.file_name))
|
||||||
|
selectedScreenshotNames.value = new Set(
|
||||||
|
[...selectedScreenshotNames.value].filter((fileName) => currentNames.has(fileName)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleScreenshotSelection(fileName: string) {
|
||||||
|
if (bulkBusy.value) return
|
||||||
|
const nextSelection = new Set(selectedScreenshotNames.value)
|
||||||
|
if (nextSelection.has(fileName)) {
|
||||||
|
nextSelection.delete(fileName)
|
||||||
|
} else {
|
||||||
|
nextSelection.add(fileName)
|
||||||
|
}
|
||||||
|
selectedScreenshotNames.value = nextSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateScreenshot(screenshot: InstanceScreenshot, event?: MouseEvent) {
|
||||||
|
if (selectionActive.value || event?.shiftKey) {
|
||||||
|
toggleScreenshotSelection(screenshot.file_name)
|
||||||
|
} else {
|
||||||
|
showPreview(screenshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScreenshotKeydown(event: KeyboardEvent, screenshot: InstanceScreenshot) {
|
||||||
|
if (event.target !== event.currentTarget) return
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
activateScreenshot(screenshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScreenshotSelection() {
|
||||||
|
if (bulkBusy.value) return
|
||||||
|
selectedScreenshotNames.value = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
function markLoaded(fileName: string) {
|
||||||
|
loadedScreenshots.value.add(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPreview(screenshot: InstanceScreenshot) {
|
||||||
|
const index = screenshots.value.findIndex((item) => item.file_name === screenshot.file_name)
|
||||||
|
if (index >= 0) previewModal.value?.show(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(screenshot: InstanceScreenshot) {
|
||||||
|
deleteFromPreview.value = false
|
||||||
|
screenshotToDelete.value = screenshot
|
||||||
|
deleteModal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenshotByFileName(fileName: string) {
|
||||||
|
return screenshots.value.find((screenshot) => screenshot.file_name === fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestPreviewDelete(fileName: string) {
|
||||||
|
const screenshot = screenshotByFileName(fileName)
|
||||||
|
if (!screenshot) return
|
||||||
|
deleteFromPreview.value = true
|
||||||
|
screenshotToDelete.value = screenshot
|
||||||
|
deleteModal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const screenshot = screenshotToDelete.value
|
||||||
|
if (!screenshot) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await delete_screenshot(instanceId.value, screenshot.file_name)
|
||||||
|
if (deleteFromPreview.value) previewModal.value?.hide()
|
||||||
|
await queryClient.invalidateQueries({ queryKey: instanceKeys.screenshots(instanceId.value) })
|
||||||
|
addNotification({ type: 'success', title: formatMessage(messages.deleteSuccess) })
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
screenshotToDelete.value = null
|
||||||
|
deleteFromPreview.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedScreenshots() {
|
||||||
|
const selected = selectedScreenshots.value
|
||||||
|
if (bulkBusy.value || selected.length === 0) return
|
||||||
|
|
||||||
|
bulkDeleting.value = true
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
selected.map((screenshot) => delete_screenshot(instanceId.value, screenshot.file_name)),
|
||||||
|
)
|
||||||
|
const deletedNames = new Set<string>()
|
||||||
|
|
||||||
|
for (const [index, result] of results.entries()) {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
deletedNames.add(selected[index].file_name)
|
||||||
|
} else {
|
||||||
|
handleError(result.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedNames.size > 0) {
|
||||||
|
selectedScreenshotNames.value = new Set(
|
||||||
|
[...selectedScreenshotNames.value].filter((fileName) => !deletedNames.has(fileName)),
|
||||||
|
)
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: instanceKeys.screenshots(instanceId.value),
|
||||||
|
})
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: formatMessage(messages.bulkDeleteSuccess, { count: deletedNames.size }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bulkDeleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportSelectedScreenshots() {
|
||||||
|
const selected = selectedScreenshots.value
|
||||||
|
if (bulkBusy.value || selected.length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instanceName = instancePage.instance.value.name.replace(/[\\/:*?"<>|]/g, '-')
|
||||||
|
const outputPath = await save({
|
||||||
|
defaultPath: `${instanceName} screenshots.zip`,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: formatMessage(messages.zipArchive),
|
||||||
|
extensions: ['zip'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if (!outputPath) return
|
||||||
|
|
||||||
|
bulkExporting.value = true
|
||||||
|
await export_screenshots(
|
||||||
|
instanceId.value,
|
||||||
|
selected.map((screenshot) => screenshot.file_name),
|
||||||
|
outputPath,
|
||||||
|
)
|
||||||
|
addNotification({ type: 'success', title: formatMessage(messages.exportSuccess) })
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
bulkExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyScreenshotByFileName(fileName: string) {
|
||||||
|
const screenshot = screenshotByFileName(fileName)
|
||||||
|
if (screenshot) void copyScreenshot(screenshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyScreenshot(screenshot: InstanceScreenshot) {
|
||||||
|
try {
|
||||||
|
const png = readFile(screenshot.path).then((bytes) => new Blob([bytes], { type: 'image/png' }))
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })])
|
||||||
|
addNotification({ type: 'success', title: formatMessage(messages.copySuccess) })
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openScreenshot(screenshot: InstanceScreenshot) {
|
||||||
|
try {
|
||||||
|
await open_screenshot(instanceId.value, screenshot.file_name)
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openScreenshotByFileName(fileName: string) {
|
||||||
|
const screenshot = screenshotByFileName(fileName)
|
||||||
|
if (screenshot) void openScreenshot(screenshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlistenInstance = await instance_listener(
|
||||||
|
(event: { instance_id: string; event: string }) => {
|
||||||
|
if (event.instance_id !== instanceId.value || event.event !== 'screenshots_updated') return
|
||||||
|
void queryClient.invalidateQueries({ queryKey: instanceKeys.screenshots(instanceId.value) })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
unlistenInstance()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -14,86 +14,29 @@
|
|||||||
</span>
|
</span>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="expandedGalleryItem" class="expanded-image-modal" @click="hideImage">
|
<ImagePreviewModal
|
||||||
<div class="content">
|
ref="imagePreviewModal"
|
||||||
<img
|
:items="previewItems"
|
||||||
class="image"
|
@navigate="trackPreviewNavigation"
|
||||||
:class="{ 'zoomed-in': zoomedIn }"
|
>
|
||||||
:src="
|
<template #actions="{ item }">
|
||||||
expandedGalleryItem.raw_url
|
<ButtonLink
|
||||||
? expandedGalleryItem.raw_url
|
class="open btn icon-only !w-9 !rounded-full !px-0"
|
||||||
: 'https://cdn.modrinth.com/placeholder-banner.svg'
|
target="_blank"
|
||||||
"
|
:href="item.src"
|
||||||
:alt="expandedGalleryItem.title ? expandedGalleryItem.title : 'gallery-image'"
|
>
|
||||||
@click.stop="() => {}"
|
<ExternalIcon aria-hidden="true" />
|
||||||
/>
|
</ButtonLink>
|
||||||
|
</template>
|
||||||
<div class="floating" @click.stop="() => {}">
|
</ImagePreviewModal>
|
||||||
<div class="text">
|
|
||||||
<h2 v-if="expandedGalleryItem.title">
|
|
||||||
{{ expandedGalleryItem.title }}
|
|
||||||
</h2>
|
|
||||||
<p v-if="expandedGalleryItem.description">
|
|
||||||
{{ expandedGalleryItem.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="controls">
|
|
||||||
<div class="buttons">
|
|
||||||
<IconButton label="Close" class="close" @click="hideImage">
|
|
||||||
<XIcon aria-hidden="true" />
|
|
||||||
</IconButton>
|
|
||||||
<ButtonLink
|
|
||||||
class="open btn icon-only !w-9 !px-0 !rounded-full"
|
|
||||||
target="_blank"
|
|
||||||
:href="
|
|
||||||
expandedGalleryItem.raw_url
|
|
||||||
? expandedGalleryItem.raw_url
|
|
||||||
: 'https://cdn.modrinth.com/placeholder-banner.svg'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<ExternalIcon aria-hidden="true" />
|
|
||||||
</ButtonLink>
|
|
||||||
<IconButton label="Toggle zoom" @click="zoomedIn = !zoomedIn">
|
|
||||||
<ExpandIcon v-if="!zoomedIn" aria-hidden="true" />
|
|
||||||
<ContractIcon v-else aria-hidden="true" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
v-if="filteredGallery.length > 1"
|
|
||||||
label="Previous image"
|
|
||||||
class="previous"
|
|
||||||
@click="previousImage()"
|
|
||||||
>
|
|
||||||
<LeftArrowIcon aria-hidden="true" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
v-if="filteredGallery.length > 1"
|
|
||||||
label="Next image"
|
|
||||||
class="next"
|
|
||||||
@click="nextImage()"
|
|
||||||
>
|
|
||||||
<RightArrowIcon aria-hidden="true" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { CalendarIcon, ExternalIcon } from '@modrinth/assets'
|
||||||
CalendarIcon,
|
import { ButtonLink, Card, useFormatDateTime } from '@modrinth/ui'
|
||||||
ContractIcon,
|
import { computed, ref } from 'vue'
|
||||||
ExpandIcon,
|
|
||||||
ExternalIcon,
|
|
||||||
LeftArrowIcon,
|
|
||||||
RightArrowIcon,
|
|
||||||
XIcon,
|
|
||||||
} from '@modrinth/assets'
|
|
||||||
import { ButtonLink, Card, IconButton, useFormatDateTime } from '@modrinth/ui'
|
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
||||||
|
|
||||||
import { release_ads_window_hold, take_ads_window_hold } from '@/helpers/ads.js'
|
import ImagePreviewModal from '@/components/ui/ImagePreviewModal.vue'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
|
|
||||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||||
@@ -115,51 +58,19 @@ const filteredGallery = computed(
|
|||||||
() => props.project.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
|
() => props.project.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
|
||||||
)
|
)
|
||||||
|
|
||||||
const expandedGalleryItem = ref(null)
|
const imagePreviewModal = ref()
|
||||||
const expandedGalleryIndex = ref(0)
|
const previewItems = computed(() =>
|
||||||
const zoomedIn = ref(false)
|
filteredGallery.value.map((item) => ({
|
||||||
let adsWindowHold = false
|
id: item.url,
|
||||||
|
src: item.raw_url || 'https://cdn.modrinth.com/placeholder-banner.svg',
|
||||||
const hideImage = () => {
|
alt: item.title || 'gallery-image',
|
||||||
expandedGalleryItem.value = null
|
title: item.title,
|
||||||
if (adsWindowHold) {
|
description: item.description,
|
||||||
adsWindowHold = false
|
})),
|
||||||
release_ads_window_hold()
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextImage = () => {
|
|
||||||
expandedGalleryIndex.value++
|
|
||||||
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
|
|
||||||
expandedGalleryIndex.value = 0
|
|
||||||
}
|
|
||||||
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
|
|
||||||
trackEvent('GalleryImageNext', {
|
|
||||||
project_id: props.project.id,
|
|
||||||
url: expandedGalleryItem.value.url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousImage = () => {
|
|
||||||
expandedGalleryIndex.value--
|
|
||||||
if (expandedGalleryIndex.value < 0) {
|
|
||||||
expandedGalleryIndex.value = filteredGallery.value.length - 1
|
|
||||||
}
|
|
||||||
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
|
|
||||||
trackEvent('GalleryImagePrevious', {
|
|
||||||
project_id: props.project.id,
|
|
||||||
url: expandedGalleryItem.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const expandImage = (item, index) => {
|
const expandImage = (item, index) => {
|
||||||
if (!adsWindowHold) {
|
imagePreviewModal.value?.show(index)
|
||||||
adsWindowHold = true
|
|
||||||
take_ads_window_hold()
|
|
||||||
}
|
|
||||||
expandedGalleryItem.value = item
|
|
||||||
expandedGalleryIndex.value = index
|
|
||||||
zoomedIn.value = false
|
|
||||||
|
|
||||||
trackEvent('GalleryImageExpand', {
|
trackEvent('GalleryImageExpand', {
|
||||||
project_id: props.project.id,
|
project_id: props.project.id,
|
||||||
@@ -167,32 +78,12 @@ const expandImage = (item, index) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function keyListener(e) {
|
function trackPreviewNavigation(_item, index, direction) {
|
||||||
if (expandedGalleryItem.value) {
|
trackEvent(direction === 'next' ? 'GalleryImageNext' : 'GalleryImagePrevious', {
|
||||||
if (e.key === 'Escape') {
|
project_id: props.project.id,
|
||||||
e.preventDefault()
|
url: filteredGallery.value[index]?.url,
|
||||||
hideImage()
|
})
|
||||||
} else if (e.key === 'ArrowLeft') {
|
|
||||||
e.preventDefault()
|
|
||||||
previousImage()
|
|
||||||
} else if (e.key === 'ArrowRight') {
|
|
||||||
e.preventDefault()
|
|
||||||
nextImage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('keydown', keyListener)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.removeEventListener('keydown', keyListener)
|
|
||||||
if (adsWindowHold) {
|
|
||||||
adsWindowHold = false
|
|
||||||
release_ads_window_hold()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -227,140 +118,4 @@ onUnmounted(() => {
|
|||||||
vertical-align: center;
|
vertical-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.expanded-image-modal {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 11;
|
|
||||||
overflow: auto;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: #000000;
|
|
||||||
background-color: rgba(0, 0, 0, 0.7);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.content {
|
|
||||||
position: relative;
|
|
||||||
width: calc(100vw - 2 * var(--gap-lg));
|
|
||||||
height: calc(100vh - 2 * var(--gap-lg));
|
|
||||||
|
|
||||||
.circle-button {
|
|
||||||
padding: 0.5rem;
|
|
||||||
line-height: 1;
|
|
||||||
display: flex;
|
|
||||||
max-width: 2rem;
|
|
||||||
color: var(--color-button-text);
|
|
||||||
background-color: var(--color-button-bg);
|
|
||||||
border-radius: var(--size-rounded-max);
|
|
||||||
margin: 0;
|
|
||||||
box-shadow: inset 0px -1px 1px rgb(17 24 39 / 10%);
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-button-bg-hover) !important;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
color: var(--color-button-text-hover) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background-color: var(--color-button-bg-active) !important;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
color: var(--color-button-text-active) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
height: 1rem;
|
|
||||||
width: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.image {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
max-width: calc(100vw - 2 * var(--gap-lg));
|
|
||||||
max-height: calc(100vh - 2 * var(--gap-lg));
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
|
|
||||||
&.zoomed-in {
|
|
||||||
object-fit: cover;
|
|
||||||
width: auto;
|
|
||||||
height: calc(100vh - 2 * var(--gap-lg));
|
|
||||||
max-width: calc(100vw - 2 * var(--gap-lg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.floating {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
bottom: var(--gap-md);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--gap-md);
|
|
||||||
transition: opacity 0.25s ease-in-out;
|
|
||||||
opacity: 1;
|
|
||||||
padding: 2rem 2rem 0 2rem;
|
|
||||||
|
|
||||||
&:not(&:hover) {
|
|
||||||
opacity: 0.4;
|
|
||||||
.text {
|
|
||||||
transform: translateY(2.5rem) scale(0.8);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
.controls {
|
|
||||||
transform: translateY(0.25rem) scale(0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-width: 40rem;
|
|
||||||
transition:
|
|
||||||
opacity 0.25s ease-in-out,
|
|
||||||
transform 0.25s ease-in-out;
|
|
||||||
text-shadow: 1px 1px 10px #000000d4;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
gap: 0.5rem;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
color: var(--dark-color-base);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
text-align: center;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: var(--dark-color-base);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.controls {
|
|
||||||
background-color: var(--color-raised-bg);
|
|
||||||
padding: var(--gap-md);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
transition:
|
|
||||||
opacity 0.25s ease-in-out,
|
|
||||||
transform 0.25s ease-in-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ export default new createRouter({
|
|||||||
name: 'InstanceWorlds',
|
name: 'InstanceWorlds',
|
||||||
component: Instance.Worlds,
|
component: Instance.Worlds,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'screenshots',
|
||||||
|
name: 'InstanceScreenshots',
|
||||||
|
component: Instance.Screenshots,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'share',
|
path: 'share',
|
||||||
name: 'InstanceShare',
|
name: 'InstanceShare',
|
||||||
|
|||||||
@@ -207,6 +207,10 @@ fn main() {
|
|||||||
"instance_get_optimal_jre_key",
|
"instance_get_optimal_jre_key",
|
||||||
"instance_get_full_path",
|
"instance_get_full_path",
|
||||||
"instance_get_mod_full_path",
|
"instance_get_mod_full_path",
|
||||||
|
"instance_list_screenshots",
|
||||||
|
"instance_delete_screenshot",
|
||||||
|
"instance_export_screenshots",
|
||||||
|
"instance_open_screenshot",
|
||||||
"instance_list",
|
"instance_list",
|
||||||
"instance_check_installed",
|
"instance_check_installed",
|
||||||
"instance_update_all",
|
"instance_update_all",
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ use path_util::SafeRelativeUtf8UnixPathBuf;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use tauri::{AppHandle, Manager, Runtime};
|
||||||
|
use tauri_plugin_fs::FsExt;
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use theseus::DownloadReason;
|
use theseus::DownloadReason;
|
||||||
use theseus::data::{
|
use theseus::data::{
|
||||||
AppliedContentSetPatch, ContentItem, Dependency,
|
AppliedContentSetPatch, ContentItem, Dependency,
|
||||||
@@ -37,6 +40,10 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
instance_get_optimal_jre_key,
|
instance_get_optimal_jre_key,
|
||||||
instance_get_full_path,
|
instance_get_full_path,
|
||||||
instance_get_mod_full_path,
|
instance_get_mod_full_path,
|
||||||
|
instance_list_screenshots,
|
||||||
|
instance_delete_screenshot,
|
||||||
|
instance_export_screenshots,
|
||||||
|
instance_open_screenshot,
|
||||||
instance_check_installed,
|
instance_check_installed,
|
||||||
instance_update_all,
|
instance_update_all,
|
||||||
instance_update_project,
|
instance_update_project,
|
||||||
@@ -70,6 +77,14 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug, Clone)]
|
||||||
|
pub struct InstanceScreenshot {
|
||||||
|
pub file_name: String,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub url: url::Url,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Debug, Clone)]
|
#[derive(Serialize, Debug, Clone)]
|
||||||
pub struct Instance {
|
pub struct Instance {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -581,6 +596,72 @@ pub async fn instance_get_mod_full_path(
|
|||||||
Ok(theseus::instance::get_mod_full_path(instance_id, project_path).await?)
|
Ok(theseus::instance::get_mod_full_path(instance_id, project_path).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_list_screenshots<R: Runtime>(
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
instance_id: &str,
|
||||||
|
) -> Result<Vec<InstanceScreenshot>> {
|
||||||
|
let screenshots = theseus::instance::list_screenshots(instance_id).await?;
|
||||||
|
let mut result = Vec::with_capacity(screenshots.len());
|
||||||
|
|
||||||
|
for screenshot in screenshots {
|
||||||
|
app_handle
|
||||||
|
.asset_protocol_scope()
|
||||||
|
.allow_file(&screenshot.path)
|
||||||
|
.map_err(|error| std::io::Error::other(error.to_string()))?;
|
||||||
|
app_handle
|
||||||
|
.fs_scope()
|
||||||
|
.allow_file(&screenshot.path)
|
||||||
|
.map_err(|error| std::io::Error::other(error.to_string()))?;
|
||||||
|
let url = super::utils::tauri_convert_file_src(&screenshot.path)?;
|
||||||
|
result.push(InstanceScreenshot {
|
||||||
|
file_name: screenshot.file_name,
|
||||||
|
created_at: screenshot.created_at,
|
||||||
|
path: screenshot.path,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_delete_screenshot(
|
||||||
|
instance_id: &str,
|
||||||
|
file_name: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
Ok(theseus::instance::delete_screenshot(instance_id, file_name).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_export_screenshots(
|
||||||
|
instance_id: &str,
|
||||||
|
file_names: Vec<String>,
|
||||||
|
export_path: PathBuf,
|
||||||
|
) -> Result<()> {
|
||||||
|
Ok(theseus::instance::export_screenshots(
|
||||||
|
instance_id,
|
||||||
|
&file_names,
|
||||||
|
export_path,
|
||||||
|
)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_open_screenshot<R: Runtime>(
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
instance_id: &str,
|
||||||
|
file_name: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let path =
|
||||||
|
theseus::instance::get_screenshot_path(instance_id, file_name).await?;
|
||||||
|
app_handle
|
||||||
|
.opener()
|
||||||
|
.reveal_item_in_dir(path)
|
||||||
|
.map_err(|error| std::io::Error::other(error.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn instance_get_optimal_jre_key(
|
pub async fn instance_get_optimal_jre_key(
|
||||||
instance_id: &str,
|
instance_id: &str,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ mod lifecycle;
|
|||||||
mod paths;
|
mod paths;
|
||||||
mod projects;
|
mod projects;
|
||||||
mod run;
|
mod run;
|
||||||
|
mod screenshots;
|
||||||
mod shared;
|
mod shared;
|
||||||
|
|
||||||
pub use self::content::{
|
pub use self::content::{
|
||||||
@@ -41,6 +42,10 @@ pub use self::projects::{
|
|||||||
pub use self::run::{
|
pub use self::run::{
|
||||||
QuickPlayType, kill, run, try_update_playtime_by_instance_id,
|
QuickPlayType, kill, run, try_update_playtime_by_instance_id,
|
||||||
};
|
};
|
||||||
|
pub use self::screenshots::{
|
||||||
|
InstanceScreenshot, delete_screenshot, export_screenshots,
|
||||||
|
get_screenshot_path, list_screenshots,
|
||||||
|
};
|
||||||
pub(crate) use self::shared::{
|
pub(crate) use self::shared::{
|
||||||
CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_EXTENSIONS,
|
CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_EXTENSIONS,
|
||||||
CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES,
|
CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES,
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
use async_zip::tokio::write::ZipFileWriter;
|
||||||
|
use async_zip::{Compression, ZipEntryBuilder};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
use tokio::fs::File;
|
||||||
|
use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
||||||
|
|
||||||
|
use crate::util::io::{self, IOError};
|
||||||
|
|
||||||
|
const SCREENSHOTS_DIRECTORY: &str = "screenshots";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct InstanceScreenshot {
|
||||||
|
pub file_name: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_screenshots(
|
||||||
|
instance_id: &str,
|
||||||
|
) -> crate::Result<Vec<InstanceScreenshot>> {
|
||||||
|
let screenshots_dir = screenshots_dir(instance_id).await?;
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
let mut screenshots = Vec::new();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = entry.path();
|
||||||
|
if !has_png_extension(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(file_name) = entry.file_name().to_str().map(str::to_owned)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let metadata = entry
|
||||||
|
.metadata()
|
||||||
|
.await
|
||||||
|
.map_err(|error| IOError::with_path(error, &path))?;
|
||||||
|
let created_at = metadata
|
||||||
|
.created()
|
||||||
|
.or_else(|_| metadata.modified())
|
||||||
|
.map(DateTime::<Utc>::from)
|
||||||
|
.map_err(|error| IOError::with_path(error, &path))?;
|
||||||
|
|
||||||
|
screenshots.push(InstanceScreenshot {
|
||||||
|
file_name,
|
||||||
|
created_at,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshots.sort_by_key(|screenshot| std::cmp::Reverse(screenshot.created_at));
|
||||||
|
Ok(screenshots)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_screenshot(
|
||||||
|
instance_id: &str,
|
||||||
|
file_name: &str,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
let path = get_screenshot_path(instance_id, file_name).await?;
|
||||||
|
io::remove_file(path).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn export_screenshots(
|
||||||
|
instance_id: &str,
|
||||||
|
file_names: &[String],
|
||||||
|
export_path: PathBuf,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
if file_names.is_empty() {
|
||||||
|
return Err(crate::ErrorKind::InputError(
|
||||||
|
"At least one screenshot must be selected".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut screenshots = Vec::with_capacity(file_names.len());
|
||||||
|
for file_name in file_names {
|
||||||
|
screenshots.push((
|
||||||
|
file_name,
|
||||||
|
get_screenshot_path(instance_id, file_name).await?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = File::create(&export_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| IOError::with_path(error, &export_path))?;
|
||||||
|
let mut writer = ZipFileWriter::with_tokio(&mut file);
|
||||||
|
|
||||||
|
for (file_name, path) in screenshots {
|
||||||
|
let mut stream = writer
|
||||||
|
.write_entry_stream(
|
||||||
|
ZipEntryBuilder::new(
|
||||||
|
file_name.as_str().into(),
|
||||||
|
Compression::Stored,
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.compat_write();
|
||||||
|
let mut source = File::open(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| IOError::with_path(error, &path))?;
|
||||||
|
tokio::io::copy(&mut source, &mut stream)
|
||||||
|
.await
|
||||||
|
.map_err(IOError::from)?;
|
||||||
|
stream.into_inner().close().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.close().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_screenshot_path(
|
||||||
|
instance_id: &str,
|
||||||
|
file_name: &str,
|
||||||
|
) -> crate::Result<PathBuf> {
|
||||||
|
validate_file_name(file_name)?;
|
||||||
|
|
||||||
|
let screenshots_dir = screenshots_dir(instance_id).await?;
|
||||||
|
let canonical_dir = tokio::fs::canonicalize(&screenshots_dir)
|
||||||
|
.await
|
||||||
|
.map_err(|error| IOError::with_path(error, &screenshots_dir))?;
|
||||||
|
let requested_path = screenshots_dir.join(file_name);
|
||||||
|
let metadata = tokio::fs::symlink_metadata(&requested_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| IOError::with_path(error, &requested_path))?;
|
||||||
|
|
||||||
|
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||||
|
return Err(crate::ErrorKind::InputError(
|
||||||
|
"Screenshot must be a regular file".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical_path = tokio::fs::canonicalize(&requested_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| IOError::with_path(error, &requested_path))?;
|
||||||
|
if !canonical_path.starts_with(&canonical_dir) {
|
||||||
|
return Err(crate::ErrorKind::InputError(
|
||||||
|
"Screenshot path is outside the instance screenshots directory"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(canonical_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn screenshots_dir(instance_id: &str) -> crate::Result<PathBuf> {
|
||||||
|
let path = super::get_full_path(instance_id)
|
||||||
|
.await?
|
||||||
|
.join(SCREENSHOTS_DIRECTORY);
|
||||||
|
|
||||||
|
match tokio::fs::symlink_metadata(&path).await {
|
||||||
|
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||||
|
Err(crate::ErrorKind::InputError(
|
||||||
|
"Instance screenshots directory cannot be a symbolic link"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
Ok(_) => Ok(path),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(path),
|
||||||
|
Err(error) => Err(IOError::with_path(error, &path).into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_file_name(file_name: &str) -> crate::Result<()> {
|
||||||
|
let path = Path::new(file_name);
|
||||||
|
let mut components = path.components();
|
||||||
|
let is_single_file = matches!(components.next(), Some(Component::Normal(_)))
|
||||||
|
&& components.next().is_none();
|
||||||
|
|
||||||
|
if !is_single_file || !has_png_extension(path) {
|
||||||
|
return Err(crate::ErrorKind::InputError(
|
||||||
|
"Invalid screenshot file name".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_png_extension(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.is_some_and(|extension| extension.eq_ignore_ascii_case("png"))
|
||||||
|
}
|
||||||
@@ -276,6 +276,7 @@ pub enum InstancePayloadType {
|
|||||||
Created,
|
Created,
|
||||||
Synced,
|
Synced,
|
||||||
ServersUpdated,
|
ServersUpdated,
|
||||||
|
ScreenshotsUpdated,
|
||||||
WorldUpdated {
|
WorldUpdated {
|
||||||
world: String,
|
world: String,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
|
|||||||
.is_some_and(|x| *x == "servers.dat")
|
.is_some_and(|x| *x == "servers.dat")
|
||||||
{
|
{
|
||||||
Some(InstancePayloadType::ServersUpdated)
|
Some(InstancePayloadType::ServersUpdated)
|
||||||
|
} else if first_file_name
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|x| *x == "screenshots")
|
||||||
|
{
|
||||||
|
Some(
|
||||||
|
InstancePayloadType::ScreenshotsUpdated,
|
||||||
|
)
|
||||||
} else if first_file_name.as_ref().is_some_and(
|
} else if first_file_name.as_ref().is_some_and(
|
||||||
|x| {
|
|x| {
|
||||||
*x == "saves"
|
*x == "saves"
|
||||||
@@ -222,6 +229,7 @@ pub(crate) async fn watch_instance_folder(
|
|||||||
for sub_path in ProjectType::iterator().map(|x| x.get_folder()).chain([
|
for sub_path in ProjectType::iterator().map(|x| x.get_folder()).chain([
|
||||||
"crash-reports",
|
"crash-reports",
|
||||||
"saves",
|
"saves",
|
||||||
|
"screenshots",
|
||||||
CONFIG_DIRECTORY,
|
CONFIG_DIRECTORY,
|
||||||
]) {
|
]) {
|
||||||
let full_path = full_instance_path.join(sub_path);
|
let full_path = full_instance_path.join(sub_path);
|
||||||
|
|||||||
Reference in New Issue
Block a user