Improvements to file uploads. UI improvements for future looper and audio capture plugins.
This commit is contained in:
@@ -12,7 +12,9 @@ import JackHostStatus from './JackHostStatus';
|
||||
import { PiPedalError } from './PiPedalError';
|
||||
import DialogEx from './DialogEx';
|
||||
|
||||
import { Theme, createStyles } from '@mui/material/styles';
|
||||
import createStyles from '@mui/styles/createStyles';
|
||||
|
||||
import { Theme } from '@mui/material/styles';
|
||||
import { withStyles, WithStyles } from '@mui/styles';
|
||||
import Slide, { SlideProps } from '@mui/material/Slide';
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ import ButtonBase from "@mui/material/ButtonBase";
|
||||
import Slide, { SlideProps } from '@mui/material/Slide';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import { Theme, createStyles } from '@mui/material/styles';
|
||||
import createStyles from '@mui/styles/createStyles';
|
||||
|
||||
import { Theme } from '@mui/material/styles';
|
||||
import { WithStyles, withStyles } from "@mui/styles";
|
||||
import DraggableGrid, { ScrollDirection } from './DraggableGrid';
|
||||
import Fade from '@mui/material/Fade';
|
||||
@@ -304,8 +306,8 @@ const BankDialog = withStyles(styles, { withTheme: true })(
|
||||
>
|
||||
<SelectHoverBackground selected={bankEntry.instanceId === selectedItem} showHover={true} />
|
||||
<div className={classes.itemFrame}>
|
||||
<div className={classes.ListItemIcon}>
|
||||
<BankIcon className={classes.listIcon}/>
|
||||
<div className={classes.listIcon}>
|
||||
<BankIcon />
|
||||
</div>
|
||||
<div className={classes.itemLabel}>
|
||||
<Typography noWrap variant="body2" color="textPrimary">
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
|
||||
|
||||
import React from 'react';
|
||||
import { Theme, createStyles } from '@mui/material/styles';
|
||||
import createStyles from '@mui/styles/createStyles';
|
||||
|
||||
import { Theme } from '@mui/material/styles';
|
||||
import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder';
|
||||
import RenameDialog from './RenameDialog';
|
||||
import Divider from '@mui/material/Divider';
|
||||
@@ -32,7 +34,7 @@ import Button from '@mui/material/Button';
|
||||
import FileUploadIcon from '@mui/icons-material/FileUpload';
|
||||
import AudioFileIcon from '@mui/icons-material/AudioFile';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
@@ -92,6 +94,7 @@ function isAudioFile(filename: string) {
|
||||
export interface FilePropertyDialogProps extends WithStyles<typeof styles> {
|
||||
open: boolean,
|
||||
fileProperty: UiFileProperty,
|
||||
instanceId: number,
|
||||
selectedFile: string,
|
||||
onOk: (fileProperty: UiFileProperty, selectedItem: string) => void,
|
||||
onCancel: () => void
|
||||
@@ -557,7 +560,7 @@ export default withStyles(styles, { withTheme: true })(
|
||||
}
|
||||
if (fileEntry.isDirectory) {
|
||||
return (
|
||||
<FolderOutlinedIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />
|
||||
<FolderIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />
|
||||
);
|
||||
}
|
||||
if (isAudioFile(fileEntry.pathname)) {
|
||||
@@ -750,6 +753,8 @@ export default withStyles(styles, { withTheme: true })(
|
||||
}
|
||||
uploadPage={
|
||||
"uploadUserFile?directory=" + encodeURIComponent(this.state.uploadDirectory)
|
||||
+ "&id=" + this.props.instanceId.toString()
|
||||
+ "&property=" + encodeURIComponent(this.props.fileProperty.patchProperty)
|
||||
+ "&ext="
|
||||
+ encodeURIComponent(this.getFileExtensionList(this.props.fileProperty))
|
||||
}
|
||||
@@ -794,8 +799,10 @@ export default withStyles(styles, { withTheme: true })(
|
||||
(
|
||||
<FilePropertyDirectorySelectDialog
|
||||
open={this.state.moveDialogOpen}
|
||||
dialogTitle="Move to"
|
||||
uiFileProperty={this.props.fileProperty}
|
||||
defaultPath={this.getDefaultPath()}
|
||||
selectedFile={this.state.selectedFile}
|
||||
excludeDirectory={
|
||||
this.isDirectory(this.state.selectedFile)
|
||||
? this.state.selectedFile : ""}
|
||||
|
||||
@@ -121,9 +121,11 @@ class DirectoryTree {
|
||||
|
||||
export interface FilePropertyDirectorySelectDialogProps {
|
||||
open: boolean,
|
||||
dialogTitle: string,
|
||||
uiFileProperty: UiFileProperty;
|
||||
defaultPath: string,
|
||||
excludeDirectory: string,
|
||||
selectedFile: string,
|
||||
onOk: (path: string) => void,
|
||||
onClose: () => void
|
||||
};
|
||||
@@ -181,7 +183,7 @@ export default class FilePropertyDirectorySelectDialog extends ResizeResponsiveC
|
||||
requestDirectoryTree() {
|
||||
if (!this.props.open) return;
|
||||
|
||||
this.model.getFilePropertyDirectoryTree(this.props.uiFileProperty)
|
||||
this.model.getFilePropertyDirectoryTree(this.props.uiFileProperty,this.props.selectedFile)
|
||||
.then((filePropertyDirectoryTree) => {
|
||||
let myTree = new DirectoryTree(filePropertyDirectoryTree);
|
||||
if (this.props.excludeDirectory.length !== 0)
|
||||
@@ -327,7 +329,7 @@ export default class FilePropertyDirectorySelectDialog extends ResizeResponsiveC
|
||||
PaperProps={{ style: { minHeight: "80%", maxHeight: "80%", minWidth: "75%", maxWidth: "75%", overflowY: "visible" } }}
|
||||
>
|
||||
<DialogTitle>
|
||||
<Typography variant="body1">Select folder</Typography>
|
||||
<Typography variant="body1">{this.props.dialogTitle}</Typography>
|
||||
</DialogTitle>
|
||||
<Divider />
|
||||
<DialogContent style={{marginLeft: 0, paddingLeft: 0}} >
|
||||
|
||||
@@ -43,7 +43,8 @@ import { FixedSizeGrid } from 'react-window';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import StarBorderIcon from '@mui/icons-material/StarBorder';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import { createStyles, Theme } from '@mui/material/styles';
|
||||
import createStyles from '@mui/styles/createStyles';
|
||||
import { Theme } from '@mui/material/styles';
|
||||
import { WithStyles, withStyles } from '@mui/styles';
|
||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||
import FilterListOffIcon from '@mui/icons-material/FilterListOff';
|
||||
|
||||
@@ -38,8 +38,8 @@ export const modDirectories: ModDirectory[] = [
|
||||
{modType: "ir", pipedalPath: "ReverbImpulseFiles", displayName: "Impulse Responses", defaultFileExtensions: ["audio/*"]}, // Impulse Responses
|
||||
{modType: "midiclip", pipedalPath: "shared/midiClips",displayName: "MIDI Clips", defaultFileExtensions: [".mid", ".midi"]}, // MIDI Clips, to be used in sync with host tempo, must have mid or midi file extension
|
||||
{modType: "midisong", pipedalPath: "shared/midiSongs", displayName: "MIDI Songs", defaultFileExtensions: [".mid", ".midi"]}, // MIDI Songs, meant to be used as full-performance/song or backtrack
|
||||
{modType: "sf2", pipedalPath: "shared/sf2",displayName: "Sound Fonts", defaultFileExtensions: ["sf2", "sf3"]}, // SF2 Instruments, must have sf2 or sf3 file extension
|
||||
{modType: "sfz", pipedalPath: "shared/sfz",displayName: "Sfz Files", defaultFileExtensions: ["sfz"]}, // SFZ Instruments, must have sfz file extension
|
||||
{modType: "sf2", pipedalPath: "shared/sf2",displayName: "Sound Fonts", defaultFileExtensions: [".sf2", ".sf3"]}, // SF2 Instruments, must have sf2 or sf3 file extension
|
||||
{modType: "sfz", pipedalPath: "shared/sfz",displayName: "Sfz Files", defaultFileExtensions: [".sfz"]}, // SFZ Instruments, must have sfz file extension
|
||||
// extensions observed in the field.
|
||||
{modType: "audio", pipedalPath: "shared/audio", displayName: "Audio", defaultFileExtensions: ["audio/*"]}, // all audio files (Ratatoille)
|
||||
{modType: "nammodel", pipedalPath: "NeuralAmpModels", displayName: "Neural Amp Models", defaultFileExtensions: [".nam"]}, // Ratatoille, Mike's NAM.
|
||||
|
||||
@@ -2610,7 +2610,7 @@ export class PiPedalModel //implements PiPedalModel
|
||||
});
|
||||
}
|
||||
|
||||
getFilePropertyDirectoryTree(uiFileProperty: UiFileProperty): Promise<FilePropertyDirectoryTree> {
|
||||
getFilePropertyDirectoryTree(uiFileProperty: UiFileProperty,selectedPath: string): Promise<FilePropertyDirectoryTree> {
|
||||
return new Promise<FilePropertyDirectoryTree>((resolve, reject) => {
|
||||
let ws = this.webSocket;
|
||||
if (!ws) {
|
||||
@@ -2619,7 +2619,7 @@ export class PiPedalModel //implements PiPedalModel
|
||||
}
|
||||
ws.request<FilePropertyDirectoryTree>(
|
||||
"getFilePropertyDirectoryTree",
|
||||
uiFileProperty
|
||||
{fileProperty: uiFileProperty, selectedPath: selectedPath}
|
||||
).then((result) => {
|
||||
resolve(new FilePropertyDirectoryTree().deserialize(result));
|
||||
}).catch((e) => {
|
||||
|
||||
+99
-36
@@ -73,7 +73,7 @@ export const pluginControlStyles = (theme: Theme) => createStyles({
|
||||
},
|
||||
|
||||
titleSection: {
|
||||
flex: "0 0 auto", alignSelf:"stretch", marginBottom: 8, marginLeft: 0, marginRight: 0
|
||||
flex: "0 0 auto", alignSelf: "stretch", marginBottom: 8, marginLeft: 0, marginRight: 0
|
||||
},
|
||||
displayValue: {
|
||||
position: "absolute",
|
||||
@@ -87,10 +87,10 @@ export const pluginControlStyles = (theme: Theme) => createStyles({
|
||||
// zIndex: -1,
|
||||
},
|
||||
midSection: {
|
||||
flex: "1 1 1", display: "flex",flexFlow: "column nowrap",alignContent: "center",justifyContent: "center"
|
||||
flex: "1 1 1", display: "flex", flexFlow: "column nowrap", alignContent: "center", justifyContent: "center"
|
||||
},
|
||||
editSection: {
|
||||
flex: "0 0 0", display: "flex", flexFlow: "column nowrap", justifyContent: "center",position: "relative", width: 60, height: 28,minHeight: 28
|
||||
flex: "0 0 0", display: "flex", flexFlow: "column nowrap", justifyContent: "center", position: "relative", width: 60, height: 28, minHeight: 28
|
||||
}
|
||||
|
||||
});
|
||||
@@ -500,14 +500,12 @@ const PluginControl =
|
||||
}
|
||||
handleTriggerMouseDown() {
|
||||
let uiControl = this.props.uiControl;
|
||||
if (uiControl)
|
||||
{
|
||||
if (uiControl) {
|
||||
let value = uiControl.max_value;
|
||||
if (uiControl.max_value === uiControl.default_value)
|
||||
{
|
||||
if (uiControl.max_value === uiControl.default_value) {
|
||||
value = uiControl.min_value;
|
||||
}
|
||||
this.model.sendPedalboardControlTrigger(this.props.instanceId,uiControl.symbol,value);
|
||||
this.model.sendPedalboardControlTrigger(this.props.instanceId, uiControl.symbol, value);
|
||||
}
|
||||
}
|
||||
handleTriggerMouseUp() {
|
||||
@@ -650,13 +648,39 @@ const PluginControl =
|
||||
}
|
||||
let range: number;
|
||||
if (uiControl.is_logarithmic) {
|
||||
range = Math.log(value / uiControl.min_value) / Math.log(uiControl.max_value / uiControl.min_value);
|
||||
let minValue = uiControl.min_value;
|
||||
if (minValue === 0) // LSP plugins do this.
|
||||
{
|
||||
minValue = 0.0001;
|
||||
}
|
||||
range = Math.log(value / minValue) / Math.log(uiControl.max_value / minValue);
|
||||
if (!isFinite(range)) {
|
||||
if (range < 0)
|
||||
{
|
||||
range = 0;
|
||||
} else {
|
||||
range = 1.0;
|
||||
}
|
||||
} else if (isNaN(range)) {
|
||||
range = 0;
|
||||
}
|
||||
if (uiControl.range_steps > 1) {
|
||||
range = Math.round(range * (uiControl.range_steps - 1)) / (uiControl.range_steps - 1);
|
||||
}
|
||||
} else {
|
||||
range = (value - uiControl.min_value) / (uiControl.max_value - uiControl.min_value);
|
||||
if (!isFinite(range)) {
|
||||
if (range < 0)
|
||||
{
|
||||
range = 0;
|
||||
} else {
|
||||
range = 1.0;
|
||||
}
|
||||
} else if (isNaN(range)) {
|
||||
range = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (range > 1) range = 1;
|
||||
if (range < 0) range = 0;
|
||||
return range;
|
||||
@@ -676,7 +700,17 @@ const PluginControl =
|
||||
value = uiControl.min_value;
|
||||
} else {
|
||||
if (uiControl.is_logarithmic) {
|
||||
value = uiControl.min_value * Math.pow(uiControl.max_value / uiControl.min_value, range);
|
||||
let minValue = uiControl.min_value;
|
||||
if (minValue === 0) // LSP controls.
|
||||
{
|
||||
minValue = 0.0001;
|
||||
}
|
||||
value = minValue * Math.pow(uiControl.max_value / minValue, range);
|
||||
if (!isFinite(value)) {
|
||||
value = uiControl.max_value;
|
||||
} else if (isNaN(value)) {
|
||||
value = uiControl.min_value;
|
||||
}
|
||||
} else {
|
||||
value = range * (uiControl.max_value - uiControl.min_value) + uiControl.min_value;
|
||||
}
|
||||
@@ -731,23 +765,23 @@ const PluginControl =
|
||||
}
|
||||
|
||||
let item_width: number | undefined = isSelect ? 160 : 80;
|
||||
if (isTrigger)
|
||||
{
|
||||
if (isTrigger) {
|
||||
item_width = undefined;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div ref={this.frameRef}
|
||||
<div ref={this.frameRef}
|
||||
className={this.props.classes.controlFrame}
|
||||
style={{ width: item_width }}
|
||||
>
|
||||
>
|
||||
{/* TITLE SECTION */}
|
||||
<div className={this.props.classes.titleSection}
|
||||
style={
|
||||
{ alignSelf:"stretch", marginBottom: 8, marginLeft: isSelect ? 8 : 0, marginRight: 0
|
||||
style={
|
||||
{
|
||||
alignSelf: "stretch", marginBottom: 8, marginLeft: isSelect ? 8 : 0, marginRight: 0
|
||||
|
||||
}}>
|
||||
}}>
|
||||
<ControlTooltip uiControl={control} >
|
||||
<Typography variant="caption" display="block" noWrap style={{
|
||||
width: "100%",
|
||||
@@ -758,27 +792,56 @@ const PluginControl =
|
||||
{/* CONTROL SECTION */}
|
||||
|
||||
<div className={this.props.classes.midSection}>
|
||||
|
||||
{isTrigger ? (
|
||||
<Button variant="contained" color="primary" size="small"
|
||||
onMouseDown={
|
||||
(evt)=> { this.handleTriggerMouseDown(); }
|
||||
}
|
||||
onMouseUp={
|
||||
(evt)=> { this.handleTriggerMouseUp(); }
|
||||
}
|
||||
style={{
|
||||
textTransform: "none",
|
||||
background: (isDarkMode() ? "#6750A4" : undefined),
|
||||
marginLeft: 8, marginRight: 8,minWidth:60,
|
||||
marginTop: 0
|
||||
|
||||
}}
|
||||
{isTrigger ?
|
||||
(
|
||||
control.name.length !== 1 ? (
|
||||
<Button variant="contained" color="primary" size="small"
|
||||
onMouseDown={
|
||||
(evt) => { this.handleTriggerMouseDown(); }
|
||||
}
|
||||
onMouseUp={
|
||||
(evt) => { this.handleTriggerMouseUp(); }
|
||||
}
|
||||
style={{
|
||||
textTransform: "none",
|
||||
background: (isDarkMode() ? "#6750A4" : undefined),
|
||||
marginLeft: 8, marginRight: 8, minWidth: 60,
|
||||
marginTop: 0
|
||||
|
||||
}}
|
||||
|
||||
>
|
||||
{control.name}
|
||||
</Button>
|
||||
|
||||
) : (
|
||||
<Button variant="contained" color="primary" size="small"
|
||||
onMouseDown={
|
||||
(evt) => { this.handleTriggerMouseDown(); }
|
||||
}
|
||||
onMouseUp={
|
||||
(evt) => { this.handleTriggerMouseUp(); }
|
||||
}
|
||||
style={{
|
||||
textTransform: "none",
|
||||
background: (isDarkMode() ? "#6750A4" : undefined),
|
||||
marginLeft: 8, marginRight: 8,
|
||||
paddingLeft: 0, paddingRight: 0,
|
||||
width: 36, height: 36,
|
||||
marginTop: 0,
|
||||
borderRadius: 8,
|
||||
minWidth: 0,
|
||||
fontSize: "1.2em"
|
||||
|
||||
}}
|
||||
|
||||
>
|
||||
{control.name}
|
||||
</Button>
|
||||
)
|
||||
)
|
||||
|
||||
>
|
||||
{control.name}
|
||||
</Button>
|
||||
)
|
||||
: ((isSelect || isAbSwitch || isOnOffSwitch) ? (
|
||||
this.makeSelect(control, value)
|
||||
) : (
|
||||
|
||||
@@ -767,6 +767,7 @@ const PluginControlView =
|
||||
|
||||
<FilePropertyDialog open={this.state.showFileDialog}
|
||||
fileProperty={this.state.dialogFileProperty}
|
||||
instanceId={this.props.instanceId}
|
||||
selectedFile={this.state.dialogFileValue}
|
||||
onCancel={() => {
|
||||
this.setState({ showFileDialog: false });
|
||||
|
||||
@@ -43,7 +43,8 @@ import EditIcon from '@mui/icons-material//Edit';
|
||||
import RenameDialog from './RenameDialog';
|
||||
|
||||
import Slide, {SlideProps} from '@mui/material/Slide';
|
||||
import { createStyles, Theme } from '@mui/material/styles';
|
||||
import createStyles from '@mui/styles/createStyles';
|
||||
import { Theme } from '@mui/material/styles';
|
||||
import { WithStyles, withStyles} from '@mui/styles';
|
||||
import {ReactComponent as PluginPresetIcon} from "./svg/ic_pluginpreset2.svg";
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ import EditIcon from '@mui/icons-material//Edit';
|
||||
import RenameDialog from './RenameDialog';
|
||||
|
||||
import Slide, {SlideProps} from '@mui/material/Slide';
|
||||
import { createStyles, Theme } from '@mui/material/styles';
|
||||
import createStyles from '@mui/styles/createStyles';
|
||||
import { Theme } from '@mui/material/styles';
|
||||
import { WithStyles, withStyles} from '@mui/styles';
|
||||
import { ReactComponent as DownloadIcon} from './svg/file_download_black_24dp.svg';
|
||||
import { ReactComponent as UploadIcon} from './svg/file_upload_black_24dp.svg';
|
||||
|
||||
@@ -49,7 +49,8 @@ import SystemMidiBindingsDialog from './SystemMidiBindingsDialog';
|
||||
import SelectThemeDialog from './SelectThemeDialog';
|
||||
|
||||
import Slide, { SlideProps } from '@mui/material/Slide';
|
||||
import { createStyles, Theme } from '@mui/material/styles';
|
||||
import createStyles from '@mui/styles/createStyles';
|
||||
import { Theme } from '@mui/material/styles';
|
||||
import { WithStyles, withStyles } from '@mui/styles';
|
||||
import { canScaleWindow, getWindowScaleOptions, getWindowScaleText, setWindowScale, getWindowScale } from './WindowScale';
|
||||
import OptionsDialog from './OptionsDialog';
|
||||
|
||||
@@ -284,23 +284,39 @@ export default class UploadFileDialog extends ResizeResponsiveComponent<UploadFi
|
||||
return this.state.files.length !== 0;
|
||||
}
|
||||
|
||||
getIcon(status: FileUploadStatus) {
|
||||
let size = 14;
|
||||
|
||||
private innerGetIcon(status: FileUploadStatus)
|
||||
{
|
||||
let size=16;
|
||||
let style = { width: size, height: size, opacity: 0.75 };
|
||||
let top = 0;
|
||||
|
||||
switch (status) {
|
||||
case FileUploadStatus.Error:
|
||||
return (<div style={{ position: "relative", top: top }}><ErrorIcon color="error" style={style} /></div>);
|
||||
return (<ErrorIcon color="error" style={style} />);
|
||||
case FileUploadStatus.Uploaded:
|
||||
return (<div style={{ position: "relative", top: top }}><CheckCircleOutlineIcon color="success" style={style}/></div >);
|
||||
return (
|
||||
<CheckCircleOutlineIcon color="success" style={style}/>);
|
||||
case FileUploadStatus.Uploading:
|
||||
return (<CircularProgress size={size } style={{ margin: 2 }} />);
|
||||
|
||||
return (<CircularProgress size={size + "px"} />);
|
||||
|
||||
|
||||
default:
|
||||
return (<SvgIcon style={{ width: size, height: size, }} />);
|
||||
}
|
||||
return (<SvgIcon style={{ width: size, height: size, }} />)
|
||||
}
|
||||
|
||||
}
|
||||
getIcon(status: FileUploadStatus) {
|
||||
let size = 16;
|
||||
|
||||
//let top = 0;
|
||||
|
||||
return (
|
||||
<div style={{ width: size, position: "relative", top: 1 }}>
|
||||
{
|
||||
this.innerGetIcon(status)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
makeBrowserAcceptList() {
|
||||
let result = UiFileType.MergeMimeTypes(this.props.fileProperty.fileTypes);
|
||||
@@ -368,9 +384,13 @@ export default class UploadFileDialog extends ResizeResponsiveComponent<UploadFi
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ verticalAlign: "middle" }}>
|
||||
<div ref={myRef} style={{ display: "flex", flexFlow: "row nowrap", alignItems: "center" }}>
|
||||
<div ref={myRef} style={{
|
||||
display: "flex",
|
||||
flexFlow: "row nowrap",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
{this.getIcon(upload.status)}
|
||||
<Typography variant="caption" noWrap display="block" style={{ marginLeft: 4 }}> {upload.name} </Typography>
|
||||
<Typography variant="caption" noWrap display="inline-block" style={{ marginLeft: 4 }}> {upload.name} </Typography>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ verticalAlign: "middle" }}>
|
||||
|
||||
Reference in New Issue
Block a user