implement functionality with staging api

This commit is contained in:
Prospector
2026-08-02 16:52:19 -07:00
parent 21a31b337f
commit 57be502f99
26 changed files with 1620 additions and 385 deletions
@@ -222,4 +222,52 @@ export class LabrinthProjectsV3Module extends AbstractModule {
method: 'DELETE',
})
}
/**
* Get content disclosures for a project
*
* @param id - Project ID or slug
* @returns Promise resolving to the project's disclosures
*
* @example
* ```typescript
* const { disclosures } = await client.labrinth.projects_v3.getDisclosures('sodium')
* ```
*/
public async getDisclosures(id: string): Promise<Labrinth.Projects.v3.GetProjectDisclosures> {
return this.client.request<Labrinth.Projects.v3.GetProjectDisclosures>(
`/project/${id}/disclosures`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Modify content disclosures for a project
*
* @param id - Project ID or slug
* @param data - Disclosures to set and types to remove
*
* @example
* ```typescript
* await client.labrinth.projects_v3.modifyDisclosures('sodium', {
* set: [{ type: 'ai_content', uses: ['text'], note: 'Translations are AI-generated.' }],
* remove: ['advertisements'],
* })
* ```
*/
public async modifyDisclosures(
id: string,
data: Labrinth.Projects.v3.ModifyProjectDisclosures,
): Promise<void> {
return this.client.request(`/project/${id}/disclosures`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: data,
})
}
}
@@ -1300,6 +1300,70 @@ export namespace Labrinth {
projects: Project[]
versions: Labrinth.Versions.v3.Version[]
}
export type TelemetryConsent = 'opt_in' | 'opt_out' | 'always_active'
export type AiUsage = 'code' | 'assets' | 'text' | 'functionality'
export type DerivativeSource = {
label: string
link?: string | null
note?: string | null
}
export type ProjectDisclosure =
| {
type: 'ai_content'
uses: AiUsage[]
note?: string | null
}
| {
type: 'advertisements'
note?: string | null
}
| {
type: 'epilepsy_triggers'
note?: string | null
}
| {
type: 'system_interactions'
note?: string | null
}
| {
type: 'telemetry'
consent: TelemetryConsent
data_collected: string[]
}
| {
type: 'derivative_work'
sources: DerivativeSource[]
}
| {
type: 'paid_features'
features: string[]
}
export type ProjectDisclosureData = ProjectDisclosure & {
set_by_moderator: boolean
updated_at: string
updated_by?: string | null
}
export type ProjectDisclosureType = ProjectDisclosure['type']
export type ProjectDisclosureOf<T extends ProjectDisclosureType> = Extract<
ProjectDisclosureData,
{ type: T }
>
export type GetProjectDisclosures = {
disclosures: ProjectDisclosureData[]
}
export type ModifyProjectDisclosures = {
set: ProjectDisclosure[]
remove: ProjectDisclosureType[]
}
}
}
+2
View File
@@ -82,6 +82,7 @@ import _CircleArrowRightIcon from './icons/circle-arrow-right.svg?component'
import _CircleDashedIcon from './icons/circle-dashed.svg?component'
import _CircleDollarSignIcon from './icons/circle-dollar-sign.svg?component'
import _CircleUserIcon from './icons/circle-user.svg?component'
import _CircuitBoardIcon from './icons/circuit-board.svg?component'
import _ClearIcon from './icons/clear.svg?component'
import _ClientIcon from './icons/client.svg?component'
import _ClipboardCopyIcon from './icons/clipboard-copy.svg?component'
@@ -519,6 +520,7 @@ export const CircleArrowRightIcon = _CircleArrowRightIcon
export const CircleDashedIcon = _CircleDashedIcon
export const CircleDollarSignIcon = _CircleDollarSignIcon
export const CircleUserIcon = _CircleUserIcon
export const CircuitBoardIcon = _CircuitBoardIcon
export const ClearIcon = _ClearIcon
export const ClientIcon = _ClientIcon
export const ClipboardCopyIcon = _ClipboardCopyIcon
+19
View File
@@ -0,0 +1,19 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-circuit-board"
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 width="18" height="18" x="3" y="3" rx="2" />
<path d="M11 9h4a2 2 0 0 0 2-2V3" />
<circle cx="9" cy="9" r="2" />
<path d="M7 21v-4a2 2 0 0 1 2-2h4" />
<circle cx="15" cy="15" r="2" />
</svg>

After

Width:  |  Height:  |  Size: 493 B

@@ -0,0 +1,29 @@
<script setup lang="ts">
defineProps<{
title?: string
titleFor?: string
description?: string
}>()
</script>
<template>
<div class="flex flex-col gap-2">
<div
v-if="title || description || $slots.title || $slots.description"
:class="description || $slots.description ? 'flex flex-col gap-1' : undefined"
>
<label v-if="title && titleFor" :for="titleFor" class="m-0 leading-normal text-contrast">
{{ title }}
</label>
<p v-else-if="title" class="m-0 leading-normal text-contrast">
{{ title }}
</p>
<slot name="title" />
<p v-if="description" class="m-0 leading-normal text-primary">
{{ description }}
</p>
<slot name="description" />
</div>
<slot />
</div>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { Component } from 'vue'
import ToggleCard from './ToggleCard.vue'
defineProps<{
title: string
icon?: Component
description?: string
disabled?: boolean
}>()
const enabled = defineModel<boolean>({ required: true })
</script>
<template>
<ToggleCard v-model="enabled" :disabled="disabled">
<h3 class="mb-1 mt-0 flex items-center gap-2 text-base font-semibold text-contrast">
<component :is="icon" v-if="icon" class="size-5 shrink-0 text-primary" aria-hidden="true" />
{{ title }}
</h3>
<div
v-if="description || $slots.default"
class="flex flex-col gap-2 text-sm leading-normal [&>p]:m-0"
>
<p v-if="description">{{ description }}</p>
<slot />
</div>
<template v-if="$slots.expanded" #expanded>
<div class="flex flex-col gap-4">
<slot name="expanded" />
</div>
</template>
</ToggleCard>
</template>
+20 -4
View File
@@ -2,10 +2,19 @@
import { SmartClickable, Toggle } from '@modrinth/ui'
import { computed, useId } from 'vue'
const props = defineProps<{
disabled?: boolean
}>()
const value = defineModel<boolean>({ required: true })
const baseId = useId()
const toggleId = computed(() => `toggle-card-toggle-${baseId}`)
function toggle() {
if (props.disabled) return
value.value = !value.value
}
</script>
<template>
@@ -16,8 +25,10 @@ const toggleId = computed(() => `toggle-card-toggle-${baseId}`)
<button
aria-hidden="true"
tabindex="-1"
class="flex h-full w-full cursor-pointer"
@click="value = !value"
:disabled="disabled"
class="flex h-full w-full"
:class="disabled ? 'cursor-not-allowed' : 'cursor-pointer'"
@click="toggle"
/>
</template>
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-6 p-4">
@@ -25,8 +36,13 @@ const toggleId = computed(() => `toggle-card-toggle-${baseId}`)
<slot :toggle-id="toggleId" />
</div>
<div>
<slot name="toggle">
<Toggle :id="toggleId" v-model="value" class="smart-clickable:allow-pointer-events" />
<slot name="toggle" :disabled="disabled">
<Toggle
:id="toggleId"
v-model="value"
:disabled="disabled"
class="smart-clickable:allow-pointer-events"
/>
</slot>
</div>
</div>
@@ -14,20 +14,25 @@ const emit = defineEmits<{
(e: 'reset' | 'save', event: MouseEvent): void
}>()
type SaveDisabledReason = MessageDescriptor | string
const props = withDefaults(
defineProps<{
canReset?: boolean
canSave?: boolean
original: T
modified: Partial<T>
saving?: boolean
text?: MessageDescriptor | string
saveLabel?: MessageDescriptor | string
savingLabel?: MessageDescriptor | string
saveDisabledReason?: SaveDisabledReason | SaveDisabledReason[]
saveIcon?: Component
inline?: boolean
}>(),
{
canReset: true,
canSave: true,
saving: false,
text: () =>
defineMessage({
@@ -36,6 +41,7 @@ const props = withDefaults(
}),
saveLabel: () => commonMessages.saveButton,
savingLabel: () => commonMessages.savingButton,
saveDisabledReason: undefined,
saveIcon: SaveIcon,
inline: false,
},
@@ -49,6 +55,22 @@ function localizeIfPossible(message: MessageDescriptor | string) {
return typeof message === 'string' ? message : formatMessage(message)
}
const saveDisabled = computed(() => props.saving || !props.canSave)
const saveDisabledTooltip = computed(() => {
if (!saveDisabled.value || props.saving || !props.saveDisabledReason) {
return undefined
}
const reasons = (
Array.isArray(props.saveDisabledReason) ? props.saveDisabledReason : [props.saveDisabledReason]
).map(localizeIfPossible)
return reasons.length > 0
? { content: reasons.join('\n'), popperClass: 'unsaved-changes-save-tooltip' }
: undefined
})
const actionBar = ref<InstanceType<typeof FloatingActionBar> | null>(null)
function nudge(): void {
@@ -67,13 +89,32 @@ defineExpose({ nudge })
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="saving" @click="(e) => emit('save', e)">
<SpinnerIcon v-if="saving" class="animate-spin" />
<component :is="saveIcon" v-else />
{{ localizeIfPossible(saving ? savingLabel : saveLabel) }}
</button>
</ButtonStyled>
<span
v-tooltip="saveDisabledTooltip"
class="flex"
:class="{ 'cursor-not-allowed': saveDisabled }"
>
<ButtonStyled color="brand">
<button
:disabled="saveDisabled"
:class="{ 'pointer-events-none': saveDisabled }"
@click="(e) => emit('save', e)"
>
<SpinnerIcon v-if="saving" class="animate-spin" />
<component :is="saveIcon" v-else />
{{ localizeIfPossible(saving ? savingLabel : saveLabel) }}
</button>
</ButtonStyled>
</span>
</div>
</FloatingActionBar>
</template>
<style lang="scss">
/* unscoped because floating-vue teleports the tooltip outside of scope */
.v-popper__popper.v-popper--theme-tooltip.unsaved-changes-save-tooltip .v-popper__inner {
max-width: 22rem;
white-space: pre-line;
line-height: 1.4;
}
</style>
+2
View File
@@ -92,7 +92,9 @@ export { default as RadioButtons } from './RadioButtons.vue'
export { default as ReadyTransition } from './ReadyTransition.vue'
export { default as ScrollablePanel } from './ScrollablePanel.vue'
export { default as ServerNotice } from './ServerNotice.vue'
export { default as SettingsFormGroup } from './SettingsFormGroup.vue'
export { default as SettingsLabel } from './SettingsLabel.vue'
export { default as SettingsToggleCard } from './SettingsToggleCard.vue'
export { default as SimpleBadge } from './SimpleBadge.vue'
export { default as Slider } from './Slider.vue'
export { default as SmartClickable } from './SmartClickable.vue'
@@ -16,46 +16,85 @@
<div
class="flex flex-col gap-3 [&>div>svg]:shrink-0 [&>div>svg]:mt-[1px] [&>div]:flex [&>div]:gap-2 [&>div]:items-start"
>
<div>
<SparklesIcon aria-hidden="true" />
<div v-if="photosensitivityDisclosure" class="text-orange">
<EyeIcon aria-hidden="true" />
<div class="flex flex-col gap-1">
<span>
{{ capitalizeString(formatMessage(messages.aiGeneratedContent, { type: 'code' })) }}
{{ capitalizeString(formatMessage(messages.photosensitivityTitle)) }}
</span>
<span class="text-sm text-secondary">
The Chinese and Arabic translations are AI-generated
<span v-if="photosensitivityDisclosure.note" class="text-sm text-secondary">
{{ photosensitivityDisclosure.note }}
</span>
</div>
</div>
<div>
<div v-if="aiDisclosure">
<SparklesIcon aria-hidden="true" />
<div class="flex flex-col gap-1">
<span>
{{
capitalizeString(formatMessage(messages.aiGeneratedContent, { type: aiContentType }))
}}
</span>
<span v-if="aiDisclosure.note" class="text-sm text-secondary">
{{ aiDisclosure.note }}
</span>
</div>
</div>
<div v-if="advertisingDisclosure">
<MegaphoneIcon aria-hidden="true" />
<div class="flex flex-col gap-1">
<span>
{{ capitalizeString(formatMessage(messages.advertisingTitle)) }}
</span>
<span class="text-sm text-secondary"> Title screen has Essential promotion </span>
<span v-if="advertisingDisclosure.note" class="text-sm text-secondary">
{{ advertisingDisclosure.note }}
</span>
</div>
</div>
<div>
<div v-if="paidFeaturesDisclosure">
<CircleDollarSignIcon aria-hidden="true" />
<div class="flex flex-col gap-1">
<span>
{{ capitalizeString(formatMessage(messages.paidFeatures)) }}
</span>
<span class="text-sm text-secondary"> Cosmetics available as Patreon reward </span>
<span
v-for="(feature, index) in paidFeaturesDisclosure.features"
:key="`${feature}-${index}`"
class="text-sm text-secondary"
>
{{ feature }}
</span>
</div>
</div>
<div>
<div v-if="telemetryDisclosure">
<RadioTowerIcon aria-hidden="true" />
<div class="flex flex-col gap-1">
<span>
{{ capitalizeString(formatMessage(messages.telemetryTitle, { consent: 'opt_out' })) }}
{{
capitalizeString(
formatMessage(messages.telemetryTitle, {
consent: telemetryDisclosure.consent,
}),
)
}}
</span>
<span class="text-sm text-secondary">
Update checker provides anonymous launch analytics to Modrinth
<span
v-for="(entry, index) in telemetryDisclosure.data_collected"
:key="`${entry}-${index}`"
class="text-sm text-secondary"
>
{{ entry }}
</span>
<span class="text-sm text-blue flex items-center gap-1">
View privacy policy <ExternalIcon />
</div>
</div>
<div v-if="systemInteractionsDisclosure">
<CircuitBoardIcon aria-hidden="true" />
<div class="flex flex-col gap-1">
<span>
{{ capitalizeString(formatMessage(messages.systemInteractionsTitle)) }}
</span>
<span v-if="systemInteractionsDisclosure.note" class="text-sm text-secondary">
{{ systemInteractionsDisclosure.note }}
</span>
</div>
</div>
@@ -86,17 +125,29 @@
</IntlFormatted>
</div>
</div>
<div>
<div v-if="derivativeWorkDisclosure">
<GitForkIcon aria-hidden="true" />
<div class="flex flex-col gap-2">
<span>
{{ capitalizeString(formatMessage(messages.derivativeWork)) }}
</span>
<div class="flex flex-col gap-1">
<span class="text-blue text-sm flex items-center gap-1">
Modification Menu <ExternalIcon />
</span>
<span class="text-sm text-secondary"> Forked to add Fun Mode </span>
<div
v-for="(source, index) in derivativeWorkDisclosure.sources"
:key="`${source.label}-${index}`"
class="flex flex-col gap-1"
>
<a
v-if="source.link"
:href="source.link"
:target="linkTarget"
rel="noopener nofollow ugc"
class="text-blue text-sm flex items-center gap-1 hover:underline"
>
{{ source.label }}
<ExternalIcon />
</a>
<span v-else class="text-sm">{{ source.label }}</span>
<span v-if="source.note" class="text-sm text-secondary">{{ source.note }}</span>
</div>
</div>
</div>
@@ -157,7 +208,9 @@ import {
BookTextIcon,
CalendarIcon,
CircleDollarSignIcon,
CircuitBoardIcon,
ExternalIcon,
EyeIcon,
GitForkIcon,
HeartIcon,
MegaphoneIcon,
@@ -178,6 +231,7 @@ import { Avatar, IntlFormatted } from '../base'
import { NewModal } from '../modal'
const LICENSE_STALE_TIME = 1000 * 60 * 10
const DISCLOSURE_STALE_TIME = 1000 * 60 * 5
const { formatMessage } = useVIntl()
const { labrinth } = injectModrinthClient()
@@ -225,7 +279,7 @@ const messages = defineMessages({
aiGeneratedContent: {
id: 'project.disclosure.ai-generated-content.title',
defaultMessage:
'Contains AI-generated {type, select, code {code} assets {assets} code_assets {code and assets} text {text} other {content}}',
'Contains AI-generated {type, select, code {code} assets {assets} code_assets {code and assets} text {text} functionality {functionality} other {content}}',
},
derivativeWork: {
id: 'project.disclosure.derivative-work.title',
@@ -234,8 +288,54 @@ const messages = defineMessages({
telemetryTitle: {
id: 'project.disclosure.telemetry.title',
defaultMessage:
'Contains {consent, select, opt_in {opt-in telemetry} opt_out {opt-out telemetry} other {telemetry}}',
'Contains {consent, select, opt_in {opt-in telemetry} opt_out {opt-out telemetry} always_active {always-active telemetry} other {telemetry}}',
},
photosensitivityTitle: {
id: 'project.disclosure.photosensitivity.title',
defaultMessage: 'Photosensitivity warning',
},
systemInteractionsTitle: {
id: 'project.disclosure.system-interactions.title',
defaultMessage: 'Contains external system interactions',
},
})
const { data: disclosuresResponse } = useQuery({
queryKey: computed(() => ['project', 'disclosures', 'v3', props.project.id] as const),
queryFn: () => labrinth.projects_v3.getDisclosures(props.project.id),
staleTime: DISCLOSURE_STALE_TIME,
})
const disclosures = computed(() => disclosuresResponse.value?.disclosures ?? [])
function findDisclosure<T extends Labrinth.Projects.v3.ProjectDisclosureType>(type: T) {
return disclosures.value.find(
(d): d is Labrinth.Projects.v3.ProjectDisclosureOf<T> => d.type === type,
)
}
const aiDisclosure = computed(() => findDisclosure('ai_content'))
const advertisingDisclosure = computed(() => findDisclosure('advertisements'))
const paidFeaturesDisclosure = computed(() => findDisclosure('paid_features'))
const telemetryDisclosure = computed(() => findDisclosure('telemetry'))
const derivativeWorkDisclosure = computed(() => findDisclosure('derivative_work'))
const photosensitivityDisclosure = computed(() => findDisclosure('epilepsy_triggers'))
const systemInteractionsDisclosure = computed(() => findDisclosure('system_interactions'))
const aiContentType = computed(() => {
const disclosure = aiDisclosure.value
if (!disclosure) {
return 'other'
}
const uses = new Set(disclosure.uses)
if (uses.size === 2 && uses.has('code') && uses.has('assets')) {
return 'code_assets'
}
if (uses.size === 1) {
return uses.values().next().value
}
return 'other'
})
const createdDate = computed(() =>
+11 -2
View File
@@ -131,6 +131,9 @@
"button.accept": {
"defaultMessage": "Accept"
},
"button.add-another": {
"defaultMessage": "Add another"
},
"button.add-server-to-instance": {
"defaultMessage": "Add server to instance"
},
@@ -3246,7 +3249,7 @@
"defaultMessage": "Contains advertising"
},
"project.disclosure.ai-generated-content.title": {
"defaultMessage": "Contains AI-generated {type, select, code {code} assets {assets} code_assets {code and assets} text {text} other {content}}"
"defaultMessage": "Contains AI-generated {type, select, code {code} assets {assets} code_assets {code and assets} text {text} functionality {functionality} other {content}}"
},
"project.disclosure.derivative-work.title": {
"defaultMessage": "This is a derivative work of:"
@@ -3254,8 +3257,14 @@
"project.disclosure.paid-features.title": {
"defaultMessage": "Contains paid features"
},
"project.disclosure.photosensitivity.title": {
"defaultMessage": "Photosensitivity warning"
},
"project.disclosure.system-interactions.title": {
"defaultMessage": "Contains external system interactions"
},
"project.disclosure.telemetry.title": {
"defaultMessage": "Contains {consent, select, opt_in {opt-in telemetry} opt_out {opt-out telemetry} other {telemetry}}"
"defaultMessage": "Contains {consent, select, opt_in {opt-in telemetry} opt_out {opt-out telemetry} always_active {always-active telemetry} other {telemetry}}"
},
"project.download-count-tooltip": {
"defaultMessage": "{count, number} {count, plural, one {download} other {downloads}}"
+4
View File
@@ -35,6 +35,10 @@ export const commonMessages = defineMessages({
id: 'project-type.all',
defaultMessage: 'All',
},
addAnotherButton: {
id: 'button.add-another',
defaultMessage: 'Add another',
},
addServerToInstanceButton: {
id: 'button.add-server-to-instance',
defaultMessage: 'Add server to instance',
+4 -4
View File
@@ -1,4 +1,4 @@
import { isEqual } from 'es-toolkit'
import { cloneDeep, isEqual } from 'es-toolkit'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref } from 'vue'
@@ -15,7 +15,7 @@ export function useSavable<T extends Record<string, unknown>>(
save: () => Promise<void>
} {
const savedValues = computed(data)
const currentValues = ref({ ...data() }) as Ref<T>
const currentValues = ref(cloneDeep(data())) as Ref<T>
const saving = ref(false)
const changes = computed<Partial<T>>(() => {
@@ -32,7 +32,7 @@ export function useSavable<T extends Record<string, unknown>>(
const hasChanges = computed(() => Object.keys(changes.value).length > 0)
const reset = () => {
currentValues.value = data()
currentValues.value = cloneDeep(data())
}
const saveInternal = async () => {
@@ -40,7 +40,7 @@ export function useSavable<T extends Record<string, unknown>>(
saving.value = true
try {
await save(changes.value)
currentValues.value = data()
currentValues.value = cloneDeep(data())
} finally {
saving.value = false
}