mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 09:34:50 +00:00
* Begin external projects moderator database frontend * add copy link button * begin project page permissions settings * MEL database backend routes * include filename in external files * wip: when uploading a version file, fetch its overrides as a list * wip: override license checks * improve FileHost ref counting * file host read capability * scan files when inserting version file * add dependency sha1 field * clean up version files * wip: attributions * update s3 file host * attribution scanning basic works * works * insert attribution info after resolving * add routes * remove dep sha1 stuff * prepr * wip: override file sources * add files_missing_attributions to versions * return extended version info + attributed at/by * hook up frontend to backend (mostly) * expose version date published * withholding version visibility * frontend work * prepr * use api-client for img upload * moar frontend * prepr * Add schema to attribution resolution and Flame project results * sqlx prepare * changes * remove feature flag, fix optional proof images * fix schema * fmt * fix deletion and file fetch * prepare * fix admonition * update frontend stuff to new schema * prepr * attribution on dependencies * fixes * sqlx prepare * fixes * routes * fix routes * Version grandfathering * prepare * wip: bulk routes * pushing what i've got rn * include link in NoPermission * change hash insert to bulk route * query flame even if entry in MEL * delete file with weird name * Prioritise putting override files in existing groups even with ExternalLicense * fix how hex bytes are handled in route * feat: coolbot moderation changes (#6215) * Update moderator checklist * move permissions stage order * Updated nagContext.versions to v3, added nag for permissions * Update permissions.vue default messages * prepr --------- Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com> * QA * prepr * should group by project * return attribution resolution correctly * updated by moderator info * Track what moderator reviewed an attribution moderation status * default deser FMA field * new version page * clean up fetching + add a couple missing features * qa items * prepr * provide moderation package stuff with DI * format? * don't redact moderated_at * move supplementary resources * Reorganize moderation messages. * Quick replies for external content permissions. * prepare * QA * allow exempting projects * Ignore Flame projects which 404 * fix ci * fix cross project attribution stuff * Fix permission error * change what files get cscanned * add more logging * QA Jun 22 * fix * idempotency * Expose route for rescanning * update blog link --------- Co-authored-by: aecsocket <aecsocket@tutanota.com> Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com> Co-authored-by: aecsocket <43144841+aecsocket@users.noreply.github.com>
259 lines
8.0 KiB
Vue
259 lines
8.0 KiB
Vue
<script setup lang="ts">
|
|
import type { Labrinth } from '@modrinth/api-client'
|
|
import { CheckCircleIcon, ScaleIcon, UserRoundIcon, XCircleIcon } from '@modrinth/assets'
|
|
import { builtinLicenses } from '@modrinth/utils'
|
|
import { computed } from 'vue'
|
|
|
|
import { IntlFormatted } from '#ui/components'
|
|
import { AutoLink, Avatar } from '#ui/components/base'
|
|
|
|
import { useFormatDateTime } from '../../composables/format-date-time'
|
|
import { defineMessage, defineMessages, useVIntl } from '../../composables/i18n'
|
|
import type { ProjectPermissionField } from './external-project-utils'
|
|
import {
|
|
attributionLinkToWork,
|
|
isAutomaticNoPermissionAttribution,
|
|
isCustomAttributionLicense,
|
|
isHttpUrl,
|
|
PERMISSION_REASONS,
|
|
} from './external-project-utils'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
attribution: Labrinth.Attribution.Internal.AttributionResolution
|
|
attributedAt?: string | null
|
|
attributedBy?: string | null
|
|
attributorHref: string | null
|
|
attributorLabel: string
|
|
attributorAvatarUrl?: string | null
|
|
moderator?: boolean
|
|
}>(),
|
|
{
|
|
moderator: false,
|
|
},
|
|
)
|
|
|
|
const { formatMessage } = useVIntl()
|
|
const formatDate = useFormatDateTime({ dateStyle: 'long' })
|
|
|
|
const messages = defineMessages({
|
|
linkLabel: {
|
|
id: 'external-files.permissions-card.link-label',
|
|
defaultMessage: 'Link to work:',
|
|
},
|
|
notesLabel: {
|
|
id: 'external-files.permissions-card.notes-label',
|
|
defaultMessage: 'Notes:',
|
|
},
|
|
licensedAs: {
|
|
id: 'external-files.permissions-card.licensed-as',
|
|
defaultMessage: 'Licensed:',
|
|
},
|
|
lastUpdated: {
|
|
id: 'external-files.permissions-card.last-updated',
|
|
defaultMessage: 'Last updated on {date} by {user}',
|
|
},
|
|
proofImagesLabel: {
|
|
id: 'external-files.permissions-card.proof-images-label',
|
|
defaultMessage: 'Proof images:',
|
|
},
|
|
proofImageThumbnailAlt: {
|
|
id: 'external-files.permissions-card.proof-image-alt',
|
|
defaultMessage: 'Proof screenshot {n}',
|
|
},
|
|
updatedByModerator: {
|
|
id: 'external-files.permissions-card.updated-by-moderator',
|
|
defaultMessage: 'Moderator',
|
|
},
|
|
})
|
|
|
|
const unknownLicenseMessage = defineMessage({
|
|
id: 'external-files.permissions-card.license.unknown',
|
|
defaultMessage: 'Unknown',
|
|
})
|
|
|
|
const notesNoneMessage = defineMessage({
|
|
id: 'external-files.permissions-card.notes-none',
|
|
defaultMessage: 'None',
|
|
})
|
|
|
|
const automaticNoPermission = computed(() =>
|
|
isAutomaticNoPermissionAttribution(props.attribution, props.attributedBy),
|
|
)
|
|
|
|
const readViewFields = computed(() => {
|
|
if (automaticNoPermission.value) {
|
|
return ['link_to_work'] as ProjectPermissionField[]
|
|
}
|
|
return PERMISSION_REASONS[props.attribution.kind]?.fields ?? []
|
|
})
|
|
|
|
const automaticAttributionDescription = computed(() => {
|
|
if (props.attribution.kind === 'globally_allowed') {
|
|
return PERMISSION_REASONS.globally_allowed.description
|
|
}
|
|
if (automaticNoPermission.value) {
|
|
return PERMISSION_REASONS.no_permission.automaticDescription ?? null
|
|
}
|
|
return null
|
|
})
|
|
|
|
const licenseReadDisplay = computed(() => {
|
|
const attr = props.attribution
|
|
if (attr.kind !== 'license' && attr.kind !== 'my_project') {
|
|
return null
|
|
}
|
|
if (isCustomAttributionLicense(attr.license)) {
|
|
return { kind: 'custom' as const, value: attr.license.name }
|
|
}
|
|
const licenseId = attr.license
|
|
if (licenseId) {
|
|
const friendly =
|
|
builtinLicenses.find((license) => license.short === licenseId)?.friendly ?? licenseId
|
|
return { kind: 'standard' as const, value: friendly }
|
|
}
|
|
return { kind: 'unknown' as const, value: formatMessage(unknownLicenseMessage) }
|
|
})
|
|
|
|
const linkToWork = computed(() => attributionLinkToWork(props.attribution))
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div
|
|
class="flex flex-col gap-4 rounded-t-2xl p-4 mt-2 bg-surface-3 border border-solid border-surface-4"
|
|
:class="{ 'rounded-b-2xl': !$slots.footer, 'border-b-0': $slots.footer }"
|
|
>
|
|
<div class="flex gap-4">
|
|
<div class="flex flex-col gap-3 w-full">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<span class="text-contrast font-semibold flex items-center gap-2">
|
|
<CheckCircleIcon
|
|
v-if="attribution.kind === 'globally_allowed'"
|
|
class="text-green size-5"
|
|
/>
|
|
<XCircleIcon v-else-if="automaticNoPermission" class="text-red size-5" />
|
|
{{ formatMessage(PERMISSION_REASONS[attribution.kind].label) }}
|
|
</span>
|
|
</div>
|
|
<template v-if="automaticAttributionDescription">
|
|
<p class="m-0">
|
|
{{ formatMessage(automaticAttributionDescription) }}
|
|
</p>
|
|
</template>
|
|
<div
|
|
v-if="!(readViewFields.includes('link_to_work') && !linkToWork)"
|
|
class="flex flex-col gap-3"
|
|
>
|
|
<div class="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 items-baseline">
|
|
<template v-if="attribution.kind === 'license' || attribution.kind === 'my_project'">
|
|
<span class="text-secondary font-medium">
|
|
{{ formatMessage(messages.licensedAs) }}
|
|
</span>
|
|
<a
|
|
v-if="
|
|
licenseReadDisplay?.kind === 'custom' && isHttpUrl(licenseReadDisplay.value)
|
|
"
|
|
:href="licenseReadDisplay.value"
|
|
target="_blank"
|
|
rel="noopener"
|
|
class="text-link truncate"
|
|
>
|
|
{{ licenseReadDisplay.value }}
|
|
</a>
|
|
<span v-else class="text-primary whitespace-pre-wrap break-words">
|
|
{{ licenseReadDisplay?.value }}
|
|
</span>
|
|
</template>
|
|
<template v-if="readViewFields.includes('link_to_work') && linkToWork">
|
|
<span class="text-secondary font-medium">
|
|
{{ formatMessage(messages.linkLabel) }}
|
|
</span>
|
|
<a :href="linkToWork" target="_blank" rel="noopener" class="text-link truncate">{{
|
|
linkToWork
|
|
}}</a>
|
|
</template>
|
|
<template v-if="readViewFields.includes('notes')">
|
|
<span class="text-secondary font-medium">
|
|
{{ formatMessage(messages.notesLabel) }}
|
|
</span>
|
|
<span class="text-primary whitespace-pre-wrap break-words">
|
|
{{
|
|
attribution.notes?.trim() ? attribution.notes : formatMessage(notesNoneMessage)
|
|
}}
|
|
</span>
|
|
</template>
|
|
</div>
|
|
<div v-if="attribution.image_urls?.length" class="flex flex-col gap-2">
|
|
<span class="text-secondary font-medium">
|
|
{{ formatMessage(messages.proofImagesLabel) }}
|
|
</span>
|
|
<div class="flex flex-wrap gap-2">
|
|
<a
|
|
v-for="(src, idx) in attribution.image_urls"
|
|
:key="`${src}-${idx}`"
|
|
:href="src"
|
|
target="_blank"
|
|
rel="noopener"
|
|
class="block rounded-xl border-[1px] border-solid border-surface-5 overflow-hidden shrink-0"
|
|
>
|
|
<img
|
|
:src="src"
|
|
:alt="formatMessage(messages.proofImageThumbnailAlt, { n: idx + 1 })"
|
|
class="max-h-40 max-w-full object-contain"
|
|
/>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="$slots.actions">
|
|
<slot name="actions" />
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="attributedAt"
|
|
class="inline-flex items-center flex-wrap gap-x-2 gap-y-1 pt-3 mt-1 border-0 border-t border-solid border-surface-5"
|
|
>
|
|
<IntlFormatted
|
|
:message-id="messages.lastUpdated"
|
|
:values="{ date: formatDate(attributedAt) }"
|
|
>
|
|
<template #user>
|
|
<span
|
|
v-if="!moderator && attribution.updated_by_moderator"
|
|
class="text-orange flex items-center gap-1"
|
|
>
|
|
<ScaleIcon class="size-4 shrink-0" />
|
|
{{ formatMessage(messages.updatedByModerator) }}
|
|
</span>
|
|
<AutoLink
|
|
v-if="attributedBy && moderator"
|
|
:to="attributorHref"
|
|
class="inline-flex items-center gap-1.5 text-primary font-medium hover:underline max-w-full min-w-0"
|
|
>
|
|
<Avatar
|
|
v-if="attributorAvatarUrl"
|
|
:src="attributorAvatarUrl"
|
|
:alt="attributorLabel"
|
|
size="18px"
|
|
class="shrink-0"
|
|
circle
|
|
/>
|
|
<UserRoundIcon v-else class="size-4 shrink-0" />
|
|
<span class="truncate">{{ attributorLabel }}</span>
|
|
</AutoLink>
|
|
</template>
|
|
</IntlFormatted>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="$slots.footer"
|
|
class="p-4 border-surface-4 border bg-surface-2 border-solid rounded-b-2xl"
|
|
>
|
|
<slot name="footer" />
|
|
</div>
|
|
</div>
|
|
</template>
|