Merge 8a6abcbe5b21cddcbc250e897cd5467fa6b4603f into d22c9e24f4ca63c8757af0e0d9640f5d0431e815
This commit is contained in:
commit
09abc6cdba
@ -7,6 +7,7 @@ import {
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
HomeIcon,
|
||||
ImageIcon,
|
||||
LeftArrowIcon,
|
||||
LibraryIcon,
|
||||
LogInIcon,
|
||||
@ -447,6 +448,18 @@ function handleAuxClick(e) {
|
||||
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
|
||||
<ChangeSkinIcon />
|
||||
</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
|
||||
v-tooltip.right="'Library'"
|
||||
to="/library"
|
||||
|
||||
293
apps/app-frontend/src/components/ui/RenameFileModal.vue
Normal file
293
apps/app-frontend/src/components/ui/RenameFileModal.vue
Normal 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>
|
||||
234
apps/app-frontend/src/components/ui/ScreenshotGrid.vue
Normal file
234
apps/app-frontend/src/components/ui/ScreenshotGrid.vue
Normal 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>
|
||||
282
apps/app-frontend/src/components/ui/ScreenshotModal.vue
Normal file
282
apps/app-frontend/src/components/ui/ScreenshotModal.vue
Normal file
@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<div v-if="showModal && selectedScreenshot" class="modal-overlay" @click="closeModal">
|
||||
<button class="modal-close" @click="closeModal">×</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>
|
||||
32
apps/app-frontend/src/components/ui/SearchBar.vue
Normal file
32
apps/app-frontend/src/components/ui/SearchBar.vue
Normal 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>
|
||||
289
apps/app-frontend/src/composables/useScreenshots.js
Normal file
289
apps/app-frontend/src/composables/useScreenshots.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -202,3 +202,28 @@ export async function finish_install(instance) {
|
||||
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 })
|
||||
}
|
||||
|
||||
170
apps/app-frontend/src/pages/screenshots/Custom.vue
Normal file
170
apps/app-frontend/src/pages/screenshots/Custom.vue
Normal 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>
|
||||
148
apps/app-frontend/src/pages/screenshots/Downloaded.vue
Normal file
148
apps/app-frontend/src/pages/screenshots/Downloaded.vue
Normal 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>
|
||||
214
apps/app-frontend/src/pages/screenshots/Index.vue
Normal file
214
apps/app-frontend/src/pages/screenshots/Index.vue
Normal 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>
|
||||
142
apps/app-frontend/src/pages/screenshots/Overview.vue
Normal file
142
apps/app-frontend/src/pages/screenshots/Overview.vue
Normal 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>
|
||||
6
apps/app-frontend/src/pages/screenshots/index.js
Normal file
6
apps/app-frontend/src/pages/screenshots/index.js
Normal 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 }
|
||||
@ -3,6 +3,7 @@ import * as Pages from '@/pages'
|
||||
import * as Project from '@/pages/project'
|
||||
import * as Instance from '@/pages/instance'
|
||||
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.
|
||||
@ -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',
|
||||
name: 'Project',
|
||||
|
||||
@ -179,6 +179,11 @@ fn main() {
|
||||
"profile_edit_icon",
|
||||
"profile_export_mrpack",
|
||||
"profile_get_pack_export_candidates",
|
||||
"profile_get_screenshots",
|
||||
"profile_get_all_screenshots",
|
||||
"profile_open_screenshots_folder",
|
||||
"show_in_folder",
|
||||
"rename_file",
|
||||
])
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
|
||||
@ -33,6 +33,11 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
profile_edit_icon,
|
||||
profile_export_mrpack,
|
||||
profile_get_pack_export_candidates,
|
||||
profile_get_screenshots,
|
||||
profile_get_all_screenshots,
|
||||
profile_open_screenshots_folder,
|
||||
show_in_folder,
|
||||
rename_file,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@ -393,3 +398,224 @@ pub async fn profile_edit_icon(
|
||||
profile::edit_icon(path, icon_path).await?;
|
||||
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,
|
||||
}
|
||||
|
||||
@ -12,7 +12,12 @@
|
||||
"copyright": "",
|
||||
"targets": "all",
|
||||
"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": {
|
||||
"nsis": {
|
||||
"installMode": "perMachine",
|
||||
@ -35,7 +40,9 @@
|
||||
},
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["mrpack"],
|
||||
"ext": [
|
||||
"mrpack"
|
||||
],
|
||||
"mimeType": "application/x-modrinth-modpack+zip"
|
||||
}
|
||||
]
|
||||
@ -47,7 +54,9 @@
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["modrinth"]
|
||||
"schemes": [
|
||||
"modrinth"
|
||||
]
|
||||
},
|
||||
"mobile": []
|
||||
}
|
||||
@ -80,15 +89,24 @@
|
||||
"$CONFIG/caches/icons/*",
|
||||
"$APPDATA/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
|
||||
},
|
||||
"capabilities": ["ads", "core", "plugins"],
|
||||
"capabilities": [
|
||||
"ads",
|
||||
"core",
|
||||
"plugins"
|
||||
],
|
||||
"csp": {
|
||||
"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:",
|
||||
"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:",
|
||||
"style-src": "'unsafe-inline' 'self'",
|
||||
"script-src": "https://*.posthog.com 'self'",
|
||||
@ -97,4 +115,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user