mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 19:46:33 +00:00
1655 lines
53 KiB
Vue
1655 lines
53 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
DragDropProvider,
|
|
type DragEndEvent,
|
|
type DragMoveEvent,
|
|
type DragOverEvent,
|
|
DragOverlay,
|
|
type DragStartEvent,
|
|
} from '@dnd-kit/vue'
|
|
import {
|
|
CheckIcon,
|
|
ClipboardCopyIcon,
|
|
EditIcon,
|
|
FileArchiveIcon,
|
|
FolderOpenIcon,
|
|
MinusIcon,
|
|
SquarePlusIcon,
|
|
TrashIcon,
|
|
XIcon,
|
|
} from '@modrinth/assets'
|
|
import {
|
|
Avatar,
|
|
Button,
|
|
type ButtonMenuOption,
|
|
type ComboboxOption,
|
|
commonMessages,
|
|
ConfirmModal,
|
|
ContextMenu,
|
|
defineMessages,
|
|
EmptyState,
|
|
FloatingActionBar,
|
|
IconButton,
|
|
ImageViewerEditor,
|
|
type ImageViewerEditorSavePayload,
|
|
injectNotificationManager,
|
|
ReadyTransition,
|
|
useFormatDateTime,
|
|
useReadyState,
|
|
useScrollViewport,
|
|
useVIntl,
|
|
} from '@modrinth/ui'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
|
import { save } from '@tauri-apps/plugin-dialog'
|
|
import { readFile } from '@tauri-apps/plugin-fs'
|
|
import { useElementSize, useStorage, useWindowSize } from '@vueuse/core'
|
|
import dayjs from 'dayjs'
|
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
import { useAppEvent } from '@/composables/use-app-event'
|
|
import {
|
|
create_screenshot_group,
|
|
delete_screenshot_group,
|
|
delete_screenshots,
|
|
export_screenshots,
|
|
getInstanceIconUrl,
|
|
import_screenshot_groups,
|
|
type InstanceScreenshot,
|
|
list_all_screenshots,
|
|
move_screenshots,
|
|
open_screenshot,
|
|
rename_screenshot_group,
|
|
save_edited_screenshot,
|
|
type ScreenshotGroup,
|
|
type ScreenshotGroupImport,
|
|
type ScreenshotKey,
|
|
set_screenshot_group_memberships,
|
|
} from '@/helpers/instance'
|
|
import { MAX_INSTANCE_GROUP_NAME_LENGTH } from '@/helpers/instance-groups'
|
|
import {
|
|
instanceListQueryOptions,
|
|
instanceScreenshotsQueryOptions,
|
|
screenshotGroupsQueryOptions,
|
|
screenshotKeys,
|
|
syncedScreenshotsQueryOptions,
|
|
} from '@/pages/instance/query-options'
|
|
|
|
import ScreenshotDragGather from './drag-gather.vue'
|
|
import ScreenshotDragPreview from './drag-preview.vue'
|
|
import ScreenshotGroupSection from './group.vue'
|
|
import ScreenshotToolbar from './toolbar.vue'
|
|
import { type ActiveScreenshotDrag, useScreenshotDragGather } from './use-screenshot-drag-gather'
|
|
|
|
type ScreenshotSort = 'newest' | 'oldest' | 'name'
|
|
type ScreenshotGroupBy = 'custom' | 'instance' | 'date' | 'none'
|
|
|
|
type LegacyCustomScreenshotGrouping = {
|
|
groups: ScreenshotGroup[]
|
|
assignments: Record<string, string>
|
|
}
|
|
|
|
type ScreenshotGroupData = {
|
|
id: string
|
|
title: string
|
|
screenshots: InstanceScreenshot[]
|
|
dropInstanceId?: string
|
|
customGroupId?: string | null
|
|
}
|
|
|
|
type ScreenshotDragData = {
|
|
selectionKey: string
|
|
instanceId: string
|
|
}
|
|
|
|
type ScreenshotDropData = {
|
|
groupId: string
|
|
instanceId?: string
|
|
customGroupId?: string | null
|
|
}
|
|
|
|
type ScreenshotGroupLayout = {
|
|
group: ScreenshotGroupData
|
|
top: number
|
|
height: number
|
|
isOpen: boolean
|
|
gridHeight: number
|
|
gridTop: number
|
|
}
|
|
|
|
type VisibleScreenshotGroupLayout = ScreenshotGroupLayout & {
|
|
renderedScreenshots: InstanceScreenshot[]
|
|
virtualGridTop: number
|
|
}
|
|
|
|
const SCREENSHOT_GRID_GAP = 12
|
|
const SCREENSHOT_GROUP_SPACING = 12
|
|
const SCREENSHOT_GROUP_HEADER_HEIGHT = 40
|
|
const SCREENSHOT_GROUP_CONTENT_SPACING = 10
|
|
const SCREENSHOT_GROUP_OVERSCAN = 900
|
|
const SCREENSHOT_GRID_MIN_HEIGHT = 45
|
|
const FALLBACK_SCREENSHOT_CARD_WIDTH = 320
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
instanceId?: string
|
|
showHeading?: boolean
|
|
}>(),
|
|
{
|
|
instanceId: undefined,
|
|
showHeading: false,
|
|
},
|
|
)
|
|
|
|
const isGlobal = computed(() => !props.instanceId)
|
|
const storageSuffix = props.instanceId ? 'instance' : 'global'
|
|
const search = ref('')
|
|
const sort = useStorage<ScreenshotSort>(`screenshots-sort-${storageSuffix}`, 'newest')
|
|
const groupBy = useStorage<ScreenshotGroupBy>(`screenshots-group-v2-${storageSuffix}`, 'date')
|
|
const sortModel = computed<string>({
|
|
get: () => sort.value,
|
|
set: (value) => {
|
|
sort.value = value as ScreenshotSort
|
|
},
|
|
})
|
|
const groupByModel = computed<string>({
|
|
get: () => groupBy.value,
|
|
set: (value) => {
|
|
groupBy.value = value as ScreenshotGroupBy
|
|
},
|
|
})
|
|
const collapsedGroups = useStorage<Record<string, boolean>>(
|
|
`screenshots-collapsed-groups-${storageSuffix}`,
|
|
{},
|
|
)
|
|
const legacyCustomGrouping = useStorage<LegacyCustomScreenshotGrouping>(
|
|
'screenshots-custom-groups',
|
|
{
|
|
groups: [],
|
|
assignments: {},
|
|
},
|
|
)
|
|
const selectedKeys = ref(new Set<string>())
|
|
const copiedScreenshotIds = ref(new Set<string>())
|
|
const copiedResetTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
|
const screenshotsPage = ref<HTMLElement>()
|
|
const regrouping = ref(false)
|
|
const screenshotsScrolling = ref(false)
|
|
const screenshotToDelete = ref<InstanceScreenshot | null>(null)
|
|
const deleteFromPreview = ref(false)
|
|
const activeDrag = ref<ActiveScreenshotDrag | null>(null)
|
|
const activeDropGroupId = ref<string | null>(null)
|
|
const imageViewer = ref<InstanceType<typeof ImageViewerEditor>>()
|
|
const screenshotOptionsMenu = ref<InstanceType<typeof ContextMenu>>()
|
|
const screenshotOptionsTarget = ref<InstanceScreenshot>()
|
|
const deleteModal = ref<InstanceType<typeof ConfirmModal>>()
|
|
const bulkDeleteModal = ref<InstanceType<typeof ConfirmModal>>()
|
|
const deleteGroupModal = ref<InstanceType<typeof ConfirmModal>>()
|
|
const customGroupToDelete = ref<ScreenshotGroup>()
|
|
const groupIdPendingNameEdit = ref<string>()
|
|
const migratingLegacyGroups = ref(false)
|
|
const creatingCustomGroup = ref(false)
|
|
const updatingCustomGroupMemberships = ref(false)
|
|
const revealedScreenshotId = ref<string>()
|
|
const highlightedScreenshotId = ref<string>()
|
|
const queryClient = useQueryClient()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const { formatMessage } = useVIntl()
|
|
const { addNotification, handleError } = injectNotificationManager()
|
|
let screenshotsScrollIdleTimeout: ReturnType<typeof setTimeout> | undefined
|
|
const {
|
|
listContainer: screenshotListContainer,
|
|
containerOffset: screenshotListOffset,
|
|
relativeScrollTop: screenshotListScrollTop,
|
|
scrollContainer: screenshotScrollContainer,
|
|
viewportHeight: screenshotViewportHeight,
|
|
} = useScrollViewport({
|
|
onScroll: () => {
|
|
screenshotsScrolling.value = true
|
|
if (screenshotsScrollIdleTimeout) clearTimeout(screenshotsScrollIdleTimeout)
|
|
screenshotsScrollIdleTimeout = setTimeout(() => {
|
|
screenshotsScrolling.value = false
|
|
screenshotsScrollIdleTimeout = undefined
|
|
}, 120)
|
|
},
|
|
})
|
|
const { width: screenshotListWidth } = useElementSize(screenshotListContainer)
|
|
const { width: windowWidth } = useWindowSize()
|
|
const formatDateTime = useFormatDateTime({ dateStyle: 'long', timeStyle: 'short' })
|
|
const formatMonth = useFormatDateTime({ month: 'long', year: 'numeric' })
|
|
const messages = defineMessages({
|
|
heading: { id: 'app.screenshots.heading', defaultMessage: 'Screenshots' },
|
|
emptyHeading: { id: 'app.screenshots.empty-heading', defaultMessage: 'No screenshots yet' },
|
|
emptyDescription: {
|
|
id: 'app.screenshots.empty-description',
|
|
defaultMessage: 'Screenshots you take in-game will appear here.',
|
|
},
|
|
noResultsHeading: {
|
|
id: 'app.screenshots.no-results-heading',
|
|
defaultMessage: 'No matching screenshots',
|
|
},
|
|
noResultsDescription: {
|
|
id: 'app.screenshots.no-results-description',
|
|
defaultMessage: 'Try a different search or filter.',
|
|
},
|
|
errorHeading: {
|
|
id: 'app.screenshots.error-heading',
|
|
defaultMessage: 'Failed to load screenshots',
|
|
},
|
|
newest: { id: 'app.screenshots.sort.newest', defaultMessage: 'Newest' },
|
|
oldest: { id: 'app.screenshots.sort.oldest', defaultMessage: 'Oldest' },
|
|
name: { id: 'app.screenshots.sort.name', defaultMessage: 'Name' },
|
|
custom: { id: 'app.screenshots.group.custom', defaultMessage: 'Custom group' },
|
|
instance: { id: 'app.screenshots.group.instance', defaultMessage: 'Instance' },
|
|
date: { id: 'app.screenshots.group.date', defaultMessage: 'Date' },
|
|
none: { id: 'app.screenshots.group.none', defaultMessage: 'No grouping' },
|
|
today: { id: 'app.screenshots.group.today', defaultMessage: 'Today' },
|
|
yesterday: { id: 'app.screenshots.group.yesterday', defaultMessage: 'Yesterday' },
|
|
thisWeek: { id: 'app.screenshots.group.this-week', defaultMessage: 'This week' },
|
|
thisMonth: { id: 'app.screenshots.group.this-month', defaultMessage: 'This month' },
|
|
allScreenshots: {
|
|
id: 'app.screenshots.group.all-screenshots',
|
|
defaultMessage: 'All screenshots',
|
|
},
|
|
ungrouped: { id: 'app.screenshots.group.ungrouped', defaultMessage: 'Ungrouped' },
|
|
editGroup: { id: 'app.screenshots.group.edit', defaultMessage: 'Edit group name' },
|
|
deleteGroup: { id: 'app.screenshots.group.delete', defaultMessage: 'Delete group' },
|
|
deleteGroupDescription: {
|
|
id: 'app.screenshots.group.delete-description',
|
|
defaultMessage: 'Screenshots in this group will become ungrouped.',
|
|
},
|
|
instanceAndDate: {
|
|
id: 'app.screenshots.preview.instance-and-date',
|
|
defaultMessage: '{instance} · {date}',
|
|
},
|
|
copy: { id: 'app.screenshots.copy', defaultMessage: 'Copy image' },
|
|
copied: { id: 'app.screenshots.copied', defaultMessage: 'Copied' },
|
|
edit: { id: 'app.screenshots.edit', defaultMessage: 'Edit screenshot' },
|
|
showInFolder: { id: 'app.screenshots.show-in-folder', defaultMessage: 'Show in folder' },
|
|
goToInstance: { id: 'app.screenshots.go-to-instance', defaultMessage: 'Go to instance' },
|
|
deleteTitle: { id: 'app.screenshots.delete-title', defaultMessage: 'Delete screenshot' },
|
|
deleteDescription: {
|
|
id: 'app.screenshots.delete-description',
|
|
defaultMessage: 'Permanently delete {name}? This action cannot be undone.',
|
|
},
|
|
deleteSuccess: { id: 'app.screenshots.delete-success', defaultMessage: 'Screenshot deleted' },
|
|
selectionAriaLabel: {
|
|
id: 'app.screenshots.selection.aria-label',
|
|
defaultMessage: 'Selected screenshots',
|
|
},
|
|
selectedCount: {
|
|
id: 'app.screenshots.selection.selected-count',
|
|
defaultMessage: '{count} selected',
|
|
},
|
|
exportZip: { id: 'app.screenshots.selection.export-zip', defaultMessage: 'Export ZIP' },
|
|
newGroup: { id: 'app.screenshots.group.new', defaultMessage: 'New group' },
|
|
removeFromGroup: {
|
|
id: 'app.screenshots.selection.remove-from-group',
|
|
defaultMessage: 'Remove from group',
|
|
},
|
|
zipArchive: { id: 'app.screenshots.selection.zip-archive', defaultMessage: 'ZIP archive' },
|
|
globalExportFilename: {
|
|
id: 'app.screenshots.selection.global-export-filename',
|
|
defaultMessage: 'Modrinth screenshots.zip',
|
|
},
|
|
instanceExportFilename: {
|
|
id: 'app.screenshots.selection.instance-export-filename',
|
|
defaultMessage: '{instance} screenshots.zip',
|
|
},
|
|
bulkDeleteTitle: {
|
|
id: 'app.screenshots.selection.delete-title',
|
|
defaultMessage: 'Delete selected screenshots',
|
|
},
|
|
bulkDeleteDescription: {
|
|
id: 'app.screenshots.selection.delete-description',
|
|
defaultMessage:
|
|
'Delete {count, plural, one {# screenshot} other {# screenshots}}? This action cannot be undone.',
|
|
},
|
|
bulkDeleteSuccess: {
|
|
id: 'app.screenshots.selection.delete-success',
|
|
defaultMessage: '{count, plural, one {# screenshot deleted} other {# screenshots deleted}}',
|
|
},
|
|
})
|
|
|
|
const screenshotsQuery = useQuery(
|
|
computed(() =>
|
|
props.instanceId
|
|
? instanceScreenshotsQueryOptions(props.instanceId)
|
|
: syncedScreenshotsQueryOptions(),
|
|
),
|
|
)
|
|
const instancesQuery = useQuery(instanceListQueryOptions())
|
|
const screenshotGroupsQuery = useQuery(screenshotGroupsQueryOptions())
|
|
const screenshotsQueryPending = useReadyState(screenshotsQuery)
|
|
const screenshotGroupsQueryPending = useReadyState(screenshotGroupsQuery)
|
|
const screenshotsReadyPending = computed(
|
|
() =>
|
|
screenshotsQueryPending.value ||
|
|
(groupBy.value === 'custom' &&
|
|
(screenshotGroupsQueryPending.value || migratingLegacyGroups.value)),
|
|
)
|
|
const screenshots = computed(() => screenshotsQuery.data.value ?? [])
|
|
const customGroups = computed(() => screenshotGroupsQuery.data.value ?? [])
|
|
const screenshotOptionsInstance = computed(() =>
|
|
(instancesQuery.data.value ?? []).find(
|
|
(instance) => instance.id === screenshotOptionsTarget.value?.instance_id,
|
|
),
|
|
)
|
|
const screenshotsError = computed(() => {
|
|
const error =
|
|
screenshotsQuery.error.value ||
|
|
(groupBy.value === 'custom' ? screenshotGroupsQuery.error.value : null)
|
|
return error instanceof Error ? error : error ? new Error(String(error)) : null
|
|
})
|
|
const selectionActive = computed(() => selectedKeys.value.size > 0)
|
|
const selectedScreenshots = computed(() =>
|
|
screenshots.value.filter((screenshot) => selectedKeys.value.has(getSelectionKey(screenshot))),
|
|
)
|
|
const selectedGroupedScreenshots = computed(() =>
|
|
selectedScreenshots.value.filter((screenshot) => screenshot.group_id),
|
|
)
|
|
const activeDraggedKeys = computed(() => new Set(activeDrag.value?.selectionKeys ?? []))
|
|
const activeDraggedScreenshots = computed(() => {
|
|
const drag = activeDrag.value
|
|
if (!drag) return []
|
|
|
|
const screenshotsByKey = new Map(
|
|
screenshots.value.map((screenshot) => [getSelectionKey(screenshot), screenshot]),
|
|
)
|
|
return drag.selectionKeys.flatMap((selectionKey) => {
|
|
const screenshot = screenshotsByKey.get(selectionKey)
|
|
return screenshot ? [screenshot] : []
|
|
})
|
|
})
|
|
const activeDraggedScreenshot = computed(() =>
|
|
activeDrag.value
|
|
? screenshots.value.find(
|
|
(screenshot) => getSelectionKey(screenshot) === activeDrag.value?.primarySelectionKey,
|
|
)
|
|
: undefined,
|
|
)
|
|
const {
|
|
items: gatherItems,
|
|
target: gatherTarget,
|
|
isGathering,
|
|
start: startGather,
|
|
updateTarget: updateGatherTarget,
|
|
clear: clearGather,
|
|
finish: finishGather,
|
|
} = useScreenshotDragGather(screenshots)
|
|
const isNarrowingResults = computed(() => search.value.trim().length > 0)
|
|
|
|
const sortOptions = computed<ComboboxOption<string>[]>(() => [
|
|
{ value: 'newest', label: formatMessage(messages.newest) },
|
|
{ value: 'oldest', label: formatMessage(messages.oldest) },
|
|
{ value: 'name', label: formatMessage(messages.name) },
|
|
])
|
|
const groupOptions = computed<ComboboxOption<string>[]>(() => [
|
|
{ value: 'custom', label: formatMessage(messages.custom) },
|
|
...(isGlobal.value ? [{ value: 'instance', label: formatMessage(messages.instance) }] : []),
|
|
{ value: 'date', label: formatMessage(messages.date) },
|
|
{ value: 'none', label: formatMessage(messages.none) },
|
|
])
|
|
|
|
const filteredScreenshots = computed(() => {
|
|
const query = search.value.trim().toLocaleLowerCase()
|
|
const filtered = screenshots.value.filter((screenshot) => {
|
|
if (screenshot.id === revealedScreenshotId.value) return true
|
|
return (
|
|
!query ||
|
|
screenshot.file_name.toLocaleLowerCase().includes(query) ||
|
|
screenshot.instance_name.toLocaleLowerCase().includes(query)
|
|
)
|
|
})
|
|
|
|
return filtered.sort((a, b) => {
|
|
if (sort.value === 'name') return a.file_name.localeCompare(b.file_name)
|
|
const difference = new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
|
return sort.value === 'oldest' ? difference : -difference
|
|
})
|
|
})
|
|
|
|
const groupedScreenshots = computed((): ScreenshotGroupData[] => {
|
|
if (groupBy.value === 'none') {
|
|
return [
|
|
{
|
|
id: 'all',
|
|
title: formatMessage(messages.allScreenshots),
|
|
screenshots: filteredScreenshots.value,
|
|
},
|
|
]
|
|
}
|
|
|
|
if (groupBy.value === 'instance') {
|
|
const screenshotGroups = new Map<string, InstanceScreenshot[]>()
|
|
for (const screenshot of filteredScreenshots.value) {
|
|
const group = screenshotGroups.get(screenshot.instance_id) ?? []
|
|
group.push(screenshot)
|
|
screenshotGroups.set(screenshot.instance_id, group)
|
|
}
|
|
|
|
const syncedInstances = (instancesQuery.data.value ?? [])
|
|
.filter((instance) => instance.synced_options.screenshots)
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
const groups = syncedInstances.flatMap((instance) => {
|
|
const instanceScreenshots = screenshotGroups.get(instance.id)
|
|
return instanceScreenshots
|
|
? [
|
|
{
|
|
id: `instance:${instance.id}`,
|
|
title: instance.name,
|
|
screenshots: instanceScreenshots,
|
|
dropInstanceId: instance.id,
|
|
},
|
|
]
|
|
: []
|
|
})
|
|
|
|
for (const [instanceId, instanceScreenshots] of screenshotGroups) {
|
|
if (groups.some((group) => group.dropInstanceId === instanceId)) continue
|
|
groups.push({
|
|
id: `instance:${instanceId}`,
|
|
title: instanceScreenshots[0]?.instance_name ?? instanceId,
|
|
screenshots: instanceScreenshots,
|
|
dropInstanceId: instanceId,
|
|
})
|
|
}
|
|
|
|
return groups
|
|
}
|
|
|
|
if (groupBy.value === 'custom') {
|
|
const screenshotGroups = new Map<string, InstanceScreenshot[]>()
|
|
const ungroupedScreenshots: InstanceScreenshot[] = []
|
|
const validGroupIds = new Set(customGroups.value.map((group) => group.id))
|
|
for (const screenshot of filteredScreenshots.value) {
|
|
const customGroupId = screenshot.group_id
|
|
if (!customGroupId || !validGroupIds.has(customGroupId)) {
|
|
ungroupedScreenshots.push(screenshot)
|
|
continue
|
|
}
|
|
const group = screenshotGroups.get(customGroupId) ?? []
|
|
group.push(screenshot)
|
|
screenshotGroups.set(customGroupId, group)
|
|
}
|
|
|
|
const groups: ScreenshotGroupData[] = customGroups.value.map((group) => ({
|
|
id: `custom:${group.id}`,
|
|
title: group.name,
|
|
screenshots: screenshotGroups.get(group.id) ?? [],
|
|
customGroupId: group.id,
|
|
}))
|
|
groups.push({
|
|
id: 'custom:ungrouped',
|
|
title: formatMessage(messages.ungrouped),
|
|
screenshots: ungroupedScreenshots,
|
|
customGroupId: null,
|
|
})
|
|
return isNarrowingResults.value
|
|
? groups.filter(
|
|
(group) =>
|
|
group.screenshots.length > 0 || group.customGroupId === groupIdPendingNameEdit.value,
|
|
)
|
|
: groups
|
|
}
|
|
|
|
const groups = new Map<string, ScreenshotGroupData>()
|
|
for (const screenshot of filteredScreenshots.value) {
|
|
const dateGroup = getDateGroup(screenshot.created_at)
|
|
const group = groups.get(dateGroup.id) ?? { ...dateGroup, screenshots: [] }
|
|
group.screenshots.push(screenshot)
|
|
groups.set(dateGroup.id, group)
|
|
}
|
|
return [...groups.values()].sort((a, b) => {
|
|
const aTime = Math.max(
|
|
...a.screenshots.map((screenshot) => new Date(screenshot.created_at).getTime()),
|
|
)
|
|
const bTime = Math.max(
|
|
...b.screenshots.map((screenshot) => new Date(screenshot.created_at).getTime()),
|
|
)
|
|
return sort.value === 'oldest' ? aTime - bTime : bTime - aTime
|
|
})
|
|
})
|
|
|
|
const screenshotColumnCount = computed(() => {
|
|
if (windowWidth.value >= 1536) return 4
|
|
if (windowWidth.value >= 640) return 2
|
|
return 1
|
|
})
|
|
|
|
const screenshotCardWidth = computed(() => {
|
|
if (screenshotListWidth.value <= 0) return FALLBACK_SCREENSHOT_CARD_WIDTH
|
|
const gapsWidth = (screenshotColumnCount.value - 1) * SCREENSHOT_GRID_GAP
|
|
return Math.max(0, (screenshotListWidth.value - gapsWidth) / screenshotColumnCount.value)
|
|
})
|
|
|
|
const screenshotCardHeight = computed(() => (screenshotCardWidth.value * 9) / 16)
|
|
const screenshotRowHeight = computed(() => screenshotCardHeight.value + SCREENSHOT_GRID_GAP)
|
|
|
|
const screenshotGroupLayouts = computed<ScreenshotGroupLayout[]>(() => {
|
|
const layouts: ScreenshotGroupLayout[] = []
|
|
let top = 0
|
|
|
|
for (const group of groupedScreenshots.value) {
|
|
const isHeaderHidden = groupBy.value === 'none'
|
|
const isOpen = isHeaderHidden || search.value.length > 0 || !collapsedGroups.value[group.id]
|
|
const rowCount = Math.ceil(group.screenshots.length / screenshotColumnCount.value)
|
|
const gridHeight =
|
|
rowCount === 0
|
|
? SCREENSHOT_GRID_MIN_HEIGHT
|
|
: rowCount * screenshotCardHeight.value + Math.max(0, rowCount - 1) * SCREENSHOT_GRID_GAP
|
|
const headerHeight = isHeaderHidden ? 0 : SCREENSHOT_GROUP_HEADER_HEIGHT
|
|
const gridTop = top + headerHeight + SCREENSHOT_GROUP_CONTENT_SPACING
|
|
const height =
|
|
headerHeight +
|
|
(isOpen ? SCREENSHOT_GROUP_CONTENT_SPACING + gridHeight : 0) +
|
|
SCREENSHOT_GROUP_SPACING
|
|
|
|
layouts.push({ group, top, height, isOpen, gridHeight, gridTop })
|
|
top += height
|
|
}
|
|
|
|
return layouts
|
|
})
|
|
|
|
const screenshotListHeight = computed(() => {
|
|
const lastGroup = screenshotGroupLayouts.value[screenshotGroupLayouts.value.length - 1]
|
|
return lastGroup ? lastGroup.top + lastGroup.height : 0
|
|
})
|
|
|
|
const visibleScreenshotGroups = computed<VisibleScreenshotGroupLayout[]>(() => {
|
|
const hasViewport = Boolean(screenshotListContainer.value && screenshotScrollContainer.value)
|
|
const viewportStart = hasViewport
|
|
? Math.max(0, screenshotListScrollTop.value - SCREENSHOT_GROUP_OVERSCAN)
|
|
: 0
|
|
const viewportEnd = hasViewport
|
|
? screenshotListScrollTop.value + screenshotViewportHeight.value + SCREENSHOT_GROUP_OVERSCAN
|
|
: SCREENSHOT_GROUP_OVERSCAN
|
|
|
|
return screenshotGroupLayouts.value
|
|
.filter((layout) => layout.top + layout.height >= viewportStart && layout.top <= viewportEnd)
|
|
.map((layout) => {
|
|
if (!layout.isOpen || layout.group.screenshots.length === 0) {
|
|
return { ...layout, renderedScreenshots: [], virtualGridTop: 0 }
|
|
}
|
|
|
|
const rowCount = Math.ceil(layout.group.screenshots.length / screenshotColumnCount.value)
|
|
const firstRow = Math.min(
|
|
rowCount,
|
|
Math.max(0, Math.floor((viewportStart - layout.gridTop) / screenshotRowHeight.value)),
|
|
)
|
|
const lastRow = Math.min(
|
|
rowCount,
|
|
Math.max(
|
|
firstRow,
|
|
Math.ceil(
|
|
(viewportEnd - layout.gridTop + SCREENSHOT_GRID_GAP) / screenshotRowHeight.value,
|
|
),
|
|
),
|
|
)
|
|
const firstScreenshot = firstRow * screenshotColumnCount.value
|
|
const lastScreenshot = lastRow * screenshotColumnCount.value
|
|
|
|
return {
|
|
...layout,
|
|
renderedScreenshots: layout.group.screenshots.slice(firstScreenshot, lastScreenshot),
|
|
virtualGridTop: firstRow * screenshotRowHeight.value,
|
|
}
|
|
})
|
|
})
|
|
|
|
const previewItems = computed(() =>
|
|
filteredScreenshots.value.map((screenshot) => ({
|
|
id: getSelectionKey(screenshot),
|
|
src: screenshot.url,
|
|
alt: screenshot.file_name,
|
|
title: screenshot.file_name,
|
|
editorSource: {
|
|
id: getSelectionKey(screenshot),
|
|
path: screenshot.path,
|
|
},
|
|
description: isGlobal.value
|
|
? formatMessage(messages.instanceAndDate, {
|
|
instance: screenshot.instance_name,
|
|
date: formatDateTime(screenshot.created_at),
|
|
})
|
|
: formatDateTime(screenshot.created_at),
|
|
})),
|
|
)
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (keys: ScreenshotKey[]) => delete_screenshots(keys),
|
|
onSuccess: async (_, keys) => {
|
|
await invalidateScreenshots(keys.map((key) => key.instance_id))
|
|
},
|
|
})
|
|
const exportMutation = useMutation({
|
|
mutationFn: ({ keys, path }: { keys: ScreenshotKey[]; path: string }) =>
|
|
export_screenshots(keys, path),
|
|
})
|
|
const moveMutation = useMutation({
|
|
mutationFn: ({ keys, targetInstanceId }: { keys: ScreenshotKey[]; targetInstanceId: string }) =>
|
|
move_screenshots(keys, targetInstanceId),
|
|
onSuccess: async (_, variables) => {
|
|
await invalidateScreenshots([
|
|
...variables.keys.map((key) => key.instance_id),
|
|
variables.targetInstanceId,
|
|
])
|
|
selectedKeys.value = new Set()
|
|
},
|
|
})
|
|
const saveEditMutation = useMutation({
|
|
mutationFn: (payload: {
|
|
screenshot: InstanceScreenshot
|
|
pngBytes: Uint8Array
|
|
mode: 'create_copy' | 'replace_edit'
|
|
}) =>
|
|
save_edited_screenshot(getScreenshotKey(payload.screenshot), payload.pngBytes, payload.mode),
|
|
onSuccess: async (saved) => {
|
|
await invalidateScreenshots([saved.instance_id])
|
|
await imageViewer.value?.markSavedAndView(getSelectionKey(saved))
|
|
await revealScreenshot(saved.id)
|
|
},
|
|
onError: handleError,
|
|
})
|
|
|
|
function saveScreenshotEdit(payload: ImageViewerEditorSavePayload) {
|
|
const screenshot = screenshotBySelectionKey(payload.item.id)
|
|
if (!screenshot) return
|
|
saveEditMutation.mutate({
|
|
screenshot,
|
|
pngBytes: payload.pngBytes,
|
|
mode: payload.mode,
|
|
})
|
|
}
|
|
|
|
const bulkBusy = computed(
|
|
() =>
|
|
deleteMutation.isPending.value ||
|
|
exportMutation.isPending.value ||
|
|
moveMutation.isPending.value ||
|
|
creatingCustomGroup.value ||
|
|
updatingCustomGroupMemberships.value,
|
|
)
|
|
|
|
watch(screenshots, (currentScreenshots) => {
|
|
const currentKeys = new Set(currentScreenshots.map(getSelectionKey))
|
|
selectedKeys.value = new Set([...selectedKeys.value].filter((key) => currentKeys.has(key)))
|
|
})
|
|
|
|
watch(groupBy, async (currentGroupBy, previousGroupBy) => {
|
|
if (currentGroupBy !== 'custom') {
|
|
groupIdPendingNameEdit.value = undefined
|
|
}
|
|
if (currentGroupBy === previousGroupBy) return
|
|
|
|
const previousPositions = getScreenshotCardPositions()
|
|
regrouping.value = true
|
|
await nextTick()
|
|
animateScreenshotCardsFrom(previousPositions)
|
|
regrouping.value = false
|
|
})
|
|
|
|
let handledFocus: string | undefined
|
|
|
|
watch(
|
|
[() => route.query.focus, screenshots],
|
|
([focus]) => {
|
|
if (typeof focus !== 'string') {
|
|
handledFocus = undefined
|
|
return
|
|
}
|
|
if (handledFocus === focus) return
|
|
if (!screenshots.value.some((screenshot) => screenshot.id === focus)) return
|
|
handledFocus = focus
|
|
void revealScreenshot(focus)
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch(
|
|
() => screenshotGroupsQuery.isSuccess.value,
|
|
(groupsLoaded) => {
|
|
if (groupsLoaded) void migrateLegacyScreenshotGroups()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
useAppEvent('instance', (event) => {
|
|
if (event.event !== 'screenshots_updated') return
|
|
void invalidateScreenshots([event.instance_id])
|
|
})
|
|
|
|
function getSelectionKey(screenshot: InstanceScreenshot) {
|
|
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
|
|
}
|
|
|
|
function getScreenshotCardPositions() {
|
|
const positions = new Map<string, DOMRect>()
|
|
const cards = screenshotsPage.value?.querySelectorAll<HTMLElement>('[data-screenshot-card]') ?? []
|
|
for (const card of cards) {
|
|
const selectionKey = card.dataset.selectionKey
|
|
if (selectionKey) positions.set(selectionKey, card.getBoundingClientRect())
|
|
}
|
|
return positions
|
|
}
|
|
|
|
function animateScreenshotCardsFrom(previousPositions: Map<string, DOMRect>) {
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
|
|
|
const cards = screenshotsPage.value?.querySelectorAll<HTMLElement>('[data-screenshot-card]') ?? []
|
|
for (const card of cards) {
|
|
const selectionKey = card.dataset.selectionKey
|
|
const previousPosition = selectionKey ? previousPositions.get(selectionKey) : undefined
|
|
if (!previousPosition) continue
|
|
|
|
const currentPosition = card.getBoundingClientRect()
|
|
const translateX = previousPosition.left - currentPosition.left
|
|
const translateY = previousPosition.top - currentPosition.top
|
|
if (translateX === 0 && translateY === 0) continue
|
|
|
|
card.animate(
|
|
[
|
|
{ transform: `translate(${translateX}px, ${translateY}px)` },
|
|
{ transform: 'translate(0, 0)' },
|
|
],
|
|
{ duration: 200, easing: 'ease-out' },
|
|
)
|
|
}
|
|
}
|
|
|
|
function getScreenshotKey(screenshot: InstanceScreenshot): ScreenshotKey {
|
|
return {
|
|
instance_id: screenshot.instance_id,
|
|
file_name: screenshot.file_name,
|
|
}
|
|
}
|
|
|
|
async function migrateLegacyScreenshotGroups() {
|
|
const legacy = legacyCustomGrouping.value
|
|
if (migratingLegacyGroups.value || legacy.groups.length === 0) return
|
|
|
|
migratingLegacyGroups.value = true
|
|
try {
|
|
const allScreenshots = await list_all_screenshots()
|
|
const screenshotIdsByLegacyKey = new Map(
|
|
allScreenshots.map((screenshot) => [getSelectionKey(screenshot), screenshot.id]),
|
|
)
|
|
const screenshotIdsByGroupId = new Map<string, string[]>()
|
|
for (const [legacyKey, groupId] of Object.entries(legacy.assignments)) {
|
|
const screenshotId = screenshotIdsByLegacyKey.get(legacyKey)
|
|
if (!screenshotId) continue
|
|
const screenshotIds = screenshotIdsByGroupId.get(groupId) ?? []
|
|
screenshotIds.push(screenshotId)
|
|
screenshotIdsByGroupId.set(groupId, screenshotIds)
|
|
}
|
|
const groups: ScreenshotGroupImport[] = legacy.groups.map((group) => ({
|
|
...group,
|
|
screenshot_ids: screenshotIdsByGroupId.get(group.id) ?? [],
|
|
}))
|
|
await import_screenshot_groups(groups)
|
|
legacyCustomGrouping.value = { groups: [], assignments: {} }
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: screenshotKeys.groups() }),
|
|
invalidateScreenshots(allScreenshots.map((screenshot) => screenshot.instance_id)),
|
|
])
|
|
} catch (error) {
|
|
handleError(error)
|
|
} finally {
|
|
migratingLegacyGroups.value = false
|
|
}
|
|
}
|
|
|
|
function getDefaultCustomGroupName() {
|
|
const existingNames = new Set(
|
|
customGroups.value.map((group) => group.name.trim().toLocaleLowerCase()),
|
|
)
|
|
let groupNumber = customGroups.value.length + 1
|
|
while (existingNames.has(`group ${groupNumber}`.toLocaleLowerCase())) {
|
|
groupNumber += 1
|
|
}
|
|
return `Group ${groupNumber}`
|
|
}
|
|
|
|
async function createCustomGroup() {
|
|
if (creatingCustomGroup.value) return
|
|
creatingCustomGroup.value = true
|
|
try {
|
|
const screenshotsToGroup = [...selectedScreenshots.value]
|
|
const group = await create_screenshot_group(
|
|
getDefaultCustomGroupName(),
|
|
screenshotsToGroup.map((screenshot) => screenshot.id),
|
|
)
|
|
queryClient.setQueryData<ScreenshotGroup[]>(screenshotKeys.groups(), (groups = []) => [
|
|
group,
|
|
...groups.filter((existingGroup) => existingGroup.id !== group.id),
|
|
])
|
|
await invalidateScreenshots([
|
|
...new Set(screenshotsToGroup.map((screenshot) => screenshot.instance_id)),
|
|
])
|
|
groupBy.value = 'custom'
|
|
selectedKeys.value = new Set()
|
|
groupIdPendingNameEdit.value = group.id
|
|
} catch (error) {
|
|
handleError(error)
|
|
} finally {
|
|
creatingCustomGroup.value = false
|
|
}
|
|
}
|
|
|
|
function validateCustomGroupName(value: string) {
|
|
const normalizedGroupName = value.trim()
|
|
return (
|
|
normalizedGroupName.length > 0 && normalizedGroupName.length <= MAX_INSTANCE_GROUP_NAME_LENGTH
|
|
)
|
|
}
|
|
|
|
async function renameCustomGroup(groupId: string | null | undefined, name: string) {
|
|
if (!groupId) return false
|
|
try {
|
|
const group = await rename_screenshot_group(groupId, name)
|
|
queryClient.setQueryData<ScreenshotGroup[]>(screenshotKeys.groups(), (groups = []) =>
|
|
groups.map((existingGroup) => (existingGroup.id === group.id ? group : existingGroup)),
|
|
)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function requestCustomGroupDeletion(groupId: string) {
|
|
const group = customGroups.value.find((candidate) => candidate.id === groupId)
|
|
if (!group) return
|
|
|
|
customGroupToDelete.value = group
|
|
try {
|
|
const allScreenshots = await list_all_screenshots()
|
|
if (allScreenshots.some((screenshot) => screenshot.group_id === groupId)) {
|
|
deleteGroupModal.value?.show()
|
|
} else {
|
|
await deleteCustomGroup()
|
|
}
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
async function deleteCustomGroup() {
|
|
const group = customGroupToDelete.value
|
|
if (!group) return
|
|
try {
|
|
await delete_screenshot_group(group.id)
|
|
queryClient.setQueryData<ScreenshotGroup[]>(screenshotKeys.groups(), (groups = []) =>
|
|
groups.filter((candidate) => candidate.id !== group.id),
|
|
)
|
|
await invalidateScreenshots(screenshots.value.map((screenshot) => screenshot.instance_id))
|
|
if (groupIdPendingNameEdit.value === group.id) {
|
|
groupIdPendingNameEdit.value = undefined
|
|
}
|
|
customGroupToDelete.value = undefined
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
async function assignCustomGroup(
|
|
screenshotsToMove: InstanceScreenshot[],
|
|
customGroupId: string | null,
|
|
) {
|
|
const movedScreenshots = screenshotsToMove.filter(
|
|
(screenshot) => (screenshot.group_id ?? null) !== customGroupId,
|
|
)
|
|
if (movedScreenshots.length === 0 || updatingCustomGroupMemberships.value) return
|
|
updatingCustomGroupMemberships.value = true
|
|
try {
|
|
await set_screenshot_group_memberships(
|
|
movedScreenshots.map((screenshot) => ({
|
|
screenshot_id: screenshot.id,
|
|
group_id: customGroupId,
|
|
})),
|
|
)
|
|
await invalidateScreenshots(movedScreenshots.map((screenshot) => screenshot.instance_id))
|
|
selectedKeys.value = new Set()
|
|
} catch (error) {
|
|
handleError(error)
|
|
} finally {
|
|
updatingCustomGroupMemberships.value = false
|
|
}
|
|
}
|
|
|
|
function removeSelectedScreenshotsFromGroups() {
|
|
if (bulkBusy.value || selectedGroupedScreenshots.value.length === 0) return
|
|
void assignCustomGroup(selectedGroupedScreenshots.value, null)
|
|
}
|
|
|
|
function getDateGroup(createdAt: string): Omit<ScreenshotGroupData, 'screenshots'> {
|
|
const created = dayjs(createdAt)
|
|
const now = dayjs()
|
|
if (created.isSame(now, 'day')) {
|
|
return { id: 'date:today', title: formatMessage(messages.today) }
|
|
}
|
|
if (created.isSame(now.subtract(1, 'day'), 'day')) {
|
|
return { id: 'date:yesterday', title: formatMessage(messages.yesterday) }
|
|
}
|
|
if (created.isSame(now, 'week')) {
|
|
return {
|
|
id: `date:week:${created.startOf('week').format('YYYY-MM-DD')}`,
|
|
title: formatMessage(messages.thisWeek),
|
|
}
|
|
}
|
|
if (created.isSame(now, 'month')) {
|
|
return {
|
|
id: `date:month:${created.format('YYYY-MM')}`,
|
|
title: formatMessage(messages.thisMonth),
|
|
}
|
|
}
|
|
return {
|
|
id: `date:month:${created.format('YYYY-MM')}`,
|
|
title: formatMonth(created.toDate()),
|
|
}
|
|
}
|
|
|
|
async function invalidateScreenshots(instanceIds: string[]) {
|
|
const uniqueInstanceIds = [...new Set(instanceIds)]
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: screenshotKeys.global() }),
|
|
...uniqueInstanceIds.map((instanceId) =>
|
|
queryClient.invalidateQueries({ queryKey: screenshotKeys.instance(instanceId) }),
|
|
),
|
|
])
|
|
}
|
|
|
|
function toggleScreenshotSelection(screenshot: InstanceScreenshot) {
|
|
if (bulkBusy.value) return
|
|
const key = getSelectionKey(screenshot)
|
|
const next = new Set(selectedKeys.value)
|
|
if (next.has(key)) {
|
|
next.delete(key)
|
|
} else {
|
|
next.add(key)
|
|
}
|
|
selectedKeys.value = next
|
|
}
|
|
|
|
function activateScreenshot(screenshot: InstanceScreenshot, event: MouseEvent | KeyboardEvent) {
|
|
if (selectionActive.value || event.shiftKey) {
|
|
toggleScreenshotSelection(screenshot)
|
|
return
|
|
}
|
|
const index = filteredScreenshots.value.findIndex(
|
|
(candidate) => getSelectionKey(candidate) === getSelectionKey(screenshot),
|
|
)
|
|
if (index >= 0) {
|
|
screenshotOptionsMenu.value?.close()
|
|
imageViewer.value?.show(index)
|
|
}
|
|
}
|
|
|
|
function clearSelection() {
|
|
if (!bulkBusy.value) selectedKeys.value = new Set()
|
|
}
|
|
|
|
function requestDelete(screenshot: InstanceScreenshot, fromPreview = false) {
|
|
deleteFromPreview.value = fromPreview
|
|
screenshotToDelete.value = screenshot
|
|
deleteModal.value?.show()
|
|
}
|
|
|
|
function showScreenshotOptions(screenshot: InstanceScreenshot, event: MouseEvent) {
|
|
screenshotOptionsTarget.value = screenshot
|
|
const options: ButtonMenuOption[] = [
|
|
...(event.type === 'contextmenu'
|
|
? [
|
|
{
|
|
id: 'edit',
|
|
label: formatMessage(messages.edit),
|
|
icon: EditIcon,
|
|
action: () => editScreenshot(screenshot),
|
|
},
|
|
{
|
|
id: 'copy',
|
|
label: formatMessage(messages.copy),
|
|
icon: ClipboardCopyIcon,
|
|
action: () => void copyScreenshot(screenshot),
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
id: 'open',
|
|
label: formatMessage(messages.showInFolder),
|
|
icon: FolderOpenIcon,
|
|
action: () => void openScreenshot(screenshot),
|
|
},
|
|
{
|
|
id: 'go-to-instance',
|
|
label: formatMessage(messages.goToInstance),
|
|
action: () => void goToInstance(screenshot),
|
|
},
|
|
{ type: 'divider' },
|
|
{
|
|
id: 'delete',
|
|
label: formatMessage(commonMessages.deleteLabel),
|
|
icon: TrashIcon,
|
|
tone: 'red',
|
|
action: () => requestDelete(screenshot),
|
|
},
|
|
]
|
|
screenshotOptionsMenu.value?.open(event, options)
|
|
}
|
|
|
|
function goToInstance(screenshot: InstanceScreenshot) {
|
|
return router.push(`/instance/${encodeURIComponent(screenshot.instance_id)}`)
|
|
}
|
|
|
|
function screenshotBySelectionKey(selectionKey: string) {
|
|
return screenshots.value.find((screenshot) => getSelectionKey(screenshot) === selectionKey)
|
|
}
|
|
|
|
function copyScreenshotBySelectionKey(selectionKey: string) {
|
|
const screenshot = screenshotBySelectionKey(selectionKey)
|
|
if (screenshot) void copyScreenshot(screenshot)
|
|
}
|
|
|
|
function isScreenshotCopiedBySelectionKey(selectionKey: string) {
|
|
const screenshot = screenshotBySelectionKey(selectionKey)
|
|
return screenshot ? copiedScreenshotIds.value.has(screenshot.id) : false
|
|
}
|
|
|
|
function openScreenshotBySelectionKey(selectionKey: string) {
|
|
const screenshot = screenshotBySelectionKey(selectionKey)
|
|
if (screenshot) void openScreenshot(screenshot)
|
|
}
|
|
|
|
function requestPreviewDelete(selectionKey: string) {
|
|
const screenshot = screenshotBySelectionKey(selectionKey)
|
|
if (screenshot) requestDelete(screenshot, true)
|
|
}
|
|
|
|
function editScreenshot(screenshot: InstanceScreenshot) {
|
|
const index = filteredScreenshots.value.findIndex(
|
|
(candidate) => getSelectionKey(candidate) === getSelectionKey(screenshot),
|
|
)
|
|
if (index >= 0) {
|
|
screenshotOptionsMenu.value?.close()
|
|
void imageViewer.value?.edit(index)
|
|
}
|
|
}
|
|
|
|
let revealTimeout: ReturnType<typeof setTimeout> | undefined
|
|
|
|
async function revealScreenshot(id: string) {
|
|
if (revealTimeout) clearTimeout(revealTimeout)
|
|
revealedScreenshotId.value = id
|
|
await nextTick()
|
|
|
|
const group = groupedScreenshots.value.find((candidate) =>
|
|
candidate.screenshots.some((screenshot) => screenshot.id === id),
|
|
)
|
|
if (group) setGroupCollapsed(group.id, false)
|
|
|
|
await nextTick()
|
|
await waitForScreenshotViewport()
|
|
highlightedScreenshotId.value = id
|
|
|
|
const layout = screenshotGroupLayouts.value.find((candidate) => candidate.group.id === group?.id)
|
|
const screenshotIndex = layout?.group.screenshots.findIndex((screenshot) => screenshot.id === id)
|
|
const scrollTarget = screenshotScrollContainer.value
|
|
if (layout && screenshotIndex !== undefined && screenshotIndex >= 0 && scrollTarget) {
|
|
const row = Math.floor(screenshotIndex / screenshotColumnCount.value)
|
|
const top = Math.max(
|
|
0,
|
|
screenshotListOffset.value +
|
|
layout.gridTop +
|
|
row * screenshotRowHeight.value +
|
|
screenshotCardHeight.value / 2 -
|
|
screenshotViewportHeight.value / 2,
|
|
)
|
|
scrollTarget.scrollTo({ top, behavior: 'smooth' })
|
|
}
|
|
|
|
const card = await waitForScreenshotCard(id)
|
|
card?.focus({ preventScroll: true })
|
|
revealTimeout = setTimeout(() => {
|
|
if (highlightedScreenshotId.value === id) highlightedScreenshotId.value = undefined
|
|
if (revealedScreenshotId.value === id) revealedScreenshotId.value = undefined
|
|
revealTimeout = undefined
|
|
}, 2400)
|
|
}
|
|
|
|
async function waitForScreenshotViewport() {
|
|
for (let frame = 0; frame < 10; frame++) {
|
|
if (screenshotListContainer.value && screenshotScrollContainer.value) return
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
|
}
|
|
}
|
|
|
|
async function waitForScreenshotCard(id: string) {
|
|
const selector = `[data-screenshot-id="${CSS.escape(id)}"]`
|
|
for (let frame = 0; frame < 60; frame++) {
|
|
const card = screenshotListContainer.value?.querySelector<HTMLElement>(selector)
|
|
if (card) return card
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
const screenshot = screenshotToDelete.value
|
|
if (!screenshot) return
|
|
try {
|
|
await deleteMutation.mutateAsync([getScreenshotKey(screenshot)])
|
|
if (deleteFromPreview.value) imageViewer.value?.hide()
|
|
addNotification({ type: 'success', title: formatMessage(messages.deleteSuccess) })
|
|
} catch (error) {
|
|
handleError(error)
|
|
} finally {
|
|
screenshotToDelete.value = null
|
|
deleteFromPreview.value = false
|
|
}
|
|
}
|
|
|
|
async function deleteSelected() {
|
|
const selected = selectedScreenshots.value
|
|
if (bulkBusy.value || selected.length === 0) return
|
|
try {
|
|
await deleteMutation.mutateAsync(selected.map(getScreenshotKey))
|
|
selectedKeys.value = new Set()
|
|
addNotification({
|
|
type: 'success',
|
|
title: formatMessage(messages.bulkDeleteSuccess, { count: selected.length }),
|
|
})
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
async function exportSelected() {
|
|
const selected = selectedScreenshots.value
|
|
if (bulkBusy.value || selected.length === 0) return
|
|
const instanceName =
|
|
selected[0]?.instance_name.replace(/[\\/:*?"<>|]/g, '-') ?? formatMessage(messages.heading)
|
|
const outputPath = await save({
|
|
defaultPath: isGlobal.value
|
|
? formatMessage(messages.globalExportFilename)
|
|
: formatMessage(messages.instanceExportFilename, { instance: instanceName }),
|
|
filters: [{ name: formatMessage(messages.zipArchive), extensions: ['zip'] }],
|
|
})
|
|
if (!outputPath) return
|
|
try {
|
|
await exportMutation.mutateAsync({
|
|
keys: selected.map(getScreenshotKey),
|
|
path: outputPath,
|
|
})
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
async function copyScreenshot(screenshot: InstanceScreenshot) {
|
|
try {
|
|
const png = readFile(screenshot.path).then((bytes) => new Blob([bytes], { type: 'image/png' }))
|
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })])
|
|
markScreenshotCopied(screenshot.id)
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
function markScreenshotCopied(id: string) {
|
|
copiedScreenshotIds.value = new Set([...copiedScreenshotIds.value, id])
|
|
const existingTimeout = copiedResetTimeouts.get(id)
|
|
if (existingTimeout) clearTimeout(existingTimeout)
|
|
copiedResetTimeouts.set(
|
|
id,
|
|
setTimeout(() => {
|
|
const nextCopiedScreenshotIds = new Set(copiedScreenshotIds.value)
|
|
nextCopiedScreenshotIds.delete(id)
|
|
copiedScreenshotIds.value = nextCopiedScreenshotIds
|
|
copiedResetTimeouts.delete(id)
|
|
}, 2000),
|
|
)
|
|
}
|
|
|
|
async function openScreenshot(screenshot: InstanceScreenshot) {
|
|
try {
|
|
await open_screenshot(getScreenshotKey(screenshot))
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
function handleDragStart(event: DragStartEvent) {
|
|
const source = event.operation.source?.data as ScreenshotDragData | undefined
|
|
if (!source) return
|
|
|
|
const selectedDragKeys = selectedKeys.value.has(source.selectionKey)
|
|
? selectedScreenshots.value.map(getSelectionKey)
|
|
: []
|
|
const selectionKeys = [
|
|
source.selectionKey,
|
|
...selectedDragKeys.filter((selectionKey) => selectionKey !== source.selectionKey),
|
|
]
|
|
activeDrag.value = {
|
|
primarySelectionKey: source.selectionKey,
|
|
selectionKeys,
|
|
}
|
|
startGather(activeDrag.value, event.operation.position.current)
|
|
}
|
|
|
|
function handleDragMove(event: DragMoveEvent) {
|
|
if (gatherItems.value.length === 0) return
|
|
updateGatherTarget(event.to ?? event.operation.position.current)
|
|
}
|
|
|
|
function handleDragOver(event: DragOverEvent) {
|
|
const target = event.operation.target?.data as ScreenshotDropData | undefined
|
|
activeDropGroupId.value = target?.groupId ?? null
|
|
}
|
|
|
|
function canDropScreenshotsOnTarget(target: ScreenshotDropData) {
|
|
if (!activeDrag.value || activeDraggedScreenshots.value.length === 0) return false
|
|
|
|
if ('customGroupId' in target) {
|
|
const targetGroupId = target.customGroupId ?? null
|
|
return activeDraggedScreenshots.value.some(
|
|
(screenshot) => (screenshot.group_id ?? null) !== targetGroupId,
|
|
)
|
|
}
|
|
|
|
return Boolean(
|
|
target.instanceId &&
|
|
activeDraggedScreenshots.value.some(
|
|
(screenshot) => screenshot.instance_id !== target.instanceId,
|
|
),
|
|
)
|
|
}
|
|
|
|
function canDropScreenshotsOnGroup(group: ScreenshotGroupData) {
|
|
if (groupBy.value === 'custom') {
|
|
return canDropScreenshotsOnTarget({
|
|
groupId: group.id,
|
|
customGroupId: group.customGroupId ?? null,
|
|
})
|
|
}
|
|
if (groupBy.value === 'instance' && group.dropInstanceId) {
|
|
return canDropScreenshotsOnTarget({
|
|
groupId: group.id,
|
|
instanceId: group.dropInstanceId,
|
|
})
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function handleDragEnd(event: DragEndEvent) {
|
|
const drag = activeDrag.value
|
|
const target = event.operation.target?.data as ScreenshotDropData | undefined
|
|
clearGroupHoverOpenTimeout()
|
|
if (!event.canceled && drag && target && canDropScreenshotsOnTarget(target)) {
|
|
if ('customGroupId' in target) {
|
|
await assignCustomGroup(activeDraggedScreenshots.value, target.customGroupId ?? null)
|
|
await nextTick()
|
|
} else if (target.instanceId) {
|
|
const keys = activeDraggedScreenshots.value.map(getScreenshotKey)
|
|
const movableKeys = keys.filter((key) => key.instance_id !== target.instanceId)
|
|
if (movableKeys.length > 0) {
|
|
moveMutation.mutate({ keys: movableKeys, targetInstanceId: target.instanceId })
|
|
}
|
|
}
|
|
}
|
|
|
|
clearGather()
|
|
activeDrag.value = null
|
|
activeDropGroupId.value = null
|
|
}
|
|
|
|
function setGroupCollapsed(groupId: string, collapsed: boolean) {
|
|
collapsedGroups.value = { ...collapsedGroups.value, [groupId]: collapsed }
|
|
}
|
|
|
|
const GROUP_HOVER_OPEN_DELAY = 750
|
|
let groupHoverOpenTimeout: ReturnType<typeof setTimeout> | undefined
|
|
|
|
function clearGroupHoverOpenTimeout() {
|
|
if (groupHoverOpenTimeout !== undefined) {
|
|
clearTimeout(groupHoverOpenTimeout)
|
|
}
|
|
groupHoverOpenTimeout = undefined
|
|
}
|
|
|
|
watch(activeDropGroupId, (groupId) => {
|
|
clearGroupHoverOpenTimeout()
|
|
if (!groupId || !collapsedGroups.value[groupId]) return
|
|
|
|
const group = groupedScreenshots.value.find((candidate) => candidate.id === groupId)
|
|
if (!group || !canDropScreenshotsOnGroup(group)) return
|
|
|
|
groupHoverOpenTimeout = setTimeout(() => {
|
|
groupHoverOpenTimeout = undefined
|
|
if (activeDropGroupId.value === groupId) {
|
|
setGroupCollapsed(groupId, false)
|
|
}
|
|
}, GROUP_HOVER_OPEN_DELAY)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
clearGroupHoverOpenTimeout()
|
|
if (revealTimeout) clearTimeout(revealTimeout)
|
|
if (screenshotsScrollIdleTimeout) clearTimeout(screenshotsScrollIdleTimeout)
|
|
for (const timeout of copiedResetTimeouts.values()) clearTimeout(timeout)
|
|
copiedResetTimeouts.clear()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<ConfirmModal
|
|
ref="deleteModal"
|
|
:title="formatMessage(messages.deleteTitle)"
|
|
:description="
|
|
formatMessage(messages.deleteDescription, {
|
|
name: screenshotToDelete?.file_name ?? '',
|
|
})
|
|
"
|
|
:proceed-label="formatMessage(commonMessages.deleteLabel)"
|
|
:markdown="false"
|
|
@proceed="confirmDelete"
|
|
/>
|
|
<ConfirmModal
|
|
ref="bulkDeleteModal"
|
|
:title="formatMessage(messages.bulkDeleteTitle)"
|
|
:description="formatMessage(messages.bulkDeleteDescription, { count: selectedKeys.size })"
|
|
:proceed-label="formatMessage(commonMessages.deleteLabel)"
|
|
:markdown="false"
|
|
@proceed="deleteSelected"
|
|
/>
|
|
<ConfirmModal
|
|
ref="deleteGroupModal"
|
|
:title="formatMessage(messages.deleteGroup)"
|
|
:description="formatMessage(messages.deleteGroupDescription)"
|
|
:proceed-label="formatMessage(commonMessages.deleteLabel)"
|
|
:markdown="false"
|
|
@proceed="deleteCustomGroup"
|
|
/>
|
|
<ContextMenu ref="screenshotOptionsMenu" :label="formatMessage(commonMessages.actionsLabel)">
|
|
<template #go-to-instance>
|
|
<Avatar
|
|
:src="getInstanceIconUrl(screenshotOptionsInstance?.icon_path)"
|
|
:tint-by="screenshotOptionsTarget?.instance_id"
|
|
alt=""
|
|
size="1rem"
|
|
class="shrink-0"
|
|
/>
|
|
{{ formatMessage(messages.goToInstance) }}
|
|
</template>
|
|
</ContextMenu>
|
|
<ImageViewerEditor
|
|
ref="imageViewer"
|
|
:items="previewItems"
|
|
editor="enabled"
|
|
:saving="saveEditMutation.isPending.value"
|
|
@save="saveScreenshotEdit"
|
|
>
|
|
<template #actions="{ item }">
|
|
<IconButton
|
|
v-tooltip="
|
|
formatMessage(isScreenshotCopiedBySelectionKey(item.id) ? messages.copied : messages.copy)
|
|
"
|
|
:label="
|
|
formatMessage(isScreenshotCopiedBySelectionKey(item.id) ? messages.copied : messages.copy)
|
|
"
|
|
type="quiet"
|
|
@click="copyScreenshotBySelectionKey(item.id)"
|
|
>
|
|
<CheckIcon v-if="isScreenshotCopiedBySelectionKey(item.id)" class="text-green" />
|
|
<ClipboardCopyIcon v-else />
|
|
</IconButton>
|
|
<IconButton
|
|
v-tooltip="formatMessage(messages.showInFolder)"
|
|
:label="formatMessage(messages.showInFolder)"
|
|
type="quiet"
|
|
@click="openScreenshotBySelectionKey(item.id)"
|
|
>
|
|
<FolderOpenIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
v-tooltip="formatMessage(commonMessages.deleteLabel)"
|
|
:label="formatMessage(commonMessages.deleteLabel)"
|
|
type="quiet"
|
|
class="hover:!bg-red focus-visible:!bg-red hover:!text-[var(--color-accent-contrast)] focus-visible:!text-[var(--color-accent-contrast)]"
|
|
@click="requestPreviewDelete(item.id)"
|
|
>
|
|
<TrashIcon />
|
|
</IconButton>
|
|
</template>
|
|
</ImageViewerEditor>
|
|
|
|
<div
|
|
ref="screenshotsPage"
|
|
class="flex h-full w-full flex-col gap-3"
|
|
:class="{ 'justify-center': screenshots.length === 0 && !groupIdPendingNameEdit }"
|
|
>
|
|
<template v-if="screenshots.length > 0 || groupIdPendingNameEdit">
|
|
<h1 v-if="showHeading" class="m-0 text-2xl font-bold text-contrast">
|
|
{{ formatMessage(messages.heading) }}
|
|
</h1>
|
|
|
|
<ScreenshotToolbar
|
|
v-model:search="search"
|
|
v-model:sort="sortModel"
|
|
v-model:group="groupByModel"
|
|
:sort-options="sortOptions"
|
|
:group-options="groupOptions"
|
|
@new-group="createCustomGroup"
|
|
/>
|
|
</template>
|
|
|
|
<ReadyTransition :pending="screenshotsReadyPending">
|
|
<EmptyState
|
|
v-if="screenshotsError"
|
|
type="error"
|
|
:heading="formatMessage(messages.errorHeading)"
|
|
:description="screenshotsError.message"
|
|
>
|
|
<template #actions>
|
|
<Button type="outlined" @click="screenshotsQuery.refetch()">
|
|
{{ formatMessage(commonMessages.retryButton) }}
|
|
</Button>
|
|
</template>
|
|
</EmptyState>
|
|
|
|
<EmptyState
|
|
v-else-if="screenshots.length === 0 && !groupIdPendingNameEdit"
|
|
type="no-images"
|
|
:heading="formatMessage(messages.emptyHeading)"
|
|
:description="formatMessage(messages.emptyDescription)"
|
|
/>
|
|
<EmptyState
|
|
v-else-if="filteredScreenshots.length === 0 && !groupIdPendingNameEdit"
|
|
type="no-images"
|
|
:heading="formatMessage(messages.noResultsHeading)"
|
|
:description="formatMessage(messages.noResultsDescription)"
|
|
/>
|
|
|
|
<DragDropProvider
|
|
v-else
|
|
@drag-start="handleDragStart"
|
|
@drag-move="handleDragMove"
|
|
@drag-over="handleDragOver"
|
|
@drag-end="handleDragEnd"
|
|
>
|
|
<div
|
|
ref="screenshotListContainer"
|
|
class="relative w-full"
|
|
:style="{ height: `${screenshotListHeight}px`, overflowAnchor: 'none' }"
|
|
>
|
|
<div
|
|
v-for="{
|
|
group,
|
|
top,
|
|
gridHeight,
|
|
renderedScreenshots,
|
|
virtualGridTop,
|
|
} in visibleScreenshotGroups"
|
|
:key="group.id"
|
|
class="absolute inset-x-0 transition-transform duration-300 ease-in-out will-change-transform motion-reduce:transition-none"
|
|
:style="{ transform: `translateY(${top}px)` }"
|
|
>
|
|
<ScreenshotGroupSection
|
|
:id="group.id"
|
|
:title="group.title"
|
|
:screenshots="group.screenshots"
|
|
:rendered-screenshots="renderedScreenshots"
|
|
:virtual-grid-height="gridHeight"
|
|
:virtual-grid-top="virtualGridTop"
|
|
:selected-keys="selectedKeys"
|
|
:selection-active="selectionActive"
|
|
:active-dragged-keys="activeDraggedKeys"
|
|
:show-drop-outline="
|
|
activeDropGroupId === group.id && canDropScreenshotsOnGroup(group)
|
|
"
|
|
:can-drag="groupBy === 'custom' || (isGlobal && groupBy === 'instance')"
|
|
:drop-instance-id="groupBy === 'instance' ? group.dropInstanceId : undefined"
|
|
:drop-custom-group="groupBy === 'custom'"
|
|
:drop-custom-group-id="group.customGroupId ?? undefined"
|
|
:show-instance-name="isGlobal && groupBy !== 'instance'"
|
|
:highlighted-screenshot-id="highlightedScreenshotId"
|
|
:copied-screenshot-ids="copiedScreenshotIds"
|
|
:animate-entry="!regrouping && !screenshotsScrolling"
|
|
:force-open="search.length > 0"
|
|
:hide-header="groupBy === 'none'"
|
|
:editable-title="Boolean(group.customGroupId)"
|
|
:start-editing-title="groupIdPendingNameEdit === group.customGroupId"
|
|
:max-title-length="MAX_INSTANCE_GROUP_NAME_LENGTH"
|
|
:validate-title="validateCustomGroupName"
|
|
:on-title-change="(name: string) => renameCustomGroup(group.customGroupId, name)"
|
|
:collapsed="Boolean(collapsedGroups[group.id])"
|
|
@update:collapsed="(value) => setGroupCollapsed(group.id, value)"
|
|
@activate="activateScreenshot"
|
|
@toggle-selection="toggleScreenshotSelection"
|
|
@copy="copyScreenshot"
|
|
@edit="editScreenshot"
|
|
@more="showScreenshotOptions"
|
|
>
|
|
<template v-if="group.customGroupId" #actions="{ startEditing }">
|
|
<div
|
|
class="flex shrink-0 items-center opacity-0 transition-opacity duration-250 group-hover/header:opacity-100 focus-within:opacity-100"
|
|
>
|
|
<IconButton
|
|
v-tooltip="formatMessage(messages.editGroup)"
|
|
:label="formatMessage(messages.editGroup)"
|
|
type="quiet"
|
|
size="sm"
|
|
@click.stop="startEditing"
|
|
>
|
|
<EditIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
v-tooltip="formatMessage(messages.deleteGroup)"
|
|
:label="formatMessage(messages.deleteGroup)"
|
|
type="quiet"
|
|
size="sm"
|
|
@click.stop="requestCustomGroupDeletion(group.customGroupId)"
|
|
>
|
|
<TrashIcon />
|
|
</IconButton>
|
|
</div>
|
|
</template>
|
|
</ScreenshotGroupSection>
|
|
</div>
|
|
</div>
|
|
<Teleport to="body">
|
|
<div class="pointer-events-none fixed inset-0 z-[9999]">
|
|
<DragOverlay :drop-animation="null">
|
|
<div
|
|
v-if="activeDraggedScreenshot"
|
|
class="w-full transition-all duration-150 ease-out"
|
|
:class="isGathering ? 'scale-[0.975]' : 'scale-100'"
|
|
>
|
|
<ScreenshotDragPreview
|
|
:screenshot="activeDraggedScreenshot"
|
|
:count="activeDraggedScreenshots.length"
|
|
/>
|
|
</div>
|
|
</DragOverlay>
|
|
</div>
|
|
</Teleport>
|
|
</DragDropProvider>
|
|
<ScreenshotDragGather
|
|
v-if="gatherItems.length > 0"
|
|
:items="gatherItems"
|
|
:target="gatherTarget"
|
|
@complete="finishGather"
|
|
/>
|
|
</ReadyTransition>
|
|
</div>
|
|
|
|
<FloatingActionBar
|
|
:shown="selectionActive"
|
|
:aria-label="formatMessage(messages.selectionAriaLabel)"
|
|
hide-when-modal-open
|
|
>
|
|
<div class="flex items-center gap-0.5">
|
|
<span class="px-4 py-2.5 text-base font-semibold tabular-nums text-contrast">
|
|
{{ formatMessage(messages.selectedCount, { count: selectedKeys.size }) }}
|
|
</span>
|
|
<div class="mx-1 h-6 w-px bg-surface-5" />
|
|
<Button
|
|
v-tooltip="formatMessage(commonMessages.clearButton)"
|
|
type="quiet"
|
|
:aria-label="formatMessage(commonMessages.clearButton)"
|
|
:disabled="bulkBusy"
|
|
@click="clearSelection"
|
|
>
|
|
<XIcon class="hidden cq-show-icon" />
|
|
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
|
|
</Button>
|
|
</div>
|
|
<div class="ml-auto flex items-center gap-0.5">
|
|
<Button
|
|
v-tooltip="formatMessage(messages.newGroup)"
|
|
type="quiet"
|
|
:aria-label="formatMessage(messages.newGroup)"
|
|
:disabled="bulkBusy"
|
|
@click="createCustomGroup"
|
|
>
|
|
<SquarePlusIcon />
|
|
<span class="bar-label">{{ formatMessage(messages.newGroup) }}</span>
|
|
</Button>
|
|
<Button
|
|
v-if="selectedGroupedScreenshots.length > 0"
|
|
v-tooltip="formatMessage(messages.removeFromGroup)"
|
|
type="quiet"
|
|
:aria-label="formatMessage(messages.removeFromGroup)"
|
|
:disabled="bulkBusy"
|
|
@click="removeSelectedScreenshotsFromGroups"
|
|
>
|
|
<MinusIcon />
|
|
<span class="bar-label">{{ formatMessage(messages.removeFromGroup) }}</span>
|
|
</Button>
|
|
<Button
|
|
v-tooltip="formatMessage(messages.exportZip)"
|
|
type="quiet"
|
|
:aria-label="formatMessage(messages.exportZip)"
|
|
:disabled="bulkBusy"
|
|
@click="exportSelected"
|
|
>
|
|
<FileArchiveIcon />
|
|
<span class="bar-label">{{ formatMessage(messages.exportZip) }}</span>
|
|
</Button>
|
|
<div class="mx-1 h-6 w-px bg-surface-5" />
|
|
<Button
|
|
v-tooltip="formatMessage(commonMessages.deleteLabel)"
|
|
type="quiet"
|
|
color="red"
|
|
interaction="filled"
|
|
:aria-label="formatMessage(commonMessages.deleteLabel)"
|
|
:disabled="bulkBusy"
|
|
@click="bulkDeleteModal?.show()"
|
|
>
|
|
<TrashIcon />
|
|
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
|
</Button>
|
|
</div>
|
|
</FloatingActionBar>
|
|
</template>
|