Merge remote-tracking branch 'origin/main' into cal/breadcrumbs-app

# Conflicts:
#	apps/app-frontend/src/App.vue
#	apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue
#	apps/app-frontend/src/locales/en-US/index.json
#	apps/app-frontend/src/pages/Browse.vue
#	apps/app-frontend/src/pages/Worlds.vue
#	apps/app-frontend/src/routes.js
This commit is contained in:
Calum H. (IMB11)
2026-07-28 20:57:36 +01:00
72 changed files with 1383 additions and 777 deletions
+90 -196
View File
@@ -10,25 +10,21 @@ import {
} from '@modrinth/api-client'
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
ExternalIcon,
HomeIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
NotepadTextIcon,
PlusIcon,
RefreshCwIcon,
RightArrowIcon,
ServerStackIcon,
SettingsIcon,
ShirtIcon,
UserIcon,
WorldIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
@@ -40,6 +36,7 @@ import {
CreationFlowModal,
defineMessages,
I18nDebugPanel,
IntlFormatted,
LoadingBar,
NewsArticleCard,
NotificationPanel,
@@ -64,7 +61,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'
@@ -89,6 +85,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'
@@ -105,12 +102,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'
@@ -150,6 +142,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'
@@ -304,7 +297,6 @@ const {
} = setupProviders(notificationManager, popupNotificationManager)
const news = ref([])
const availableSurvey = ref(false)
const displayedServerInviteNotifications = new Set()
const serverInvitePopupNotificationIds = new Set()
let liveNotificationGeneration = 0
@@ -418,6 +410,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) {
@@ -586,12 +626,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)
@@ -1455,115 +1489,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>
@@ -1586,7 +1511,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>
@@ -1609,27 +1534,24 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
/>
<UnknownPackWarningModal ref="unknownPackWarningModal" />
<div
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.5rem] 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="
@@ -1642,19 +1564,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"
>
@@ -1669,7 +1590,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="[
{
@@ -1688,19 +1609,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>
<ExternalIcon />
</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" />
@@ -1765,28 +1694,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="{
@@ -1850,7 +1758,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>
@@ -1865,7 +1775,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"
@@ -1874,7 +1786,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>
@@ -1887,7 +1800,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>
@@ -2099,26 +2013,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,19 +1,124 @@
<script setup>
import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, injectNotificationManager } from '@modrinth/ui'
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
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 queryClient = useQueryClient()
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)
@@ -21,27 +126,26 @@ const getInstances = async () => {
queryClient.setQueryData(['instances', 'summary', instance.id], instance)
}
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') {
@@ -49,29 +153,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">
@@ -761,6 +761,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"
},
@@ -785,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}"
},
@@ -971,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"
},
@@ -1130,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."
},
+2 -5
View File
@@ -60,6 +60,7 @@ import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { handleSevereError } from '@/store/error'
import { useTheming } from '@/store/state'
import { appMessages } from '@/utils/app-messages'
useRootBreadcrumb({
slot: 'root',
@@ -78,10 +79,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',
@@ -1081,7 +1078,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)]"
-16
View File
@@ -1,16 +0,0 @@
<script setup lang="ts">
import { WorldIcon } from '@modrinth/assets'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
useRootBreadcrumb({
slot: 'root',
id: 'worlds',
label: 'Worlds',
to: '/worlds',
visual: { type: 'icon', component: WorldIcon },
})
</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 }
-5
View File
@@ -17,11 +17,6 @@ export default new createRouter({
name: 'Home',
component: Pages.Index,
},
{
path: '/worlds',
name: 'Worlds',
component: Pages.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
@@ -79,6 +79,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)
"
/>
@@ -95,6 +103,7 @@ import {
LanguagesIcon,
MonitorSmartphoneIcon,
PaintbrushIcon,
ScaleIcon,
ServerIcon,
ShieldIcon,
ToggleRightIcon,
@@ -107,6 +116,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>