mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-18 23:09:47 +02:00
Pair checkboxes and fix twig indent
This commit is contained in:
80
package-lock.json
generated
80
package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"@lezer/generator": "^1.2.2",
|
"@lezer/generator": "^1.2.2",
|
||||||
"@lezer/highlight": "^1.1.3",
|
"@lezer/highlight": "^1.1.3",
|
||||||
"@lezer/lr": "^1.3.3",
|
"@lezer/lr": "^1.3.3",
|
||||||
|
"@radix-ui/react-checkbox": "^1.0.3",
|
||||||
"@radix-ui/react-dialog": "^1.0.2",
|
"@radix-ui/react-dialog": "^1.0.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.2",
|
"@radix-ui/react-dropdown-menu": "^2.0.2",
|
||||||
"@radix-ui/react-icons": "^1.2.0",
|
"@radix-ui/react-icons": "^1.2.0",
|
||||||
@@ -1276,6 +1277,39 @@
|
|||||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-checkbox": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-55B8/vKzTuzxllH5sGJO4zaBf9gYpJuJRRzaOKm+0oAefRnMvbf+Kgww7IOANVN0w3z7agFJgtnXaZl8Uj95AA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/primitive": "1.0.0",
|
||||||
|
"@radix-ui/react-compose-refs": "1.0.0",
|
||||||
|
"@radix-ui/react-context": "1.0.0",
|
||||||
|
"@radix-ui/react-presence": "1.0.0",
|
||||||
|
"@radix-ui/react-primitive": "1.0.2",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.0.0",
|
||||||
|
"@radix-ui/react-use-previous": "1.0.0",
|
||||||
|
"@radix-ui/react-use-size": "1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-slot": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-collection": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.1.tgz",
|
||||||
@@ -1735,6 +1769,17 @@
|
|||||||
"react": "^16.8 || ^17.0 || ^18.0"
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-previous": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-RG2K8z/K7InnOKpq6YLDmT49HGjNmrK+fr82UCVKT2sW0GYfVnYp4wZWBooT/EYfQ5faA9uIjvsuMMhH61rheg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-rect": {
|
"node_modules/@radix-ui/react-use-rect": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.0.0.tgz",
|
||||||
@@ -8930,6 +8975,33 @@
|
|||||||
"@radix-ui/react-primitive": "1.0.1"
|
"@radix-ui/react-primitive": "1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@radix-ui/react-checkbox": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-55B8/vKzTuzxllH5sGJO4zaBf9gYpJuJRRzaOKm+0oAefRnMvbf+Kgww7IOANVN0w3z7agFJgtnXaZl8Uj95AA==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/primitive": "1.0.0",
|
||||||
|
"@radix-ui/react-compose-refs": "1.0.0",
|
||||||
|
"@radix-ui/react-context": "1.0.0",
|
||||||
|
"@radix-ui/react-presence": "1.0.0",
|
||||||
|
"@radix-ui/react-primitive": "1.0.2",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.0.0",
|
||||||
|
"@radix-ui/react-use-previous": "1.0.0",
|
||||||
|
"@radix-ui/react-use-size": "1.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-slot": "1.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"@radix-ui/react-collection": {
|
"@radix-ui/react-collection": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.1.tgz",
|
||||||
@@ -9287,6 +9359,14 @@
|
|||||||
"@babel/runtime": "^7.13.10"
|
"@babel/runtime": "^7.13.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@radix-ui/react-use-previous": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-RG2K8z/K7InnOKpq6YLDmT49HGjNmrK+fr82UCVKT2sW0GYfVnYp4wZWBooT/EYfQ5faA9uIjvsuMMhH61rheg==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@radix-ui/react-use-rect": {
|
"@radix-ui/react-use-rect": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.0.0.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@lezer/generator": "^1.2.2",
|
"@lezer/generator": "^1.2.2",
|
||||||
"@lezer/highlight": "^1.1.3",
|
"@lezer/highlight": "^1.1.3",
|
||||||
"@lezer/lr": "^1.3.3",
|
"@lezer/lr": "^1.3.3",
|
||||||
|
"@radix-ui/react-checkbox": "^1.0.3",
|
||||||
"@radix-ui/react-dialog": "^1.0.2",
|
"@radix-ui/react-dialog": "^1.0.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.2",
|
"@radix-ui/react-dropdown-menu": "^2.0.2",
|
||||||
"@radix-ui/react-icons": "^1.2.0",
|
"@radix-ui/react-icons": "^1.2.0",
|
||||||
|
|||||||
Binary file not shown.
@@ -18,6 +18,8 @@ pub struct Workspace {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct HttpRequestHeader {
|
pub struct HttpRequestHeader {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub value: String,
|
pub value: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
|
||||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||||
import { useKeyValue } from '../hooks/useKeyValue';
|
import { useKeyValue } from '../hooks/useKeyValue';
|
||||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||||
import { tryFormatJson } from '../lib/formatters';
|
import { tryFormatJson } from '../lib/formatters';
|
||||||
import type { HttpHeader } from '../lib/models';
|
import type { HttpHeader } from '../lib/models';
|
||||||
import { Editor } from './core/Editor';
|
import { Editor } from './core/Editor';
|
||||||
import { PairEditor } from './core/PairEditor';
|
|
||||||
import type { TabItem } from './core/Tabs/Tabs';
|
import type { TabItem } from './core/Tabs/Tabs';
|
||||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||||
import { GraphQLEditor } from './GraphQLEditor';
|
import { GraphQLEditor } from './GraphQLEditor';
|
||||||
@@ -26,7 +24,7 @@ export function RequestPane({ fullHeight, className }: Props) {
|
|||||||
const updateRequest = useUpdateRequest(activeRequestId);
|
const updateRequest = useUpdateRequest(activeRequestId);
|
||||||
const activeTab = useKeyValue<string>({
|
const activeTab = useKeyValue<string>({
|
||||||
key: ['active_request_body_tab', activeRequestId ?? 'n/a'],
|
key: ['active_request_body_tab', activeRequestId ?? 'n/a'],
|
||||||
initialValue: 'body',
|
defaultValue: 'body',
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabs: TabItem[] = useMemo(
|
const tabs: TabItem[] = useMemo(
|
||||||
@@ -40,6 +38,7 @@ export function RequestPane({ fullHeight, className }: Props) {
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'No Body', value: 'nobody' },
|
{ label: 'No Body', value: 'nobody' },
|
||||||
{ label: 'JSON', value: 'json' },
|
{ label: 'JSON', value: 'json' },
|
||||||
|
{ label: 'XML', value: 'xml' },
|
||||||
{ label: 'GraphQL', value: 'graphql' },
|
{ label: 'GraphQL', value: 'graphql' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -57,53 +56,65 @@ export function RequestPane({ fullHeight, className }: Props) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (activeRequest === null) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classnames(className, 'py-3 grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}>
|
<div className={classnames(className, 'py-3 grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}>
|
||||||
<UrlBar className="pl-3" request={activeRequest} />
|
{activeRequest && (
|
||||||
<Tabs
|
<>
|
||||||
value={activeTab.value}
|
<UrlBar className="pl-3" request={activeRequest} />
|
||||||
onChangeValue={activeTab.set}
|
<Tabs
|
||||||
tabs={tabs}
|
value={activeTab.value}
|
||||||
className="mt-2"
|
onChangeValue={activeTab.set}
|
||||||
tabListClassName="pl-3"
|
tabs={tabs}
|
||||||
label="Request body"
|
className="mt-2"
|
||||||
>
|
tabListClassName="pl-3"
|
||||||
<TabContent value="headers">
|
label="Request body"
|
||||||
<HeaderEditor
|
>
|
||||||
key={activeRequestId}
|
<TabContent value="headers">
|
||||||
headers={activeRequest.headers}
|
<HeaderEditor
|
||||||
onChange={handleHeadersChange}
|
key={activeRequestId}
|
||||||
/>
|
headers={activeRequest.headers}
|
||||||
</TabContent>
|
onChange={handleHeadersChange}
|
||||||
<TabContent value="params">
|
/>
|
||||||
<ParametersEditor key={activeRequestId} parameters={[]} onChange={() => null} />
|
</TabContent>
|
||||||
</TabContent>
|
<TabContent value="params">
|
||||||
<TabContent value="body" className="pl-3 mt-1">
|
<ParametersEditor key={activeRequestId} parameters={[]} onChange={() => null} />
|
||||||
{activeRequest.bodyType === 'json' ? (
|
</TabContent>
|
||||||
<Editor
|
<TabContent value="body" className="pl-3 mt-1">
|
||||||
key={activeRequest.id}
|
{activeRequest.bodyType === 'json' ? (
|
||||||
useTemplating
|
<Editor
|
||||||
className="!bg-gray-50"
|
key={activeRequest.id}
|
||||||
heightMode={fullHeight ? 'full' : 'auto'}
|
useTemplating
|
||||||
defaultValue={activeRequest.body ?? ''}
|
className="!bg-gray-50"
|
||||||
contentType="application/json"
|
heightMode={fullHeight ? 'full' : 'auto'}
|
||||||
onChange={handleBodyChange}
|
defaultValue={activeRequest.body ?? ''}
|
||||||
format={activeRequest.bodyType === 'json' ? (v) => tryFormatJson(v) : undefined}
|
contentType="application/json"
|
||||||
/>
|
onChange={handleBodyChange}
|
||||||
) : activeRequest.bodyType === 'graphql' ? (
|
format={(v) => tryFormatJson(v)}
|
||||||
<GraphQLEditor
|
/>
|
||||||
key={activeRequest.id}
|
) : activeRequest.bodyType === 'xml' ? (
|
||||||
className="!bg-gray-50"
|
<Editor
|
||||||
defaultValue={activeRequest?.body ?? ''}
|
key={activeRequest.id}
|
||||||
onChange={handleBodyChange}
|
useTemplating
|
||||||
/>
|
className="!bg-gray-50"
|
||||||
) : (
|
heightMode={fullHeight ? 'full' : 'auto'}
|
||||||
<div className="h-full text-gray-400 flex items-center justify-center">No Body</div>
|
defaultValue={activeRequest.body ?? ''}
|
||||||
)}
|
contentType="text/xml"
|
||||||
</TabContent>
|
onChange={handleBodyChange}
|
||||||
</Tabs>
|
/>
|
||||||
|
) : activeRequest.bodyType === 'graphql' ? (
|
||||||
|
<GraphQLEditor
|
||||||
|
key={activeRequest.id}
|
||||||
|
className="!bg-gray-50"
|
||||||
|
defaultValue={activeRequest?.body ?? ''}
|
||||||
|
onChange={handleBodyChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-full text-gray-400 flex items-center justify-center">No Body</div>
|
||||||
|
)}
|
||||||
|
</TabContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ const _SidebarItem = forwardRef(function SidebarItem(
|
|||||||
className={classnames(
|
className={classnames(
|
||||||
buttonClassName,
|
buttonClassName,
|
||||||
'w-full',
|
'w-full',
|
||||||
editing && 'focus-within:border-blue-400/40',
|
editing && 'focus-within:border-focus',
|
||||||
active
|
active
|
||||||
? 'bg-gray-200/70 text-gray-900'
|
? 'bg-gray-200/70 text-gray-900'
|
||||||
: 'text-gray-600 group-hover/item:text-gray-800 active:bg-gray-200/30',
|
: 'text-gray-600 group-hover/item:text-gray-800 active:bg-gray-200/30',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
||||||
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||||
@@ -24,7 +24,10 @@ export function WorkspaceDropdown({ className }: Props) {
|
|||||||
label: w.name,
|
label: w.name,
|
||||||
value: w.id,
|
value: w.id,
|
||||||
leftSlot: activeWorkspace?.id === w.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
leftSlot: activeWorkspace?.id === w.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||||
onSelect: () => navigate(`/workspaces/${w.id}`),
|
onSelect: () => {
|
||||||
|
if (w.id === activeWorkspace?.id) return;
|
||||||
|
navigate(`/workspaces/${w.id}`);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
35
src-web/components/core/Checkbox.tsx
Normal file
35
src-web/components/core/Checkbox.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { CheckedState } from '@radix-ui/react-checkbox';
|
||||||
|
import * as CB from '@radix-ui/react-checkbox';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
checked: CheckedState;
|
||||||
|
onChange: (checked: CheckedState) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Checkbox({ checked, onChange, className, disabled }: Props) {
|
||||||
|
return (
|
||||||
|
<CB.Root
|
||||||
|
disabled={disabled}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
className={classnames(
|
||||||
|
className,
|
||||||
|
'w-5 h-5 border border-gray-200 rounded',
|
||||||
|
'focus:border-focus',
|
||||||
|
'disabled:opacity-disabled',
|
||||||
|
'outline-none',
|
||||||
|
checked && 'bg-gray-200/10',
|
||||||
|
// Remove focus style
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CB.Indicator className="flex items-center justify-center">
|
||||||
|
{checked === 'indeterminate' && <Icon icon="dividerH" />}
|
||||||
|
{checked === true && <Icon icon="check" />}
|
||||||
|
</CB.Indicator>
|
||||||
|
</CB.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -182,7 +182,7 @@ const DropdownMenuItem = memo(function DropdownMenuItem({
|
|||||||
<D.Item
|
<D.Item
|
||||||
asChild
|
asChild
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={classnames(className, disabled && 'opacity-30')}
|
className={classnames(className, disabled && 'opacity-disabled')}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
|
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { tags as t } from '@lezer/highlight';
|
import { tags as t } from '@lezer/highlight';
|
||||||
import { graphqlLanguageSupport } from 'cm6-graphql';
|
import { graphqlLanguageSupport } from 'cm6-graphql';
|
||||||
import type { GenericCompletionOption } from './genericCompletion';
|
|
||||||
import type { EditorProps } from './index';
|
import type { EditorProps } from './index';
|
||||||
import { text } from './text/extension';
|
import { text } from './text/extension';
|
||||||
import { twig } from './twig/extension';
|
import { twig } from './twig/extension';
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||||
import { parser } from './text';
|
import { parser } from './text';
|
||||||
|
|
||||||
|
export const textLanguageName = 'text';
|
||||||
|
|
||||||
const textLanguage = LRLanguage.define({
|
const textLanguage = LRLanguage.define({
|
||||||
name: 'text',
|
name: textLanguageName,
|
||||||
parser,
|
parser,
|
||||||
languageData: {},
|
languageData: {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,47 +1,53 @@
|
|||||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
import type { LanguageSupport } from '@codemirror/language';
|
||||||
|
import { LRLanguage } from '@codemirror/language';
|
||||||
import { parseMixed } from '@lezer/common';
|
import { parseMixed } from '@lezer/common';
|
||||||
import type { GenericCompletionConfig } from '../genericCompletion';
|
import type { GenericCompletionConfig } from '../genericCompletion';
|
||||||
import { genericCompletion } from '../genericCompletion';
|
import { genericCompletion } from '../genericCompletion';
|
||||||
|
import { textLanguageName } from '../text/extension';
|
||||||
import { placeholders } from '../widgets';
|
import { placeholders } from '../widgets';
|
||||||
import { completions } from './completion';
|
import { completions } from './completion';
|
||||||
import { parser as twigParser } from './twig';
|
import { parser as twigParser } from './twig';
|
||||||
|
|
||||||
export function twig(base?: LanguageSupport, autocomplete?: GenericCompletionConfig) {
|
export function twig(base: LanguageSupport, autocomplete?: GenericCompletionConfig) {
|
||||||
const language = mixedOrPlainLanguage(base);
|
const language = mixLanguage(base);
|
||||||
const additionalCompletion =
|
const additionalCompletion = autocomplete
|
||||||
autocomplete && base
|
? [language.data.of({ autocomplete: genericCompletion(autocomplete) })]
|
||||||
? [language.data.of({ autocomplete: genericCompletion(autocomplete) })]
|
: [];
|
||||||
: [];
|
|
||||||
const completion = language.data.of({
|
const completion = language.data.of({
|
||||||
autocomplete: completions,
|
autocomplete: completions,
|
||||||
});
|
});
|
||||||
const languageSupport = new LanguageSupport(language, [completion, ...additionalCompletion]);
|
|
||||||
|
|
||||||
if (base) {
|
if (base) {
|
||||||
const completion2 = base.language.data.of({ autocomplete: completions });
|
const completionBase = base.language.data.of({
|
||||||
const languageSupport2 = new LanguageSupport(base.language, [completion2]);
|
autocomplete: completions,
|
||||||
return [languageSupport, languageSupport2, base.support];
|
});
|
||||||
|
return [
|
||||||
|
language,
|
||||||
|
completion,
|
||||||
|
completionBase,
|
||||||
|
base.support,
|
||||||
|
// placeholders,
|
||||||
|
...additionalCompletion,
|
||||||
|
];
|
||||||
} else {
|
} else {
|
||||||
return [languageSupport];
|
return [language, completion, placeholders];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mixedOrPlainLanguage(base?: LanguageSupport): LRLanguage {
|
function mixLanguage(base: LanguageSupport): LRLanguage {
|
||||||
const name = 'twig';
|
const name = 'twig';
|
||||||
|
|
||||||
if (!base) {
|
|
||||||
return LRLanguage.define({ name, parser: twigParser });
|
|
||||||
}
|
|
||||||
|
|
||||||
const parser = twigParser.configure({
|
const parser = twigParser.configure({
|
||||||
wrap: parseMixed((node) => {
|
wrap: parseMixed((node) => {
|
||||||
|
console.log('HELLO', node.type.name, node.type.isTop);
|
||||||
// If the base language is text, we can overwrite at the top
|
// If the base language is text, we can overwrite at the top
|
||||||
if (base.language.name !== 'text' && !node.type.isTop) {
|
if (base.language.name !== textLanguageName && !node.type.isTop) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
parser: base.language.parser,
|
parser: base.language.parser,
|
||||||
overlay: (node) => node.type.name === 'Text' || node.type.name === 'Template',
|
overlay: (node) => node.type.name === 'Text',
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { styleTags, tags as t } from '@lezer/highlight';
|
import { styleTags, tags as t } from '@lezer/highlight';
|
||||||
|
|
||||||
export const highlight = styleTags({
|
export const highlight = styleTags({
|
||||||
Open: t.meta,
|
Open: t.tagName,
|
||||||
Close: t.meta,
|
Close: t.tagName,
|
||||||
Content: t.comment,
|
Content: t.keyword,
|
||||||
Template: t.comment,
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
@top Template { Tag | Text }
|
@top Template { (Tag | Text)* }
|
||||||
|
|
||||||
@local tokens {
|
@local tokens {
|
||||||
Close { "]}" }
|
Close { "]}" }
|
||||||
@else Content
|
@else Content
|
||||||
}
|
}
|
||||||
|
|
||||||
@skip {} {
|
@skip { } {
|
||||||
Open { "${[" }
|
Open { "${[" }
|
||||||
Tag { Open (Content)+ Close }
|
Tag { Open (Content)+ Close }
|
||||||
}
|
}
|
||||||
|
|
||||||
@tokens {
|
@tokens {
|
||||||
Text { _ }
|
Text { ![$] Text? }
|
||||||
}
|
}
|
||||||
|
|
||||||
@external propSource highlight from "./highlight"
|
@external propSource highlight from "./highlight"
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ import {LRParser, LocalTokenGroup} from "@lezer/lr"
|
|||||||
import {highlight} from "./highlight"
|
import {highlight} from "./highlight"
|
||||||
export const parser = LRParser.deserialize({
|
export const parser = LRParser.deserialize({
|
||||||
version: 14,
|
version: 14,
|
||||||
states: "!QOQOPOOOOOO'#C_'#C_OYOQO'#C^QOOOOOOOOO'#Cc'#CcO_OQO,58xOOOO-E6a-E6aOOOO1G.d1G.d",
|
states: "!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d",
|
||||||
stateData: "g~OUROXPO~OSSO~OSSOTVO~O",
|
stateData: "g~OUROYPO~OSTO~OSTOTXO~O",
|
||||||
goto: "eWPPX[PPP_RRORQOQTQRUT",
|
goto: "nXPPY^PPPbhTROSTQOSQSORVSQUQRWU",
|
||||||
nodeNames: "⚠ Template Tag Open Content Close Text",
|
nodeNames: "⚠ Template Tag Open Content Close Text",
|
||||||
maxTerm: 9,
|
maxTerm: 10,
|
||||||
propSources: [highlight],
|
propSources: [highlight],
|
||||||
skippedNodes: [0],
|
skippedNodes: [0],
|
||||||
repeatNodeCount: 1,
|
repeatNodeCount: 2,
|
||||||
tokenData: "!S~RTOtbtugu;'Sb;'S;=`z;=`Ob~gOU~~lPU~#o#po~rP!}#Ou~zOX~~!PPU~;=`<%lb",
|
tokenData: "![~RTOtbtuyu;'Sb;'S;=`s<%lOb~gSU~Otbu;'Sb;'S;=`s<%lOb~vP;=`<%lb~|P#o#p!P~!SP!}#O!V~![OY~",
|
||||||
tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)],
|
tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)],
|
||||||
topRules: {"Template":[0,1]},
|
topRules: {"Template":[0,1]},
|
||||||
tokenPrec: 0
|
tokenPrec: 0
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
CameraIcon,
|
CameraIcon,
|
||||||
|
CheckboxIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
ColorWheelIcon,
|
ColorWheelIcon,
|
||||||
Cross2Icon,
|
Cross2Icon,
|
||||||
|
DividerHorizontalIcon,
|
||||||
DotsHorizontalIcon,
|
DotsHorizontalIcon,
|
||||||
DotsVerticalIcon,
|
DotsVerticalIcon,
|
||||||
DragHandleDots2Icon,
|
DragHandleDots2Icon,
|
||||||
@@ -35,6 +37,7 @@ const icons = {
|
|||||||
archive: ArchiveIcon,
|
archive: ArchiveIcon,
|
||||||
camera: CameraIcon,
|
camera: CameraIcon,
|
||||||
check: CheckIcon,
|
check: CheckIcon,
|
||||||
|
checkbox: CheckboxIcon,
|
||||||
clock: ClockIcon,
|
clock: ClockIcon,
|
||||||
code: CodeIcon,
|
code: CodeIcon,
|
||||||
colorWheel: ColorWheelIcon,
|
colorWheel: ColorWheelIcon,
|
||||||
@@ -50,6 +53,7 @@ const icons = {
|
|||||||
moon: MoonIcon,
|
moon: MoonIcon,
|
||||||
paperPlane: PaperPlaneIcon,
|
paperPlane: PaperPlaneIcon,
|
||||||
plus: PlusIcon,
|
plus: PlusIcon,
|
||||||
|
dividerH: DividerHorizontalIcon,
|
||||||
plusCircle: PlusCircledIcon,
|
plusCircle: PlusCircledIcon,
|
||||||
question: QuestionMarkIcon,
|
question: QuestionMarkIcon,
|
||||||
rows: RowsIcon,
|
rows: RowsIcon,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function Input({
|
|||||||
className={classnames(
|
className={classnames(
|
||||||
containerClassName,
|
containerClassName,
|
||||||
'relative w-full rounded-md text-gray-900',
|
'relative w-full rounded-md text-gray-900',
|
||||||
'border border-gray-200 focus-within:border-blue-400/40',
|
'border border-gray-200 focus-within:border-focus',
|
||||||
size === 'md' && 'h-9',
|
size === 'md' && 'h-9',
|
||||||
size === 'sm' && 'h-7',
|
size === 'sm' && 'h-7',
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import type { CheckedState } from '@radix-ui/react-checkbox';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import React, { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { XYCoord } from 'react-dnd';
|
import type { XYCoord } from 'react-dnd';
|
||||||
import { useDrag, useDrop } from 'react-dnd';
|
import { useDrag, useDrop } from 'react-dnd';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { DropMarker } from '../DropMarker';
|
import { DropMarker } from '../DropMarker';
|
||||||
|
import { Checkbox } from './Checkbox';
|
||||||
import type { GenericCompletionConfig } from './Editor/genericCompletion';
|
import type { GenericCompletionConfig } from './Editor/genericCompletion';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
@@ -20,6 +22,7 @@ export type PairEditorProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type Pair = {
|
type Pair = {
|
||||||
|
enabled?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
@@ -84,20 +87,17 @@ export const PairEditor = memo(function PairEditor({
|
|||||||
[hoveredIndex],
|
[hoveredIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChangeHeader = useCallback((pair: PairContainer) => {
|
const handleChange = useCallback(
|
||||||
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair)));
|
(pair: PairContainer) =>
|
||||||
}, []);
|
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Ensure there's always at least one pair
|
const handleDelete = useCallback(
|
||||||
useEffect(() => {
|
(pair: PairContainer) =>
|
||||||
if (pairs.length === 0) {
|
setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id)),
|
||||||
setPairs((pairs) => [...pairs, newPairContainer()]);
|
[],
|
||||||
}
|
);
|
||||||
}, [pairs]);
|
|
||||||
|
|
||||||
const handleDelete = useCallback((pair: PairContainer) => {
|
|
||||||
setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFocus = useCallback(
|
const handleFocus = useCallback(
|
||||||
(pair: PairContainer) => {
|
(pair: PairContainer) => {
|
||||||
@@ -109,6 +109,13 @@ export const PairEditor = memo(function PairEditor({
|
|||||||
[pairs],
|
[pairs],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Ensure there's always at least one pair
|
||||||
|
useEffect(() => {
|
||||||
|
if (pairs.length === 0) {
|
||||||
|
setPairs((pairs) => [...pairs, newPairContainer()]);
|
||||||
|
}
|
||||||
|
}, [pairs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classnames(
|
className={classnames(
|
||||||
@@ -126,11 +133,11 @@ export const PairEditor = memo(function PairEditor({
|
|||||||
<FormRow
|
<FormRow
|
||||||
pairContainer={p}
|
pairContainer={p}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
onChange={handleChangeHeader}
|
|
||||||
nameAutocomplete={nameAutocomplete}
|
nameAutocomplete={nameAutocomplete}
|
||||||
valueAutocomplete={valueAutocomplete}
|
valueAutocomplete={valueAutocomplete}
|
||||||
namePlaceholder={namePlaceholder}
|
namePlaceholder={namePlaceholder}
|
||||||
valuePlaceholder={valuePlaceholder}
|
valuePlaceholder={valuePlaceholder}
|
||||||
|
onChange={handleChange}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onDelete={isLast ? undefined : handleDelete}
|
onDelete={isLast ? undefined : handleDelete}
|
||||||
onEnd={handleEnd}
|
onEnd={handleEnd}
|
||||||
@@ -177,14 +184,20 @@ const FormRow = memo(function FormRow({
|
|||||||
const { id } = pairContainer;
|
const { id } = pairContainer;
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleChangeEnabled = useMemo(
|
||||||
|
() => (enabled: CheckedState) =>
|
||||||
|
onChange({ id, pair: { ...pairContainer.pair, enabled: !!enabled } }),
|
||||||
|
[onChange, pairContainer.pair.name, pairContainer.pair.value],
|
||||||
|
);
|
||||||
|
|
||||||
const handleChangeName = useMemo(
|
const handleChangeName = useMemo(
|
||||||
() => (name: string) => onChange({ id, pair: { name, value: pairContainer.pair.value } }),
|
() => (name: string) => onChange({ id, pair: { ...pairContainer.pair, name } }),
|
||||||
[onChange, pairContainer.pair.value],
|
[onChange, pairContainer.pair.value, pairContainer.pair.enabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChangeValue = useMemo(
|
const handleChangeValue = useMemo(
|
||||||
() => (value: string) => onChange({ id, pair: { value, name: pairContainer.pair.name } }),
|
() => (value: string) => onChange({ id, pair: { ...pairContainer.pair, value } }),
|
||||||
[onChange, pairContainer.pair.name],
|
[onChange, pairContainer.pair.name, pairContainer.pair.enabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nameEditorConfig = useMemo(
|
const nameEditorConfig = useMemo(
|
||||||
@@ -231,7 +244,11 @@ const FormRow = memo(function FormRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="pb-2 group grid grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)_auto] grid-rows-1 gap-2 items-center"
|
className={classnames(
|
||||||
|
'pb-2 group grid grid-cols-[auto_auto_minmax(0,1fr)_minmax(0,1fr)_auto]',
|
||||||
|
'grid-rows-1 gap-2 items-center',
|
||||||
|
!pairContainer.pair.enabled && 'opacity-60',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{!isLast ? (
|
{!isLast ? (
|
||||||
<div
|
<div
|
||||||
@@ -245,6 +262,12 @@ const FormRow = memo(function FormRow({
|
|||||||
) : (
|
) : (
|
||||||
<span className="w-1" />
|
<span className="w-1" />
|
||||||
)}
|
)}
|
||||||
|
<Checkbox
|
||||||
|
disabled={isLast}
|
||||||
|
checked={!!pairContainer.pair.enabled}
|
||||||
|
onChange={handleChangeEnabled}
|
||||||
|
className={isLast ? '!opacity-disabled' : undefined}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
hideLabel
|
hideLabel
|
||||||
containerClassName={classnames(isLast && 'border-dashed')}
|
containerClassName={classnames(isLast && 'border-dashed')}
|
||||||
@@ -283,5 +306,5 @@ const FormRow = memo(function FormRow({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const newPairContainer = (pair?: Pair): PairContainer => {
|
const newPairContainer = (pair?: Pair): PairContainer => {
|
||||||
return { pair: pair ?? { name: '', value: '' }, id: uuid() };
|
return { pair: pair ?? { name: '', value: '', enabled: true }, id: uuid() };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export type TabItem = {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label: string;
|
label: string;
|
||||||
|
value?: string;
|
||||||
onChangeValue: (value: string) => void;
|
onChangeValue: (value: string) => void;
|
||||||
value: string;
|
|
||||||
tabs: TabItem[];
|
tabs: TabItem[];
|
||||||
tabListClassName?: string;
|
tabListClassName?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -16,16 +16,15 @@ export function keyValueQueryKey({
|
|||||||
export function useKeyValue<T extends string | number | boolean>({
|
export function useKeyValue<T extends string | number | boolean>({
|
||||||
namespace = DEFAULT_NAMESPACE,
|
namespace = DEFAULT_NAMESPACE,
|
||||||
key,
|
key,
|
||||||
initialValue,
|
defaultValue,
|
||||||
}: {
|
}: {
|
||||||
namespace?: string;
|
namespace?: string;
|
||||||
key: string | string[];
|
key: string | string[];
|
||||||
initialValue: T;
|
defaultValue: T;
|
||||||
}) {
|
}) {
|
||||||
const query = useQuery<T>({
|
const query = useQuery<T>({
|
||||||
initialData: initialValue,
|
|
||||||
queryKey: keyValueQueryKey({ namespace, key }),
|
queryKey: keyValueQueryKey({ namespace, key }),
|
||||||
queryFn: async () => getKeyValue({ namespace, key, fallback: initialValue }),
|
queryFn: async () => getKeyValue({ namespace, key, fallback: defaultValue }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mutate = useMutation<T, unknown, T>({
|
const mutate = useMutation<T, unknown, T>({
|
||||||
@@ -34,6 +33,7 @@ export function useKeyValue<T extends string | number | boolean>({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
value: query.data,
|
value: query.data,
|
||||||
|
isLoading: query.isLoading,
|
||||||
set: (value: T) => mutate.mutate(value),
|
set: (value: T) => mutate.mutate(value),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useKeyValue } from './useKeyValue';
|
import { useKeyValue } from './useKeyValue';
|
||||||
|
|
||||||
export function useResponseViewMode(requestId?: string): [string, () => void] {
|
export function useResponseViewMode(requestId?: string): [string | undefined, () => void] {
|
||||||
const v = useKeyValue<string>({
|
const v = useKeyValue<string>({
|
||||||
namespace: 'app',
|
namespace: 'app',
|
||||||
key: ['response_view_mode', requestId ?? 'n/a'],
|
key: ['response_view_mode', requestId ?? 'n/a'],
|
||||||
initialValue: 'pretty',
|
defaultValue: 'pretty',
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface Workspace extends BaseModel {
|
|||||||
export interface HttpHeader {
|
export interface HttpHeader {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HttpRequest extends BaseModel {
|
export interface HttpRequest extends BaseModel {
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ module.exports = {
|
|||||||
"./src-web/**/*.{html,js,jsx,ts,tsx}"
|
"./src-web/**/*.{html,js,jsx,ts,tsx}"
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
opacity: {
|
||||||
|
'disabled': '0.3',
|
||||||
|
}
|
||||||
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
"mono": ["JetBrains Mono", "Menlo", "monospace"],
|
"mono": ["JetBrains Mono", "Menlo", "monospace"],
|
||||||
"sans": ["Inter", "sans-serif"],
|
"sans": ["Inter", "sans-serif"],
|
||||||
@@ -21,6 +25,7 @@ module.exports = {
|
|||||||
"5xl": "3.052rem"
|
"5xl": "3.052rem"
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
|
focus: "hsl(var(--color-blue-500) / 0.6)",
|
||||||
highlight: "hsl(var(--color-gray-200) / 0.3)",
|
highlight: "hsl(var(--color-gray-200) / 0.3)",
|
||||||
transparent: "transparent",
|
transparent: "transparent",
|
||||||
white: "hsl(0 100% 100% / <alpha-value>)",
|
white: "hsl(0 100% 100% / <alpha-value>)",
|
||||||
|
|||||||
Reference in New Issue
Block a user