mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-22 16:29:09 +01:00
[EC-457] Component library icon buttons (#3372)
* [EC-457] feat: initial version of icon button * [EC-457] feat: modify template and start adding inputs * [EC-457] feat: implement all styles * [EC-457] chore: cleanup * [EC-457] feat: fix hover styles after discussions * [EC-457] feat: add focus ring workaround * [EC-457] chore: refactor stories a bit * [EC-457] fix: button style attr name reserved word collision * [EC-356] feat: match padding with figma * [EC-457] feat: use icon button in banner * [EC-457] chore: cleanup css classes * [EC-457] feat: improve aria * [EC-457] feat: use icon button in dialog * [EC-457] fix: make focus and hover styles independent * [EC-457] fix: remove primary 500 border * [EC-457] chore: cleanup * [EC-457] chore: move css class to common list * [EC-457] fix: use focus-visible * [EC-457] chore: expand on workaround fix * [EC-457] fix: default sizing * [EC-457] fix: align trash icon right * [EC-457] fix: add missing aria labels * [EC-457] fix: add i18n service to banner tests * [EC-457] chore: rename size `default` to `button` * [EC-457] feat: double padding * [EC-457] feat: simplify sizes - update default * [EC-457] fix: revert selector fix - gonna create separate pr * [EC-457] chore: remove superfluous dependencies * [EC-457] fix: remove non-working onClose handler Removing this storybook action because we already test it as part of the dialog service stories. It requries mocking the dialogRef to capture the close function which makes this story more complex but adds very little value as we already test it elsewhere.
This commit is contained in:
parent
d2065cc91f
commit
d666d66886
@ -1,5 +1,5 @@
|
||||
<div
|
||||
class="tw-flex tw-items-center tw-gap-2 tw-py-2.5 tw-px-4 tw-text-contrast"
|
||||
class="tw-flex tw-items-center tw-gap-2 tw-py-2.5 tw-px-4 tw-pr-2.5 tw-text-contrast"
|
||||
[ngClass]="bannerClass"
|
||||
[attr.role]="useAlertRole ? 'status' : null"
|
||||
[attr.aria-live]="useAlertRole ? 'polite' : null"
|
||||
@ -8,7 +8,12 @@
|
||||
<span class="tw-grow tw-text-base">
|
||||
<ng-content></ng-content>
|
||||
</span>
|
||||
<button class="tw-border-0 tw-bg-transparent tw-p-0 tw-text-contrast" (click)="onClose.emit()">
|
||||
<i class="bwi bwi-close tw-text-sm" *ngIf="icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="contrast"
|
||||
size="default"
|
||||
(click)="onClose.emit()"
|
||||
[attr.title]="'close' | i18n"
|
||||
[attr.aria-label]="'close' | i18n"
|
||||
></button>
|
||||
</div>
|
||||
|
@ -1,5 +1,10 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { SharedModule } from "../shared/shared.module";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { BannerComponent } from "./banner.component";
|
||||
|
||||
describe("BannerComponent", () => {
|
||||
@ -8,7 +13,17 @@ describe("BannerComponent", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SharedModule],
|
||||
declarations: [BannerComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () =>
|
||||
new I18nMockService({
|
||||
close: "Close",
|
||||
}),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(BannerComponent);
|
||||
|
@ -1,10 +1,13 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
import { SharedModule } from "../shared/shared.module";
|
||||
|
||||
import { BannerComponent } from "./banner.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, SharedModule, IconButtonModule],
|
||||
exports: [BannerComponent],
|
||||
declarations: [BannerComponent],
|
||||
})
|
||||
|
@ -1,19 +1,43 @@
|
||||
import { Meta, Story } from "@storybook/angular";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
import { SharedModule } from "../shared/shared.module";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { BannerComponent } from "./banner.component";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Banner",
|
||||
component: BannerComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [SharedModule, IconButtonModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
close: "Close",
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
bannerType: "warning",
|
||||
},
|
||||
argTypes: {
|
||||
onClose: { action: "onClose" },
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const Template: Story<BannerComponent> = (args: BannerComponent) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-banner [bannerType]="bannerType">Content Really Long Text Lorem Ipsum Ipsum Ipsum <button>Button</button></bit-banner>
|
||||
<bit-banner [bannerType]="bannerType" (onClose)="onClose($event)">Content Really Long Text Lorem Ipsum Ipsum Ipsum <button>Button</button></bit-banner>
|
||||
`,
|
||||
});
|
||||
|
||||
|
@ -9,8 +9,6 @@ const buttonStyles: Record<ButtonTypes, string[]> = {
|
||||
"!tw-text-contrast",
|
||||
"hover:tw-bg-primary-700",
|
||||
"hover:tw-border-primary-700",
|
||||
"focus:tw-bg-primary-700",
|
||||
"focus:tw-border-primary-700",
|
||||
"disabled:tw-bg-primary-500/60",
|
||||
"disabled:tw-border-primary-500/60",
|
||||
"disabled:!tw-text-contrast/60",
|
||||
@ -23,9 +21,6 @@ const buttonStyles: Record<ButtonTypes, string[]> = {
|
||||
"hover:tw-bg-secondary-500",
|
||||
"hover:tw-border-secondary-500",
|
||||
"hover:!tw-text-contrast",
|
||||
"focus:tw-bg-secondary-500",
|
||||
"focus:tw-border-secondary-500",
|
||||
"focus:!tw-text-contrast",
|
||||
"disabled:tw-bg-transparent",
|
||||
"disabled:tw-border-text-muted/60",
|
||||
"disabled:!tw-text-muted/60",
|
||||
@ -37,9 +32,6 @@ const buttonStyles: Record<ButtonTypes, string[]> = {
|
||||
"hover:tw-bg-danger-500",
|
||||
"hover:tw-border-danger-500",
|
||||
"hover:!tw-text-contrast",
|
||||
"focus:tw-bg-danger-500",
|
||||
"focus:tw-border-danger-500",
|
||||
"focus:!tw-text-contrast",
|
||||
"disabled:tw-bg-transparent",
|
||||
"disabled:tw-border-danger-500/60",
|
||||
"disabled:!tw-text-danger/60",
|
||||
@ -62,10 +54,10 @@ export class ButtonDirective {
|
||||
"tw-text-center",
|
||||
"hover:tw-no-underline",
|
||||
"focus:tw-outline-none",
|
||||
"focus:tw-ring",
|
||||
"focus:tw-ring-offset-2",
|
||||
"focus:tw-ring-primary-700",
|
||||
"focus:tw-z-10",
|
||||
"focus-visible:tw-ring",
|
||||
"focus-visible:tw-ring-offset-2",
|
||||
"focus-visible:tw-ring-primary-700",
|
||||
"focus-visible:tw-z-10",
|
||||
]
|
||||
.concat(this.block ? ["tw-w-full", "tw-block"] : ["tw-inline-block"])
|
||||
.concat(buttonStyles[this.buttonType ?? "secondary"]);
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { DialogModule as CdkDialogModule } from "@angular/cdk/dialog";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
import { SharedModule } from "../shared";
|
||||
|
||||
import { DialogService } from "./dialog.service";
|
||||
@ -10,11 +11,11 @@ import { DialogTitleContainerDirective } from "./directives/dialog-title-contain
|
||||
import { SimpleDialogComponent } from "./simple-dialog/simple-dialog.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, CdkDialogModule],
|
||||
imports: [SharedModule, IconButtonModule, CdkDialogModule],
|
||||
declarations: [
|
||||
DialogCloseDirective,
|
||||
DialogComponent,
|
||||
DialogTitleContainerDirective,
|
||||
DialogComponent,
|
||||
SimpleDialogComponent,
|
||||
],
|
||||
exports: [CdkDialogModule, DialogComponent, SimpleDialogComponent],
|
||||
|
@ -3,19 +3,19 @@
|
||||
class="tw-my-4 tw-flex tw-max-h-screen tw-flex-col tw-overflow-hidden tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-text-contrast tw-text-main"
|
||||
>
|
||||
<div
|
||||
class="tw-flex tw-gap-4 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-p-4"
|
||||
class="tw-flex tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-p-4"
|
||||
>
|
||||
<h1 bitDialogTitleContainer class="tw-mb-0 tw-grow tw-text-lg tw-uppercase">
|
||||
<ng-content select="[bitDialogTitle]"></ng-content>
|
||||
</h1>
|
||||
<button
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="main"
|
||||
size="default"
|
||||
bitDialogClose
|
||||
class="tw-border-0 tw-bg-transparent tw-p-0"
|
||||
title="{{ 'close' | i18n }}"
|
||||
attr.aria-label="{{ 'close' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-close tw-text-xs tw-font-bold tw-text-main" aria-hidden="true"></i>
|
||||
</button>
|
||||
[attr.title]="'close' | i18n"
|
||||
[attr.aria-label]="'close' | i18n"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div class="tw-overflow-y-auto tw-p-4 tw-pb-8">
|
||||
|
@ -3,6 +3,7 @@ import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { ButtonModule } from "../../button";
|
||||
import { IconButtonModule } from "../../icon-button";
|
||||
import { SharedModule } from "../../shared";
|
||||
import { I18nMockService } from "../../utils/i18n-mock.service";
|
||||
import { DialogCloseDirective } from "../directives/dialog-close.directive";
|
||||
@ -15,7 +16,7 @@ export default {
|
||||
component: DialogComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [SharedModule, ButtonModule],
|
||||
imports: [ButtonModule, SharedModule, IconButtonModule],
|
||||
declarations: [DialogTitleContainerDirective, DialogCloseDirective],
|
||||
providers: [
|
||||
{
|
||||
@ -46,9 +47,16 @@ const Template: Story<DialogComponent> = (args: DialogComponent) => ({
|
||||
<bit-dialog [dialogSize]="dialogSize">
|
||||
<span bitDialogTitle>{{title}}</span>
|
||||
<span bitDialogContent>Dialog body text goes here.</span>
|
||||
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
|
||||
<div bitDialogFooter class="tw-flex tw-items-center tw-flex-row tw-gap-2">
|
||||
<button bitButton buttonType="primary">Save</button>
|
||||
<button bitButton buttonType="secondary">Cancel</button>
|
||||
<button
|
||||
class="tw-ml-auto"
|
||||
bitIconButton="bwi-trash"
|
||||
buttonType="danger"
|
||||
size="default"
|
||||
title="Delete"
|
||||
aria-label="Delete"></button>
|
||||
</div>
|
||||
</bit-dialog>
|
||||
`,
|
||||
|
@ -2,7 +2,12 @@ import { DialogModule, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { ButtonModule } from "../button";
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
import { SharedModule } from "../shared/shared.module";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { DialogService } from "./dialog.service";
|
||||
import { DialogCloseDirective } from "./directives/dialog-close.directive";
|
||||
@ -60,13 +65,23 @@ export default {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [
|
||||
DialogCloseDirective,
|
||||
SimpleDialogComponent,
|
||||
DialogTitleContainerDirective,
|
||||
StoryDialogContentComponent,
|
||||
DialogCloseDirective,
|
||||
DialogTitleContainerDirective,
|
||||
SimpleDialogComponent,
|
||||
],
|
||||
imports: [SharedModule, IconButtonModule, ButtonModule, DialogModule],
|
||||
providers: [
|
||||
DialogService,
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
close: "Close",
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
imports: [ButtonModule, DialogModule],
|
||||
providers: [DialogService],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
|
115
libs/components/src/icon-button/icon-button.component.ts
Normal file
115
libs/components/src/icon-button/icon-button.component.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { Component, HostBinding, Input } from "@angular/core";
|
||||
|
||||
export type IconButtonStyle = "contrast" | "main" | "muted" | "primary" | "secondary" | "danger";
|
||||
|
||||
const styles: Record<IconButtonStyle, string[]> = {
|
||||
contrast: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-contrast",
|
||||
"tw-border-transparent",
|
||||
"hover:tw-bg-transparent-hover",
|
||||
"hover:tw-border-text-contrast",
|
||||
"focus-visible:before:tw-ring-text-contrast",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
],
|
||||
main: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-main",
|
||||
"tw-border-transparent",
|
||||
"hover:tw-bg-transparent-hover",
|
||||
"hover:tw-border-text-main",
|
||||
"focus-visible:before:tw-ring-text-main",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
],
|
||||
muted: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-muted",
|
||||
"tw-border-transparent",
|
||||
"hover:tw-bg-transparent-hover",
|
||||
"hover:tw-border-primary-700",
|
||||
"focus-visible:before:tw-ring-primary-700",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
],
|
||||
primary: [
|
||||
"tw-bg-primary-500",
|
||||
"!tw-text-contrast",
|
||||
"tw-border-primary-500",
|
||||
"hover:tw-bg-primary-700",
|
||||
"hover:tw-border-primary-700",
|
||||
"focus-visible:before:tw-ring-primary-700",
|
||||
"disabled:hover:tw-bg-primary-500",
|
||||
],
|
||||
secondary: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-muted",
|
||||
"tw-border-text-muted",
|
||||
"hover:!tw-text-contrast",
|
||||
"hover:tw-bg-text-muted",
|
||||
"focus-visible:before:tw-ring-primary-700",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
"disabled:hover:!tw-text-muted",
|
||||
"disabled:hover:tw-border-text-muted",
|
||||
],
|
||||
danger: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-danger",
|
||||
"tw-border-danger-500",
|
||||
"hover:!tw-text-contrast",
|
||||
"hover:tw-bg-danger-500",
|
||||
"focus-visible:before:tw-ring-primary-700",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
"disabled:hover:!tw-text-danger",
|
||||
"disabled:hover:tw-border-danger-500",
|
||||
],
|
||||
};
|
||||
|
||||
export type IconButtonSize = "default" | "small";
|
||||
|
||||
const sizes: Record<IconButtonSize, string[]> = {
|
||||
default: ["tw-px-2.5", "tw-py-1.5"],
|
||||
small: ["tw-leading-none", "tw-text-base", "tw-p-1"],
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "button[bitIconButton]",
|
||||
template: `<i class="bwi" [ngClass]="icon" aria-hidden="true"></i>`,
|
||||
})
|
||||
export class BitIconButtonComponent {
|
||||
@Input("bitIconButton") icon: string;
|
||||
|
||||
@Input() buttonType: IconButtonStyle = "main";
|
||||
|
||||
@Input() size: IconButtonSize = "default";
|
||||
|
||||
@HostBinding("class") get classList() {
|
||||
return [
|
||||
"tw-font-semibold",
|
||||
"tw-border",
|
||||
"tw-border-solid",
|
||||
"tw-rounded",
|
||||
"tw-transition",
|
||||
"hover:tw-no-underline",
|
||||
"disabled:tw-opacity-60",
|
||||
"disabled:hover:tw-border-transparent",
|
||||
"focus:tw-outline-none",
|
||||
|
||||
// Workaround for box-shadow with transparent offset issue:
|
||||
// https://github.com/tailwindlabs/tailwindcss/issues/3595
|
||||
// Remove `before:` and use regular `tw-ring` when browser no longer has bug, or better:
|
||||
// switch to `outline` with `outline-offset` when Safari supports border radius on outline.
|
||||
// Using `box-shadow` to create outlines is a hack and as such `outline` should be preferred.
|
||||
"tw-relative",
|
||||
"before:tw-content-['']",
|
||||
"before:tw-block",
|
||||
"before:tw-absolute",
|
||||
"before:-tw-inset-[3px]",
|
||||
"before:tw-rounded-md",
|
||||
"before:tw-transition",
|
||||
"before:tw-ring",
|
||||
"focus-visible:before:tw-ring-text-contrast",
|
||||
"focus-visible:tw-z-10",
|
||||
]
|
||||
.concat(styles[this.buttonType])
|
||||
.concat(sizes[this.size]);
|
||||
}
|
||||
}
|
11
libs/components/src/icon-button/icon-button.module.ts
Normal file
11
libs/components/src/icon-button/icon-button.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { BitIconButtonComponent } from "./icon-button.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule],
|
||||
declarations: [BitIconButtonComponent],
|
||||
exports: [BitIconButtonComponent],
|
||||
})
|
||||
export class IconButtonModule {}
|
59
libs/components/src/icon-button/icon-button.stories.ts
Normal file
59
libs/components/src/icon-button/icon-button.stories.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { Meta, Story } from "@storybook/angular";
|
||||
|
||||
import { BitIconButtonComponent } from "./icon-button.component";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Icon Button",
|
||||
component: BitIconButtonComponent,
|
||||
args: {
|
||||
bitIconButton: "bwi-plus",
|
||||
buttonType: "primary",
|
||||
size: "default",
|
||||
disabled: false,
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const Template: Story<BitIconButtonComponent> = (args: BitIconButtonComponent) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<div class="tw-p-5" [class.tw-bg-primary-500]="buttonType === 'contrast'">
|
||||
<button
|
||||
[bitIconButton]="bitIconButton"
|
||||
[buttonType]="buttonType"
|
||||
[size]="size"
|
||||
[disabled]="disabled"
|
||||
title="Example icon button"
|
||||
aria-label="Example icon button"></button>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
export const Contrast = Template.bind({});
|
||||
Contrast.args = {
|
||||
buttonType: "contrast",
|
||||
};
|
||||
|
||||
export const Main = Template.bind({});
|
||||
Main.args = {
|
||||
buttonType: "main",
|
||||
};
|
||||
|
||||
export const Muted = Template.bind({});
|
||||
Muted.args = {
|
||||
buttonType: "muted",
|
||||
};
|
||||
|
||||
export const Primary = Template.bind({});
|
||||
Primary.args = {
|
||||
buttonType: "primary",
|
||||
};
|
||||
|
||||
export const Secondary = Template.bind({});
|
||||
Secondary.args = {
|
||||
buttonType: "secondary",
|
||||
};
|
||||
|
||||
export const Danger = Template.bind({});
|
||||
Danger.args = {
|
||||
buttonType: "danger",
|
||||
};
|
1
libs/components/src/icon-button/index.ts
Normal file
1
libs/components/src/icon-button/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./icon-button.module";
|
@ -4,6 +4,7 @@ export * from "./button";
|
||||
export * from "./callout";
|
||||
export * from "./form-field";
|
||||
export * from "./icon";
|
||||
export * from "./icon-button";
|
||||
export * from "./menu";
|
||||
export * from "./dialog";
|
||||
export * from "./submit-button";
|
||||
|
@ -1,4 +1,6 @@
|
||||
:root {
|
||||
--color-transparent-hover: rgb(0 0 0 / 0.03);
|
||||
|
||||
--color-background: 255 255 255;
|
||||
--color-background-alt: 251 251 251;
|
||||
--color-background-alt2: 23 92 219;
|
||||
@ -37,6 +39,8 @@
|
||||
}
|
||||
|
||||
.theme_dark {
|
||||
--color-transparent-hover: rgb(255 255 255 / 0.12);
|
||||
|
||||
--color-background: 31 36 46;
|
||||
--color-background-alt: 22 28 38;
|
||||
--color-background-alt2: 47 52 61;
|
||||
|
@ -12,7 +12,10 @@ module.exports = {
|
||||
corePlugins: { preflight: false },
|
||||
theme: {
|
||||
colors: {
|
||||
transparent: colors.transparent,
|
||||
transparent: {
|
||||
DEFAULT: colors.transparent,
|
||||
hover: "var(--color-transparent-hover)",
|
||||
},
|
||||
current: colors.current,
|
||||
black: colors.black,
|
||||
primary: {
|
||||
|
Loading…
Reference in New Issue
Block a user