mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +00:00
Merge remote-tracking branch 'origin/main' into cal/user-blocking-profile-settings
# Conflicts: # apps/app-frontend/src/App.vue
This commit is contained in:
+90
-194
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "探索伺服器"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]"
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<div class="p-6 flex flex-col gap-2">Worlds</div>
|
||||
</template>
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -53,7 +53,6 @@ pub struct Settings {
|
||||
pub enum FeatureFlag {
|
||||
PagePath,
|
||||
ProjectBackground,
|
||||
WorldsTab,
|
||||
WorldsInHome,
|
||||
ServerRamAsBytesAlwaysOn,
|
||||
AlwaysShowAppControls,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user