fix(front-end): calculator improvements
This commit is contained in:
@@ -3,6 +3,7 @@ package com.printcalculator.controller;
|
|||||||
import com.printcalculator.entity.PrinterMachine;
|
import com.printcalculator.entity.PrinterMachine;
|
||||||
import com.printcalculator.entity.QuoteLineItem;
|
import com.printcalculator.entity.QuoteLineItem;
|
||||||
import com.printcalculator.entity.QuoteSession;
|
import com.printcalculator.entity.QuoteSession;
|
||||||
|
import com.printcalculator.model.ModelDimensions;
|
||||||
import com.printcalculator.model.PrintStats;
|
import com.printcalculator.model.PrintStats;
|
||||||
import com.printcalculator.model.QuoteResult;
|
import com.printcalculator.model.QuoteResult;
|
||||||
import com.printcalculator.repository.PrinterMachineRepository;
|
import com.printcalculator.repository.PrinterMachineRepository;
|
||||||
@@ -28,6 +29,7 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.Optional;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.UrlResource;
|
import org.springframework.core.io.UrlResource;
|
||||||
|
|
||||||
@@ -183,6 +185,8 @@ public class QuoteSessionController {
|
|||||||
null, // machine overrides
|
null, // machine overrides
|
||||||
processOverrides
|
processOverrides
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Optional<ModelDimensions> modelDimensions = slicerService.inspectModelDimensions(persistentPath.toFile());
|
||||||
|
|
||||||
// 4. Calculate Quote
|
// 4. Calculate Quote
|
||||||
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
|
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
|
||||||
@@ -206,14 +210,16 @@ public class QuoteSessionController {
|
|||||||
breakdown.put("setup_fee", 0);
|
breakdown.put("setup_fee", 0);
|
||||||
item.setPricingBreakdown(breakdown);
|
item.setPricingBreakdown(breakdown);
|
||||||
|
|
||||||
// Dimensions
|
// Dimensions for shipping/package checks are computed server-side from the uploaded model.
|
||||||
// Cannot get bb from GCodeParser yet?
|
item.setBoundingBoxXMm(modelDimensions
|
||||||
// If GCodeParser doesn't return size, we might defaults or 0.
|
.map(dim -> BigDecimal.valueOf(dim.xMm()))
|
||||||
// Stats has filament used.
|
.orElseGet(() -> settings.getBoundingBoxX() != null ? BigDecimal.valueOf(settings.getBoundingBoxX()) : BigDecimal.ZERO));
|
||||||
// Let's set dummy for now or upgrade parser later.
|
item.setBoundingBoxYMm(modelDimensions
|
||||||
item.setBoundingBoxXMm(settings.getBoundingBoxX() != null ? BigDecimal.valueOf(settings.getBoundingBoxX()) : BigDecimal.ZERO);
|
.map(dim -> BigDecimal.valueOf(dim.yMm()))
|
||||||
item.setBoundingBoxYMm(settings.getBoundingBoxY() != null ? BigDecimal.valueOf(settings.getBoundingBoxY()) : BigDecimal.ZERO);
|
.orElseGet(() -> settings.getBoundingBoxY() != null ? BigDecimal.valueOf(settings.getBoundingBoxY()) : BigDecimal.ZERO));
|
||||||
item.setBoundingBoxZMm(settings.getBoundingBoxZ() != null ? BigDecimal.valueOf(settings.getBoundingBoxZ()) : BigDecimal.ZERO);
|
item.setBoundingBoxZMm(modelDimensions
|
||||||
|
.map(dim -> BigDecimal.valueOf(dim.zMm()))
|
||||||
|
.orElseGet(() -> settings.getBoundingBoxZ() != null ? BigDecimal.valueOf(settings.getBoundingBoxZ()) : BigDecimal.ZERO));
|
||||||
|
|
||||||
item.setCreatedAt(OffsetDateTime.now());
|
item.setCreatedAt(OffsetDateTime.now());
|
||||||
item.setUpdatedAt(OffsetDateTime.now());
|
item.setUpdatedAt(OffsetDateTime.now());
|
||||||
@@ -371,7 +377,16 @@ public class QuoteSessionController {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BigDecimal shippingCostChf = exceedsBaseSize ? BigDecimal.valueOf(4.00) : BigDecimal.valueOf(2.00);
|
int totalQuantity = items.stream()
|
||||||
|
.mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 1)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
BigDecimal shippingCostChf;
|
||||||
|
if (exceedsBaseSize) {
|
||||||
|
shippingCostChf = totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00);
|
||||||
|
} else {
|
||||||
|
shippingCostChf = BigDecimal.valueOf(2.00);
|
||||||
|
}
|
||||||
|
|
||||||
BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCostChf);
|
BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCostChf);
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,15 @@ public class OrderService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
order.setShippingCostChf(exceedsBaseSize ? BigDecimal.valueOf(4.00) : BigDecimal.valueOf(2.00));
|
int totalQuantity = quoteItems.stream()
|
||||||
|
.mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 1)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
if (exceedsBaseSize) {
|
||||||
|
order.setShippingCostChf(totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00));
|
||||||
|
} else {
|
||||||
|
order.setShippingCostChf(BigDecimal.valueOf(2.00));
|
||||||
|
}
|
||||||
|
|
||||||
order = orderRepo.save(order);
|
order = orderRepo.save(order);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.printcalculator.service;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.printcalculator.model.ModelDimensions;
|
||||||
import com.printcalculator.model.PrintStats;
|
import com.printcalculator.model.PrintStats;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -15,13 +16,19 @@ import java.util.ArrayList;
|
|||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SlicerService {
|
public class SlicerService {
|
||||||
|
|
||||||
private static final Logger logger = Logger.getLogger(SlicerService.class.getName());
|
private static final Logger logger = Logger.getLogger(SlicerService.class.getName());
|
||||||
|
private static final Pattern SIZE_X_PATTERN = Pattern.compile("(?m)^\\s*size_x\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
|
||||||
|
private static final Pattern SIZE_Y_PATTERN = Pattern.compile("(?m)^\\s*size_y\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
|
||||||
|
private static final Pattern SIZE_Z_PATTERN = Pattern.compile("(?m)^\\s*size_z\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
|
||||||
|
|
||||||
private final String slicerPath;
|
private final String slicerPath;
|
||||||
private final ProfileManager profileManager;
|
private final ProfileManager profileManager;
|
||||||
@@ -149,6 +156,89 @@ public class SlicerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Optional<ModelDimensions> inspectModelDimensions(File inputModel) {
|
||||||
|
Path tempDir = null;
|
||||||
|
try {
|
||||||
|
tempDir = Files.createTempDirectory("slicer_info_");
|
||||||
|
Path infoLogPath = tempDir.resolve("orcaslicer-info.log");
|
||||||
|
|
||||||
|
List<String> command = new ArrayList<>();
|
||||||
|
command.add(slicerPath);
|
||||||
|
command.add("--info");
|
||||||
|
command.add(inputModel.getAbsolutePath());
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
|
pb.directory(tempDir.toFile());
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
pb.redirectOutput(infoLogPath.toFile());
|
||||||
|
|
||||||
|
Process process = pb.start();
|
||||||
|
boolean finished = process.waitFor(2, TimeUnit.MINUTES);
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
logger.warning("Model info extraction timed out for " + inputModel.getName());
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String output = Files.exists(infoLogPath)
|
||||||
|
? Files.readString(infoLogPath, StandardCharsets.UTF_8)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (process.exitValue() != 0) {
|
||||||
|
logger.warning("OrcaSlicer --info failed (exit " + process.exitValue() + ") for "
|
||||||
|
+ inputModel.getName() + ": " + output);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<ModelDimensions> parsed = parseModelDimensionsFromInfoOutput(output);
|
||||||
|
if (parsed.isEmpty()) {
|
||||||
|
logger.warning("Could not parse size_x/size_y/size_z from OrcaSlicer --info output for "
|
||||||
|
+ inputModel.getName() + ": " + output);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warning("Failed to inspect model dimensions for " + inputModel.getName() + ": " + e.getMessage());
|
||||||
|
return Optional.empty();
|
||||||
|
} finally {
|
||||||
|
if (tempDir != null) {
|
||||||
|
deleteRecursively(tempDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Optional<ModelDimensions> parseModelDimensionsFromInfoOutput(String output) {
|
||||||
|
if (output == null || output.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
Double x = extractDouble(SIZE_X_PATTERN, output);
|
||||||
|
Double y = extractDouble(SIZE_Y_PATTERN, output);
|
||||||
|
Double z = extractDouble(SIZE_Z_PATTERN, output);
|
||||||
|
|
||||||
|
if (x == null || y == null || z == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x <= 0 || y <= 0 || z <= 0) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.of(new ModelDimensions(x, y, z));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double extractDouble(Pattern pattern, String text) {
|
||||||
|
Matcher matcher = pattern.matcher(text);
|
||||||
|
if (!matcher.find()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(matcher.group(1));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void deleteRecursively(Path path) {
|
private void deleteRecursively(Path path) {
|
||||||
if (path == null || !Files.exists(path)) {
|
if (path == null || !Files.exists(path)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -10,15 +10,11 @@ import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl
|
|||||||
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
|
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
|
||||||
import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, MaterialOption, VariantOption } from '../../services/quote-estimator.service';
|
import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, MaterialOption, VariantOption } from '../../services/quote-estimator.service';
|
||||||
import { getColorHex } from '../../../../core/constants/colors.const';
|
import { getColorHex } from '../../../../core/constants/colors.const';
|
||||||
import * as THREE from 'three';
|
|
||||||
// @ts-ignore
|
|
||||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
|
||||||
|
|
||||||
interface FormItem {
|
interface FormItem {
|
||||||
file: File;
|
file: File;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
color: string;
|
color: string;
|
||||||
dimensions?: {x: number, y: number, z: number};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -74,35 +70,6 @@ export class UploadFormComponent implements OnInit {
|
|||||||
return name.endsWith('.stl');
|
return name.endsWith('.stl');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getStlDimensions(file: File): Promise<{x: number, y: number, z: number} | null> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
try {
|
|
||||||
const loader = new STLLoader();
|
|
||||||
const geometry = loader.parse(e.target?.result as ArrayBuffer);
|
|
||||||
geometry.computeBoundingBox();
|
|
||||||
if (geometry.boundingBox) {
|
|
||||||
const size = new THREE.Vector3();
|
|
||||||
geometry.boundingBox.getSize(size);
|
|
||||||
resolve({
|
|
||||||
x: Math.round(size.x * 10) / 10,
|
|
||||||
y: Math.round(size.y * 10) / 10,
|
|
||||||
z: Math.round(size.z * 10) / 10
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(null);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error parsing STL for dimensions:", err);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.onerror = () => resolve(null);
|
|
||||||
reader.readAsArrayBuffer(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
itemsTouched: [false], // Hack to track touched state for custom items list
|
itemsTouched: [false], // Hack to track touched state for custom items list
|
||||||
@@ -189,7 +156,7 @@ export class UploadFormComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onFilesDropped(newFiles: File[]) {
|
onFilesDropped(newFiles: File[]) {
|
||||||
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
|
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
|
||||||
const validItems: FormItem[] = [];
|
const validItems: FormItem[] = [];
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
@@ -198,13 +165,8 @@ export class UploadFormComponent implements OnInit {
|
|||||||
if (file.size > MAX_SIZE) {
|
if (file.size > MAX_SIZE) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
} else {
|
} else {
|
||||||
let dimensions = undefined;
|
|
||||||
if (file.name.toLowerCase().endsWith('.stl')) {
|
|
||||||
const dims = await this.getStlDimensions(file);
|
|
||||||
if (dims) dimensions = dims;
|
|
||||||
}
|
|
||||||
// Default color is Black
|
// Default color is Black
|
||||||
validItems.push({ file, quantity: 1, color: 'Black', dimensions });
|
validItems.push({ file, quantity: 1, color: 'Black' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,16 +258,11 @@ export class UploadFormComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFiles(files: File[]) {
|
setFiles(files: File[]) {
|
||||||
const validItems: FormItem[] = [];
|
const validItems: FormItem[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
let dimensions = undefined;
|
|
||||||
if (file.name.toLowerCase().endsWith('.stl')) {
|
|
||||||
const dims = await this.getStlDimensions(file);
|
|
||||||
if (dims) dimensions = dims;
|
|
||||||
}
|
|
||||||
// Default color is Black or derive from somewhere if possible, but here we just init
|
// Default color is Black or derive from somewhere if possible, but here we just init
|
||||||
validItems.push({ file, quantity: 1, color: 'Black', dimensions });
|
validItems.push({ file, quantity: 1, color: 'Black' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validItems.length > 0) {
|
if (validItems.length > 0) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { map, catchError, tap } from 'rxjs/operators';
|
|||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
|
|
||||||
export interface QuoteRequest {
|
export interface QuoteRequest {
|
||||||
items: { file: File, quantity: number, color?: string, dimensions?: {x: number, y: number, z: number} }[];
|
items: { file: File, quantity: number, color?: string }[];
|
||||||
material: string;
|
material: string;
|
||||||
quality: string;
|
quality: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -273,9 +273,6 @@ export class QuoteEstimatorService {
|
|||||||
quality: easyPreset ? easyPreset.quality : request.quality,
|
quality: easyPreset ? easyPreset.quality : request.quality,
|
||||||
supportsEnabled: request.supportEnabled,
|
supportsEnabled: request.supportEnabled,
|
||||||
color: item.color || '#FFFFFF',
|
color: item.color || '#FFFFFF',
|
||||||
boundingBoxX: item.dimensions?.x,
|
|
||||||
boundingBoxY: item.dimensions?.y,
|
|
||||||
boundingBoxZ: item.dimensions?.z,
|
|
||||||
layerHeight: easyPreset ? easyPreset.layerHeight : request.layerHeight,
|
layerHeight: easyPreset ? easyPreset.layerHeight : request.layerHeight,
|
||||||
infillDensity: easyPreset ? easyPreset.infillDensity : request.infillDensity,
|
infillDensity: easyPreset ? easyPreset.infillDensity : request.infillDensity,
|
||||||
infillPattern: easyPreset ? easyPreset.infillPattern : request.infillPattern,
|
infillPattern: easyPreset ? easyPreset.infillPattern : request.infillPattern,
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
|
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div formGroupName="billingAddress">
|
<div formGroupName="billingAddress">
|
||||||
|
<!-- User Type Selector -->
|
||||||
|
<app-toggle-selector class="mb-4 user-type-selector-compact"
|
||||||
|
[options]="userTypeOptions"
|
||||||
|
[selectedValue]="checkoutForm.get('customerType')?.value"
|
||||||
|
(selectionChange)="setCustomerType($event)">
|
||||||
|
</app-toggle-selector>
|
||||||
|
|
||||||
<!-- Private Person Fields -->
|
<!-- Private Person Fields -->
|
||||||
<div *ngIf="!isCompany" class="form-row">
|
<div *ngIf="!isCompany" class="form-row">
|
||||||
@@ -44,13 +50,6 @@
|
|||||||
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
|
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
|
||||||
<app-input formControlName="referencePerson" [label]="'CONTACT.REF_PERSON' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
|
<app-input formControlName="referencePerson" [label]="'CONTACT.REF_PERSON' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Type Selector -->
|
|
||||||
<app-toggle-selector
|
|
||||||
[options]="userTypeOptions"
|
|
||||||
[selectedValue]="checkoutForm.get('customerType')?.value"
|
|
||||||
(selectionChange)="setCustomerType($event)">
|
|
||||||
</app-toggle-selector>
|
|
||||||
|
|
||||||
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
|
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
|
||||||
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
|
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
|
||||||
|
|||||||
@@ -65,6 +65,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* User Type Selector CSS has been extracted to app-toggle-selector component */
|
/* User Type Selector CSS has been extracted to app-toggle-selector component */
|
||||||
|
app-toggle-selector.user-type-selector-compact {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
.company-fields {
|
.company-fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user