mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
Feat: Many moderation checklist changes (#6730)
* Rule 1.x, 3.2 and 3.3x support * reorganize * Kinda Jank dynamic list stuff that should probably be implemented better but its 1:30 am * Checklist Stage Title * Make Stage Title Good * Rule 1.x, 3.2 and 3.3x support * reorganize * Kinda Jank dynamic list stuff that should probably be implemented better but its 1:30 am * Checklist Stage Title * Make Stage Title Good * oops i forgot 3.1 * Insufficient description improvement test * "i forgor" checklist tooltips tooltips (kinda jank but they work) update to latest * only use indexedDb for moderation storage, localStorage forces a bunch of cringe * store moderation stuff separately + compact checklist stuff * give stages actual titles + rename what was previously called titles to hints * Title & Slug, Summary Description, Links, License, Gallery, Versions and Reupload migrated to new system™️ * put stageoptions before nodes * do state and variablse in tooltips * checklist fully migrated to new system, not much new has been added (yet) * forgot to only do modpack permissions for modpacks * Idk why I wasn't just mutating the builder * actual node type hierarchy + action abstraction + improve message/text handling * dropdown + "functional" quick fixes * dropdown + "functional" quick fixes * Allow moderators to suggest correct environment. * remove vestigial function * Prioritization™️ * quickest fixes in the west * start of metadata stage + optimize checklist parsing + a couple of qol features * futureproof™️ environment metadata + just give me project context everywhere * tidy up after today's refactoring * tidy up after tidying up after today's refactoring * move loaders to metadata (from versions) * better required display + vueify + reorganize messages + tag quick fixes + todo * fix log spam + concat md * more bugs * nested state access * update description stage, add showcase clarity * Fix links stage mostly, and tweak metadata stage. * fix links lag + actions on stages * setup links bonus buttons * Change stage order * Update rules stage * Move conditional stages to the top where they belong. * fix some metadata stuff * fix stage selector * don't show non-english buttons on servers without english support. * prepr * stop linter from trying to break my markdown * fix: some issues * fix: lint * not sure why but link's links weren't links * fix quick fixes (again) (idk how they keep breaking) * make messages only actually count as messages if they actually have content * rename horror button to spoilers button * Update links name to match the links settings * re-review stage * unbreak stage selector * passing off version stage fixing. * actually fix the stage selector this time * fix showcase clarity msg * redirect when checklist updates slug fix initial stage not being first if first stage is only shown under certain conditions move to a consistent naming scheme * redirect when checklist updates slug fix initial stage not being first if first stage is only shown under certain conditions move to a consistent naming scheme * ok maybe lets use kebab * move custom description advice stage to first button. * temp priority on corrections applied * make metadata page go to non setting versions page * links stage bonus buttons, src required flag, show link value in msgs, fix msg groupings. * update re-review button * reupload stage navigates to project Description. * fix: link stage using page context too late * chore: run prepr * fix(ci): tell typo check to ignore moderation utils * fix: pr comment if storybook fails * make complex nodes less complex (for the most part) fix re-review stage shown condition new fancy message collection system all buttons that contribute to messages now have relevant tooltips don't store default values in checklist db * make complex nodes less complex (for the most part) fix re-review stage shown condition new fancy message collection system all buttons that contribute to messages now have relevant tooltips don't store default values in checklist db * maybe fixed markdown escaping? * tweak re-review wording. * Update license stage handling and info * add mixed option back for env info * fix: formatEnvironments fix: formatEnvironments * update metadata environment group shown * fix: status alert priority * fix: display suggested slug in slug/misused message * actually navigate when we change project slug * ok like redirect but like don't lose state when slug changes :smart: * make checklist reset button reset only current stage if there's modifications to it instead of full checklist * fix versions stage messages not working * fix donation links not being in a column * I think slug stuff is working? also like actually 100% garunteed markdown escaping works now * prepr * pseudo stages no longer interact with non-pseudo stages at all also query projects better I think? fix repetitive message spam in links stage * apply quick fixes before refreshing page * differentiate reset and return to start * remove random empty lines in some situations * better inaccurate slug message * slug correction input reset button resets to current slug again instead of auto * better keybind navigation * incorrect environment message tweak * use environment name(s) in checklist info * prepr --------- Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com> Co-authored-by: chyzman <chyzalt@gmail.com> Co-authored-by: Calum H. (IMB11) <contact@cal.engineer> Co-authored-by: Gravy Boat <gravy@thatgravyboat.tech> Co-authored-by: Michael H. <michael@iptables.sh> Co-authored-by: chyz <32403637+chyzman@users.noreply.github.com> Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
co-authored by
chyzman
Calum H.
Gravy Boat
Michael H.
chyz
Prospector
parent
30e23e5cc2
commit
e2085c43c5
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5 text-sm" :class="colorClass">
|
||||
<SpinnerIcon v-if="type === 'checking'" class="size-4 flex-none animate-spin" />
|
||||
<component :is="icon" v-else class="size-4 flex-none" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, InfoIcon, IssuesIcon, SpinnerIcon, XCircleIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'warning' | 'error' | 'success' | 'info' | 'checking'
|
||||
}>()
|
||||
|
||||
const colorClass = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'warning':
|
||||
return 'text-brand-orange'
|
||||
case 'error':
|
||||
return 'text-brand-red'
|
||||
case 'success':
|
||||
return 'text-brand-green'
|
||||
case 'checking':
|
||||
return 'text-secondary'
|
||||
default:
|
||||
return 'text-brand-blue'
|
||||
}
|
||||
})
|
||||
|
||||
const icon = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'warning':
|
||||
return IssuesIcon
|
||||
case 'error':
|
||||
return XCircleIcon
|
||||
case 'success':
|
||||
return CheckCircleIcon
|
||||
default:
|
||||
return InfoIcon
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -34,11 +34,11 @@
|
||||
:spellcheck="spellcheck"
|
||||
:maxlength="maxlength"
|
||||
:rows="rows"
|
||||
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow bg-surface-4 border-none rounded-xl"
|
||||
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none bg-surface-4 border-none rounded-xl"
|
||||
:class="[
|
||||
inputClass,
|
||||
'pl-3 pr-3 py-2 text-base',
|
||||
error ? 'outline outline-2 outline-red bg-warning-bg' : 'outline-none',
|
||||
feedbackRingClass,
|
||||
disabled ? 'cursor-not-allowed' : '',
|
||||
resizeClass,
|
||||
]"
|
||||
@@ -68,13 +68,13 @@
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow"
|
||||
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none"
|
||||
:class="[
|
||||
inputClass,
|
||||
variant === 'filled' && icon ? 'pl-10' : 'pl-3',
|
||||
clearable && model && variant === 'filled' ? 'pr-8' : 'pr-3',
|
||||
size === 'small' ? 'h-8 py-1.5 text-sm' : 'h-9 py-2 text-base',
|
||||
error ? 'outline outline-2 outline-red bg-warning-bg' : 'outline-none',
|
||||
feedbackRingClass,
|
||||
disabled ? 'cursor-not-allowed' : '',
|
||||
variant === 'outlined'
|
||||
? 'bg-transparent border border-solid border-button-bg rounded-l-xl border-r-0'
|
||||
@@ -119,6 +119,11 @@
|
||||
import { SearchIcon, XIcon } from '@modrinth/assets'
|
||||
import { type Component, computed, ref } from 'vue'
|
||||
|
||||
const RING = {
|
||||
error: 'ring-4 ring-red focus:ring-4 focus:ring-red',
|
||||
none: 'outline-none focus:ring-4 focus:ring-brand-shadow',
|
||||
} as const
|
||||
|
||||
const model = defineModel<string | number | undefined>()
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -170,11 +175,17 @@ const emit = defineEmits<{
|
||||
|
||||
const inputRef = ref<HTMLInputElement | HTMLTextAreaElement>()
|
||||
const isFocused = ref(false)
|
||||
const feedbackRingClass = computed(() => (props.error ? RING.error : RING.none))
|
||||
const resizeClass = computed(
|
||||
() => ({ none: 'resize-none', vertical: 'resize-y', both: 'resize' })[props.resize ?? 'none'],
|
||||
)
|
||||
|
||||
defineExpose({ focus: () => inputRef.value?.focus() })
|
||||
defineExpose({
|
||||
focus: () => inputRef.value?.focus(),
|
||||
setValue: (v: string) => {
|
||||
if (inputRef.value) inputRef.value.value = v
|
||||
},
|
||||
})
|
||||
|
||||
function onInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement
|
||||
|
||||
@@ -29,11 +29,12 @@
|
||||
v-if="isOpen"
|
||||
ref="menuRef"
|
||||
data-pyro-telepopover-root
|
||||
class="fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
||||
class="fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-x-hidden overflow-y-auto rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
@mousedown.stop
|
||||
@wheel.stop
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<template
|
||||
@@ -160,6 +161,7 @@ const props = withDefaults(
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
ariaLabel?: string
|
||||
placement?: 'right' | 'center'
|
||||
}>(),
|
||||
{
|
||||
hoverable: false,
|
||||
@@ -167,6 +169,7 @@ const props = withDefaults(
|
||||
disabled: false,
|
||||
tooltip: undefined,
|
||||
ariaLabel: undefined,
|
||||
placement: 'right',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -189,41 +192,52 @@ const hoveringMenu = useElementHover(menuRef)
|
||||
|
||||
const hovering = computed(() => hoveringTrigger.value || hoveringMenu.value)
|
||||
|
||||
const menuStyle = ref({
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
const menuStyle = ref<Record<string, string>>({
|
||||
top: '-9999px',
|
||||
left: '-9999px',
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false))
|
||||
|
||||
const calculateMenuPosition = () => {
|
||||
if (!triggerRef.value || !menuRef.value) return { top: '0px', left: '0px' }
|
||||
if (!triggerRef.value || !menuRef.value) return null
|
||||
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||
// offsetWidth is not affected by CSS transforms (unlike getBoundingClientRect)
|
||||
// scrollHeight gives the full content height regardless of any maxHeight constraint,
|
||||
// preventing a feedback loop where clamped offsetHeight makes it look like the menu fits
|
||||
const menuWidth = menuRef.value.offsetWidth
|
||||
const menuHeight = menuRef.value.offsetHeight
|
||||
const menuHeight = menuRef.value.scrollHeight
|
||||
const margin = 8
|
||||
|
||||
let top: number
|
||||
let left: number
|
||||
let maxHeight: number | null = null
|
||||
|
||||
if (triggerRect.bottom + menuHeight + margin <= window.innerHeight) {
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom - margin
|
||||
const spaceAbove = triggerRect.top - margin
|
||||
|
||||
if (menuHeight <= spaceBelow) {
|
||||
top = triggerRect.bottom + margin
|
||||
} else if (triggerRect.top - menuHeight - margin >= 0) {
|
||||
} else if (menuHeight <= spaceAbove) {
|
||||
top = triggerRect.top - menuHeight - margin
|
||||
} else if (spaceBelow >= spaceAbove) {
|
||||
top = triggerRect.bottom + margin
|
||||
maxHeight = spaceBelow
|
||||
} else {
|
||||
top = Math.max(margin, window.innerHeight - menuHeight - margin)
|
||||
maxHeight = spaceAbove
|
||||
top = triggerRect.top - maxHeight - margin
|
||||
}
|
||||
|
||||
if (triggerRect.right - menuWidth >= margin) {
|
||||
left = triggerRect.right - menuWidth
|
||||
} else {
|
||||
left = Math.max(margin, triggerRect.left)
|
||||
}
|
||||
const preferredLeft =
|
||||
props.placement === 'center'
|
||||
? triggerRect.left + triggerRect.width / 2 - menuWidth / 2
|
||||
: triggerRect.right - menuWidth
|
||||
const left = Math.max(margin, Math.min(preferredLeft, window.innerWidth - menuWidth - margin))
|
||||
|
||||
return {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
...(maxHeight !== null ? { maxHeight: `${maxHeight}px` } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,20 +255,24 @@ const toggleMenu = (event: MouseEvent) => {
|
||||
|
||||
const openMenu = () => {
|
||||
if (props.disabled) return
|
||||
menuStyle.value = { top: '-9999px', left: '-9999px' }
|
||||
isOpen.value = true
|
||||
emit('open')
|
||||
disableBodyScroll()
|
||||
nextTick(() => {
|
||||
menuStyle.value = calculateMenuPosition()
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
focusFirstMenuItem()
|
||||
})
|
||||
// nextTick lets Vue render the element, then requestAnimationFrame waits for the
|
||||
// browser to complete layout so offsetWidth/offsetHeight are real values.
|
||||
nextTick(() =>
|
||||
requestAnimationFrame(() => {
|
||||
const pos = calculateMenuPosition()
|
||||
if (pos) menuStyle.value = pos
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
focusFirstMenuItem()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
isOpen.value = false
|
||||
selectedIndex.value = -1
|
||||
enableBodyScroll()
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
|
||||
@@ -324,14 +342,6 @@ const handleMouseOver = (index: number) => {
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
|
||||
const disableBodyScroll = () => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
const enableBodyScroll = () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
const focusFirstMenuItem = () => {
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
menuItemsRef.value[0]?.focus?.()
|
||||
@@ -421,10 +431,25 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleResizeOrScroll = () => {
|
||||
if (isOpen.value) {
|
||||
menuStyle.value = calculateMenuPosition()
|
||||
const handleResize = () => {
|
||||
if (!isOpen.value) return
|
||||
const pos = calculateMenuPosition()
|
||||
if (pos) menuStyle.value = pos
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!isOpen.value) return
|
||||
const pos = calculateMenuPosition()
|
||||
if (!pos) return
|
||||
// On scroll only the vertical position changes — don't update left, which would shift
|
||||
// the menu horizontally due to scrollbar appearing/disappearing changing offsetWidth
|
||||
const updated: Record<string, string> = { ...menuStyle.value, top: pos.top }
|
||||
if (pos.maxHeight) {
|
||||
updated.maxHeight = pos.maxHeight
|
||||
} else {
|
||||
delete updated.maxHeight
|
||||
}
|
||||
menuStyle.value = updated
|
||||
}
|
||||
|
||||
const throttle = <T extends unknown[]>(
|
||||
@@ -441,23 +466,23 @@ const throttle = <T extends unknown[]>(
|
||||
}
|
||||
}
|
||||
|
||||
const throttledHandleResizeOrScroll = throttle(handleResizeOrScroll, 100)
|
||||
const throttledHandleResize = throttle(handleResize, 100)
|
||||
const throttledHandleScroll = throttle(handleScroll, 100)
|
||||
|
||||
onMounted(() => {
|
||||
triggerRef.value?.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('resize', throttledHandleResizeOrScroll)
|
||||
window.addEventListener('scroll', throttledHandleResizeOrScroll)
|
||||
window.addEventListener('resize', throttledHandleResize)
|
||||
window.addEventListener('scroll', throttledHandleScroll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
triggerRef.value?.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('resize', throttledHandleResizeOrScroll)
|
||||
window.removeEventListener('scroll', throttledHandleResizeOrScroll)
|
||||
window.removeEventListener('resize', throttledHandleResize)
|
||||
window.removeEventListener('scroll', throttledHandleScroll)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
if (typeAheadTimeout.value) {
|
||||
clearTimeout(typeAheadTimeout.value)
|
||||
}
|
||||
enableBodyScroll()
|
||||
})
|
||||
|
||||
watch(isOpen, (newValue) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as Accordion } from './Accordion.vue'
|
||||
export { default as Admonition } from './Admonition.vue'
|
||||
export { default as Alert } from './Alert.vue'
|
||||
export { default as AppearingProgressBar } from './AppearingProgressBar.vue'
|
||||
export { default as AutoBrandIcon } from './AutoBrandIcon.vue'
|
||||
export { default as AutoLink } from './AutoLink.vue'
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="item.type !== 'neutral'"></div>
|
||||
<div class="col-span-2 text-sm text-primary">{{ item.text }}</div>
|
||||
<div class="col-span-2 whitespace-pre-wrap text-sm text-primary">{{ item.text }}</div>
|
||||
<template v-if="item.errorCode">
|
||||
<div></div>
|
||||
<div class="m-0 text-wrap text-xs font-medium text-secondary">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { DeepReadonly, Ref } from 'vue'
|
||||
import type { DeepReadonly, Ref, ShallowRef } from 'vue'
|
||||
|
||||
import { createContext } from '.'
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface ProjectPageContext {
|
||||
dependencies: Ref<Labrinth.Projects.v2.DependencyInfo | null>
|
||||
dependenciesLoading: Ref<boolean>
|
||||
|
||||
thread: ShallowRef<Labrinth.Threads.v3.Thread | null | undefined>
|
||||
|
||||
cdnDownloadReason: DeepReadonly<Ref<CdnDownloadReason>>
|
||||
|
||||
// Invalidate all project queries (auto-refetches active ones)
|
||||
|
||||
Reference in New Issue
Block a user