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>
|
||||
Reference in New Issue
Block a user