Merge 8a6abcbe5b21cddcbc250e897cd5467fa6b4603f into d22c9e24f4ca63c8757af0e0d9640f5d0431e815

This commit is contained in:
Dakroach 2025-08-07 13:08:22 +01:00 committed by GitHub
commit 09abc6cdba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 2130 additions and 7 deletions

View File

@ -7,6 +7,7 @@ import {
CompassIcon, CompassIcon,
DownloadIcon, DownloadIcon,
HomeIcon, HomeIcon,
ImageIcon,
LeftArrowIcon, LeftArrowIcon,
LibraryIcon, LibraryIcon,
LogInIcon, LogInIcon,
@ -447,6 +448,18 @@ function handleAuxClick(e) {
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins"> <NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
<ChangeSkinIcon /> <ChangeSkinIcon />
</NavButton> </NavButton>
<NavButton
v-tooltip.right="'Screen Shots (Alpha)'"
to="/screenshots"
:is-subpage="
() =>
route.path.startsWith('/instance') ||
((route.path.startsWith('/browse') || route.path.startsWith('/project')) &&
route.query.i)
"
>
<ImageIcon />
</NavButton>
<NavButton <NavButton
v-tooltip.right="'Library'" v-tooltip.right="'Library'"
to="/library" to="/library"

View File

@ -0,0 +1,293 @@
<template>
<ModalWrapper
ref="modal"
:header="'Rename ' + (isScreenshot ? 'Screenshot' : 'File')"
@hide="onHide"
>
<div class="modal-body">
<div class="input-group">
<label for="new-filename" class="label">New filename:</label>
<input
id="new-filename"
ref="filenameInput"
v-model="newFilename"
type="text"
class="input"
placeholder="Enter new filename"
@keydown.enter="confirmRename"
@keydown.esc="cancel"
/>
<div v-if="error" class="error-message">
{{ error }}
</div>
</div>
<div class="preview">
<p class="text-sm text-secondary"><strong>Current:</strong> {{ originalFilename }}</p>
<p class="text-sm text-secondary">
<strong>New:</strong> {{ newFilename || 'Enter a filename...' }}
</p>
</div>
</div>
<div class="modal-footer">
<Button @click="cancel">Cancel</Button>
<Button color="primary" :disabled="!newFilename.trim() || isRenaming" @click="confirmRename">
<SpinnerIcon v-if="isRenaming" class="animate-spin w-4 h-4" />
{{ isRenaming ? 'Renaming...' : 'Rename' }}
</Button>
</div>
</ModalWrapper>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { Button } from '@modrinth/ui'
import { SpinnerIcon } from '@modrinth/assets'
import { renameFile } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
// Simple path utilities for Tauri
const pathUtils = {
basename: (path) => {
return path.split(/[\\/]/).pop() || ''
},
dirname: (path) => {
const parts = path.split(/[\\/]/)
parts.pop()
return parts.join('\\') // Windows path separator
},
join: (dir, filename) => {
return dir + '\\' + filename
},
}
const modal = ref()
const filenameInput = ref()
const newFilename = ref('')
const originalFilename = ref('')
const originalPath = ref('')
const error = ref('')
const isRenaming = ref(false)
const isScreenshot = ref(true)
const emit = defineEmits(['renamed', 'cancelled'])
// Extract just the filename without extension
const __filenameWithoutExt = computed(() => {
const filename = newFilename.value || originalFilename.value
const lastDotIndex = filename.lastIndexOf('.')
return lastDotIndex > 0 ? filename.substring(0, lastDotIndex) : filename
})
// Extract the file extension
const fileExtension = computed(() => {
const filename = originalFilename.value
const lastDotIndex = filename.lastIndexOf('.')
return lastDotIndex > 0 ? filename.substring(lastDotIndex) : ''
})
// Show the modal with file information
const show = (filePath, filename = null, isScreenshotFile = true) => {
originalPath.value = filePath
originalFilename.value = filename || pathUtils.basename(filePath)
isScreenshot.value = isScreenshotFile
// Set initial filename (without extension for easier editing)
const nameWithoutExt = originalFilename.value.replace(/\.[^/.]+$/, '')
newFilename.value = nameWithoutExt
error.value = ''
isRenaming.value = false
modal.value.show()
// Focus the input after the modal is shown
nextTick(() => {
if (filenameInput.value) {
filenameInput.value.focus()
filenameInput.value.select()
}
})
}
// Hide the modal
const hide = () => {
modal.value.hide()
}
// Handle modal hide event
const onHide = () => {
newFilename.value = ''
originalFilename.value = ''
originalPath.value = ''
error.value = ''
isRenaming.value = false
emit('cancelled')
}
// Cancel renaming
const cancel = () => {
hide()
}
// Validate the new filename
const validateFilename = (filename) => {
if (!filename.trim()) {
return 'Filename cannot be empty'
}
// Check for invalid characters
const invalidChars = /[<>:"/\\|?*]/
if (invalidChars.test(filename)) {
return 'Filename contains invalid characters'
}
// Check if it's the same as original (case-insensitive)
const originalWithoutExt = originalFilename.value.replace(/\.[^/.]+$/, '')
if (filename.toLowerCase() === originalWithoutExt.toLowerCase()) {
return 'New filename must be different from the original'
}
return null
}
// Confirm the rename operation
const confirmRename = async () => {
const trimmedFilename = newFilename.value.trim()
// Validate the filename
const validationError = validateFilename(trimmedFilename)
if (validationError) {
error.value = validationError
return
}
// Add the original extension back
const finalFilename = trimmedFilename + fileExtension.value
// Construct the new path
const directory = pathUtils.dirname(originalPath.value)
const newPath = pathUtils.join(directory, finalFilename)
isRenaming.value = true
error.value = ''
try {
await renameFile(originalPath.value, newPath)
emit('renamed', {
originalPath: originalPath.value,
newPath: newPath,
originalFilename: originalFilename.value,
newFilename: finalFilename,
})
hide()
} catch (err) {
console.error('Failed to rename file:', err)
error.value = err.message || 'Failed to rename file'
handleError(err)
} finally {
isRenaming.value = false
}
}
// Expose methods
defineExpose({
show,
hide,
})
</script>
<style lang="scss" scoped>
.modal-header {
padding: var(--gap-lg);
border-bottom: 1px solid var(--color-divider);
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
}
.modal-body {
padding: var(--gap-lg);
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
.input-group {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary);
}
.input {
padding: var(--gap-sm) var(--gap-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text-primary);
font-size: 0.875rem;
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.2);
}
}
.error-message {
color: var(--color-red);
font-size: 0.75rem;
margin-top: var(--gap-xs);
}
.preview {
background: var(--color-bg-secondary);
padding: var(--gap-md);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.text-sm {
font-size: 0.875rem;
margin: 0 0 var(--gap-xs) 0;
&:last-child {
margin-bottom: 0;
}
}
.text-secondary {
color: var(--color-text-secondary);
}
.modal-footer {
padding: var(--gap-lg);
border-top: 1px solid var(--color-divider);
display: flex;
justify-content: flex-end;
gap: var(--gap-sm);
}
/* Ensure this modal appears above other modals like the image viewer */
:deep(.modal-container) {
z-index: 1100 !important;
}
:deep(.modal-overlay) {
z-index: 1099 !important;
}
:deep(.tauri-overlay) {
z-index: 1098 !important;
}
</style>

View File

@ -0,0 +1,234 @@
<template>
<!-- Grouped view -->
<div v-if="groupByInstance && typeof organizedScreenshots === 'object'">
<div
v-for="(screenshots, instancePath) in organizedScreenshots"
:key="instancePath"
class="instance-group"
>
<div class="instance-header" @click="toggleInstanceCollapse(instancePath)">
<h3 class="instance-title">{{ instancePath }}</h3>
<div class="collapse-icon" :class="{ collapsed: isInstanceCollapsed(instancePath) }">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 12l-4-4h8l-4 4z" />
</svg>
</div>
</div>
<div v-if="!isInstanceCollapsed(instancePath)" class="screenshots-grid instance-screenshots">
<div
v-for="screenshot in screenshots"
:key="screenshot.path"
class="screenshot-card"
@click="openModal(screenshot)"
>
<div class="screenshot-image">
<img
:src="getScreenshotUrl(screenshot.path)"
:alt="screenshot.filename"
loading="lazy"
/>
</div>
<div class="screenshot-info bg-bg-raised">
<p class="screenshot-filename">{{ screenshot.filename }}</p>
<p v-if="!groupByInstance" class="screenshot-instance">{{ screenshot.profile_path }}</p>
<p class="screenshot-date">{{ formatDate(screenshot.created) }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Flat view (newest first) -->
<div v-else class="screenshots-grid">
<div
v-for="screenshot in organizedScreenshots"
:key="screenshot.path"
class="screenshot-card"
@click="openModal(screenshot)"
>
<div class="screenshot-image">
<img :src="getScreenshotUrl(screenshot.path)" :alt="screenshot.filename" loading="lazy" />
</div>
<div class="screenshot-info bg-bg-raised">
<p class="screenshot-filename">{{ screenshot.filename }}</p>
<p v-if="showInstancePath && !groupByInstance" class="screenshot-instance">
{{ screenshot.profile_path }}
</p>
<p class="screenshot-date">{{ formatDate(screenshot.created) }}</p>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
organizedScreenshots: {
type: [Object, Array],
required: true,
},
groupByInstance: {
type: Boolean,
required: true,
},
showInstancePath: {
type: Boolean,
default: true,
},
toggleInstanceCollapse: {
type: Function,
required: true,
},
isInstanceCollapsed: {
type: Function,
required: true,
},
openModal: {
type: Function,
required: true,
},
getScreenshotUrl: {
type: Function,
required: true,
},
formatDate: {
type: Function,
required: true,
},
})
</script>
<style lang="scss" scoped>
.instance-group {
margin-bottom: var(--gap-xl);
&:last-child {
margin-bottom: 0;
}
}
.instance-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
padding: var(--gap-sm);
margin: calc(var(--gap-sm) * -1);
border-radius: var(--radius-md);
&:hover {
background-color: var(--color-bg-tertiary);
}
}
.instance-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
color: var(--color-text-primary);
flex: 1;
}
.collapse-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--color-text-secondary);
transition: transform 0.2s ease;
&.collapsed {
transform: rotate(-90deg);
}
svg {
width: 16px;
height: 16px;
}
}
.instance-screenshots {
margin-top: var(--gap-md);
}
.screenshots-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--gap-lg);
contain: layout style;
}
.screenshot-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-bg-secondary);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
cursor: pointer;
will-change: transform;
contain: layout style;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
}
.screenshot-image {
width: 100%;
height: 160px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-tertiary);
contain: layout style;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
will-change: transform;
}
.screenshot-card:hover & img {
transform: scale(1.05);
}
}
.screenshot-info {
padding: var(--gap-md);
background-color: var(--color-raised-bg);
.screenshot-filename {
margin: 0 0 var(--gap-xs) 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.screenshot-instance {
margin: 0 0 var(--gap-xs) 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.screenshot-date {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-tertiary);
}
}
</style>

View File

@ -0,0 +1,282 @@
<template>
<div v-if="showModal && selectedScreenshot" class="modal-overlay" @click="closeModal">
<button class="modal-close" @click="closeModal">&times;</button>
<!-- Navigation buttons -->
<button
v-if="hasPrevious"
class="nav-btn nav-prev"
title="Previous image"
@click.stop="goToPrevious"
>
<svg width="24" height="24" viewBox="0 0 16 16" fill="currentColor">
<path
fill-rule="evenodd"
d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"
/>
</svg>
</button>
<button v-if="hasNext" class="nav-btn nav-next" title="Next image" @click.stop="goToNext">
<svg width="24" height="24" viewBox="0 0 16 16" fill="currentColor">
<path
fill-rule="evenodd"
d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"
/>
</svg>
</button>
<div class="modal-content" @click.stop>
<img
:src="getScreenshotUrl(selectedScreenshot.path)"
:alt="selectedScreenshot.filename"
class="modal-image"
/>
<div class="modal-info">
<h3>{{ selectedScreenshot.filename }}</h3>
<p v-if="showInstancePath">Instance: {{ selectedScreenshot.profile_path }}</p>
<p>Date: {{ formatDate(selectedScreenshot.created) }}</p>
<div class="modal-actions">
<button
class="action-btn"
title="Show in File Explorer"
@click="showInExplorer(selectedScreenshot.path)"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M1.5 1A1.5 1.5 0 0 0 0 2.5v11A1.5 1.5 0 0 0 1.5 15h13a1.5 1.5 0 0 0 1.5-1.5v-11A1.5 1.5 0 0 0 14.5 1h-13zM1 2.5A.5.5 0 0 1 1.5 2h13a.5.5 0 0 1 .5.5V4H1V2.5zM1 5h14v8.5a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5V5z"
/>
<path
d="M3.5 6.5a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 0 1h-8a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 0 1h-8a.5.5 0 0 1-.5-.5z"
/>
</svg>
Show in Explorer
</button>
<button
class="action-btn"
title="Rename File"
@click="showRenameModal(selectedScreenshot)"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708L14.5 5.207l-3-3L12.146.146ZM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5Zm1.586 3L10.5 3.207 4 9.707V13h3.293l6.5-6.5Z"
/>
<path
fill-rule="evenodd"
d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5v11Z"
/>
</svg>
Rename
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
showModal: {
type: Boolean,
required: true,
},
selectedScreenshot: {
type: Object,
default: null,
},
showInstancePath: {
type: Boolean,
default: true,
},
closeModal: {
type: Function,
required: true,
},
getScreenshotUrl: {
type: Function,
required: true,
},
formatDate: {
type: Function,
required: true,
},
showInExplorer: {
type: Function,
required: true,
},
showRenameModal: {
type: Function,
required: true,
},
// Navigation props
hasPrevious: {
type: Boolean,
default: false,
},
hasNext: {
type: Boolean,
default: false,
},
goToPrevious: {
type: Function,
default: () => {},
},
goToNext: {
type: Function,
default: () => {},
},
})
</script>
<style lang="scss" scoped>
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--gap-lg);
backdrop-filter: blur(4px);
}
.modal-content {
position: relative;
max-width: 95vw;
max-height: 95vh;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--gap-md);
}
.modal-close {
position: fixed;
top: 70px;
right: 70px;
background: rgba(79, 79, 79, 0.7);
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
z-index: 1001;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s ease;
backdrop-filter: blur(4px);
&:hover {
background: rgba(133, 83, 83, 0.9);
}
}
.nav-btn {
position: fixed;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.7);
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
z-index: 1001;
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
backdrop-filter: blur(4px);
&:hover {
background: rgba(0, 0, 0, 0.9);
transform: translateY(-50%) scale(1.1);
}
svg {
width: 24px;
height: 24px;
}
}
.nav-prev {
left: 20px;
}
.nav-next {
right: 20px;
}
.modal-image {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
border-radius: var(--radius-md);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.modal-info {
background: var(--color-bg-secondary);
padding: var(--gap-md);
border-radius: var(--radius-md);
text-align: center;
min-width: 300px;
h3 {
margin: 0 0 var(--gap-sm) 0;
color: var(--color-text-primary);
font-size: 1.125rem;
}
p {
margin: 0 0 var(--gap-xs) 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
&:last-child {
margin-bottom: var(--gap-md);
}
}
}
.modal-actions {
display: flex;
gap: var(--gap-sm);
margin-top: var(--gap-md);
}
.action-btn {
background: var(--color-button-bg);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--gap-sm) var(--gap-md);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
font-weight: 500;
display: flex;
align-items: center;
gap: var(--gap-xs);
&:hover {
background: var(--color-button-bg-hover);
transform: translateY(-1px);
}
svg {
width: 16px;
height: 16px;
}
}
</style>

View File

@ -0,0 +1,32 @@
<template>
<div class="iconified-input flex-1">
<SearchIcon />
<input
:value="modelValue"
type="text"
:placeholder="placeholder"
@input="$emit('update:modelValue', $event.target.value)"
/>
<Button v-if="modelValue" class="r-btn" @click="$emit('clear')">
<XIcon />
</Button>
</div>
</template>
<script setup>
import { SearchIcon, XIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
defineProps({
modelValue: {
type: String,
default: '',
},
placeholder: {
type: String,
default: 'Search...',
},
})
defineEmits(['update:modelValue', 'clear'])
</script>

View File

@ -0,0 +1,289 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { list, getAllScreenshots, showInFolder } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
import { convertFileSrc } from '@tauri-apps/api/core'
/**
* Composable for managing screenshot functionality
* @param {Object} options - Configuration options
* @param {Function} options.filterScreenshots - Function to filter screenshots
* @param {boolean} options.defaultGrouping - Default grouping preference
*/
export function useScreenshots({ filterScreenshots, defaultGrouping = false } = {}) {
// Reactive state
const instances = ref([])
const screenshots = ref([])
const selectedScreenshot = ref(null)
const showModal = ref(false)
const groupByInstance = ref(defaultGrouping)
const collapsedInstances = ref(new Set())
const renameModal = ref(null)
const searchQuery = ref('')
const debouncedSearchQuery = ref('')
// Debounce search query to improve performance
let searchTimeout = null
watch(
searchQuery,
(newQuery) => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
debouncedSearchQuery.value = newQuery
}, 300) // 300ms debounce
},
{ immediate: true },
)
// Computed property to organize screenshots based on grouping preference
const organizedScreenshots = computed(() => {
// Filter screenshots based on debounced search query
let filteredScreenshots = screenshots.value
if (debouncedSearchQuery.value.trim()) {
const query = debouncedSearchQuery.value.toLowerCase().trim()
filteredScreenshots = screenshots.value.filter((screenshot) => {
return (
screenshot.filename.toLowerCase().includes(query) ||
screenshot.profile_path.toLowerCase().includes(query)
)
})
}
if (groupByInstance.value) {
// Group screenshots by instance
const grouped = {}
filteredScreenshots.forEach((screenshot) => {
const instancePath = screenshot.profile_path
if (!grouped[instancePath]) {
grouped[instancePath] = []
}
grouped[instancePath].push(screenshot)
})
// Sort screenshots within each group by date (newest first)
Object.keys(grouped).forEach((instancePath) => {
grouped[instancePath].sort((a, b) => b.created - a.created)
})
return grouped
} else {
// Return flat array sorted by newest first
return [...filteredScreenshots].sort((a, b) => b.created - a.created)
}
})
// Functions
const toggleGrouping = () => {
groupByInstance.value = !groupByInstance.value
// Clear collapsed state when switching modes
collapsedInstances.value.clear()
}
const toggleInstanceCollapse = (instancePath) => {
if (collapsedInstances.value.has(instancePath)) {
collapsedInstances.value.delete(instancePath)
} else {
collapsedInstances.value.add(instancePath)
}
}
const isInstanceCollapsed = (instancePath) => {
return collapsedInstances.value.has(instancePath)
}
const formatDate = (timestamp) => {
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
const getScreenshotUrl = (path) => {
try {
return convertFileSrc(path)
} catch (error) {
console.error('Failed to convert file path:', path, error)
return null
}
}
const openModal = (screenshot) => {
selectedScreenshot.value = screenshot
showModal.value = true
}
const closeModal = () => {
showModal.value = false
selectedScreenshot.value = null
}
const showInExplorer = async (screenshotPath) => {
try {
await showInFolder(screenshotPath)
} catch (error) {
console.error('Failed to show file in explorer:', error)
handleError(error)
}
}
const showRenameModal = (screenshot) => {
if (renameModal.value) {
renameModal.value.show(screenshot.path, screenshot.filename, true)
}
}
const onFileRenamed = async (renameData) => {
try {
// Update the screenshot in our list
const screenshotIndex = screenshots.value.findIndex((s) => s.path === renameData.originalPath)
if (screenshotIndex !== -1) {
screenshots.value[screenshotIndex] = {
...screenshots.value[screenshotIndex],
path: renameData.newPath,
filename: renameData.newFilename,
}
}
// Update selectedScreenshot if it's the one being renamed
if (selectedScreenshot.value && selectedScreenshot.value.path === renameData.originalPath) {
selectedScreenshot.value = {
...selectedScreenshot.value,
path: renameData.newPath,
filename: renameData.newFilename,
}
}
} catch (error) {
console.error('Failed to update screenshot after rename:', error)
handleError(error)
}
}
const handleKeydown = (event) => {
if (event.key === 'Escape') {
closeModal()
} else if (event.key === 'ArrowLeft') {
goToPrevious()
} else if (event.key === 'ArrowRight') {
goToNext()
}
}
// Navigation functions
const getFlatScreenshots = () => {
// Get a flat array of all screenshots in current view
if (groupByInstance.value && typeof organizedScreenshots.value === 'object') {
const flat = []
Object.values(organizedScreenshots.value).forEach((screenshots) => {
flat.push(...screenshots)
})
return flat.sort((a, b) => b.created - a.created)
} else {
return organizedScreenshots.value
}
}
const getCurrentIndex = () => {
if (!selectedScreenshot.value) return -1
const flatScreenshots = getFlatScreenshots()
return flatScreenshots.findIndex((s) => s.path === selectedScreenshot.value.path)
}
const hasPrevious = computed(() => {
const currentIndex = getCurrentIndex()
return currentIndex > 0
})
const hasNext = computed(() => {
const currentIndex = getCurrentIndex()
const flatScreenshots = getFlatScreenshots()
return currentIndex !== -1 && currentIndex < flatScreenshots.length - 1
})
const goToPrevious = () => {
const currentIndex = getCurrentIndex()
if (currentIndex > 0) {
const flatScreenshots = getFlatScreenshots()
selectedScreenshot.value = flatScreenshots[currentIndex - 1]
}
}
const goToNext = () => {
const currentIndex = getCurrentIndex()
const flatScreenshots = getFlatScreenshots()
if (currentIndex !== -1 && currentIndex < flatScreenshots.length - 1) {
selectedScreenshot.value = flatScreenshots[currentIndex + 1]
}
}
const clearSearch = () => {
searchQuery.value = ''
debouncedSearchQuery.value = ''
if (searchTimeout) {
clearTimeout(searchTimeout)
searchTimeout = null
}
}
const loadScreenshots = async () => {
try {
// Load instances and screenshots
instances.value = await list().catch(handleError)
const allScreenshots = await getAllScreenshots()
// Apply filter if provided, otherwise use all screenshots
if (filterScreenshots) {
screenshots.value = filterScreenshots(allScreenshots, instances.value)
} else {
screenshots.value = allScreenshots
}
} catch (error) {
console.error('Failed to load screenshots:', error)
handleError(error)
}
}
// Lifecycle
onMounted(() => {
loadScreenshots()
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
return {
// State
instances,
screenshots,
selectedScreenshot,
showModal,
groupByInstance,
collapsedInstances,
renameModal,
searchQuery,
// Computed
organizedScreenshots,
hasPrevious,
hasNext,
// Functions
toggleGrouping,
toggleInstanceCollapse,
isInstanceCollapsed,
formatDate,
getScreenshotUrl,
openModal,
closeModal,
goToPrevious,
goToNext,
showInExplorer,
showRenameModal,
onFileRenamed,
loadScreenshots,
clearSearch,
}
}

View File

@ -202,3 +202,28 @@ export async function finish_install(instance) {
await install(instance.path, false).catch(handleError) await install(instance.path, false).catch(handleError)
} }
} }
// Get screenshots from a specific profile
export async function getScreenshots(path) {
return await invoke('plugin:profile|profile_get_screenshots', { path })
}
// Get screenshots from all profiles
export async function getAllScreenshots() {
return await invoke('plugin:profile|profile_get_all_screenshots')
}
// Open screenshots folder for a profile
export async function openScreenshotsFolder(path) {
return await invoke('plugin:profile|profile_open_screenshots_folder', { path })
}
// Show file in explorer/finder
export async function showInFolder(path) {
return await invoke('plugin:profile|show_in_folder', { path })
}
// Rename a file
export async function renameFile(oldPath, newPath) {
return await invoke('plugin:profile|rename_file', { oldPath, newPath })
}

View File

@ -0,0 +1,170 @@
<script setup>
import { computed } from 'vue'
import { useScreenshots } from '@/composables/useScreenshots.js'
import RenameFileModal from '@/components/ui/RenameFileModal.vue'
import ScreenshotGrid from '@/components/ui/ScreenshotGrid.vue'
import ScreenshotModal from '@/components/ui/ScreenshotModal.vue'
import SearchBar from '@/components/ui/SearchBar.vue'
// Use the composable with a filter for custom instances only
const {
instances,
screenshots,
organizedScreenshots,
selectedScreenshot,
showModal,
groupByInstance,
renameModal,
searchQuery,
hasPrevious,
hasNext,
toggleGrouping,
toggleInstanceCollapse,
isInstanceCollapsed,
formatDate,
getScreenshotUrl,
openModal,
closeModal,
goToPrevious,
goToNext,
showInExplorer,
showRenameModal,
onFileRenamed,
clearSearch,
} = useScreenshots({
filterScreenshots: (allScreenshots, instances) => {
const customInstances = instances.filter((i) => !i.linked_data)
const customInstancePaths = customInstances.map((i) => i.path)
return allScreenshots.filter((screenshot) =>
customInstancePaths.includes(screenshot.profile_path),
)
},
defaultGrouping: true, // Default to grouped view for custom instances
})
// Computed properties
const hasCustomInstances = computed(() => instances.value.some((i) => !i.linked_data))
</script>
<template>
<div>
<div v-if="screenshots.length > 0" class="screenshots-section">
<div class="section-header">
<h2 class="text-xl font-semibold">Screenshots from Custom Instances</h2>
<div class="header-controls">
<SearchBar
v-model="searchQuery"
placeholder="Search screenshots..."
@clear="clearSearch"
/>
<button
class="group-toggle-btn"
:class="{ 'bg-bg-raised': groupByInstance }"
@click="toggleGrouping"
>
{{ groupByInstance ? 'Sort by Newest' : 'Group by Instance' }}
</button>
</div>
</div>
<ScreenshotGrid
:organized-screenshots="organizedScreenshots"
:group-by-instance="groupByInstance"
:show-instance-path="!groupByInstance"
:toggle-instance-collapse="toggleInstanceCollapse"
:is-instance-collapsed="isInstanceCollapsed"
:open-modal="openModal"
:get-screenshot-url="getScreenshotUrl"
:format-date="formatDate"
/>
</div>
<div v-else-if="hasCustomInstances" class="no-screenshots">
<h3>No screenshots found</h3>
<p>Take some screenshots in your custom instances to see them here!</p>
</div>
<div v-else class="no-instances">
<h3>No custom instances</h3>
<p>Create some custom instances to see their screenshots here!</p>
</div>
<!-- Screenshot Modal -->
<ScreenshotModal
:show-modal="showModal"
:selected-screenshot="selectedScreenshot"
:show-instance-path="true"
:close-modal="closeModal"
:get-screenshot-url="getScreenshotUrl"
:format-date="formatDate"
:show-in-explorer="showInExplorer"
:show-rename-modal="showRenameModal"
:has-previous="hasPrevious"
:has-next="hasNext"
:go-to-previous="goToPrevious"
:go-to-next="goToNext"
/>
</div>
<!-- Rename Modal -->
<RenameFileModal ref="renameModal" @renamed="onFileRenamed" />
</template>
<style lang="scss" scoped>
.screenshots-section {
margin-top: var(--gap-lg);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--gap-lg);
gap: var(--gap-lg);
}
.header-controls {
display: flex;
align-items: center;
gap: var(--gap-md);
flex: 1;
}
.group-toggle-btn {
background: var(--color-button-bg);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--gap-sm) var(--gap-md);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
font-weight: 500;
&:hover {
background: var(--color-button-bg-hover);
transform: translateY(-1px);
}
}
.no-screenshots,
.no-instances {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
gap: var(--gap-sm);
text-align: center;
color: var(--color-text-secondary);
h3 {
margin: 0;
color: var(--color-text-primary);
}
p {
margin: 0;
}
}
</style>

View File

@ -0,0 +1,148 @@
<script setup>
import { computed } from 'vue'
import { useScreenshots } from '@/composables/useScreenshots.js'
import RenameFileModal from '@/components/ui/RenameFileModal.vue'
import ScreenshotGrid from '@/components/ui/ScreenshotGrid.vue'
import ScreenshotModal from '@/components/ui/ScreenshotModal.vue'
// Use the composable with a filter for downloaded instances only
const {
instances,
screenshots,
organizedScreenshots,
selectedScreenshot,
showModal,
groupByInstance,
renameModal,
__searchQuery,
hasPrevious,
hasNext,
toggleGrouping,
toggleInstanceCollapse,
isInstanceCollapsed,
formatDate,
getScreenshotUrl,
openModal,
closeModal,
goToPrevious,
goToNext,
showInExplorer,
showRenameModal,
onFileRenamed,
__clearSearch,
} = useScreenshots({
filterScreenshots: (allScreenshots, instances) => {
const downloadedInstances = instances.filter((i) => i.linked_data)
const downloadedInstancePaths = downloadedInstances.map((i) => i.path)
return allScreenshots.filter((screenshot) =>
downloadedInstancePaths.includes(screenshot.profile_path),
)
},
defaultGrouping: false,
})
// Computed properties
const hasDownloadedInstances = computed(() => instances.value.some((i) => i.linked_data))
</script>
<template>
<div>
<div v-if="screenshots.length > 0" class="screenshots-section">
<div class="section-header">
<h2 class="text-xl font-semibold">Screenshots from Downloaded Instances</h2>
<button
class="group-toggle-btn"
:class="{ 'bg-bg-raised': groupByInstance }"
@click="toggleGrouping"
>
{{ groupByInstance ? 'Sort by Newest' : 'Group by Instance' }}
</button>
</div>
<ScreenshotGrid
:organized-screenshots="organizedScreenshots"
:group-by-instance="groupByInstance"
:show-instance-path="!groupByInstance"
:toggle-instance-collapse="toggleInstanceCollapse"
:is-instance-collapsed="isInstanceCollapsed"
:open-modal="openModal"
:get-screenshot-url="getScreenshotUrl"
:format-date="formatDate"
/>
</div>
<div v-else-if="hasDownloadedInstances" class="no-screenshots">
<h3>No screenshots found</h3>
<p>Take some screenshots in your downloaded instances to see them here!</p>
</div>
<!-- Screenshot Modal -->
<ScreenshotModal
:show-modal="showModal"
:selected-screenshot="selectedScreenshot"
:show-instance-path="true"
:close-modal="closeModal"
:get-screenshot-url="getScreenshotUrl"
:format-date="formatDate"
:show-in-explorer="showInExplorer"
:show-rename-modal="showRenameModal"
:has-previous="hasPrevious"
:has-next="hasNext"
:go-to-previous="goToPrevious"
:go-to-next="goToNext"
/>
</div>
<!-- Rename Modal -->
<RenameFileModal ref="renameModal" @renamed="onFileRenamed" />
</template>
<style lang="scss" scoped>
.screenshots-section {
margin-top: var(--gap-lg);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--gap-lg);
}
.group-toggle-btn {
background: var(--color-button-bg);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--gap-sm) var(--gap-md);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
font-weight: 500;
&:hover {
background: var(--color-button-bg-hover);
transform: translateY(-1px);
}
}
.no-screenshots {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
gap: var(--gap-sm);
text-align: center;
color: var(--color-text-secondary);
h3 {
margin: 0;
color: var(--color-text-primary);
}
p {
margin: 0;
}
}
</style>

View File

@ -0,0 +1,214 @@
<script setup>
import { onUnmounted, ref, shallowRef } from 'vue'
import { list } from '@/helpers/profile.js'
import { useRoute } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
import { profile_listener } from '@/helpers/events.js'
import { handleError } from '@/store/notifications.js'
import { Button } from '@modrinth/ui'
import { PlusIcon } from '@modrinth/assets'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { NewInstanceImage } from '@/assets/icons'
import NavTabs from '@/components/ui/NavTabs.vue'
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
breadcrumbs.setRootContext({ name: 'Screenshots', link: route.path })
const instances = shallowRef(await list().catch(handleError))
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
offline.value = true
})
window.addEventListener('online', () => {
offline.value = false
})
const unlistenProfile = await profile_listener(async () => {
instances.value = await list().catch(handleError)
})
onUnmounted(() => {
unlistenProfile()
})
</script>
<template>
<div class="p-6 flex flex-col gap-3">
<h1 class="m-0 text-2xl hidden">Screen shots</h1>
<NavTabs
:links="[
{ label: 'Recent', href: `/screenshots` },
{ label: 'Downloaded Instances', href: `/screenshots/downloaded` },
{ label: 'Custom Instances', href: `/screenshots/custom` },
{ label: 'Shared with me', href: `/screenshots/shared`, shown: false },
{ label: 'Saved', href: `/screenshots/saved`, shown: false },
]"
/>
<template v-if="instances && instances.length > 0">
<RouterView />
</template>
<div v-else-if="instances && instances.length === 0" class="no-instance">
<div class="icon">
<NewInstanceImage />
</div>
<h3>No instances found</h3>
<Button color="primary" :disabled="offline" @click="$refs.installationModal.show()">
<PlusIcon />
Create new instance
</Button>
<InstanceCreationModal ref="installationModal" />
</div>
<div v-else class="loading">
<p>Loading instances...</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.debug-info {
background: var(--color-bg-tertiary);
padding: var(--gap-sm);
border-radius: var(--radius-sm);
margin-bottom: var(--gap-md);
p {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
p {
margin: 0;
color: var(--color-text-secondary);
}
}
.no-instance {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--gap-md);
p,
h3 {
margin: 0;
}
.icon {
svg {
width: 10rem;
height: 10rem;
}
}
}
.no-screenshots {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
gap: var(--gap-sm);
text-align: center;
color: var(--color-text-secondary);
h3 {
margin: 0;
color: var(--color-text-primary);
}
p {
margin: 0;
}
}
.screenshots-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--gap-lg);
}
.screenshot-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-bg-secondary);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
.screenshot-image {
width: 100%;
height: 200px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-tertiary);
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
}
&:hover img {
transform: scale(1.05);
}
}
.screenshot-info {
padding: var(--gap-md);
.screenshot-title {
margin: 0 0 var(--gap-xs) 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.screenshot-instance {
margin: 0 0 var(--gap-xs) 0;
font-size: 0.875rem;
color: var(--color-text-secondary);
font-weight: 500;
}
.screenshot-date,
.screenshot-size {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-tertiary);
}
.screenshot-date {
margin-bottom: var(--gap-xs);
}
.open-folder-btn {
margin-top: var(--gap-sm);
width: 100%;
}
}
</style>

View File

@ -0,0 +1,142 @@
<script setup>
import { computed } from 'vue'
import { useScreenshots } from '@/composables/useScreenshots.js'
import RenameFileModal from '@/components/ui/RenameFileModal.vue'
import ScreenshotGrid from '@/components/ui/ScreenshotGrid.vue'
import ScreenshotModal from '@/components/ui/ScreenshotModal.vue'
// Use the composable with a filter for recent screenshots (last 12)
const {
screenshots,
organizedScreenshots,
selectedScreenshot,
showModal,
groupByInstance,
renameModal,
__searchQuery,
hasPrevious,
hasNext,
toggleGrouping,
toggleInstanceCollapse,
isInstanceCollapsed,
formatDate,
getScreenshotUrl,
openModal,
closeModal,
goToPrevious,
goToNext,
showInExplorer,
showRenameModal,
onFileRenamed,
__clearSearch,
} = useScreenshots({
filterScreenshots: (allScreenshots) =>
allScreenshots.sort((a, b) => b.created - a.created).slice(0, 12),
defaultGrouping: false,
})
// Computed property for the recent screenshots count
const recentCount = computed(() => screenshots.value.length)
</script>
<template>
<div>
<div v-if="recentCount > 0" class="screenshots-section">
<div class="section-header">
<h2 class="text-xl font-semibold">Recent Screenshots</h2>
<button
class="group-toggle-btn"
:class="{ 'bg-bg-raised': groupByInstance }"
@click="toggleGrouping"
>
{{ groupByInstance ? 'Sort by Newest' : 'Group by Instance' }}
</button>
</div>
<ScreenshotGrid
:organized-screenshots="organizedScreenshots"
:group-by-instance="groupByInstance"
:show-instance-path="true"
:toggle-instance-collapse="toggleInstanceCollapse"
:is-instance-collapsed="isInstanceCollapsed"
:open-modal="openModal"
:get-screenshot-url="getScreenshotUrl"
:format-date="formatDate"
/>
</div>
<div v-else class="no-screenshots">
<h3>No screenshots found</h3>
<p>Take some screenshots in your instances to see them here!</p>
</div>
<!-- Screenshot Modal -->
<ScreenshotModal
:show-modal="showModal"
:selected-screenshot="selectedScreenshot"
:show-instance-path="true"
:close-modal="closeModal"
:get-screenshot-url="getScreenshotUrl"
:format-date="formatDate"
:show-in-explorer="showInExplorer"
:show-rename-modal="showRenameModal"
:has-previous="hasPrevious"
:has-next="hasNext"
:go-to-previous="goToPrevious"
:go-to-next="goToNext"
/>
</div>
<!-- Rename Modal -->
<RenameFileModal ref="renameModal" @renamed="onFileRenamed" />
</template>
<style lang="scss" scoped>
.screenshots-section {
margin-top: var(--gap-lg);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--gap-lg);
}
.group-toggle-btn {
background: var(--color-button-bg);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--gap-sm) var(--gap-md);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
font-weight: 500;
&:hover {
background: var(--color-button-bg-hover);
transform: translateY(-1px);
}
}
.no-screenshots {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
gap: var(--gap-sm);
text-align: center;
color: var(--color-text-secondary);
h3 {
margin: 0;
color: var(--color-text-primary);
}
p {
margin: 0;
}
}
</style>

View File

@ -0,0 +1,6 @@
import Index from './Index.vue'
import Overview from './Overview.vue'
import Downloaded from './Downloaded.vue'
import Custom from './Custom.vue'
export { Index, Overview, Downloaded, Custom }

View File

@ -3,6 +3,7 @@ import * as Pages from '@/pages'
import * as Project from '@/pages/project' import * as Project from '@/pages/project'
import * as Instance from '@/pages/instance' import * as Instance from '@/pages/instance'
import * as Library from '@/pages/library' import * as Library from '@/pages/library'
import * as Screenshots from '@/pages/screenshots'
/** /**
* Configures application routing. Add page to pages/index and then add to route table here. * Configures application routing. Add page to pages/index and then add to route table here.
@ -67,6 +68,31 @@ export default new createRouter({
}, },
], ],
}, },
{
path: '/screenshots',
name: 'Screenshots',
component: Screenshots.Index,
meta: {
breadcrumb: [{ name: 'Screenshots' }],
},
children: [
{
path: '',
name: 'ScreenshotsOverview',
component: Screenshots.Overview,
},
{
path: 'downloaded',
name: 'ScreenshotsDownloaded',
component: Screenshots.Downloaded,
},
{
path: 'custom',
name: 'ScreenshotsCustom',
component: Screenshots.Custom,
},
],
},
{ {
path: '/project/:id', path: '/project/:id',
name: 'Project', name: 'Project',

View File

@ -179,6 +179,11 @@ fn main() {
"profile_edit_icon", "profile_edit_icon",
"profile_export_mrpack", "profile_export_mrpack",
"profile_get_pack_export_candidates", "profile_get_pack_export_candidates",
"profile_get_screenshots",
"profile_get_all_screenshots",
"profile_open_screenshots_folder",
"show_in_folder",
"rename_file",
]) ])
.default_permission( .default_permission(
DefaultPermissionRule::AllowAllCommands, DefaultPermissionRule::AllowAllCommands,

View File

@ -33,6 +33,11 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
profile_edit_icon, profile_edit_icon,
profile_export_mrpack, profile_export_mrpack,
profile_get_pack_export_candidates, profile_get_pack_export_candidates,
profile_get_screenshots,
profile_get_all_screenshots,
profile_open_screenshots_folder,
show_in_folder,
rename_file,
]) ])
.build() .build()
} }
@ -393,3 +398,224 @@ pub async fn profile_edit_icon(
profile::edit_icon(path, icon_path).await?; profile::edit_icon(path, icon_path).await?;
Ok(()) Ok(())
} }
// Get screenshots from a profile's screenshots folder
// invoke('plugin:profile|profile_get_screenshots')
#[tauri::command]
pub async fn profile_get_screenshots(path: &str) -> Result<Vec<Screenshot>> {
let full_path = profile::get_full_path(path).await?;
let screenshots_path = full_path.join("screenshots");
let mut screenshots = Vec::new();
if screenshots_path.exists() && screenshots_path.is_dir() {
let mut entries = tokio::fs::read_dir(&screenshots_path).await?;
while let Some(entry) = entries.next_entry().await? {
let entry_path = entry.path();
if entry_path.is_file() {
if let Some(extension) = entry_path.extension() {
let ext = extension.to_string_lossy().to_lowercase();
if matches!(
ext.as_str(),
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp"
) {
if let Some(filename) = entry_path.file_name() {
let metadata = entry.metadata().await?;
let modified = metadata
.modified()
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
let created = metadata
.created()
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
screenshots.push(Screenshot {
filename: filename
.to_string_lossy()
.to_string(),
path: entry_path.to_string_lossy().to_string(),
size: metadata.len(),
modified: modified
.duration_since(
std::time::SystemTime::UNIX_EPOCH,
)
.unwrap_or_default()
.as_secs(),
created: created
.duration_since(
std::time::SystemTime::UNIX_EPOCH,
)
.unwrap_or_default()
.as_secs(),
profile_path: path.to_string(),
});
}
}
}
}
}
}
// Sort by creation time, newest first
screenshots.sort_by(|a, b| b.created.cmp(&a.created));
Ok(screenshots)
}
// Get screenshots from all profiles
// invoke('plugin:profile|profile_get_all_screenshots')
#[tauri::command]
pub async fn profile_get_all_screenshots() -> Result<Vec<Screenshot>> {
let profiles = profile::list().await?;
let mut all_screenshots = Vec::new();
for profile in profiles {
match profile_get_screenshots(&profile.path).await {
Ok(mut screenshots) => {
all_screenshots.append(&mut screenshots);
}
Err(_) => {
// Continue if a profile fails, don't break the whole operation
}
}
}
// Sort all screenshots by creation time, newest first
all_screenshots.sort_by(|a, b| b.created.cmp(&a.created));
Ok(all_screenshots)
}
// Opens the screenshots folder of a profile
// invoke('plugin:profile|profile_open_screenshots_folder', path)
#[tauri::command]
pub async fn profile_open_screenshots_folder(path: &str) -> Result<()> {
let full_path = profile::get_full_path(path).await?;
let screenshots_path = full_path.join("screenshots");
// Create the screenshots folder if it doesn't exist
if !screenshots_path.exists() {
tokio::fs::create_dir_all(&screenshots_path).await?;
}
// Open the folder using the system's default file manager
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.arg(&screenshots_path)
.spawn()?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(&screenshots_path)
.spawn()?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&screenshots_path)
.spawn()?;
}
Ok(())
}
// Shows a specific file in the system's file explorer
// invoke('show_in_folder', { path: '/path/to/file.png' })
#[tauri::command]
pub async fn show_in_folder(path: &str) -> Result<()> {
let file_path = std::path::Path::new(path);
if !file_path.exists() {
return Err(theseus::Error::from(theseus::ErrorKind::FSError(
"File does not exist".to_string(),
))
.into());
}
// Open the file in the system's default file manager and select it
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.args(["/select,", path])
.spawn()?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.args(["-R", path])
.spawn()?;
}
#[cfg(target_os = "linux")]
{
// For Linux, we'll open the parent directory since most file managers
// don't support selecting a specific file
if let Some(parent_dir) = file_path.parent() {
std::process::Command::new("xdg-open")
.arg(parent_dir)
.spawn()?;
}
}
Ok(())
}
// Rename a file from old path to new path
// invoke('rename_file', { oldPath: '/path/to/old.png', newPath: '/path/to/new.png' })
#[tauri::command]
pub async fn rename_file(old_path: &str, new_path: &str) -> Result<()> {
let old_file_path = std::path::Path::new(old_path);
let new_file_path = std::path::Path::new(new_path);
// Check if the old file exists
if !old_file_path.exists() {
return Err(theseus::Error::from(theseus::ErrorKind::FSError(
"Source file does not exist".to_string(),
))
.into());
}
// Check if the new file already exists
if new_file_path.exists() {
return Err(theseus::Error::from(theseus::ErrorKind::FSError(
"Target file already exists".to_string(),
))
.into());
}
// Ensure the parent directory exists for the new path
if let Some(parent_dir) = new_file_path.parent() {
if !parent_dir.exists() {
std::fs::create_dir_all(parent_dir).map_err(|e| {
theseus::Error::from(theseus::ErrorKind::FSError(format!(
"Failed to create parent directory: {e}"
)))
})?;
}
}
// Rename/move the file
std::fs::rename(old_path, new_path).map_err(|e| {
theseus::Error::from(theseus::ErrorKind::FSError(format!(
"Failed to rename file: {e}"
)))
})?;
Ok(())
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Screenshot {
pub filename: String,
pub path: String,
pub size: u64,
pub modified: u64,
pub created: u64,
pub profile_path: String,
}

View File

@ -12,7 +12,12 @@
"copyright": "", "copyright": "",
"targets": "all", "targets": "all",
"externalBin": [], "externalBin": [],
"icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "icon": [
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"windows": { "windows": {
"nsis": { "nsis": {
"installMode": "perMachine", "installMode": "perMachine",
@ -35,7 +40,9 @@
}, },
"fileAssociations": [ "fileAssociations": [
{ {
"ext": ["mrpack"], "ext": [
"mrpack"
],
"mimeType": "application/x-modrinth-modpack+zip" "mimeType": "application/x-modrinth-modpack+zip"
} }
] ]
@ -47,7 +54,9 @@
"plugins": { "plugins": {
"deep-link": { "deep-link": {
"desktop": { "desktop": {
"schemes": ["modrinth"] "schemes": [
"modrinth"
]
}, },
"mobile": [] "mobile": []
} }
@ -80,15 +89,24 @@
"$CONFIG/caches/icons/*", "$CONFIG/caches/icons/*",
"$APPDATA/profiles/*/saves/*/icon.png", "$APPDATA/profiles/*/saves/*/icon.png",
"$APPCONFIG/profiles/*/saves/*/icon.png", "$APPCONFIG/profiles/*/saves/*/icon.png",
"$CONFIG/profiles/*/saves/*/icon.png" "$CONFIG/profiles/*/saves/*/icon.png",
"$APPDATA/com.modrinth.theseus/profiles/*/screenshots/*",
"$APPCONFIG/com.modrinth.theseus/profiles/*/screenshots/*",
"$CONFIG/com.modrinth.theseus/profiles/*/screenshots/*"
], ],
"enable": true "enable": true
}, },
"capabilities": ["ads", "core", "plugins"], "capabilities": [
"ads",
"core",
"plugins"
],
"csp": { "csp": {
"default-src": "'self' customprotocol: asset:", "default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:", "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:",
"font-src": ["https://cdn-raw.modrinth.com/fonts/"], "font-src": [
"https://cdn-raw.modrinth.com/fonts/"
],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:", "img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'", "style-src": "'unsafe-inline' 'self'",
"script-src": "https://*.posthog.com 'self'", "script-src": "https://*.posthog.com 'self'",