From 57be502f99d48eb1a7632cc70a2179fa835613bb Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:52:19 -0700 Subject: [PATCH] implement functionality with staging api --- apps/frontend/src/components/ui/Chips.vue | 2 +- .../disclosures/AdvertisingDisclosureCard.vue | 87 ++++ .../disclosures/AiDisclosureCard.vue | 147 ++++++ .../disclosures/DerivativeDisclosureCard.vue | 169 +++++++ .../PaidFeaturesDisclosureCard.vue | 100 ++++ .../PhotosensitivityDisclosureCard.vue | 68 +++ .../SystemInteractionsDisclosureCard.vue | 68 +++ .../disclosures/TelemetryDisclosureCard.vue | 139 ++++++ .../ui/project-settings/disclosures/form.ts | 180 +++++++ .../ui/project-settings/disclosures/index.ts | 9 + .../ui/project-settings/disclosures/types.ts | 46 ++ apps/frontend/src/locales/en-US/index.json | 80 ++- .../[type]/[project]/settings/disclosures.vue | 461 +++++------------- .../src/modules/labrinth/projects/v3.ts | 48 ++ .../api-client/src/modules/labrinth/types.ts | 64 +++ packages/assets/generated-icons.ts | 2 + packages/assets/icons/circuit-board.svg | 19 + .../src/components/base/SettingsFormGroup.vue | 29 ++ .../components/base/SettingsToggleCard.vue | 35 ++ .../ui/src/components/base/ToggleCard.vue | 24 +- .../components/base/UnsavedChangesPopup.vue | 55 ++- packages/ui/src/components/base/index.ts | 2 + .../project/ProjectSidebarDetails.vue | 146 +++++- packages/ui/src/locales/en-US/index.json | 13 +- packages/ui/src/utils/common-messages.ts | 4 + packages/ui/src/utils/savable.ts | 8 +- 26 files changed, 1620 insertions(+), 385 deletions(-) create mode 100644 apps/frontend/src/components/ui/project-settings/disclosures/AdvertisingDisclosureCard.vue create mode 100644 apps/frontend/src/components/ui/project-settings/disclosures/AiDisclosureCard.vue create mode 100644 apps/frontend/src/components/ui/project-settings/disclosures/DerivativeDisclosureCard.vue create mode 100644 apps/frontend/src/components/ui/project-settings/disclosures/PaidFeaturesDisclosureCard.vue create mode 100644 apps/frontend/src/components/ui/project-settings/disclosures/PhotosensitivityDisclosureCard.vue create mode 100644 apps/frontend/src/components/ui/project-settings/disclosures/SystemInteractionsDisclosureCard.vue create mode 100644 apps/frontend/src/components/ui/project-settings/disclosures/TelemetryDisclosureCard.vue create mode 100644 apps/frontend/src/components/ui/project-settings/disclosures/form.ts create mode 100644 apps/frontend/src/components/ui/project-settings/disclosures/index.ts create mode 100644 apps/frontend/src/components/ui/project-settings/disclosures/types.ts create mode 100644 packages/assets/icons/circuit-board.svg create mode 100644 packages/ui/src/components/base/SettingsFormGroup.vue create mode 100644 packages/ui/src/components/base/SettingsToggleCard.vue diff --git a/apps/frontend/src/components/ui/Chips.vue b/apps/frontend/src/components/ui/Chips.vue index 17905aa13c..002416be67 100644 --- a/apps/frontend/src/components/ui/Chips.vue +++ b/apps/frontend/src/components/ui/Chips.vue @@ -54,7 +54,7 @@ export default { }, }, created() { - if (this.items.length > 0 && this.neverEmpty) { + if (this.items.length > 0 && this.neverEmpty && !this.modelValue) { this.selected = this.items[0] } }, diff --git a/apps/frontend/src/components/ui/project-settings/disclosures/AdvertisingDisclosureCard.vue b/apps/frontend/src/components/ui/project-settings/disclosures/AdvertisingDisclosureCard.vue new file mode 100644 index 0000000000..70ee057c8c --- /dev/null +++ b/apps/frontend/src/components/ui/project-settings/disclosures/AdvertisingDisclosureCard.vue @@ -0,0 +1,87 @@ + + + diff --git a/apps/frontend/src/components/ui/project-settings/disclosures/AiDisclosureCard.vue b/apps/frontend/src/components/ui/project-settings/disclosures/AiDisclosureCard.vue new file mode 100644 index 0000000000..bda96e4f0b --- /dev/null +++ b/apps/frontend/src/components/ui/project-settings/disclosures/AiDisclosureCard.vue @@ -0,0 +1,147 @@ + + + diff --git a/apps/frontend/src/components/ui/project-settings/disclosures/DerivativeDisclosureCard.vue b/apps/frontend/src/components/ui/project-settings/disclosures/DerivativeDisclosureCard.vue new file mode 100644 index 0000000000..f348252e71 --- /dev/null +++ b/apps/frontend/src/components/ui/project-settings/disclosures/DerivativeDisclosureCard.vue @@ -0,0 +1,169 @@ + + + diff --git a/apps/frontend/src/components/ui/project-settings/disclosures/PaidFeaturesDisclosureCard.vue b/apps/frontend/src/components/ui/project-settings/disclosures/PaidFeaturesDisclosureCard.vue new file mode 100644 index 0000000000..070ff3b6de --- /dev/null +++ b/apps/frontend/src/components/ui/project-settings/disclosures/PaidFeaturesDisclosureCard.vue @@ -0,0 +1,100 @@ + + + diff --git a/apps/frontend/src/components/ui/project-settings/disclosures/PhotosensitivityDisclosureCard.vue b/apps/frontend/src/components/ui/project-settings/disclosures/PhotosensitivityDisclosureCard.vue new file mode 100644 index 0000000000..c70edfb402 --- /dev/null +++ b/apps/frontend/src/components/ui/project-settings/disclosures/PhotosensitivityDisclosureCard.vue @@ -0,0 +1,68 @@ + + + diff --git a/apps/frontend/src/components/ui/project-settings/disclosures/SystemInteractionsDisclosureCard.vue b/apps/frontend/src/components/ui/project-settings/disclosures/SystemInteractionsDisclosureCard.vue new file mode 100644 index 0000000000..060e0eec97 --- /dev/null +++ b/apps/frontend/src/components/ui/project-settings/disclosures/SystemInteractionsDisclosureCard.vue @@ -0,0 +1,68 @@ + + + diff --git a/apps/frontend/src/components/ui/project-settings/disclosures/TelemetryDisclosureCard.vue b/apps/frontend/src/components/ui/project-settings/disclosures/TelemetryDisclosureCard.vue new file mode 100644 index 0000000000..0647fb058e --- /dev/null +++ b/apps/frontend/src/components/ui/project-settings/disclosures/TelemetryDisclosureCard.vue @@ -0,0 +1,139 @@ + + + diff --git a/apps/frontend/src/components/ui/project-settings/disclosures/form.ts b/apps/frontend/src/components/ui/project-settings/disclosures/form.ts new file mode 100644 index 0000000000..89bb99c91a --- /dev/null +++ b/apps/frontend/src/components/ui/project-settings/disclosures/form.ts @@ -0,0 +1,180 @@ +import type { Labrinth } from '@modrinth/api-client' + +import type { + DisclosureFormState, + DisclosureOf, + DisclosureType, + NoteDisclosure, + ProjectDisclosure, + ProjectDisclosureData, +} from './types' + +function findDisclosure( + disclosures: ProjectDisclosureData[], + type: T, +): DisclosureOf | undefined { + return disclosures.find((disclosure): disclosure is DisclosureOf => disclosure.type === type) +} + +type NoteDisclosureType = 'advertisements' | 'epilepsy_triggers' | 'system_interactions' + +function createNoteModel( + disclosures: ProjectDisclosureData[], + type: NoteDisclosureType, +): NoteDisclosure { + const disclosure = findDisclosure(disclosures, type) + return { + enabled: !!disclosure, + note: disclosure?.note ?? '', + } +} + +function nonemptyOrPlaceholder(values: string[]): string[] { + return values.length > 0 ? [...values] : [''] +} + +export function disclosuresToForm(disclosures: ProjectDisclosureData[]): DisclosureFormState { + const ai = findDisclosure(disclosures, 'ai_content') + const paidFeatures = findDisclosure(disclosures, 'paid_features') + const telemetry = findDisclosure(disclosures, 'telemetry') + const derivative = findDisclosure(disclosures, 'derivative_work') + + return { + ai: { + enabled: !!ai, + uses: ai ? [...(ai.uses ?? [])] : [], + note: ai?.note ?? '', + }, + advertising: createNoteModel(disclosures, 'advertisements'), + paidFeatures: { + enabled: !!paidFeatures, + features: paidFeatures ? nonemptyOrPlaceholder(paidFeatures.features) : [], + }, + telemetry: { + enabled: !!telemetry, + consent: telemetry?.consent ?? 'opt_in', + entries: telemetry ? nonemptyOrPlaceholder(telemetry.data_collected) : [], + }, + derivative: { + enabled: !!derivative, + sources: derivative ? derivative.sources.map((source) => ({ ...source })) : [], + }, + photosensitivity: createNoteModel(disclosures, 'epilepsy_triggers'), + systemInteractions: createNoteModel(disclosures, 'system_interactions'), + } +} + +export function formToDisclosures(form: DisclosureFormState): ProjectDisclosure[] { + const set: ProjectDisclosure[] = [] + + if (form.ai.enabled) { + set.push({ + type: 'ai_content', + uses: [...form.ai.uses], + note: form.ai.note.trim() || null, + }) + } + + if (form.advertising.enabled) { + set.push({ type: 'advertisements', note: form.advertising.note.trim() || null }) + } + + if (form.paidFeatures.enabled) { + set.push({ + type: 'paid_features', + features: form.paidFeatures.features.map((feature) => feature.trim()).filter(Boolean), + }) + } + + if (form.telemetry.enabled) { + set.push({ + type: 'telemetry', + consent: form.telemetry.consent, + data_collected: form.telemetry.entries.map((entry) => entry.trim()).filter(Boolean), + }) + } + + if (form.derivative.enabled) { + set.push({ + type: 'derivative_work', + sources: form.derivative.sources.map((source) => ({ + label: source.label.trim(), + link: source.link?.trim() || null, + note: source.note?.trim() || null, + })), + }) + } + + if (form.photosensitivity.enabled) { + set.push({ type: 'epilepsy_triggers', note: form.photosensitivity.note.trim() || null }) + } + + if (form.systemInteractions.enabled) { + set.push({ type: 'system_interactions', note: form.systemInteractions.note.trim() || null }) + } + + return set +} + +export function toModifyRequest( + form: DisclosureFormState, + previous: DisclosureFormState, +): Labrinth.Projects.v3.ModifyProjectDisclosures { + const set = formToDisclosures(form) + const nextTypes = new Set(set.map((disclosure) => disclosure.type)) + return { + set, + remove: formToDisclosures(previous) + .map((disclosure) => disclosure.type) + .filter((type) => !nextTypes.has(type)), + } +} + +export function toCachedDisclosures(set: ProjectDisclosure[]): ProjectDisclosureData[] { + const now = new Date().toISOString() + return set.map((disclosure) => ({ + ...disclosure, + set_by_moderator: false, + updated_at: now, + updated_by: null, + })) +} + +export type DisclosureFormIssue = + | 'advertising-note' + | 'paid-features-empty' + | 'telemetry-empty' + | 'derivative-empty' + | 'derivative-source-label' + | 'photosensitivity-note' + | 'system-interactions-note' + +export function getDisclosureFormIssues(form: DisclosureFormState): DisclosureFormIssue[] { + const issues: DisclosureFormIssue[] = [] + const missingNote = (model: NoteDisclosure) => model.enabled && !model.note.trim() + + if (missingNote(form.advertising)) { + issues.push('advertising-note') + } + if (form.paidFeatures.enabled && !form.paidFeatures.features.some((feature) => feature.trim())) { + issues.push('paid-features-empty') + } + if (form.telemetry.enabled && !form.telemetry.entries.some((entry) => entry.trim())) { + issues.push('telemetry-empty') + } + if (form.derivative.enabled) { + if (form.derivative.sources.length === 0) { + issues.push('derivative-empty') + } else if (form.derivative.sources.some((source) => !source.label?.trim())) { + issues.push('derivative-source-label') + } + } + if (missingNote(form.photosensitivity)) { + issues.push('photosensitivity-note') + } + if (missingNote(form.systemInteractions)) { + issues.push('system-interactions-note') + } + + return issues +} diff --git a/apps/frontend/src/components/ui/project-settings/disclosures/index.ts b/apps/frontend/src/components/ui/project-settings/disclosures/index.ts new file mode 100644 index 0000000000..cb172e45b7 --- /dev/null +++ b/apps/frontend/src/components/ui/project-settings/disclosures/index.ts @@ -0,0 +1,9 @@ +export { default as AdvertisingDisclosureCard } from './AdvertisingDisclosureCard.vue' +export { default as AiDisclosureCard } from './AiDisclosureCard.vue' +export { default as DerivativeDisclosureCard } from './DerivativeDisclosureCard.vue' +export * from './form' +export { default as PaidFeaturesDisclosureCard } from './PaidFeaturesDisclosureCard.vue' +export { default as PhotosensitivityDisclosureCard } from './PhotosensitivityDisclosureCard.vue' +export { default as SystemInteractionsDisclosureCard } from './SystemInteractionsDisclosureCard.vue' +export { default as TelemetryDisclosureCard } from './TelemetryDisclosureCard.vue' +export * from './types' diff --git a/apps/frontend/src/components/ui/project-settings/disclosures/types.ts b/apps/frontend/src/components/ui/project-settings/disclosures/types.ts new file mode 100644 index 0000000000..21619bb2ae --- /dev/null +++ b/apps/frontend/src/components/ui/project-settings/disclosures/types.ts @@ -0,0 +1,46 @@ +import type { Labrinth } from '@modrinth/api-client' + +export type TelemetryConsent = Labrinth.Projects.v3.TelemetryConsent +export type AiUsage = Labrinth.Projects.v3.AiUsage +export type DerivativeSource = Labrinth.Projects.v3.DerivativeSource +export type ProjectDisclosure = Labrinth.Projects.v3.ProjectDisclosure +export type ProjectDisclosureData = Labrinth.Projects.v3.ProjectDisclosureData +export type DisclosureType = Labrinth.Projects.v3.ProjectDisclosureType +export type DisclosureOf = Labrinth.Projects.v3.ProjectDisclosureOf + +export type NoteDisclosure = { + enabled: boolean + note: string +} + +export type AiDisclosure = { + enabled: boolean + uses: AiUsage[] + note: string +} + +export type PaidFeaturesDisclosure = { + enabled: boolean + features: string[] +} + +export type TelemetryDisclosure = { + enabled: boolean + consent: TelemetryConsent + entries: string[] +} + +export type DerivativeDisclosure = { + enabled: boolean + sources: DerivativeSource[] +} + +export type DisclosureFormState = { + ai: AiDisclosure + advertising: NoteDisclosure + paidFeatures: PaidFeaturesDisclosure + telemetry: TelemetryDisclosure + derivative: DerivativeDisclosure + photosensitivity: NoteDisclosure + systemInteractions: NoteDisclosure +} diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 9b4822bd3c..b232c86b23 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -3761,9 +3761,6 @@ "project.settings.back-to-project-page": { "message": "Back to project page" }, - "project.settings.disclosures.add-another": { - "message": "Add another" - }, "project.settings.disclosures.advertising.description.1": { "message": "You must enable this if your project contains advertisements, sponsorships, or promotions of other works." }, @@ -3774,7 +3771,7 @@ "message": "Please explain how your project utilizes advertising so that users can know what to expect." }, "project.settings.disclosures.advertising.note-placeholder": { - "message": "e.g. \"Adds the Modrinth SMP server to your server list automatically.\"" + "message": "e.g. Adds the Modrinth SMP server to your server list automatically." }, "project.settings.disclosures.advertising.title": { "message": "Contains advertisements" @@ -3812,6 +3809,27 @@ "project.settings.disclosures.content-disclosures": { "message": "Content disclosures" }, + "project.settings.disclosures.derivative.description": { + "message": "You must enable this if your project is derivative of another project, such as being a fork or containing a substantial amount of someone else’s work." + }, + "project.settings.disclosures.derivative.link-label": { + "message": "Link to original work" + }, + "project.settings.disclosures.derivative.link-placeholder": { + "message": "https://example.com" + }, + "project.settings.disclosures.derivative.name-label": { + "message": "Name of original work" + }, + "project.settings.disclosures.derivative.name-placeholder": { + "message": "Example project" + }, + "project.settings.disclosures.derivative.note-label": { + "message": "Please explain how your project is based on the original work" + }, + "project.settings.disclosures.derivative.title": { + "message": "Contains derivative content" + }, "project.settings.disclosures.description": { "message": "You must add any applicable content disclosures to your project in compliance with Modrinth's Content Rules." }, @@ -3819,7 +3837,7 @@ "message": "You must enable this if your project contains features that can be obtained by spending real-world money." }, "project.settings.disclosures.paid-features.feature-placeholder": { - "message": "e.g. “Cosmetics available as Patreon reward.”" + "message": "e.g. Cosmetics available as Patreon reward" }, "project.settings.disclosures.paid-features.features-description": { "message": "What kinds of paid features does it add?" @@ -3827,11 +3845,53 @@ "project.settings.disclosures.paid-features.title": { "message": "Contains paid features" }, - "project.settings.disclosures.telemetry.add-data-type": { - "message": "Add type of data" + "project.settings.disclosures.photosensitivity.description": { + "message": "Enable this if your project contains anything that you think may be dangerous to certain people who are sensitive to flashing lights or patterns." }, - "project.settings.disclosures.telemetry.add-privacy-policy": { - "message": "Add privacy policy" + "project.settings.disclosures.photosensitivity.note-label": { + "message": "Please briefly describe why your project has a photosensitivity warning." + }, + "project.settings.disclosures.photosensitivity.note-placeholder": { + "message": "e.g. It adds a flashlight item that has a strobe mode. It can be disabled in Accessibility settings in-game." + }, + "project.settings.disclosures.photosensitivity.title": { + "message": "Photosensitivity warning" + }, + "project.settings.disclosures.save-blocked.advertising-note": { + "message": "Advertising disclosure requires explanation." + }, + "project.settings.disclosures.save-blocked.derivative-empty": { + "message": "Derivative works disclosure must list at least one source work." + }, + "project.settings.disclosures.save-blocked.derivative-source-label": { + "message": "Derivative work sources must have a name." + }, + "project.settings.disclosures.save-blocked.no-permission": { + "message": "You don't have permission to edit this project's disclosures." + }, + "project.settings.disclosures.save-blocked.paid-features-empty": { + "message": "Paid features disclosure must list at least one paid feature." + }, + "project.settings.disclosures.save-blocked.photosensitivity-note": { + "message": "Photosensitivity warning disclosure must include a description." + }, + "project.settings.disclosures.save-blocked.system-interactions-note": { + "message": "External system interactions disclosure must include a description." + }, + "project.settings.disclosures.save-blocked.telemetry-empty": { + "message": "Telemetry disclosure must list at least one type of data collected." + }, + "project.settings.disclosures.system-interactions.description": { + "message": "You must enable this if your project reads or edits things on the user's system outside of the game." + }, + "project.settings.disclosures.system-interactions.note-label": { + "message": "Please describe the external system interactions in the mod so users can know what to expect." + }, + "project.settings.disclosures.system-interactions.note-placeholder": { + "message": "e.g. It adds a file to the desktop called wake_up.txt" + }, + "project.settings.disclosures.system-interactions.title": { + "message": "External system interactions" }, "project.settings.disclosures.telemetry.consent-always-active": { "message": "Always active" @@ -3852,7 +3912,7 @@ "message": "What data is being collected?" }, "project.settings.disclosures.telemetry.data-placeholder": { - "message": "e.g. “Anonymous launch analytics to track Minecraft version and mod loader usage.”" + "message": "e.g. Anonymous launch analytics to track Minecraft version and mod loader usage." }, "project.settings.disclosures.telemetry.description": { "message": "You must enable this if your project sends usage data back to yourself or a third party." diff --git a/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue b/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue index bdc743a72d..7066265879 100644 --- a/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue +++ b/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue @@ -1,26 +1,42 @@

- -

- - {{ formatMessage(aiDisclosureMessages.title) }} -

-

- {{ formatMessage(aiDisclosureMessages.description) }} -

-

- - - -

- -
- -

- - {{ formatMessage(advertisingDisclosureMessages.title) }} -

-

- {{ formatMessage(advertisingDisclosureMessages.description1) }} -

-

- - - - -

- -
- -

- - {{ formatMessage(paidFeaturesDisclosureMessages.title) }} -

-

- {{ formatMessage(paidFeaturesDisclosureMessages.description) }} -

- -
- -

- - {{ formatMessage(telemetryDisclosureMessages.title) }} -

-

- {{ formatMessage(telemetryDisclosureMessages.description) }} -

- -
+
+ + + + + + + +
+ diff --git a/packages/api-client/src/modules/labrinth/projects/v3.ts b/packages/api-client/src/modules/labrinth/projects/v3.ts index 1c86154e88..077168575d 100644 --- a/packages/api-client/src/modules/labrinth/projects/v3.ts +++ b/packages/api-client/src/modules/labrinth/projects/v3.ts @@ -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 { + return this.client.request( + `/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 { + return this.client.request(`/project/${id}/disclosures`, { + api: 'labrinth', + version: 3, + method: 'PATCH', + body: data, + }) + } } diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 0e571a6291..a2d7fca8ab 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -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 = Extract< + ProjectDisclosureData, + { type: T } + > + + export type GetProjectDisclosures = { + disclosures: ProjectDisclosureData[] + } + + export type ModifyProjectDisclosures = { + set: ProjectDisclosure[] + remove: ProjectDisclosureType[] + } } } diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index 85dfd308a5..60f65b1ca2 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -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 diff --git a/packages/assets/icons/circuit-board.svg b/packages/assets/icons/circuit-board.svg new file mode 100644 index 0000000000..a6dac3f828 --- /dev/null +++ b/packages/assets/icons/circuit-board.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/packages/ui/src/components/base/SettingsFormGroup.vue b/packages/ui/src/components/base/SettingsFormGroup.vue new file mode 100644 index 0000000000..cdf723cf15 --- /dev/null +++ b/packages/ui/src/components/base/SettingsFormGroup.vue @@ -0,0 +1,29 @@ + + + diff --git a/packages/ui/src/components/base/SettingsToggleCard.vue b/packages/ui/src/components/base/SettingsToggleCard.vue new file mode 100644 index 0000000000..3929845195 --- /dev/null +++ b/packages/ui/src/components/base/SettingsToggleCard.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/ui/src/components/base/ToggleCard.vue b/packages/ui/src/components/base/ToggleCard.vue index 5881f1e541..42cf15f52b 100644 --- a/packages/ui/src/components/base/ToggleCard.vue +++ b/packages/ui/src/components/base/ToggleCard.vue @@ -2,10 +2,19 @@ import { SmartClickable, Toggle } from '@modrinth/ui' import { computed, useId } from 'vue' +const props = defineProps<{ + disabled?: boolean +}>() + const value = defineModel({ required: true }) const baseId = useId() const toggleId = computed(() => `toggle-card-toggle-${baseId}`) + +function toggle() { + if (props.disabled) return + value.value = !value.value +}
@@ -25,8 +36,13 @@ const toggleId = computed(() => `toggle-card-toggle-${baseId}`)
- - + +
diff --git a/packages/ui/src/components/base/UnsavedChangesPopup.vue b/packages/ui/src/components/base/UnsavedChangesPopup.vue index 4606042c86..5876ae8507 100644 --- a/packages/ui/src/components/base/UnsavedChangesPopup.vue +++ b/packages/ui/src/components/base/UnsavedChangesPopup.vue @@ -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 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 | null>(null) function nudge(): void { @@ -67,13 +89,32 @@ defineExpose({ nudge }) {{ formatMessage(commonMessages.resetButton) }} - - - + + + + + + + diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts index cf5a7a9540..b27ad214bf 100644 --- a/packages/ui/src/components/base/index.ts +++ b/packages/ui/src/components/base/index.ts @@ -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' diff --git a/packages/ui/src/components/project/ProjectSidebarDetails.vue b/packages/ui/src/components/project/ProjectSidebarDetails.vue index c7f39cb7e9..67d4d33d57 100644 --- a/packages/ui/src/components/project/ProjectSidebarDetails.vue +++ b/packages/ui/src/components/project/ProjectSidebarDetails.vue @@ -16,46 +16,85 @@
-
-