Listo para release
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"defultServer": "http://localhost:3000",
|
||||
"defaultPrinter": "192.168.1.254"
|
||||
"defaultServer": "http://localhost:3000",
|
||||
"defaultPrinterIp": "192.168.1.254",
|
||||
"defaultPrinterPort": 9100
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export class LogService {
|
||||
this.info(`--- Log initialized at ${now.toISOString()} ---`);
|
||||
console.log(`Log file: ${this.logPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Garantizar que sea singleton
|
||||
* @returns
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function printReqHandler(
|
||||
logger.logOperation("printReq", data);
|
||||
return new Promise((res, rej) => {
|
||||
console.log("nfc:printReq", data);
|
||||
const port = 9100;
|
||||
const port = data.printerPort;
|
||||
let finished = false;
|
||||
|
||||
const socket = createConnection(port, data.printerURL);
|
||||
|
||||
@@ -5,6 +5,7 @@ import icon from "../../resources/icon.png?asset";
|
||||
import { NfcService } from "../services/NfcService";
|
||||
import { labelReqHandler, printReqHandler } from "./handlers";
|
||||
import { logger } from "./LogService";
|
||||
import { createConnection } from "net";
|
||||
|
||||
function createWindow(): void {
|
||||
// Create the browser window.
|
||||
@@ -87,6 +88,41 @@ app.whenReady().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"ping:socket",
|
||||
async (_event, data: { ip: string; port: number }) => {
|
||||
return new Promise((req, rej) => {
|
||||
const { ip, port } = data;
|
||||
let finished = false;
|
||||
console.log("Pinging,", ip, port);
|
||||
const socket = createConnection(port, ip);
|
||||
socket.setTimeout(3 * 1000);
|
||||
|
||||
socket.on("connect", (e) => {
|
||||
console.log("Conectado!", e);
|
||||
logger.info("Printer connected, sending label...");
|
||||
socket.end();
|
||||
finished = true;
|
||||
req(true);
|
||||
});
|
||||
|
||||
socket.on("timeout", (err) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
socket.destroy();
|
||||
rej(false);
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
socket.destroy();
|
||||
rej(false);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
createWindow();
|
||||
|
||||
app.on("activate", function () {
|
||||
|
||||
3
src/preload/index.d.ts
vendored
3
src/preload/index.d.ts
vendored
@@ -19,6 +19,7 @@ export type CodeRequest = {
|
||||
|
||||
export type PrinterRequest = {
|
||||
printerURL: string;
|
||||
printerPort: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
@@ -31,10 +32,12 @@ export interface NfcAPI {
|
||||
onRemoved: (callback: (event: { uid: string }) => void) => void;
|
||||
onError: (callback: (event: { message: string }) => void) => void;
|
||||
ping: (url: string) => Promise<boolean>;
|
||||
pingSocket: (args: { ip: string; port: number }) => Promise<boolean>;
|
||||
removeAllListeners: () => void;
|
||||
labelReq: (args: CodeRequest) => Promise<Result<string, CodeResponse>>;
|
||||
printReq: (args: {
|
||||
printerURL: string;
|
||||
printerPort: number;
|
||||
label: string;
|
||||
}) => Promise<PrinterResponse>;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ const api = {
|
||||
ping: (url: string): Promise<boolean> => {
|
||||
return ipcRenderer.invoke("ping:url", url);
|
||||
},
|
||||
pingSocket: (args: { ip: string; port: number }): Promise<boolean> => {
|
||||
return ipcRenderer.invoke("ping:socket", args);
|
||||
},
|
||||
removeAllListeners: (): void => {
|
||||
ipcRenderer.removeAllListeners("nfc:reader");
|
||||
ipcRenderer.removeAllListeners("nfc:tag");
|
||||
|
||||
@@ -18,13 +18,13 @@ const navigateTo = (view: "main" | "config"): void => {
|
||||
<div class="logo-text">NFC.INTERFACE.v1</div>
|
||||
<div class="technical-info">READY // LOCAL_ACCESS</div>
|
||||
</div>
|
||||
|
||||
|
||||
<nav class="nav-section">
|
||||
<button v-if="currentView === 'main'" class="nav-btn" @click="navigateTo('config')">
|
||||
CONFIG
|
||||
</button>
|
||||
<button v-else class="nav-btn" @click="navigateTo('main')">
|
||||
EXIT_CONFIG
|
||||
SALIR_CONFIG
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { getConfig, getDefaultConfig } from "../utils/config";
|
||||
|
||||
const printerUrl = ref(localStorage.getItem("printerUrl") || "192.168.1.254");
|
||||
const serverUrl = ref(
|
||||
localStorage.getItem("serverUrl") || "http://localhost:3000",
|
||||
);
|
||||
const config = getConfig();
|
||||
const printerUrl = ref(config.printerIp);
|
||||
const printerPort = ref(config.printerPort);
|
||||
const serverUrl = ref(config.serverUrl);
|
||||
const errorMsg = ref("");
|
||||
const isSuccess = ref(false);
|
||||
const pingStatus = ref<{
|
||||
@@ -14,22 +15,12 @@ const pingStatus = ref<{
|
||||
server: "idle",
|
||||
});
|
||||
|
||||
const validateIpOrUrl = (value: string): boolean => {
|
||||
if (!value) return false;
|
||||
|
||||
// Basic IP regex
|
||||
const ipRegex = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
|
||||
// Basic URL regex
|
||||
const urlRegex = /^(http|https):\/\/[^ "]+$/;
|
||||
|
||||
return ipRegex.test(value) || urlRegex.test(value);
|
||||
};
|
||||
|
||||
const handleSave = (): void => {
|
||||
errorMsg.value = "";
|
||||
isSuccess.value = false;
|
||||
|
||||
localStorage.setItem("printerUrl", printerUrl.value);
|
||||
localStorage.setItem("printerIp", printerUrl.value);
|
||||
localStorage.setItem("printerPort", printerPort.value.toString());
|
||||
localStorage.setItem("serverUrl", serverUrl.value);
|
||||
isSuccess.value = true;
|
||||
|
||||
@@ -38,15 +29,29 @@ const handleSave = (): void => {
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleReset = (): void => {
|
||||
const defaults = getDefaultConfig();
|
||||
printerUrl.value = defaults.printerIp;
|
||||
printerPort.value = defaults.printerPort;
|
||||
serverUrl.value = defaults.serverUrl;
|
||||
};
|
||||
|
||||
const performPing = async (type: "printer" | "server"): Promise<void> => {
|
||||
errorMsg.value = "";
|
||||
isSuccess.value = false;
|
||||
|
||||
const url = type === "printer" ? printerUrl.value : serverUrl.value;
|
||||
const ip = printerUrl.value;
|
||||
const port = printerPort.value;
|
||||
|
||||
pingStatus.value[type] = "pinging";
|
||||
try {
|
||||
const ok = await window.api.nfc.ping(url);
|
||||
const promise =
|
||||
type == "printer"
|
||||
? window.api.nfc.pingSocket({ ip, port })
|
||||
: window.api.nfc.ping(url);
|
||||
const ok = await promise;
|
||||
|
||||
pingStatus.value[type] = ok ? "success" : "error";
|
||||
if (!ok) {
|
||||
errorMsg.value = `${type === "printer" ? "Printer" : "Server"} is unreachable`;
|
||||
@@ -85,7 +90,7 @@ const handlePingServer = (): void => {
|
||||
<div class="config-item">
|
||||
<div class="item-meta">
|
||||
<span class="item-index">01</span>
|
||||
<label for="printer-url">PRINTER_ADDRESS</label>
|
||||
<label for="printer-url">IP_IMPRESORA</label>
|
||||
</div>
|
||||
<div class="input-with-action">
|
||||
<input
|
||||
@@ -110,11 +115,27 @@ const handlePingServer = (): void => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Config -->
|
||||
<!-- Printer Port Config -->
|
||||
<div class="config-item">
|
||||
<div class="item-meta">
|
||||
<span class="item-index">02</span>
|
||||
<label for="server-url">GATEWAY_SERVER</label>
|
||||
<label for="printer-port">PUERTO_IMPRESORA</label>
|
||||
</div>
|
||||
<div class="input-with-action">
|
||||
<input
|
||||
id="printer-port"
|
||||
v-model.number="printerPort"
|
||||
type="number"
|
||||
placeholder="PORT"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Config -->
|
||||
<div class="config-item">
|
||||
<div class="item-meta">
|
||||
<span class="item-index">03</span>
|
||||
<label for="server-url">URL_SERVIDOR</label>
|
||||
</div>
|
||||
<div class="input-with-action">
|
||||
<input
|
||||
@@ -135,7 +156,7 @@ const handlePingServer = (): void => {
|
||||
v-if="pingStatus.server === 'success'"
|
||||
class="field-status success"
|
||||
>
|
||||
REACHABLE
|
||||
CONECTADO
|
||||
</div>
|
||||
<div
|
||||
v-if="pingStatus.server === 'error'"
|
||||
@@ -152,7 +173,10 @@ const handlePingServer = (): void => {
|
||||
|
||||
<div class="panel-actions">
|
||||
<button class="save-btn" @click="handleSave">
|
||||
WRITE_TO_MEMORY
|
||||
GUARDAR CAMBIOS
|
||||
</button>
|
||||
<button class="reset-btn" @click="handleReset">
|
||||
RESETEAR CONFIGURACIÓN
|
||||
</button>
|
||||
<span v-if="isSuccess" class="write-success">WRITE_OK</span>
|
||||
</div>
|
||||
@@ -290,6 +314,25 @@ input:focus {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--te-gray);
|
||||
color: var(--te-gray);
|
||||
padding: 10px 24px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
border-color: var(--te-red);
|
||||
color: var(--te-red);
|
||||
}
|
||||
|
||||
.reset-btn:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.write-success {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { type CodeResponse } from "src/preload/index.d.js";
|
||||
import { tryCatch } from "../../../types/Result.js";
|
||||
import { Result, tryCatch } from "../../../types/Result.js";
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { getConfig } from "../utils/config";
|
||||
/**
|
||||
* waiting = esperando a leer una tarjeta (igual es mejor ready)
|
||||
* reading = proceso de lectura
|
||||
* sucess = se ha leido la tarjeta y sigue en el lector
|
||||
* error
|
||||
*/
|
||||
const readerState = ref<"waiting" | "reading" | "success" | "error">("waiting");
|
||||
const readerState = ref<
|
||||
"waiting" | "reading" | "success" | "printing" | "error"
|
||||
>("waiting");
|
||||
const uid = ref<string | null>(null);
|
||||
const errorMsg = ref<string | null>(null);
|
||||
const labelOutputStructured = ref<CodeResponse | null>(null);
|
||||
// Mensaje actual, no se reseta hasta la siguiente llamada
|
||||
const currentLabel = ref<CodeResponse | null>(null);
|
||||
// Nombre del lector de nfc o "OFFLINE" si no está conectado
|
||||
const readerName = ref<string>("OFFLINE");
|
||||
// Lista de erroes para mostrar errores de conexion o parecido.
|
||||
// De momento está un poco crudo.
|
||||
const errors = ref<string[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
// Get initial reader name
|
||||
readerName.value = await window.api.nfc.getReaderName();
|
||||
|
||||
window.api.nfc.onReader((event) => {
|
||||
@@ -71,7 +77,6 @@ const mockReadCard = (): void => {
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
|
||||
const readCard = async (): Promise<void> => {
|
||||
// Reset de los errores anteriores
|
||||
errors.value = [];
|
||||
@@ -79,41 +84,106 @@ const readCard = async (): Promise<void> => {
|
||||
if (readerState.value !== "waiting") return;
|
||||
readerState.value = "reading";
|
||||
|
||||
const serverURL =
|
||||
localStorage.getItem("serverUrl") || "http://localhost:3000";
|
||||
const printerURL = localStorage.getItem("printerUrl") || "192.168.1.254";
|
||||
|
||||
// TODO: De momento está hardcodeado porque no se puede leer la tarjeta
|
||||
const card_id = "019cdd39-fc08-7417-b16d-a78794a24c01";
|
||||
const override = false;
|
||||
|
||||
const res = await loadLabel({
|
||||
card_id,
|
||||
override,
|
||||
});
|
||||
|
||||
if (res.error != undefined) {
|
||||
return;
|
||||
}
|
||||
const labelData = res.data.data;
|
||||
|
||||
if (labelData.reused == true) {
|
||||
// Si la etiqueta ya ha sido impresa,
|
||||
// se espera confiramcion de volver a imprimir o generar una nueva
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await printCurrentLabel();
|
||||
|
||||
delayWaiting();
|
||||
};
|
||||
|
||||
/**
|
||||
* Solicita un nuevo código, se puede solicitar sobreescribir el anterior
|
||||
* No se guarda el resultado.
|
||||
* No se imprime.
|
||||
* TODO:
|
||||
* - Si crece meter estas llamadas en un repositorio
|
||||
*/
|
||||
const requestLabel = async (args: {
|
||||
override: boolean;
|
||||
card_id: string;
|
||||
}): Promise<Result<string, CodeResponse>> => {
|
||||
const { serverUrl: serverURL } = getConfig();
|
||||
const { override, card_id } = args;
|
||||
const codePromise = window.api.nfc.labelReq({
|
||||
serverURL,
|
||||
card_id,
|
||||
override,
|
||||
});
|
||||
|
||||
const res = await tryCatch(codePromise);
|
||||
const res = await codePromise;
|
||||
console.log("RES IPC:", res);
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* Solicita una etiqueta y la carga en `currentLabel`.
|
||||
* No se imprime.
|
||||
* @param args
|
||||
*/
|
||||
const loadLabel = async (args: {
|
||||
card_id: string;
|
||||
override: boolean;
|
||||
}): Promise<Result<string, CodeResponse>> => {
|
||||
readerState.value = "reading";
|
||||
|
||||
const label = await requestLabel(args);
|
||||
// Error de la llamada -> sin conexion al servidor
|
||||
if (res.error != undefined) {
|
||||
errors.value = [...errors.value, String(res.error)];
|
||||
delayWaiting();
|
||||
return;
|
||||
if (label.error != undefined) {
|
||||
errors.value = [...errors.value, String(label.error)];
|
||||
}
|
||||
|
||||
// Error que viene del servidor
|
||||
if (res.data?.error != undefined) {
|
||||
errors.value = [...errors.value, String(res.data.error)];
|
||||
delayWaiting();
|
||||
if (label.data?.error != undefined) {
|
||||
errors.value = [...errors.value, String(label.data.error)];
|
||||
}
|
||||
|
||||
const labeldata = label.data;
|
||||
if (labeldata != undefined) currentLabel.value = labeldata;
|
||||
|
||||
delayWaiting();
|
||||
return label;
|
||||
};
|
||||
|
||||
/**
|
||||
* Imprime la etiqueta que se ha solicitado y esta almacenada
|
||||
* en currentLabel.
|
||||
*/
|
||||
const printCurrentLabel = async (): Promise<void> => {
|
||||
// Doble check por si acaso
|
||||
readerState.value = "printing";
|
||||
if (
|
||||
currentLabel.value == undefined ||
|
||||
currentLabel.value.data == undefined
|
||||
) {
|
||||
console.error("Error currentLabel", currentLabel);
|
||||
return;
|
||||
}
|
||||
const labelData = res.data.data.data;
|
||||
labelOutputStructured.value = res.data.data;
|
||||
console.log("label data", labelData);
|
||||
const labelData = currentLabel.value.data;
|
||||
const { printerIp: printerURL, printerPort } = getConfig();
|
||||
|
||||
const impPromise = window.api.nfc.printReq({
|
||||
printerURL,
|
||||
printerPort,
|
||||
label: labelData.label,
|
||||
});
|
||||
|
||||
@@ -125,17 +195,36 @@ const readCard = async (): Promise<void> => {
|
||||
errorStr = "Error de conexión con la impresora";
|
||||
}
|
||||
errors.value = [...errors.value, errorStr];
|
||||
delayWaiting();
|
||||
}
|
||||
delayWaiting();
|
||||
};
|
||||
|
||||
const generateNewLabelAndPrint = async (args: {
|
||||
override?: boolean;
|
||||
}): Promise<void> => {
|
||||
// TODO: De momento está hardcodeado porque no se puede leer la tarjeta
|
||||
const card_id = "019cdd39-fc08-7417-b16d-a78794a24c01";
|
||||
const override = args.override ?? false;
|
||||
|
||||
const labelRes = await loadLabel({
|
||||
card_id,
|
||||
override,
|
||||
});
|
||||
|
||||
if (labelRes.error != undefined) {
|
||||
console.error(labelRes.error);
|
||||
}
|
||||
|
||||
delayWaiting();
|
||||
await printCurrentLabel();
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
const delayWaiting = (): void => {
|
||||
setTimeout((): void => {
|
||||
readerState.value = "waiting";
|
||||
errorMsg.value = null;
|
||||
}, 3000);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const triggerError = (): void => {
|
||||
@@ -148,6 +237,7 @@ const triggerError = (): void => {
|
||||
<template>
|
||||
<div class="main-display">
|
||||
<div class="flex-row grow">
|
||||
<!-- Lector -->
|
||||
<div class="display-container bg-dots">
|
||||
<div class="display-grid"></div>
|
||||
|
||||
@@ -170,7 +260,19 @@ const triggerError = (): void => {
|
||||
class="reading-state"
|
||||
>
|
||||
<div class="reading-indicator"></div>
|
||||
<h1 class="status-msg reading">PROCESSING_DATA...</h1>
|
||||
<h1 class="status-msg reading">
|
||||
PROCESANDO TARJETA...
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="readerState === 'printing'"
|
||||
class="reading-state"
|
||||
>
|
||||
<div class="reading-indicator"></div>
|
||||
<h1 class="status-msg reading">
|
||||
IMPRIMIENDO ETIQUETA...
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -190,13 +292,14 @@ const triggerError = (): void => {
|
||||
class="error-state"
|
||||
>
|
||||
<div class="error-indicator"></div>
|
||||
<h1 class="status-msg error">INTERFACE_ERROR</h1>
|
||||
<h1 class="status-msg error">ERROR DEL LECTOR</h1>
|
||||
<p class="error-desc">
|
||||
{{ errorMsg || "UNKNOWN_FAILURE" }}
|
||||
{{ errorMsg || "ERROR DESCONOCIDO" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Salida por pantalla de las respuestas del servidor y la impresora -->
|
||||
<div class="display-container bg-dots">
|
||||
<!-- ERRORES -->
|
||||
@@ -205,31 +308,40 @@ const triggerError = (): void => {
|
||||
<span>Error: {{ error }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="labelOutputStructured != undefined" class="flex-col">
|
||||
<div v-if="currentLabel != undefined" class="flex-col">
|
||||
<!-- Errores -->
|
||||
|
||||
<span
|
||||
v-if="labelOutputStructured.error != undefined"
|
||||
v-if="currentLabel.error != undefined"
|
||||
class="error"
|
||||
>
|
||||
{{ JSON.stringify(labelOutputStructured.error) }}
|
||||
{{ JSON.stringify(currentLabel.error) }}
|
||||
</span>
|
||||
<!-- Datos -->
|
||||
<span> Código {{ labelOutputStructured.data.code }} </span>
|
||||
<span> Código {{ currentLabel.data.code }} </span>
|
||||
<span>
|
||||
Etiqueta {{ labelOutputStructured.data.label }}
|
||||
Etiqueta {{ currentLabel.data.label }}
|
||||
</span>
|
||||
<span v-if="labelOutputStructured.data.reused == true">
|
||||
YA SE HA IMPRIMIDO
|
||||
<span v-if="currentLabel.data.reused == true">
|
||||
YA SE HA IMPRESO ANTERIORMENTE, CONFIRMA REIMPRESION
|
||||
</span>
|
||||
<span v-if="labelOutputStructured.data.overriden == true">
|
||||
SOBREESCRITA
|
||||
<span v-if="currentLabel.data.overriden == true">
|
||||
SE HA SOBREESCRITO EL CODIGO ANTERIOR
|
||||
</span>
|
||||
<!-- Caso de error -->
|
||||
|
||||
</div>
|
||||
<div v-if="currentLabel?.data.reused == true" class="dev-panel">
|
||||
<button class="dev-btn" @click="printCurrentLabel">
|
||||
IMPRIMIR
|
||||
</button>
|
||||
<button
|
||||
class="dev-btn"
|
||||
@click="generateNewLabelAndPrint({ override: true })"
|
||||
>
|
||||
GENERAR NUEVO </button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discrete mock controls for dev testing -->
|
||||
<div class="dev-panel">
|
||||
<button
|
||||
|
||||
32
src/renderer/src/utils/config.ts
Normal file
32
src/renderer/src/utils/config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import baseConfig from "../../../config/base.json";
|
||||
|
||||
export interface AppConfig {
|
||||
serverUrl: string;
|
||||
printerIp: string;
|
||||
printerPort: number;
|
||||
}
|
||||
|
||||
export const getConfig = (): AppConfig => {
|
||||
const serverUrl =
|
||||
localStorage.getItem("serverUrl") || baseConfig.defaultServer;
|
||||
const printerIp =
|
||||
localStorage.getItem("ipPrinter") || baseConfig.defaultPrinterIp;
|
||||
const printerPortRaw = localStorage.getItem("printerPort");
|
||||
const printerPort = printerPortRaw
|
||||
? parseInt(printerPortRaw, 10)
|
||||
: baseConfig.defaultPrinterPort;
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
printerIp,
|
||||
printerPort,
|
||||
};
|
||||
};
|
||||
|
||||
export const getDefaultConfig = (): AppConfig => {
|
||||
return {
|
||||
serverUrl: baseConfig.defaultServer,
|
||||
printerIp: baseConfig.defaultPrinterIp,
|
||||
printerPort: baseConfig.defaultPrinterPort,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user