External projects moderator database (#5692)

* Begin external projects moderator database frontend

* add copy link button

* begin project page permissions settings

* MEL database backend routes

* include filename in external files

* Hook up frontend external license page to backend

* more work on user-facing external projects stuff

* put user-facing stuff behind feature flag

* prepr

* clippy

---------

Co-authored-by: aecsocket <aecsocket@tutanota.com>
This commit is contained in:
Prospector
2026-05-04 09:31:37 -07:00
committed by GitHub
parent 565ac2cb53
commit e13a89dd72
40 changed files with 2099 additions and 95 deletions

View File

@@ -71,6 +71,9 @@
variant === 'outlined'
? 'bg-transparent border border-solid border-button-bg rounded-l-xl border-r-0'
: 'bg-surface-4 border-none rounded-xl',
{
'placeholder:text-sm': type === 'search',
},
]"
@input="onInput"
@focus="isFocused = true"

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import { TagItem } from '#ui/components'
import { externalProjectLicenseStatusMessages } from '#ui/utils'
import { useVIntl } from '../../composables/i18n'
import type { ExternalLicenseStatus } from './types.ts'
const props = defineProps<{
state: ExternalLicenseStatus
}>()
const { formatMessage } = useVIntl()
</script>
<template>
<TagItem>
{{ formatMessage(externalProjectLicenseStatusMessages[props.state]) }}
</TagItem>
</template>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import {
ClipboardCopyIcon,
CurseForgeIcon,
FileIcon,
LinkIcon,
UnknownIcon,
} from '@modrinth/assets'
import { Menu } from 'floating-vue'
import { computed } from 'vue'
import { ButtonStyled, CopyCode } from '#ui/components'
import ExternalProjectLicenseStateTag from './ExternalProjectLicenseStateTag.vue'
import type { ExternalLicenseStatus } from './types.ts'
const props = defineProps<{
title: string | null
link: string | null
state: ExternalLicenseStatus
proof: string | null
notes: string | null
last_updated?: string
last_updated_by?: string
cf_id?: number | null
files: {
sha1: string
name: string | null
}[]
}>()
const lastEditedText = computed(() =>
props.last_updated
? `Last edited on ${props.last_updated} by ${props.last_updated_by ?? 'unknown'}`
: '',
)
async function copyProjectLink() {
if (!props.link) return
await navigator.clipboard.writeText(props.link)
}
</script>
<template>
<div class="bg-surface-3 p-4 rounded-2xl flex flex-col gap-3">
<div class="flex gap-4 justify-between">
<div class="flex flex-col gap-2">
<span class="text-contrast font-semibold">{{ title }}</span>
<div v-if="!!link" class="flex gap-2 items-center">
<a
class="flex items-center gap-2 font-medium hover:underline focus:underline w-fit text-blue"
:href="link"
target="_blank"
>
<template v-if="link?.includes('curseforge.com')">
<CurseForgeIcon class="size-5 shrink-0" />
CurseForge project
</template>
<template v-else> <LinkIcon class="size-5 shrink-0" /> Project link </template>
</a>
<ButtonStyled circular type="transparent" size="small">
<button v-tooltip="'Copy link'" @click="copyProjectLink">
<ClipboardCopyIcon />
</button>
</ButtonStyled>
</div>
</div>
<slot name="actions" />
</div>
<div class="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2">
<div class="font-medium">Allowed:</div>
<div>
<ExternalProjectLicenseStateTag :state="state" />
</div>
<div class="font-medium">Proof:</div>
<div>{{ proof ?? 'N/A' }}</div>
<template v-if="cf_id">
<div class="font-medium">CurseForge ID:</div>
<CopyCode :text="`${cf_id}`" />
</template>
<div class="font-medium">Notes:</div>
<div>{{ notes ?? 'N/A' }}</div>
</div>
<div class="bg-surface-2 p-4 rounded-2xl flex flex-col gap-3">
<span class="text-contrast font-semibold">Files</span>
<span v-if="!(files?.length > 0)" class="text-secondary">
No files available for external project.
</span>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-2">
<Menu v-for="file in files" :key="file.sha1" :delay="{ hide: 50, show: 0 }">
<div
class="line-clamp-1 truncate px-2 py-1 flex gap-2 rounded-xl items-center border-solid border-2 border-surface-5 text-sm font-medium text-secondary"
>
<FileIcon class="shrink-0 size-4" /> {{ file.name ?? file.sha1 }}
</div>
<template #popper>
<div class="text-primary p-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2">
<div>First identified name:</div>
<div v-if="file.name" class="text-sm">
{{ file.name }}
</div>
<div v-else class="text-secondary flex items-center gap-2 text-sm">
<UnknownIcon /> Unknown
</div>
<div>SHA-1:</div>
<div class="text-sm"><CopyCode :text="file.sha1" /></div>
</div>
</template>
</Menu>
</div>
</div>
<div v-if="last_updated" class="pt-4 border-t-[1px] border-solid border-surface-5">
{{ lastEditedText }}
</div>
</div>
</template>

View File

@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ChevronDownIcon, ListBulletedIcon, SaveIcon, VersionIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { Admonition, ButtonStyled, Chips, Collapsible, Combobox, StyledInput } from '#ui/components'
defineProps<{
title: string
}>()
const collapsed = ref(true)
const selectedPermissionsType = ref('My project')
</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">
<button
class="flex grow m-0 appearance-none p-4 bg-transparent group transition-all"
@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>
</span>
</button>
<div class="flex items-center gap-2 m-4 ml-0">
<ButtonStyled type="outlined">
<button>
<ListBulletedIcon />
Versions
</button>
</ButtonStyled>
</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.
</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
</button>
</ButtonStyled>
</div>
</div>
</div>
</Collapsible>
</div>
</template>

View File

@@ -0,0 +1,3 @@
export { default as ExternalProjectLicenseStateTag } from './ExternalProjectLicenseStateTag.vue'
export { default as ExternalProjectLookupCard } from './ExternalProjectLookupCard.vue'
export type { ExternalLicenseStatus } from './types.ts'

View File

@@ -0,0 +1,7 @@
export type ExternalLicenseStatus =
| 'yes'
| 'with-attribution-and-source'
| 'with-attribution'
| 'no'
| 'permanent-no'
| 'unidentified'

View File

@@ -5,6 +5,7 @@ export * from './brand'
export * from './changelog'
export * from './chart'
export * from './content'
export * from './external_files'
export * from './modal'
export * from './nav'
export * from './page'

View File

@@ -758,6 +758,24 @@
"creation-flow.title.set-up-server": {
"defaultMessage": "Set up server"
},
"external-project-license-status.no": {
"defaultMessage": "No"
},
"external-project-license-status.permanent-no": {
"defaultMessage": "Permanent no"
},
"external-project-license-status.unidentified": {
"defaultMessage": "Unidentified"
},
"external-project-license-status.with-attribution": {
"defaultMessage": "With attribution"
},
"external-project-license-status.with-attribution-and-source": {
"defaultMessage": "With attribution and source"
},
"external-project-license-status.yes": {
"defaultMessage": "Yes"
},
"files.conflict-modal.header": {
"defaultMessage": "Extract summary"
},
@@ -2789,6 +2807,9 @@
"project.settings.upload.title": {
"defaultMessage": "Upload"
},
"project.settings.versions.permissions": {
"defaultMessage": "Permissions"
},
"project.settings.versions.title": {
"defaultMessage": "Versions"
},

View File

@@ -952,6 +952,10 @@ export const commonProjectSettingsMessages = defineMessages({
id: 'project.settings.versions.title',
defaultMessage: 'Versions',
},
permissions: {
id: 'project.settings.versions.permissions',
defaultMessage: 'Permissions',
},
view: {
id: 'project.settings.view.title',
defaultMessage: 'View',
@@ -1090,3 +1094,30 @@ export const paymentMethodMessages = defineMessages({
defaultMessage: 'Charities',
},
})
export const externalProjectLicenseStatusMessages = defineMessages({
yes: {
id: 'external-project-license-status.yes',
defaultMessage: 'Yes',
},
'with-attribution-and-source': {
id: 'external-project-license-status.with-attribution-and-source',
defaultMessage: 'With attribution and source',
},
'with-attribution': {
id: 'external-project-license-status.with-attribution',
defaultMessage: 'With attribution',
},
no: {
id: 'external-project-license-status.no',
defaultMessage: 'No',
},
'permanent-no': {
id: 'external-project-license-status.permanent-no',
defaultMessage: 'Permanent no',
},
unidentified: {
id: 'external-project-license-status.unidentified',
defaultMessage: 'Unidentified',
},
})