refactor: project saving logic (#5225)

* fix: project data saving not visually shown immediately

* feat: useSavable improvements

* feat: migrate where possible to useSavable

* fix: gitignore

* feat: use es-toolkit
This commit is contained in:
Calum H.
2026-01-28 16:46:14 +00:00
committed by GitHub
parent e57c15b3ce
commit 400c571fe6
15 changed files with 699 additions and 507 deletions

View File

@@ -1647,6 +1647,15 @@ async function resetVersions() {
await resetVersionsV3()
}
// Helper to invalidate project queries after mutations settle
async function invalidateProjectQueries(projectId) {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] })
}
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId] })
}
// Mutation for patching project data
const patchProjectMutation = useMutation({
mutationFn: async ({ projectId, data }) => {
@@ -1691,12 +1700,7 @@ const patchProjectMutation = useMutation({
},
onSettled: async (_data, _error, { projectId }) => {
// Invalidate both slug-based and ID-based cache keys to ensure consistency
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] })
}
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId] })
await invalidateProjectQueries(projectId)
},
})
@@ -1741,11 +1745,7 @@ const patchStatusMutation = useMutation({
},
onSettled: async (_data, _error, { projectId }) => {
// Invalidate both slug-based and ID-based cache keys to ensure consistency
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] })
}
await invalidateProjectQueries(projectId)
},
})
@@ -1779,8 +1779,171 @@ const patchIconMutation = useMutation({
},
onSettled: async (_data, _error, { projectId }) => {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId] })
await invalidateProjectQueries(projectId)
},
})
const createGalleryItemMutation = useMutation({
mutationFn: async ({ projectId, file, title, description, featured, ordering }) => {
let url = `project/${projectId}/gallery?ext=${
file.type.split('/')[file.type.split('/').length - 1]
}&featured=${featured ?? false}`
if (title) {
url += `&title=${encodeURIComponent(title)}`
}
if (description) {
url += `&description=${encodeURIComponent(description)}`
}
if (ordering !== null && ordering !== undefined) {
url += `&ordering=${ordering}`
}
await useBaseFetch(url, {
method: 'POST',
body: file,
})
},
onMutate: async ({ title, description, featured, ordering }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
if (!old) return old
const newItem = {
url: '',
raw_url: '',
featured: featured ?? false,
title: title ?? '',
description: description ?? '',
created: new Date().toISOString(),
ordering: ordering ?? old.gallery.length,
}
return {
...old,
gallery: [...old.gallery, newItem],
}
})
return { previousProject }
},
onError: (err, _variables, context) => {
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
},
onSettled: async (_data, _error, { projectId }) => {
await invalidateProjectQueries(projectId)
},
})
const editGalleryItemMutation = useMutation({
mutationFn: async ({ projectId, imageUrl, title, description, featured, ordering }) => {
let url = `project/${projectId}/gallery?url=${encodeURIComponent(imageUrl)}&featured=${featured ?? false}`
if (title) {
url += `&title=${encodeURIComponent(title)}`
}
if (description) {
url += `&description=${encodeURIComponent(description)}`
}
if (ordering !== null && ordering !== undefined) {
url += `&ordering=${ordering}`
}
await useBaseFetch(url, {
method: 'PATCH',
})
},
onMutate: async ({ imageUrl, title, description, featured, ordering }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
if (!old) return old
return {
...old,
gallery: old.gallery.map((item) => {
if (item.url === imageUrl) {
return {
...item,
title: title ?? item.title,
description: description ?? item.description,
featured: featured ?? item.featured,
ordering: ordering ?? item.ordering,
}
}
return item
}),
}
})
return { previousProject }
},
onError: (err, _variables, context) => {
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
},
onSettled: async (_data, _error, { projectId }) => {
await invalidateProjectQueries(projectId)
},
})
const deleteGalleryItemMutation = useMutation({
mutationFn: async ({ projectId, imageUrl }) => {
await useBaseFetch(`project/${projectId}/gallery?url=${encodeURIComponent(imageUrl)}`, {
method: 'DELETE',
})
},
onMutate: async ({ imageUrl }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
if (!old) return old
return {
...old,
gallery: old.gallery.filter((item) => item.url !== imageUrl),
}
})
return { previousProject }
},
onError: (err, _variables, context) => {
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
},
onSettled: async (_data, _error, { projectId }) => {
await invalidateProjectQueries(projectId)
},
})
@@ -1971,6 +2134,51 @@ async function patchIcon(icon) {
})
}
async function createGalleryItem(file, title, description, featured, ordering) {
startLoading()
return new Promise((resolve) => {
createGalleryItemMutation.mutate(
{ projectId: project.value.id, file, title, description, featured, ordering },
{
onSuccess: () => resolve(true),
onError: () => resolve(false),
onSettled: () => stopLoading(),
},
)
})
}
async function editGalleryItem(imageUrl, title, description, featured, ordering) {
startLoading()
return new Promise((resolve) => {
editGalleryItemMutation.mutate(
{ projectId: project.value.id, imageUrl, title, description, featured, ordering },
{
onSuccess: () => resolve(true),
onError: () => resolve(false),
onSettled: () => stopLoading(),
},
)
})
}
async function deleteGalleryItem(imageUrl) {
startLoading()
return new Promise((resolve) => {
deleteGalleryItemMutation.mutate(
{ projectId: project.value.id, imageUrl },
{
onSuccess: () => resolve(true),
onError: () => resolve(false),
onSettled: () => stopLoading(),
},
)
})
}
async function refreshMembers() {
// Simply invalidate and refetch - the computed allMembers will auto-update
await queryClient.invalidateQueries({ queryKey: ['project', projectId.value, 'members'] })
@@ -2103,6 +2311,11 @@ provideProjectPageContext({
patchProject,
patchIcon,
setProcessing,
// Gallery mutation functions
createGalleryItem,
editGalleryItem,
deleteGalleryItem,
})
</script>

View File

@@ -14,7 +14,7 @@
</span>
</div>
<MarkdownEditor
v-model="description"
v-model="current.description"
:disabled="
!currentMember ||
(currentMember?.permissions! & TeamMemberPermission.EDIT_BODY) !==
@@ -26,36 +26,42 @@
<TriangleAlertIcon class="my-auto" />
{{ descriptionWarning }}
</div>
<div class="input-group markdown-disclaimer">
<button
:disabled="!hasChanges"
class="iconified-button brand-button"
type="button"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</div>
<UnsavedChangesPopup
:original="saved"
:modified="current"
:saving="saving"
@reset="reset"
@save="save"
/>
</div>
</template>
<script lang="ts" setup>
import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets'
import { TriangleAlertIcon } from '@modrinth/assets'
import { countText, MIN_DESCRIPTION_CHARS } from '@modrinth/moderation'
import { injectProjectPageContext, MarkdownEditor } from '@modrinth/ui'
import {
injectProjectPageContext,
MarkdownEditor,
UnsavedChangesPopup,
useSavable,
} from '@modrinth/ui'
import { TeamMemberPermission } from '@modrinth/utils'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useImageUpload } from '~/composables/image-upload.ts'
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
const description = ref(project.value.body)
const { saved, current, saving, reset, save } = useSavable(
() => ({ description: project.value.body }),
async ({ description }) => {
await patchProject({ body: description })
},
)
const descriptionWarning = computed(() => {
const text = description.value?.trim() || ''
const text = current.value.description?.trim() || ''
const charCount = countText(text)
if (charCount < MIN_DESCRIPTION_CHARS) {
@@ -65,26 +71,6 @@ const descriptionWarning = computed(() => {
return null
})
const patchRequestPayload = computed(() => {
const payload: {
body?: string
} = {}
if (description.value !== project.value.body) {
payload.body = description.value
}
return payload
})
const hasChanges = computed(() => {
return Object.keys(patchRequestPayload.value).length > 0
})
function saveChanges() {
patchProject(patchRequestPayload.value)
}
async function onUploadHandler(file: File) {
const response = await useImageUpload(file, {
context: 'project',

View File

@@ -299,15 +299,19 @@ import {
ConfirmModal,
DropArea,
FileInput,
injectNotificationManager,
injectProjectPageContext,
NewModal as Modal,
} from '@modrinth/ui'
import { isPermission } from '~/utils/permissions.ts'
const { addNotification } = injectNotificationManager()
const { projectV2: project, currentMember, refreshProject } = injectProjectPageContext()
const {
projectV2: project,
currentMember,
createGalleryItem: createGalleryItemMutation,
editGalleryItem: editGalleryItemMutation,
deleteGalleryItem: deleteGalleryItemMutation,
} = injectProjectPageContext()
const title = `${project.value.title} - Gallery`
const description = `View ${project.value.gallery?.length ?? 0} images of ${project.value.title} on Modrinth.`
@@ -391,103 +395,42 @@ const showPreviewImage = () => {
const createGalleryItem = async () => {
shouldPreventActions.value = true
startLoading()
try {
let url = `project/${project.value.id}/gallery?ext=${
editFile.value
? editFile.value.type.split('/')[editFile.value.type.split('/').length - 1]
: null
}&featured=${editFeatured.value}`
if (editTitle.value) {
url += `&title=${encodeURIComponent(editTitle.value)}`
}
if (editDescription.value) {
url += `&description=${encodeURIComponent(editDescription.value)}`
}
if (editOrder.value) {
url += `&ordering=${editOrder.value}`
}
await useBaseFetch(url, {
method: 'POST',
body: editFile.value,
})
await refreshProject()
const success = await createGalleryItemMutation(
editFile.value,
editTitle.value || undefined,
editDescription.value || undefined,
editFeatured.value,
editOrder.value ?? undefined,
)
if (success) {
modal_edit_item.value.hide()
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
shouldPreventActions.value = false
}
const editGalleryItem = async () => {
shouldPreventActions.value = true
startLoading()
try {
let url = `project/${project.value.id}/gallery?url=${encodeURIComponent(
project.value.gallery[editIndex.value].url,
)}&featured=${editFeatured.value}`
if (editTitle.value) {
url += `&title=${encodeURIComponent(editTitle.value)}`
}
if (editDescription.value) {
url += `&description=${encodeURIComponent(editDescription.value)}`
}
if (editOrder.value) {
url += `&ordering=${editOrder.value}`
}
const success = await editGalleryItemMutation(
project.value.gallery[editIndex.value].url,
editTitle.value || undefined,
editDescription.value || undefined,
editFeatured.value,
editOrder.value ?? undefined,
)
await useBaseFetch(url, {
method: 'PATCH',
})
await refreshProject()
if (success) {
modal_edit_item.value.hide()
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
shouldPreventActions.value = false
}
const deleteGalleryImage = async () => {
startLoading()
try {
await useBaseFetch(
`project/${project.value.id}/gallery?url=${encodeURIComponent(
project.value.gallery[deleteIndex.value].url,
)}`,
{
method: 'DELETE',
},
)
await refreshProject()
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
await deleteGalleryItemMutation(project.value.gallery[deleteIndex.value].url)
}
const handleKeydown = (e) => {

View File

@@ -2,8 +2,6 @@
import {
defineMessages,
IconSelect,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
type MessageDescriptor,
SettingsLabel,
@@ -14,34 +12,21 @@ import {
const { formatMessage } = useVIntl()
const { projectV2: project, refreshProject } = injectProjectPageContext()
const { handleError } = injectNotificationManager()
const client = injectModrinthClient()
const { projectV2: project, patchProject } = injectProjectPageContext()
const saving = ref(false)
const { saved, current, reset, save } = useSavable(
const { saved, current, saving, reset, save } = useSavable(
() => ({
title: project.value.title,
tagline: project.value.description,
url: project.value.slug,
icon: project.value.icon_url,
}),
({ title, tagline, url }) => {
const data: Record<string, string> = {
async ({ title, tagline, url }) => {
await patchProject({
...(title !== undefined && { title }),
...(tagline !== undefined && { description: tagline }),
...(url !== undefined && { slug: url }),
}
if (data) {
saving.value = true
client.labrinth.projects_v2
.edit(project.value.id, { title, description: tagline, slug: url })
.then(() => refreshProject().then(reset))
.catch(handleError)
.finally(() => (saving.value = false))
}
})
},
)

View File

@@ -28,7 +28,7 @@
<div class="w-1/2">
<DropdownSelect
v-model="license"
v-model="current.license"
name="License selector"
:options="builtinLicenses"
:display-name="(chosen: BuiltinLicense) => chosen.friendly"
@@ -37,7 +37,7 @@
</div>
</div>
<div v-if="license.requiresOnlyOrLater" class="adjacent-input">
<div v-if="current.license.requiresOnlyOrLater" class="adjacent-input">
<label for="or-later-checkbox">
<span class="label__title">Later editions</span>
<span class="label__description">
@@ -48,7 +48,7 @@
<Checkbox
id="or-later-checkbox"
v-model="allowOrLater"
v-model="current.allowOrLater"
:disabled="!hasPermission"
description="Allow later editions"
class="w-1/2"
@@ -60,7 +60,7 @@
<div class="adjacent-input">
<label for="license-url">
<span class="label__title">License URL</span>
<span v-if="license?.friendly !== 'Custom'" class="label__description">
<span v-if="current.license?.friendly !== 'Custom'" class="label__description">
The web location of the full license text. If you don't provide a link, the license text
will be displayed instead.
</span>
@@ -73,18 +73,20 @@
<div class="w-1/2">
<input
id="license-url"
v-model="licenseUrl"
v-model="current.licenseUrl"
type="url"
maxlength="2048"
:placeholder="license?.friendly !== 'Custom' ? `License URL (optional)` : `License URL`"
:placeholder="
current.license?.friendly !== 'Custom' ? `License URL (optional)` : `License URL`
"
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
class="w-full"
/>
</div>
</div>
<div v-if="license?.friendly === 'Custom'" class="adjacent-input">
<label v-if="!nonSpdxLicense" for="license-spdx">
<div v-if="current.license?.friendly === 'Custom'" class="adjacent-input">
<label v-if="!current.nonSpdxLicense" for="license-spdx">
<span class="label__title">SPDX identifier</span>
<span class="label__description">
If your license does not have an offical
@@ -93,7 +95,7 @@
>, check the box and enter the name of the license instead.
</span>
</label>
<label v-else for="license-name">
<label v-if="current.nonSpdxLicense" for="license-name">
<span class="label__title">License name</span>
<span class="label__description"
>The full name of the license. If the license has a SPDX identifier, please uncheck the
@@ -103,9 +105,9 @@
<div class="input-stack w-1/2">
<input
v-if="!nonSpdxLicense"
v-if="!current.nonSpdxLicense"
id="license-spdx"
v-model="license.short"
v-model="current.license.short"
class="w-full"
type="text"
maxlength="128"
@@ -115,7 +117,7 @@
<input
v-else
id="license-name"
v-model="license.short"
v-model="current.license.short"
class="w-full"
type="text"
maxlength="128"
@@ -124,8 +126,8 @@
/>
<Checkbox
v-if="license?.friendly === 'Custom'"
v-model="nonSpdxLicense"
v-if="current.license?.friendly === 'Custom'"
v-model="current.nonSpdxLicense"
:disabled="!hasPermission"
description="License does not have a SPDX identifier"
>
@@ -133,74 +135,91 @@
</Checkbox>
</div>
</div>
<div class="input-stack">
<button
type="button"
class="iconified-button brand-button"
:disabled="
!hasChanges ||
!hasPermission ||
(license.friendly === 'Custom' && (license.short === '' || licenseUrl === ''))
"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
<UnsavedChangesPopup
:original="saved"
:modified="current"
:saving="saving"
:can-save="
hasPermission &&
!(
current.license.friendly === 'Custom' &&
(current.license.short === '' || current.licenseUrl === '')
)
"
@reset="reset"
@save="save"
/>
</div>
</template>
<script setup lang="ts">
import { SaveIcon } from '@modrinth/assets'
import { Checkbox, DropdownSelect, injectProjectPageContext } from '@modrinth/ui'
import {
Checkbox,
DropdownSelect,
injectProjectPageContext,
UnsavedChangesPopup,
useSavable,
} from '@modrinth/ui'
import {
type BuiltinLicense,
builtinLicenses,
formatProjectType,
TeamMemberPermission,
} from '@modrinth/utils'
import { computed, type Ref, ref } from 'vue'
import { computed } from 'vue'
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
const licenseUrl = ref(project.value.license.url)
const license: Ref<{
friendly: string
short: string
requiresOnlyOrLater?: boolean
}> = ref({
friendly: '',
short: '',
requiresOnlyOrLater: false,
})
function getInitialLicense() {
const oldLicenseId = project.value.license.id
const trimmedLicenseId = oldLicenseId
.replaceAll('-only', '')
.replaceAll('-or-later', '')
.replaceAll('LicenseRef-', '')
const allowOrLater = ref(project.value.license.id.includes('-or-later'))
const nonSpdxLicense = ref(project.value.license.id.includes('LicenseRef-'))
const oldLicenseId = project.value.license.id
const trimmedLicenseId = oldLicenseId
.replaceAll('-only', '')
.replaceAll('-or-later', '')
.replaceAll('LicenseRef-', '')
license.value = builtinLicenses.find((x) => x.short === trimmedLicenseId) ?? {
friendly: 'Custom',
short: oldLicenseId.replaceAll('LicenseRef-', ''),
requiresOnlyOrLater: oldLicenseId.includes('-or-later'),
}
if (oldLicenseId === 'LicenseRef-Unknown') {
// Mark it as not having a license, forcing the user to select one
license.value = {
friendly: '',
short: oldLicenseId.replaceAll('LicenseRef-', ''),
requiresOnlyOrLater: false,
if (oldLicenseId === 'LicenseRef-Unknown') {
return {
friendly: '',
short: oldLicenseId.replaceAll('LicenseRef-', ''),
requiresOnlyOrLater: false,
}
}
return (
builtinLicenses.find((x) => x.short === trimmedLicenseId) ?? {
friendly: 'Custom',
short: oldLicenseId.replaceAll('LicenseRef-', ''),
requiresOnlyOrLater: oldLicenseId.includes('-or-later'),
}
)
}
const { saved, current, saving, reset, save } = useSavable(
() => ({
license: getInitialLicense(),
licenseUrl: project.value.license.url ?? '',
allowOrLater: project.value.license.id.includes('-or-later'),
nonSpdxLicense: project.value.license.id.includes('LicenseRef-'),
}),
async () => {
const payload: {
license_id?: string
license_url?: string | null
} = {}
if (licenseId.value !== project.value.license.id) {
payload.license_id = licenseId.value
}
if (current.value.licenseUrl !== project.value.license.url) {
payload.license_url = current.value.licenseUrl ? current.value.licenseUrl : null
}
await patchProject(payload)
},
)
const hasPermission = computed(() => {
return (currentMember.value?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS
})
@@ -209,47 +228,22 @@ const licenseId = computed(() => {
let id = ''
if (
(nonSpdxLicense.value && license.value.friendly === 'Custom') ||
license.value.short === 'All-Rights-Reserved' ||
license.value.short === 'Unknown'
(current.value.nonSpdxLicense && current.value.license.friendly === 'Custom') ||
current.value.license.short === 'All-Rights-Reserved' ||
current.value.license.short === 'Unknown'
) {
id += 'LicenseRef-'
}
id += license.value.short
if (license.value.requiresOnlyOrLater) {
id += allowOrLater.value ? '-or-later' : '-only'
id += current.value.license.short
if (current.value.license.requiresOnlyOrLater) {
id += current.value.allowOrLater ? '-or-later' : '-only'
}
if (nonSpdxLicense.value && license.value.friendly === 'Custom') {
if (current.value.nonSpdxLicense && current.value.license.friendly === 'Custom') {
id = id.replaceAll(' ', '-')
}
return id
})
const patchRequestPayload = computed(() => {
const payload: {
license_id?: string
license_url?: string | null // null = remove url
} = {}
if (licenseId.value !== project.value.license.id) {
payload.license_id = licenseId.value
}
if (licenseUrl.value !== project.value.license.url) {
payload.license_url = licenseUrl.value ? licenseUrl.value : null
}
return payload
})
const hasChanges = computed(() => {
return Object.keys(patchRequestPayload.value).length > 0
})
function saveChanges() {
patchProject(patchRequestPayload.value)
}
</script>

View File

@@ -65,7 +65,7 @@
<Checkbox
v-for="category in categoryLists[header]"
:key="`category-${header}-${category.name}`"
:model-value="selectedTags.includes(category)"
:model-value="current.selectedTags.includes(category)"
:description="formatCategory(category.name)"
class="category-selector"
@update:model-value="toggleCategory(category)"
@@ -91,17 +91,17 @@
featured if you do not select all 3.
</span>
</div>
<p v-if="selectedTags.length < 1">
<p v-if="current.selectedTags.length < 1">
Select at least one category in order to feature a category.
</p>
<div class="category-list input-div">
<Checkbox
v-for="category in selectedTags"
v-for="category in current.selectedTags"
:key="`featured-category-${category.name}`"
class="category-selector"
:model-value="featuredTags.includes(category)"
:model-value="current.featuredTags.includes(category)"
:description="formatCategory(category.name)"
:disabled="featuredTags.length >= 3 && !featuredTags.includes(category)"
:disabled="current.featuredTags.length >= 3 && !current.featuredTags.includes(category)"
@update:model-value="toggleFeaturedCategory(category)"
>
<div class="category-selector__label">
@@ -116,32 +116,27 @@
</Checkbox>
</div>
</template>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
<UnsavedChangesPopup
:original="saved"
:modified="current"
:saving="saving"
@reset="reset"
@save="save"
/>
</div>
</template>
<script setup lang="ts">
import { SaveIcon, StarIcon, TriangleAlertIcon } from '@modrinth/assets'
import { Checkbox, injectProjectPageContext } from '@modrinth/ui'
import { StarIcon, TriangleAlertIcon } from '@modrinth/assets'
import { Checkbox, injectProjectPageContext, UnsavedChangesPopup, useSavable } from '@modrinth/ui'
import {
formatCategory,
formatCategoryHeader,
formatProjectType,
sortedCategories,
} from '@modrinth/utils'
import { computed, ref } from 'vue'
import { computed } from 'vue'
interface Category {
name: string
@@ -154,21 +149,56 @@ const tags = useGeneratedState()
const { projectV2: project, patchProject } = injectProjectPageContext()
const selectedTags = ref<Category[]>(
sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === project.value.actualProjectType &&
(project.value.categories.includes(x.name) ||
project.value.additional_categories.includes(x.name)),
),
)
const { saved, current, saving, reset, save } = useSavable(
() => ({
selectedTags: sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === project.value.actualProjectType &&
(project.value.categories.includes(x.name) ||
project.value.additional_categories.includes(x.name)),
) as Category[],
featuredTags: sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === project.value.actualProjectType &&
project.value.categories.includes(x.name),
) as Category[],
}),
async () => {
// Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = current.value.featuredTags.slice()
if (newFeaturedTags.length < 1 && current.value.selectedTags.length > newFeaturedTags.length) {
const nonFeaturedCategories = current.value.selectedTags.filter(
(x) => !newFeaturedTags.includes(x),
)
nonFeaturedCategories
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
.forEach((x) => newFeaturedTags.push(x))
}
const featuredTags = ref<Category[]>(
sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === project.value.actualProjectType &&
project.value.categories.includes(x.name),
),
// Convert selected and featured categories to backend-usable arrays
const categories = newFeaturedTags.map((x) => x.name)
const additionalCategories = current.value.selectedTags
.filter((x) => !newFeaturedTags.includes(x))
.map((x) => x.name)
const data: Record<string, string[]> = {}
if (
categories.length !== project.value.categories.length ||
categories.some((value) => !project.value.categories.includes(value))
) {
data.categories = categories
}
if (
additionalCategories.length !== project.value.additional_categories.length ||
additionalCategories.some((value) => !project.value.additional_categories.includes(value))
) {
data.additional_categories = additionalCategories
}
await patchProject(data)
},
)
const categoryLists = computed(() => {
@@ -186,7 +216,7 @@ const categoryLists = computed(() => {
})
const tooManyTagsWarning = computed(() => {
const tagCount = selectedTags.value.length
const tagCount = current.value.selectedTags.length
if (tagCount > 8) {
return `You've selected ${tagCount} tags. Consider reducing to 8 or fewer to keep your project focused and easier to discover.`
}
@@ -196,7 +226,7 @@ const tooManyTagsWarning = computed(() => {
const multipleResolutionTagsWarning = computed(() => {
if (project.value.actualProjectType !== 'resourcepack') return null
const resolutionTags = selectedTags.value.filter((tag) =>
const resolutionTags = current.value.selectedTags.filter((tag) =>
['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+'].includes(tag.name),
)
@@ -217,7 +247,7 @@ const allTagsSelectedWarning = computed(() => {
const categoriesForProjectType = sortedCategories(tags.value).filter(
(x: Category) => x.project_type === project.value.actualProjectType,
)
const totalSelectedTags = selectedTags.value.length
const totalSelectedTags = current.value.selectedTags.length
if (
totalSelectedTags === categoriesForProjectType.length &&
@@ -228,68 +258,22 @@ const allTagsSelectedWarning = computed(() => {
return null
})
const patchData = computed(() => {
const data: Record<string, string[]> = {}
// Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = featuredTags.value.slice()
if (newFeaturedTags.length < 1 && selectedTags.value.length > newFeaturedTags.length) {
const nonFeaturedCategories = selectedTags.value.filter((x) => !newFeaturedTags.includes(x))
nonFeaturedCategories
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
.forEach((x) => newFeaturedTags.push(x))
}
// Convert selected and featured categories to backend-usable arrays
const categories = newFeaturedTags.map((x) => x.name)
const additionalCategories = selectedTags.value
.filter((x) => !newFeaturedTags.includes(x))
.map((x) => x.name)
if (
categories.length !== project.value.categories.length ||
categories.some((value) => !project.value.categories.includes(value))
) {
data.categories = categories
}
if (
additionalCategories.length !== project.value.additional_categories.length ||
additionalCategories.some((value) => !project.value.additional_categories.includes(value))
) {
data.additional_categories = additionalCategories
}
return data
})
const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0
})
const toggleCategory = (category: Category) => {
if (selectedTags.value.includes(category)) {
selectedTags.value = selectedTags.value.filter((x) => x !== category)
if (featuredTags.value.includes(category)) {
featuredTags.value = featuredTags.value.filter((x) => x !== category)
if (current.value.selectedTags.includes(category)) {
current.value.selectedTags = current.value.selectedTags.filter((x) => x !== category)
if (current.value.featuredTags.includes(category)) {
current.value.featuredTags = current.value.featuredTags.filter((x) => x !== category)
}
} else {
selectedTags.value.push(category)
current.value.selectedTags = [...current.value.selectedTags, category]
}
}
const toggleFeaturedCategory = (category: Category) => {
if (featuredTags.value.includes(category)) {
featuredTags.value = featuredTags.value.filter((x) => x !== category)
if (current.value.featuredTags.includes(category)) {
current.value.featuredTags = current.value.featuredTags.filter((x) => x !== category)
} else {
featuredTags.value.push(category)
}
}
const saveChanges = () => {
if (hasChanges.value) {
patchProject(patchData.value)
current.value.featuredTags = [...current.value.featuredTags, category]
}
}
</script>

View File

@@ -1,6 +1,14 @@
<script setup>
import { SaveIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import { Avatar, Button, ConfirmModal, FileInput, injectNotificationManager } from '@modrinth/ui'
import { TrashIcon, UploadIcon } from '@modrinth/assets'
import {
Avatar,
Button,
ConfirmModal,
FileInput,
injectNotificationManager,
UnsavedChangesPopup,
useSavable,
} from '@modrinth/ui'
import { injectOrganizationContext } from '~/providers/organization-context.ts'
@@ -14,32 +22,49 @@ const {
patchOrganization,
} = injectOrganizationContext()
// Icon state (separate from useSavable, like collection page)
const icon = ref(null)
const deletedIcon = ref(false)
const previewImage = ref(null)
const name = ref(organization.value.name)
const slug = ref(organization.value.slug)
const {
saved,
current,
saving,
hasChanges: hasFieldChanges,
reset: resetFields,
} = useSavable(
() => ({
name: organization.value.name,
slug: organization.value.slug,
summary: organization.value.description,
}),
async ({ name, slug, summary }) => {
await patchOrganization({
...(name !== undefined && { name }),
...(slug !== undefined && { slug }),
...(summary !== undefined && { description: summary }),
})
},
)
const summary = ref(organization.value.description)
// Combined state for UnsavedChangesPopup
const originalState = computed(() => ({
...saved.value,
iconChanged: false,
}))
const patchData = computed(() => {
const data = {}
if (name.value !== organization.value.name) {
data.name = name.value
}
if (slug.value !== organization.value.slug) {
data.slug = slug.value
}
if (summary.value !== organization.value.description) {
data.description = summary.value
}
return data
})
const modifiedState = computed(() => ({
...current.value,
iconChanged: !!(deletedIcon.value || icon.value),
}))
const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0 || deletedIcon.value || icon.value
})
const reset = () => {
resetFields()
icon.value = null
deletedIcon.value = false
previewImage.value = null
}
const markIconForDeletion = () => {
deletedIcon.value = true
@@ -61,11 +86,16 @@ const showPreviewImage = (files) => {
const orgId = useRouteId()
const onSaveChanges = useClientTry(async () => {
// Only PATCH organization details if there are actual field changes
const hasOrgFieldChanges = Object.keys(patchData.value).length > 0
if (hasOrgFieldChanges) {
await patchOrganization(patchData.value)
const save = async () => {
// Save field changes via useSavable
if (hasFieldChanges.value) {
await patchOrganization({
...(current.value.name !== organization.value.name && { name: current.value.name }),
...(current.value.slug !== organization.value.slug && { slug: current.value.slug }),
...(current.value.summary !== organization.value.description && {
description: current.value.summary,
}),
})
}
// Handle icon deletion / upload separately
@@ -85,7 +115,7 @@ const onSaveChanges = useClientTry(async () => {
text: 'Your organization has been updated.',
type: 'success',
})
})
}
const onDeleteOrganization = useClientTry(async () => {
await useBaseFetch(`organization/${orgId}`, {
@@ -159,7 +189,7 @@ const onDeleteOrganization = useClientTry(async () => {
</label>
<input
id="project-name"
v-model="name"
v-model="current.name"
maxlength="2048"
type="text"
:disabled="!hasPermission"
@@ -172,7 +202,7 @@ const onDeleteOrganization = useClientTry(async () => {
<div class="text-input-wrapper__before">https://modrinth.com/organization/</div>
<input
id="project-slug"
v-model="slug"
v-model="current.slug"
type="text"
maxlength="64"
autocomplete="off"
@@ -186,17 +216,11 @@ const onDeleteOrganization = useClientTry(async () => {
<div class="textarea-wrapper summary-input">
<textarea
id="project-summary"
v-model="summary"
v-model="current.summary"
maxlength="256"
:disabled="!hasPermission"
/>
</div>
<div class="button-group">
<Button color="primary" :disabled="!hasChanges" @click="onSaveChanges">
<SaveIcon />
Save changes
</Button>
</div>
</div>
<div class="universal-card">
<div class="label">
@@ -213,6 +237,13 @@ const onDeleteOrganization = useClientTry(async () => {
Delete organization
</Button>
</div>
<UnsavedChangesPopup
:original="originalState"
:modified="modifiedState"
:saving="saving"
@reset="reset"
@save="save"
/>
</div>
</template>

View File

@@ -56,41 +56,32 @@
{{ formatMessage(messages.usernameDescription) }}
</span>
</label>
<input id="username-field" v-model="username" type="text" />
<input id="username-field" v-model="current.username" type="text" />
<label for="bio-field">
<span class="label__title">{{ formatMessage(messages.bioTitle) }}</span>
<span class="label__description">
{{ formatMessage(messages.bioDescription) }}
</span>
</label>
<textarea id="bio-field" v-model="bio" type="text" />
<div v-if="hasUnsavedChanges" class="input-group">
<Button color="primary" :action="() => saveChanges()">
<SaveIcon /> {{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
<Button :action="() => cancel()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</Button>
</div>
<div v-else class="input-group">
<Button disabled color="primary" :action="() => saveChanges()">
<SaveIcon />
{{
saved
? formatMessage(commonMessages.changesSavedLabel)
: formatMessage(commonMessages.saveChangesButton)
}}
</Button>
<textarea id="bio-field" v-model="current.bio" type="text" />
<div class="input-group">
<Button :link="`/user/${auth.user.username}`">
<UserIcon /> {{ formatMessage(commonMessages.visitYourProfile) }}
</Button>
</div>
</section>
<UnsavedChangesPopup
:original="originalState"
:modified="modifiedState"
:saving="saving"
@reset="reset"
@save="save"
/>
</div>
</template>
<script setup>
import { SaveIcon, TrashIcon, UndoIcon, UploadIcon, UserIcon, XIcon } from '@modrinth/assets'
import { TrashIcon, UndoIcon, UploadIcon, UserIcon } from '@modrinth/assets'
import {
Avatar,
Button,
@@ -99,6 +90,8 @@ import {
FileInput,
injectNotificationManager,
IntlFormatted,
UnsavedChangesPopup,
useSavable,
useVIntl,
} from '@modrinth/ui'
@@ -151,21 +144,43 @@ const messages = defineMessages({
const auth = await useAuth()
const username = ref(auth.value.user.username)
const bio = ref(auth.value.user.bio)
// Avatar state (separate from useSavable)
const avatarUrl = ref(auth.value.user.avatar_url)
const icon = shallowRef(null)
const previewImage = shallowRef(null)
const pendingAvatarDeletion = ref(false)
const saved = ref(false)
const saving = ref(false)
const hasUnsavedChanges = computed(
() =>
username.value !== auth.value.user.username ||
bio.value !== auth.value.user.bio ||
previewImage.value,
const {
saved,
current,
reset: resetFields,
} = useSavable(
() => ({
username: auth.value.user.username,
bio: auth.value.user.bio ?? '',
}),
async () => {}, // Save is handled manually due to complex icon logic
)
// Combined state for UnsavedChangesPopup
const originalState = computed(() => ({
...saved.value,
avatarChanged: false,
}))
const modifiedState = computed(() => ({
...current.value,
avatarChanged: !!(previewImage.value || pendingAvatarDeletion.value),
}))
const reset = () => {
resetFields()
icon.value = null
previewImage.value = null
pendingAvatarDeletion.value = false
}
function showPreviewImage(files) {
const reader = new FileReader()
icon.value = files[0]
@@ -180,16 +195,8 @@ function removePreviewImage() {
previewImage.value = 'https://cdn.modrinth.com/placeholder.png'
}
function cancel() {
icon.value = null
previewImage.value = null
pendingAvatarDeletion.value = false
username.value = auth.value.user.username
bio.value = auth.value.user.bio
}
async function saveChanges() {
startLoading()
async function save() {
saving.value = true
try {
if (pendingAvatarDeletion.value) {
await useBaseFetch(`user/${auth.value.user.id}/icon`, {
@@ -215,12 +222,12 @@ async function saveChanges() {
const body = {}
if (auth.value.user.username !== username.value) {
body.username = username.value
if (auth.value.user.username !== current.value.username) {
body.username = current.value.username
}
if (auth.value.user.bio !== bio.value) {
body.bio = bio.value
if (auth.value.user.bio !== current.value.bio) {
body.bio = current.value.bio
}
await useBaseFetch(`user/${auth.value.user.id}`, {
@@ -229,7 +236,6 @@ async function saveChanges() {
})
await useAuth(auth.value.token)
avatarUrl.value = auth.value.user.avatar_url
saved.value = true
} catch (err) {
addNotification({
title: 'An error occurred',
@@ -243,7 +249,7 @@ async function saveChanges() {
type: 'error',
})
}
stopLoading()
saving.value = false
}
</script>
<style lang="scss" scoped>