diff --git a/apps/frontend/src/composables/queries/project.ts b/apps/frontend/src/composables/queries/project.ts index d73b93606..8612e516f 100644 --- a/apps/frontend/src/composables/queries/project.ts +++ b/apps/frontend/src/composables/queries/project.ts @@ -1,6 +1,7 @@ import type { AbstractModrinthClient } from '@modrinth/api-client' -const STALE_TIME = 1000 * 60 * 5 // 5 minutes +export const STALE_TIME = 1000 * 60 * 5 // 5 minutes +export const STALE_TIME_LONG = 1000 * 60 * 10 // 10 minutes export const projectQueryOptions = { v2: (projectId: string, client: AbstractModrinthClient) => ({ diff --git a/apps/frontend/src/composables/user.js b/apps/frontend/src/composables/user.js index fc04a14c9..100e66bbe 100644 --- a/apps/frontend/src/composables/user.js +++ b/apps/frontend/src/composables/user.js @@ -1,3 +1,5 @@ +import { useAppQueryClient } from '@/composables/query-client' + export const useUser = async (force = false) => { const user = useState('user', () => {}) @@ -158,5 +160,6 @@ export const logout = async () => { await useAuth('none') useCookie('auth-token').value = null + useAppQueryClient().clear() stopLoading() } diff --git a/apps/frontend/src/middleware/project.global.ts b/apps/frontend/src/middleware/project.global.ts index 6268e5800..347e055d4 100644 --- a/apps/frontend/src/middleware/project.global.ts +++ b/apps/frontend/src/middleware/project.global.ts @@ -1,4 +1,5 @@ import { useGeneratedState } from '~/composables/generated' +import { projectQueryOptions } from '~/composables/queries/project' import { useAppQueryClient } from '~/composables/query-client' import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js' import { useServerModrinthClient } from '~/server/utils/api-client' @@ -21,11 +22,7 @@ export default defineNuxtRouteMiddleware(async (to) => { try { // Fetch v2 project for redirect check AND cache it for the page // Using fetchQuery ensures the page's useQuery gets this cached result - const project = await queryClient.fetchQuery({ - queryKey: ['project', 'v2', projectId], - queryFn: () => client.labrinth.projects_v2.get(projectId), - staleTime: 1000 * 60 * 5, - }) + const project = await queryClient.fetchQuery(projectQueryOptions.v2(projectId, client)) // Let page handle 404 if (!project) return diff --git a/apps/frontend/src/pages/[type]/[id].vue b/apps/frontend/src/pages/[type]/[id].vue index b407ca482..daa121919 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -35,7 +35,7 @@ :is-settings="route.name.startsWith('type-id-settings')" :set-processing="setProcessing" :all-members="allMembers" - :update-members="refreshMembers" + :update-members="invalidateProject" :auth="auth" :tags="tags" /> @@ -747,7 +747,7 @@ :collapsed="collapsedChecklist" :toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)" :all-members="allMembers" - :update-members="updateMembers" + :update-members="invalidateProject" :auth="auth" :tags="tags" /> @@ -1008,6 +1008,7 @@ import ModerationChecklist from '~/components/ui/moderation/checklist/Moderation import NavTabs from '~/components/ui/NavTabs.vue' import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue' import { saveFeatureFlags } from '~/composables/featureFlags.ts' +import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project' import { userCollectProject, userFollowProject } from '~/composables/user.js' import { useModerationStore } from '~/store/moderation.ts' import { reportProject } from '~/utils/report-helpers.ts' @@ -1472,14 +1473,10 @@ const client = injectModrinthClient() const queryClient = useQueryClient() // V2 Project - hits middleware cache (uses route param for lookup) -const { - data: projectRaw, - error: projectV2Error, - refetch: resetProjectV2, -} = useQuery({ +const { data: projectRaw, error: projectV2Error } = useQuery({ queryKey: computed(() => ['project', 'v2', routeProjectId.value]), queryFn: () => client.labrinth.projects_v2.get(routeProjectId.value), - staleTime: 1000 * 60 * 5, + staleTime: STALE_TIME, }) // Handle project not found - use showError since watch runs outside Nuxt context @@ -1522,26 +1519,18 @@ const project = computed(() => { const projectId = computed(() => projectRaw.value?.id) // V3 Project -const { - data: projectV3, - error: _projectV3Error, - refetch: resetProjectV3, -} = useQuery({ +const { data: projectV3, error: _projectV3Error } = useQuery({ queryKey: computed(() => ['project', 'v3', projectId.value]), queryFn: () => client.labrinth.projects_v3.get(projectId.value), - staleTime: 1000 * 60 * 5, + staleTime: STALE_TIME, enabled: computed(() => !!projectId.value), }) // Members -const { - data: allMembersRaw, - error: _membersError, - refetch: _resetMembers, -} = useQuery({ +const { data: allMembersRaw, error: _membersError } = useQuery({ queryKey: computed(() => ['project', projectId.value, 'members']), queryFn: () => client.labrinth.projects_v3.getMembers(projectId.value), - staleTime: 1000 * 60 * 5, + staleTime: STALE_TIME, enabled: computed(() => !!projectId.value), }) @@ -1556,25 +1545,26 @@ const allMembers = computed(() => { }) // Dependencies - lazy loaded client-side only +const dependenciesEnabled = ref(false) const { data: dependenciesRaw, error: _dependenciesError, isFetching: dependenciesLoading, - refetch: refetchDependencies, } = useQuery({ queryKey: computed(() => ['project', projectId.value, 'dependencies']), queryFn: () => client.labrinth.projects_v2.getDependencies(projectId.value), - staleTime: 1000 * 60 * 10, + staleTime: STALE_TIME_LONG, + enabled: computed(() => !!projectId.value && dependenciesEnabled.value), }) const dependencies = computed(() => dependenciesRaw.value ?? null) // V3 Versions - lazy loaded client-side only +const versionsEnabled = ref(false) const { data: versionsV3, error: _versionsV3Error, isFetching: versionsV3Loading, - refetch: resetVersionsV3, } = useQuery({ queryKey: computed(() => ['project', projectId.value, 'versions', 'v3']), queryFn: () => @@ -1582,15 +1572,16 @@ const { include_changelog: false, apiVersion: 3, }), - staleTime: 1000 * 60 * 10, + staleTime: STALE_TIME_LONG, + enabled: computed(() => !!projectId.value && versionsEnabled.value), }) // Organization // Only fetch organization if project belongs to one -const { data: organization, refetch: _resetOrganization } = useQuery({ +const { data: organization } = useQuery({ queryKey: computed(() => ['project', projectId.value, 'organization']), queryFn: () => client.labrinth.projects_v3.getOrganization(projectId.value), - staleTime: 1000 * 60 * 5, + staleTime: STALE_TIME, enabled: computed(() => !!projectId.value && !!projectRaw.value?.organization), }) @@ -1616,17 +1607,13 @@ const versions = computed(() => { const versionsLoading = computed(() => versionsV3Loading.value) // Load versions on demand (client-side only) -async function loadVersions() { - // Skip if already loaded or loading - if (versionsV3.value || versionsV3Loading.value) return - await resetVersionsV3() +function loadVersions() { + versionsEnabled.value = true } // Load dependencies on demand (client-side only) -async function loadDependencies() { - // Skip if already loaded or loading - if (dependenciesRaw.value || dependenciesLoading.value) return - await refetchDependencies() +function loadDependencies() { + dependenciesEnabled.value = true } // Check if project has versions using the ID array from the V2 project @@ -1654,25 +1641,13 @@ async function updateProjectRoute() { } } -async function resetProject() { - await invalidateProjectQueries(projectId.value) - await resetProjectV2() - await resetProjectV3() -} - -async function resetVersions() { - await invalidateProjectQueries(projectId.value) - await resetVersionsV3() -} - -// Helper to invalidate project queries after mutations settle -async function invalidateProjectQueries(projectId) { +async function invalidateProject() { await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] }) - if (routeProjectId.value !== projectId) { - await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] }) + if (routeProjectId.value !== projectId.value) { + await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId.value] }) } - await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId] }) - await queryClient.invalidateQueries({ queryKey: ['project', projectId, 'versions', 'v3'] }) + // Prefix match — invalidates members, versions, dependencies, organization + await queryClient.invalidateQueries({ queryKey: ['project', projectId.value] }) } // Mutation for patching project data @@ -1718,8 +1693,8 @@ const patchProjectMutation = useMutation({ window.scrollTo({ top: 0, behavior: 'smooth' }) }, - onSettled: async (_data, _error, { projectId }) => { - await invalidateProjectQueries(projectId) + onSettled: async () => { + await invalidateProject() }, }) @@ -1763,8 +1738,8 @@ const patchStatusMutation = useMutation({ }) }, - onSettled: async (_data, _error, { projectId }) => { - await invalidateProjectQueries(projectId) + onSettled: async () => { + await invalidateProject() }, }) @@ -1797,8 +1772,8 @@ const patchIconMutation = useMutation({ window.scrollTo({ top: 0, behavior: 'smooth' }) }, - onSettled: async (_data, _error, { projectId }) => { - await invalidateProjectQueries(projectId) + onSettled: async () => { + await invalidateProject() }, }) @@ -1860,8 +1835,8 @@ const createGalleryItemMutation = useMutation({ }) }, - onSettled: async (_data, _error, { projectId }) => { - await invalidateProjectQueries(projectId) + onSettled: async () => { + await invalidateProject() }, }) @@ -1922,8 +1897,8 @@ const editGalleryItemMutation = useMutation({ }) }, - onSettled: async (_data, _error, { projectId }) => { - await invalidateProjectQueries(projectId) + onSettled: async () => { + await invalidateProject() }, }) @@ -1961,8 +1936,8 @@ const deleteGalleryItemMutation = useMutation({ }) }, - onSettled: async (_data, _error, { projectId }) => { - await invalidateProjectQueries(projectId) + onSettled: async () => { + await invalidateProject() }, }) @@ -2202,15 +2177,6 @@ async function deleteGalleryItem(imageUrl) { }) } -async function refreshMembers() { - // Simply invalidate and refetch - the computed allMembers will auto-update - await queryClient.invalidateQueries({ queryKey: ['project', projectId.value, 'members'] }) -} - -async function refreshOrganization() { - await queryClient.invalidateQueries({ queryKey: ['project', projectId.value, 'organization'] }) -} - async function copyId() { await navigator.clipboard.writeText(project.value.id) } @@ -2266,8 +2232,7 @@ async function deleteVersion(id) { method: 'DELETE', }) - // Refetch versions to reflect deletion (versions is a computed ref) - await resetVersions() + await invalidateProject() stopLoading() } @@ -2320,11 +2285,8 @@ provideProjectPageContext({ dependencies, dependenciesLoading: computed(() => dependenciesLoading.value), - // Refresh functions (invalidate + refetch) - refreshProject: resetProject, - refreshVersions: resetVersions, - refreshMembers, - refreshOrganization, + // Invalidate all project queries (auto-refetches active ones) + invalidate: invalidateProject, // Lazy loading loadVersions, diff --git a/apps/frontend/src/pages/[type]/[id]/gallery.vue b/apps/frontend/src/pages/[type]/[id]/gallery.vue index a6f70f098..406cf0097 100644 --- a/apps/frontend/src/pages/[type]/[id]/gallery.vue +++ b/apps/frontend/src/pages/[type]/[id]/gallery.vue @@ -340,7 +340,6 @@ import { ConfirmModal, DropArea, FileInput, - injectNotificationManager, injectProjectPageContext, NewModal as Modal, } from '@modrinth/ui' @@ -352,8 +351,13 @@ import { isPermission } from '~/utils/permissions.ts' const router = useRouter() // Single DI injection -const { addNotification } = injectNotificationManager() -const { projectV2: project, currentMember, refreshProject } = injectProjectPageContext() +const { + projectV2: project, + currentMember, + createGalleryItem: contextCreateGalleryItem, + editGalleryItem: contextEditGalleryItem, + deleteGalleryItem: contextDeleteGalleryItem, +} = injectProjectPageContext() // Template refs const modalEditItem = useTemplateRef('modal_edit_item') @@ -486,37 +490,16 @@ async function createGalleryItem() { 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 contextCreateGalleryItem( + editFile.value!, + editTitle.value || undefined, + editDescription.value || undefined, + editFeatured.value, + editOrder.value ? Number(editOrder.value) : undefined, + ) + if (success) { modalEditItem.value?.hide() - } catch (err: unknown) { - const error = err as { data?: { description?: string } } - addNotification({ - title: 'An error occurred', - text: error.data?.description ?? String(err), - type: 'error', - }) } stopLoading() @@ -526,34 +509,18 @@ async function createGalleryItem() { async function editGalleryItem() { 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 imageUrl = project.value!.gallery![editIndex.value].url + const success = await contextEditGalleryItem( + imageUrl, + editTitle.value || undefined, + editDescription.value || undefined, + editFeatured.value, + editOrder.value ? Number(editOrder.value) : undefined, + ) - await useBaseFetch(url, { - method: 'PATCH', - }) - - await refreshProject() + if (success) { modalEditItem.value?.hide() - } catch (err: unknown) { - const error = err as { data?: { description?: string } } - addNotification({ - title: 'An error occurred', - text: error.data?.description ?? String(err), - type: 'error', - }) } stopLoading() @@ -563,25 +530,8 @@ async function editGalleryItem() { async function deleteGalleryImage() { startLoading() - try { - await useBaseFetch( - `project/${project.value.id}/gallery?url=${encodeURIComponent( - project.value!.gallery![deleteIndex.value].url!, - )}`, - { - method: 'DELETE', - }, - ) - - await refreshProject() - } catch (err: unknown) { - const error = err as { data?: { description?: string } } - addNotification({ - title: 'An error occurred', - text: error.data?.description ?? String(err), - type: 'error', - }) - } + const imageUrl = project.value!.gallery![deleteIndex.value].url! + await contextDeleteGalleryItem(imageUrl) stopLoading() } diff --git a/apps/frontend/src/pages/[type]/[id]/moderation.vue b/apps/frontend/src/pages/[type]/[id]/moderation.vue index 276b91189..32742b504 100644 --- a/apps/frontend/src/pages/[type]/[id]/moderation.vue +++ b/apps/frontend/src/pages/[type]/[id]/moderation.vue @@ -113,7 +113,7 @@ import { } from '~/helpers/projects.js' const { addNotification } = injectNotificationManager() -const { projectV2: project, currentMember, refreshProject } = injectProjectPageContext() +const { projectV2: project, currentMember, invalidate } = injectProjectPageContext() const auth = await useAuth() @@ -134,7 +134,7 @@ async function setStatus(status) { }) project.value.status = status - await refreshProject() + await invalidate() thread.value = await useBaseFetch(`thread/${thread.value.id}`) } catch (err) { addNotification({ diff --git a/apps/frontend/src/pages/[type]/[id]/settings/index.vue b/apps/frontend/src/pages/[type]/[id]/settings/index.vue index 77e306851..2c6ff17de 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings/index.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings/index.vue @@ -274,7 +274,7 @@ const { currentMember, patchProject, patchIcon, - refreshProject, + invalidate, } = injectProjectPageContext() const flags = useFeatureFlags() @@ -407,7 +407,7 @@ const deleteIcon = async () => { await useBaseFetch(`project/${project.value.id}/icon`, { method: 'DELETE', }) - await refreshProject() + await invalidate() addNotification({ title: 'Project icon removed', text: "Your project's icon has been removed.", diff --git a/apps/frontend/src/pages/[type]/[id]/settings/members.vue b/apps/frontend/src/pages/[type]/[id]/settings/members.vue index 78c781abc..de5edf6a1 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings/members.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings/members.vue @@ -562,9 +562,7 @@ const { organization, allMembers, currentMember, - refreshProject, - refreshOrganization, - refreshMembers, + invalidate, } = injectProjectPageContext() const cosmetics = useCosmetics() @@ -838,7 +836,7 @@ async function updateOrgMember(index) { } const updateMembers = async () => { - await Promise.all([refreshProject(), refreshOrganization(), refreshMembers()]) + await invalidate() } diff --git a/apps/frontend/src/pages/[type]/[id]/settings/versions.vue b/apps/frontend/src/pages/[type]/[id]/settings/versions.vue index 028bbd389..af62980bd 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings/versions.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings/versions.vue @@ -315,7 +315,7 @@ const { projectV2: project, currentMember, versions, - refreshVersions, + invalidate, loadVersions, } = injectProjectPageContext() @@ -387,7 +387,7 @@ async function deleteVersion() { }) } - refreshVersions() + await invalidate() selectedVersion.value = null stopLoading() diff --git a/apps/frontend/src/pages/[type]/[id]/version/[version].vue b/apps/frontend/src/pages/[type]/[id]/version/[version].vue index d3141057d..108e619a4 100644 --- a/apps/frontend/src/pages/[type]/[id]/version/[version].vue +++ b/apps/frontend/src/pages/[type]/[id]/version/[version].vue @@ -1,10 +1,6 @@