refactor: align files tab with content tab design (#5621)
* fix: files.vue bugs before styling changes * feat: move files tab to shared layout structure * fix: qa * fix: qa * fix: bugs * fix: lint * fix: admonition cleanup with progress + actions * fix: cleanup * fix: modals * fix: admon title * fix: i18n standard * fix: lint + i18n pass * fix: remove transition * fix: type errors * feat: files tab in app * fix: qa * fix: backup item minmax * fix: use ContentPageHeader for server panel * fix: lint * fix: lint * fix: lint * feat: page leave safety * fix: lint * fix: cargo fmt fix * fix: blank in prod * fix: content card table stuff * Revert "fix: blank in prod" This reverts commit 74758fe185cf85a4a20355857f889cb091b97ace. * fix: import * feat: browse worlds/servers flow * fix: worlds tab parity with content tab * fix: perf bug + shader filter pill copy * feat: singleplayer filter * fix: ordering * fix: breadcrumbs * fix: lint * fix: qa * feat: store server proj id when adding to a non-linked instance * fix: lint * fix: i18n + qa * fix: conflict * qa: already installed modal + placeholders not server-specific * fix: qa * fix: add + edit server modals * fix: qa * fix: security * fix: devin flags * fix: lint * chore: change file to break build cache * fix: admon * fix: import path stuff * feat: qa * fix: fmt fmt idiot --------- Signed-off-by: Calum H. <calum@modrinth.com>
This commit is contained in:
@@ -446,13 +446,6 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
input[type='button'] {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
kbd {
|
||||
background-color: var(--color-code-bg);
|
||||
color: var(--color-code-text);
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
<template>
|
||||
<nav
|
||||
v-if="filteredLinks.length > 1"
|
||||
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="{ 'card-shadow': mode === 'navigation' }"
|
||||
>
|
||||
<template v-if="mode === 'navigation'">
|
||||
<NuxtLink
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown ?? true"
|
||||
:key="link.href"
|
||||
ref="tabLinkElements"
|
||||
:replace="replace"
|
||||
: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="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 ?? 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="getIconClasses(index)" />
|
||||
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Animated slider background -->
|
||||
<div
|
||||
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"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const route = useNativeRoute()
|
||||
|
||||
interface Tab {
|
||||
label: string
|
||||
href: string
|
||||
shown?: boolean
|
||||
icon?: Component
|
||||
subpages?: string[]
|
||||
onHover?: () => void
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
replace?: boolean
|
||||
links: Tab[]
|
||||
query?: string
|
||||
mode?: 'navigation' | 'local'
|
||||
activeIndex?: number
|
||||
}>(),
|
||||
{
|
||||
mode: 'navigation',
|
||||
query: undefined,
|
||||
activeIndex: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
// SSR state
|
||||
const sliderReady = ref(false) // Slider is positioned and should be visible
|
||||
const transitionsEnabled = ref(false) // CSS transitions should apply (after first paint)
|
||||
|
||||
// Stagger delays for the trailing edges of the slider animation
|
||||
const sliderDelays = ref({ left: '0ms', top: '0ms', right: '0ms', bottom: '0ms' })
|
||||
|
||||
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 leftDelay = computed(() => sliderDelays.value.left)
|
||||
const rightDelay = computed(() => sliderDelays.value.right)
|
||||
const topDelay = computed(() => sliderDelays.value.top)
|
||||
const bottomDelay = computed(() => sliderDelays.value.bottom)
|
||||
|
||||
const isActiveAndNotSubpage = computed(
|
||||
() => (index: number) => currentActiveIndex.value === index && !subpageSelected.value,
|
||||
)
|
||||
|
||||
function getSSRFallbackClasses(index: number) {
|
||||
if (sliderReady.value) return {}
|
||||
if (currentActiveIndex.value !== index) return {}
|
||||
|
||||
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) {
|
||||
return {
|
||||
index: Math.min(props.activeIndex, filteredLinks.value.length - 1),
|
||||
isSubpage: false,
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
|
||||
const link = filteredLinks.value[i]
|
||||
const decodedPath = decodeURIComponent(route.path)
|
||||
|
||||
// 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 (index === -1) return null
|
||||
|
||||
const container = scrollContainer.value as HTMLElement | undefined
|
||||
if (!container) return null
|
||||
|
||||
const tabs = container.querySelectorAll('.button-animation')
|
||||
const element = tabs[index] as HTMLElement | undefined
|
||||
|
||||
if (!element) return null
|
||||
|
||||
// In navigation mode, elements are NuxtLinks, but since we used querySelectorAll,
|
||||
// we already have the raw HTMLElement ($el), so no further conversion is needed.
|
||||
// In local mode, elements are already plain divs.
|
||||
return 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 = '200ms'
|
||||
|
||||
// Set stagger delays: leading edge moves immediately, trailing edge is delayed
|
||||
sliderDelays.value = {
|
||||
left: newPosition.left < sliderLeft.value ? '0ms' : STAGGER_DELAY,
|
||||
right: newPosition.left < sliderLeft.value ? STAGGER_DELAY : '0ms',
|
||||
top: newPosition.top < sliderTop.value ? '0ms' : STAGGER_DELAY,
|
||||
bottom: newPosition.top < sliderTop.value ? STAGGER_DELAY : '0ms',
|
||||
}
|
||||
|
||||
sliderLeft.value = newPosition.left
|
||||
sliderRight.value = newPosition.right
|
||||
sliderTop.value = newPosition.top
|
||||
sliderBottom.value = newPosition.bottom
|
||||
}
|
||||
|
||||
async function updateActiveTab() {
|
||||
await nextTick()
|
||||
const { index, isSubpage } = computeActiveIndex()
|
||||
currentActiveIndex.value = index
|
||||
subpageSelected.value = isSubpage
|
||||
|
||||
if (index !== -1) {
|
||||
positionSlider()
|
||||
} else {
|
||||
sliderLeft.value = 0
|
||||
sliderRight.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const initialActive = computeActiveIndex()
|
||||
currentActiveIndex.value = initialActive.index
|
||||
subpageSelected.value = initialActive.isSubpage
|
||||
|
||||
onMounted(updateActiveTab)
|
||||
|
||||
watch(
|
||||
() => [route.path, route.query],
|
||||
() => {
|
||||
if (props.mode === 'navigation') {
|
||||
updateActiveTab()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.activeIndex,
|
||||
() => {
|
||||
if (props.mode === 'local') {
|
||||
updateActiveTab()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.links,
|
||||
async () => {
|
||||
await nextTick()
|
||||
updateActiveTab()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navtabs-transition {
|
||||
transition:
|
||||
left 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(leftDelay),
|
||||
right 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(rightDelay),
|
||||
top 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(topDelay),
|
||||
bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay),
|
||||
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||
}
|
||||
|
||||
.card-shadow {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
</style>
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type OverflowMenuOption,
|
||||
useFormatDateTime,
|
||||
} from '@modrinth/ui'
|
||||
import { NavTabs } from '@modrinth/ui'
|
||||
import {
|
||||
capitalizeString,
|
||||
formatProjectType,
|
||||
@@ -41,7 +42,6 @@ import dayjs from 'dayjs'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue'
|
||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||
import ThreadView from '~/components/ui/thread/ThreadView.vue'
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
@@ -51,14 +51,14 @@
|
||||
</NewModal>
|
||||
|
||||
<div class="flex flex-row items-center gap-2 rounded-lg">
|
||||
<ButtonStyled v-if="isInstalling" type="standard" color="brand">
|
||||
<ButtonStyled v-if="isInstalling" type="standard" color="brand" size="large">
|
||||
<button disabled class="flex-shrink-0">
|
||||
<PanelSpinner class="size-5" /> Installing...
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<template v-else>
|
||||
<ButtonStyled v-if="showStopButton" type="transparent">
|
||||
<ButtonStyled v-if="showStopButton" type="transparent" size="large">
|
||||
<button :disabled="!canTakeAction" @click="initiateAction('Stop')">
|
||||
<div class="flex gap-1">
|
||||
<StopCircleIcon class="h-5 w-5" />
|
||||
@@ -67,7 +67,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="standard" color="brand">
|
||||
<ButtonStyled type="standard" color="brand" size="large">
|
||||
<button v-tooltip="busyReason" :disabled="!canTakeAction" @click="handlePrimaryAction">
|
||||
<div v-if="isTransitionState" class="grid place-content-center">
|
||||
<LoadingIcon />
|
||||
@@ -77,7 +77,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled circular type="transparent">
|
||||
<ButtonStyled circular type="transparent" size="large">
|
||||
<TeleportOverflowMenu :options="[...menuOptions]">
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #kill>
|
||||
|
||||
@@ -1076,6 +1076,7 @@ import {
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
NavTabs,
|
||||
NewModal,
|
||||
OpenInAppModal,
|
||||
OverflowMenu,
|
||||
@@ -1115,7 +1116,6 @@ import AutomaticAccordion from '~/components/ui/AutomaticAccordion.vue'
|
||||
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
|
||||
import MessageBanner from '~/components/ui/MessageBanner.vue'
|
||||
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
|
||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
|
||||
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<ConfirmLeaveModal ref="confirmLeaveModal" />
|
||||
<div class="universal-card">
|
||||
<div class="markdown-disclaimer">
|
||||
<h2>Description</h2>
|
||||
@@ -41,9 +42,11 @@
|
||||
import { TriangleAlertIcon } from '@modrinth/assets'
|
||||
import { countText, MIN_DESCRIPTION_CHARS } from '@modrinth/moderation'
|
||||
import {
|
||||
ConfirmLeaveModal,
|
||||
injectProjectPageContext,
|
||||
MarkdownEditor,
|
||||
UnsavedChangesPopup,
|
||||
usePageLeaveSafety,
|
||||
useSavable,
|
||||
} from '@modrinth/ui'
|
||||
import { TeamMemberPermission } from '@modrinth/utils'
|
||||
@@ -53,13 +56,15 @@ import { useImageUpload } from '~/composables/image-upload.ts'
|
||||
|
||||
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
|
||||
|
||||
const { saved, current, saving, reset, save } = useSavable(
|
||||
const { saved, current, saving, hasChanges, reset, save } = useSavable(
|
||||
() => ({ description: project.value.body }),
|
||||
async ({ description }) => {
|
||||
await patchProject({ body: description })
|
||||
},
|
||||
)
|
||||
|
||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
|
||||
const descriptionWarning = computed(() => {
|
||||
const text = current.value.description?.trim() || ''
|
||||
const charCount = countText(text)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ConfirmLeaveModal,
|
||||
defineMessages,
|
||||
IconSelect,
|
||||
injectProjectPageContext,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
SettingsLabel,
|
||||
StyledInput,
|
||||
UnsavedChangesPopup,
|
||||
usePageLeaveSafety,
|
||||
useSavable,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
@@ -15,7 +17,7 @@ const { formatMessage } = useVIntl()
|
||||
|
||||
const { projectV2: project, patchProject } = injectProjectPageContext()
|
||||
|
||||
const { saved, current, saving, reset, save } = useSavable(
|
||||
const { saved, current, saving, hasChanges, reset, save } = useSavable(
|
||||
() => ({
|
||||
title: project.value.title,
|
||||
tagline: project.value.description,
|
||||
@@ -31,6 +33,8 @@ const { saved, current, saving, reset, save } = useSavable(
|
||||
},
|
||||
)
|
||||
|
||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
|
||||
const messages = defineMessages({
|
||||
nameTitle: {
|
||||
id: 'project.settings.general.name.title',
|
||||
@@ -117,6 +121,7 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<ConfirmLeaveModal ref="confirmLeaveModal" />
|
||||
<UnsavedChangesPopup
|
||||
:original="saved"
|
||||
:modified="current"
|
||||
|
||||
@@ -302,6 +302,7 @@
|
||||
@reset="resetChanges"
|
||||
@save="handleSave"
|
||||
/>
|
||||
<ConfirmLeaveModal ref="confirmLeaveModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -319,12 +320,14 @@ import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
|
||||
import {
|
||||
Avatar,
|
||||
Combobox,
|
||||
ConfirmLeaveModal,
|
||||
ConfirmModal,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
StyledInput,
|
||||
UnsavedChangesPopup,
|
||||
usePageLeaveSafety,
|
||||
} from '@modrinth/ui'
|
||||
import { fileIsValid, formatProjectStatus, formatProjectType } from '@modrinth/utils'
|
||||
|
||||
@@ -480,6 +483,12 @@ const modified = computed(() => ({
|
||||
deletedBanner: deletedBanner.value,
|
||||
}))
|
||||
|
||||
const hasChanges = computed(() =>
|
||||
Object.keys(modified.value).some((key) => original.value[key] !== modified.value[key]),
|
||||
)
|
||||
|
||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
|
||||
function resetChanges() {
|
||||
name.value = project.value.title
|
||||
slug.value = project.value.slug
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<ConfirmLeaveModal ref="confirmLeaveModal" />
|
||||
<section class="universal-card">
|
||||
<h2 class="label__title size-card-header">License</h2>
|
||||
<p class="label__description">
|
||||
@@ -154,10 +155,12 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Checkbox,
|
||||
ConfirmLeaveModal,
|
||||
DropdownSelect,
|
||||
injectProjectPageContext,
|
||||
StyledInput,
|
||||
UnsavedChangesPopup,
|
||||
usePageLeaveSafety,
|
||||
useSavable,
|
||||
} from '@modrinth/ui'
|
||||
import {
|
||||
@@ -194,7 +197,7 @@ function getInitialLicense() {
|
||||
)
|
||||
}
|
||||
|
||||
const { saved, current, saving, reset, save } = useSavable(
|
||||
const { saved, current, saving, hasChanges, reset, save } = useSavable(
|
||||
() => ({
|
||||
license: getInitialLicense(),
|
||||
licenseUrl: project.value.license.url ?? '',
|
||||
@@ -219,6 +222,8 @@ const { saved, current, saving, reset, save } = useSavable(
|
||||
},
|
||||
)
|
||||
|
||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
|
||||
const hasPermission = computed(() => {
|
||||
return (currentMember.value?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<ConfirmLeaveModal ref="confirmLeaveModal" />
|
||||
<section class="universal-card">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-2xl font-semibold text-contrast">Server details</div>
|
||||
@@ -161,6 +162,7 @@ import { InfoIcon, RefreshCwIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
ConfirmLeaveModal,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
@@ -169,6 +171,7 @@ import {
|
||||
SERVER_REGIONS,
|
||||
StyledInput,
|
||||
UnsavedChangesPopup,
|
||||
usePageLeaveSafety,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
@@ -364,6 +367,19 @@ const modified = computed(() => ({
|
||||
languages: languages.value,
|
||||
}))
|
||||
|
||||
const hasChanges = computed(() =>
|
||||
Object.keys(original.value).some((key) => {
|
||||
const a = original.value[key]
|
||||
const b = modified.value[key]
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
return a.length !== b.length || a.some((v, i) => v !== b[i])
|
||||
}
|
||||
return a !== b
|
||||
}),
|
||||
)
|
||||
|
||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
|
||||
function resetChanges() {
|
||||
javaAddress.value = projectV3.value?.minecraft_java_server?.address ?? ''
|
||||
bedrockAddress.value = projectV3.value?.minecraft_bedrock_server?.address ?? ''
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<ConfirmLeaveModal ref="confirmLeaveModal" />
|
||||
<section class="universal-card">
|
||||
<div class="label">
|
||||
<h3>
|
||||
@@ -143,11 +144,13 @@ import {
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Checkbox,
|
||||
ConfirmLeaveModal,
|
||||
formatCategory,
|
||||
formatCategoryHeader,
|
||||
FormattedTag,
|
||||
injectProjectPageContext,
|
||||
UnsavedChangesPopup,
|
||||
usePageLeaveSafety,
|
||||
useSavable,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
@@ -187,7 +190,7 @@ const matchesProjectType = (x: Category) => {
|
||||
}
|
||||
}
|
||||
|
||||
const { saved, current, saving, reset, save } = useSavable(
|
||||
const { saved, current, saving, hasChanges, reset, save } = useSavable(
|
||||
() => ({
|
||||
selectedTags: sortedCategories(tags.value, formatCategoryName, locale.value).filter(
|
||||
(x: Category) =>
|
||||
@@ -237,6 +240,8 @@ const { saved, current, saving, reset, save } = useSavable(
|
||||
},
|
||||
)
|
||||
|
||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
|
||||
const categoryLists = computed(() => {
|
||||
const lists: Record<string, Category[]> = {}
|
||||
sortedCategories(tags.value, formatCategoryName, locale.value).forEach((x: Category) => {
|
||||
|
||||
@@ -397,6 +397,7 @@ import {
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
NavTabs,
|
||||
NewModal,
|
||||
normalizeChildren,
|
||||
NormalPage,
|
||||
@@ -417,7 +418,6 @@ import { useQuery } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const api = injectModrinthClient()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { commonProjectTypeCategoryMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||
import { commonProjectTypeCategoryMessages, NavTabs, useVIntl } from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
|
||||
@@ -103,68 +103,89 @@
|
||||
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
|
||||
}"
|
||||
>
|
||||
<div class="border-0 border-b border-solid border-divider pb-4">
|
||||
<NuxtLink to="/hosting/manage" class="breadcrumb goto-link flex w-fit items-center">
|
||||
<LeftArrowIcon />
|
||||
All servers
|
||||
</NuxtLink>
|
||||
<div class="flex w-full min-w-0 select-none flex-col items-center gap-4 pt-4 sm:flex-row">
|
||||
<NuxtLink to="/hosting/manage" class="breadcrumb goto-link flex w-fit items-center">
|
||||
<LeftArrowIcon />
|
||||
All servers
|
||||
</NuxtLink>
|
||||
<ContentPageHeader>
|
||||
<template #icon>
|
||||
<ServerIcon
|
||||
:image="
|
||||
serverData.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverImage
|
||||
"
|
||||
class="drop-shadow-lg sm:drop-shadow-none"
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
{{ serverData.name }}
|
||||
</template>
|
||||
<template #stats>
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start"
|
||||
v-if="serverData.flows?.intro"
|
||||
class="flex items-center gap-2 font-semibold text-secondary"
|
||||
>
|
||||
<div class="flex w-full flex-col items-center gap-4 sm:flex-row">
|
||||
<h1
|
||||
class="m-0 w-screen flex-shrink gap-3 truncate px-3 text-center text-2xl font-bold text-contrast sm:w-full sm:p-0 sm:text-left"
|
||||
>
|
||||
{{ serverData.name }}
|
||||
</h1>
|
||||
<div
|
||||
v-if="isConnected"
|
||||
data-pyro-server-action-buttons
|
||||
class="server-action-buttons-anim flex w-fit flex-shrink-0"
|
||||
>
|
||||
<PanelServerActionButton
|
||||
v-if="!serverData.flows?.intro"
|
||||
class="flex-shrink-0"
|
||||
:is-online="isServerRunning"
|
||||
:is-actioning="isActioning"
|
||||
:is-installing="serverData.status === 'installing'"
|
||||
:disabled="isActioning || !!error"
|
||||
:server-name="serverData.name"
|
||||
:server-data="serverData"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:busy-reason="
|
||||
busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined
|
||||
"
|
||||
@action="sendPowerAction"
|
||||
/>
|
||||
</div>
|
||||
<SettingsIcon /> Configuring server...
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap items-center gap-2">
|
||||
<div v-if="serverData.loader" class="flex items-center gap-2 font-medium capitalize">
|
||||
<LoaderIcon :loader="serverData.loader" class="flex shrink-0 [&&]:size-5" />
|
||||
{{ serverData.loader }} {{ serverData.mc_version }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="serverData.flows?.intro"
|
||||
class="flex items-center gap-2 font-semibold text-secondary"
|
||||
v-if="serverData.loader && serverData.net?.domain"
|
||||
class="h-1.5 w-1.5 rounded-full bg-surface-5"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-if="serverData.net?.domain"
|
||||
v-tooltip="'Copy server address'"
|
||||
class="flex cursor-pointer items-center gap-2 font-medium hover:underline"
|
||||
@click="copyServerAddress"
|
||||
>
|
||||
<SettingsIcon /> Configuring server...
|
||||
<LinkIcon class="flex size-5 shrink-0" />
|
||||
{{ serverData.net.domain }}.modrinth.gg
|
||||
</div>
|
||||
<ServerInfoLabels
|
||||
v-else
|
||||
|
||||
<div v-if="uptimeSeconds" class="h-1.5 w-1.5 rounded-full bg-surface-5"></div>
|
||||
|
||||
<div v-if="uptimeSeconds" class="flex items-center gap-2 font-medium">
|
||||
<TimerIcon class="flex size-5 shrink-0" />
|
||||
{{ formattedUptime }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="serverProject && (serverData.loader || serverData.net?.domain || uptimeSeconds)"
|
||||
class="h-1.5 w-1.5 rounded-full bg-surface-5"
|
||||
></div>
|
||||
|
||||
<div v-if="serverProject" class="flex items-center gap-1.5 font-medium text-primary">
|
||||
Linked to
|
||||
<Avatar :src="serverProject.icon_url" :alt="serverProject.title" size="24px" />
|
||||
<NuxtLink
|
||||
:to="`/project/${serverProject.slug ?? serverProject.id}`"
|
||||
class="truncate text-primary hover:underline"
|
||||
>
|
||||
{{ serverProject.title }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div v-if="isConnected && !serverData.flows?.intro" class="flex gap-2">
|
||||
<PanelServerActionButton
|
||||
:is-online="isServerRunning"
|
||||
:is-actioning="isActioning"
|
||||
:is-installing="serverData.status === 'installing'"
|
||||
:disabled="isActioning || !!error"
|
||||
:server-name="serverData.name"
|
||||
:server-data="serverData"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:linked="true"
|
||||
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
:busy-reason="busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined"
|
||||
@action="sendPowerAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
|
||||
<ServerOnboardingPanelPage v-if="serverData.flows?.intro" />
|
||||
|
||||
@@ -351,24 +372,29 @@ import {
|
||||
IssuesIcon,
|
||||
LayoutTemplateIcon,
|
||||
LeftArrowIcon,
|
||||
LinkIcon,
|
||||
LockIcon,
|
||||
RightArrowIcon,
|
||||
SettingsIcon,
|
||||
TimerIcon,
|
||||
TransferIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { BusyReason } from '@modrinth/ui'
|
||||
import {
|
||||
Avatar,
|
||||
BackupProgressAdmonitions,
|
||||
ButtonStyled,
|
||||
ContentPageHeader,
|
||||
defineMessage,
|
||||
ErrorInformationCard,
|
||||
formatLoaderLabel,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
InstallingBanner,
|
||||
LoaderIcon,
|
||||
NavTabs,
|
||||
provideModrinthServerContext,
|
||||
ServerIcon,
|
||||
ServerInfoLabels,
|
||||
ServerNotice,
|
||||
ServerOnboardingPanelPage,
|
||||
useDebugLogger,
|
||||
@@ -381,7 +407,6 @@ import DOMPurify from 'dompurify'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { reloadNuxtApp } from '#app'
|
||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||
import PanelErrorIcon from '~/components/ui/servers/icons/PanelErrorIcon.vue'
|
||||
import MedalServerCountdown from '~/components/ui/servers/marketing/MedalServerCountdown.vue'
|
||||
import PanelServerActionButton from '~/components/ui/servers/PanelServerActionButton.vue'
|
||||
@@ -589,6 +614,30 @@ provideModrinthServerContext({
|
||||
})
|
||||
|
||||
const uptimeSeconds = ref(0)
|
||||
|
||||
const formattedUptime = computed(() => {
|
||||
const days = Math.floor(uptimeSeconds.value / (24 * 3600))
|
||||
const hours = Math.floor((uptimeSeconds.value % (24 * 3600)) / 3600)
|
||||
const minutes = Math.floor((uptimeSeconds.value % 3600) / 60)
|
||||
const seconds = uptimeSeconds.value % 60
|
||||
|
||||
let formatted = ''
|
||||
if (days > 0) formatted += `${days}d `
|
||||
if (hours > 0 || days > 0) formatted += `${hours}h `
|
||||
formatted += `${minutes}m ${seconds}s`
|
||||
return formatted.trim()
|
||||
})
|
||||
|
||||
function copyServerAddress() {
|
||||
if (!serverData.value?.net?.domain) return
|
||||
navigator.clipboard.writeText(serverData.value.net.domain + '.modrinth.gg')
|
||||
addNotification({
|
||||
title: 'Server address copied',
|
||||
text: "Your server's address has been copied to your clipboard.",
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
const copied = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
|
||||
@@ -628,9 +677,6 @@ const stats = ref<Stats>({
|
||||
},
|
||||
})
|
||||
|
||||
const showGameLabel = computed(() => !!serverData.value?.game)
|
||||
const showLoaderLabel = computed(() => !!serverData.value?.loader)
|
||||
|
||||
const navLinks = [
|
||||
{
|
||||
label: 'Overview',
|
||||
|
||||
@@ -18,9 +18,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
|
||||
import { Chips, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import NavTabs from '@/components/ui/NavTabs.vue'
|
||||
import { Chips, defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { getChangelog, type Product } from '@modrinth/blog'
|
||||
import { ChangelogEntry } from '@modrinth/ui'
|
||||
import { ChangelogEntry, NavTabs } from '@modrinth/ui'
|
||||
import Timeline from '@modrinth/ui/src/components/base/Timeline.vue'
|
||||
|
||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const filter = ref<Product | undefined>(undefined)
|
||||
|
||||
@@ -299,6 +299,7 @@ import {
|
||||
commonMessages,
|
||||
ContentPageHeader,
|
||||
injectModrinthClient,
|
||||
NavTabs,
|
||||
OverflowMenu,
|
||||
ProjectCard,
|
||||
ProjectCardList,
|
||||
@@ -313,7 +314,6 @@ import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
|
||||
import {
|
||||
OrganizationContext,
|
||||
|
||||
@@ -495,6 +495,7 @@ import {
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
NavTabs,
|
||||
NewModal,
|
||||
OverflowMenu,
|
||||
ProjectCard,
|
||||
@@ -521,7 +522,6 @@ import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
|
||||
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
|
||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||
import { reportUser } from '~/utils/report-helpers.ts'
|
||||
|
||||
const data = useNuxtApp()
|
||||
|
||||
Reference in New Issue
Block a user