mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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'
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user