feat: new proj moderation page (#6044)

* feat: new proj moderation page

* make requested changes

* add boolean for showing delay message

* fix server icon + shortened code

* fix server icon

* refactor admonitions

* msg correction.

* correction + change spam-notice

* Separate status info from instruction details

* Tweak timing delay msg, thread activity warning, and refer to moderation with consistent terms.

* Whoops, actually updated msgs correctly now.

* prepr + margin

* split out strings, simplify code again

* fix: a few more moderation fixes (#6048)

* fix: move tooltip to button

* fix: lock status buttons after pressing

* fix: unlisted/withheld icon on legacy badge

* prepprrr

* fix banners, add some extra dev mode stuff

* fix thread id copy padding

* tweak: adjust some of the status change messages (#6041)

* update messages & bunch of other stuff

* rename toggle

* change hover to 2.5, fix error size

* private msg overlay

---------

Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
This commit is contained in:
Prospector
2026-05-12 22:23:18 -07:00
committed by GitHub
parent d87f93fdd5
commit 0ffdabb2a3
39 changed files with 1963 additions and 766 deletions

View File

@@ -24,7 +24,7 @@
{{ relativeTimeLabel }}
</span>
</div>
<div class="font-normal text-contrast/85">
<div class="font-normal text-contrast/85 leading-tight">
<slot>{{ body }}</slot>
</div>
<div v-if="showActionsUnderneath || $slots.actions" class="mt-2">
@@ -80,7 +80,7 @@ import ButtonStyled from './ButtonStyled.vue'
const props = withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'critical' | 'success'
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation'
header?: string
body?: string
showActionsUnderneath?: boolean
@@ -141,6 +141,7 @@ const typeClasses = {
warning: 'border-brand-orange bg-bg-orange',
critical: 'border-brand-red bg-bg-red',
success: 'border-brand-green bg-bg-green',
moderation: 'border-brand-orange bg-bg-orange',
}
const iconClasses = {
@@ -148,6 +149,7 @@ const iconClasses = {
warning: 'text-brand-orange',
critical: 'text-brand-red',
success: 'text-brand-green',
moderation: 'text-brand-orange',
}
const buttonColors = {
@@ -155,6 +157,7 @@ const buttonColors = {
warning: 'orange',
critical: 'red',
success: 'green',
moderation: 'orange',
} as const
const progressTrackClasses = {
@@ -162,6 +165,7 @@ const progressTrackClasses = {
warning: 'bg-brand-orange/20',
critical: 'bg-brand-red/20',
success: 'bg-brand-green/20',
moderation: 'bg-brand-orange/20',
}
const progressFillClasses = {

View File

@@ -21,10 +21,10 @@
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.approvedLabel) }}
</template>
<template v-else-if="type === 'unlisted'">
<EyeOffIcon aria-hidden="true" /> {{ formatMessage(messages.unlistedLabel) }}
<LinkIcon aria-hidden="true" /> {{ formatMessage(messages.unlistedLabel) }}
</template>
<template v-else-if="type === 'withheld'">
<EyeOffIcon aria-hidden="true" /> {{ formatMessage(messages.withheldLabel) }}
<LinkIcon aria-hidden="true" /> {{ formatMessage(messages.withheldLabel) }}
</template>
<template v-else-if="type === 'private'">
<LockIcon aria-hidden="true" /> {{ formatMessage(messages.privateLabel) }}
@@ -89,9 +89,9 @@ import {
BugIcon,
CalendarIcon,
CheckIcon,
EyeOffIcon,
FileTextIcon,
GlobeIcon,
LinkIcon,
LockIcon,
ModrinthIcon,
ScaleIcon,

View File

@@ -1,6 +1,6 @@
<template>
<button
class="group bg-transparent border-none p-0 m-0 flex items-center gap-3 checkbox-outer outline-offset-4 text-contrast"
class="group bg-transparent border-none p-0 m-0 flex items-center text-left gap-3 checkbox-outer outline-offset-4 text-contrast"
:disabled="disabled"
:class="
disabled
@@ -13,7 +13,7 @@
@click="toggle"
>
<span
class="w-5 h-5 rounded-md flex items-center justify-center border-[1px] border-solid"
class="w-5 h-5 rounded-md flex items-center justify-center border-[1px] border-solid shrink-0"
:class="{
'bg-brand border-button-border text-brand-inverted': modelValue,
'bg-surface-2 border-surface-5 text-primary': !modelValue,

View File

@@ -1,39 +1,41 @@
<template>
<NewModal ref="linkModal" header="Insert link">
<NewModal ref="linkModal" :header="formatMessage(messages.linkModalHeader)" class="!w-[40rem]">
<div class="modal-insert">
<label class="label" for="insert-link-label">
<span class="label__title">Label</span>
<span class="label__title">{{ formatMessage(messages.linkModalLabelFieldTitle) }}</span>
</label>
<StyledInput
id="insert-link-label"
v-model="linkText"
:icon="AlignLeftIcon"
type="text"
placeholder="Enter label..."
:placeholder="formatMessage(messages.linkModalLabelFieldPlaceholder)"
clearable
wrapper-class="w-full"
/>
<label class="label" for="insert-link-url">
<span class="label__title">URL<span class="required">*</span></span>
<span class="label__title">
{{ formatMessage(messages.urlLabel) }}<span class="required">*</span>
</span>
</label>
<StyledInput
id="insert-link-url"
v-model="linkUrl"
:icon="LinkIcon"
type="text"
placeholder="Enter the link's URL..."
:placeholder="formatMessage(messages.linkModalUrlFieldPlaceholder)"
clearable
wrapper-class="w-full"
@input="validateURL"
/>
<template v-if="linkValidationErrorMessage">
<span class="label">
<span class="label__title">Error</span>
<span class="label__title">{{ formatMessage(messages.errorLabel) }}</span>
<span class="label__description">{{ linkValidationErrorMessage }}</span>
</span>
</template>
<span class="label">
<span class="label__title">Preview</span>
<span class="label__title">{{ formatMessage(messages.previewLabel) }}</span>
<span class="label__description"></span>
</span>
<div class="markdown-body-wrapper">
@@ -45,7 +47,9 @@
</div>
<div class="flex gap-2 justify-end mt-4">
<ButtonStyled type="outlined">
<button @click="() => linkModal?.hide()"><XIcon /> Cancel</button>
<button @click="() => linkModal?.hide()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
@@ -57,18 +61,21 @@
}
"
>
<PlusIcon /> Insert
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal ref="imageModal" header="Insert image">
<NewModal ref="imageModal" :header="formatMessage(messages.imageModalHeader)" class="!w-[40rem]">
<div class="modal-insert">
<label class="label" for="insert-image-alt">
<span class="label__title">Description (alt text)<span class="required">*</span></span>
<span class="label__title">
{{ formatMessage(messages.imageModalDescriptionFieldTitle) }}
<span class="required">*</span>
</span>
<span class="label__description">
Describe the image completely as you would to someone who could not see the image.
{{ formatMessage(messages.imageModalDescriptionFieldDescription) }}
</span>
</label>
<StyledInput
@@ -76,15 +83,22 @@
v-model="linkText"
:icon="AlignLeftIcon"
type="text"
placeholder="Describe the image..."
:placeholder="formatMessage(messages.imageModalDescriptionFieldPlaceholder)"
clearable
wrapper-class="w-full"
/>
<label class="label" for="insert-link-url">
<span class="label__title">URL<span class="required">*</span></span>
<span class="label__title">
{{ formatMessage(messages.urlLabel) }}<span class="required">*</span>
</span>
</label>
<div v-if="props.onImageUpload" class="image-strategy-chips">
<Chips v-model="imageUploadOption" :items="['upload', 'link']" />
<Chips
v-model="imageUploadOption"
:items="['upload', 'link']"
:format-label="formatImageUploadOption"
:aria-label="formatMessage(messages.imageModalUploadModeLabel)"
/>
</div>
<div
v-if="props.onImageUpload && imageUploadOption === 'upload'"
@@ -92,7 +106,7 @@
>
<FileInput
accept="image/png,image/jpeg,image/gif,image/webp"
prompt="Drag and drop to upload or click to select file"
:prompt="formatMessage(messages.imageModalUploadPrompt)"
long-style
should-always-reset
class="file-input"
@@ -107,19 +121,19 @@
v-model="linkUrl"
:icon="ImageIcon"
type="text"
placeholder="Enter the image URL..."
:placeholder="formatMessage(messages.imageModalUrlFieldPlaceholder)"
clearable
wrapper-class="w-full"
@input="validateURL"
/>
<template v-if="linkValidationErrorMessage">
<span class="label">
<span class="label__title">Error</span>
<span class="label__title">{{ formatMessage(messages.errorLabel) }}</span>
<span class="label__description">{{ linkValidationErrorMessage }}</span>
</span>
</template>
<span class="label">
<span class="label__title">Preview</span>
<span class="label__title">{{ formatMessage(messages.previewLabel) }}</span>
<span class="label__description"></span>
</span>
<div class="markdown-body-wrapper">
@@ -131,7 +145,9 @@
</div>
<div class="flex gap-2 justify-end mt-4">
<ButtonStyled type="outlined">
<button @click="() => imageModal?.hide()"><XIcon /> Cancel</button>
<button @click="() => imageModal?.hide()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
@@ -143,36 +159,40 @@
}
"
>
<PlusIcon /> Insert
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal ref="videoModal" header="Insert YouTube video">
<NewModal ref="videoModal" :header="formatMessage(messages.videoModalHeader)" class="!w-[40rem]">
<div class="modal-insert">
<label class="label" for="insert-video-url">
<span class="label__title">YouTube video URL<span class="required">*</span></span>
<span class="label__description"> Enter a valid link to a YouTube video. </span>
<span class="label__title">
{{ formatMessage(messages.videoModalUrlFieldTitle) }}<span class="required">*</span>
</span>
<span class="label__description">
{{ formatMessage(messages.videoModalUrlFieldDescription) }}
</span>
</label>
<StyledInput
id="insert-video-url"
v-model="linkUrl"
:icon="YouTubeIcon"
type="text"
placeholder="Enter YouTube video URL"
:placeholder="formatMessage(messages.videoModalUrlFieldPlaceholder)"
clearable
wrapper-class="w-full"
@input="validateURL"
/>
<template v-if="linkValidationErrorMessage">
<span class="label">
<span class="label__title">Error</span>
<span class="label__title">{{ formatMessage(messages.errorLabel) }}</span>
<span class="label__description">{{ linkValidationErrorMessage }}</span>
</span>
</template>
<span class="label">
<span class="label__title">Preview</span>
<span class="label__title">{{ formatMessage(messages.previewLabel) }}</span>
<span class="label__description"></span>
</span>
@@ -185,7 +205,9 @@
</div>
<div class="flex gap-2 justify-end mt-4">
<ButtonStyled type="outlined">
<button @click="() => videoModal?.hide()"><XIcon /> Cancel</button>
<button @click="() => videoModal?.hide()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
@@ -197,37 +219,41 @@
}
"
>
<PlusIcon /> Insert
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="block grow w-full">
<div class="editor-action-row">
<div class="editor-actions">
<template
v-for="(buttonGroup, _i) in Object.values(BUTTONS).filter((bg) => bg.display)"
:key="_i"
>
<div class="divider"></div>
<template v-for="button in buttonGroup.buttons" :key="button.label">
<ButtonStyled circular>
<button
v-tooltip="button.label"
:aria-label="button.label"
:class="{ 'mobile-hidden-group': !!buttonGroup.hideOnMobile }"
:disabled="previewMode || disabled"
@click="() => button.action(editor)"
>
<component :is="button.icon" />
</button>
</ButtonStyled>
<div class="editor-action-row w-full">
<div class="w-full flex justify-between items-center flex-wrap gap-2">
<div class="editor-actions">
<template
v-for="(buttonGroup, _i) in Object.values(BUTTONS).filter((bg) => bg.display)"
:key="_i"
>
<div class="divider"></div>
<template v-for="button in buttonGroup.buttons" :key="button.label.id">
<ButtonStyled circular>
<button
v-tooltip="formatMessage(button.label)"
:aria-label="formatMessage(button.label)"
:class="{ 'mobile-hidden-group': !!buttonGroup.hideOnMobile }"
:disabled="previewMode || disabled"
@click="() => button.action(editor)"
>
<component :is="button.icon" />
</button>
</ButtonStyled>
</template>
</template>
</template>
<div class="preview">
<Toggle id="preview" v-model="previewMode" />
<label class="label" for="preview"> Preview </label>
</div>
<div class="flex items-center gap-2">
<Toggle id="preview" v-model="previewMode" small />
<label class="label" for="preview">
{{ formatMessage(messages.editorPreviewToggleLabel) }}
</label>
</div>
</div>
</div>
@@ -235,20 +261,29 @@
<div v-if="!previewMode" class="info-blurb mt-2">
<div class="info-blurb">
<InfoIcon />
<span
>This editor supports
<a
class="markdown-resource-link"
href="https://support.modrinth.com/en/articles/8801962-advanced-markdown-formatting"
target="_blank"
>Markdown formatting</a
>.</span
>
<IntlFormatted :message-id="messages.editorMarkdownFormattingSupport">
<template #markdown-link="{ children }">
<a
class="markdown-resource-link"
href="https://support.modrinth.com/en/articles/8801962-advanced-markdown-formatting"
target="_blank"
>
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</div>
<div :class="{ hide: !props.maxLength }" class="max-length-label">
<span>Max length: </span>
<span>{{ formatMessage(messages.editorMaxLengthLabel) }} </span>
<span>
{{ props.maxLength ? `${currentValue?.length || 0}/${props.maxLength}` : 'Unlimited' }}
{{
props.maxLength
? formatMessage(messages.editorMaxLengthValue, {
currentLength: currentValue?.length || 0,
maxLength: props.maxLength,
})
: formatMessage(messages.editorMaxLengthUnlimited)
}}
</span>
</div>
</div>
@@ -298,13 +333,214 @@ import { markdownCommands, modrinthMarkdownEditorKeymap } from '@modrinth/utils/
import { renderHighlightedString } from '@modrinth/utils/highlightjs'
import { type Component, computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages.ts'
import NewModal from '../modal/NewModal.vue'
import ButtonStyled from './ButtonStyled.vue'
import Chips from './Chips.vue'
import FileInput from './FileInput.vue'
import IntlFormatted from './IntlFormatted.vue'
import StyledInput from './StyledInput.vue'
import Toggle from './Toggle.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
insertButton: {
id: 'markdown-editor.insert-button',
defaultMessage: 'Insert',
},
urlLabel: {
id: 'markdown-editor.url-label',
defaultMessage: 'URL',
},
errorLabel: {
id: 'markdown-editor.error-label',
defaultMessage: 'Error',
},
previewLabel: {
id: 'markdown-editor.preview-label',
defaultMessage: 'Preview',
},
linkModalHeader: {
id: 'markdown-editor.link-modal.header',
defaultMessage: 'Insert link',
},
linkModalLabelFieldTitle: {
id: 'markdown-editor.link-modal.label-field.title',
defaultMessage: 'Label',
},
linkModalLabelFieldPlaceholder: {
id: 'markdown-editor.link-modal.label-field.placeholder',
defaultMessage: 'Enter label...',
},
linkModalUrlFieldPlaceholder: {
id: 'markdown-editor.link-modal.url-field.placeholder',
defaultMessage: "Enter the link's URL...",
},
imageModalHeader: {
id: 'markdown-editor.image-modal.header',
defaultMessage: 'Insert image',
},
imageModalDescriptionFieldTitle: {
id: 'markdown-editor.image-modal.description-field.title',
defaultMessage: 'Description (alt text)',
},
imageModalDescriptionFieldDescription: {
id: 'markdown-editor.image-modal.description-field.description',
defaultMessage:
'Describe the image completely as you would to someone who could not see the image.',
},
imageModalDescriptionFieldPlaceholder: {
id: 'markdown-editor.image-modal.description-field.placeholder',
defaultMessage: 'Describe the image...',
},
imageModalUploadModeLabel: {
id: 'markdown-editor.image-modal.upload-mode.label',
defaultMessage: 'Image source',
},
imageModalUploadModeUpload: {
id: 'markdown-editor.image-modal.upload-mode.upload',
defaultMessage: 'Upload',
},
imageModalUploadModeLink: {
id: 'markdown-editor.image-modal.upload-mode.link',
defaultMessage: 'Link',
},
imageModalUploadPrompt: {
id: 'markdown-editor.image-modal.upload.prompt',
defaultMessage: 'Drag and drop to upload or click to select file',
},
imageModalUrlFieldPlaceholder: {
id: 'markdown-editor.image-modal.url-field.placeholder',
defaultMessage: 'Enter the image URL...',
},
videoModalHeader: {
id: 'markdown-editor.video-modal.header',
defaultMessage: 'Insert YouTube video',
},
videoModalUrlFieldTitle: {
id: 'markdown-editor.video-modal.url-field.title',
defaultMessage: 'YouTube video URL',
},
videoModalUrlFieldDescription: {
id: 'markdown-editor.video-modal.url-field.description',
defaultMessage: 'Enter a valid link to a YouTube video.',
},
videoModalUrlFieldPlaceholder: {
id: 'markdown-editor.video-modal.url-field.placeholder',
defaultMessage: 'Enter YouTube video URL',
},
editorPreviewToggleLabel: {
id: 'markdown-editor.preview-toggle.label',
defaultMessage: 'Preview',
},
editorMarkdownFormattingSupport: {
id: 'markdown-editor.markdown-formatting-support',
defaultMessage: 'This editor supports <markdown-link>Markdown formatting</markdown-link>.',
},
editorMaxLengthLabel: {
id: 'markdown-editor.max-length.label',
defaultMessage: 'Max length:',
},
editorMaxLengthValue: {
id: 'markdown-editor.max-length.value',
defaultMessage: '{currentLength}/{maxLength}',
},
editorMaxLengthUnlimited: {
id: 'markdown-editor.max-length.unlimited',
defaultMessage: 'Unlimited',
},
editorPlaceholder: {
id: 'markdown-editor.placeholder',
defaultMessage: 'Write something...',
},
toolbarHeading1: {
id: 'markdown-editor.toolbar.heading-1',
defaultMessage: 'Heading 1',
},
toolbarHeading2: {
id: 'markdown-editor.toolbar.heading-2',
defaultMessage: 'Heading 2',
},
toolbarHeading3: {
id: 'markdown-editor.toolbar.heading-3',
defaultMessage: 'Heading 3',
},
toolbarBold: {
id: 'markdown-editor.toolbar.bold',
defaultMessage: 'Bold',
},
toolbarItalic: {
id: 'markdown-editor.toolbar.italic',
defaultMessage: 'Italic',
},
toolbarStrikethrough: {
id: 'markdown-editor.toolbar.strikethrough',
defaultMessage: 'Strikethrough',
},
toolbarCode: {
id: 'markdown-editor.toolbar.code',
defaultMessage: 'Code',
},
toolbarSpoiler: {
id: 'markdown-editor.toolbar.spoiler',
defaultMessage: 'Spoiler',
},
toolbarBulletedList: {
id: 'markdown-editor.toolbar.bulleted-list',
defaultMessage: 'Bulleted list',
},
toolbarOrderedList: {
id: 'markdown-editor.toolbar.ordered-list',
defaultMessage: 'Ordered list',
},
toolbarQuote: {
id: 'markdown-editor.toolbar.quote',
defaultMessage: 'Quote',
},
toolbarLink: {
id: 'markdown-editor.toolbar.link',
defaultMessage: 'Link',
},
toolbarImage: {
id: 'markdown-editor.toolbar.image',
defaultMessage: 'Image',
},
toolbarVideo: {
id: 'markdown-editor.toolbar.video',
defaultMessage: 'Video',
},
videoEmbedTitle: {
id: 'markdown-editor.video-embed.title',
defaultMessage: 'YouTube video player',
},
urlValidationErrorMalformed: {
id: 'markdown-editor.url-validation-error.malformed',
defaultMessage: 'Invalid URL. Make sure the URL is well-formed.',
},
urlValidationErrorUnsupportedProtocol: {
id: 'markdown-editor.url-validation-error.unsupported-protocol',
defaultMessage: 'Unsupported protocol. Use http or https.',
},
urlValidationErrorBlockedDomain: {
id: 'markdown-editor.url-validation-error.blocked-domain',
defaultMessage: 'Invalid URL. This domain is not allowed.',
},
uploadErrorNoHandler: {
id: 'markdown-editor.upload-error.no-handler',
defaultMessage: 'No image upload handler provided',
},
uploadErrorNoFile: {
id: 'markdown-editor.upload-error.no-file',
defaultMessage: 'No file provided',
},
defaultImageAltText: {
id: 'markdown-editor.default-image-alt-text',
defaultMessage: 'Replace this with a description',
},
})
const props = withDefaults(
defineProps<{
modelValue: string
@@ -325,7 +561,7 @@ const props = withDefaults(
disabled: false,
headingButtons: true,
onImageUpload: undefined,
placeholder: 'Write something...',
placeholder: undefined,
maxLength: undefined,
maxHeight: undefined,
minHeight: undefined,
@@ -338,6 +574,9 @@ let isDisabledCompartment: Compartment | null = null
let editorThemeCompartment: Compartment | null = null
const emit = defineEmits(['update:modelValue'])
const resolvedPlaceholder = computed(
() => props.placeholder ?? formatMessage(messages.editorPlaceholder),
)
onMounted(() => {
const updateListener = EditorView.updateListener.of((update) => {
@@ -393,7 +632,7 @@ onMounted(() => {
uploadImagesFromList(clipboardData.files)
.then(function (url) {
const selection = markdownCommands.yankSelection(view)
const altText = selection || 'Replace this with a description'
const altText = selection || formatMessage(messages.defaultImageAltText)
const linkMarkdown = `![${altText}](${url})`
return markdownCommands.replaceSelection(view, linkMarkdown)
})
@@ -466,7 +705,7 @@ onMounted(() => {
addKeymap: false,
}),
keymap.of(historyKeymap),
cm_placeholder(props.placeholder || ''),
cm_placeholder(resolvedPlaceholder.value),
inputFilter,
isDisabledCompartment.of(disabledCompartment),
editorThemeCompartment.of(theme),
@@ -494,7 +733,7 @@ onBeforeUnmount(() => {
})
type ButtonAction = {
label: string
label: MessageDescriptor
icon: Component
action: (editor: EditorView | null) => void
}
@@ -515,12 +754,12 @@ function runEditorCommand(command: (view: EditorView) => boolean, editor: Editor
}
const composeCommandButton = (
name: string,
label: MessageDescriptor,
icon: Component,
command: (view: EditorView) => boolean,
) => {
return {
label: name,
label,
icon,
action: (e: EditorView | null) => runEditorCommand(command, e),
}
@@ -531,33 +770,41 @@ const BUTTONS: ButtonGroupMap = {
display: props.headingButtons,
hideOnMobile: false,
buttons: [
composeCommandButton('Heading 1', Heading1Icon, markdownCommands.toggleHeader),
composeCommandButton('Heading 2', Heading2Icon, markdownCommands.toggleHeader2),
composeCommandButton('Heading 3', Heading3Icon, markdownCommands.toggleHeader3),
composeCommandButton(messages.toolbarHeading1, Heading1Icon, markdownCommands.toggleHeader),
composeCommandButton(messages.toolbarHeading2, Heading2Icon, markdownCommands.toggleHeader2),
composeCommandButton(messages.toolbarHeading3, Heading3Icon, markdownCommands.toggleHeader3),
],
},
stylizing: {
display: true,
hideOnMobile: false,
buttons: [
composeCommandButton('Bold', BoldIcon, markdownCommands.toggleBold),
composeCommandButton('Italic', ItalicIcon, markdownCommands.toggleItalic),
composeCommandButton(messages.toolbarBold, BoldIcon, markdownCommands.toggleBold),
composeCommandButton(messages.toolbarItalic, ItalicIcon, markdownCommands.toggleItalic),
composeCommandButton(
'Strikethrough',
messages.toolbarStrikethrough,
StrikethroughIcon,
markdownCommands.toggleStrikethrough,
),
composeCommandButton('Code', CodeIcon, markdownCommands.toggleCodeBlock),
composeCommandButton('Spoiler', ScanEyeIcon, markdownCommands.toggleSpoiler),
composeCommandButton(messages.toolbarCode, CodeIcon, markdownCommands.toggleCodeBlock),
composeCommandButton(messages.toolbarSpoiler, ScanEyeIcon, markdownCommands.toggleSpoiler),
],
},
lists: {
display: true,
hideOnMobile: false,
buttons: [
composeCommandButton('Bulleted list', ListBulletedIcon, markdownCommands.toggleBulletList),
composeCommandButton('Ordered list', ListOrderedIcon, markdownCommands.toggleOrderedList),
composeCommandButton('Quote', TextQuoteIcon, markdownCommands.toggleQuote),
composeCommandButton(
messages.toolbarBulletedList,
ListBulletedIcon,
markdownCommands.toggleBulletList,
),
composeCommandButton(
messages.toolbarOrderedList,
ListOrderedIcon,
markdownCommands.toggleOrderedList,
),
composeCommandButton(messages.toolbarQuote, TextQuoteIcon, markdownCommands.toggleQuote),
],
},
components: {
@@ -565,17 +812,17 @@ const BUTTONS: ButtonGroupMap = {
hideOnMobile: false,
buttons: [
{
label: 'Link',
label: messages.toolbarLink,
icon: LinkIcon,
action: () => openLinkModal(),
},
{
label: 'Image',
label: messages.toolbarImage,
icon: ImageIcon,
action: () => openImageModal(),
},
{
label: 'Video',
label: messages.toolbarVideo,
icon: YouTubeIcon,
action: () => openVideoModal(),
},
@@ -693,12 +940,12 @@ function cleanUrl(input: string): string {
try {
url = new URL(input)
} catch {
throw new Error('Invalid URL. Make sure the URL is well-formed.')
throw new Error(formatMessage(messages.urlValidationErrorMalformed))
}
// Check for unsupported protocols
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Unsupported protocol. Use http or https.')
throw new Error(formatMessage(messages.urlValidationErrorUnsupportedProtocol))
}
// If the scheme is "http", automatically upgrade it to "https"
@@ -709,7 +956,7 @@ function cleanUrl(input: string): string {
// Block certain domains for compliance
const blockedDomains = ['forgecdn', 'cdn.discordapp', 'media.discordapp']
if (blockedDomains.some((domain) => url.hostname.includes(domain))) {
throw new Error('Invalid URL. This domain is not allowed.')
throw new Error(formatMessage(messages.urlValidationErrorBlockedDomain))
}
return url.toString()
@@ -733,7 +980,7 @@ const linkMarkdown = computed(() => {
const uploadImagesFromList = async (files: FileList): Promise<string> => {
const file = files[0]
if (!props.onImageUpload) {
throw new Error('No image upload handler provided')
throw new Error(formatMessage(messages.uploadErrorNoHandler))
}
if (file) {
try {
@@ -746,7 +993,7 @@ const uploadImagesFromList = async (files: FileList): Promise<string> => {
}
}
}
throw new Error('No file provided')
throw new Error(formatMessage(messages.uploadErrorNoFile))
}
const handleImageUpload = async (files: FileList) => {
@@ -765,6 +1012,15 @@ const handleImageUpload = async (files: FileList) => {
}
const imageUploadOption = ref<string>('upload')
function formatImageUploadOption(option: string) {
if (option === 'upload') {
return formatMessage(messages.imageModalUploadModeUpload)
}
if (option === 'link') {
return formatMessage(messages.imageModalUploadModeLink)
}
return option
}
const imageMarkdown = computed(() => (linkMarkdown.value.length ? `!${linkMarkdown.value}` : ''))
const canInsertImage = computed(() => {
@@ -781,7 +1037,7 @@ const youtubeRegex =
const videoMarkdown = computed(() => {
const match = youtubeRegex.exec(linkUrl.value)
if (match) {
return `<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/${match[1]}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
return `<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/${match[1]}" title="${formatMessage(messages.videoEmbedTitle)}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
}
return ''
})
@@ -924,11 +1180,13 @@ function openVideoModal() {
}
.modal-insert {
padding: var(--gap-lg);
.label {
margin-block: var(--gap-lg) var(--gap-sm);
display: block;
&:first-child {
margin-top: 0;
}
}
.label__title {

View File

@@ -2,8 +2,8 @@
<nav
v-if="filteredLinks.length > 1"
ref="scrollContainer"
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold drop-shadow-xl"
:class="{ 'shadow-sm': mode === 'navigation' }"
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
:class="{ 'drop-shadow-xl': mode === 'navigation' }"
>
<template v-if="mode === 'navigation'">
<RouterLink

View File

@@ -25,6 +25,7 @@
aria-modal="true"
:aria-labelledby="headerId"
class="modal-body flex flex-col bg-bg-raised rounded-2xl border border-solid border-surface-5"
v-bind="$attrs"
@keydown="handleKeyDown"
>
<div
@@ -345,6 +346,10 @@ function handleKeyDown(event: KeyboardEvent) {
}
}
}
defineOptions({
inheritAttrs: false,
})
</script>
<style lang="scss" scoped>

View File

@@ -1,6 +1,10 @@
<template>
<div
:class="['banner-grid relative border-b-2 border-solid border-0', containerClasses[variant]]"
:class="[
'banner-grid relative border-b-2 border-solid border-0 z-10',
containerClasses[variant],
{ 'no-actions': !$slots.actions, slim: slim },
]"
>
<div
:class="[
@@ -16,12 +20,20 @@
<slot name="description" />
</div>
<div v-if="$slots.actions" class="grid-area-[actions]">
<div v-if="$slots.actions" class="grid-area-[actions] flex items-center gap-2">
<slot name="actions" />
</div>
<div v-if="$slots.actions_right" class="grid-area-[actions_right]">
<slot name="actions_right" />
<div
v-if="$slots.actions_right || $slots.actions_top_right"
class="grid-area-[actions_right] flex flex-col gap-2 items-end"
>
<div v-if="$slots.actions_top_right" class="flex items-center gap-2 justify-end">
<slot name="actions_top_right" />
</div>
<div v-if="$slots.actions_right" class="flex items-center gap-2 justify-end my-auto">
<slot name="actions_right" />
</div>
</div>
</div>
</template>
@@ -29,9 +41,15 @@
<script lang="ts" setup>
import { getSeverityIcon } from '../../utils'
defineProps<{
variant: 'error' | 'warning' | 'info'
}>()
withDefaults(
defineProps<{
variant: 'error' | 'warning' | 'info'
slim?: boolean
}>(),
{
slim: false,
},
)
const containerClasses = {
error: 'bg-banners-error-bg text-banners-error-text border-banners-error-border',
@@ -58,6 +76,16 @@ const iconClasses = {
padding-inline: max(calc((100% - 80rem) / 2 + var(--gap-md)), var(--gap-xl));
}
.banner-grid.no-actions {
grid-template-areas:
'title actions_right'
'description actions_right';
}
.banner-grid.slim {
@apply flex py-4 gap-2 items-center;
}
.grid-area-\[title\] {
grid-area: title;
}

View File

@@ -2036,6 +2036,150 @@
"locale.zh-TW": {
"defaultMessage": "Chinese (Traditional)"
},
"markdown-editor.default-image-alt-text": {
"defaultMessage": "Replace this with a description"
},
"markdown-editor.error-label": {
"defaultMessage": "Error"
},
"markdown-editor.image-modal.description-field.description": {
"defaultMessage": "Describe the image completely as you would to someone who could not see the image."
},
"markdown-editor.image-modal.description-field.placeholder": {
"defaultMessage": "Describe the image..."
},
"markdown-editor.image-modal.description-field.title": {
"defaultMessage": "Description (alt text)"
},
"markdown-editor.image-modal.header": {
"defaultMessage": "Insert image"
},
"markdown-editor.image-modal.upload-mode.label": {
"defaultMessage": "Image source"
},
"markdown-editor.image-modal.upload-mode.link": {
"defaultMessage": "Link"
},
"markdown-editor.image-modal.upload-mode.upload": {
"defaultMessage": "Upload"
},
"markdown-editor.image-modal.upload.prompt": {
"defaultMessage": "Drag and drop to upload or click to select file"
},
"markdown-editor.image-modal.url-field.placeholder": {
"defaultMessage": "Enter the image URL..."
},
"markdown-editor.insert-button": {
"defaultMessage": "Insert"
},
"markdown-editor.link-modal.header": {
"defaultMessage": "Insert link"
},
"markdown-editor.link-modal.label-field.placeholder": {
"defaultMessage": "Enter label..."
},
"markdown-editor.link-modal.label-field.title": {
"defaultMessage": "Label"
},
"markdown-editor.link-modal.url-field.placeholder": {
"defaultMessage": "Enter the link's URL..."
},
"markdown-editor.markdown-formatting-support": {
"defaultMessage": "This editor supports <markdown-link>Markdown formatting</markdown-link>."
},
"markdown-editor.max-length.label": {
"defaultMessage": "Max length:"
},
"markdown-editor.max-length.unlimited": {
"defaultMessage": "Unlimited"
},
"markdown-editor.max-length.value": {
"defaultMessage": "{currentLength}/{maxLength}"
},
"markdown-editor.placeholder": {
"defaultMessage": "Write something..."
},
"markdown-editor.preview-label": {
"defaultMessage": "Preview"
},
"markdown-editor.preview-toggle.label": {
"defaultMessage": "Preview"
},
"markdown-editor.toolbar.bold": {
"defaultMessage": "Bold"
},
"markdown-editor.toolbar.bulleted-list": {
"defaultMessage": "Bulleted list"
},
"markdown-editor.toolbar.code": {
"defaultMessage": "Code"
},
"markdown-editor.toolbar.heading-1": {
"defaultMessage": "Heading 1"
},
"markdown-editor.toolbar.heading-2": {
"defaultMessage": "Heading 2"
},
"markdown-editor.toolbar.heading-3": {
"defaultMessage": "Heading 3"
},
"markdown-editor.toolbar.image": {
"defaultMessage": "Image"
},
"markdown-editor.toolbar.italic": {
"defaultMessage": "Italic"
},
"markdown-editor.toolbar.link": {
"defaultMessage": "Link"
},
"markdown-editor.toolbar.ordered-list": {
"defaultMessage": "Ordered list"
},
"markdown-editor.toolbar.quote": {
"defaultMessage": "Quote"
},
"markdown-editor.toolbar.spoiler": {
"defaultMessage": "Spoiler"
},
"markdown-editor.toolbar.strikethrough": {
"defaultMessage": "Strikethrough"
},
"markdown-editor.toolbar.video": {
"defaultMessage": "Video"
},
"markdown-editor.upload-error.no-file": {
"defaultMessage": "No file provided"
},
"markdown-editor.upload-error.no-handler": {
"defaultMessage": "No image upload handler provided"
},
"markdown-editor.url-label": {
"defaultMessage": "URL"
},
"markdown-editor.url-validation-error.blocked-domain": {
"defaultMessage": "Invalid URL. This domain is not allowed."
},
"markdown-editor.url-validation-error.malformed": {
"defaultMessage": "Invalid URL. Make sure the URL is well-formed."
},
"markdown-editor.url-validation-error.unsupported-protocol": {
"defaultMessage": "Unsupported protocol. Use http or https."
},
"markdown-editor.video-embed.title": {
"defaultMessage": "YouTube video player"
},
"markdown-editor.video-modal.header": {
"defaultMessage": "Insert YouTube video"
},
"markdown-editor.video-modal.url-field.description": {
"defaultMessage": "Enter a valid link to a YouTube video."
},
"markdown-editor.video-modal.url-field.placeholder": {
"defaultMessage": "Enter YouTube video URL"
},
"markdown-editor.video-modal.url-field.title": {
"defaultMessage": "YouTube video URL"
},
"modal.add-payment-method.action": {
"defaultMessage": "Add payment method"
},

View File

@@ -25,6 +25,8 @@ import {
PayPalIcon,
PlugIcon,
PolygonIcon,
ScaleIcon,
ServerIcon,
UnknownIcon,
UpdatedIcon,
USDCColorIcon,
@@ -49,6 +51,7 @@ export const PROJECT_TYPE_ICONS: Record<ProjectType, Component> = {
plugin: PlugIcon,
datapack: BracesIcon,
project: BoxIcon,
minecraft_java_server: ServerIcon,
}
export const PAYMENT_METHOD_ICONS: Record<string, Component> = {
@@ -68,6 +71,7 @@ export const SEVERITY_ICONS: Record<string, Component> = {
error: XCircleIcon,
critical: XCircleIcon,
success: CheckCircleIcon,
moderation: ScaleIcon,
}
export const PROJECT_STATUS_ICONS: Record<ProjectStatus, Component> = {