You've already forked AstralRinth
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:
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
52
apps/frontend/src/composables/queries/project.ts
Normal file
52
apps/frontend/src/composables/queries/project.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
14
apps/frontend/src/composables/query-client.ts
Normal file
14
apps/frontend/src/composables/query-client.ts
Normal 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
|
||||
}
|
||||
@@ -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
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] =
|
||||
|
||||
Reference in New Issue
Block a user