Files
AstralRinth/apps/frontend/src/pages/moderation/external-projects.vue
T
Prospector e13a89dd72 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>
2026-05-04 16:31:37 +00:00

394 lines
10 KiB
Vue

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