diff --git a/.gitignore b/.gitignore
index ec6a56c66..da91bfc92 100644
--- a/.gitignore
+++ b/.gitignore
@@ -65,6 +65,7 @@ app-playground-data/*
.astro
.claude
+.letta
# labrinth demo fixtures
apps/labrinth/fixtures/demo
diff --git a/apps/frontend/src/pages/[type]/[id].vue b/apps/frontend/src/pages/[type]/[id].vue
index a44ae0e9c..f6bc2767c 100644
--- a/apps/frontend/src/pages/[type]/[id].vue
+++ b/apps/frontend/src/pages/[type]/[id].vue
@@ -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,
})
diff --git a/apps/frontend/src/pages/[type]/[id]/settings/description.vue b/apps/frontend/src/pages/[type]/[id]/settings/description.vue
index 7c09d0b39..78fa0482c 100644
--- a/apps/frontend/src/pages/[type]/[id]/settings/description.vue
+++ b/apps/frontend/src/pages/[type]/[id]/settings/description.vue
@@ -14,7 +14,7 @@
+
Select at least one category in order to feature a category.