Merged LesionTracker and OHIF Viewer, refactored out common components
This commit is contained in:
1 parent
34adbe3fbc
commit
b774c22593
144 files changed
+27820
No files matched your search
@@ -0,0 +1,5 @@
|
||||
/*AccountsTemplates.configure({
|
||||
forbidClientAccountCreation: true // do not allow account creation
|
||||
});
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
createStacks = function(study) {
|
||||
var stacks = [];
|
||||
// TODO: Split by multi-frame, modality, image size, etc
|
||||
study.seriesList.forEach(function(series) {
|
||||
if (!series.instances) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't display thumbnails for non-image modalities
|
||||
// All imaging modalities must have a valid value for rows (or columns)
|
||||
var anInstance = series.instances[0];
|
||||
if (!anInstance || !anInstance.rows) {
|
||||
return;
|
||||
}
|
||||
stacks.push(series);
|
||||
});
|
||||
return stacks;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Converts the properties to URL query parameters. Based on:
|
||||
* http://stackoverflow.com/questions/111529/create-query-parameters-in-javascript
|
||||
*
|
||||
* @param data
|
||||
* @returns {string}
|
||||
*/
|
||||
encodeQueryData = function(data)
|
||||
{
|
||||
var ret = [];
|
||||
for (var d in data) {
|
||||
if(data[d]) {
|
||||
ret.push(encodeURIComponent(d) + "=" + encodeURIComponent(data[d]));
|
||||
}
|
||||
}
|
||||
return ret.join("&");
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
function checkToken(token, data, dataOffset) {
|
||||
|
||||
if(dataOffset + token.length > data.length) {
|
||||
//console.log('dataOffset >> ', dataOffset);
|
||||
return false;
|
||||
}
|
||||
|
||||
var endIndex = dataOffset;
|
||||
|
||||
for(var i = 0; i < token.length; i++) {
|
||||
if(token[i] !== data[endIndex++]) {
|
||||
if(endIndex > 520000) {
|
||||
console.log('token=',uint8ArrayToString(token));
|
||||
console.log('data=', uint8ArrayToString(data, dataOffset, endIndex-dataOffset));
|
||||
console.log('miss at %d %s dataOffset=%d', i, String.fromCharCode(data[endIndex]), endIndex);
|
||||
console.log('miss at %d %s dataOffset=%d', i, String.fromCharCode(token[endIndex]), endIndex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
findIndexOfString = function(data, str, offset) {
|
||||
|
||||
offset = offset || 0;
|
||||
|
||||
var token = stringToUint8Array(str);
|
||||
|
||||
for(var i=offset; i < data.length; i++) {
|
||||
if(token[0] === data[i]) {
|
||||
//console.log('match @', i);
|
||||
if(checkToken(token, data, i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
getActiveViewportElement = function() {
|
||||
// TODO: Fix this, just hardcoded for now
|
||||
var element = $('.imageViewerViewport').get(0);
|
||||
return element;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
getImageId = function(instance) {
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
if (instance.wadouri) {
|
||||
return 'dicomweb:' + instance.wadouri; // WADO-URI
|
||||
} else {
|
||||
return getWADORSImageId(instance); // WADO-RS Retrieve Frame
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
getWADORSImageId = function(instance) {
|
||||
var columnPixelSpacing = 1.0;
|
||||
var rowPixelSpacing = 1.0;
|
||||
if (instance.pixelSpacing) {
|
||||
var split = instance.pixelSpacing.split('\\');
|
||||
rowPixelSpacing = parseFloat(split[0]);
|
||||
columnPixelSpacing = parseFloat(split[1]);
|
||||
}
|
||||
|
||||
var windowWidth;
|
||||
var windowCenter;
|
||||
|
||||
if (instance.windowWidth && instance.windowCenter) {
|
||||
windowWidth = parseFloat(instance.windowWidth.split('\\')[0]);
|
||||
windowCenter = parseFloat(instance.windowCenter.split('\\')[0]);
|
||||
}
|
||||
|
||||
var image = {
|
||||
uri: instance.wadorsuri,
|
||||
//imageId : '',
|
||||
//minPixelValue : 0,
|
||||
//maxPixelValue : 255,
|
||||
slope: instance.rescaleSlope,
|
||||
intercept: instance.rescaleIntercept,
|
||||
windowCenter : windowCenter,
|
||||
windowWidth : windowWidth,
|
||||
//render: cornerstone.renderColorImage,
|
||||
//getPixelData: getPixelData,
|
||||
//getImageData: getImageData,
|
||||
//getCanvas: getCanvas,
|
||||
rows: instance.rows,
|
||||
columns: instance.columns,
|
||||
height: instance.rows,
|
||||
width: instance.columns,
|
||||
color: false,
|
||||
columnPixelSpacing: columnPixelSpacing,
|
||||
rowPixelSpacing: rowPixelSpacing,
|
||||
invert: false,
|
||||
sizeInBytes: instance.rows * instance.columns * (instance.bitsAllocated / 8),
|
||||
instance: instance
|
||||
};
|
||||
|
||||
var imageId = cornerstoneWADORSImageLoader.addImage(image);
|
||||
return imageId;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
UI.registerHelper('formatDA', function (context, format, options) {
|
||||
if (!context) {
|
||||
return undefined;
|
||||
}
|
||||
/*var date = DICOMUtilities.toDate(context, '', false);
|
||||
if(!date) {
|
||||
return context;
|
||||
}*/
|
||||
var date = context;
|
||||
var dateAsMoment = moment(date);
|
||||
var strFormat = "MMM D, YYYY";
|
||||
if (options) {
|
||||
strFormat = format;
|
||||
}
|
||||
return dateAsMoment.format(strFormat);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
UI.registerHelper('formatNumberPrecision', function(context, precision, options) {
|
||||
if(context) {
|
||||
return parseFloat(context).toFixed(precision);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
UI.registerHelper('formatDA', function (context, format, options) {
|
||||
if (!context) {
|
||||
return undefined;
|
||||
}
|
||||
var pn = context;
|
||||
var str = '';
|
||||
if(pn.prefix) {
|
||||
str = pn.prefix + ' ';
|
||||
}
|
||||
str += (pn.familyName || '') + ', ';
|
||||
str += (pn.givenName || '');
|
||||
if(pn.middleName) {
|
||||
str += ' ' + pn.middleName;
|
||||
}
|
||||
if(pn.suffix) {
|
||||
str += ' ' + pn.suffix;
|
||||
}
|
||||
|
||||
return str;
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
UI.registerHelper('formatTM', function(context, format, options) {
|
||||
if (!context) {
|
||||
return undefined;
|
||||
}
|
||||
var date = context;
|
||||
var dateAsMoment = moment(date);
|
||||
var strFormat = "HH:mm:ss";
|
||||
if (options) {
|
||||
strFormat = format;
|
||||
}
|
||||
|
||||
return dateAsMoment.format(strFormat);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
isTouchDevice = function() {
|
||||
return (('ontouchstart' in window) ||
|
||||
(navigator.MaxTouchPoints > 0) ||
|
||||
(navigator.msMaxTouchPoints > 0));
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
var metaDataLookup = {};
|
||||
|
||||
addMetaData = function(imageId, data) {
|
||||
var instanceMetaData = data.instance;
|
||||
var seriesMetaData = data.series;
|
||||
var studyMetaData = data.study;
|
||||
var imageIndex = data.imageIndex;
|
||||
var numImages = data.numImages;
|
||||
|
||||
var metaData = {};
|
||||
|
||||
metaData.study = {
|
||||
date: instanceMetaData.studyDate,
|
||||
time: instanceMetaData.studyTime,
|
||||
description: instanceMetaData.studyDescription
|
||||
};
|
||||
|
||||
metaData.series = {
|
||||
description: seriesMetaData.seriesDescription,
|
||||
number: seriesMetaData.seriesNumber,
|
||||
modality: seriesMetaData.modality,
|
||||
instanceUid: seriesMetaData.instanceUid,
|
||||
numImages: numImages
|
||||
};
|
||||
|
||||
metaData.instance = {
|
||||
number: instanceMetaData.instanceNumber,
|
||||
index: imageIndex
|
||||
};
|
||||
|
||||
metaData.patient = {
|
||||
name: instanceMetaData.patientName,
|
||||
id: instanceMetaData.patientId
|
||||
};
|
||||
|
||||
// If there is sufficient information, populate
|
||||
// the imagePlane object for easier use in the Viewer
|
||||
if (instanceMetaData.frameOfReferenceUID &&
|
||||
instanceMetaData.imageOrientationPatient &&
|
||||
instanceMetaData.imagePositionPatient) {
|
||||
|
||||
var imageOrientation = instanceMetaData.imageOrientationPatient.split('\\');
|
||||
var imagePosition = instanceMetaData.imagePositionPatient.split('\\');
|
||||
|
||||
var columnPixelSpacing = 1.0;
|
||||
var rowPixelSpacing = 1.0;
|
||||
if (instanceMetaData.pixelSpacing) {
|
||||
var split = instanceMetaData.pixelSpacing.split('\\');
|
||||
rowPixelSpacing = parseFloat(split[0]);
|
||||
columnPixelSpacing = parseFloat(split[1]);
|
||||
}
|
||||
|
||||
metaData.imagePlane = {
|
||||
frameOfReferenceUID: instanceMetaData.frameOfReferenceUID,
|
||||
rows: instanceMetaData.rows,
|
||||
columns: instanceMetaData.columns,
|
||||
rowCosines: new cornerstoneMath.Vector3(parseFloat(imageOrientation[0]), parseFloat(imageOrientation[1]), parseFloat(imageOrientation[2])),
|
||||
columnCosines: new cornerstoneMath.Vector3(parseFloat(imageOrientation[3]), parseFloat(imageOrientation[4]), parseFloat(imageOrientation[5])),
|
||||
imagePositionPatient: new cornerstoneMath.Vector3(parseFloat(imagePosition[0]), parseFloat(imagePosition[1]), parseFloat(imagePosition[2])),
|
||||
rowPixelSpacing: rowPixelSpacing,
|
||||
columnPixelSpacing: columnPixelSpacing,
|
||||
};
|
||||
}
|
||||
|
||||
// Add the metaData to the imageId lookup object
|
||||
metaDataLookup[imageId] = metaData;
|
||||
};
|
||||
|
||||
function provider(type, imageId) {
|
||||
var imageMetaData = metaDataLookup[imageId];
|
||||
if (!imageMetaData) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (imageMetaData.hasOwnProperty(type)) {
|
||||
return imageMetaData[type];
|
||||
}
|
||||
}
|
||||
|
||||
Meteor.startup(function() {
|
||||
cornerstoneTools.metaData.addProvider(provider);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* This function destroys and rerenders the imageViewerViewport template.
|
||||
* It uses the data provided (object containing seriesIndex and studyIndex) to
|
||||
* load a new series into the produced viewport.
|
||||
*
|
||||
* @param element
|
||||
* @param data An object containing a seriesIndex and studyIndex for a study to load into this viewport
|
||||
*/
|
||||
rerenderViewportWithNewSeries = function(element, data) {
|
||||
var container = $(element).parents('.viewportContainer').get(0);
|
||||
var viewportIndex = $(container).index();
|
||||
data.viewportIndex = viewportIndex;
|
||||
|
||||
// Update the dictionary of loaded series for the specified viewport
|
||||
OHIF.viewer.imageViewerLoadedSeriesDictionary[viewportIndex] = {
|
||||
seriesInstanceUid: data.seriesInstanceUid,
|
||||
studyInstanceUid: data.studyInstanceUid
|
||||
};
|
||||
|
||||
// Remove the hover styling
|
||||
$(element).find('canvas').not('.magnifyTool').removeClass("faded");
|
||||
|
||||
// Reset the current image index for this viewport
|
||||
delete OHIF.viewer.imageViewerCurrentImageIdIndexDictionary[viewportIndex];
|
||||
|
||||
// Remove the whole template, add in the new one
|
||||
var viewportContainer = $(element).parents('.removable');
|
||||
|
||||
var newViewportContainer = document.createElement('div');
|
||||
newViewportContainer.className = 'removable';
|
||||
|
||||
// Remove the parent element of the template
|
||||
// This is a workaround since otherwise Blaze UI onDestroyed doesn't fire
|
||||
viewportContainer.remove();
|
||||
|
||||
container.appendChild(newViewportContainer);
|
||||
|
||||
// Render and insert the template
|
||||
UI.renderWithData(Template.imageViewerViewport, data, newViewportContainer);
|
||||
|
||||
Session.set('loadedSeriesDictionary', OHIF.viewer.imageViewerLoadedSeriesDictionary);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
sortStudy = function(study) {
|
||||
if (!study) {
|
||||
return;
|
||||
}
|
||||
|
||||
study.seriesList.sort(function(a, b) {
|
||||
return a.seriesNumber - b.seriesNumber;
|
||||
});
|
||||
|
||||
study.seriesList.forEach(function(series){
|
||||
series.instances.sort(function(a, b) {
|
||||
return a.instanceNumber - b.instanceNumber;
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
stringToUint8Array = function(str) {
|
||||
var uint=new Uint8Array(str.length);
|
||||
for(var i=0,j=str.length;i<j;i++){
|
||||
uint[i]=str.charCodeAt(i);
|
||||
}
|
||||
return uint;
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
var activeTool;
|
||||
|
||||
var tools = {};
|
||||
|
||||
var initialized = false;
|
||||
|
||||
function configureTools() {
|
||||
// Set the configuration for the multitouch pan tool
|
||||
var multiTouchPanConfig = {
|
||||
testPointers: function(eventData) {
|
||||
return (eventData.numPointers >= 3);
|
||||
}
|
||||
};
|
||||
cornerstoneTools.panMultiTouch.setConfiguration(multiTouchPanConfig);
|
||||
}
|
||||
|
||||
toolManager = {
|
||||
init: function() {
|
||||
toolManager.addTool('wwwc', {
|
||||
mouse: cornerstoneTools.wwwc,
|
||||
touch: cornerstoneTools.wwwcTouchDrag
|
||||
});
|
||||
toolManager.addTool('zoom', {
|
||||
mouse: cornerstoneTools.zoom,
|
||||
touch: cornerstoneTools.zoomTouchDrag
|
||||
});
|
||||
toolManager.addTool('wwwcRegion', {
|
||||
mouse: cornerstoneTools.wwwcRegion,
|
||||
touch: cornerstoneTools.wwwcRegionTouch
|
||||
});
|
||||
toolManager.addTool('dragProbe', {
|
||||
mouse: cornerstoneTools.dragProbe,
|
||||
touch: cornerstoneTools.dragProbeTouch
|
||||
});
|
||||
toolManager.addTool('pan', {
|
||||
mouse: cornerstoneTools.pan,
|
||||
touch: cornerstoneTools.panTouchDrag
|
||||
});
|
||||
toolManager.addTool('stackScroll', {
|
||||
mouse: cornerstoneTools.stackScroll,
|
||||
touch: cornerstoneTools.stackScrollTouchDrag
|
||||
});
|
||||
toolManager.addTool('length', {
|
||||
mouse: cornerstoneTools.length,
|
||||
touch: cornerstoneTools.lengthTouch
|
||||
});
|
||||
toolManager.addTool('angle', {
|
||||
mouse: cornerstoneTools.simpleAngle,
|
||||
touch: cornerstoneTools.simpleAngleTouch
|
||||
});
|
||||
toolManager.addTool('magnify', {
|
||||
mouse: cornerstoneTools.magnify,
|
||||
touch: cornerstoneTools.magnifyTouchDrag
|
||||
});
|
||||
toolManager.addTool('ellipticalRoi', {
|
||||
mouse: cornerstoneTools.ellipticalRoi,
|
||||
touch: cornerstoneTools.ellipticalRoiTouch
|
||||
});
|
||||
toolManager.addTool('rectangleRoi', {
|
||||
mouse: cornerstoneTools.rectangleRoi,
|
||||
touch: cornerstoneTools.rectangleRoiTouch
|
||||
});
|
||||
toolManager.addTool('annotate', {
|
||||
mouse: cornerstoneTools.arrowAnnotate,
|
||||
touch: cornerstoneTools.arrowAnnotateTouch
|
||||
});
|
||||
activeTool = OHIF.viewer.defaultTool;
|
||||
|
||||
configureTools();
|
||||
initialized = true;
|
||||
},
|
||||
addTool: function(name, base) {
|
||||
tools[name] = base;
|
||||
},
|
||||
setActiveTool: function(tool) {
|
||||
if (!initialized) {
|
||||
toolManager.init();
|
||||
}
|
||||
|
||||
$('#toolRow .btn-group button').removeClass('active');
|
||||
var toolButton = document.getElementById(tool);
|
||||
toolButton.classList.add('active');
|
||||
|
||||
$('.imageViewerViewport').each(function(index, element) {
|
||||
var canvases = $(element).find('canvas');
|
||||
if (!element.classList.contains('empty') && canvases.length > 0) {
|
||||
// First, deactivate the current active tool
|
||||
tools[activeTool].mouse.deactivate(element, 1);
|
||||
tools[activeTool].touch.deactivate(element);
|
||||
|
||||
// First, get the stack toolData
|
||||
var toolData = cornerstoneTools.getToolState(element, 'stack');
|
||||
if (toolData === undefined || toolData.data === undefined || toolData.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the imageIds for this element
|
||||
var imageIds = toolData.data[0].imageIds;
|
||||
|
||||
// Deactivate all the middle mouse, right click, and scroll wheel tools
|
||||
cornerstoneTools.pan.deactivate(element);
|
||||
cornerstoneTools.zoom.deactivate(element);
|
||||
cornerstoneTools.zoomWheel.deactivate(element);
|
||||
cornerstoneTools.stackScrollWheel.deactivate(element);
|
||||
|
||||
// Reactivate the relevant scrollwheel tool for this element
|
||||
if (imageIds.length > 1) {
|
||||
// scroll is the default tool for middle mouse wheel for stacks
|
||||
cornerstoneTools.stackScrollWheel.activate(element);
|
||||
} else {
|
||||
// zoom is the default tool for middle mouse wheel for single images (non stacks)
|
||||
cornerstoneTools.zoomWheel.activate(element);
|
||||
}
|
||||
|
||||
// This block ensures that the middle mouse and scroll tools keep working
|
||||
if (tool === 'pan') {
|
||||
cornerstoneTools.pan.activate(element, 3); // 3 means left mouse button and middle mouse button
|
||||
cornerstoneTools.zoom.activate(element, 4); // zoom is the default tool for right mouse button
|
||||
} else if (tool === 'zoom') {
|
||||
cornerstoneTools.pan.activate(element, 2); // pan is the default tool for middle mouse button
|
||||
cornerstoneTools.zoom.activate(element, 5); // 5 means left mouse button and right mouse button
|
||||
} else {
|
||||
// Reactivate the middle mouse and right click tools
|
||||
cornerstoneTools.pan.activate(element, 2); // pan is the default tool for middle mouse button
|
||||
cornerstoneTools.zoom.activate(element, 4); // zoom is the default tool for right mouse button
|
||||
|
||||
// Activate the chosen tool
|
||||
tools[tool].mouse.activate(element, 1);
|
||||
}
|
||||
tools[tool].touch.activate(element);
|
||||
|
||||
cornerstoneTools.zoomTouchPinch.activate(element);
|
||||
cornerstoneTools.panMultiTouch.activate(element);
|
||||
}
|
||||
});
|
||||
activeTool = tool;
|
||||
},
|
||||
getActiveTool: function() {
|
||||
if (!activeTool) {
|
||||
activeTool = OHIF.viewer.defaultTool;
|
||||
}
|
||||
return activeTool;
|
||||
},
|
||||
setDefaultTool: function(tool) {
|
||||
OHIF.viewer.defaultTool = tool;
|
||||
},
|
||||
getDefaultTool: function() {
|
||||
return OHIF.viewer.defaultTool;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
uint8ArrayToString = function(data, offset, length) {
|
||||
offset = offset || 0;
|
||||
length = length || data.length - offset;
|
||||
var str = "";
|
||||
for(var i=offset; i < offset + length; i++) {
|
||||
str += String.fromCharCode(data[i]);
|
||||
}
|
||||
return str;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
updateOrientationMarkers = function(element, viewport) {
|
||||
// Updates the orientation labels on the viewport
|
||||
var enabledElement = cornerstone.getEnabledElement(element);
|
||||
var imagePlane = cornerstoneTools.metaData.get('imagePlane', enabledElement.image.imageId);
|
||||
|
||||
if (!imagePlane || !imagePlane.rowCosines || !imagePlane.columnCosines) {
|
||||
return;
|
||||
}
|
||||
|
||||
var rowString = cornerstoneTools.orientation.getOrientationString(imagePlane.rowCosines);
|
||||
var columnString = cornerstoneTools.orientation.getOrientationString(imagePlane.columnCosines);
|
||||
var oppositeRowString = cornerstoneTools.orientation.invertOrientationString(rowString);
|
||||
var oppositeColumnString = cornerstoneTools.orientation.invertOrientationString(columnString);
|
||||
|
||||
var markers = {
|
||||
top: oppositeColumnString,
|
||||
left: oppositeRowString,
|
||||
};
|
||||
|
||||
// If any vertical or horizontal flips are applied, change the orientation strings ahead of
|
||||
// the rotation applications
|
||||
if (viewport.vflip) {
|
||||
markers.top = cornerstoneTools.orientation.invertOrientationString(markers.top);
|
||||
}
|
||||
if (viewport.hflip) {
|
||||
markers.left = cornerstoneTools.orientation.invertOrientationString(markers.left);
|
||||
}
|
||||
|
||||
// Get the viewport orientation marker DOM elements
|
||||
var viewportOrientationMarkers = $(element).siblings(".viewportOrientationMarkers");
|
||||
var topMarker = viewportOrientationMarkers.find('.topMid');
|
||||
var leftMarker = viewportOrientationMarkers.find('.leftMid');
|
||||
|
||||
// Swap the labels accordingly if the viewport has been rotated
|
||||
// This could be done in a more complex way for intermediate rotation values (e.g. 45 degrees)
|
||||
if (viewport.rotation === 90 || viewport.rotation === -270) {
|
||||
topMarker.text(markers.left);
|
||||
leftMarker.text(cornerstoneTools.orientation.invertOrientationString(markers.top));
|
||||
} else if (viewport.rotation === -90 || viewport.rotation === 270) {
|
||||
topMarker.text(cornerstoneTools.orientation.invertOrientationString(markers.left));
|
||||
leftMarker.text(markers.top);
|
||||
} else if (viewport.rotation === 180 || viewport.rotation === -180) {
|
||||
topMarker.text(cornerstoneTools.orientation.invertOrientationString(markers.top));
|
||||
leftMarker.text(cornerstoneTools.orientation.invertOrientationString(markers.left));
|
||||
} else {
|
||||
topMarker.text(markers.top);
|
||||
leftMarker.text(markers.left);
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user