External projects moderator database (#5692)

* Begin external projects moderator database frontend

* add copy link button

* begin project page permissions settings

* MEL database backend routes

* include filename in external files

* Hook up frontend external license page to backend

* more work on user-facing external projects stuff

* put user-facing stuff behind feature flag

* prepr

* clippy

---------

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

View File

@@ -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

View File

@@ -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',

View File

@@ -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 isnt hosted on Modrinth, you need to let us know where its 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}"
},

View File

@@ -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),

View 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 isnt hosted on Modrinth, you need to let us know where its 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>

View File

@@ -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>

View File

@@ -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({

View 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>