Compare commits

...
9 changed files with 264 additions and 150 deletions
@@ -37,21 +37,22 @@
> >
{{ formatRelativeTime(report.created) }} {{ formatRelativeTime(report.created) }}
</span> </span>
<ButtonStyled circular> <div class="flex items-center gap-2">
<OverflowMenu :options="quickActions"> <ButtonStyled circular>
<template #default> <button v-tooltip="'Copy ID'" @click="copyId">
<EllipsisVerticalIcon class="size-4" />
</template>
<template #copy-id>
<ClipboardCopyIcon /> <ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span> </button>
</template> </ButtonStyled>
<template #copy-link> <ButtonStyled circular>
<LinkIcon /> <a
<span class="hidden sm:inline">Copy link</span> v-tooltip="'Open in new tab'"
</template> :href="`/moderation/reports/${props.report.id}`"
</OverflowMenu> target="_blank"
</ButtonStyled> >
<ExternalIcon />
</a>
</ButtonStyled>
</div>
</div> </div>
</div> </div>
@@ -183,12 +184,7 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import { CheckCircleIcon, ClipboardCopyIcon, ExternalIcon } from '@modrinth/assets'
CheckCircleIcon,
ClipboardCopyIcon,
EllipsisVerticalIcon,
LinkIcon,
} from '@modrinth/assets'
import { type ExtendedReport, reportQuickReplies } from '@modrinth/moderation' import { type ExtendedReport, reportQuickReplies } from '@modrinth/moderation'
import { import {
Avatar, Avatar,
@@ -196,8 +192,6 @@ import {
CollapsibleRegion, CollapsibleRegion,
getProjectTypeIcon, getProjectTypeIcon,
injectNotificationManager, injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useFormatDateTime, useFormatDateTime,
useRelativeTime, useRelativeTime,
} from '@modrinth/ui' } from '@modrinth/ui'
@@ -328,35 +322,6 @@ function updateThread(newThread: any) {
} }
} }
const quickActions: OverflowMenuOption[] = [
{
id: 'copy-link',
action: () => {
const base = window.location.origin
const reportUrl = `${base}/moderation/reports/${props.report.id}`
navigator.clipboard.writeText(reportUrl).then(() => {
addNotification({
type: 'success',
title: 'Report link copied',
text: 'The link to this report has been copied to your clipboard.',
})
})
},
},
{
id: 'copy-id',
action: () => {
navigator.clipboard.writeText(props.report.id).then(() => {
addNotification({
type: 'success',
title: 'Report ID copied',
text: 'The ID of this report has been copied to your clipboard.',
})
})
},
},
]
const reportItemAvatarUrl = computed(() => { const reportItemAvatarUrl = computed(() => {
switch (props.report.item_type) { switch (props.report.item_type) {
case 'project': case 'project':
@@ -395,4 +360,14 @@ const formattedReportType = computed(() => {
const words = reportType.includes('-') ? reportType.split('-') : reportType.split(' ') const words = reportType.includes('-') ? reportType.split('-') : reportType.split(' ')
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}) })
function copyId() {
navigator.clipboard.writeText(props.report.id).then(() => {
addNotification({
type: 'success',
title: 'Report ID copied',
text: 'The ID of this report has been copied to your clipboard.',
})
})
}
</script> </script>
@@ -123,30 +123,36 @@ const projectStatusActions = computed<OverflowMenuOption[]>(() => [
color: 'green', color: 'green',
action: () => setStatus('approved'), action: () => setStatus('approved'),
hoverFilled: true, hoverFilled: true,
disabled: isProjectApproved.value || isLoadingStatusAction.value, disabled: isStatusActionDisabled('approved'),
}, },
{ {
id: 'withhold', id: 'withhold',
color: 'orange', color: 'orange',
action: () => setStatus('withheld'), action: () => setStatus('withheld'),
hoverFilled: true, hoverFilled: true,
disabled: projectStatus.value === 'withheld' || isLoadingStatusAction.value, disabled: isStatusActionDisabled('withheld'),
}, },
{ {
id: 'send-to-review', id: 'send-to-review',
action: () => setStatus('processing'), action: () => setStatus('processing'),
hoverFilled: true, hoverFilled: true,
disabled: projectStatus.value === 'processing' || isLoadingStatusAction.value, disabled: isStatusActionDisabled('processing'),
}, },
{ {
id: 'reject', id: 'reject',
color: 'red', color: 'red',
action: () => setStatus('rejected'), action: () => setStatus('rejected'),
hoverFilled: true, hoverFilled: true,
disabled: projectStatus.value === 'rejected' || isLoadingStatusAction.value, disabled: isStatusActionDisabled('rejected'),
}, },
]) ])
function isStatusActionDisabled(status: Labrinth.Projects.v2.ProjectStatus): boolean {
const currentStatus = projectStatus.value
const isLoading = isLoadingStatusAction.value
return currentStatus === status || isLoading
}
async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) { async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) {
isLoadingStatusAction.value = true isLoadingStatusAction.value = true
try { try {
@@ -0,0 +1,116 @@
<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(structuredClone(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,26 +1,32 @@
<template> <template>
<NewModal ref="modal" header="Moderation shortcuts" :closable="true"> <NewModal ref="modal" header="Moderation shortcuts" :closable="true">
<div> <div id="moderation-checklist-keybinds-modal">
<div class="keybinds-sections"> <div class="keybinds-sections">
<div class="grid grid-cols-2 gap-x-12 gap-y-3"> <div class="grid grid-cols-2 gap-x-12 gap-y-3">
<div <div
v-for="keybind in keybinds" v-for="[id, keybind] in Object.entries(keybinds)"
:key="keybind.id" :key="id"
class="keybind-item flex items-center justify-between gap-4" class="keybind-item flex flex-wrap items-center justify-between gap-4"
:class="{ :class="{
'col-span-2': keybinds.length % 2 === 1 && keybinds[keybinds.length - 1] === keybind, '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> <span class="text-sm text-secondary">{{ keybind.description }}</span>
<div class="flex items-center gap-1"> <ChecklistKeybind
<kbd :definitions="
v-for="(key, index) in parseKeybindDisplay(keybind.keybind)" (!Array.isArray(keybind.keybind) ? [keybind.keybind] : keybind.keybind).map(
:key="`${keybind.id}-key-${index}`" normalizeKeybind,
class="keybind-key" )
> "
{{ key }} :on-change="
</kbd> (definitions) => {
</div> keybinds[id].keybind = definitions
saveModerationKeybinds()
}
"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -29,43 +35,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { type KeybindListener, keybinds, normalizeKeybind } from '@modrinth/moderation' import { normalizeKeybind } from '@modrinth/moderation'
import NewModal from '@modrinth/ui/src/components/modal/NewModal.vue' import NewModal from '@modrinth/ui/src/components/modal/NewModal.vue'
import { ref } from '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 modal = ref<InstanceType<typeof NewModal>>()
const keybinds = useModerationKeybinds()
function parseKeybindDisplay(keybind: KeybindListener['keybind']): string[] {
const keybinds = Array.isArray(keybind) ? keybind : [keybind]
const normalized = keybinds[0]
const def = normalizeKeybind(normalized)
const keys = []
if (def.ctrl || def.meta) {
keys.push(isMac() ? 'CMD' : 'CTRL')
}
if (def.shift) keys.push('SHIFT')
if (def.alt) keys.push('ALT')
const mainKey = def.key
.replace('ArrowLeft', '←')
.replace('ArrowRight', '→')
.replace('ArrowUp', '↑')
.replace('ArrowDown', '↓')
.replace('Enter', '↵')
.replace('Space', 'SPACE')
.replace('Escape', 'ESC')
.toUpperCase()
keys.push(mainKey)
return keys
}
function isMac() {
return navigator.platform.toUpperCase().includes('MAC')
}
function show(event?: MouseEvent) { function show(event?: MouseEvent) {
modal.value?.show(event) modal.value?.show(event)
@@ -82,29 +60,6 @@ defineExpose({
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.keybind-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
padding: 0.25rem 0.5rem;
background-color: var(--color-bg);
border: 1px solid var(--color-divider);
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-contrast);
+ .keybind-key {
margin-left: 0.25rem;
}
}
.keybind-item {
min-height: 2rem;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.keybinds-sections { .keybinds-sections {
.grid { .grid {
@@ -478,7 +478,6 @@ import {
handleKeybind, handleKeybind,
initializeActionState, initializeActionState,
kebabToTitleCase, kebabToTitleCase,
keybinds,
type MultiSelectChipsAction, type MultiSelectChipsAction,
processMessage, processMessage,
type Stage, type Stage,
@@ -533,6 +532,7 @@ import ModpackPermissionsFlow from './ModpackPermissionsFlow.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 keybindsModal = ref<InstanceType<typeof KeybindsModal>>() const keybindsModal = ref<InstanceType<typeof KeybindsModal>>()
const takeOverModal = ref<InstanceType<typeof ConfirmModal>>() const takeOverModal = ref<InstanceType<typeof ConfirmModal>>()
@@ -1266,7 +1266,7 @@ function handleKeybinds(event: KeyboardEvent) {
}, },
}, },
}, },
keybinds, Object.values(keybinds.value),
) )
} }
@@ -0,0 +1,61 @@
import {
type KeybindDefinition,
type KeybindListener,
keybinds,
normalizeKeybind,
} from '@modrinth/moderation'
import type { CookieOptions } from '#app'
const moderationKeybindsId = 'moderation-keybinds'
type StoredKeybinds = { [id: string]: KeybindDefinition[] }
type PartialStoredKeybinds = Partial<StoredKeybinds>
const getCookieOptions = () =>
({
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: useRuntimeConfig().public.cookieSecure,
httpOnly: false,
path: '/',
}) satisfies CookieOptions<PartialStoredKeybinds>
export const useModerationKeybinds = () =>
useState<{ [id: string]: KeybindListener }>(moderationKeybindsId, () => {
const storedKeybinds = useCookie<PartialStoredKeybinds>(
moderationKeybindsId,
getCookieOptions(),
)
if (!storedKeybinds.value) {
storedKeybinds.value = {}
}
const output: { [id: string]: KeybindListener } = {}
for (const [id, keybind] of Object.entries(keybinds)) {
const definitions = storedKeybinds.value[id]
output[id] = {
keybind: definitions !== undefined ? definitions : keybind.keybind,
description: keybind.description,
enabled: keybind.enabled,
action: keybind.action,
}
}
return output
})
export const saveModerationKeybinds = () => {
const keybinds = useModerationKeybinds()
const cookie = useCookie<PartialStoredKeybinds>(moderationKeybindsId, getCookieOptions())
const storedKeybinds: PartialStoredKeybinds = {}
for (const [id, keybind] of Object.entries(keybinds.value)) {
storedKeybinds[id] = (Array.isArray(keybind.keybind) ? keybind.keybind : [keybind.keybind]).map(
normalizeKeybind,
)
}
cookie.value = storedKeybinds
}
@@ -246,7 +246,9 @@ const { data: allReports } = await useLazyAsyncData('new-moderation-reports', as
const enrichmentPromise = enrichReportBatch(reports) const enrichmentPromise = enrichReportBatch(reports)
enrichmentPromises.push(enrichmentPromise) enrichmentPromises.push(enrichmentPromise)
currentOffset += reports.length // this is explicitly not the length of the reports array, because the API may return fewer reports due to a report in the middle not being
// serializable if the offset is set to the reports array you can get the same report from the end multiple times.
currentOffset += REPORT_ENDPOINT_COUNT
if (enrichmentPromises.length >= 3) { if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2)) const completed = await Promise.all(enrichmentPromises.splice(0, 2))
+8 -20
View File
@@ -1,51 +1,39 @@
import type { KeybindListener } from '../types/keybinds' import type { KeybindListener } from '../types/keybinds'
const keybinds: KeybindListener[] = [ const keybinds: { [id: string]: KeybindListener } = {
{ 'next-stage': {
id: 'next-stage',
keybind: 'ArrowRight', keybind: 'ArrowRight',
description: 'Go to next stage', description: 'Go to next stage',
enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage, enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage,
action: (ctx) => ctx.actions.tryGoNext(), action: (ctx) => ctx.actions.tryGoNext(),
}, },
{ 'previous-stage': {
id: 'previous-stage',
keybind: 'ArrowLeft', keybind: 'ArrowLeft',
description: 'Go to previous stage', description: 'Go to previous stage',
enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage, enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage,
action: (ctx) => ctx.actions.tryGoBack(), action: (ctx) => ctx.actions.tryGoBack(),
}, },
{ 'generate-message': {
id: 'generate-message',
keybind: 'Ctrl+Shift+E', keybind: 'Ctrl+Shift+E',
description: 'Generate moderation message', description: 'Generate moderation message',
action: (ctx) => ctx.actions.tryGenerateMessage(), action: (ctx) => ctx.actions.tryGenerateMessage(),
}, },
{ 'toggle-collapse': {
id: 'toggle-collapse',
keybind: 'Shift+C', keybind: 'Shift+C',
description: 'Toggle collapse/expand', description: 'Toggle collapse/expand',
action: (ctx) => ctx.actions.tryToggleCollapse(), action: (ctx) => ctx.actions.tryToggleCollapse(),
}, },
{ 'reset-progress': {
id: 'reset-progress',
keybind: 'Ctrl+Shift+R', keybind: 'Ctrl+Shift+R',
description: 'Reset moderation progress', description: 'Reset moderation progress',
action: (ctx) => ctx.actions.tryResetProgress(), action: (ctx) => ctx.actions.tryResetProgress(),
}, },
{ 'skip-project': {
id: 'reset-progress-alt',
keybind: 'Alt+R',
description: 'Reset moderation progress',
action: (ctx) => ctx.actions.tryResetProgress(),
},
{
id: 'skip-project',
keybind: 'Ctrl+Shift+S', keybind: 'Ctrl+Shift+S',
description: 'Skip to next project', description: 'Skip to next project',
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(),
}, },
] }
export default keybinds export default keybinds
+13 -2
View File
@@ -59,7 +59,6 @@ export interface KeybindDefinition {
} }
export interface KeybindListener { export interface KeybindListener {
id: string
keybind: KeybindDefinition | KeybindDefinition[] | string | string[] keybind: KeybindDefinition | KeybindDefinition[] | string | string[]
description: string description: string
enabled?: (ctx: ModerationContext) => boolean enabled?: (ctx: ModerationContext) => boolean
@@ -94,6 +93,17 @@ export function matchesKeybind(event: KeyboardEvent, keybind: KeybindDefinition
) )
} }
export function toKeybindDefinition(event: KeyboardEvent): KeybindDefinition {
return {
key: event.key.toLowerCase(),
ctrl: event.ctrlKey,
shift: event.shiftKey,
alt: event.altKey,
meta: event.metaKey,
preventDefault: true,
}
}
export function handleKeybind( export function handleKeybind(
event: KeyboardEvent, event: KeyboardEvent,
ctx: ModerationContext, ctx: ModerationContext,
@@ -104,7 +114,8 @@ export function handleKeybind(
event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLTextAreaElement ||
(event.target as HTMLElement)?.closest('.cm-editor') || (event.target as HTMLElement)?.closest('.cm-editor') ||
(event.target as HTMLElement)?.classList?.contains('cm-content') || (event.target as HTMLElement)?.classList?.contains('cm-content') ||
(event.target as HTMLElement)?.classList?.contains('cm-line') (event.target as HTMLElement)?.classList?.contains('cm-line') ||
document.getElementById('moderation-checklist-keybinds-modal')
) { ) {
return false return false
} }