feat: moderation settings (#6895)

* feat: moderation settings

* chore: move moderation specific feature flag to settings

* feat: force open collapsed regions if opened in new tab

* chore: remove unused field

* chore: run prepr
This commit is contained in:
ThatGravyBoat
2026-07-28 19:38:56 +00:00
committed by GitHub
parent 82348fe40a
commit fe69e04785
25 changed files with 698 additions and 416 deletions
@@ -300,6 +300,7 @@ type SharedInstanceVersionDependency = Labrinth.Versions.v2.Dependency & {
const props = defineProps<{ const props = defineProps<{
report: ExtendedReport report: ExtendedReport
collapsed: boolean
sharedInstanceDetailsLoader?: () => Promise<SharedInstanceReportDetails> sharedInstanceDetailsLoader?: () => Promise<SharedInstanceReportDetails>
sharedInstanceVersionContentLoader?: ( sharedInstanceVersionContentLoader?: (
instanceId: string, instanceId: string,
@@ -311,7 +312,7 @@ const reportThread = ref<{
setReplyContent: (content: string) => void setReplyContent: (content: string) => void
sendReply: (privateMessage?: boolean) => Promise<void> sendReply: (privateMessage?: boolean) => Promise<void>
} | null>(null) } | null>(null)
const isThreadCollapsed = ref(true) const isThreadCollapsed = ref(props.collapsed)
const sharedInstanceDetails = ref<SharedInstanceReportDetails | null>(null) const sharedInstanceDetails = ref<SharedInstanceReportDetails | null>(null)
const sharedInstanceLoading = ref(false) const sharedInstanceLoading = ref(false)
const sharedInstanceError = ref<string | null>(null) const sharedInstanceError = ref<string | null>(null)
@@ -94,6 +94,7 @@ const props = defineProps<{
focusedDetailId?: string | null focusedDetailId?: string | null
loadingIssues: Set<string> loadingIssues: Set<string>
decompiledSources: Map<string, string> decompiledSources: Map<string, string>
collapsed: boolean
}>() }>()
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
@@ -173,7 +174,7 @@ type Tab = 'Thread' | 'Files' | 'File'
const tabs: readonly ('Thread' | 'Files')[] = ['Thread', 'Files'] const tabs: readonly ('Thread' | 'Files')[] = ['Thread', 'Files']
const currentTab = ref<Tab>('Thread') const currentTab = ref<Tab>('Thread')
const isThreadCollapsed = ref(true) const isThreadCollapsed = ref(props.collapsed)
const remainingMessageCount = computed(() => { const remainingMessageCount = computed(() => {
if (!props.item.thread?.messages) return 0 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> <template>
<KeybindsModal ref="keybindsModal" />
<ConfirmModal <ConfirmModal
v-if="isLockedByOther" v-if="isLockedByOther"
ref="takeOverModal" ref="takeOverModal"
@@ -13,7 +12,12 @@
<div <div
tabindex="0" 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="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 grow-0 flex-col gap-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -52,11 +56,6 @@
{{ checklistTitleText }} {{ checklistTitleText }}
</button> </button>
</h1> </h1>
<ButtonStyled circular>
<button v-tooltip="`Keyboard shortcuts`" @click="keybindsModal?.show($event)">
<KeyboardIcon />
</button>
</ButtonStyled>
<ButtonStyled v-if="!isPseudoStage && currentStageObj._guidanceUrl" circular> <ButtonStyled v-if="!isPseudoStage && currentStageObj._guidanceUrl" circular>
<a v-tooltip="`Stage guidance`" target="_blank" :href="currentStageObj._guidanceUrl"> <a v-tooltip="`Stage guidance`" target="_blank" :href="currentStageObj._guidanceUrl">
<FileTextIcon /> <FileTextIcon />
@@ -370,7 +369,6 @@ import {
CheckIcon, CheckIcon,
DropdownIcon, DropdownIcon,
FileTextIcon, FileTextIcon,
KeyboardIcon,
LeftArrowIcon, LeftArrowIcon,
LinkIcon, LinkIcon,
ListBulletedIcon, ListBulletedIcon,
@@ -383,12 +381,13 @@ import {
UndoIcon, UndoIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import type { import {
IdentifiedNodeBuilder, type IdentifiedNodeBuilder,
NodeState, moderationSettings,
Priority, type NodeState,
StageNodeBuilder, type Priority,
ValueNodeBuilder, type StageNodeBuilder,
type ValueNodeBuilder,
} from '@modrinth/moderation' } from '@modrinth/moderation'
import { import {
createTrackedPatch, createTrackedPatch,
@@ -396,7 +395,6 @@ import {
expandVariables, expandVariables,
getBooleanChildState, getBooleanChildState,
GLOBAL_STATE_KEY, GLOBAL_STATE_KEY,
handleKeybind,
isNodeActive, isNodeActive,
kebabToTitleCase, kebabToTitleCase,
NodeBuilder, NodeBuilder,
@@ -438,15 +436,14 @@ import type { LockAcquireResponse } from '~/services/moderation-queue.ts'
import { useModerationQueue } 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 { type ActiveAction, type LiveNode, NODE_META_KEY, STATE_KEY } from './checklist-context'
import KeybindsModal from './ChecklistKeybindsModal.vue'
import NodeRenderer from './NodeRenderer.vue' import NodeRenderer from './NodeRenderer.vue'
const notifications = injectNotificationManager() const notifications = injectNotificationManager()
const { addNotification } = notifications const { addNotification } = notifications
const debug = useDebugLogger('ModerationChecklist') const debug = useDebugLogger('ModerationChecklist')
const keybinds = useModerationKeybinds() const keybinds = useModerationKeybinds()
const settings = useModerationSettings()
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>()
const takeOverModal = ref<InstanceType<typeof ConfirmModal>>() const takeOverModal = ref<InstanceType<typeof ConfirmModal>>()
const props = defineProps<{ const props = defineProps<{
@@ -1223,83 +1220,43 @@ interface MessagePart {
content: string content: string
} }
function ignoreLegacyActionKeybind() {
return undefined
}
function handleKeybinds(event: KeyboardEvent) { function handleKeybinds(event: KeyboardEvent) {
handleKeybind( keybinds.value.handle(event, {
event, project: projectV2.value,
{ scope: 'checklist',
project: projectV2.value, state: {
state: { currentStage: currentStage.value,
currentStage: currentStage.value, totalStages: resolvedStages.value.length,
totalStages: resolvedStages.value.length, currentStageId: currentStageObj.value.id,
currentStageId: currentStageObj.value.id, currentStageTitle: currentStageObj.value.label,
currentStageTitle: currentStageObj.value.label,
isCollapsed: props.collapsed, isCollapsed: props.collapsed,
isDone: done.value, isDone: done.value,
hasGeneratedMessage: generatedMessage.value, hasGeneratedMessage: generatedMessage.value,
isLoadingMessage: loadingMessage.value, isLoadingMessage: loadingMessage.value,
futureProjectCount: moderationQueue.queueLength, futureProjectCount: moderationQueue.queueLength,
visibleActionsCount: resolveChildren( visibleActionsCount: resolveChildren(
currentStageObj.value, currentStageObj.value,
nodeStates.value[currentStageObj.value.id!] ?? {}, nodeStates.value[currentStageObj.value.id!] ?? {},
).filter((c) => c instanceof NodeBuilder).length, ).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,
},
}, },
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 // Trigger debounced prefetch when user progresses through stages
@@ -1315,7 +1272,9 @@ onMounted(async () => {
window.addEventListener('keydown', handleKeybinds) window.addEventListener('keydown', handleKeybinds)
window.addEventListener('beforeunload', handleBeforeUnload) window.addEventListener('beforeunload', handleBeforeUnload)
document.addEventListener('visibilitychange', handleVisibilityChange) 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') const finishedId = localStorage.getItem('moderation-checklist-finished')
if (finishedId === projectV2.value.id) { if (finishedId === projectV2.value.id) {
@@ -1870,6 +1829,12 @@ const stageOptions = computed<StageOption[]>(() => {
<style scoped lang="scss"> <style scoped lang="scss">
.moderation-checklist { .moderation-checklist {
position: fixed;
bottom: 1rem;
overflow-y: auto;
z-index: 50;
transition: bottom 0.25s ease-in-out;
@media (prefers-reduced-motion) { @media (prefers-reduced-motion) {
transition: none !important; 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, 'has-body': message.body.type === 'text' && !forceCompact,
'no-actions': noLinks, 'no-actions': noLinks,
private: isPrivateMessage, private: isPrivateMessage,
'show-private-bg': flags.showModeratorPrivateMessageHighlight, 'show-private-bg': settings.get(moderationSettings.General.PrivateMessageHighlight),
}" }"
> >
<template v-if="members[message.author_id]"> <template v-if="members[message.author_id]">
@@ -149,6 +149,7 @@ import {
ScaleIcon, ScaleIcon,
TrashIcon, TrashIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { moderationSettings } from '@modrinth/moderation'
import { import {
AutoLink, AutoLink,
Avatar, Avatar,
@@ -194,7 +195,7 @@ const props = defineProps({
}) })
const emit = defineEmits(['update-thread']) const emit = defineEmits(['update-thread'])
const flags = useFeatureFlags() const settings = useModerationSettings()
const formattedMessage = computed(() => { const formattedMessage = computed(() => {
const body = renderString(props.message.body.body) const body = renderString(props.message.body.body)
@@ -53,7 +53,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
alwaysIgnoreErrorBanner: false, alwaysIgnoreErrorBanner: false,
showViewProdRouteBanner: false, showViewProdRouteBanner: false,
showModeratorProjectMemberUi: false, showModeratorProjectMemberUi: false,
showModeratorPrivateMessageHighlight: true,
archonApiStaging: false, archonApiStaging: false,
showHostingAccessInstanceAuditLog: false, showHostingAccessInstanceAuditLog: false,
versionDevInfoCollapsed: true, versionDevInfoCollapsed: true,
+57 -37
View File
@@ -1,61 +1,81 @@
import { import { type KeybindDefinition, Keybinds, Settings } from '@modrinth/moderation'
type KeybindDefinition, import { computed } from 'vue'
type KeybindListener,
keybinds,
normalizeKeybind,
} from '@modrinth/moderation'
import type { CookieOptions } from '#app' import type { CookieOptions } from '#app'
const moderationKeybindsId = 'moderation-keybinds' const moderationKeybindsId = 'moderation-keybinds'
const moderationSettingsId = 'moderation-settings'
type StoredKeybinds = { [id: string]: KeybindDefinition[] } 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, maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax', sameSite: 'lax',
secure: useRuntimeConfig().public.cookieSecure, secure: useRuntimeConfig().public.cookieSecure,
httpOnly: false, httpOnly: false,
path: '/', path: '/',
}) satisfies CookieOptions<PartialStoredKeybinds> }) satisfies CookieOptions<T>
export const useModerationKeybinds = () => const useModerationCookies = () => {
useState<{ [id: string]: KeybindListener }>(moderationKeybindsId, () => { const keybindCookie = useCookie<Partial<StoredKeybinds> | null>(
const storedKeybinds = useCookie<PartialStoredKeybinds>( moderationKeybindsId,
moderationKeybindsId, getCookieOptions(),
getCookieOptions(), )
) const optionsCookie = useCookie<Partial<StoredOptions>>(moderationSettingsId, getCookieOptions())
if (!storedKeybinds.value) { if (keybindCookie.value && !optionsCookie.value) {
storedKeybinds.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 } = {} const settingsOutput: StoredSettings = {}
for (const [id, setting] of Object.entries(cookie.value.settings || {})) {
for (const [id, keybind] of Object.entries(keybinds)) { settingsOutput[id] = setting
const definitions = storedKeybinds.value[id]
output[id] = {
keybind: definitions !== undefined ? definitions : keybind.keybind,
description: keybind.description,
enabled: keybind.enabled,
action: keybind.action,
}
} }
return output return {
keybinds: keybindOutput,
settings: settingsOutput,
}
}) })
export const saveModerationKeybinds = () => { export const useModerationKeybinds = () =>
const keybinds = useModerationKeybinds() computed(() => new Keybinds(useModerationOptions().value.keybinds))
const cookie = useCookie<PartialStoredKeybinds>(moderationKeybindsId, getCookieOptions())
const storedKeybinds: PartialStoredKeybinds = {} export const useModerationSettings = () =>
for (const [id, keybind] of Object.entries(keybinds.value)) { computed(() => new Settings(useModerationOptions().value.settings, saveModerationOptions))
storedKeybinds[id] = (Array.isArray(keybind.keybind) ? keybind.keybind : [keybind.keybind]).map(
normalizeKeybind, export const saveModerationOptions = () => {
) const options = useModerationOptions()
const cookie = useModerationCookies()
cookie.value = {
keybinds: options.value.keybinds,
settings: options.value.settings,
} }
cookie.value = storedKeybinds
} }
+28 -29
View File
@@ -469,16 +469,12 @@
</div> </div>
<ClientOnly> <ClientOnly>
<div <ModerationChecklist
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist" v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
class="moderation-checklist" :collapsed="collapsedModerationChecklist"
> @exit="setModerationChecklistOpen(false)"
<ModerationChecklist @toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
:collapsed="collapsedModerationChecklist" />
@exit="setModerationChecklistOpen(false)"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/>
</div>
</ClientOnly> </ClientOnly>
<template v-if="hasEditDetailsPermission"> <template v-if="hasEditDetailsPermission">
@@ -505,6 +501,7 @@ import {
SettingsIcon, SettingsIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { moderationSettings } from '@modrinth/moderation'
import { import {
Admonition, Admonition,
Avatar, Avatar,
@@ -535,7 +532,7 @@ import {
useStickyObserver, useStickyObserver,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils' import { formatProjectType, isStaff } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core' import { useLocalStorage } from '@vueuse/core'
import { Tooltip } from 'floating-vue' import { Tooltip } from 'floating-vue'
@@ -572,6 +569,8 @@ const router = useRouter()
const signInRouteObj = computed(() => getSignInRouteObj(route)) const signInRouteObj = computed(() => getSignInRouteObj(route))
const config = useRuntimeConfig() const config = useRuntimeConfig()
const moderationQueue = useModerationQueue() const moderationQueue = useModerationQueue()
const keybinds = useModerationKeybinds()
const modSettings = useModerationSettings()
const notifications = injectNotificationManager() const notifications = injectNotificationManager()
const { addNotification } = notifications const { addNotification } = notifications
@@ -1965,6 +1964,25 @@ async function deleteVersion(id) {
stopLoading() 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 navLinks = computed(() => {
const routeType = route.params.type || project.value.project_type const routeType = route.params.type || project.value.project_type
const projectUrl = `/${routeType}/${project.value.slug ? project.value.slug : project.value.id}` 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 { .new-page {
column-gap: 1.5rem; column-gap: 1.5rem;
} }
</style> </style>
<style lang="scss">
body.floating-action-bar-shown .moderation-checklist {
bottom: 6rem;
}
</style>
@@ -28,6 +28,6 @@ const { data: report } = useQuery({
<template> <template>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<ModerationReportCard v-if="report" :report="report" /> <ModerationReportCard v-if="report" :report="report" :collapsed="false" />
</div> </div>
</template> </template>
@@ -177,7 +177,12 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div> <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>
<div v-if="totalPages > 1" class="mt-4 flex justify-center"> <div v-if="totalPages > 1" class="mt-4 flex justify-center">
@@ -305,6 +305,7 @@ function refetch() {
:focused-detail-id="focusedDetailId" :focused-detail-id="focusedDetailId"
:loading-issues="loadingIssues" :loading-issues="loadingIssues"
:decompiled-sources="decompiledSources" :decompiled-sources="decompiledSources"
:collapsed="false"
@refetch="refetch" @refetch="refetch"
@load-issue-sources="handleLoadIssueSources" @load-issue-sources="handleLoadIssueSources"
@mark-complete="handleMarkComplete" @mark-complete="handleMarkComplete"
@@ -719,6 +719,7 @@ watch(totalPages, (pages) => {
:item="item" :item="item"
:loading-issues="loadingIssues" :loading-issues="loadingIssues"
:decompiled-sources="decompiledSources" :decompiled-sources="decompiledSources"
:collapsed="true"
@refetch="refetch" @refetch="refetch"
@load-issue-sources="handleLoadIssueSources" @load-issue-sources="handleLoadIssueSources"
@mark-complete="handleMarkComplete" @mark-complete="handleMarkComplete"
+10
View File
@@ -79,6 +79,14 @@
icon: ServerIcon, icon: ServerIcon,
} }
: null, : null,
isStaff(auth.user) ? { type: 'heading', label: 'Staff' } : null,
isStaff(auth.user)
? {
link: '/settings/moderation',
label: 'Moderation',
icon: ScaleIcon,
}
: null,
].filter(Boolean) ].filter(Boolean)
" "
/> />
@@ -95,6 +103,7 @@ import {
LanguagesIcon, LanguagesIcon,
MonitorSmartphoneIcon, MonitorSmartphoneIcon,
PaintbrushIcon, PaintbrushIcon,
ScaleIcon,
ServerIcon, ServerIcon,
ShieldIcon, ShieldIcon,
ToggleRightIcon, ToggleRightIcon,
@@ -107,6 +116,7 @@ import {
NormalPage, NormalPage,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { isStaff } from '@modrinth/utils'
import NavStack from '~/components/ui/NavStack.vue' 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>
+46 -5
View File
@@ -1,77 +1,118 @@
import type { KeybindListener } from '../types/keybinds' 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 } = { const keybinds: { [id: string]: KeybindListener } = {
'next-stage': { 'next-stage': {
keybind: 'ArrowRight', keybind: 'ArrowRight',
description: 'Go to next stage', description: 'Go to next stage',
scope: 'checklist',
enabled: (ctx) => !ctx.state.isDone, enabled: (ctx) => !ctx.state.isDone,
action: (ctx) => ctx.actions.tryGoNext(), action: (ctx) => ctx.actions.tryGoNext(),
}, },
'previous-stage': { 'previous-stage': {
keybind: 'ArrowLeft', keybind: 'ArrowLeft',
description: 'Go to previous stage', description: 'Go to previous stage',
scope: 'checklist',
enabled: (ctx) => !ctx.state.isDone, enabled: (ctx) => !ctx.state.isDone,
action: (ctx) => ctx.actions.tryGoBack(), action: (ctx) => ctx.actions.tryGoBack(),
}, },
'generate-message': { 'generate-message': {
keybind: 'Ctrl+Shift+E', keybind: 'Ctrl+Shift+E',
description: 'Generate moderation message', description: 'Generate moderation message',
scope: 'checklist',
action: (ctx) => ctx.actions.tryGenerateMessage(), action: (ctx) => ctx.actions.tryGenerateMessage(),
}, },
'toggle-collapse': { 'toggle-collapse': {
keybind: 'Shift+C', keybind: 'Shift+C',
description: 'Toggle collapse/expand', description: 'Toggle collapse/expand',
scope: 'checklist',
action: (ctx) => ctx.actions.tryToggleCollapse(), action: (ctx) => ctx.actions.tryToggleCollapse(),
}, },
'reset-progress': { 'reset-progress': {
keybind: 'Ctrl+Shift+R', keybind: 'Ctrl+Shift+R',
description: 'Reset moderation progress', description: 'Reset moderation progress',
scope: 'checklist',
action: (ctx) => ctx.actions.tryResetProgress(), action: (ctx) => ctx.actions.tryResetProgress(),
}, },
'skip-project': { 'skip-project': {
keybind: 'Ctrl+Shift+S', keybind: 'Ctrl+Shift+S',
description: 'Skip to next project', description: 'Skip to next project',
scope: 'checklist',
enabled: (ctx) => ctx.state.futureProjectCount > 0 && !ctx.state.isDone, enabled: (ctx) => ctx.state.futureProjectCount > 0 && !ctx.state.isDone,
action: (ctx) => ctx.actions.trySkipProject(), action: (ctx) => ctx.actions.trySkipProject(),
}, },
'copy-permalink': { 'copy-permalink': {
keybind: 'Ctrl+Alt+C', keybind: 'Ctrl+Alt+C',
description: 'Copy permalink', 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': { 'copy-relative-permalink': {
keybind: 'Ctrl+Alt+R', keybind: 'Ctrl+Alt+R',
description: 'Copy relative permalink', 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': { 'copy-page-permalink': {
keybind: 'Shift+Ctrl+Alt+C', keybind: 'Shift+Ctrl+Alt+C',
description: 'Copy permalink with page', 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': { 'copy-page-relative-permalink': {
keybind: 'Shift+Ctrl+Alt+R', keybind: 'Shift+Ctrl+Alt+R',
description: 'Copy relative permalink with page', 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': { 'copy-id': {
keybind: 'Ctrl+Alt+D', keybind: 'Ctrl+Alt+D',
description: 'Copy Project ID', description: 'Copy Project ID',
action: (ctx) => ctx.actions.tryCopyId(), scope: 'project',
action: async (ctx) => await navigator.clipboard.writeText(ctx.project.id),
}, },
'approve-project': { 'approve-project': {
keybind: 'Shift+Alt+A', keybind: 'Shift+Alt+A',
description: 'Approve project', description: 'Approve project',
scope: 'checklist',
action: (ctx) => ctx.actions.tryApprove(), action: (ctx) => ctx.actions.tryApprove(),
}, },
'withhold-project': { 'withhold-project': {
keybind: 'Shift+Alt+W', keybind: 'Shift+Alt+W',
description: 'Withhold project', description: 'Withhold project',
scope: 'checklist',
action: (ctx) => ctx.actions.tryWithhold(), action: (ctx) => ctx.actions.tryWithhold(),
}, },
'reject-project': { 'reject-project': {
keybind: 'Shift+Alt+R', keybind: 'Shift+Alt+R',
description: 'Reject project', description: 'Reject project',
scope: 'checklist',
action: (ctx) => ctx.actions.tryReject(), action: (ctx) => ctx.actions.tryReject(),
}, },
} }
+33
View File
@@ -0,0 +1,33 @@
import type { EnumSettingDefinition, ToggleSettingDefinition } from '../types/settings.ts'
const settings = {
General: {
ChecklistPosition: {
type: 'enum',
id: 'checklist-position',
title: 'Checklist Position',
description: 'Where the checklist should be displayed on the page',
entries: [
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' },
],
default: 'right',
} as EnumSettingDefinition,
ProjectKeybinds: {
type: 'toggle',
id: 'project-keybinds',
title: 'Enable Project Keybinds',
description: 'Weather certain keybinds should work without the checklist visible.',
default: false,
} as ToggleSettingDefinition,
PrivateMessageHighlight: {
type: 'toggle',
id: 'private-message-highlight',
title: 'Highlight Private Messages',
description: 'Whether private messages should be highlighted in the chat.',
default: true,
} as ToggleSettingDefinition,
},
} as const
export default settings
@@ -0,0 +1,82 @@
import {
type KeybindDefinition,
type KeybindListener,
matchesKeybind,
type ModerationContext,
normalizeKeybind,
} from '../types/keybinds.ts'
import keybinds from '../data/keybinds.ts'
function normalizeKeybinds(
keybind: KeybindDefinition | KeybindDefinition[] | string | string[],
): KeybindDefinition[] {
return Array.isArray(keybind) ? keybind.map(normalizeKeybind) : [normalizeKeybind(keybind)]
}
export type KeybindListenerWithDefault = KeybindListener & {
keybind: KeybindDefinition[]
defaultKeybind: KeybindDefinition[]
}
export class Keybinds {
private readonly configured: { [id: string]: KeybindDefinition[] } = {}
constructor(keybinds: { [id: string]: KeybindDefinition[] }) {
this.configured = keybinds
}
*[Symbol.iterator](): IterableIterator<[string, KeybindListenerWithDefault]> {
for (const [id, keybind] of Object.entries(keybinds)) {
yield [
id,
{
...keybind,
keybind: this.configured[id] ?? normalizeKeybinds(keybind.keybind),
defaultKeybind: normalizeKeybinds(keybind.keybind),
},
]
}
}
set(id: string, keybind: KeybindDefinition | KeybindDefinition[] | string | string[]): void {
this.configured[id] = normalizeKeybinds(keybind)
}
handle(event: KeyboardEvent, ctx: ModerationContext): boolean {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
(event.target as HTMLElement)?.closest('.cm-editor') ||
(event.target as HTMLElement)?.classList?.contains('cm-content') ||
(event.target as HTMLElement)?.classList?.contains('cm-line')
) {
return false
}
for (const [id, keybind] of Object.entries(keybinds)) {
if (ctx.scope !== keybind.scope) {
continue
}
if (keybind.enabled && !keybind.enabled(ctx as any)) {
continue
}
const definitions = this.configured[id] ?? normalizeKeybinds(keybind.keybind)
const matches = definitions.some((def) => matchesKeybind(event, def))
if (matches) {
keybind.action(ctx as any)
const shouldPrevent = definitions.some((def) => def.preventDefault !== false)
if (shouldPrevent) {
event.preventDefault()
}
return true
}
}
return false
}
}
@@ -0,0 +1,25 @@
import type { SettingDefinitionBase } from '../types/settings.ts'
export class Settings {
private readonly settings: { [id: string]: any }
private readonly onChange: () => void
constructor(
settings: { [id: string]: any } | undefined = undefined,
onChange: () => void = () => {},
) {
this.settings = settings || {}
this.onChange = onChange
}
get<T>(definition: SettingDefinitionBase<T>): T {
return this.settings[definition.id] ?? definition.default
}
set<T>(definition: SettingDefinitionBase<T>, value?: T): void {
const previous = this.settings[definition.id] ?? definition.default
this.settings[definition.id] = value
definition.onChange?.(previous, value ?? definition.default)
this.onChange()
}
}
+4
View File
@@ -1,5 +1,6 @@
export { default as checklist, stages, useStages } from './data/checklist' export { default as checklist, stages, useStages } from './data/checklist'
export { default as keybinds } from './data/keybinds' export { default as keybinds } from './data/keybinds'
export { default as moderationSettings } from './data/settings'
export { default as nags } from './data/nags' export { default as nags } from './data/nags'
export * from './data/nags/index' export * from './data/nags/index'
export { default as attributionQuickReplies } from './data/quick-replies/permissions-quick-replies' export { default as attributionQuickReplies } from './data/quick-replies/permissions-quick-replies'
@@ -11,6 +12,7 @@ export {
export * from './locales' export * from './locales'
export * from './types/actions' export * from './types/actions'
export * from './types/keybinds' export * from './types/keybinds'
export * from './types/settings'
export * from './types/messages' export * from './types/messages'
export * from './types/nags' export * from './types/nags'
export * from './types/node' export * from './types/node'
@@ -19,3 +21,5 @@ export * from './types/quick-reply'
export * from './types/reports' export * from './types/reports'
export * from './types/stage' export * from './types/stage'
export * from './utils' export * from './utils'
export * from './handles/keybinds'
export * from './handles/settings'
+21 -60
View File
@@ -14,17 +14,6 @@ export interface ModerationActions {
tryReject: () => void tryReject: () => void
tryWithhold: () => void tryWithhold: () => void
tryEditMessage: () => 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 { export interface ModerationState {
@@ -41,17 +30,22 @@ export interface ModerationState {
futureProjectCount: number futureProjectCount: number
visibleActionsCount: number visibleActionsCount: number
focusedActionIndex: number | null
focusedActionType: 'button' | 'toggle' | 'dropdown' | 'multi-select' | null
} }
export interface ModerationContext { export type ModerationProjectContext = {
project: Labrinth.Projects.v2.Project project: Labrinth.Projects.v2.Project
scope: 'project'
}
export type ModerationChecklistContext = {
project: Labrinth.Projects.v2.Project
scope: 'checklist'
state: ModerationState state: ModerationState
actions: ModerationActions actions: ModerationActions
} }
export type ModerationContext = ModerationProjectContext | ModerationChecklistContext
export interface KeybindDefinition { export interface KeybindDefinition {
key: string key: string
ctrl?: boolean ctrl?: boolean
@@ -61,13 +55,22 @@ export interface KeybindDefinition {
preventDefault?: boolean preventDefault?: boolean
} }
export interface KeybindListener { export type BaseKeybindListener<T> = {
keybind: KeybindDefinition | KeybindDefinition[] | string | string[] keybind: KeybindDefinition | KeybindDefinition[] | string | string[]
description: string description: string
enabled?: (ctx: ModerationContext) => boolean scope: 'project' | 'checklist'
action: (ctx: ModerationContext) => void 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 { export function parseKeybind(keybindString: string): KeybindDefinition {
const parts = keybindString.split('+').map((p) => p.trim().toLowerCase()) const parts = keybindString.split('+').map((p) => p.trim().toLowerCase())
@@ -106,45 +109,3 @@ export function toKeybindDefinition(event: KeyboardEvent): KeybindDefinition {
preventDefault: true, 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
}
+29
View File
@@ -0,0 +1,29 @@
export interface SettingDefinitionBase<T> {
type: string
id: string
title: string
description: string
default: T
onChange?: (previous: T | undefined, current: T) => void
}
export interface ToggleSettingDefinition extends SettingDefinitionBase<boolean> {
type: 'toggle'
}
export interface EnumSettingDefinition extends SettingDefinitionBase<string> {
type: 'enum'
entries: {
label: string
value: string
}[]
}
export interface StringSettingDefinition extends SettingDefinitionBase<string> {
type: 'string'
}
export type SettingDefinition =
| ToggleSettingDefinition
| EnumSettingDefinition
| StringSettingDefinition