mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
288 lines
8.4 KiB
Vue
288 lines
8.4 KiB
Vue
<script setup lang="ts">
|
|
import type { Labrinth } from '@modrinth/api-client'
|
|
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
|
import { computed, nextTick, ref } from 'vue'
|
|
|
|
import { ButtonStyled, Modal, StyledInput } from '#ui/components'
|
|
import { commonMessages } from '#ui/utils'
|
|
|
|
import { defineMessages, useVIntl } from '../../composables/i18n'
|
|
|
|
const props = defineProps<{
|
|
groupId: string
|
|
pending?: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'confirm', sha1s: string[]): void
|
|
}>()
|
|
|
|
const { formatMessage } = useVIntl()
|
|
|
|
const messages = defineMessages({
|
|
defaultGroupTitle: {
|
|
id: 'external-files.permissions-card.fallback-group-title',
|
|
defaultMessage: 'Attribution group {id}',
|
|
},
|
|
addFilesModalTitle: {
|
|
id: 'external-files.permissions-card.add-files-modal.title',
|
|
defaultMessage: 'Add files to this group',
|
|
},
|
|
addFilesModalDescription: {
|
|
id: 'external-files.permissions-card.add-files-modal.description',
|
|
defaultMessage: 'Select any files that should be moved into this group.',
|
|
},
|
|
addFilesModalEmpty: {
|
|
id: 'external-files.permissions-card.add-files-modal.empty',
|
|
defaultMessage: 'There are no files in other groups that can be moved here.',
|
|
},
|
|
addFilesModalSearchPlaceholder: {
|
|
id: 'external-files.permissions-card.add-files-modal.search-placeholder',
|
|
defaultMessage: 'Search files...',
|
|
},
|
|
addFilesModalNoSearchResults: {
|
|
id: 'external-files.permissions-card.add-files-modal.no-search-results',
|
|
defaultMessage: 'No files match your search.',
|
|
},
|
|
addFilesModalConfirm: {
|
|
id: 'external-files.permissions-card.add-files-modal.confirm',
|
|
defaultMessage: '{count, plural, one {Add file} other {Add files}}',
|
|
},
|
|
addFilesModalSelectedCount: {
|
|
id: 'external-files.permissions-card.add-files-modal.selected-count',
|
|
defaultMessage: '{count, plural, one {# file selected} other {# files selected}}',
|
|
},
|
|
})
|
|
|
|
type AssignableFileEntry = {
|
|
sha1: string
|
|
displayName: string
|
|
sourceLabel: string
|
|
}
|
|
|
|
const modalRef = ref<InstanceType<typeof Modal> | null>(null)
|
|
const assignableEntries = ref<AssignableFileEntry[]>([])
|
|
const searchQuery = ref('')
|
|
const searchInputRef = ref<{ focus: () => void } | null>(null)
|
|
const selectedSha1s = ref<Set<string>>(new Set())
|
|
const selectionAnchorSha1 = ref<string | null>(null)
|
|
|
|
const filteredEntries = computed(() => {
|
|
const q = searchQuery.value.trim().toLowerCase()
|
|
if (!q) {
|
|
return assignableEntries.value
|
|
}
|
|
return assignableEntries.value.filter(
|
|
(e) =>
|
|
e.displayName.toLowerCase().includes(q) ||
|
|
e.sourceLabel.toLowerCase().includes(q) ||
|
|
e.sha1.toLowerCase().includes(q),
|
|
)
|
|
})
|
|
|
|
const selectedFileCount = computed(() => selectedSha1s.value.size)
|
|
|
|
function isFileSelected(sha1: string) {
|
|
return selectedSha1s.value.has(sha1)
|
|
}
|
|
|
|
function toggleFileSelection(sha1: string, event: MouseEvent) {
|
|
const next = new Set(selectedSha1s.value)
|
|
const shouldSelect = !next.has(sha1)
|
|
const entryIndex = filteredEntries.value.findIndex((entry) => entry.sha1 === sha1)
|
|
const anchorIndex = filteredEntries.value.findIndex(
|
|
(entry) => entry.sha1 === selectionAnchorSha1.value,
|
|
)
|
|
|
|
const sha1sToToggle =
|
|
event.shiftKey && entryIndex !== -1 && anchorIndex !== -1
|
|
? filteredEntries.value
|
|
.slice(Math.min(entryIndex, anchorIndex), Math.max(entryIndex, anchorIndex) + 1)
|
|
.map((entry) => entry.sha1)
|
|
: [sha1]
|
|
|
|
for (const sha1ToToggle of sha1sToToggle) {
|
|
if (shouldSelect) {
|
|
next.add(sha1ToToggle)
|
|
} else {
|
|
next.delete(sha1ToToggle)
|
|
}
|
|
}
|
|
selectedSha1s.value = next
|
|
selectionAnchorSha1.value = sha1
|
|
}
|
|
|
|
function clearSelectedFiles() {
|
|
selectedSha1s.value = new Set()
|
|
selectionAnchorSha1.value = null
|
|
}
|
|
|
|
function focusSearchOnOpen() {
|
|
nextTick(() => {
|
|
setTimeout(() => {
|
|
searchInputRef.value?.focus()
|
|
}, 75)
|
|
})
|
|
}
|
|
|
|
function buildAssignableEntries(
|
|
groups: Labrinth.Attribution.Internal.AttributionGroup[],
|
|
): AssignableFileEntry[] {
|
|
const entries: AssignableFileEntry[] = []
|
|
for (const g of groups) {
|
|
if (g.id === props.groupId) {
|
|
continue
|
|
}
|
|
const firstName = g.files?.[0]?.name
|
|
const sourceLabel =
|
|
g.flame_project?.title ??
|
|
(firstName ? (firstName.split('/').pop() ?? firstName) : null) ??
|
|
formatMessage(messages.defaultGroupTitle, { id: g.id })
|
|
for (const f of g.files ?? []) {
|
|
const displayName = f.name.split('/').pop() ?? f.name
|
|
entries.push({ sha1: f.sha1, displayName, sourceLabel })
|
|
}
|
|
}
|
|
entries.sort((a, b) => {
|
|
const byName = a.displayName.localeCompare(b.displayName)
|
|
if (byName !== 0) {
|
|
return byName
|
|
}
|
|
return a.sourceLabel.localeCompare(b.sourceLabel)
|
|
})
|
|
return entries
|
|
}
|
|
|
|
function handleConfirm() {
|
|
if (selectedFileCount.value === 0) {
|
|
return
|
|
}
|
|
emit('confirm', [...selectedSha1s.value])
|
|
hide()
|
|
}
|
|
|
|
function show(event: MouseEvent, groups: Labrinth.Attribution.Internal.AttributionGroup[]) {
|
|
assignableEntries.value = buildAssignableEntries(groups)
|
|
searchQuery.value = ''
|
|
clearSelectedFiles()
|
|
modalRef.value?.show(event)
|
|
}
|
|
|
|
function hide() {
|
|
modalRef.value?.hide()
|
|
}
|
|
|
|
defineExpose({ show, hide })
|
|
</script>
|
|
|
|
<template>
|
|
<Modal
|
|
ref="modalRef"
|
|
:header="formatMessage(messages.addFilesModalTitle)"
|
|
max-width="560px"
|
|
:disable-close="pending"
|
|
:on-show="focusSearchOnOpen"
|
|
:on-hide="clearSelectedFiles"
|
|
no-padding
|
|
>
|
|
<div class="flex flex-col gap-4 p-4 relative">
|
|
<p class="text-secondary m-0">
|
|
{{ formatMessage(messages.addFilesModalDescription) }}
|
|
</p>
|
|
<StyledInput
|
|
ref="searchInputRef"
|
|
v-model="searchQuery"
|
|
type="text"
|
|
autocomplete="off"
|
|
:placeholder="formatMessage(messages.addFilesModalSearchPlaceholder)"
|
|
:icon="SearchIcon"
|
|
:disabled="pending"
|
|
input-class="h-[40px]"
|
|
class="sticky top-0"
|
|
clearable
|
|
/>
|
|
</div>
|
|
<div
|
|
class="flex flex-col gap-2 bg-surface-2 overflow-y-auto overflow-x-hidden border-0 border-y border-solid border-surface-5 max-h-[calc(max(50vh,600px))]"
|
|
>
|
|
<div v-if="assignableEntries.length === 0" class="text-secondary text-sm p-4 m-0">
|
|
{{ formatMessage(messages.addFilesModalEmpty) }}
|
|
</div>
|
|
<div v-else-if="filteredEntries.length === 0" class="text-secondary text-sm m-0 p-4">
|
|
{{ formatMessage(messages.addFilesModalNoSearchResults) }}
|
|
</div>
|
|
<ul v-else class="p-0 m-0 list-none">
|
|
<li
|
|
v-for="entry in filteredEntries"
|
|
:key="entry.sha1"
|
|
:class="
|
|
isFileSelected(entry.sha1)
|
|
? 'border-brand bg-surface-4 hover:bg-surface-5'
|
|
: 'hover:bg-surface-3 even:bg-surface-2.5'
|
|
"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="w-full text-left p-3 flex flex-col gap-1 appearance-none bg-transparent 3 transition-colors disabled:opacity-50"
|
|
:disabled="pending"
|
|
:aria-pressed="isFileSelected(entry.sha1)"
|
|
@click="toggleFileSelection(entry.sha1, $event)"
|
|
>
|
|
<span class="flex gap-2 font-medium">
|
|
<span
|
|
class="size-4 shrink-0 rounded flex items-center justify-center border-[1px] border-solid"
|
|
:class="
|
|
isFileSelected(entry.sha1)
|
|
? 'bg-brand border-button-border text-brand-inverted'
|
|
: 'bg-surface-2 border-surface-5'
|
|
"
|
|
>
|
|
<CheckIcon
|
|
v-if="isFileSelected(entry.sha1)"
|
|
class="size-3"
|
|
aria-hidden="true"
|
|
stroke-width="3"
|
|
/>
|
|
</span>
|
|
<span class="flex flex-col gap-1 truncate">
|
|
<span class="truncate">{{ entry.displayName }}</span>
|
|
<span
|
|
v-if="entry.sourceLabel !== entry.displayName"
|
|
class="text-secondary text-xs truncate"
|
|
>{{ entry.sourceLabel }}</span
|
|
>
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<template #actions>
|
|
<div class="flex justify-between items-center gap-3 w-full pt-4">
|
|
<p v-if="selectedFileCount > 0" class="text-secondary text-sm m-0">
|
|
{{ formatMessage(messages.addFilesModalSelectedCount, { count: selectedFileCount }) }}
|
|
</p>
|
|
<div class="flex gap-2 ml-auto">
|
|
<ButtonStyled type="outlined">
|
|
<button type="button" :disabled="pending" @click="hide">
|
|
<XIcon class="size-4 shrink-0" />
|
|
{{ formatMessage(commonMessages.cancelButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled color="brand">
|
|
<button
|
|
type="button"
|
|
:disabled="selectedFileCount === 0 || pending"
|
|
@click="handleConfirm"
|
|
>
|
|
<SpinnerIcon v-if="pending" class="size-4 shrink-0 animate-spin" />
|
|
<PlusIcon v-else class="size-4 shrink-0" />
|
|
{{ formatMessage(messages.addFilesModalConfirm, { count: selectedFileCount }) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</Modal>
|
|
</template>
|