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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user