mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-10 03:13:33 +02:00
2024.5.0 (#39)
This commit is contained in:
@@ -5,7 +5,9 @@
|
||||
@apply w-full block text-base;
|
||||
|
||||
.cm-cursor {
|
||||
@apply border-gray-800 !important;
|
||||
@apply border-fg !important;
|
||||
/* Widen the cursor */
|
||||
@apply border-l-[2px];
|
||||
}
|
||||
|
||||
&.cm-focused {
|
||||
@@ -17,7 +19,7 @@
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply text-gray-800 pl-1 pr-1.5;
|
||||
@apply text-fg pl-1 pr-1.5;
|
||||
}
|
||||
|
||||
.cm-placeholder {
|
||||
@@ -47,27 +49,21 @@
|
||||
/* Style gutters */
|
||||
|
||||
.cm-gutters {
|
||||
@apply border-0 text-gray-500/50;
|
||||
@apply border-0 text-fg-subtler bg-transparent;
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply cursor-default;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder-widget {
|
||||
@apply text-xs text-violet-700 dark:text-violet-700 px-1 mx-[0.5px] rounded cursor-default dark:shadow;
|
||||
.placeholder {
|
||||
/* Colors */
|
||||
@apply bg-background text-fg-subtle border-background-highlight-secondary;
|
||||
@apply hover:border-background-highlight hover:text-fg hover:bg-background-highlight-secondary;
|
||||
|
||||
/* NOTE: Background and border are translucent so we can see text selection through it */
|
||||
@apply bg-violet-500/20 border border-violet-500/20 border-opacity-40;
|
||||
|
||||
/* Bring above on hover */
|
||||
@apply hover:z-10 relative;
|
||||
@apply border px-1 mx-[0.5px] rounded cursor-default dark:shadow;
|
||||
|
||||
-webkit-text-security: none;
|
||||
|
||||
&.placeholder-widget-error {
|
||||
@apply text-red-700 dark:text-red-800 bg-red-300/30 border-red-300/80 border-opacity-40 hover:border-red-300 hover:bg-red-300/40;
|
||||
}
|
||||
}
|
||||
|
||||
.hyperlink-widget {
|
||||
@@ -108,8 +104,7 @@
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono text-[0.75rem];
|
||||
|
||||
@apply font-mono text-editor;
|
||||
/*
|
||||
* Round corners or they'll stick out of the editor bounds of editor is rounded.
|
||||
* Could potentially be pushed up from the editor like we do with bg color but this
|
||||
@@ -137,7 +132,7 @@
|
||||
|
||||
.cm-editor .fold-gutter-icon::after {
|
||||
@apply block w-1.5 h-1.5 border-transparent -rotate-45
|
||||
border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
|
||||
border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon[data-open] {
|
||||
@@ -149,12 +144,12 @@
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon:hover {
|
||||
@apply text-gray-900 bg-gray-300/50;
|
||||
@apply text-fg bg-background-highlight;
|
||||
}
|
||||
|
||||
.cm-editor .cm-foldPlaceholder {
|
||||
@apply px-2 border border-gray-400/50 bg-gray-300/50;
|
||||
@apply hover:text-gray-800 hover:border-gray-400;
|
||||
@apply px-2 border border-fg-subtler bg-background-highlight;
|
||||
@apply hover:text-fg hover:border-fg-subtle text-fg;
|
||||
@apply cursor-default !important;
|
||||
}
|
||||
|
||||
@@ -164,11 +159,13 @@
|
||||
|
||||
.cm-wrapper:not(.cm-readonly) .cm-editor {
|
||||
&.cm-focused .cm-activeLineGutter {
|
||||
@apply text-gray-600;
|
||||
@apply text-fg-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-wrapper.cm-readonly .cm-editor {
|
||||
.cm-cursor {
|
||||
@apply border-l-2 border-gray-800;
|
||||
@apply border-fg-danger !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,18 +184,18 @@
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-hover {
|
||||
@apply shadow-lg bg-gray-100 rounded text-gray-700 border border-gray-500 z-50 pointer-events-auto text-xs;
|
||||
@apply shadow-lg bg-background rounded text-fg-subtle border border-fg-subtler z-50 pointer-events-auto text-sm;
|
||||
@apply px-2 py-1;
|
||||
|
||||
a {
|
||||
@apply text-gray-800;
|
||||
@apply text-fg;
|
||||
|
||||
&:hover {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@apply text-gray-800 bg-gray-800 h-3 w-3 ml-1;
|
||||
@apply text-fg bg-fg-secondary h-3 w-3 ml-1;
|
||||
content: '';
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='black' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z'/%3E%3Cpath fill-rule='evenodd' d='M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z'/%3E%3C/svg%3E");
|
||||
-webkit-mask-size: contain;
|
||||
@@ -210,7 +207,7 @@
|
||||
/* NOTE: Extra selector required to override default styles */
|
||||
.cm-tooltip.cm-tooltip-autocomplete,
|
||||
.cm-tooltip.cm-completionInfo {
|
||||
@apply shadow-lg bg-gray-100 rounded text-gray-700 border border-gray-300 z-50 pointer-events-auto text-xs;
|
||||
@apply shadow-lg bg-background rounded text-fg-subtle border border-background-highlight z-50 pointer-events-auto text-sm;
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply italic font-mono;
|
||||
@@ -269,7 +266,7 @@
|
||||
}
|
||||
|
||||
&.cm-completionInfo-right {
|
||||
@apply ml-1 -mt-0.5 text-sm;
|
||||
@apply ml-1 -mt-0.5 font-sans;
|
||||
}
|
||||
|
||||
&.cm-completionInfo-right-narrow {
|
||||
@@ -281,24 +278,26 @@
|
||||
}
|
||||
|
||||
&.cm-tooltip-autocomplete {
|
||||
@apply font-mono;
|
||||
|
||||
& > ul {
|
||||
@apply p-1 max-h-[40vh];
|
||||
}
|
||||
|
||||
& > ul > li {
|
||||
@apply cursor-default px-2 rounded-sm text-gray-600 h-7 flex items-center;
|
||||
@apply cursor-default px-2 py-1.5 rounded-sm text-fg-subtle flex items-center;
|
||||
}
|
||||
|
||||
& > ul > li[aria-selected] {
|
||||
@apply bg-highlight text-gray-900;
|
||||
@apply bg-background-highlight-secondary text-fg;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply text-xs flex items-center pb-0.5 flex-shrink-0;
|
||||
@apply text-sm flex items-center pb-0.5 flex-shrink-0;
|
||||
}
|
||||
|
||||
.cm-completionLabel {
|
||||
@apply text-gray-700;
|
||||
@apply text-fg-subtle;
|
||||
}
|
||||
|
||||
.cm-completionDetail {
|
||||
@@ -308,7 +307,7 @@
|
||||
}
|
||||
|
||||
.cm-editor .cm-panels {
|
||||
@apply bg-gray-100 backdrop-blur-sm p-1 mb-1 text-gray-800 z-20 rounded-md;
|
||||
@apply bg-background-highlight-secondary backdrop-blur-sm p-1 mb-1 text-fg z-20 rounded-md;
|
||||
|
||||
input,
|
||||
button {
|
||||
@@ -316,19 +315,21 @@
|
||||
}
|
||||
|
||||
button {
|
||||
@apply appearance-none bg-none bg-gray-200 hocus:bg-gray-300 hocus:text-gray-950 border-0 text-gray-800 cursor-default;
|
||||
@apply border-fg-subtler bg-background-highlight text-fg hover:border-fg-info;
|
||||
@apply appearance-none bg-none cursor-default;
|
||||
}
|
||||
|
||||
button[name='close'] {
|
||||
@apply text-gray-600 hocus:text-gray-900 px-2 -mr-1.5 !important;
|
||||
@apply text-fg-subtle hocus:text-fg px-2 -mr-1.5 !important;
|
||||
}
|
||||
|
||||
input {
|
||||
@apply bg-gray-50 border border-gray-500/50 focus:border-focus outline-none;
|
||||
@apply bg-background border-background-highlight focus:border-border-focus;
|
||||
@apply border outline-none cursor-text;
|
||||
}
|
||||
|
||||
label {
|
||||
@apply focus-within:text-gray-950;
|
||||
@apply focus-within:text-fg;
|
||||
}
|
||||
|
||||
/* Hide the "All" button */
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'react';
|
||||
import { useActiveEnvironment } from '../../../hooks/useActiveEnvironment';
|
||||
import { useActiveWorkspace } from '../../../hooks/useActiveWorkspace';
|
||||
import { useSettings } from '../../../hooks/useSettings';
|
||||
import { IconButton } from '../IconButton';
|
||||
import { HStack } from '../Stacks';
|
||||
import './Editor.css';
|
||||
@@ -85,11 +86,16 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
}: EditorProps,
|
||||
ref,
|
||||
) {
|
||||
const s = useSettings();
|
||||
const e = useActiveEnvironment();
|
||||
const w = useActiveWorkspace();
|
||||
const environment = autocompleteVariables ? e : null;
|
||||
const workspace = autocompleteVariables ? w : null;
|
||||
|
||||
if (s && wrapLines === undefined) {
|
||||
wrapLines = s.editorSoftWrap;
|
||||
}
|
||||
|
||||
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
|
||||
useImperativeHandle(ref, () => cm.current?.view);
|
||||
|
||||
@@ -189,7 +195,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
placeholderCompartment.current.of(
|
||||
placeholderExt(placeholderElFromText(placeholder ?? '')),
|
||||
),
|
||||
wrapLinesCompartment.current.of([]),
|
||||
wrapLinesCompartment.current.of(wrapLines ? [EditorView.lineWrapping] : []),
|
||||
...getExtensions({
|
||||
container,
|
||||
readOnly,
|
||||
@@ -206,7 +212,6 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
|
||||
view = new EditorView({ state, parent: container });
|
||||
cm.current = { view, languageCompartment };
|
||||
syncGutterBg({ parent: container, bgClassList });
|
||||
if (autoFocus) {
|
||||
view.focus();
|
||||
}
|
||||
@@ -270,7 +275,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
ref={initEditorRef}
|
||||
className={classNames(
|
||||
className,
|
||||
'cm-wrapper text-base bg-gray-50',
|
||||
'cm-wrapper text-base',
|
||||
type === 'password' && 'cm-obscure-text',
|
||||
heightMode === 'auto' ? 'cm-auto-height' : 'cm-full-height',
|
||||
singleLine ? 'cm-singleline' : 'cm-multiline',
|
||||
@@ -284,12 +289,11 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group relative h-full w-full">
|
||||
<div className="group relative h-full w-full x-theme-editor bg-background">
|
||||
{cmContainer}
|
||||
{decoratedActions && (
|
||||
<HStack
|
||||
space={1}
|
||||
alignItems="center"
|
||||
justifyContent="end"
|
||||
className={classNames(
|
||||
'absolute bottom-2 left-0 right-0',
|
||||
@@ -327,7 +331,25 @@ function getExtensions({
|
||||
undefined;
|
||||
|
||||
return [
|
||||
// NOTE: These *must* be anonymous functions so the references update properly
|
||||
...baseExtensions, // Must be first
|
||||
tooltips({ parent }),
|
||||
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
|
||||
...(singleLine ? [singleLineExt()] : []),
|
||||
...(!singleLine ? [multiLineExtensions] : []),
|
||||
...(readOnly
|
||||
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
|
||||
: []),
|
||||
|
||||
// ------------------------ //
|
||||
// Things that must be last //
|
||||
// ------------------------ //
|
||||
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (onChange && update.docChanged) {
|
||||
onChange.current?.(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
onFocus.current?.();
|
||||
@@ -342,38 +364,9 @@ function getExtensions({
|
||||
onPaste.current?.(e.clipboardData?.getData('text/plain') ?? '');
|
||||
},
|
||||
}),
|
||||
tooltips({ parent }),
|
||||
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
|
||||
...(singleLine ? [singleLineExt()] : []),
|
||||
...(!singleLine ? [multiLineExtensions] : []),
|
||||
...(readOnly
|
||||
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
|
||||
: []),
|
||||
|
||||
// Handle onChange
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (onChange && update.docChanged) {
|
||||
onChange.current?.(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
|
||||
...baseExtensions,
|
||||
];
|
||||
}
|
||||
|
||||
const syncGutterBg = ({
|
||||
parent,
|
||||
bgClassList,
|
||||
}: {
|
||||
parent: HTMLDivElement;
|
||||
bgClassList: string[];
|
||||
}) => {
|
||||
const gutterEl = parent.querySelector<HTMLDivElement>('.cm-gutters');
|
||||
if (gutterEl) {
|
||||
gutterEl?.classList.add(...bgClassList);
|
||||
}
|
||||
};
|
||||
|
||||
const placeholderElFromText = (text: string) => {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = text.replace('\n', '<br/>');
|
||||
|
||||
@@ -39,51 +39,29 @@ import { text } from './text/extension';
|
||||
import { twig } from './twig/extension';
|
||||
import { url } from './url/extension';
|
||||
|
||||
export const myHighlightStyle = HighlightStyle.define([
|
||||
export const syntaxHighlightStyle = HighlightStyle.define([
|
||||
{
|
||||
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
|
||||
color: 'hsl(var(--color-gray-600))',
|
||||
color: 'var(--fg-subtler)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
{
|
||||
tag: [t.paren],
|
||||
color: 'hsl(var(--color-gray-900))',
|
||||
tag: [t.paren, t.bracket, t.brace],
|
||||
color: 'var(--fg)',
|
||||
},
|
||||
{
|
||||
tag: [t.name, t.tagName, t.angleBracket, t.docString, t.number],
|
||||
color: 'hsl(var(--color-blue-600))',
|
||||
tag: [t.link, t.name, t.tagName, t.angleBracket, t.docString, t.number],
|
||||
color: 'var(--fg-info)',
|
||||
},
|
||||
{ tag: [t.variableName], color: 'hsl(var(--color-green-600))' },
|
||||
{ tag: [t.bool], color: 'hsl(var(--color-pink-600))' },
|
||||
{ tag: [t.attributeName, t.propertyName], color: 'hsl(var(--color-violet-600))' },
|
||||
{ tag: [t.attributeValue], color: 'hsl(var(--color-orange-600))' },
|
||||
{ tag: [t.string], color: 'hsl(var(--color-yellow-600))' },
|
||||
{ tag: [t.keyword, t.meta, t.operator], color: 'hsl(var(--color-red-600))' },
|
||||
{ tag: [t.variableName], color: 'var(--fg-success)' },
|
||||
{ tag: [t.bool], color: 'var(--fg-warning)' },
|
||||
{ tag: [t.attributeName, t.propertyName], color: 'var(--fg-primary)' },
|
||||
{ tag: [t.attributeValue], color: 'var(--fg-warning)' },
|
||||
{ tag: [t.string], color: 'var(--fg-notice)' },
|
||||
{ tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: 'var(--fg-danger)' },
|
||||
]);
|
||||
|
||||
const myTheme = EditorView.theme({}, { dark: true });
|
||||
|
||||
// export const defaultHighlightStyle = HighlightStyle.define([
|
||||
// { tag: t.meta, color: '#404740' },
|
||||
// { tag: t.link, textDecoration: 'underline' },
|
||||
// { tag: t.heading, textDecoration: 'underline', fontWeight: 'bold' },
|
||||
// { tag: t.emphasis, fontStyle: 'italic' },
|
||||
// { tag: t.strong, fontWeight: 'bold' },
|
||||
// { tag: t.strikethrough, textDecoration: 'line-through' },
|
||||
// { tag: t.keyword, color: '#708' },
|
||||
// { tag: [t.atom, t.bool, t.url, t.contentSeparator, t.labelName], color: '#219' },
|
||||
// { tag: [t.literal, t.inserted], color: '#164' },
|
||||
// { tag: [t.string, t.deleted], color: '#a11' },
|
||||
// { tag: [t.regexp, t.escape, t.special(t.string)], color: '#e40' },
|
||||
// { tag: t.definition(t.variableName), color: '#00f' },
|
||||
// { tag: t.local(t.variableName), color: '#30a' },
|
||||
// { tag: [t.typeName, t.namespace], color: '#085' },
|
||||
// { tag: t.className, color: '#167' },
|
||||
// { tag: [t.special(t.variableName), t.macroName], color: '#256' },
|
||||
// { tag: t.definition(t.propertyName), color: '#00c' },
|
||||
// { tag: t.comment, color: '#940' },
|
||||
// { tag: t.invalid, color: '#f00' },
|
||||
// ]);
|
||||
const syntaxTheme = EditorView.theme({}, { dark: true });
|
||||
|
||||
const syntaxExtensions: Record<string, LanguageSupport> = {
|
||||
'application/graphql': graphqlLanguageSupport(),
|
||||
@@ -123,14 +101,15 @@ export const baseExtensions = [
|
||||
dropCursor(),
|
||||
drawSelection(),
|
||||
autocompletion({
|
||||
tooltipClass: () => 'x-theme-menu',
|
||||
closeOnBlur: true, // Set to `false` for debugging in devtools without closing it
|
||||
compareCompletions: (a, b) => {
|
||||
// Don't sort completions at all, only on boost
|
||||
return (a.boost ?? 0) - (b.boost ?? 0);
|
||||
},
|
||||
}),
|
||||
syntaxHighlighting(myHighlightStyle),
|
||||
myTheme,
|
||||
syntaxHighlighting(syntaxHighlightStyle),
|
||||
syntaxTheme,
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
];
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@ export interface GenericCompletionConfig {
|
||||
/**
|
||||
* Complete options, always matching until the start of the line
|
||||
*/
|
||||
export function genericCompletion({ options, minMatch = 1 }: GenericCompletionConfig) {
|
||||
export function genericCompletion(config?: GenericCompletionConfig) {
|
||||
if (config == null) return [];
|
||||
|
||||
const { minMatch = 1, options } = config;
|
||||
|
||||
return function completions(context: CompletionContext) {
|
||||
const toMatch = context.matchBefore(/.*/);
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { LanguageSupport } from '@codemirror/language';
|
||||
import { LRLanguage } from '@codemirror/language';
|
||||
import { parseMixed } from '@lezer/common';
|
||||
import type { Environment, Workspace } from '../../../../lib/models';
|
||||
import type { GenericCompletionConfig } from '../genericCompletion';
|
||||
import { genericCompletion } from '../genericCompletion';
|
||||
import { placeholders } from './placeholder';
|
||||
import { textLanguageName } from '../text/extension';
|
||||
import { twigCompletion } from './completion';
|
||||
import { placeholders } from './placeholder';
|
||||
import { parser as twigParser } from './twig';
|
||||
import type { Environment, Workspace } from '../../../../lib/models';
|
||||
|
||||
export function twig(
|
||||
base: LanguageSupport,
|
||||
@@ -15,25 +15,19 @@ export function twig(
|
||||
workspace: Workspace | null,
|
||||
autocomplete?: GenericCompletionConfig,
|
||||
) {
|
||||
const variables =
|
||||
[...(workspace?.variables ?? []), ...(environment?.variables ?? [])].filter((v) => v.enabled) ??
|
||||
[];
|
||||
const completions = twigCompletion({ options: variables });
|
||||
|
||||
const language = mixLanguage(base);
|
||||
const completion = language.data.of({ autocomplete: completions });
|
||||
const completionBase = base.language.data.of({ autocomplete: completions });
|
||||
const additionalCompletion = autocomplete
|
||||
? [base.language.data.of({ autocomplete: genericCompletion(autocomplete) })]
|
||||
: [];
|
||||
const allVariables = [...(workspace?.variables ?? []), ...(environment?.variables ?? [])];
|
||||
const variables = allVariables.filter((v) => v.enabled) ?? [];
|
||||
const completions = twigCompletion({ options: variables });
|
||||
|
||||
return [
|
||||
language,
|
||||
completion,
|
||||
completionBase,
|
||||
base.support,
|
||||
placeholders(variables),
|
||||
...additionalCompletion,
|
||||
language.data.of({ autocomplete: completions }),
|
||||
base.language.data.of({ autocomplete: completions }),
|
||||
language.data.of({ autocomplete: genericCompletion(autocomplete) }),
|
||||
base.language.data.of({ autocomplete: genericCompletion(autocomplete) }),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ class PlaceholderWidget extends WidgetType {
|
||||
}
|
||||
toDOM() {
|
||||
const elt = document.createElement('span');
|
||||
elt.className = `placeholder-widget ${
|
||||
!this.isExistingVariable ? 'placeholder-widget-error' : ''
|
||||
elt.className = `x-theme-placeholder placeholder ${
|
||||
this.isExistingVariable ? 'x-theme-placeholder--primary' : 'x-theme-placeholder--danger'
|
||||
}`;
|
||||
elt.title = !this.isExistingVariable ? 'Variable not found in active environment' : '';
|
||||
elt.textContent = this.name;
|
||||
|
||||
Reference in New Issue
Block a user