added upload image to dialog
This commit is contained in:
parent
34f38e90d5
commit
4aae0c1724
13
package-lock.json
generated
13
package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@solid-primitives/upload": "^0.0.117",
|
||||||
"@solidjs/router": "^0.13.5",
|
"@solidjs/router": "^0.13.5",
|
||||||
"solid-collapse": "^1.1.0",
|
"solid-collapse": "^1.1.0",
|
||||||
"solid-icons": "^1.1.0",
|
"solid-icons": "^1.1.0",
|
||||||
@ -1224,11 +1225,21 @@
|
|||||||
"solid-js": "^1.6.12"
|
"solid-js": "^1.6.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@solid-primitives/upload": {
|
||||||
|
"version": "0.0.117",
|
||||||
|
"resolved": "https://registry.npmjs.org/@solid-primitives/upload/-/upload-0.0.117.tgz",
|
||||||
|
"integrity": "sha512-szDksm4u67JgiMtkpX8RPccxqfid4OCQ/zpJ1yB1PMFmenLjz8YKldGIIt+Yn3bEcfHBbdkPa2uu/y2FAdPeSw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@solid-primitives/utils": "^6.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"solid-js": "^1.6.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@solid-primitives/utils": {
|
"node_modules/@solid-primitives/utils": {
|
||||||
"version": "6.2.3",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@solid-primitives/utils/-/utils-6.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@solid-primitives/utils/-/utils-6.2.3.tgz",
|
||||||
"integrity": "sha512-CqAwKb2T5Vi72+rhebSsqNZ9o67buYRdEJrIFzRXz3U59QqezuuxPsyzTSVCacwS5Pf109VRsgCJQoxKRoECZQ==",
|
"integrity": "sha512-CqAwKb2T5Vi72+rhebSsqNZ9o67buYRdEJrIFzRXz3U59QqezuuxPsyzTSVCacwS5Pf109VRsgCJQoxKRoECZQ==",
|
||||||
"dev": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"solid-js": "^1.6.12"
|
"solid-js": "^1.6.12"
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
"vite-plugin-solid": "^2.8.2"
|
"vite-plugin-solid": "^2.8.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@solid-primitives/upload": "^0.0.117",
|
||||||
"@solidjs/router": "^0.13.5",
|
"@solidjs/router": "^0.13.5",
|
||||||
"solid-collapse": "^1.1.0",
|
"solid-collapse": "^1.1.0",
|
||||||
"solid-icons": "^1.1.0",
|
"solid-icons": "^1.1.0",
|
||||||
|
32
src/api.ts
32
src/api.ts
@ -1,5 +1,6 @@
|
|||||||
import { createSignal } from "solid-js";
|
import { Accessor, createSignal } from "solid-js";
|
||||||
import { localState } from "./state";
|
import { localState } from "./state";
|
||||||
|
import { UploadFile } from "@solid-primitives/upload";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
username: string,
|
username: string,
|
||||||
@ -91,7 +92,7 @@ export interface MapRequest {
|
|||||||
|
|
||||||
export interface ProfileRequest {
|
export interface ProfileRequest {
|
||||||
name: string,
|
name: string,
|
||||||
image: File,
|
image: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerTypeRequest {
|
export interface PlayerTypeRequest {
|
||||||
@ -195,6 +196,33 @@ export class API {
|
|||||||
await this.checkResponse(response);
|
await this.checkResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async addStratType(groupId: string, stratName: string): Promise<Group> {
|
||||||
|
const response = await fetch(this.apiURL + '/group/' + groupId + '/strat-type', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + this.ensureLoggedIn().token
|
||||||
|
},
|
||||||
|
body: JSON.stringify(stratName)
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.checkResponse(response);
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeStratType(groupId: string, stratName: string): Promise<void> {
|
||||||
|
const response = await fetch(this.apiURL + '/group/' + groupId + '/strat-type/' + stratName, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + this.ensureLoggedIn().token
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.checkResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
public async updateGroup(id: string, group: GroupRequest): Promise<Group> {
|
public async updateGroup(id: string, group: GroupRequest): Promise<Group> {
|
||||||
const response = await fetch(this.apiURL + '/group/' + id, {
|
const response = await fetch(this.apiURL + '/group/' + id, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import { Component, For } from 'solid-js';
|
import { Component, For, Setter, Show } from 'solid-js';
|
||||||
|
|
||||||
import styles from './Dialog.module.css';
|
import styles from './Dialog.module.css';
|
||||||
|
import { UploadFile, FileUploader } from '@solid-primitives/upload';
|
||||||
|
|
||||||
export interface DialogButton {
|
export interface DialogButton {
|
||||||
name: string,
|
name: string,
|
||||||
action?: () => void,
|
action?: () => void,
|
||||||
type?: 'default' | 'danger' | 'success'
|
type?: 'default' | 'danger' | 'success',
|
||||||
|
closeOnClick?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DialogInputField {
|
export interface DialogInputField {
|
||||||
@ -13,6 +15,11 @@ export interface DialogInputField {
|
|||||||
onInput: (text: string) => void,
|
onInput: (text: string) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageInputField {
|
||||||
|
name: string,
|
||||||
|
onInput: () => Setter<UploadFile[]>,
|
||||||
|
}
|
||||||
|
|
||||||
export interface DialogProps {
|
export interface DialogProps {
|
||||||
title: string,
|
title: string,
|
||||||
text: string,
|
text: string,
|
||||||
@ -21,11 +28,11 @@ export interface DialogProps {
|
|||||||
inputFields?: DialogInputField[],
|
inputFields?: DialogInputField[],
|
||||||
}
|
}
|
||||||
|
|
||||||
function createButton(button: (string | DialogButton), dismiss?: () => void) {
|
function createButton(button: (string | DialogButton), dismiss?: () => void) { //TODO configurable if dismiss on click
|
||||||
if (typeof button == 'string') {
|
if (typeof button == 'string') {
|
||||||
return <button onclick={dismiss} class={styles.defaultButton}>{button}</button>;
|
return <button onclick={dismiss} class={styles.defaultButton}>{button}</button>;
|
||||||
} else {
|
} else {
|
||||||
return <button onclick={() => { button.action?.(); dismiss?.(); }} class={styles[button.type + 'Button']}>{button.name}</button>;
|
return <button onclick={() => { button.action?.(); button.closeOnClick ? dismiss?.() : () => { }; }} class={styles[button.type + 'Button']}>{button.name}</button>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ const MapItem: Component<MapItemProps> = (props) => {
|
|||||||
{
|
{
|
||||||
name: 'Remove',
|
name: 'Remove',
|
||||||
type: 'danger',
|
type: 'danger',
|
||||||
|
closeOnClick: true,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await api().removeMap(props.group.id, props.map.id);
|
await api().removeMap(props.group.id, props.map.id);
|
||||||
@ -32,6 +33,7 @@ const MapItem: Component<MapItemProps> = (props) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Cancel',
|
name: 'Cancel',
|
||||||
|
closeOnClick: true,
|
||||||
action: () => { }
|
action: () => { }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -21,6 +21,7 @@ const MemberItem: Component<MemberItemProps> = (props) => {
|
|||||||
{
|
{
|
||||||
name: 'Remove',
|
name: 'Remove',
|
||||||
type: 'danger',
|
type: 'danger',
|
||||||
|
closeOnClick: true,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await api().removeMember(props.group.id, props.member.username);
|
await api().removeMember(props.group.id, props.member.username);
|
||||||
@ -32,6 +33,7 @@ const MemberItem: Component<MemberItemProps> = (props) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Cancel',
|
name: 'Cancel',
|
||||||
|
closeOnClick: true,
|
||||||
action: () => { }
|
action: () => { }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -21,6 +21,7 @@ const ProfileItem: Component<ProfileItemProps> = (props) => {
|
|||||||
{
|
{
|
||||||
name: 'Remove',
|
name: 'Remove',
|
||||||
type: 'danger',
|
type: 'danger',
|
||||||
|
closeOnClick: true,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await api().removeProfileFromGroup(props.group.id, props.profile.id);
|
await api().removeProfileFromGroup(props.group.id, props.profile.id);
|
||||||
@ -32,6 +33,7 @@ const ProfileItem: Component<ProfileItemProps> = (props) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Cancel',
|
name: 'Cancel',
|
||||||
|
closeOnClick: true,
|
||||||
action: () => { }
|
action: () => { }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -21,6 +21,7 @@ const StratTypeItem: Component<StratTypeItem> = (props) => {
|
|||||||
{
|
{
|
||||||
name: 'Remove',
|
name: 'Remove',
|
||||||
type: 'danger',
|
type: 'danger',
|
||||||
|
closeOnClick: true,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await api().removeStratType(props.group.id, props.stratType);
|
await api().removeStratType(props.group.id, props.stratType);
|
||||||
@ -32,6 +33,7 @@ const StratTypeItem: Component<StratTypeItem> = (props) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Cancel',
|
name: 'Cancel',
|
||||||
|
closeOnClick: true,
|
||||||
action: () => { }
|
action: () => { }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -4,7 +4,7 @@ import { Component, Show, createEffect, createSignal } from "solid-js";
|
|||||||
import styles from './GroupPage.module.css'
|
import styles from './GroupPage.module.css'
|
||||||
import { Group, Map, Profile, Strat, User, api } from "../api";
|
import { Group, Map, Profile, Strat, User, api } from "../api";
|
||||||
import { showDialog, showInputDialog, showMessageDialog } from "../state";
|
import { showDialog, showInputDialog, showMessageDialog } from "../state";
|
||||||
import { errorToString, normalize } from "../util";
|
import { _arrayBufferToBase64, errorToString, normalize } from "../util";
|
||||||
import { BsCheck2, BsPencil, BsTrash } from "solid-icons/bs";
|
import { BsCheck2, BsPencil, BsTrash } from "solid-icons/bs";
|
||||||
import { Collapse } from "solid-collapse";
|
import { Collapse } from "solid-collapse";
|
||||||
import MemberItem from "../components/MemberItem";
|
import MemberItem from "../components/MemberItem";
|
||||||
@ -12,6 +12,7 @@ import MapItem from "../components/MapItem";
|
|||||||
import StratItem from "../components/StratItem";
|
import StratItem from "../components/StratItem";
|
||||||
import StratTypeItem from "../components/StratTypeItem";
|
import StratTypeItem from "../components/StratTypeItem";
|
||||||
import ProfileItem from "../components/ProfileItem";
|
import ProfileItem from "../components/ProfileItem";
|
||||||
|
import { UploadFile, createFileUploader } from "@solid-primitives/upload";
|
||||||
|
|
||||||
const GroupPage: Component = () => {
|
const GroupPage: Component = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -81,7 +82,56 @@ const GroupPage: Component = () => {
|
|||||||
showMessageDialog('Failed to add member', errorToString(e));
|
showMessageDialog('Failed to add member', errorToString(e));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const addStratType = async () => {
|
||||||
|
showInputDialog('Add strat type to Group', 'enter the name you want to add', 'strat type', async (stratType) => {
|
||||||
|
try {
|
||||||
|
const newGroup = await api().addStratType(group().id, stratType);
|
||||||
|
setGroup(newGroup);
|
||||||
|
} catch (e) {
|
||||||
|
showMessageDialog('Failed to add strat type', errorToString(e));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const addProfile = async () => {
|
||||||
|
const [profileName, setProfileName] = createSignal('');
|
||||||
|
const { files, selectFiles } = createFileUploader({
|
||||||
|
multiple: false,
|
||||||
|
accept: "image/*",
|
||||||
|
});
|
||||||
|
|
||||||
|
let fileReader = new FileReader();
|
||||||
|
|
||||||
|
fileReader.onload = async event => {
|
||||||
|
try {
|
||||||
|
const newGroup = await api().addProfileToGroup(group().id, { name: profileName(), image: _arrayBufferToBase64(event.target!.result as ArrayBuffer) });
|
||||||
|
setGroup(newGroup);
|
||||||
|
} catch (e) {
|
||||||
|
showMessageDialog('Failed to add profile', errorToString(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showDialog({
|
||||||
|
title: 'Add profile to Group',
|
||||||
|
text: 'create a new profile',
|
||||||
|
inputFields: [{
|
||||||
|
onInput: setProfileName,
|
||||||
|
placeholder: 'name'
|
||||||
|
}],
|
||||||
|
onDismiss: async () => {
|
||||||
|
fileReader.readAsArrayBuffer(files()[0].file);
|
||||||
|
},
|
||||||
|
buttons: [{
|
||||||
|
name: 'add image', action: () => selectFiles(([{ source, name, size, file }]) => {
|
||||||
|
console.log({ source, name, size, file });
|
||||||
|
}),
|
||||||
|
closeOnClick: false
|
||||||
|
},
|
||||||
|
'confirm'
|
||||||
|
],
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleEdit = async () => {
|
const toggleEdit = async () => {
|
||||||
@ -128,31 +178,37 @@ const GroupPage: Component = () => {
|
|||||||
<hr />
|
<hr />
|
||||||
<div>
|
<div>
|
||||||
<button onClick={() => setMembersIsExpanded(!membersIsExpanded())}>Members</button>
|
<button onClick={() => setMembersIsExpanded(!membersIsExpanded())}>Members</button>
|
||||||
|
<button class={styles.addMemberButton} onClick={addMember}>+</button>
|
||||||
<Collapse value={membersIsExpanded()} class={styles.members}>
|
<Collapse value={membersIsExpanded()} class={styles.members}>
|
||||||
<button class={styles.addMemberButton} onClick={addMember}>Add Member</button>
|
|
||||||
<div class={styles.groupMembers}>
|
<div class={styles.groupMembers}>
|
||||||
{group().members?.map(m => <MemberItem group={group()} member={m} onDelete={onDeleteMember} />)}
|
{group().members?.map(m => <MemberItem group={group()} member={m} onDelete={onDeleteMember} />)}
|
||||||
</div>
|
</div>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
<br />
|
||||||
<button onClick={() => setStratsIsExpanded(!stratsIsExpanded())}>strats</button>
|
<button onClick={() => setStratsIsExpanded(!stratsIsExpanded())}>strats</button>
|
||||||
<Collapse value={stratsIsExpanded()} class={styles.strats}>
|
<Collapse value={stratsIsExpanded()} class={styles.strats}>
|
||||||
<div class={styles.strats}>
|
<div class={styles.strats}>
|
||||||
{group().strats?.map(s => <StratItem group={group()} strat={s} onDelete={onDeleteStrat} />)}
|
{group().strats?.map(s => <StratItem group={group()} strat={s} onDelete={onDeleteStrat} />)}
|
||||||
</div>
|
</div>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
<br />
|
||||||
<button onClick={() => setMapsIsExpanded(!mapsIsExpanded())}>maps</button>
|
<button onClick={() => setMapsIsExpanded(!mapsIsExpanded())}>maps</button>
|
||||||
<Collapse value={mapsIsExpanded()} class={styles.maps}>
|
<Collapse value={mapsIsExpanded()} class={styles.maps}>
|
||||||
<div class={styles.maps}>
|
<div class={styles.maps}>
|
||||||
{group().maps?.map(m => <MapItem group={group()} map={m} onDelete={onDeleteMap} />)}
|
{group().maps?.map(m => <MapItem group={group()} map={m} onDelete={onDeleteMap} />)}
|
||||||
</div>
|
</div>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
<br />
|
||||||
<button onClick={() => setStratTypesIsExpanded(!stratTypesIsExpanded())}>strat types</button>
|
<button onClick={() => setStratTypesIsExpanded(!stratTypesIsExpanded())}>strat types</button>
|
||||||
|
<button class={styles.addMemberButton} onClick={addStratType}>+</button>
|
||||||
<Collapse value={stratTypesIsExpanded()} class={styles.stratTypes}>
|
<Collapse value={stratTypesIsExpanded()} class={styles.stratTypes}>
|
||||||
<div class={styles.stratTypes}>
|
<div class={styles.stratTypes}>
|
||||||
{group().stratTypes?.map(s => <StratTypeItem group={group()} stratType={s} onDelete={onDeleteStratType} />)}
|
{group().stratTypes?.map(s => <StratTypeItem group={group()} stratType={s} onDelete={onDeleteStratType} />)}
|
||||||
</div>
|
</div>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
<br />
|
||||||
<button onClick={() => setProfilesIsExpanded(!profilesIsExpanded())}>profiles</button>
|
<button onClick={() => setProfilesIsExpanded(!profilesIsExpanded())}>profiles</button>
|
||||||
|
<button class={styles.addMemberButton} onClick={addProfile}>+</button>
|
||||||
<Collapse value={profilesIsExpanded()} class={styles.profiles}>
|
<Collapse value={profilesIsExpanded()} class={styles.profiles}>
|
||||||
<div class={styles.profiles}>
|
<div class={styles.profiles}>
|
||||||
{group().profiles?.map(p => <ProfileItem group={group()} profile={p} onDelete={onDeleteProfile} />)}
|
{group().profiles?.map(p => <ProfileItem group={group()} profile={p} onDelete={onDeleteProfile} />)}
|
||||||
|
13
src/state.ts
13
src/state.ts
@ -1,7 +1,7 @@
|
|||||||
import { createEffect, createSignal } from 'solid-js';
|
import { createEffect, createSignal } from 'solid-js';
|
||||||
import { SetStoreFunction, Store, createStore } from 'solid-js/store';
|
import { SetStoreFunction, Store, createStore } from 'solid-js/store';
|
||||||
import { AuthenticationResponse } from './api';
|
import { AuthenticationResponse } from './api';
|
||||||
import { DialogProps } from './components/Dialog';
|
import { DialogInputField, DialogProps, ImageInputField } from './components/Dialog';
|
||||||
|
|
||||||
// Source: https://www.solidjs.com/examples/todos
|
// Source: https://www.solidjs.com/examples/todos
|
||||||
function createLocalStore<T extends object>(
|
function createLocalStore<T extends object>(
|
||||||
@ -47,7 +47,16 @@ export const showInputDialog = (title: string, message: string, placeholder: str
|
|||||||
title,
|
title,
|
||||||
text: message,
|
text: message,
|
||||||
inputFields: [{ placeholder, onInput: setText }],
|
inputFields: [{ placeholder, onInput: setText }],
|
||||||
buttons: [{ name: 'Okay', action: () => callback(text()) }]
|
buttons: [{ name: 'Okay', action: () => callback(text()), closeOnClick: true }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showInputsDialog = async (title: string, message: string, inputFields: DialogInputField[], callback: () => void) => {
|
||||||
|
showDialog({
|
||||||
|
title,
|
||||||
|
text: message,
|
||||||
|
inputFields: inputFields,
|
||||||
|
buttons: [{ name: 'Okay', action: () => callback(), closeOnClick: true }]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
30
src/util.ts
30
src/util.ts
@ -37,3 +37,33 @@ export function normalizeRating(text: string) {
|
|||||||
|
|
||||||
return clamp(rating, 0, 5);
|
return clamp(rating, 0, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function _arrayBufferToBase64(buffer: ArrayBuffer) {
|
||||||
|
var binary = '';
|
||||||
|
var bytes = new Uint8Array(buffer);
|
||||||
|
var len = bytes.byteLength;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return window.btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const b64toBlob = (b64Data: string, contentType = '', sliceSize = 512) => {
|
||||||
|
const byteCharacters = atob(b64Data);
|
||||||
|
const byteArrays = [];
|
||||||
|
|
||||||
|
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
|
||||||
|
const slice = byteCharacters.slice(offset, offset + sliceSize);
|
||||||
|
|
||||||
|
const byteNumbers = new Array(slice.length);
|
||||||
|
for (let i = 0; i < slice.length; i++) {
|
||||||
|
byteNumbers[i] = slice.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byteArray = new Uint8Array(byteNumbers);
|
||||||
|
byteArrays.push(byteArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob(byteArrays, { type: contentType });
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user