mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 18:14:49 +00:00
feat: new modpack permissions system (#6005)
* Begin external projects moderator database frontend * add copy link button * begin project page permissions settings * MEL database backend routes * include filename in external files * wip: when uploading a version file, fetch its overrides as a list * wip: override license checks * improve FileHost ref counting * file host read capability * scan files when inserting version file * add dependency sha1 field * clean up version files * wip: attributions * update s3 file host * attribution scanning basic works * works * insert attribution info after resolving * add routes * remove dep sha1 stuff * prepr * wip: override file sources * add files_missing_attributions to versions * return extended version info + attributed at/by * hook up frontend to backend (mostly) * expose version date published * withholding version visibility * frontend work * prepr * use api-client for img upload * moar frontend * prepr * Add schema to attribution resolution and Flame project results * sqlx prepare * changes * remove feature flag, fix optional proof images * fix schema * fmt * fix deletion and file fetch * prepare * fix admonition * update frontend stuff to new schema * prepr * attribution on dependencies * fixes * sqlx prepare * fixes * routes * fix routes * Version grandfathering * prepare * wip: bulk routes * pushing what i've got rn * include link in NoPermission * change hash insert to bulk route * query flame even if entry in MEL * delete file with weird name * Prioritise putting override files in existing groups even with ExternalLicense * fix how hex bytes are handled in route * feat: coolbot moderation changes (#6215) * Update moderator checklist * move permissions stage order * Updated nagContext.versions to v3, added nag for permissions * Update permissions.vue default messages * prepr --------- Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com> * QA * prepr * should group by project * return attribution resolution correctly * updated by moderator info * Track what moderator reviewed an attribution moderation status * default deser FMA field * new version page * clean up fetching + add a couple missing features * qa items * prepr * provide moderation package stuff with DI * format? * don't redact moderated_at * move supplementary resources * Reorganize moderation messages. * Quick replies for external content permissions. * prepare * QA * allow exempting projects * Ignore Flame projects which 404 * fix ci * fix cross project attribution stuff * Fix permission error * change what files get cscanned * add more logging * QA Jun 22 * fix * idempotency * Expose route for rescanning * update blog link --------- Co-authored-by: aecsocket <aecsocket@tutanota.com> Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com> Co-authored-by: aecsocket <43144841+aecsocket@users.noreply.github.com>
This commit is contained in:
co-authored by
coolbot100s
aecsocket
aecsocket
parent
a686a93858
commit
e7926083fb
@@ -0,0 +1,269 @@
|
||||
<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, NewModal, 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 NewModal> | null>(null)
|
||||
const assignableEntries = ref<AssignableFileEntry[]>([])
|
||||
const searchQuery = ref('')
|
||||
const searchInputRef = ref<{ focus: () => void } | null>(null)
|
||||
const selectedSha1s = ref<Set<string>>(new Set())
|
||||
|
||||
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) {
|
||||
const next = new Set(selectedSha1s.value)
|
||||
if (next.has(sha1)) {
|
||||
next.delete(sha1)
|
||||
} else {
|
||||
next.add(sha1)
|
||||
}
|
||||
selectedSha1s.value = next
|
||||
}
|
||||
|
||||
function clearSelectedFiles() {
|
||||
selectedSha1s.value = new Set()
|
||||
}
|
||||
|
||||
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>
|
||||
<NewModal
|
||||
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)"
|
||||
>
|
||||
<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>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { useMutation } from '@tanstack/vue-query'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { Accordion, ButtonStyled, NewModal, StyledInput } from '#ui/components'
|
||||
|
||||
import { injectModrinthClient, injectNotificationManager } from '../../providers'
|
||||
import AttributionGroupFilePicker from './AttributionGroupFilePicker.vue'
|
||||
import {
|
||||
MODERATOR_ATTRIBUTION_KIND_LABELS,
|
||||
moderatorAttributionGroupTitle,
|
||||
parseInitialAttribution,
|
||||
} from './external-project-utils'
|
||||
import ExternalProjectLookupCard from './ExternalProjectLookupCard.vue'
|
||||
import type { ExternalLicenseStatus } from './types.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
group: Labrinth.Attribution.Internal.AttributionGroup
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
type ExternalProject = {
|
||||
id: number
|
||||
title: string | null
|
||||
status: ExternalLicenseStatus
|
||||
link: string | null
|
||||
exceptions: string | null
|
||||
proof: string | null
|
||||
flame_project_id: number | null
|
||||
files: {
|
||||
sha1: string
|
||||
name: string | null
|
||||
}[]
|
||||
}
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
|
||||
const searchAccordionRef = useTemplateRef<InstanceType<typeof Accordion>>('searchAccordionRef')
|
||||
const filesAccordionRef = useTemplateRef<InstanceType<typeof Accordion>>('filesAccordionRef')
|
||||
|
||||
const query = ref('')
|
||||
const isLoading = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
const activeQuery = ref('')
|
||||
const externalProjects = ref<ExternalProject[]>([])
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
const selectedSha1s = ref<Set<string>>(new Set())
|
||||
|
||||
const hasMultipleFiles = computed(() => props.group.files.length > 1)
|
||||
|
||||
const groupPreviewTitle = computed(() => moderatorAttributionGroupTitle(props.group))
|
||||
|
||||
const attributionKindLabel = computed(() => {
|
||||
const attribution = parseInitialAttribution(props.group.attribution)
|
||||
if (!attribution) {
|
||||
return null
|
||||
}
|
||||
return `Attribution: ${MODERATOR_ATTRIBUTION_KIND_LABELS[attribution.kind]}`
|
||||
})
|
||||
|
||||
function mapExternalProject(
|
||||
project: Labrinth.ExternalProjects.Internal.ExternalProject,
|
||||
): ExternalProject {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
status: project.status,
|
||||
link: project.link,
|
||||
exceptions: project.exceptions,
|
||||
proof: project.proof,
|
||||
flame_project_id: project.flame_project_id,
|
||||
files: project.linked_files ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
const searchNeedsInput = computed(() => hasSearched.value && activeQuery.value.trim().length < 3)
|
||||
|
||||
const addFilesMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (selectedProjectId.value === null) {
|
||||
throw new Error('No external project selected')
|
||||
}
|
||||
|
||||
await client.labrinth.external_projects_internal.addFile({
|
||||
hashes: [...selectedSha1s.value],
|
||||
license_id: selectedProjectId.value,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
selectedProjectId.value !== null &&
|
||||
selectedSha1s.value.size > 0 &&
|
||||
!addFilesMutation.isPending.value,
|
||||
)
|
||||
|
||||
function resetForm() {
|
||||
query.value = ''
|
||||
hasSearched.value = false
|
||||
activeQuery.value = ''
|
||||
externalProjects.value = []
|
||||
selectedProjectId.value = null
|
||||
selectedSha1s.value = new Set(props.group.files.map((file) => file.sha1))
|
||||
}
|
||||
|
||||
function handleSearchPanelOpen() {
|
||||
filesAccordionRef.value?.close()
|
||||
}
|
||||
|
||||
function handleFilesPanelOpen() {
|
||||
searchAccordionRef.value?.close()
|
||||
}
|
||||
|
||||
function selectProject(projectId: number) {
|
||||
selectedProjectId.value = projectId
|
||||
}
|
||||
|
||||
async function executeSearch() {
|
||||
hasSearched.value = true
|
||||
activeQuery.value = query.value
|
||||
externalProjects.value = []
|
||||
selectedProjectId.value = null
|
||||
|
||||
if (activeQuery.value.trim().length < 3) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await client.labrinth.external_projects_internal.search({
|
||||
title: activeQuery.value.trim(),
|
||||
})
|
||||
externalProjects.value = response.map(mapExternalProject)
|
||||
} catch {
|
||||
externalProjects.value = []
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await addFilesMutation.mutateAsync()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Files added to existing entry',
|
||||
autoCloseMs: 3000,
|
||||
})
|
||||
hide()
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Could not add files to existing entry',
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
resetForm()
|
||||
modalRef.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modalRef.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modalRef"
|
||||
header="Add to existing entry"
|
||||
max-width="720px"
|
||||
:disable-close="addFilesMutation.isPending.value"
|
||||
>
|
||||
<div class="flex flex-col gap-4 w-[650px]">
|
||||
<div
|
||||
class="rounded-xl bg-surface-3 border border-solid border-surface-5 p-3 flex flex-col gap-1 shrink-0"
|
||||
>
|
||||
<span class="font-semibold text-contrast">{{ groupPreviewTitle }}</span>
|
||||
<span v-if="attributionKindLabel" class="text-secondary text-sm">{{
|
||||
attributionKindLabel
|
||||
}}</span>
|
||||
</div>
|
||||
<Accordion
|
||||
ref="searchAccordionRef"
|
||||
class="w-full bg-surface-4 border border-solid border-surface-5 rounded-2xl overflow-clip"
|
||||
button-class="p-4 w-full border-b border-solid border-b-surface-5 bg-surface-2 -mb-px hover:brightness-[--hover-brightness] group"
|
||||
open-by-default
|
||||
@on-open="handleSearchPanelOpen"
|
||||
>
|
||||
<template #title>
|
||||
<span class="flex items-center gap-3 text-contrast group-active:scale-[0.98]">
|
||||
Select an external project
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col bg-surface-4 min-h-0">
|
||||
<form
|
||||
class="flex flex-wrap gap-2 shrink-0 p-4 bg-surface-3 border-0 border-b border-solid border-surface-5"
|
||||
@submit.prevent="executeSearch"
|
||||
>
|
||||
<StyledInput
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Search external projects…"
|
||||
clearable
|
||||
wrapper-class="flex-1 min-w-[12rem]"
|
||||
:disabled="addFilesMutation.isPending.value"
|
||||
/>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="submit" :disabled="addFilesMutation.isPending.value">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
Search
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</form>
|
||||
<div
|
||||
class="flex flex-col min-h-0 max-h-[min(50vh,24rem)] overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<div
|
||||
v-if="searchNeedsInput || isLoading || externalProjects.length === 0"
|
||||
class="p-8 flex flex-col gap-2 items-center justify-center"
|
||||
>
|
||||
<span class="text-contrast font-semibold">
|
||||
<template v-if="searchNeedsInput"> Enter a search term to get started </template>
|
||||
<template v-else-if="isLoading"> Loading external projects… </template>
|
||||
<template v-else> No projects matched that search </template>
|
||||
</span>
|
||||
<span class="text-secondary text-sm">
|
||||
<template v-if="searchNeedsInput">
|
||||
Type at least 3 characters of a project's title to begin browsing.
|
||||
</template>
|
||||
<template v-else-if="isLoading"> Loading external projects… </template>
|
||||
<template v-else> No projects matched that search </template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="externalProjects.length > 0" class="flex flex-col gap-3">
|
||||
<ExternalProjectLookupCard
|
||||
v-for="project in externalProjects"
|
||||
:key="project.id"
|
||||
:title="project.title"
|
||||
:state="project.status"
|
||||
:link="project.link"
|
||||
:notes="project.exceptions"
|
||||
:proof="project.proof"
|
||||
:files="project.files"
|
||||
:cf_id="project.flame_project_id"
|
||||
class="mx-4 mt-3"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="
|
||||
selectedProjectId === project.id || addFilesMutation.isPending.value
|
||||
"
|
||||
@click="selectProject(project.id)"
|
||||
>
|
||||
{{ selectedProjectId === project.id ? 'Selected' : 'Select' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ExternalProjectLookupCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
<Accordion
|
||||
v-if="hasMultipleFiles"
|
||||
ref="filesAccordionRef"
|
||||
class="w-full bg-surface-4 border border-solid border-surface-5 rounded-2xl overflow-clip"
|
||||
button-class="p-4 w-full border-b border-solid border-b-surface-5 bg-surface-2 -mb-px hover:brightness-[--hover-brightness] group"
|
||||
@on-open="handleFilesPanelOpen"
|
||||
>
|
||||
<template #title>
|
||||
<span class="flex items-center gap-3 text-contrast group-active:scale-[0.98]">
|
||||
Select files to add
|
||||
</span>
|
||||
</template>
|
||||
<AttributionGroupFilePicker
|
||||
v-model:selected-sha1s="selectedSha1s"
|
||||
:files="group.files"
|
||||
:disabled="addFilesMutation.isPending.value"
|
||||
/>
|
||||
</Accordion>
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" :disabled="addFilesMutation.isPending.value" @click="hide">
|
||||
<XIcon class="size-4 shrink-0" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" :disabled="!canSubmit" @click="handleSubmit">
|
||||
<SpinnerIcon
|
||||
v-if="addFilesMutation.isPending.value"
|
||||
class="size-4 shrink-0 animate-spin"
|
||||
/>
|
||||
<PlusIcon v-else class="size-4 shrink-0" />
|
||||
Add files to entry
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { PlusIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { useMutation } from '@tanstack/vue-query'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
} from '#ui/components'
|
||||
|
||||
import { injectModrinthClient, injectNotificationManager } from '../../providers'
|
||||
import AttributionGroupFilePicker from './AttributionGroupFilePicker.vue'
|
||||
import {
|
||||
attributionKindToDefaultExternalStatus,
|
||||
buildExternalLicenseProofFromAttribution,
|
||||
groupLinkForExternalLicense,
|
||||
moderatorAttributionGroupTitle,
|
||||
parseInitialAttribution,
|
||||
} from './external-project-utils'
|
||||
import type { ExternalLicenseStatus } from './types.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
group: Labrinth.Attribution.Internal.AttributionGroup
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
|
||||
|
||||
const statusOptions: ComboboxOption<ExternalLicenseStatus>[] = [
|
||||
{ value: 'yes', label: 'Yes' },
|
||||
{ value: 'with-attribution-and-source', label: 'With attribution and source' },
|
||||
{ value: 'with-attribution', label: 'With attribution' },
|
||||
{ value: 'no', label: 'No' },
|
||||
{ value: 'permanent-no', label: 'Permanent no' },
|
||||
{ value: 'unidentified', label: 'Unidentified' },
|
||||
]
|
||||
|
||||
const title = ref('')
|
||||
const link = ref('')
|
||||
const flameProjectId = ref('')
|
||||
const proof = ref('')
|
||||
const status = ref<ExternalLicenseStatus | undefined>(undefined)
|
||||
const selectedSha1s = ref<Set<string>>(new Set())
|
||||
|
||||
const hasMultipleFiles = computed(() => props.group.files.length > 1)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!status.value) {
|
||||
throw new Error('Status is required')
|
||||
}
|
||||
|
||||
const parsedFlameProjectId = Number.parseInt(flameProjectId.value.trim(), 10)
|
||||
const hasFlameId = Number.isFinite(parsedFlameProjectId)
|
||||
const trimmedTitle = title.value.trim()
|
||||
const trimmedLink = link.value.trim()
|
||||
const trimmedProof = proof.value.trim()
|
||||
const judgements: Labrinth.Moderation.Internal.ProjectJudgements = {}
|
||||
|
||||
for (const sha1 of selectedSha1s.value) {
|
||||
if (hasFlameId) {
|
||||
judgements[sha1] = {
|
||||
type: 'flame',
|
||||
id: parsedFlameProjectId,
|
||||
status: status.value,
|
||||
link:
|
||||
trimmedLink || `https://www.curseforge.com/minecraft/mc-mods/${parsedFlameProjectId}`,
|
||||
title: trimmedTitle || moderatorAttributionGroupTitle(props.group),
|
||||
}
|
||||
} else {
|
||||
judgements[sha1] = {
|
||||
type: 'unknown',
|
||||
status: status.value,
|
||||
proof: trimmedProof || undefined,
|
||||
link: trimmedLink || undefined,
|
||||
title: trimmedTitle || undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return client.labrinth.moderation_internal.setProjectJudgements(judgements)
|
||||
},
|
||||
})
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
selectedSha1s.value.size > 0 && status.value !== undefined && !createMutation.isPending.value,
|
||||
)
|
||||
|
||||
function resetForm() {
|
||||
const attribution = parseInitialAttribution(props.group.attribution)
|
||||
title.value = moderatorAttributionGroupTitle(props.group)
|
||||
link.value = groupLinkForExternalLicense(props.group, attribution)
|
||||
flameProjectId.value = props.group.flame_project?.id?.toString() ?? ''
|
||||
proof.value = attribution ? buildExternalLicenseProofFromAttribution(attribution) : ''
|
||||
status.value = attribution ? attributionKindToDefaultExternalStatus(attribution.kind) : undefined
|
||||
selectedSha1s.value = new Set(props.group.files.map((file) => file.sha1))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await createMutation.mutateAsync()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Added to global database',
|
||||
autoCloseMs: 3000,
|
||||
})
|
||||
hide()
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Could not add to global database',
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
resetForm()
|
||||
modalRef.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modalRef.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modalRef"
|
||||
header="Adding to global permissions database"
|
||||
max-width="640px"
|
||||
:disable-close="createMutation.isPending.value"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-semibold text-contrast" for="add-global-title">Title</label>
|
||||
<StyledInput
|
||||
id="add-global-title"
|
||||
v-model="title"
|
||||
type="text"
|
||||
:disabled="createMutation.isPending.value"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-semibold text-contrast" for="add-global-link">Link</label>
|
||||
<StyledInput
|
||||
id="add-global-link"
|
||||
v-model="link"
|
||||
type="text"
|
||||
:disabled="createMutation.isPending.value"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-semibold text-contrast" for="add-global-flame-id">
|
||||
CurseForge project ID
|
||||
</label>
|
||||
<StyledInput
|
||||
id="add-global-flame-id"
|
||||
v-model="flameProjectId"
|
||||
type="text"
|
||||
placeholder="1234567"
|
||||
input-class="h-[40px]"
|
||||
:disabled="createMutation.isPending.value"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-semibold text-contrast" for="add-global-status">Allowed?</label>
|
||||
<Combobox
|
||||
id="add-global-status"
|
||||
v-model="status"
|
||||
:options="statusOptions"
|
||||
placeholder="Select status"
|
||||
class="!w-full min-w-[18rem]"
|
||||
:disabled="createMutation.isPending.value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-semibold text-contrast" for="add-global-proof">Proof or notes</label>
|
||||
<StyledInput
|
||||
id="add-global-proof"
|
||||
v-model="proof"
|
||||
type="text"
|
||||
multiline
|
||||
input-class="min-h-[6rem]"
|
||||
resize="vertical"
|
||||
:disabled="createMutation.isPending.value"
|
||||
/>
|
||||
</div>
|
||||
<Accordion
|
||||
v-if="hasMultipleFiles"
|
||||
class="w-full bg-surface-4 border border-solid border-surface-5 rounded-2xl overflow-clip"
|
||||
button-class="p-4 w-full border-b border-solid border-b-surface-5 bg-surface-2 -mb-px hover:brightness-[--hover-brightness] group"
|
||||
open-by-default
|
||||
>
|
||||
<template #title>
|
||||
<span class="flex items-center gap-3 text-contrast group-active:scale-[0.98]">
|
||||
Select files to add
|
||||
</span>
|
||||
</template>
|
||||
<div>
|
||||
<AttributionGroupFilePicker
|
||||
v-model:selected-sha1s="selectedSha1s"
|
||||
:files="group.files"
|
||||
:disabled="createMutation.isPending.value"
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" :disabled="createMutation.isPending.value" @click="hide">
|
||||
<XIcon class="size-4 shrink-0" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" :disabled="!canSubmit" @click="handleSubmit">
|
||||
<SpinnerIcon
|
||||
v-if="createMutation.isPending.value"
|
||||
class="size-4 shrink-0 animate-spin"
|
||||
/>
|
||||
<PlusIcon v-else class="size-4 shrink-0" />
|
||||
Add to global database
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { CheckCircleIcon, ScaleIcon, UserRoundIcon, XCircleIcon } from '@modrinth/assets'
|
||||
import { builtinLicenses } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { IntlFormatted } from '#ui/components'
|
||||
import { AutoLink, Avatar } from '#ui/components/base'
|
||||
|
||||
import { useFormatDateTime } from '../../composables/format-date-time'
|
||||
import { defineMessage, defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import type { ProjectPermissionField } from './external-project-utils'
|
||||
import {
|
||||
attributionLinkToWork,
|
||||
isAutomaticNoPermissionAttribution,
|
||||
isCustomAttributionLicense,
|
||||
isHttpUrl,
|
||||
PERMISSION_REASONS,
|
||||
} from './external-project-utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
attribution: Labrinth.Attribution.Internal.AttributionResolution
|
||||
attributedAt?: string | null
|
||||
attributedBy?: string | null
|
||||
attributorHref: string | null
|
||||
attributorLabel: string
|
||||
attributorAvatarUrl?: string | null
|
||||
moderator?: boolean
|
||||
}>(),
|
||||
{
|
||||
moderator: false,
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatDate = useFormatDateTime({ dateStyle: 'long' })
|
||||
|
||||
const messages = defineMessages({
|
||||
linkLabel: {
|
||||
id: 'external-files.permissions-card.link-label',
|
||||
defaultMessage: 'Link to work:',
|
||||
},
|
||||
notesLabel: {
|
||||
id: 'external-files.permissions-card.notes-label',
|
||||
defaultMessage: 'Notes:',
|
||||
},
|
||||
licensedAs: {
|
||||
id: 'external-files.permissions-card.licensed-as',
|
||||
defaultMessage: 'Licensed:',
|
||||
},
|
||||
lastUpdated: {
|
||||
id: 'external-files.permissions-card.last-updated',
|
||||
defaultMessage: 'Last updated on {date} by {user}',
|
||||
},
|
||||
proofImagesLabel: {
|
||||
id: 'external-files.permissions-card.proof-images-label',
|
||||
defaultMessage: 'Proof images:',
|
||||
},
|
||||
proofImageThumbnailAlt: {
|
||||
id: 'external-files.permissions-card.proof-image-alt',
|
||||
defaultMessage: 'Proof screenshot {n}',
|
||||
},
|
||||
updatedByModerator: {
|
||||
id: 'external-files.permissions-card.updated-by-moderator',
|
||||
defaultMessage: 'Moderator',
|
||||
},
|
||||
})
|
||||
|
||||
const unknownLicenseMessage = defineMessage({
|
||||
id: 'external-files.permissions-card.license.unknown',
|
||||
defaultMessage: 'Unknown',
|
||||
})
|
||||
|
||||
const notesNoneMessage = defineMessage({
|
||||
id: 'external-files.permissions-card.notes-none',
|
||||
defaultMessage: 'None',
|
||||
})
|
||||
|
||||
const automaticNoPermission = computed(() =>
|
||||
isAutomaticNoPermissionAttribution(props.attribution, props.attributedBy),
|
||||
)
|
||||
|
||||
const readViewFields = computed(() => {
|
||||
if (automaticNoPermission.value) {
|
||||
return ['link_to_work'] as ProjectPermissionField[]
|
||||
}
|
||||
return PERMISSION_REASONS[props.attribution.kind]?.fields ?? []
|
||||
})
|
||||
|
||||
const automaticAttributionDescription = computed(() => {
|
||||
if (props.attribution.kind === 'globally_allowed') {
|
||||
return PERMISSION_REASONS.globally_allowed.description
|
||||
}
|
||||
if (automaticNoPermission.value) {
|
||||
return PERMISSION_REASONS.no_permission.automaticDescription ?? null
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const licenseReadDisplay = computed(() => {
|
||||
const attr = props.attribution
|
||||
if (attr.kind !== 'license' && attr.kind !== 'my_project') {
|
||||
return null
|
||||
}
|
||||
if (isCustomAttributionLicense(attr.license)) {
|
||||
return { kind: 'custom' as const, value: attr.license.name }
|
||||
}
|
||||
const licenseId = attr.license
|
||||
if (licenseId) {
|
||||
const friendly =
|
||||
builtinLicenses.find((license) => license.short === licenseId)?.friendly ?? licenseId
|
||||
return { kind: 'standard' as const, value: friendly }
|
||||
}
|
||||
return { kind: 'unknown' as const, value: formatMessage(unknownLicenseMessage) }
|
||||
})
|
||||
|
||||
const linkToWork = computed(() => attributionLinkToWork(props.attribution))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="flex flex-col gap-4 rounded-t-2xl p-4 mt-2 bg-surface-3 border border-solid border-surface-4"
|
||||
:class="{ 'rounded-b-2xl': !$slots.footer, 'border-b-0': $slots.footer }"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="text-contrast font-semibold flex items-center gap-2">
|
||||
<CheckCircleIcon
|
||||
v-if="attribution.kind === 'globally_allowed'"
|
||||
class="text-green size-5"
|
||||
/>
|
||||
<XCircleIcon v-else-if="automaticNoPermission" class="text-red size-5" />
|
||||
{{ formatMessage(PERMISSION_REASONS[attribution.kind].label) }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="automaticAttributionDescription">
|
||||
<p class="m-0">
|
||||
{{ formatMessage(automaticAttributionDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
<div
|
||||
v-if="!(readViewFields.includes('link_to_work') && !linkToWork)"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<div class="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 items-baseline">
|
||||
<template v-if="attribution.kind === 'license' || attribution.kind === 'my_project'">
|
||||
<span class="text-secondary font-medium">
|
||||
{{ formatMessage(messages.licensedAs) }}
|
||||
</span>
|
||||
<a
|
||||
v-if="
|
||||
licenseReadDisplay?.kind === 'custom' && isHttpUrl(licenseReadDisplay.value)
|
||||
"
|
||||
:href="licenseReadDisplay.value"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-link truncate"
|
||||
>
|
||||
{{ licenseReadDisplay.value }}
|
||||
</a>
|
||||
<span v-else class="text-primary whitespace-pre-wrap break-words">
|
||||
{{ licenseReadDisplay?.value }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="readViewFields.includes('link_to_work') && linkToWork">
|
||||
<span class="text-secondary font-medium">
|
||||
{{ formatMessage(messages.linkLabel) }}
|
||||
</span>
|
||||
<a :href="linkToWork" target="_blank" rel="noopener" class="text-link truncate">{{
|
||||
linkToWork
|
||||
}}</a>
|
||||
</template>
|
||||
<template v-if="readViewFields.includes('notes')">
|
||||
<span class="text-secondary font-medium">
|
||||
{{ formatMessage(messages.notesLabel) }}
|
||||
</span>
|
||||
<span class="text-primary whitespace-pre-wrap break-words">
|
||||
{{
|
||||
attribution.notes?.trim() ? attribution.notes : formatMessage(notesNoneMessage)
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="attribution.image_urls?.length" class="flex flex-col gap-2">
|
||||
<span class="text-secondary font-medium">
|
||||
{{ formatMessage(messages.proofImagesLabel) }}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a
|
||||
v-for="(src, idx) in attribution.image_urls"
|
||||
:key="`${src}-${idx}`"
|
||||
:href="src"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="block rounded-xl border-[1px] border-solid border-surface-5 overflow-hidden shrink-0"
|
||||
>
|
||||
<img
|
||||
:src="src"
|
||||
:alt="formatMessage(messages.proofImageThumbnailAlt, { n: idx + 1 })"
|
||||
class="max-h-40 max-w-full object-contain"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="attributedAt"
|
||||
class="inline-flex items-center flex-wrap gap-x-2 gap-y-1 pt-3 mt-1 border-0 border-t border-solid border-surface-5"
|
||||
>
|
||||
<IntlFormatted
|
||||
:message-id="messages.lastUpdated"
|
||||
:values="{ date: formatDate(attributedAt) }"
|
||||
>
|
||||
<template #user>
|
||||
<span
|
||||
v-if="!moderator && attribution.updated_by_moderator"
|
||||
class="text-orange flex items-center gap-1"
|
||||
>
|
||||
<ScaleIcon class="size-4 shrink-0" />
|
||||
{{ formatMessage(messages.updatedByModerator) }}
|
||||
</span>
|
||||
<AutoLink
|
||||
v-if="attributedBy && moderator"
|
||||
:to="attributorHref"
|
||||
class="inline-flex items-center gap-1.5 text-primary font-medium hover:underline max-w-full min-w-0"
|
||||
>
|
||||
<Avatar
|
||||
v-if="attributorAvatarUrl"
|
||||
:src="attributorAvatarUrl"
|
||||
:alt="attributorLabel"
|
||||
size="18px"
|
||||
class="shrink-0"
|
||||
circle
|
||||
/>
|
||||
<UserRoundIcon v-else class="size-4 shrink-0" />
|
||||
<span class="truncate">{{ attributorLabel }}</span>
|
||||
</AutoLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="p-4 border-surface-4 border bg-surface-2 border-solid rounded-b-2xl"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,661 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckIcon,
|
||||
InfoIcon,
|
||||
IssuesIcon,
|
||||
SaveIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { builtinLicenses } from '@modrinth/utils'
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { ButtonStyled, Chips, Combobox, type ComboboxOption, StyledInput } from '#ui/components'
|
||||
import { FileInput } from '#ui/components/base'
|
||||
import { commonMessages } from '#ui/utils'
|
||||
|
||||
import { defineMessage, defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { injectModrinthClient } from '../../providers'
|
||||
import {
|
||||
attributionLinkToWork,
|
||||
attributionProofValidationError,
|
||||
CUSTOM_LICENSE_VALUE,
|
||||
isHttpUrl,
|
||||
parseAttributionLicense,
|
||||
parseInitialAttribution,
|
||||
PERMISSION_REASONS,
|
||||
permissionKinds,
|
||||
type ProjectPermissionField,
|
||||
} from './external-project-utils'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
groupId: string
|
||||
attribution?: Labrinth.Attribution.Internal.AttributionResolution | null | undefined
|
||||
flameProjectUrl?: string | null
|
||||
/** Increments when the parent resumes editing so local fields reset */
|
||||
resumeKey: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'saved' | 'updated' | 'cancel'): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const initialAttribution = computed<Labrinth.Attribution.Internal.AttributionResolution | null>(
|
||||
() => parseInitialAttribution(props.attribution),
|
||||
)
|
||||
|
||||
const isAttributed = computed(() => initialAttribution.value !== null)
|
||||
|
||||
const messages = defineMessages({
|
||||
typeLabel: {
|
||||
id: 'external-files.permissions-card.editor.type-label',
|
||||
defaultMessage: 'Permission reason',
|
||||
},
|
||||
licenseLabel: {
|
||||
id: 'external-files.permissions-card.editor.license-label',
|
||||
defaultMessage: 'License',
|
||||
},
|
||||
selectLicenseLabel: {
|
||||
id: 'external-files.permissions-card.editor.select-license-label',
|
||||
defaultMessage: 'Select a license...',
|
||||
},
|
||||
linkLabel: {
|
||||
id: 'external-files.permissions-card.editor.link-label',
|
||||
defaultMessage: 'Link to work',
|
||||
},
|
||||
linkToWorkUrlPlaceholder: {
|
||||
id: 'external-files.permissions-card.editor.link-to-work-url-placeholder',
|
||||
defaultMessage: 'link-to-work',
|
||||
},
|
||||
notesLabel: {
|
||||
id: 'external-files.permissions-card.editor.notes-label',
|
||||
defaultMessage: 'Notes',
|
||||
},
|
||||
optional: {
|
||||
id: 'external-files.permissions-card.editor.input-optional',
|
||||
defaultMessage: '(optional)',
|
||||
},
|
||||
notesPlaceholder: {
|
||||
id: 'external-files.permissions-card.editor.notes-placeholder',
|
||||
defaultMessage: 'Write something here...',
|
||||
},
|
||||
proofWarningTitle: {
|
||||
id: 'external-files.permissions-card.editor.proof-warning.title',
|
||||
defaultMessage: 'Modrinth staff may verify submitted proof',
|
||||
},
|
||||
proofWarningBody: {
|
||||
id: 'external-files.permissions-card.editor.proof-warning.body',
|
||||
defaultMessage:
|
||||
'If you are found to have lied or manipulated the images uploaded, your project and account may be terminated.',
|
||||
},
|
||||
saveAttribution: {
|
||||
id: 'external-files.permissions-card.editor.save',
|
||||
defaultMessage: 'Save attribution',
|
||||
},
|
||||
addAttribution: {
|
||||
id: 'external-files.permissions-card.editor.add',
|
||||
defaultMessage: 'Add attribution',
|
||||
},
|
||||
licenseRequired: {
|
||||
id: 'external-files.permissions-card.editor.error.license-required',
|
||||
defaultMessage: 'Please select a license.',
|
||||
},
|
||||
customLicenseLabel: {
|
||||
id: 'external-files.permissions-card.editor.custom-license-label',
|
||||
defaultMessage: 'Link to license',
|
||||
},
|
||||
customLicenseMyProjectLabel: {
|
||||
id: 'external-files.permissions-card.editor.custom-license-my-project-label',
|
||||
defaultMessage: 'License name, preferably a SPDX identifier',
|
||||
},
|
||||
linkToLicenseUrlPlaceholder: {
|
||||
id: 'external-files.permissions-card.editor.link-to-license-url-placeholder',
|
||||
defaultMessage: 'link-to-license',
|
||||
},
|
||||
linkInvalidUrl: {
|
||||
id: 'external-files.permissions-card.editor.error.link-invalid-url',
|
||||
defaultMessage: 'Link must be a valid URL.',
|
||||
},
|
||||
proofImagesLabel: {
|
||||
id: 'external-files.permissions-card.editor.proof-images-label',
|
||||
defaultMessage: 'Proof images',
|
||||
},
|
||||
proofImagesUploadPrompt: {
|
||||
id: 'external-files.permissions-card.editor.proof-images-upload-prompt',
|
||||
defaultMessage: 'Drag and drop to upload or click to select an image',
|
||||
},
|
||||
proofImageThumbnailAlt: {
|
||||
id: 'external-files.permissions-card.editor.proof-image-alt',
|
||||
defaultMessage: 'Proof screenshot {n}',
|
||||
},
|
||||
proofImageRemove: {
|
||||
id: 'external-files.permissions-card.editor.proof-image-remove',
|
||||
defaultMessage: 'Remove image',
|
||||
},
|
||||
modrinthLinkToWork: {
|
||||
id: 'external-files.permissions-card.editor.modrinth-link-to-work',
|
||||
defaultMessage: `This appears to be a Modrinth link. If this content is available on Modrinth, your pack was likely exported incorrectly. If you downloaded it from another site, try downloading the Modrinth version instead; sometimes they are not identical files.`,
|
||||
},
|
||||
arrLabel: {
|
||||
id: 'external-files.permissions-card.editor.all-rights-reserved',
|
||||
defaultMessage: `All Rights Reserved/No license`,
|
||||
},
|
||||
exampleSpdxLicense: {
|
||||
id: 'external-files.permissions-card.editor.example-spdx-license',
|
||||
defaultMessage: 'e.g. MPL-1.1',
|
||||
},
|
||||
})
|
||||
|
||||
const MAX_PROOF_IMAGE_BYTES = 1_048_576
|
||||
|
||||
const selectedKind = ref<Labrinth.Attribution.Internal.AttributionResolutionKind>(
|
||||
initialAttribution.value?.kind ?? 'license',
|
||||
)
|
||||
|
||||
const licenseIdInput = ref('')
|
||||
const customLicenseInput = ref('')
|
||||
const linkInput = ref('')
|
||||
const notesInput = ref('')
|
||||
const inputError = ref<string | null>(null)
|
||||
const proofImageUrls = ref<string[]>([])
|
||||
|
||||
function extFromImageFile(file: File): Labrinth.Images.v3.ImageExtension | null {
|
||||
const byMime: Partial<Record<string, Labrinth.Images.v3.ImageExtension>> = {
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'image/bmp': 'bmp',
|
||||
'image/jpeg': 'jpg',
|
||||
}
|
||||
const mime = byMime[file.type]
|
||||
if (mime) {
|
||||
return mime
|
||||
}
|
||||
const ext = file.name.toLowerCase().split('.').pop()
|
||||
if (ext === 'jpg' || ext === 'jpeg') {
|
||||
return 'jpg'
|
||||
}
|
||||
if (ext === 'png' || ext === 'gif' || ext === 'webp' || ext === 'bmp') {
|
||||
return ext
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resetInputs() {
|
||||
const payload = initialAttribution.value
|
||||
selectedKind.value = payload?.kind ?? 'license'
|
||||
const license =
|
||||
payload && (payload.kind === 'license' || payload.kind === 'my_project')
|
||||
? parseAttributionLicense(payload.license)
|
||||
: { spdx: '', custom: '' }
|
||||
licenseIdInput.value = license.spdx
|
||||
customLicenseInput.value = license.custom
|
||||
const linkFallback = props.flameProjectUrl ?? ''
|
||||
linkInput.value = attributionLinkToWork(payload) ?? linkFallback
|
||||
notesInput.value = payload?.notes ?? ''
|
||||
proofImageUrls.value = payload?.image_urls ?? []
|
||||
inputError.value = null
|
||||
}
|
||||
|
||||
resetInputs()
|
||||
|
||||
watch(licenseIdInput, (value) => {
|
||||
if (value !== CUSTOM_LICENSE_VALUE) {
|
||||
customLicenseInput.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.attribution,
|
||||
() => {
|
||||
resetInputs()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.resumeKey,
|
||||
() => {
|
||||
resetInputs()
|
||||
},
|
||||
)
|
||||
|
||||
watch(selectedKind, () => {
|
||||
inputError.value = null
|
||||
})
|
||||
|
||||
const permissionReasonFields = computed<ProjectPermissionField[]>(() => {
|
||||
return PERMISSION_REASONS[selectedKind.value]?.fields ?? []
|
||||
})
|
||||
|
||||
const selectedPermissionReason = computed(() => PERMISSION_REASONS[selectedKind.value])
|
||||
|
||||
const notesAfterProofImages = computed(() => selectedKind.value === 'special_permissions')
|
||||
|
||||
const notesInputRows = computed(() => (notesAfterProofImages.value ? 2 : 3))
|
||||
|
||||
const proofImagesShowOptional = computed(() => {
|
||||
const reason = selectedPermissionReason.value
|
||||
return (
|
||||
reason.proofRequirement === null ||
|
||||
(reason.proofRequirement === 'explanation_or_images' && reason.notesShowsOptional)
|
||||
)
|
||||
})
|
||||
|
||||
const attributionFieldSections = computed(() => {
|
||||
const fields = permissionReasonFields.value
|
||||
const sections: Array<'notes' | 'image_urls'> = []
|
||||
if (fields.includes('notes') && !notesAfterProofImages.value) {
|
||||
sections.push('notes')
|
||||
}
|
||||
if (fields.includes('image_urls')) {
|
||||
sections.push('image_urls')
|
||||
}
|
||||
if (fields.includes('notes') && notesAfterProofImages.value) {
|
||||
sections.push('notes')
|
||||
}
|
||||
return sections
|
||||
})
|
||||
|
||||
const isCustomLicense = computed(() => licenseIdInput.value === CUSTOM_LICENSE_VALUE)
|
||||
|
||||
const licenseOptions = computed<ComboboxOption<string>[]>(() => [
|
||||
...builtinLicenses
|
||||
.filter((license) => license.short !== '')
|
||||
.map((license) => ({
|
||||
value: license.short,
|
||||
label:
|
||||
license.short === 'All-Rights-Reserved' ? formatMessage(messages.arrLabel) : license.short,
|
||||
})),
|
||||
{
|
||||
value: CUSTOM_LICENSE_VALUE,
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: 'external-files.permissions-card.custom-license-option',
|
||||
defaultMessage: 'Other',
|
||||
}),
|
||||
),
|
||||
},
|
||||
])
|
||||
|
||||
function buildAttributionLicense(): Labrinth.Attribution.Internal.AttributionLicense | null {
|
||||
const custom = isCustomLicense.value
|
||||
if (!licenseIdInput.value) {
|
||||
inputError.value = formatMessage(messages.licenseRequired)
|
||||
return null
|
||||
}
|
||||
const customLicense = customLicenseInput.value.trim()
|
||||
if (custom && !customLicense) {
|
||||
inputError.value = formatMessage(
|
||||
defineMessage({
|
||||
id: 'external-files.permissions-card.error.custom-license-required',
|
||||
defaultMessage: `Please include a link to your license. If you have none, you should likely select 'All Rights Reserved/No license'`,
|
||||
}),
|
||||
)
|
||||
return null
|
||||
}
|
||||
return custom ? { name: customLicense } : licenseIdInput.value
|
||||
}
|
||||
|
||||
function buildEditedData(): Labrinth.Attribution.Internal.AttributionResolution | null {
|
||||
inputError.value = null
|
||||
const notes = notesInput.value.trim()
|
||||
const image_urls = [...proofImageUrls.value]
|
||||
const proofError = attributionProofValidationError(
|
||||
selectedPermissionReason.value.proofRequirement,
|
||||
notes,
|
||||
image_urls,
|
||||
selectedPermissionReason.value.proofValidationError,
|
||||
)
|
||||
if (proofError) {
|
||||
inputError.value = formatMessage(proofError)
|
||||
return null
|
||||
}
|
||||
const base = {
|
||||
notes,
|
||||
image_urls,
|
||||
updated_by_moderator: false,
|
||||
}
|
||||
switch (selectedKind.value) {
|
||||
case 'license': {
|
||||
const license = buildAttributionLicense()
|
||||
if (!license) {
|
||||
return null
|
||||
}
|
||||
const linkRaw = linkInput.value.trim()
|
||||
if (!linkRaw) {
|
||||
inputError.value = formatMessage(
|
||||
defineMessage({
|
||||
id: 'external-files.permissions-card.error.link-required',
|
||||
defaultMessage: 'Please provide a link.',
|
||||
}),
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (!isHttpUrl(linkRaw)) {
|
||||
inputError.value = formatMessage(messages.linkInvalidUrl)
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
kind: 'license',
|
||||
license,
|
||||
link_to_work: linkRaw,
|
||||
}
|
||||
}
|
||||
case 'my_project': {
|
||||
const license = buildAttributionLicense()
|
||||
if (!license) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
kind: 'my_project',
|
||||
license,
|
||||
}
|
||||
}
|
||||
case 'special_permissions': {
|
||||
const linkRaw = linkInput.value.trim()
|
||||
if (!linkRaw) {
|
||||
inputError.value = formatMessage(
|
||||
defineMessage({
|
||||
id: 'external-files.permissions-card.error.link-required',
|
||||
defaultMessage: 'Please provide a link.',
|
||||
}),
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (!isHttpUrl(linkRaw)) {
|
||||
inputError.value = formatMessage(messages.linkInvalidUrl)
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
kind: 'special_permissions',
|
||||
link_to_work: linkRaw,
|
||||
}
|
||||
}
|
||||
case 'no_permission':
|
||||
return {
|
||||
...base,
|
||||
kind: 'no_permission',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const uploadProofImageMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const ext = extFromImageFile(file)
|
||||
if (!ext) {
|
||||
throw new Error(
|
||||
formatMessage(
|
||||
defineMessage({
|
||||
id: 'external-files.permissions-card.error.proof-image-invalid-type',
|
||||
defaultMessage: 'Please upload a PNG, JPEG, GIF, WebP, or BMP image.',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
const result = await client.labrinth.images_v3.uploadImage(file, ext, {
|
||||
context: 'project',
|
||||
project_id: props.projectId,
|
||||
}).promise
|
||||
return result.url
|
||||
},
|
||||
onSuccess(url) {
|
||||
proofImageUrls.value = [...proofImageUrls.value, url]
|
||||
},
|
||||
})
|
||||
|
||||
function handleProofImagesSelected(files: File[]) {
|
||||
const file = files[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
inputError.value = null
|
||||
uploadProofImageMutation.mutate(file)
|
||||
}
|
||||
|
||||
function removeProofImage(index: number) {
|
||||
proofImageUrls.value = proofImageUrls.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (payload: Labrinth.Attribution.Internal.AttributionResolution) =>
|
||||
client.labrinth.attribution_internal.updateGroup(props.groupId, {
|
||||
attribution: payload,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['project-attribution', props.projectId] })
|
||||
emit('updated')
|
||||
emit('saved')
|
||||
},
|
||||
})
|
||||
|
||||
function handleSave() {
|
||||
const data = buildEditedData()
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
saveMutation.mutate(data)
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
resetInputs()
|
||||
if (isAttributed.value) {
|
||||
emit('cancel')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-contrast font-semibold">
|
||||
{{ formatMessage(messages.typeLabel) }}
|
||||
</span>
|
||||
<Chips
|
||||
v-model="selectedKind"
|
||||
:items="permissionKinds.filter((kind) => kind !== 'globally_allowed')"
|
||||
:format-label="(kind) => formatMessage(PERMISSION_REASONS[kind].label)"
|
||||
:capitalize="false"
|
||||
/>
|
||||
<span>{{ formatMessage(PERMISSION_REASONS[selectedKind].description) }}</span>
|
||||
<div v-if="permissionReasonFields.includes('link_to_work')" class="flex flex-col gap-2">
|
||||
<span class="text-contrast font-semibold mt-1">
|
||||
{{ formatMessage(messages.linkLabel) }}
|
||||
</span>
|
||||
<StyledInput
|
||||
v-model="linkInput"
|
||||
type="text"
|
||||
class="max-w-[40rem]"
|
||||
:placeholder="`https://example.com/${formatMessage(messages.linkToWorkUrlPlaceholder)}`"
|
||||
/>
|
||||
<span
|
||||
v-if="
|
||||
linkInput.startsWith('https://modrinth.com/') ||
|
||||
linkInput.startsWith('https://www.modrinth.com/')
|
||||
"
|
||||
class="flex text-orange gap-2 font-medium mt-2"
|
||||
>
|
||||
<IssuesIcon class="shrink-0 mt-0.5" /> {{ formatMessage(messages.modrinthLinkToWork) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="permissionReasonFields.includes('license_id')" class="flex flex-col gap-2">
|
||||
<span class="text-contrast font-semibold mt-1">
|
||||
{{ formatMessage(messages.licenseLabel) }}
|
||||
</span>
|
||||
<Combobox
|
||||
v-model="licenseIdInput"
|
||||
class="max-w-80"
|
||||
:options="licenseOptions"
|
||||
searchable
|
||||
:search-placeholder="formatMessage(messages.selectLicenseLabel)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="permissionReasonFields.includes('custom_license') && isCustomLicense"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<span class="text-contrast font-semibold mt-1">
|
||||
{{
|
||||
formatMessage(
|
||||
selectedKind === 'my_project'
|
||||
? messages.customLicenseMyProjectLabel
|
||||
: messages.customLicenseLabel,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<StyledInput
|
||||
v-model="customLicenseInput"
|
||||
type="text"
|
||||
class="max-w-[40rem]"
|
||||
:placeholder="
|
||||
selectedKind === 'my_project'
|
||||
? formatMessage(messages.exampleSpdxLicense)
|
||||
: `https://example.com/${formatMessage(messages.linkToLicenseUrlPlaceholder)}`
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-for="section in attributionFieldSections" :key="section">
|
||||
<div v-if="section === 'notes'" class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-1 mt-1">
|
||||
<span class="text-contrast font-semibold">
|
||||
{{ formatMessage(selectedPermissionReason.notesLabel ?? messages.notesLabel) }}
|
||||
<span
|
||||
v-if="selectedPermissionReason.notesShowsOptional"
|
||||
class="font-normal text-primary"
|
||||
>{{ formatMessage(messages.optional) }}</span
|
||||
>
|
||||
</span>
|
||||
<span v-if="selectedPermissionReason.notesDescription">{{
|
||||
formatMessage(selectedPermissionReason.notesDescription)
|
||||
}}</span>
|
||||
</div>
|
||||
<StyledInput
|
||||
v-model="notesInput"
|
||||
type="text"
|
||||
resize="both"
|
||||
multiline
|
||||
:rows="notesInputRows"
|
||||
class="max-w-[40rem]"
|
||||
:placeholder="formatMessage(messages.notesPlaceholder)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="section === 'image_urls'" class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2 mt-1">
|
||||
<div class="flex flex-col gap-1 mt-1">
|
||||
<span class="text-contrast font-semibold">
|
||||
{{ formatMessage(messages.proofImagesLabel) }}
|
||||
<span v-if="proofImagesShowOptional" class="font-normal text-primary">{{
|
||||
formatMessage(messages.optional)
|
||||
}}</span>
|
||||
</span>
|
||||
<span v-if="selectedPermissionReason.proofImagesDescription">{{
|
||||
formatMessage(selectedPermissionReason.proofImagesDescription)
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="proofImageUrls.length > 0" class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="(src, idx) in proofImageUrls"
|
||||
:key="`${src}-${idx}`"
|
||||
class="relative rounded-xl border-[1px] border-solid border-surface-5 overflow-hidden shrink-0"
|
||||
>
|
||||
<img
|
||||
:src="src"
|
||||
:alt="formatMessage(messages.proofImageThumbnailAlt, { n: idx + 1 })"
|
||||
class="flex w-full object-contain bg-surface-3"
|
||||
/>
|
||||
<div class="absolute top-2 right-2">
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.proofImageRemove)"
|
||||
type="button"
|
||||
@click="removeProofImage(idx)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FileInput
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/bmp"
|
||||
:prompt="formatMessage(messages.proofImagesUploadPrompt)"
|
||||
long-style
|
||||
should-always-reset
|
||||
:max-size="MAX_PROOF_IMAGE_BYTES"
|
||||
:disabled="uploadProofImageMutation.isPending.value || saveMutation.isPending.value"
|
||||
class="!bg-surface-3 !border-surface-5"
|
||||
@change="handleProofImagesSelected"
|
||||
>
|
||||
<UploadIcon class="size-5 shrink-0" />
|
||||
</FileInput>
|
||||
</div>
|
||||
<p v-if="uploadProofImageMutation.isError.value" class="text-red text-sm m-0">
|
||||
{{ String(uploadProofImageMutation.error.value) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedKind === 'my_project' || selectedKind === 'special_permissions'"
|
||||
class="grid grid-cols-[auto_1fr] gap-2"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<InfoIcon class="size-5 text-blue" />
|
||||
<div class="w-[2px] flex-grow bg-blue mt-[-1px]"></div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-medium leading-[1.25] text-blue">{{
|
||||
formatMessage(messages.proofWarningTitle)
|
||||
}}</span>
|
||||
<span class="text-contrast">{{ formatMessage(messages.proofWarningBody) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="inputError" class="text-red m-0">{{ inputError }}</p>
|
||||
<p v-else-if="saveMutation.isError.value" class="text-red m-0">
|
||||
{{ String(saveMutation.error.value) }}
|
||||
</p>
|
||||
|
||||
<hr class="mt-1 bg-surface-5 border-none h-[1px] w-full" />
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<ButtonStyled v-if="isAttributed" type="outlined">
|
||||
<button
|
||||
:disabled="saveMutation.isPending.value || uploadProofImageMutation.isPending.value"
|
||||
@click="cancelEditing"
|
||||
>
|
||||
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="saveMutation.isPending.value || uploadProofImageMutation.isPending.value"
|
||||
@click="handleSave"
|
||||
>
|
||||
<template v-if="saveMutation.isPending.value">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
{{ formatMessage(commonMessages.savingButton) }}
|
||||
</template>
|
||||
<template v-else-if="isAttributed">
|
||||
<SaveIcon /> {{ formatMessage(messages.saveAttribution) }}
|
||||
</template>
|
||||
<template v-else> <CheckIcon /> {{ formatMessage(messages.addAttribution) }} </template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Checkbox } from '#ui/components'
|
||||
|
||||
const props = defineProps<{
|
||||
files: Labrinth.Attribution.Internal.AttributionFile[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const selectedSha1s = defineModel<Set<string>>('selectedSha1s', { required: true })
|
||||
|
||||
const allSelected = computed(
|
||||
() => props.files.length > 0 && props.files.every((file) => selectedSha1s.value.has(file.sha1)),
|
||||
)
|
||||
|
||||
const someSelected = computed(
|
||||
() => props.files.some((file) => selectedSha1s.value.has(file.sha1)) && !allSelected.value,
|
||||
)
|
||||
|
||||
function displayName(file: Labrinth.Attribution.Internal.AttributionFile) {
|
||||
return file.name.split('/').pop() ?? file.name
|
||||
}
|
||||
|
||||
function setAllSelected(selected: boolean) {
|
||||
const next = new Set(selectedSha1s.value)
|
||||
for (const file of props.files) {
|
||||
if (selected) {
|
||||
next.add(file.sha1)
|
||||
} else {
|
||||
next.delete(file.sha1)
|
||||
}
|
||||
}
|
||||
selectedSha1s.value = next
|
||||
}
|
||||
|
||||
function toggleFile(sha1: string, selected: boolean) {
|
||||
const next = new Set(selectedSha1s.value)
|
||||
if (selected) {
|
||||
next.add(sha1)
|
||||
} else {
|
||||
next.delete(sha1)
|
||||
}
|
||||
selectedSha1s.value = next
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="files.length > 1" class="flex flex-col gap-2">
|
||||
<Checkbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
label="Select files to add"
|
||||
:disabled="disabled"
|
||||
@update:model-value="setAllSelected"
|
||||
/>
|
||||
<div class="flex flex-col [&>*:nth-child(even)]:bg-surface-3">
|
||||
<Checkbox
|
||||
v-for="file in files"
|
||||
:key="file.sha1"
|
||||
:model-value="selectedSha1s.has(file.sha1)"
|
||||
:label="displayName(file)"
|
||||
:disabled="disabled"
|
||||
class="w-full px-4 py-2 hover:bg-surface-4 text-primary"
|
||||
@update:model-value="(selected) => toggleFile(file.sha1, selected)"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ScaleIcon } from '@modrinth/assets'
|
||||
import { sortByIndex } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { TagItem } from '#ui/components'
|
||||
|
||||
import { MODERATION_DB_BADGE } from './external-project-utils'
|
||||
|
||||
const props = defineProps<{
|
||||
files?: Labrinth.Attribution.Internal.AttributionGroup['files']
|
||||
}>()
|
||||
|
||||
const MODERATION_STATUS_PRIORITY: Labrinth.ExternalProjects.Internal.ExternalLicenseStatus[] = [
|
||||
'permanent-no',
|
||||
'no',
|
||||
'yes',
|
||||
'with-attribution',
|
||||
'with-attribution-and-source',
|
||||
'unidentified',
|
||||
]
|
||||
|
||||
function statusLabel(status: Labrinth.ExternalProjects.Internal.ExternalLicenseStatus): string {
|
||||
return MODERATION_DB_BADGE[status]?.label ?? status
|
||||
}
|
||||
|
||||
const moderationStatuses = computed<{
|
||||
primary: Labrinth.ExternalProjects.Internal.ExternalLicenseStatus
|
||||
others?: Labrinth.ExternalProjects.Internal.ExternalLicenseStatus[]
|
||||
}>(() => {
|
||||
const statuses = (props.files ?? []).map((file) => {
|
||||
const status = file.moderation_external_license?.status
|
||||
if (!status) {
|
||||
return 'unidentified'
|
||||
} else {
|
||||
return status
|
||||
}
|
||||
})
|
||||
const sorted = sortByIndex(MODERATION_STATUS_PRIORITY, [...new Set(statuses)])
|
||||
const primary = sorted[0] ?? 'unidentified'
|
||||
return {
|
||||
primary,
|
||||
...(sorted.length > 1 ? { others: sorted.slice(1) } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
const otherStatusesTooltip = computed(() => {
|
||||
const others = moderationStatuses.value.others
|
||||
if (!others?.length) {
|
||||
return undefined
|
||||
}
|
||||
return others.map((status) => statusLabel(status)).join(', ')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagItem
|
||||
v-tooltip="`Other statuses: ${otherStatusesTooltip}`"
|
||||
:style="{ color: MODERATION_DB_BADGE[moderationStatuses.primary]?.color }"
|
||||
>
|
||||
<ScaleIcon class="size-4 shrink-0" />
|
||||
{{ statusLabel(moderationStatuses.primary) }}
|
||||
<span class="text-primary">
|
||||
{{
|
||||
moderationStatuses.others?.length > 0 ? `+ ${moderationStatuses.others?.length} more` : ''
|
||||
}}
|
||||
</span>
|
||||
</TagItem>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { TagItem } from '#ui/components'
|
||||
|
||||
import { defineMessage, useVIntl } from '../../composables/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
variant: 'pending' | 'attributed' | 'no_permission' | 'proof_rejected' | 'not_allowed'
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const badge = computed(() => {
|
||||
switch (props.variant) {
|
||||
case 'no_permission':
|
||||
return {
|
||||
style: { '--_bg-color': 'var(--color-red-bg)', '--_color': 'var(--color-red)' },
|
||||
message: defineMessage({
|
||||
id: 'external-files.permissions-card.badge.no-permission',
|
||||
defaultMessage: 'No permission',
|
||||
}),
|
||||
}
|
||||
case 'proof_rejected':
|
||||
return {
|
||||
style: { '--_bg-color': 'var(--color-orange-bg)', '--_color': 'var(--color-orange)' },
|
||||
message: defineMessage({
|
||||
id: 'external-files.permissions-card.badge.proof-rejected',
|
||||
defaultMessage: 'Information rejected',
|
||||
}),
|
||||
}
|
||||
case 'not_allowed':
|
||||
return {
|
||||
style: { '--_bg-color': 'var(--color-red-bg)', '--_color': 'var(--color-red)' },
|
||||
message: defineMessage({
|
||||
id: 'external-files.permissions-card.badge.not-allowed',
|
||||
defaultMessage: 'Not allowed',
|
||||
}),
|
||||
}
|
||||
case 'attributed':
|
||||
return {
|
||||
style: { '--_bg-color': 'var(--color-green-bg)', '--_color': 'var(--color-green)' },
|
||||
message: defineMessage({
|
||||
id: 'external-files.permissions-card.badge.attributed',
|
||||
defaultMessage: 'Completed',
|
||||
}),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
style: {
|
||||
'--_bg-color': 'var(--color-orange-bg)',
|
||||
'--_color': 'var(--color-orange)',
|
||||
},
|
||||
message: defineMessage({
|
||||
id: 'external-files.permissions-card.badge.pending',
|
||||
defaultMessage: 'Pending',
|
||||
}),
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagItem :style="badge.style">
|
||||
{{ formatMessage(badge.message) }}
|
||||
</TagItem>
|
||||
</template>
|
||||
@@ -1,176 +1,804 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ListBulletedIcon, SaveIcon, VersionIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
EditIcon,
|
||||
FileIcon,
|
||||
PlusIcon,
|
||||
ReportIcon,
|
||||
ScaleIcon,
|
||||
SpinnerIcon,
|
||||
VersionIcon,
|
||||
XCircleIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { Admonition, ButtonStyled, Chips, Collapsible, Combobox, StyledInput } from '#ui/components'
|
||||
import { ButtonStyled, Collapsible, OverflowMenu } from '#ui/components'
|
||||
import type { OverflowMenuOption } from '#ui/components/base'
|
||||
import { commonMessages } from '#ui/utils'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
import { defineMessage, defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import {
|
||||
injectAttributionModeration,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
} from '../../providers'
|
||||
import type { QuickReply } from '../../providers/attribution-moderation'
|
||||
import StyledInput from '../base/StyledInput.vue'
|
||||
import AddFilesToAttributionGroupModal from './AddFilesToAttributionGroupModal.vue'
|
||||
import AddToExistingExternalProjectModal from './AddToExistingExternalProjectModal.vue'
|
||||
import AddToGlobalPermissionsDatabaseModal from './AddToGlobalPermissionsDatabaseModal.vue'
|
||||
import AttributionDisplay from './AttributionDisplay.vue'
|
||||
import AttributionEditor from './AttributionEditor.vue'
|
||||
import AttributionModerationDbBadge from './AttributionModerationDbBadge.vue'
|
||||
import AttributionStatusTag from './AttributionStatusTag.vue'
|
||||
import {
|
||||
attributionLinkToWork,
|
||||
createAttributionGroupTitle,
|
||||
MODERATION_DB_BADGE,
|
||||
parseInitialAttribution,
|
||||
} from './external-project-utils'
|
||||
import OriginalPageLink from './OriginalPageLink.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
projectId: string
|
||||
group: Labrinth.Attribution.Internal.AttributionGroup
|
||||
isModerator?: boolean
|
||||
}>(),
|
||||
{
|
||||
isModerator: false,
|
||||
},
|
||||
)
|
||||
|
||||
const collapsedModel = defineModel<boolean>('collapsed')
|
||||
|
||||
const collapsed = computed({
|
||||
get: () =>
|
||||
collapsedModel.value ??
|
||||
(!props.isModerator &&
|
||||
!!props.group.attribution &&
|
||||
props.group.attribution?.moderation_status?.kind !== 'bad_proof'),
|
||||
set: (value) => {
|
||||
collapsedModel.value = value
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updated'): void
|
||||
}>()
|
||||
|
||||
const collapsed = ref(true)
|
||||
const addFilesModalRef = useTemplateRef<typeof AddFilesToAttributionGroupModal>('addFilesModalRef')
|
||||
const addToGlobalModalRef =
|
||||
useTemplateRef<typeof AddToGlobalPermissionsDatabaseModal>('addToGlobalModalRef')
|
||||
const addToExistingModalRef =
|
||||
useTemplateRef<typeof AddToExistingExternalProjectModal>('addToExistingModalRef')
|
||||
|
||||
const selectedPermissionsType = ref('My project')
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { allMembers } = injectProjectPageContext()
|
||||
const attributionModeration = injectAttributionModeration(null)
|
||||
|
||||
const attributorMember = computed(() => {
|
||||
const userId = props.group.attributed_by
|
||||
if (!userId || !allMembers.value) {
|
||||
return null
|
||||
}
|
||||
return allMembers.value.find((member) => member.user.id === userId) ?? null
|
||||
})
|
||||
|
||||
const attributorLink = computed(() => {
|
||||
const id = props.group.attributed_by
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
const slug = attributorMember.value?.user?.username ?? id
|
||||
return `/user/${slug}`
|
||||
})
|
||||
|
||||
const attributorLabel = computed(() => {
|
||||
if (attributorMember.value) {
|
||||
return attributorMember.value.user.username
|
||||
}
|
||||
return props.group.attributed_by ?? 'unknown'
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
fileCount: {
|
||||
id: 'external-files.permissions-card.file-count',
|
||||
defaultMessage: '{count, plural, one {# file} other {# files}}',
|
||||
},
|
||||
includedInVersions: {
|
||||
id: 'external-files.permissions-card.included-in-versions',
|
||||
defaultMessage: 'Included in {count, plural, one {# version} other {# versions}}:',
|
||||
},
|
||||
includedFiles: {
|
||||
id: 'external-files.permissions-card.included-files',
|
||||
defaultMessage: 'Included files:',
|
||||
},
|
||||
notUsedInVersions: {
|
||||
id: 'external-files.permissions-card.not-used-in-versions',
|
||||
defaultMessage: 'These files are not currently used by any version.',
|
||||
},
|
||||
splitFile: {
|
||||
id: 'external-files.permissions-card.split-file',
|
||||
defaultMessage: 'Remove from group',
|
||||
},
|
||||
addFilesToGroup: {
|
||||
id: 'external-files.permissions-card.add-files-to-group',
|
||||
defaultMessage: 'Add files...',
|
||||
},
|
||||
moderationReasonLabel: {
|
||||
id: 'external-files.permissions-card.moderation-reason',
|
||||
defaultMessage: 'Reason',
|
||||
},
|
||||
})
|
||||
|
||||
type EditingMode = 'attribution' | 'moderation_review'
|
||||
|
||||
const editingMode = ref<EditingMode | null>(null)
|
||||
const editorResumeKey = ref(0)
|
||||
|
||||
const isEditingAttribution = computed(() => editingMode.value === 'attribution')
|
||||
const isEditingModerationReview = computed(() => editingMode.value === 'moderation_review')
|
||||
|
||||
const initialAttribution = computed<Labrinth.Attribution.Internal.AttributionResolution | null>(
|
||||
() => parseInitialAttribution(props.group.attribution),
|
||||
)
|
||||
|
||||
const isAttributed = computed(() => initialAttribution.value !== null)
|
||||
const attributionStatusVariant = computed<
|
||||
'pending' | 'attributed' | 'no_permission' | 'proof_rejected' | 'not_allowed'
|
||||
>(() => {
|
||||
if (isAttributed.value && props.group.attribution?.kind === 'no_permission') {
|
||||
return 'no_permission'
|
||||
} else if (
|
||||
isAttributed.value &&
|
||||
props.group.attribution?.moderation_status?.kind === 'not_allowed'
|
||||
) {
|
||||
return 'not_allowed'
|
||||
} else if (
|
||||
isAttributed.value &&
|
||||
props.group.attribution?.moderation_status?.kind === 'bad_proof'
|
||||
) {
|
||||
return 'proof_rejected'
|
||||
} else if (isAttributed.value) {
|
||||
return 'attributed'
|
||||
}
|
||||
return 'pending'
|
||||
})
|
||||
|
||||
const title = computed(() => createAttributionGroupTitle(props.group, formatMessage))
|
||||
const fileCount = computed(() => props.group.files?.length ?? 0)
|
||||
|
||||
const containingVersions = computed(() => {
|
||||
const versionIds = new Set<string>()
|
||||
for (const file of props.group.files ?? []) {
|
||||
for (const versionId of file.versions ?? []) {
|
||||
versionIds.add(versionId)
|
||||
}
|
||||
}
|
||||
return props.group.versions?.filter((v) => versionIds.has(v.id))
|
||||
})
|
||||
|
||||
const pendingSplitSha1 = ref<string | null>(null)
|
||||
|
||||
const assignFilesMutation = useMutation({
|
||||
mutationFn: async (sha1s: string[]) => {
|
||||
for (const sha1 of sha1s) {
|
||||
await client.labrinth.attribution_internal.assignFileToGroup({
|
||||
sha1,
|
||||
target_group_id: props.group.id,
|
||||
project_id: props.projectId,
|
||||
})
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['project-attribution', props.projectId] })
|
||||
emit('updated')
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(
|
||||
defineMessage({
|
||||
id: 'external-files.permissions-card.assign-files-error.title',
|
||||
defaultMessage: 'Could not add files',
|
||||
}),
|
||||
),
|
||||
text: error.message,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const splitFileMutation = useMutation({
|
||||
mutationFn: (sha1: string) =>
|
||||
client.labrinth.attribution_internal.splitFile({
|
||||
sha1,
|
||||
project_id: props.projectId,
|
||||
}),
|
||||
onMutate(sha1) {
|
||||
pendingSplitSha1.value = sha1
|
||||
},
|
||||
onSettled() {
|
||||
pendingSplitSha1.value = null
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['project-attribution', props.projectId] })
|
||||
emit('updated')
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(
|
||||
defineMessage({
|
||||
id: 'external-files.permissions-card.split-file-error.title',
|
||||
defaultMessage: 'Could not split file',
|
||||
}),
|
||||
),
|
||||
text: error.message,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function startEditingAttribution() {
|
||||
editingMode.value = 'attribution'
|
||||
collapsed.value = false
|
||||
editorResumeKey.value += 1
|
||||
}
|
||||
|
||||
function startEditingModerationReview() {
|
||||
syncReviewReasonInput()
|
||||
editingMode.value = 'moderation_review'
|
||||
}
|
||||
|
||||
function stopEditing() {
|
||||
editingMode.value = null
|
||||
}
|
||||
|
||||
function cancelModerationReviewEditing() {
|
||||
syncReviewReasonInput()
|
||||
stopEditing()
|
||||
}
|
||||
|
||||
function handleEditorUpdated() {
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
function handleSplitFile(sha1: string) {
|
||||
splitFileMutation.mutate(sha1)
|
||||
}
|
||||
|
||||
function handleConfirmAddFiles(sha1s: string[]) {
|
||||
assignFilesMutation.mutate(sha1s)
|
||||
}
|
||||
|
||||
async function handleAddFilesToGroup(event: MouseEvent) {
|
||||
try {
|
||||
const groups = await queryClient.ensureQueryData({
|
||||
queryKey: ['project-attribution', props.projectId],
|
||||
queryFn: () => client.labrinth.attribution_internal.listProjectAttribution(props.projectId),
|
||||
})
|
||||
addFilesModalRef.value?.show(event, groups)
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(
|
||||
defineMessage({
|
||||
id: 'external-files.permissions-card.add-files-modal.load-error.title',
|
||||
defaultMessage: 'Could not load files',
|
||||
}),
|
||||
),
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModerationDbUpdated() {
|
||||
await queryClient.invalidateQueries({ queryKey: ['project-attribution', props.projectId] })
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
function handleAddToGlobalDatabase(event: MouseEvent) {
|
||||
addToGlobalModalRef.value?.show(event)
|
||||
}
|
||||
|
||||
function handleAddToExistingEntry(event: MouseEvent) {
|
||||
addToExistingModalRef.value?.show(event)
|
||||
}
|
||||
|
||||
const originalProjectUrl = computed(
|
||||
() => attributionLinkToWork(initialAttribution.value) ?? props.group.flame_project?.url,
|
||||
)
|
||||
|
||||
const moderationStatusKind = computed(
|
||||
() => props.group.attribution?.moderation_status?.kind ?? null,
|
||||
)
|
||||
|
||||
const moderationStatusIndicator = computed(() => {
|
||||
if (!moderationStatusKind.value) {
|
||||
return null
|
||||
}
|
||||
switch (moderationStatusKind.value) {
|
||||
case 'approved':
|
||||
return {
|
||||
icon: CheckCircleIcon,
|
||||
class: 'text-green',
|
||||
name: defineMessage({
|
||||
id: 'external-files.permissions-card.attribution.moderation-status.passed',
|
||||
defaultMessage: 'Passed',
|
||||
}),
|
||||
}
|
||||
case 'bad_proof':
|
||||
return {
|
||||
icon: XCircleIcon,
|
||||
class: 'text-red',
|
||||
name: defineMessage({
|
||||
id: 'external-files.permissions-card.attribution.moderation-status.rejected-proof',
|
||||
defaultMessage: 'Proof rejected',
|
||||
}),
|
||||
}
|
||||
case 'not_allowed':
|
||||
return {
|
||||
icon: ReportIcon,
|
||||
class: 'text-red',
|
||||
name: defineMessage({
|
||||
id: 'external-files.permissions-card.attribution.moderation-status.content-not-allowed',
|
||||
defaultMessage: 'Content not allowed',
|
||||
}),
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const reviewReasonInput = ref('')
|
||||
|
||||
function syncReviewReasonInput() {
|
||||
reviewReasonInput.value = props.group.attribution?.moderation_status?.reason ?? ''
|
||||
}
|
||||
|
||||
syncReviewReasonInput()
|
||||
|
||||
watch(
|
||||
() => props.group.attribution?.moderation_status,
|
||||
() => {
|
||||
if (!isEditingModerationReview.value) {
|
||||
syncReviewReasonInput()
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const pendingModerationStatusKind =
|
||||
ref<Labrinth.Attribution.Internal.AttributionModerationStatusKind | null>(null)
|
||||
|
||||
const setModerationStatusMutation = useMutation({
|
||||
mutationFn: (kind: Labrinth.Attribution.Internal.AttributionModerationStatusKind) => {
|
||||
if (!initialAttribution.value) {
|
||||
throw new Error('Attribution is required')
|
||||
}
|
||||
return client.labrinth.attribution_internal.updateGroup(props.group.id, {
|
||||
attribution: {
|
||||
...initialAttribution.value,
|
||||
moderation_status: {
|
||||
kind,
|
||||
reason: reviewReasonInput.value.trim(),
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
onMutate(kind) {
|
||||
pendingModerationStatusKind.value = kind
|
||||
},
|
||||
onSettled() {
|
||||
pendingModerationStatusKind.value = null
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['project-attribution', props.projectId] })
|
||||
stopEditing()
|
||||
collapsed.value = true
|
||||
emit('updated')
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(
|
||||
defineMessage({
|
||||
id: 'external-files.permissions-card.moderation.error.title',
|
||||
defaultMessage: 'Could not save moderation review',
|
||||
}),
|
||||
),
|
||||
text: error.message,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function handleSetModerationStatus(
|
||||
kind: Labrinth.Attribution.Internal.AttributionModerationStatusKind,
|
||||
) {
|
||||
setModerationStatusMutation.mutate(kind)
|
||||
}
|
||||
|
||||
async function handleQuickReply(reply: QuickReply) {
|
||||
const message =
|
||||
typeof reply.message === 'function' ? await reply.message(undefined) : reply.message
|
||||
reviewReasonInput.value = message
|
||||
}
|
||||
|
||||
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
|
||||
const replies = attributionModeration?.attributionQuickReplies
|
||||
|
||||
if (!replies) return []
|
||||
|
||||
return replies
|
||||
.filter((reply) => {
|
||||
if (reply.shouldShow === undefined) return true
|
||||
return reply.shouldShow(undefined)
|
||||
})
|
||||
.map(
|
||||
(reply) =>
|
||||
({
|
||||
id: reply.label,
|
||||
action: () => handleQuickReply(reply),
|
||||
}) as OverflowMenuOption,
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-surface-2 p-0 rounded-2xl flex flex-col border-[1px] border-solid border-surface-5 overflow-hidden"
|
||||
>
|
||||
<div class="flex items-center bg-surface-3">
|
||||
<div class="flex items-center bg-surface-3 gap-3">
|
||||
<button
|
||||
class="flex grow m-0 appearance-none p-4 bg-transparent group transition-all"
|
||||
class="flex grow items-center m-0 appearance-none p-4 bg-transparent group transition-all gap-3 text-left min-w-0 outline-offset-[-3px] rounded-2xl"
|
||||
:class="{
|
||||
'rounded-b-none': !collapsed,
|
||||
'rounded-r-none': group.flame_project?.url || isModerator,
|
||||
}"
|
||||
@click="collapsed = !collapsed"
|
||||
>
|
||||
<span class="flex items-center gap-3 group-active:scale-[0.98]">
|
||||
<ChevronDownIcon
|
||||
class="size-6 text-primary transition-transform duration-300"
|
||||
:class="{ 'rotate-180': !collapsed }"
|
||||
/>
|
||||
<span class="text-contrast font-semibold">{{ title }}</span>
|
||||
<ChevronDownIcon
|
||||
class="size-6 text-primary transition-transform duration-300 shrink-0 mb-auto"
|
||||
:class="{ 'rotate-180': !collapsed }"
|
||||
/>
|
||||
<span class="flex flex-col items-start min-w-0 group-active:scale-[0.98]">
|
||||
<span class="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
<span class="text-contrast truncate font-semibold">{{ title }}</span>
|
||||
<component
|
||||
:is="moderationStatusIndicator.icon"
|
||||
v-if="moderationStatusIndicator"
|
||||
v-tooltip="formatMessage(moderationStatusIndicator.name)"
|
||||
:class="moderationStatusIndicator.class"
|
||||
class="size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<AttributionStatusTag :variant="attributionStatusVariant" />
|
||||
<OriginalPageLink v-if="originalProjectUrl && isModerator" :href="originalProjectUrl" />
|
||||
</span>
|
||||
<span v-if="fileCount > 1" class="text-secondary text-sm font-normal">
|
||||
{{ formatMessage(messages.fileCount, { count: fileCount }) }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-2 m-4 ml-0">
|
||||
<ButtonStyled type="outlined">
|
||||
<button>
|
||||
<ListBulletedIcon />
|
||||
Versions
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="mr-4 flex items-center gap-2">
|
||||
<AttributionModerationDbBadge v-if="isModerator" :files="group.files" />
|
||||
<OriginalPageLink v-else-if="originalProjectUrl" :href="originalProjectUrl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible
|
||||
:collapsed="collapsed"
|
||||
class="border-0 border-solid border-t border-surface-5 rounded-b-2xl"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<span class="text-contrast font-semibold">Included in versions:</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template v-for="version in ['4.0.0', '3.5.15', '3.5.14']" :key="version">
|
||||
<div
|
||||
class="px-3 py-2 rounded-xl flex items-center gap-2 border-[1px] border-solid border-surface-5"
|
||||
>
|
||||
<VersionIcon />
|
||||
{{ version }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-2xl p-4 mt-2 border-[1px] border-solid border-surface-5 flex flex-col gap-3"
|
||||
>
|
||||
<span class="text-contrast font-semibold">Type</span>
|
||||
<Chips
|
||||
v-model="selectedPermissionsType"
|
||||
:items="['License', 'My project', 'Special permission', 'No permission']"
|
||||
/>
|
||||
<template v-if="selectedPermissionsType === 'License'">
|
||||
<span>The license of this work permits you to redistribute it in your modpack.</span>
|
||||
<span class="text-contrast font-semibold mt-1">License</span>
|
||||
<Combobox
|
||||
class="max-w-80"
|
||||
:options="[{ label: 'MIT', value: 'MIT' }]"
|
||||
:model-value="'MIT'"
|
||||
/>
|
||||
<span class="text-contrast font-semibold mt-1"> Link to work </span>
|
||||
<StyledInput
|
||||
type="text"
|
||||
class="max-w-[30rem]"
|
||||
placeholder="https://example.com/link-to-work"
|
||||
/>
|
||||
<span class="text-contrast font-semibold mt-1">
|
||||
Notes
|
||||
<span class="font-normal text-primary">(optional)</span>
|
||||
</span>
|
||||
<StyledInput
|
||||
type="text"
|
||||
resize="both"
|
||||
multiline
|
||||
class="max-w-[40rem]"
|
||||
placeholder="Write something here..."
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="selectedPermissionsType === 'My project'">
|
||||
<span>Original work created by you.</span>
|
||||
<span class="text-contrast font-semibold mt-1">License</span>
|
||||
<Combobox
|
||||
class="max-w-80"
|
||||
:options="[{ label: 'MIT', value: 'MIT' }]"
|
||||
:model-value="'MIT'"
|
||||
/>
|
||||
<span class="text-contrast font-semibold mt-1">
|
||||
Notes
|
||||
<span class="font-normal text-primary">(optional)</span>
|
||||
</span>
|
||||
<StyledInput
|
||||
type="text"
|
||||
resize="both"
|
||||
multiline
|
||||
class="max-w-[40rem]"
|
||||
placeholder="Write something here..."
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="selectedPermissionsType === 'Special permission'">
|
||||
<span>
|
||||
You have obtained special permission to redistribute this work in your modpack.
|
||||
</span>
|
||||
<span class="text-contrast font-semibold mt-1"> Link to work </span>
|
||||
<StyledInput
|
||||
type="text"
|
||||
class="max-w-[30rem]"
|
||||
placeholder="https://example.com/link-to-work"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 mt-1">
|
||||
<span class="text-contrast font-semibold"> Proof and explanation </span>
|
||||
<span>
|
||||
Include screenshots of messages, emails, or replies from the copyright owner showing
|
||||
that they granted you permission to redistribute their work in your modpack.
|
||||
<div class="flex flex-col gap-3 p-4">
|
||||
<span class="text-contrast font-semibold">
|
||||
{{ formatMessage(messages.includedFiles) }}
|
||||
</span>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<span
|
||||
v-for="file in group.files"
|
||||
:key="file.sha1"
|
||||
class="pl-3 rounded-xl grid grid-cols-[auto_1fr_auto] gap-2 items-start border-[1px] border-solid border-surface-5 bg-surface-2"
|
||||
:style="{
|
||||
'border-color':
|
||||
isModerator && file.moderation_external_license
|
||||
? MODERATION_DB_BADGE[file.moderation_external_license?.status]?.color
|
||||
: undefined,
|
||||
color:
|
||||
isModerator && file.moderation_external_license
|
||||
? MODERATION_DB_BADGE[file.moderation_external_license?.status]?.color
|
||||
: undefined,
|
||||
}"
|
||||
>
|
||||
<FileIcon class="size-4 shrink-0 mt-2.5" />
|
||||
<div class="max-w-[22rem] min-w-0 flex flex-col gap-1 py-2">
|
||||
<span class="truncate">
|
||||
{{ file.name.split('/').pop() }}
|
||||
</span>
|
||||
</div>
|
||||
<StyledInput
|
||||
type="text"
|
||||
resize="both"
|
||||
multiline
|
||||
class="max-w-[40rem]"
|
||||
placeholder="Write something here..."
|
||||
/>
|
||||
<Admonition
|
||||
type="warning"
|
||||
header="Modrinth staff may attempt to verify submitted proof"
|
||||
>
|
||||
If you are found to have lied or manipulated the images uploaded, your project and
|
||||
account may be terminated.
|
||||
</Admonition>
|
||||
</template>
|
||||
<template v-else-if="selectedPermissionsType === 'No permission'">
|
||||
<span>You don't have permission to use this work.</span>
|
||||
<span class="text-contrast font-semibold mt-1">
|
||||
Notes
|
||||
<span class="font-normal text-primary">(optional)</span>
|
||||
</span>
|
||||
<StyledInput
|
||||
type="text"
|
||||
resize="both"
|
||||
multiline
|
||||
class="max-w-[40rem]"
|
||||
placeholder="Write something here..."
|
||||
/>
|
||||
</template>
|
||||
<hr class="mt-1 bg-surface-5 border-none h-[1px] w-full" />
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button>
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button>
|
||||
<SaveIcon />
|
||||
Save
|
||||
<div class="flex items-center gap-1 my-auto">
|
||||
<ButtonStyled v-if="group.files.length > 1" circular size="small">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.splitFile)"
|
||||
class="m-1"
|
||||
:disabled="splitFileMutation.isPending.value"
|
||||
@click="handleSplitFile(file.sha1)"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="splitFileMutation.isPending.value && pendingSplitSha1 === file.sha1"
|
||||
class="size-4 shrink-0 animate-spin"
|
||||
/>
|
||||
<XIcon v-else class="size-4 shrink-0" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</span>
|
||||
<div>
|
||||
<ButtonStyled>
|
||||
<button @click="handleAddFilesToGroup($event)">
|
||||
<PlusIcon class="size-4 shrink-0" /> {{ formatMessage(messages.addFilesToGroup) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="(containingVersions?.length ?? 0) > 0">
|
||||
<span class="text-contrast font-semibold">
|
||||
{{
|
||||
formatMessage(messages.includedInVersions, { count: containingVersions?.length ?? 0 })
|
||||
}}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<nuxt-link
|
||||
v-for="version in containingVersions"
|
||||
:key="version.id"
|
||||
:to="`/project/${projectId}/version/${version.id}`"
|
||||
target="_blank"
|
||||
class="px-3 py-2 rounded-xl flex items-center gap-2 border-[1px] border-solid border-surface-5 bg-surface-3 hover:bg-surface-4"
|
||||
>
|
||||
<VersionIcon class="size-4 shrink-0" />
|
||||
<span class="max-w-[22rem] truncate">
|
||||
{{ version.version_number }}
|
||||
</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-secondary text-sm">
|
||||
{{ formatMessage(messages.notUsedInVersions) }}
|
||||
</span>
|
||||
</template>
|
||||
<AttributionDisplay
|
||||
v-if="!isEditingAttribution && initialAttribution"
|
||||
:attribution="initialAttribution"
|
||||
:attributed-at="group.attributed_at"
|
||||
:attributed-by="group.attributed_by"
|
||||
:attributor-href="attributorLink"
|
||||
:attributor-label="attributorLabel"
|
||||
:attributor-avatar-url="attributorMember?.user.avatar_url"
|
||||
:moderator="isModerator"
|
||||
>
|
||||
<template
|
||||
v-if="
|
||||
group.attribution?.kind !== 'globally_allowed' &&
|
||||
(group.attribution?.moderation_status?.kind !== 'not_allowed' || isModerator)
|
||||
"
|
||||
#actions
|
||||
>
|
||||
<ButtonStyled>
|
||||
<button @click="startEditingAttribution">
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
(isModerator || group.attribution?.moderation_status) &&
|
||||
group.attribution?.kind !== 'globally_allowed'
|
||||
"
|
||||
#footer
|
||||
>
|
||||
<div class="flex gap-4 flex-wrap">
|
||||
<div>
|
||||
<p
|
||||
class="font-semibold m-0 flex items-center gap-2"
|
||||
:class="isModerator ? 'text-orange' : moderationStatusIndicator?.class"
|
||||
>
|
||||
<template v-if="isModerator">
|
||||
<ScaleIcon class="size-5 shrink-0" />
|
||||
Review attribution
|
||||
</template>
|
||||
<template v-else-if="moderationStatusIndicator">
|
||||
<component
|
||||
:is="moderationStatusIndicator.icon"
|
||||
class="size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ formatMessage(moderationStatusIndicator.name) }}
|
||||
</template>
|
||||
</p>
|
||||
<template v-if="isModerator">
|
||||
<template
|
||||
v-if="group.attribution?.moderation_status && !isEditingModerationReview"
|
||||
>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-y-3 gap-x-4 mt-3">
|
||||
<div>Status:</div>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
:class="moderationStatusIndicator ? moderationStatusIndicator.class : ''"
|
||||
>
|
||||
<template v-if="moderationStatusIndicator">
|
||||
<component
|
||||
:is="moderationStatusIndicator.icon"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ formatMessage(moderationStatusIndicator.name) }}
|
||||
</template>
|
||||
</div>
|
||||
<div class="leading-[1.5]">Reason:</div>
|
||||
<div>
|
||||
<div
|
||||
class="markdown-body"
|
||||
v-html="
|
||||
renderString(group.attribution?.moderation_status?.reason || 'N/A')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<StyledInput
|
||||
v-model="reviewReasonInput"
|
||||
multiline
|
||||
placeholder="Explanation of review (optional)"
|
||||
class="mt-3"
|
||||
/>
|
||||
<div class="flex items-center gap-2 flex-wrap mt-3">
|
||||
<ButtonStyled v-if="visibleQuickReplies.length > 0">
|
||||
<OverflowMenu :options="visibleQuickReplies">
|
||||
Reply presets
|
||||
<ChevronDownIcon />
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="green" color-fill="text">
|
||||
<button
|
||||
:disabled="setModerationStatusMutation.isPending.value"
|
||||
@click="handleSetModerationStatus('approved')"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="
|
||||
setModerationStatusMutation.isPending.value &&
|
||||
pendingModerationStatusKind === 'approved'
|
||||
"
|
||||
class="size-4 shrink-0 animate-spin"
|
||||
/>
|
||||
<CheckCircleIcon v-else />
|
||||
Approve
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" color-fill="text">
|
||||
<button
|
||||
:disabled="setModerationStatusMutation.isPending.value"
|
||||
@click="handleSetModerationStatus('bad_proof')"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="
|
||||
setModerationStatusMutation.isPending.value &&
|
||||
pendingModerationStatusKind === 'bad_proof'
|
||||
"
|
||||
class="size-4 shrink-0 animate-spin"
|
||||
/>
|
||||
<XCircleIcon v-else />
|
||||
Reject: Insufficient proof
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" color-fill="text">
|
||||
<button
|
||||
:disabled="setModerationStatusMutation.isPending.value"
|
||||
@click="handleSetModerationStatus('not_allowed')"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="
|
||||
setModerationStatusMutation.isPending.value &&
|
||||
pendingModerationStatusKind === 'not_allowed'
|
||||
"
|
||||
class="size-4 shrink-0 animate-spin"
|
||||
/>
|
||||
<ReportIcon v-else />
|
||||
Reject: Not allowed
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="isEditingModerationReview" type="outlined">
|
||||
<button
|
||||
:disabled="setModerationStatusMutation.isPending.value"
|
||||
@click="cancelModerationReviewEditing"
|
||||
>
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap mt-3">
|
||||
<ButtonStyled>
|
||||
<button @click="handleAddToGlobalDatabase">
|
||||
<ScaleIcon /> Add files to database...
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleAddToExistingEntry">
|
||||
<ScaleIcon /> Add to existing entry...
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="group.attribution?.moderation_status?.reason"
|
||||
class="flex flex-col gap-2 mt-3"
|
||||
>
|
||||
<div
|
||||
class="markdown-body"
|
||||
v-html="renderString(group.attribution?.moderation_status?.reason)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
isModerator && !isEditingModerationReview && group.attribution?.moderation_status
|
||||
"
|
||||
class="ml-auto"
|
||||
>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="startEditingModerationReview">
|
||||
<ScaleIcon />
|
||||
{{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AttributionDisplay>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-2xl p-4 mt-2 border-[1px] border-solid border-surface-5 flex flex-col gap-3"
|
||||
>
|
||||
<AttributionEditor
|
||||
:project-id="projectId"
|
||||
:group-id="group.id"
|
||||
:attribution="group.attribution"
|
||||
:flame-project-url="group.flame_project?.url"
|
||||
:resume-key="editorResumeKey"
|
||||
@updated="handleEditorUpdated"
|
||||
@saved="stopEditing"
|
||||
@cancel="stopEditing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
<AddFilesToAttributionGroupModal
|
||||
ref="addFilesModalRef"
|
||||
:group-id="group.id"
|
||||
:pending="assignFilesMutation.isPending.value"
|
||||
@confirm="handleConfirmAddFiles"
|
||||
/>
|
||||
<AddToGlobalPermissionsDatabaseModal
|
||||
ref="addToGlobalModalRef"
|
||||
:group="group"
|
||||
@success="handleModerationDbUpdated"
|
||||
/>
|
||||
<AddToExistingExternalProjectModal
|
||||
ref="addToExistingModalRef"
|
||||
:group="group"
|
||||
@success="handleModerationDbUpdated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon } from '@modrinth/assets'
|
||||
|
||||
import { defineMessage, useVIntl } from '../../composables/i18n'
|
||||
|
||||
defineProps<{
|
||||
href: string
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const label = defineMessage({
|
||||
id: 'external-files.permissions-card.original-project-page',
|
||||
defaultMessage: 'Original project',
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<a
|
||||
class="text-link flex items-center outline-offset-[4px] rounded-sm truncate"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:href="href"
|
||||
>
|
||||
<span class="truncate">{{ formatMessage(label) }}</span>
|
||||
<ExternalIcon class="size-3 shrink-0 mb-2 ml-1" />
|
||||
</a>
|
||||
</template>
|
||||
@@ -0,0 +1,391 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import { defineMessage, type MessageDescriptor } from '../../composables/i18n'
|
||||
|
||||
export const permissionKinds: Labrinth.Attribution.Internal.AttributionResolutionKind[] = [
|
||||
'license',
|
||||
'my_project',
|
||||
'special_permissions',
|
||||
'no_permission',
|
||||
'globally_allowed',
|
||||
]
|
||||
|
||||
/** Combobox value when the user picks a non-SPDX custom license (stored as `{ name }`). */
|
||||
export const CUSTOM_LICENSE_VALUE = '__custom__'
|
||||
|
||||
export type ProjectPermissionField =
|
||||
| 'license_id'
|
||||
| 'custom_license'
|
||||
| 'link_to_work'
|
||||
| 'notes'
|
||||
| 'image_urls'
|
||||
|
||||
export type AttributionProofRequirement = 'explanation_or_images' | 'images' | null
|
||||
|
||||
type PermissionReasonConfig = {
|
||||
label: MessageDescriptor
|
||||
description: MessageDescriptor
|
||||
notesLabel: MessageDescriptor | null
|
||||
notesDescription: MessageDescriptor | null
|
||||
notesShowsOptional: boolean
|
||||
proofImagesDescription: MessageDescriptor | null
|
||||
proofRequirement: AttributionProofRequirement
|
||||
proofValidationError?: MessageDescriptor
|
||||
automaticDescription?: MessageDescriptor
|
||||
fields: ProjectPermissionField[]
|
||||
}
|
||||
|
||||
export const PERMISSION_REASONS: Record<
|
||||
Labrinth.Attribution.Internal.AttributionResolutionKind,
|
||||
PermissionReasonConfig
|
||||
> = {
|
||||
license: {
|
||||
label: defineMessage({
|
||||
id: 'external-files.permissions-card.reason.license',
|
||||
defaultMessage: 'License',
|
||||
}),
|
||||
description: defineMessage({
|
||||
id: 'external-files.permissions-card.reason.license.description',
|
||||
defaultMessage: 'The license of this work permits you to redistribute it in your modpack.',
|
||||
}),
|
||||
notesLabel: defineMessage({
|
||||
id: 'external-files.permissions-card.explanation-label',
|
||||
defaultMessage: 'Explanation',
|
||||
}),
|
||||
notesDescription: null,
|
||||
notesShowsOptional: true,
|
||||
proofImagesDescription: defineMessage({
|
||||
id: 'external-files.permissions-card.reason.license.proof-images-description',
|
||||
defaultMessage: 'Upload supporting documentation related to this license.',
|
||||
}),
|
||||
proofRequirement: null,
|
||||
fields: ['license_id', 'custom_license', 'link_to_work', 'notes', 'image_urls'] as const,
|
||||
},
|
||||
my_project: {
|
||||
label: defineMessage({
|
||||
id: 'external-files.permissions-card.reason.my-project',
|
||||
defaultMessage: 'My project',
|
||||
}),
|
||||
description: defineMessage({
|
||||
id: 'external-files.permissions-card.reason.my-project.description',
|
||||
defaultMessage: 'Original work created by you.',
|
||||
}),
|
||||
notesLabel: defineMessage({
|
||||
id: 'external-files.permissions-card.explanation-label',
|
||||
defaultMessage: 'Explanation',
|
||||
}),
|
||||
notesDescription: defineMessage({
|
||||
id: 'external-files.permissions-card.explanation-description',
|
||||
defaultMessage: 'A short explanation or proof images are required.',
|
||||
}),
|
||||
notesShowsOptional: false,
|
||||
proofImagesDescription: defineMessage({
|
||||
id: 'external-files.permissions-card.reason.my-project.proof-images-description',
|
||||
defaultMessage: 'Upload files that help verify you created this work.',
|
||||
}),
|
||||
proofRequirement: 'explanation_or_images',
|
||||
fields: ['license_id', 'custom_license', 'notes', 'image_urls'] as const,
|
||||
},
|
||||
special_permissions: {
|
||||
label: defineMessage({
|
||||
id: 'external-files.permissions-card.reason.special-permission',
|
||||
defaultMessage: 'Special permission',
|
||||
}),
|
||||
description: defineMessage({
|
||||
id: 'external-files.permissions-card.reason.special-permission.description',
|
||||
defaultMessage:
|
||||
'You have obtained special permission to redistribute this work in your modpack.',
|
||||
}),
|
||||
notesLabel: null,
|
||||
notesDescription: null,
|
||||
notesShowsOptional: true,
|
||||
proofImagesDescription: null,
|
||||
proofRequirement: 'explanation_or_images',
|
||||
proofValidationError: defineMessage({
|
||||
id: 'external-files.permissions-card.error.notes-or-images-required',
|
||||
defaultMessage: 'Please provide a note or upload at least one proof image.',
|
||||
}),
|
||||
fields: ['link_to_work', 'notes', 'image_urls'] as const,
|
||||
},
|
||||
no_permission: {
|
||||
label: defineMessage({
|
||||
id: 'external-files.permissions-card.reason.no-permission',
|
||||
defaultMessage: 'No permission',
|
||||
}),
|
||||
description: defineMessage({
|
||||
id: 'external-files.permissions-card.reason.no-permission.description',
|
||||
defaultMessage: "You don't have permission to use this work.",
|
||||
}),
|
||||
notesLabel: null,
|
||||
notesDescription: null,
|
||||
notesShowsOptional: true,
|
||||
proofImagesDescription: null,
|
||||
proofRequirement: null,
|
||||
automaticDescription: defineMessage({
|
||||
id: 'external-files.permissions-card.reason.no-permission.automatic.description',
|
||||
defaultMessage:
|
||||
"We've seen this file before and its license does not normally allow redistribution.",
|
||||
}),
|
||||
fields: ['notes'] as const,
|
||||
},
|
||||
globally_allowed: {
|
||||
label: defineMessage({
|
||||
id: 'external-files.permissions-card.reason.globally-allowed',
|
||||
defaultMessage: 'Automatically attributed',
|
||||
}),
|
||||
description: defineMessage({
|
||||
id: 'external-files.permissions-card.reason.globally-allowed.description',
|
||||
defaultMessage:
|
||||
"We've seen this file before and have prepared an attribution for you. If something seems wrong, please contact Modrinth Support via the Help Center",
|
||||
}),
|
||||
notesLabel: null,
|
||||
notesDescription: null,
|
||||
notesShowsOptional: false,
|
||||
proofImagesDescription: null,
|
||||
proofRequirement: null,
|
||||
fields: ['link_to_work'] as const,
|
||||
},
|
||||
}
|
||||
|
||||
export function isAttributionProofValid(
|
||||
requirement: AttributionProofRequirement,
|
||||
notes: string,
|
||||
imageUrls: readonly string[],
|
||||
): boolean {
|
||||
switch (requirement) {
|
||||
case 'explanation_or_images':
|
||||
return notes.trim().length > 0 || imageUrls.length > 0
|
||||
case 'images':
|
||||
return imageUrls.length > 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function attributionProofValidationError(
|
||||
requirement: AttributionProofRequirement,
|
||||
notes: string,
|
||||
imageUrls: readonly string[],
|
||||
validationError?: MessageDescriptor,
|
||||
): MessageDescriptor | null {
|
||||
if (isAttributionProofValid(requirement, notes, imageUrls)) {
|
||||
return null
|
||||
}
|
||||
if (validationError) {
|
||||
return validationError
|
||||
}
|
||||
switch (requirement) {
|
||||
case 'explanation_or_images':
|
||||
return defineMessage({
|
||||
id: 'external-files.permissions-card.error.explanation-or-images-required',
|
||||
defaultMessage: 'Please provide an explanation or upload at least one proof image.',
|
||||
})
|
||||
case 'images':
|
||||
return defineMessage({
|
||||
id: 'external-files.permissions-card.error.proof-images-required',
|
||||
defaultMessage: 'Please upload at least one proof image.',
|
||||
})
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isHttpUrl(raw: string): boolean {
|
||||
const s = raw.trim()
|
||||
if (!s) return false
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(s)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
}
|
||||
|
||||
export function isCustomAttributionLicense(
|
||||
license: Labrinth.Attribution.Internal.AttributionLicense,
|
||||
): license is { name: string } {
|
||||
return typeof license === 'object' && license !== null && 'name' in license
|
||||
}
|
||||
|
||||
export function parseAttributionLicense(
|
||||
license: Labrinth.Attribution.Internal.AttributionLicense | undefined,
|
||||
): {
|
||||
spdx: string
|
||||
custom: string
|
||||
} {
|
||||
if (!license) {
|
||||
return { spdx: '', custom: '' }
|
||||
}
|
||||
if (isCustomAttributionLicense(license)) {
|
||||
return { spdx: CUSTOM_LICENSE_VALUE, custom: license.name }
|
||||
}
|
||||
return { spdx: license, custom: '' }
|
||||
}
|
||||
|
||||
export function isAutomaticNoPermissionAttribution(
|
||||
attribution: Labrinth.Attribution.Internal.AttributionResolution | null | undefined,
|
||||
attributedBy: string | null | undefined,
|
||||
): boolean {
|
||||
return attribution?.kind === 'no_permission' && attributedBy == null
|
||||
}
|
||||
|
||||
export function attributionLinkToWork(
|
||||
attribution: Labrinth.Attribution.Internal.AttributionResolution | null | undefined,
|
||||
): string | undefined {
|
||||
if (!attribution) {
|
||||
return undefined
|
||||
}
|
||||
switch (attribution.kind) {
|
||||
case 'license':
|
||||
case 'special_permissions':
|
||||
case 'globally_allowed':
|
||||
return attribution.link_to_work
|
||||
case 'no_permission':
|
||||
return attribution.link_to_work
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function parseInitialAttribution(
|
||||
raw: unknown,
|
||||
): Labrinth.Attribution.Internal.AttributionResolution | null {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null
|
||||
}
|
||||
const obj = raw as Record<string, unknown>
|
||||
const kind = obj.kind
|
||||
if (typeof kind !== 'string' || !(permissionKinds as string[]).includes(kind)) {
|
||||
return null
|
||||
}
|
||||
return obj as Labrinth.Attribution.Internal.AttributionResolution
|
||||
}
|
||||
|
||||
const unnamedMultiAttributionGroupTitle = defineMessage({
|
||||
id: 'external-files.permissions-card.unnamed-multi-group-title',
|
||||
defaultMessage: '{filename} + {count} more',
|
||||
})
|
||||
|
||||
const fallbackAttributionGroupTitle = defineMessage({
|
||||
id: 'external-files.permissions-card.fallback-group-title',
|
||||
defaultMessage: 'Attribution group {id}',
|
||||
})
|
||||
|
||||
export function createAttributionGroupTitle(
|
||||
group: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
formatMessage: (descriptor: MessageDescriptor, values?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
const fileCount = group.files?.length ?? 0
|
||||
if (group.flame_project?.title) {
|
||||
return group.flame_project.title
|
||||
}
|
||||
const firstFileName = group.files?.[0]?.name ?? group.files?.[0]?.sha1 ?? ''
|
||||
if (firstFileName) {
|
||||
const base = firstFileName.split('/').pop() ?? firstFileName
|
||||
if (fileCount === 1) {
|
||||
return base
|
||||
}
|
||||
return formatMessage(unnamedMultiAttributionGroupTitle, {
|
||||
filename: base,
|
||||
count: fileCount - 1,
|
||||
})
|
||||
}
|
||||
return formatMessage(fallbackAttributionGroupTitle, { id: group.id })
|
||||
}
|
||||
|
||||
export function moderatorAttributionGroupTitle(
|
||||
group: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
): string {
|
||||
const fileCount = group.files?.length ?? 0
|
||||
if (group.flame_project?.title) {
|
||||
return group.flame_project.title
|
||||
}
|
||||
const firstFileName = group.files?.[0]?.name ?? group.files?.[0]?.sha1 ?? ''
|
||||
if (firstFileName) {
|
||||
const base = firstFileName.split('/').pop() ?? firstFileName
|
||||
if (fileCount === 1) {
|
||||
return base
|
||||
}
|
||||
return `${base} + ${fileCount - 1} more`
|
||||
}
|
||||
return `Attribution group ${group.id}`
|
||||
}
|
||||
|
||||
export const MODERATOR_ATTRIBUTION_KIND_LABELS: Record<
|
||||
Labrinth.Attribution.Internal.AttributionResolutionKind,
|
||||
string
|
||||
> = {
|
||||
license: 'License',
|
||||
my_project: 'My project',
|
||||
special_permissions: 'Special permission',
|
||||
no_permission: 'No permission',
|
||||
globally_allowed: 'Automatically attributed',
|
||||
}
|
||||
|
||||
export type ExternalLicenseStatus = Labrinth.ExternalProjects.Internal.ExternalLicenseStatus
|
||||
|
||||
export function attributionKindToDefaultExternalStatus(
|
||||
kind: Labrinth.Attribution.Internal.AttributionResolutionKind,
|
||||
): ExternalLicenseStatus | undefined {
|
||||
if (kind === 'no_permission') {
|
||||
return 'no'
|
||||
}
|
||||
if (kind === 'license') {
|
||||
return 'yes'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function buildExternalLicenseProofFromAttribution(
|
||||
attribution: Labrinth.Attribution.Internal.AttributionResolution,
|
||||
): string {
|
||||
const parts: string[] = []
|
||||
const notes = attribution.notes?.trim()
|
||||
if (notes) {
|
||||
parts.push(notes)
|
||||
}
|
||||
for (const url of attribution.image_urls ?? []) {
|
||||
parts.push(``)
|
||||
}
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
export function groupLinkForExternalLicense(
|
||||
group: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
attribution: Labrinth.Attribution.Internal.AttributionResolution | null,
|
||||
): string {
|
||||
return attributionLinkToWork(attribution) ?? group.flame_project?.url ?? ''
|
||||
}
|
||||
|
||||
export const MODERATION_DB_BADGE: Record<
|
||||
Labrinth.ExternalProjects.Internal.ExternalLicenseStatus,
|
||||
{
|
||||
color?: string
|
||||
label: string
|
||||
}
|
||||
> = {
|
||||
no: {
|
||||
color: 'var(--color-red)',
|
||||
label: 'Restrictive license',
|
||||
},
|
||||
'permanent-no': {
|
||||
color: 'var(--color-purple)',
|
||||
label: 'Prohibited content',
|
||||
},
|
||||
yes: {
|
||||
color: 'var(--color-green)',
|
||||
label: 'Permissive license',
|
||||
},
|
||||
'with-attribution': {
|
||||
color: 'var(--color-green)',
|
||||
label: 'Permissive license',
|
||||
},
|
||||
'with-attribution-and-source': {
|
||||
color: 'var(--color-green)',
|
||||
label: 'Permissive license',
|
||||
},
|
||||
unidentified: {
|
||||
label: 'Unidentified',
|
||||
},
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
export { default as ExternalProjectLicenseStateTag } from './ExternalProjectLicenseStateTag.vue'
|
||||
export { default as AddFilesToAttributionGroupModal } from './AddFilesToAttributionGroupModal.vue'
|
||||
export { default as AddToExistingExternalProjectModal } from './AddToExistingExternalProjectModal.vue'
|
||||
export { default as AddToGlobalPermissionsDatabaseModal } from './AddToGlobalPermissionsDatabaseModal.vue'
|
||||
export { default as AttributionDisplay } from './AttributionDisplay.vue'
|
||||
export { default as AttributionEditor } from './AttributionEditor.vue'
|
||||
export { default as AttributionModerationDbBadge } from './AttributionModerationDbBadge.vue'
|
||||
export { default as AttributionStatusTag } from './AttributionStatusTag.vue'
|
||||
export { default as ExternalProjectLookupCard } from './ExternalProjectLookupCard.vue'
|
||||
export { default as ExternalProjectPermissionsCard } from './ExternalProjectPermissionsCard.vue'
|
||||
export type { ExternalLicenseStatus } from './types.ts'
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export type ExternalLicenseStatus =
|
||||
| 'yes'
|
||||
| 'with-attribution-and-source'
|
||||
| 'with-attribution'
|
||||
| 'no'
|
||||
| 'permanent-no'
|
||||
| 'unidentified'
|
||||
Reference in New Issue
Block a user