mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 02:16:27 +00:00
fix(ui): make codex config.toml viewer selectable
Replace the hand-rolled textarea+Prism overlay in CodeEditor with CodeMirror 6. The overlay hid the textarea selection with `selection:bg-transparent` and `WebkitTextFillColor: transparent` as a workaround for wrap drift between the highlight layer and the textarea, which left Ctrl+A / drag-select / Ctrl+C with no visible result. CodeMirror handles selection, copy, undo, and wrap natively. Custom themes match the previous prism-react-renderer github (light) and nightOwl (dark) palettes; background is transparent so the surrounding bg-muted/30 still shows; font, font-size, line-height, padding, and gutter-less layout are preserved. Public CodeEditor props are unchanged (value, onChange, language, readonly, exactText, className, minHeight, heightMode) so all seven call sites keep working. The sensitive-key blur is reimplemented as a CodeMirror Decoration and stays gated behind the existing Eye toggle. Test setup gets a Range.getClientRects polyfill so CM mounts cleanly in JSDOM, and shared-page.test.tsx checks .cm-content text directly. Closes #1247
This commit is contained in:
1 parent
aba15d8646
commit
420494e0e0
6 files changed
+327
-265
No files matched your search
@@ -1,16 +1,19 @@
|
||||
/**
|
||||
* Code Editor Component
|
||||
* Lightweight JSON/TOML editor with syntax highlighting, line numbers, and validation
|
||||
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
|
||||
* CodeMirror 6 backed JSON/YAML/TOML editor with native selection, copy, undo,
|
||||
* syntax highlighting, validation, and sensitive-value masking.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, type UIEvent } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { Highlight, themes } from 'prism-react-renderer';
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/components/prism-toml';
|
||||
import { useMemo, useState } from 'react';
|
||||
import CodeMirror, { EditorView, type Extension } from '@uiw/react-codemirror';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { yaml } from '@codemirror/lang-yaml';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { toml } from '@codemirror/legacy-modes/mode/toml';
|
||||
import { createTheme } from '@uiw/codemirror-themes';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { RangeSetBuilder } from '@codemirror/state';
|
||||
import { Decoration, type DecorationSet, ViewPlugin, type ViewUpdate } from '@codemirror/view';
|
||||
import { parse as parseToml } from 'smol-toml';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -24,6 +27,10 @@ interface CodeEditorProps {
|
||||
onChange: (value: string) => void;
|
||||
language?: 'json' | 'yaml' | 'toml';
|
||||
readonly?: boolean;
|
||||
/**
|
||||
* Retained for API compatibility. CodeMirror always preserves exact text,
|
||||
* so this flag is a no-op; callers can pass it without effect.
|
||||
*/
|
||||
exactText?: boolean;
|
||||
className?: string;
|
||||
minHeight?: string;
|
||||
@@ -36,53 +43,31 @@ interface ValidationResult {
|
||||
line?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JSON and extract error location
|
||||
*/
|
||||
function validateJson(code: string): ValidationResult {
|
||||
if (!code.trim()) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
if (!code.trim()) return { valid: true };
|
||||
try {
|
||||
JSON.parse(code);
|
||||
return { valid: true };
|
||||
} catch (e) {
|
||||
const error = e as SyntaxError;
|
||||
const message = error.message;
|
||||
|
||||
// Try to extract line number from error message
|
||||
// Format: "... at position X" or "... at line Y column Z"
|
||||
const message = (e as SyntaxError).message;
|
||||
const posMatch = message.match(/position (\d+)/);
|
||||
if (posMatch) {
|
||||
const pos = parseInt(posMatch[1], 10);
|
||||
const lines = code.substring(0, pos).split('\n');
|
||||
return {
|
||||
valid: false,
|
||||
error: message,
|
||||
line: lines.length,
|
||||
};
|
||||
return { valid: false, error: message, line: lines.length };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: message,
|
||||
};
|
||||
return { valid: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
function validateToml(code: string): ValidationResult {
|
||||
if (!code.trim()) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
if (!code.trim()) return { valid: true };
|
||||
try {
|
||||
parseToml(code);
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
const lineMatch = message.match(/line\s+(\d+)/i);
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: message,
|
||||
@@ -91,12 +76,139 @@ function validateToml(code: string): ValidationResult {
|
||||
}
|
||||
}
|
||||
|
||||
const SENSITIVE_PATTERNS: Record<'json' | 'yaml' | 'toml', RegExp> = {
|
||||
json: /"([^"\\]+)"\s*:\s*(?=("[^"\\]*"|true|false|null|-?\d[\d.eE+-]*))/g,
|
||||
yaml: /^\s*([A-Za-z0-9_.-]+)\s*:\s*(?=\S)/gm,
|
||||
toml: /^\s*([A-Za-z0-9_.-]+)\s*=\s*(?=\S)/gm,
|
||||
};
|
||||
|
||||
function buildSensitiveDecorations(
|
||||
view: EditorView,
|
||||
language: 'json' | 'yaml' | 'toml'
|
||||
): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
const pattern = new RegExp(SENSITIVE_PATTERNS[language].source, 'gm');
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
const text = view.state.doc.sliceString(from, to);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
const key = match[1];
|
||||
if (!isSensitiveKey(key)) continue;
|
||||
const valueStart = from + match.index + match[0].length;
|
||||
const line = view.state.doc.lineAt(valueStart);
|
||||
const valueEnd = line.to;
|
||||
const trimmed = view.state.doc.sliceString(valueStart, valueEnd).replace(/\s+$/, '');
|
||||
if (!trimmed) continue;
|
||||
builder.add(
|
||||
valueStart,
|
||||
valueStart + trimmed.length,
|
||||
Decoration.mark({ class: 'cm-sensitive-mask' })
|
||||
);
|
||||
}
|
||||
}
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
function makeSensitivePlugin(language: 'json' | 'yaml' | 'toml'): Extension {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildSensitiveDecorations(view, language);
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildSensitiveDecorations(update.view, language);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations }
|
||||
);
|
||||
}
|
||||
|
||||
// Visual parity with the previous prism-react-renderer themes. Hex values are
|
||||
// copied from prism-react-renderer/themes/nightOwl.js and themes/github.js so
|
||||
// the dashboard appearance does not shift with the editor swap.
|
||||
//
|
||||
// Background is left transparent on both themes so the surrounding
|
||||
// `bg-muted/30` container shows through, matching the old Editor which only
|
||||
// painted token colors over a transparent textarea.
|
||||
const prismGithubTheme = createTheme({
|
||||
theme: 'light',
|
||||
settings: {
|
||||
background: 'transparent',
|
||||
foreground: '#393A34',
|
||||
caret: '#393A34',
|
||||
selection: 'rgba(57, 58, 52, 0.18)',
|
||||
selectionMatch: 'rgba(57, 58, 52, 0.12)',
|
||||
lineHighlight: 'transparent',
|
||||
gutterBackground: 'transparent',
|
||||
gutterForeground: '#999988',
|
||||
},
|
||||
styles: [
|
||||
{ tag: [t.comment, t.lineComment, t.blockComment], color: '#999988', fontStyle: 'italic' },
|
||||
{ tag: [t.string, t.special(t.string), t.regexp], color: '#e3116c' },
|
||||
{
|
||||
tag: [t.number, t.bool, t.null, t.atom, t.variableName, t.propertyName, t.url],
|
||||
color: '#36acaa',
|
||||
},
|
||||
{ tag: [t.keyword, t.attributeName, t.modifier, t.operatorKeyword], color: '#00a4db' },
|
||||
{ tag: [t.function(t.variableName), t.labelName], color: '#6f42c1' },
|
||||
{ tag: [t.tagName, t.heading], color: '#00009f', fontWeight: 'bold' },
|
||||
{ tag: [t.deleted, t.invalid], color: '#d73a49' },
|
||||
{ tag: [t.punctuation, t.operator, t.bracket, t.brace, t.separator], color: '#393A34' },
|
||||
],
|
||||
});
|
||||
|
||||
const prismNightOwlTheme = createTheme({
|
||||
theme: 'dark',
|
||||
settings: {
|
||||
background: 'transparent',
|
||||
foreground: '#d6deeb',
|
||||
caret: '#80a4c2',
|
||||
selection: 'rgba(29, 59, 83, 0.99)',
|
||||
selectionMatch: 'rgba(35, 77, 112, 0.6)',
|
||||
lineHighlight: 'transparent',
|
||||
gutterBackground: 'transparent',
|
||||
gutterForeground: '#4b6479',
|
||||
},
|
||||
styles: [
|
||||
{ tag: [t.comment, t.lineComment, t.blockComment], color: '#637777', fontStyle: 'italic' },
|
||||
{ tag: [t.string, t.special(t.string), t.url], color: 'rgb(173, 219, 103)' },
|
||||
{ tag: t.number, color: 'rgb(247, 140, 108)' },
|
||||
{ tag: [t.bool, t.null, t.atom], color: 'rgb(255, 88, 116)' },
|
||||
{ tag: [t.keyword, t.modifier, t.operatorKeyword], color: 'rgb(127, 219, 202)' },
|
||||
{ tag: [t.propertyName, t.attributeName], color: 'rgb(128, 203, 196)' },
|
||||
{ tag: [t.variableName, t.labelName], color: 'rgb(214, 222, 235)' },
|
||||
{ tag: t.function(t.variableName), color: 'rgb(130, 170, 255)' },
|
||||
{ tag: [t.tagName, t.heading], color: 'rgb(127, 219, 202)' },
|
||||
{
|
||||
tag: [t.bracket, t.punctuation, t.operator, t.brace, t.separator],
|
||||
color: 'rgb(199, 146, 234)',
|
||||
},
|
||||
{ tag: [t.className, t.special(t.string)], color: 'rgb(255, 203, 139)' },
|
||||
{ tag: [t.deleted, t.invalid], color: 'rgba(239, 83, 80, 0.56)' },
|
||||
],
|
||||
});
|
||||
|
||||
const sensitiveMaskTheme = EditorView.baseTheme({
|
||||
'.cm-sensitive-mask': {
|
||||
filter: 'blur(3px)',
|
||||
opacity: '0.7',
|
||||
transition: 'filter 200ms ease, opacity 200ms ease',
|
||||
},
|
||||
'.cm-editor.cm-sensitive-revealed .cm-sensitive-mask': {
|
||||
filter: 'none',
|
||||
opacity: '1',
|
||||
},
|
||||
});
|
||||
|
||||
export function CodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
language = 'json',
|
||||
readonly = false,
|
||||
exactText = false,
|
||||
exactText: _exactText = false,
|
||||
className,
|
||||
minHeight = '300px',
|
||||
heightMode = 'content',
|
||||
@@ -107,210 +219,97 @@ export function CodeEditor({
|
||||
const [isMasked, setIsMasked] = useState(true);
|
||||
const isFillParent = heightMode === 'fill-parent';
|
||||
|
||||
// Validate on every change for JSON
|
||||
const validation = useMemo(() => {
|
||||
if (language === 'json') {
|
||||
return validateJson(value);
|
||||
}
|
||||
if (language === 'toml') {
|
||||
return validateToml(value);
|
||||
}
|
||||
const validation = useMemo<ValidationResult>(() => {
|
||||
if (language === 'json') return validateJson(value);
|
||||
if (language === 'toml') return validateToml(value);
|
||||
return { valid: true };
|
||||
}, [value, language]);
|
||||
|
||||
// Highlight function using prism-react-renderer
|
||||
// Note: Line numbers removed - they break textarea/pre alignment in react-simple-code-editor
|
||||
const highlightCode = useCallback(
|
||||
(code: string) => (
|
||||
<Highlight
|
||||
prism={Prism}
|
||||
theme={isDark ? themes.nightOwl : themes.github}
|
||||
code={code}
|
||||
language={language}
|
||||
>
|
||||
{({ tokens, getLineProps, getTokenProps }) => {
|
||||
let nextValueIsSensitive = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{tokens.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
{...getLineProps({ line })}
|
||||
className={cn(validation.line === i + 1 && 'bg-destructive/20')}
|
||||
>
|
||||
{line.map((token, key) => {
|
||||
let isSensitive = false;
|
||||
|
||||
// Check for sensitive keys
|
||||
if (token.types.includes('property')) {
|
||||
const content = token.content.replace(/['"]/g, '');
|
||||
// Use shared sensitive key detection utility
|
||||
if (isSensitiveKey(content)) {
|
||||
nextValueIsSensitive = true;
|
||||
} else {
|
||||
nextValueIsSensitive = false;
|
||||
}
|
||||
}
|
||||
// Apply masking to values following sensitive keys
|
||||
else if (
|
||||
(token.types.includes('string') ||
|
||||
token.types.includes('number') ||
|
||||
token.types.includes('boolean')) &&
|
||||
nextValueIsSensitive
|
||||
) {
|
||||
isSensitive = true;
|
||||
// Consumes the flag for this value
|
||||
nextValueIsSensitive = false;
|
||||
}
|
||||
// Reset flag on commas or new keys (handled by property check),
|
||||
// but persist through colons and whitespace
|
||||
else if (token.types.includes('punctuation')) {
|
||||
if (token.content !== ':' && token.content !== '[' && token.content !== '{') {
|
||||
nextValueIsSensitive = false;
|
||||
}
|
||||
}
|
||||
|
||||
const tokenProps = getTokenProps({ token });
|
||||
// Prism TOML emits a `table` class, which collides with Tailwind's layout utility.
|
||||
tokenProps.style = {
|
||||
...tokenProps.style,
|
||||
display: 'inline',
|
||||
};
|
||||
|
||||
if (isSensitive && isMasked) {
|
||||
tokenProps.className = cn(
|
||||
tokenProps.className,
|
||||
'blur-[3px] select-none opacity-70 transition-all duration-200'
|
||||
);
|
||||
}
|
||||
|
||||
return <span key={key} {...tokenProps} />;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Highlight>
|
||||
),
|
||||
[isDark, language, validation.line, isMasked]
|
||||
);
|
||||
const highlightLayerRef = useRef<HTMLDivElement>(null);
|
||||
const syncHighlightScroll = useCallback((event: UIEvent<HTMLTextAreaElement>) => {
|
||||
const layer = highlightLayerRef.current;
|
||||
if (!layer) return;
|
||||
|
||||
const { scrollTop } = event.currentTarget;
|
||||
layer.style.transform = `translateY(${-scrollTop}px)`;
|
||||
}, []);
|
||||
const extensions = useMemo<Extension[]>(() => {
|
||||
const langExt =
|
||||
language === 'json' ? json() : language === 'yaml' ? yaml() : StreamLanguage.define(toml);
|
||||
return [
|
||||
langExt,
|
||||
EditorView.lineWrapping,
|
||||
sensitiveMaskTheme,
|
||||
makeSensitivePlugin(language),
|
||||
EditorView.theme({
|
||||
// Match the previous Prism overlay editor: 0.875rem mono, 1.625
|
||||
// line-height, 12px padding, transparent background so the parent
|
||||
// `bg-muted/30` shows through.
|
||||
'&': {
|
||||
fontSize: '0.875rem',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-content': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
padding: '12px 0',
|
||||
caretColor: 'currentColor',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 12px',
|
||||
lineHeight: '1.625',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
lineHeight: '1.625',
|
||||
},
|
||||
'.cm-gutters': { display: 'none' },
|
||||
}),
|
||||
];
|
||||
}, [language]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex min-h-0 flex-col', isFillParent && 'h-full', className)}
|
||||
style={isFillParent ? { height: minHeight === 'auto' ? undefined : minHeight } : undefined}
|
||||
data-slot="code-editor-root"
|
||||
>
|
||||
{/* Editor container */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-md border overflow-hidden',
|
||||
'bg-muted/30',
|
||||
'relative rounded-md border overflow-hidden bg-muted/30',
|
||||
isFillParent && 'flex min-h-0 flex-1 flex-col',
|
||||
isFocused && 'ring-2 ring-ring ring-offset-2 ring-offset-background',
|
||||
readonly && 'opacity-70 cursor-not-allowed',
|
||||
readonly && 'opacity-70',
|
||||
!validation.valid && 'border-destructive'
|
||||
)}
|
||||
data-slot="code-editor-surface"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
isFillParent && 'scrollbar-editor min-h-0 flex-1',
|
||||
isFillParent && (exactText ? 'overflow-hidden' : 'overflow-auto')
|
||||
isFillParent ? 'scrollbar-editor min-h-0 flex-1 overflow-hidden' : undefined
|
||||
)}
|
||||
data-slot={isFillParent ? 'code-editor-viewport' : undefined}
|
||||
>
|
||||
{exactText ? (
|
||||
// Exact mode keeps the native textarea as the editable/copyable source of truth while
|
||||
// rendering Prism colors behind it with the same soft-wrap layout contract.
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden',
|
||||
isFillParent ? 'h-full min-h-full' : 'min-h-[300px]'
|
||||
)}
|
||||
style={{
|
||||
minHeight,
|
||||
}}
|
||||
data-slot="code-editor-exact-highlight-wrapper"
|
||||
>
|
||||
<div
|
||||
ref={highlightLayerRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-x-0 top-0 w-full p-3 font-mono text-sm leading-relaxed',
|
||||
'whitespace-pre-wrap overflow-visible break-words'
|
||||
)}
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: '0.875rem',
|
||||
minHeight,
|
||||
tabSize: 2,
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
data-slot="code-editor-highlight-layer"
|
||||
>
|
||||
{highlightCode(value)}
|
||||
</div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
if (!readonly) onChange(event.target.value);
|
||||
}}
|
||||
readOnly={readonly}
|
||||
wrap="soft"
|
||||
spellCheck={false}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onScroll={syncHighlightScroll}
|
||||
className={cn(
|
||||
'absolute inset-0 z-10 block h-full w-full resize-none border-0 bg-transparent p-3 font-mono text-sm leading-relaxed',
|
||||
'whitespace-pre-wrap overflow-y-auto overflow-x-hidden break-words text-transparent caret-foreground selection:bg-transparent focus:outline-none',
|
||||
readonly && 'cursor-not-allowed'
|
||||
)}
|
||||
style={{
|
||||
minHeight,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: '0.875rem',
|
||||
tabSize: 2,
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
data-slot="code-editor-plain-textarea"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Editor
|
||||
value={value}
|
||||
onValueChange={readonly ? () => {} : onChange}
|
||||
highlight={highlightCode}
|
||||
key={isDark ? 'dark-editor' : 'light-editor'}
|
||||
padding={12}
|
||||
disabled={readonly}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
textareaClassName={cn(
|
||||
'focus:outline-none font-mono text-sm',
|
||||
readonly && 'cursor-not-allowed'
|
||||
)}
|
||||
preClassName="font-mono text-sm"
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: '0.875rem',
|
||||
minHeight,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CodeMirror
|
||||
value={value}
|
||||
onChange={readonly ? undefined : onChange}
|
||||
readOnly={readonly}
|
||||
editable={!readonly}
|
||||
theme={isDark ? prismNightOwlTheme : prismGithubTheme}
|
||||
extensions={extensions}
|
||||
height={isFillParent ? '100%' : undefined}
|
||||
minHeight={isFillParent ? undefined : minHeight}
|
||||
basicSetup={{
|
||||
// Keep visual parity with the previous Prism overlay editor:
|
||||
// no gutter, no line numbers, no active-line tint.
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false,
|
||||
autocompletion: false,
|
||||
searchKeymap: true,
|
||||
}}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
className={cn(
|
||||
'cm-editor-root',
|
||||
!isMasked && 'cm-sensitive-revealed',
|
||||
isFillParent && 'h-full'
|
||||
)}
|
||||
data-slot="code-editor-codemirror"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2 z-20 opacity-50 hover:opacity-100 transition-opacity">
|
||||
@@ -326,7 +325,6 @@ export function CodeEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation status */}
|
||||
<div className="flex items-center gap-2 mt-2 text-xs">
|
||||
{validation.valid ? (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
|
||||
Reference in new issue
Block a user