Merge 8a6abcbe5b21cddcbc250e897cd5467fa6b4603f into d22c9e24f4ca63c8757af0e0d9640f5d0431e815
This commit is contained in:
commit
09abc6cdba
@ -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"
|
||||||
|
|||||||
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)
|
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 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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@ -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'",
|
||||||
@ -97,4 +115,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user