Files
Starlight_Lancher/apps/app-frontend/src/components/ui/ContentInstallPreviewModal.vue
T

726 lines
23 KiB
Vue

<script setup lang="ts">
import { ChevronDownIcon, XIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, Checkbox, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref } from 'vue'
import { getActiveDependencyConflictIdentities } from '@/providers/content-selection-logic'
export interface ContentInstallPreviewDependency {
id: string
title: string
iconUrl?: string | null
versionNumber?: string
fileName?: string
description?: string
projectUrl?: string
requiredBy: string[]
alreadyInstalled: boolean
status?: 'installed' | 'included'
versionMismatch?: boolean
selectionReason?: string
required?: boolean
requiredByKeys?: string[]
}
export interface ContentInstallPreviewSkipped {
id: string
title: string
reason: string
requiredByKeys?: string[]
}
export interface ContentInstallPreviewData {
primary?: ContentInstallPreviewPrimary
primaries?: ContentInstallPreviewPrimary[]
instanceName: string
installDependencies: boolean
dependencies: ContentInstallPreviewDependency[]
skipped: ContentInstallPreviewSkipped[]
}
export interface ContentInstallPreviewPrimary {
key?: string
title: string
iconUrl?: string | null
versionNumber?: string
provider?: string
contentType?: string
error?: string
conflictIdentities?: string[]
removable?: boolean
}
export interface ContentInstallBatchPreviewResult {
approvedIds: string[]
primaryKeys: string[]
}
export interface ContentInstallConflictPrompt {
candidate: {
title: string
provider: string
contentType: string
iconUrl?: string | null
}
existing: Array<{
title: string
provider: string
fileName?: string
}>
source: 'heuristic'
confidence: 'high' | 'possible'
}
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.content-install.preview.header',
defaultMessage: 'Confirm installation',
},
description: {
id: 'app.content-install.preview.description',
defaultMessage:
'{count, plural, one {# dependency will be installed automatically} other {# dependencies will be installed automatically}} for {project} in {instance}.',
},
batchDescription: {
id: 'app.content-install.preview.batch-description',
defaultMessage:
'Review {projectCount, plural, one {# project} other {# projects}} and {dependencyCount, plural, one {# dependency} other {# dependencies}} for {instance}.',
},
selectedContentHeader: {
id: 'app.content-install.preview.selected-content-header',
defaultMessage: 'Selected content',
},
removeProject: {
id: 'app.content-install.preview.remove-project',
defaultMessage: 'Remove {project} from this installation',
},
dependenciesHeader: {
id: 'app.content-install.preview.dependencies-header',
defaultMessage: 'Dependencies',
},
dependenciesCount: {
id: 'app.content-install.preview.dependencies-count',
defaultMessage: '{count, plural, one {# dependency} other {# dependencies}}',
},
requiredDependenciesHeader: {
id: 'app.content-install.preview.required-dependencies-header',
defaultMessage: 'Required dependencies',
},
optionalDependenciesHeader: {
id: 'app.content-install.preview.optional-dependencies-header',
defaultMessage: 'Optional dependencies',
},
requiredBy: {
id: 'app.content-install.preview.required-by',
defaultMessage: 'Required by {projects}',
},
alreadyInstalled: {
id: 'app.content-install.preview.already-installed',
defaultMessage: 'Already installed',
},
alreadyIncluded: {
id: 'app.content-install.preview.already-included',
defaultMessage: 'Already included',
},
versionMismatch: {
id: 'app.content-install.preview.version-mismatch',
defaultMessage: 'Version may not match this instance',
},
skippedHeader: {
id: 'app.content-install.preview.skipped-header',
defaultMessage: 'Skipped',
},
onlyChecked: {
id: 'app.content-install.preview.only-checked',
defaultMessage: 'Only checked dependencies will be installed.',
},
selectAll: {
id: 'app.content-install.preview.select-all',
defaultMessage: 'Select all',
},
clearAll: {
id: 'app.content-install.preview.clear-all',
defaultMessage: 'Clear all',
},
cancel: {
id: 'app.content-install.preview.cancel',
defaultMessage: 'Cancel',
},
viewDetails: {
id: 'app.content-install.preview.view-details',
defaultMessage: 'View details',
},
openProjectPage: {
id: 'app.content-install.preview.open-project-page',
defaultMessage: 'Open project page',
},
descriptionUnavailable: {
id: 'app.content-install.preview.description-unavailable',
defaultMessage: 'No description available.',
},
install: {
id: 'app.content-install.preview.install',
defaultMessage: 'Install',
},
installResolved: {
id: 'app.content-install.preview.install-resolved',
defaultMessage: 'Install resolved content',
},
conflictHeader: {
id: 'app.content-install.preview.conflict-header',
defaultMessage: 'Possible duplicate content',
},
conflictDescription: {
id: 'app.content-install.preview.conflict-description',
defaultMessage:
'{candidate} may be the same content as an installed or selected project. Continue anyway?',
},
continueAnyway: {
id: 'app.content-install.preview.continue-anyway',
defaultMessage: 'Install anyway',
},
existingContent: {
id: 'app.content-install.preview.existing-content',
defaultMessage: 'Existing content',
},
})
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const data = ref<ContentInstallPreviewData | null>(null)
const selectedIds = ref<Set<string>>(new Set())
const expandedDependencyIds = ref<Set<string>>(new Set())
const removedPrimaryKeys = ref<Set<string>>(new Set())
let settled = false
let batchMode = false
let conflictMode = false
const conflictPrompt = ref<ContentInstallConflictPrompt | null>(null)
let resolveShow:
| ((result: string[] | ContentInstallBatchPreviewResult | boolean | null) => void)
| null = null
const primaries = computed(() => {
if (!data.value) return []
if (data.value.primaries?.length) return data.value.primaries
return data.value.primary ? [data.value.primary] : []
})
const visiblePrimaries = computed(() =>
primaries.value.filter((primary) => !primary.key || !removedPrimaryKeys.value.has(primary.key)),
)
const visiblePrimaryKeys = computed(() =>
visiblePrimaries.value.map((primary) => primary.key).filter((key): key is string => !!key),
)
const visiblePrimaryKeySet = computed(() => new Set(visiblePrimaryKeys.value))
const visibleDependencies = computed(
() =>
data.value?.dependencies.filter(
(dependency) =>
!dependency.requiredByKeys?.length ||
dependency.requiredByKeys.some((key) => visiblePrimaryKeySet.value.has(key)),
) ?? [],
)
const activeConflictIdentities = computed(() =>
getActiveDependencyConflictIdentities(data.value?.dependencies ?? [], visiblePrimaryKeySet.value),
)
function primaryError(primary: ContentInstallPreviewPrimary) {
if (!primary.error) return null
if (!primary.conflictIdentities?.length) return primary.error
return primary.conflictIdentities.some((identity) => activeConflictIdentities.value.has(identity))
? primary.error
: null
}
const visibleSkipped = computed(
() =>
data.value?.skipped.filter(
(skipped) =>
!skipped.requiredByKeys?.length ||
skipped.requiredByKeys.some((key) => visiblePrimaryKeySet.value.has(key)),
) ?? [],
)
const hasBlockingPrimary = computed(() =>
visiblePrimaries.value.some((primary) => !!primaryError(primary)),
)
const installableDependencies = computed(() => visibleDependencies.value)
const dependencyGroups = computed(() =>
[
{
id: 'required',
header: messages.requiredDependenciesHeader,
dependencies: visibleDependencies.value.filter((dependency) => dependency.required !== false),
},
{
id: 'optional',
header: messages.optionalDependenciesHeader,
dependencies: visibleDependencies.value.filter((dependency) => dependency.required === false),
},
].filter((group) => group.dependencies.length > 0),
)
const selectedInstallableCount = computed(
() =>
installableDependencies.value.filter((dependency) => selectedIds.value.has(dependency.id))
.length,
)
const hasUnresolvedDependencies = computed(() => visibleSkipped.value.length > 0)
function toggleDependency(id: string, value: boolean) {
const next = new Set(selectedIds.value)
if (value) next.add(id)
else next.delete(id)
selectedIds.value = next
}
function toggleAll(value: boolean) {
const next = new Set(selectedIds.value)
for (const dependency of installableDependencies.value) {
if (value) next.add(dependency.id)
else next.delete(dependency.id)
}
selectedIds.value = next
}
function hasDependencyDetails(dependency: ContentInstallPreviewDependency) {
return !!dependency.description || !!dependency.projectUrl
}
function toggleDependencyDetails(id: string) {
const next = new Set(expandedDependencyIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedDependencyIds.value = next
}
async function openDependencyPage(dependency: ContentInstallPreviewDependency) {
if (!dependency.projectUrl) return
await openUrl(dependency.projectUrl)
}
function initialSelectedIds(value: ContentInstallPreviewData) {
if (!value.installDependencies) return new Set<string>()
return new Set(
value.dependencies
.filter((dependency) => !dependency.alreadyInstalled && dependency.required !== false)
.map((dependency) => dependency.id),
)
}
function finish(result: string[] | ContentInstallBatchPreviewResult | boolean | null) {
if (settled) return
settled = true
const resolve = resolveShow
resolveShow = null
if (resolve) resolve(result)
modal.value?.hide()
}
function confirm() {
if (conflictMode) {
finish(true)
return
}
if (hasBlockingPrimary.value || visiblePrimaries.value.length === 0) return
const approvedIds = visibleDependencies.value
.filter((dependency) => selectedIds.value.has(dependency.id))
.map((dependency) => dependency.id)
finish(batchMode ? { approvedIds, primaryKeys: visiblePrimaryKeys.value } : approvedIds)
}
function hide() {
finish(null)
}
function show(value: ContentInstallPreviewData): Promise<string[] | null> {
resolveShow?.(null)
resolveShow = null
data.value = value
batchMode = false
conflictMode = false
conflictPrompt.value = null
removedPrimaryKeys.value = new Set()
selectedIds.value = initialSelectedIds(value)
expandedDependencyIds.value = new Set()
settled = false
modal.value?.show()
return new Promise<string[] | null>((resolve) => {
resolveShow = (result) => resolve(Array.isArray(result) ? result : null)
})
}
function showBatch(
value: ContentInstallPreviewData,
): Promise<ContentInstallBatchPreviewResult | null> {
resolveShow?.(null)
resolveShow = null
data.value = value
batchMode = true
conflictMode = false
conflictPrompt.value = null
removedPrimaryKeys.value = new Set()
selectedIds.value = initialSelectedIds(value)
expandedDependencyIds.value = new Set()
settled = false
modal.value?.show()
return new Promise<ContentInstallBatchPreviewResult | null>((resolve) => {
resolveShow = (result) =>
resolve(result && !Array.isArray(result) && typeof result !== 'boolean' ? result : null)
})
}
function showConflict(value: ContentInstallConflictPrompt): Promise<boolean> {
resolveShow?.(null)
resolveShow = null
data.value = null
batchMode = false
conflictMode = true
conflictPrompt.value = value
settled = false
modal.value?.show()
return new Promise<boolean>((resolve) => {
resolveShow = (result) => resolve(result === true)
})
}
function removePrimary(key: string) {
removedPrimaryKeys.value = new Set([...removedPrimaryKeys.value, key])
}
defineExpose({ show, showBatch, showConflict })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(conflictMode ? messages.conflictHeader : messages.header)"
scrollable
max-content-height="70vh"
width="40rem"
max-width="40rem"
:on-hide="hide"
>
<div v-if="conflictPrompt" class="flex flex-col gap-4">
<div
class="flex items-center gap-3 rounded-lg border border-solid border-warning bg-warning-bg p-3"
>
<Avatar
:src="conflictPrompt.candidate.iconUrl"
:alt="conflictPrompt.candidate.title"
size="2.5rem"
:tint-by="conflictPrompt.candidate.title"
no-shadow
/>
<div class="min-w-0 flex-1">
<span class="block truncate font-semibold text-contrast">{{
conflictPrompt.candidate.title
}}</span>
<span class="block truncate text-sm text-secondary">
{{
[conflictPrompt.candidate.provider, conflictPrompt.candidate.contentType].join(' · ')
}}
</span>
</div>
</div>
<p class="m-0 text-primary">
{{
formatMessage(messages.conflictDescription, {
candidate: conflictPrompt.candidate.title,
})
}}
</p>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
formatMessage(messages.existingContent)
}}</span>
<div
v-for="item in conflictPrompt.existing"
:key="`${item.provider}:${item.title}:${item.fileName ?? ''}`"
class="flex items-center justify-between gap-3 rounded-lg border border-solid border-surface-4 bg-surface-2 px-3 py-2"
>
<span class="min-w-0 truncate font-medium text-contrast">{{ item.title }}</span>
<span class="shrink-0 text-sm text-secondary">{{ item.provider }}</span>
</div>
</div>
</div>
<div v-else-if="data" class="flex min-w-0 flex-col gap-4">
<div v-if="batchMode" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
formatMessage(messages.selectedContentHeader)
}}</span>
<div
v-for="primary in visiblePrimaries"
:key="primary.key ?? primary.title"
class="flex items-center gap-3 rounded-lg border border-solid border-surface-4 bg-surface-2 p-3"
>
<Avatar
:src="primary.iconUrl"
:alt="primary.title"
size="2.5rem"
:tint-by="primary.title"
no-shadow
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="truncate font-semibold text-contrast">{{ primary.title }}</span>
<span class="truncate text-sm text-secondary">
{{
[primary.versionNumber, primary.provider, primary.contentType]
.filter(Boolean)
.join(' · ')
}}
</span>
<span v-if="primaryError(primary)" class="text-sm text-red">
{{ primaryError(primary) }}
</span>
</div>
<ButtonStyled v-if="primary.removable && primary.key" circular type="transparent">
<button
type="button"
:aria-label="formatMessage(messages.removeProject, { project: primary.title })"
@click="removePrimary(primary.key)"
>
<XIcon />
</button>
</ButtonStyled>
</div>
</div>
<div
v-else-if="visiblePrimaries[0]"
class="flex items-center gap-3 rounded-lg border border-solid border-surface-4 bg-surface-2 p-3"
>
<Avatar
:src="visiblePrimaries[0].iconUrl"
:alt="visiblePrimaries[0].title"
size="2.5rem"
:tint-by="visiblePrimaries[0].title"
no-shadow
/>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="truncate font-semibold text-contrast">{{ visiblePrimaries[0].title }}</span>
<span v-if="visiblePrimaries[0].versionNumber" class="truncate text-sm text-secondary">
{{ visiblePrimaries[0].versionNumber }}
</span>
</div>
</div>
<p class="m-0 text-primary">
{{
batchMode
? formatMessage(messages.batchDescription, {
projectCount: visiblePrimaries.length,
dependencyCount: visibleDependencies.length,
instance: data.instanceName,
})
: formatMessage(messages.description, {
count: selectedInstallableCount,
project: visiblePrimaries[0]?.title ?? '',
instance: data.instanceName,
})
}}
</p>
<div v-if="visibleDependencies.length > 0" class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="flex items-center gap-2 font-semibold text-contrast">
{{ formatMessage(messages.dependenciesHeader) }}
<span
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium tabular-nums text-secondary"
>
{{ formatMessage(messages.dependenciesCount, { count: visibleDependencies.length }) }}
</span>
</span>
<ButtonStyled v-if="installableDependencies.length > 1" size="small" type="transparent">
<button @click="toggleAll(selectedInstallableCount !== installableDependencies.length)">
{{
selectedInstallableCount === installableDependencies.length
? formatMessage(messages.clearAll)
: formatMessage(messages.selectAll)
}}
</button>
</ButtonStyled>
</div>
<div
class="grid grid-cols-1 gap-3"
:class="{ 'sm:grid-cols-2': dependencyGroups.length > 1 }"
>
<div
v-for="group in dependencyGroups"
:key="group.id"
class="flex min-w-0 flex-col gap-2"
>
<span class="flex items-center gap-2 font-semibold text-contrast">
{{ formatMessage(group.header) }}
<span
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium tabular-nums text-secondary"
>
{{ group.dependencies.length }}
</span>
</span>
<div
v-for="dependency in group.dependencies"
:key="dependency.id"
class="flex w-full min-w-0 flex-col overflow-hidden rounded-xl border border-solid border-surface-4 bg-surface-2"
:class="{ 'opacity-60': dependency.alreadyInstalled }"
>
<div class="flex items-start gap-3 p-3">
<Checkbox
:model-value="selectedIds.has(dependency.id)"
class="mt-2 shrink-0"
@update:model-value="(value) => toggleDependency(dependency.id, value)"
/>
<Avatar
:src="dependency.iconUrl"
:alt="dependency.title"
size="2.5rem"
:tint-by="dependency.title"
no-shadow
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<button
v-if="hasDependencyDetails(dependency)"
type="button"
class="group flex w-full min-w-0 cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left"
:aria-expanded="expandedDependencyIds.has(dependency.id)"
:aria-label="formatMessage(messages.viewDetails, { project: dependency.title })"
@click="toggleDependencyDetails(dependency.id)"
>
<span
v-tooltip="
dependency.description
? {
content: dependency.description,
placement: 'top',
popperClass: 'preview-dependency-tooltip',
}
: null
"
class="min-w-0 truncate font-semibold text-contrast group-hover:underline"
>
{{ dependency.title }}
</span>
<ChevronDownIcon
aria-hidden="true"
class="shrink-0 text-secondary transition-transform duration-150"
:class="{
'rotate-180': expandedDependencyIds.has(dependency.id),
}"
/>
</button>
<span v-else class="truncate font-semibold text-contrast">
{{ dependency.title }}
</span>
<span
v-if="dependency.versionNumber"
class="min-w-0 truncate text-sm text-secondary"
>
{{ dependency.versionNumber }}
</span>
<span
v-if="dependency.requiredBy.length > 0"
class="min-w-0 truncate text-sm text-secondary"
>
{{
formatMessage(messages.requiredBy, {
projects: dependency.requiredBy.join(', '),
})
}}
</span>
<div class="flex flex-wrap gap-1 pt-1">
<span
v-if="dependency.versionMismatch"
class="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-medium text-warning-text"
>
{{ formatMessage(messages.versionMismatch) }}
</span>
<span
v-if="dependency.selectionReason"
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium text-secondary"
>
{{ dependency.selectionReason }}
</span>
<span
v-if="dependency.alreadyInstalled"
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium text-secondary"
>
{{
formatMessage(
dependency.status === 'included'
? messages.alreadyIncluded
: messages.alreadyInstalled,
)
}}
</span>
</div>
</div>
</div>
<div
v-if="expandedDependencyIds.has(dependency.id)"
class="mb-3 flex w-auto min-w-0 flex-col gap-2.5 rounded-lg bg-surface-1 px-3 py-2.5 mx-3"
>
<p
v-if="dependency.description"
class="m-0 w-full min-w-0 text-sm leading-relaxed text-secondary [overflow-wrap:anywhere]"
>
{{ dependency.description }}
</p>
<p v-else class="m-0 w-full min-w-0 text-sm text-secondary">
{{ formatMessage(messages.descriptionUnavailable) }}
</p>
<ButtonStyled v-if="dependency.projectUrl" class="self-start" type="outlined">
<button type="button" @click="openDependencyPage(dependency)">
{{ formatMessage(messages.openProjectPage) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<span class="text-sm text-secondary">{{ formatMessage(messages.onlyChecked) }}</span>
</div>
<div v-if="visibleSkipped.length > 0" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.skippedHeader) }}</span>
<div
v-for="skipped in visibleSkipped"
:key="skipped.id"
class="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-solid border-surface-4 bg-surface-2 px-3 py-2"
>
<span class="min-w-0 flex-1 truncate font-medium text-contrast">
{{ skipped.title }}
</span>
<span class="shrink-0 text-sm text-secondary">{{ skipped.reason }}</span>
</div>
</div>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="hide">{{ formatMessage(messages.cancel) }}</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
:disabled="!conflictMode && (hasBlockingPrimary || visiblePrimaries.length === 0)"
@click="confirm"
>
{{
conflictMode
? formatMessage(messages.continueAnyway)
: hasUnresolvedDependencies
? formatMessage(messages.installResolved)
: formatMessage(messages.install)
}}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<style>
.preview-dependency-tooltip.v-popper--theme-tooltip .v-popper__inner {
max-width: 22rem;
white-space: normal;
overflow-wrap: anywhere;
}
</style>