Skip to content

feat(app): instance screenshot management #3637

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
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: 1 addition & 0 deletions apps/app-frontend/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default createConfigForNuxt().append([
rules: {
'vue/html-self-closing': 'off',
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
},
])
190 changes: 190 additions & 0 deletions apps/app-frontend/src/components/ui/ScreenshotCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<template>
<div
ref="wrapperContainer"
class="group rounded-lg relative overflow-hidden shadow-md w-full text-contrast"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<div
v-if="loaded"
class="absolute top-2 right-2 flex gap-1 transition-opacity duration-200 z-10"
:class="{ 'opacity-0': !isHovered, 'opacity-100': isHovered }"
>
<Button v-tooltip="'Copy'" icon-only title="Copy" @click="copyImageToClipboard">
<ClipboardCopyIcon />
</Button>
<Button v-tooltip="'View in folder'" icon-only title="View in folder" @click="viewInFolder">
<ExternalIcon />
</Button>
<Button v-tooltip="'Delete'" color="red" icon-only title="Delete" @click="deleteScreenshot">
<TrashIcon />
</Button>
</div>

<div class="aspect-video bg-bg-raised overflow-hidden">
<div v-if="!loaded" class="absolute inset-0 skeleton"></div>
<img
v-else
:alt="getScreenshotFileName(screenshot.path)"
:src="blobUrl"
class="w-full h-full object-cover transition-opacity duration-700"
:class="{ 'opacity-0': !loaded, 'opacity-100': loaded }"
@load="onLoad"
@click="
imagePreviewModal.show(blobUrl, getScreenshotFileName(screenshot.path), {
...screenshot,
title: getScreenshotFileName(screenshot.path),
description: `Taken on ${dayjs(screenshot.creation_date).format('MMMM Do, YYYY')}`,
})
"
/>
</div>
</div>
</template>

<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ClipboardCopyIcon, TrashIcon, ExternalIcon } from '@modrinth/assets'
import type { ImagePreviewModal } from '@modrinth/ui'
import { Button } from '@modrinth/ui'
import {
type Screenshot,
deleteProfileScreenshot,
openProfileScreenshot,
getScreenshotData,
getScreenshotFileName,
} from '@/helpers/screenshots'
import { useNotifications } from '@/store/state'
import dayjs from 'dayjs'

const props = defineProps<{
screenshot: Screenshot
profilePath: string
imagePreviewModal: typeof ImagePreviewModal
}>()
const emit = defineEmits(['deleted'])
const notifications = useNotifications()

const loaded = ref(false)
const blobUrl = ref<string>('')
const isHovered = ref(false)
const wrapperContainer = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null

function onLoad() {
loaded.value = true
}

async function loadData(): Promise<void> {
try {
const base64 = await getScreenshotData(props.profilePath, props.screenshot)
if (!base64) {
notifications.addNotification({
title: 'Failed to load screenshot:',
text: props.screenshot.path,
type: 'error',
})
return
}

const binary = atob(base64)
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
const blob = new Blob([bytes], { type: 'image/png' })

blobUrl.value = URL.createObjectURL(blob)
loaded.value = true
} catch (err: any) {
notifications.addNotification({
title: 'Error fetching screenshot',
text: err.message,
type: 'error',
})
}
}

onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
loadData()
if (observer && wrapperContainer.value) {
observer.unobserve(wrapperContainer.value)
}
}
}
},
{ rootMargin: '100px', threshold: 0.1 },
)
if (wrapperContainer.value) observer.observe(wrapperContainer.value)
})

onBeforeUnmount(() => {
if (observer && wrapperContainer.value) {
observer.unobserve(wrapperContainer.value)
}

if (blobUrl.value) {
URL.revokeObjectURL(blobUrl.value)
}
})

async function copyImageToClipboard() {
try {
const resp = await fetch(blobUrl.value)
const blob = await resp.blob()
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })])
notifications.addNotification({
title: 'Copied to clipboard',
text: 'The screenshot has been copied successfully.',
type: 'success',
})
} catch (error: any) {
notifications.addNotification({ title: 'Copy failed', text: error.message, type: 'error' })
}
}

async function deleteScreenshot() {
try {
const ok = await deleteProfileScreenshot(props.profilePath, props.screenshot)
if (!ok) throw new Error('Delete returned false')
notifications.addNotification({ title: 'Successfully deleted screenshot', type: 'success' })
emit('deleted')
} catch (err: any) {
notifications.addNotification({
title: 'Error deleting screenshot',
text: err.message,
type: 'error',
})
}
}

async function viewInFolder() {
const ok = await openProfileScreenshot(props.profilePath, props.screenshot)
if (!ok) {
notifications.addNotification({ title: 'Unable to open screenshot in folder.', type: 'error' })
}
}
</script>

<style scoped>
.skeleton {
background: linear-gradient(
90deg,
var(--color-bg) 25%,
var(--color-raised-bg) 50%,
var(--color-bg) 75%
);
background-size: 200% 100%;
animation: wave 1500ms infinite linear;
}

@keyframes wave {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
</style>
47 changes: 47 additions & 0 deletions apps/app-frontend/src/helpers/screenshots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { invoke } from '@tauri-apps/api/core'

export type Screenshot = {
path: string
creation_date: string
}

export async function getAllProfileScreenshots(profilePath: string): Promise<Screenshot[]> {
return await invoke<Screenshot[]>('plugin:screenshots|get_all_profile_screenshots', {
path: profilePath,
})
}

export async function deleteProfileScreenshot(
profilePath: string,
screenshot: Screenshot,
): Promise<boolean> {
return await invoke<boolean>('plugin:screenshots|delete_profile_screenshot', {
path: profilePath,
screenshot,
})
}

export async function openProfileScreenshot(
profilePath: string,
screenshot: Screenshot,
): Promise<boolean> {
return await invoke<boolean>('plugin:screenshots|open_profile_screenshot', {
path: profilePath,
screenshot,
})
}

export async function getScreenshotData(
profilePath: string,
screenshot: Screenshot,
): Promise<string | undefined> {
return await invoke<string | undefined>('plugin:screenshots|get_screenshot_data', {
path: profilePath,
screenshot,
})
}

export function getScreenshotFileName(path: string | undefined) {
if (!path) return 'Untitled'
return path.split('/').pop()!
}
23 changes: 23 additions & 0 deletions apps/app-frontend/src/helpers/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ModrinthId } from '@modrinth/utils'
import type { Screenshot } from '@/helpers/screenshots.ts'

type GameInstance = {
path: string
Expand Down Expand Up @@ -140,3 +141,25 @@ export type InstanceSettingsTabProps = {
instance: GameInstance
offline?: boolean
}

export type ServersUpdatedEvent = { event: 'servers_updated' }
export type WorldUpdatedEvent = { event: 'world_updated'; world: string }
export type ServerJoinedEvent = {
event: 'server_joined'
host: string
port: number
timestamp: string
}
export type ScreenshotChangedEvent = {
event: 'screenshot_changed'
screenshot: Screenshot
file_exists: boolean
}

// TODO: Refactor events.js -> events.ts with proper types for all the events.
export type ProfileEvent = { profile_path_id: string } & (
| ServersUpdatedEvent
| WorldUpdatedEvent
| ServerJoinedEvent
| ScreenshotChangedEvent
)
17 changes: 1 addition & 16 deletions apps/app-frontend/src/helpers/worlds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { openPath } from '@/helpers/utils'
import { autoToHTML } from '@geometrically/minecraft-motd-parser'
import dayjs from 'dayjs'
import type { GameVersion } from '@modrinth/ui'
import type { ProfileEvent } from '@/helpers/types'

type BaseWorld = {
name: string
Expand Down Expand Up @@ -309,19 +310,3 @@ export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion:

return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
}

export type ProfileEvent = { profile_path_id: string } & (
| {
event: 'servers_updated'
}
| {
event: 'world_updated'
world: string
}
| {
event: 'server_joined'
host: string
port: number
timestamp: string
}
)
4 changes: 4 additions & 0 deletions apps/app-frontend/src/pages/instance/Index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,10 @@ const tabs = computed(() => [
label: 'Worlds',
href: `${basePath.value}/worlds`,
},
{
label: 'Screenshots',
href: `${basePath.value}/screenshots`,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
Expand Down
Loading
Loading