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

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": {

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),
for (let i = filteredLinks.value.length - 1; i >= 0; i--) { isSubpage: false,
const link = filteredLinks.value[i]
if (props.query) {
if (route.query[props.query] === link.href || (!route.query[props.query] && !link.href)) {
index = i
break
}
} else if (decodeURIComponent(route.path) === link.href) {
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
}
} }
} }
currentActiveIndex.value = index for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
const decodedPath = decodeURIComponent(route.path)
if (currentActiveIndex.value !== -1) { // Query-based matching
nextTick(() => startAnimation()) if (props.query) {
const queryValue = route.query[props.query]
if (queryValue === link.href || (!queryValue && !link.href)) {
return { index: i, isSubpage: false }
}
continue
}
// 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
subpageSelected.value = isSubpage
if (index !== -1) {
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>

View File

@@ -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,

View File

@@ -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,
}),
}

View File

@@ -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
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -1,108 +1,108 @@
<template> <template>
<div class="content"> <div class="content">
<div class="mb-3 flex"> <!-- Loading state for initial version load -->
<VersionFilterControl <div
:versions="props.versions" v-if="versionsLoading && !versions?.length"
:game-versions="tags.gameVersions" class="flex items-center justify-center gap-2 py-8"
@update:query="updateQuery" >
/> <SpinnerIcon class="animate-spin" />
<span>Loading changelog...</span>
</div>
<template v-else>
<div class="mb-3 flex">
<VersionFilterControl
:versions="versions ?? []"
:game-versions="tags.gameVersions"
@update:query="updateQuery"
/>
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="ml-auto mt-auto"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
</div>
<div class="card changelog-wrapper">
<template v-if="paginatedVersions && !isLoadingVersions">
<div v-for="version in paginatedVersions" :key="version.id" class="changelog-item">
<div
:class="`changelog-bar ${version.version_type} ${version.duplicate ? 'duplicate' : ''}`"
/>
<div class="version-wrapper">
<div class="version-header">
<div class="version-header-text">
<h2 class="name">
<nuxt-link
:to="`/${projectV2.project_type}/${
projectV2.slug ? projectV2.slug : projectV2.id
}/version/${encodeURI(version.displayUrlEnding)}`"
>
{{ version.name }}
</nuxt-link>
</h2>
<span v-if="version.author">
by
<nuxt-link class="text-link" :to="'/user/' + version.author.user.username">{{
version.author.user.username
}}</nuxt-link>
</span>
<span>
on
{{ $dayjs(version.date_published).format('MMM D, YYYY') }}</span
>
</div>
<a
:href="version.primaryFile?.url"
class="iconified-button download"
:title="`Download ${version.name}`"
>
<DownloadIcon aria-hidden="true" />
Download
</a>
</div>
<div
v-if="version.changelog && !version.duplicate"
class="markdown-body"
v-html="renderHighlightedString(version.changelog)"
/>
</div>
</div>
</template>
<template v-else>
<SpinnerIcon class="animate-spin" />
</template>
</div>
<Pagination <Pagination
:page="currentPage" :page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)" :count="Math.ceil(filteredVersions.length / 20)"
class="ml-auto mt-auto" class="mb-2 flex justify-end"
:link-function="(page) => `?page=${page}`" :link-function="(page) => `?page=${page}`"
@switch-page="switchPage" @switch-page="switchPage"
/> />
</div> </template>
<div class="card changelog-wrapper">
<template v-if="paginatedVersions && !isLoadingVersions">
<div v-for="version in paginatedVersions" :key="version.id" class="changelog-item">
<div
:class="`changelog-bar ${version.version_type} ${version.duplicate ? 'duplicate' : ''}`"
/>
<div class="version-wrapper">
<div class="version-header">
<div class="version-header-text">
<h2 class="name">
<nuxt-link
:to="`/${props.project.project_type}/${
props.project.slug ? props.project.slug : props.project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
>
{{ version.name }}
</nuxt-link>
</h2>
<span v-if="version.author">
by
<nuxt-link class="text-link" :to="'/user/' + version.author.user.username">{{
version.author.user.username
}}</nuxt-link>
</span>
<span>
on
{{ $dayjs(version.date_published).format('MMM D, YYYY') }}</span
>
</div>
<a
:href="version.primaryFile?.url"
class="iconified-button download"
:title="`Download ${version.name}`"
>
<DownloadIcon aria-hidden="true" />
Download
</a>
</div>
<div
v-if="version.changelog && !version.duplicate"
class="markdown-body"
v-html="renderHighlightedString(version.changelog)"
/>
</div>
</div>
</template>
<template v-else>
<SpinnerIcon class="animate-spin" />
</template>
</div>
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="mb-2 flex justify-end"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
</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) =>

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
featured: boolean
title?: string
description?: string
created: string
ordering: number
}
return { // Expanded image modal state
addNotification, const expandedGalleryItem = ref<GalleryItem | null>(null)
const expandedGalleryIndex = ref(0)
const zoomedIn = ref(false)
// Delete state
const deleteIndex = ref(-1)
// Edit state
const editIndex = ref(-1)
const editTitle = ref('')
const editDescription = ref('')
const editFeatured = ref(false)
const editOrder = ref<number | null>(null)
const editFile = ref<File | null>(null)
const previewImage = ref<string | null>(null)
// UI state
const shouldPreventActions = ref(false)
// Constant for accepted file types
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
// Keyboard navigation for expanded image modal
useEventListener(document, 'keydown', (e) => {
if (expandedGalleryItem.value) {
e.preventDefault()
if (e.key === 'Escape') {
expandedGalleryItem.value = null
} else if (e.key === 'ArrowLeft') {
e.stopPropagation()
previousImage()
} else if (e.key === 'ArrowRight') {
e.stopPropagation()
nextImage()
} }
}, }
data() {
return {
expandedGalleryItem: null,
expandedGalleryIndex: 0,
zoomedIn: false,
deleteIndex: -1,
editIndex: -1,
editTitle: '',
editDescription: '',
editFeatured: false,
editOrder: null,
editFile: null,
previewImage: null,
shouldPreventActions: false,
}
},
computed: {
acceptFileTypes() {
return 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
},
},
mounted() {
this._keyListener = function (e) {
if (this.expandedGalleryItem) {
e.preventDefault()
if (e.key === 'Escape') {
this.expandedGalleryItem = null
} else if (e.key === 'ArrowLeft') {
e.stopPropagation()
this.previousImage()
} else if (e.key === 'ArrowRight') {
e.stopPropagation()
this.nextImage()
}
}
}
document.addEventListener('keydown', this._keyListener.bind(this))
},
methods: {
nextImage() {
this.expandedGalleryIndex++
if (this.expandedGalleryIndex >= this.project.gallery.length) {
this.expandedGalleryIndex = 0
}
this.expandedGalleryItem = this.project.gallery[this.expandedGalleryIndex]
},
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()
this.$refs.modal_edit_item.show()
},
showPreviewImage() {
const reader = new FileReader()
if (this.editFile instanceof Blob) {
reader.readAsDataURL(this.editFile)
reader.onload = (event) => {
this.previewImage = event.target.result
}
}
},
async createGalleryItem() {
this.shouldPreventActions = true
startLoading()
try {
let url = `project/${this.project.id}/gallery?ext=${
this.editFile
? this.editFile.type.split('/')[this.editFile.type.split('/').length - 1]
: null
}&featured=${this.editFeatured}`
if (this.editTitle) {
url += `&title=${encodeURIComponent(this.editTitle)}`
}
if (this.editDescription) {
url += `&description=${encodeURIComponent(this.editDescription)}`
}
if (this.editOrder) {
url += `&ordering=${this.editOrder}`
}
await useBaseFetch(url, {
method: 'POST',
body: this.editFile,
})
await this.resetProject()
this.$refs.modal_edit_item.hide()
} catch (err) {
this.addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
this.shouldPreventActions = false
},
async editGalleryItem() {
this.shouldPreventActions = true
startLoading()
try {
let url = `project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.editIndex].url,
)}&featured=${this.editFeatured}`
if (this.editTitle) {
url += `&title=${encodeURIComponent(this.editTitle)}`
}
if (this.editDescription) {
url += `&description=${encodeURIComponent(this.editDescription)}`
}
if (this.editOrder) {
url += `&ordering=${this.editOrder}`
}
await useBaseFetch(url, {
method: 'PATCH',
})
await this.resetProject()
this.$refs.modal_edit_item.hide()
} catch (err) {
this.addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
this.shouldPreventActions = false
},
async deleteGalleryImage() {
startLoading()
try {
await useBaseFetch(
`project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.deleteIndex].url,
)}`,
{
method: 'DELETE',
},
)
await this.resetProject()
} catch (err) {
this.addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
},
},
}) })
// 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
}
function previousImage() {
expandedGalleryIndex.value--
if (expandedGalleryIndex.value < 0) {
expandedGalleryIndex.value = project.value.gallery!.length - 1
}
expandedGalleryItem.value = project.value.gallery![expandedGalleryIndex.value] as GalleryItem
}
function expandImage(item: GalleryItem, index: number) {
expandedGalleryItem.value = item
expandedGalleryIndex.value = index
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()
if (editFile.value instanceof Blob) {
reader.readAsDataURL(editFile.value)
reader.onload = (event) => {
previewImage.value = event.target?.result as string | null
}
}
}
// CRUD operations
async function createGalleryItem() {
shouldPreventActions.value = true
startLoading()
try {
let url = `project/${project.value.id}/gallery?ext=${
editFile.value
? editFile.value.type.split('/')[editFile.value.type.split('/').length - 1]
: null
}&featured=${editFeatured.value}`
if (editTitle.value) {
url += `&title=${encodeURIComponent(editTitle.value)}`
}
if (editDescription.value) {
url += `&description=${encodeURIComponent(editDescription.value)}`
}
if (editOrder.value) {
url += `&ordering=${editOrder.value}`
}
await useBaseFetch(url, {
method: 'POST',
body: editFile.value,
})
await refreshProject()
modalEditItem.value?.hide()
} catch (err: unknown) {
const error = err as { data?: { description?: string } }
addNotification({
title: 'An error occurred',
text: error.data?.description ?? String(err),
type: 'error',
})
}
stopLoading()
shouldPreventActions.value = false
}
async function editGalleryItem() {
shouldPreventActions.value = true
startLoading()
try {
let url = `project/${project.value.id}/gallery?url=${encodeURIComponent(
project.value!.gallery![editIndex.value].url,
)}&featured=${editFeatured.value}`
if (editTitle.value) {
url += `&title=${encodeURIComponent(editTitle.value)}`
}
if (editDescription.value) {
url += `&description=${encodeURIComponent(editDescription.value)}`
}
if (editOrder.value) {
url += `&ordering=${editOrder.value}`
}
await useBaseFetch(url, {
method: 'PATCH',
})
await refreshProject()
modalEditItem.value?.hide()
} catch (err: unknown) {
const error = err as { data?: { description?: string } }
addNotification({
title: 'An error occurred',
text: error.data?.description ?? String(err),
type: 'error',
})
}
stopLoading()
shouldPreventActions.value = false
}
async function deleteGalleryImage() {
startLoading()
try {
await useBaseFetch(
`project/${project.value.id}/gallery?url=${encodeURIComponent(
project.value!.gallery![deleteIndex.value].url!,
)}`,
{
method: 'DELETE',
},
)
await refreshProject()
} catch (err: unknown) {
const error = err as { data?: { description?: string } }
addNotification({
title: 'An error occurred',
text: error.data?.description ?? String(err),
type: 'error',
})
}
stopLoading()
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

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>

View File

@@ -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({

View File

@@ -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>

View File

@@ -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">

View File

@@ -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

View File

@@ -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',

View File

@@ -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.",

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -1,260 +1,272 @@
<template> <template>
<section class="experimental-styles-within overflow-visible"> <section class="experimental-styles-within overflow-visible">
<CreateProjectVersionModal <!-- Loading state -->
v-if="currentMember" <div
ref="create-project-version-modal" v-if="versionsLoading && !versions?.length"
></CreateProjectVersionModal> class="flex items-center justify-center gap-2 py-8"
<ConfirmModal
v-if="currentMember"
ref="deleteVersionModal"
title="Are you sure you want to delete this version?"
description="This will remove this version forever (like really forever)."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteVersion()"
/>
<Admonition v-if="!hideVersionsAdmonition && currentMember" type="info" class="mb-4">
Creating and editing project versions can now be done directly from the
<NuxtLink to="settings/versions" class="font-medium text-blue hover:underline"
>project settings</NuxtLink
>.
<template #actions>
<div class="flex gap-2">
<ButtonStyled color="blue">
<button
aria-label="Project Settings"
class="!shadow-none"
@click="() => router.push('settings/versions')"
>
<SettingsIcon />
Edit versions
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
aria-label="Dismiss"
class="!shadow-none"
@click="() => (hideVersionsAdmonition = true)"
>
Dismiss
</button>
</ButtonStyled>
</div>
</template>
</Admonition>
<ProjectPageVersions
v-if="versions.length"
:project="project"
:versions="versions"
:show-files="flags.showVersionFilesInTable"
:current-member="!!currentMember"
:loaders="tags.loaders"
:game-versions="tags.gameVersions"
:base-id="baseDropdownId"
:version-link="
(version) =>
`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding ? version.displayUrlEnding : version.id)}`
"
:open-modal="currentMember ? () => handleOpenCreateVersionModal() : undefined"
> >
<template #actions="{ version }"> <SpinnerIcon class="animate-spin" />
<ButtonStyled circular type="transparent"> <span>Loading versions...</span>
<a </div>
v-tooltip="`Download`"
:href="getPrimaryFile(version).url"
class="hover:!bg-button-bg [&>svg]:!text-green"
aria-label="Download"
@click="emit('onDownload')"
>
<DownloadIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled v-if="currentMember" circular type="transparent">
<OverflowMenu
v-tooltip="'Edit version'"
class="hover:!bg-button-bg"
:dropdown-id="`${baseDropdownId}-edit-${version.id}`"
:options="[
{
id: 'edit-metadata',
action: () => handleOpenEditVersionModal(version.id, project.id, 'metadata'),
},
{
id: 'edit-details',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-details'),
},
{
id: 'edit-files',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-files'),
},
]"
aria-label="Edit version"
>
<EditIcon aria-hidden="true" />
<template #edit-files>
<FileIcon aria-hidden="true" />
Edit files
</template>
<template #edit-details>
<InfoIcon aria-hidden="true" />
Edit details
</template>
<template #edit-metadata>
<BoxIcon aria-hidden="true" />
Edit metadata
</template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
v-tooltip="'More options'"
class="hover:!bg-button-bg"
:dropdown-id="`${baseDropdownId}-${version.id}`"
:options="[
{
id: 'download',
color: 'primary',
hoverFilled: true,
link: getPrimaryFile(version).url,
action: () => {
emit('onDownload')
},
},
{
id: 'new-tab',
action: () => {},
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
external: true,
},
{
id: 'copy-link',
action: () =>
copyToClipboard(
`https://modrinth.com/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
),
},
{
id: 'share',
action: () => {},
shown: false,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
action: () => (auth.user ? reportVersion(version.id) : navigateTo('/auth/sign-in')),
shown: !currentMember,
},
{ divider: true, shown: currentMember || flags.developerMode },
{
id: 'copy-id',
action: () => {
copyToClipboard(version.id)
},
shown: currentMember || flags.developerMode,
},
{
id: 'copy-maven',
action: () => {
copyToClipboard(`maven.modrinth:${project.slug}:${version.id}`)
},
shown: flags.developerMode,
},
{ divider: true, shown: !!currentMember },
{
id: 'edit-metadata',
action: () => handleOpenEditVersionModal(version.id, project.id, 'metadata'),
shown: !!currentMember,
},
{
id: 'edit-details',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-details'),
shown: !!currentMember,
},
{
id: 'edit-files',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-files'),
shown: !!currentMember,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => {
selectedVersion = version.id
deleteVersionModal?.show()
},
shown: !!currentMember,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #download>
<DownloadIcon aria-hidden="true" />
Download
</template>
<template #new-tab>
<ExternalIcon aria-hidden="true" />
Open in new tab
</template>
<template #copy-link>
<LinkIcon aria-hidden="true" />
Copy link
</template>
<template #share>
<ShareIcon aria-hidden="true" />
Share
</template>
<template #report>
<ReportIcon aria-hidden="true" />
Report
</template>
<template #edit-files>
<FileIcon aria-hidden="true" />
Edit files
</template>
<template #edit-details>
<InfoIcon aria-hidden="true" />
Edit details
</template>
<template #edit-metadata>
<BoxIcon aria-hidden="true" />
Edit metadata
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
Delete
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
</template>
<template #copy-maven>
<ClipboardCopyIcon aria-hidden="true" />
Copy Maven coordinates
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectPageVersions>
<template v-else> <template v-else>
<p class="ml-2"> <CreateProjectVersionModal
No versions in project. Visit v-if="currentMember"
<NuxtLink to="settings/versions"> ref="create-project-version-modal"
<span class="font-medium text-green hover:underline">project settings</span> to ></CreateProjectVersionModal>
</NuxtLink>
upload your first version. <ConfirmModal
</p> v-if="currentMember"
ref="deleteVersionModal"
title="Are you sure you want to delete this version?"
description="This will remove this version forever (like really forever)."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteVersion()"
/>
<Admonition v-if="!hideVersionsAdmonition && currentMember" type="info" class="mb-4">
Creating and editing project versions can now be done directly from the
<NuxtLink to="settings/versions" class="font-medium text-blue hover:underline"
>project settings</NuxtLink
>.
<template #actions>
<div class="flex gap-2">
<ButtonStyled color="blue">
<button
aria-label="Project Settings"
class="!shadow-none"
@click="() => router.push('settings/versions')"
>
<SettingsIcon />
Edit versions
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
aria-label="Dismiss"
class="!shadow-none"
@click="() => (hideVersionsAdmonition = true)"
>
Dismiss
</button>
</ButtonStyled>
</div>
</template>
</Admonition>
<ProjectPageVersions
v-if="versions?.length"
:project="project"
:versions="versions"
:show-files="flags.showVersionFilesInTable"
:current-member="!!currentMember"
:loaders="tags.loaders"
:game-versions="tags.gameVersions"
:base-id="baseDropdownId"
:version-link="
(version) =>
`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding ? version.displayUrlEnding : version.id)}`
"
:open-modal="currentMember ? () => handleOpenCreateVersionModal() : undefined"
>
<template #actions="{ version }">
<ButtonStyled circular type="transparent">
<a
v-tooltip="`Download`"
:href="getPrimaryFile(version).url"
class="hover:!bg-button-bg [&>svg]:!text-green"
aria-label="Download"
@click="emit('onDownload')"
>
<DownloadIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled v-if="currentMember" circular type="transparent">
<OverflowMenu
v-tooltip="'Edit version'"
class="hover:!bg-button-bg"
:dropdown-id="`${baseDropdownId}-edit-${version.id}`"
:options="[
{
id: 'edit-metadata',
action: () => handleOpenEditVersionModal(version.id, project.id, 'metadata'),
},
{
id: 'edit-details',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-details'),
},
{
id: 'edit-files',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-files'),
},
]"
aria-label="Edit version"
>
<EditIcon aria-hidden="true" />
<template #edit-files>
<FileIcon aria-hidden="true" />
Edit files
</template>
<template #edit-details>
<InfoIcon aria-hidden="true" />
Edit details
</template>
<template #edit-metadata>
<BoxIcon aria-hidden="true" />
Edit metadata
</template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
v-tooltip="'More options'"
class="hover:!bg-button-bg"
:dropdown-id="`${baseDropdownId}-${version.id}`"
:options="[
{
id: 'download',
color: 'primary',
hoverFilled: true,
link: getPrimaryFile(version).url,
action: () => {
emit('onDownload')
},
},
{
id: 'new-tab',
action: () => {},
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
external: true,
},
{
id: 'copy-link',
action: () =>
copyToClipboard(
`https://modrinth.com/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
),
},
{
id: 'share',
action: () => {},
shown: false,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
action: () =>
auth.user ? reportVersion(version.id) : navigateTo('/auth/sign-in'),
shown: !currentMember,
},
{ divider: true, shown: currentMember || flags.developerMode },
{
id: 'copy-id',
action: () => {
copyToClipboard(version.id)
},
shown: currentMember || flags.developerMode,
},
{
id: 'copy-maven',
action: () => {
copyToClipboard(`maven.modrinth:${project.slug}:${version.id}`)
},
shown: flags.developerMode,
},
{ divider: true, shown: !!currentMember },
{
id: 'edit-metadata',
action: () => handleOpenEditVersionModal(version.id, project.id, 'metadata'),
shown: !!currentMember,
},
{
id: 'edit-details',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-details'),
shown: !!currentMember,
},
{
id: 'edit-files',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-files'),
shown: !!currentMember,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => {
selectedVersion = version.id
deleteVersionModal?.show()
},
shown: !!currentMember,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #download>
<DownloadIcon aria-hidden="true" />
Download
</template>
<template #new-tab>
<ExternalIcon aria-hidden="true" />
Open in new tab
</template>
<template #copy-link>
<LinkIcon aria-hidden="true" />
Copy link
</template>
<template #share>
<ShareIcon aria-hidden="true" />
Share
</template>
<template #report>
<ReportIcon aria-hidden="true" />
Report
</template>
<template #edit-files>
<FileIcon aria-hidden="true" />
Edit files
</template>
<template #edit-details>
<InfoIcon aria-hidden="true" />
Edit details
</template>
<template #edit-metadata>
<BoxIcon aria-hidden="true" />
Edit metadata
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
Delete
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
</template>
<template #copy-maven>
<ClipboardCopyIcon aria-hidden="true" />
Copy Maven coordinates
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectPageVersions>
<template v-else>
<p class="ml-2">
No versions in project. Visit
<NuxtLink to="settings/versions">
<span class="font-medium text-green hover:underline">project settings</span> to
</NuxtLink>
upload your first version.
</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'])

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)
})
} }
}) })

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),
) )
} }

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
} }

View File

@@ -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',
})
}
} }

View File

@@ -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'

View File

@@ -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,
}) })

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',

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.
*/ */

View File

@@ -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>

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] =