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:
Prospector
2026-06-23 21:27:51 +02:00
committed by GitHub
co-authored by coolbot100s aecsocket aecsocket
parent a686a93858
commit e7926083fb
315 changed files with 11106 additions and 2209 deletions
@@ -80,7 +80,7 @@ import ButtonStyled from './ButtonStyled.vue'
const props = withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation'
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning'
header?: string
body?: string
showActionsUnderneath?: boolean
@@ -139,6 +139,7 @@ const timestampTooltip = computed(() => {
const typeClasses = {
info: 'border-brand-blue bg-bg-blue',
warning: 'border-brand-orange bg-bg-orange',
'circle-warning': 'border-brand-orange bg-bg-orange',
critical: 'border-brand-red bg-bg-red',
success: 'border-brand-green bg-bg-green',
moderation: 'border-brand-orange bg-bg-orange',
@@ -147,6 +148,7 @@ const typeClasses = {
const iconClasses = {
info: 'text-brand-blue',
warning: 'text-brand-orange',
'circle-warning': 'text-brand-orange',
critical: 'text-brand-red',
success: 'text-brand-green',
moderation: 'text-brand-orange',
@@ -155,6 +157,7 @@ const iconClasses = {
const buttonColors = {
info: 'blue',
warning: 'orange',
'circle-warning': 'orange',
critical: 'red',
success: 'green',
moderation: 'orange',
@@ -163,6 +166,7 @@ const buttonColors = {
const progressTrackClasses = {
info: 'bg-brand-blue/20',
warning: 'bg-brand-orange/20',
'circle-warning': 'bg-brand-orange/20',
critical: 'bg-brand-red/20',
success: 'bg-brand-green/20',
moderation: 'bg-brand-orange/20',
@@ -171,6 +175,7 @@ const progressTrackClasses = {
const progressFillClasses = {
info: 'bg-brand-blue',
warning: 'bg-brand-orange',
'circle-warning': 'bg-brand-orange',
critical: 'bg-brand-red',
success: 'bg-brand-green',
blue: 'bg-brand-blue',
+18 -5
View File
@@ -6,16 +6,23 @@
"
:to="to"
v-bind="$attrs"
:class="linkClass"
>
<slot />
</router-link>
<a v-else-if="typeof to === 'string' && to?.startsWith('http')" :href="to" v-bind="$attrs">
<a
v-else-if="typeof to === 'string' && to?.startsWith('http')"
:href="to"
v-bind="$attrs"
:class="linkClass"
>
<slot />
</a>
<button
v-else-if="typeof to === 'function'"
v-bind="$attrs"
class="inline bg-transparent border-none p-0 m-0 cursor-pointer"
:class="linkClass"
@click="to()"
>
<slot />
@@ -26,10 +33,16 @@
</template>
<script setup lang="ts">
defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
to: any
}>()
withDefaults(
defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
to: any
linkClass?: string
}>(),
{
linkClass: '',
},
)
defineOptions({
inheritAttrs: false,
@@ -1,5 +1,6 @@
<template>
<button
type="button"
class="group bg-transparent border-none p-0 m-0 flex items-center text-left gap-3 checkbox-outer outline-offset-4 text-contrast"
:disabled="disabled"
:class="
@@ -96,7 +96,7 @@ label {
grid-gap: 0.5rem;
background-color: var(--color-button-bg);
border-radius: var(--radius-sm);
border: dashed 2px var(--color-contrast);
border: dashed 2px var(--color-secondary);
cursor: pointer;
color: var(--color-contrast);
}
@@ -14,7 +14,7 @@
<div
v-if="isDivider(option)"
:key="`divider-${index}`"
class="h-px mx-3 my-2 bg-surface-5"
class="h-px mx-[0.625rem] my-2 bg-surface-5"
></div>
<Button
v-else
@@ -144,9 +144,6 @@ defineExpose({ open, close })
--text-color: var(--color-base);
--background-color: transparent;
justify-content: flex-start;
&:not(:last-child) {
margin-bottom: var(--gap-xs);
}
padding: 0.55rem 0.625rem;
}
</style>
+1 -1
View File
@@ -32,7 +32,7 @@
<th
v-for="column in columns"
:key="column.key"
class="h-14 first:pl-4 last:pr-4"
class="h-12 first:pl-4 last:pr-4"
:class="[
`text-${column.align ?? 'left'}`,
column.enableSorting ? 'cursor-pointer select-none' : '',
@@ -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(`![proof image](${url})`)
}
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'
@@ -123,7 +123,11 @@
<slot> You just lost the game.</slot>
</div>
<div v-if="$slots.actions" class="p-4 pt-0">
<div
v-if="$slots.actions"
:class="{ 'pt-4 border-0 border-t border-solid border-surface-5': actionsDivider }"
class="p-4 pt-0"
>
<slot name="actions" />
</div>
</div>
@@ -178,6 +182,7 @@ const props = withDefaults(
width?: string
/** Disables all close actions (close button, ESC key, click outside). */
disableClose?: boolean
actionsDivider?: boolean
}>(),
{
type: true,
@@ -200,6 +205,7 @@ const props = withDefaults(
maxWidth: undefined,
width: undefined,
disableClose: false,
actionsDivider: false,
},
)
@@ -0,0 +1,97 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ClientIcon, MonitorSmartphoneIcon, ServerIcon, UserIcon } from '@modrinth/assets'
import { type Component, computed } from 'vue'
import { defineMessage, type MessageDescriptor, useVIntl } from '#ui/composables/i18n.ts'
import TagItem from '../base/TagItem.vue'
const { formatMessage } = useVIntl()
const props = defineProps<{
environment: Labrinth.Projects.v3.Environment
}>()
type EnvironmentTag = {
icon: Component
message: MessageDescriptor
environments: Labrinth.Projects.v3.Environment[]
}
const environmentTags: EnvironmentTag[] = [
{
icon: ClientIcon,
message: defineMessage({
id: `project.about.compatibility.environments.client-side`,
defaultMessage: 'Client-side',
}),
environments: [
'client_only',
'client_only_server_optional',
'client_or_server',
'client_or_server_prefers_both',
],
},
{
icon: ServerIcon,
message: defineMessage({
id: `project.about.compatibility.environments.server-side`,
defaultMessage: 'Server-side',
}),
environments: [
'server_only',
'server_only_client_optional',
'client_or_server',
'client_or_server_prefers_both',
],
},
{
icon: ServerIcon,
message: defineMessage({
id: `project.about.compatibility.environments.dedicated-servers-only`,
defaultMessage: 'Dedicated servers only',
}),
environments: ['dedicated_server_only'],
},
{
icon: UserIcon,
message: defineMessage({
id: `project.about.compatibility.environments.singleplayer-only`,
defaultMessage: 'Singleplayer only',
}),
environments: ['singleplayer_only'],
},
{
icon: UserIcon,
message: defineMessage({
id: `project.about.compatibility.environments.singleplayer`,
defaultMessage: 'Singleplayer',
}),
environments: ['server_only'],
},
{
icon: MonitorSmartphoneIcon,
message: defineMessage({
id: `project.about.compatibility.environments.client-and-server`,
defaultMessage: 'Client and server',
}),
environments: [
'client_and_server',
'client_only_server_optional',
'server_only_client_optional',
'client_or_server_prefers_both',
],
},
]
const tags = computed(() => {
return environmentTags.filter((x) => x.environments.includes(props.environment ?? 'unknown'))
})
</script>
<template>
<TagItem v-for="tag in tags" :key="`environment-tag-${tag.message.id}`">
<component :is="tag.icon" />
{{ formatMessage(tag.message) }}
</TagItem>
</template>
@@ -110,14 +110,27 @@
</div>
</div>
<div
class="pointer-events-none relative z-[1] flex flex-col justify-center overflow-hidden min-w-32"
class="pointer-events-none relative z-[1] flex flex-col gap-1 justify-center overflow-hidden min-w-32"
:class="{
'group-hover:underline': !!versionLink,
}"
title="`${version.version_number} - ${version.name}`"
>
<div class="font-bold text-contrast text-ellipsis overflow-hidden">
{{ version.version_number }}
<div class="flex items-center gap-2">
<div class="font-bold text-contrast text-ellipsis overflow-hidden">
{{ version.version_number }}
</div>
<div
v-if="version.files_missing_attribution"
v-tooltip="formatMessage(messages.withheldTooltip)"
class="z-[1]"
:style="{
'--_bg-color': 'var(--color-orange-bg)',
'--_color': 'var(--color-orange)',
}"
>
<TagItem> <CircleAlertIcon /> {{ formatMessage(messages.withheld) }}</TagItem>
</div>
</div>
<div class="text-xs font-medium text-ellipsis overflow-hidden">
{{ version.name }}
@@ -260,7 +273,14 @@
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { CalendarIcon, DownloadIcon, getLoaderIcon, PlusIcon, StarIcon } from '@modrinth/assets'
import {
CalendarIcon,
CircleAlertIcon,
DownloadIcon,
getLoaderIcon,
PlusIcon,
StarIcon,
} from '@modrinth/assets'
import {
AutoLink,
ButtonStyled,
@@ -279,7 +299,7 @@ import { computed, type Ref, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRelativeTime } from '../../composables'
import { useVIntl } from '../../composables/i18n'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getEnvironmentTags } from './settings/environment/environments'
const { formatMessage } = useVIntl()
@@ -446,6 +466,17 @@ function updateQuery(newQueries: Record<string, string | string[] | undefined |
},
})
}
const messages = defineMessages({
withheld: {
id: 'project.versions.version.withheld',
defaultMessage: 'Withheld',
},
withheldTooltip: {
id: 'project.versions.version.withheld.tooltip',
defaultMessage: 'Version withheld due to missing permissions',
},
})
</script>
<style scoped>
.versions-grid-row {
@@ -2,7 +2,9 @@
<div v-if="project.versions?.length > 0" class="flex flex-col gap-3">
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
<section class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.minecraftJava) }}</h3>
<h3 class="text-primary !font-normal text-base m-0">
{{ formatMessage(messages.minecraftJava) }}
</h3>
<div class="flex flex-wrap gap-1">
<TagItem
v-for="version in getVersionsToDisplay(project, tags.gameVersions)"
@@ -13,7 +15,9 @@
</div>
</section>
<section v-if="project.project_type !== 'resourcepack'" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.platforms) }}</h3>
<h3 class="text-primary !font-normal text-base m-0">
{{ formatMessage(messages.platforms) }}
</h3>
<div class="flex flex-wrap gap-1">
<template v-if="noModpackLoader">
<TagItem class="border !border-solid border-surface-5 hover:no-underline">
@@ -34,12 +38,11 @@
</div>
</section>
<section v-if="showEnvironments" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.environments) }}</h3>
<h3 class="text-primary !font-normal text-base m-0">
{{ formatMessage(messages.environments) }}
</h3>
<div class="flex flex-wrap gap-1">
<TagItem v-for="tag in primaryEnvironmentTags" :key="`environment-tag-${tag.message.id}`">
<component :is="tag.icon" />
{{ formatMessage(tag.message) }}
</TagItem>
<EnvironmentTags :environment="primaryEnvironment" />
</div>
</section>
<section
@@ -100,18 +103,14 @@ import {
ServerIcon,
UserIcon,
} from '@modrinth/assets'
import { FormattedTag, TagItem } from '@modrinth/ui'
import type { EnvironmentV3, GameVersionTag, PlatformTag } from '@modrinth/utils'
import { FormattedTag, projectCompatibilityMessages, TagItem } from '@modrinth/ui'
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
import { getVersionsToDisplay } from '@modrinth/utils'
import { type Component, computed } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import {
defineMessage,
defineMessages,
type MessageDescriptor,
useVIntl,
} from '../../composables/i18n'
import { useVIntl } from '../../composables/i18n'
import EnvironmentTags from './EnvironmentTags.vue'
const { formatMessage } = useVIntl()
// TODO: anything in this component that uses the router will not work in the app. and this component is used in the app.
@@ -153,104 +152,9 @@ const showEnvironments = computed(
primaryEnvironment.value,
)
const primaryEnvironment = computed<EnvironmentV3 | undefined>(() =>
props.projectV3?.environment?.find((x) => x !== 'unknown'),
const primaryEnvironment = computed<Labrinth.Projects.v3.Environment>(
() => props.projectV3?.environment?.find((x) => x !== 'unknown') ?? 'unknown',
)
type EnvironmentTag = {
icon: Component
message: MessageDescriptor
environments: EnvironmentV3[]
}
const environmentTags: EnvironmentTag[] = [
{
icon: ClientIcon,
message: defineMessage({
id: `project.about.compatibility.environments.client-side`,
defaultMessage: 'Client-side',
}),
environments: [
'client_only',
'client_only_server_optional',
'client_or_server',
'client_or_server_prefers_both',
],
},
{
icon: ServerIcon,
message: defineMessage({
id: `project.about.compatibility.environments.server-side`,
defaultMessage: 'Server-side',
}),
environments: [
'server_only',
'server_only_client_optional',
'client_or_server',
'client_or_server_prefers_both',
],
},
{
icon: ServerIcon,
message: defineMessage({
id: `project.about.compatibility.environments.dedicated-servers-only`,
defaultMessage: 'Dedicated servers only',
}),
environments: ['dedicated_server_only'],
},
{
icon: UserIcon,
message: defineMessage({
id: `project.about.compatibility.environments.singleplayer-only`,
defaultMessage: 'Singleplayer only',
}),
environments: ['singleplayer_only'],
},
{
icon: UserIcon,
message: defineMessage({
id: `project.about.compatibility.environments.singleplayer`,
defaultMessage: 'Singleplayer',
}),
environments: ['server_only'],
},
{
icon: MonitorSmartphoneIcon,
message: defineMessage({
id: `project.about.compatibility.environments.client-and-server`,
defaultMessage: 'Client and server',
}),
environments: [
'client_and_server',
'client_only_server_optional',
'server_only_client_optional',
'client_or_server_prefers_both',
],
},
]
const primaryEnvironmentTags = computed(() => {
return primaryEnvironment.value
? environmentTags.filter((x) => x.environments.includes(primaryEnvironment.value ?? 'unknown'))
: []
})
const messages = defineMessages({
title: {
id: `project.about.compatibility.title`,
defaultMessage: 'Compatibility',
},
minecraftJava: {
id: `project.about.compatibility.game.minecraftJava`,
defaultMessage: 'Minecraft: Java Edition',
},
platforms: {
id: `project.about.compatibility.platforms`,
defaultMessage: 'Platforms',
},
environments: {
id: `project.about.compatibility.environments`,
defaultMessage: 'Supported environments',
},
})
const messages = projectCompatibilityMessages
</script>
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-3">
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
<h2 class="text-lg font-semibold m-0">{{ formatMessage(messages.title) }}</h2>
<div class="flex flex-col gap-3 font-semibold">
<template v-if="organization">
<AutoLink
@@ -10,10 +10,10 @@
>
<Avatar :src="organization.icon_url" :alt="organization.name" size="32px" />
<div class="flex flex-col flex-nowrap justify-center">
<span class="group-hover:underline">
<span class="group-hover:underline font-normal text-contrast">
{{ organization.name }}
</span>
<span class="text-secondary text-sm font-medium flex items-center gap-1"
<span class="text-sm font-normal flex items-center gap-1"
><OrganizationIcon /> {{ formatMessage(messages.organization) }}</span
>
</div>
@@ -30,7 +30,9 @@
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle />
<div class="flex flex-col">
<span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
<span class="min-w-0 overflow-hidden truncate">{{ member.user.username }}</span>
<span class="min-w-0 overflow-hidden font-normal text-contrast truncate">{{
member.user.username
}}</span>
<CrownIcon
v-if="member.is_owner"
v-tooltip="formatMessage(messages.owner)"
@@ -38,7 +40,7 @@
/>
<ExternalIcon v-if="linkTarget === '_blank'" />
</span>
<span class="text-secondary text-sm font-medium">{{ member.role }}</span>
<span class="text-sm font-normal">{{ member.role }}</span>
</div>
</AutoLink>
</div>
@@ -9,7 +9,7 @@
>
<template #title>
<slot name="header" :filter="filterType">
<h2>{{ filterType.formatted_name }}</h2>
<h2 class="font-semibold">{{ filterType.formatted_name }}</h2>
</slot>
</template>
<template
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { VersionChannel } from '@modrinth/utils'
import { computed } from 'vue'
import { commonMessages } from '#ui/utils/common-messages.ts'
import { useVIntl } from '../../composables/i18n'
import TagItem from '../base/TagItem.vue'
const { formatMessage } = useVIntl()
const props = defineProps<{
channel: VersionChannel
}>()
const RELEASE_CHANNELS = {
release: {
message: commonMessages.release,
bgColor: 'var(--color-green-bg)',
color: 'var(--color-green)',
},
beta: {
message: commonMessages.beta,
bgColor: 'var(--color-orange-bg)',
color: 'var(--color-orange)',
},
alpha: {
message: commonMessages.alpha,
bgColor: 'var(--color-red-bg)',
color: 'var(--color-red)',
},
}
const releaseChannel = computed(() => RELEASE_CHANNELS[props.channel])
</script>
<template>
<TagItem
class="py-1.5"
:style="{ '--_bg-color': releaseChannel.bgColor, '--_color': releaseChannel.color }"
>
{{ formatMessage(releaseChannel.message) }}
</TagItem>
</template>
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { InfoIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '#ui/composables/i18n.ts'
import AutoLink from '../base/AutoLink.vue'
import Avatar from '../base/Avatar.vue'
import type { DependencyContext } from './index.ts'
const { formatMessage } = useVIntl()
withDefaults(
defineProps<{
context: DependencyContext
dependencyLink?: string
linkTabbable?: boolean
}>(),
{
dependencyLink: undefined,
linkTabbable: false,
},
)
const messages = defineMessages({
anyVersion: {
id: 'version.section.dependencies.any-version',
defaultMessage: 'Any version',
},
anyCompatibleVersion: {
id: 'version.section.dependencies.any-compatible-version',
defaultMessage: 'Any compatible version',
},
unavailableVersion: {
id: 'version.section.dependencies.unavailable-version',
defaultMessage: 'Unavailable version',
},
unavailableVersionDescription: {
id: 'version.section.dependencies.unavailable-version.description',
defaultMessage: 'This version may have been deleted or is being reviewed by moderators.',
},
unavailableRequiredVersionDescription: {
id: 'version.section.dependencies.unavailable-version.description.required',
defaultMessage:
'The required version may have been deleted or is being reviewed by moderators.',
},
})
</script>
<template>
<div class="flex gap-3 items-center">
<AutoLink
:to="dependencyLink"
class="flex gap-3 items-center group"
:tabindex="linkTabbable ? undefined : '-1'"
>
<Avatar :src="context.project?.icon_url" alt="" size="48px" no-shadow />
<div class="flex flex-col gap-1">
<span
class="text-contrast font-medium"
:class="{ 'group-hover:underline': !!dependencyLink }"
>{{ context.project?.title ?? context.dependency.file_name }}</span
>
<span class="text-secondary flex items-center gap-1">
<template v-if="!context.dependency.version_id">
{{
formatMessage(
context.dependency.dependency_type === 'incompatible'
? messages.anyVersion
: messages.anyCompatibleVersion,
)
}}
</template>
<template v-else-if="context.version">
{{ context.version.version_number }}
</template>
<template v-else>
{{ formatMessage(messages.unavailableVersion) }}
<InfoIcon
v-tooltip="
formatMessage(
context.dependency.dependency_type === 'required'
? messages.unavailableRequiredVersionDescription
: messages.unavailableVersionDescription,
)
"
class="shrink-0"
/>
</template>
</span>
</div>
</AutoLink>
<div class="flex items-center gap-2 ml-auto">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,491 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, FileIcon, SearchIcon } from '@modrinth/assets'
import { capitalizeString, renderHighlightedString } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { useFormatBytes } from '#ui/composables/format-bytes.ts'
import { useFormatDateTime } from '#ui/composables/format-date-time.ts'
import { useCompactNumber, useFormatNumber } from '#ui/composables/format-number.ts'
import { useRelativeTime } from '#ui/composables/how-ago.ts'
import { defineMessage, defineMessages, useVIntl } from '#ui/composables/i18n.ts'
import { injectModrinthClient } from '#ui/providers/api-client.ts'
import {
commonMessages,
fileTypeMessages,
projectCompatibilityMessages,
} from '#ui/utils/common-messages.ts'
import AutoLink from '../base/AutoLink.vue'
import Avatar from '../base/Avatar.vue'
import StyledInput from '../base/StyledInput.vue'
import Table from '../base/Table.vue'
import TagItem from '../base/TagItem.vue'
import TagTagItem from '../base/TagTagItem.vue'
import EnvironmentTags from '../project/EnvironmentTags.vue'
import VersionChannelTag from './VersionChannelTag.vue'
import VersionDependencyItem from './VersionDependencyItem.vue'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatBytes = useFormatBytes()
const formatNumber = useFormatNumber()
const { formatCompactNumber } = useCompactNumber()
const props = defineProps<{
version: Labrinth.Versions.v3.Version
enrichment?: Labrinth.Projects.v2.DependencyInfo
dependencyLinkCreator: (context: DependencyContext) => string | undefined
members?: Labrinth.Projects.v3.TeamMember[]
userLinkCreator?: (user: Labrinth.Users.v3.User) => string | undefined
}>()
const api = injectModrinthClient()
const versionNumber = computed(() => props.version.version_number)
const versionSubtitle = computed(() => props.version.name)
const publishDate = computed(() => formatRelativeTime(props.version.date_published))
const publishDateTooltip = computed(() => formatDateTime(props.version.date_published))
const isModpack = computed(() => props.version.loaders.includes('mrpack'))
const platforms = computed(() =>
isModpack.value ? props.version.mrpack_loaders : props.version.loaders,
)
const noModpackLoader = computed(
() =>
(isModpack.value &&
props.version.mrpack_loaders?.length === 1 &&
props.version.mrpack_loaders?.[0] === 'minecraft') ||
props.version.mrpack_loaders?.length === 0,
)
const primaryFile = computed(
() => props.version.files?.find((file) => file.primary) ?? props.version.files?.[0] ?? {},
)
const promotedSupplementaryFiles = computed(() =>
props.version.files.filter(
(file) =>
file.file_type &&
['required-resource-pack', 'optional-resource-pack'].includes(file.file_type),
),
)
type DependencyContext = {
dependency: Labrinth.Versions.v3.Dependency
project?: Labrinth.Projects.v2.Project
version?: Labrinth.Versions.v2.Version
}
const dependencies = computed<DependencyContext[]>(() =>
props.version.dependencies.map(
(dep) =>
({
dependency: dep,
project: props.enrichment?.projects.find((x) => x.id === dep.project_id),
version: props.enrichment?.versions.find((x) => x.id === dep.version_id),
}) satisfies DependencyContext,
),
)
const requiredContent = computed(() =>
dependencies.value.filter((dep) => dep.dependency.dependency_type === 'required'),
)
const optionalContent = computed(() =>
dependencies.value.filter((dep) => dep.dependency.dependency_type === 'optional'),
)
const incompatibleContent = computed(() =>
dependencies.value.filter((dep) => dep.dependency.dependency_type === 'incompatible'),
)
const includedContent = computed(() =>
dependencies.value
.filter((context) => context.dependency.dependency_type === 'embedded')
.map((context) => ({
icon_url: context.project
? context.project.icon_url
: context.dependency.attribution?.icon_url,
name: context.project ? context.project.title : context.dependency.file_name,
version: context.version ? context.version.version_number : undefined,
link: context.project
? props.dependencyLinkCreator(context)
: context.dependency.attribution?.link,
hasProject: !!context.project,
})),
)
const contentTableColumns = computed(() => {
const cols = [
{
key: 'icon',
width: '3.75rem',
},
{
key: 'name',
label: formatMessage(defineMessage({ id: 'version.content.name', defaultMessage: 'Name' })),
},
]
if (includedContent.value.some((x) => x.version)) {
cols.push({
key: 'version',
label: formatMessage(
defineMessage({ id: 'version.content.version', defaultMessage: 'Version' }),
),
})
}
return cols
})
const supplementaryResourcesTableColumns = computed(() => [
{
key: 'file',
label: formatMessage(
defineMessage({ id: 'version.supplementary-resources.file', defaultMessage: 'File' }),
),
width: '20rem',
},
{
key: 'type',
label: formatMessage(
defineMessage({ id: 'version.supplementary-resources.type', defaultMessage: 'Type' }),
),
width: '14rem',
},
{
key: 'size',
label: formatMessage(
defineMessage({ id: 'version.supplementary-resources.size', defaultMessage: 'Size' }),
),
width: '5rem',
},
{
key: 'actions',
label: '',
width: '14rem',
},
])
const supplementaryResources = computed(() =>
props.version.files
.filter((file) => file.primary === false && file !== primaryFile.value)
.map((file) => ({
file: file,
})),
)
const messages = defineMessages({
compatibility: {
id: 'version.section.compatibility',
defaultMessage: 'Compatibility',
},
required: {
id: 'version.section.required-content',
defaultMessage: 'Required content',
},
optionalDeps: {
id: 'version.section.optional-dependencies',
defaultMessage: 'Optional dependencies',
},
knownIncompatibilities: {
id: 'version.section.known-incompatibilities',
defaultMessage: 'Known incompatibilities',
},
content: {
id: 'version.section.content',
defaultMessage: 'Content',
},
includedContent: {
id: 'version.section.included-content',
defaultMessage: 'Included content',
},
noModpackLoader: {
id: 'version.section.no-modpack-mod-loader',
defaultMessage: 'No mod loader',
},
changes: {
id: 'version.section.changes',
defaultMessage: 'Changes',
},
noChanges: {
id: 'version.section.no-changes',
defaultMessage: 'No changelog was provided.',
},
files: {
id: 'version.section.files',
defaultMessage: 'Files',
},
supplementaryResources: {
id: 'version.section.supplementary-resources',
defaultMessage: 'Supplementary resources',
},
searchContent: {
id: 'version.section.content.search-placeholder',
defaultMessage: 'Search content...',
},
})
const contentSearchQuery = ref('')
const formattedDownloads = computed(() => formatNumber(props.version.downloads))
const compactDownloads = computed(() => formatCompactNumber(props.version.downloads))
const authorMember = computed(
() => props.members?.find((member) => member.user.id === props.version.author_id)?.user,
)
const { data: externalAuthor, isLoading: loadingAuthor } = useQuery({
queryKey: ['user', props.version.author_id],
queryFn: () => api.labrinth.users_v3.get(props.version.author_id),
enabled: computed(() => !authorMember.value),
})
const author = computed(() => authorMember.value ?? externalAuthor.value)
const authorLink = computed(() =>
author.value ? props.userLinkCreator?.(author.value) : undefined,
)
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap gap-4 justify-between items-center">
<div class="flex flex-col gap-1.5">
<div class="flex flex-wrap gap-2 items-center">
<h2 class="m-0 leading-tight font-semibold">{{ versionNumber }}</h2>
<div>
<VersionChannelTag :channel="version.version_type" />
</div>
</div>
<div class="flex items-center gap-2 flex-col sm:flex-row">
<span>{{ versionSubtitle }}</span>
<span class="bg-surface-5 size-1.5 rounded-full hidden sm:block" />
<span class="flex items-center gap-2 sm:content">
<span v-tooltip="publishDateTooltip">{{ publishDate }}</span>
<span class="bg-surface-5 size-1.5 rounded-full" />
<span
v-tooltip="
compactDownloads !== formattedDownloads
? capitalizeString(
formatMessage(commonMessages.projectDownloads, {
count: props.version.downloads,
}),
)
: undefined
"
class="flex items-center gap-1"
>
<DownloadIcon class="size-5" /> {{ compactDownloads }}
</span>
</span>
</div>
<div
v-if="(author && authorLink) || loadingAuthor"
class="flex items-center gap-1 text-secondary"
>
Uploaded by
<AutoLink
v-if="author && authorLink"
:to="authorLink"
class="flex items-center gap-1 hover:underline"
>
<Avatar :src="author?.avatar_url" size="1.5rem" circle />
{{ author?.username }}
</AutoLink>
<div
v-else-if="loadingAuthor"
class="w-32 h-6 bg-surface-3 rounded-md animate-pulse flex"
></div>
</div>
</div>
<div class="flex gap-2 flex-wrap items-center">
<slot
name="headerActions"
:primary-file="primaryFile"
:promoted-files="promotedSupplementaryFiles"
/>
</div>
</div>
<hr class="w-full border-none h-[1px] bg-surface-4 m-0" />
<section v-if="requiredContent.length > 0" id="dependencies">
<h3 class="mt-0 mb-2 text-lg font-semibold">{{ formatMessage(messages.required) }}</h3>
<div class="grid md:grid-cols-2 gap-4">
<VersionDependencyItem
v-for="depContext in requiredContent"
:key="`required-dep-${depContext.dependency.version_id ?? depContext.dependency.project_id ?? depContext.dependency.file_name}`"
:context="depContext"
:dependency-link="dependencyLinkCreator(depContext)"
target="_blank"
class="bg-surface-3 border border-solid border-surface-4 p-4 rounded-2xl"
>
<slot name="dependencyActions" :dependency="depContext" />
</VersionDependencyItem>
</div>
</section>
<section id="compatibility">
<h3 class="mt-0 mb-2 text-lg font-semibold">{{ formatMessage(messages.compatibility) }}</h3>
<div
class="grid gap-3 md:gap-4 bg-surface-3 border border-solid border-surface-4 p-4 rounded-2xl md:p-0 md:border-0 md:bg-transparent"
:class="version.environment ? 'md:grid-cols-3' : 'md:grid-cols-2'"
>
<div
class="md:bg-surface-3 md:border md:border-solid md:border-surface-4 md:p-4 md:rounded-2xl"
>
{{ formatMessage(projectCompatibilityMessages.minecraftJava) }}
<div class="flex gap-1 flex-wrap mt-2">
<TagItem
v-for="gameVersion in version.game_versions"
:key="`version-compat-game-version-${gameVersion}`"
>{{ gameVersion }}</TagItem
>
</div>
</div>
<div
class="md:bg-surface-3 md:border md:border-solid md:border-surface-4 md:p-4 md:rounded-2xl"
>
{{
formatMessage(projectCompatibilityMessages.platformsPlural, {
count: platforms?.length ?? 0,
})
}}
<div class="flex gap-1 flex-wrap mt-2">
<template v-if="isModpack && noModpackLoader">
<TagItem class="border !border-solid border-surface-5 hover:no-underline">
{{ formatMessage(messages.noModpackLoader) }}
</TagItem>
</template>
<template v-else>
<TagTagItem
v-for="platform in platforms"
:key="`version-compat-platform-${platform}`"
:tag="platform"
/>
</template>
</div>
</div>
<div
v-if="version.environment"
class="md:bg-surface-3 md:border md:border-solid md:border-surface-4 md:p-4 md:rounded-2xl"
>
{{ formatMessage(projectCompatibilityMessages.environments) }}
<div class="flex gap-1 flex-wrap mt-2">
<EnvironmentTags :environment="version.environment" />
</div>
</div>
</div>
</section>
<section id="changes">
<h3 class="mt-0 mb-2 text-lg font-semibold">{{ formatMessage(messages.changes) }}</h3>
<div class="p-4 bg-surface-3 rounded-2xl flex border-solid border border-surface-4">
<div
v-if="version.changelog"
class="markdown-body"
v-html="renderHighlightedString(version.changelog)"
/>
<div v-else class="text-secondary">{{ formatMessage(messages.noChanges) }}</div>
</div>
</section>
<section v-if="optionalContent.length > 0" id="optional-dependencies">
<h3 class="mt-0 mb-2 text-lg font-semibold">{{ formatMessage(messages.optionalDeps) }}</h3>
<div class="grid md:grid-cols-2 gap-4">
<VersionDependencyItem
v-for="depContext in optionalContent"
:key="`optional-dep-${depContext.dependency.version_id ?? depContext.dependency.project_id ?? depContext.dependency.file_name}`"
:context="depContext"
:dependency-link="dependencyLinkCreator(depContext)"
target="_blank"
class="bg-surface-3 border border-solid border-surface-4 p-4 rounded-2xl"
>
<slot name="dependencyActions" :dependency="depContext" />
</VersionDependencyItem>
</div>
</section>
<section v-if="incompatibleContent.length > 0" id="known-incompatibilities">
<h3 class="mt-0 mb-2 text-lg font-semibold">
{{ formatMessage(messages.knownIncompatibilities) }}
</h3>
<div class="grid md:grid-cols-2 gap-4">
<VersionDependencyItem
v-for="depContext in incompatibleContent"
:key="`incompatible-dep-${depContext.dependency.version_id ?? depContext.dependency.project_id ?? depContext.dependency.file_name}`"
:context="depContext"
:dependency-link="dependencyLinkCreator(depContext)"
target="_blank"
class="bg-surface-3 border border-solid border-surface-4 p-4 rounded-2xl"
/>
</div>
</section>
<section v-if="includedContent?.length > 0" id="content">
<h3 class="mt-0 mb-2 text-lg font-semibold">
{{
formatMessage(
version.loaders.includes('mrpack') ? messages.content : messages.includedContent,
)
}}
</h3>
<Table :columns="contentTableColumns" :data="includedContent">
<template #header-actions>
<StyledInput
v-model="contentSearchQuery"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchContent)"
clearable
wrapper-class="w-full sm:w-64"
/>
</template>
<template #cell-icon="{ row }">
<AutoLink :to="row.link" tabindex="-1" class="flex" target="_blank">
<Avatar v-if="row.icon_url" :src="row.icon_url" alt="" size="2rem" />
<div v-else class="size-[2rem] flex items-center justify-center">
<FileIcon class="size-5 text-secondary" />
</div>
</AutoLink>
</template>
<template #cell-name="{ row }">
<AutoLink
:to="row.link"
class="flex w-fit"
link-class="hover:underline hover:text-contrast"
target="_blank"
>
{{ row.name }}
</AutoLink>
</template>
<template #cell-version="{ row }">
{{ (row.version ?? row.hasProject) ? formatMessage(commonMessages.unknownLabel) : '—' }}
</template>
</Table>
</section>
<section v-if="supplementaryResources?.length > 0" id="supplementary-resources">
<h3 class="mt-0 mb-2 text-lg font-semibold">
{{ formatMessage(messages.supplementaryResources) }}
</h3>
<Table :columns="supplementaryResourcesTableColumns" :data="supplementaryResources">
<template #cell-file="{ row }">
{{ row.file.filename }}
</template>
<template #cell-type="{ row }">
{{
formatMessage(
!row.file.file_type || row.file.file_type === 'unknown'
? commonMessages.unknownLabel
: fileTypeMessages[row.file.file_type],
)
}}
</template>
<template #cell-size="{ row }">
{{ formatBytes(row.file.size) }}
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<slot name="supplementaryResourceActions" :file="row.file" />
</div>
</template>
</Table>
</section>
</div>
</template>
<style>
.markdown-body ul {
padding-left: 1.25rem;
}
</style>
@@ -1,3 +1,13 @@
import type { Labrinth } from '@modrinth/api-client'
export { default as VersionChannelIndicator } from './VersionChannelIndicator.vue'
export { default as VersionDependencyItem } from './VersionDependencyItem.vue'
export { default as VersionFilterControl } from './VersionFilterControl.vue'
export { default as VersionPage } from './VersionPage.vue'
export { default as VersionSummary } from './VersionSummary.vue'
export type DependencyContext = {
dependency: Labrinth.Versions.v3.Dependency
project?: Labrinth.Projects.v2.Project
version?: Labrinth.Versions.v2.Version
}