mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
Merge remote-tracking branch 'origin/main' into prospector/disclosures-frontend
This commit is contained in:
@@ -312,7 +312,7 @@ defineExpose({ open: openMenu, close: closeMenu })
|
||||
v-if="isOpen"
|
||||
:id="menuId"
|
||||
ref="panelElement"
|
||||
class="fixed isolate z-[9999] flex min-w-48 flex-col gap-1 rounded-[14px] bg-surface-3 p-2 shadow-lg ring-1 ring-surface-5"
|
||||
class="fixed isolate z-[9999] rounded-[14px] bg-surface-3 shadow-lg ring-1 ring-surface-5"
|
||||
:style="[panelStyle, { transformOrigin: menuTransformOrigin }]"
|
||||
role="menu"
|
||||
:aria-label="props.label"
|
||||
@@ -326,76 +326,85 @@ defineExpose({ open: openMenu, close: closeMenu })
|
||||
:data-side="resolvedSide"
|
||||
:style="anchorStyle"
|
||||
/>
|
||||
<template v-for="(option, index) in visibleOptions" :key="option.id ?? `divider-${index}`">
|
||||
<div v-if="isDivider(option)" role="separator" class="my-1 h-px bg-surface-5" />
|
||||
|
||||
<RouterLink
|
||||
v-else-if="isLink(option) && option.to !== undefined && !option.disabled"
|
||||
v-tooltip="option.tooltip"
|
||||
:to="option.to"
|
||||
:class="menuItemClasses"
|
||||
:style="getMenuItemStyle(option)"
|
||||
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
|
||||
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
|
||||
:data-hover-filled-only="option.hoverFilledOnly || undefined"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
@click="handleLink(option, $event)"
|
||||
@keydown="handleLinkKeydown(option, $event)"
|
||||
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
|
||||
<div
|
||||
data-anchored-scroll-region
|
||||
class="flex min-w-48 flex-col gap-1 overflow-y-auto p-2"
|
||||
:style="{ maxHeight: panelStyle.maxHeight }"
|
||||
>
|
||||
<template
|
||||
v-for="(option, index) in visibleOptions"
|
||||
:key="option.id ?? `divider-${index}`"
|
||||
>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</RouterLink>
|
||||
<div v-if="isDivider(option)" role="separator" class="my-1 h-px bg-surface-5" />
|
||||
|
||||
<a
|
||||
v-else-if="isLink(option)"
|
||||
v-tooltip="option.tooltip"
|
||||
:href="option.disabled ? undefined : option.href"
|
||||
:target="option.target"
|
||||
:rel="option.rel ?? (option.target === '_blank' ? 'noopener noreferrer' : undefined)"
|
||||
:download="option.download"
|
||||
:aria-disabled="option.disabled || undefined"
|
||||
:class="menuItemClasses"
|
||||
:style="getMenuItemStyle(option)"
|
||||
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
|
||||
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
|
||||
:data-hover-filled-only="option.hoverFilledOnly || undefined"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
@click="handleLink(option, $event)"
|
||||
@keydown="handleLinkKeydown(option, $event)"
|
||||
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</a>
|
||||
<RouterLink
|
||||
v-else-if="isLink(option) && option.to !== undefined && !option.disabled"
|
||||
v-tooltip="option.tooltip"
|
||||
:to="option.to"
|
||||
:class="menuItemClasses"
|
||||
:style="getMenuItemStyle(option)"
|
||||
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
|
||||
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
|
||||
:data-hover-filled-only="option.hoverFilledOnly || undefined"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
@click="handleLink(option, $event)"
|
||||
@keydown="handleLinkKeydown(option, $event)"
|
||||
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</RouterLink>
|
||||
|
||||
<button
|
||||
v-else
|
||||
v-tooltip="option.tooltip"
|
||||
type="button"
|
||||
:aria-disabled="option.disabled || undefined"
|
||||
:class="menuItemClasses"
|
||||
:style="getMenuItemStyle(option)"
|
||||
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
|
||||
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
|
||||
:data-hover-filled-only="option.hoverFilledOnly || undefined"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
@click="handleAction(option, $event)"
|
||||
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
<a
|
||||
v-else-if="isLink(option)"
|
||||
v-tooltip="option.tooltip"
|
||||
:href="option.disabled ? undefined : option.href"
|
||||
:target="option.target"
|
||||
:rel="option.rel ?? (option.target === '_blank' ? 'noopener noreferrer' : undefined)"
|
||||
:download="option.download"
|
||||
:aria-disabled="option.disabled || undefined"
|
||||
:class="menuItemClasses"
|
||||
:style="getMenuItemStyle(option)"
|
||||
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
|
||||
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
|
||||
:data-hover-filled-only="option.hoverFilledOnly || undefined"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
@click="handleLink(option, $event)"
|
||||
@keydown="handleLinkKeydown(option, $event)"
|
||||
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</a>
|
||||
|
||||
<button
|
||||
v-else
|
||||
v-tooltip="option.tooltip"
|
||||
type="button"
|
||||
:aria-disabled="option.disabled || undefined"
|
||||
:class="menuItemClasses"
|
||||
:style="getMenuItemStyle(option)"
|
||||
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
|
||||
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
|
||||
:data-hover-filled-only="option.hoverFilledOnly || undefined"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
@click="handleAction(option, $event)"
|
||||
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
@@ -128,7 +128,7 @@ defineExpose({ open: openMenu, close: closeMenu })
|
||||
v-if="isOpen"
|
||||
:id="panelId"
|
||||
ref="panelElement"
|
||||
class="fixed isolate z-[9999] rounded-[14px] bg-surface-3 p-4 text-primary shadow-lg ring-1 ring-surface-5"
|
||||
class="fixed isolate z-[9999] overflow-y-auto rounded-[14px] bg-surface-3 p-4 text-primary shadow-lg ring-1 ring-surface-5"
|
||||
:style="panelStyle"
|
||||
:role="props.panelRole"
|
||||
:aria-label="props.label"
|
||||
|
||||
@@ -474,7 +474,6 @@ import {
|
||||
import {
|
||||
type GameVersionTag,
|
||||
getVersionGroupsForDisplay,
|
||||
type Version,
|
||||
type VersionDisplayGroup,
|
||||
} from '@modrinth/utils'
|
||||
import { Menu } from 'floating-vue'
|
||||
@@ -500,15 +499,12 @@ const formatBytes = useFormatBytes()
|
||||
const MAX_GAME_VERSION_TAGS = 5
|
||||
const MAX_PLATFORM_TAGS = 3
|
||||
|
||||
type VersionWithDisplayUrlEnding = Version & {
|
||||
type VersionWithDisplayUrlEnding = Labrinth.Versions.v3.Version & {
|
||||
displayUrlEnding: string
|
||||
environment?: Labrinth.Projects.v3.Environment
|
||||
mrpack_loaders?: string[]
|
||||
}
|
||||
|
||||
type DisplayVersion = VersionWithDisplayUrlEnding & {
|
||||
noModLoader: boolean
|
||||
files_missing_attribution?: boolean
|
||||
}
|
||||
|
||||
type VersionTableColumn =
|
||||
@@ -536,7 +532,7 @@ const props = withDefaults(
|
||||
currentMember?: boolean
|
||||
loaders: Labrinth.Tags.v2.Loader[]
|
||||
gameVersions: GameVersionTag[]
|
||||
versionLink?: (version: Version) => string
|
||||
versionLink?: (version: Labrinth.Versions.v3.Version) => string
|
||||
openModal?: () => void
|
||||
createVersionButtonSecondary?: boolean
|
||||
}>(),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<AutoLink
|
||||
:to="`/project/${project.slug}`"
|
||||
:target="newTab ? '_blank' : undefined"
|
||||
class="group inline-flex min-w-0 max-w-full items-center gap-1"
|
||||
:style="{ color: `var(--color-${getProjectStatusColor(project.status)})` }"
|
||||
>
|
||||
<component :is="getProjectStatusIcon(project.status)" class="size-4 shrink-0" />
|
||||
<span class="min-w-0 truncate font-medium group-hover:underline">{{ project.name }}</span>
|
||||
</AutoLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import { getProjectStatusColor, getProjectStatusIcon } from '../../utils'
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
project: Pick<Labrinth.Projects.v3.Project, 'name' | 'slug' | 'status'>
|
||||
newTab?: boolean
|
||||
}>(),
|
||||
{
|
||||
newTab: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -17,4 +17,5 @@ export { default as ProjectSidebarLinks } from './ProjectSidebarLinks.vue'
|
||||
export { default as ProjectSidebarServerInfo } from './ProjectSidebarServerInfo.vue'
|
||||
export { default as ProjectSidebarTags } from './ProjectSidebarTags.vue'
|
||||
export { default as ProjectStatusBadge } from './ProjectStatusBadge.vue'
|
||||
export { default as ProjectStatusLink } from './ProjectStatusLink.vue'
|
||||
export { default as TagsOverflow } from './TagsOverflow.vue'
|
||||
|
||||
@@ -120,16 +120,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ChevronLeftIcon, FilterIcon, XCircleIcon, XIcon } from '@modrinth/assets'
|
||||
import type { MultiSelectOption } from '@modrinth/ui'
|
||||
import { Checkbox, formatLoader, FormattedTag, MultiSelect, TagItem, useVIntl } from '@modrinth/ui'
|
||||
import type { GameVersionTag, Version } from '@modrinth/utils'
|
||||
import type { GameVersionTag } from '@modrinth/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { LocationQueryValue } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
versions: Version[]
|
||||
versions: Labrinth.Versions.v3.Version[]
|
||||
gameVersions: GameVersionTag[]
|
||||
baseId?: string
|
||||
}>()
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ProjectPageContext {
|
||||
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>
|
||||
versions: Ref<Labrinth.Versions.v3.Version[] | null>
|
||||
versionsLoading: Ref<boolean>
|
||||
versionsLoaded: Ref<boolean>
|
||||
// Lazy dependencies loading (client-side only)
|
||||
|
||||
@@ -89,6 +89,22 @@ export const PROJECT_STATUS_ICONS: Record<ProjectStatus, Component> = {
|
||||
unknown: UnknownIcon,
|
||||
}
|
||||
|
||||
// this should probably be abstracted or something idk
|
||||
export type BadgeColor = 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'gray'
|
||||
|
||||
export const PROJECT_STATUS_COLORS: Record<ProjectStatus, BadgeColor> = {
|
||||
approved: 'blue',
|
||||
unlisted: 'purple',
|
||||
withheld: 'red',
|
||||
private: 'gray',
|
||||
scheduled: 'orange',
|
||||
draft: 'gray',
|
||||
archived: 'gray',
|
||||
rejected: 'red',
|
||||
processing: 'orange',
|
||||
unknown: 'gray',
|
||||
}
|
||||
|
||||
export const DIRECTORY_ICONS: Record<string, Component> = {
|
||||
config: FolderOpenIcon,
|
||||
world: FolderOpenIcon,
|
||||
@@ -124,6 +140,10 @@ export function getProjectStatusIcon(status: ProjectStatus): Component {
|
||||
return PROJECT_STATUS_ICONS[status] ?? UnknownIcon
|
||||
}
|
||||
|
||||
export function getProjectStatusColor(status: ProjectStatus): BadgeColor {
|
||||
return PROJECT_STATUS_COLORS[status] ?? `gray`
|
||||
}
|
||||
|
||||
export function getDirectoryIcon(name: string): Component {
|
||||
return DIRECTORY_ICONS[name.toLowerCase()] ?? DIRECTORY_ICONS._default
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
export type AnchoredTeleportPlacement =
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'bottom-center'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'top-center'
|
||||
| 'right-start'
|
||||
| 'right-end'
|
||||
| 'left-start'
|
||||
@@ -37,40 +39,63 @@ export function useAnchoredTeleport(
|
||||
if (!isOpen.value || !trigger.value || !panel.value) return
|
||||
|
||||
const triggerRect = trigger.value.getBoundingClientRect()
|
||||
const panelWidth = panel.value.offsetWidth
|
||||
const panelHeight = panel.value.offsetHeight
|
||||
const scrollRegion =
|
||||
panel.value.querySelector<HTMLElement>('[data-anchored-scroll-region]') ?? panel.value
|
||||
const panelRect = { width: panel.value.offsetWidth, height: scrollRegion.scrollHeight }
|
||||
const offset = distance.value
|
||||
const alignsEnd = placement.value.endsWith('end')
|
||||
const alignsCenter = placement.value.endsWith('center')
|
||||
const isHorizontal = placement.value.startsWith('right') || placement.value.startsWith('left')
|
||||
let idealTop: number
|
||||
let idealLeft: number
|
||||
let maxHeight: number | undefined
|
||||
|
||||
if (isHorizontal) {
|
||||
const prefersRight = placement.value.startsWith('right')
|
||||
const spaceRight = window.innerWidth - triggerRect.right - viewportPadding
|
||||
const spaceLeft = triggerRect.left - viewportPadding
|
||||
const opensRight = prefersRight
|
||||
? panelWidth + offset <= spaceRight || spaceRight > spaceLeft
|
||||
: panelWidth + offset > spaceLeft && spaceRight > spaceLeft
|
||||
? panelRect.width + offset <= spaceRight || spaceRight > spaceLeft
|
||||
: panelRect.width + offset > spaceLeft && spaceRight > spaceLeft
|
||||
|
||||
resolvedSide.value = opensRight ? 'right' : 'left'
|
||||
idealTop = alignsEnd ? triggerRect.bottom - panelHeight : triggerRect.top
|
||||
idealLeft = opensRight ? triggerRect.right + offset : triggerRect.left - panelWidth - offset
|
||||
idealTop = alignsEnd ? triggerRect.bottom - panelRect.height : triggerRect.top
|
||||
idealLeft = opensRight
|
||||
? triggerRect.right + offset
|
||||
: triggerRect.left - panelRect.width - offset
|
||||
} else {
|
||||
const prefersTop = placement.value.startsWith('top')
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom - viewportPadding
|
||||
const spaceAbove = triggerRect.top - viewportPadding
|
||||
const opensAbove = prefersTop
|
||||
? panelHeight + offset <= spaceAbove || spaceAbove > spaceBelow
|
||||
: panelHeight + offset > spaceBelow && spaceAbove > spaceBelow
|
||||
? panelRect.height + offset <= spaceAbove || spaceAbove > spaceBelow
|
||||
: panelRect.height + offset > spaceBelow && spaceAbove > spaceBelow
|
||||
|
||||
resolvedSide.value = opensAbove ? 'top' : 'bottom'
|
||||
idealTop = opensAbove ? triggerRect.top - panelHeight - offset : triggerRect.bottom + offset
|
||||
idealLeft = alignsEnd ? triggerRect.right - panelWidth : triggerRect.left
|
||||
const availableHeight = opensAbove ? spaceAbove : spaceBelow
|
||||
if (panelRect.height + offset > availableHeight) {
|
||||
maxHeight = Math.max(0, availableHeight - offset)
|
||||
}
|
||||
idealTop = opensAbove
|
||||
? triggerRect.top - (maxHeight ?? panelRect.height) - offset
|
||||
: triggerRect.bottom + offset
|
||||
if (alignsCenter) {
|
||||
const centered = triggerRect.left + triggerRect.width / 2 - panelRect.width / 2
|
||||
if (centered < viewportPadding) {
|
||||
idealLeft = triggerRect.left
|
||||
} else if (centered + panelRect.width > window.innerWidth - viewportPadding) {
|
||||
idealLeft = triggerRect.right - panelRect.width
|
||||
} else {
|
||||
idealLeft = centered
|
||||
}
|
||||
} else {
|
||||
idealLeft = alignsEnd ? triggerRect.right - panelRect.width : triggerRect.left
|
||||
}
|
||||
}
|
||||
|
||||
const maxTop = Math.max(viewportPadding, window.innerHeight - panelHeight - viewportPadding)
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding)
|
||||
const effectiveHeight = maxHeight ?? panelRect.height
|
||||
const maxTop = Math.max(viewportPadding, window.innerHeight - effectiveHeight - viewportPadding)
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelRect.width - viewportPadding)
|
||||
const panelTop = Math.min(Math.max(idealTop, viewportPadding), maxTop)
|
||||
const panelLeft = Math.min(Math.max(idealLeft, viewportPadding), maxLeft)
|
||||
|
||||
@@ -78,18 +103,19 @@ export function useAnchoredTeleport(
|
||||
top: `${panelTop}px`,
|
||||
left: `${panelLeft}px`,
|
||||
visibility: 'visible',
|
||||
...(maxHeight !== undefined ? { maxHeight: `${maxHeight}px` } : {}),
|
||||
}
|
||||
anchorStyle.value = isHorizontal
|
||||
? {
|
||||
top: `${Math.min(
|
||||
Math.max(triggerRect.top + triggerRect.height / 2 - panelTop, anchorPadding),
|
||||
Math.max(anchorPadding, panelHeight - anchorPadding),
|
||||
Math.max(anchorPadding, panelRect.height - anchorPadding),
|
||||
)}px`,
|
||||
}
|
||||
: {
|
||||
left: `${Math.min(
|
||||
Math.max(triggerRect.left + triggerRect.width / 2 - panelLeft, anchorPadding),
|
||||
Math.max(anchorPadding, panelWidth - anchorPadding),
|
||||
Math.max(anchorPadding, panelRect.width - anchorPadding),
|
||||
)}px`,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user