feat: ssr fixes + switch project page to tanstack (#5192)

* feat: ssr fixes

* feat: lazy load non-core data

* feat: ssr timing debugging

* feat: go back to all parallel

* feat: migrate to DI + set up mutators

* feat: remove double get versions request, only call v3

* refactor: [version].vue page to use composition API and typescript

* feat: gallery.vue start

* fix: remove left behind console log

* fix: type issues + gallery

* fix: versionsummary modal + version page direct join

* fix: projectRaw guard

* fix: currentMember val fix

* fix: actualProjectType

* fix: vers summary link same page

* fix: lint

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
Calum H.
2026-01-23 20:12:50 +00:00
committed by GitHub
parent b54fcaa0b1
commit 986a7e6216
33 changed files with 3083 additions and 3305 deletions
+2 -2
View File
@@ -11,8 +11,8 @@
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .", "fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/{components,composables,layouts,middleware,modules,pages,plugins,utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", "intl:extract": "formatjs extract \"src/{components,composables,layouts,middleware,modules,pages,plugins,utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"cf-deploy": "pnpm run build && wrangler deploy --env preview", "cf-deploy": "pnpm run build && wrangler deploy --env staging",
"cf-dev": "pnpm run build && wrangler dev --env preview", "cf-dev": "pnpm run build && wrangler dev --env staging",
"cf-typegen": "wrangler types" "cf-typegen": "wrangler types"
}, },
"devDependencies": { "devDependencies": {
+180 -140
View File
@@ -2,78 +2,54 @@
<nav <nav
ref="scrollContainer" ref="scrollContainer"
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold" class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
:class="[mode === 'navigation' ? 'card-shadow' : undefined]" :class="{ 'card-shadow': mode === 'navigation' }"
> >
<template v-if="mode === 'navigation'"> <template v-if="mode === 'navigation'">
<NuxtLink <NuxtLink
v-for="(link, index) in filteredLinks" v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown" v-show="link.shown ?? true"
:key="link.href" :key="link.href"
ref="tabLinkElements" ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href" :to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full" class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@mouseenter="link.onHover?.()"
@focus="link.onHover?.()"
> >
<component <component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
:is="link.icon" <span class="text-nowrap" :class="getLabelClasses(index)">
v-if="link.icon" {{ link.label }}
class="size-5" </span>
:class="{
'text-button-textSelected': currentActiveIndex === index && !subpageSelected,
'text-secondary': currentActiveIndex !== index || subpageSelected,
}"
/>
<span
class="text-nowrap"
:class="{
'text-button-textSelected': currentActiveIndex === index && !subpageSelected,
'text-contrast': currentActiveIndex !== index || subpageSelected,
}"
>{{ link.label }}</span
>
</NuxtLink> </NuxtLink>
</template> </template>
<template v-else> <template v-else>
<div <div
v-for="(link, index) in filteredLinks" v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown" v-show="link.shown ?? true"
:key="link.href" :key="link.href"
ref="tabLinkElements" ref="tabLinkElements"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full" class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@click="emit('tabClick', index, link)" @click="emit('tabClick', index, link)"
> >
<component <component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
:is="link.icon" <span class="text-nowrap" :class="getLabelClasses(index)">
v-if="link.icon" {{ link.label }}
class="size-5" </span>
:class="{
'text-button-textSelected': currentActiveIndex === index && !subpageSelected,
'text-secondary': currentActiveIndex !== index || subpageSelected,
}"
/>
<span
class="text-nowrap"
:class="{
'text-button-textSelected': currentActiveIndex === index && !subpageSelected,
'text-contrast': currentActiveIndex !== index || subpageSelected,
}"
>{{ link.label }}</span
>
</div> </div>
</template> </template>
<!-- Animated slider background -->
<div <div
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${ class="pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1"
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected' :class="[
}`" subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
:style="{ { 'navtabs-transition': transitionsEnabled },
left: sliderLeftPx, ]"
top: sliderTopPx, :style="sliderStyle"
right: sliderRightPx,
bottom: sliderBottomPx,
opacity:
sliderLeft === 4 && sliderLeft === sliderRight ? 0 : currentActiveIndex === -1 ? 0 : 1,
}"
aria-hidden="true" aria-hidden="true"
></div> />
</nav> </nav>
</template> </template>
@@ -89,6 +65,7 @@ interface Tab {
shown?: boolean shown?: boolean
icon?: Component icon?: Component
subpages?: string[] subpages?: string[]
onHover?: () => void
} }
const props = withDefaults( const props = withDefaults(
@@ -109,124 +86,194 @@ const emit = defineEmits<{
tabClick: [index: number, tab: Tab] tabClick: [index: number, tab: Tab]
}>() }>()
// DOM refs
const scrollContainer = ref<HTMLElement | null>(null) const scrollContainer = ref<HTMLElement | null>(null)
const tabLinkElements = ref<HTMLElement[]>()
// Slider pos state
const sliderLeft = ref(4) const sliderLeft = ref(4)
const sliderTop = ref(4) const sliderTop = ref(4)
const sliderRight = ref(4) const sliderRight = ref(4)
const sliderBottom = ref(4) const sliderBottom = ref(4)
// active tab state
const currentActiveIndex = ref(-1) const currentActiveIndex = ref(-1)
const subpageSelected = ref(false) const subpageSelected = ref(false)
const filteredLinks = computed(() => // SSR state
props.links.filter((x) => (x.shown === undefined ? true : x.shown)), const sliderReady = ref(false) // Slider is positioned and should be visible
const transitionsEnabled = ref(false) // CSS transitions should apply (after first paint)
const filteredLinks = computed(() => props.links.filter((link) => link.shown ?? true))
const sliderStyle = computed(() => ({
left: `${sliderLeft.value}px`,
top: `${sliderTop.value}px`,
right: `${sliderRight.value}px`,
bottom: `${sliderBottom.value}px`,
opacity: sliderReady.value && currentActiveIndex.value !== -1 ? 1 : 0,
}))
const isActiveAndNotSubpage = computed(
() => (index: number) => currentActiveIndex.value === index && !subpageSelected.value,
) )
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
const sliderTopPx = computed(() => `${sliderTop.value}px`)
const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
const tabLinkElements = ref() function getSSRFallbackClasses(index: number) {
if (sliderReady.value) return {}
if (currentActiveIndex.value !== index) return {}
function pickLink() { return {
let index = -1 'rounded-full': true,
subpageSelected.value = false 'bg-button-bgSelected': !subpageSelected.value,
'bg-button-bg': subpageSelected.value,
}
}
function getIconClasses(index: number) {
return {
'text-button-textSelected': isActiveAndNotSubpage.value(index),
'text-secondary': !isActiveAndNotSubpage.value(index),
}
}
function getLabelClasses(index: number) {
return {
'text-button-textSelected': isActiveAndNotSubpage.value(index),
'text-contrast': !isActiveAndNotSubpage.value(index),
}
}
function computeActiveIndex(): { index: number; isSubpage: boolean } {
if (props.mode === 'local' && props.activeIndex !== undefined) { if (props.mode === 'local' && props.activeIndex !== undefined) {
index = Math.min(props.activeIndex, filteredLinks.value.length - 1) return {
} else { index: Math.min(props.activeIndex, filteredLinks.value.length - 1),
isSubpage: false,
}
}
for (let i = filteredLinks.value.length - 1; i >= 0; i--) { for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i] const link = filteredLinks.value[i]
const decodedPath = decodeURIComponent(route.path)
// Query-based matching
if (props.query) { if (props.query) {
if (route.query[props.query] === link.href || (!route.query[props.query] && !link.href)) { const queryValue = route.query[props.query]
index = i if (queryValue === link.href || (!queryValue && !link.href)) {
break return { index: i, isSubpage: false }
} }
} else if (decodeURIComponent(route.path) === link.href) { continue
index = i
break
} else if (
decodeURIComponent(route.path).includes(link.href) ||
(link.subpages &&
link.subpages.some((subpage) => decodeURIComponent(route.path).includes(subpage)))
) {
index = i
subpageSelected.value = true
break
} }
// Exact path match
if (decodedPath === link.href) {
return { index: i, isSubpage: false }
}
// Subpage match
const isSubpageMatch =
decodedPath.includes(link.href) ||
link.subpages?.some((subpage) => decodedPath.includes(subpage))
if (isSubpageMatch) {
return { index: i, isSubpage: true }
} }
} }
return { index: -1, isSubpage: false }
}
function getTabElement(index: number): HTMLElement | null {
if (!tabLinkElements.value?.[index]) return null
// In navigation mode, elements are NuxtLinks with $el property
// In local mode, elements are plain divs
const element = tabLinkElements.value[index]
return props.mode === 'navigation' ? (element as any).$el : element
}
function positionSlider() {
const el = getTabElement(currentActiveIndex.value)
if (!el?.offsetParent) return
const parent = el.offsetParent as HTMLElement
const newPosition = {
left: el.offsetLeft,
top: el.offsetTop,
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
}
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
if (isInitialPosition) {
// Initial positioning: set position instantly, no animation
sliderLeft.value = newPosition.left
sliderRight.value = newPosition.right
sliderTop.value = newPosition.top
sliderBottom.value = newPosition.bottom
sliderReady.value = true
// enable transitions after slider is painted, so future changes animate
requestAnimationFrame(() => {
transitionsEnabled.value = true
})
} else {
animateSliderTo(newPosition)
}
}
function animateSliderTo(newPosition: {
left: number
top: number
right: number
bottom: number
}) {
const STAGGER_DELAY = 200
// Horizontal animation - lead with the direction of movement
if (newPosition.left < sliderLeft.value) {
sliderLeft.value = newPosition.left
setTimeout(() => (sliderRight.value = newPosition.right), STAGGER_DELAY)
} else {
sliderRight.value = newPosition.right
setTimeout(() => (sliderLeft.value = newPosition.left), STAGGER_DELAY)
}
// Vertical animation - lead with the direction of movement
if (newPosition.top < sliderTop.value) {
sliderTop.value = newPosition.top
setTimeout(() => (sliderBottom.value = newPosition.bottom), STAGGER_DELAY)
} else {
sliderBottom.value = newPosition.bottom
setTimeout(() => (sliderTop.value = newPosition.top), STAGGER_DELAY)
}
}
function updateActiveTab() {
const { index, isSubpage } = computeActiveIndex()
currentActiveIndex.value = index currentActiveIndex.value = index
subpageSelected.value = isSubpage
if (currentActiveIndex.value !== -1) { if (index !== -1) {
nextTick(() => startAnimation()) nextTick(positionSlider)
} else { } else {
sliderLeft.value = 0 sliderLeft.value = 0
sliderRight.value = 0 sliderRight.value = 0
} }
} }
function startAnimation() { const initialActive = computeActiveIndex()
// In navigation mode, elements are NuxtLinks with $el property currentActiveIndex.value = initialActive.index
// In local mode, elements are plain divs subpageSelected.value = initialActive.isSubpage
const el =
props.mode === 'navigation'
? tabLinkElements.value[currentActiveIndex.value]?.$el
: tabLinkElements.value[currentActiveIndex.value]
if (!el || !el.offsetParent) return onMounted(updateActiveTab)
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
}
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
} else {
const delay = 200
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left
setTimeout(() => {
sliderRight.value = newValues.right
}, delay)
} else {
sliderRight.value = newValues.right
setTimeout(() => {
sliderLeft.value = newValues.left
}, delay)
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top
setTimeout(() => {
sliderBottom.value = newValues.bottom
}, delay)
} else {
sliderBottom.value = newValues.bottom
setTimeout(() => {
sliderTop.value = newValues.top
}, delay)
}
}
}
onMounted(() => {
pickLink()
})
watch( watch(
() => [route.path, route.query], () => [route.path, route.query],
() => { () => {
if (props.mode === 'navigation') { if (props.mode === 'navigation') {
pickLink() updateActiveTab()
} }
}, },
) )
@@ -235,19 +282,12 @@ watch(
() => props.activeIndex, () => props.activeIndex,
() => { () => {
if (props.mode === 'local') { if (props.mode === 'local') {
pickLink() updateActiveTab()
} }
}, },
) )
watch( watch(() => props.links, updateActiveTab, { deep: true })
() => props.links,
() => {
// Re-trigger animation when links change
pickLink()
},
{ deep: true },
)
</script> </script>
<style scoped> <style scoped>
@@ -78,6 +78,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { import {
AsteriskIcon, AsteriskIcon,
ChevronRightIcon, ChevronRightIcon,
@@ -90,7 +91,6 @@ import {
import type { Nag, NagContext, NagStatus } from '@modrinth/moderation' import type { Nag, NagContext, NagStatus } from '@modrinth/moderation'
import { nags } from '@modrinth/moderation' import { nags } from '@modrinth/moderation'
import { ButtonStyled, defineMessages, type MessageDescriptor, useVIntl } from '@modrinth/ui' import { ButtonStyled, defineMessages, type MessageDescriptor, useVIntl } from '@modrinth/ui'
import type { Project, User, Version } from '@modrinth/utils'
import type { Component } from 'vue' import type { Component } from 'vue'
import { computed } from 'vue' import { computed } from 'vue'
@@ -98,16 +98,10 @@ interface Tags {
rejectedStatuses: string[] rejectedStatuses: string[]
} }
interface Member {
accepted?: boolean
project_role?: string
user?: Partial<User>
}
interface Props { interface Props {
project: Project project: Labrinth.Projects.v2.Project
versions?: Version[] versions?: Labrinth.Versions.v2.Version[]
currentMember?: Member | null currentMember?: Labrinth.Projects.v3.TeamMember | null
collapsed?: boolean collapsed?: boolean
routeName?: string routeName?: string
tags: Tags tags: Tags
@@ -179,7 +173,7 @@ const emit = defineEmits<{
const nagContext = computed<NagContext>(() => ({ const nagContext = computed<NagContext>(() => ({
project: props.project, project: props.project,
versions: props.versions, versions: props.versions,
currentMember: props.currentMember as User, currentMember: props.currentMember?.user as Labrinth.Users.v2.User,
currentRoute: props.routeName, currentRoute: props.routeName,
tags: props.tags, tags: props.tags,
submitProject: submitForReview, submitProject: submitForReview,
@@ -0,0 +1,52 @@
import type { AbstractModrinthClient } from '@modrinth/api-client'
const STALE_TIME = 1000 * 60 * 5 // 5 minutes
export const projectQueryOptions = {
v2: (projectId: string, client: AbstractModrinthClient) => ({
queryKey: ['project', 'v2', projectId] as const,
queryFn: () => client.labrinth.projects_v2.get(projectId),
staleTime: STALE_TIME,
}),
v3: (projectId: string, client: AbstractModrinthClient) => ({
queryKey: ['project', 'v3', projectId] as const,
queryFn: () => client.labrinth.projects_v3.get(projectId),
staleTime: STALE_TIME,
}),
members: (projectId: string, client: AbstractModrinthClient) => ({
queryKey: ['project', projectId, 'members'] as const,
queryFn: () => client.labrinth.projects_v3.getMembers(projectId),
staleTime: STALE_TIME,
}),
dependencies: (projectId: string, client: AbstractModrinthClient) => ({
queryKey: ['project', projectId, 'dependencies'] as const,
queryFn: () => client.labrinth.projects_v2.getDependencies(projectId),
staleTime: STALE_TIME,
}),
versionsV2: (projectId: string, client: AbstractModrinthClient) => ({
queryKey: ['project', projectId, 'versions', 'v2'] as const,
queryFn: () =>
client.labrinth.versions_v3.getProjectVersions(projectId, { include_changelog: false }),
staleTime: STALE_TIME,
}),
versionsV3: (projectId: string, client: AbstractModrinthClient) => ({
queryKey: ['project', projectId, 'versions', 'v3'] as const,
queryFn: () =>
client.labrinth.versions_v3.getProjectVersions(projectId, {
include_changelog: false,
apiVersion: 3,
}),
staleTime: STALE_TIME,
}),
organization: (projectId: string, client: AbstractModrinthClient) => ({
queryKey: ['project', projectId, 'organization'] as const,
queryFn: () => client.labrinth.projects_v3.getOrganization(projectId),
staleTime: STALE_TIME,
}),
}
@@ -0,0 +1,14 @@
import type { QueryClient } from '@tanstack/vue-query'
import { useQueryClient } from '@tanstack/vue-query'
import { getCurrentInstance } from 'vue'
export function useAppQueryClient(): QueryClient {
// In components, use the standard composable
if (getCurrentInstance()) {
return useQueryClient()
}
// In middleware/server context, use the provided instance
const nuxtApp = useNuxtApp()
return nuxtApp.$queryClient as QueryClient
}
@@ -1,3 +1,5 @@
import { useGeneratedState } from '~/composables/generated'
import { useAppQueryClient } from '~/composables/query-client'
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js' import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
import { useServerModrinthClient } from '~/server/utils/api-client' import { useServerModrinthClient } from '~/server/utils/api-client'
@@ -10,15 +12,30 @@ export default defineNuxtRouteMiddleware(async (to) => {
return return
} }
const queryClient = useAppQueryClient()
const authToken = useCookie('auth-token') const authToken = useCookie('auth-token')
const client = useServerModrinthClient({ authToken: authToken.value || undefined }) const client = useServerModrinthClient({ authToken: authToken.value || undefined })
const tags = useGeneratedState() const tags = useGeneratedState()
const projectId = to.params.id as string
try { try {
const project = await client.labrinth.projects_v2.get(to.params.id as string) // 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,
})
if (!project) { // Let page handle 404
return if (!project) return
// Cache by slug if we looked up by ID (or vice versa)
if (projectId !== project.slug) {
queryClient.setQueryData(['project', 'v2', project.slug], project)
}
if (projectId !== project.id) {
queryClient.setQueryData(['project', 'v2', project.id], project)
} }
// Determine the correct URL type // Determine the correct URL type
+392 -280
View File
@@ -1,4 +1,5 @@
<template> <template>
<template v-if="project">
<Teleport v-if="flags.projectBackground" to="#fixed-background-teleport"> <Teleport v-if="flags.projectBackground" to="#fixed-background-teleport">
<ProjectBackgroundGradient :project="project" /> <ProjectBackgroundGradient :project="project" />
</Teleport> </Teleport>
@@ -34,29 +35,13 @@
:is-settings="route.name.startsWith('type-id-settings')" :is-settings="route.name.startsWith('type-id-settings')"
:set-processing="setProcessing" :set-processing="setProcessing"
:all-members="allMembers" :all-members="allMembers"
:update-members="updateMembers" :update-members="refreshMembers"
:auth="auth" :auth="auth"
:tags="tags" :tags="tags"
/> />
</div> </div>
<div class="normal-page__content"> <div class="normal-page__content">
<NuxtPage <NuxtPage />
v-model:project="project"
v-model:project-v3="projectV3"
v-model:members="members"
v-model:all-members="allMembers"
v-model:dependencies="dependencies"
v-model:organization="organization"
v-model:versions="versions"
:current-member="currentMember"
:patch-project="patchProject"
:patch-icon="patchIcon"
:reset-project="resetProject"
:reset-versions="resetVersions"
:reset-organization="resetOrganization"
:reset-members="resetMembers"
:route="route"
/>
</div> </div>
</div> </div>
@@ -245,7 +230,8 @@
: null : null
" "
:class="{ :class="{
'looks-disabled !text-brand-red': !possibleGameVersions.includes(gameVersion), 'looks-disabled !text-brand-red':
!possibleGameVersions.includes(gameVersion),
}" }"
@click=" @click="
() => { () => {
@@ -383,19 +369,19 @@
v-if="filteredRelease" v-if="filteredRelease"
:version="filteredRelease" :version="filteredRelease"
@on-download="onDownload" @on-download="onDownload"
@on-navigate="downloadModal.hide" @on-navigate="onVersionNavigate"
/> />
<VersionSummary <VersionSummary
v-if="filteredBeta" v-if="filteredBeta"
:version="filteredBeta" :version="filteredBeta"
@on-download="onDownload" @on-download="onDownload"
@on-navigate="downloadModal.hide" @on-navigate="onVersionNavigate"
/> />
<VersionSummary <VersionSummary
v-if="filteredAlpha" v-if="filteredAlpha"
:version="filteredAlpha" :version="filteredAlpha"
@on-download="onDownload" @on-download="onDownload"
@on-navigate="downloadModal.hide" @on-navigate="onVersionNavigate"
/> />
<p <p
v-if=" v-if="
@@ -469,6 +455,8 @@
v-tooltip=" v-tooltip="
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : '' auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
" "
@mouseenter="loadVersions"
@focus="loadVersions"
@click="(event) => downloadModal.show(event)" @click="(event) => downloadModal.show(event)"
> >
<DownloadIcon aria-hidden="true" /> <DownloadIcon aria-hidden="true" />
@@ -488,6 +476,8 @@
<button <button
:aria-label="formatMessage(commonMessages.downloadButton)" :aria-label="formatMessage(commonMessages.downloadButton)"
class="flex sm:hidden" class="flex sm:hidden"
@mouseenter="loadVersions"
@focus="loadVersions"
@click="(event) => downloadModal.show(event)" @click="(event) => downloadModal.show(event)"
> >
<DownloadIcon aria-hidden="true" /> <DownloadIcon aria-hidden="true" />
@@ -719,7 +709,8 @@
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProject) }} <ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProject) }}
</template> </template>
<template #report> <template #report>
<ReportIcon aria-hidden="true" /> {{ formatMessage(commonMessages.reportButton) }} <ReportIcon aria-hidden="true" />
{{ formatMessage(commonMessages.reportButton) }}
</template> </template>
<template #copy-id> <template #copy-id>
<ClipboardCopyIcon aria-hidden="true" /> <ClipboardCopyIcon aria-hidden="true" />
@@ -751,9 +742,9 @@
<Admonition <Admonition
v-if=" v-if="
currentMember && currentMember &&
projectV3.side_types_migration_review_status === 'pending' && projectV3?.side_types_migration_review_status === 'pending' &&
projectV3.environment?.length === 1 && projectV3?.environment?.length === 1 &&
projectV3.environment[0] !== 'unknown' projectV3?.environment[0] !== 'unknown'
" "
type="warning" type="warning"
:header=" :header="
@@ -907,22 +898,7 @@
<div class="normal-page__content"> <div class="normal-page__content">
<div class="overflow-x-auto"><NavTabs :links="navLinks" class="mb-4" /></div> <div class="overflow-x-auto"><NavTabs :links="navLinks" class="mb-4" /></div>
<NuxtPage <NuxtPage @on-download="triggerDownloadAnimation" @delete-version="deleteVersion" />
v-model:project="project"
v-model:versions="versions"
v-model:members="members"
v-model:all-members="allMembers"
v-model:dependencies="dependencies"
v-model:organization="organization"
:current-member="currentMember"
:reset-project="resetProject"
:reset-versions="resetVersions"
:reset-organization="resetOrganization"
:reset-members="resetMembers"
:route="route"
@on-download="triggerDownloadAnimation"
@delete-version="deleteVersion"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -944,6 +920,7 @@
<ProjectEnvironmentModal ref="projectEnvironmentModal" /> <ProjectEnvironmentModal ref="projectEnvironmentModal" />
</template> </template>
</template> </template>
</template>
<script setup> <script setup>
import { import {
@@ -979,6 +956,7 @@ import {
Checkbox, Checkbox,
commonMessages, commonMessages,
defineMessages, defineMessages,
injectModrinthClient,
injectNotificationManager, injectNotificationManager,
IntlFormatted, IntlFormatted,
NewModal, NewModal,
@@ -1000,10 +978,11 @@ import {
} from '@modrinth/ui' } from '@modrinth/ui'
import VersionSummary from '@modrinth/ui/src/components/version/VersionSummary.vue' import VersionSummary from '@modrinth/ui/src/components/version/VersionSummary.vue'
import { formatCategory, formatPrice, formatProjectType, renderString } from '@modrinth/utils' import { formatCategory, formatPrice, formatProjectType, renderString } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core' import { useLocalStorage } from '@vueuse/core'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue' import { Tooltip } from 'floating-vue'
import { useTemplateRef } from 'vue' import { nextTick, useTemplateRef, watch } from 'vue'
import { navigateTo } from '#app' import { navigateTo } from '#app'
import Accordion from '~/components/ui/Accordion.vue' import Accordion from '~/components/ui/Accordion.vue'
@@ -1056,6 +1035,7 @@ const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
const baseId = useId() const baseId = useId()
const currentGameVersion = computed(() => { const currentGameVersion = computed(() => {
if (!project.value) return null
return ( return (
userSelectedGameVersion.value || userSelectedGameVersion.value ||
(project.value.game_versions.length === 1 && project.value.game_versions[0]) (project.value.game_versions.length === 1 && project.value.game_versions[0])
@@ -1075,6 +1055,7 @@ const possiblePlatforms = computed(() => {
}) })
const currentPlatform = computed(() => { const currentPlatform = computed(() => {
if (!project.value) return null
return ( return (
userSelectedPlatform.value || (project.value.loaders.length === 1 && project.value.loaders[0]) userSelectedPlatform.value || (project.value.loaders.length === 1 && project.value.loaders[0])
) )
@@ -1459,114 +1440,182 @@ if (
) )
) { ) {
throw createError({ throw createError({
fatal: true, fatal: false,
statusCode: 404, statusCode: 404,
message: formatMessage(messages.pageNotFound), message: formatMessage(messages.pageNotFound),
}) })
} }
const projectId = ref(route.params.id) // Route param for initial lookup (middleware caches by both slug and ID)
const routeProjectId = computed(() => route.params.id)
let project, // Use DI client for TanStack Query
projectV3, const client = injectModrinthClient()
resetProjectV2, const queryClient = useQueryClient()
resetProjectV3,
allMembers, // V2 Project - hits middleware cache (uses route param for lookup)
resetMembers, const {
dependencies, data: projectRaw,
versions, error: projectV2Error,
versionsV3, refetch: resetProjectV2,
resetVersionsV2, } = useQuery({
organization, queryKey: computed(() => ['project', 'v2', routeProjectId.value]),
resetOrganization, queryFn: () => client.labrinth.projects_v2.get(routeProjectId.value),
staleTime: 1000 * 60 * 5,
})
// Handle project not found - use showError since watch runs outside Nuxt context
watch(
projectV2Error, projectV2Error,
projectV3Error, (error) => {
membersError, if (error) {
dependenciesError, // error.statusCode from ModrinthApiError, error.status as fallback
versionsError, const status = error.statusCode ?? error.status ?? 500
versionsV3Error, showError({
resetVersionsV3
try {
;[
{ data: project, error: projectV2Error, refresh: resetProjectV2 },
{ data: projectV3, error: projectV3Error, refresh: resetProjectV3 },
{ data: allMembers, error: membersError, refresh: resetMembers },
{ data: dependencies, error: dependenciesError },
{ data: versions, error: versionsError, refresh: resetVersionsV2 },
{ data: versionsV3, error: versionsV3Error, refresh: resetVersionsV3 },
{ data: organization, refresh: resetOrganization },
] = await Promise.all([
useAsyncData(`project/${projectId.value}`, () => useBaseFetch(`project/${projectId.value}`), {
transform: (project) => {
if (project) {
project.actualProjectType = JSON.parse(JSON.stringify(project.project_type))
project.project_type = data.$getProjectTypeForUrl(
project.project_type,
project.loaders,
tags.value,
)
projectId.value = project.id
}
return project
},
}),
useAsyncData(`projectV3/${projectId.value}`, () =>
useBaseFetch(`project/${projectId.value}`, {
apiVersion: 3,
}),
),
useAsyncData(
`project/${projectId.value}/members`,
() => useBaseFetch(`project/${projectId.value}/members`, { apiVersion: 3 }),
{
transform: (members) => {
members.forEach((it, index) => {
members[index].avatar_url = it.user.avatar_url
members[index].name = it.user.username
})
return members
},
},
),
useAsyncData(`project/${projectId.value}/dependencies`, () =>
useBaseFetch(`project/${projectId.value}/dependencies`, {}),
),
useAsyncData(`project/${projectId.value}/version`, () =>
useBaseFetch(`project/${projectId.value}/version`, {
query: {
include_changelog: false,
},
}),
),
useAsyncData(`project/${projectId.value}/version/v3`, () =>
useBaseFetch(`project/${projectId.value}/version`, {
apiVersion: 3,
query: {
include_changelog: false,
},
}),
),
useAsyncData(`project/${projectId.value}/organization`, () =>
useBaseFetch(`project/${projectId.value}/organization`, { apiVersion: 3 }),
),
])
versions = shallowRef(toRaw(versions))
versionsV3 = shallowRef(toRaw(versionsV3))
versions.value = (versions.value ?? []).map((v) => ({
...v,
environment: versionsV3.value?.find((v3) => v3.id === v.id)?.environment,
}))
} catch (err) {
throw createError({
fatal: true, fatal: true,
statusCode: err.statusCode ?? 500, statusCode: status,
message: formatMessage(messages.errorLoadingProject, { message:
message: err.message ? `: ${err.message}` : '', status === 404
? formatMessage(messages.projectNotFound)
: formatMessage(messages.errorLoadingProject, {
message: error.message ? `: ${error.message}` : '',
}), }),
}) })
} }
},
{ immediate: true },
)
// Transform project via computed
const project = computed(() => {
if (!projectRaw.value) return null
return {
...projectRaw.value,
actualProjectType: projectRaw.value.project_type,
project_type: data.$getProjectTypeForUrl(
projectRaw.value.project_type,
projectRaw.value.loaders,
tags.value,
),
}
})
// Use actual project ID for dependent queries (ensures cache consistency)
const projectId = computed(() => projectRaw.value?.id)
// V3 Project
const {
data: projectV3,
error: _projectV3Error,
refetch: resetProjectV3,
} = useQuery({
queryKey: computed(() => ['project', 'v3', projectId.value]),
queryFn: () => client.labrinth.projects_v3.get(projectId.value),
staleTime: 1000 * 60 * 5,
enabled: computed(() => !!projectId.value),
})
// Members
const {
data: allMembersRaw,
error: _membersError,
refetch: _resetMembers,
} = useQuery({
queryKey: computed(() => ['project', projectId.value, 'members']),
queryFn: () => client.labrinth.projects_v3.getMembers(projectId.value),
staleTime: 1000 * 60 * 5,
enabled: computed(() => !!projectId.value),
})
// Transform members via computed
const allMembers = computed(() => {
if (!allMembersRaw.value) return []
return allMembersRaw.value.map((it) => ({
...it,
avatar_url: it.user.avatar_url,
name: it.user.username,
}))
})
// Dependencies - lazy loaded client-side only
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 * 5,
enabled: false, // Never auto-fetch, always triggered manually
})
const dependencies = computed(() => dependenciesRaw.value ?? null)
// V3 Versions - lazy loaded client-side only
const {
data: versionsV3,
error: _versionsV3Error,
isFetching: versionsV3Loading,
refetch: resetVersionsV3,
} = useQuery({
queryKey: computed(() => ['project', projectId.value, 'versions', 'v3']),
queryFn: () =>
client.labrinth.versions_v3.getProjectVersions(projectId.value, {
include_changelog: false,
apiVersion: 3,
}),
staleTime: 1000 * 60 * 5,
enabled: false, // Never auto-fetch, always triggered manually
})
// Organization
// Only fetch organization if project belongs to one
const { data: organization, refetch: _resetOrganization } = useQuery({
queryKey: computed(() => ['project', projectId.value, 'organization']),
queryFn: () => client.labrinth.projects_v3.getOrganization(projectId.value),
staleTime: 1000 * 60 * 5,
enabled: computed(() => !!projectId.value && !!projectRaw.value?.organization),
})
// Transform versionsV3 to be same shape as versionsV2 for compatibility in project pages
const versionsRaw = computed(() => {
return (versionsV3.value ?? []).map((v) => {
const isModpack = v.project_types?.includes('modpack')
return {
...v,
loaders: isModpack && v.mrpack_loaders ? v.mrpack_loaders : v.loaders,
}
})
})
// Apply version computations (slug generation, author lookup, etc.)
const versions = computed(() => {
if (!versionsRaw.value.length || !allMembers.value.length) return versionsRaw.value
return data.$computeVersions(versionsRaw.value, allMembers.value)
})
// Versions loading state
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()
}
// Load dependencies on demand (client-side only)
async function loadDependencies() {
// Skip if already loaded or loading
if (dependenciesRaw.value || dependenciesLoading.value) return
await refetchDependencies()
}
// Check if project has versions using the ID array from the V2 project
// This allows showing/hiding UI elements without loading full version data
const hasVersions = computed(() => (project.value?.versions?.length ?? 0) > 0)
async function updateProjectRoute() { async function updateProjectRoute() {
if ( if (
@@ -1595,42 +1644,127 @@ async function resetProject() {
} }
async function resetVersions() { async function resetVersions() {
await resetVersionsV2()
await resetVersionsV3() await resetVersionsV3()
versions.value = (versions.value ?? []).map((v) => ({
...v,
environment: versionsV3.value?.find((v3) => v3.id === v.id)?.environment,
}))
} }
function handleError(err, project = false) { // Mutation for patching project data
if (err.value && err.value.statusCode) { const patchProjectMutation = useMutation({
throw createError({ mutationFn: async ({ projectId, data }) => {
fatal: true, await useBaseFetch(`project/${projectId}`, {
statusCode: err.value.statusCode, method: 'PATCH',
message: body: data,
err.value.statusCode === 404 && project
? formatMessage(messages.projectNotFound)
: err.value.message,
}) })
} return data
} },
handleError(projectV2Error, true) onMutate: async ({ projectId, data }) => {
handleError(projectV3Error) // Cancel outgoing refetches
handleError(membersError) await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
handleError(dependenciesError)
handleError(versionsError)
handleError(versionsV3Error)
if (!project.value) { // Snapshot previous value
throw createError({ const previousProject = queryClient.getQueryData(['project', 'v2', projectId])
fatal: true,
statusCode: 404, // Optimistic update
message: formatMessage(messages.projectNotFound), queryClient.setQueryData(['project', 'v2', projectId], (old) => {
if (!old) return old
return { ...old, ...data }
}) })
return { previousProject }
},
onError: (err, { projectId }, context) => {
// Rollback on error
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', projectId], context.previousProject)
} }
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
},
onSettled: async (_data, _error, { projectId }) => {
// Always refetch to ensure consistency
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId] })
},
})
// Mutation for changing project status (setProcessing)
const patchStatusMutation = useMutation({
mutationFn: async ({ projectId, status }) => {
await useBaseFetch(`project/${projectId}`, {
method: 'PATCH',
body: { status },
})
},
onMutate: async ({ projectId, status }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
const previousProject = queryClient.getQueryData(['project', 'v2', projectId])
// Optimistic update
queryClient.setQueryData(['project', 'v2', projectId], (old) => {
if (!old) return old
return { ...old, status }
})
return { previousProject }
},
onError: (err, { projectId }, context) => {
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', projectId], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
},
onSettled: async (_data, _error, { projectId }) => {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] })
},
})
// Mutation for patching project icon
const patchIconMutation = useMutation({
mutationFn: async ({ projectId, icon }) => {
await useBaseFetch(
`project/${projectId}/icon?ext=${icon.type.split('/')[icon.type.split('/').length - 1]}`,
{
method: 'PATCH',
body: icon,
},
)
},
onSuccess: () => {
addNotification({
title: formatMessage(messages.projectIconUpdated),
text: formatMessage(messages.projectIconUpdatedMessage),
type: 'success',
})
},
onError: (err) => {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
},
onSettled: async (_data, _error, { projectId }) => {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId] })
},
})
// Members should be an array of all members, without the accepted ones, and with the user with the Owner role at the start // Members should be an array of all members, without the accepted ones, and with the user with the Owner role at the start
// The rest of the members should be sorted by role, then by name // The rest of the members should be sorted by role, then by name
@@ -1668,9 +1802,14 @@ const currentMember = computed(() => {
val = organization.value.members.find((x) => x.user.id === auth.value.user.id) val = organization.value.members.find((x) => x.user.id === auth.value.user.id)
} }
if (!val && auth.value.user && tags.value.staffRoles.includes(auth.value.user.role)) { if (
!val &&
auth.value.user &&
project.value &&
tags.value.staffRoles.includes(auth.value.user.role)
) {
val = { val = {
team_id: project.team_id, team_id: project.value.team_id,
user: auth.value.user, user: auth.value.user,
role: auth.value.role, role: auth.value.role,
permissions: auth.value.user.role === 'admin' ? 1023 : 12, permissions: auth.value.user.role === 'admin' ? 1023 : 12,
@@ -1689,30 +1828,33 @@ const hasEditDetailsPermission = computed(() => {
return (currentMember.value?.permissions & EDIT_DETAILS) === EDIT_DETAILS return (currentMember.value?.permissions & EDIT_DETAILS) === EDIT_DETAILS
}) })
versions.value = data.$computeVersions(versions.value, allMembers.value) const projectTypeDisplay = computed(() => {
if (!project.value) return ''
const projectTypeDisplay = computed(() => return formatProjectType(
formatProjectType(
data.$getProjectTypeForDisplay(project.value.project_type, project.value.loaders), data.$getProjectTypeForDisplay(project.value.project_type, project.value.loaders),
),
) )
})
const following = computed(() => { const following = computed(() => {
if (!user.value?.follows) { if (!user.value?.follows || !project.value) {
return false return false
} }
return !!user.value.follows.find((x) => x.id === project.value.id) return !!user.value.follows.find((x) => x.id === project.value.id)
}) })
const title = computed(() => `${project.value.title} - Minecraft ${projectTypeDisplay.value}`) const title = computed(() =>
const description = computed( project.value ? `${project.value.title} - Minecraft ${projectTypeDisplay.value}` : '',
() => )
`${project.value.description} - Download the Minecraft ${projectTypeDisplay.value} ${ const description = computed(() =>
project.value
? `${project.value.description} - Download the Minecraft ${projectTypeDisplay.value} ${
project.value.title project.value.title
} by ${members.value.find((x) => x.is_owner)?.user?.username || 'a creator'} on Modrinth`, } by ${members.value.find((x) => x.is_owner)?.user?.username || 'a creator'} on Modrinth`
: '',
) )
const canCreateServerFrom = computed(() => { const canCreateServerFrom = computed(() => {
if (!project.value) return false
return project.value.project_type === 'modpack' && project.value.server_side !== 'unsupported' return project.value.project_type === 'modpack' && project.value.server_side !== 'unsupported'
}) })
@@ -1721,10 +1863,10 @@ if (!route.name.startsWith('type-id-settings')) {
title: () => title.value, title: () => title.value,
description: () => description.value, description: () => description.value,
ogTitle: () => title.value, ogTitle: () => title.value,
ogDescription: () => project.value.description, ogDescription: () => project.value?.description ?? '',
ogImage: () => project.value.icon_url ?? 'https://cdn.modrinth.com/placeholder.png', ogImage: () => project.value?.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
robots: () => robots: () =>
project.value.status === 'approved' || project.value.status === 'archived' project.value?.status === 'approved' || project.value?.status === 'archived'
? 'all' ? 'all'
: 'noindex', : 'noindex',
}) })
@@ -1735,17 +1877,18 @@ const onUserCollectProject = useClientTry(userCollectProject)
const { version, loader } = route.query const { version, loader } = route.query
if ( if (
project.value &&
project.value.game_versions.length > 0 && project.value.game_versions.length > 0 &&
project.value.game_versions.every((v) => !isReleaseGameVersion(v)) project.value.game_versions.every((v) => !isReleaseGameVersion(v))
) { ) {
showAllVersions.value = true showAllVersions.value = true
} }
if (version !== undefined && project.value.game_versions.includes(version)) { if (project.value && version !== undefined && project.value.game_versions.includes(version)) {
userSelectedGameVersion.value = version userSelectedGameVersion.value = version
} }
if (loader !== undefined && project.value.loaders.includes(loader)) { if (project.value && loader !== undefined && project.value.loaders.includes(loader)) {
userSelectedPlatform.value = loader userSelectedPlatform.value = loader
} }
@@ -1760,51 +1903,21 @@ watch(downloadModal, (modal) => {
async function setProcessing() { async function setProcessing() {
startLoading() startLoading()
patchStatusMutation.mutate(
try { { projectId: project.value.id, status: 'processing' },
await useBaseFetch(`project/${project.value.id}`, { { onSettled: () => stopLoading() },
method: 'PATCH', )
body: {
status: 'processing',
},
})
project.value.status = 'processing'
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
} }
async function patchProject(resData, quiet = false) { async function patchProject(resData, quiet = false) {
let result = false
startLoading() startLoading()
try { return new Promise((resolve) => {
await useBaseFetch(`project/${project.value.id}`, { patchProjectMutation.mutate(
method: 'PATCH', { projectId: project.value.id, data: resData },
body: resData, {
}) onSuccess: async () => {
for (const key in resData) {
project.value[key] = resData[key]
}
await updateProjectRoute() await updateProjectRoute()
if ('license_id' in resData) {
project.value.license.id = resData.license_id
}
if ('license_url' in resData) {
project.value.license.url = resData.license_url
}
result = true
if (!quiet) { if (!quiet) {
addNotification({ addNotification({
title: formatMessage(messages.projectUpdated), title: formatMessage(messages.projectUpdated),
@@ -1813,70 +1926,37 @@ async function patchProject(resData, quiet = false) {
}) })
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
} }
} catch (err) { resolve(true)
addNotification({ },
title: formatMessage(commonMessages.errorNotificationTitle), onError: () => resolve(false),
text: err.data ? err.data.description : err, onSettled: () => stopLoading(),
type: 'error', },
)
}) })
window.scrollTo({ top: 0, behavior: 'smooth' })
}
stopLoading()
return result
} }
async function patchIcon(icon) { async function patchIcon(icon) {
let result = false
startLoading() startLoading()
try { return new Promise((resolve) => {
await useBaseFetch( patchIconMutation.mutate(
`project/${project.value.id}/icon?ext=${ { projectId: project.value.id, icon },
icon.type.split('/')[icon.type.split('/').length - 1]
}`,
{ {
method: 'PATCH', onSuccess: () => resolve(true),
body: icon, onError: () => resolve(false),
onSettled: () => stopLoading(),
}, },
) )
await resetProject()
result = true
addNotification({
title: formatMessage(messages.projectIconUpdated),
text: formatMessage(messages.projectIconUpdatedMessage),
type: 'success',
}) })
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
} }
stopLoading() async function refreshMembers() {
return result // Simply invalidate and refetch - the computed allMembers will auto-update
await queryClient.invalidateQueries({ queryKey: ['project', projectId.value, 'members'] })
} }
async function updateMembers() { async function refreshOrganization() {
allMembers.value = await useAsyncData( await queryClient.invalidateQueries({ queryKey: ['project', projectId.value, 'organization'] })
`project/${projectId.value}/members`,
() => useBaseFetch(`project/${projectId.value}/members`),
{
transform: (members) => {
members.forEach((it, index) => {
members[index].avatar_url = it.user.avatar_url
members[index].name = it.user.username
})
return members
},
},
)
} }
async function copyId() { async function copyId() {
@@ -1890,7 +1970,7 @@ async function copyPermalink() {
const collapsedChecklist = ref(false) const collapsedChecklist = ref(false)
const showModerationChecklist = useLocalStorage( const showModerationChecklist = useLocalStorage(
`show-moderation-checklist-${project.value.id}`, `show-moderation-checklist-${project.value?.id ?? 'unknown'}`,
false, false,
) )
const collapsedModerationChecklist = useLocalStorage('collapsed-moderation-checklist', false) const collapsedModerationChecklist = useLocalStorage('collapsed-moderation-checklist', false)
@@ -1918,6 +1998,13 @@ function onDownload(event) {
}, 400) }, 400)
} }
function onVersionNavigate(url) {
closeDownloadModal()
nextTick(() => {
navigateTo(url)
})
}
async function deleteVersion(id) { async function deleteVersion(id) {
if (!id) return if (!id) return
@@ -1927,7 +2014,8 @@ async function deleteVersion(id) {
method: 'DELETE', method: 'DELETE',
}) })
versions.value = versions.value.filter((x) => x.id !== id) // Refetch versions to reflect deletion (versions is a computed ref)
await resetVersions()
stopLoading() stopLoading()
} }
@@ -1948,13 +2036,15 @@ const navLinks = computed(() => {
{ {
label: formatMessage(messages.changelogTab), label: formatMessage(messages.changelogTab),
href: `${projectUrl}/changelog`, href: `${projectUrl}/changelog`,
shown: versions.value.length > 0, shown: hasVersions.value,
onHover: loadVersions,
}, },
{ {
label: formatMessage(messages.versionsTab), label: formatMessage(messages.versionsTab),
href: `${projectUrl}/versions`, href: `${projectUrl}/versions`,
shown: versions.value.length > 0 || !!currentMember.value, shown: hasVersions.value || !!currentMember.value,
subpages: [`${projectUrl}/version/`], subpages: [`${projectUrl}/version/`],
onHover: loadVersions,
}, },
{ {
label: formatMessage(messages.moderationTab), label: formatMessage(messages.moderationTab),
@@ -1965,11 +2055,33 @@ const navLinks = computed(() => {
}) })
provideProjectPageContext({ provideProjectPageContext({
// Data refs
projectV2: project, projectV2: project,
projectV3, projectV3,
currentMember,
allMembers,
organization,
// Lazy version loading
versions,
versionsLoading,
// Lazy dependencies loading
dependencies,
dependenciesLoading: computed(() => dependenciesLoading.value),
// Refresh functions (invalidate + refetch)
refreshProject: resetProject, refreshProject: resetProject,
refreshVersions: resetVersions, refreshVersions: resetVersions,
currentMember, refreshMembers,
refreshOrganization,
// Lazy loading
loadVersions,
loadDependencies,
// Mutation functions
patchProject,
patchIcon,
setProcessing,
}) })
</script> </script>
@@ -1,8 +1,18 @@
<template> <template>
<div class="content"> <div class="content">
<!-- Loading state for initial version load -->
<div
v-if="versionsLoading && !versions?.length"
class="flex items-center justify-center gap-2 py-8"
>
<SpinnerIcon class="animate-spin" />
<span>Loading changelog...</span>
</div>
<template v-else>
<div class="mb-3 flex"> <div class="mb-3 flex">
<VersionFilterControl <VersionFilterControl
:versions="props.versions" :versions="versions ?? []"
:game-versions="tags.gameVersions" :game-versions="tags.gameVersions"
@update:query="updateQuery" @update:query="updateQuery"
/> />
@@ -25,8 +35,8 @@
<div class="version-header-text"> <div class="version-header-text">
<h2 class="name"> <h2 class="name">
<nuxt-link <nuxt-link
:to="`/${props.project.project_type}/${ :to="`/${projectV2.project_type}/${
props.project.slug ? props.project.slug : props.project.id projectV2.slug ? projectV2.slug : projectV2.id
}/version/${encodeURI(version.displayUrlEnding)}`" }/version/${encodeURI(version.displayUrlEnding)}`"
> >
{{ version.name }} {{ version.name }}
@@ -71,38 +81,28 @@
:link-function="(page) => `?page=${page}`" :link-function="(page) => `?page=${page}`"
@switch-page="switchPage" @switch-page="switchPage"
/> />
</template>
</div> </div>
</template> </template>
<script setup> <script setup>
import { DownloadIcon, SpinnerIcon } from '@modrinth/assets' import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
import { injectModrinthClient, Pagination } from '@modrinth/ui' import { injectModrinthClient, injectProjectPageContext, Pagination } from '@modrinth/ui'
import VersionFilterControl from '@modrinth/ui/src/components/version/VersionFilterControl.vue' import VersionFilterControl from '@modrinth/ui/src/components/version/VersionFilterControl.vue'
import { renderHighlightedString } from '@modrinth/utils' import { renderHighlightedString } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query' import { useQuery } from '@tanstack/vue-query'
import { onMounted } from 'vue'
const props = defineProps({ const { projectV2, versions, versionsLoading, loadVersions } = injectProjectPageContext()
project: {
type: Object, // Load versions on mount (client-side)
default() { onMounted(() => {
return {} loadVersions()
},
},
versions: {
type: Array,
default() {
return []
},
},
members: {
type: Array,
default() {
return []
},
},
}) })
const title = `${props.project.title} - Changelog` const title = computed(() => `${projectV2.value.title} - Changelog`)
const description = `View the changelog of ${props.project.title}'s ${props.versions.length} versions.` const description = computed(
() => `View the changelog of ${projectV2.value.title}'s ${versions.value?.length ?? 0} versions.`,
)
useSeoMeta({ useSeoMeta({
title, title,
@@ -117,11 +117,13 @@ const tags = useGeneratedState()
const currentPage = ref(Number(route.query.page ?? 1)) const currentPage = ref(Number(route.query.page ?? 1))
const filteredVersions = computed(() => { const filteredVersions = computed(() => {
if (!versions.value) return []
const selectedGameVersions = getArrayOrString(route.query.g) ?? [] const selectedGameVersions = getArrayOrString(route.query.g) ?? []
const selectedLoaders = getArrayOrString(route.query.l) ?? [] const selectedLoaders = getArrayOrString(route.query.l) ?? []
const selectedVersionTypes = getArrayOrString(route.query.c) ?? [] const selectedVersionTypes = getArrayOrString(route.query.c) ?? []
return props.versions.filter( return versions.value.filter(
(projectVersion) => (projectVersion) =>
(selectedGameVersions.length === 0 || (selectedGameVersions.length === 0 ||
selectedGameVersions.some((gameVersion) => selectedGameVersions.some((gameVersion) =>
+169 -162
View File
@@ -32,7 +32,7 @@
:src=" :src="
previewImage previewImage
? previewImage ? previewImage
: project.gallery[editIndex] && project.gallery[editIndex].url : project.gallery?.[editIndex]?.url
? project.gallery[editIndex].url ? project.gallery[editIndex].url
: 'https://cdn.modrinth.com/placeholder-banner.svg' : 'https://cdn.modrinth.com/placeholder-banner.svg'
" "
@@ -95,7 +95,7 @@
Unfeature image Unfeature image
</button> </button>
<div class="button-group"> <div class="button-group">
<button class="iconified-button" @click="$refs.modal_edit_item.hide()"> <button class="iconified-button" @click="modalEditItem?.hide()">
<XIcon aria-hidden="true" /> <XIcon aria-hidden="true" />
Cancel Cancel
</button> </button>
@@ -165,8 +165,8 @@
class="open circle-button" class="open circle-button"
target="_blank" target="_blank"
:href=" :href="
expandedGalleryItem.raw_url expandedGalleryItem?.raw_url
? expandedGalleryItem.raw_url ? expandedGalleryItem?.raw_url
: 'https://cdn.modrinth.com/placeholder-banner.svg' : 'https://cdn.modrinth.com/placeholder-banner.svg'
" "
> >
@@ -177,14 +177,14 @@
<ContractIcon v-else aria-hidden="true" /> <ContractIcon v-else aria-hidden="true" />
</button> </button>
<button <button
v-if="project.gallery.length > 1" v-if="(project?.gallery?.length ?? 0) > 1"
class="previous circle-button" class="previous circle-button"
@click="previousImage()" @click="previousImage()"
> >
<LeftArrowIcon aria-hidden="true" /> <LeftArrowIcon aria-hidden="true" />
</button> </button>
<button <button
v-if="project.gallery.length > 1" v-if="(project?.gallery?.length ?? 0) > 1"
class="next circle-button" class="next circle-button"
@click="nextImage()" @click="nextImage()"
> >
@@ -206,7 +206,7 @@
<button <button
aria-label="Project Settings" aria-label="Project Settings"
class="!shadow-none" class="!shadow-none"
@click="() => $router.push('settings/gallery')" @click="() => router.push('settings/gallery')"
> >
<SettingsIcon /> <SettingsIcon />
Edit gallery Edit gallery
@@ -224,7 +224,7 @@
</div> </div>
</template> </template>
</Admonition> </Admonition>
<div v-if="currentMember && project.gallery.length" class="card header-buttons"> <div v-if="currentMember && project?.gallery?.length" class="card header-buttons">
<FileInput <FileInput
:max-size="5242880" :max-size="5242880"
:accept="acceptFileTypes" :accept="acceptFileTypes"
@@ -245,9 +245,9 @@
@change="handleFiles" @change="handleFiles"
/> />
</div> </div>
<div v-if="project.gallery.length" class="items"> <div v-if="project?.gallery?.length" class="items">
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item"> <div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
<a class="gallery-thumbnail" @click="expandImage(item, index)"> <a class="gallery-thumbnail" @click="expandImage(item as GalleryItem, index)">
<img <img
:src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'" :src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
:alt="item.title ? item.title : 'gallery-image'" :alt="item.title ? item.title : 'gallery-image'"
@@ -275,11 +275,11 @@
() => { () => {
resetEdit() resetEdit()
editIndex = index editIndex = index
editTitle = item.title editTitle = item.title ?? ''
editDescription = item.description editDescription = item.description ?? ''
editFeatured = item.featured editFeatured = item.featured
editOrder = item.ordering editOrder = item.ordering
$refs.modal_edit_item.show() modalEditItem?.show()
} }
" "
> >
@@ -291,7 +291,7 @@
@click=" @click="
() => { () => {
deleteIndex = index deleteIndex = index
$refs.modal_confirm.show() modalConfirm?.show()
} }
" "
> >
@@ -314,7 +314,7 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { import {
CalendarIcon, CalendarIcon,
ContractIcon, ContractIcon,
@@ -341,34 +341,29 @@ import {
DropArea, DropArea,
FileInput, FileInput,
injectNotificationManager, injectNotificationManager,
injectProjectPageContext,
NewModal as Modal, NewModal as Modal,
} from '@modrinth/ui' } from '@modrinth/ui'
import { useLocalStorage } from '@vueuse/core' import { useEventListener, useLocalStorage } from '@vueuse/core'
import { isPermission } from '~/utils/permissions.ts' import { isPermission } from '~/utils/permissions.ts'
const props = defineProps({ // Router
project: { const router = useRouter()
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
resetProject: {
type: Function,
required: true,
default: () => {},
},
})
const title = `${props.project.title} - Gallery` // Single DI injection
const description = `View ${props.project.gallery.length} images of ${props.project.title} on Modrinth.` const { addNotification } = injectNotificationManager()
const { projectV2: project, currentMember, refreshProject } = injectProjectPageContext()
// Template refs
const modalEditItem = useTemplateRef('modal_edit_item')
const modalConfirm = useTemplateRef('modal_confirm')
// SEO
const title = computed(() => `${project.value.title} - Gallery`)
const description = computed(
() => `View ${project.value.gallery?.length ?? 0} images of ${project.value.title} on Modrinth.`,
)
useSeoMeta({ useSeoMeta({
title, title,
@@ -377,207 +372,219 @@ useSeoMeta({
ogDescription: description, ogDescription: description,
}) })
// Local storage state
const hideGalleryAdmonition = useLocalStorage( const hideGalleryAdmonition = useLocalStorage(
'hideGalleryHasMovedAdmonition', 'hideGalleryHasMovedAdmonition',
!props.project.gallery.length, !project.value.gallery?.length,
) )
</script>
<script> // Gallery item type matching actual v2 API response (LegacyGalleryItem in labrinth)
export default defineNuxtComponent({ // raw_url is optional in TS types but present in API response
setup() { interface GalleryItem {
const { addNotification } = injectNotificationManager() url: string
raw_url?: string
return { featured: boolean
addNotification, title?: string
description?: string
created: string
ordering: number
} }
},
data() {
return {
expandedGalleryItem: null,
expandedGalleryIndex: 0,
zoomedIn: false,
deleteIndex: -1, // Expanded image modal state
const expandedGalleryItem = ref<GalleryItem | null>(null)
const expandedGalleryIndex = ref(0)
const zoomedIn = ref(false)
editIndex: -1, // Delete state
editTitle: '', const deleteIndex = ref(-1)
editDescription: '',
editFeatured: false, // Edit state
editOrder: null, const editIndex = ref(-1)
editFile: null, const editTitle = ref('')
previewImage: null, const editDescription = ref('')
shouldPreventActions: false, const editFeatured = ref(false)
} const editOrder = ref<number | null>(null)
}, const editFile = ref<File | null>(null)
computed: { const previewImage = ref<string | null>(null)
acceptFileTypes() {
return 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp' // UI state
}, const shouldPreventActions = ref(false)
},
mounted() { // Constant for accepted file types
this._keyListener = function (e) { const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
if (this.expandedGalleryItem) {
// Keyboard navigation for expanded image modal
useEventListener(document, 'keydown', (e) => {
if (expandedGalleryItem.value) {
e.preventDefault() e.preventDefault()
if (e.key === 'Escape') { if (e.key === 'Escape') {
this.expandedGalleryItem = null expandedGalleryItem.value = null
} else if (e.key === 'ArrowLeft') { } else if (e.key === 'ArrowLeft') {
e.stopPropagation() e.stopPropagation()
this.previousImage() previousImage()
} else if (e.key === 'ArrowRight') { } else if (e.key === 'ArrowRight') {
e.stopPropagation() e.stopPropagation()
this.nextImage() nextImage()
} }
} }
})
// Navigation functions
function nextImage() {
expandedGalleryIndex.value++
if (expandedGalleryIndex.value >= project.value.gallery!.length) {
expandedGalleryIndex.value = 0
}
expandedGalleryItem.value = project.value.gallery![expandedGalleryIndex.value] as GalleryItem
} }
document.addEventListener('keydown', this._keyListener.bind(this)) function previousImage() {
}, expandedGalleryIndex.value--
methods: { if (expandedGalleryIndex.value < 0) {
nextImage() { expandedGalleryIndex.value = project.value.gallery!.length - 1
this.expandedGalleryIndex++
if (this.expandedGalleryIndex >= this.project.gallery.length) {
this.expandedGalleryIndex = 0
} }
this.expandedGalleryItem = this.project.gallery[this.expandedGalleryIndex] expandedGalleryItem.value = project.value.gallery![expandedGalleryIndex.value] as GalleryItem
},
previousImage() {
this.expandedGalleryIndex--
if (this.expandedGalleryIndex < 0) {
this.expandedGalleryIndex = this.project.gallery.length - 1
} }
this.expandedGalleryItem = this.project.gallery[this.expandedGalleryIndex]
},
expandImage(item, index) {
this.expandedGalleryItem = item
this.expandedGalleryIndex = index
this.zoomedIn = false
},
resetEdit() {
this.editIndex = -1
this.editTitle = ''
this.editDescription = ''
this.editFeatured = false
this.editOrder = null
this.editFile = null
this.previewImage = null
},
handleFiles(files) {
this.resetEdit()
this.editFile = files[0]
this.showPreviewImage() function expandImage(item: GalleryItem, index: number) {
this.$refs.modal_edit_item.show() expandedGalleryItem.value = item
}, expandedGalleryIndex.value = index
showPreviewImage() { zoomedIn.value = false
}
// Edit state management
function resetEdit() {
editIndex.value = -1
editTitle.value = ''
editDescription.value = ''
editFeatured.value = false
editOrder.value = null
editFile.value = null
previewImage.value = null
}
function handleFiles(files: File[]) {
resetEdit()
editFile.value = files[0]
showPreviewImage()
modalEditItem.value?.show()
}
function showPreviewImage() {
const reader = new FileReader() const reader = new FileReader()
if (this.editFile instanceof Blob) { if (editFile.value instanceof Blob) {
reader.readAsDataURL(this.editFile) reader.readAsDataURL(editFile.value)
reader.onload = (event) => { reader.onload = (event) => {
this.previewImage = event.target.result previewImage.value = event.target?.result as string | null
} }
} }
}, }
async createGalleryItem() {
this.shouldPreventActions = true // CRUD operations
async function createGalleryItem() {
shouldPreventActions.value = true
startLoading() startLoading()
try { try {
let url = `project/${this.project.id}/gallery?ext=${ let url = `project/${project.value.id}/gallery?ext=${
this.editFile editFile.value
? this.editFile.type.split('/')[this.editFile.type.split('/').length - 1] ? editFile.value.type.split('/')[editFile.value.type.split('/').length - 1]
: null : null
}&featured=${this.editFeatured}` }&featured=${editFeatured.value}`
if (this.editTitle) { if (editTitle.value) {
url += `&title=${encodeURIComponent(this.editTitle)}` url += `&title=${encodeURIComponent(editTitle.value)}`
} }
if (this.editDescription) { if (editDescription.value) {
url += `&description=${encodeURIComponent(this.editDescription)}` url += `&description=${encodeURIComponent(editDescription.value)}`
} }
if (this.editOrder) { if (editOrder.value) {
url += `&ordering=${this.editOrder}` url += `&ordering=${editOrder.value}`
} }
await useBaseFetch(url, { await useBaseFetch(url, {
method: 'POST', method: 'POST',
body: this.editFile, body: editFile.value,
}) })
await this.resetProject() await refreshProject()
this.$refs.modal_edit_item.hide() modalEditItem.value?.hide()
} catch (err) { } catch (err: unknown) {
this.addNotification({ const error = err as { data?: { description?: string } }
addNotification({
title: 'An error occurred', title: 'An error occurred',
text: err.data ? err.data.description : err, text: error.data?.description ?? String(err),
type: 'error', type: 'error',
}) })
} }
stopLoading() stopLoading()
this.shouldPreventActions = false shouldPreventActions.value = false
}, }
async editGalleryItem() {
this.shouldPreventActions = true async function editGalleryItem() {
shouldPreventActions.value = true
startLoading() startLoading()
try { try {
let url = `project/${this.project.id}/gallery?url=${encodeURIComponent( let url = `project/${project.value.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.editIndex].url, project.value!.gallery![editIndex.value].url,
)}&featured=${this.editFeatured}` )}&featured=${editFeatured.value}`
if (this.editTitle) { if (editTitle.value) {
url += `&title=${encodeURIComponent(this.editTitle)}` url += `&title=${encodeURIComponent(editTitle.value)}`
} }
if (this.editDescription) { if (editDescription.value) {
url += `&description=${encodeURIComponent(this.editDescription)}` url += `&description=${encodeURIComponent(editDescription.value)}`
} }
if (this.editOrder) { if (editOrder.value) {
url += `&ordering=${this.editOrder}` url += `&ordering=${editOrder.value}`
} }
await useBaseFetch(url, { await useBaseFetch(url, {
method: 'PATCH', method: 'PATCH',
}) })
await this.resetProject() await refreshProject()
this.$refs.modal_edit_item.hide() modalEditItem.value?.hide()
} catch (err) { } catch (err: unknown) {
this.addNotification({ const error = err as { data?: { description?: string } }
addNotification({
title: 'An error occurred', title: 'An error occurred',
text: err.data ? err.data.description : err, text: error.data?.description ?? String(err),
type: 'error', type: 'error',
}) })
} }
stopLoading() stopLoading()
this.shouldPreventActions = false shouldPreventActions.value = false
}, }
async deleteGalleryImage() {
async function deleteGalleryImage() {
startLoading() startLoading()
try { try {
await useBaseFetch( await useBaseFetch(
`project/${this.project.id}/gallery?url=${encodeURIComponent( `project/${project.value.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.deleteIndex].url, project.value!.gallery![deleteIndex.value].url!,
)}`, )}`,
{ {
method: 'DELETE', method: 'DELETE',
}, },
) )
await this.resetProject() await refreshProject()
} catch (err) { } catch (err: unknown) {
this.addNotification({ const error = err as { data?: { description?: string } }
addNotification({
title: 'An error occurred', title: 'An error occurred',
text: err.data ? err.data.description : err, text: error.data?.description ?? String(err),
type: 'error', type: 'error',
}) })
} }
stopLoading() stopLoading()
}, }
},
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
+4 -29
View File
@@ -1,7 +1,7 @@
<template> <template>
<section class="normal-page__content"> <section class="normal-page__content">
<div v-if="project.body" class="card"> <div v-if="projectV2.body" class="card">
<ProjectPageDescription :description="project.body" /> <ProjectPageDescription :description="projectV2.body" />
</div> </div>
<p v-else class="ml-2"> <p v-else class="ml-2">
No description provided. Visit No description provided. Visit
@@ -14,34 +14,9 @@
</template> </template>
<script setup> <script setup>
import { ProjectPageDescription } from '@modrinth/ui' import { injectProjectPageContext, ProjectPageDescription } from '@modrinth/ui'
const route = useRoute() const route = useRoute()
defineProps({ const { projectV2 } = injectProjectPageContext()
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
members: {
type: Array,
default() {
return []
},
},
organization: {
type: Object,
default() {
return {}
},
},
})
</script> </script>
@@ -100,7 +100,7 @@
</template> </template>
<script setup> <script setup>
import { CheckIcon, IssuesIcon, XIcon } from '@modrinth/assets' import { CheckIcon, IssuesIcon, XIcon } from '@modrinth/assets'
import { Badge, injectNotificationManager } from '@modrinth/ui' import { Badge, injectNotificationManager, injectProjectPageContext } from '@modrinth/ui'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue' import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import { import {
@@ -113,45 +113,28 @@ import {
} from '~/helpers/projects.js' } from '~/helpers/projects.js'
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const props = defineProps({ const { projectV2: project, currentMember, refreshProject } = injectProjectPageContext()
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
resetProject: {
type: Function,
required: true,
default: () => {},
},
})
const auth = await useAuth() const auth = await useAuth()
const { data: thread } = await useAsyncData(`thread/${props.project.thread_id}`, () => const { data: thread } = await useAsyncData(
useBaseFetch(`thread/${props.project.thread_id}`), () => `thread/${project.value.thread_id}`,
() => useBaseFetch(`thread/${project.value.thread_id}`),
) )
async function setStatus(status) { async function setStatus(status) {
startLoading() startLoading()
try { try {
const data = {} const data = {}
data.status = status data.status = status
await useBaseFetch(`project/${props.project.id}`, { await useBaseFetch(`project/${project.value.id}`, {
method: 'PATCH', method: 'PATCH',
body: data, body: data,
}) })
const project = props.project project.value.status = status
project.status = status await refreshProject()
await props.resetProject()
thread.value = await useBaseFetch(`thread/${thread.value.id}`) thread.value = await useBaseFetch(`thread/${thread.value.id}`)
} catch (err) { } catch (err) {
addNotification({ addNotification({
@@ -14,10 +14,10 @@ import {
import { import {
commonMessages, commonMessages,
commonProjectSettingsMessages, commonProjectSettingsMessages,
injectNotificationManager, injectProjectPageContext,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { isStaff, type Project, type ProjectV3Partial } from '@modrinth/utils' import { isStaff } from '@modrinth/utils'
import { useLocalStorage, useScroll } from '@vueuse/core' import { useLocalStorage, useScroll } from '@vueuse/core'
import { computed } from 'vue' import { computed } from 'vue'
@@ -26,32 +26,22 @@ import NavStack from '~/components/ui/NavStack.vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const props = defineProps<{ const {
currentMember: any projectV2: project,
patchProject: any projectV3,
patchIcon: any versions,
resetProject: any currentMember,
resetVersions: any setProcessing,
resetOrganization: any } = injectProjectPageContext()
resetMembers: any
}>()
const flags = useFeatureFlags() const flags = useFeatureFlags()
const project = defineModel<Project>('project', { required: true })
const projectV3 = defineModel<ProjectV3Partial>('projectV3', { required: true })
const versions = defineModel<any>('versions')
const members = defineModel<any>('members')
const allMembers = defineModel<any>('allMembers')
const dependencies = defineModel<any>('dependencies')
const organization = defineModel<any>('organization')
const navItems = computed(() => { const navItems = computed(() => {
const base = `${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}` const base = `${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
const showEnvironment = const showEnvironment =
projectV3.value.project_types.some((type) => ['mod', 'modpack'].includes(type)) && projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) &&
isStaff(props.currentMember?.user) isStaff(currentMember.value?.user)
const items = [ const items = [
{ {
@@ -118,35 +108,10 @@ const navItems = computed(() => {
return items.filter(Boolean) as any[] return items.filter(Boolean) as any[]
}) })
const { addNotification } = injectNotificationManager()
const tags = useGeneratedState() const tags = useGeneratedState()
const route = useRoute() const route = useRoute()
const collapsedChecklist = useLocalStorage(`project-checklist-collapsed-${project.value.id}`, false) const collapsedChecklist = useLocalStorage(`project-checklist-collapsed-${project.value.id}`, false)
async function setProcessing() {
startLoading()
try {
await useBaseFetch(`project/${project.value.id}`, {
method: 'PATCH',
body: {
status: 'processing',
},
})
project.value.status = 'processing'
} catch (err: any) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
// To persist scroll position through settings pages // To persist scroll position through settings pages
// This scroll code is jank asf, if anyone has a better way please do suggest it // This scroll code is jank asf, if anyone has a better way please do suggest it
const scroll = useScroll(window) const scroll = useScroll(window)
@@ -167,7 +132,7 @@ watch(route, () => {
:versions="versions" :versions="versions"
:current-member="currentMember" :current-member="currentMember"
:collapsed="collapsedChecklist" :collapsed="collapsedChecklist"
:route-name="route.name as string" :route-name="route.name"
:tags="tags" :tags="tags"
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)" @toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing" @set-processing="setProcessing"
@@ -177,22 +142,7 @@ watch(route, () => {
<NavStack :items="navItems" /> <NavStack :items="navItems" />
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<NuxtPage <NuxtPage />
v-model:project="project"
v-model:project-v3="projectV3"
v-model:versions="versions"
v-model:members="members"
v-model:all-members="allMembers"
v-model:dependencies="dependencies"
v-model:organization="organization"
:current-member="currentMember"
:patch-project="patchProject"
:patch-icon="patchIcon"
:reset-project="resetProject"
:reset-versions="resetVersions"
:reset-organization="resetOrganization"
:reset-members="resetMembers"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -11,21 +11,16 @@
</p> </p>
</div> </div>
<ChartDisplay :projects="[props.project]" /> <ChartDisplay :projects="[project]" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { injectProjectPageContext } from '@modrinth/ui'
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue' import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
const props = defineProps({ const { projectV2: project } = injectProjectPageContext()
project: {
type: Object,
default() {
return {}
},
},
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -17,7 +17,7 @@
v-model="description" v-model="description"
:disabled=" :disabled="
!currentMember || !currentMember ||
(currentMember.permissions & TeamMemberPermission.EDIT_BODY) !== (currentMember?.permissions! & TeamMemberPermission.EDIT_BODY) !==
TeamMemberPermission.EDIT_BODY TeamMemberPermission.EDIT_BODY
" "
:on-image-upload="onUploadHandler" :on-image-upload="onUploadHandler"
@@ -44,20 +44,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets' import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets'
import { countText, MIN_DESCRIPTION_CHARS } from '@modrinth/moderation' import { countText, MIN_DESCRIPTION_CHARS } from '@modrinth/moderation'
import { MarkdownEditor } from '@modrinth/ui' import { injectProjectPageContext, MarkdownEditor } from '@modrinth/ui'
import { type Project, type TeamMember, TeamMemberPermission } from '@modrinth/utils' import { TeamMemberPermission } from '@modrinth/utils'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useImageUpload } from '~/composables/image-upload.ts' import { useImageUpload } from '~/composables/image-upload.ts'
const props = defineProps<{ const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
project: Project
allMembers: TeamMember[]
currentMember: TeamMember | undefined
patchProject: (payload: object, quiet?: boolean) => object
}>()
const description = ref(props.project.body) const description = ref(project.value.body)
const descriptionWarning = computed(() => { const descriptionWarning = computed(() => {
const text = description.value?.trim() || '' const text = description.value?.trim() || ''
@@ -75,7 +70,7 @@ const patchRequestPayload = computed(() => {
body?: string body?: string
} = {} } = {}
if (description.value !== props.project.body) { if (description.value !== project.value.body) {
payload.body = description.value payload.body = description.value
} }
@@ -87,13 +82,13 @@ const hasChanges = computed(() => {
}) })
function saveChanges() { function saveChanges() {
props.patchProject(patchRequestPayload.value) patchProject(patchRequestPayload.value)
} }
async function onUploadHandler(file: File) { async function onUploadHandler(file: File) {
const response = await useImageUpload(file, { const response = await useImageUpload(file, {
context: 'project', context: 'project',
projectID: props.project.id, projectID: project.value.id,
}) })
return response.url return response.url
@@ -300,33 +300,16 @@ import {
DropArea, DropArea,
FileInput, FileInput,
injectNotificationManager, injectNotificationManager,
injectProjectPageContext,
NewModal as Modal, NewModal as Modal,
} from '@modrinth/ui' } from '@modrinth/ui'
import { isPermission } from '~/utils/permissions.ts' import { isPermission } from '~/utils/permissions.ts'
const props = defineProps({ const { projectV2: project, currentMember } = injectProjectPageContext()
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
resetProject: {
type: Function,
required: true,
default: () => {},
},
})
const title = `${props.project.title} - Gallery` const title = `${project.value.title} - Gallery`
const description = `View ${props.project.gallery.length} images of ${props.project.title} on Modrinth.` const description = `View ${project.value.gallery?.length ?? 0} images of ${project.value.title} on Modrinth.`
useSeoMeta({ useSeoMeta({
title, title,
@@ -340,9 +323,12 @@ useSeoMeta({
export default defineNuxtComponent({ export default defineNuxtComponent({
setup() { setup() {
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const { projectV2: project, refreshProject } = injectProjectPageContext()
return { return {
addNotification, addNotification,
project,
refreshProject,
} }
}, },
data() { data() {
@@ -456,7 +442,7 @@ export default defineNuxtComponent({
method: 'POST', method: 'POST',
body: this.editFile, body: this.editFile,
}) })
await this.resetProject() await this.refreshProject()
this.$refs.modal_edit_item.hide() this.$refs.modal_edit_item.hide()
} catch (err) { } catch (err) {
@@ -492,7 +478,7 @@ export default defineNuxtComponent({
method: 'PATCH', method: 'PATCH',
}) })
await this.resetProject() await this.refreshProject()
this.$refs.modal_edit_item.hide() this.$refs.modal_edit_item.hide()
} catch (err) { } catch (err) {
this.addNotification({ this.addNotification({
@@ -518,7 +504,7 @@ export default defineNuxtComponent({
}, },
) )
await this.resetProject() await this.refreshProject()
} catch (err) { } catch (err) {
this.addNotification({ this.addNotification({
title: 'An error occurred', title: 'An error occurred',
@@ -256,7 +256,12 @@ import {
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { MIN_SUMMARY_CHARS } from '@modrinth/moderation' import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
import { Avatar, ConfirmModal, injectNotificationManager } from '@modrinth/ui' import {
Avatar,
ConfirmModal,
injectNotificationManager,
injectProjectPageContext,
} from '@modrinth/ui'
import { formatProjectStatus, formatProjectType } from '@modrinth/utils' import { formatProjectStatus, formatProjectType } from '@modrinth/utils'
import { Multiselect } from 'vue-multiselect' import { Multiselect } from 'vue-multiselect'
@@ -264,67 +269,41 @@ import FileInput from '~/components/ui/FileInput.vue'
import { useFeatureFlags } from '~/composables/featureFlags.ts' import { useFeatureFlags } from '~/composables/featureFlags.ts'
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const {
projectV2: project,
currentMember,
patchProject,
patchIcon,
refreshProject,
} = injectProjectPageContext()
const flags = useFeatureFlags() const flags = useFeatureFlags()
const props = defineProps({
project: {
type: Object,
required: true,
default: () => ({}),
},
projectV3: {
type: Object,
required: true,
default: () => ({}),
},
currentMember: {
type: Object,
required: true,
default: () => ({}),
},
patchProject: {
type: Function,
required: true,
default: () => {},
},
patchIcon: {
type: Function,
required: true,
default: () => {},
},
resetProject: {
type: Function,
required: true,
default: () => {},
},
})
const tags = useGeneratedState() const tags = useGeneratedState()
const router = useNativeRouter() const router = useNativeRouter()
const name = ref(props.project.title) const name = ref(project.value.title)
const slug = ref(props.project.slug) const slug = ref(project.value.slug)
const summary = ref(props.project.description) const summary = ref(project.value.description)
const icon = ref(null) const icon = ref(null)
const previewImage = ref(null) const previewImage = ref(null)
const clientSide = ref(props.project.client_side) const clientSide = ref(project.value.client_side)
const serverSide = ref(props.project.server_side) const serverSide = ref(project.value.server_side)
const deletedIcon = ref(false) const deletedIcon = ref(false)
const visibility = ref( const visibility = ref(
tags.value.approvedStatuses.includes(props.project.status) tags.value.approvedStatuses.includes(project.value.status)
? props.project.status ? project.value.status
: props.project.requested_status, : project.value.requested_status,
) )
const hasPermission = computed(() => { const hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2 const EDIT_DETAILS = 1 << 2
return (props.currentMember?.permissions & EDIT_DETAILS) === EDIT_DETAILS return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
}) })
const hasDeletePermission = computed(() => { const hasDeletePermission = computed(() => {
const DELETE_PROJECT = 1 << 7 const DELETE_PROJECT = 1 << 7
return (props.currentMember?.permissions & DELETE_PROJECT) === DELETE_PROJECT return ((currentMember.value?.permissions ?? 0) & DELETE_PROJECT) === DELETE_PROJECT
}) })
const summaryWarning = computed(() => { const summaryWarning = computed(() => {
@@ -343,26 +322,26 @@ const sideTypes = ['required', 'optional', 'unsupported']
const patchData = computed(() => { const patchData = computed(() => {
const data = {} const data = {}
if (name.value !== props.project.title) { if (name.value !== project.value.title) {
data.title = name.value.trim() data.title = name.value.trim()
} }
if (slug.value !== props.project.slug) { if (slug.value !== project.value.slug) {
data.slug = slug.value.trim() data.slug = slug.value.trim()
} }
if (summary.value !== props.project.description) { if (summary.value !== project.value.description) {
data.description = summary.value.trim() data.description = summary.value.trim()
} }
if (clientSide.value !== props.project.client_side) { if (clientSide.value !== project.value.client_side) {
data.client_side = clientSide.value data.client_side = clientSide.value
} }
if (serverSide.value !== props.project.server_side) { if (serverSide.value !== project.value.server_side) {
data.server_side = serverSide.value data.server_side = serverSide.value
} }
if (tags.value.approvedStatuses.includes(props.project.status)) { if (tags.value.approvedStatuses.includes(project.value.status)) {
if (visibility.value !== props.project.status) { if (visibility.value !== project.value.status) {
data.status = visibility.value data.status = visibility.value
} }
} else if (visibility.value !== props.project.requested_status) { } else if (visibility.value !== project.value.requested_status) {
data.requested_status = visibility.value data.requested_status = visibility.value
} }
@@ -374,23 +353,23 @@ const hasChanges = computed(() => {
}) })
const hasModifiedVisibility = () => { const hasModifiedVisibility = () => {
const originalVisibility = tags.value.approvedStatuses.includes(props.project.status) const originalVisibility = tags.value.approvedStatuses.includes(project.value.status)
? props.project.status ? project.value.status
: props.project.requested_status : project.value.requested_status
return originalVisibility !== visibility.value return originalVisibility !== visibility.value
} }
const saveChanges = async () => { const saveChanges = async () => {
if (hasChanges.value) { if (hasChanges.value) {
await props.patchProject(patchData.value) await patchProject(patchData.value)
} }
if (deletedIcon.value) { if (deletedIcon.value) {
await deleteIcon() await deleteIcon()
deletedIcon.value = false deletedIcon.value = false
} else if (icon.value) { } else if (icon.value) {
await props.patchIcon(icon.value) await patchIcon(icon.value)
icon.value = null icon.value = null
} }
} }
@@ -401,12 +380,12 @@ const showPreviewImage = (files) => {
deletedIcon.value = false deletedIcon.value = false
reader.readAsDataURL(icon.value) reader.readAsDataURL(icon.value)
reader.onload = (event) => { reader.onload = (event) => {
previewImage.value = event.target.result previewImage.value = event.target?.result
} }
} }
const deleteProject = async () => { const deleteProject = async () => {
await useBaseFetch(`project/${props.project.id}`, { await useBaseFetch(`project/${project.value.id}`, {
method: 'DELETE', method: 'DELETE',
}) })
await initUserProjects() await initUserProjects()
@@ -425,10 +404,10 @@ const markIconForDeletion = () => {
} }
const deleteIcon = async () => { const deleteIcon = async () => {
await useBaseFetch(`project/${props.project.id}/icon`, { await useBaseFetch(`project/${project.value.id}/icon`, {
method: 'DELETE', method: 'DELETE',
}) })
await props.resetProject() await refreshProject()
addNotification({ addNotification({
title: 'Project icon removed', title: 'Project icon removed',
text: "Your project's icon has been removed.", text: "Your project's icon has been removed.",
@@ -155,24 +155,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { SaveIcon } from '@modrinth/assets' import { SaveIcon } from '@modrinth/assets'
import { Checkbox, DropdownSelect } from '@modrinth/ui' import { Checkbox, DropdownSelect, injectProjectPageContext } from '@modrinth/ui'
import { import {
type BuiltinLicense, type BuiltinLicense,
builtinLicenses, builtinLicenses,
formatProjectType, formatProjectType,
type Project,
type TeamMember,
TeamMemberPermission, TeamMemberPermission,
} from '@modrinth/utils' } from '@modrinth/utils'
import { computed, type Ref, ref } from 'vue' import { computed, type Ref, ref } from 'vue'
const props = defineProps<{ const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
project: Project
currentMember: TeamMember | undefined
patchProject: (payload: object, quiet?: boolean) => object
}>()
const licenseUrl = ref(props.project.license.url) const licenseUrl = ref(project.value.license.url)
const license: Ref<{ const license: Ref<{
friendly: string friendly: string
short: string short: string
@@ -183,10 +177,10 @@ const license: Ref<{
requiresOnlyOrLater: false, requiresOnlyOrLater: false,
}) })
const allowOrLater = ref(props.project.license.id.includes('-or-later')) const allowOrLater = ref(project.value.license.id.includes('-or-later'))
const nonSpdxLicense = ref(props.project.license.id.includes('LicenseRef-')) const nonSpdxLicense = ref(project.value.license.id.includes('LicenseRef-'))
const oldLicenseId = props.project.license.id const oldLicenseId = project.value.license.id
const trimmedLicenseId = oldLicenseId const trimmedLicenseId = oldLicenseId
.replaceAll('-only', '') .replaceAll('-only', '')
.replaceAll('-or-later', '') .replaceAll('-or-later', '')
@@ -208,7 +202,7 @@ if (oldLicenseId === 'LicenseRef-Unknown') {
} }
const hasPermission = computed(() => { const hasPermission = computed(() => {
return (props.currentMember?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS return (currentMember.value?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS
}) })
const licenseId = computed(() => { const licenseId = computed(() => {
@@ -240,11 +234,11 @@ const patchRequestPayload = computed(() => {
license_url?: string | null // null = remove url license_url?: string | null // null = remove url
} = {} } = {}
if (licenseId.value !== props.project.license.id) { if (licenseId.value !== project.value.license.id) {
payload.license_id = licenseId.value payload.license_id = licenseId.value
} }
if (licenseUrl.value !== props.project.license.url) { if (licenseUrl.value !== project.value.license.url) {
payload.license_url = licenseUrl.value ? licenseUrl.value : null payload.license_url = licenseUrl.value ? licenseUrl.value : null
} }
@@ -256,6 +250,6 @@ const hasChanges = computed(() => {
}) })
function saveChanges() { function saveChanges() {
props.patchProject(patchRequestPayload.value) patchProject(patchRequestPayload.value)
} }
</script> </script>
@@ -174,35 +174,16 @@
<script setup> <script setup>
import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets' import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets'
import { commonLinkDomains, isCommonUrl, isDiscordUrl, isLinkShortener } from '@modrinth/moderation' import { commonLinkDomains, isCommonUrl, isDiscordUrl, isLinkShortener } from '@modrinth/moderation'
import { DropdownSelect } from '@modrinth/ui' import { DropdownSelect, injectProjectPageContext } from '@modrinth/ui'
const tags = useGeneratedState() const tags = useGeneratedState()
const props = defineProps({ const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
patchProject: {
type: Function,
default() {
return () => {}
},
},
})
const issuesUrl = ref(props.project.issues_url) const issuesUrl = ref(project.value.issues_url)
const sourceUrl = ref(props.project.source_url) const sourceUrl = ref(project.value.source_url)
const wikiUrl = ref(props.project.wiki_url) const wikiUrl = ref(project.value.wiki_url)
const discordUrl = ref(props.project.discord_url) const discordUrl = ref(project.value.discord_url)
const isIssuesUrlCommon = computed(() => { const isIssuesUrlCommon = computed(() => {
if (!issuesUrl.value || issuesUrl.value.trim().length === 0) return true if (!issuesUrl.value || issuesUrl.value.trim().length === 0) return true
@@ -244,7 +225,7 @@ const isDiscordLinkShortener = computed(() => {
return isLinkShortener(discordUrl.value) return isLinkShortener(discordUrl.value)
}) })
const rawDonationLinks = JSON.parse(JSON.stringify(props.project.donation_urls)) const rawDonationLinks = JSON.parse(JSON.stringify(project.value.donation_urls))
rawDonationLinks.push({ rawDonationLinks.push({
id: null, id: null,
platform: null, platform: null,
@@ -254,32 +235,32 @@ const donationLinks = ref(rawDonationLinks)
const hasPermission = computed(() => { const hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2 const EDIT_DETAILS = 1 << 2
return (props.currentMember?.permissions & EDIT_DETAILS) === EDIT_DETAILS return (currentMember.value?.permissions & EDIT_DETAILS) === EDIT_DETAILS
}) })
const patchData = computed(() => { const patchData = computed(() => {
const data = {} const data = {}
if (checkDifference(issuesUrl.value, props.project.issues_url)) { if (checkDifference(issuesUrl.value, project.value.issues_url)) {
data.issues_url = issuesUrl.value === '' ? null : issuesUrl.value.trim() data.issues_url = issuesUrl.value === '' ? null : issuesUrl.value.trim()
} }
if (checkDifference(sourceUrl.value, props.project.source_url)) { if (checkDifference(sourceUrl.value, project.value.source_url)) {
data.source_url = sourceUrl.value === '' ? null : sourceUrl.value.trim() data.source_url = sourceUrl.value === '' ? null : sourceUrl.value.trim()
} }
if (checkDifference(wikiUrl.value, props.project.wiki_url)) { if (checkDifference(wikiUrl.value, project.value.wiki_url)) {
data.wiki_url = wikiUrl.value === '' ? null : wikiUrl.value.trim() data.wiki_url = wikiUrl.value === '' ? null : wikiUrl.value.trim()
} }
if (checkDifference(discordUrl.value, props.project.discord_url)) { if (checkDifference(discordUrl.value, project.value.discord_url)) {
data.discord_url = discordUrl.value === '' ? null : discordUrl.value.trim() data.discord_url = discordUrl.value === '' ? null : discordUrl.value.trim()
} }
const validDonationLinks = donationLinks.value.filter((link) => link.url && link.id) const validDonationLinks = donationLinks.value.filter((link) => link.url && link.id)
if ( if (
validDonationLinks !== props.project.donation_urls && validDonationLinks !== project.value.donation_urls &&
!( !(
props.project.donation_urls && project.value.donation_urls &&
props.project.donation_urls.length === 0 && project.value.donation_urls.length === 0 &&
validDonationLinks.length === 0 validDonationLinks.length === 0
) )
) { ) {
@@ -301,8 +282,8 @@ const hasChanges = computed(() => {
}) })
async function saveChanges() { async function saveChanges() {
if (patchData.value && (await props.patchProject(patchData.value))) { if (patchData.value && (await patchProject(patchData.value))) {
donationLinks.value = JSON.parse(JSON.stringify(props.project.donation_urls)) donationLinks.value = JSON.parse(JSON.stringify(project.value.donation_urls))
donationLinks.value.push({ donationLinks.value.push({
id: null, id: null,
platform: null, platform: null,
@@ -27,13 +27,13 @@
v-model="currentUsername" v-model="currentUsername"
type="text" type="text"
placeholder="Username" placeholder="Username"
:disabled="(props.currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES" :disabled="(currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES"
@keypress.enter="inviteTeamMember()" @keypress.enter="inviteTeamMember()"
/> />
<label for="username" class="hidden">Username</label> <label for="username" class="hidden">Username</label>
<button <button
class="iconified-button brand-button" class="iconified-button brand-button"
:disabled="(props.currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES" :disabled="(currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES"
@click="inviteTeamMember()" @click="inviteTeamMember()"
> >
<UserPlusIcon /> <UserPlusIcon />
@@ -47,11 +47,9 @@
</span> </span>
<button <button
class="iconified-button danger-button" class="iconified-button danger-button"
:disabled="props.currentMember?.is_owner" :disabled="currentMember?.is_owner"
:title=" :title="
props.currentMember?.is_owner currentMember?.is_owner ? 'You cannot leave the project if you are the owner!' : ''
? 'You cannot leave the project if you are the owner!'
: ''
" "
@click="leaveProject()" @click="leaveProject()"
> >
@@ -104,7 +102,7 @@
:id="`member-${allTeamMembers[index].user.username}-role`" :id="`member-${allTeamMembers[index].user.username}-role`"
v-model="allTeamMembers[index].role" v-model="allTeamMembers[index].role"
type="text" type="text"
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER" :disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
/> />
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
@@ -119,7 +117,7 @@
:id="`member-${allTeamMembers[index].user.username}-monetization-weight`" :id="`member-${allTeamMembers[index].user.username}-monetization-weight`"
v-model="allTeamMembers[index].payouts_split" v-model="allTeamMembers[index].payouts_split"
type="number" type="number"
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER" :disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
/> />
</div> </div>
<template v-if="!member.is_owner"> <template v-if="!member.is_owner">
@@ -130,8 +128,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & UPLOAD_VERSION) === UPLOAD_VERSION" :model-value="(member?.permissions & UPLOAD_VERSION) === UPLOAD_VERSION"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION (currentMember?.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION
" "
label="Upload version" label="Upload version"
@update:model-value="allTeamMembers[index].permissions ^= UPLOAD_VERSION" @update:model-value="allTeamMembers[index].permissions ^= UPLOAD_VERSION"
@@ -139,8 +137,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & DELETE_VERSION) === DELETE_VERSION" :model-value="(member?.permissions & DELETE_VERSION) === DELETE_VERSION"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & DELETE_VERSION) !== DELETE_VERSION (currentMember?.permissions & DELETE_VERSION) !== DELETE_VERSION
" "
label="Delete version" label="Delete version"
@update:model-value="allTeamMembers[index].permissions ^= DELETE_VERSION" @update:model-value="allTeamMembers[index].permissions ^= DELETE_VERSION"
@@ -148,8 +146,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & EDIT_DETAILS) === EDIT_DETAILS" :model-value="(member?.permissions & EDIT_DETAILS) === EDIT_DETAILS"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & EDIT_DETAILS) !== EDIT_DETAILS (currentMember?.permissions & EDIT_DETAILS) !== EDIT_DETAILS
" "
label="Edit details" label="Edit details"
@update:model-value="allTeamMembers[index].permissions ^= EDIT_DETAILS" @update:model-value="allTeamMembers[index].permissions ^= EDIT_DETAILS"
@@ -157,8 +155,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & EDIT_BODY) === EDIT_BODY" :model-value="(member?.permissions & EDIT_BODY) === EDIT_BODY"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & EDIT_BODY) !== EDIT_BODY (currentMember?.permissions & EDIT_BODY) !== EDIT_BODY
" "
label="Edit body" label="Edit body"
@update:model-value="allTeamMembers[index].permissions ^= EDIT_BODY" @update:model-value="allTeamMembers[index].permissions ^= EDIT_BODY"
@@ -166,8 +164,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & MANAGE_INVITES) === MANAGE_INVITES" :model-value="(member?.permissions & MANAGE_INVITES) === MANAGE_INVITES"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES (currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES
" "
label="Manage invites" label="Manage invites"
@update:model-value="allTeamMembers[index].permissions ^= MANAGE_INVITES" @update:model-value="allTeamMembers[index].permissions ^= MANAGE_INVITES"
@@ -175,23 +173,23 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & REMOVE_MEMBER) === REMOVE_MEMBER" :model-value="(member?.permissions & REMOVE_MEMBER) === REMOVE_MEMBER"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER (currentMember?.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER
" "
label="Remove member" label="Remove member"
@update:model-value="allTeamMembers[index].permissions ^= REMOVE_MEMBER" @update:model-value="allTeamMembers[index].permissions ^= REMOVE_MEMBER"
/> />
<Checkbox <Checkbox
:model-value="(member?.permissions & EDIT_MEMBER) === EDIT_MEMBER" :model-value="(member?.permissions & EDIT_MEMBER) === EDIT_MEMBER"
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER" :disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
label="Edit member" label="Edit member"
@update:model-value="allTeamMembers[index].permissions ^= EDIT_MEMBER" @update:model-value="allTeamMembers[index].permissions ^= EDIT_MEMBER"
/> />
<Checkbox <Checkbox
:model-value="(member?.permissions & DELETE_PROJECT) === DELETE_PROJECT" :model-value="(member?.permissions & DELETE_PROJECT) === DELETE_PROJECT"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & DELETE_PROJECT) !== DELETE_PROJECT (currentMember?.permissions & DELETE_PROJECT) !== DELETE_PROJECT
" "
label="Delete project" label="Delete project"
@update:model-value="allTeamMembers[index].permissions ^= DELETE_PROJECT" @update:model-value="allTeamMembers[index].permissions ^= DELETE_PROJECT"
@@ -199,8 +197,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & VIEW_ANALYTICS) === VIEW_ANALYTICS" :model-value="(member?.permissions & VIEW_ANALYTICS) === VIEW_ANALYTICS"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS (currentMember?.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS
" "
label="View analytics" label="View analytics"
@update:model-value="allTeamMembers[index].permissions ^= VIEW_ANALYTICS" @update:model-value="allTeamMembers[index].permissions ^= VIEW_ANALYTICS"
@@ -208,8 +206,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & VIEW_PAYOUTS) === VIEW_PAYOUTS" :model-value="(member?.permissions & VIEW_PAYOUTS) === VIEW_PAYOUTS"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS (currentMember?.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS
" "
label="View revenue" label="View revenue"
@update:model-value="allTeamMembers[index].permissions ^= VIEW_PAYOUTS" @update:model-value="allTeamMembers[index].permissions ^= VIEW_PAYOUTS"
@@ -219,7 +217,7 @@
<div class="input-group"> <div class="input-group">
<button <button
class="iconified-button brand-button" class="iconified-button brand-button"
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER" :disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
@click="updateTeamMember(index)" @click="updateTeamMember(index)"
> >
<SaveIcon /> <SaveIcon />
@@ -228,14 +226,14 @@
<button <button
v-if="!member.is_owner" v-if="!member.is_owner"
class="iconified-button danger-button" class="iconified-button danger-button"
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER" :disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
@click="removeTeamMember(index)" @click="removeTeamMember(index)"
> >
<UserXIcon /> <UserXIcon />
Remove member Remove member
</button> </button>
<button <button
v-if="!member.is_owner && props.currentMember?.is_owner && member.accepted" v-if="!member.is_owner && currentMember?.is_owner && member.accepted"
class="iconified-button" class="iconified-button"
@click="transferOwnership(index)" @click="transferOwnership(index)"
> >
@@ -249,26 +247,26 @@
<div class="label"> <div class="label">
<span class="label__title size-card-header">Organization</span> <span class="label__title size-card-header">Organization</span>
</div> </div>
<div v-if="props.organization"> <div v-if="organization">
<p> <p>
This project is managed by {{ props.organization.name }}. The defaults for member This project is managed by {{ organization.name }}. The defaults for member permissions
permissions are set in the are set in the
<nuxt-link :to="`/organization/${props.organization.slug}/settings/members`"> <nuxt-link :to="`/organization/${organization.slug}/settings/members`">
organization settings organization settings
</nuxt-link> </nuxt-link>
. You may override them below. . You may override them below.
</p> </p>
<nuxt-link <nuxt-link
:to="`/organization/${props.organization.slug}`" :to="`/organization/${organization.slug}`"
class="universal-card button-base recessed org" class="universal-card button-base recessed org"
> >
<Avatar :src="props.organization.icon_url" :alt="props.organization.name" size="md" /> <Avatar :src="organization.icon_url" :alt="organization.name" size="md" />
<div class="details"> <div class="details">
<div class="title"> <div class="title">
{{ props.organization.name }} {{ organization.name }}
</div> </div>
<div class="description"> <div class="description">
{{ props.organization.description }} {{ organization.description }}
</div> </div>
<span class="stat-bar"> <span class="stat-bar">
<div class="stats"> <div class="stats">
@@ -288,7 +286,7 @@
This project is not managed by an organization. If you are the member of any organizations, This project is not managed by an organization. If you are the member of any organizations,
you can transfer management to one of them. you can transfer management to one of them.
</p> </p>
<div v-if="!props.organization" class="input-group"> <div v-if="!organization" class="input-group">
<Multiselect <Multiselect
id="organization-picker" id="organization-picker"
v-model="selectedOrganization" v-model="selectedOrganization"
@@ -300,14 +298,14 @@
:show-labels="false" :show-labels="false"
:allow-empty="false" :allow-empty="false"
:options="organizations || []" :options="organizations || []"
:disabled="!props.currentMember?.is_owner || organizations?.length === 0" :disabled="!currentMember?.is_owner || organizations?.length === 0"
/> />
<button class="btn btn-primary" :disabled="!selectedOrganization" @click="onAddToOrg"> <button class="btn btn-primary" :disabled="!selectedOrganization" @click="onAddToOrg">
<CheckIcon /> <CheckIcon />
Transfer management Transfer management
</button> </button>
</div> </div>
<button v-if="props.organization" class="btn" @click="$refs.modal_remove.show()"> <button v-if="organization" class="btn" @click="$refs.modal_remove.show()">
<OrganizationIcon /> <OrganizationIcon />
Remove from organization Remove from organization
</button> </button>
@@ -358,7 +356,7 @@
v-model="allOrgMembers[index].override" v-model="allOrgMembers[index].override"
class="switch stylized-toggle" class="switch stylized-toggle"
type="checkbox" type="checkbox"
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER" :disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
/> />
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
@@ -373,7 +371,7 @@
v-model="allOrgMembers[index].role" v-model="allOrgMembers[index].role"
type="text" type="text"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
!allOrgMembers[index].override !allOrgMembers[index].override
" "
/> />
@@ -391,7 +389,7 @@
v-model="allOrgMembers[index].payouts_split" v-model="allOrgMembers[index].payouts_split"
type="number" type="number"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
!allOrgMembers[index].override !allOrgMembers[index].override
" "
/> />
@@ -404,8 +402,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & UPLOAD_VERSION) === UPLOAD_VERSION" :model-value="(member?.permissions & UPLOAD_VERSION) === UPLOAD_VERSION"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION || (currentMember?.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION ||
!allOrgMembers[index].override !allOrgMembers[index].override
" "
label="Upload version" label="Upload version"
@@ -414,8 +412,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & DELETE_VERSION) === DELETE_VERSION" :model-value="(member?.permissions & DELETE_VERSION) === DELETE_VERSION"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & DELETE_VERSION) !== DELETE_VERSION || (currentMember?.permissions & DELETE_VERSION) !== DELETE_VERSION ||
!allOrgMembers[index].override !allOrgMembers[index].override
" "
label="Delete version" label="Delete version"
@@ -424,8 +422,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & EDIT_DETAILS) === EDIT_DETAILS" :model-value="(member?.permissions & EDIT_DETAILS) === EDIT_DETAILS"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & EDIT_DETAILS) !== EDIT_DETAILS || (currentMember?.permissions & EDIT_DETAILS) !== EDIT_DETAILS ||
!allOrgMembers[index].override !allOrgMembers[index].override
" "
label="Edit details" label="Edit details"
@@ -434,8 +432,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & EDIT_BODY) === EDIT_BODY" :model-value="(member?.permissions & EDIT_BODY) === EDIT_BODY"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & EDIT_BODY) !== EDIT_BODY || (currentMember?.permissions & EDIT_BODY) !== EDIT_BODY ||
!allOrgMembers[index].override !allOrgMembers[index].override
" "
label="Edit body" label="Edit body"
@@ -444,8 +442,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & MANAGE_INVITES) === MANAGE_INVITES" :model-value="(member?.permissions & MANAGE_INVITES) === MANAGE_INVITES"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES || (currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES ||
!allOrgMembers[index].override !allOrgMembers[index].override
" "
label="Manage invites" label="Manage invites"
@@ -454,8 +452,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & REMOVE_MEMBER) === REMOVE_MEMBER" :model-value="(member?.permissions & REMOVE_MEMBER) === REMOVE_MEMBER"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER || (currentMember?.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER ||
!allOrgMembers[index].override !allOrgMembers[index].override
" "
label="Remove member" label="Remove member"
@@ -464,7 +462,7 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & EDIT_MEMBER) === EDIT_MEMBER" :model-value="(member?.permissions & EDIT_MEMBER) === EDIT_MEMBER"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
!allOrgMembers[index].override !allOrgMembers[index].override
" "
label="Edit member" label="Edit member"
@@ -473,8 +471,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & DELETE_PROJECT) === DELETE_PROJECT" :model-value="(member?.permissions & DELETE_PROJECT) === DELETE_PROJECT"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & DELETE_PROJECT) !== DELETE_PROJECT || (currentMember?.permissions & DELETE_PROJECT) !== DELETE_PROJECT ||
!allOrgMembers[index].override !allOrgMembers[index].override
" "
label="Delete project" label="Delete project"
@@ -483,8 +481,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & VIEW_ANALYTICS) === VIEW_ANALYTICS" :model-value="(member?.permissions & VIEW_ANALYTICS) === VIEW_ANALYTICS"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS || (currentMember?.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS ||
!allOrgMembers[index].override !allOrgMembers[index].override
" "
label="View analytics" label="View analytics"
@@ -493,8 +491,8 @@
<Checkbox <Checkbox
:model-value="(member?.permissions & VIEW_PAYOUTS) === VIEW_PAYOUTS" :model-value="(member?.permissions & VIEW_PAYOUTS) === VIEW_PAYOUTS"
:disabled=" :disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS || (currentMember?.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS ||
!allOrgMembers[index].override !allOrgMembers[index].override
" "
label="View revenue" label="View revenue"
@@ -505,7 +503,7 @@
<div class="input-group"> <div class="input-group">
<button <button
class="iconified-button brand-button" class="iconified-button brand-button"
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER" :disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
@click="updateOrgMember(index)" @click="updateOrgMember(index)"
> >
<SaveIcon /> <SaveIcon />
@@ -536,54 +534,22 @@ import {
Checkbox, Checkbox,
ConfirmModal, ConfirmModal,
injectNotificationManager, injectNotificationManager,
injectProjectPageContext,
} from '@modrinth/ui' } from '@modrinth/ui'
import { Multiselect } from 'vue-multiselect' import { Multiselect } from 'vue-multiselect'
import { removeSelfFromTeam } from '~/helpers/teams.js' import { removeSelfFromTeam } from '~/helpers/teams.js'
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const {
const props = defineProps({ projectV2: project,
project: { organization,
type: Object, allMembers,
default() { currentMember,
return {} refreshProject,
}, refreshOrganization,
}, refreshMembers,
organization: { } = injectProjectPageContext()
type: Object,
default() {
return {}
},
},
allMembers: {
type: Array,
default() {
return []
},
},
currentMember: {
type: Object,
default() {
return null
},
},
resetProject: {
type: Function,
required: true,
default: () => {},
},
resetOrganization: {
type: Function,
required: true,
default: () => {},
},
resetMembers: {
type: Function,
required: true,
default: () => {},
},
})
const cosmetics = useCosmetics() const cosmetics = useCosmetics()
const auth = await useAuth() const auth = await useAuth()
@@ -592,14 +558,14 @@ const allTeamMembers = ref([])
const allOrgMembers = ref([]) const allOrgMembers = ref([])
const acceptedOrgMembers = computed(() => { const acceptedOrgMembers = computed(() => {
return props.organization?.members?.filter((x) => x.accepted) || [] return organization.value?.members?.filter((x) => x.accepted) || []
}) })
function initMembers() { function initMembers() {
const orgMembers = props.organization?.members || [] const orgMembers = organization.value?.members || []
const selectedMembersForOrg = orgMembers.map((partialOrgMember) => { const selectedMembersForOrg = orgMembers.map((partialOrgMember) => {
const foundMember = props.allMembers.find((tM) => tM.user.id === partialOrgMember.user.id) const foundMember = allMembers.value.find((tM) => tM.user.id === partialOrgMember.user.id)
const returnVal = foundMember ?? partialOrgMember const returnVal = foundMember ?? partialOrgMember
// If replacing a partial with a full member, we need to mark as such. // If replacing a partial with a full member, we need to mark as such.
@@ -613,20 +579,12 @@ function initMembers() {
allOrgMembers.value = selectedMembersForOrg allOrgMembers.value = selectedMembersForOrg
allTeamMembers.value = props.allMembers.filter( allTeamMembers.value = allMembers.value.filter(
(x) => !selectedMembersForOrg.some((y) => y.user.id === x.user.id), (x) => !selectedMembersForOrg.some((y) => y.user.id === x.user.id),
) )
} }
watch( watch([allMembers, organization, project, currentMember], initMembers)
[
() => props.allMembers,
() => props.organization,
() => props.project,
() => props.currentMember,
],
initMembers,
)
initMembers() initMembers()
const currentUsername = ref('') const currentUsername = ref('')
@@ -656,7 +614,7 @@ const onAddToOrg = useClientTry(async () => {
await useBaseFetch(`organization/${selectedOrganization.value.id}/projects`, { await useBaseFetch(`organization/${selectedOrganization.value.id}/projects`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
project_id: props.project.id, project_id: project.value.id,
}), }),
apiVersion: 3, apiVersion: 3,
}) })
@@ -671,9 +629,9 @@ const onAddToOrg = useClientTry(async () => {
}) })
const onRemoveFromOrg = useClientTry(async () => { const onRemoveFromOrg = useClientTry(async () => {
if (!props.project.organization || !auth.value?.user?.id) return if (!project.value.organization || !auth.value?.user?.id) return
await useBaseFetch(`organization/${props.project.organization}/projects/${props.project.id}`, { await useBaseFetch(`organization/${project.value.organization}/projects/${project.value.id}`, {
method: 'DELETE', method: 'DELETE',
body: JSON.stringify({ body: JSON.stringify({
new_owner: auth.value.user.id, new_owner: auth.value.user.id,
@@ -691,7 +649,7 @@ const onRemoveFromOrg = useClientTry(async () => {
}) })
const leaveProject = async () => { const leaveProject = async () => {
await removeSelfFromTeam(props.project.team) await removeSelfFromTeam(project.value.team)
navigateTo('/dashboard/projects') navigateTo('/dashboard/projects')
} }
@@ -703,7 +661,7 @@ const inviteTeamMember = async () => {
const data = { const data = {
user_id: user.id.trim(), user_id: user.id.trim(),
} }
await useBaseFetch(`team/${props.project.team}/members`, { await useBaseFetch(`team/${project.value.team}/members`, {
method: 'POST', method: 'POST',
body: data, body: data,
}) })
@@ -725,7 +683,7 @@ const removeTeamMember = async (index) => {
try { try {
await useBaseFetch( await useBaseFetch(
`team/${props.project.team}/members/${allTeamMembers.value[index].user.id}`, `team/${project.value.team}/members/${allTeamMembers.value[index].user.id}`,
{ {
method: 'DELETE', method: 'DELETE',
}, },
@@ -758,7 +716,7 @@ const updateTeamMember = async (index) => {
} }
await useBaseFetch( await useBaseFetch(
`team/${props.project.team}/members/${allTeamMembers.value[index].user.id}`, `team/${project.value.team}/members/${allTeamMembers.value[index].user.id}`,
{ {
method: 'PATCH', method: 'PATCH',
body: data, body: data,
@@ -785,7 +743,7 @@ const transferOwnership = async (index) => {
startLoading() startLoading()
try { try {
await useBaseFetch(`team/${props.project.team}/owner`, { await useBaseFetch(`team/${project.value.team}/owner`, {
method: 'PATCH', method: 'PATCH',
body: { body: {
user_id: allTeamMembers.value[index].user.id, user_id: allTeamMembers.value[index].user.id,
@@ -808,7 +766,7 @@ async function updateOrgMember(index) {
try { try {
if (allOrgMembers.value[index].override && !allOrgMembers.value[index].oldOverride) { if (allOrgMembers.value[index].override && !allOrgMembers.value[index].oldOverride) {
await useBaseFetch(`team/${props.project.team}/members`, { await useBaseFetch(`team/${project.value.team}/members`, {
method: 'POST', method: 'POST',
body: { body: {
permissions: allOrgMembers.value[index].permissions, permissions: allOrgMembers.value[index].permissions,
@@ -819,14 +777,14 @@ async function updateOrgMember(index) {
}) })
} else if (!allOrgMembers.value[index].override && allOrgMembers.value[index].oldOverride) { } else if (!allOrgMembers.value[index].override && allOrgMembers.value[index].oldOverride) {
await useBaseFetch( await useBaseFetch(
`team/${props.project.team}/members/${allOrgMembers.value[index].user.id}`, `team/${project.value.team}/members/${allOrgMembers.value[index].user.id}`,
{ {
method: 'DELETE', method: 'DELETE',
}, },
) )
} else { } else {
await useBaseFetch( await useBaseFetch(
`team/${props.project.team}/members/${allOrgMembers.value[index].user.id}`, `team/${project.value.team}/members/${allOrgMembers.value[index].user.id}`,
{ {
method: 'PATCH', method: 'PATCH',
body: { body: {
@@ -850,7 +808,7 @@ async function updateOrgMember(index) {
} }
const updateMembers = async () => { const updateMembers = async () => {
await Promise.all([props.resetProject(), props.resetOrganization(), props.resetMembers()]) await Promise.all([refreshProject(), refreshOrganization(), refreshMembers()])
} }
</script> </script>
@@ -134,12 +134,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { SaveIcon, StarIcon, TriangleAlertIcon } from '@modrinth/assets' import { SaveIcon, StarIcon, TriangleAlertIcon } from '@modrinth/assets'
import { Checkbox } from '@modrinth/ui' import { Checkbox, injectProjectPageContext } from '@modrinth/ui'
import { import {
formatCategory, formatCategory,
formatCategoryHeader, formatCategoryHeader,
formatProjectType, formatProjectType,
type Project,
sortedCategories, sortedCategories,
} from '@modrinth/utils' } from '@modrinth/utils'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@@ -151,50 +150,31 @@ interface Category {
project_type: string project_type: string
} }
interface Props {
project: Project & {
actualProjectType: string
}
allMembers?: any[]
currentMember?: any
patchProject?: (data: any) => void
}
const tags = useGeneratedState() const tags = useGeneratedState()
const props = withDefaults(defineProps<Props>(), { const { projectV2: project, patchProject } = injectProjectPageContext()
allMembers: () => [],
currentMember: null,
patchProject: () => {
addNotification({
title: 'An error occurred',
text: 'Patch project function not found',
type: 'error',
})
},
})
const selectedTags = ref<Category[]>( const selectedTags = ref<Category[]>(
sortedCategories(tags.value).filter( sortedCategories(tags.value).filter(
(x: Category) => (x: Category) =>
x.project_type === props.project.actualProjectType && x.project_type === project.value.actualProjectType &&
(props.project.categories.includes(x.name) || (project.value.categories.includes(x.name) ||
props.project.additional_categories.includes(x.name)), project.value.additional_categories.includes(x.name)),
), ),
) )
const featuredTags = ref<Category[]>( const featuredTags = ref<Category[]>(
sortedCategories(tags.value).filter( sortedCategories(tags.value).filter(
(x: Category) => (x: Category) =>
x.project_type === props.project.actualProjectType && x.project_type === project.value.actualProjectType &&
props.project.categories.includes(x.name), project.value.categories.includes(x.name),
), ),
) )
const categoryLists = computed(() => { const categoryLists = computed(() => {
const lists: Record<string, Category[]> = {} const lists: Record<string, Category[]> = {}
sortedCategories(tags.value).forEach((x: Category) => { sortedCategories(tags.value).forEach((x: Category) => {
if (x.project_type === props.project.actualProjectType) { if (x.project_type === project.value.actualProjectType) {
const header = x.header const header = x.header
if (!lists[header]) { if (!lists[header]) {
lists[header] = [] lists[header] = []
@@ -214,7 +194,7 @@ const tooManyTagsWarning = computed(() => {
}) })
const multipleResolutionTagsWarning = computed(() => { const multipleResolutionTagsWarning = computed(() => {
if (props.project.project_type !== 'resourcepack') return null if (project.value.actualProjectType !== 'resourcepack') return null
const resolutionTags = selectedTags.value.filter((tag) => const resolutionTags = selectedTags.value.filter((tag) =>
['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+'].includes(tag.name), ['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+'].includes(tag.name),
@@ -235,7 +215,7 @@ const multipleResolutionTagsWarning = computed(() => {
const allTagsSelectedWarning = computed(() => { const allTagsSelectedWarning = computed(() => {
const categoriesForProjectType = sortedCategories(tags.value).filter( const categoriesForProjectType = sortedCategories(tags.value).filter(
(x: Category) => x.project_type === props.project.actualProjectType, (x: Category) => x.project_type === project.value.actualProjectType,
) )
const totalSelectedTags = selectedTags.value.length const totalSelectedTags = selectedTags.value.length
@@ -268,15 +248,15 @@ const patchData = computed(() => {
.map((x) => x.name) .map((x) => x.name)
if ( if (
categories.length !== props.project.categories.length || categories.length !== project.value.categories.length ||
categories.some((value) => !props.project.categories.includes(value)) categories.some((value) => !project.value.categories.includes(value))
) { ) {
data.categories = categories data.categories = categories
} }
if ( if (
additionalCategories.length !== props.project.additional_categories.length || additionalCategories.length !== project.value.additional_categories.length ||
additionalCategories.some((value) => !props.project.additional_categories.includes(value)) additionalCategories.some((value) => !project.value.additional_categories.includes(value))
) { ) {
data.additional_categories = additionalCategories data.additional_categories = additionalCategories
} }
@@ -309,7 +289,7 @@ const toggleFeaturedCategory = (category: Category) => {
const saveChanges = () => { const saveChanges = () => {
if (hasChanges.value) { if (hasChanges.value) {
props.patchProject(patchData.value) patchProject(patchData.value)
} }
} }
</script> </script>
@@ -16,7 +16,7 @@
/> />
<ProjectPageVersions <ProjectPageVersions
v-if="versions.length > 0" v-if="versions?.length"
:project="project" :project="project"
:versions="versionsWithDisplayUrl" :versions="versionsWithDisplayUrl"
:show-files="flags.showVersionFilesInTable" :show-files="flags.showVersionFilesInTable"
@@ -207,7 +207,7 @@
</template> </template>
</ProjectPageVersions> </ProjectPageVersions>
<template v-if="!versions.length"> <template v-if="!versions?.length">
<div class="grid place-content-center py-10"> <div class="grid place-content-center py-10">
<svg <svg
width="250" width="250"
@@ -309,18 +309,9 @@ import { useTemplateRef } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue' import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import { reportVersion } from '~/utils/report-helpers.ts' import { reportVersion } from '~/utils/report-helpers.ts'
interface Props {
project: Labrinth.Projects.v2.Project
currentMember?: object
}
const { project, currentMember } = defineProps<Props>()
const versions = defineModel<Labrinth.Versions.v3.Version[]>('versions', { required: true })
const client = injectModrinthClient() const client = injectModrinthClient()
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const { refreshVersions } = injectProjectPageContext() const { projectV2: project, currentMember, versions, refreshVersions } = injectProjectPageContext()
const tags = useGeneratedState() const tags = useGeneratedState()
const flags = useFeatureFlags() const flags = useFeatureFlags()
@@ -331,7 +322,7 @@ const deleteVersionModal = ref<InstanceType<typeof ConfirmModal>>()
const selectedVersion = ref<string | null>(null) const selectedVersion = ref<string | null>(null)
const handleOpenCreateVersionModal = () => { const handleOpenCreateVersionModal = () => {
if (!currentMember) return if (!currentMember.value) return
createProjectVersionModal.value?.openCreateVersionModal() createProjectVersionModal.value?.openCreateVersionModal()
} }
@@ -340,12 +331,12 @@ const handleOpenEditVersionModal = (
projectId: string, projectId: string,
stageId?: string | null, stageId?: string | null,
) => { ) => {
if (!currentMember) return if (!currentMember.value) return
createProjectVersionModal.value?.openEditVersionModal(versionId, projectId, stageId) createProjectVersionModal.value?.openEditVersionModal(versionId, projectId, stageId)
} }
const versionsWithDisplayUrl = computed(() => const versionsWithDisplayUrl = computed(() =>
versions.value.map((v) => ({ (versions.value ?? []).map((v) => ({
...v, ...v,
displayUrlEnding: v.id, displayUrlEnding: v.id,
})), })),
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,15 @@
<template> <template>
<section class="experimental-styles-within overflow-visible"> <section class="experimental-styles-within overflow-visible">
<!-- Loading state -->
<div
v-if="versionsLoading && !versions?.length"
class="flex items-center justify-center gap-2 py-8"
>
<SpinnerIcon class="animate-spin" />
<span>Loading versions...</span>
</div>
<template v-else>
<CreateProjectVersionModal <CreateProjectVersionModal
v-if="currentMember" v-if="currentMember"
ref="create-project-version-modal" ref="create-project-version-modal"
@@ -46,7 +56,7 @@
</Admonition> </Admonition>
<ProjectPageVersions <ProjectPageVersions
v-if="versions.length" v-if="versions?.length"
:project="project" :project="project"
:versions="versions" :versions="versions"
:show-files="flags.showVersionFilesInTable" :show-files="flags.showVersionFilesInTable"
@@ -151,7 +161,8 @@
id: 'report', id: 'report',
color: 'red', color: 'red',
hoverFilled: true, hoverFilled: true,
action: () => (auth.user ? reportVersion(version.id) : navigateTo('/auth/sign-in')), action: () =>
auth.user ? reportVersion(version.id) : navigateTo('/auth/sign-in'),
shown: !currentMember, shown: !currentMember,
}, },
{ divider: true, shown: currentMember || flags.developerMode }, { divider: true, shown: currentMember || flags.developerMode },
@@ -256,6 +267,7 @@
upload your first version. upload your first version.
</p> </p>
</template> </template>
</template>
</section> </section>
</template> </template>
@@ -273,6 +285,7 @@ import {
ReportIcon, ReportIcon,
SettingsIcon, SettingsIcon,
ShareIcon, ShareIcon,
SpinnerIcon,
TrashIcon, TrashIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
@@ -286,57 +299,48 @@ import {
ProjectPageVersions, ProjectPageVersions,
} from '@modrinth/ui' } from '@modrinth/ui'
import { useLocalStorage } from '@vueuse/core' import { useLocalStorage } from '@vueuse/core'
import { useTemplateRef } from 'vue' import { onMounted, useTemplateRef } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue' import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import { reportVersion } from '~/utils/report-helpers.ts' import { reportVersion } from '~/utils/report-helpers.ts'
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
currentMember: {
type: Object,
default() {
return null
},
},
})
const tags = useGeneratedState() const tags = useGeneratedState()
const flags = useFeatureFlags() const flags = useFeatureFlags()
const auth = await useAuth() const auth = await useAuth()
const client = injectModrinthClient() const client = injectModrinthClient()
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const { refreshVersions } = injectProjectPageContext() const {
projectV2: project,
currentMember,
refreshVersions,
versions,
versionsLoading,
loadVersions,
} = injectProjectPageContext()
// Load versions on mount (client-side)
onMounted(() => {
loadVersions()
})
const deleteVersionModal = ref() const deleteVersionModal = ref()
const selectedVersion = ref(null) const selectedVersion = ref(null)
const createProjectVersionModal = useTemplateRef('create-project-version-modal') const createProjectVersionModal = useTemplateRef('create-project-version-modal')
const handleOpenCreateVersionModal = () => { const handleOpenCreateVersionModal = () => {
if (!props.currentMember) return if (!currentMember.value) return
createProjectVersionModal.value?.openCreateVersionModal() createProjectVersionModal.value?.openCreateVersionModal()
} }
const handleOpenEditVersionModal = (versionId, projectId, stageId) => { const handleOpenEditVersionModal = (versionId, projectId, stageId) => {
if (!props.currentMember) return if (!currentMember.value) return
createProjectVersionModal.value?.openEditVersionModal(versionId, projectId, stageId) createProjectVersionModal.value?.openEditVersionModal(versionId, projectId, stageId)
} }
const hideVersionsAdmonition = useLocalStorage( const hideVersionsAdmonition = useLocalStorage(
'hideVersionsHasMovedAdmonition', 'hideVersionsHasMovedAdmonition',
!props.versions.length, !versions.value?.length,
) )
const emit = defineEmits(['onDownload', 'deleteVersion']) const emit = defineEmits(['onDownload', 'deleteVersion'])
+3 -2
View File
@@ -14,6 +14,9 @@ export default defineNuxtPlugin((nuxt) => {
nuxt.vueApp.use(VueQueryPlugin, options) nuxt.vueApp.use(VueQueryPlugin, options)
// Expose queryClient for middleware and composables
nuxt.provide('queryClient', queryClient)
if (import.meta.server) { if (import.meta.server) {
nuxt.hooks.hook('app:rendered', () => { nuxt.hooks.hook('app:rendered', () => {
vueQueryState.value = dehydrate(queryClient) vueQueryState.value = dehydrate(queryClient)
@@ -21,8 +24,6 @@ export default defineNuxtPlugin((nuxt) => {
} }
if (import.meta.client) { if (import.meta.client) {
nuxt.hooks.hook('app:created', () => {
hydrate(queryClient, vueQueryState.value) hydrate(queryClient, vueQueryState.value)
})
} }
}) })
+8 -2
View File
@@ -1,4 +1,10 @@
import { AuthFeature, type NuxtClientConfig, NuxtModrinthClient } from '@modrinth/api-client' import {
type AuthConfig,
AuthFeature,
type FeatureConfig,
type NuxtClientConfig,
NuxtModrinthClient,
} from '@modrinth/api-client'
import type { H3Event } from 'h3' import type { H3Event } from 'h3'
async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> { async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
@@ -27,7 +33,7 @@ export function useServerModrinthClient(options?: ServerModrinthClientOptions):
new AuthFeature({ new AuthFeature({
token: options.authToken, token: options.authToken,
tokenPrefix: '', tokenPrefix: '',
}), } as AuthConfig as FeatureConfig),
) )
} }
+1 -1
View File
@@ -1,4 +1,4 @@
export const isPermission = (perms?: number, bitflag?: number) => { export const isPermission = (perms?: number | null, bitflag?: number | null) => {
if (!perms || !bitflag) return false if (!perms || !bitflag) return false
return (perms & bitflag) === bitflag return (perms & bitflag) === bitflag
} }
@@ -114,4 +114,25 @@ export class LabrinthProjectsV2Module extends AbstractModule {
method: 'DELETE', method: 'DELETE',
}) })
} }
/**
* Get dependencies for a project
*
* @param id - Project ID or slug
* @returns Promise resolving to dependency info (projects and versions)
*
* @example
* ```typescript
* const deps = await client.labrinth.projects_v2.getDependencies('sodium')
* console.log(deps.projects) // dependent projects
* console.log(deps.versions) // dependent versions
* ```
*/
public async getDependencies(id: string): Promise<Labrinth.Projects.v2.DependencyInfo> {
return this.client.request<Labrinth.Projects.v2.DependencyInfo>(`/project/${id}/dependencies`, {
api: 'labrinth',
version: 2,
method: 'GET',
})
}
} }
@@ -194,6 +194,7 @@ export namespace Labrinth {
slug: string slug: string
project_type: ProjectType project_type: ProjectType
team: string team: string
organization: string | null
title: string title: string
description: string description: string
body: string body: string
@@ -271,6 +272,11 @@ export namespace Labrinth {
offset?: number offset?: number
limit?: number limit?: number
} }
export interface DependencyInfo {
projects: Project[]
versions: Labrinth.Versions.v2.Version[]
}
} }
export namespace v3 { export namespace v3 {
@@ -371,8 +377,8 @@ export namespace Labrinth {
team_id: string team_id: string
description: string description: string
icon_url: string | null icon_url: string | null
color: number color: number | null
members: OrganizationMember[] members: TeamMember[]
} }
export type OrganizationMember = { export type OrganizationMember = {
@@ -391,11 +397,12 @@ export namespace Labrinth {
team_id: string team_id: string
user: Users.v3.User user: Users.v3.User
role: string role: string
permissions: number
accepted: boolean
payouts_split: number
ordering: number
is_owner: boolean is_owner: boolean
permissions: number | null
organization_permissions: number | null
accepted: boolean
payouts_split: number | null
ordering: number
} }
} }
} }
@@ -416,8 +423,13 @@ export namespace Labrinth {
export type FileType = 'required-resource-pack' | 'optional-resource-pack' | 'unknown' export type FileType = 'required-resource-pack' | 'optional-resource-pack' | 'unknown'
export type VersionFileHash = {
sha512: string
sha1: string
}
export type VersionFile = { export type VersionFile = {
hashes: Record<string, string> hashes: VersionFileHash
url: string url: string
filename: string filename: string
primary: boolean primary: boolean
@@ -471,6 +483,8 @@ export namespace Labrinth {
export interface GetProjectVersionsParams { export interface GetProjectVersionsParams {
game_versions?: string[] game_versions?: string[]
loaders?: string[] loaders?: string[]
include_changelog?: boolean
apiVersion?: 2 | 3
} }
export type VersionChannel = 'release' | 'beta' | 'alpha' export type VersionChannel = 'release' | 'beta' | 'alpha'
@@ -28,17 +28,20 @@ export class LabrinthVersionsV3Module extends AbstractModule {
id: string, id: string,
options?: Labrinth.Versions.v3.GetProjectVersionsParams, options?: Labrinth.Versions.v3.GetProjectVersionsParams,
): Promise<Labrinth.Versions.v3.Version[]> { ): Promise<Labrinth.Versions.v3.Version[]> {
const params: Record<string, string> = {} const params: Record<string, string | boolean> = {}
if (options?.game_versions?.length) { if (options?.game_versions?.length) {
params.game_versions = JSON.stringify(options.game_versions) params.game_versions = JSON.stringify(options.game_versions)
} }
if (options?.loaders?.length) { if (options?.loaders?.length) {
params.loaders = JSON.stringify(options.loaders) params.loaders = JSON.stringify(options.loaders)
} }
if (options?.include_changelog !== undefined) {
params.include_changelog = options.include_changelog
}
return this.client.request<Labrinth.Versions.v3.Version[]>(`/project/${id}/version`, { return this.client.request<Labrinth.Versions.v3.Version[]>(`/project/${id}/version`, {
api: 'labrinth', api: 'labrinth',
version: 2, // TODO: move this to a versions v2 module to keep api-client clean and organized version: options?.apiVersion ?? 2,
method: 'GET', method: 'GET',
params: Object.keys(params).length > 0 ? params : undefined, params: Object.keys(params).length > 0 ? params : undefined,
}) })
+1 -1
View File
@@ -200,7 +200,7 @@ export const coreNags: Nag[] = [
context.project.source_url || context.project.source_url ||
context.project.wiki_url || context.project.wiki_url ||
context.project.discord_url || context.project.discord_url ||
context.project.donation_urls.length > 0 context.project.donation_urls?.length
), ),
link: { link: {
path: 'settings/links', path: 'settings/links',
+2 -3
View File
@@ -1,6 +1,5 @@
import type { Labrinth } from '@modrinth/api-client' import type { Labrinth } from '@modrinth/api-client'
import type { MessageDescriptor } from '@modrinth/ui' import type { MessageDescriptor } from '@modrinth/ui'
import type { User, Version } from '@modrinth/utils'
import type { FunctionalComponent, SVGAttributes } from 'vue' import type { FunctionalComponent, SVGAttributes } from 'vue'
/** /**
@@ -25,11 +24,11 @@ export interface NagContext {
/** /**
* The versions associated with the project. * The versions associated with the project.
*/ */
versions: Version[] versions: Labrinth.Versions.v2.Version[]
/** /**
* The current project member viewing the nag. * The current project member viewing the nag.
*/ */
currentMember: User currentMember: Labrinth.Users.v2.User
/** /**
* The current route in the application. * The current route in the application.
*/ */
@@ -17,14 +17,15 @@
</a> </a>
</ButtonStyled> </ButtonStyled>
<ButtonStyled circular> <ButtonStyled circular>
<nuxt-link <button
:to="`/project/${props.version.project_id}/version/${props.version.id}`"
class="min-w-0" class="min-w-0"
aria-label="Open project page" aria-label="View version"
@click="emit('onNavigate')" @click="
emit('onNavigate', `/project/${props.version.project_id}/version/${props.version.id}`)
"
> >
<ExternalIcon aria-hidden="true" /> <ExternalIcon aria-hidden="true" />
</nuxt-link> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</template> </template>
@@ -45,5 +46,8 @@ const downloadUrl = computed(() => {
return primary.url return primary.url
}) })
const emit = defineEmits(['onDownload', 'onNavigate']) const emit = defineEmits<{
onDownload: []
onNavigate: [url: string]
}>()
</script> </script>
+23 -3
View File
@@ -1,16 +1,36 @@
import type { Labrinth } from '@modrinth/api-client/src/modules/types' import type { Labrinth } from '@modrinth/api-client/src/modules/types'
// TODO: api client this shit
import type { TeamMember } from '@modrinth/utils'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { createContext } from '.' import { createContext } from '.'
export interface ProjectPageContext { export interface ProjectPageContext {
// Data refs
projectV2: Ref<Labrinth.Projects.v2.Project> projectV2: Ref<Labrinth.Projects.v2.Project>
projectV3: Ref<Labrinth.Projects.v3.Project> projectV3: Ref<Labrinth.Projects.v3.Project>
currentMember: Ref<Labrinth.Projects.v3.TeamMember | null>
allMembers: Ref<Labrinth.Projects.v3.TeamMember[]>
organization: Ref<Labrinth.Projects.v3.Organization | null>
// Lazy version loading (client-side only)
versions: Ref<Labrinth.Versions.v2.Version[] | null>
versionsLoading: Ref<boolean>
// Lazy dependencies loading (client-side only)
dependencies: Ref<Labrinth.Projects.v2.DependencyInfo | null>
dependenciesLoading: Ref<boolean>
// Refresh functions (invalidate + refetch)
refreshProject: () => Promise<void> refreshProject: () => Promise<void>
refreshVersions: () => Promise<void> refreshVersions: () => Promise<void>
currentMember: Ref<TeamMember> refreshMembers: () => Promise<void>
refreshOrganization: () => Promise<void>
// Lazy loading
loadVersions: () => Promise<void>
loadDependencies: () => Promise<void>
// Mutation functions
patchProject: (data: Record<string, unknown>, quiet?: boolean) => Promise<boolean>
patchIcon: (icon: File) => Promise<boolean>
setProcessing: () => Promise<void>
} }
export const [injectProjectPageContext, provideProjectPageContext] = export const [injectProjectPageContext, provideProjectPageContext] =