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 .",
"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",
"cf-deploy": "pnpm run build && wrangler deploy --env preview",
"cf-dev": "pnpm run build && wrangler dev --env preview",
"cf-deploy": "pnpm run build && wrangler deploy --env staging",
"cf-dev": "pnpm run build && wrangler dev --env staging",
"cf-typegen": "wrangler types"
},
"devDependencies": {

View File

@@ -2,78 +2,54 @@
<nav
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="[mode === 'navigation' ? 'card-shadow' : undefined]"
:class="{ 'card-shadow': mode === 'navigation' }"
>
<template v-if="mode === 'navigation'">
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
v-show="link.shown ?? true"
:key="link.href"
ref="tabLinkElements"
: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="getSSRFallbackClasses(index)"
@mouseenter="link.onHover?.()"
@focus="link.onHover?.()"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
: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
>
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</NuxtLink>
</template>
<template v-else>
<div
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
v-show="link.shown ?? true"
:key="link.href"
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="getSSRFallbackClasses(index)"
@click="emit('tabClick', index, link)"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
: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
>
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</div>
</template>
<!-- Animated slider background -->
<div
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'
}`"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity:
sliderLeft === 4 && sliderLeft === sliderRight ? 0 : currentActiveIndex === -1 ? 0 : 1,
}"
class="pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1"
:class="[
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
{ 'navtabs-transition': transitionsEnabled },
]"
:style="sliderStyle"
aria-hidden="true"
></div>
/>
</nav>
</template>
@@ -89,6 +65,7 @@ interface Tab {
shown?: boolean
icon?: Component
subpages?: string[]
onHover?: () => void
}
const props = withDefaults(
@@ -109,124 +86,194 @@ const emit = defineEmits<{
tabClick: [index: number, tab: Tab]
}>()
// DOM refs
const scrollContainer = ref<HTMLElement | null>(null)
const tabLinkElements = ref<HTMLElement[]>()
// Slider pos state
const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
// active tab state
const currentActiveIndex = ref(-1)
const subpageSelected = ref(false)
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
// SSR state
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() {
let index = -1
subpageSelected.value = false
return {
'rounded-full': true,
'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) {
index = Math.min(props.activeIndex, filteredLinks.value.length - 1)
} else {
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
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
}
return {
index: Math.min(props.activeIndex, filteredLinks.value.length - 1),
isSubpage: false,
}
}
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) {
nextTick(() => startAnimation())
// Query-based matching
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 {
sliderLeft.value = 0
sliderRight.value = 0
}
}
function startAnimation() {
// In navigation mode, elements are NuxtLinks with $el property
// In local mode, elements are plain divs
const el =
props.mode === 'navigation'
? tabLinkElements.value[currentActiveIndex.value]?.$el
: tabLinkElements.value[currentActiveIndex.value]
const initialActive = computeActiveIndex()
currentActiveIndex.value = initialActive.index
subpageSelected.value = initialActive.isSubpage
if (!el || !el.offsetParent) return
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()
})
onMounted(updateActiveTab)
watch(
() => [route.path, route.query],
() => {
if (props.mode === 'navigation') {
pickLink()
updateActiveTab()
}
},
)
@@ -235,19 +282,12 @@ watch(
() => props.activeIndex,
() => {
if (props.mode === 'local') {
pickLink()
updateActiveTab()
}
},
)
watch(
() => props.links,
() => {
// Re-trigger animation when links change
pickLink()
},
{ deep: true },
)
watch(() => props.links, updateActiveTab, { deep: true })
</script>
<style scoped>

View File

@@ -78,6 +78,7 @@
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
AsteriskIcon,
ChevronRightIcon,
@@ -90,7 +91,6 @@ import {
import type { Nag, NagContext, NagStatus } from '@modrinth/moderation'
import { nags } from '@modrinth/moderation'
import { ButtonStyled, defineMessages, type MessageDescriptor, useVIntl } from '@modrinth/ui'
import type { Project, User, Version } from '@modrinth/utils'
import type { Component } from 'vue'
import { computed } from 'vue'
@@ -98,16 +98,10 @@ interface Tags {
rejectedStatuses: string[]
}
interface Member {
accepted?: boolean
project_role?: string
user?: Partial<User>
}
interface Props {
project: Project
versions?: Version[]
currentMember?: Member | null
project: Labrinth.Projects.v2.Project
versions?: Labrinth.Versions.v2.Version[]
currentMember?: Labrinth.Projects.v3.TeamMember | null
collapsed?: boolean
routeName?: string
tags: Tags
@@ -179,7 +173,7 @@ const emit = defineEmits<{
const nagContext = computed<NagContext>(() => ({
project: props.project,
versions: props.versions,
currentMember: props.currentMember as User,
currentMember: props.currentMember?.user as Labrinth.Users.v2.User,
currentRoute: props.routeName,
tags: props.tags,
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 { useServerModrinthClient } from '~/server/utils/api-client'
@@ -10,15 +12,30 @@ export default defineNuxtRouteMiddleware(async (to) => {
return
}
const queryClient = useAppQueryClient()
const authToken = useCookie('auth-token')
const client = useServerModrinthClient({ authToken: authToken.value || undefined })
const tags = useGeneratedState()
const projectId = to.params.id as string
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) {
return
// Let page handle 404
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

File diff suppressed because it is too large Load Diff

View File

@@ -1,108 +1,108 @@
<template>
<div class="content">
<div class="mb-3 flex">
<VersionFilterControl
:versions="props.versions"
:game-versions="tags.gameVersions"
@update:query="updateQuery"
/>
<!-- Loading state for initial version load -->
<div
v-if="versionsLoading && !versions?.length"
class="flex items-center justify-center gap-2 py-8"
>
<SpinnerIcon class="animate-spin" />
<span>Loading changelog...</span>
</div>
<template v-else>
<div class="mb-3 flex">
<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
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="ml-auto mt-auto"
class="mb-2 flex justify-end"
: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="`/${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"
/>
</template>
</div>
</template>
<script setup>
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 { renderHighlightedString } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { onMounted } from 'vue'
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
members: {
type: Array,
default() {
return []
},
},
const { projectV2, versions, versionsLoading, loadVersions } = injectProjectPageContext()
// Load versions on mount (client-side)
onMounted(() => {
loadVersions()
})
const title = `${props.project.title} - Changelog`
const description = `View the changelog of ${props.project.title}'s ${props.versions.length} versions.`
const title = computed(() => `${projectV2.value.title} - Changelog`)
const description = computed(
() => `View the changelog of ${projectV2.value.title}'s ${versions.value?.length ?? 0} versions.`,
)
useSeoMeta({
title,
@@ -117,11 +117,13 @@ const tags = useGeneratedState()
const currentPage = ref(Number(route.query.page ?? 1))
const filteredVersions = computed(() => {
if (!versions.value) return []
const selectedGameVersions = getArrayOrString(route.query.g) ?? []
const selectedLoaders = getArrayOrString(route.query.l) ?? []
const selectedVersionTypes = getArrayOrString(route.query.c) ?? []
return props.versions.filter(
return versions.value.filter(
(projectVersion) =>
(selectedGameVersions.length === 0 ||
selectedGameVersions.some((gameVersion) =>

View File

@@ -32,7 +32,7 @@
:src="
previewImage
? previewImage
: project.gallery[editIndex] && project.gallery[editIndex].url
: project.gallery?.[editIndex]?.url
? project.gallery[editIndex].url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
@@ -95,7 +95,7 @@
Unfeature image
</button>
<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" />
Cancel
</button>
@@ -165,8 +165,8 @@
class="open circle-button"
target="_blank"
:href="
expandedGalleryItem.raw_url
? expandedGalleryItem.raw_url
expandedGalleryItem?.raw_url
? expandedGalleryItem?.raw_url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
>
@@ -177,14 +177,14 @@
<ContractIcon v-else aria-hidden="true" />
</button>
<button
v-if="project.gallery.length > 1"
v-if="(project?.gallery?.length ?? 0) > 1"
class="previous circle-button"
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</button>
<button
v-if="project.gallery.length > 1"
v-if="(project?.gallery?.length ?? 0) > 1"
class="next circle-button"
@click="nextImage()"
>
@@ -206,7 +206,7 @@
<button
aria-label="Project Settings"
class="!shadow-none"
@click="() => $router.push('settings/gallery')"
@click="() => router.push('settings/gallery')"
>
<SettingsIcon />
Edit gallery
@@ -224,7 +224,7 @@
</div>
</template>
</Admonition>
<div v-if="currentMember && project.gallery.length" class="card header-buttons">
<div v-if="currentMember && project?.gallery?.length" class="card header-buttons">
<FileInput
:max-size="5242880"
:accept="acceptFileTypes"
@@ -245,9 +245,9 @@
@change="handleFiles"
/>
</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">
<a class="gallery-thumbnail" @click="expandImage(item, index)">
<a class="gallery-thumbnail" @click="expandImage(item as GalleryItem, index)">
<img
:src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
:alt="item.title ? item.title : 'gallery-image'"
@@ -275,11 +275,11 @@
() => {
resetEdit()
editIndex = index
editTitle = item.title
editDescription = item.description
editTitle = item.title ?? ''
editDescription = item.description ?? ''
editFeatured = item.featured
editOrder = item.ordering
$refs.modal_edit_item.show()
modalEditItem?.show()
}
"
>
@@ -291,7 +291,7 @@
@click="
() => {
deleteIndex = index
$refs.modal_confirm.show()
modalConfirm?.show()
}
"
>
@@ -314,7 +314,7 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import {
CalendarIcon,
ContractIcon,
@@ -341,34 +341,29 @@ import {
DropArea,
FileInput,
injectNotificationManager,
injectProjectPageContext,
NewModal as Modal,
} from '@modrinth/ui'
import { useLocalStorage } from '@vueuse/core'
import { useEventListener, useLocalStorage } from '@vueuse/core'
import { isPermission } from '~/utils/permissions.ts'
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
resetProject: {
type: Function,
required: true,
default: () => {},
},
})
// Router
const router = useRouter()
const title = `${props.project.title} - Gallery`
const description = `View ${props.project.gallery.length} images of ${props.project.title} on Modrinth.`
// Single DI injection
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({
title,
@@ -377,207 +372,219 @@ useSeoMeta({
ogDescription: description,
})
// Local storage state
const hideGalleryAdmonition = useLocalStorage(
'hideGalleryHasMovedAdmonition',
!props.project.gallery.length,
!project.value.gallery?.length,
)
</script>
<script>
export default defineNuxtComponent({
setup() {
const { addNotification } = injectNotificationManager()
// Gallery item type matching actual v2 API response (LegacyGalleryItem in labrinth)
// raw_url is optional in TS types but present in API response
interface GalleryItem {
url: string
raw_url?: string
featured: boolean
title?: string
description?: string
created: string
ordering: number
}
return {
addNotification,
// Expanded image modal state
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>
<style lang="scss" scoped>

View File

@@ -1,7 +1,7 @@
<template>
<section class="normal-page__content">
<div v-if="project.body" class="card">
<ProjectPageDescription :description="project.body" />
<div v-if="projectV2.body" class="card">
<ProjectPageDescription :description="projectV2.body" />
</div>
<p v-else class="ml-2">
No description provided. Visit
@@ -14,34 +14,9 @@
</template>
<script setup>
import { ProjectPageDescription } from '@modrinth/ui'
import { injectProjectPageContext, ProjectPageDescription } from '@modrinth/ui'
const route = useRoute()
defineProps({
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
members: {
type: Array,
default() {
return []
},
},
organization: {
type: Object,
default() {
return {}
},
},
})
const { projectV2 } = injectProjectPageContext()
</script>

View File

@@ -100,7 +100,7 @@
</template>
<script setup>
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 {
@@ -113,45 +113,28 @@ import {
} from '~/helpers/projects.js'
const { addNotification } = injectNotificationManager()
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
resetProject: {
type: Function,
required: true,
default: () => {},
},
})
const { projectV2: project, currentMember, refreshProject } = injectProjectPageContext()
const auth = await useAuth()
const { data: thread } = await useAsyncData(`thread/${props.project.thread_id}`, () =>
useBaseFetch(`thread/${props.project.thread_id}`),
const { data: thread } = await useAsyncData(
() => `thread/${project.value.thread_id}`,
() => useBaseFetch(`thread/${project.value.thread_id}`),
)
async function setStatus(status) {
startLoading()
try {
const data = {}
data.status = status
await useBaseFetch(`project/${props.project.id}`, {
await useBaseFetch(`project/${project.value.id}`, {
method: 'PATCH',
body: data,
})
const project = props.project
project.status = status
await props.resetProject()
project.value.status = status
await refreshProject()
thread.value = await useBaseFetch(`thread/${thread.value.id}`)
} catch (err) {
addNotification({

View File

@@ -14,10 +14,10 @@ import {
import {
commonMessages,
commonProjectSettingsMessages,
injectNotificationManager,
injectProjectPageContext,
useVIntl,
} from '@modrinth/ui'
import { isStaff, type Project, type ProjectV3Partial } from '@modrinth/utils'
import { isStaff } from '@modrinth/utils'
import { useLocalStorage, useScroll } from '@vueuse/core'
import { computed } from 'vue'
@@ -26,32 +26,22 @@ import NavStack from '~/components/ui/NavStack.vue'
const { formatMessage } = useVIntl()
const props = defineProps<{
currentMember: any
patchProject: any
patchIcon: any
resetProject: any
resetVersions: any
resetOrganization: any
resetMembers: any
}>()
const {
projectV2: project,
projectV3,
versions,
currentMember,
setProcessing,
} = injectProjectPageContext()
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 base = `${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
const showEnvironment =
projectV3.value.project_types.some((type) => ['mod', 'modpack'].includes(type)) &&
isStaff(props.currentMember?.user)
projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) &&
isStaff(currentMember.value?.user)
const items = [
{
@@ -118,35 +108,10 @@ const navItems = computed(() => {
return items.filter(Boolean) as any[]
})
const { addNotification } = injectNotificationManager()
const tags = useGeneratedState()
const route = useRoute()
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
// This scroll code is jank asf, if anyone has a better way please do suggest it
const scroll = useScroll(window)
@@ -167,7 +132,7 @@ watch(route, () => {
:versions="versions"
:current-member="currentMember"
:collapsed="collapsedChecklist"
:route-name="route.name as string"
:route-name="route.name"
:tags="tags"
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing"
@@ -177,22 +142,7 @@ watch(route, () => {
<NavStack :items="navItems" />
</div>
<div class="min-w-0">
<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"
/>
<NuxtPage />
</div>
</div>
</div>

View File

@@ -11,21 +11,16 @@
</p>
</div>
<ChartDisplay :projects="[props.project]" />
<ChartDisplay :projects="[project]" />
</div>
</template>
<script setup>
import { injectProjectPageContext } from '@modrinth/ui'
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
})
const { projectV2: project } = injectProjectPageContext()
</script>
<style scoped lang="scss">

View File

@@ -17,7 +17,7 @@
v-model="description"
:disabled="
!currentMember ||
(currentMember.permissions & TeamMemberPermission.EDIT_BODY) !==
(currentMember?.permissions! & TeamMemberPermission.EDIT_BODY) !==
TeamMemberPermission.EDIT_BODY
"
:on-image-upload="onUploadHandler"
@@ -44,20 +44,15 @@
<script lang="ts" setup>
import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets'
import { countText, MIN_DESCRIPTION_CHARS } from '@modrinth/moderation'
import { MarkdownEditor } from '@modrinth/ui'
import { type Project, type TeamMember, TeamMemberPermission } from '@modrinth/utils'
import { injectProjectPageContext, MarkdownEditor } from '@modrinth/ui'
import { TeamMemberPermission } from '@modrinth/utils'
import { computed, ref } from 'vue'
import { useImageUpload } from '~/composables/image-upload.ts'
const props = defineProps<{
project: Project
allMembers: TeamMember[]
currentMember: TeamMember | undefined
patchProject: (payload: object, quiet?: boolean) => object
}>()
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
const description = ref(props.project.body)
const description = ref(project.value.body)
const descriptionWarning = computed(() => {
const text = description.value?.trim() || ''
@@ -75,7 +70,7 @@ const patchRequestPayload = computed(() => {
body?: string
} = {}
if (description.value !== props.project.body) {
if (description.value !== project.value.body) {
payload.body = description.value
}
@@ -87,13 +82,13 @@ const hasChanges = computed(() => {
})
function saveChanges() {
props.patchProject(patchRequestPayload.value)
patchProject(patchRequestPayload.value)
}
async function onUploadHandler(file: File) {
const response = await useImageUpload(file, {
context: 'project',
projectID: props.project.id,
projectID: project.value.id,
})
return response.url

View File

@@ -300,33 +300,16 @@ import {
DropArea,
FileInput,
injectNotificationManager,
injectProjectPageContext,
NewModal as Modal,
} from '@modrinth/ui'
import { isPermission } from '~/utils/permissions.ts'
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
resetProject: {
type: Function,
required: true,
default: () => {},
},
})
const { projectV2: project, currentMember } = injectProjectPageContext()
const title = `${props.project.title} - Gallery`
const description = `View ${props.project.gallery.length} images of ${props.project.title} on Modrinth.`
const title = `${project.value.title} - Gallery`
const description = `View ${project.value.gallery?.length ?? 0} images of ${project.value.title} on Modrinth.`
useSeoMeta({
title,
@@ -340,9 +323,12 @@ useSeoMeta({
export default defineNuxtComponent({
setup() {
const { addNotification } = injectNotificationManager()
const { projectV2: project, refreshProject } = injectProjectPageContext()
return {
addNotification,
project,
refreshProject,
}
},
data() {
@@ -456,7 +442,7 @@ export default defineNuxtComponent({
method: 'POST',
body: this.editFile,
})
await this.resetProject()
await this.refreshProject()
this.$refs.modal_edit_item.hide()
} catch (err) {
@@ -492,7 +478,7 @@ export default defineNuxtComponent({
method: 'PATCH',
})
await this.resetProject()
await this.refreshProject()
this.$refs.modal_edit_item.hide()
} catch (err) {
this.addNotification({
@@ -518,7 +504,7 @@ export default defineNuxtComponent({
},
)
await this.resetProject()
await this.refreshProject()
} catch (err) {
this.addNotification({
title: 'An error occurred',

View File

@@ -256,7 +256,12 @@ import {
XIcon,
} from '@modrinth/assets'
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 { Multiselect } from 'vue-multiselect'
@@ -264,67 +269,41 @@ import FileInput from '~/components/ui/FileInput.vue'
import { useFeatureFlags } from '~/composables/featureFlags.ts'
const { addNotification } = injectNotificationManager()
const {
projectV2: project,
currentMember,
patchProject,
patchIcon,
refreshProject,
} = injectProjectPageContext()
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 router = useNativeRouter()
const name = ref(props.project.title)
const slug = ref(props.project.slug)
const summary = ref(props.project.description)
const name = ref(project.value.title)
const slug = ref(project.value.slug)
const summary = ref(project.value.description)
const icon = ref(null)
const previewImage = ref(null)
const clientSide = ref(props.project.client_side)
const serverSide = ref(props.project.server_side)
const clientSide = ref(project.value.client_side)
const serverSide = ref(project.value.server_side)
const deletedIcon = ref(false)
const visibility = ref(
tags.value.approvedStatuses.includes(props.project.status)
? props.project.status
: props.project.requested_status,
tags.value.approvedStatuses.includes(project.value.status)
? project.value.status
: project.value.requested_status,
)
const hasPermission = computed(() => {
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 DELETE_PROJECT = 1 << 7
return (props.currentMember?.permissions & DELETE_PROJECT) === DELETE_PROJECT
return ((currentMember.value?.permissions ?? 0) & DELETE_PROJECT) === DELETE_PROJECT
})
const summaryWarning = computed(() => {
@@ -343,26 +322,26 @@ const sideTypes = ['required', 'optional', 'unsupported']
const patchData = computed(() => {
const data = {}
if (name.value !== props.project.title) {
if (name.value !== project.value.title) {
data.title = name.value.trim()
}
if (slug.value !== props.project.slug) {
if (slug.value !== project.value.slug) {
data.slug = slug.value.trim()
}
if (summary.value !== props.project.description) {
if (summary.value !== project.value.description) {
data.description = summary.value.trim()
}
if (clientSide.value !== props.project.client_side) {
if (clientSide.value !== project.value.client_side) {
data.client_side = clientSide.value
}
if (serverSide.value !== props.project.server_side) {
if (serverSide.value !== project.value.server_side) {
data.server_side = serverSide.value
}
if (tags.value.approvedStatuses.includes(props.project.status)) {
if (visibility.value !== props.project.status) {
if (tags.value.approvedStatuses.includes(project.value.status)) {
if (visibility.value !== project.value.status) {
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
}
@@ -374,23 +353,23 @@ const hasChanges = computed(() => {
})
const hasModifiedVisibility = () => {
const originalVisibility = tags.value.approvedStatuses.includes(props.project.status)
? props.project.status
: props.project.requested_status
const originalVisibility = tags.value.approvedStatuses.includes(project.value.status)
? project.value.status
: project.value.requested_status
return originalVisibility !== visibility.value
}
const saveChanges = async () => {
if (hasChanges.value) {
await props.patchProject(patchData.value)
await patchProject(patchData.value)
}
if (deletedIcon.value) {
await deleteIcon()
deletedIcon.value = false
} else if (icon.value) {
await props.patchIcon(icon.value)
await patchIcon(icon.value)
icon.value = null
}
}
@@ -401,12 +380,12 @@ const showPreviewImage = (files) => {
deletedIcon.value = false
reader.readAsDataURL(icon.value)
reader.onload = (event) => {
previewImage.value = event.target.result
previewImage.value = event.target?.result
}
}
const deleteProject = async () => {
await useBaseFetch(`project/${props.project.id}`, {
await useBaseFetch(`project/${project.value.id}`, {
method: 'DELETE',
})
await initUserProjects()
@@ -425,10 +404,10 @@ const markIconForDeletion = () => {
}
const deleteIcon = async () => {
await useBaseFetch(`project/${props.project.id}/icon`, {
await useBaseFetch(`project/${project.value.id}/icon`, {
method: 'DELETE',
})
await props.resetProject()
await refreshProject()
addNotification({
title: 'Project icon removed',
text: "Your project's icon has been removed.",

View File

@@ -155,24 +155,18 @@
<script setup lang="ts">
import { SaveIcon } from '@modrinth/assets'
import { Checkbox, DropdownSelect } from '@modrinth/ui'
import { Checkbox, DropdownSelect, injectProjectPageContext } from '@modrinth/ui'
import {
type BuiltinLicense,
builtinLicenses,
formatProjectType,
type Project,
type TeamMember,
TeamMemberPermission,
} from '@modrinth/utils'
import { computed, type Ref, ref } from 'vue'
const props = defineProps<{
project: Project
currentMember: TeamMember | undefined
patchProject: (payload: object, quiet?: boolean) => object
}>()
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
const licenseUrl = ref(props.project.license.url)
const licenseUrl = ref(project.value.license.url)
const license: Ref<{
friendly: string
short: string
@@ -183,10 +177,10 @@ const license: Ref<{
requiresOnlyOrLater: false,
})
const allowOrLater = ref(props.project.license.id.includes('-or-later'))
const nonSpdxLicense = ref(props.project.license.id.includes('LicenseRef-'))
const allowOrLater = ref(project.value.license.id.includes('-or-later'))
const nonSpdxLicense = ref(project.value.license.id.includes('LicenseRef-'))
const oldLicenseId = props.project.license.id
const oldLicenseId = project.value.license.id
const trimmedLicenseId = oldLicenseId
.replaceAll('-only', '')
.replaceAll('-or-later', '')
@@ -208,7 +202,7 @@ if (oldLicenseId === 'LicenseRef-Unknown') {
}
const hasPermission = computed(() => {
return (props.currentMember?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS
return (currentMember.value?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS
})
const licenseId = computed(() => {
@@ -240,11 +234,11 @@ const patchRequestPayload = computed(() => {
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
}
if (licenseUrl.value !== props.project.license.url) {
if (licenseUrl.value !== project.value.license.url) {
payload.license_url = licenseUrl.value ? licenseUrl.value : null
}
@@ -256,6 +250,6 @@ const hasChanges = computed(() => {
})
function saveChanges() {
props.patchProject(patchRequestPayload.value)
patchProject(patchRequestPayload.value)
}
</script>

View File

@@ -174,35 +174,16 @@
<script setup>
import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets'
import { commonLinkDomains, isCommonUrl, isDiscordUrl, isLinkShortener } from '@modrinth/moderation'
import { DropdownSelect } from '@modrinth/ui'
import { DropdownSelect, injectProjectPageContext } from '@modrinth/ui'
const tags = useGeneratedState()
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
patchProject: {
type: Function,
default() {
return () => {}
},
},
})
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
const issuesUrl = ref(props.project.issues_url)
const sourceUrl = ref(props.project.source_url)
const wikiUrl = ref(props.project.wiki_url)
const discordUrl = ref(props.project.discord_url)
const issuesUrl = ref(project.value.issues_url)
const sourceUrl = ref(project.value.source_url)
const wikiUrl = ref(project.value.wiki_url)
const discordUrl = ref(project.value.discord_url)
const isIssuesUrlCommon = computed(() => {
if (!issuesUrl.value || issuesUrl.value.trim().length === 0) return true
@@ -244,7 +225,7 @@ const isDiscordLinkShortener = computed(() => {
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({
id: null,
platform: null,
@@ -254,32 +235,32 @@ const donationLinks = ref(rawDonationLinks)
const hasPermission = computed(() => {
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 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()
}
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()
}
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()
}
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()
}
const validDonationLinks = donationLinks.value.filter((link) => link.url && link.id)
if (
validDonationLinks !== props.project.donation_urls &&
validDonationLinks !== project.value.donation_urls &&
!(
props.project.donation_urls &&
props.project.donation_urls.length === 0 &&
project.value.donation_urls &&
project.value.donation_urls.length === 0 &&
validDonationLinks.length === 0
)
) {
@@ -301,8 +282,8 @@ const hasChanges = computed(() => {
})
async function saveChanges() {
if (patchData.value && (await props.patchProject(patchData.value))) {
donationLinks.value = JSON.parse(JSON.stringify(props.project.donation_urls))
if (patchData.value && (await patchProject(patchData.value))) {
donationLinks.value = JSON.parse(JSON.stringify(project.value.donation_urls))
donationLinks.value.push({
id: null,
platform: null,

View File

@@ -27,13 +27,13 @@
v-model="currentUsername"
type="text"
placeholder="Username"
:disabled="(props.currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES"
:disabled="(currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES"
@keypress.enter="inviteTeamMember()"
/>
<label for="username" class="hidden">Username</label>
<button
class="iconified-button brand-button"
:disabled="(props.currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES"
:disabled="(currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES"
@click="inviteTeamMember()"
>
<UserPlusIcon />
@@ -47,11 +47,9 @@
</span>
<button
class="iconified-button danger-button"
:disabled="props.currentMember?.is_owner"
:disabled="currentMember?.is_owner"
:title="
props.currentMember?.is_owner
? 'You cannot leave the project if you are the owner!'
: ''
currentMember?.is_owner ? 'You cannot leave the project if you are the owner!' : ''
"
@click="leaveProject()"
>
@@ -104,7 +102,7 @@
:id="`member-${allTeamMembers[index].user.username}-role`"
v-model="allTeamMembers[index].role"
type="text"
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
:disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
/>
</div>
<div class="adjacent-input">
@@ -119,7 +117,7 @@
:id="`member-${allTeamMembers[index].user.username}-monetization-weight`"
v-model="allTeamMembers[index].payouts_split"
type="number"
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
:disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
/>
</div>
<template v-if="!member.is_owner">
@@ -130,8 +128,8 @@
<Checkbox
:model-value="(member?.permissions & UPLOAD_VERSION) === UPLOAD_VERSION"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION
"
label="Upload version"
@update:model-value="allTeamMembers[index].permissions ^= UPLOAD_VERSION"
@@ -139,8 +137,8 @@
<Checkbox
:model-value="(member?.permissions & DELETE_VERSION) === DELETE_VERSION"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & DELETE_VERSION) !== DELETE_VERSION
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & DELETE_VERSION) !== DELETE_VERSION
"
label="Delete version"
@update:model-value="allTeamMembers[index].permissions ^= DELETE_VERSION"
@@ -148,8 +146,8 @@
<Checkbox
:model-value="(member?.permissions & EDIT_DETAILS) === EDIT_DETAILS"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & EDIT_DETAILS) !== EDIT_DETAILS
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
label="Edit details"
@update:model-value="allTeamMembers[index].permissions ^= EDIT_DETAILS"
@@ -157,8 +155,8 @@
<Checkbox
:model-value="(member?.permissions & EDIT_BODY) === EDIT_BODY"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & EDIT_BODY) !== EDIT_BODY
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & EDIT_BODY) !== EDIT_BODY
"
label="Edit body"
@update:model-value="allTeamMembers[index].permissions ^= EDIT_BODY"
@@ -166,8 +164,8 @@
<Checkbox
:model-value="(member?.permissions & MANAGE_INVITES) === MANAGE_INVITES"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES
"
label="Manage invites"
@update:model-value="allTeamMembers[index].permissions ^= MANAGE_INVITES"
@@ -175,23 +173,23 @@
<Checkbox
:model-value="(member?.permissions & REMOVE_MEMBER) === REMOVE_MEMBER"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER
"
label="Remove member"
@update:model-value="allTeamMembers[index].permissions ^= REMOVE_MEMBER"
/>
<Checkbox
: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"
@update:model-value="allTeamMembers[index].permissions ^= EDIT_MEMBER"
/>
<Checkbox
:model-value="(member?.permissions & DELETE_PROJECT) === DELETE_PROJECT"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & DELETE_PROJECT) !== DELETE_PROJECT
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & DELETE_PROJECT) !== DELETE_PROJECT
"
label="Delete project"
@update:model-value="allTeamMembers[index].permissions ^= DELETE_PROJECT"
@@ -199,8 +197,8 @@
<Checkbox
:model-value="(member?.permissions & VIEW_ANALYTICS) === VIEW_ANALYTICS"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS
"
label="View analytics"
@update:model-value="allTeamMembers[index].permissions ^= VIEW_ANALYTICS"
@@ -208,8 +206,8 @@
<Checkbox
:model-value="(member?.permissions & VIEW_PAYOUTS) === VIEW_PAYOUTS"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS
"
label="View revenue"
@update:model-value="allTeamMembers[index].permissions ^= VIEW_PAYOUTS"
@@ -219,7 +217,7 @@
<div class="input-group">
<button
class="iconified-button brand-button"
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
:disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
@click="updateTeamMember(index)"
>
<SaveIcon />
@@ -228,14 +226,14 @@
<button
v-if="!member.is_owner"
class="iconified-button danger-button"
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
:disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
@click="removeTeamMember(index)"
>
<UserXIcon />
Remove member
</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"
@click="transferOwnership(index)"
>
@@ -249,26 +247,26 @@
<div class="label">
<span class="label__title size-card-header">Organization</span>
</div>
<div v-if="props.organization">
<div v-if="organization">
<p>
This project is managed by {{ props.organization.name }}. The defaults for member
permissions are set in the
<nuxt-link :to="`/organization/${props.organization.slug}/settings/members`">
This project is managed by {{ organization.name }}. The defaults for member permissions
are set in the
<nuxt-link :to="`/organization/${organization.slug}/settings/members`">
organization settings
</nuxt-link>
. You may override them below.
</p>
<nuxt-link
:to="`/organization/${props.organization.slug}`"
:to="`/organization/${organization.slug}`"
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="title">
{{ props.organization.name }}
{{ organization.name }}
</div>
<div class="description">
{{ props.organization.description }}
{{ organization.description }}
</div>
<span class="stat-bar">
<div class="stats">
@@ -288,7 +286,7 @@
This project is not managed by an organization. If you are the member of any organizations,
you can transfer management to one of them.
</p>
<div v-if="!props.organization" class="input-group">
<div v-if="!organization" class="input-group">
<Multiselect
id="organization-picker"
v-model="selectedOrganization"
@@ -300,14 +298,14 @@
:show-labels="false"
:allow-empty="false"
: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">
<CheckIcon />
Transfer management
</button>
</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 />
Remove from organization
</button>
@@ -358,7 +356,7 @@
v-model="allOrgMembers[index].override"
class="switch stylized-toggle"
type="checkbox"
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
:disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
/>
</div>
<div class="adjacent-input">
@@ -373,7 +371,7 @@
v-model="allOrgMembers[index].role"
type="text"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
!allOrgMembers[index].override
"
/>
@@ -391,7 +389,7 @@
v-model="allOrgMembers[index].payouts_split"
type="number"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
!allOrgMembers[index].override
"
/>
@@ -404,8 +402,8 @@
<Checkbox
:model-value="(member?.permissions & UPLOAD_VERSION) === UPLOAD_VERSION"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION ||
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION ||
!allOrgMembers[index].override
"
label="Upload version"
@@ -414,8 +412,8 @@
<Checkbox
:model-value="(member?.permissions & DELETE_VERSION) === DELETE_VERSION"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & DELETE_VERSION) !== DELETE_VERSION ||
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & DELETE_VERSION) !== DELETE_VERSION ||
!allOrgMembers[index].override
"
label="Delete version"
@@ -424,8 +422,8 @@
<Checkbox
:model-value="(member?.permissions & EDIT_DETAILS) === EDIT_DETAILS"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & EDIT_DETAILS) !== EDIT_DETAILS ||
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & EDIT_DETAILS) !== EDIT_DETAILS ||
!allOrgMembers[index].override
"
label="Edit details"
@@ -434,8 +432,8 @@
<Checkbox
:model-value="(member?.permissions & EDIT_BODY) === EDIT_BODY"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & EDIT_BODY) !== EDIT_BODY ||
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & EDIT_BODY) !== EDIT_BODY ||
!allOrgMembers[index].override
"
label="Edit body"
@@ -444,8 +442,8 @@
<Checkbox
:model-value="(member?.permissions & MANAGE_INVITES) === MANAGE_INVITES"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES ||
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES ||
!allOrgMembers[index].override
"
label="Manage invites"
@@ -454,8 +452,8 @@
<Checkbox
:model-value="(member?.permissions & REMOVE_MEMBER) === REMOVE_MEMBER"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER ||
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER ||
!allOrgMembers[index].override
"
label="Remove member"
@@ -464,7 +462,7 @@
<Checkbox
:model-value="(member?.permissions & EDIT_MEMBER) === EDIT_MEMBER"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
!allOrgMembers[index].override
"
label="Edit member"
@@ -473,8 +471,8 @@
<Checkbox
:model-value="(member?.permissions & DELETE_PROJECT) === DELETE_PROJECT"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & DELETE_PROJECT) !== DELETE_PROJECT ||
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & DELETE_PROJECT) !== DELETE_PROJECT ||
!allOrgMembers[index].override
"
label="Delete project"
@@ -483,8 +481,8 @@
<Checkbox
:model-value="(member?.permissions & VIEW_ANALYTICS) === VIEW_ANALYTICS"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS ||
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS ||
!allOrgMembers[index].override
"
label="View analytics"
@@ -493,8 +491,8 @@
<Checkbox
:model-value="(member?.permissions & VIEW_PAYOUTS) === VIEW_PAYOUTS"
:disabled="
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(props.currentMember?.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS ||
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember?.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS ||
!allOrgMembers[index].override
"
label="View revenue"
@@ -505,7 +503,7 @@
<div class="input-group">
<button
class="iconified-button brand-button"
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
:disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
@click="updateOrgMember(index)"
>
<SaveIcon />
@@ -536,54 +534,22 @@ import {
Checkbox,
ConfirmModal,
injectNotificationManager,
injectProjectPageContext,
} from '@modrinth/ui'
import { Multiselect } from 'vue-multiselect'
import { removeSelfFromTeam } from '~/helpers/teams.js'
const { addNotification } = injectNotificationManager()
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
organization: {
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 {
projectV2: project,
organization,
allMembers,
currentMember,
refreshProject,
refreshOrganization,
refreshMembers,
} = injectProjectPageContext()
const cosmetics = useCosmetics()
const auth = await useAuth()
@@ -592,14 +558,14 @@ const allTeamMembers = ref([])
const allOrgMembers = ref([])
const acceptedOrgMembers = computed(() => {
return props.organization?.members?.filter((x) => x.accepted) || []
return organization.value?.members?.filter((x) => x.accepted) || []
})
function initMembers() {
const orgMembers = props.organization?.members || []
const orgMembers = organization.value?.members || []
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
// If replacing a partial with a full member, we need to mark as such.
@@ -613,20 +579,12 @@ function initMembers() {
allOrgMembers.value = selectedMembersForOrg
allTeamMembers.value = props.allMembers.filter(
allTeamMembers.value = allMembers.value.filter(
(x) => !selectedMembersForOrg.some((y) => y.user.id === x.user.id),
)
}
watch(
[
() => props.allMembers,
() => props.organization,
() => props.project,
() => props.currentMember,
],
initMembers,
)
watch([allMembers, organization, project, currentMember], initMembers)
initMembers()
const currentUsername = ref('')
@@ -656,7 +614,7 @@ const onAddToOrg = useClientTry(async () => {
await useBaseFetch(`organization/${selectedOrganization.value.id}/projects`, {
method: 'POST',
body: JSON.stringify({
project_id: props.project.id,
project_id: project.value.id,
}),
apiVersion: 3,
})
@@ -671,9 +629,9 @@ const onAddToOrg = 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',
body: JSON.stringify({
new_owner: auth.value.user.id,
@@ -691,7 +649,7 @@ const onRemoveFromOrg = useClientTry(async () => {
})
const leaveProject = async () => {
await removeSelfFromTeam(props.project.team)
await removeSelfFromTeam(project.value.team)
navigateTo('/dashboard/projects')
}
@@ -703,7 +661,7 @@ const inviteTeamMember = async () => {
const data = {
user_id: user.id.trim(),
}
await useBaseFetch(`team/${props.project.team}/members`, {
await useBaseFetch(`team/${project.value.team}/members`, {
method: 'POST',
body: data,
})
@@ -725,7 +683,7 @@ const removeTeamMember = async (index) => {
try {
await useBaseFetch(
`team/${props.project.team}/members/${allTeamMembers.value[index].user.id}`,
`team/${project.value.team}/members/${allTeamMembers.value[index].user.id}`,
{
method: 'DELETE',
},
@@ -758,7 +716,7 @@ const updateTeamMember = async (index) => {
}
await useBaseFetch(
`team/${props.project.team}/members/${allTeamMembers.value[index].user.id}`,
`team/${project.value.team}/members/${allTeamMembers.value[index].user.id}`,
{
method: 'PATCH',
body: data,
@@ -785,7 +743,7 @@ const transferOwnership = async (index) => {
startLoading()
try {
await useBaseFetch(`team/${props.project.team}/owner`, {
await useBaseFetch(`team/${project.value.team}/owner`, {
method: 'PATCH',
body: {
user_id: allTeamMembers.value[index].user.id,
@@ -808,7 +766,7 @@ async function updateOrgMember(index) {
try {
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',
body: {
permissions: allOrgMembers.value[index].permissions,
@@ -819,14 +777,14 @@ async function updateOrgMember(index) {
})
} else if (!allOrgMembers.value[index].override && allOrgMembers.value[index].oldOverride) {
await useBaseFetch(
`team/${props.project.team}/members/${allOrgMembers.value[index].user.id}`,
`team/${project.value.team}/members/${allOrgMembers.value[index].user.id}`,
{
method: 'DELETE',
},
)
} else {
await useBaseFetch(
`team/${props.project.team}/members/${allOrgMembers.value[index].user.id}`,
`team/${project.value.team}/members/${allOrgMembers.value[index].user.id}`,
{
method: 'PATCH',
body: {
@@ -850,7 +808,7 @@ async function updateOrgMember(index) {
}
const updateMembers = async () => {
await Promise.all([props.resetProject(), props.resetOrganization(), props.resetMembers()])
await Promise.all([refreshProject(), refreshOrganization(), refreshMembers()])
}
</script>

View File

@@ -134,12 +134,11 @@
<script setup lang="ts">
import { SaveIcon, StarIcon, TriangleAlertIcon } from '@modrinth/assets'
import { Checkbox } from '@modrinth/ui'
import { Checkbox, injectProjectPageContext } from '@modrinth/ui'
import {
formatCategory,
formatCategoryHeader,
formatProjectType,
type Project,
sortedCategories,
} from '@modrinth/utils'
import { computed, ref } from 'vue'
@@ -151,50 +150,31 @@ interface Category {
project_type: string
}
interface Props {
project: Project & {
actualProjectType: string
}
allMembers?: any[]
currentMember?: any
patchProject?: (data: any) => void
}
const tags = useGeneratedState()
const props = withDefaults(defineProps<Props>(), {
allMembers: () => [],
currentMember: null,
patchProject: () => {
addNotification({
title: 'An error occurred',
text: 'Patch project function not found',
type: 'error',
})
},
})
const { projectV2: project, patchProject } = injectProjectPageContext()
const selectedTags = ref<Category[]>(
sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === props.project.actualProjectType &&
(props.project.categories.includes(x.name) ||
props.project.additional_categories.includes(x.name)),
x.project_type === project.value.actualProjectType &&
(project.value.categories.includes(x.name) ||
project.value.additional_categories.includes(x.name)),
),
)
const featuredTags = ref<Category[]>(
sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === props.project.actualProjectType &&
props.project.categories.includes(x.name),
x.project_type === project.value.actualProjectType &&
project.value.categories.includes(x.name),
),
)
const categoryLists = computed(() => {
const lists: Record<string, 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
if (!lists[header]) {
lists[header] = []
@@ -214,7 +194,7 @@ const tooManyTagsWarning = 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) =>
['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+'].includes(tag.name),
@@ -235,7 +215,7 @@ const multipleResolutionTagsWarning = computed(() => {
const allTagsSelectedWarning = computed(() => {
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
@@ -268,15 +248,15 @@ const patchData = computed(() => {
.map((x) => x.name)
if (
categories.length !== props.project.categories.length ||
categories.some((value) => !props.project.categories.includes(value))
categories.length !== project.value.categories.length ||
categories.some((value) => !project.value.categories.includes(value))
) {
data.categories = categories
}
if (
additionalCategories.length !== props.project.additional_categories.length ||
additionalCategories.some((value) => !props.project.additional_categories.includes(value))
additionalCategories.length !== project.value.additional_categories.length ||
additionalCategories.some((value) => !project.value.additional_categories.includes(value))
) {
data.additional_categories = additionalCategories
}
@@ -309,7 +289,7 @@ const toggleFeaturedCategory = (category: Category) => {
const saveChanges = () => {
if (hasChanges.value) {
props.patchProject(patchData.value)
patchProject(patchData.value)
}
}
</script>

View File

@@ -16,7 +16,7 @@
/>
<ProjectPageVersions
v-if="versions.length > 0"
v-if="versions?.length"
:project="project"
:versions="versionsWithDisplayUrl"
:show-files="flags.showVersionFilesInTable"
@@ -207,7 +207,7 @@
</template>
</ProjectPageVersions>
<template v-if="!versions.length">
<template v-if="!versions?.length">
<div class="grid place-content-center py-10">
<svg
width="250"
@@ -309,18 +309,9 @@ import { useTemplateRef } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
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 { addNotification } = injectNotificationManager()
const { refreshVersions } = injectProjectPageContext()
const { projectV2: project, currentMember, versions, refreshVersions } = injectProjectPageContext()
const tags = useGeneratedState()
const flags = useFeatureFlags()
@@ -331,7 +322,7 @@ const deleteVersionModal = ref<InstanceType<typeof ConfirmModal>>()
const selectedVersion = ref<string | null>(null)
const handleOpenCreateVersionModal = () => {
if (!currentMember) return
if (!currentMember.value) return
createProjectVersionModal.value?.openCreateVersionModal()
}
@@ -340,12 +331,12 @@ const handleOpenEditVersionModal = (
projectId: string,
stageId?: string | null,
) => {
if (!currentMember) return
if (!currentMember.value) return
createProjectVersionModal.value?.openEditVersionModal(versionId, projectId, stageId)
}
const versionsWithDisplayUrl = computed(() =>
versions.value.map((v) => ({
(versions.value ?? []).map((v) => ({
...v,
displayUrlEnding: v.id,
})),

File diff suppressed because it is too large Load Diff

View File

@@ -1,260 +1,272 @@
<template>
<section class="experimental-styles-within overflow-visible">
<CreateProjectVersionModal
v-if="currentMember"
ref="create-project-version-modal"
></CreateProjectVersionModal>
<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"
<!-- Loading state -->
<div
v-if="versionsLoading && !versions?.length"
class="flex items-center justify-center gap-2 py-8"
>
<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>
<SpinnerIcon class="animate-spin" />
<span>Loading versions...</span>
</div>
<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>
<CreateProjectVersionModal
v-if="currentMember"
ref="create-project-version-modal"
></CreateProjectVersionModal>
<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 }">
<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>
</section>
</template>
@@ -273,6 +285,7 @@ import {
ReportIcon,
SettingsIcon,
ShareIcon,
SpinnerIcon,
TrashIcon,
} from '@modrinth/assets'
import {
@@ -286,57 +299,48 @@ import {
ProjectPageVersions,
} from '@modrinth/ui'
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 { 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 flags = useFeatureFlags()
const auth = await useAuth()
const client = injectModrinthClient()
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 selectedVersion = ref(null)
const createProjectVersionModal = useTemplateRef('create-project-version-modal')
const handleOpenCreateVersionModal = () => {
if (!props.currentMember) return
if (!currentMember.value) return
createProjectVersionModal.value?.openCreateVersionModal()
}
const handleOpenEditVersionModal = (versionId, projectId, stageId) => {
if (!props.currentMember) return
if (!currentMember.value) return
createProjectVersionModal.value?.openEditVersionModal(versionId, projectId, stageId)
}
const hideVersionsAdmonition = useLocalStorage(
'hideVersionsHasMovedAdmonition',
!props.versions.length,
!versions.value?.length,
)
const emit = defineEmits(['onDownload', 'deleteVersion'])

View File

@@ -14,6 +14,9 @@ export default defineNuxtPlugin((nuxt) => {
nuxt.vueApp.use(VueQueryPlugin, options)
// Expose queryClient for middleware and composables
nuxt.provide('queryClient', queryClient)
if (import.meta.server) {
nuxt.hooks.hook('app:rendered', () => {
vueQueryState.value = dehydrate(queryClient)
@@ -21,8 +24,6 @@ export default defineNuxtPlugin((nuxt) => {
}
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'
async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
@@ -27,7 +33,7 @@ export function useServerModrinthClient(options?: ServerModrinthClientOptions):
new AuthFeature({
token: options.authToken,
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
return (perms & bitflag) === bitflag
}

View File

@@ -114,4 +114,25 @@ export class LabrinthProjectsV2Module extends AbstractModule {
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
project_type: ProjectType
team: string
organization: string | null
title: string
description: string
body: string
@@ -271,6 +272,11 @@ export namespace Labrinth {
offset?: number
limit?: number
}
export interface DependencyInfo {
projects: Project[]
versions: Labrinth.Versions.v2.Version[]
}
}
export namespace v3 {
@@ -371,8 +377,8 @@ export namespace Labrinth {
team_id: string
description: string
icon_url: string | null
color: number
members: OrganizationMember[]
color: number | null
members: TeamMember[]
}
export type OrganizationMember = {
@@ -391,11 +397,12 @@ export namespace Labrinth {
team_id: string
user: Users.v3.User
role: string
permissions: number
accepted: boolean
payouts_split: number
ordering: number
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 VersionFileHash = {
sha512: string
sha1: string
}
export type VersionFile = {
hashes: Record<string, string>
hashes: VersionFileHash
url: string
filename: string
primary: boolean
@@ -471,6 +483,8 @@ export namespace Labrinth {
export interface GetProjectVersionsParams {
game_versions?: string[]
loaders?: string[]
include_changelog?: boolean
apiVersion?: 2 | 3
}
export type VersionChannel = 'release' | 'beta' | 'alpha'

View File

@@ -28,17 +28,20 @@ export class LabrinthVersionsV3Module extends AbstractModule {
id: string,
options?: Labrinth.Versions.v3.GetProjectVersionsParams,
): Promise<Labrinth.Versions.v3.Version[]> {
const params: Record<string, string> = {}
const params: Record<string, string | boolean> = {}
if (options?.game_versions?.length) {
params.game_versions = JSON.stringify(options.game_versions)
}
if (options?.loaders?.length) {
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`, {
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',
params: Object.keys(params).length > 0 ? params : undefined,
})

View File

@@ -200,7 +200,7 @@ export const coreNags: Nag[] = [
context.project.source_url ||
context.project.wiki_url ||
context.project.discord_url ||
context.project.donation_urls.length > 0
context.project.donation_urls?.length
),
link: {
path: 'settings/links',

View File

@@ -1,6 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
import type { MessageDescriptor } from '@modrinth/ui'
import type { User, Version } from '@modrinth/utils'
import type { FunctionalComponent, SVGAttributes } from 'vue'
/**
@@ -25,11 +24,11 @@ export interface NagContext {
/**
* The versions associated with the project.
*/
versions: Version[]
versions: Labrinth.Versions.v2.Version[]
/**
* The current project member viewing the nag.
*/
currentMember: User
currentMember: Labrinth.Users.v2.User
/**
* The current route in the application.
*/

View File

@@ -17,14 +17,15 @@
</a>
</ButtonStyled>
<ButtonStyled circular>
<nuxt-link
:to="`/project/${props.version.project_id}/version/${props.version.id}`"
<button
class="min-w-0"
aria-label="Open project page"
@click="emit('onNavigate')"
aria-label="View version"
@click="
emit('onNavigate', `/project/${props.version.project_id}/version/${props.version.id}`)
"
>
<ExternalIcon aria-hidden="true" />
</nuxt-link>
</button>
</ButtonStyled>
</div>
</template>
@@ -45,5 +46,8 @@ const downloadUrl = computed(() => {
return primary.url
})
const emit = defineEmits(['onDownload', 'onNavigate'])
const emit = defineEmits<{
onDownload: []
onNavigate: [url: string]
}>()
</script>

View File

@@ -1,16 +1,36 @@
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 { createContext } from '.'
export interface ProjectPageContext {
// Data refs
projectV2: Ref<Labrinth.Projects.v2.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>
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] =