Merge remote-tracking branch 'origin/main' into cal/user-blocking-profile-settings

# Conflicts:
#	apps/app-frontend/src/App.vue
This commit is contained in:
Calum H. (IMB11)
2026-07-28 20:54:33 +01:00
104 changed files with 1387 additions and 868 deletions
+90 -194
View File
@@ -10,7 +10,6 @@ import {
} from '@modrinth/api-client'
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
CompassIcon,
HomeIcon,
LeftArrowIcon,
@@ -18,15 +17,13 @@ import {
LogInIcon,
LogOutIcon,
NewspaperIcon,
NotepadTextIcon,
PlusIcon,
RefreshCwIcon,
RightArrowIcon,
ServerStackIcon,
SettingsIcon,
ShirtIcon,
UserIcon,
WorldIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
@@ -38,6 +35,7 @@ import {
CreationFlowModal,
defineMessages,
I18nDebugPanel,
IntlFormatted,
LoadingBar,
NewsArticleCard,
NotificationPanel,
@@ -62,7 +60,6 @@ import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type } from '@tauri-apps/plugin-os'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { $fetch } from 'ofetch'
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
@@ -87,6 +84,7 @@ import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import SurveyPopup from '@/components/ui/SurveyPopup.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { config } from '@/config'
@@ -103,12 +101,7 @@ import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import {
can_current_user_use_shared_instances,
get as getInstance,
list,
run,
} from '@/helpers/instance'
import { can_current_user_use_shared_instances, get as getInstance, run } from '@/helpers/instance'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
@@ -147,6 +140,7 @@ import { setupAuthProvider } from '@/providers/setup/auth'
import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
import { useError } from '@/store/error.js'
import { useTheming } from '@/store/state'
import { appMessages } from '@/utils/app-messages'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { get_available_capes, get_available_skins } from './helpers/skins'
@@ -289,7 +283,6 @@ const {
} = setupProviders(notificationManager, popupNotificationManager)
const news = ref([])
const availableSurvey = ref(false)
const displayedServerInviteNotifications = new Set()
const serverInvitePopupNotificationIds = new Set()
let liveNotificationGeneration = 0
@@ -403,6 +396,54 @@ const messages = defineMessages({
id: 'app.ads-consent.accept',
defaultMessage: 'Accept all',
},
home: {
id: 'app.nav.home',
defaultMessage: 'Home',
},
library: {
id: 'app.nav.library',
defaultMessage: 'Library',
},
modrinthHosting: {
id: 'app.nav.modrinth-hosting',
defaultMessage: 'Modrinth Hosting',
},
createNewInstance: {
id: 'app.nav.create-new-instance',
defaultMessage: 'Create new instance',
},
modrinthAccount: {
id: 'app.nav.modrinth-account',
defaultMessage: 'Modrinth account',
},
signedInAs: {
id: 'app.nav.signed-in-as',
defaultMessage: 'Signed in as <user>{username}</user>',
},
signInToModrinthAccount: {
id: 'app.nav.sign-in-to-modrinth-account',
defaultMessage: 'Sign in to a Modrinth account',
},
restarting: {
id: 'app.restarting',
defaultMessage: 'Restarting...',
},
upgradeToModrinthPlus: {
id: 'app.nav.upgrade-to-modrinth-plus',
defaultMessage: 'Upgrade to Modrinth+',
},
news: {
id: 'app.news.title',
defaultMessage: 'News',
},
viewAllNews: {
id: 'app.news.view-all',
defaultMessage: 'View all news',
},
playingAs: {
id: 'app.sidebar.playing-as',
defaultMessage: 'Playing as',
},
})
function handleAdsConsentRequired(required) {
@@ -571,12 +612,6 @@ async function setupApp() {
settings.pending_update_toast_for_version = null
await setSettings(settings)
}
if (osType === 'windows') {
await processPendingSurveys()
} else {
console.info('Skipping user surveys on non-Windows platforms')
}
}
const stateFailed = ref(false)
@@ -1441,115 +1476,6 @@ function handleAuxClick(e) {
}
}
function cleanupOldSurveyDisplayData() {
const threeWeeksAgo = new Date()
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21)
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key.startsWith('survey-') && key.endsWith('-display')) {
const dateValue = new Date(localStorage.getItem(key))
if (dateValue < threeWeeksAgo) {
localStorage.removeItem(key)
}
}
}
}
async function openSurvey() {
if (!availableSurvey.value) {
console.error('No survey to open')
return
}
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const formId = availableSurvey.value.tally_id
const popupOptions = {
layout: 'modal',
width: 700,
autoClose: 2000,
hideTitle: true,
hiddenFields: {
user_id: userId,
},
onOpen: () => console.info('Opened user survey'),
onClose: () => {
console.info('Closed user survey')
show_ads_window()
},
onSubmit: () => console.info('Active user survey submitted'),
}
try {
hide_ads_window()
if (window.Tally?.openPopup) {
console.info(`Opening Tally popup for user survey (form ID: ${formId})`)
dismissSurvey()
window.Tally.openPopup(formId, popupOptions)
} else {
console.warn('Tally script not yet loaded')
show_ads_window()
}
} catch (e) {
console.error('Error opening Tally popup:', e)
show_ads_window()
}
console.info(`Found user survey to show with tally_id: ${formId}`)
window.Tally.openPopup(formId, popupOptions)
}
function dismissSurvey() {
localStorage.setItem(`survey-${availableSurvey.value.id}-display`, new Date())
availableSurvey.value = undefined
}
async function processPendingSurveys() {
function isWithinLastTwoWeeks(date) {
const twoWeeksAgo = new Date()
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14)
return date >= twoWeeksAgo
}
cleanupOldSurveyDisplayData()
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const instances = (await list().catch(handleError)) ?? []
const isActivePlayer = instances.some(
(instance) =>
isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created),
)
let surveys = []
try {
surveys = await $fetch('https://api.modrinth.com/v2/surveys')
} catch (e) {
console.error('Error fetching surveys:', e)
}
const surveyToShow = surveys.find(
(survey) =>
!!(
localStorage.getItem(`survey-${survey.id}-display`) === null &&
survey.type === 'tally_app' &&
((survey.condition === 'active_player' && isActivePlayer) ||
(survey.assigned_users?.includes(userId) && !survey.dismissed_users?.includes(userId)))
),
)
if (surveyToShow) {
availableSurvey.value = surveyToShow
} else {
console.info('No user survey to show')
}
}
provideAppUpdateDownloadProgress(appUpdateDownload)
</script>
@@ -1572,7 +1498,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="flex items-center gap-4 text-contrast font-semibold text-xl select-none cursor-default"
>
<RefreshCwIcon data-tauri-drag-region class="animate-spin w-6 h-6" />
Restarting...
{{ formatMessage(messages.restarting) }}
</span>
</div>
</Transition>
@@ -1595,27 +1521,24 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
/>
<UnknownPackWarningModal ref="unknownPackWarningModal" />
<div
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-1.5 w-[--left-bar-width]"
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.25rem] w-[--left-bar-width]"
>
<NavButton v-tooltip.right="'Home'" to="/">
<NavButton v-tooltip.right="formatMessage(messages.home)" to="/">
<HomeIcon />
</NavButton>
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
<WorldIcon />
</NavButton>
<NavButton
v-tooltip.right="'Discover content'"
v-tooltip.right="formatMessage(commonMessages.discoverContentLabel)"
to="/browse/modpack"
:is-primary="() => route.path.startsWith('/browse') && !route.query.i"
:is-subpage="(route) => route.path.startsWith('/project') && !route.query.i"
>
<CompassIcon />
</NavButton>
<NavButton v-tooltip.right="'Skin selector'" to="/skins">
<ChangeSkinIcon />
<NavButton v-tooltip.right="formatMessage(appMessages.skinSelectorLabel)" to="/skins">
<ShirtIcon />
</NavButton>
<NavButton
v-tooltip.right="'Library'"
v-tooltip.right="formatMessage(messages.library)"
to="/library"
:is-primary="(r) => r.path === '/library' || r.path === '/library'"
:is-subpage="
@@ -1628,19 +1551,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<LibraryIcon />
</NavButton>
<NavButton
v-tooltip.right="'Modrinth Hosting'"
v-tooltip.right="formatMessage(messages.modrinthHosting)"
to="/hosting/manage"
:is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'"
:is-subpage="(r) => r.path.startsWith('/hosting/manage/') && r.path !== '/hosting/manage/'"
>
<ServerStackIcon />
</NavButton>
<div class="h-px w-6 mx-auto my-2 bg-surface-5"></div>
<suspense>
<QuickInstanceSwitcher />
</suspense>
<NavButton
v-tooltip.right="'Create new instance'"
v-tooltip.right="formatMessage(messages.createNewInstance)"
:to="() => installationModal?.show()"
:disabled="offline"
>
@@ -1655,7 +1577,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</NavButton>
<OverflowMenu
v-if="credentials?.user"
v-tooltip.right="`Modrinth account`"
v-tooltip.right="formatMessage(messages.modrinthAccount)"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast border-0 cursor-pointer"
:options="[
{
@@ -1674,18 +1596,27 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<template #view-profile>
<UserIcon />
<span class="inline-flex items-center gap-1">
Signed in as
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials?.user?.avatar_url" alt="" size="20px" circle />
{{ credentials?.user?.username }}
</span>
<IntlFormatted
:message-id="messages.signedInAs"
:values="{ username: credentials?.user?.username }"
>
<template #user="{ children }">
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials?.user?.avatar_url" alt="" size="20px" circle />
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</span>
</template>
<template #sign-out> <LogOutIcon /> Sign out </template>
<template #sign-out>
<LogOutIcon />
{{ formatMessage(commonMessages.signOutButton) }}
</template>
</OverflowMenu>
<NavButton
v-else
v-tooltip.right="'Sign in to a Modrinth account'"
v-tooltip.right="formatMessage(messages.signInToModrinthAccount)"
:to="() => requestSignIn()"
>
<LogInIcon class="text-brand" />
@@ -1742,28 +1673,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
}"
>
<div class="app-viewport flex-grow router-view">
<transition name="popup-survey">
<div
v-if="availableSurvey"
class="w-[400px] z-20 fixed -bottom-12 pb-16 right-[--right-bar-width] mr-4 rounded-t-2xl card-shadow bg-bg-raised border-surface-5 border-[1px] border-solid border-b-0 p-4"
>
<h2 class="text-lg font-extrabold mt-0 mb-2">Hey there Modrinth user!</h2>
<p class="m-0 leading-tight">
Would you mind answering a few questions about your experience with Modrinth App?
</p>
<p class="mt-3 mb-4 leading-tight">
This feedback will go directly to the Modrinth team and help guide future updates!
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="openSurvey"><NotepadTextIcon /> Take survey</button>
</ButtonStyled>
<ButtonStyled>
<button @click="dismissSurvey"><XIcon /> No thanks</button>
</ButtonStyled>
</div>
</div>
</transition>
<SurveyPopup />
<div
class="loading-indicator-container h-8 fixed z-50 pointer-events-none"
:style="{
@@ -1827,7 +1737,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<h3 class="text-base text-primary font-medium m-0">Playing as</h3>
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.playingAs) }}
</h3>
<suspense>
<AccountsCard ref="accounts" />
</suspense>
@@ -1842,7 +1754,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
/>
<div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">News</h3>
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">
{{ formatMessage(messages.news) }}
</h3>
<div class="space-y-4 flex flex-col items-center w-full">
<NewsArticleCard
v-for="(item, index) in news"
@@ -1851,7 +1765,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
/>
<ButtonStyled color="brand" size="large">
<a href="https://modrinth.com/news" target="_blank" class="my-4">
<NewspaperIcon /> View all news
<NewspaperIcon />
{{ formatMessage(messages.viewAllNews) }}
</a>
</ButtonStyled>
</div>
@@ -1864,7 +1779,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="absolute bottom-[250px] w-full flex justify-center items-center gap-1 px-4 py-3 text-purple font-medium hover:underline z-10"
target="_blank"
>
<ArrowBigUpDashIcon class="text-2xl" /> Upgrade to Modrinth+
<ArrowBigUpDashIcon class="text-2xl" />
{{ formatMessage(messages.upgradeToModrinthPlus) }}
</a>
<PromotionWrapper />
</template>
@@ -2076,26 +1992,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
display: contents;
}
.popup-survey-enter-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.51, 1.08, 0.35, 1.15);
transform-origin: top center;
}
.popup-survey-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.68, -0.17, 0.23, 0.11);
transform-origin: top center;
}
.popup-survey-enter-from,
.popup-survey-leave-to {
opacity: 0;
transform: translateY(10rem) scale(0.8) scaleY(1.6);
}
@media (prefers-reduced-motion: no-preference) {
.nav-button-animated-enter-active {
transition: all 0.5s cubic-bezier(0.15, 1.4, 0.64, 0.96);
@@ -1,41 +1,145 @@
<script setup>
import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, injectNotificationManager } from '@modrinth/ui'
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import NavButton from '@/components/ui/NavButton.vue'
import { instance_listener } from '@/helpers/events.js'
import { list } from '@/helpers/instance'
const ITEM_SIZE = 52
const APPROX_USED_VERTICAL_SPACE = 513 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
const STORAGE_KEY = 'modrinth-quick-instance-count'
const { handleError } = injectNotificationManager()
const recentInstances = ref([])
const { formatMessage } = useVIntl()
const maxAuto = ref(0)
const allInstances = ref([])
const dragging = ref(false)
const stored = localStorage.getItem(STORAGE_KEY)
const userLimit = ref(stored === null ? null : Number(stored))
const maxVisible = computed(() => Math.min(maxAuto.value, allInstances.value.length))
const visibleCount = computed(() => Math.min(userLimit.value ?? maxVisible.value, maxVisible.value))
const recentInstances = computed(() => allInstances.value.slice(0, visibleCount.value))
const canDrag = computed(() => maxVisible.value > 0)
const showOverdrag = ref(false)
const updateMaxAuto = () => {
maxAuto.value = Math.max(
0,
Math.floor((window.innerHeight - APPROX_USED_VERTICAL_SPACE) / ITEM_SIZE),
)
}
const setLimit = (count) => {
const clamped = Math.max(0, Math.min(count, maxVisible.value))
if (clamped >= maxVisible.value) {
userLimit.value = null
localStorage.removeItem(STORAGE_KEY)
} else {
userLimit.value = clamped
localStorage.setItem(STORAGE_KEY, String(clamped))
}
}
let dragStartY = 0
let dragStartCount = 0
let wasOverdragging = false
let overdragTimeout = null
const clearOverdragFlash = () => {
showOverdrag.value = false
if (overdragTimeout !== null) {
clearTimeout(overdragTimeout)
overdragTimeout = null
}
}
const flashOverdrag = () => {
showOverdrag.value = true
if (overdragTimeout !== null) {
clearTimeout(overdragTimeout)
}
overdragTimeout = setTimeout(() => {
showOverdrag.value = false
overdragTimeout = null
}, 500)
}
const onDividerPointerDown = (event) => {
if (!canDrag.value) {
return
}
event.preventDefault()
dragging.value = true
wasOverdragging = false
clearOverdragFlash()
dragStartY = event.clientY
dragStartCount = visibleCount.value
document.body.classList.add('quick-instance-dragging')
event.currentTarget.setPointerCapture(event.pointerId)
}
const onDividerPointerMove = (event) => {
if (!dragging.value) {
return
}
const delta = event.clientY - dragStartY
const target = dragStartCount + Math.round(delta / ITEM_SIZE)
const isOverdragging = target < 0 || target > maxAuto.value
if (isOverdragging && !wasOverdragging) {
flashOverdrag()
}
wasOverdragging = isOverdragging
setLimit(target)
}
const endDrag = (event) => {
if (!dragging.value) {
return
}
dragging.value = false
wasOverdragging = false
clearOverdragFlash()
document.body.classList.remove('quick-instance-dragging')
if (event?.currentTarget?.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
}
const onDividerPointerUp = (event) => {
endDrag(event)
}
const getInstances = async () => {
const instances = await list().catch(handleError)
recentInstances.value = instances
.sort((a, b) => {
const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
allInstances.value = instances.sort((a, b) => {
const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
const dateBCreated = dayjs(b.created)
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
const dateBCreated = dayjs(b.created)
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
return dateB - dateA
})
.slice(0, 3)
return dateB - dateA
})
}
await getInstances()
updateMaxAuto()
const unlistenInstance = await instance_listener(async (event) => {
if (event.event !== 'synced') {
@@ -43,29 +147,157 @@ const unlistenInstance = await instance_listener(async (event) => {
}
})
onMounted(() => {
window.addEventListener('resize', updateMaxAuto)
})
onUnmounted(() => {
window.removeEventListener('resize', updateMaxAuto)
document.body.classList.remove('quick-instance-dragging')
clearOverdragFlash()
unlistenInstance()
})
const messages = defineMessages({
dragTooltip: {
id: 'app.quick-instance-switcher.drag-tooltip',
defaultMessage: 'Drag to resize',
},
dragShowTooltip: {
id: 'app.quick-instance-switcher.drag-show-tooltip',
defaultMessage: 'Drag to show recent instances',
},
})
const dividerTooltip = computed(() => {
if (!canDrag.value || dragging.value) {
return null
}
return formatMessage(visibleCount.value === 0 ? messages.dragShowTooltip : messages.dragTooltip)
})
</script>
<template>
<div v-for="instance in recentInstances" :key="instance.id" v-tooltip.right="instance.name">
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.id"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10 pointer-events-none"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
<Transition name="top-divider">
<div
v-if="recentInstances.length > 0"
class="top-divider flex items-center justify-center overflow-hidden"
>
<div class="h-px w-8 bg-surface-5 shrink-0"></div>
</div>
</Transition>
<TransitionGroup name="quick-instance" tag="div" class="flex flex-col items-center">
<div
v-for="instance in recentInstances"
:key="instance.id"
v-tooltip.right="instance.name"
class="quick-instance-item"
>
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.id"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10 pointer-events-none"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
</div>
</TransitionGroup>
<div
v-tooltip.right="dividerTooltip"
class="flex items-center justify-center py-2 select-none"
:class="canDrag ? 'cursor-ns-resize touch-none group' : ''"
@pointerdown="onDividerPointerDown"
@pointermove="onDividerPointerMove"
@pointerup="onDividerPointerUp"
@pointercancel="onDividerPointerUp"
>
<div
class="h-px w-8 transition-colors duration-200"
:class="
showOverdrag ? 'bg-red' : canDrag ? 'bg-surface-5 group-hover:bg-secondary' : 'bg-surface-5'
"
></div>
</div>
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-divider"></div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.top-divider {
height: calc(1rem + 1px);
}
.top-divider-enter-active,
.top-divider-leave-active {
transition:
opacity 0.25s ease,
height 0.25s ease;
}
.top-divider-enter-from,
.top-divider-leave-to {
opacity: 0;
height: 0;
}
.quick-instance-item {
height: 3rem;
overflow: hidden;
& + & {
margin-top: 0.25rem;
}
}
.quick-instance-enter-active,
.quick-instance-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s ease,
height 0.25s ease,
margin-top 0.25s ease;
}
.quick-instance-enter-from,
.quick-instance-leave-to {
opacity: 0;
transform: scale(0.5);
height: 0;
margin-top: 0 !important;
}
@media (prefers-reduced-motion: reduce) {
.top-divider-enter-active,
.top-divider-leave-active,
.quick-instance-enter-active,
.quick-instance-leave-active {
transition: none;
}
.top-divider-enter-from,
.top-divider-leave-to {
opacity: 1;
height: calc(1rem + 1px);
}
.quick-instance-enter-from,
.quick-instance-leave-to {
opacity: 1;
transform: none;
height: 3rem;
margin-top: unset !important;
}
}
</style>
<style lang="scss">
body.quick-instance-dragging,
body.quick-instance-dragging * {
cursor: ns-resize !important;
}
</style>
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { NotepadTextIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { type } from '@tauri-apps/plugin-os'
import { $fetch } from 'ofetch'
import { onMounted, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { list } from '@/helpers/instance'
import { get as getCreds } from '@/helpers/mr_auth.ts'
type Survey = {
id: string
tally_id: string
type: string
condition?: string
assigned_users?: string[]
dismissed_users?: string[]
}
type TallyApi = {
openPopup: (formId: string, options: object) => void
}
const tallyWindow = window as Window & { Tally?: TallyApi }
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const availableSurvey = ref<Survey | null>(null)
const messages = defineMessages({
surveyTitle: {
id: 'app.survey.title',
defaultMessage: 'Hey there Modrinth user!',
},
surveyBody: {
id: 'app.survey.body',
defaultMessage:
'Would you mind answering a few questions about your experience with Modrinth App?',
},
surveyFooter: {
id: 'app.survey.footer',
defaultMessage:
'This feedback will go directly to the Modrinth team and help guide future updates!',
},
takeSurvey: {
id: 'app.survey.take-survey',
defaultMessage: 'Take survey',
},
surveyNoThanks: {
id: 'app.survey.no-thanks',
defaultMessage: 'No thanks',
},
})
function cleanupOldSurveyDisplayData() {
const threeWeeksAgo = new Date()
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21)
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith('survey-') && key.endsWith('-display')) {
const dateValue = new Date(localStorage.getItem(key) ?? '')
if (dateValue < threeWeeksAgo) {
localStorage.removeItem(key)
}
}
}
}
async function openSurvey() {
if (!availableSurvey.value) {
console.error('No survey to open')
return
}
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const formId = availableSurvey.value.tally_id
const popupOptions = {
layout: 'modal',
width: 700,
autoClose: 2000,
hideTitle: true,
hiddenFields: {
user_id: userId,
},
onOpen: () => console.info('Opened user survey'),
onClose: () => {
console.info('Closed user survey')
show_ads_window()
},
onSubmit: () => console.info('Active user survey submitted'),
}
try {
hide_ads_window()
if (tallyWindow.Tally?.openPopup) {
console.info(`Opening Tally popup for user survey (form ID: ${formId})`)
dismissSurvey()
tallyWindow.Tally.openPopup(formId, popupOptions)
} else {
console.warn('Tally script not yet loaded')
show_ads_window()
}
} catch (e) {
console.error('Error opening Tally popup:', e)
show_ads_window()
}
console.info(`Found user survey to show with tally_id: ${formId}`)
tallyWindow.Tally?.openPopup(formId, popupOptions)
}
function dismissSurvey() {
if (!availableSurvey.value) return
localStorage.setItem(`survey-${availableSurvey.value.id}-display`, String(new Date()))
availableSurvey.value = null
}
async function processPendingSurveys() {
function isWithinLastTwoWeeks(date: string | Date | null | undefined) {
if (!date) return false
const twoWeeksAgo = new Date()
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14)
return new Date(date) >= twoWeeksAgo
}
cleanupOldSurveyDisplayData()
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const instances = (await list().catch(handleError)) ?? []
const isActivePlayer = instances.some(
(instance) =>
isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created),
)
let surveys: Survey[] = []
try {
surveys = await $fetch('https://api.modrinth.com/v2/surveys')
} catch (e) {
console.error('Error fetching surveys:', e)
}
const surveyToShow = surveys.find(
(survey) =>
!!(
localStorage.getItem(`survey-${survey.id}-display`) === null &&
survey.type === 'tally_app' &&
((survey.condition === 'active_player' && isActivePlayer) ||
(!!userId &&
survey.assigned_users?.includes(userId) &&
!survey.dismissed_users?.includes(userId)))
),
)
if (surveyToShow) {
availableSurvey.value = surveyToShow
} else {
console.info('No user survey to show')
}
}
onMounted(async () => {
const osType = await type()
if (osType === 'windows') {
await processPendingSurveys()
} else {
console.info('Skipping user surveys on non-Windows platforms')
}
})
</script>
<template>
<transition name="popup-survey">
<div
v-if="availableSurvey"
class="w-[400px] z-20 fixed -bottom-12 pb-16 right-[--right-bar-width] mr-4 rounded-t-2xl card-shadow bg-bg-raised border-surface-5 border-[1px] border-solid border-b-0 p-4"
>
<h2 class="text-lg font-extrabold mt-0 mb-2">
{{ formatMessage(messages.surveyTitle) }}
</h2>
<p class="m-0 leading-tight">
{{ formatMessage(messages.surveyBody) }}
</p>
<p class="mt-3 mb-4 leading-tight">
{{ formatMessage(messages.surveyFooter) }}
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="openSurvey">
<NotepadTextIcon />
{{ formatMessage(messages.takeSurvey) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="dismissSurvey">
<XIcon />
{{ formatMessage(messages.surveyNoThanks) }}
</button>
</ButtonStyled>
</div>
</div>
</transition>
</template>
<style scoped>
.popup-survey-enter-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.51, 1.08, 0.35, 1.15);
transform-origin: top center;
}
.popup-survey-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.68, -0.17, 0.23, 0.11);
transform-origin: top center;
}
.popup-survey-enter-from,
.popup-survey-leave-to {
opacity: 0;
transform: translateY(10rem) scale(0.8) scaleY(1.6);
}
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { LoaderCircleIcon } from '@modrinth/assets'
import type { GameVersion } from '@modrinth/ui'
import { GAME_MODES, HeadingLink, injectNotificationManager } from '@modrinth/ui'
import { GAME_MODES, injectNotificationManager } from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
@@ -268,13 +268,7 @@ onUnmounted(() => {
</div>
</div>
<div v-else-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1">
Jump back in
</HeadingLink>
<span
v-else
class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold"
>
<span class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold">
Jump back in
</span>
<div class="grid-when-huge flex flex-col w-full gap-2">
@@ -233,9 +233,6 @@
"app.browse.back-to-instance": {
"message": "العودة للنموذج"
},
"app.browse.discover-content": {
"message": "استكشف محتوى"
},
"app.browse.discover-servers": {
"message": "استكشف خوادم"
},
@@ -164,9 +164,6 @@
"app.browse.back-to-instance": {
"message": "Zpět k instanci"
},
"app.browse.discover-content": {
"message": "Objevit obsah"
},
"app.browse.discover-servers": {
"message": "Objevit servery"
},
@@ -218,9 +218,6 @@
"app.browse.back-to-instance": {
"message": "Tilbage til instance"
},
"app.browse.discover-content": {
"message": "Opdag indhold"
},
"app.browse.discover-servers": {
"message": "Opdag servere"
},
@@ -242,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Zu Instanz zurückgehen"
},
"app.browse.discover-content": {
"message": "Inhalte entdecken"
},
"app.browse.discover-servers": {
"message": "Server entdecken"
},
@@ -242,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Zurück zur Instanz"
},
"app.browse.discover-content": {
"message": "Inhalte entdecken"
},
"app.browse.discover-servers": {
"message": "Server entdecken"
},
+57 -3
View File
@@ -251,9 +251,6 @@
"app.browse.back-to-instance": {
"message": "Back to instance"
},
"app.browse.discover-content": {
"message": "Discover content"
},
"app.browse.discover-servers": {
"message": "Discover servers"
},
@@ -767,6 +764,36 @@
"app.modal.update-to-play.update-required-description": {
"message": "An update is required to play {name}. Please update to latest version to launch the game."
},
"app.nav.create-new-instance": {
"message": "Create new instance"
},
"app.nav.home": {
"message": "Home"
},
"app.nav.library": {
"message": "Library"
},
"app.nav.modrinth-account": {
"message": "Modrinth account"
},
"app.nav.modrinth-hosting": {
"message": "Modrinth Hosting"
},
"app.nav.sign-in-to-modrinth-account": {
"message": "Sign in to a Modrinth account"
},
"app.nav.signed-in-as": {
"message": "Signed in as <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Upgrade to Modrinth+"
},
"app.news.title": {
"message": "News"
},
"app.news.view-all": {
"message": "View all news"
},
"app.project.install-button.already-installed": {
"message": "This project is already installed"
},
@@ -788,6 +815,15 @@
"app.project.versions.already-installed": {
"message": "Already installed"
},
"app.quick-instance-switcher.drag-show-tooltip": {
"message": "Drag to show recent instances"
},
"app.quick-instance-switcher.drag-tooltip": {
"message": "Drag to resize"
},
"app.restarting": {
"message": "Restarting..."
},
"app.settings.app-version": {
"message": "Modrinth App {version}"
},
@@ -974,6 +1010,9 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"app.sidebar.playing-as": {
"message": "Playing as"
},
"app.skins.add-button": {
"message": "Add skin"
},
@@ -1133,6 +1172,21 @@
"app.skins.toggle-ears-features-on": {
"message": "Toggle on"
},
"app.survey.body": {
"message": "Would you mind answering a few questions about your experience with Modrinth App?"
},
"app.survey.footer": {
"message": "This feedback will go directly to the Modrinth team and help guide future updates!"
},
"app.survey.no-thanks": {
"message": "No thanks"
},
"app.survey.take-survey": {
"message": "Take survey"
},
"app.survey.title": {
"message": "Hey there Modrinth user!"
},
"app.update-popup.body.download-complete": {
"message": "Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App."
},
@@ -224,9 +224,6 @@
"app.browse.back-to-instance": {
"message": "Volver a instancia"
},
"app.browse.discover-content": {
"message": "Descubrir contenido"
},
"app.browse.discover-servers": {
"message": "Descubrir servidores"
},
@@ -158,9 +158,6 @@
"app.browse.back-to-instance": {
"message": "Volver a la instancia"
},
"app.browse.discover-content": {
"message": "Descubrir contenido"
},
"app.browse.discover-servers": {
"message": "Descubrir servidores"
},
@@ -170,9 +170,6 @@
"app.browse.back-to-instance": {
"message": "Takaisin instanssiin"
},
"app.browse.discover-content": {
"message": "Löydä sisältöä"
},
"app.browse.discover-servers": {
"message": "Löydä palvelimia"
},
@@ -137,9 +137,6 @@
"app.browse.back-to-instance": {
"message": "Bumalik sa instansiya"
},
"app.browse.discover-content": {
"message": "Tumuklas ng kontento"
},
"app.browse.discover-servers": {
"message": "Tumuklas ng mga server"
},
@@ -242,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Retour à l'instance"
},
"app.browse.discover-content": {
"message": "Découvrir du contenu"
},
"app.browse.discover-servers": {
"message": "Découvrir des serveurs"
},
@@ -44,9 +44,6 @@
"app.browse.already-added": {
"message": "כבר נוסף"
},
"app.browse.discover-content": {
"message": "גלה תוכן"
},
"app.browse.discover-servers": {
"message": "גלה שרתים"
},
@@ -242,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Vissza a játékpéldányhoz"
},
"app.browse.discover-content": {
"message": "Tartalom böngészése"
},
"app.browse.discover-servers": {
"message": "Szerverek böngészése"
},
@@ -137,9 +137,6 @@
"app.browse.back-to-instance": {
"message": "Kembali ke instans"
},
"app.browse.discover-content": {
"message": "Temukan konten"
},
"app.browse.discover-servers": {
"message": "Temukan server"
},
@@ -242,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Torna all'istanza"
},
"app.browse.discover-content": {
"message": "Esplora i contenuti"
},
"app.browse.discover-servers": {
"message": "Sfoglia i server"
},
@@ -239,9 +239,6 @@
"app.browse.back-to-instance": {
"message": "インスタンスに戻る"
},
"app.browse.discover-content": {
"message": "コンテンツを探す"
},
"app.browse.discover-servers": {
"message": "サーバーを探す"
},
@@ -242,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "인스턴스로 돌아가기"
},
"app.browse.discover-content": {
"message": "콘텐츠 탐색하기"
},
"app.browse.discover-servers": {
"message": "서버 탐색하기"
},
@@ -134,9 +134,6 @@
"app.browse.back-to-instance": {
"message": "Kembali ke pemasangan"
},
"app.browse.discover-content": {
"message": "Temui kandungan"
},
"app.browse.discover-servers": {
"message": "Temui pelayan"
},
@@ -242,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Terug naar instantie"
},
"app.browse.discover-content": {
"message": "Inhoud ontdekken"
},
"app.browse.discover-servers": {
"message": "Ontdek servers"
},
@@ -140,9 +140,6 @@
"app.browse.back-to-instance": {
"message": "Tilbake til tilfelle"
},
"app.browse.discover-content": {
"message": "Oppdag innhold"
},
"app.browse.discover-servers": {
"message": "Oppdag servere"
},
@@ -242,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Powrót do instancji"
},
"app.browse.discover-content": {
"message": "Odkrywaj zawartość"
},
"app.browse.discover-servers": {
"message": "Odkryj serwery"
},
@@ -242,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Voltar à instância"
},
"app.browse.discover-content": {
"message": "Descobrir conteúdo"
},
"app.browse.discover-servers": {
"message": "Descobrir servidores"
},
@@ -239,9 +239,6 @@
"app.browse.back-to-instance": {
"message": "Voltar à instância"
},
"app.browse.discover-content": {
"message": "Descobrir conteúdo"
},
"app.browse.discover-servers": {
"message": "Descobrir servidores"
},
@@ -242,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Вернуться к сборке"
},
"app.browse.discover-content": {
"message": "Поиск проектов"
},
"app.browse.discover-servers": {
"message": "Поиск серверов"
},
@@ -224,9 +224,6 @@
"app.browse.back-to-instance": {
"message": "Nazad na instancu"
},
"app.browse.discover-content": {
"message": "Otkrij sadržaj"
},
"app.browse.discover-servers": {
"message": "Otkrij servere"
},
@@ -224,9 +224,6 @@
"app.browse.back-to-instance": {
"message": "Tillbaka till instans"
},
"app.browse.discover-content": {
"message": "Upptäck innehåll"
},
"app.browse.discover-servers": {
"message": "Upptäck servrar"
},
@@ -152,9 +152,6 @@
"app.browse.back-to-instance": {
"message": "กลับสู่อินสแตนซ์"
},
"app.browse.discover-content": {
"message": "สำรวจเนื้อหา"
},
"app.browse.discover-servers": {
"message": "สำรวจเซิร์ฟเวอร์"
},
@@ -242,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Kuruluma dön"
},
"app.browse.discover-content": {
"message": "İçerik keşfet"
},
"app.browse.discover-servers": {
"message": "Sunucuları keşfet"
},
@@ -239,9 +239,6 @@
"app.browse.back-to-instance": {
"message": "Назад до профілю"
},
"app.browse.discover-content": {
"message": "Дослідити вміст"
},
"app.browse.discover-servers": {
"message": "Дослідити сервери"
},
@@ -233,9 +233,6 @@
"app.browse.back-to-instance": {
"message": "Quay lại phiên bản"
},
"app.browse.discover-content": {
"message": "Khám phá nội dung"
},
"app.browse.discover-servers": {
"message": "Khám phá máy chủ"
},
@@ -242,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "返回实例"
},
"app.browse.discover-content": {
"message": "发现内容"
},
"app.browse.discover-servers": {
"message": "发现服务器"
},
@@ -242,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "回到實例"
},
"app.browse.discover-content": {
"message": "探索內容"
},
"app.browse.discover-servers": {
"message": "探索伺服器"
},
+4 -6
View File
@@ -385,10 +385,6 @@ const messages = defineMessages({
id: 'app.browse.add-to-an-instance',
defaultMessage: 'Add to an instance',
},
discoverContent: {
id: 'app.browse.discover-content',
defaultMessage: 'Discover content',
},
discoverServers: {
id: 'app.browse.discover-servers',
defaultMessage: 'Discover servers',
@@ -450,7 +446,9 @@ const messages = defineMessages({
const breadcrumbs = useBreadcrumbs()
const browseTitle = computed(() =>
formatMessage(isFromWorlds.value ? messages.discoverServers : messages.discoverContent),
formatMessage(
isFromWorlds.value ? messages.discoverServers : commonMessages.discoverContentLabel,
),
)
breadcrumbs.setName('BrowseTitle', browseTitle.value)
if (instance.value) {
@@ -484,7 +482,7 @@ function resetInstanceContext() {
hiddenInstanceProjectIds.value = new Set()
hiddenInstanceProjectIdsInitialized.value = false
isServerInstance.value = false
breadcrumbs.setName('BrowseTitle', formatMessage(messages.discoverContent))
breadcrumbs.setName('BrowseTitle', formatMessage(commonMessages.discoverContentLabel))
breadcrumbs.setContext(null)
}
+2 -5
View File
@@ -58,6 +58,7 @@ import {
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
import { handleSevereError } from '@/store/error'
import { useTheming } from '@/store/state'
import { appMessages } from '@/utils/app-messages'
type UnlistenFn = () => void
type VirtualSkinSectionListExpose = {
@@ -68,10 +69,6 @@ const PENDING_SKIN_REFRESH_DELAY_MS = 11_000
const DEFAULT_SKIN_SECTION_SORT_ORDER = ['Default skins', 'Modrinth Pride']
const EARS_NOTICE_PLACEHOLDER = '__EARS_MOD_NAME__'
const messages = defineMessages({
skinSelectorTitle: {
id: 'app.skins.title',
defaultMessage: 'Skin selector',
},
modrinthPrideSection: {
id: 'app.skins.section.modrinth-pride',
defaultMessage: 'Modrinth Pride',
@@ -1071,7 +1068,7 @@ await loadSkins()
<div v-if="currentUser" class="skin-layout box-border min-h-full p-4">
<div class="sticky top-6 self-start p-2 pt-0">
<h1 class="m-0 text-2xl font-bold flex items-center gap-2">
{{ formatMessage(messages.skinSelectorTitle) }}
{{ formatMessage(appMessages.skinSelectorLabel) }}
</h1>
<div
class="ml-5 mt-4 flex h-[calc(80vh-1rem)] items-center justify-center max-[700px]:h-[calc(50vh-1rem)]"
-4
View File
@@ -1,4 +0,0 @@
<script setup lang="ts"></script>
<template>
<div class="p-6 flex flex-col gap-2">Worlds</div>
</template>
+1 -2
View File
@@ -3,6 +3,5 @@ import Index from './Index.vue'
import Servers from './Servers.vue'
import Skins from './Skins.vue'
import User from './User.vue'
import Worlds from './Worlds.vue'
export { Browse, Index, Servers, Skins, User, Worlds }
export { Browse, Index, Servers, Skins, User }
-8
View File
@@ -20,14 +20,6 @@ export default new createRouter({
breadcrumb: [{ name: 'Home' }],
},
},
{
path: '/worlds',
name: 'Worlds',
component: Pages.Worlds,
meta: {
breadcrumb: [{ name: 'Worlds' }],
},
},
{
path: '/hosting/manage/',
name: 'Servers',
-1
View File
@@ -5,7 +5,6 @@ let systemThemeMq: MediaQueryList | null = null
export const DEFAULT_FEATURE_FLAGS = {
project_background: false,
page_path: false,
worlds_tab: false,
worlds_in_home: true,
server_project_qa: false,
show_version_environment_column: false,
@@ -0,0 +1,8 @@
import { defineMessages } from '@modrinth/ui'
export const appMessages = defineMessages({
skinSelectorLabel: {
id: 'app.skins.title',
defaultMessage: 'Skin selector',
},
})
@@ -300,6 +300,7 @@ type SharedInstanceVersionDependency = Labrinth.Versions.v2.Dependency & {
const props = defineProps<{
report: ExtendedReport
collapsed: boolean
sharedInstanceDetailsLoader?: () => Promise<SharedInstanceReportDetails>
sharedInstanceVersionContentLoader?: (
instanceId: string,
@@ -311,7 +312,7 @@ const reportThread = ref<{
setReplyContent: (content: string) => void
sendReply: (privateMessage?: boolean) => Promise<void>
} | null>(null)
const isThreadCollapsed = ref(true)
const isThreadCollapsed = ref(props.collapsed)
const sharedInstanceDetails = ref<SharedInstanceReportDetails | null>(null)
const sharedInstanceLoading = ref(false)
const sharedInstanceError = ref<string | null>(null)
@@ -94,6 +94,7 @@ const props = defineProps<{
focusedDetailId?: string | null
loadingIssues: Set<string>
decompiledSources: Map<string, string>
collapsed: boolean
}>()
const { addNotification } = injectNotificationManager()
@@ -173,7 +174,7 @@ type Tab = 'Thread' | 'Files' | 'File'
const tabs: readonly ('Thread' | 'Files')[] = ['Thread', 'Files']
const currentTab = ref<Tab>('Thread')
const isThreadCollapsed = ref(true)
const isThreadCollapsed = ref(props.collapsed)
const remainingMessageCount = computed(() => {
if (!props.item.thread?.messages) return 0
@@ -1,116 +0,0 @@
<template>
<div class="flex flex-row items-center gap-2">
<kbd
v-for="(definition, index) in definitions"
:key="`keybind-${index}`"
ref="keybinding"
class="cursor-pointer border-2 !text-lg font-bold"
:class="{
editing: editing === index,
}"
@click="startEditing(index)"
>
{{ toDisplay(definition) }}
</kbd>
</div>
</template>
<script setup lang="ts">
import { type KeybindDefinition, toKeybindDefinition } from '@modrinth/moderation'
import { onUnmounted } from 'vue'
const props = defineProps<{
definitions: KeybindDefinition[]
onChange: (definitions: KeybindDefinition[]) => void
}>()
const keybinding = useTemplateRef('keybinding')
const definitions = ref(JSON.parse(JSON.stringify(props.definitions)))
const editing = ref(-1)
function startEditing(index: number) {
if (editing.value === index) {
stopEditing()
} else {
editing.value = index
window.addEventListener('keyup', handleKeybinds)
window.addEventListener('click', handleMouse)
}
}
function stopEditing() {
console.log('stop editing')
editing.value = -1
window.removeEventListener('keyup', handleKeybinds)
window.removeEventListener('click', handleMouse)
}
function handleMouse(event: MouseEvent) {
if (keybinding.value && event.target && editing.value != -1) {
const editingRef = keybinding.value[editing.value]
if (editingRef === event.target || editingRef.contains(event.target)) {
return
}
}
stopEditing()
}
function handleKeybinds(event: KeyboardEvent) {
definitions.value[editing.value] = toKeybindDefinition(event)
props.onChange(definitions.value)
stopEditing()
event.preventDefault()
event.stopPropagation()
}
function toDisplay(definition: KeybindDefinition): string {
const keys = []
if (definition.ctrl || definition.meta) {
keys.push(isMac() ? 'CMD' : 'CTRL')
}
if (definition.shift) keys.push('SHIFT')
if (definition.alt) keys.push('ALT')
const mainKey = definition.key
.toUpperCase()
.replace('ARROWLEFT', '←')
.replace('ARROWRIGHT', '→')
.replace('ARROWUP', '↑')
.replace('ARROWDOWN', '↓')
.replace('ENTER', '↵')
.replace('ESCAPE', 'ESC')
keys.push(mainKey)
return keys.join(' + ')
}
function isMac() {
return navigator.platform.toUpperCase().includes('MAC')
}
onUnmounted(stopEditing)
</script>
<style scoped lang="scss">
.editing {
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%,
100% {
border-color: var(--color-red);
box-shadow: 0 0 10px 1px var(--color-red);
}
50% {
border-color: transparent;
box-shadow: none;
}
}
</style>
@@ -1,71 +0,0 @@
<template>
<NewModal ref="modal" header="Moderation shortcuts" :closable="true">
<div id="moderation-checklist-keybinds-modal">
<div class="keybinds-sections">
<div class="grid grid-cols-2 gap-x-12 gap-y-3">
<div
v-for="[id, keybind] in Object.entries(keybinds)"
:key="id"
class="keybind-item flex flex-wrap items-center justify-between gap-4"
:class="{
'col-span-2':
Object.keys(keybinds).length % 2 === 1 &&
Object.keys(keybinds)[Object.keys(keybinds).length - 1] === id,
}"
>
<span class="text-sm text-secondary">{{ keybind.description }}</span>
<ChecklistKeybind
:definitions="
(!Array.isArray(keybind.keybind) ? [keybind.keybind] : keybind.keybind).map(
normalizeKeybind,
)
"
:on-change="
(definitions) => {
keybinds[id].keybind = definitions
saveModerationKeybinds()
}
"
/>
</div>
</div>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { normalizeKeybind } from '@modrinth/moderation'
import NewModal from '@modrinth/ui/src/components/modal/NewModal.vue'
import { ref } from 'vue'
import { saveModerationKeybinds } from '#imports'
import ChecklistKeybind from '~/components/ui/moderation/checklist/ChecklistKeybind.vue'
const modal = ref<InstanceType<typeof NewModal>>()
const keybinds = useModerationKeybinds()
function show(event?: MouseEvent) {
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
defineExpose({
show,
hide,
})
</script>
<style scoped lang="scss">
@media (max-width: 768px) {
.keybinds-sections {
.grid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
}
</style>
@@ -1,5 +1,4 @@
<template>
<KeybindsModal ref="keybindsModal" />
<ConfirmModal
v-if="isLockedByOther"
ref="takeOverModal"
@@ -13,7 +12,12 @@
<div
tabindex="0"
class="moderation-checklist flex max-h-[calc(100vh-2rem)] w-[600px] max-w-full flex-col overflow-hidden rounded-2xl border-[1px] border-solid border-orange bg-bg-raised p-4 transition-all delay-200 duration-200 ease-in-out"
:class="{ '!w-fit': collapsed, locked: isLockedByOther }"
:class="{
'!w-fit': collapsed,
locked: isLockedByOther,
'right-4': settings.get(moderationSettings.General.ChecklistPosition) === 'right',
'left-4': settings.get(moderationSettings.General.ChecklistPosition) === 'left',
}"
>
<div class="flex grow-0 flex-col gap-1">
<div class="flex items-center gap-2">
@@ -52,11 +56,6 @@
{{ checklistTitleText }}
</button>
</h1>
<ButtonStyled circular>
<button v-tooltip="`Keyboard shortcuts`" @click="keybindsModal?.show($event)">
<KeyboardIcon />
</button>
</ButtonStyled>
<ButtonStyled v-if="!isPseudoStage && currentStageObj._guidanceUrl" circular>
<a v-tooltip="`Stage guidance`" target="_blank" :href="currentStageObj._guidanceUrl">
<FileTextIcon />
@@ -370,7 +369,6 @@ import {
CheckIcon,
DropdownIcon,
FileTextIcon,
KeyboardIcon,
LeftArrowIcon,
LinkIcon,
ListBulletedIcon,
@@ -383,12 +381,13 @@ import {
UndoIcon,
XIcon,
} from '@modrinth/assets'
import type {
IdentifiedNodeBuilder,
NodeState,
Priority,
StageNodeBuilder,
ValueNodeBuilder,
import {
type IdentifiedNodeBuilder,
moderationSettings,
type NodeState,
type Priority,
type StageNodeBuilder,
type ValueNodeBuilder,
} from '@modrinth/moderation'
import {
createTrackedPatch,
@@ -396,7 +395,6 @@ import {
expandVariables,
getBooleanChildState,
GLOBAL_STATE_KEY,
handleKeybind,
isNodeActive,
kebabToTitleCase,
NodeBuilder,
@@ -438,15 +436,14 @@ import type { LockAcquireResponse } from '~/services/moderation-queue.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
import { type ActiveAction, type LiveNode, NODE_META_KEY, STATE_KEY } from './checklist-context'
import KeybindsModal from './ChecklistKeybindsModal.vue'
import NodeRenderer from './NodeRenderer.vue'
const notifications = injectNotificationManager()
const { addNotification } = notifications
const debug = useDebugLogger('ModerationChecklist')
const keybinds = useModerationKeybinds()
const settings = useModerationSettings()
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>()
const takeOverModal = ref<InstanceType<typeof ConfirmModal>>()
const props = defineProps<{
@@ -1223,83 +1220,43 @@ interface MessagePart {
content: string
}
function ignoreLegacyActionKeybind() {
return undefined
}
function handleKeybinds(event: KeyboardEvent) {
handleKeybind(
event,
{
project: projectV2.value,
state: {
currentStage: currentStage.value,
totalStages: resolvedStages.value.length,
currentStageId: currentStageObj.value.id,
currentStageTitle: currentStageObj.value.label,
keybinds.value.handle(event, {
project: projectV2.value,
scope: 'checklist',
state: {
currentStage: currentStage.value,
totalStages: resolvedStages.value.length,
currentStageId: currentStageObj.value.id,
currentStageTitle: currentStageObj.value.label,
isCollapsed: props.collapsed,
isDone: done.value,
hasGeneratedMessage: generatedMessage.value,
isLoadingMessage: loadingMessage.value,
isCollapsed: props.collapsed,
isDone: done.value,
hasGeneratedMessage: generatedMessage.value,
isLoadingMessage: loadingMessage.value,
futureProjectCount: moderationQueue.queueLength,
visibleActionsCount: resolveChildren(
currentStageObj.value,
nodeStates.value[currentStageObj.value.id!] ?? {},
).filter((c) => c instanceof NodeBuilder).length,
focusedActionIndex: null,
focusedActionType: null,
},
actions: {
tryGoNext: nextStage,
tryGoBack: previousStage,
tryGenerateMessage: generateMessage,
trySkipProject: skipCurrentProject,
tryToggleCollapse: () => emit('toggleCollapsed'),
tryResetProgress: resetProgress,
tryExitModeration: handleExit,
tryApprove: () => sendMessage(approveSendStatus.value),
tryReject: () => sendMessage('rejected'),
tryWithhold: () => sendMessage('withheld'),
tryEditMessage: previousStage,
tryCopyLink: async (permalink: boolean, relative: boolean, page: boolean) => {
let url = ``
if (relative) {
url += `${globalThis.location.origin}`
} else {
url += `https://modrinth.com`
}
if (permalink) {
url += `/project/${projectV2.value.id}`
} else {
url += `/${projectV2.value.project_type}/${projectV2.value.slug}`
}
if (page) {
url += `/${globalThis.location.pathname.split('/').slice(3).join('/')}`
}
await navigator.clipboard.writeText(url)
},
tryCopyId: async () => await navigator.clipboard.writeText(projectV2.value.id),
tryToggleAction: ignoreLegacyActionKeybind,
trySelectDropdownOption: ignoreLegacyActionKeybind,
tryToggleChip: ignoreLegacyActionKeybind,
tryFocusNextAction: ignoreLegacyActionKeybind,
tryFocusPreviousAction: ignoreLegacyActionKeybind,
tryActivateFocusedAction: ignoreLegacyActionKeybind,
},
futureProjectCount: moderationQueue.queueLength,
visibleActionsCount: resolveChildren(
currentStageObj.value,
nodeStates.value[currentStageObj.value.id!] ?? {},
).filter((c) => c instanceof NodeBuilder).length,
},
Object.values(keybinds.value),
)
actions: {
tryGoNext: nextStage,
tryGoBack: previousStage,
tryGenerateMessage: generateMessage,
trySkipProject: skipCurrentProject,
tryToggleCollapse: () => emit('toggleCollapsed'),
tryResetProgress: resetProgress,
tryExitModeration: handleExit,
tryApprove: () => sendMessage(approveSendStatus.value),
tryReject: () => sendMessage('rejected'),
tryWithhold: () => sendMessage('withheld'),
tryEditMessage: previousStage,
},
})
}
// Trigger debounced prefetch when user progresses through stages
@@ -1315,7 +1272,9 @@ onMounted(async () => {
window.addEventListener('keydown', handleKeybinds)
window.addEventListener('beforeunload', handleBeforeUnload)
document.addEventListener('visibilitychange', handleVisibilityChange)
notifications.setNotificationLocation('left')
if (settings.value.get(moderationSettings.General.ChecklistPosition) === 'right') {
notifications.setNotificationLocation('left')
}
const finishedId = localStorage.getItem('moderation-checklist-finished')
if (finishedId === projectV2.value.id) {
@@ -1870,6 +1829,12 @@ const stageOptions = computed<StageOption[]>(() => {
<style scoped lang="scss">
.moderation-checklist {
position: fixed;
bottom: 1rem;
overflow-y: auto;
z-index: 50;
transition: bottom 0.25s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
@@ -0,0 +1,164 @@
<template>
<div>
<span class="flex flex-row items-center gap-2 text-sm text-secondary">
<GlobeIcon
v-if="props.global"
v-tooltip="'Can be used without the checklist open if setting enabled.'"
/>
{{ props.title }}
<ButtonStyled size="small" circular type="transparent">
<Button :disabled="!hasChanged" @click="resetToDefault">
<RotateCounterClockwiseIcon />
</Button>
</ButtonStyled>
</span>
<div class="flex flex-row items-center gap-2">
<kbd
v-if="definitions.length === 0"
ref="keybinding"
class="cursor-pointer border-2 !text-lg font-bold text-secondary"
:class="{ editing: editing === 0 }"
@click="startEditing(0)"
>
Not Bound
</kbd>
<kbd
v-for="(definition, index) in definitions"
v-else
:key="`keybind-${index}`"
ref="keybinding"
class="cursor-pointer border-2 !text-lg font-bold"
:class="{
editing: editing === index,
}"
@click="startEditing(index)"
>
{{ toDisplay(definition) }}
</kbd>
</div>
</div>
</template>
<script setup lang="ts">
import { GlobeIcon, RotateCounterClockwiseIcon } from '@modrinth/assets'
import { type KeybindDefinition, toKeybindDefinition } from '@modrinth/moderation'
import { Button, ButtonStyled } from '@modrinth/ui'
import { onUnmounted } from 'vue'
const props = defineProps<{
title: string
global: boolean
definitions: KeybindDefinition[]
default: KeybindDefinition[]
onChange: (definitions: KeybindDefinition[]) => void
}>()
const keybinding = useTemplateRef('keybinding')
const definitions = ref(JSON.parse(JSON.stringify(props.definitions)))
const editing = ref(-1)
const hasChanged = computed(
() => JSON.stringify(definitions.value) !== JSON.stringify(props.default),
)
const isMac = ref(false)
function startEditing(index: number) {
if (editing.value === index) {
stopEditing()
} else {
editing.value = index
window.addEventListener('keyup', handleKeybinds)
window.addEventListener('click', handleMouse)
}
}
function stopEditing() {
editing.value = -1
window.removeEventListener('keyup', handleKeybinds)
window.removeEventListener('click', handleMouse)
}
function resetToDefault() {
definitions.value = JSON.parse(JSON.stringify(props.default))
props.onChange(definitions.value)
}
function handleMouse(event: MouseEvent) {
if (keybinding.value && event.target && event.target instanceof Node && editing.value != -1) {
const editingRef = Array.isArray(keybinding.value)
? keybinding.value[editing.value]
: keybinding.value
if (editingRef === event.target || editingRef.contains(event.target)) {
return
}
}
stopEditing()
}
function handleKeybinds(event: KeyboardEvent) {
if (event.key === 'Escape') {
definitions.value.splice(editing.value, 1)
} else if (definitions.value && definitions.value.length > 0) {
definitions.value[editing.value] = toKeybindDefinition(event)
} else {
definitions.value.push(toKeybindDefinition(event))
}
props.onChange(definitions.value)
stopEditing()
event.preventDefault()
event.stopPropagation()
}
function toDisplay(definition: KeybindDefinition): string {
const keys = []
if (definition.ctrl || definition.meta) {
keys.push(isMac.value ? 'CMD' : 'CTRL')
}
if (definition.shift) keys.push('SHIFT')
if (definition.alt) keys.push('ALT')
const mainKey = definition.key
.toUpperCase()
.replace('ARROWLEFT', '←')
.replace('ARROWRIGHT', '→')
.replace('ARROWUP', '↑')
.replace('ARROWDOWN', '↓')
.replace('ENTER', '↵')
keys.push(mainKey)
return keys.join(' + ')
}
onUnmounted(() => {
stopEditing()
isMac.value = navigator.platform.toUpperCase().includes('MAC')
})
defineExpose({
setDefinitions(newDefinitions: KeybindDefinition[]) {
definitions.value = JSON.parse(JSON.stringify(newDefinitions))
},
})
</script>
<style scoped lang="scss">
.editing {
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%,
100% {
border-color: var(--color-red);
box-shadow: 0 0 10px 1px var(--color-red);
}
50% {
border-color: transparent;
box-shadow: none;
}
}
</style>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import ModerationKeybind from '~/components/ui/moderation/settings/ModerationKeybind.vue'
const keybinds = useModerationKeybinds()
</script>
<template>
<div class="universal-card">
<h2 class="text-2xl">Keybinds</h2>
<div class="grid grid-cols-2 gap-x-12 gap-y-4">
<ModerationKeybind
v-for="[id, keybind] in keybinds"
:key="id"
:title="keybind.description"
:global="keybind.scope === 'project'"
:definitions="keybind.keybind"
:default="keybind.defaultKeybind"
:on-change="
(definitions) => {
keybinds.set(id, definitions)
saveModerationOptions()
}
"
/>
</div>
</div>
</template>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { moderationSettings, type SettingDefinition } from '@modrinth/moderation'
import { Combobox, Toggle } from '@modrinth/ui'
const flattenedSettings = Object.entries(moderationSettings).reduce(
(acc, [group, settings]) => {
acc[group] = Object.values(settings)
return acc
},
{} as { [name: string]: SettingDefinition[] },
)
onMounted(() => {
const merged: { [name: string]: SettingDefinition[] } = {}
const addMergedSettings = (settings: { [name: string]: SettingDefinition[] }) => {
for (const [groupId, groupSettings] of Object.entries(settings)) {
const group = (merged[groupId] = merged[groupId] || [])
group.push(...groupSettings)
}
}
addMergedSettings(flattenedSettings)
const event = new CustomEvent('request-moderation-settings', {
detail: {
addSettings: addMergedSettings,
},
})
window.dispatchEvent(event)
displayedSettings.value = merged
})
const configuredSettings = useModerationSettings()
const displayedSettings = ref<{ [name: string]: SettingDefinition[] }>(flattenedSettings)
</script>
<template>
<div v-for="[name, page] of Object.entries(displayedSettings)" :key="name" class="universal-card">
<h2 class="text-2xl">{{ name }}</h2>
<div class="flex flex-col gap-2">
<div
v-for="setting in page"
:key="setting.id"
class="flex flex-row flex-wrap items-center justify-between gap-2"
>
<label class="flex-1">
<span class="block font-semibold text-contrast">{{ setting.title }}</span>
<span class="block text-secondary">{{ setting.description }}</span>
<span class="block text-secondary">
Default:
<span
class="font-semibold"
:class="{
'text-red': setting.type === 'toggle' && !setting.default,
'text-green': setting.type === 'toggle' && setting.default,
}"
>{{ setting.default }}</span
>
</span>
</label>
<Toggle
v-if="setting.type === 'toggle'"
:model-value="configuredSettings.get(setting)"
class="shrink-0"
@update:model-value="(value) => configuredSettings.set(setting, value)"
/>
<Combobox
v-if="setting.type === 'enum'"
:model-value="configuredSettings.get(setting)"
:options="setting.entries.map((entry) => ({ value: entry.value, label: entry.label }))"
class="!w-1/4"
@update:model-value="(value) => configuredSettings.set(setting, value)"
/>
<input
v-if="setting.type === 'string'"
type="text"
:value="configuredSettings.get(setting)"
class="input !w-1/4"
@input="
(event) => configuredSettings.set(setting, (event.target as HTMLInputElement).value)
"
/>
</div>
</div>
</div>
</template>
@@ -5,7 +5,7 @@
'has-body': message.body.type === 'text' && !forceCompact,
'no-actions': noLinks,
private: isPrivateMessage,
'show-private-bg': flags.showModeratorPrivateMessageHighlight,
'show-private-bg': settings.get(moderationSettings.General.PrivateMessageHighlight),
}"
>
<template v-if="members[message.author_id]">
@@ -149,6 +149,7 @@ import {
ScaleIcon,
TrashIcon,
} from '@modrinth/assets'
import { moderationSettings } from '@modrinth/moderation'
import {
AutoLink,
Avatar,
@@ -194,7 +195,7 @@ const props = defineProps({
})
const emit = defineEmits(['update-thread'])
const flags = useFeatureFlags()
const settings = useModerationSettings()
const formattedMessage = computed(() => {
const body = renderString(props.message.body.body)
@@ -53,7 +53,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
alwaysIgnoreErrorBanner: false,
showViewProdRouteBanner: false,
showModeratorProjectMemberUi: false,
showModeratorPrivateMessageHighlight: true,
archonApiStaging: false,
showHostingAccessInstanceAuditLog: false,
versionDevInfoCollapsed: true,
+57 -37
View File
@@ -1,61 +1,81 @@
import {
type KeybindDefinition,
type KeybindListener,
keybinds,
normalizeKeybind,
} from '@modrinth/moderation'
import { type KeybindDefinition, Keybinds, Settings } from '@modrinth/moderation'
import { computed } from 'vue'
import type { CookieOptions } from '#app'
const moderationKeybindsId = 'moderation-keybinds'
const moderationSettingsId = 'moderation-settings'
type StoredKeybinds = { [id: string]: KeybindDefinition[] }
type PartialStoredKeybinds = Partial<StoredKeybinds>
type StoredSettings = { [id: string]: any }
type StoredOptions = {
keybinds: Partial<StoredKeybinds>
settings: Partial<StoredSettings>
}
const getCookieOptions = () =>
const getCookieOptions = <T>() =>
({
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: useRuntimeConfig().public.cookieSecure,
httpOnly: false,
path: '/',
}) satisfies CookieOptions<PartialStoredKeybinds>
}) satisfies CookieOptions<T>
export const useModerationKeybinds = () =>
useState<{ [id: string]: KeybindListener }>(moderationKeybindsId, () => {
const storedKeybinds = useCookie<PartialStoredKeybinds>(
moderationKeybindsId,
getCookieOptions(),
)
const useModerationCookies = () => {
const keybindCookie = useCookie<Partial<StoredKeybinds> | null>(
moderationKeybindsId,
getCookieOptions(),
)
const optionsCookie = useCookie<Partial<StoredOptions>>(moderationSettingsId, getCookieOptions())
if (!storedKeybinds.value) {
storedKeybinds.value = {}
if (keybindCookie.value && !optionsCookie.value) {
optionsCookie.value = {
keybinds: keybindCookie.value,
settings: {},
}
keybindCookie.value = null
} else if (keybindCookie.value && optionsCookie.value) {
keybindCookie.value = null // options is the new cookie so it will override the existing keybinds.
}
return optionsCookie
}
const useModerationOptions = () =>
useState<{ keybinds: StoredKeybinds; settings: StoredSettings }>(moderationKeybindsId, () => {
const cookie = useModerationCookies()
const keybindOutput: StoredKeybinds = {}
for (const [id, definition] of Object.entries(cookie.value.keybinds || {})) {
if (!definition) continue
keybindOutput[id] = definition
}
const output: { [id: string]: KeybindListener } = {}
for (const [id, keybind] of Object.entries(keybinds)) {
const definitions = storedKeybinds.value[id]
output[id] = {
keybind: definitions !== undefined ? definitions : keybind.keybind,
description: keybind.description,
enabled: keybind.enabled,
action: keybind.action,
}
const settingsOutput: StoredSettings = {}
for (const [id, setting] of Object.entries(cookie.value.settings || {})) {
settingsOutput[id] = setting
}
return output
return {
keybinds: keybindOutput,
settings: settingsOutput,
}
})
export const saveModerationKeybinds = () => {
const keybinds = useModerationKeybinds()
const cookie = useCookie<PartialStoredKeybinds>(moderationKeybindsId, getCookieOptions())
export const useModerationKeybinds = () =>
computed(() => new Keybinds(useModerationOptions().value.keybinds))
const storedKeybinds: PartialStoredKeybinds = {}
for (const [id, keybind] of Object.entries(keybinds.value)) {
storedKeybinds[id] = (Array.isArray(keybind.keybind) ? keybind.keybind : [keybind.keybind]).map(
normalizeKeybind,
)
export const useModerationSettings = () =>
computed(() => new Settings(useModerationOptions().value.settings, saveModerationOptions))
export const saveModerationOptions = () => {
const options = useModerationOptions()
const cookie = useModerationCookies()
cookie.value = {
keybinds: options.value.keybinds,
settings: options.value.settings,
}
cookie.value = storedKeybinds
}
+1 -5
View File
@@ -253,7 +253,7 @@
/>
<CompassIcon v-else aria-hidden="true" />
<span class="hidden md:contents">{{
formatMessage(navMenuMessages.discoverContent)
formatMessage(commonMessages.discoverContentLabel)
}}</span>
<span class="contents md:hidden">{{ formatMessage(navMenuMessages.discover) }}</span>
<DropdownIcon aria-hidden="true" class="h-5 w-5" />
@@ -933,10 +933,6 @@ const navMenuMessages = defineMessages({
id: 'layout.nav.search',
defaultMessage: 'Search',
},
discoverContent: {
id: 'layout.nav.discover-content',
defaultMessage: 'Discover content',
},
discover: {
id: 'layout.nav.discover',
defaultMessage: 'Discover',
@@ -1949,9 +1949,6 @@
"layout.nav.discover": {
"message": "Objevit"
},
"layout.nav.discover-content": {
"message": "Objevit obsah"
},
"layout.nav.get-modrinth-app": {
"message": "Získat Modrinth App"
},
@@ -1985,9 +1985,6 @@
"layout.nav.discover": {
"message": "Opdag"
},
"layout.nav.discover-content": {
"message": "Opdag indhold"
},
"layout.nav.get-modrinth-app": {
"message": "Hent Modrinth-app"
},
@@ -2879,9 +2879,6 @@
"layout.nav.discover": {
"message": "Entdecken"
},
"layout.nav.discover-content": {
"message": "Inhalte entdecken"
},
"layout.nav.get-modrinth-app": {
"message": "Modrinth App herunterladen"
},
@@ -2879,9 +2879,6 @@
"layout.nav.discover": {
"message": "Entdecken"
},
"layout.nav.discover-content": {
"message": "Inhalte entdecken"
},
"layout.nav.get-modrinth-app": {
"message": "Modrinth App herunterladen"
},
@@ -2879,9 +2879,6 @@
"layout.nav.discover": {
"message": "Discover"
},
"layout.nav.discover-content": {
"message": "Discover content"
},
"layout.nav.get-modrinth-app": {
"message": "Get Modrinth App"
},
@@ -2630,9 +2630,6 @@
"layout.nav.discover": {
"message": "Descubre"
},
"layout.nav.discover-content": {
"message": "Descubrir contenido"
},
"layout.nav.get-modrinth-app": {
"message": "Obtener la Modrinth App"
},
@@ -2498,9 +2498,6 @@
"layout.nav.discover": {
"message": "Descubre"
},
"layout.nav.discover-content": {
"message": "Descubra el contenido"
},
"layout.nav.get-modrinth-app": {
"message": "Descargar la aplicación Modrinth"
},
@@ -1829,9 +1829,6 @@
"layout.nav.discover": {
"message": "Tumuklas"
},
"layout.nav.discover-content": {
"message": "Tumuklas ng kontento"
},
"layout.nav.get-modrinth-app": {
"message": "Kunin ang Modrinth App"
},
@@ -2873,9 +2873,6 @@
"layout.nav.discover": {
"message": "Découvrir"
},
"layout.nav.discover-content": {
"message": "Découvrir du contenu"
},
"layout.nav.get-modrinth-app": {
"message": "Obtenir Modrinth App"
},
@@ -1580,9 +1580,6 @@
"layout.nav.discover": {
"message": "גלה"
},
"layout.nav.discover-content": {
"message": "גלה תוכן"
},
"layout.nav.get-modrinth-app": {
"message": "להוריד אפליקציאת Modrinth"
},
@@ -2699,9 +2699,6 @@
"layout.nav.discover": {
"message": "Felfedezés"
},
"layout.nav.discover-content": {
"message": "Tartalom felfedezése"
},
"layout.nav.get-modrinth-app": {
"message": "Szerezd meg a Modrinth Appot"
},
@@ -1841,9 +1841,6 @@
"layout.nav.discover": {
"message": "Temukan"
},
"layout.nav.discover-content": {
"message": "Temukan konten"
},
"layout.nav.get-modrinth-app": {
"message": "Dapatkan Modrinth App"
},
@@ -2870,9 +2870,6 @@
"layout.nav.discover": {
"message": "Esplora"
},
"layout.nav.discover-content": {
"message": "Sfoglia i contenuti"
},
"layout.nav.get-modrinth-app": {
"message": "Scarica Modrinth App"
},
@@ -2369,9 +2369,6 @@
"layout.nav.discover": {
"message": "探索"
},
"layout.nav.discover-content": {
"message": "コンテンツを探索"
},
"layout.nav.get-modrinth-app": {
"message": "Modrinth Appを入手"
},
@@ -2873,9 +2873,6 @@
"layout.nav.discover": {
"message": "탐색"
},
"layout.nav.discover-content": {
"message": "콘텐츠 탐색하기"
},
"layout.nav.get-modrinth-app": {
"message": "Modrinth App 받기"
},
@@ -2354,9 +2354,6 @@
"layout.nav.discover": {
"message": "Temui"
},
"layout.nav.discover-content": {
"message": "Temui kandungan"
},
"layout.nav.get-modrinth-app": {
"message": "Dapatkan Modrinth App"
},
@@ -2876,9 +2876,6 @@
"layout.nav.discover": {
"message": "Ontdek"
},
"layout.nav.discover-content": {
"message": "Ontdek inhoud"
},
"layout.nav.get-modrinth-app": {
"message": "Modrinth-app downloaden"
},
@@ -2285,9 +2285,6 @@
"layout.nav.discover": {
"message": "Utforsk"
},
"layout.nav.discover-content": {
"message": "Utforsk innhold"
},
"layout.nav.get-modrinth-app": {
"message": "Skaff deg Modrinth App-en"
},
@@ -2870,9 +2870,6 @@
"layout.nav.discover": {
"message": "Odkrywaj"
},
"layout.nav.discover-content": {
"message": "Odkrywaj zawartość"
},
"layout.nav.get-modrinth-app": {
"message": "Pobierz Modrinth App"
},
@@ -2879,9 +2879,6 @@
"layout.nav.discover": {
"message": "Descubra"
},
"layout.nav.discover-content": {
"message": "Descubra conteúdo"
},
"layout.nav.get-modrinth-app": {
"message": "Baixe o Modrinth App"
},
@@ -2183,9 +2183,6 @@
"layout.nav.discover": {
"message": "Descobrir"
},
"layout.nav.discover-content": {
"message": "Descobrir conteúdo"
},
"layout.nav.get-modrinth-app": {
"message": "Obter a Modrinth App"
},
@@ -1106,9 +1106,6 @@
"layout.nav.discover": {
"message": "Descoperă"
},
"layout.nav.discover-content": {
"message": "Descoperă conținut"
},
"layout.nav.get-modrinth-app": {
"message": "Ia-ți Modrinth App"
},
@@ -2873,9 +2873,6 @@
"layout.nav.discover": {
"message": "Найти"
},
"layout.nav.discover-content": {
"message": "Найти проекты"
},
"layout.nav.get-modrinth-app": {
"message": "Скачать Modrinth App"
},
@@ -2552,9 +2552,6 @@
"layout.nav.discover": {
"message": "Utforska"
},
"layout.nav.discover-content": {
"message": "Utforska innehåll"
},
"layout.nav.get-modrinth-app": {
"message": "Installera Modrinth App"
},
@@ -2630,9 +2630,6 @@
"layout.nav.discover": {
"message": "Keşfet"
},
"layout.nav.discover-content": {
"message": "İçerik keşfet"
},
"layout.nav.get-modrinth-app": {
"message": "Modrinth App yükle"
},
@@ -2870,9 +2870,6 @@
"layout.nav.discover": {
"message": "Дослідити"
},
"layout.nav.discover-content": {
"message": "Дослідити вміст"
},
"layout.nav.get-modrinth-app": {
"message": "Установити Modrinth App"
},
@@ -2519,9 +2519,6 @@
"layout.nav.discover": {
"message": "Khám phá"
},
"layout.nav.discover-content": {
"message": "Khám phá nội dung"
},
"layout.nav.get-modrinth-app": {
"message": "Tải ứng dụng Modrinth"
},
@@ -2879,9 +2879,6 @@
"layout.nav.discover": {
"message": "发现"
},
"layout.nav.discover-content": {
"message": "发现内容"
},
"layout.nav.get-modrinth-app": {
"message": "获取 Modrinth App"
},
@@ -2879,9 +2879,6 @@
"layout.nav.discover": {
"message": "探索"
},
"layout.nav.discover-content": {
"message": "探索內容"
},
"layout.nav.get-modrinth-app": {
"message": "取得 Modrinth App"
},
+28 -29
View File
@@ -469,16 +469,12 @@
</div>
<ClientOnly>
<div
<ModerationChecklist
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
class="moderation-checklist"
>
<ModerationChecklist
:collapsed="collapsedModerationChecklist"
@exit="setModerationChecklistOpen(false)"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/>
</div>
:collapsed="collapsedModerationChecklist"
@exit="setModerationChecklistOpen(false)"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/>
</ClientOnly>
<template v-if="hasEditDetailsPermission">
@@ -505,6 +501,7 @@ import {
SettingsIcon,
XIcon,
} from '@modrinth/assets'
import { moderationSettings } from '@modrinth/moderation'
import {
Admonition,
Avatar,
@@ -535,7 +532,7 @@ import {
useStickyObserver,
useVIntl,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import { formatProjectType, isStaff } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core'
import { Tooltip } from 'floating-vue'
@@ -572,6 +569,8 @@ const router = useRouter()
const signInRouteObj = computed(() => getSignInRouteObj(route))
const config = useRuntimeConfig()
const moderationQueue = useModerationQueue()
const keybinds = useModerationKeybinds()
const modSettings = useModerationSettings()
const notifications = injectNotificationManager()
const { addNotification } = notifications
@@ -1965,6 +1964,25 @@ async function deleteVersion(id) {
stopLoading()
}
// moderation project keybinds
onMounted(() => window.addEventListener('keydown', handleKeybinds))
onUnmounted(() => window.removeEventListener('keydown', handleKeybinds))
function handleKeybinds(event) {
if (!isStaff(auth.value.user)) return
if (
!showModerationChecklist.value &&
!modSettings.value.get(moderationSettings.General.ProjectKeybinds)
)
return
keybinds.value.handle(event, {
project: projectRaw.value,
scope: 'project',
})
}
const navLinks = computed(() => {
const routeType = route.params.type || project.value.project_type
const projectUrl = `/${routeType}/${project.value.slug ? project.value.slug : project.value.id}`
@@ -2189,26 +2207,7 @@ provideProjectPageContext({
}
}
.moderation-checklist {
position: fixed;
bottom: 1rem;
right: 1rem;
overflow-y: auto;
z-index: 50;
transition: bottom 0.25s ease-in-out;
> div {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
}
.new-page {
column-gap: 1.5rem;
}
</style>
<style lang="scss">
body.floating-action-bar-shown .moderation-checklist {
bottom: 6rem;
}
</style>
@@ -28,6 +28,6 @@ const { data: report } = useQuery({
<template>
<div class="flex flex-col gap-3">
<ModerationReportCard v-if="report" :report="report" />
<ModerationReportCard v-if="report" :report="report" :collapsed="false" />
</div>
</template>
@@ -177,7 +177,12 @@
<div class="flex flex-col gap-4">
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
<ReportCard v-for="report in paginatedReports" :key="report.id" :report="report" />
<ReportCard
v-for="report in paginatedReports"
:key="report.id"
:report="report"
:collapsed="true"
/>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
@@ -305,6 +305,7 @@ function refetch() {
:focused-detail-id="focusedDetailId"
:loading-issues="loadingIssues"
:decompiled-sources="decompiledSources"
:collapsed="false"
@refetch="refetch"
@load-issue-sources="handleLoadIssueSources"
@mark-complete="handleMarkComplete"
@@ -719,6 +719,7 @@ watch(totalPages, (pages) => {
:item="item"
:loading-issues="loadingIssues"
:decompiled-sources="decompiledSources"
:collapsed="true"
@refetch="refetch"
@load-issue-sources="handleLoadIssueSources"
@mark-complete="handleMarkComplete"
+10
View File
@@ -86,6 +86,14 @@
icon: ServerIcon,
}
: null,
isStaff(auth.user) ? { type: 'heading', label: 'Staff' } : null,
isStaff(auth.user)
? {
link: '/settings/moderation',
label: 'Moderation',
icon: ScaleIcon,
}
: null,
].filter(Boolean)
"
/>
@@ -103,6 +111,7 @@ import {
LanguagesIcon,
MonitorSmartphoneIcon,
PaintbrushIcon,
ScaleIcon,
ServerIcon,
ShieldIcon,
ToggleRightIcon,
@@ -115,6 +124,7 @@ import {
NormalPage,
useVIntl,
} from '@modrinth/ui'
import { isStaff } from '@modrinth/utils'
import NavStack from '~/components/ui/NavStack.vue'
@@ -0,0 +1,13 @@
<script setup lang="ts">
import ModerationKeybinds from '~/components/ui/moderation/settings/ModerationKeybinds.vue'
import ModerationSettings from '~/components/ui/moderation/settings/ModerationSettings.vue'
useSeoMeta({
robots: 'noindex',
})
</script>
<template>
<ModerationKeybinds />
<ModerationSettings />
</template>
-1
View File
@@ -53,7 +53,6 @@ pub struct Settings {
pub enum FeatureFlag {
PagePath,
ProjectBackground,
WorldsTab,
WorldsInHome,
ServerRamAsBytesAlwaysOn,
AlwaysShowAppControls,
+2
View File
@@ -244,6 +244,7 @@ import _ShareIcon from './icons/share.svg?component'
import _ShieldIcon from './icons/shield.svg?component'
import _ShieldAlertIcon from './icons/shield-alert.svg?component'
import _ShieldCheckIcon from './icons/shield-check.svg?component'
import _ShirtIcon from './icons/shirt.svg?component'
import _SignalIcon from './icons/signal.svg?component'
import _SignatureIcon from './icons/signature.svg?component'
import _SkullIcon from './icons/skull.svg?component'
@@ -676,6 +677,7 @@ export const ShareIcon = _ShareIcon
export const ShieldIcon = _ShieldIcon
export const ShieldAlertIcon = _ShieldAlertIcon
export const ShieldCheckIcon = _ShieldCheckIcon
export const ShirtIcon = _ShirtIcon
export const SignalIcon = _SignalIcon
export const SignatureIcon = _SignatureIcon
export const SkullIcon = _SkullIcon
+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-shirt"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20.38 3.46 16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z" />
</svg>

After

Width:  |  Height:  |  Size: 478 B

+46 -5
View File
@@ -1,77 +1,118 @@
import type { KeybindListener } from '../types/keybinds'
import type { Labrinth } from '@modrinth/api-client'
const copyProjectLink = async (
project: Labrinth.Projects.v2.Project,
permalink: boolean,
relative: boolean,
page: boolean,
) => {
let url = ``
if (relative) {
url += `${globalThis.location.origin}`
} else {
url += `https://modrinth.com`
}
if (permalink) {
url += `/project/${project.id}`
} else {
url += `/${project.project_type}/${project.slug}`
}
if (page) {
url += `/${globalThis.location.pathname.split('/').slice(3).join('/')}`
}
await navigator.clipboard.writeText(url)
}
const keybinds: { [id: string]: KeybindListener } = {
'next-stage': {
keybind: 'ArrowRight',
description: 'Go to next stage',
scope: 'checklist',
enabled: (ctx) => !ctx.state.isDone,
action: (ctx) => ctx.actions.tryGoNext(),
},
'previous-stage': {
keybind: 'ArrowLeft',
description: 'Go to previous stage',
scope: 'checklist',
enabled: (ctx) => !ctx.state.isDone,
action: (ctx) => ctx.actions.tryGoBack(),
},
'generate-message': {
keybind: 'Ctrl+Shift+E',
description: 'Generate moderation message',
scope: 'checklist',
action: (ctx) => ctx.actions.tryGenerateMessage(),
},
'toggle-collapse': {
keybind: 'Shift+C',
description: 'Toggle collapse/expand',
scope: 'checklist',
action: (ctx) => ctx.actions.tryToggleCollapse(),
},
'reset-progress': {
keybind: 'Ctrl+Shift+R',
description: 'Reset moderation progress',
scope: 'checklist',
action: (ctx) => ctx.actions.tryResetProgress(),
},
'skip-project': {
keybind: 'Ctrl+Shift+S',
description: 'Skip to next project',
scope: 'checklist',
enabled: (ctx) => ctx.state.futureProjectCount > 0 && !ctx.state.isDone,
action: (ctx) => ctx.actions.trySkipProject(),
},
'copy-permalink': {
keybind: 'Ctrl+Alt+C',
description: 'Copy permalink',
action: (ctx) => ctx.actions.tryCopyLink(true, false, false),
scope: 'project',
action: async (ctx) => copyProjectLink(ctx.project, true, false, false),
},
'copy-relative-permalink': {
keybind: 'Ctrl+Alt+R',
description: 'Copy relative permalink',
action: (ctx) => ctx.actions.tryCopyLink(true, true, false),
scope: 'project',
action: async (ctx) => copyProjectLink(ctx.project, true, true, false),
},
'copy-page-permalink': {
keybind: 'Shift+Ctrl+Alt+C',
description: 'Copy permalink with page',
action: (ctx) => ctx.actions.tryCopyLink(true, false, true),
scope: 'project',
action: async (ctx) => copyProjectLink(ctx.project, true, false, true),
},
'copy-page-relative-permalink': {
keybind: 'Shift+Ctrl+Alt+R',
description: 'Copy relative permalink with page',
action: (ctx) => ctx.actions.tryCopyLink(true, true, true),
scope: 'project',
action: async (ctx) => copyProjectLink(ctx.project, true, true, true),
},
'copy-id': {
keybind: 'Ctrl+Alt+D',
description: 'Copy Project ID',
action: (ctx) => ctx.actions.tryCopyId(),
scope: 'project',
action: async (ctx) => await navigator.clipboard.writeText(ctx.project.id),
},
'approve-project': {
keybind: 'Shift+Alt+A',
description: 'Approve project',
scope: 'checklist',
action: (ctx) => ctx.actions.tryApprove(),
},
'withhold-project': {
keybind: 'Shift+Alt+W',
description: 'Withhold project',
scope: 'checklist',
action: (ctx) => ctx.actions.tryWithhold(),
},
'reject-project': {
keybind: 'Shift+Alt+R',
description: 'Reject project',
scope: 'checklist',
action: (ctx) => ctx.actions.tryReject(),
},
}
+33
View File
@@ -0,0 +1,33 @@
import type { EnumSettingDefinition, ToggleSettingDefinition } from '../types/settings.ts'
const settings = {
General: {
ChecklistPosition: {
type: 'enum',
id: 'checklist-position',
title: 'Checklist Position',
description: 'Where the checklist should be displayed on the page',
entries: [
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' },
],
default: 'right',
} as EnumSettingDefinition,
ProjectKeybinds: {
type: 'toggle',
id: 'project-keybinds',
title: 'Enable Project Keybinds',
description: 'Weather certain keybinds should work without the checklist visible.',
default: false,
} as ToggleSettingDefinition,
PrivateMessageHighlight: {
type: 'toggle',
id: 'private-message-highlight',
title: 'Highlight Private Messages',
description: 'Whether private messages should be highlighted in the chat.',
default: true,
} as ToggleSettingDefinition,
},
} as const
export default settings
@@ -0,0 +1,82 @@
import {
type KeybindDefinition,
type KeybindListener,
matchesKeybind,
type ModerationContext,
normalizeKeybind,
} from '../types/keybinds.ts'
import keybinds from '../data/keybinds.ts'
function normalizeKeybinds(
keybind: KeybindDefinition | KeybindDefinition[] | string | string[],
): KeybindDefinition[] {
return Array.isArray(keybind) ? keybind.map(normalizeKeybind) : [normalizeKeybind(keybind)]
}
export type KeybindListenerWithDefault = KeybindListener & {
keybind: KeybindDefinition[]
defaultKeybind: KeybindDefinition[]
}
export class Keybinds {
private readonly configured: { [id: string]: KeybindDefinition[] } = {}
constructor(keybinds: { [id: string]: KeybindDefinition[] }) {
this.configured = keybinds
}
*[Symbol.iterator](): IterableIterator<[string, KeybindListenerWithDefault]> {
for (const [id, keybind] of Object.entries(keybinds)) {
yield [
id,
{
...keybind,
keybind: this.configured[id] ?? normalizeKeybinds(keybind.keybind),
defaultKeybind: normalizeKeybinds(keybind.keybind),
},
]
}
}
set(id: string, keybind: KeybindDefinition | KeybindDefinition[] | string | string[]): void {
this.configured[id] = normalizeKeybinds(keybind)
}
handle(event: KeyboardEvent, ctx: ModerationContext): boolean {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
(event.target as HTMLElement)?.closest('.cm-editor') ||
(event.target as HTMLElement)?.classList?.contains('cm-content') ||
(event.target as HTMLElement)?.classList?.contains('cm-line')
) {
return false
}
for (const [id, keybind] of Object.entries(keybinds)) {
if (ctx.scope !== keybind.scope) {
continue
}
if (keybind.enabled && !keybind.enabled(ctx as any)) {
continue
}
const definitions = this.configured[id] ?? normalizeKeybinds(keybind.keybind)
const matches = definitions.some((def) => matchesKeybind(event, def))
if (matches) {
keybind.action(ctx as any)
const shouldPrevent = definitions.some((def) => def.preventDefault !== false)
if (shouldPrevent) {
event.preventDefault()
}
return true
}
}
return false
}
}
@@ -0,0 +1,25 @@
import type { SettingDefinitionBase } from '../types/settings.ts'
export class Settings {
private readonly settings: { [id: string]: any }
private readonly onChange: () => void
constructor(
settings: { [id: string]: any } | undefined = undefined,
onChange: () => void = () => {},
) {
this.settings = settings || {}
this.onChange = onChange
}
get<T>(definition: SettingDefinitionBase<T>): T {
return this.settings[definition.id] ?? definition.default
}
set<T>(definition: SettingDefinitionBase<T>, value?: T): void {
const previous = this.settings[definition.id] ?? definition.default
this.settings[definition.id] = value
definition.onChange?.(previous, value ?? definition.default)
this.onChange()
}
}
+4
View File
@@ -1,5 +1,6 @@
export { default as checklist, stages, useStages } from './data/checklist'
export { default as keybinds } from './data/keybinds'
export { default as moderationSettings } from './data/settings'
export { default as nags } from './data/nags'
export * from './data/nags/index'
export { default as attributionQuickReplies } from './data/quick-replies/permissions-quick-replies'
@@ -11,6 +12,7 @@ export {
export * from './locales'
export * from './types/actions'
export * from './types/keybinds'
export * from './types/settings'
export * from './types/messages'
export * from './types/nags'
export * from './types/node'
@@ -19,3 +21,5 @@ export * from './types/quick-reply'
export * from './types/reports'
export * from './types/stage'
export * from './utils'
export * from './handles/keybinds'
export * from './handles/settings'
+21 -60
View File
@@ -14,17 +14,6 @@ export interface ModerationActions {
tryReject: () => void
tryWithhold: () => void
tryEditMessage: () => void
tryToggleAction: (actionIndex: number) => void
trySelectDropdownOption: (actionIndex: number, optionIndex: number) => void
tryToggleChip: (actionIndex: number, chipIndex: number) => void
tryFocusNextAction: () => void
tryFocusPreviousAction: () => void
tryActivateFocusedAction: () => void
tryCopyLink: (permalink: boolean, relative: boolean, page: boolean) => void
tryCopyId: () => void
}
export interface ModerationState {
@@ -41,17 +30,22 @@ export interface ModerationState {
futureProjectCount: number
visibleActionsCount: number
focusedActionIndex: number | null
focusedActionType: 'button' | 'toggle' | 'dropdown' | 'multi-select' | null
}
export interface ModerationContext {
export type ModerationProjectContext = {
project: Labrinth.Projects.v2.Project
scope: 'project'
}
export type ModerationChecklistContext = {
project: Labrinth.Projects.v2.Project
scope: 'checklist'
state: ModerationState
actions: ModerationActions
}
export type ModerationContext = ModerationProjectContext | ModerationChecklistContext
export interface KeybindDefinition {
key: string
ctrl?: boolean
@@ -61,13 +55,22 @@ export interface KeybindDefinition {
preventDefault?: boolean
}
export interface KeybindListener {
export type BaseKeybindListener<T> = {
keybind: KeybindDefinition | KeybindDefinition[] | string | string[]
description: string
enabled?: (ctx: ModerationContext) => boolean
action: (ctx: ModerationContext) => void
scope: 'project' | 'checklist'
enabled?: (ctx: T) => boolean
action: (ctx: T) => void
}
export type KeybindProjectListener = BaseKeybindListener<ModerationProjectContext> & {
scope: 'project'
}
export type KeybindChecklistListener = BaseKeybindListener<ModerationChecklistContext> & {
scope: 'checklist'
}
export type KeybindListener = KeybindProjectListener | KeybindChecklistListener
export function parseKeybind(keybindString: string): KeybindDefinition {
const parts = keybindString.split('+').map((p) => p.trim().toLowerCase())
@@ -106,45 +109,3 @@ export function toKeybindDefinition(event: KeyboardEvent): KeybindDefinition {
preventDefault: true,
}
}
export function handleKeybind(
event: KeyboardEvent,
ctx: ModerationContext,
keybinds: KeybindListener[],
): boolean {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
(event.target as HTMLElement)?.closest('.cm-editor') ||
(event.target as HTMLElement)?.classList?.contains('cm-content') ||
(event.target as HTMLElement)?.classList?.contains('cm-line') ||
document.getElementById('moderation-checklist-keybinds-modal')
) {
return false
}
for (const keybind of keybinds) {
if (keybind.enabled && !keybind.enabled(ctx)) {
continue
}
const keybindDefs = Array.isArray(keybind.keybind)
? keybind.keybind.map(normalizeKeybind)
: [normalizeKeybind(keybind.keybind)]
const matches = keybindDefs.some((def) => matchesKeybind(event, def))
if (matches) {
keybind.action(ctx)
const shouldPrevent = keybindDefs.some((def) => def.preventDefault !== false)
if (shouldPrevent) {
event.preventDefault()
}
return true
}
}
return false
}

Some files were not shown because too many files have changed in this diff Show More