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:
@@ -47,6 +47,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
showDiscoverProjectButtons: false,
|
||||
useV1ContentTabAPI: true,
|
||||
labrinthApiCanary: false,
|
||||
dismissedExternalProjectsInfo: false,
|
||||
modpackPermissionsPage: false,
|
||||
} as const)
|
||||
|
||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||
|
||||
@@ -323,6 +323,11 @@
|
||||
color: 'orange',
|
||||
link: '/moderation/reports',
|
||||
},
|
||||
{
|
||||
id: 'external-projects',
|
||||
color: 'orange',
|
||||
link: '/moderation/external-projects',
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
},
|
||||
@@ -377,6 +382,9 @@
|
||||
<template #review-reports>
|
||||
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.reports) }}
|
||||
</template>
|
||||
<template #external-projects>
|
||||
<GlobeIcon aria-hidden="true" /> {{ formatMessage(messages.externalProjects) }}
|
||||
</template>
|
||||
<template #user-lookup>
|
||||
<UserSearchIcon aria-hidden="true" /> {{ formatMessage(messages.lookupByEmail) }}
|
||||
</template>
|
||||
@@ -705,6 +713,7 @@ import {
|
||||
DropdownIcon,
|
||||
FileIcon,
|
||||
GlassesIcon,
|
||||
GlobeIcon,
|
||||
HamburgerIcon,
|
||||
HomeIcon,
|
||||
IssuesIcon,
|
||||
@@ -880,6 +889,10 @@ const messages = defineMessages({
|
||||
id: 'layout.action.reports',
|
||||
defaultMessage: 'Review reports',
|
||||
},
|
||||
externalProjects: {
|
||||
id: 'layout.action.external-projects',
|
||||
defaultMessage: 'External projects',
|
||||
},
|
||||
lookupByEmail: {
|
||||
id: 'layout.action.lookup-by-email',
|
||||
defaultMessage: 'Lookup by email',
|
||||
|
||||
@@ -1676,6 +1676,9 @@
|
||||
"layout.action.create-new": {
|
||||
"message": "Create new..."
|
||||
},
|
||||
"layout.action.external-projects": {
|
||||
"message": "External projects"
|
||||
},
|
||||
"layout.action.file-lookup": {
|
||||
"message": "File lookup"
|
||||
},
|
||||
@@ -1928,6 +1931,9 @@
|
||||
"moderation.moderate": {
|
||||
"message": "Moderate"
|
||||
},
|
||||
"moderation.page.external-projects": {
|
||||
"message": "External projects"
|
||||
},
|
||||
"moderation.page.projects": {
|
||||
"message": "Projects"
|
||||
},
|
||||
@@ -1935,7 +1941,7 @@
|
||||
"message": "Reports"
|
||||
},
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "Technical Review"
|
||||
"message": "Tech review"
|
||||
},
|
||||
"muralpay.account-type.checking": {
|
||||
"message": "Checking"
|
||||
@@ -2621,6 +2627,45 @@
|
||||
"project.settings.general.url.title": {
|
||||
"message": "URL"
|
||||
},
|
||||
"project.settings.permissions.attention-needed.description.proj-approved": {
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files and any withheld versions will be automatically published."
|
||||
},
|
||||
"project.settings.permissions.attention-needed.description.proj-draft": {
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files before you can submit your project for review."
|
||||
},
|
||||
"project.settings.permissions.attention-needed.title": {
|
||||
"message": "Unknown embedded content"
|
||||
},
|
||||
"project.settings.permissions.completed.description": {
|
||||
"message": "All external content has attributions provided."
|
||||
},
|
||||
"project.settings.permissions.completed.title": {
|
||||
"message": "Attributions completed!"
|
||||
},
|
||||
"project.settings.permissions.empty-state.description": {
|
||||
"message": "None of your versions contain external content, so you don't need to worry about obtaining permissions."
|
||||
},
|
||||
"project.settings.permissions.empty-state.heading": {
|
||||
"message": "You're all set!"
|
||||
},
|
||||
"project.settings.permissions.fail.description": {
|
||||
"message": "You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content."
|
||||
},
|
||||
"project.settings.permissions.fail.title": {
|
||||
"message": "Some content can't be included"
|
||||
},
|
||||
"project.settings.permissions.info-banner.description": {
|
||||
"message": "If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn about how to do this properly!"
|
||||
},
|
||||
"project.settings.permissions.info-banner.title": {
|
||||
"message": "Learn how attributions work"
|
||||
},
|
||||
"project.settings.permissions.learn-more": {
|
||||
"message": "Learn more"
|
||||
},
|
||||
"project.settings.permissions.search-placeholder": {
|
||||
"message": "Search {count} {count, plural, one {external project} other {external projects}}..."
|
||||
},
|
||||
"project.settings.title": {
|
||||
"message": "Settings"
|
||||
},
|
||||
@@ -2639,6 +2684,15 @@
|
||||
"project.versions.title": {
|
||||
"message": "Versions"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {This version is} other {These versions are}} currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included in the modpack {count, plural, one {version} other {versions}}."
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Resolve"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {Version {version_name}} other {Versions}} withheld due to unknown embedded content"
|
||||
},
|
||||
"report.already-reported": {
|
||||
"message": "You've already reported {title}"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
InfoIcon,
|
||||
LinkIcon,
|
||||
ServerIcon,
|
||||
SignatureIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
VersionIcon,
|
||||
@@ -46,6 +47,12 @@ const navItems = computed(() => {
|
||||
projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) &&
|
||||
isStaff(currentMember.value?.user)
|
||||
|
||||
const hasPermissionsPage = computed(
|
||||
() =>
|
||||
flags.value.modpackPermissionsPage &&
|
||||
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
|
||||
)
|
||||
|
||||
const items = [
|
||||
{
|
||||
link: `/${base}/settings`,
|
||||
@@ -75,6 +82,11 @@ const navItems = computed(() => {
|
||||
label: formatMessage(commonProjectSettingsMessages.description),
|
||||
icon: AlignLeftIcon,
|
||||
},
|
||||
hasPermissionsPage.value && {
|
||||
link: `/${base}/settings/permissions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.permissions),
|
||||
icon: SignatureIcon,
|
||||
},
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/versions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.versions),
|
||||
|
||||
184
apps/frontend/src/pages/[type]/[id]/settings/permissions.vue
Normal file
184
apps/frontend/src/pages/[type]/[id]/settings/permissions.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
EmptyState,
|
||||
IntlFormatted,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import ExternalProjectPermissionsCard from '@modrinth/ui/src/components/external_files/ExternalProjectPermissionsCard.vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
if (!flags.value.modpackPermissionsPage) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
})
|
||||
}
|
||||
|
||||
const externalFiles = ref([{}])
|
||||
const searchQuery = ref('')
|
||||
const currentSortType = ref('Oldest')
|
||||
|
||||
const sortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Oldest', label: 'Oldest' },
|
||||
{ value: 'Newest', label: 'Newest' },
|
||||
]
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'project.settings.permissions.search-placeholder',
|
||||
defaultMessage:
|
||||
'Search {count} {count, plural, one {external project} other {external projects}}...',
|
||||
},
|
||||
infoBannerTitle: {
|
||||
id: 'project.settings.permissions.info-banner.title',
|
||||
defaultMessage: 'Learn how attributions work',
|
||||
},
|
||||
infoBannerDescription: {
|
||||
id: 'project.settings.permissions.info-banner.description',
|
||||
defaultMessage: `If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn about how to do this properly!`,
|
||||
},
|
||||
learnMore: {
|
||||
id: 'project.settings.permissions.learn-more',
|
||||
defaultMessage: 'Learn more',
|
||||
},
|
||||
emptyStateHeading: {
|
||||
id: 'project.settings.permissions.empty-state.heading',
|
||||
defaultMessage: `You're all set!`,
|
||||
},
|
||||
emptyStateDescription: {
|
||||
id: 'project.settings.permissions.empty-state.description',
|
||||
defaultMessage: `None of your versions contain external content, so you don't need to worry about obtaining permissions.`,
|
||||
},
|
||||
completedTitle: {
|
||||
id: 'project.settings.permissions.completed.title',
|
||||
defaultMessage: `Attributions completed!`,
|
||||
},
|
||||
completedDescription: {
|
||||
id: 'project.settings.permissions.completed.description',
|
||||
defaultMessage: 'All external content has attributions provided.',
|
||||
},
|
||||
failTitle: {
|
||||
id: 'project.settings.permissions.fail.title',
|
||||
defaultMessage: `Some content can't be included`,
|
||||
},
|
||||
failDescription: {
|
||||
id: 'project.settings.permissions.fail.description',
|
||||
defaultMessage: `You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content.`,
|
||||
},
|
||||
attentionNeededTitle: {
|
||||
id: 'project.settings.permissions.attention-needed.title',
|
||||
defaultMessage: `Unknown embedded content`,
|
||||
},
|
||||
attentionNeededDescriptionApproved: {
|
||||
id: 'project.settings.permissions.attention-needed.description.proj-approved',
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files and any withheld versions will be automatically published.`,
|
||||
},
|
||||
attentionNeededDescriptionDraft: {
|
||||
id: 'project.settings.permissions.attention-needed.description.proj-draft',
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files before you can submit your project for review.`,
|
||||
},
|
||||
})
|
||||
|
||||
function dismissInfoBanner() {
|
||||
flags.value.dismissedExternalProjectsInfo = true
|
||||
saveFeatureFlags()
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<template v-if="externalFiles.length > 0">
|
||||
<Admonition
|
||||
v-if="!flags.dismissedExternalProjectsInfo"
|
||||
type="info"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.infoBannerTitle)"
|
||||
dismissible
|
||||
@dismiss="dismissInfoBanner"
|
||||
>
|
||||
<IntlFormatted :message-id="messages.infoBannerDescription">
|
||||
<template #link="{ children }">
|
||||
<a class="text-link" target="_blank"> <component :is="() => children" /> </a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
<template #actions>
|
||||
<div class="flex">
|
||||
<ButtonStyled color="blue">
|
||||
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-if="true"
|
||||
type="success"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.completedTitle)"
|
||||
:body="formatMessage(messages.completedDescription)"
|
||||
/>
|
||||
<Admonition
|
||||
v-if="true"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.attentionNeededTitle)"
|
||||
:body="formatMessage(messages.attentionNeededDescriptionDraft)"
|
||||
/>
|
||||
<Admonition
|
||||
v-if="true"
|
||||
type="critical"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.failTitle)"
|
||||
:body="formatMessage(messages.failDescription)"
|
||||
/>
|
||||
<div class="grid grid-cols-[1fr_auto] gap-2">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
formatMessage(messages.searchPlaceholder, {
|
||||
count: externalFiles.length,
|
||||
})
|
||||
"
|
||||
:icon="SearchIcon"
|
||||
input-class="h-[40px]"
|
||||
/>
|
||||
<div>
|
||||
<Combobox
|
||||
v-model="currentSortType"
|
||||
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||
:options="sortTypes"
|
||||
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<SortAscIcon
|
||||
v-if="currentSortType === 'Oldest'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
|
||||
<span class="truncate text-contrast">{{ currentSortType }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-3">
|
||||
<ExternalProjectPermissionsCard title="FTB Library" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<EmptyState
|
||||
:heading="formatMessage(messages.emptyStateHeading)"
|
||||
:description="formatMessage(messages.emptyStateDescription)"
|
||||
type="done"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -15,6 +15,37 @@
|
||||
@proceed="deleteVersion()"
|
||||
/>
|
||||
|
||||
<Admonition
|
||||
v-if="flags.modpackPermissionsPage && withheldVersions.length > 0"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
:header="
|
||||
formatMessage(messages.withheldVersionsWarningTitle, {
|
||||
count: withheldVersions.length,
|
||||
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
|
||||
})
|
||||
"
|
||||
:body="
|
||||
formatMessage(messages.withheldVersionsWarningDescription, {
|
||||
count: withheldVersions.length,
|
||||
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
|
||||
})
|
||||
"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex">
|
||||
<ButtonStyled color="orange">
|
||||
<nuxt-link
|
||||
:to="`/${project.project_type}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}/settings/permissions`"
|
||||
>
|
||||
{{ formatMessage(messages.withheldVersionsWarningResolve) }} <RightArrowIcon />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</Admonition>
|
||||
<ProjectPageVersions
|
||||
v-if="versions?.length"
|
||||
:project="project"
|
||||
@@ -293,17 +324,21 @@ import {
|
||||
MoreVerticalIcon,
|
||||
PlusIcon,
|
||||
ReportIcon,
|
||||
RightArrowIcon,
|
||||
ShareIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
ConfirmModal,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
OverflowMenu,
|
||||
ProjectPageVersions,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
@@ -315,6 +350,7 @@ const route = useRoute()
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
projectV2: project,
|
||||
currentMember,
|
||||
@@ -396,4 +432,23 @@ async function deleteVersion() {
|
||||
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const withheldVersions = computed(() => ['4.0.0'])
|
||||
|
||||
const messages = defineMessages({
|
||||
withheldVersionsWarningTitle: {
|
||||
id: 'project.versions.withheld-versions-warning.title',
|
||||
defaultMessage:
|
||||
'{count, plural, one {Version {version_name}} other {Versions}} withheld due to unknown embedded content',
|
||||
},
|
||||
withheldVersionsWarningDescription: {
|
||||
id: 'project.versions.withheld-versions-warning.description',
|
||||
defaultMessage:
|
||||
'{count, plural, one {This version is} other {These versions are}} currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included in the modpack {count, plural, one {version} other {versions}}.',
|
||||
},
|
||||
withheldVersionsWarningResolve: {
|
||||
id: 'project.versions.withheld-versions-warning.resolve-button',
|
||||
defaultMessage: 'Resolve',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
|
||||
import { FolderIcon, GlobeIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
|
||||
import { Chips, defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
|
||||
|
||||
definePageMeta({
|
||||
@@ -37,12 +37,16 @@ const messages = defineMessages({
|
||||
},
|
||||
technicalReviewTitle: {
|
||||
id: 'moderation.page.technicalReview',
|
||||
defaultMessage: 'Technical Review',
|
||||
defaultMessage: 'Tech review',
|
||||
},
|
||||
reportsTitle: {
|
||||
id: 'moderation.page.reports',
|
||||
defaultMessage: 'Reports',
|
||||
},
|
||||
externalFilesTitle: {
|
||||
id: 'moderation.page.external-projects',
|
||||
defaultMessage: 'External projects',
|
||||
},
|
||||
})
|
||||
|
||||
const moderationLinks = [
|
||||
@@ -53,12 +57,18 @@ const moderationLinks = [
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
{ label: formatMessage(messages.reportsTitle), href: '/moderation/reports', icon: ReportIcon },
|
||||
{
|
||||
label: formatMessage(messages.externalFilesTitle),
|
||||
href: '/moderation/external-projects',
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
]
|
||||
|
||||
const mobileNavOptions = [
|
||||
formatMessage(messages.projectsTitle),
|
||||
formatMessage(messages.technicalReviewTitle),
|
||||
formatMessage(messages.reportsTitle),
|
||||
formatMessage(messages.externalFilesTitle),
|
||||
]
|
||||
|
||||
const selectedChip = computed({
|
||||
|
||||
393
apps/frontend/src/pages/moderation/external-projects.vue
Normal file
393
apps/frontend/src/pages/moderation/external-projects.vue
Normal file
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<NewModal ref="editModal" header="Edit external project">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="saveExternalProjectEdit">
|
||||
<label class="font-semibold text-contrast" for="edit-form-title">Title</label>
|
||||
<StyledInput id="edit-form-title" v-model="editForm.title" type="text" />
|
||||
<label class="mt-2 font-semibold text-contrast" for="edit-form-link">Link</label>
|
||||
<StyledInput id="edit-form-link" v-model="editForm.link" type="text" />
|
||||
<label class="mt-2 font-semibold text-contrast" for="edit-form-cf-id">
|
||||
CurseForge project ID
|
||||
</label>
|
||||
<StyledInput id="edit-form-cf-id" v-model="editForm.flameProjectId" type="text" />
|
||||
<label class="mt-2 font-semibold text-contrast" for="edit-form-status">Allowed?</label>
|
||||
<Combobox
|
||||
id="edit-form-status"
|
||||
v-model="editForm.status"
|
||||
:options="statusOptions"
|
||||
class="!w-full"
|
||||
/>
|
||||
<label class="mt-2 font-semibold text-contrast" for="edit-form-proof">Proof</label>
|
||||
<StyledInput
|
||||
id="edit-form-proof"
|
||||
v-model="editForm.proof"
|
||||
type="text"
|
||||
multiline
|
||||
resize="both"
|
||||
class="w-[30rem]"
|
||||
/>
|
||||
<label class="mt-2 font-semibold text-contrast" for="edit-form-exceptions">
|
||||
Exceptions / notes
|
||||
</label>
|
||||
<StyledInput
|
||||
id="edit-form-exceptions"
|
||||
v-model="editForm.exceptions"
|
||||
type="text"
|
||||
multiline
|
||||
resize="both"
|
||||
class="w-[30rem]"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="closeEditModal">Cancel</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="submit" :disabled="isSavingEdit">
|
||||
{{ isSavingEdit ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
<div>
|
||||
<form class="flex gap-2" @submit.prevent="executeSearch">
|
||||
<StyledInput
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Search external projects..."
|
||||
clearable
|
||||
wrapper-class="flex-1 w-full"
|
||||
/>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="submit">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
Search by title
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="executeFlameIdLookup">
|
||||
<BinaryIcon aria-hidden="true" />
|
||||
Lookup CurseForge ID
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="executeSha1Lookup">
|
||||
<HashIcon aria-hidden="true" />
|
||||
Lookup SHA-1
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</form>
|
||||
<div>
|
||||
<template v-if="lastSearchKind !== 'none'">
|
||||
<template v-if="lookupNeedsInput">
|
||||
<EmptyState
|
||||
type="no-search-result"
|
||||
:heading="lookupEmptyHeading"
|
||||
:description="lookupEmptyDescription"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<EmptyState
|
||||
v-if="isLoading"
|
||||
type="no-search-result"
|
||||
heading="Loading external projects..."
|
||||
/>
|
||||
<div v-else-if="displayProjects.length > 0" class="mt-4 flex flex-col gap-3">
|
||||
<ExternalProjectLookupCard
|
||||
v-for="project in displayProjects"
|
||||
: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"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled>
|
||||
<button @click="openEditModal(project)">
|
||||
<EditIcon />
|
||||
Edit
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ExternalProjectLookupCard>
|
||||
</div>
|
||||
<EmptyState v-else type="no-search-result" :heading="noResultsHeading" />
|
||||
</template>
|
||||
</template>
|
||||
<EmptyState
|
||||
v-else
|
||||
type="no-search-result"
|
||||
heading="Enter a search term to get started"
|
||||
description="Type at least 3 characters of a project's title to begin browsing."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { BinaryIcon, EditIcon, HashIcon, SearchIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
EmptyState,
|
||||
type ExternalLicenseStatus,
|
||||
externalProjectLicenseStatusMessages,
|
||||
ExternalProjectLookupCard,
|
||||
injectModrinthClient,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const query = ref('')
|
||||
const isLoading = ref(false)
|
||||
const isSavingEdit = ref(false)
|
||||
const client = injectModrinthClient()
|
||||
const editModal = useTemplateRef<InstanceType<typeof NewModal>>('editModal')
|
||||
|
||||
useHead({ title: 'External projects - Modrinth' })
|
||||
|
||||
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
|
||||
}[]
|
||||
}
|
||||
|
||||
type SearchKind = 'none' | 'title' | 'flame_id' | 'sha1'
|
||||
|
||||
const lastSearchKind = ref<SearchKind>('none')
|
||||
const activeQuery = ref('')
|
||||
const externalProjects = ref<ExternalProject[]>([])
|
||||
|
||||
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 displayProjects = computed(() => {
|
||||
return externalProjects.value
|
||||
})
|
||||
|
||||
const lookupNeedsInput = computed(() => {
|
||||
const q = activeQuery.value
|
||||
const kind = lastSearchKind.value
|
||||
|
||||
if (kind === 'title') {
|
||||
return q.length < 3
|
||||
}
|
||||
if (kind === 'flame_id' || kind === 'sha1') {
|
||||
return q.trim().length === 0
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const lookupEmptyHeading = computed(() => {
|
||||
const kind = lastSearchKind.value
|
||||
if (kind === 'title') {
|
||||
return 'Enter a search term to get started'
|
||||
}
|
||||
if (kind === 'flame_id') {
|
||||
return 'Enter a CurseForge project ID'
|
||||
}
|
||||
if (kind === 'sha1') {
|
||||
return 'Enter a SHA-1 hash'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const lookupEmptyDescription = computed(() => {
|
||||
const kind = lastSearchKind.value
|
||||
if (kind === 'title') {
|
||||
return "Type at least 3 characters of a project's title to begin browsing."
|
||||
}
|
||||
if (kind === 'flame_id') {
|
||||
return 'Type the numeric project ID, then use lookup for an exact match.'
|
||||
}
|
||||
if (kind === 'sha1') {
|
||||
return 'Paste the full 40-character hex hash, then use lookup for an exact match.'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const noResultsHeading = computed(() => {
|
||||
const kind = lastSearchKind.value
|
||||
if (kind === 'title') {
|
||||
return 'No projects matched that title search'
|
||||
}
|
||||
if (kind === 'flame_id') {
|
||||
return 'No external project has that CurseForge ID'
|
||||
}
|
||||
if (kind === 'sha1') {
|
||||
return 'No external file has that SHA-1 hash'
|
||||
}
|
||||
return 'No projects matched that title search'
|
||||
})
|
||||
|
||||
const statusOptions = computed<ComboboxOption<ExternalLicenseStatus>[]>(() => [
|
||||
{ value: 'yes' as const, label: formatMessage(externalProjectLicenseStatusMessages.yes) },
|
||||
{
|
||||
value: 'with-attribution-and-source' as const,
|
||||
label: formatMessage(externalProjectLicenseStatusMessages['with-attribution-and-source']),
|
||||
},
|
||||
{
|
||||
value: 'with-attribution' as const,
|
||||
label: formatMessage(externalProjectLicenseStatusMessages['with-attribution']),
|
||||
},
|
||||
{ value: 'no' as const, label: formatMessage(externalProjectLicenseStatusMessages.no) },
|
||||
{
|
||||
value: 'permanent-no' as const,
|
||||
label: formatMessage(externalProjectLicenseStatusMessages['permanent-no']),
|
||||
},
|
||||
{
|
||||
value: 'unidentified' as const,
|
||||
label: formatMessage(externalProjectLicenseStatusMessages.unidentified),
|
||||
},
|
||||
])
|
||||
|
||||
const editingProjectId = ref<number | null>(null)
|
||||
const editForm = ref({
|
||||
title: '',
|
||||
status: 'unidentified' as ExternalLicenseStatus,
|
||||
link: '',
|
||||
proof: '',
|
||||
exceptions: '',
|
||||
flameProjectId: '',
|
||||
})
|
||||
|
||||
function openEditModal(project: ExternalProject) {
|
||||
editingProjectId.value = project.id
|
||||
editForm.value = {
|
||||
title: project.title ?? '',
|
||||
status: project.status,
|
||||
link: project.link ?? '',
|
||||
proof: project.proof ?? '',
|
||||
exceptions: project.exceptions ?? '',
|
||||
flameProjectId: project.flame_project_id?.toString() ?? '',
|
||||
}
|
||||
editModal.value?.show()
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
editModal.value?.hide()
|
||||
}
|
||||
|
||||
async function saveExternalProjectEdit() {
|
||||
if (!editingProjectId.value) return
|
||||
isSavingEdit.value = true
|
||||
|
||||
const parsedFlameProjectId = Number.parseInt(editForm.value.flameProjectId.trim(), 10)
|
||||
|
||||
try {
|
||||
const updated = await client.labrinth.external_projects_internal.update(
|
||||
editingProjectId.value,
|
||||
{
|
||||
status: editForm.value.status,
|
||||
title: editForm.value.title.trim() || undefined,
|
||||
link: editForm.value.link.trim() || undefined,
|
||||
proof: editForm.value.proof.trim() || undefined,
|
||||
exceptions: editForm.value.exceptions.trim() || undefined,
|
||||
flame_project_id: Number.isFinite(parsedFlameProjectId) ? parsedFlameProjectId : undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const mapped = mapExternalProject(updated)
|
||||
const index = externalProjects.value.findIndex((project) => project.id === mapped.id)
|
||||
if (index >= 0) {
|
||||
externalProjects.value[index] = mapped
|
||||
}
|
||||
|
||||
closeEditModal()
|
||||
} catch (error) {
|
||||
console.error('Failed to update external project', error)
|
||||
} finally {
|
||||
isSavingEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function executeLookup(kind: SearchKind) {
|
||||
if (kind === 'none') return
|
||||
|
||||
lastSearchKind.value = kind
|
||||
activeQuery.value = query.value
|
||||
externalProjects.value = []
|
||||
|
||||
if (lookupNeedsInput.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
if (kind === 'title') {
|
||||
const response = await client.labrinth.external_projects_internal.search({
|
||||
title: activeQuery.value.trim(),
|
||||
})
|
||||
externalProjects.value = response.map(mapExternalProject)
|
||||
return
|
||||
}
|
||||
|
||||
if (kind === 'flame_id') {
|
||||
const parsedFlameId = Number.parseInt(activeQuery.value.trim(), 10)
|
||||
if (!Number.isFinite(parsedFlameId)) {
|
||||
externalProjects.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const response = await client.labrinth.external_projects_internal.search({
|
||||
flame_id: parsedFlameId,
|
||||
})
|
||||
externalProjects.value = response.map(mapExternalProject)
|
||||
return
|
||||
}
|
||||
|
||||
if (kind === 'sha1') {
|
||||
const response = await client.labrinth.external_projects_internal.getBySha1(
|
||||
activeQuery.value.trim(),
|
||||
)
|
||||
externalProjects.value = [mapExternalProject(response)]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to query external projects', error)
|
||||
externalProjects.value = []
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function executeSearch() {
|
||||
await executeLookup('title')
|
||||
}
|
||||
|
||||
async function executeFlameIdLookup() {
|
||||
await executeLookup('flame_id')
|
||||
}
|
||||
|
||||
async function executeSha1Lookup() {
|
||||
await executeLookup('sha1')
|
||||
}
|
||||
</script>
|
||||
21
apps/labrinth/.sqlx/query-56428d0eec87c0cdaca1183c642f0478b9974de6b6d95f7ca48de605b3bf1103.json
generated
Normal file
21
apps/labrinth/.sqlx/query-56428d0eec87c0cdaca1183c642f0478b9974de6b6d95f7ca48de605b3bf1103.json
generated
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO moderation_external_licenses (id, title, status, link, proof, flame_project_id, inserted_at, inserted_by, updated_at, updated_by)\n SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::varchar[], $4::varchar[], $5::varchar[], $6::integer[], $7::timestamptz[], $8::bigint[], $7::timestamptz[], $8::bigint[])\n ON CONFLICT (id) DO UPDATE SET\n title = EXCLUDED.title,\n status = EXCLUDED.status,\n link = EXCLUDED.link,\n proof = EXCLUDED.proof,\n flame_project_id = EXCLUDED.flame_project_id,\n updated_at = EXCLUDED.updated_at,\n updated_by = EXCLUDED.updated_by\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array",
|
||||
"VarcharArray",
|
||||
"VarcharArray",
|
||||
"VarcharArray",
|
||||
"VarcharArray",
|
||||
"Int4Array",
|
||||
"TimestamptzArray",
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "56428d0eec87c0cdaca1183c642f0478b9974de6b6d95f7ca48de605b3bf1103"
|
||||
}
|
||||
83
apps/labrinth/.sqlx/query-6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57.json
generated
Normal file
83
apps/labrinth/.sqlx/query-6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57.json
generated
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_licenses mel\n WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%')\n AND ($2::integer IS NULL OR mel.flame_project_id = $2)\n ORDER BY mel.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57"
|
||||
}
|
||||
34
apps/labrinth/.sqlx/query-7720108c0a9e93119f5252e2102eeea0ee67b228924e288d1d6c3e169e941688.json
generated
Normal file
34
apps/labrinth/.sqlx/query-7720108c0a9e93119f5252e2102eeea0ee67b228924e288d1d6c3e169e941688.json
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mef.external_license_id,\n mef.sha1,\n mef.filename\n FROM moderation_external_files mef\n WHERE mef.external_license_id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "external_license_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "sha1",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "filename",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7720108c0a9e93119f5252e2102eeea0ee67b228924e288d1d6c3e169e941688"
|
||||
}
|
||||
82
apps/labrinth/.sqlx/query-99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4.json
generated
Normal file
82
apps/labrinth/.sqlx/query-99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4.json
generated
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id\n WHERE mef.sha1 = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4"
|
||||
}
|
||||
90
apps/labrinth/.sqlx/query-b539eccb9fbb13270748f5c102dc0eb3325a39daa45f8964704713fb704a3e26.json
generated
Normal file
90
apps/labrinth/.sqlx/query-b539eccb9fbb13270748f5c102dc0eb3325a39daa45f8964704713fb704a3e26.json
generated
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE moderation_external_licenses\n SET title = COALESCE($2, title),\n status = $3,\n link = COALESCE($4, link),\n exceptions = COALESCE($5, exceptions),\n proof = COALESCE($6, proof),\n flame_project_id = COALESCE($7, flame_project_id),\n updated_at = $8,\n updated_by = $9\n WHERE id = $1\n RETURNING id, title, status, link, exceptions, proof, flame_project_id,\n inserted_at, inserted_by, updated_at, updated_by\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int4",
|
||||
"Timestamptz",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "b539eccb9fbb13270748f5c102dc0eb3325a39daa45f8964704713fb704a3e26"
|
||||
}
|
||||
18
apps/labrinth/.sqlx/query-f26e41a619a51d5b5a39af6117b7d2f6106ec67a209a285b0a523a677dba4a5b.json
generated
Normal file
18
apps/labrinth/.sqlx/query-f26e41a619a51d5b5a39af6117b7d2f6106ec67a209a285b0a523a677dba4a5b.json
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO moderation_external_files (sha1, filename, external_license_id, inserted_at, inserted_by, updated_at, updated_by)\n SELECT * FROM UNNEST ($1::bytea[], $2::varchar[], $3::bigint[], $4::timestamptz[], $5::bigint[], $4::timestamptz[], $5::bigint[])\n ON CONFLICT (sha1) DO UPDATE SET\n filename = COALESCE(EXCLUDED.filename, moderation_external_files.filename),\n external_license_id = EXCLUDED.external_license_id,\n updated_at = EXCLUDED.updated_at,\n updated_by = EXCLUDED.updated_by\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray",
|
||||
"VarcharArray",
|
||||
"Int8Array",
|
||||
"TimestamptzArray",
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f26e41a619a51d5b5a39af6117b7d2f6106ec67a209a285b0a523a677dba4a5b"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO moderation_external_files (sha1, external_license_id)\n SELECT * FROM UNNEST ($1::bytea[], $2::bigint[])\n ON CONFLICT (sha1) DO NOTHING\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray",
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f297b517bc3bbd8628c0c222c0e3daf8f4efbe628ee2e8ddbbb4b9734cc9c915"
|
||||
}
|
||||
@@ -1,30 +1 @@
|
||||
# Labrinth
|
||||
|
||||
Labrinth is the backend API service for Modrinth, written in Rust.
|
||||
|
||||
## Pre-PR Checks
|
||||
|
||||
When the user refers to "perform[ing] pre-PR checks", do the following:
|
||||
|
||||
- Run `cargo clippy -p labrinth --all-targets` — there must be ZERO warnings, otherwise CI will fail
|
||||
- DO NOT run tests unless explicitly requested (they take a long time)
|
||||
- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare -- --tests`
|
||||
- NEVER run `cargo sqlx prepare --workspace`
|
||||
|
||||
## Testing
|
||||
|
||||
- Run `cargo test -p labrinth --all-targets` to test your changes — all tests must pass
|
||||
|
||||
## Local Services
|
||||
|
||||
- Read the root `docker-compose.yml` to see what running services are available while developing
|
||||
- Use `docker exec` to access these services
|
||||
|
||||
### Clickhouse
|
||||
|
||||
- Access: `docker exec labrinth-clickhouse clickhouse-client`
|
||||
- Database: `staging_ariadne`
|
||||
|
||||
### Postgres
|
||||
|
||||
- Access: `docker exec labrinth-postgres psql -U labrinth -d labrinth -c "<query>"`
|
||||
Read @AGENTS.md
|
||||
|
||||
18
apps/labrinth/fixtures/license.sql
Normal file
18
apps/labrinth/fixtures/license.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Dummy moderation_external_licenses (explicit IDs required)
|
||||
INSERT INTO moderation_external_licenses (id, title, status, link, exceptions, proof, flame_project_id, inserted_at, inserted_by, updated_at, updated_by)
|
||||
VALUES
|
||||
(9001, 'Example Mod', 'yes', 'https://example.com/license', NULL, 'Verified by team', 101, now(), 0, now(), 0),
|
||||
(9002, 'Cool Resource Pack', 'no', 'https://coolpack.com/terms', 'Non-commercial only', 'DMCA takedown filed', 202, now(), 0, now(), 0),
|
||||
(9003, 'Mystery Project', 'unidentified', NULL, NULL, NULL, NULL, now(), 0, now(), 0),
|
||||
(9004, 'Widget Lib', 'with-attribution', 'https://widgets.dev/MIT', NULL, 'License header in JAR', 303, now(), 0, now(), 0),
|
||||
(9005, 'Shadow Mod', 'permanent-no', 'https://shadow.net/eula', 'Redistribution restricted','Under review', NULL, now(), 0, now(), 0);
|
||||
|
||||
-- Dummy moderation_external_files (sha1 stored as ASCII bytes of hex string, matching Rust's .as_bytes())
|
||||
INSERT INTO moderation_external_files (sha1, filename, external_license_id)
|
||||
VALUES
|
||||
('aabbccdd11223344aabbccdd11223344aabbccdd', 'example-mod-1.0.jar', 9001),
|
||||
('11223344aabbccdd11223344aabbccdd11223344', 'example-mod-1.1.jar', 9001),
|
||||
('deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', 'coolpack-v2.zip', 9002),
|
||||
('cafebabecafebabecafebabecafebabecafebabe', 'mystery.dat', 9003),
|
||||
('0102030405060708090a0b0c0d0e0f1011121314', 'widget-lib.jar', 9004);
|
||||
-- License 9005 intentionally has no files (tests empty linked_files case)
|
||||
18
apps/labrinth/fixtures/moderation-data.sql
Normal file
18
apps/labrinth/fixtures/moderation-data.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Dummy moderation_external_licenses (explicit IDs required)
|
||||
INSERT INTO moderation_external_licenses (id, title, status, link, exceptions, proof, flame_project_id, inserted_at, inserted_by, updated_at, updated_by)
|
||||
VALUES
|
||||
(9001, 'Example Mod', 'yes', 'https://example.com/license', NULL, 'Verified by team', 101, now(), 0, now(), 0),
|
||||
(9002, 'Cool Resource Pack', 'no', 'https://coolpack.com/terms', 'Non-commercial only', 'DMCA takedown filed', 202, now(), 0, now(), 0),
|
||||
(9003, 'Mystery Project', 'unidentified', NULL, NULL, NULL, NULL, now(), 0, now(), 0),
|
||||
(9004, 'Widget Lib', 'with-attribution', 'https://widgets.dev/MIT', NULL, 'License header in JAR', 303, now(), 0, now(), 0),
|
||||
(9005, 'Shadow Mod', 'permanent-no', 'https://shadow.net/eula', 'Redistribution restricted','Under review', NULL, now(), 0, now(), 0);
|
||||
|
||||
-- Dummy moderation_external_files (sha1 stored as ASCII bytes of hex string, matching Rust's .as_bytes())
|
||||
INSERT INTO moderation_external_files (sha1, filename, external_license_id)
|
||||
VALUES
|
||||
('aabbccdd11223344aabbccdd11223344aabbccdd', 'example-mod-1.0.jar', 9001),
|
||||
('11223344aabbccdd11223344aabbccdd11223344', 'example-mod-1.1.jar', 9001),
|
||||
('deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', 'coolpack-v2.zip', 9002),
|
||||
('cafebabecafebabecafebabecafebabecafebabe', 'mystery.dat', 9003),
|
||||
('0102030405060708090a0b0c0d0e0f1011121314', 'widget-lib.jar', 9004);
|
||||
-- License 9005 intentionally has no files (tests empty linked_files case)
|
||||
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE moderation_external_licenses
|
||||
ADD COLUMN inserted_at timestamptz,
|
||||
ADD COLUMN inserted_by bigint REFERENCES users(id),
|
||||
ADD COLUMN updated_at timestamptz,
|
||||
ADD COLUMN updated_by bigint REFERENCES users(id);
|
||||
|
||||
ALTER TABLE moderation_external_files
|
||||
ADD COLUMN filename text,
|
||||
ADD COLUMN inserted_at timestamptz,
|
||||
ADD COLUMN inserted_by bigint REFERENCES users(id),
|
||||
ADD COLUMN updated_at timestamptz,
|
||||
ADD COLUMN updated_by bigint REFERENCES users(id);
|
||||
@@ -11,6 +11,7 @@ pub mod ids;
|
||||
pub mod image_item;
|
||||
pub mod legacy_loader_fields;
|
||||
pub mod loader_fields;
|
||||
pub mod moderation_external_item;
|
||||
pub mod moderation_lock_item;
|
||||
pub mod notification_item;
|
||||
pub mod notifications_deliveries_item;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::database::models::DBUserId;
|
||||
|
||||
pub struct ExternalLicense {
|
||||
pub id: i64,
|
||||
pub title: Option<String>,
|
||||
pub status: String,
|
||||
pub link: Option<String>,
|
||||
pub proof: Option<String>,
|
||||
pub flame_project_id: Option<i32>,
|
||||
}
|
||||
|
||||
impl ExternalLicense {
|
||||
pub async fn insert_many(
|
||||
exec: impl sqlx::PgExecutor<'_>,
|
||||
licenses: &[ExternalLicense],
|
||||
user_id: DBUserId,
|
||||
) -> sqlx::Result<()> {
|
||||
let now = Utc::now();
|
||||
|
||||
let ids: Vec<i64> = licenses.iter().map(|x| x.id).collect();
|
||||
let titles: Vec<Option<String>> =
|
||||
licenses.iter().map(|x| x.title.clone()).collect();
|
||||
let statuses: Vec<String> =
|
||||
licenses.iter().map(|x| x.status.clone()).collect();
|
||||
let links: Vec<Option<String>> =
|
||||
licenses.iter().map(|x| x.link.clone()).collect();
|
||||
let proofs: Vec<Option<String>> =
|
||||
licenses.iter().map(|x| x.proof.clone()).collect();
|
||||
let flame_ids: Vec<Option<i32>> =
|
||||
licenses.iter().map(|x| x.flame_project_id).collect();
|
||||
let nows: Vec<DateTime<Utc>> = vec![now; licenses.len()];
|
||||
let user_ids: Vec<i64> = vec![user_id.0; licenses.len()];
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO moderation_external_licenses (id, title, status, link, proof, flame_project_id, inserted_at, inserted_by, updated_at, updated_by)
|
||||
SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::varchar[], $4::varchar[], $5::varchar[], $6::integer[], $7::timestamptz[], $8::bigint[], $7::timestamptz[], $8::bigint[])
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
status = EXCLUDED.status,
|
||||
link = EXCLUDED.link,
|
||||
proof = EXCLUDED.proof,
|
||||
flame_project_id = EXCLUDED.flame_project_id,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
updated_by = EXCLUDED.updated_by
|
||||
"#,
|
||||
&ids,
|
||||
&titles as _,
|
||||
&statuses,
|
||||
&links as _,
|
||||
&proofs as _,
|
||||
&flame_ids as _,
|
||||
&nows,
|
||||
&user_ids,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn insert_files(
|
||||
exec: impl sqlx::PgExecutor<'_>,
|
||||
hashes: &[Vec<u8>],
|
||||
filenames: &[Option<String>],
|
||||
license_ids: &[i64],
|
||||
user_id: DBUserId,
|
||||
) -> sqlx::Result<()> {
|
||||
let now = Utc::now();
|
||||
let nows: Vec<DateTime<Utc>> = vec![now; license_ids.len()];
|
||||
let user_ids: Vec<i64> = vec![user_id.0; license_ids.len()];
|
||||
|
||||
let filenames: Vec<Option<String>> = filenames.to_vec();
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO moderation_external_files (sha1, filename, external_license_id, inserted_at, inserted_by, updated_at, updated_by)
|
||||
SELECT * FROM UNNEST ($1::bytea[], $2::varchar[], $3::bigint[], $4::timestamptz[], $5::bigint[], $4::timestamptz[], $5::bigint[])
|
||||
ON CONFLICT (sha1) DO UPDATE SET
|
||||
filename = COALESCE(EXCLUDED.filename, moderation_external_files.filename),
|
||||
external_license_id = EXCLUDED.external_license_id,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
updated_by = EXCLUDED.updated_by
|
||||
"#,
|
||||
hashes,
|
||||
&filenames as _,
|
||||
license_ids,
|
||||
&nows,
|
||||
&user_ids,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::auth::checks::filter_visible_versions;
|
||||
use crate::database;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::models::DBUserId;
|
||||
use crate::database::models::notification_item::NotificationBuilder;
|
||||
use crate::database::models::thread_item::ThreadMessageBuilder;
|
||||
use crate::database::redis::RedisPool;
|
||||
@@ -507,6 +508,7 @@ impl AutomatedModerationQueue {
|
||||
.fetch_all(&pool).await?;
|
||||
|
||||
let mut insert_hashes = Vec::new();
|
||||
let mut insert_filenames = Vec::new();
|
||||
let mut insert_ids = Vec::new();
|
||||
|
||||
for row in rows {
|
||||
@@ -518,6 +520,7 @@ impl AutomatedModerationQueue {
|
||||
});
|
||||
|
||||
insert_hashes.push(hash.clone().as_bytes().to_vec());
|
||||
insert_filenames.push(Some(file_name.clone()));
|
||||
insert_ids.push(row.id);
|
||||
|
||||
hashes.remove(index);
|
||||
@@ -526,16 +529,13 @@ impl AutomatedModerationQueue {
|
||||
}
|
||||
|
||||
if !insert_ids.is_empty() && !insert_hashes.is_empty() {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO moderation_external_files (sha1, external_license_id)
|
||||
SELECT * FROM UNNEST ($1::bytea[], $2::bigint[])
|
||||
ON CONFLICT (sha1) DO NOTHING
|
||||
",
|
||||
&insert_hashes[..],
|
||||
&insert_ids[..]
|
||||
crate::database::models::moderation_external_item::ExternalLicense::insert_files(
|
||||
&pool,
|
||||
&insert_hashes,
|
||||
&insert_filenames,
|
||||
&insert_ids,
|
||||
DBUserId(0),
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
|
||||
334
apps/labrinth/src/routes/internal/moderation/external_license.rs
Normal file
334
apps/labrinth/src/routes/internal/moderation/external_license.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use actix_web::{HttpRequest, get, patch, post, web};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::database::PgPool;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::queue::moderation::ApprovalType;
|
||||
use crate::routes::ApiError;
|
||||
use crate::{auth::check_is_moderator_from_headers, queue::session::AuthQueue};
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(search)
|
||||
.service(get_by_sha1)
|
||||
.service(update_license);
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ExternalProject {
|
||||
pub id: i64,
|
||||
pub title: Option<String>,
|
||||
pub status: ApprovalType,
|
||||
pub link: Option<String>,
|
||||
pub exceptions: Option<String>,
|
||||
pub proof: Option<String>,
|
||||
pub flame_project_id: Option<i32>,
|
||||
pub inserted_at: Option<DateTime<Utc>>,
|
||||
pub inserted_by: Option<i64>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
pub updated_by: Option<i64>,
|
||||
pub linked_files: Vec<LinkedFile>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
pub struct LinkedFile {
|
||||
pub name: Option<String>,
|
||||
pub sha1: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct SearchRequest {
|
||||
pub title: Option<String>,
|
||||
pub flame_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateLicenseRequest {
|
||||
pub title: Option<String>,
|
||||
pub status: ApprovalType,
|
||||
pub link: Option<String>,
|
||||
pub exceptions: Option<String>,
|
||||
pub proof: Option<String>,
|
||||
pub flame_project_id: Option<i32>,
|
||||
}
|
||||
|
||||
struct LicenseRow {
|
||||
id: i64,
|
||||
title: Option<String>,
|
||||
status: String,
|
||||
link: Option<String>,
|
||||
exceptions: Option<String>,
|
||||
proof: Option<String>,
|
||||
flame_project_id: Option<i32>,
|
||||
inserted_at: Option<DateTime<Utc>>,
|
||||
inserted_by: Option<i64>,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
updated_by: Option<i64>,
|
||||
}
|
||||
|
||||
impl LicenseRow {
|
||||
fn into_external_project(
|
||||
self,
|
||||
linked_files: Vec<LinkedFile>,
|
||||
) -> ExternalProject {
|
||||
ExternalProject {
|
||||
id: self.id,
|
||||
title: self.title,
|
||||
status: ApprovalType::from_string(&self.status)
|
||||
.unwrap_or(ApprovalType::Unidentified),
|
||||
link: self.link,
|
||||
exceptions: self.exceptions,
|
||||
proof: self.proof,
|
||||
flame_project_id: self.flame_project_id,
|
||||
inserted_at: self.inserted_at,
|
||||
inserted_by: self.inserted_by,
|
||||
updated_at: self.updated_at,
|
||||
updated_by: self.updated_by,
|
||||
linked_files,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_linked_files(
|
||||
pool: &PgPool,
|
||||
license_ids: &[i64],
|
||||
) -> Result<HashMap<i64, Vec<LinkedFile>>, ApiError> {
|
||||
if license_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let file_rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
mef.external_license_id,
|
||||
mef.sha1,
|
||||
mef.filename
|
||||
FROM moderation_external_files mef
|
||||
WHERE mef.external_license_id = ANY($1)
|
||||
"#,
|
||||
license_ids,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut map: HashMap<i64, Vec<LinkedFile>> = HashMap::new();
|
||||
for row in file_rows {
|
||||
map.entry(row.external_license_id)
|
||||
.or_default()
|
||||
.push(LinkedFile {
|
||||
name: row.filename,
|
||||
sha1: hex::encode(&row.sha1),
|
||||
});
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("/search")]
|
||||
async fn search(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
body: web::Json<SearchRequest>,
|
||||
) -> Result<web::Json<Vec<ExternalProject>>, ApiError> {
|
||||
check_is_moderator_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_READ,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
mel.id,
|
||||
mel.title,
|
||||
mel.status,
|
||||
mel.link,
|
||||
mel.exceptions,
|
||||
mel.proof,
|
||||
mel.flame_project_id,
|
||||
mel.inserted_at,
|
||||
mel.inserted_by,
|
||||
mel.updated_at,
|
||||
mel.updated_by
|
||||
FROM moderation_external_licenses mel
|
||||
WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%')
|
||||
AND ($2::integer IS NULL OR mel.flame_project_id = $2)
|
||||
ORDER BY mel.id
|
||||
"#,
|
||||
body.title,
|
||||
body.flame_id,
|
||||
)
|
||||
.fetch_all(&**pool)
|
||||
.await?;
|
||||
|
||||
let license_ids: Vec<i64> = rows.iter().map(|r| r.id).collect();
|
||||
let files_map = fetch_linked_files(&pool, &license_ids).await?;
|
||||
|
||||
let results = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let linked_files =
|
||||
files_map.get(&row.id).cloned().unwrap_or_default();
|
||||
LicenseRow {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
link: row.link,
|
||||
exceptions: row.exceptions,
|
||||
proof: row.proof,
|
||||
flame_project_id: row.flame_project_id,
|
||||
inserted_at: row.inserted_at,
|
||||
inserted_by: row.inserted_by,
|
||||
updated_at: row.updated_at,
|
||||
updated_by: row.updated_by,
|
||||
}
|
||||
.into_external_project(linked_files)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(web::Json(results))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[get("/by-sha1/{sha1}")]
|
||||
async fn get_by_sha1(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
path: web::Path<(String,)>,
|
||||
) -> Result<web::Json<ExternalProject>, ApiError> {
|
||||
check_is_moderator_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_READ,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let sha1 = path.into_inner().0;
|
||||
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
mel.id,
|
||||
mel.title,
|
||||
mel.status,
|
||||
mel.link,
|
||||
mel.exceptions,
|
||||
mel.proof,
|
||||
mel.flame_project_id,
|
||||
mel.inserted_at,
|
||||
mel.inserted_by,
|
||||
mel.updated_at,
|
||||
mel.updated_by
|
||||
FROM moderation_external_files mef
|
||||
INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id
|
||||
WHERE mef.sha1 = $1
|
||||
"#,
|
||||
sha1.as_bytes().to_vec(),
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let files_map = fetch_linked_files(&pool, &[row.id]).await?;
|
||||
let linked_files = files_map.get(&row.id).cloned().unwrap_or_default();
|
||||
|
||||
Ok(web::Json(
|
||||
LicenseRow {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
link: row.link,
|
||||
exceptions: row.exceptions,
|
||||
proof: row.proof,
|
||||
flame_project_id: row.flame_project_id,
|
||||
inserted_at: row.inserted_at,
|
||||
inserted_by: row.inserted_by,
|
||||
updated_at: row.updated_at,
|
||||
updated_by: row.updated_by,
|
||||
}
|
||||
.into_external_project(linked_files),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[patch("/{id}")]
|
||||
async fn update_license(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
path: web::Path<(i64,)>,
|
||||
body: web::Json<UpdateLicenseRequest>,
|
||||
) -> Result<web::Json<ExternalProject>, ApiError> {
|
||||
let user = check_is_moderator_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_READ,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let id = path.into_inner().0;
|
||||
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE moderation_external_licenses
|
||||
SET title = COALESCE($2, title),
|
||||
status = $3,
|
||||
link = COALESCE($4, link),
|
||||
exceptions = COALESCE($5, exceptions),
|
||||
proof = COALESCE($6, proof),
|
||||
flame_project_id = COALESCE($7, flame_project_id),
|
||||
updated_at = $8,
|
||||
updated_by = $9
|
||||
WHERE id = $1
|
||||
RETURNING id, title, status, link, exceptions, proof, flame_project_id,
|
||||
inserted_at, inserted_by, updated_at, updated_by
|
||||
"#,
|
||||
id,
|
||||
body.title,
|
||||
body.status.as_str(),
|
||||
body.link,
|
||||
body.exceptions,
|
||||
body.proof,
|
||||
body.flame_project_id,
|
||||
Utc::now(),
|
||||
user.id.0 as i64,
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let files_map = fetch_linked_files(&pool, &[id]).await?;
|
||||
let linked_files = files_map.get(&id).cloned().unwrap_or_default();
|
||||
|
||||
Ok(web::Json(
|
||||
LicenseRow {
|
||||
id: result.id,
|
||||
title: result.title,
|
||||
status: result.status,
|
||||
link: result.link,
|
||||
exceptions: result.exceptions,
|
||||
proof: result.proof,
|
||||
flame_project_id: result.flame_project_id,
|
||||
inserted_at: result.inserted_at,
|
||||
inserted_by: result.inserted_by,
|
||||
updated_at: result.updated_at,
|
||||
updated_by: result.updated_by,
|
||||
}
|
||||
.into_external_project(linked_files),
|
||||
))
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use crate::auth::get_user_from_headers;
|
||||
use crate::database;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::models::DBModerationLock;
|
||||
use crate::database::models::moderation_external_item;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::OrganizationId;
|
||||
use crate::models::projects::{Project, ProjectStatus};
|
||||
@@ -20,6 +21,7 @@ use ownership::get_projects_ownership;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
mod external_license;
|
||||
mod ownership;
|
||||
mod tech_review;
|
||||
|
||||
@@ -36,6 +38,10 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
.service(
|
||||
utoipa_actix_web::scope("/tech-review")
|
||||
.configure(tech_review::config),
|
||||
)
|
||||
.service(
|
||||
utoipa_actix_web::scope("/external-license")
|
||||
.configure(external_license::config),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -412,7 +418,7 @@ async fn set_project_meta(
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
judgements: web::Json<HashMap<String, Judgement>>,
|
||||
) -> Result<(), ApiError> {
|
||||
check_is_moderator_from_headers(
|
||||
let user = check_is_moderator_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
@@ -423,14 +429,10 @@ async fn set_project_meta(
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let mut ids = Vec::new();
|
||||
let mut titles = Vec::new();
|
||||
let mut statuses = Vec::new();
|
||||
let mut links = Vec::new();
|
||||
let mut proofs = Vec::new();
|
||||
let mut flame_ids = Vec::new();
|
||||
|
||||
let mut licenses = Vec::new();
|
||||
let mut file_hashes = Vec::new();
|
||||
let mut file_filenames = Vec::new();
|
||||
let mut file_license_ids = Vec::new();
|
||||
|
||||
for (hash, judgement) in judgements.0 {
|
||||
let id = random_base62(8);
|
||||
@@ -456,41 +458,38 @@ async fn set_project_meta(
|
||||
} => (title, status, link, proof, None),
|
||||
};
|
||||
|
||||
ids.push(id as i64);
|
||||
titles.push(title);
|
||||
statuses.push(status.as_str());
|
||||
links.push(link);
|
||||
proofs.push(proof);
|
||||
flame_ids.push(flame_id);
|
||||
licenses.push(moderation_external_item::ExternalLicense {
|
||||
id: id as i64,
|
||||
title,
|
||||
status: status.as_str().to_string(),
|
||||
link,
|
||||
proof,
|
||||
flame_project_id: flame_id,
|
||||
});
|
||||
file_hashes.push(hash);
|
||||
file_filenames.push(None);
|
||||
file_license_ids.push(id as i64);
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO moderation_external_licenses (id, title, status, link, proof, flame_project_id)
|
||||
SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::varchar[], $4::varchar[], $5::varchar[], $6::integer[])
|
||||
"
|
||||
)
|
||||
.bind(&ids[..])
|
||||
.bind(&titles[..])
|
||||
.bind(&statuses[..])
|
||||
.bind(&links[..])
|
||||
.bind(&proofs[..])
|
||||
.bind(&flame_ids[..])
|
||||
.execute(&mut transaction)
|
||||
.await?;
|
||||
let user_id = database::models::ids::DBUserId::from(user.id);
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO moderation_external_files (sha1, external_license_id)
|
||||
SELECT * FROM UNNEST ($1::bytea[], $2::bigint[])
|
||||
ON CONFLICT (sha1)
|
||||
DO NOTHING
|
||||
",
|
||||
moderation_external_item::ExternalLicense::insert_many(
|
||||
&mut transaction,
|
||||
&licenses,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
moderation_external_item::ExternalLicense::insert_files(
|
||||
&mut transaction,
|
||||
&file_hashes
|
||||
.iter()
|
||||
.map(|x| x.as_bytes().to_vec())
|
||||
.collect::<Vec<_>>(),
|
||||
&file_filenames,
|
||||
&file_license_ids,
|
||||
user_id,
|
||||
)
|
||||
.bind(&file_hashes[..])
|
||||
.bind(&ids[..])
|
||||
.execute(&mut transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
|
||||
import { LabrinthAuthV2Module } from './labrinth/auth/v2'
|
||||
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
|
||||
import { LabrinthCollectionsModule } from './labrinth/collections'
|
||||
import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal'
|
||||
import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal'
|
||||
import { LabrinthLimitsV3Module } from './labrinth/limits/v3'
|
||||
import { LabrinthModerationInternalModule } from './labrinth/moderation/internal'
|
||||
@@ -75,6 +76,7 @@ export const MODULE_REGISTRY = {
|
||||
labrinth_auth_v2: LabrinthAuthV2Module,
|
||||
labrinth_billing_internal: LabrinthBillingInternalModule,
|
||||
labrinth_collections: LabrinthCollectionsModule,
|
||||
labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule,
|
||||
labrinth_globals_internal: LabrinthGlobalsInternalModule,
|
||||
labrinth_moderation_internal: LabrinthModerationInternalModule,
|
||||
labrinth_notifications_v2: LabrinthNotificationsV2Module,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthExternalProjectsInternalModule extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_external_projects_internal'
|
||||
}
|
||||
|
||||
public async search(
|
||||
data: Labrinth.ExternalProjects.Internal.SearchRequest,
|
||||
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject[]> {
|
||||
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject[]>(
|
||||
'/moderation/external-license/search',
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: data,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public async getBySha1(
|
||||
sha1: string,
|
||||
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject> {
|
||||
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject>(
|
||||
`/moderation/external-license/by-sha1/${sha1}`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public async update(
|
||||
id: number,
|
||||
data: Labrinth.ExternalProjects.Internal.UpdateLicenseRequest,
|
||||
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject> {
|
||||
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject>(
|
||||
`/moderation/external-license/${id}`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from './auth/internal'
|
||||
export * from './auth/v2'
|
||||
export * from './billing/internal'
|
||||
export * from './collections'
|
||||
export * from './external-projects/internal'
|
||||
export * from './globals/internal'
|
||||
export * from './limits/v3'
|
||||
export * from './moderation/internal'
|
||||
|
||||
@@ -1462,6 +1462,52 @@ export namespace Labrinth {
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ExternalProjects {
|
||||
export namespace Internal {
|
||||
export type ExternalLicenseStatus =
|
||||
| 'yes'
|
||||
| 'with-attribution-and-source'
|
||||
| 'with-attribution'
|
||||
| 'no'
|
||||
| 'permanent-no'
|
||||
| 'unidentified'
|
||||
|
||||
export type LinkedFile = {
|
||||
name: string | null
|
||||
sha1: string
|
||||
}
|
||||
|
||||
export type ExternalProject = {
|
||||
id: number
|
||||
title: string | null
|
||||
status: ExternalLicenseStatus
|
||||
link: string | null
|
||||
exceptions: string | null
|
||||
proof: string | null
|
||||
flame_project_id: number | null
|
||||
inserted_at: string | null
|
||||
inserted_by: number | null
|
||||
updated_at: string | null
|
||||
updated_by: number | null
|
||||
linked_files: LinkedFile[]
|
||||
}
|
||||
|
||||
export type SearchRequest = {
|
||||
title?: string
|
||||
flame_id?: number
|
||||
}
|
||||
|
||||
export type UpdateLicenseRequest = {
|
||||
title?: string
|
||||
status: ExternalLicenseStatus
|
||||
link?: string
|
||||
exceptions?: string
|
||||
proof?: string
|
||||
flame_project_id?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace TechReview {
|
||||
export namespace Internal {
|
||||
export type SearchProjectsRequest = {
|
||||
|
||||
@@ -26,6 +26,7 @@ import _BadgeDollarSignIcon from './icons/badge-dollar-sign.svg?component'
|
||||
import _BanIcon from './icons/ban.svg?component'
|
||||
import _BellIcon from './icons/bell.svg?component'
|
||||
import _BellRingIcon from './icons/bell-ring.svg?component'
|
||||
import _BinaryIcon from './icons/binary.svg?component'
|
||||
import _BlendIcon from './icons/blend.svg?component'
|
||||
import _BlocksIcon from './icons/blocks.svg?component'
|
||||
import _BoldIcon from './icons/bold.svg?component'
|
||||
@@ -211,6 +212,7 @@ import _ShieldIcon from './icons/shield.svg?component'
|
||||
import _ShieldAlertIcon from './icons/shield-alert.svg?component'
|
||||
import _ShieldCheckIcon from './icons/shield-check.svg?component'
|
||||
import _SignalIcon from './icons/signal.svg?component'
|
||||
import _SignatureIcon from './icons/signature.svg?component'
|
||||
import _SkullIcon from './icons/skull.svg?component'
|
||||
import _SlashIcon from './icons/slash.svg?component'
|
||||
import _SortAscIcon from './icons/sort-asc.svg?component'
|
||||
@@ -416,6 +418,7 @@ export const BadgeDollarSignIcon = _BadgeDollarSignIcon
|
||||
export const BanIcon = _BanIcon
|
||||
export const BellIcon = _BellIcon
|
||||
export const BellRingIcon = _BellRingIcon
|
||||
export const BinaryIcon = _BinaryIcon
|
||||
export const BlendIcon = _BlendIcon
|
||||
export const BlocksIcon = _BlocksIcon
|
||||
export const BoldIcon = _BoldIcon
|
||||
@@ -601,6 +604,7 @@ export const ShieldIcon = _ShieldIcon
|
||||
export const ShieldAlertIcon = _ShieldAlertIcon
|
||||
export const ShieldCheckIcon = _ShieldCheckIcon
|
||||
export const SignalIcon = _SignalIcon
|
||||
export const SignatureIcon = _SignatureIcon
|
||||
export const SkullIcon = _SkullIcon
|
||||
export const SlashIcon = _SlashIcon
|
||||
export const SortAscIcon = _SortAscIcon
|
||||
|
||||
20
packages/assets/icons/binary.svg
Normal file
20
packages/assets/icons/binary.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-binary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="14" y="14" width="4" height="6" rx="2" />
|
||||
<rect x="6" y="4" width="4" height="6" rx="2" />
|
||||
<path d="M6 20h4" />
|
||||
<path d="M14 10h4" />
|
||||
<path d="M6 14h2v6" />
|
||||
<path d="M14 4h2v6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 487 B |
16
packages/assets/icons/signature.svg
Normal file
16
packages/assets/icons/signature.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-signature"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m21 17-2.156-1.868A.5.5 0 0 0 18 15.5v.5a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1c0-2.545-3.991-3.97-8.5-4a1 1 0 0 0 0 5c4.153 0 4.745-11.295 5.708-13.5a2.5 2.5 0 1 1 3.31 3.284" />
|
||||
<path d="M3 21h18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
3
packages/ui/src/components/external_files/index.ts
Normal file
3
packages/ui/src/components/external_files/index.ts
Normal 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'
|
||||
7
packages/ui/src/components/external_files/types.ts
Normal file
7
packages/ui/src/components/external_files/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type ExternalLicenseStatus =
|
||||
| 'yes'
|
||||
| 'with-attribution-and-source'
|
||||
| 'with-attribution'
|
||||
| 'no'
|
||||
| 'permanent-no'
|
||||
| 'unidentified'
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user